diff --git a/templates/content/app/components/editor/BubbleToolbar.test.tsx b/templates/content/app/components/editor/BubbleToolbar.test.tsx index 08ee68c75d..5deaa972d7 100644 --- a/templates/content/app/components/editor/BubbleToolbar.test.tsx +++ b/templates/content/app/components/editor/BubbleToolbar.test.tsx @@ -34,7 +34,15 @@ vi.mock("@/components/ui/tooltip", () => ({ TooltipTrigger: ({ children }: { children: ReactNode }) => children, })); -describe("BubbleToolbar link shortcut", () => { +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => children, + PopoverTrigger: ({ children }: { children: ReactNode }) => children, + PopoverContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +describe("BubbleToolbar", () => { let editor: Editor | null = null; let root: Root | null = null; let editorElement: HTMLDivElement | null = null; @@ -147,4 +155,158 @@ describe("BubbleToolbar link shortcut", () => { toolbarElement.querySelector('input[aria-label="editor.pasteLink"]'), ).not.toBeNull(); }); + + it("shows the active text style and converts a heading to Text by keyboard", () => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Selected heading

", + }); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + + const trigger = toolbarElement.querySelector( + 'button[aria-label="editor.slash.turnInto: editor.heading2"]', + ); + expect(trigger?.textContent).toContain("H2"); + + const textOption = [ + ...toolbarElement.querySelectorAll( + 'button[role="menuitemradio"]', + ), + ].find((button) => button.textContent?.includes("editor.slash.text")); + act(() => { + textOption!.dispatchEvent( + new MouseEvent("click", { + detail: 0, + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(editor.getHTML()).toContain("

Selected heading

"); + expect(editor.getHTML()).not.toContain("

Selected heading

"); + }); + + it.each([5, 6] as const)( + "identifies an H%s block and converts it to Text", + (level) => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: `Selected heading`, + }); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + + const trigger = toolbarElement.querySelector( + `button[aria-label="editor.slash.turnInto: editor.heading${level}"]`, + ); + expect(trigger?.textContent).toContain(`H${level}`); + + const textOption = [ + ...toolbarElement.querySelectorAll( + 'button[role="menuitemradio"]', + ), + ].find((button) => button.textContent?.includes("editor.slash.text")); + act(() => textOption!.click()); + + expect(editor.getHTML()).toContain("

Selected heading

"); + expect(editor.getHTML()).not.toContain( + `Selected heading`, + ); + }, + ); + + it("updates the selector when the active block becomes an H5", () => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Selected heading

", + }); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + act(() => editor!.commands.setHeading({ level: 5 })); + + const trigger = toolbarElement.querySelector( + 'button[aria-label="editor.slash.turnInto: editor.heading5"]', + ); + expect(trigger?.textContent).toContain("H5"); + }); + + it("sets an exact heading level without toggling the current style off", () => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Stable heading

", + }); + editor.commands.setTextSelection({ from: 1, to: 7 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + + const headingOption = [ + ...toolbarElement.querySelectorAll( + 'button[role="menuitemradio"]', + ), + ].find((button) => button.textContent?.includes("editor.heading3")); + expect(headingOption?.getAttribute("aria-checked")).toBe("true"); + act(() => headingOption!.click()); + + expect(editor.getHTML()).toContain("

Stable heading

"); + expect(editor.getHTML()).not.toContain("

Stable heading

"); + }); + + it("sets a heading from a pointer click", () => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Selected paragraph

", + }); + editor.commands.setTextSelection({ from: 1, to: 9 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + + const headingOption = [ + ...toolbarElement.querySelectorAll( + 'button[role="menuitemradio"]', + ), + ].find((button) => button.textContent?.includes("editor.heading3")); + editor.commands.blur(); + act(() => { + headingOption!.dispatchEvent( + new PointerEvent("pointerdown", { + button: 0, + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(editor.getHTML()).toContain("

Selected paragraph

"); + expect(editor.getHTML()).not.toContain("

Selected paragraph

"); + }); }); diff --git a/templates/content/app/components/editor/BubbleToolbar.tsx b/templates/content/app/components/editor/BubbleToolbar.tsx index 2cccf27e0f..2326beb9bd 100644 --- a/templates/content/app/components/editor/BubbleToolbar.tsx +++ b/templates/content/app/components/editor/BubbleToolbar.tsx @@ -1,15 +1,13 @@ import { useT } from "@agent-native/core/client/i18n"; import { IconBold, + IconCheck, + IconChevronDown, IconItalic, IconStrikethrough, IconCode, IconLink, IconMessageCircle, - IconH1, - IconH2, - IconH3, - IconH4, } from "@tabler/icons-react"; import { NodeSelection, @@ -20,8 +18,13 @@ import { import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { Editor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Tooltip, TooltipContent, @@ -56,6 +59,15 @@ type SelectionFillRange = { to: number; }; +type TextStyle = "paragraph" | 1 | 2 | 3 | 4 | 5 | 6; + +function activeTextStyle(editor: Editor): TextStyle { + for (const level of [1, 2, 3, 4, 5, 6] as const) { + if (editor.isActive("heading", { level })) return level; + } + return "paragraph"; +} + const selectionFillPluginKey = new PluginKey( "contentSelectionFill", ); @@ -106,6 +118,87 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { const t = useT(); const [showLinkInput, setShowLinkInput] = useState(false); const [linkUrl, setLinkUrl] = useState(""); + const [textStyleOpen, setTextStyleOpen] = useState(false); + const textStyleSelection = useRef<{ from: number; to: number } | null>(null); + const [textStyle, setTextStyle] = useState(() => + activeTextStyle(editor), + ); + + useEffect(() => { + const syncTextStyle = () => { + setTextStyle(activeTextStyle(editor)); + const { from, to } = editor.state.selection; + if (from !== to) textStyleSelection.current = { from, to }; + }; + editor.on("selectionUpdate", syncTextStyle); + editor.on("transaction", syncTextStyle); + syncTextStyle(); + return () => { + editor.off("selectionUpdate", syncTextStyle); + editor.off("transaction", syncTextStyle); + }; + }, [editor]); + + const textStyles = [ + { + value: "paragraph" as const, + shortLabel: t("editor.slash.text"), + menuLabel: "T", + label: t("editor.slash.text"), + }, + { + value: 1 as const, + shortLabel: "H1", + menuLabel: "H1", + label: t("editor.heading1"), + }, + { + value: 2 as const, + shortLabel: "H2", + menuLabel: "H2", + label: t("editor.heading2"), + }, + { + value: 3 as const, + shortLabel: "H3", + menuLabel: "H3", + label: t("editor.heading3"), + }, + { + value: 4 as const, + shortLabel: "H4", + menuLabel: "H4", + label: t("editor.heading4"), + }, + ]; + const selectedTextStyle = + textStyles.find((style) => style.value === textStyle) ?? + (typeof textStyle === "number" + ? { + value: textStyle, + shortLabel: `H${textStyle}`, + menuLabel: `H${textStyle}`, + label: textStyle === 5 ? t("editor.heading5") : t("editor.heading6"), + } + : textStyles[0]); + + const applyTextStyle = (style: TextStyle) => { + const chain = editor.chain(); + if (textStyleSelection.current) { + chain.setTextSelection(textStyleSelection.current); + } + if (style === "paragraph") { + if (textStyle === "paragraph") { + chain.focus().run(); + } else { + chain.toggleHeading({ level: textStyle }).focus().run(); + } + } else { + chain.setHeading({ level: style }).focus().run(); + } + setTextStyle(style); + setTextStyleOpen(false); + }; const openLinkInput = useCallback(() => { setLinkUrl(editor.getAttributes("link").href || ""); @@ -251,30 +344,7 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { isActive: () => editor.isActive("code"), }, { type: "divider" as const }, - { - icon: IconH1, - title: t("editor.heading1"), - action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), - isActive: () => editor.isActive("heading", { level: 1 }), - }, - { - icon: IconH2, - title: t("editor.heading2"), - action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), - isActive: () => editor.isActive("heading", { level: 2 }), - }, - { - icon: IconH3, - title: t("editor.heading3"), - action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), - isActive: () => editor.isActive("heading", { level: 3 }), - }, - { - icon: IconH4, - title: t("editor.heading4"), - action: () => editor.chain().focus().toggleHeading({ level: 4 }).run(), - isActive: () => editor.isActive("heading", { level: 4 }), - }, + { type: "text-style" as const }, { type: "divider" as const }, { icon: IconLink, @@ -354,7 +424,7 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { ) : (
e.preventDefault()} > {items.map((item, i) => { @@ -363,6 +433,85 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
); } + if ("type" in item && item.type === "text-style") { + return ( + { + if (open) { + const { from, to } = editor.state.selection; + if (from !== to) + textStyleSelection.current = { from, to }; + } + setTextStyleOpen(open); + }} + > + + + + event.preventDefault()} + > +
+ {t("editor.slash.turnInto")} +
+
+ {textStyles.map((style) => { + const isSelected = style.value === textStyle; + return ( + + ); + })} +
+
+
+ ); + } const { icon: Icon, title, diff --git a/templates/content/changelog/2026-08-03-choose-text-or-a-heading-from-one-compact-formatting-control.md b/templates/content/changelog/2026-08-03-choose-text-or-a-heading-from-one-compact-formatting-control.md new file mode 100644 index 0000000000..9319af1709 --- /dev/null +++ b/templates/content/changelog/2026-08-03-choose-text-or-a-heading-from-one-compact-formatting-control.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-08-03 +--- + +Choose Text or a heading from one compact formatting control. diff --git a/templates/content/docs/solutions/evidence/content-text-selector/README.md b/templates/content/docs/solutions/evidence/content-text-selector/README.md new file mode 100644 index 0000000000..a8f56b0a5c --- /dev/null +++ b/templates/content/docs/solutions/evidence/content-text-selector/README.md @@ -0,0 +1,26 @@ +# Content text selector visual evidence + +Matched desktop captures for the compact Text/H1-H4 selector in the Content editor. + +## Before + +- Source revision: `dcc028cb7d67a0f9279f9235cdc4c16710ce5689` +- Route: `http://127.0.0.1:3317/page/j4a5KAEDBHXY` +- Viewport: `1496 x 846`, desktop Chrome +- Fixture account: disposable local account `toolbar-demo@example.com` +- Fixture page: `Toolbar comparison` +- Fixture paragraph: `Select this sentence to compare the formatting toolbar.` +- UI state: Personal workspace expanded, agent panel closed, full paragraph selected, formatting bubble open, text-style submenu closed + +![Before: separate H1-H4 toolbar buttons](./before.png) + +## After + +- Source: this pull request's production build +- Route: `http://127.0.0.1:3317/page/vDisteHisXMq` +- Viewport: `1496 x 846`, desktop Chrome +- Fixture account, page title, paragraph, sidebar state, and agent-panel state match the before capture +- UI state: full paragraph selected, formatting bubble open, Text selector expanded, Text checked, H1-H4 visible +- The route differs because the isolated worktree uses its own disposable local fixture database; the rendered fixture and viewport are matched + +![After: compact Text selector with H1-H4 menu](./after.png) diff --git a/templates/content/docs/solutions/evidence/content-text-selector/after.png b/templates/content/docs/solutions/evidence/content-text-selector/after.png new file mode 100644 index 0000000000..f563cecf30 Binary files /dev/null and b/templates/content/docs/solutions/evidence/content-text-selector/after.png differ diff --git a/templates/content/docs/solutions/evidence/content-text-selector/before.png b/templates/content/docs/solutions/evidence/content-text-selector/before.png new file mode 100644 index 0000000000..74429c5835 Binary files /dev/null and b/templates/content/docs/solutions/evidence/content-text-selector/before.png differ