├── .gitignore ├── LICENSE ├── README.txt ├── package-lock.json ├── package.json ├── src ├── bg │ └── bg.ts ├── content-scripts │ └── content.ts ├── context-menu │ └── context-menu.ts ├── find-window │ ├── clustering.ts │ ├── find-result-cache.ts │ ├── find-window.ts │ ├── finder.ts │ ├── history.ts │ └── query-store.ts ├── messages │ └── messages.ts ├── options │ ├── options.ts │ └── store.ts ├── types.ts └── util │ ├── cancellable-delay.ts │ ├── events.ts │ ├── messaging.ts │ ├── mutex.ts │ └── timestamp.ts ├── tsconfig.json ├── webext ├── content-scripts │ └── main.css ├── find-window │ ├── index.css │ └── index.html ├── img │ └── icon.svg ├── manifest.json └── options │ ├── options.css │ └── options.html └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | web-ext-artifacts/ 3 | .DS_Store 4 | webext/out/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Yusuke Takeuchi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | Search a web page and show preview images of the matches. 2 | Several matches can be clustered into an image. 3 | 4 | Restriction: 5 | - Cannot search input control values. 6 | - Cannot search inside inline frames. 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find-in-page-with-preview", 3 | "version": "0.1.17", 4 | "description": "Find in page and show preview images.", 5 | "scripts": { 6 | "build": "npx webpack" 7 | }, 8 | "author": "Yusuke Takeuchi", 9 | "license": "MIT", 10 | "dependencies": { 11 | "compute-scroll-into-view": "^1.0.13", 12 | "rtree": "^1.4.2" 13 | }, 14 | "devDependencies": { 15 | "@types/rtree": "^1.4.27", 16 | "ts-loader": "^7.0.2", 17 | "typescript": "^3.8.3", 18 | "web-ext-types": "^3.2.1", 19 | "webpack": "^4.43.0", 20 | "webpack-cli": "^3.3.11" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/bg/bg.ts: -------------------------------------------------------------------------------- 1 | import { OptionStore } from "../options/store" 2 | import { setupContextMenu } from "../context-menu/context-menu" 3 | import { MessagesBG } from "../messages/messages" 4 | 5 | function init(){ 6 | /** Observe popup windows **/ 7 | browser.runtime.onConnect.addListener( (port) => { 8 | port.onDisconnect.addListener( () => { 9 | resetFind(); 10 | }); 11 | }); 12 | 13 | initContextMenu(); 14 | } 15 | 16 | function resetFind(): void{ 17 | browser.find.removeHighlighting(); 18 | } 19 | 20 | async function initContextMenu(){ 21 | const options = await OptionStore.load(); 22 | setupContextMenu({ 23 | popup: options.showContextMenuPopup, 24 | sidebar: options.showContextMenuSidebar, 25 | }); 26 | } 27 | 28 | MessagesBG.receive({ 29 | SetContextMenu({ popup, sidebar }){ 30 | // BG need to do this because the option page cannot execute commands after it is closed 31 | setupContextMenu({ popup, sidebar }); 32 | } 33 | }) 34 | 35 | init(); -------------------------------------------------------------------------------- /src/content-scripts/content.ts: -------------------------------------------------------------------------------- 1 | import { Rect, Size2d, ScreenshotResult, ScreenshotResultMaybeError } from '../types'; 2 | import { Messages,MessagesFindWindow } from "../messages/messages" 3 | 4 | type Box = { 5 | left: number, 6 | top: number, 7 | right: number, 8 | bottom: number 9 | }; 10 | 11 | type NodeAndOffset = [Node, number]; 12 | 13 | const PreviewMargin = { 14 | width: 20, 15 | height : 10 16 | }; 17 | 18 | class TextRangeError extends Error { 19 | constructor(message: string){ 20 | super(message); 21 | this.name = "TextRangeError"; 22 | } 23 | } 24 | 25 | /** Take the screenshot for the specified range. 26 | * 27 | * @param x 28 | * @param y 29 | * @param w 30 | * @param h 31 | * @return Data URL of the image 32 | **/ 33 | function screenshot({x,y,w,h}: Rect): string{ 34 | const canvas = document.createElement("canvas"); 35 | 36 | canvas.width = w; 37 | canvas.height = h; 38 | 39 | const ctx = canvas.getContext('2d'); 40 | // @ts-ignore 41 | ctx.drawWindow(window, x, y, w, h, "rgb(255,255,255)"); 42 | return canvas.toDataURL("image/png"); 43 | } 44 | 45 | function notifyMutationToBG(isonload: boolean): void{ 46 | MessagesFindWindow.sendToBG("NotifyMutation", isonload); 47 | } 48 | 49 | class FindResultContext{ 50 | 51 | private documentTextNodes: Text[]; 52 | 53 | private resultRanges: Range[]; 54 | 55 | private targetElements: Element[]; 56 | 57 | constructor(){ 58 | this.documentTextNodes = this.collectTextNodes(); 59 | this.resultRanges = []; 60 | this.targetElements = []; 61 | 62 | const observerConfig = { 63 | attributes: false, 64 | childList: true, 65 | subtree: true, 66 | }; 67 | const observer = new MutationObserver(this.mutationObserverCallback.bind(this)); 68 | observer.observe(document.body, observerConfig); 69 | } 70 | 71 | private collectTextNodes(): Text[]{ 72 | const textNodes: Text[] = []; 73 | const walker = document.createTreeWalker(document, NodeFilter.SHOW_TEXT, null, false); 74 | let node: Node | null; 75 | while(node = walker.nextNode()){ 76 | textNodes.push(node as Text); 77 | } 78 | return textNodes; 79 | } 80 | 81 | /** Register result range and return ID 82 | * @param range 83 | **/ 84 | registerRange(range: Range): number{ 85 | this.resultRanges.push(range); 86 | return this.resultRanges.length - 1; 87 | } 88 | 89 | /** 90 | * @param id 91 | * @return 92 | **/ 93 | getRange(id: number): Range{ 94 | return this.resultRanges[id]; 95 | } 96 | 97 | 98 | /** 99 | * @param ranges 100 | * @return 101 | **/ 102 | createRangeFromFindRanges(ranges: browser.find.RangeData[]): Range{ 103 | const domRange = document.createRange(); 104 | let initialized = false; 105 | 106 | try{ 107 | for (const range of ranges){ 108 | const startPoint: NodeAndOffset = [this.documentTextNodes[range.startTextNodePos], range.startOffset], 109 | endPoint: NodeAndOffset = [this.documentTextNodes[range.endTextNodePos], range.endOffset]; 110 | if (!initialized || domRange.comparePoint(...startPoint) < 0){ 111 | domRange.setStart(...startPoint); 112 | } 113 | if (!initialized || domRange.comparePoint(...endPoint) > 0){ 114 | domRange.setEnd(...endPoint); 115 | } 116 | initialized = true; 117 | } 118 | }catch(e){ 119 | throw new TextRangeError(e.message); 120 | } 121 | 122 | return domRange; 123 | } 124 | 125 | /** 126 | * @param id 127 | * @param smoothScroll 128 | **/ 129 | gotoResult(id: number, {smoothScroll=true}): void{ 130 | const targetElement = this.createTargetElement(id); 131 | targetElement.scrollIntoView({ 132 | // @ts-ignore 133 | behavior: smoothScroll ? "smooth" : "instant", 134 | block: "center", 135 | inline: "end" 136 | }); 137 | targetElement.remove(); 138 | } 139 | 140 | private createTargetElement(id: number): HTMLElement{ 141 | const targetElt = document.createElement("SPAN"), 142 | range = this.getRange(id); 143 | if (range == null){ 144 | throw new Error("Invalid result id"); 145 | } 146 | targetElt.className = "fipwp-goto-target-element"; 147 | targetElt.style.visibility = "hidden"; 148 | targetElt.style.width = "0"; 149 | targetElt.style.borderWidth = "0"; 150 | targetElt.style.padding = "0"; 151 | targetElt.style.margin = "0"; 152 | 153 | const newRange = range.cloneRange(); 154 | newRange.collapse(false); 155 | newRange.insertNode(targetElt); 156 | return targetElt; 157 | } 158 | 159 | /** 160 | * Basic idea: 161 | * First find the element E such that: 162 | * - E is a parent of the found ranges 163 | * - E's width is smaller than SS width 164 | * - E's parent's width is bigger than SS width 165 | * Second decide SS range R so that: 166 | * - R contains E's horizontal range 167 | * - R is contained by E's parent's horizontal range 168 | * - The distance between R's center pos and the center pos of the found ranges is minimized 169 | * 170 | * Do the same for y. 171 | **/ 172 | private computeScreenshotStartPosForClusterCommon( 173 | xory: "x" | "y", 174 | clusterRect: Rect, 175 | domRange: Range, 176 | ssSize: Size2d 177 | ): number{ 178 | const horizontal = (xory == "x"); 179 | 180 | const x = horizontal ? "x" : "y", 181 | w = horizontal ? "w" : "h", 182 | left = horizontal ? "left" : "top", 183 | right = horizontal ? "right" : "bottom", 184 | width = horizontal ? "width" : "height", 185 | scrollWidth = horizontal ? "scrollWidth" : "scrollHeight"; 186 | 187 | const clusterCenter: number = clusterRect[x] + clusterRect[w]/2; 188 | 189 | let xRangeContained = { // SS contains this 190 | [left]: Math.max(0, clusterRect[x] - PreviewMargin[width]), 191 | [right]: Math.min(document.documentElement[scrollWidth], 192 | clusterRect[x] + clusterRect[w] + PreviewMargin[width]), 193 | }, 194 | xRangeContaining = null; // SS is contained by this 195 | 196 | const baseElt = commonAncestorElement(domRange); 197 | 198 | for (let currentElement: Node | null = baseElt; 199 | currentElement != null && currentElement.nodeType === Node.ELEMENT_NODE; 200 | currentElement = currentElement.parentNode 201 | ){ 202 | const eltBox = getElementBox(currentElement as HTMLElement), 203 | newLeft = Math.min(xRangeContained[left], eltBox[left]), 204 | newRight = Math.max(xRangeContained[right], eltBox[right]), 205 | newWidth = newRight - newLeft; 206 | 207 | if (newWidth >= ssSize[width]){ 208 | xRangeContaining = { 209 | [left]: Math.min(newLeft, xRangeContained[left]), 210 | [right]: Math.max(newRight, xRangeContained[right]), 211 | }; 212 | break; 213 | }else{ 214 | xRangeContained = { 215 | [left]: newLeft, 216 | [right]: newRight 217 | }; 218 | xRangeContaining = null; 219 | } 220 | } 221 | if (xRangeContaining == null){ // not element big enough 222 | xRangeContaining = { 223 | [left]: 0, 224 | [right]: Infinity 225 | }; 226 | } 227 | 228 | // find cx such that: 229 | // - xRangeContaining.left <= cx-ssSize.width/2 <= xRangeContained.left 230 | // - xRangeContained.right <= cx+ssSize.width/2 <= xRangeContaining.right 231 | // - minimize the distance between cx and clusterCenter 232 | 233 | const cxMin = Math.max(xRangeContaining[left] + ssSize[width]/2, 234 | xRangeContained[right] - ssSize[width]/2), 235 | cxMax = Math.min(xRangeContained[left] + ssSize[width]/2, 236 | xRangeContaining[right] - ssSize[width]/2); 237 | const cx = clamp(clusterCenter, cxMin, cxMax); 238 | 239 | return cx - ssSize[width]/2; 240 | 241 | function clamp(val: number, min: number, max: number): number{ 242 | return Math.max(min, Math.min(val, max)); 243 | } 244 | } 245 | 246 | computeScreenshotRectForClusterRect(clusterRect: Rect, domRange: Range, ssSize: Size2d): Rect{ 247 | return { 248 | x: this.computeScreenshotStartPosForClusterCommon("x", clusterRect, domRange, ssSize), 249 | y: this.computeScreenshotStartPosForClusterCommon("y", clusterRect, domRange, ssSize), 250 | w: ssSize.width, 251 | h: ssSize.height, 252 | }; 253 | } 254 | 255 | private mutationObserverCallback(mutationList: MutationRecord[]): void { 256 | // ignore if mutationList contains a fipwp target element 257 | const doNotifyMutation = mutationList.every( (record) => { 258 | if (record.type != "childList"){ 259 | return false; 260 | } 261 | const isFipwpAnchorElement = (node: Node) => 262 | (node.nodeType === Node.ELEMENT_NODE) && (node as Element).classList.contains("fipwp-goto-target-element"); 263 | return !Array.from(record.addedNodes).some(isFipwpAnchorElement); 264 | }); 265 | 266 | if (doNotifyMutation){ 267 | notifyMutationToBG(false); 268 | } 269 | } 270 | } 271 | 272 | let context: FindResultContext | null = null; 273 | 274 | /** 275 | * @param range 276 | * @return 277 | **/ 278 | function commonAncestorElement(range: Range): HTMLElement { 279 | const node = range.commonAncestorContainer; 280 | return (node.nodeType === Node.TEXT_NODE ? node.parentNode : node) as HTMLElement; 281 | } 282 | 283 | /** 284 | * @see {@link http://uhyo.hatenablog.com/entry/2017/03/15/130825} 285 | * 286 | * @param elt 287 | **/ 288 | function getElementBox(elt: HTMLElement): Box{ 289 | return getPageBox(elt.getBoundingClientRect()); 290 | } 291 | 292 | /** 293 | * @param domRect value returned by getClientRects()[] or getBoundingClientRect() 294 | **/ 295 | function getPageBox(domRect: DOMRect): Box{ 296 | const {left, top, width, height} = domRect, 297 | {left: bleft, top: btop} = document.body.getBoundingClientRect(); 298 | return { 299 | left: left - bleft, 300 | top: top - btop, 301 | right: left -bleft + width, 302 | bottom: top - btop + height, 303 | }; 304 | } 305 | 306 | function boxToRect(box: Box): Rect{ 307 | return { 308 | x: box.left, 309 | y: box.top, 310 | w: box.right - box.left, 311 | h: box.bottom - box.top 312 | }; 313 | } 314 | 315 | let camouflageMap: Map | null = null; 316 | 317 | const receiver = { 318 | /** Extremely dirty hack to work around FF's bug 319 | * @see {@link https://bugzilla.mozilla.org/show_bug.cgi?id=1448564} 320 | **/ 321 | CamouflageInputs(q: string){ 322 | if (camouflageMap != null){ 323 | return; 324 | } 325 | 326 | camouflageMap = new Map; 327 | 328 | if (q.length === 0){ 329 | return; 330 | } 331 | 332 | const inputElts: NodeListOf = document.querySelectorAll(`input, textarea`); 333 | for (const elt of inputElts){ 334 | if (typeof elt.value === "string"){ 335 | camouflageMap.set(elt, elt.style.visibility); 336 | elt.style.visibility = "hidden"; 337 | } 338 | } 339 | }, 340 | 341 | UncamouflageInputs(){ 342 | if (camouflageMap == null){ 343 | return; 344 | } 345 | 346 | for (const [elt,visibility] of camouflageMap){ 347 | elt.style.visibility = visibility; 348 | } 349 | camouflageMap = null; 350 | }, 351 | 352 | Start(){ 353 | context = new FindResultContext; 354 | }, 355 | 356 | async Screenshot( {clusterRect, ranges, ssSize} :{ 357 | clusterRect: Rect | null, 358 | ranges: browser.find.RangeData[], 359 | ssSize: Size2d, 360 | } ): Promise{ 361 | if (context == null){ 362 | return Promise.reject("not searched"); 363 | } 364 | 365 | try{ 366 | if (clusterRect == null){ 367 | return await this.registerRanges(context, {ranges, ssSize} ); 368 | }else{ 369 | return await this.screenshotClusterRect(context, {clusterRect, ranges, ssSize} ); 370 | } 371 | }catch(e){ 372 | console.log({s: "Screenshot error", e}); 373 | if (e instanceof TextRangeError){ 374 | return {error: e.message}; 375 | }else{ 376 | throw e; 377 | } 378 | } 379 | }, 380 | 381 | async registerRanges(context: FindResultContext, {ranges, ssSize}: {ranges: browser.find.RangeData[], ssSize: Size2d}): Promise{ 382 | const domRange = context.createRangeFromFindRanges(ranges), 383 | gotoID = context.registerRange(domRange), 384 | cRect = boxToRect(getPageBox(domRange.getClientRects()[0])), 385 | rect = context.computeScreenshotRectForClusterRect(cRect, domRange, ssSize); 386 | return { 387 | gotoID, 388 | rect, 389 | url: null, 390 | }; 391 | }, 392 | 393 | async screenshotClusterRect(context: FindResultContext, {clusterRect, ranges, ssSize}: {clusterRect: Rect, ranges: browser.find.RangeData[], ssSize: Size2d} ): Promise{ 394 | const domRange = context.createRangeFromFindRanges(ranges); 395 | const gotoID = context.registerRange(domRange); 396 | const rect = context.computeScreenshotRectForClusterRect(clusterRect, domRange, ssSize); 397 | return { 398 | gotoID, 399 | rect, 400 | url: screenshot(rect), 401 | }; 402 | }, 403 | 404 | async GotoID( {id, smoothScroll}: {id: number, smoothScroll: boolean} ){ 405 | if (context == null){ 406 | throw new Error("No match"); 407 | } 408 | context.gotoResult(id, {smoothScroll}); 409 | }, 410 | 411 | async Reset(): Promise<{success: boolean}>{ 412 | let success; 413 | if (context){ 414 | context = null; 415 | success = true; 416 | }else{ 417 | success = false; 418 | } 419 | return {success}; 420 | }, 421 | 422 | /** Check whether this page has been searched **/ 423 | async Ping(): Promise<{result: boolean}>{ 424 | return { 425 | result: context ? true: false, 426 | }; 427 | }, 428 | }; 429 | 430 | Messages.receive(receiver); -------------------------------------------------------------------------------- /src/context-menu/context-menu.ts: -------------------------------------------------------------------------------- 1 | import { CancellableDelay } from "../util/cancellable-delay" 2 | 3 | function setupContextMenu({ popup, sidebar }: { popup: boolean, sidebar: boolean }){ 4 | browser.menus.removeAll(); 5 | 6 | if (popup){ 7 | browser.menus.create({ 8 | title: "Find in Page (Popup)", 9 | contexts: ["selection"], 10 | async onclick(info, tab){ 11 | await browser.browserAction.openPopup(); 12 | const q = info.selectionText; 13 | if (q){ 14 | openAndWaitForFindWindow(q.trim(), "popup", null); 15 | } 16 | } 17 | }); 18 | } 19 | 20 | if (sidebar){ 21 | browser.menus.create({ 22 | title: "Find in Page (Sidebar)", 23 | contexts: ["selection"], 24 | async onclick(info, tab){ 25 | browser.sidebarAction.open(); 26 | const q = info.selectionText; 27 | if (q){ 28 | const curWin = await browser.windows.getCurrent(); 29 | openAndWaitForFindWindow(q.trim(), "sidebar", curWin.id); 30 | } 31 | } 32 | }); 33 | } 34 | } 35 | 36 | const RetryCount = 50; 37 | const WaitMs = 100; 38 | 39 | async function openAndWaitForFindWindow(q: string, type: "popup" | "sidebar", windowId: number | null | undefined){ 40 | const delay = new CancellableDelay; 41 | // wait for a popup to open and be initialized 42 | retry: for (let i=0; i = Rect & { 7 | indices: number[], 8 | values: T[], 9 | } 10 | 11 | type RTreeLeaf = Rect & { leaf: number }; 12 | type RTreeParent = { nodes: (RTreeLeaf | RTreeParent)[] }; 13 | type RTreeNode = RTreeLeaf | RTreeParent; 14 | 15 | /** Clusterer groups a set of rects. 16 | * Each rect of resulting rects contains one or more rects of input elements 17 | * and has a smaller (or equal) width and height of clusterWidth and clusterHeight. 18 | **/ 19 | export class Clusterer{ 20 | static execute(rects: RectWithValue[], clusterSize: Size2d){ 21 | return new Clusterer(clusterSize.width, clusterSize.height).execute(rects); 22 | } 23 | 24 | private readonly clusterWidth: number; 25 | private readonly clusterHeight: number; 26 | 27 | /** 28 | * @param clusterWidth maximum width of a cluster 29 | * @param clusterHeight maximum height of a cluster 30 | **/ 31 | constructor(clusterWidth: number, clusterHeight: number){ 32 | this.clusterWidth = clusterWidth; 33 | this.clusterHeight = clusterHeight; 34 | } 35 | 36 | /** 37 | * @param rects 38 | * the property 'value' of elements are appended to result elements's 'values' field 39 | * @return 40 | **/ 41 | execute(rects: RectWithValue[]): RectWithIndicesAndValues[]{ 42 | const rtree = (RTree as unknown as RTreeFactory)(), 43 | resultClusters: RectCluster[] = []; 44 | 45 | // feed indices as leaf values 46 | rects.forEach(rtree.insert.bind(rtree)); 47 | 48 | let pivotRect: RTreeLeaf | null, 49 | currentCluster: RectCluster; 50 | 51 | while (pivotRect = this.getLeaf(rtree)){ 52 | currentCluster = new RectCluster(pivotRect, pivotRect.leaf); 53 | rtree.remove(pivotRect, pivotRect.leaf); 54 | 55 | const currentClusterBoundingRect = currentCluster.getBoundingRect(); 56 | 57 | const clusterableRectIndices: number[] = rtree.search( 58 | this.clusterableRect(currentCluster) 59 | ).sort( (i1,i2) => 60 | this.rectDistance(rects[i1], currentClusterBoundingRect) - 61 | this.rectDistance(rects[i2], currentClusterBoundingRect) 62 | ); 63 | while (clusterableRectIndices.length > 0){ 64 | const rectIndexToAdd = clusterableRectIndices.shift() as number; 65 | const rectToAdd = rects[rectIndexToAdd]; 66 | if (this.rectContains(this.clusterableRect(currentCluster), rectToAdd)){ 67 | currentCluster.addRect(rectToAdd, rectIndexToAdd); 68 | rtree.remove(rectToAdd, rectIndexToAdd); 69 | } 70 | } 71 | resultClusters.push(currentCluster); 72 | } 73 | 74 | return resultClusters.map( (cluster) => { 75 | const rect = cluster.getBoundingRect(); 76 | return { 77 | indices: cluster.indices, 78 | values: cluster.indices.map( (i) => rects[i].value ), 79 | ...rect 80 | }; 81 | }); 82 | } 83 | 84 | /** 85 | * @param cluster 86 | * @return 87 | **/ 88 | clusterableRect(cluster: RectCluster): Rect{ 89 | return cluster.getClusterableRect(this.clusterWidth, this.clusterHeight); 90 | } 91 | 92 | /** Find a leaf in RTree. 93 | * @param rtree 94 | * @return 95 | **/ 96 | private getLeaf(rtree: RTreeStatic): RTreeLeaf | null{ 97 | // @ts-ignore (calling deprecated getTree) 98 | return search(rtree.getTree() as RTreeNode); 99 | 100 | function search(node: RTreeNode): RTreeLeaf | null{ 101 | if ("leaf" in node){ 102 | return node; 103 | }else{ 104 | for (const child of node.nodes){ 105 | const leaf = search(child); 106 | if (leaf){ 107 | return leaf; 108 | } 109 | } 110 | return null; 111 | } 112 | } 113 | } 114 | 115 | private rectDistance(rect1: Rect, rect2: Rect){ 116 | return Math.max(rect1.x + rect1.w - rect2.x, 117 | rect2.x + rect2.w - rect1.x) + 118 | Math.max(rect1.y + rect1.h - rect2.y, 119 | rect2.y + rect2.h - rect1.y); 120 | } 121 | 122 | /** 123 | * @private 124 | **/ 125 | rectContains(parent: Rect, child: Rect){ 126 | return (parent.x <= child.x) && (child.x + child.w <= parent.x + parent.w) && 127 | (parent.y <= child.y) && (child.y + child.h <= parent.y + parent.h); 128 | } 129 | } 130 | 131 | class RectCluster{ 132 | readonly rects: Rect[]; 133 | readonly indices: number[]; 134 | 135 | /** 136 | * @param initialRect 137 | * @param index 138 | **/ 139 | constructor(initialRect: Rect, index: number){ 140 | this.rects = [initialRect]; 141 | this.indices = [index]; 142 | } 143 | 144 | /** 145 | * @param rect 146 | * @param index 147 | **/ 148 | addRect(rect: Rect, index: number){ 149 | this.rects.push(rect); 150 | this.indices.push(index); 151 | } 152 | 153 | getBoundingRect(): Rect{ 154 | if (this.rects.length === 0){ 155 | throw new Error("rects empty"); 156 | } 157 | const x = Math.min(...(this.rects.map( rect => rect.x ))), 158 | y = Math.min(...(this.rects.map( rect => rect.y ))), 159 | xRight = Math.max(...(this.rects.map( rect => rect.x + rect.w))), 160 | yBottom = Math.max(...(this.rects.map( rect => rect.y + rect.h))); 161 | return { 162 | x, y, 163 | w: xRight - x, 164 | h: yBottom - y, 165 | }; 166 | } 167 | 168 | /** Returns the rect which contains all possible area that this cluster can be extended. 169 | * @param clusterWidth 170 | * @param clusterHeight 171 | **/ 172 | getClusterableRect(clusterWidth: number, clusterHeight: number): Rect{ 173 | const br = this.getBoundingRect(); 174 | return { 175 | x: br.x + br.w - clusterWidth, 176 | y: br.y + br.h - clusterHeight, 177 | w: clusterWidth * 2 - br.w, 178 | h: clusterHeight * 2 - br.h, 179 | }; 180 | } 181 | 182 | } 183 | -------------------------------------------------------------------------------- /src/find-window/find-result-cache.ts: -------------------------------------------------------------------------------- 1 | type RectData = browser.find.RectData; 2 | 3 | export class FindResultCache{ 4 | private rectData: RectData[] | null; 5 | 6 | constructor(){ 7 | this.rectData = null; 8 | } 9 | 10 | update(newRectData: RectData[]): boolean{ 11 | const equal = (this.rectData != null) && this.isEqualRectData(this.rectData, newRectData); 12 | if (!equal){ 13 | this.rectData = newRectData; 14 | } 15 | return !equal; 16 | } 17 | 18 | private isEqualRectData(rd1: RectData[], rd2: RectData[]): boolean{ 19 | if (rd1.length !== rd2.length){ 20 | return false; 21 | } 22 | return rd1.every((val1, i) => { 23 | const val2 = rd2[i]; 24 | const rectList1 = val1.rectsAndTexts.rectList; 25 | const rectList2 = val2.rectsAndTexts.rectList; 26 | if (rectList1.length !== rectList2.length){ 27 | return false; 28 | } 29 | return rectList1.every((rectItem1, j) => { 30 | const rectItem2 = rectList2[j]; 31 | return rectItem1.top === rectItem2.top && 32 | rectItem1.right === rectItem2.right && 33 | rectItem1.bottom === rectItem2.bottom && 34 | rectItem1.right === rectItem2.right; 35 | }); 36 | }); 37 | } 38 | } -------------------------------------------------------------------------------- /src/find-window/find-window.ts: -------------------------------------------------------------------------------- 1 | import computeScrollIntoView from 'compute-scroll-into-view'; 2 | 3 | import { Size2d, Rect, ScreenshotResult, RectWithValue, ClusterRange } from '../types'; 4 | import { SimpleEvent } from '../util/events'; 5 | //import { Messaging } from '../util/messaging'; 6 | import { Messages,MessagesFindWindow } from '../messages/messages'; 7 | import { CancellableDelay } from '../util/cancellable-delay'; 8 | import { Timestamp } from '../util/timestamp'; 9 | import { Mutex } from '../util/mutex'; 10 | 11 | import { PageFinder } from './finder'; 12 | import { FindResultCache } from './find-result-cache'; 13 | import { OptionObject, OptionStore } from '../options/store'; 14 | import { Clusterer } from './clustering'; 15 | import { InputHistory } from './history'; 16 | import { QueryData, QueryStore } from './query-store'; 17 | 18 | type SearchResultsUIOptions = { 19 | imageSize: Size2d, 20 | smoothScroll: boolean, 21 | imageSizeFitToWindow: boolean, 22 | }; 23 | 24 | type ExtraFindOptions = { 25 | delay: number, 26 | useCache: boolean, 27 | }; 28 | 29 | class TextRangeError extends Error { 30 | constructor(message: string){ 31 | super(message); 32 | this.name = "TextRangeError"; 33 | } 34 | } 35 | 36 | class SearchResultsUI{ 37 | private containerElt: HTMLElement; 38 | private imageSize: Size2d; 39 | private smoothScroll: boolean; 40 | private imageSizeFitToWindow: boolean; 41 | 42 | private flagWillClear: boolean; 43 | private focusedResultElt: HTMLElement | null; 44 | private selectedResultElt: HTMLElement | null; 45 | private noPreviewImageURL: string | null; 46 | private tabId: number | null; 47 | onSelected: SimpleEvent; 48 | 49 | constructor(containerElt: HTMLElement, {imageSize, smoothScroll, imageSizeFitToWindow}: SearchResultsUIOptions){ 50 | this.containerElt = containerElt; 51 | this.imageSize = imageSize; 52 | this.smoothScroll = smoothScroll; 53 | this.imageSizeFitToWindow = imageSizeFitToWindow; 54 | 55 | this.flagWillClear = false; // whether clear() when add() is called 56 | 57 | this.focusedResultElt = null; 58 | this.selectedResultElt = null; 59 | this.noPreviewImageURL = null; 60 | 61 | this.tabId = null; 62 | 63 | this.onSelected = new SimpleEvent(); 64 | 65 | this.setupKeyboardEvents(); 66 | } 67 | 68 | setupKeyboardEvents(): void{ 69 | document.body.addEventListener("keydown", this.keyPressed.bind(this), false); 70 | } 71 | 72 | keyPressed(e: KeyboardEvent): void{ 73 | switch (e.key){ 74 | case "Enter": 75 | if (this.focusedResultElt){ 76 | this.focusedResultElt.click(); // TODO: make better 77 | } 78 | break; 79 | 80 | case "ArrowUp": 81 | doFocus(this, e, this.focusedResultElt == null 82 | ? this.containerElt.lastElementChild 83 | : this.focusedResultElt.previousSibling 84 | ); 85 | break; 86 | 87 | case "ArrowDown": 88 | doFocus(this, e, this.focusedResultElt == null 89 | ? this.containerElt.firstElementChild 90 | : this.focusedResultElt.nextSibling 91 | ); 92 | break; 93 | } 94 | 95 | function doFocus(_this: SearchResultsUI, e: KeyboardEvent, elt: Node | null){ 96 | e.preventDefault(); 97 | e.stopPropagation(); 98 | 99 | if (elt == null){ 100 | return; 101 | } 102 | _this.setFocusedResult(elt as HTMLElement); 103 | doScroll(elt as HTMLElement); 104 | } 105 | 106 | function doScroll(aElt: HTMLElement){ 107 | const actions = computeScrollIntoView(aElt, { 108 | scrollMode: "if-needed", 109 | block: "nearest", 110 | }); 111 | for (const {el, top} of actions){ 112 | el.scrollTop = top; 113 | // ignore horizontal scroll 114 | } 115 | } 116 | } 117 | 118 | setTabId(tabId: number): void{ 119 | this.tabId = tabId; 120 | } 121 | 122 | add(previewRect: Rect, imgURL: string | null, gotoID: number): void{ 123 | if (this.flagWillClear){ 124 | this.flagWillClear = false; 125 | this.clear(); 126 | } 127 | 128 | const imgElt = this.createPreviewImage( 129 | imgURL || this.getNoPreviewImageURL(), 130 | previewRect 131 | ); 132 | 133 | const aElt = document.createElement("A"); 134 | aElt.className = "search-result-item-a"; 135 | aElt.appendChild(imgElt); 136 | aElt.addEventListener("click", () => { 137 | this.onSearchResutClicked(aElt, gotoID); 138 | }); 139 | 140 | this.containerElt.appendChild(aElt); 141 | } 142 | 143 | async onSearchResutClicked(aElt: HTMLElement, gotoID: number): Promise{ 144 | if (this.tabId == null){ 145 | // should not happen 146 | throw "tabId is null"; 147 | } 148 | 149 | this.setSelectedResult(aElt); 150 | try{ 151 | await browser.tabs.update(this.tabId, { 152 | active: true 153 | }); 154 | await Messages.sendToTab(this.tabId, "GotoID", { 155 | id: gotoID, 156 | smoothScroll: this.smoothScroll 157 | }); 158 | }catch(e){ 159 | this.showMessage("Page is no longer available"); 160 | console.error(e); 161 | return; 162 | } 163 | this.onSelected.dispatch(); 164 | } 165 | 166 | clear(): void{ 167 | this.containerElt.innerHTML = ""; 168 | this.focusedResultElt = null; 169 | this.selectedResultElt = null; 170 | } 171 | 172 | /** clear() when next add() is called 173 | * This method can be used to avoid flickering. 174 | **/ 175 | willClear(): void{ 176 | this.flagWillClear = true; 177 | } 178 | 179 | async clearAll(): Promise{ 180 | this.clear(); 181 | if (this.tabId == null){ 182 | return; 183 | } 184 | if (this.tabId !== (await getActiveTabId())){ 185 | return; 186 | } 187 | const result = await Messages.sendToTab(this.tabId, "Reset"); 188 | if (!result){ 189 | return; 190 | } 191 | if (result.success){ 192 | browser.find.removeHighlighting(); 193 | } 194 | } 195 | 196 | setFocusedResult(aElt: HTMLElement): void{ 197 | if (this.focusedResultElt){ 198 | this.focusedResultElt.classList.remove("search-result-item-focused"); 199 | } 200 | aElt.classList.add("search-result-item-focused"); 201 | this.focusedResultElt = aElt; 202 | } 203 | 204 | setSelectedResult(aElt: HTMLElement): void{ 205 | this.setFocusedResult(aElt); 206 | if (this.selectedResultElt){ 207 | this.selectedResultElt.classList.remove("search-result-item-selected"); 208 | } 209 | aElt.classList.add("search-result-item-selected"); 210 | this.selectedResultElt = aElt; 211 | } 212 | 213 | private createPreviewImage(imgURL: string, previewRect: Rect): HTMLElement{ 214 | const imgElt = document.createElement("img"); 215 | imgElt.className = "search-result-item-img"; 216 | imgElt.src = imgURL; 217 | imgElt.style.width = `${this.imageSize.width}px` 218 | imgElt.style.height = `${this.imageSize.height}px`; 219 | if (this.imageSizeFitToWindow){ 220 | imgElt.style.maxWidth = '100%'; 221 | } 222 | //imgElt.title = JSON.stringify(previewRect) + this.containerElt.childNodes.length.toString(); 223 | return imgElt; 224 | } 225 | 226 | private getNoPreviewImageURL(): string{ 227 | if (this.noPreviewImageURL){ 228 | return this.noPreviewImageURL; 229 | } 230 | const canvas = document.createElement("canvas"); 231 | canvas.width = this.imageSize.width; 232 | canvas.height = this.imageSize.height; 233 | const ctx = canvas.getContext("2d")!; 234 | ctx.textBaseline = "top"; 235 | ctx.font = "24px serif"; 236 | ctx.fillText("No preview available", 10, 10); 237 | this.noPreviewImageURL = canvas.toDataURL("image/png"); 238 | return this.noPreviewImageURL; 239 | } 240 | 241 | showMessage(text: string){ 242 | const elt = document.getElementById("message-container")!; 243 | elt.textContent = text; 244 | elt.classList.add("message-show"); 245 | elt.addEventListener("animationend", () => { 246 | elt.classList.remove("message-show"); 247 | }, {once: true}); 248 | } 249 | } 250 | 251 | type AppOptions = OptionObject & { popupMode: boolean }; 252 | 253 | class App{ 254 | private initialized: boolean; 255 | private popupMode: boolean; 256 | 257 | private previewSize: Size2d; 258 | private imageSize: Size2d; 259 | private useSmoothScroll: boolean; 260 | private useIncrementalSearch: boolean; 261 | private imageSizeFitToWindow: boolean; 262 | private delayAfterMutation: number; 263 | 264 | private delay: CancellableDelay; 265 | private pageChangeDelay: CancellableDelay; 266 | private pageFinder: PageFinder; 267 | private searchResultsUI: SearchResultsUI; 268 | private lastSearchQuery: string | null; 269 | private lastSearchTimestamp: Timestamp; 270 | private lastFindStartTimestamp: Timestamp; 271 | private camouflageMutex: Mutex; 272 | private inputHistory: InputHistory; 273 | private findResultCache: FindResultCache; 274 | 275 | constructor(options: AppOptions){ 276 | this.initialized = false; 277 | 278 | this.popupMode = options.popupMode; 279 | 280 | this.previewSize = { 281 | width: Math.max(options.previewWidth, 100), 282 | height: Math.max(options.previewHeight, 40), 283 | }; 284 | 285 | this.imageSize = options.imageSizeSameAsPreview ? 286 | this.previewSize : { 287 | width: Math.max(options.imageWidth, 100), 288 | height: Math.max(options.imageHeight, 40), 289 | }; 290 | 291 | this.useSmoothScroll = options.useSmoothScroll; 292 | this.useIncrementalSearch = options.useIncrementalSearch; 293 | this.imageSizeFitToWindow = options.imageSizeFitToWindow; 294 | this.delayAfterMutation = 5000; // TODO: make configurable 295 | 296 | if (options.popupMode){ 297 | document.body.style.width = `${this.imageSize.width+40}px`; 298 | } 299 | 300 | // BG detects when this window has closed 301 | browser.runtime.connect(); 302 | 303 | this.delay = new CancellableDelay; 304 | this.pageChangeDelay = new CancellableDelay; 305 | this.pageFinder = new PageFinder; 306 | this.searchResultsUI = this.createSearchResultsUI({ 307 | imageSize: this.imageSize, 308 | smoothScroll: this.useSmoothScroll, 309 | imageSizeFitToWindow: this.imageSizeFitToWindow, 310 | }); 311 | this.lastSearchQuery = null; 312 | this.lastSearchTimestamp = new Timestamp; 313 | this.lastFindStartTimestamp = new Timestamp; 314 | this.findResultCache = new FindResultCache; 315 | this.setupSearchInput(); 316 | this.setupSearchOptions(); 317 | 318 | this.camouflageMutex = new Mutex; 319 | 320 | this.inputHistory = new InputHistory( 321 | document.getElementById("search-text-datalist")!, 322 | { 323 | storageKey: "history", 324 | maxHistory: options.maxHistory, 325 | } 326 | ); 327 | 328 | this.receivePageChangeMessages(); 329 | 330 | this.restoreQuery().then(() => { 331 | this.initialized = true; 332 | }); 333 | } 334 | 335 | private receivePageChangeMessages(): void{ 336 | const app = this; 337 | MessagesFindWindow.receive({ 338 | async NotifyMutation(isonload: boolean, { sender } : { sender: browser.runtime.MessageSender }){ 339 | console.log({ s: "mutation occured", isonload, sender }); 340 | if (sender.tab != null && sender.frameId === 0 && sender.tab.id === await getActiveTabId()){ 341 | app.pageChanged(isonload); 342 | } 343 | }, 344 | }); 345 | browser.tabs.onUpdated.addListener(async (tabId: number, { status } : { status?: string }) => { 346 | if (status === "complete" && tabId === await getActiveTabId()){ 347 | app.pageChanged(true); 348 | } 349 | // @ts-ignore 350 | }, { properties: ["status"] }); 351 | browser.tabs.onActivated.addListener(async ({ tabId }) => { 352 | if (tabId === await getActiveTabId()){ 353 | app.pageChanged(true); 354 | } 355 | }); 356 | } 357 | 358 | private async pageChanged(isonload: boolean): Promise{ 359 | switch (this.getAutoSearchOption()){ 360 | case "none": 361 | return; 362 | case "on-page-load": 363 | if (!isonload){ 364 | return; 365 | } 366 | break; 367 | } 368 | 369 | if (!isonload){ 370 | if (this.pageChangeDelay.isExecuting()){ 371 | return; 372 | } 373 | const delayMs = Math.max(0, this.delayAfterMutation - this.lastFindStartTimestamp.elapsedMillisecond()); 374 | if (!this.pageChangeDelay.cancelAndExecute(delayMs)){ 375 | return; 376 | } 377 | } 378 | this.submit({ delay: 0, useCache: !isonload }); 379 | } 380 | 381 | private createSearchResultsUI(options: SearchResultsUIOptions): SearchResultsUI{ 382 | const ui = new SearchResultsUI( 383 | document.getElementById("search-results-container")!, 384 | options 385 | ); 386 | ui.onSelected.addListener( () => { 387 | if (this.lastSearchQuery != null){ 388 | this.inputHistory.add(this.lastSearchQuery); 389 | } 390 | }); 391 | return ui; 392 | } 393 | 394 | private setupSearchInput(): void{ 395 | const inputElt = document.getElementById("search-text-input")!; 396 | document.addEventListener("load", () => { inputElt.focus() }); 397 | inputElt.addEventListener("input", (e) => { 398 | if ((e as InputEvent).isComposing){ 399 | return; 400 | } 401 | if (this.useIncrementalSearch){ 402 | this.submit(); 403 | } 404 | }); 405 | inputElt.addEventListener("keydown", (e) => { 406 | switch (e.key){ 407 | case "ArrowUp": 408 | case "ArrowDown": 409 | inputElt.blur(); 410 | break; 411 | case "Enter": 412 | this.submit(); 413 | break; 414 | } 415 | }); 416 | } 417 | 418 | private setupSearchOptions(): void{ 419 | const containerElt = document.getElementById("search-options-container")!; 420 | containerElt.addEventListener("change", (e) => { 421 | this.searchOptionChanged(e); 422 | }); 423 | document.getElementById("search-options-toggle-show")!.addEventListener("change", (e) => { 424 | document.getElementById("search-options-container")!.style.display = (e.target as HTMLInputElement).checked ? "block" : "none"; 425 | }); 426 | 427 | document.getElementById("find-again-button")!.addEventListener("click", () => this.submit()); 428 | document.getElementById("reset-button")!.addEventListener("click", this.reset.bind(this)); 429 | } 430 | 431 | showResultCountMessage({q, count}: {q: string, count: number}){ 432 | (document.getElementById("count-output") as HTMLOutputElement).value = q === "" ? "" : `${count} matches`; 433 | } 434 | 435 | setQuery(q: string): void{ 436 | this.getInputElement("search-text-input").value = q; 437 | this.submit(); 438 | } 439 | 440 | isInitialized(): boolean{ 441 | return this.initialized; 442 | } 443 | 444 | private async restoreQuery(): Promise{ 445 | const result = await QueryStore.load(); 446 | if (result != null){ 447 | this.getInputElement("search-text-input").value = result.query; 448 | this.getInputElement("case-sensitive-checkbox").checked = result.caseSensitive; 449 | this.getInputElement("entire-word-checkbox").checked = result.entireWord; 450 | this.getInputElement("restore-last-query-checkbox").checked = true; 451 | this.submit(); 452 | } 453 | } 454 | 455 | async getWindowId(): Promise{ 456 | return (await browser.windows.getCurrent()).id; 457 | } 458 | 459 | private searchOptionChanged(e: Event): void{ 460 | if ((e.target as HTMLElement).dataset.noSubmit != null){ 461 | this.saveQueryMaybe(); 462 | }else{ 463 | this.submit(); 464 | } 465 | } 466 | 467 | private getAutoSearchOption(): "none" | "on-page-load" | "on-page-modify" { 468 | return (document.getElementById("auto-search-select") as HTMLSelectElement).value as 469 | "none" | "on-page-load" | "on-page-modify"; 470 | } 471 | 472 | private getQuery(): QueryData{ 473 | const query = this.getInputElement("search-text-input").value 474 | const findOptions = { 475 | caseSensitive: this.getInputElement("case-sensitive-checkbox").checked, 476 | entireWord: this.getInputElement("entire-word-checkbox").checked, 477 | }; 478 | return { 479 | query, 480 | ...findOptions, 481 | }; 482 | } 483 | 484 | private saveQueryMaybe(): void{ 485 | const saveQuery = this.getInputElement("restore-last-query-checkbox").checked; 486 | if (saveQuery){ 487 | QueryStore.save(this.getQuery()); 488 | }else{ 489 | QueryStore.save(null); 490 | } 491 | } 492 | 493 | submit(options?: Partial): void{ 494 | this.saveQueryMaybe(); 495 | const query = this.getQuery(); 496 | this.findWithRetry(query.query, { 497 | caseSensitive: query.caseSensitive, 498 | entireWord: query.entireWord, 499 | }, options); 500 | } 501 | 502 | getInputElement(id: string): HTMLInputElement{ 503 | return document.getElementById(id) as HTMLInputElement; 504 | } 505 | 506 | /** 507 | * @param q string to search 508 | * @param options pass to browser.find.find() 509 | **/ 510 | async findWithRetry(q: string, 511 | findOptions: Partial, 512 | extraFindOptions: Partial = {}): Promise{ 513 | 514 | const delay = extraFindOptions.delay == null ? this.getDelayForQuery(q) : extraFindOptions.delay; 515 | if (!await this.delay.cancelAndExecute(delay)){ 516 | return; 517 | } 518 | 519 | const localDelay = new CancellableDelay; 520 | const findStartTime = this.lastFindStartTimestamp.update(); 521 | 522 | let retryCount = 3; 523 | while (retryCount > 0 && !this.lastFindStartTimestamp.isUpdatedSince(findStartTime)){ 524 | try{ 525 | return await this.findStart(q, findOptions, extraFindOptions); 526 | }catch(e){ 527 | if (e instanceof TextRangeError){ 528 | console.log(`text range error: retry(${retryCount})`); 529 | retryCount--; 530 | await localDelay.cancelAndExecute(500); 531 | continue; 532 | } 533 | } 534 | break; 535 | } 536 | } 537 | 538 | /** 539 | * @param q string to search 540 | * @param options pass to browser.find.find() 541 | **/ 542 | private async findStart(q: string, 543 | findOptions: Partial, 544 | extraFindOptions: Partial = {}): Promise{ 545 | 546 | const tabId = await getActiveTabId(); 547 | if (tabId == null){ 548 | console.log("Cannot get an active tab"); 549 | return; 550 | } 551 | 552 | const findResultPromise = this.findWithCamouflage(q, tabId, findOptions); 553 | 554 | if (!await this.delay.cancelAndExecute(300)){ 555 | return; 556 | } 557 | 558 | const findResult = await findResultPromise; 559 | const count = findResult == null ? 0 : findResult.count; 560 | 561 | this.showResultCountMessage({q, count}); 562 | 563 | if (findResult == null || count === 0){ // not found or query is empty 564 | this.searchResultsUI.clear(); 565 | this.lastSearchTimestamp.update(); // finish existing preview listing 566 | return; 567 | } 568 | 569 | this.lastSearchQuery = q; 570 | 571 | const { rectData, rangeData } = findResult; 572 | if (typeof rectData == "undefined"){ 573 | throw "rectData is undefined (shoud be a bug)"; 574 | } 575 | if (typeof rangeData == "undefined"){ 576 | throw "rangeData is undefined (shoud be a bug)"; 577 | } 578 | const findResultUpdated = this.findResultCache.update(rectData); 579 | if (findResultUpdated || (extraFindOptions.useCache == null || !extraFindOptions.useCache)){ 580 | await this.showPreviews(tabId, {rectData, rangeData}); 581 | }else{ 582 | console.log({ msg: "using cache for preview images", findResultUpdated }); 583 | } 584 | } 585 | 586 | private async findWithCamouflage(q: string, tabId: number, findOptions: Partial){ 587 | return this.camouflageMutex.transact( async () => { 588 | try{ 589 | await Messages.sendToTab(tabId, "CamouflageInputs", q); 590 | return await this.pageFinder.find(q, {tabId, ...findOptions}); 591 | }finally{ 592 | await Messages.sendToTab(tabId, "UncamouflageInputs"); 593 | } 594 | }); 595 | } 596 | 597 | async showPreviews(tabId: number, {rectData, rangeData}: { 598 | rectData: browser.find.RectData[], 599 | rangeData: browser.find.RangeData[], 600 | }): Promise{ 601 | await Messages.sendToTab(tabId, "Start"); 602 | 603 | const startTime = Date.now(); 604 | 605 | const timestamp = this.lastSearchTimestamp.update(), 606 | clusterRanges = makeClusterRanges(rectData, rangeData, this.getClusterSize()); 607 | 608 | this.searchResultsUI.setTabId(tabId); 609 | this.searchResultsUI.willClear(); 610 | 611 | for (const clusterRange of clusterRanges){ 612 | console.debug("clusterRange", clusterRange); 613 | const {rect, url, gotoID} = await this.takeScreenshotForCluster(tabId, clusterRange); 614 | 615 | if (this.lastSearchTimestamp.isUpdatedSince(timestamp)){ 616 | console.log("last search timestamp updated while taking screenshots, exit"); 617 | break; 618 | } 619 | 620 | this.searchResultsUI.add(rect, url, gotoID); 621 | } 622 | 623 | const finishTime = Date.now(); 624 | 625 | console.log(`All preview images created in ${(finishTime-startTime)/1000} sec`); 626 | } 627 | 628 | getClusterSize(): Size2d{ 629 | return { 630 | width : Math.max(this.previewSize.width - 40, 0), 631 | height: Math.max(this.previewSize.height - 20, 0), 632 | }; 633 | } 634 | 635 | async takeScreenshotForCluster(tabId: number, clusterRange: ClusterRange): Promise{ 636 | try{ 637 | const result = await Messages.sendToTab(tabId, "Screenshot", { 638 | clusterRect: clusterRange.rect, 639 | ranges: clusterRange.ranges, 640 | ssSize: this.previewSize, 641 | }); 642 | if (result == null){ 643 | throw "Cannot take screenshot"; 644 | } 645 | if ('error' in result){ 646 | throw new TextRangeError(result.error); 647 | } 648 | return result; 649 | }catch(e){ 650 | console.log({s: "takeScreenshotForCluster", e}); 651 | throw e; 652 | } 653 | } 654 | 655 | getDelayForQuery(q: string): number{ 656 | if (!this.useIncrementalSearch){ 657 | return 0; 658 | } 659 | 660 | switch (q.length){ 661 | case 1: return 800; 662 | case 2: return 400; 663 | case 3: return 200; 664 | default: return 100; 665 | } 666 | } 667 | 668 | reset(): void{ 669 | (document.getElementById("search-text-input") as HTMLInputElement).value = ""; 670 | this.searchResultsUI.clearAll(); 671 | this.lastSearchTimestamp.update(); 672 | (document.getElementById("count-output") as HTMLInputElement).value = ""; 673 | } 674 | } 675 | 676 | async function startApp(): Promise{ 677 | const options = await OptionStore.load(), 678 | searchParams = new URLSearchParams(location.search); 679 | 680 | setStyles(options) 681 | 682 | //@ts-ignore 683 | window["App"] = new App({ 684 | ...options, 685 | popupMode: parseInt(searchParams.get("popup") || "") > 0, 686 | }); 687 | } 688 | 689 | startApp(); 690 | 691 | function setStyles(options: OptionObject){ 692 | const keys = { 693 | "fgColorInput": true, 694 | "bgColorInput": true, 695 | "fgColorSearchForm": true, 696 | "bgColorSearchForm": true, 697 | "bgColorSearchFormHover": true, 698 | "bgColorSearchResult": true, 699 | "borderColor": true, 700 | "borderColorSelected": true, 701 | }; 702 | for (const propName of (Object.keys(keys) as (keyof typeof keys)[])){ 703 | document.documentElement.style.setProperty("--" + propName, options[propName]); 704 | } 705 | } 706 | 707 | function makeClusterRanges(rectData: browser.find.RectData[], rangeData: browser.find.RangeData[], clusterSize: Size2d): ClusterRange[]{ 708 | const yesRects: RectWithValue[] = [], 709 | noRectIndices: number[] = []; 710 | 711 | rectData.forEach( (rdElt,i) => { 712 | if (rangeData[i].framePos !== 0){ 713 | return; // ignore inline frames 714 | } 715 | const rdPos = rdElt.rectsAndTexts.rectList[0]; 716 | if (rdPos == null){ // maybe rect is out of window? (FF61) 717 | noRectIndices.push(i); 718 | }else{ 719 | yesRects.push({ 720 | x: rdPos.left, 721 | y: rdPos.top, 722 | w: rdPos.right - rdPos.left, 723 | h: rdPos.bottom - rdPos.top, 724 | value: i 725 | }); 726 | } 727 | }); 728 | 729 | return Clusterer.execute(yesRects, clusterSize).map( (cluster) => ( 730 | { 731 | indices: cluster.values, 732 | rect: cluster, 733 | containedRects: cluster.indices.map( (i) => yesRects[i] ), 734 | ranges: cluster.values.map( (i) => rangeData[i] ), 735 | } 736 | )).concat(noRectIndices.map( (i) => ( 737 | { 738 | indices: [i], 739 | rect: null, 740 | containedRects: null, 741 | ranges: [rangeData[i]] 742 | } 743 | ))).sort( ({ranges: [range1]}, {ranges: [range2]}) => 744 | range1.startTextNodePos - range2.startTextNodePos || 745 | range1.startOffset - range2.startOffset 746 | ); 747 | } 748 | 749 | async function getActiveTabId(): Promise{ 750 | const [tab, ...rest] = await browser.tabs.query({ 751 | active: true, 752 | currentWindow: true, 753 | }); 754 | console.assert(rest.length === 0, "multiple active tabs"); 755 | if (tab && tab.id != null){ 756 | return tab.id; 757 | }else{ 758 | return null; 759 | } 760 | } -------------------------------------------------------------------------------- /src/find-window/finder.ts: -------------------------------------------------------------------------------- 1 | /** A simple wrapper for browser.find with basic mutual exclusion 2 | **/ 3 | 4 | import { Mutex } from '../util/mutex'; 5 | 6 | export class PageFinder{ 7 | private readonly mutex: Mutex; 8 | private lastFindStartTime: number | null; 9 | private readonly outputLog: boolean; 10 | 11 | constructor({outputLog=false}={}){ 12 | this.mutex = new Mutex; 13 | this.lastFindStartTime = null; 14 | this.outputLog = outputLog; 15 | } 16 | 17 | /** 18 | * @param q string to search for 19 | * @param options options pass to browser.find.find() (tabId is required) 20 | * @return the result of browser.find.find 21 | **/ 22 | async find(q: string, options: Partial & { tabId: number }): Promise{ 23 | if (!q){ // reject empty string 24 | browser.find.removeHighlighting(); 25 | return null; 26 | } 27 | 28 | const time = Date.now(); 29 | this.lastFindStartTime = time; 30 | 31 | const result = await this.mutex.transact( async () => { 32 | this.log("find start", time); 33 | // @ts-ignore (the type definition of browser.find.find requires all of the options to be specified) 34 | const result = await browser.find.find(q, { 35 | includeRectData: true, 36 | includeRangeData: true, 37 | ...options 38 | }); 39 | this.log("find finish", time); 40 | return result; 41 | }); 42 | 43 | if (time !== this.lastFindStartTime){ 44 | // Skip highlighting because there is another method execution. 45 | return result; 46 | } 47 | 48 | if (result.count === 0){ 49 | browser.find.removeHighlighting(); 50 | return null; 51 | } 52 | 53 | this.log("highlight start", time); 54 | 55 | // do not await to run highlighting asynchronously 56 | // @ts-ignore (highlightResults requires tabId but type definitions miss it) 57 | browser.find.highlightResults({tabId: options.tabId}); 58 | 59 | return result; 60 | } 61 | 62 | private log(...args: any[]){ 63 | if (this.outputLog){ 64 | console.log(...args); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/find-window/history.ts: -------------------------------------------------------------------------------- 1 | type DatasetElement = HTMLElement & { children: NodeListOf }; 2 | 3 | export class InputHistory{ 4 | private readonly datasetElt: DatasetElement; 5 | private readonly storageKey: string; 6 | private readonly maxHistory: number; 7 | 8 | constructor(datasetElt: HTMLElement, {storageKey, maxHistory}: {storageKey: string, maxHistory: number}){ 9 | this.datasetElt = datasetElt as DatasetElement; 10 | this.storageKey = storageKey; 11 | this.maxHistory = maxHistory; 12 | 13 | this.loadHistory(); 14 | } 15 | 16 | async add(q: string): Promise{ 17 | await this.loadHistory() 18 | 19 | let optionEltToPrepend: HTMLOptionElement | null = null; 20 | for (const option of Array.from(this.datasetElt.children)){ 21 | if (option.value === q){ 22 | option.remove(); 23 | optionEltToPrepend = option; 24 | break; 25 | } 26 | } 27 | if (optionEltToPrepend == null){ 28 | optionEltToPrepend = document.createElement("option"); 29 | optionEltToPrepend.value = q; 30 | } 31 | this.datasetElt.insertAdjacentElement("afterbegin", optionEltToPrepend); 32 | while (this.datasetElt.children.length > this.maxHistory){ 33 | this.datasetElt.lastElementChild!.remove(); 34 | } 35 | 36 | await this.saveHistory(); 37 | } 38 | 39 | async saveHistory(): Promise{ 40 | const items = Array.from(this.datasetElt.children).map( (optionElt) => 41 | optionElt.value 42 | ).slice(0, this.maxHistory); 43 | await browser.storage.local.set({ 44 | [this.storageKey]: items, 45 | }); 46 | } 47 | 48 | async loadHistory(): Promise{ 49 | const {[this.storageKey]: rawItems} = await browser.storage.local.get({ 50 | [this.storageKey]: [] 51 | }); 52 | 53 | const items = rawItems.slice(0, this.maxHistory); 54 | 55 | this.datasetElt.innerHTML = ""; 56 | for (const item of items){ 57 | const optionElt = document.createElement("option"); 58 | optionElt.value = item; 59 | this.datasetElt.appendChild(optionElt); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/find-window/query-store.ts: -------------------------------------------------------------------------------- 1 | 2 | type QueryData = { 3 | query: string, 4 | caseSensitive: boolean, 5 | entireWord: boolean, 6 | } 7 | 8 | const QueryStore = { 9 | 10 | save(qd: QueryData | null): Promise{ 11 | return browser.storage.local.set({ 12 | [this.storageKey]: qd, 13 | }); 14 | }, 15 | 16 | async load(): Promise { 17 | const val = await browser.storage.local.get({ 18 | [this.storageKey]: null 19 | }); 20 | if (val == null){ 21 | return null; 22 | }else{ 23 | return val[this.storageKey]; 24 | } 25 | }, 26 | 27 | storageKey: "query-store", 28 | } 29 | 30 | export { 31 | QueryData, 32 | QueryStore, 33 | } -------------------------------------------------------------------------------- /src/messages/messages.ts: -------------------------------------------------------------------------------- 1 | import { Messaging } from '../util/messaging'; 2 | import { Rect, Size2d, ScreenshotResultMaybeError } from '../types'; 3 | 4 | type IMessages = { 5 | Ping(): Promise<{ result: boolean }>, 6 | 7 | Start(): void, 8 | 9 | Screenshot(arg: { 10 | clusterRect: Rect | null, 11 | ranges: browser.find.RangeData[], 12 | ssSize: Size2d, 13 | }): Promise, 14 | 15 | CamouflageInputs(q: string): void, 16 | 17 | UncamouflageInputs(): void, 18 | 19 | GotoID(arg: { 20 | id: number, 21 | smoothScroll: boolean 22 | }): Promise, 23 | 24 | Reset(): Promise<{ success: boolean}>, 25 | } 26 | 27 | type IMessagesBG = { 28 | SetContextMenu(arg : { popup: boolean, sidebar: boolean }): void, 29 | } 30 | 31 | type IMessagesFindWindow = { 32 | NotifyMutation(isonload: boolean, { sender } : { sender: browser.runtime.MessageSender }): void, 33 | } 34 | 35 | const Messages = new Messaging(), 36 | MessagesBG = new Messaging(), 37 | MessagesFindWindow = new Messaging(); 38 | 39 | export { 40 | Messages, 41 | MessagesBG, 42 | MessagesFindWindow, 43 | } -------------------------------------------------------------------------------- /src/options/options.ts: -------------------------------------------------------------------------------- 1 | import { DefaultValues, OptionStore } from './store'; 2 | import { setupContextMenu } from "../context-menu/context-menu" 3 | import { MessagesBG } from "../messages/messages" 4 | 5 | type OptionObject = typeof DefaultValues; 6 | type OptionObjectPartial = Partial; 7 | 8 | type FormControl = HTMLInputElement; 9 | 10 | type OptionForm = HTMLFormElement & { 11 | elements: { 12 | [key: string]: any, 13 | } 14 | }; 15 | 16 | document.addEventListener("DOMContentLoaded", initPage); 17 | 18 | async function initPage(): Promise{ 19 | const options = await OptionStore.load(), 20 | form = document.getElementById("main-form") as OptionForm; 21 | setFormValues(form, options); 22 | setFormAttrs(form); 23 | 24 | form.addEventListener("change", () => { 25 | saveOptions(form); 26 | setFormAttrs(form); 27 | updateShortcutKeys(form); 28 | updateContextMenu(form); 29 | }); 30 | } 31 | 32 | function setFormValues(form: HTMLFormElement, options: any){ 33 | for (const elt of Array.from(form.elements) as Array){ 34 | setInputValue(elt, options[elt.name]); 35 | } 36 | } 37 | 38 | function setInputValue(elt: FormControl, value: any){ 39 | if (elt.type.toLowerCase() === "checkbox"){ 40 | elt.checked = value; 41 | }else{ 42 | elt.value = value; 43 | } 44 | } 45 | 46 | 47 | function saveOptions(form: HTMLFormElement){ 48 | const obj: OptionObjectPartial = {}; 49 | for (const elt of Array.from(form.elements) as Array){ 50 | const value = getInputValue(elt); 51 | if (value != null){ 52 | // @ts-ignore 53 | obj[elt.name] = value; 54 | } 55 | } 56 | OptionStore.save(obj); 57 | } 58 | 59 | 60 | function getInputValue(elt: HTMLInputElement){ 61 | switch (elt.type.toLowerCase()){ 62 | case "checkbox": 63 | return elt.checked; 64 | case "number": 65 | return elt.valueAsNumber; 66 | default: 67 | return elt.value; 68 | } 69 | } 70 | 71 | function setFormAttrs(form: OptionForm){ 72 | form.elements["groupImageSize"].disabled = form.elements["imageSizeSameAsPreview"].checked; 73 | } 74 | 75 | async function updateShortcutKeys(form: OptionForm){ 76 | let successPopupPromise: Promise, 77 | successSidebarPromise: Promise; 78 | 79 | const shortcutPopup = 80 | form.elements["shortcutPopupEnabled"].checked && 81 | buildShortcutString( 82 | form.elements["shortcutPopupModifier"].value, 83 | form.elements["shortcutPopupModifier2"].value, 84 | form.elements["shortcutPopupKey"].value 85 | ); 86 | if (shortcutPopup){ 87 | try{ 88 | // @ts-ignore (commands.update is not defined yet) 89 | successPopupPromise = browser.commands.update({ 90 | name: "_execute_browser_action", 91 | shortcut: shortcutPopup 92 | }); 93 | }catch(e){ 94 | successPopupPromise = Promise.reject(); 95 | } 96 | }else{ 97 | // @ts-ignore (commands.reset is not defined yet) 98 | successPopupPromise = browser.commands.reset("_execute_browser_action"); 99 | } 100 | 101 | const shortcutSidebar = 102 | form.elements["shortcutSidebarEnabled"].checked && 103 | buildShortcutString( 104 | form.elements["shortcutSidebarModifier"].value, 105 | form.elements["shortcutSidebarModifier2"].value, 106 | form.elements["shortcutSidebarKey"].value 107 | ); 108 | if (shortcutSidebar){ 109 | try{ 110 | // @ts-ignore 111 | successSidebarPromise = browser.commands.update({ 112 | name: "_execute_sidebar_action", 113 | shortcut: shortcutSidebar 114 | }); 115 | }catch(e){ 116 | successSidebarPromise = Promise.reject(); 117 | } 118 | }else{ 119 | // @ts-ignore 120 | successSidebarPromise = browser.commands.reset("_execute_sidebar_action"); 121 | } 122 | 123 | successPopupPromise.then( 124 | onSuccess("shortcut-popup-result"), 125 | onFail("shortcut-popup-result") 126 | ); 127 | 128 | successSidebarPromise.then( 129 | onSuccess("shortcut-sidebar-result"), 130 | onFail("shortcut-sidebar-result") 131 | ); 132 | 133 | function onSuccess(resultEltId: string){ 134 | return () => { 135 | const elt = document.getElementById(resultEltId); 136 | if (elt == null){ 137 | throw `element not found: ${resultEltId}`; 138 | } 139 | elt.classList.add("shortcut-valid"); 140 | elt.classList.remove("shortcut-invalid"); 141 | }; 142 | } 143 | 144 | function onFail(resultEltId: string){ 145 | return () => { 146 | const elt = document.getElementById(resultEltId); 147 | if (elt == null){ 148 | throw `element not found: ${resultEltId}`; 149 | } 150 | elt.classList.add("shortcut-invalid"); 151 | elt.classList.remove("shortcut-valid"); 152 | }; 153 | } 154 | } 155 | 156 | /** 157 | * @param m1 modifier 158 | * @param m2 second modifier 159 | * @param key 160 | **/ 161 | function buildShortcutString(m1: string, m2: string, key: string): string{ 162 | return [m1, m2, key].filter( item => item != null && item !== "").join("+"); 163 | } 164 | 165 | function updateContextMenu(form: OptionForm){ 166 | MessagesBG.sendToBG("SetContextMenu", { 167 | popup: getInputValue(form.elements["showContextMenuPopup"]) as boolean, 168 | sidebar: getInputValue(form.elements["showContextMenuSidebar"]) as boolean, 169 | }); 170 | } -------------------------------------------------------------------------------- /src/options/store.ts: -------------------------------------------------------------------------------- 1 | const DefaultValues = { 2 | useIncrementalSearch: true, 3 | 4 | useSmoothScroll: true, 5 | 6 | previewWidth: 400, 7 | previewHeight: 150, 8 | 9 | imageSizeSameAsPreview: true, 10 | imageSizeFitToWindow: true, 11 | 12 | imageWidth: 400, 13 | imageHeight : 150, 14 | 15 | fgColorInput: "#000000", 16 | bgColorInput: "#ffffff", 17 | 18 | fgColorSearchForm: "#000000", 19 | bgColorSearchForm: "#ffffff", 20 | bgColorSearchFormHover: "#ddddff", 21 | 22 | bgColorSearchResult: "#ffeeee", 23 | 24 | borderColor: "#000000", 25 | borderColorSelected: "#FF0000", 26 | 27 | maxHistory: 20, 28 | 29 | shortcutPopupEnabled: false, 30 | shortcutPopupModifier: "", 31 | shortcutPopupModifier2: "", 32 | shortcutPopupKey: "", 33 | 34 | shortcutSidebarEnabled: false, 35 | shortcutSidebarModifier: "", 36 | shortcutSidebarModifier2: "", 37 | shortcutSidebarKey: "", 38 | 39 | showContextMenuPopup: true, 40 | showContextMenuSidebar: false, 41 | }; 42 | 43 | type OptionObject = typeof DefaultValues; 44 | 45 | const OptionStore = { 46 | async load(): Promise{ 47 | const obj = await browser.storage.local.get({ 48 | [this.storageKey]: DefaultValues, 49 | }); 50 | return { 51 | ...DefaultValues, 52 | ...obj[this.storageKey], 53 | } 54 | }, 55 | 56 | async save(obj: {}): Promise{ 57 | this.checkValues(obj); 58 | await browser.storage.local.set({ 59 | [this.storageKey]: obj 60 | }); 61 | }, 62 | 63 | checkValues(obj: {}): void{ 64 | const errors = []; 65 | for (const [key,val] of Object.entries(obj)){ 66 | if (!(key in DefaultValues)){ 67 | errors.push(`Unknown key: ${key}`); 68 | continue; 69 | } 70 | // @ts-ignore 71 | const dv = DefaultValues[key]; 72 | if (dv != null && typeof dv !== typeof val){ 73 | errors.push(`Value for ${key} should have type ${typeof dv} (actual: ${JSON.stringify(val)})`); 74 | } 75 | } 76 | if (errors.length !== 0){ 77 | console.error("Error saving options", errors); 78 | throw new Error("Invalid option values"); 79 | } 80 | }, 81 | 82 | storageKey: "options" 83 | }; 84 | 85 | export { 86 | DefaultValues, 87 | OptionObject, 88 | OptionStore, 89 | }; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type Size2d = { 2 | width : number, 3 | height: number, 4 | } 5 | 6 | // RTree's rect 7 | type Rect = { 8 | x: number, 9 | y: number, 10 | w: number, 11 | h: number, 12 | } 13 | 14 | type RectWithValue = Rect & { 15 | value: T, 16 | } 17 | 18 | type RTreeLeaf = Rect & { 19 | leaf: any, 20 | } 21 | 22 | type ClusterRange = { 23 | indices: number[], 24 | rect: Rect | null, 25 | containedRects: Rect[] | null, 26 | ranges: browser.find.RangeData[], 27 | } 28 | 29 | type ScreenshotResult = { 30 | gotoID: number, 31 | rect: Rect, 32 | url: string | null, 33 | } 34 | 35 | type ScreenshotResultMaybeError = ScreenshotResult | { 36 | error: string, 37 | } 38 | 39 | export { 40 | Size2d, 41 | Rect, 42 | RectWithValue, 43 | RTreeLeaf, 44 | ClusterRange, 45 | ScreenshotResult, 46 | ScreenshotResultMaybeError, 47 | }; -------------------------------------------------------------------------------- /src/util/cancellable-delay.ts: -------------------------------------------------------------------------------- 1 | const Cancelled = Symbol("Cancelled"); 2 | 3 | export class CancellableDelay{ 4 | private timer: number | null; 5 | 6 | private lastExecutionReject: ((c: typeof Cancelled) => void) | null; 7 | 8 | constructor(){ 9 | this.timer = null; 10 | this.lastExecutionReject = null; 11 | } 12 | 13 | isExecuting(){ 14 | return this.timer != null && this.lastExecutionReject != null; 15 | } 16 | 17 | cancel(){ 18 | if (this.timer != null && this.lastExecutionReject != null){ 19 | clearTimeout(this.timer); 20 | this.lastExecutionReject(Cancelled); 21 | this.timer = null; 22 | this.lastExecutionReject = null; 23 | } 24 | } 25 | 26 | /** Cancel the previous execution and run new one 27 | * @param ms milliseconds to be delayed 28 | * @return A Promise resolved ms milliseconds later, 29 | * populated with false if canceled, true otherwise. 30 | **/ 31 | async cancelAndExecute(ms: number): Promise{ 32 | this.cancel(); 33 | 34 | try{ 35 | await new Promise((resolve,reject) => { 36 | this.lastExecutionReject = reject; 37 | this.timer = window.setTimeout(() => { 38 | resolve(null); 39 | }, ms); 40 | }); 41 | this.lastExecutionReject = null; 42 | this.timer = null; 43 | return true; 44 | }catch(e){ 45 | if (e === Cancelled){ 46 | return false; 47 | }else{ 48 | // this should not happen 49 | throw e; 50 | } 51 | } 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/util/events.ts: -------------------------------------------------------------------------------- 1 | type SimpleEventListener = (data: T) => void; 2 | 3 | export class SimpleEvent { 4 | private listeners: Set>; 5 | 6 | constructor(){ 7 | this.listeners = new Set>(); 8 | } 9 | 10 | addListener(listener: SimpleEventListener){ 11 | this.listeners.add(listener); 12 | return this; 13 | } 14 | 15 | hasListener(listener: SimpleEventListener){ 16 | return this.listeners.has(listener); 17 | } 18 | 19 | removeListener(listener: SimpleEventListener){ 20 | return this.listeners.delete(listener); 21 | } 22 | 23 | dispatch(data: T){ 24 | for (const listener of this.listeners){ 25 | listener(data); 26 | } 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /src/util/messaging.ts: -------------------------------------------------------------------------------- 1 | type Receiver = { 2 | [command: string]: (arg:any, meta:any) => any 3 | } 4 | 5 | type Meta = { 6 | command: keyof T, 7 | sender: browser.runtime.MessageSender, 8 | } 9 | 10 | type CommandArg = { 11 | [Cmd in keyof T]: 12 | T[Cmd] extends ( () => any ) // if the listener method takes no argument, sendToXX methods don't require an argument either 13 | ? [] 14 | : [T[Cmd] extends (arg: infer A, meta: Meta) => (infer X) ? A : never] 15 | } 16 | 17 | type CommandResult = { 18 | [Cmd in keyof T]: T[Cmd] extends (arg: infer A, meta: Meta) => (Promise | infer X) ? X : never 19 | } 20 | 21 | type Message = { 22 | command: C, 23 | args: CommandArg[C], 24 | } 25 | 26 | 27 | export class Messaging{ 28 | constructor(){ 29 | 30 | } 31 | 32 | receive(receiver: R){ 33 | // @ts-ignore 34 | browser.runtime.onMessage.addListener( ({command, args}: { command: keyof T, args: any }, sender: browser.runtime.MessageSender) => { 35 | const meta = { command, sender }; 36 | const method = receiver[command]; 37 | if (method){ 38 | return Promise.resolve(method.call(receiver, args, meta)); 39 | }else{ 40 | // just ignore unknown messages 41 | //return Promise.reject(`cannot handle message: ${command}`); 42 | } 43 | }); 44 | } 45 | async sendToBG(command: C, ...argsMaybe: CommandArg[C]): Promise[C]>{ 46 | return browser.runtime.sendMessage( {command, args: argsMaybe.length > 0 ? argsMaybe[0] : null }); 47 | } 48 | 49 | async sendToTab(tabId: number, command: C, ...argsMaybe: CommandArg[C]): Promise[C] | void>{ 50 | return browser.tabs.sendMessage(tabId, {command, args: argsMaybe.length > 0 ? argsMaybe[0] : null }); 51 | } 52 | 53 | async sendToActiveTab(command: C, ...args: CommandArg[C]): Promise[C] | void>{ 54 | const tabs = await browser.tabs.query({ 55 | currentWindow: true, 56 | active: true, 57 | }); 58 | if (tabs.length !== 1 || tabs[0].id == null){ 59 | throw "Failed to get active tab"; 60 | } 61 | return this.sendToTab(tabs[0].id, command, ...args); 62 | } 63 | } -------------------------------------------------------------------------------- /src/util/mutex.ts: -------------------------------------------------------------------------------- 1 | type ResolveType = () => void; 2 | 3 | export class Mutex { 4 | private locked: boolean; 5 | 6 | private queue: ResolveType[]; 7 | 8 | constructor(){ 9 | this.locked = false; 10 | this.queue = []; 11 | } 12 | 13 | async transact(exec: () => (T | Promise)): Promise{ 14 | try{ 15 | await this.lock(); 16 | return (await exec()); // OK if exec is not an async function 17 | }finally{ 18 | this.unlock(); 19 | } 20 | } 21 | 22 | lock(): Promise{ 23 | if (this.locked){ 24 | return new Promise((resolve) => { 25 | this.queue.push(resolve); 26 | }); 27 | }else{ 28 | this.locked = true; 29 | return Promise.resolve(); 30 | } 31 | } 32 | 33 | unlock(): void{ 34 | if (this.queue.length == 0){ 35 | this.locked = false; 36 | }else{ 37 | //console.log(this.queue); 38 | this.queue.shift()!(); 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/util/timestamp.ts: -------------------------------------------------------------------------------- 1 | export class Timestamp{ 2 | private lastUpdate: number; 3 | 4 | constructor(){ 5 | this.lastUpdate = Date.now(); 6 | } 7 | 8 | update(): number{ 9 | this.lastUpdate = Date.now(); 10 | return this.lastUpdate; 11 | } 12 | 13 | isUpdatedSince(time: number): boolean{ 14 | return this.lastUpdate > time; 15 | } 16 | 17 | elapsedMillisecond(time: number = Date.now()): number{ 18 | return time - this.lastUpdate; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 7 | "lib": ["ES2017", "DOM", "DOM.Iterable"], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | "typeRoots": [ 47 | "node_modules/@types", 48 | "node_modules/web-ext-types" 49 | ], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /webext/content-scripts/main.css: -------------------------------------------------------------------------------- 1 | .fipwp-goto-target-element::before { 2 | display : none !important; 3 | } 4 | 5 | .fipwp-goto-target-element::after { 6 | display : none !important; 7 | } 8 | -------------------------------------------------------------------------------- /webext/find-window/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | min-width : 300px; 3 | min-height : 50px; 4 | margin : 0; 5 | padding : 0; 6 | overflow-x : hidden; 7 | background-color : var(--bgColorSearchResult); 8 | } 9 | 10 | .block-container { 11 | margin : 0; 12 | padding : 0; 13 | border : 0; 14 | } 15 | 16 | .action-button { 17 | background-color: inherit; 18 | border: none; 19 | cursor: pointer; 20 | outline: none; 21 | padding: 0 4px; 22 | appearance: none; 23 | } 24 | 25 | .action-button:hover { 26 | background-color : var(--bgColorSearchFormHover); 27 | } 28 | 29 | #search-input-container { 30 | position : sticky; 31 | top : 0; 32 | height : fit-content; 33 | width : 100%; 34 | margin : 0; 35 | padding : 0; 36 | color : var(--fgColorSearchForm); 37 | background-color : var(--bgColorSearchForm); 38 | } 39 | 40 | #search-input-container * { 41 | vertical-align : middle; 42 | } 43 | 44 | 45 | #search-text-input-container { 46 | display : inline-block; 47 | width : 17em; 48 | resize : horizontal; 49 | overflow: hidden; 50 | margin : 0; 51 | padding : 0; 52 | } 53 | 54 | #search-text-input { 55 | width : 100%; 56 | color : var(--fgColorInput); 57 | background-color : var(--bgColorInput); 58 | } 59 | 60 | #search-options-toggle-show{ 61 | display : none; 62 | } 63 | 64 | #search-options-toggle-show + *{ 65 | display : inline-block; 66 | background-color : inherit; 67 | cursor : pointer; 68 | padding : 0 4px; 69 | } 70 | 71 | #search-options-toggle-show + *:hover{ 72 | background-color : var(--bgColorSearchFormHover); 73 | } 74 | 75 | #search-options-toggle-show:checked + *{ 76 | } 77 | 78 | 79 | #search-options-container{ 80 | display : none; 81 | } 82 | 83 | #search-options-container > *{ 84 | margin-right : 1em; 85 | } 86 | 87 | #message-container { 88 | display : none; 89 | background-color : green; 90 | color : white; 91 | font-size : 24px; 92 | padding : 10px; 93 | width : 100%; 94 | } 95 | 96 | #message-container.message-show { 97 | animation-name : fadeout; 98 | animation-duration : 1s; 99 | animation-delay : 3s; 100 | animation-iteration-count : 1; 101 | animation-fill-mode : forwards; 102 | display : block; 103 | } 104 | 105 | @keyframes fadeout { 106 | 0% { 107 | opacity : 1; 108 | } 109 | 110 | 100% { 111 | opacity : 0; 112 | } 113 | } 114 | 115 | 116 | #search-results-container { 117 | margin : 10px 10px 0 10px; 118 | } 119 | 120 | .search-result-item-a { 121 | display : block; 122 | margin : 0; 123 | margin-bottom : 20px; 124 | padding : 0; 125 | cursor : pointer; 126 | } 127 | 128 | .search-result-item-img { 129 | background-origin : content-box; 130 | background-repeat : no-repeat; 131 | overflow : hidden; 132 | display : block; 133 | margin : 0; 134 | padding : 0; 135 | border : 4px var(--borderColor) solid; 136 | } 137 | 138 | .search-result-item-focused .search-result-item-img{ 139 | border-style : dashed; 140 | } 141 | 142 | .search-result-item-selected .search-result-item-img{ 143 | border-width : 4px; 144 | border-color : var(--borderColorSelected); 145 | } 146 | -------------------------------------------------------------------------------- /webext/find-window/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 | 23 | 24 | 25 |
26 | 27 | 32 |
33 |
34 |
35 |
36 |
37 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /webext/img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 25 | 29 | 33 | 34 | 44 | 45 | 64 | 66 | 67 | 69 | image/svg+xml 70 | 72 | 73 | 74 | 75 | 76 | 81 | 88 | 95 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /webext/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Find in Page with Preview", 4 | "version": "0.1.17", 5 | 6 | "applications": { 7 | "gecko": { 8 | "id": "{ac77876d-57ef-4257-b009-3448c48646b4}", 9 | "strict_min_version": "60.0" 10 | } 11 | }, 12 | 13 | "description": "Find in page and show preview images around matches.", 14 | "icons": { 15 | "48": "img/icon.svg" 16 | }, 17 | 18 | "permissions": [ 19 | "find", 20 | "tabs", 21 | "storage", 22 | "menus", 23 | "" 24 | ], 25 | 26 | "content_scripts": [{ 27 | "matches": [ 28 | "" 29 | ], 30 | "js": [ 31 | "out/content.js" 32 | ], 33 | "css": [ 34 | "content-scripts/main.css" 35 | ], 36 | "run_at": "document_idle" 37 | }], 38 | 39 | "browser_action": { 40 | "default_icon": "img/icon.svg", 41 | "default_title": "Find in Page with Preview", 42 | "default_popup": "find-window/index.html?popup=1" 43 | }, 44 | 45 | "sidebar_action": { 46 | "default_icon": "img/icon.svg", 47 | "default_title": "Find in Page with Preview", 48 | "default_panel": "find-window/index.html" 49 | }, 50 | 51 | "commands": { 52 | "_execute_browser_action": { 53 | }, 54 | "_execute_sidebar_action": { 55 | } 56 | }, 57 | 58 | "background": { 59 | "scripts": [ 60 | "out/bg.js" 61 | ] 62 | }, 63 | 64 | "options_ui": { 65 | "page": "options/options.html", 66 | "browser_style": true 67 | }, 68 | 69 | "content_security_policy": "script-src 'self' ; object-src 'self'" 70 | } 71 | -------------------------------------------------------------------------------- /webext/options/options.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | font-size : large; 3 | margin : 0 0 5px 0; 4 | } 5 | 6 | fieldset { 7 | outline : 0; 8 | border : 0; 9 | } 10 | 11 | section { 12 | margin : 0 0 30px 0; 13 | padding : 0; 14 | border : 2px ridge gray; 15 | padding : 10px; 16 | white-space : nowrap; 17 | overflow: auto; 18 | } 19 | 20 | .lb { 21 | display : inline-block; 22 | width : 10em; 23 | } 24 | 25 | input[type="text"] { 26 | width : 8em; 27 | } 28 | 29 | .shortcut-valid { 30 | display : none; 31 | } 32 | .shortcut-invalid { 33 | display : inline; 34 | } 35 | -------------------------------------------------------------------------------- /webext/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 |

Controls

15 |
16 | 17 |
18 |
19 |
20 |

Smooth scroll

21 |
22 | 23 |
24 |
25 | 26 |
27 |

Preview range size

28 |
29 | Width 30 |
31 |
32 | Height 33 |
34 |
35 | 36 |
37 |

Image size

38 |
39 | 40 |
41 |
42 |
43 | Width 44 |
45 |
46 | Height 47 |
48 |
49 |
50 | 51 |
52 |
53 | 54 |
55 |

Context menu

56 |
57 | 58 |
59 |
60 | 61 |
62 |
63 | 64 |
65 |

Shortcut Keys

66 |
67 | Popup 68 | 69 | + 76 | + 80 | 81 | 82 |
83 |
84 | Sidebar 85 | 86 | + 93 | + 97 | 98 | 99 |
100 |
101 | 102 |
103 |

History

104 |
105 | History size 106 |
107 |
108 | 109 |
110 |

Colors

111 |
Input text
112 |
Input background
113 |
Form text
114 |
Form background
115 |
Form hover
116 |
Results background
117 |
Border
118 |
Border selected
119 |
120 | 121 |
122 | 123 | 124 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'development', 5 | 6 | devtool: 'inline-source-map', 7 | 8 | entry: { 9 | bg: './src/bg/bg.ts', 10 | content: './src/content-scripts/content.ts', 11 | options: './src/options/options.ts', 12 | "find-window": './src/find-window/find-window.ts', 13 | }, 14 | 15 | output: { 16 | path: path.join(__dirname, 'webext', 'out'), 17 | filename: '[name].js', 18 | }, 19 | 20 | resolve: { 21 | extensions: ['.ts', '.js'], 22 | }, 23 | 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.ts$/, 28 | loader: 'ts-loader', 29 | } 30 | ] 31 | } 32 | }; --------------------------------------------------------------------------------