From a88d7119cd6b3fe14d671b1c810f595e2a4ce8ef Mon Sep 17 00:00:00 2001 From: neaumusic Date: Sun, 14 Nov 2021 22:36:16 -0800 Subject: [PATCH] fix firefox; throttle selectionchange w selectionstart and selectionString --- src/highlighter/highlighter.js | 351 ++++++++++++++++++++------------- 1 file changed, 212 insertions(+), 139 deletions(-) diff --git a/src/highlighter/highlighter.js b/src/highlighter/highlighter.js index 638d157..1cb2472 100644 --- a/src/highlighter/highlighter.js +++ b/src/highlighter/highlighter.js @@ -1,27 +1,47 @@ import { defaultOptionsText } from '../options/default_options_text'; -const options = eval(defaultOptionsText); -chrome.storage.sync.get('optionsText', e => { - if (e.optionsText) { - try { - const userOptions = eval(e.optionsText); - Object.entries(userOptions).forEach(([key, value]) => { - options[key] = value; - }); - } catch (e) { console.error('Error parsing Selection Highlighter options.\n\n',e); } +( + async function () { + await getOptions(); + createMarkTemplate(options); + addGateKeyListeners(); + document.addEventListener('selectstart', onSelectStart); + document.addEventListener('selectionchange', onSelectionChange); } +)() + +const options = eval(defaultOptionsText); +function getOptions () { + return new Promise((resolve, reject) => { + chrome.storage.sync.get('optionsText', e => { + if (e.optionsText) { + try { + const userOptions = eval(e.optionsText); + Object.entries(userOptions).forEach(([key, value]) => { + options[key] = value; + }); + + } catch (e) { + console.error('Error parsing Selection Highlighter options.\n\n', e); + reject(options); + } + } - initialize(); -}); + resolve(options); + }) + }); +} -function initialize () { - const highlightedMarkTemplate = document.createElement('mark'); - highlightedMarkTemplate.className = options.highlightedClassName; +const highlightedMarkTemplate = document.createElement('mark'); +function createMarkTemplate (options) { + highlightedMarkTemplate.className = options.highlightedClassName; Object.entries(options.styles).forEach(([styleName, styleValue]) => { highlightedMarkTemplate.style[styleName] = styleValue; }); +} - const pressedKeys = []; +const pressedKeys = []; +function addGateKeyListeners (options) { document.addEventListener('keydown', e => { const index = pressedKeys.indexOf(e.key); if (index === -1) { @@ -37,155 +57,208 @@ function initialize () { window.addEventListener('blur', e => { pressedKeys.splice(0, pressedKeys.length); }); +} - document.addEventListener('selectionchange', onSelectionChange); +let lastSelectionString = null; +let isNewSelection = false; +let didClearSelection = false; +function onSelectStart () { + isNewSelection = true; +} - let latestStartTime = null; - function onSelectionChange (e) { - latestStartTime = performance.now(); +let latestRunNumber = 0; +function onSelectionChange (e) { + const selectionString = window.getSelection() + ''; + if (!isNewSelection) { + if (selectionString === lastSelectionString) { + return; + } + } + isNewSelection = false; + lastSelectionString = selectionString; + const runNumber = ++latestRunNumber; + + if (!options.isWindowLocationValid(window.location)) return; + if (!options.areKeysPressed(pressedKeys)) return; - if (!options.isWindowLocationValid(window.location)) return; - if (!options.areKeysPressed(pressedKeys)) return; + requestAnimationFrame(() => { + removeOccurrences(); + }); + requestAnimationFrame(() => { + removeScrollMarkers(); + }); + + requestAnimationFrame(() => { + highlight(runNumber); + }); +}; + +function removeOccurrences () { document.querySelectorAll('.' + options.highlightedClassName).forEach(element => { - const parent = element.parentNode; - if (parent) { - parent.replaceChild(new Text(element.textContent || ''), element); - parent.normalize(); - } - }); - if (options.areScrollMarkersEnabled()) { - document.querySelectorAll('.' + options.scrollMarkerClassName).forEach(element => { - document.body.removeChild(element); - }); + const parent = element.parentNode; + if (parent) { + parent.replaceChild(new Text(element.textContent || ''), element); + parent.normalize(); } + }); +} - highlight(latestStartTime); - }; +function removeScrollMarkers () { + if (options.areScrollMarkersEnabled()) { + document.querySelectorAll('.' + options.scrollMarkerClassName).forEach(element => { + document.body.removeChild(element); + }); + } +} - function highlight (startTime) { - const selection = document.getSelection(); - const trimmedSelection = String(selection).match(options.trimRegex()); - if (!trimmedSelection) return; +function highlight (runNumber) { + const selection = document.getSelection(); + const trimmedSelection = String(selection).match(options.trimRegex()); + if (!trimmedSelection) return; - const leadingSpaces = trimmedSelection[1]; - const selectionString = trimmedSelection[2]; - const trailingSpaces = trimmedSelection[3]; - if (!options.isSelectionValid({ selectionString, selection })) return; + const leadingSpaces = trimmedSelection[1]; + const selectionString = trimmedSelection[2]; + const trailingSpaces = trimmedSelection[3]; + if (!options.isSelectionValid({ selectionString, selection })) return; - // https://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript - const occurrenceRegex = options.occurrenceRegex(selectionString.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')); + // https://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript + const occurrenceRegex = options.occurrenceRegex(selectionString.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')); - const allTextNodes = []; - const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - while (treeWalker.nextNode()) { - allTextNodes.push(treeWalker.currentNode); - } + const allTextNodes = []; + const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + while (treeWalker.nextNode()) { + allTextNodes.push(treeWalker.currentNode); + } - for (let i = 0; i < allTextNodes.length; i++) { - const textNode = allTextNodes[i]; - const parent = textNode.parentNode; - const highlightedNodes = highlightOccurrences(textNode); - if (highlightedNodes) { - if (parent) parent.normalize(); - }; - } + for (let i = 0; i < allTextNodes.length; i++) { + const textNode = allTextNodes[i]; + const parent = textNode.parentNode; + const highlightedNodes = highlightOccurrences(textNode); + if (highlightedNodes) { + if (parent) parent.normalize(); + }; + } - if (options.areScrollMarkersEnabled()) { - requestAnimationFrame(() => { - if (startTime !== latestStartTime) return; - - const highlighted = document.querySelectorAll('.' + options.highlightedClassName); - const scrollMarkersFragment = document.createDocumentFragment(); - - for (let i = 0; i < highlighted.length; i++) { - setTimeout(() => { - if (startTime !== latestStartTime) return; - - const highlightedNode = highlighted[i]; - const scrollMarker = document.createElement('div'); - scrollMarker.className = options.scrollMarkerClassName; - const scrollMarkerStyles = options.scrollMarkerStyles({ window, document, highlightedNode }); - if (scrollMarkerStyles) { - Object.entries(scrollMarkerStyles).forEach(([styleName, styleValue]) => { - scrollMarker.style[styleName] = styleValue; - }); - scrollMarkersFragment.appendChild(scrollMarker); - } - }, 0); + if (options.areScrollMarkersEnabled()) { + const highlighted = document.querySelectorAll('.' + options.highlightedClassName); + const scrollMarkersFragment = document.createDocumentFragment(); + + for (let i = 0; i < highlighted.length; i++) { + setTimeout(() => { + if (runNumber !== latestRunNumber) return; + + const highlightedNode = highlighted[i]; + const scrollMarker = document.createElement('div'); + scrollMarker.className = options.scrollMarkerClassName; + const scrollMarkerStyles = options.scrollMarkerStyles({ window, document, highlightedNode }); + if (scrollMarkerStyles) { + Object.entries(scrollMarkerStyles).forEach(([styleName, styleValue]) => { + scrollMarker.style[styleName] = styleValue; + }); + scrollMarkersFragment.appendChild(scrollMarker); } + }, 0); + } - setTimeout(() => { - if (startTime === latestStartTime) { - document.body.appendChild(scrollMarkersFragment); - } - }, 0); - }) + setTimeout(() => { + if (runNumber === latestRunNumber) { + document.body.appendChild(scrollMarkersFragment); + } + }, 0); + } + + function highlightOccurrences (textNode) { + const match = occurrenceRegex.exec(textNode.data); + if (!match) return; + if (!options.isAncestorNodeValid(textNode.parentNode)) return; + + const matchIndex = match.index; + const anchorToFocusDirection = selection.anchorNode.compareDocumentPosition(selection.focusNode); + + function isSelectionAcrossNodesLeftToRight () { + return anchorToFocusDirection & Node.DOCUMENT_POSITION_FOLLOWING; } - function highlightOccurrences (textNode) { - const match = occurrenceRegex.exec(textNode.data); - if (!match) return; - if (!options.isAncestorNodeValid(textNode.parentNode)) return; + function isSelectionAcrossNodesRightToLeft () { + return anchorToFocusDirection & Node.DOCUMENT_POSITION_PRECEDING; + } - const matchIndex = match.index; - const anchorToFocusDirection = selection.anchorNode.compareDocumentPosition(selection.focusNode); - const isUsersSelection = ( - (anchorToFocusDirection & Node.DOCUMENT_POSITION_FOLLOWING) ? ( - (textNode === selection.anchorNode && ( + function isUsersSelection () { + if (isSelectionAcrossNodesLeftToRight()) { + if (textNode === selection.anchorNode) { + return ( selection.anchorNode.nodeType === Node.ELEMENT_NODE && selection.anchorOffset === 0 || selection.anchorOffset === matchIndex - leadingSpaces.length - )) || (textNode === selection.focusNode && ( + ); + + } else if (textNode === selection.focusNode) { + return ( selection.focusNode.nodeType === Node.ELEMENT_NODE && selection.focusOffset === 0 || selection.focusOffset === matchIndex + selectionString.length + trailingSpaces.length - )) || ( - (textNode !== selection.anchorNode && textNode !== selection.focusNode) && ( - selection.anchorNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_FOLLOWING && - selection.focusNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_PRECEDING - ) - ) - ) : (anchorToFocusDirection & Node.DOCUMENT_POSITION_PRECEDING) ? ( - (textNode === selection.anchorNode && ( + ); + + } else { + return ( + selection.anchorNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_FOLLOWING && + selection.focusNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_PRECEDING + ); + } + + } else if (isSelectionAcrossNodesRightToLeft()) { + if (textNode === selection.anchorNode) { + return ( selection.anchorNode.nodeType === Node.ELEMENT_NODE && selection.anchorOffset === 0 || - selection.anchorNode.nodeType === Node.TEXT_NODE && selection.anchorOffset === matchIndex + selectionString.length + trailingSpaces.length - )) || (textNode === selection.focusNode && ( + selection.anchorOffset === matchIndex + selectionString.length + trailingSpaces.length + ); + + } else if (textNode === selection.focusNode) { + return ( selection.focusNode.nodeType === Node.ELEMENT_NODE && selection.focusOffset === 0 || selection.focusOffset === matchIndex - leadingSpaces.length - )) || ( - (textNode !== selection.anchorNode && textNode !== selection.focusNode) && ( - selection.anchorNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_PRECEDING && - selection.focusNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_FOLLOWING - ) - ) - ) : ( - (selection.anchorOffset < selection.focusOffset) && ( - (textNode === selection.anchorNode && ( - selection.anchorOffset === matchIndex - leadingSpaces.length - )) - ) || (selection.anchorOffset > selection.focusOffset) && ( - (textNode === selection.focusNode && ( - selection.focusOffset === matchIndex - leadingSpaces.length - )) - ) - ) - ); - if (!isUsersSelection) { - const trimmedTextNode = textNode.splitText(matchIndex); - const remainingTextNode = trimmedTextNode.splitText(selectionString.length); - const highlightedNode = highlightedMarkTemplate.cloneNode(true); - highlightedNode.appendChild(trimmedTextNode.cloneNode(true)); - - const parent = trimmedTextNode.parentNode; - if (parent) parent.replaceChild(highlightedNode, trimmedTextNode); - - const otherHighlightedNodes = highlightOccurrences(remainingTextNode) || []; - return [ highlightedNode ].concat(otherHighlightedNodes); + ); + + } else { + return ( + selection.anchorNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_PRECEDING && + selection.focusNode.compareDocumentPosition(textNode) & Node.DOCUMENT_POSITION_FOLLOWING + ); + } + } else { - const clonedNode = textNode.cloneNode(); - const remainingClonedTextNode = clonedNode.splitText(matchIndex + selectionString.length); - if (occurrenceRegex.exec(remainingClonedTextNode.data)) - return highlightOccurrences(textNode.splitText(matchIndex + selectionString.length)); + if (selection.anchorOffset < selection.focusOffset) { + return ( + textNode === selection.anchorNode && + selection.anchorOffset <= matchIndex - leadingSpaces.length && + selection.focusOffset >= matchIndex + selectionString.length + trailingSpaces.length + ); + + } else if (selection.anchorOffset > selection.focusOffset) { + return ( + textNode === selection.focusNode && + selection.focusOffset <= matchIndex - leadingSpaces.length && + selection.anchorOffset >= matchIndex + selectionString.length + trailingSpaces.length + ); + } } + }; + + if (!isUsersSelection()) { + const trimmedTextNode = textNode.splitText(matchIndex); + const remainingTextNode = trimmedTextNode.splitText(selectionString.length); + const highlightedNode = highlightedMarkTemplate.cloneNode(true); + highlightedNode.appendChild(trimmedTextNode.cloneNode(true)); + + const parent = trimmedTextNode.parentNode; + if (parent) parent.replaceChild(highlightedNode, trimmedTextNode); + + const otherHighlightedNodes = highlightOccurrences(remainingTextNode) || []; + return [ highlightedNode ].concat(otherHighlightedNodes); + } else { + const clonedNode = textNode.cloneNode(); + const remainingClonedTextNode = clonedNode.splitText(matchIndex + selectionString.length); + if (occurrenceRegex.exec(remainingClonedTextNode.data)) + return highlightOccurrences(textNode.splitText(matchIndex + selectionString.length)); } - }; -} + } +};