diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 5cf684c803..3de934cee0 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -6,6 +6,14 @@ 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. + +- 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 de431f466a..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 @@ -385,4 +385,97 @@ 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(); + }); + }); + + 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/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..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); } } } @@ -807,3 +808,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/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 d919ac7550..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 @@ -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", () => { @@ -8,4 +8,50 @@ 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", () => { + 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([]); + }); }); 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,