Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command for performing action on element fuzzy searching its text #288

Merged
merged 7 commits into from
Apr 26, 2024
54 changes: 54 additions & 0 deletions src/background/messaging/sendRequestToContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { splitRequestsByFrame } from "../utils/splitRequestsByFrame";
import {
RangoActionRemoveReference,
RangoActionRunActionOnReference,
RangoActionWithTargets,
} from "../../typings/RangoAction";
import { notify } from "../utils/notify";

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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;
Expand Down
125 changes: 125 additions & 0 deletions src/content/actions/runActionOnTextMatchedElement.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
}
13 changes: 12 additions & 1 deletion src/content/actions/runRangoActionWithoutTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | boolean | undefined> {
): Promise<string | number | boolean | undefined> {
switch (request.type) {
case "historyGoBack":
window.history.back();
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 23 additions & 1 deletion src/content/wrappers/ElementWrapperClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]");
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/typings/ElementWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ElementWrapper {
intersect(isIntersecting: boolean): void;
intersectViewport(isIntersecting: boolean): void;
click(): boolean;
flashElement(): void;
hover(): void;
unhover(): void;

Expand Down
20 changes: 19 additions & 1 deletion src/typings/RangoAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -192,7 +209,8 @@ export type RangoActionWithoutTarget =
| RangoActionRemoveReference
| RangoActionScrollPosition
| RangoActionfocusOrCreateTabByUrl
| RangoActionFocusTabByText;
| RangoActionFocusTabByText
| RangoActionRunActionOnTextMatchedElement;

export type RangoAction = RangoActionWithTarget | RangoActionWithoutTarget;

Expand Down
Loading