From 5bdaacc04959d2b61da252f3aee00deec4472557 Mon Sep 17 00:00:00 2001 From: tophf Date: Fri, 1 Dec 2017 18:01:19 +0300 Subject: [PATCH] stabilize token highlighting for the match/search mode --- edit/codemirror-default.css | 8 ++ edit/match-highlighter-helper.js | 129 +++++++++++++++++++++++-------- 2 files changed, 103 insertions(+), 34 deletions(-) diff --git a/edit/codemirror-default.css b/edit/codemirror-default.css index 7c00b4c0..7cc0c8c3 100644 --- a/edit/codemirror-default.css +++ b/edit/codemirror-default.css @@ -37,3 +37,11 @@ .cm-uso-variable { 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 */ +} diff --git a/edit/match-highlighter-helper.js b/edit/match-highlighter-helper.js index f3488cfd..e60b7f4b 100644 --- a/edit/match-highlighter-helper.js +++ b/edit/match-highlighter-helper.js @@ -2,13 +2,39 @@ '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 originalAddOverlay = CodeMirror.prototype.addOverlay; const originalRemoveOverlay = CodeMirror.prototype.removeOverlay; const originalMatchesOnScrollbar = CodeMirror.prototype.showMatchesOnScrollbar; + let originalGetOption; + CodeMirror.prototype.addOverlay = addOverlay; CodeMirror.prototype.removeOverlay = removeOverlay; CodeMirror.prototype.showMatchesOnScrollbar = matchesOnScrollbar; + return; function shouldIntercept(overlay) { @@ -32,18 +58,22 @@ const state = this.state.matchHighlighter || {}; 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); - if (helper.matchesonscroll) { - // restore the original addon's unwanted removeOverlay effects - // (in case the token under cursor hasn't changed) + // the original addon just removed its overlays, which was intercepted by removeOverlayForHighlighter, + // which decided to restore it and saved the previous overlays in our helper object, + // so here we are now, restoring them + if (helper.skipMatchesOnScrollbar) { state.matchesonscroll = helper.matchesonscroll; state.overlay = helper.overlay; - helper.matchesonscroll = null; - helper.overlay = null; return true; } + // hook the newly created overlay's token() to count the occurrences if (overlay.token !== tokenHook) { overlay.highlightHelper = { token: overlay.token, @@ -52,18 +82,23 @@ overlay.token = tokenHook; } - if (this.options.lineWrapping) { - const originalGetOption = CodeMirror.prototype.getOption; - CodeMirror.prototype.getOption = function (option) { - return option !== 'lineWrapping' && originalGetOption.apply(this, arguments); - }; - setTimeout(() => { - CodeMirror.prototype.getOption = originalGetOption; - }); + // speed up rendering of scrollbar marks 4 times: we don't need ultimate precision there + // so for the duration of this event loop cycle we spoof the "lineWrapping" option + // and restore it in the next event loop cycle + if (this.options.lineWrapping && CodeMirror.prototype.getOption !== spoofLineWrappingOption) { + originalGetOption = CodeMirror.prototype.getOption; + CodeMirror.prototype.getOption = spoofLineWrappingOption; + setTimeout(() => (CodeMirror.prototype.getOption = originalGetOption)); } } + function spoofLineWrappingOption(option) { + return option !== 'lineWrapping' && originalGetOption.apply(this, arguments); + } + 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); if (style !== 'matchhighlight') { return style; @@ -79,36 +114,55 @@ function removeOverlayForHighlighter() { const state = this.state.matchHighlighter || {}; - const {query} = state.highlightHelper || state.matchesonscroll || {}; - if (!query) { + const helper = state.highlightHelper; + const {query, originalToken} = helper || state.matchesonscroll || {}; + // no current query means nothing to preserve => remove the overlay + if (!query || !originalToken) { return; } const rx = query instanceof RegExp && query; const sel = this.getSelection(); + // current query differs from the selected text => remove the overlay if (sel && (rx && !rx.test(sel) || sel.toLowerCase() !== query)) { return; } + // if token under cursor has changed => remove the overlay if (!sel) { 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 end = ch + queryLen; - const area = this.getLine(line).substring(start, end); - const startInArea = rx ? (area.match(rx) || {}).index : - (area.indexOf(query) + 1 || NaN) - 1; - if (start + startInArea > ch) { + const string = this.getLine(line); + const area = string.slice(start, end); + let startInArea; + 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; } } - // 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 = { overlay: state.overlay, - matchesonscroll: state.matchesonscroll, - showMatchesOnScrollbar: this.showMatchesOnScrollbar, + matchesonscroll: state.matchesonscroll || (helper || {}).matchesonscroll, + // 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), }; + // fool the original addon so it won't invoke state.matchesonscroll.clear() state.matchesonscroll = null; - this.showMatchesOnScrollbar = scrollbarForHighlighter; return true; } @@ -120,20 +174,27 @@ if (matchesonscroll) { matchesonscroll.clear(); } - self.showMatchesOnScrollbar = state.showMatchesOnScrollbar; state.highlightHelper = null; } - function scrollbarForHighlighter(query) { - const helper = this.state.matchHighlighter.highlightHelper; - this.showMatchesOnScrollbar = helper.showMatchesOnScrollbar; - helper.query = query; - } - function matchesOnScrollbar(query, ...args) { - if (query instanceof RegExp) { - query = new RegExp(/(?:^|[^\w.#\\-])/.source + query.source.slice(2, -2) + /(?:[^\w.#\\-]|$)/.source); + const state = this.state.matchHighlighter; + 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); } })();