diff --git a/src/background/messaging/sendRequestToContent.ts b/src/background/messaging/sendRequestToContent.ts index 23f9ce81..ab1048d8 100644 --- a/src/background/messaging/sendRequestToContent.ts +++ b/src/background/messaging/sendRequestToContent.ts @@ -7,6 +7,7 @@ import { splitRequestsByFrame } from "../utils/splitRequestsByFrame"; import { RangoActionRemoveReference, RangoActionRunActionOnReference, + RangoActionWithTargets, } from "../../typings/RangoAction"; import { notify } from "../utils/notify"; @@ -60,6 +61,53 @@ async function handleActionOnReference( } } +async function runActionOnTextMatchedElement( + actionType: RangoActionWithTargets["type"], + text: string, + prioritizeViewport: boolean +) { + const tabId = await getCurrentTabId(); + const allFrames = await browser.webNavigation.getAllFrames({ + tabId, + }); + + const bestScoreByFramePromise = allFrames.map(async (frame) => ({ + frameId: frame.frameId, + score: (await browser.tabs.sendMessage( + tabId, + { type: "matchElementByText", text, prioritizeViewport }, + { + frameId: frame.frameId, + } + )) as number | undefined, + })); + + const results = await Promise.allSettled(bestScoreByFramePromise); + const matches = results + .filter(isPromiseFulfilledResult) + .map((result) => result.value) + .filter((value) => typeof value.score === "number"); + + const sorted = matches.sort((a, b) => (a.score ?? 0) - (b.score ?? 0)); + + if (sorted[0]) { + await browser.tabs.sendMessage( + tabId, + { + type: "executeActionOnTextMatchedElement", + actionType, + }, + { + frameId: sorted[0].frameId, + } + ); + } else { + await notify("Unable to find element with matching text", { + type: "warning", + }); + } +} + // Sends a request to the content script. If tabId is not specified it will // send it to the current tab. If frameId is not specified it will send it to // the main frame (frameId 0). @@ -133,6 +181,12 @@ export async function sendRequestToContent( request.type === "runActionOnReference" ) { return handleActionOnReference(request, targetTabId); + } else if (request.type === "runActionOnTextMatchedElement") { + return runActionOnTextMatchedElement( + request.arg, + request.arg2, + request.arg3 + ); } frameId = frameId ?? toAllFrames.has(request.type) ? undefined : 0; diff --git a/src/content/actions/runActionOnTextMatchedElement.ts b/src/content/actions/runActionOnTextMatchedElement.ts new file mode 100644 index 00000000..2d80f3cf --- /dev/null +++ b/src/content/actions/runActionOnTextMatchedElement.ts @@ -0,0 +1,125 @@ +import Fuse from "fuse.js"; +import { RangoActionWithTargets } from "../../typings/RangoAction"; +import { getCachedSetting } from "../settings/cacheSettings"; +import { getToggles } from "../settings/toggles"; +import { deepGetElements } from "../utils/deepGetElements"; +import { isHintable } from "../utils/isHintable"; +import { isVisible } from "../utils/isVisible"; +import { getOrCreateWrapper } from "../wrappers/ElementWrapperClass"; +import { getAllWrappers } from "../wrappers/wrappers"; +import { runRangoActionWithTarget } from "./runRangoActionWithTarget"; + +let textMatchedElement: Element | undefined; +let shouldScrollIntoView = false; + +type Hintable = { + element: Element; + isIntersectingViewport: boolean; + trimmedTextContent: string; +}; + +function trimTextContent(element: Element) { + return element.textContent?.replace(/\d/g, "").trim() ?? ""; +} + +async function getHintables() { + // Hints are on, there will be wrappers for the hintable elements + if (getToggles().computed || getCachedSetting("alwaysComputeHintables")) { + return getAllWrappers() + .filter((wrapper) => wrapper.isHintable && isVisible(wrapper.element)) + .map((wrapper) => ({ + element: wrapper.element, + isIntersectingViewport: wrapper.isIntersectingViewport, + trimmedTextContent: trimTextContent(wrapper.element), + })); + } + + const hintables: Hintable[] = []; + const elements = deepGetElements(document.body, true); + + let resolveIntersection: (value: unknown) => void | undefined; + + const intersectionPromise = new Promise((resolve) => { + resolveIntersection = resolve; + }); + + const intersectionObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (isHintable(entry.target) && isVisible(entry.target)) { + hintables.push({ + element: entry.target, + isIntersectingViewport: entry.isIntersecting, + trimmedTextContent: trimTextContent(entry.target), + }); + } + } + + if (resolveIntersection) resolveIntersection(true); + }); + + for (const element of elements) { + intersectionObserver.observe(element); + } + + await intersectionPromise; + return hintables; +} + +export async function matchElementByText( + text: string, + prioritizeViewport: boolean +) { + const hintables = await getHintables(); + + const fuse = new Fuse(hintables, { + keys: ["trimmedTextContent"], + ignoreLocation: true, + includeScore: true, + threshold: 0.4, + }); + + const matches = fuse.search(text); + + const sortedMatches = prioritizeViewport + ? matches.sort((a, b) => { + if (a.item.isIntersectingViewport && !b.item.isIntersectingViewport) { + return -1; + } + + if (!a.item.isIntersectingViewport && b.item.isIntersectingViewport) { + return 1; + } + + return a.score! - b.score!; + }) + : matches; + + const bestMatch = sortedMatches[0]; + if (bestMatch?.item) { + textMatchedElement = bestMatch.item.element; + shouldScrollIntoView = !bestMatch.item.isIntersectingViewport; + } + + return bestMatch?.score; +} + +export async function executeActionOnTextMatchedElement( + actionType: RangoActionWithTargets["type"] +) { + if (textMatchedElement) { + const wrapper = getOrCreateWrapper(textMatchedElement, false); + + // If the element is outside of the viewport we need to scroll the element + // into view in order to get the topmost element, which is the one we need + // to click on. + if (shouldScrollIntoView) { + textMatchedElement.scrollIntoView({ + behavior: "instant", + block: "center", + inline: "center", + }); + } + + await runRangoActionWithTarget({ type: actionType, target: [] }, [wrapper]); + } +} diff --git a/src/content/actions/runRangoActionWithoutTarget.ts b/src/content/actions/runRangoActionWithoutTarget.ts index 7ec2fd23..7b1f18ff 100644 --- a/src/content/actions/runRangoActionWithoutTarget.ts +++ b/src/content/actions/runRangoActionWithoutTarget.ts @@ -18,10 +18,14 @@ import { removeReference, showReferences } from "./references"; import { refreshHints } from "./refreshHints"; import { runActionOnReference } from "./runActionOnReference"; import { scroll } from "./scroll"; +import { + executeActionOnTextMatchedElement, + matchElementByText, +} from "./runActionOnTextMatchedElement"; export async function runRangoActionWithoutTarget( request: RangoActionWithoutTarget -): Promise { +): Promise { switch (request.type) { case "historyGoBack": window.history.back(); @@ -167,6 +171,13 @@ export async function runRangoActionWithoutTarget( case "removeReference": return removeReference(request.arg); + case "matchElementByText": + return matchElementByText(request.text, request.prioritizeViewport); + + case "executeActionOnTextMatchedElement": + await executeActionOnTextMatchedElement(request.actionType); + break; + default: await notify(`Invalid action "${request.type}"`, { type: "error", diff --git a/src/content/wrappers/ElementWrapperClass.ts b/src/content/wrappers/ElementWrapperClass.ts index 87b878a5..bd6435a4 100644 --- a/src/content/wrappers/ElementWrapperClass.ts +++ b/src/content/wrappers/ElementWrapperClass.ts @@ -20,6 +20,7 @@ import { getUserScrollableContainer } from "../utils/getUserScrollableContainer" import { isDisabled } from "../utils/isDisabled"; import { isHintable } from "../utils/isHintable"; import { isVisible } from "../utils/isVisible"; +import { setStyleProperties } from "../hints/setStyleProperties"; import { refresh } from "./refresh"; import { addWrapper, @@ -452,7 +453,11 @@ class ElementWrapperClass implements ElementWrapper { click(): boolean { const pointerTarget = getPointerTarget(this.element); - this.hint?.flash(); + if (this.hint?.inner.isConnected) { + this.hint.flash(); + } else { + this.flashElement(); + } if (this.element instanceof HTMLAnchorElement) { const closestContentEditable = this.element.closest("[contenteditable]"); @@ -489,6 +494,23 @@ class ElementWrapperClass implements ElementWrapper { return dispatchClick(pointerTarget); } + flashElement() { + const element = this.element; + + if (!(element instanceof HTMLElement)) { + return; + } + + const previousOutline = element.style.outline; + + setStyleProperties(element, { + outline: "2px solid #0891b2", + }); + setTimeout(() => { + element.style.outline = previousOutline; + }, 150); + } + hover() { const pointerTarget = getPointerTarget(this.element); this.hint?.flash(); diff --git a/src/typings/ElementWrapper.ts b/src/typings/ElementWrapper.ts index 4e83e77f..2a7083a9 100644 --- a/src/typings/ElementWrapper.ts +++ b/src/typings/ElementWrapper.ts @@ -32,6 +32,7 @@ export interface ElementWrapper { intersect(isIntersecting: boolean): void; intersectViewport(isIntersecting: boolean): void; click(): boolean; + flashElement(): void; hover(): void; unhover(): void; diff --git a/src/typings/RangoAction.ts b/src/typings/RangoAction.ts index 8c19a18f..994c4624 100644 --- a/src/typings/RangoAction.ts +++ b/src/typings/RangoAction.ts @@ -173,6 +173,23 @@ interface RangoActionFocusTabByText { arg: string; } +type RangoActionRunActionOnTextMatchedElement = + | { + type: "runActionOnTextMatchedElement"; + arg: RangoActionWithTargets["type"]; + arg2: string; + arg3: boolean; + } + | { + type: "matchElementByText"; + text: string; + prioritizeViewport: boolean; + } + | { + type: "executeActionOnTextMatchedElement"; + actionType: RangoActionWithTargets["type"]; + }; + export type RangoActionWithTarget = | RangoActionWithTargets | RangoActionWithTargetsWithOptionalNumberArg @@ -192,7 +209,8 @@ export type RangoActionWithoutTarget = | RangoActionRemoveReference | RangoActionScrollPosition | RangoActionfocusOrCreateTabByUrl - | RangoActionFocusTabByText; + | RangoActionFocusTabByText + | RangoActionRunActionOnTextMatchedElement; export type RangoAction = RangoActionWithTarget | RangoActionWithoutTarget;