`;
+
+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 (
+
+
+