stabilize token highlighting for the match/search mode
This commit is contained in:
parent
2760b0764b
commit
5bdaacc049
|
@ -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 */
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
Loading…
Reference in New Issue
Block a user