stabilize token highlighting for the match/search mode

This commit is contained in:
tophf 2017-12-01 18:01:19 +03:00
parent 2760b0764b
commit 5bdaacc049
2 changed files with 103 additions and 34 deletions

View File

@ -37,3 +37,11 @@
.cm-uso-variable { .cm-uso-variable {
font-weight: bold; font-weight: bold;
} }
.cm-searching.cm-matchhighlight {
/* tokens found by manual search should not animate by cm-matchhighlight */
animation-name: search-and-match-highlighter !important;
}
@keyframes search-and-match-highlighter {
from { background-color: rgba(255, 255, 0, .4); } /* search color */
to { background-color: rgba(100, 255, 100, .4); } /* sarch + highlight */
}

View File

@ -2,13 +2,39 @@
'use strict'; 'use strict';
(() => { (() => {
/*
The original match-highlighter addon always recreates the highlight overlay
even if the token under cursor hasn't changed, which is terribly ineffective
(the entire view is re-rendered) and makes our animated token highlight effect
restart on every cursor movement.
Invocation sequence of our hooks:
1. removeOverlayForHighlighter()
The original addon removes the overlay unconditionally
so this hook saves the state if the token hasn't changed.
2. addOverlayForHighlighter()
Restores the saved state instead of creating a new overlay,
installs a hook to count occurrences.
3. matchesOnScrollbar()
Saves the query regexp passed from the original addon in our helper object,
and in case removeOverlayForHighlighter() decided to keep the overlay
only rewrites the regexp without invoking the original constructor.
*/
const HL_APPROVED = 'cm-matchhighlight-approved'; const HL_APPROVED = 'cm-matchhighlight-approved';
const originalAddOverlay = CodeMirror.prototype.addOverlay; const originalAddOverlay = CodeMirror.prototype.addOverlay;
const originalRemoveOverlay = CodeMirror.prototype.removeOverlay; const originalRemoveOverlay = CodeMirror.prototype.removeOverlay;
const originalMatchesOnScrollbar = CodeMirror.prototype.showMatchesOnScrollbar; const originalMatchesOnScrollbar = CodeMirror.prototype.showMatchesOnScrollbar;
let originalGetOption;
CodeMirror.prototype.addOverlay = addOverlay; CodeMirror.prototype.addOverlay = addOverlay;
CodeMirror.prototype.removeOverlay = removeOverlay; CodeMirror.prototype.removeOverlay = removeOverlay;
CodeMirror.prototype.showMatchesOnScrollbar = matchesOnScrollbar; CodeMirror.prototype.showMatchesOnScrollbar = matchesOnScrollbar;
return; return;
function shouldIntercept(overlay) { function shouldIntercept(overlay) {
@ -32,18 +58,22 @@
const state = this.state.matchHighlighter || {}; const state = this.state.matchHighlighter || {};
const helper = state.highlightHelper = state.highlightHelper || {}; const helper = state.highlightHelper = state.highlightHelper || {};
helper.rewriteScrollbarQuery = true;
// since we're here the original addon decided there's something to highlight,
// so we cancel removeOverlayIfExpired() scheduled in our removeOverlay hook
clearTimeout(helper.hookTimer); clearTimeout(helper.hookTimer);
if (helper.matchesonscroll) { // the original addon just removed its overlays, which was intercepted by removeOverlayForHighlighter,
// restore the original addon's unwanted removeOverlay effects // which decided to restore it and saved the previous overlays in our helper object,
// (in case the token under cursor hasn't changed) // so here we are now, restoring them
if (helper.skipMatchesOnScrollbar) {
state.matchesonscroll = helper.matchesonscroll; state.matchesonscroll = helper.matchesonscroll;
state.overlay = helper.overlay; state.overlay = helper.overlay;
helper.matchesonscroll = null;
helper.overlay = null;
return true; return true;
} }
// hook the newly created overlay's token() to count the occurrences
if (overlay.token !== tokenHook) { if (overlay.token !== tokenHook) {
overlay.highlightHelper = { overlay.highlightHelper = {
token: overlay.token, token: overlay.token,
@ -52,18 +82,23 @@
overlay.token = tokenHook; overlay.token = tokenHook;
} }
if (this.options.lineWrapping) { // speed up rendering of scrollbar marks 4 times: we don't need ultimate precision there
const originalGetOption = CodeMirror.prototype.getOption; // so for the duration of this event loop cycle we spoof the "lineWrapping" option
CodeMirror.prototype.getOption = function (option) { // and restore it in the next event loop cycle
return option !== 'lineWrapping' && originalGetOption.apply(this, arguments); if (this.options.lineWrapping && CodeMirror.prototype.getOption !== spoofLineWrappingOption) {
}; originalGetOption = CodeMirror.prototype.getOption;
setTimeout(() => { CodeMirror.prototype.getOption = spoofLineWrappingOption;
CodeMirror.prototype.getOption = originalGetOption; setTimeout(() => (CodeMirror.prototype.getOption = originalGetOption));
});
} }
} }
function spoofLineWrappingOption(option) {
return option !== 'lineWrapping' && originalGetOption.apply(this, arguments);
}
function tokenHook(stream) { function tokenHook(stream) {
// we don't highlight a single match in case 'editor.matchHighlight' option is 'token'
// so this hook counts the occurrences and toggles HL_APPROVED class on CM's wrapper element
const style = this.highlightHelper.token.call(this, stream); const style = this.highlightHelper.token.call(this, stream);
if (style !== 'matchhighlight') { if (style !== 'matchhighlight') {
return style; return style;
@ -79,36 +114,55 @@
function removeOverlayForHighlighter() { function removeOverlayForHighlighter() {
const state = this.state.matchHighlighter || {}; const state = this.state.matchHighlighter || {};
const {query} = state.highlightHelper || state.matchesonscroll || {}; const helper = state.highlightHelper;
if (!query) { const {query, originalToken} = helper || state.matchesonscroll || {};
// no current query means nothing to preserve => remove the overlay
if (!query || !originalToken) {
return; return;
} }
const rx = query instanceof RegExp && query; const rx = query instanceof RegExp && query;
const sel = this.getSelection(); const sel = this.getSelection();
// current query differs from the selected text => remove the overlay
if (sel && (rx && !rx.test(sel) || sel.toLowerCase() !== query)) { if (sel && (rx && !rx.test(sel) || sel.toLowerCase() !== query)) {
return; return;
} }
// if token under cursor has changed => remove the overlay
if (!sel) { if (!sel) {
const {line, ch} = this.getCursor(); const {line, ch} = this.getCursor();
const queryLen = rx ? rx.source.length - 4 : query.length; const queryLen = originalToken.length;
const start = Math.max(0, ch - queryLen + 1); const start = Math.max(0, ch - queryLen + 1);
const end = ch + queryLen; const end = ch + queryLen;
const area = this.getLine(line).substring(start, end); const string = this.getLine(line);
const startInArea = rx ? (area.match(rx) || {}).index : const area = string.slice(start, end);
(area.indexOf(query) + 1 || NaN) - 1; let startInArea;
if (start + startInArea > ch) { if (rx) {
const m = area.match(rx);
startInArea = !m ? NaN : m.index + m[1].length;
} else {
const i = area.indexOf(query);
startInArea = i < 0 ? NaN : i;
}
if (isNaN(startInArea) || start + startInArea > ch ||
state.options.showToken.test(string[start + startInArea - 1] || '') ||
state.options.showToken.test(string[start + startInArea + queryLen] || '')) {
// pass the displayed instance back to the original code to remove it
state.matchesonscroll = state.matchesonscroll || helper && helper.matchesonscroll;
return; return;
} }
} }
// same token on cursor => prevent the highlighter from rerunning // since the same token is under cursor we prevent the highlighter from rerunning
// by saving current overlays in a helper object so that it's restored in addOverlayForHighlighter()
state.highlightHelper = { state.highlightHelper = {
overlay: state.overlay, overlay: state.overlay,
matchesonscroll: state.matchesonscroll, matchesonscroll: state.matchesonscroll || (helper || {}).matchesonscroll,
showMatchesOnScrollbar: this.showMatchesOnScrollbar, // instruct our matchesOnScrollbar hook to preserve current scrollbar annotations
skipMatchesOnScrollbar: true,
// in case the original addon won't highlight anything we need to actually remove the overlays
// by setting a timer that runs in the next event loop cycle and can be canceled in this cycle
hookTimer: setTimeout(removeOverlayIfExpired, 0, this, state), hookTimer: setTimeout(removeOverlayIfExpired, 0, this, state),
}; };
// fool the original addon so it won't invoke state.matchesonscroll.clear()
state.matchesonscroll = null; state.matchesonscroll = null;
this.showMatchesOnScrollbar = scrollbarForHighlighter;
return true; return true;
} }
@ -120,20 +174,27 @@
if (matchesonscroll) { if (matchesonscroll) {
matchesonscroll.clear(); matchesonscroll.clear();
} }
self.showMatchesOnScrollbar = state.showMatchesOnScrollbar;
state.highlightHelper = null; state.highlightHelper = null;
} }
function scrollbarForHighlighter(query) {
const helper = this.state.matchHighlighter.highlightHelper;
this.showMatchesOnScrollbar = helper.showMatchesOnScrollbar;
helper.query = query;
}
function matchesOnScrollbar(query, ...args) { function matchesOnScrollbar(query, ...args) {
if (query instanceof RegExp) { const state = this.state.matchHighlighter;
query = new RegExp(/(?:^|[^\w.#\\-])/.source + query.source.slice(2, -2) + /(?:[^\w.#\\-]|$)/.source); const helper = state.highlightHelper = state.highlightHelper || {};
// rewrite the \btoken\b regexp so it matches .token and #token and --token
if (helper.rewriteScrollbarQuery && /^\\b.*?\\b$/.test(query.source)) {
helper.rewriteScrollbarQuery = undefined;
helper.originalToken = query.source.slice(2, -2);
const notToken = '(?!' + state.options.showToken.source + ').';
query = new RegExp(`(^|${notToken})` + helper.originalToken + `(${notToken}|$)`);
}
// save the query for future use in removeOverlayForHighlighter
helper.query = query;
// if removeOverlayForHighlighter() decided to keep the overlay
if (helper.skipMatchesOnScrollbar) {
helper.skipMatchesOnScrollbar = undefined;
return;
} else {
return originalMatchesOnScrollbar.call(this, query, ...args);
} }
return originalMatchesOnScrollbar.call(this, query, ...args);
} }
})(); })();