From e6fa339afc768fc1d4ecba0117a043d641b62677 Mon Sep 17 00:00:00 2001 From: Andrew Rogl Date: Sat, 28 Mar 2026 11:54:13 +1000 Subject: [PATCH 01/33] Add scroll marker fake scrollbar implementation --- _locales/en/messages.json | 23 ++- _locales/zh/messages.json | 23 ++- content/highlighter.js | 311 ++++++++++++++++++++++++++++++++++++-- popup/js/options-pane.js | 7 + popup/popup.html | 17 ++- 5 files changed, 346 insertions(+), 35 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d390a04..7686a13 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7,8 +7,6 @@ "message": "A find-in-page extension with support for regular expressions.", "description": "Short description of the extension in the manifest file." }, - - "search_field_title": { "message": "Search Field", "description": "Tooltip text for the search field." @@ -125,14 +123,10 @@ "message": "Save Expression in Search Field", "description": "Tooltip text for the save expression button." }, - - "replace_field_placeholder": { "message": "Replace with..", "description": "Placeholder text for the replace field." }, - - "replace_button_text": { "message": "Replace", "description": "Text displayed inside the replace button." @@ -197,6 +191,14 @@ "message": "Hide Find and Replace Pane Toggle Button", "description": "Text displayed beside the hide find and replace toggle button option." }, + "extension_option_scroll_markers_text": { + "message": "Enable Scroll Markers", + "description": "Text displayed beside the scroll markers toggle option." + }, + "extension_option_scroll_markers_title": { + "message": "If enabled, scroll markers will appear on the right edge of the page indicating the position of all matches in the document.", + "description": "Tooltip text for the scroll markers option description icon." + }, "search_option_max_results_text": { "message": "Max Highlighted Results", "description": "Text displayed above the max highlighted results option." @@ -213,23 +215,20 @@ "message": "Reset All Options", "description": "Text displayed in the reset options button." }, - "no_expressions_found_text": { "message": "No saved expressions found.", "description": "Text displayed in the saved expressions pane when no expressions exist." }, - "clear_expressions_button_text":{ + "clear_expressions_button_text": { "message": "Clear Saved Expressions", "description": "Text displayed in the saved expressions pane." }, - "save_expression_button_text":{ + "save_expression_button_text": { "message": "Save Expression", "description": "Text displayed in the saved expressions pane." }, - - "contextmenu_show_help_title": { "message": "Show Help", "description": "Text displayed inside the show-help button in the context menu." } -} +} \ No newline at end of file diff --git a/_locales/zh/messages.json b/_locales/zh/messages.json index 7bce042..3083906 100644 --- a/_locales/zh/messages.json +++ b/_locales/zh/messages.json @@ -7,8 +7,6 @@ "message": "一个支持正则表达式查找的扩展程序", "description": "Short description of the extension in the manifest file." }, - - "search_field_title": { "message": "查找栏", "description": "Tooltip text for the search field." @@ -125,14 +123,10 @@ "message": "保存当前表达式", "description": "Tooltip text for the save expression button." }, - - "replace_field_placeholder": { "message": "替换为…", "description": "Placeholder text for the replace field." }, - - "replace_button_text": { "message": "替换", "description": "Text displayed inside the replace button." @@ -197,6 +191,14 @@ "message": "隐藏“查找并替换”按钮", "description": "Text displayed beside the hide find and replace toggle button option." }, + "extension_option_scroll_markers_text": { + "message": "启用滚动标记", + "description": "Text displayed beside the scroll markers toggle option." + }, + "extension_option_scroll_markers_title": { + "message": "如果启用,滚动标记将出现在页面的右边缘,指示文档中所有匹配项的位置。", + "description": "Tooltip text for the scroll markers option description icon." + }, "search_option_max_results_text": { "message": "最大高亮数量", "description": "Text displayed above the max highlighted results option." @@ -213,23 +215,20 @@ "message": "重置所有设置", "description": "Text displayed in the reset options button." }, - "no_expressions_found_text": { "message": "表达式为空", "description": "Text displayed in the saved expressions pane when no expressions exist." }, - "clear_expressions_button_text":{ + "clear_expressions_button_text": { "message": "清空表达式", "description": "Text displayed in the saved expressions pane." }, - "save_expression_button_text":{ + "save_expression_button_text": { "message": "保存表达式", "description": "Text displayed in the saved expressions pane." }, - - "contextmenu_show_help_title": { "message": "显示帮助(英语)", "description": "Text displayed inside the show-help button in the context menu." } -} +} \ No newline at end of file diff --git a/content/highlighter.js b/content/highlighter.js index 25f2d08..bc12202 100644 --- a/content/highlighter.js +++ b/content/highlighter.js @@ -4,7 +4,7 @@ * Create the Content Highlighter namespace. This component is injected into * the page and is used to highlight occurrences of a regex in the page. * */ -Find.register('Content.Highlighter', function(self) { +Find.register('Content.Highlighter', function (self) { const indexHighlight = 'find-ext-index-highlight'; const allHighlight = 'find-ext-all-highlight'; @@ -17,7 +17,7 @@ Find.register('Content.Highlighter', function(self) { * @param {string} regex - The regular expression * @param {object} options - The search and highlight options * */ - self.highlightAll = function(occurrenceMap, regex, options) { + self.highlightAll = function (occurrenceMap, regex, options) { const tags = { occIndex: null, maxIndex: null, @@ -159,7 +159,7 @@ Find.register('Content.Highlighter', function(self) { } //Wrap matched characters in an element with class indexHighlight and occurrenceIdentifier - let matchGroup = {text: '', groupUUID: charMap[0].nodeUUID}; + let matchGroup = { text: '', groupUUID: charMap[0].nodeUUID }; let inMatch = false; for (let key = 0; key < charMap.length; key++) { tags.update(occIndex); @@ -217,6 +217,14 @@ Find.register('Content.Highlighter', function(self) { } } } + + if (options && options.scroll_markers_enabled) { + try { + createScrollMarkers(occurrenceMap, options); + } catch (e) { + console.error('Error creating scroll markers:', e); + } + } }; /** @@ -226,7 +234,7 @@ Find.register('Content.Highlighter', function(self) { * @param {number} index - The index to seek to * @param {object} options - The search options * */ - self.seekHighlight = function(index, options) { + self.seekHighlight = function (index, options) { if (index === null || options == null) { return; } @@ -261,6 +269,11 @@ Find.register('Content.Highlighter', function(self) { window.scrollBy(0, -100); } } + try { + if (options && options.scroll_markers_enabled) { updateScrollMarkerActive(index, options); } + } catch (e) { + console.error('Error updating scroll marker:', e); + } }; /** @@ -270,7 +283,7 @@ Find.register('Content.Highlighter', function(self) { * @param {number} index - The index of the occurrence that will be replaced * @param {string} replaceWith - The text that will replace the given occurrence of the regex * */ - self.replace = function(index, replaceWith) { + self.replace = function (index, replaceWith) { let els = Array.from(document.querySelectorAll('.find-ext-occr' + index)); if (els.length === 0) { @@ -289,7 +302,7 @@ Find.register('Content.Highlighter', function(self) { * @private * @param {string} replaceWith - The text that will replace all occurrences of the regex * */ - self.replaceAll = function(replaceWith) { + self.replaceAll = function (replaceWith) { let els = Array.from(document.querySelectorAll("[class*='find-ext-occr']")); let currentOccurrence = null; @@ -300,7 +313,7 @@ Find.register('Content.Highlighter', function(self) { if (occurrenceFromClass !== currentOccurrence) { currentOccurrence = occurrenceFromClass; - el.innerText = replaceWith + el.innerText = replaceWith; } else { el.innerText = ''; } @@ -312,7 +325,7 @@ Find.register('Content.Highlighter', function(self) { * * @private * */ - self.followLinkUnderFocus = function() { + self.followLinkUnderFocus = function () { let els = document.getElementsByClassName(indexHighlight); for (let index = 0; index < els.length; index++) { let el = els[index]; @@ -330,7 +343,7 @@ Find.register('Content.Highlighter', function(self) { * * @private * */ - self.restore = function() { + self.restore = function () { let classes = [indexHighlight, allHighlight]; for (let classIndex = 0; classIndex < classes.length; classIndex++) { let els = Array.from(document.querySelectorAll('.' + classes[classIndex])); @@ -347,8 +360,286 @@ Find.register('Content.Highlighter', function(self) { parent.normalize(); } } + try { + removeAllScrollMarkers(); + } catch (e) { + console.error('Error removing scroll markers:', e); + } }; + // ── Scroll Marker / Fake Scrollbar Helpers ───────────────────────────────────── + + function getNativeScrollbarWidth() { + const w = window.innerWidth - document.documentElement.clientWidth; + return w > 0 ? w : 13; + } + function isPageDark() { + const bg = getComputedStyle(document.documentElement).backgroundColor + || getComputedStyle(document.body).backgroundColor; + const m = bg.match(/\d+/g); + if (!m) return window.matchMedia('(prefers-color-scheme: dark)').matches; + const [r, g, b] = m.map(Number); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance < 0.5; + } + + function getScrollingElement() { + // Returns the element that actually scrolls the page + if (document.scrollingElement) return document.scrollingElement; + const de = document.documentElement; + if (de.scrollHeight > de.clientHeight && getComputedStyle(de).overflowY !== 'visible') return de; + return document.body; + } + + function injectFakeScrollbar(options) { + // Remove any existing overlay first + removeAllScrollMarkers(); + const dark = isPageDark(); + const trackColor = dark ? '#2b2b2b' : '#f1f1f1'; + const thumbColor = dark ? '#6b6b6b' : '#aaaaaa'; + const thumbHoverColor = dark ? '#888888' : '#888888'; + const sbWidth = getNativeScrollbarWidth(); + + // Suppress the native scrollbar + const styleEl = document.createElement('style'); + styleEl.id = 'find-ext-scrollbar-style'; + styleEl.textContent = + '::-webkit-scrollbar { width: 0px !important; height: 0px !important; }' + + 'html { scrollbar-width: none !important; }'; + document.head.appendChild(styleEl); + + // Outer track — sits exactly where the native scrollbar was + const track = document.createElement('div'); + track.id = 'find-ext-scrollbar-overlay'; + track.style.cssText = [ + 'position: fixed', + 'top: 0', + 'right: 0', + 'width: ' + sbWidth + 'px', + 'height: 100vh', + 'z-index: 2147483647', + 'pointer-events: auto', + 'background: ' + trackColor, + 'box-sizing: border-box', + 'overflow: hidden' + ].join('; '); + + // Scroll thumb + const thumb = document.createElement('div'); + thumb.id = 'find-ext-scroll-thumb'; + thumb.style.cssText = [ + 'position: absolute', + 'right: 0', + 'width: 100%', + 'min-height: 30px', + 'background: ' + thumbColor, + 'border-radius: 3px', + 'cursor: pointer', + 'box-sizing: border-box', + 'transition: background 0.15s' + ].join('; '); + thumb.addEventListener('mouseenter', function () { thumb.style.background = thumbHoverColor; }); + thumb.addEventListener('mouseleave', function () { thumb.style.background = thumbColor; }); + track.appendChild(thumb); + document.body.appendChild(track); + + function updateThumb() { + const scrollEl = getScrollingElement(); + const docHeight = scrollEl.scrollHeight; + const viewHeight = window.innerHeight; + if (docHeight <= viewHeight) { + thumb.style.display = 'none'; + return; + } + thumb.style.display = 'block'; + const thumbH = Math.max(30, (viewHeight / docHeight) * viewHeight); + const maxThumbTop = viewHeight - thumbH; + const scrollRatio = (window.scrollY || scrollEl.scrollTop) / (docHeight - viewHeight); + thumb.style.height = thumbH + 'px'; + thumb.style.top = Math.min(maxThumbTop, scrollRatio * maxThumbTop) + 'px'; + } + + updateThumb(); + let scrollRafPending = false; + const scrollListener = function () { + if (!scrollRafPending) { + scrollRafPending = true; + requestAnimationFrame(function () { + updateThumb(); + scrollRafPending = false; + }); + } + }; + window.addEventListener('scroll', scrollListener); + track._scrollListener = scrollListener; + track._styleEl = styleEl; + + // Click on track to jump + track.addEventListener('click', function (e) { + if (e.target === thumb) return; + const rect = track.getBoundingClientRect(); + const ratio = (e.clientY - rect.top) / rect.height; + const scrollEl = getScrollingElement(); + const targetY = ratio * (scrollEl.scrollHeight - window.innerHeight); + window.scrollTo({ top: targetY, behavior: 'smooth' }); + }); + + // Drag thumb + let dragStartY = 0; + let dragStartScroll = 0; + thumb.addEventListener('mousedown', function (e) { + e.preventDefault(); + dragStartY = e.clientY; + dragStartScroll = window.scrollY || getScrollingElement().scrollTop; + const onMove = function (e) { + const scrollEl = getScrollingElement(); + const docHeight = scrollEl.scrollHeight; + const viewHeight = window.innerHeight; + const thumbH = Math.max(30, (viewHeight / docHeight) * viewHeight); + const trackH = viewHeight - thumbH; + const delta = e.clientY - dragStartY; + const scrollDelta = (delta / trackH) * (docHeight - viewHeight); + window.scrollTo(0, dragStartScroll + scrollDelta); + }; + const onUp = function () { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + return track; + } + + function calculateScrollMarkerPosition(highlightedNode) { + try { + if (!highlightedNode || typeof highlightedNode.getBoundingClientRect !== 'function') return null; + const clientRect = highlightedNode.getBoundingClientRect(); + if (!clientRect || !clientRect.width || !clientRect.height) return null; + const scrollEl = getScrollingElement(); + const docHeight = scrollEl.scrollHeight; + if (!docHeight || !Number.isFinite(docHeight) || docHeight === 0) return null; + const elementAbsoluteTop = window.scrollY + clientRect.top + (0.5 * clientRect.height); + const proportion = elementAbsoluteTop / docHeight; + const markerTop = proportion * window.innerHeight; + const finalPosition = Math.max(0, Math.min(window.innerHeight - 4, markerTop)); + return Number.isFinite(finalPosition) ? finalPosition : null; + } catch (e) { + console.error('Error calculating scroll marker position:', e); + return null; + } + } + + function createScrollMarker(occurrenceId, topPosition, color) { + try { + if (topPosition === null || topPosition === undefined) return; + const container = document.getElementById('find-ext-scrollbar-overlay'); + if (!container) return; + const cssTop = typeof topPosition === 'string' ? topPosition : (topPosition + 'px'); + const marker = document.createElement('div'); + marker.className = 'find-ext-scroll-marker find-ext-marker-' + occurrenceId; + marker.style.cssText = [ + 'display: block', + 'position: absolute', + 'top: ' + cssTop, + 'left: 0', + 'right: 0', + 'width: 100%', + 'height: 4px', + 'min-height: 4px', + 'background-color: ' + (color || '#ffff00'), + 'opacity: 0.85', + 'z-index: 2', + 'box-sizing: border-box', + 'pointer-events: none', + 'margin: 0', + 'padding: 0', + 'border: none', + 'border-radius: 1px' + ].join('; '); + container.appendChild(marker); + } catch (e) { + console.error('Error creating scroll marker:', e); + } + } + + function updateScrollMarkerActive(index, options) { + try { + const markers = Array.from(document.querySelectorAll('.find-ext-scroll-marker')); + for (let i = 0; i < markers.length; i++) { + markers[i].style.backgroundColor = options.all_highlight_color.hexColor; + markers[i].style.zIndex = '2'; + } + if (index !== null) { + const activeMarkers = Array.from(document.querySelectorAll('.find-ext-scroll-marker.find-ext-marker-' + index)); + for (let i = 0; i < activeMarkers.length; i++) { + activeMarkers[i].style.backgroundColor = options.index_highlight_color.hexColor; + activeMarkers[i].style.zIndex = '3'; + } + } + } catch (e) { + console.error('Error updating scroll marker active state:', e); + } + } + + function removeAllScrollMarkers() { + try { + const track = document.getElementById('find-ext-scrollbar-overlay'); + if (track) { + if (track._scrollListener) window.removeEventListener('scroll', track._scrollListener); + if (track._styleEl && track._styleEl.parentNode) track._styleEl.parentNode.removeChild(track._styleEl); + if (track.parentNode) track.parentNode.removeChild(track); + } + // Also remove any injected style that may have been orphaned + const styleEl = document.getElementById('find-ext-scrollbar-style'); + if (styleEl && styleEl.parentNode) styleEl.parentNode.removeChild(styleEl); + // Remove any stray markers + const strays = Array.from(document.querySelectorAll('.find-ext-scroll-marker')); + for (const el of strays) { if (el.parentNode) el.parentNode.removeChild(el); } + } catch (e) { + console.error('Error removing scroll markers:', e); + } + } + + function createScrollMarkers(occurrenceMap, options) { + try { + if (!options || !options.scroll_markers_enabled) return; + if (!document.body || !document.documentElement) return; + + // Build fake scrollbar track (also clears previous) + injectFakeScrollbar(options); + + // Collect unique occurrence IDs from highlight spans + const occurrenceIds = new Set(); + const allOccurrences = Array.from(document.querySelectorAll("[class*='find-ext-occr']")); + for (let i = 0; i < allOccurrences.length; i++) { + try { + const classMatch = allOccurrences[i].getAttribute('class').match(/find-ext-occr(\d+)/); + if (classMatch && classMatch[1]) occurrenceIds.add(classMatch[1]); + } catch (e) { continue; } + } + + occurrenceIds.forEach(function (occurrenceId) { + try { + const occurrenceEl = document.querySelector('.find-ext-occr' + occurrenceId); + if (occurrenceEl) { + const markerTop = calculateScrollMarkerPosition(occurrenceEl); + if (markerTop !== null) { + createScrollMarker(occurrenceId, markerTop, options.all_highlight_color.hexColor); + } + } + } catch (e) { + console.error('Error creating marker for occurrence ' + occurrenceId + ':', e); + } + }); + } catch (e) { + console.error('Error in createScrollMarkers:', e); + } + } + + // ── Utility ──────────────────────────────────────────────────────────────────── + function isElementInViewport(element) { let elementBoundingRect = element.getBoundingClientRect(); if (elementBoundingRect.top < 0 || elementBoundingRect.left < 0) { @@ -365,4 +656,4 @@ Find.register('Content.Highlighter', function(self) { return true; } -}); \ No newline at end of file +}); diff --git a/popup/js/options-pane.js b/popup/js/options-pane.js index e28efa4..8aae046 100644 --- a/popup/js/options-pane.js +++ b/popup/js/options-pane.js @@ -21,6 +21,7 @@ Find.register('Popup.OptionsPane', function (self) { hide_saved_expressions_button: false, hide_clipboard_button: true, hide_find_replace_button: true, + scroll_markers_enabled: false, max_results: 0, index_highlight_color: Object.freeze({ hue: 34, @@ -95,6 +96,11 @@ Find.register('Popup.OptionsPane', function (self) { Find.Popup.Storage.saveOptions(options); Find.Popup.SearchPane.hideFindReplacePaneToggleButton(options.hide_find_replace_button); }); + document.getElementById('scroll-markers-enable-toggle').addEventListener('change', (e) => { + options.scroll_markers_enabled = e.target.checked; + Find.Popup.Storage.saveOptions(options); + Find.Popup.BrowserAction.updateSearch(); + }); //Add max results slider event listeners let maxResultsSlider = document.getElementById('max-results-slider'); @@ -354,6 +360,7 @@ Find.register('Popup.OptionsPane', function (self) { document.getElementById('hide-saved-expressions-pane-toggle-option-toggle').checked = options.hide_saved_expressions_button; document.getElementById('hide-copy-to-clipboard-option-toggle').checked = options.hide_clipboard_button; document.getElementById('find-replace-toggle-option-toggle').checked = options.hide_find_replace_button; + document.getElementById('scroll-markers-enable-toggle').checked = options.scroll_markers_enabled; Find.Popup.SearchPane.hideOptionsPaneToggleButton(options.hide_options_button); Find.Popup.SearchPane.hideSavedExpressionsPaneToggleButton(options.hide_saved_expressions_button); diff --git a/popup/popup.html b/popup/popup.html index ee30fe1..66b41fc 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -239,7 +239,22 @@ - + + + +