diff --git a/src/__snapshots__/Storyshots.test.js.snap b/src/__snapshots__/Storyshots.test.js.snap index 34934b166b..138d6ee978 100644 --- a/src/__snapshots__/Storyshots.test.js.snap +++ b/src/__snapshots__/Storyshots.test.js.snap @@ -3491,6 +3491,10 @@ exports[`Storyshots Editor/LogToolbar Default 1`] = ` `; +exports[`Storyshots Enhancements/SelectionToolbar Emoji Buttons 1`] = `
`; + +exports[`Storyshots Enhancements/SelectionToolbar Mixed Buttons 1`] = `
`; + exports[`Storyshots ExtensionConsole/IDBErrorDisplay Connection Error 1`] = `
. + */ + +import ActionRegistry from "@/contentScript/selectionTooltip/ActionRegistry"; +import { uuidv4 } from "@/types/helpers"; + +describe("actionRegistry", () => { + it("sets emoji from title", () => { + const componentId = uuidv4(); + const registry = new ActionRegistry(); + + registry.register(componentId, { + title: "👋 Hello", + icon: null, + handler() {}, + }); + + expect(registry.actions.get(componentId)?.emoji).toBe("👋"); + }); + + it("defaults icon to box", () => { + const componentId = uuidv4(); + const registry = new ActionRegistry(); + + registry.register(componentId, { + title: "Hello", + icon: null, + handler() {}, + }); + + expect(registry.actions.get(componentId)?.icon).toStrictEqual({ + id: "box", + library: "bootstrap", + }); + }); + + it("fires event listener on register and unregister", () => { + const componentId = uuidv4(); + const registry = new ActionRegistry(); + const listener = jest.fn(); + + registry.onChange.add(listener); + registry.register(componentId, { + title: "Hello", + icon: null, + handler() {}, + }); + + expect(listener).toBeCalledTimes(1); + + registry.unregister(componentId); + expect(listener).toBeCalledTimes(2); + }); +}); diff --git a/src/contentScript/selectionTooltip/ActionRegistry.ts b/src/contentScript/selectionTooltip/ActionRegistry.ts new file mode 100644 index 0000000000..1eadce6dc2 --- /dev/null +++ b/src/contentScript/selectionTooltip/ActionRegistry.ts @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { UUID } from "@/types/stringTypes"; +import type { IconConfig } from "@/types/iconTypes"; +import { splitStartingEmoji } from "@/utils/stringUtils"; +import { SimpleEventTarget } from "@/utils/SimpleEventTarget"; +import type { Nullishable } from "@/utils/nullishUtils"; + +type TextSelectionAction = { + // NOTE: currently there's no way to set icons for context menu items, so this will always be null + icon?: Nullishable; + title: string; + handler: (text: string) => void; +}; + +export type RegisteredAction = TextSelectionAction & { + emoji: Nullishable; +}; + +const defaultIcon: IconConfig = { + id: "box", + library: "bootstrap", +}; + +/** + * Registry for text selection actions. + * @since 1.8.10 + */ +class ActionRegistry { + /** + * Map from component UUID to registered action + */ + public readonly actions = new Map(); + + /** + * Event fired when the set of registered actions changes + */ + public readonly onChange = new SimpleEventTarget(); + + /** + * Register a new text selection action. Overwrites any existing action for the mod component. + * @param componentId the mod component id + * @param action the action definition + */ + register(componentId: UUID, action: TextSelectionAction): void { + const { startingEmoji } = splitStartingEmoji(action.title); + this.actions.set(componentId, { + ...action, + emoji: startingEmoji, + icon: action.icon ?? defaultIcon, + }); + this.onChange.emit([...this.actions.values()]); + } + + /** + * Unregister a text selection action. Does nothing if an action for the component is not registered. + * @param componentId the mod component id + */ + unregister(componentId: UUID): void { + this.actions.delete(componentId); + this.onChange.emit([...this.actions.values()]); + } +} + +export default ActionRegistry; diff --git a/src/contentScript/selectionTooltip/SelectionToolbar.scss b/src/contentScript/selectionTooltip/SelectionToolbar.scss new file mode 100644 index 0000000000..ea3f011def --- /dev/null +++ b/src/contentScript/selectionTooltip/SelectionToolbar.scss @@ -0,0 +1,54 @@ +/*! + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@import "@/themes/colors.scss"; + +.toolbar { + display: flex; + + height: 30px; + font-size: 16px; + //line-height: 20px; + + padding-left: 3px; + padding-right: 3px; +} + +.toolbarItem { + background-color: $S0; + color: black; + border-radius: 0; + border: 0; + cursor: pointer; + + vertical-align: middle; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: $P100; + } + + &:active { + background-color: $P300; + } +} + +svg { + vertical-align: middle; +} diff --git a/src/contentScript/selectionTooltip/SelectionToolbar.stories.tsx b/src/contentScript/selectionTooltip/SelectionToolbar.stories.tsx new file mode 100644 index 0000000000..5c49d87edf --- /dev/null +++ b/src/contentScript/selectionTooltip/SelectionToolbar.stories.tsx @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { ComponentMeta, ComponentStory } from "@storybook/react"; +import React from "react"; +import SelectionToolbar from "@/contentScript/selectionTooltip/SelectionToolbar"; +import ActionRegistry from "@/contentScript/selectionTooltip/ActionRegistry"; +import { uuidv4 } from "@/types/helpers"; +import { action } from "@storybook/addon-actions"; + +export default { + title: "Enhancements/SelectionToolbar", + component: SelectionToolbar, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +const emojiAction = { + title: "😊 Emoji", + handler() { + action("😊"); + }, +}; + +const textAction = { + title: "No Emoji", + handler() { + action("No Emoji"); + }, +}; + +const emojiRegistry = new ActionRegistry(); +emojiRegistry.register(uuidv4(), emojiAction); +emojiRegistry.register(uuidv4(), emojiAction); + +const mixedRegistry = new ActionRegistry(); +mixedRegistry.register(uuidv4(), emojiAction); +mixedRegistry.register(uuidv4(), textAction); + +export const EmojiButtons = Template.bind({}); +EmojiButtons.args = { + registry: emojiRegistry, + onHide: action("onHide"), +}; + +export const MixedButtons = Template.bind({}); +MixedButtons.args = { + registry: mixedRegistry, + onHide: action("onHide"), +}; diff --git a/src/contentScript/selectionTooltip/SelectionToolbar.tsx b/src/contentScript/selectionTooltip/SelectionToolbar.tsx new file mode 100644 index 0000000000..b097049e35 --- /dev/null +++ b/src/contentScript/selectionTooltip/SelectionToolbar.tsx @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; +// We're rendering in the shadow DOM, so we need to load styles as a URL. loadAsUrl doesn't work with module mangling +import stylesUrl from "@/contentScript/selectionTooltip/SelectionToolbar.scss?loadAsUrl"; +import type ActionRegistry from "@/contentScript/selectionTooltip/ActionRegistry"; +import type { RegisteredAction } from "@/contentScript/selectionTooltip/ActionRegistry"; +import Icon from "@/icons/Icon"; +import { splitStartingEmoji } from "@/utils/stringUtils"; +import { truncate } from "lodash"; +import useDocumentSelection from "@/hooks/useDocumentSelection"; +import type { Nullishable } from "@/utils/nullishUtils"; +import useActionRegistry from "@/contentScript/selectionTooltip/useActionRegistry"; +import { Stylesheets } from "@/components/Stylesheets"; +import EmotionShadowRoot from "react-shadow/emotion"; + +// "Every property exists" (via Proxy), TypeScript doesn't offer such type +// Also strictNullChecks config mismatch +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion +const ShadowRoot = EmotionShadowRoot.div!; + +const ICON_SIZE_PX = 16; + +type ActionCallbacks = { + onHide: () => void; +}; + +function selectButtonTitle( + title: string, + selection: Nullishable, + { + selectionPreviewLength = 10, + }: { + selectionPreviewLength?: number; + } = {}, +): string { + const text = splitStartingEmoji(title).rest; + // Chrome uses %s as selection placeholder, which is confusing to users. We might instead show a preview of + // the selected text here. + const selectionText = truncate(selection ?? "", { + length: selectionPreviewLength, + omission: "…", + }); + return text.replace("%s", selectionText); +} + +const ToolbarItem: React.FC< + RegisteredAction & ActionCallbacks & { selection: Nullishable } +> = ({ selection, title, handler, emoji, icon, onHide }) => ( + +); + +const SelectionToolbar: React.FC< + { registry: ActionRegistry } & ActionCallbacks +> = ({ registry, onHide }) => { + const selection = useDocumentSelection(); + const actions = useActionRegistry(registry); + + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/menu_role + return ( + + +
+ {[...actions.entries()].map(([id, action]) => ( + + ))} +
+
+
+ ); +}; + +export default SelectionToolbar; diff --git a/src/contentScript/selectionTooltip/tooltipController.test.ts b/src/contentScript/selectionTooltip/tooltipController.test.ts new file mode 100644 index 0000000000..efe5735131 --- /dev/null +++ b/src/contentScript/selectionTooltip/tooltipController.test.ts @@ -0,0 +1,57 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { uuidv4 } from "@/types/helpers"; +import { waitForEffect } from "@/testUtils/testHelpers"; +import { rectFactory } from "@/testUtils/factories/domFactories"; +import type * as controllerModule from "@/contentScript/selectionTooltip/tooltipController"; + +document.body.innerHTML = + '
Here\'s some text
'; + +// `jsdom` does not implement full layout engine +// https://github.com/jsdom/jsdom#unimplemented-parts-of-the-web-platform +(Range.prototype.getBoundingClientRect as any) = jest.fn(() => rectFactory()); +(Range.prototype.getClientRects as any) = jest.fn(() => [rectFactory()]); + +describe("tooltipController", () => { + let module: typeof controllerModule; + + async function selectText() { + const user = userEvent.setup(); + await user.tripleClick(screen.getByTestId("span")); + document.dispatchEvent(new Event("selectionchange")); + await waitForEffect(); + } + + beforeEach(async () => { + jest.resetModules(); + module = await import("@/contentScript/selectionTooltip/tooltipController"); + }); + + it("don't show tooltip if no actions are registered", async () => { + module.initSelectionTooltip(); + await selectText(); + expect( + screen.queryByTestId("pixiebrix-selection-tooltip"), + ).not.toBeInTheDocument(); + }); + + it("attach tooltip when user selects text", async () => { + module.initSelectionTooltip(); + + module.tooltipActionRegistry.register(uuidv4(), { + title: "Copy", + icon: undefined, + handler() {}, + }); + + // Surround in `act()` because icon loading is async + await selectText(); + + // I couldn't get screen from shadow-dom-testing-library to work, otherwise I would have use getByRole for 'menu' + // I think it might only work with render(). + await expect( + screen.findByTestId("pixiebrix-selection-tooltip"), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/src/contentScript/selectionTooltip/tooltipController.tsx b/src/contentScript/selectionTooltip/tooltipController.tsx new file mode 100644 index 0000000000..c3f1b7b993 --- /dev/null +++ b/src/contentScript/selectionTooltip/tooltipController.tsx @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import ActionRegistry from "@/contentScript/selectionTooltip/ActionRegistry"; +import { once } from "lodash"; +import type { Nullishable } from "@/utils/nullishUtils"; +import { render } from "react-dom"; +import React from "react"; +import { ensureTooltipsContainer } from "@/contentScript/tooltipDom"; +import { + autoUpdate, + computePosition, + inline, + offset, + type VirtualElement, +} from "@floating-ui/dom"; +import { getCaretCoordinates } from "@/utils/textAreaUtils"; +import SelectionToolbar from "@/contentScript/selectionTooltip/SelectionToolbar"; +import { expectContext } from "@/utils/expectContext"; +import { onContextInvalidated } from "webext-events"; + +const MIN_SELECTION_LENGTH_CHARS = 3; + +export const tooltipActionRegistry = new ActionRegistry(); + +let selectionTooltip: Nullishable; + +let cleanupAutoPosition: () => void; + +function showTooltip(): void { + if (tooltipActionRegistry.actions.size === 0) { + // No registered actions to show + return; + } + + selectionTooltip ??= createTooltip(); + selectionTooltip.setAttribute("aria-hidden", "false"); + selectionTooltip.style.setProperty("display", "block"); + + void updatePosition(); +} + +function hideTooltip(): void { + selectionTooltip?.setAttribute("aria-hidden", "true"); + selectionTooltip?.style.setProperty("display", "none"); + cleanupAutoPosition?.(); +} + +function createTooltip(): HTMLElement { + const container = ensureTooltipsContainer(); + + const popover = document.createElement("div"); + // Using popover attribute should keep it on top of the page + // https://developer.chrome.com/blog/introducing-popover-api + // https://developer.mozilla.org/en-US/docs/Web/API/Popover_API + popover.setAttribute("popover", ""); + popover.dataset.testid = "pixiebrix-selection-tooltip"; + + // Must be set before positioning: https://floating-ui.com/docs/computeposition#initial-layout + popover.style.setProperty("position", "fixed"); + popover.style.setProperty("width", "max-content"); + popover.style.setProperty("top", "0"); + popover.style.setProperty("left", "0"); + // Override Chrome's based styles for [popover] attribute + popover.style.setProperty("margin", "0"); + popover.style.setProperty("padding", "0"); + + render( + , + popover, + ); + + container.append(popover); + + selectionTooltip = popover; + return selectionTooltip; +} + +function destroyTooltip(): void { + selectionTooltip?.remove(); + selectionTooltip = null; +} + +function getPositionReference(selection: Selection): VirtualElement | Element { + // Browsers don't report an accurate selection within inputs/textarea + const tagName = document.activeElement?.tagName; + if (tagName === "TEXTAREA" || tagName === "INPUT") { + const activeElement = document.activeElement as + | HTMLTextAreaElement + | HTMLInputElement; + + const elementRect = activeElement.getBoundingClientRect(); + + return { + getBoundingClientRect() { + // Try to be somewhat smart about where to place the tooltip when the user has a range selected. Ideally + // In a perfect world, we'd be able to provide getClientRects for the top row so the value is consistent + // with behavior for normal text. + const topPosition = Math.min( + activeElement.selectionStart ?? 0, + activeElement.selectionEnd ?? 0, + ); + const bottomPosition = Math.max( + activeElement.selectionStart ?? 0, + activeElement.selectionEnd ?? 0, + ); + const topCaret = getCaretCoordinates(activeElement, topPosition); + const bottomCaret = getCaretCoordinates(activeElement, bottomPosition); + + const width = Math.abs(bottomCaret.left - topCaret.left); + const height = Math.abs( + bottomCaret.top - topCaret.top + bottomCaret.height, + ); + + return { + height, + width, + x: elementRect.x + topCaret.left, + y: elementRect.y + topCaret.top, + left: elementRect.x + topCaret.left, + top: elementRect.y + topCaret.top, + right: elementRect.x + width, + bottom: elementRect.y + height, + }; + }, + } satisfies VirtualElement; + } + + // Allows us to measure where the selection is on the page relative to the viewport + const range = selection.getRangeAt(0); + + // https://floating-ui.com/docs/virtual-elements#getclientrects + return { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; +} + +async function updatePosition(): Promise { + const selection = window.getSelection(); + + if (!selectionTooltip || !selection) { + // Guard against race condition + return; + } + + // https://floating-ui.com/docs/getting-started + const referenceElement = getPositionReference(selection); + const supportsInline = "getClientRects" in referenceElement; + + // Keep anchored on scroll/resize: https://floating-ui.com/docs/computeposition#anchoring + cleanupAutoPosition = autoUpdate( + referenceElement, + selectionTooltip, + async () => { + if (!selectionTooltip) { + // Handle race in async handler + return; + } + + const { x, y } = await computePosition( + referenceElement, + selectionTooltip, + { + placement: "top", + strategy: "fixed", + // Prevent from appearing detached if multiple lines selected: https://floating-ui.com/docs/inline + middleware: [...(supportsInline ? [inline()] : []), offset(10)], + }, + ); + Object.assign(selectionTooltip.style, { + left: `${x}px`, + top: `${y}px`, + }); + }, + ); +} + +/** + * Return true if selection is valid for showing a tooltip. + * @param selection + */ +function isSelectionValid(selection: Nullishable): boolean { + if (!selection) { + return false; + } + + const selectionText = selection.toString(); + + const anchorNodeParent = selection.anchorNode?.parentElement; + const focusNodeParent = selection.focusNode?.parentElement; + + if (!anchorNodeParent || !focusNodeParent) { + return false; + } + + return selectionText.length >= MIN_SELECTION_LENGTH_CHARS; +} + +/** + * Initialize the selection tooltip once. + */ +export const initSelectionTooltip = once(() => { + expectContext("contentScript"); + + console.debug("Initializing text selection toolip"); + + // https://developer.mozilla.org/en-US/docs/Web/API/Document/selectionchange_event + // Firefox has support watching carat position: https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/selectionchange_event + // but it's not supported in Chrome + + // https://developer.mozilla.org/en-US/docs/Web/API/Document/selectionchange_event + document.addEventListener( + "selectionchange", + () => { + const selection = window.getSelection(); + if (isSelectionValid(selection)) { + showTooltip(); + } else { + hideTooltip(); + } + }, + { passive: true }, + ); + + // Try to avoid sticky tool-tip on SPA navigation + document.addEventListener( + "navigate", + () => { + destroyTooltip(); + }, + { passive: true }, + ); + + tooltipActionRegistry.onChange.add(() => { + const isShowing = selectionTooltip?.checkVisibility(); + destroyTooltip(); + + // Allow live updates from the Page Editor + if (isShowing) { + showTooltip(); + } + }); + + onContextInvalidated.addListener(() => { + destroyTooltip(); + }); +}); diff --git a/src/contentScript/selectionTooltip/useActionRegistry.ts b/src/contentScript/selectionTooltip/useActionRegistry.ts new file mode 100644 index 0000000000..ed543bd801 --- /dev/null +++ b/src/contentScript/selectionTooltip/useActionRegistry.ts @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type ActionRegistry from "@/contentScript/selectionTooltip/ActionRegistry"; +import type { RegisteredAction } from "@/contentScript/selectionTooltip/ActionRegistry"; +import { useSyncExternalStore } from "use-sync-external-store/shim"; +import { useCallback } from "react"; +import type { UUID } from "@/types/stringTypes"; + +/** + * React hook to receive action updates from the toolbar registry. + * @param registry the action registry to watch + */ +function useActionRegistry( + registry: ActionRegistry, +): Map { + const subscribe = useCallback( + (callback: () => void) => { + registry.onChange.add(callback); + return () => { + registry.onChange.remove(callback); + }; + }, + [registry], + ); + + // `getSnapshot` must return a consistent reference, so just pass back the actions map directly + const getSnapshot = useCallback(() => registry.actions, [registry]); + + return useSyncExternalStore(subscribe, getSnapshot); +} + +export default useActionRegistry; diff --git a/src/contentScript/tooltipDom.ts b/src/contentScript/tooltipDom.ts new file mode 100644 index 0000000000..ca923324be --- /dev/null +++ b/src/contentScript/tooltipDom.ts @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Attaches a tooltip container to the DOM. + * + * Having a separate container instead of attaching to the body directly improves performance, see: + * https://popper.js.org/docs/v2/performance/#attaching-elements-to-the-dom + */ +export function ensureTooltipsContainer(): Element { + let container = document.querySelector("#pb-tooltips-container"); + if (!container) { + container = document.createElement("div"); + container.id = "pb-tooltips-container"; + document.body.append(container); + } + + return container; +} diff --git a/src/extensionConsole/pages/settings/ExperimentalSettings.tsx b/src/extensionConsole/pages/settings/ExperimentalSettings.tsx index 9b0ada072a..e05bbd069a 100644 --- a/src/extensionConsole/pages/settings/ExperimentalSettings.tsx +++ b/src/extensionConsole/pages/settings/ExperimentalSettings.tsx @@ -31,6 +31,7 @@ const ExperimentalSettings: React.FunctionComponent = () => { excludeRandomClasses, performanceTracing, sandboxedCode, + selectionPopover, } = useSelector(selectSettings); return ( @@ -63,6 +64,13 @@ const ExperimentalSettings: React.FunctionComponent = () => { isEnabled={performanceTracing} flag="performanceTracing" /> + {!isMV3() && ( . + */ +import { useSyncExternalStore } from "use-sync-external-store/shim"; +import type { Nullishable } from "@/utils/nullishUtils"; + +// Using the string instead of the Selection object because the reference didn't seem to change on getSelection +type TextSelection = string; + +function subscribe(callback: () => void) { + // Use document vs. window because window.selectionchange wasn't firing reliably + document.addEventListener("selectionchange", callback, { passive: true }); + return () => { + document.removeEventListener("selectionchange", callback); + }; +} + +function getSnapshot(): Nullishable { + return document.getSelection()?.toString(); +} + +/** + * Utility hook to watch for changes to the window selection. + * @since 1.8.10 + */ +function useDocumentSelection(): Nullishable { + return useSyncExternalStore(subscribe, getSnapshot); +} + +export default useDocumentSelection; diff --git a/src/starterBricks/contextMenu.ts b/src/starterBricks/contextMenu.ts index 17397d5922..bd73716d16 100644 --- a/src/starterBricks/contextMenu.ts +++ b/src/starterBricks/contextMenu.ts @@ -72,6 +72,14 @@ import pluralize from "@/utils/pluralize"; import { allSettled } from "@/utils/promiseUtils"; import batchedFunction from "batched-function"; import { onContextInvalidated } from "webext-events"; +import { + initSelectionTooltip, + tooltipActionRegistry, +} from "@/contentScript/selectionTooltip/tooltipController"; +import { getSettingsState } from "@/store/settings/settingsStorage"; +import type { Except } from "type-fest"; + +const DEFAULT_MENU_ITEM_TITLE = "Untitled menu item"; // eslint-disable-next-line local-rules/persistBackgroundData -- Function const groupRegistrationErrorNotification = batchedFunction( @@ -192,6 +200,7 @@ export abstract class ContextMenuStarterBrickABC extends StarterBrickABC { // Always install the mouse handler in case a context menu is added later installMouseHandlerOnce(); + + if (this.contexts.includes("selection") || this.contexts.includes("all")) { + const { selectionPopover } = await getSettingsState(); + if (selectionPopover) { + initSelectionTooltip(); + } + } + return this.isAvailable(); } @@ -243,7 +261,7 @@ export abstract class ContextMenuStarterBrickABC extends StarterBrickABC, ): Promise { - const { title = "Untitled menu item" } = extension.config; + const { title = DEFAULT_MENU_ITEM_TITLE } = extension.config; // Check for null/undefined to preserve backward compatability if (!isDeploymentActive(extension)) { @@ -329,7 +347,11 @@ export abstract class ContextMenuStarterBrickABC extends StarterBrickABC, ): Promise { - const { action: actionConfig, onSuccess = {} } = extension.config; + const { + action: actionConfig, + onSuccess = {}, + title = DEFAULT_MENU_ITEM_TITLE, + } = extension.config; await this.ensureMenu(extension); @@ -346,7 +368,12 @@ export abstract class ContextMenuStarterBrickABC extends StarterBrickABC { + const handler = async ( + clickData: Except< + Menus.OnClickData, + "menuItemId" | "editable" | "modifiers" + >, + ): Promise => { reportEvent(Events.HANDLE_CONTEXT_MENU, selectEventData(extension)); try { @@ -400,7 +427,17 @@ export abstract class ContextMenuStarterBrickABC extends StarterBrickABC. + */ + +import { define } from "cooky-cutter"; + +export const rectFactory = define({ + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, + toJSON: jest.fn(), +}); diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 78b60dec89..35389ebd93 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -208,6 +208,14 @@ "./components/quickBar/utils.ts", "./components/selectionToolPopover/SelectionToolPopover.tsx", "./components/walkthroughModal/showWalkthroughModal.ts", + "./contentScript/tooltipDom.ts", + "./contentScript/selectionTooltip/ActionRegistry.ts", + "./contentScript/selectionTooltip/ActionRegistry.test.ts", + "./contentScript/selectionTooltip/SelectionToolbar.tsx", + "./contentScript/selectionTooltip/useActionRegistry.ts", + "./contentScript/selectionTooltip/tooltipController.tsx", + "./contentScript/selectionTooltip/tooltipController.test.ts", + "./contentScript/selectionTooltip/SelectionToolbar.stories.tsx", "./contentScript/browserActionInstantHandler.ts", "./contentScript/context.ts", "./contentScript/contextMenus.ts", @@ -308,6 +316,7 @@ "./hooks/useUpdatableAsyncState.ts", "./hooks/useUserAction.ts", "./hooks/useWindowSize.ts", + "./hooks/useDocumentSelection.ts", "./icons/Icon.tsx", "./icons/IconSelector.tsx", "./icons/constants.ts", @@ -476,6 +485,7 @@ "./testUtils/factories/selectorFactories.ts", "./testUtils/factories/sidebarEntryFactories.ts", "./testUtils/factories/stringFactories.ts", + "./testUtils/factories/domFactories.ts", "./testUtils/factories/traceFactories.ts", "./testUtils/testAfterEnv.ts", "./testUtils/testHelpers.tsx",