You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
420 lines
13 KiB
420 lines
13 KiB
2 years ago
|
/**
|
||
|
* Enable highlighting of matching words in cells' CodeMirror editors.
|
||
|
*
|
||
|
* This extension was adapted from the CodeMirror addon
|
||
|
* codemirror/addon/search/match-highlighter.js
|
||
|
*/
|
||
|
|
||
|
define([
|
||
|
'require',
|
||
|
'jquery',
|
||
|
'base/js/namespace',
|
||
|
'notebook/js/cell',
|
||
|
'notebook/js/codecell',
|
||
|
'codemirror/lib/codemirror',
|
||
|
// The mark-selection addon is need to ensure that the highlighting styles
|
||
|
// are *not* applied to the actual selection, as otherwise it can become
|
||
|
// difficult to see which is selected vs just highlighted.
|
||
|
'codemirror/addon/selection/mark-selection'
|
||
|
], function (
|
||
|
requirejs,
|
||
|
$,
|
||
|
Jupyter,
|
||
|
cell,
|
||
|
codecell,
|
||
|
CodeMirror
|
||
|
) {
|
||
|
'use strict';
|
||
|
|
||
|
var Cell = cell.Cell;
|
||
|
var CodeCell = codecell.CodeCell;
|
||
|
|
||
|
var mod_name = 'highlight_selected_word';
|
||
|
var log_prefix = '[' + mod_name + ']';
|
||
|
var menu_toggle_class = 'highlight_selected_word_toggle';
|
||
|
|
||
|
// Parameters (potentially) stored in server config.
|
||
|
// This object gets updated on config load.
|
||
|
var params = {
|
||
|
highlight_across_all_cells: true,
|
||
|
enable_on_load : true,
|
||
|
code_cells_only: false,
|
||
|
delay: 100,
|
||
|
words_only: false,
|
||
|
highlight_only_whole_words: true,
|
||
|
min_chars: 2,
|
||
|
show_token: '[\\w$]',
|
||
|
highlight_color: '#90EE90',
|
||
|
highlight_color_blurred: '#BBFFBB',
|
||
|
highlight_style: 'matchhighlight',
|
||
|
trim: true,
|
||
|
use_toggle_hotkey: false,
|
||
|
toggle_hotkey: 'alt-h',
|
||
|
outlines_only: false,
|
||
|
outline_width: 2,
|
||
|
only_cells_in_scroll: true,
|
||
|
scroll_min_delay: 100,
|
||
|
hide_selections_in_unfocussed: false,
|
||
|
};
|
||
|
|
||
|
// these are set on registering the action(s)
|
||
|
var action_names = {
|
||
|
toggle: '',
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* the codemirror matchHighlighter has a separate state object for each cm
|
||
|
* instance, but since our state is global over all cells' editors, we can
|
||
|
* use a single object for simplicity, and don't need to store options
|
||
|
* inside the state, since we have closure-level access to the params
|
||
|
* object above.
|
||
|
*/
|
||
|
var globalState = {
|
||
|
active: false,
|
||
|
timeout: null, // only want one timeout
|
||
|
scrollTimeout: null,
|
||
|
overlay: null, // one overlay suffices, as all cells use the same one
|
||
|
};
|
||
|
|
||
|
// define a CodeMirror option for highlighting matches in all cells
|
||
|
CodeMirror.defineOption("highlightSelectionMatchesInJupyterCells", false, function (cm, val, old) {
|
||
|
if (old && old != CodeMirror.Init) {
|
||
|
globalState.active = false;
|
||
|
// remove from all relevant, this can fail gracefully if not present
|
||
|
get_relevant_cells().forEach(function (cell, idx, array) {
|
||
|
cell.code_mirror.removeOverlay(mod_name);
|
||
|
});
|
||
|
globalState.overlay = null;
|
||
|
clearTimeout(globalState.timeout);
|
||
|
globalState.timeout = null;
|
||
|
cm.off("cursorActivity", callbackCursorActivity);
|
||
|
cm.off("focus", callbackOnFocus);
|
||
|
}
|
||
|
if (val) {
|
||
|
if (cm.hasFocus()) {
|
||
|
globalState.active = true;
|
||
|
highlightMatchesInAllRelevantCells(cm);
|
||
|
}
|
||
|
else {
|
||
|
cm.on("focus", callbackOnFocus);
|
||
|
}
|
||
|
cm.on("cursorActivity", callbackCursorActivity);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
/**
|
||
|
* The functions callbackCursorActivity, callbackOnFocus and
|
||
|
* scheduleHighlight are taken without major modification from cm's
|
||
|
* match-highlighter.
|
||
|
* The main difference is using our global state rather than
|
||
|
* match-highlighter's per-cm state, and a different highlighting function
|
||
|
* is scheduled.
|
||
|
*/
|
||
|
function callbackCursorActivity (cm) {
|
||
|
if (globalState.active || cm.hasFocus()) {
|
||
|
scheduleHighlight(cm);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function callbackOnFocus (cm) {
|
||
|
// unlike cm match-highlighter, we *do* want to schedule a highight on
|
||
|
// focussing the editor
|
||
|
globalState.active = true;
|
||
|
scheduleHighlight(cm);
|
||
|
}
|
||
|
|
||
|
function scheduleHighlight (cm) {
|
||
|
clearTimeout(globalState.timeout);
|
||
|
globalState.timeout = setTimeout(function () { highlightMatchesInAllRelevantCells(cm); }, params.delay);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adapted from cm match-highlighter's highlightMatches, but adapted to
|
||
|
* use our global state and parameters, plus work either for only the
|
||
|
* current editor, or multiple cells' editors.
|
||
|
*/
|
||
|
function highlightMatchesInAllRelevantCells (cm) {
|
||
|
var newOverlay = null;
|
||
|
|
||
|
var re = params.show_token === true ? /[\w$]/ : params.show_token;
|
||
|
var from = cm.getCursor('from');
|
||
|
if (!cm.somethingSelected() && params.show_token) {
|
||
|
var line = cm.getLine(from.line), start = from.ch, end = start;
|
||
|
while (start && re.test(line.charAt(start - 1))) {
|
||
|
--start;
|
||
|
}
|
||
|
while (end < line.length && re.test(line.charAt(end))) {
|
||
|
++end;
|
||
|
}
|
||
|
if (start < end) {
|
||
|
newOverlay = makeOverlay(line.slice(start, end), re, params.highlight_style);
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
var to = cm.getCursor("to");
|
||
|
if (from.line == to.line) {
|
||
|
if (!params.words_only || isWord(cm, from, to)) {
|
||
|
var selection = cm.getRange(from, to);
|
||
|
if (params.trim) {
|
||
|
selection = selection.replace(/^\s+|\s+$/g, "");
|
||
|
}
|
||
|
if (selection.length >= params.min_chars) {
|
||
|
var hasBoundary = params.highlight_only_whole_words ? (re instanceof RegExp ? re : /[\w$]/) : false;
|
||
|
newOverlay = makeOverlay(selection, hasBoundary, params.highlight_style);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var siterect = document.getElementById('site').getBoundingClientRect();
|
||
|
var viewtop = siterect.top, viewbot = siterect.bottom;
|
||
|
var cells = params.highlight_across_all_cells ? get_relevant_cells() : [
|
||
|
$(cm.getWrapperElement()).closest('.cell').data('cell')
|
||
|
];
|
||
|
cells.forEach(function (cell, idx, array) {
|
||
|
// cm.operation to delay updating DOM until all work is done
|
||
|
cell.code_mirror.operation(function () {
|
||
|
cell.code_mirror.removeOverlay(mod_name);
|
||
|
if (newOverlay && is_in_view(cell.element[0], viewtop, viewbot)) {
|
||
|
cell.code_mirror.addOverlay(newOverlay);
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* isWord, boundariesAround and makeOverlay come pretty much directly from
|
||
|
* Codemirror/addon/search/matchHighlighter
|
||
|
* since they don't use state or config values.
|
||
|
*/
|
||
|
function isWord (cm, from, to) {
|
||
|
var str = cm.getRange(from, to);
|
||
|
if (str.match(/^\w+$/) !== null) {
|
||
|
var pos, chr;
|
||
|
if (from.ch > 0) {
|
||
|
pos = {line: from.line, ch: from.ch - 1};
|
||
|
chr = cm.getRange(pos, from);
|
||
|
if (chr.match(/\W/) === null) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
if (to.ch < cm.getLine(from.line).length) {
|
||
|
pos = {line: to.line, ch: to.ch + 1};
|
||
|
chr = cm.getRange(to, pos);
|
||
|
if (chr.match(/\W/) === null) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
function boundariesAround (stream, re) {
|
||
|
return (!stream.start || !re.test(stream.string.charAt(stream.start - 1))) &&
|
||
|
(stream.pos == stream.string.length || !re.test(stream.string.charAt(stream.pos)));
|
||
|
}
|
||
|
function makeOverlay (query, hasBoundary, style) {
|
||
|
return {
|
||
|
name: mod_name,
|
||
|
token: function (stream) {
|
||
|
if (stream.match(query) &&
|
||
|
(!hasBoundary || boundariesAround(stream, hasBoundary))) {
|
||
|
return style;
|
||
|
}
|
||
|
stream.next();
|
||
|
if (!stream.skipTo(query.charAt(0))) {
|
||
|
stream.skipToEnd();
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns true if part of elem is visible between viewtop & viewbot
|
||
|
*/
|
||
|
var is_in_view = function (elem, viewtop, viewbot) {
|
||
|
var rect = elem.getBoundingClientRect();
|
||
|
// hidden elements show height 0
|
||
|
return (rect.top < viewbot) && (rect.bottom > viewtop) && rect.height;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Return an array of cells to which match highlighting is relevant,
|
||
|
* dependent on the code_cells_only parameter
|
||
|
*/
|
||
|
function get_relevant_cells () {
|
||
|
var cells = Jupyter.notebook.get_cells();
|
||
|
return params.code_cells_only ? cells.filter(function (c) { return (c instanceof CodeCell); }) : cells;
|
||
|
}
|
||
|
|
||
|
function add_menu_item () {
|
||
|
if ($('#view_menu').find('.' + menu_toggle_class).length < 1) {
|
||
|
var menu_item = $('<li/>')
|
||
|
.appendTo('#view_menu');
|
||
|
var menu_link = $('<a/>')
|
||
|
.text('Highlight selected word')
|
||
|
.addClass(menu_toggle_class)
|
||
|
.attr({
|
||
|
title: 'Highlight all instances of the selected word in the current editor',
|
||
|
href: '#',
|
||
|
})
|
||
|
.on('click', function () { toggle_highlight_selected(); })
|
||
|
.appendTo(menu_item);
|
||
|
$('<i/>')
|
||
|
.addClass('fa menu-icon pull-right')
|
||
|
.css({'margin-top': '-2px', 'margin-right': '-16px'})
|
||
|
.prependTo(menu_link);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var throttled_highlight = (function () {
|
||
|
var last, throttle_timeout;
|
||
|
return function throttled_highlight (cm) {
|
||
|
var now = Number(new Date());
|
||
|
var do_it = function () {
|
||
|
last = Number(new Date());
|
||
|
highlightMatchesInAllRelevantCells(cm);
|
||
|
};
|
||
|
var remaining = last + params.scroll_min_delay - now;
|
||
|
if (last && remaining > 0) {
|
||
|
clearTimeout(throttle_timeout);
|
||
|
throttle_timeout = setTimeout(do_it, remaining);
|
||
|
}
|
||
|
else {
|
||
|
last = undefined; // so we will do it first time next streak
|
||
|
do_it();
|
||
|
}
|
||
|
}
|
||
|
})();
|
||
|
|
||
|
function scroll_handler (evt) {
|
||
|
if (globalState.active && Jupyter.notebook.mode === 'edit' && globalState.overlay) {
|
||
|
// add overlay to cells now in view which don't already have it.
|
||
|
// Don't bother removing from those no longer in view, as it would just
|
||
|
// cause more work for the browser, without any benefit
|
||
|
var siterect = document.getElementById('site').getBoundingClientRect();
|
||
|
get_relevant_cells().forEach(function (cell) {
|
||
|
var cm = cell.code_mirror;
|
||
|
if (is_in_view(cell.element, siterect.top, siterect.bot)) {
|
||
|
var need_it = !cm.state.overlays.some(function(ovr) {
|
||
|
return ovr.modeSpec.name === mod_name; });
|
||
|
if (need_it) cm.addOverlay(globalState.overlay);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function toggle_highlight_selected (set_on) {
|
||
|
set_on = (set_on !== undefined) ? set_on : !params.enable_on_load;
|
||
|
// update config to make changes persistent
|
||
|
if (set_on !== params.enable_on_load) {
|
||
|
params.enable_on_load = set_on;
|
||
|
Jupyter.notebook.config.update({highlight_selected_word: {enable_on_load: set_on}});
|
||
|
}
|
||
|
|
||
|
// Change defaults for new cells:
|
||
|
var cm_conf = (params.code_cells_only ? CodeCell : Cell).options_default.cm_config;
|
||
|
cm_conf.highlightSelectionMatchesInJupyterCells = cm_conf.styleSelectedText = set_on;
|
||
|
|
||
|
// And change any existing cells:
|
||
|
get_relevant_cells().forEach(function (cell, idx, array) {
|
||
|
cell.code_mirror.setOption('highlightSelectionMatchesInJupyterCells', set_on);
|
||
|
cell.code_mirror.setOption('styleSelectedText', set_on);
|
||
|
});
|
||
|
// update menu class
|
||
|
$('.' + menu_toggle_class + ' > .fa').toggleClass('fa-check', set_on);
|
||
|
// bind/unbind scroll handler
|
||
|
$('#site')[
|
||
|
(params.only_cells_in_scroll && params.scroll_min_delay > 0) ? 'on' : 'off'
|
||
|
]('scroll', scroll_handler);
|
||
|
console.log(log_prefix, 'toggled', set_on ? 'on' : 'off');
|
||
|
return set_on;
|
||
|
}
|
||
|
|
||
|
function register_new_actions () {
|
||
|
action_names.toggle = Jupyter.keyboard_manager.actions.register({
|
||
|
handler : function (env) { toggle_highlight_selected(); },
|
||
|
help : "Toggle highlighting of selected word",
|
||
|
icon : 'fa-language',
|
||
|
help_index: 'c1'
|
||
|
}, 'toggle', mod_name);
|
||
|
}
|
||
|
|
||
|
function bind_hotkeys () {
|
||
|
if (params.use_toggle_hotkey && params.toggle_hotkey) {
|
||
|
Jupyter.keyboard_manager.command_shortcuts.add_shortcut(params.toggle_hotkey, action_names.toggle);
|
||
|
Jupyter.keyboard_manager.edit_shortcuts.add_shortcut(params.toggle_hotkey, action_names.toggle);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function insert_css () {
|
||
|
var css = [// in unselected cells, matches have blurred color
|
||
|
// in selected cells, we keep CodeMirror highlight for the actual selection to avoid confusion
|
||
|
'.edit_mode .unselected .CodeMirror .cm-matchhighlight {',
|
||
|
' background-color: ' + params.highlight_color_blurred + ';',
|
||
|
'}',
|
||
|
|
||
|
// in active cell, matches which are not the current selection have focussed color
|
||
|
'.edit_mode .CodeMirror.CodeMirror-focused :not(.CodeMirror-selectedtext).cm-matchhighlight {',
|
||
|
' background-color: ' + params.highlight_color + ';',
|
||
|
'}',
|
||
|
|
||
|
// in all cells, outline matches have blurred color
|
||
|
'.edit_mode .CodeMirror .cm-matchhighlight-outline {',
|
||
|
' outline-style: solid;',
|
||
|
' outline-width: ' + params.outline_width + 'px;',
|
||
|
' outline-color: ' + params.highlight_color_blurred + ';',
|
||
|
'}',
|
||
|
|
||
|
// in active cell, outline matches have focussed color
|
||
|
'.edit_mode .CodeMirror.CodeMirror-focused .cm-matchhighlight-outline {',
|
||
|
' outline-color: ' + params.highlight_color + ';',
|
||
|
'}'
|
||
|
].join('\n');
|
||
|
|
||
|
if (params.hide_selections_in_unfocussed) {
|
||
|
css += [
|
||
|
// in unselected cells, selections which are not matches should have no background
|
||
|
'.unselected .CodeMirror :not(.cm-matchhighlight).CodeMirror-selected,',
|
||
|
'.unselected .CodeMirror :not(.cm-matchhighlight).CodeMirror-selectedtext {',
|
||
|
' background: initial;',
|
||
|
'}',
|
||
|
].join('\n');
|
||
|
}
|
||
|
|
||
|
$('<style type="text/css" id="highlight_selected_word_css">').appendTo('head').html(css);
|
||
|
}
|
||
|
|
||
|
function load_extension () {
|
||
|
// add menu item, as we need it to exist for later
|
||
|
// toggle_highlight_selected call to set its icon status
|
||
|
add_menu_item();
|
||
|
|
||
|
// load config & toggle on/off
|
||
|
Jupyter.notebook.config.loaded
|
||
|
.then(function () {
|
||
|
$.extend(true, params, Jupyter.notebook.config.data.highlight_selected_word);
|
||
|
}, function on_error (reason) {
|
||
|
console.warn(log_prefix, 'error loading config:', reason);
|
||
|
})
|
||
|
.then(insert_css)
|
||
|
.then(function () {
|
||
|
params.show_token = params.show_token ? new RegExp(params.show_token) : false;
|
||
|
if (params.outlines_only) {
|
||
|
params.highlight_style += '-outline'
|
||
|
}
|
||
|
// set highlight on/off
|
||
|
toggle_highlight_selected(params.enable_on_load);
|
||
|
|
||
|
register_new_actions();
|
||
|
bind_hotkeys();
|
||
|
})
|
||
|
// finally log any error we encountered
|
||
|
.catch(function on_error (reason) { console.warn(log_prefix, 'error loading:', reason); });
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
load_ipython_extension : load_extension
|
||
|
};
|
||
|
});
|