From c39d126233f8e5f789aa4dbf7a0037779e50197b Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 7 Sep 2026 10:59:56 -0600 Subject: [PATCH] BL-16822 Extract the canvas control menu rendering from the canvas component CanvasElementContextControls owned both the decision of which controls to show and the rendering of the menu that shows them. Inline (Word-style) images need the second without the first: the picture's menu is the canvas element menu, item for item, but the picture is not a canvas element and the canvas element manager knows nothing about it. So the rendering moves to its own module, canvasControlMenuRendering.tsx, and the canvas component calls it. No behaviour changes and no inline-image code appears here; this is the seam the rest of the stack builds on. Two small things the same need turned up: - getImageContainer treated an element whose img is a direct child, with no bloom-imageContainer, as having no container. That is the shape of a legacy image and of an inline image, and it is already the fallback buildCanvasElementControlRegistryContext uses for hasImage, so the two now agree. - IControlContext gains an optional deleteThisObject, for an object that is not a canvas element and whose deletion means more than removing one element. Left out, Delete removes the page's active canvas element as it always has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014DCBGajN5YyAYPBenEf1yy --- .../CanvasElementContextControls.tsx | 343 ++++-------------- .../canvasControlMenuRendering.tsx | 268 ++++++++++++++ .../toolbox/canvas/canvasControlRegistry.ts | 17 +- .../toolbox/canvas/canvasControlTypes.ts | 6 + 4 files changed, 352 insertions(+), 282 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/canvasElementManager/canvasControlMenuRendering.tsx diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx index 0ff4d839fb98..8ddc7d813f2d 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx @@ -7,14 +7,7 @@ import { kBloomBlue, lightTheme } from "../../../bloomMaterialUITheme"; import { SvgIconProps } from "@mui/material"; import { default as MenuIcon } from "@mui/icons-material/MoreHorizSharp"; import { ThemeProvider } from "@mui/material/styles"; -import { - divider, - ILocalizableMenuItemProps, - LocalizableMenuItem, - LocalizableNestedMenuItem, -} from "../../../react_components/localizableMenuItem"; import Menu from "@mui/material/Menu"; -import { Divider } from "@mui/material"; import { getCanvasElementManager } from "../../toolbox/canvas/canvasElementPageBridge"; import { kBackgroundImageClass } from "../../toolbox/canvas/canvasElementConstants"; import { BloomTooltip } from "../../../react_components/BloomToolTip"; @@ -27,6 +20,7 @@ import { getAudioSentencesOfVisibleEditables } from "bloom-player"; import { canvasElementControlRegistry } from "../../toolbox/canvas/canvasElementControlRegistry"; import { buildCanvasElementControlRegistryContext } from "../../toolbox/canvas/buildCanvasElementControlRegistryContext"; import { + ICanvasElementControlConfiguration, IControlContext, ILanguageNameValues, IControlMenuRow, @@ -36,17 +30,46 @@ import { getMenuSections, getToolbarItems, } from "../../toolbox/canvas/canvasControlResolution"; - -interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { - subMenu?: ILocalizableMenuItemProps[]; -} +import { + contextMenuCss, + convertControlMenuRows, + IMenuItemWithSubmenu, + joinMenuSectionsWithSingleDividers, + renderContextMenuItems, + runControlCallback, + scaleIconNode, +} from "./canvasControlMenuRendering"; // This is the controls bar that appears beneath a canvas element when it is selected. It contains buttons // for the most common operations that apply to the canvas element in its current state, and a menu for less common // operations. -const CanvasElementContextControls: React.FunctionComponent<{ +/** + * What a caller must supply to put this bar on something that is not a canvas element. An + * inline image -- a picture inside a text block -- passes this, so that a picture offers the + * same toolbar wherever the user meets one (see inlineImageInteractions.ts). + */ +export interface IControlsForNonCanvasObject { + // Which controls the bar and the menu offer, in place of the canvas element registry's + // entry for the element's type. + configuration: ICanvasElementControlConfiguration; + // The menu behind the "..." button, already built. + menuItems: IMenuItemWithSubmenu[]; + // Merged over the context the registry commands run in. This is where an object says + // how to delete itself (IControlContext.deleteThisObject). + contextAdditions: Partial; + // Run after a toolbar command's action, for the buttons this component builds itself. A + // control that supplies its own toolbar.render owns its interactions and does not go + // through here -- nothing in the inline-image toolbar does, but a control added there + // later would need to call this itself. An inline image uses it to stamp what the command + // did onto the copies in the other languages. + afterToolbarCommand?: () => void; +} + +export const CanvasElementContextControls: React.FunctionComponent<{ canvasElement: HTMLElement; + // Left out for a canvas element, which is what this bar was written for. + controlsForNonCanvasObject?: IControlsForNonCanvasObject; // These props support reusing the context controls menu for a right-click on the canvas element. // The first two make the open state of the menu a controlled property. Basically the // parent stores the state and passes it in, but to get the normal behavior of @@ -225,11 +248,13 @@ const CanvasElementContextControls: React.FunctionComponent<{ onClick: () => void; iconScale?: number; disabled?: boolean; + testId?: string; }): IToolbarItem => { return { key: props.key, node: ( void | Promise, - ): void => { - try { - const result = callback(); - if (result) { - void result.catch((error) => { - console.error( - `Canvas control callback failed (${callbackLabel})`, - error, - ); - }); - } - } catch (error) { - console.error( - `Canvas control callback failed (${callbackLabel})`, - error, - ); - } - }; - const getSpacerToolbarItem = (index: number): IToolbarItem => { return { key: `spacer-${index}`, @@ -297,76 +295,6 @@ const CanvasElementContextControls: React.FunctionComponent<{ let toolbarItems: IToolbarItem[] = []; - const convertControlMenuRows = ( - rows: IControlMenuRow[], - controlContext: IControlContext, - controlRuntime: IControlRuntime, - ): IMenuItemWithSubmenu[] => { - const convertedRows: IMenuItemWithSubmenu[] = []; - - rows.forEach((row) => { - if (row.separatorAbove && convertedRows.length > 0) { - convertedRows.push(divider as IMenuItemWithSubmenu); - } - - const convertedSubMenu = row.subMenuItems - ? convertControlMenuRows( - row.subMenuItems, - controlContext, - controlRuntime, - ) - : undefined; - - const convertedRow: IMenuItemWithSubmenu = { - l10nId: row.l10nId ?? null, - english: row.englishLabel ?? "", - subLabelL10nId: row.subLabelL10nId, - generatedSubLabel: row.subLabel, - shortcutDisplay: row.shortcut?.display, - icon: scaleIconNode(row.icon, row.iconScale), - disabled: row.disabled, - featureName: row.featureName, - subscriptionTooltipOverride: row.subscriptionTooltipOverride, - onClick: () => { - // Ordinary leaf commands close centrally here. Registry - // handlers only call runtime.closeMenu(...) for special - // cases such as dialog launches or submenu-specific focus - // behavior. - if (!convertedSubMenu) { - controlRuntime.closeMenu(); - } - runControlCallback( - `menu:${row.id ?? row.englishLabel ?? "unknown"}`, - () => row.onSelect(controlContext, controlRuntime), - ); - }, - }; - - if (convertedSubMenu) { - convertedRow.subMenu = convertedSubMenu; - } - - convertedRows.push(convertedRow); - - if (row.helpRowL10nId || row.helpRowEnglish) { - if (row.helpRowSeparatorAbove && convertedRows.length > 0) { - convertedRows.push(divider as IMenuItemWithSubmenu); - } - - convertedRows.push({ - l10nId: null, - english: "", - subLabelL10nId: row.helpRowL10nId, - generatedSubLabel: row.helpRowEnglish, - onClick: () => {}, - disabled: true, - }); - } - }); - - return convertedRows; - }; - const getToolbarItemForResolvedControl = ( item: ReturnType[number], index: number, @@ -394,11 +322,12 @@ const CanvasElementContextControls: React.FunctionComponent<{ const icon = control.toolbar?.icon ?? control.icon; const iconScale = control.toolbar?.iconScale ?? control.iconScale; const onClick = () => { - runControlCallback(`toolbar:${control.id}`, () => - control.action(controlContext, { + runControlCallback(`toolbar:${control.id}`, async () => { + await control.action(controlContext, { closeMenu: () => {}, - }), - ); + }); + props.controlsForNonCanvasObject?.afterToolbarCommand?.(); + }); }; if (typeof icon === "function") { @@ -409,6 +338,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ onClick, iconScale: iconScale ?? 1, disabled: !item.enabled, + testId: `toolbar-${control.id}`, }); } @@ -432,6 +362,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ }} >