Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/pluggableWidgets/rich-text-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,4 +385,97 @@ describe("Rich Text runtime behaviour", () => {
expect(container.querySelector(".tiptap-toolbar") !== null).toBe(hasToolbar);
});
});

describe("table toolbar", () => {
const tableValue = "<table><tbody><tr><td><p>Cell</p></td></tr></tbody></table>";

function renderWithCaretInTable(props: Partial<RichTextContainerProps>): RenderResult {
const result = render(
<RichText
{...defaultProps}
{...props}
preset="custom"
stringAttribute={new EditableValueBuilder<string>().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(
<RichText {...defaultProps} preset="custom" toolbarConfig="advanced" advancedConfig={advancedConfig} />
);
}

function insertTableButtons(container: HTMLElement): NodeListOf<HTMLButtonElement> {
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
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";
Expand Down Expand Up @@ -83,7 +83,7 @@
function ToolbarGroup(props: ToolbarGroupProps): ReactElement | null {
const { toolbar, ...rest } = props;
const { editor } = useCurrentEditor();
const [, setSelectionUpdate] = useState(0);

Check warning on line 86 in packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

useState should be destructured into a value and setter pair, e.g., const [state, setState] = useState(...)
const shouldRenderGroup = !toolbar.showWhen || (editor ? toolbar.showWhen(editor) : false);

// Force re-render when editor selection changes
Expand Down Expand Up @@ -178,13 +178,7 @@
// (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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ function findButtonByName(buttonName: string): ToolbarButtonConfig | null {
*/
export function buildAdvancedToolbar(advancedConfig: AdvancedConfigType[]): ToolbarGroupConfig[] {
const groups: ToolbarGroupConfig[] = [];
const addedButtonNames = new Set<string>();
let currentGroup: ToolbarButtonConfig[] = [];

for (const item of advancedConfig) {
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,7 +57,8 @@ export function useDropdown({
placement = "bottom-start",
offsetValue = 4,
referenceElement,
trackAvailableHeight = false
trackAvailableHeight = false,
closeOnEscape = false
}: UseDropdownOptions): UseDropdownReturn {
const ignoreClickRef = useRef<HTMLElement | null>(null);
const [availableHeight, setAvailableHeight] = useState<number | undefined>(undefined);
Expand Down Expand Up @@ -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<HTMLElement | null>,
Expand Down
Loading