diff --git a/edit.html b/edit.html index c064d89f..da439c2b 100644 --- a/edit.html +++ b/edit.html @@ -31,7 +31,6 @@ - @@ -62,6 +61,8 @@ + + @@ -77,8 +78,6 @@ - - diff --git a/edit/codemirror-factory.js b/edit/codemirror-factory.js index 68996bb9..887e6c08 100644 --- a/edit/codemirror-factory.js +++ b/edit/codemirror-factory.js @@ -32,12 +32,14 @@ const cmFactory = (() => { if (value === 'token') { cm.setOption('highlightSelectionMatches', { showToken: /[#.\-\w]/, - annotateScrollbar: true + annotateScrollbar: true, + onUpdate: updateMatchHighlightCount }); } else if (value === 'selection') { cm.setOption('highlightSelectionMatches', { showToken: false, - annotateScrollbar: true + annotateScrollbar: true, + onUpdate: updateMatchHighlightCount }); } else { cm.setOption('highlightSelectionMatches', null); @@ -80,6 +82,10 @@ const cmFactory = (() => { }); return {create, destroy, setOption}; + function updateMatchHighlightCount(cm, state) { + cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length; + } + function configureMouseFn(cm, repeat) { return repeat === 'double' ? {unit: selectTokenOnDoubleclick} : diff --git a/edit/edit.css b/edit/edit.css index 907cac06..22d9a110 100644 --- a/edit/edit.css +++ b/edit/edit.css @@ -364,14 +364,14 @@ input:invalid { .resize-grip-enabled .CodeMirror-scrollbar-filler { bottom: 7px; /* make space for resize-grip */ } -body[data-match-highlight="token"] .cm-matchhighlight-approved .cm-matchhighlight, -body[data-match-highlight="token"] .CodeMirror-selection-highlight-scrollbar { +.cm-matchhighlight, +.CodeMirror-selection-highlight-scrollbar { animation: fadein-match-highlighter 1s cubic-bezier(.97,.01,.42,.98); animation-fill-mode: both; } -body[data-match-highlight="selection"] .cm-matchhighlight-approved .cm-matchhighlight, -body[data-match-highlight="selection"] .CodeMirror-selection-highlight-scrollbar { - background-color: rgba(1, 151, 193, 0.1); +[data-match-highlight-count="1"] .cm-matchhighlight, +[data-match-highlight-count="1"] .CodeMirror-selection-highlight-scrollbar { + animation: none; } @-webkit-keyframes highlight { from { diff --git a/edit/match-highlighter-helper.js b/edit/match-highlighter-helper.js deleted file mode 100644 index 8396a7ed..00000000 --- a/edit/match-highlighter-helper.js +++ /dev/null @@ -1,223 +0,0 @@ -/* global CodeMirror prefs */ -'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 SEARCH_MATCH_TOKEN_NAME = 'searching'; - - const originalAddOverlay = CodeMirror.prototype.addOverlay; - const originalRemoveOverlay = CodeMirror.prototype.removeOverlay; - const originalMatchesOnScrollbar = CodeMirror.prototype.showMatchesOnScrollbar; - const originalSetOption = CodeMirror.prototype.setOption; - let originalGetOption; - - CodeMirror.prototype.addOverlay = addOverlay; - CodeMirror.prototype.removeOverlay = removeOverlay; - CodeMirror.prototype.showMatchesOnScrollbar = matchesOnScrollbar; - CodeMirror.prototype.setOption = setOption; - - let enabled = Boolean(prefs.get('editor.matchHighlight')); - - return; - - function setOption(option, value) { - enabled = option === 'highlightSelectionMatches' ? value : enabled; - return originalSetOption.apply(this, arguments); - } - - function shouldIntercept(overlay) { - const hlState = this.state.matchHighlighter || {}; - return overlay === hlState.overlay && (hlState.options || {}).showToken; - } - - function addOverlay() { - return enabled && shouldIntercept.apply(this, arguments) && - addOverlayForHighlighter.apply(this, arguments) || - originalAddOverlay.apply(this, arguments); - } - - function removeOverlay() { - return enabled && shouldIntercept.apply(this, arguments) && - removeOverlayForHighlighter.apply(this, arguments) || - originalRemoveOverlay.apply(this, arguments); - } - - function addOverlayForHighlighter(overlay) { - 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); - - // 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; - return true; - } - - // hook the newly created overlay's token() to count the occurrences - if (overlay.token !== tokenHook) { - overlay.highlightHelper = { - token: overlay.token, - occurrences: 0, - }; - overlay.token = tokenHook; - } - - // 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; - } - - const tokens = stream.lineOracle.baseTokens; - const tokenIndex = tokens.indexOf(stream.pos, 1); - if (tokenIndex > 0) { - const tokenStart = tokenIndex > 2 ? tokens[tokenIndex - 2] : 0; - const token = tokenStart === stream.start && tokens[tokenIndex + 1]; - const index = token && token.indexOf(SEARCH_MATCH_TOKEN_NAME); - if (token && index >= 0 && - (token[index - 1] || ' ') === ' ' && - (token[index + SEARCH_MATCH_TOKEN_NAME.length] || ' ') === ' ') { - return; - } - } - - const num = ++this.highlightHelper.occurrences; - if (num === 1) { - stream.lineOracle.doc.cm.display.wrapper.classList.remove(HL_APPROVED); - } else if (num === 2) { - stream.lineOracle.doc.cm.display.wrapper.classList.add(HL_APPROVED); - } - return style; - } - - function removeOverlayForHighlighter() { - const state = this.state.matchHighlighter || {}; - 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 sel = this.getSelection(); - // current query differs from the selected text => remove the overlay - if (sel && sel.toLowerCase() !== originalToken.toLowerCase()) { - helper.query = helper.originalToken = sel; - return; - } - // if token under cursor has changed => remove the overlay - if (!sel) { - const {line, ch} = this.getCursor(); - const queryLen = originalToken.length; - const start = Math.max(0, ch - queryLen + 1); - const end = ch + queryLen; - const string = this.getLine(line); - const area = string.slice(start, end); - const i = area.indexOf(query); - const 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; - } - } - // 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 || (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), - originalToken, - query, - }; - // fool the original addon so it won't invoke state.matchesonscroll.clear() - state.matchesonscroll = null; - return true; - } - - function removeOverlayIfExpired(self, state) { - const {overlay, matchesonscroll} = state.highlightHelper || {}; - if (overlay) { - originalRemoveOverlay.call(self, overlay); - } - if (matchesonscroll) { - matchesonscroll.clear(); - } - state.highlightHelper = null; - } - - function matchesOnScrollbar(query, ...args) { - if (!enabled) { - return originalMatchesOnScrollbar.call(this, query, ...args); - } - 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); - } - } -})(); diff --git a/vendor/codemirror/addon/search/match-highlighter.js b/vendor-overwrites/codemirror-addon/match-highlighter.js similarity index 89% rename from vendor/codemirror/addon/search/match-highlighter.js rename to vendor-overwrites/codemirror-addon/match-highlighter.js index b344ac79..cf2a53b0 100644 --- a/vendor/codemirror/addon/search/match-highlighter.js +++ b/vendor-overwrites/codemirror-addon/match-highlighter.js @@ -36,7 +36,8 @@ wordsOnly: false, annotateScrollbar: false, showToken: false, - trim: true + trim: true, + onUpdate: () => {} } function State(options) { @@ -46,6 +47,7 @@ this.overlay = this.timeout = null; this.matchesonscroll = null; this.active = false; + this.query = null; } CodeMirror.defineOption("highlightSelectionMatches", false, function(cm, val, old) { @@ -88,12 +90,24 @@ function addOverlay(cm, query, hasBoundary, style) { var state = cm.state.matchHighlighter; + if (state.query === query) { + return; + } + removeOverlay(cm); + state.query = query; cm.addOverlay(state.overlay = makeOverlay(query, hasBoundary, style)); if (state.options.annotateScrollbar && cm.showMatchesOnScrollbar) { - var searchFor = hasBoundary ? new RegExp("\\b" + query.replace(/[\\\[.+*?(){|^$]/g, "\\$&") + "\\b") : query; + var searchFor = hasBoundary ? + new RegExp( + (/[a-z]/i.test(query[0]) ? "\\b" : "") + + query.replace(/[\\\[.+*?(){|^$]/g, "\\$&") + + (/[a-z]/i.test(query[query.length - 1]) ? "\\b" : ""), + "m" + ) : query; state.matchesonscroll = cm.showMatchesOnScrollbar(searchFor, false, {className: "CodeMirror-selection-highlight-scrollbar"}); } + state.options.onUpdate(cm, state); } function removeOverlay(cm) { @@ -106,19 +120,22 @@ state.matchesonscroll = null; } } + state.query = null; } function highlightMatches(cm) { cm.operation(function() { var state = cm.state.matchHighlighter; - removeOverlay(cm); if (!cm.somethingSelected() && state.options.showToken) { var re = state.options.showToken === true ? /[\w$]/ : state.options.showToken; var cur = cm.getCursor(), line = cm.getLine(cur.line), start = cur.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) + if (start < end) { addOverlay(cm, line.slice(start, end), re, state.options.style); + } else { + removeOverlay(cm); + } return; } var from = cm.getCursor("from"), to = cm.getCursor("to"); @@ -126,8 +143,11 @@ if (state.options.wordsOnly && !isWord(cm, from, to)) return; var selection = cm.getRange(from, to) if (state.options.trim) selection = selection.replace(/^\s+|\s+$/g, "") - if (selection.length >= state.options.minChars) + if (selection.length >= state.options.minChars) { addOverlay(cm, selection, false, state.options.style); + } else { + removeOverlay(cm); + } }); }