From b17336615e6db33b6d18963ef7451438faa6d8e0 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 23 Oct 2026 10:10:46 +0200 Subject: [PATCH 1/2] fix(rich-text-web): show the table toolbar with the advanced custom toolbar The advanced custom toolbar regroups buttons under generated group names, so the table operations toolbar never matched its "tableBetter" parent group. Match secondary groups by the parent group's buttons instead. Co-Authored-By: Claude Opus 5.5 --- .../rich-text-web/CHANGELOG.md | 4 ++ .../src/__tests__/RichText.runtime.spec.tsx | 54 +++++++++++++++++++ .../src/components/toolbars/Toolbar.tsx | 10 +--- .../src/components/toolbars/ToolbarConfig.ts | 15 ++++++ .../helpers/__tests__/ToolbarConfig.spec.ts | 23 +++++++- 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 5cf684c803..863d84278e 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where the table toolbar did not appear when the cursor was in a table and the custom toolbar was set to "Advanced" with a "Table" item. + ## [5.1.1] - 2026-10-15 ### Fixed diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx index de431f466a..5ab36df542 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx @@ -385,4 +385,58 @@ describe("Rich Text runtime behaviour", () => { expect(container.querySelector(".tiptap-toolbar") !== null).toBe(hasToolbar); }); }); + + describe("table toolbar", () => { + const tableValue = "

Cell

"; + + function renderWithCaretInTable(props: Partial): RenderResult { + const result = render( + ().withValue(tableValue).build()} + /> + ); + const editor = getEditor(result.container); + let cellTextPos = -1; + editor.state.doc.descendants((node, pos) => { + if (cellTextPos === -1 && node.isText) { + cellTextPos = pos; + } + }); + act(() => { + editor.commands.setTextSelection(cellTextPos); + }); + return result; + } + + function tableConfigurationButton(container: HTMLElement): Element | null { + return container.querySelector('button[title="Table Configuration"]'); + } + + it("shows the table toolbar with the basic custom toolbar when the table group is enabled", () => { + const { container } = renderWithCaretInTable({ toolbarConfig: "basic", tableBetter: true }); + + expect(tableConfigurationButton(container)).toBeInTheDocument(); + }); + + it("shows the table toolbar with the advanced custom toolbar when it contains the table item", () => { + const { container } = renderWithCaretInTable({ + toolbarConfig: "advanced", + advancedConfig: [{ ctItemType: "bold" }, { ctItemType: "separator" }, { ctItemType: "tableBetter" }] + }); + + expect(tableConfigurationButton(container)).toBeInTheDocument(); + }); + + it("hides the table toolbar with the advanced custom toolbar when it has no table item", () => { + const { container } = renderWithCaretInTable({ + toolbarConfig: "advanced", + advancedConfig: [{ ctItemType: "bold" }] + }); + + expect(tableConfigurationButton(container)).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx index e0785f741f..0f05c5cc3b 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx @@ -11,12 +11,12 @@ import { HelpButton } from "./components/HelpButton"; import { TableGridToolbarButton } from "./components/TableGrid"; import { ToolbarButton } from "./components/ToolbarButton"; import { - SECONDARY_TOOLBAR_GROUP, ToolbarContext, DropdownCommand, ToolbarGroupConfig, ToolbarButtonConfig, getFilteredToolbarGroups, + getSecondaryToolbarGroups, ToolbarGroupsConfig, TOOLBAR_GROUPS } from "./ToolbarConfig"; @@ -178,13 +178,7 @@ export default function Toolbar(props: ToolbarProps): ReactElement | null { // (preset "full", or custom with every group enabled). const showHelpButton = helpButton !== false && filteredGroups.length === TOOLBAR_GROUPS.length; - const filteredSecondaryGroups = useMemo( - () => - SECONDARY_TOOLBAR_GROUP.filter(group => - filteredGroups.some(filteredGroup => filteredGroup.name === group.parentName) - ), - [filteredGroups] - ); + const filteredSecondaryGroups = useMemo(() => getSecondaryToolbarGroups(filteredGroups), [filteredGroups]); useEffect(() => { if (!editor) { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts index fb0de8aa2e..e0d5eed1df 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts @@ -807,3 +807,18 @@ export function getFilteredToolbarGroups( return enhancedToolbarGroups(filteredGroups, customFonts); } + +/** + * Get the secondary toolbar groups that belong to the given toolbar groups. + * A secondary group is included when any button of its parent group is present. Buttons are + * matched by name because the advanced custom toolbar regroups them under generated group names. + * @param groups - Filtered toolbar groups + * @returns Secondary toolbar groups to render + */ +export function getSecondaryToolbarGroups(groups: ToolbarGroupConfig[]): ToolbarGroupConfig[] { + const buttonNames = new Set(groups.flatMap(group => group.buttons.map(button => button.name))); + return SECONDARY_TOOLBAR_GROUP.filter(secondaryGroup => { + const parentGroup = TOOLBAR_GROUPS.find(group => group.name === secondaryGroup.parentName); + return parentGroup?.buttons.some(button => buttonNames.has(button.name)) ?? false; + }); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts index d919ac7550..db117e08d9 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts @@ -1,4 +1,4 @@ -import { buildAdvancedToolbar } from "../../ToolbarConfig"; +import { buildAdvancedToolbar, getFilteredToolbarGroups, getSecondaryToolbarGroups } from "../../ToolbarConfig"; describe("buildAdvancedToolbar", () => { it("maps the header item to the text format dropdown", () => { @@ -9,3 +9,24 @@ describe("buildAdvancedToolbar", () => { expect(groups[0].buttons[0].name).toBe("textFormat"); }); }); + +describe("getSecondaryToolbarGroups", () => { + it("includes the table operations for a preset that has the table group", () => { + const groups = getSecondaryToolbarGroups(getFilteredToolbarGroups("full")); + + expect(groups.map(group => group.name)).toEqual(["table-operations"]); + }); + + it("includes the table operations for an advanced toolbar with the table item in any group", () => { + const groups = getSecondaryToolbarGroups( + buildAdvancedToolbar([{ ctItemType: "bold" }, { ctItemType: "separator" }, { ctItemType: "tableBetter" }]) + ); + + expect(groups.map(group => group.name)).toEqual(["table-operations"]); + }); + + it("excludes the table operations when no group has the table button", () => { + expect(getSecondaryToolbarGroups(getFilteredToolbarGroups("basic"))).toEqual([]); + expect(getSecondaryToolbarGroups(buildAdvancedToolbar([{ ctItemType: "bold" }]))).toEqual([]); + }); +}); From 2f6ae332f7557454de6dc7d6ec925650f51ce220 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 3 Nov 2026 14:52:58 +0100 Subject: [PATCH 2/2] fix(rich-text-web): open toolbar popovers once and close them on escape Show each advanced toolbar item once across all groups, so one click opens a single picker or dialog. Close the insert table picker and the inline insert link, image and video dialogs on Escape. Co-Authored-By: Claude Opus 5.5 --- .../rich-text-web/CHANGELOG.md | 4 ++ .../src/__tests__/RichText.runtime.spec.tsx | 39 +++++++++++++++++++ .../src/components/toolbars/ToolbarConfig.ts | 11 +++--- .../toolbars/components/DialogShell.tsx | 6 ++- .../toolbars/components/TableGridSelector.tsx | 3 +- .../components/__tests__/DialogShell.spec.tsx | 23 +++++++++++ .../helpers/__tests__/ToolbarConfig.spec.ts | 25 ++++++++++++ .../components/toolbars/hooks/useDropdown.ts | 25 +++++++++++- 8 files changed, 127 insertions(+), 9 deletions(-) diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 863d84278e..3de934cee0 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where the table toolbar did not appear when the cursor was in a table and the custom toolbar was set to "Advanced" with a "Table" item. +- We fixed an issue where an item added more than once to the "Advanced" custom toolbar was shown more than once, and clicking one of its buttons, such as "Table" or "Link", opened a picker or dialog under every copy. Each item is now shown once. + +- We fixed an issue where pressing Escape did not close the insert table picker, or the insert link, image and video dialogs when the dialog style was "Inline". + ## [5.1.1] - 2026-10-15 ### Fixed diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx index 5ab36df542..28ebb59b7c 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx @@ -439,4 +439,43 @@ describe("Rich Text runtime behaviour", () => { expect(tableConfigurationButton(container)).not.toBeInTheDocument(); }); }); + + describe("table grid picker", () => { + function renderAdvancedToolbar(advancedConfig: RichTextContainerProps["advancedConfig"]): RenderResult { + return render( + + ); + } + + function insertTableButtons(container: HTMLElement): NodeListOf { + return container.querySelectorAll('button[title="Insert Table"]'); + } + + it("opens one picker when the table item is added to more than one group", () => { + const { container } = renderAdvancedToolbar([ + { ctItemType: "tableBetter" }, + { ctItemType: "separator" }, + { ctItemType: "tableBetter" } + ]); + + expect(insertTableButtons(container)).toHaveLength(1); + fireEvent.click(insertTableButtons(container)[0]); + + expect(container.querySelectorAll(".table-grid-selector")).toHaveLength(1); + }); + + it("closes on Escape without the Escape reaching the editor", () => { + const { container } = renderAdvancedToolbar([{ ctItemType: "tableBetter" }]); + const editorKeyDown = jest.fn(); + getEditorDom(container).addEventListener("keydown", editorKeyDown); + + fireEvent.click(insertTableButtons(container)[0]); + expect(container.querySelector(".table-grid-selector")).toBeInTheDocument(); + + fireEvent.keyDown(getEditorDom(container), { key: "Escape" }); + + expect(container.querySelector(".table-grid-selector")).not.toBeInTheDocument(); + expect(editorKeyDown).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts index e0d5eed1df..f95c65f067 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts @@ -705,6 +705,7 @@ function findButtonByName(buttonName: string): ToolbarButtonConfig | null { */ export function buildAdvancedToolbar(advancedConfig: AdvancedConfigType[]): ToolbarGroupConfig[] { const groups: ToolbarGroupConfig[] = []; + const addedButtonNames = new Set(); let currentGroup: ToolbarButtonConfig[] = []; for (const item of advancedConfig) { @@ -723,11 +724,11 @@ export function buildAdvancedToolbar(advancedConfig: AdvancedConfigType[]): Tool const buttonName = CT_ITEM_TO_BUTTON_MAP[item.ctItemType]; if (buttonName) { const button = findButtonByName(buttonName); - if (button) { - // Avoid duplicate buttons in the same group - if (!currentGroup.find(b => b.name === button.name)) { - currentGroup.push(button); - } + // Add each button once across the whole toolbar: copies of a button share one open + // state, so clicking one would open the dropdown or dialog under every copy. + if (button && !addedButtonNames.has(button.name)) { + addedButtonNames.add(button.name); + currentGroup.push(button); } } } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx index 09728c27bf..4c9e0f0ac1 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx @@ -62,7 +62,8 @@ export function DialogShell({ const isInline = mode === "inline"; // Inline positioning. `trackAvailableHeight` reports the room left at the resolved placement so - // the dialog shrinks to fit instead of overflowing the viewport. + // the dialog shrinks to fit instead of overflowing the viewport. Inline Escape is handled by + // `useDropdown`; focused mode handles it below. const { refs: inlineRefs, floatingStyles, @@ -71,7 +72,8 @@ export function DialogShell({ isOpen: isInline, onClose, referenceElement, - trackAvailableHeight: true + trackAvailableHeight: true, + closeOnEscape: true }); // Focused mode interactions. Hooks run in both modes — React requires an unconditional call — diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/TableGridSelector.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/TableGridSelector.tsx index bdcd6010f5..fb4b0c087b 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/TableGridSelector.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/TableGridSelector.tsx @@ -11,7 +11,8 @@ export function TableGridSelector({ editor, onClose, referenceElement }: TableGr const { refs, floatingStyles } = useDropdown({ isOpen: true, onClose, - referenceElement + referenceElement, + closeOnEscape: true }); const handleCellHover = (row: number, col: number): void => { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx index d18f8e0fd7..285eb5f794 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx @@ -43,6 +43,29 @@ describe("DialogShell inline mode", () => { expect(onClose).not.toHaveBeenCalled(); }); + + it("closes on Escape", () => { + const { onClose } = renderShell("inline"); + + fireEvent.keyDown(screen.getByRole("button", { name: "Close" }), { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("stops Escape before it reaches other listeners", () => { + const spy = jest.fn(); + document.body.addEventListener("keydown", spy, true); + + try { + const { onClose } = renderShell("inline"); + fireEvent.keyDown(screen.getByRole("button", { name: "Close" }), { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalled(); + } finally { + document.body.removeEventListener("keydown", spy, true); + } + }); }); describe("DialogShell focused mode", () => { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts index db117e08d9..d8be868c4a 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts @@ -8,6 +8,31 @@ describe("buildAdvancedToolbar", () => { expect(groups[0].buttons).toHaveLength(1); expect(groups[0].buttons[0].name).toBe("textFormat"); }); + + it("shows an item only once when it is added to more than one group", () => { + const groups = buildAdvancedToolbar([ + { ctItemType: "bold" }, + { ctItemType: "tableBetter" }, + { ctItemType: "separator" }, + { ctItemType: "tableBetter" }, + { ctItemType: "italic" } + ]); + + expect(groups.map(group => group.buttons.map(button => button.name))).toEqual([ + ["bold", "insertTable"], + ["italic"] + ]); + }); + + it("drops a group whose items are all shown in an earlier group", () => { + const groups = buildAdvancedToolbar([ + { ctItemType: "link" }, + { ctItemType: "separator" }, + { ctItemType: "link" } + ]); + + expect(groups.map(group => group.buttons.map(button => button.name))).toEqual([["insertLink"]]); + }); }); describe("getSecondaryToolbarGroups", () => { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts index 6202d5ba95..7daab34a57 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts @@ -20,6 +20,12 @@ export interface UseDropdownOptions { * themselves in CSS and must keep their current behaviour. */ trackAvailableHeight?: boolean; + /** + * Close on Escape. The key is caught in the capture phase and stopped there, so it does not also + * reach the editor, whose Fullscreen extension exits fullscreen on Escape. Off by default: some + * popovers handle Escape themselves, for example to discard a draft value. + */ + closeOnEscape?: boolean; } export interface UseDropdownReturn { @@ -51,7 +57,8 @@ export function useDropdown({ placement = "bottom-start", offsetValue = 4, referenceElement, - trackAvailableHeight = false + trackAvailableHeight = false, + closeOnEscape = false }: UseDropdownOptions): UseDropdownReturn { const ignoreClickRef = useRef(null); const [availableHeight, setAvailableHeight] = useState(undefined); @@ -116,6 +123,22 @@ export function useDropdown({ }; }, [isOpen, onClose, refs.floating, refs.reference]); + useEffect(() => { + if (!isOpen || !closeOnEscape) return; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onClose(); + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isOpen, closeOnEscape, onClose]); + return { refs: { reference: refs.reference as RefObject,