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