From c0fc612c8bd94d2085e14cba003a936ddb691653 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 26 Jan 2026 13:59:38 -0700 Subject: [PATCH] Refactor canvas elements: registry-driven controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CanvasElementManager had grown too large and UI affordances (context menu + mini toolbar) were being assembled imperatively, which made ordering/section dividers hard to reason about and encouraged cross-bundle imports. This change introduces a declarative canvas element registry that drives which buttons and menus are available per element type. It also makes context menu/mini-toolbar composition deterministic: fixed section ordering, exactly one divider/spacer between non-empty sections, and Duplicate/Delete always last. To reduce runtime import-cycle risk across the edit view + toolbox bundles, DOM selectors/constants move to a dependency-light module (canvasElementConstants) while canvasElementUtils is narrowed to a cross-frame bridge (getCanvasElementManager) with type-only imports. CanvasElementManager is partially decomposed into focused helper modules (Geometry/Positioning/Alternates) plus public-function wrappers, and related call sites were updated. Misc hardening: safer MUI Menu anchoring, avoid non-null assertions, fix closest() selector typo, and remove duplicate pxToNumber helper. Follow-ups in this series: - Make mini-toolbar + menu more declarative and consistent - Make `toolbarButtons` the sole source of truth for the mini-toolbar (including explicit spacers) and normalize spacer runs. - Share menu + toolbar definitions via a single command registry to keep icons/tooltips/click behavior in sync. - Replace “Set Up Hyperlink” with the “Set Destination” command in this context, and do not show either on simple image elements. --- .../bookEdit/StyleEditor/StyleEditor.ts | 2 +- src/BloomBrowserUI/bookEdit/editViewFrame.ts | 2 +- .../js/CanvasElementContextControls.tsx | 1221 ++++++++++------- .../js/CanvasElementKeyboardProvider.ts | 2 +- .../bookEdit/js/CanvasElementManager.ts | 425 ++---- .../js/CanvasElementManagerPublicFunctions.ts | 43 + .../bookEdit/js/CanvasGuideProvider.ts | 2 +- .../bookEdit/js/bloomEditing.ts | 4 +- src/BloomBrowserUI/bookEdit/js/bloomFrames.ts | 6 +- src/BloomBrowserUI/bookEdit/js/bloomImages.ts | 18 +- src/BloomBrowserUI/bookEdit/js/bloomVideo.ts | 2 +- .../CanvasElementAlternates.ts | 30 + .../CanvasElementGeometry.ts | 172 +++ .../CanvasElementPositioning.ts | 101 ++ src/BloomBrowserUI/bookEdit/js/origami.ts | 2 +- src/BloomBrowserUI/bookEdit/js/videoUtils.ts | 6 +- .../toolbox/canvas/CanvasElementItem.tsx | 16 +- .../bookEdit/toolbox/canvas/README.md | 174 +++ .../toolbox/canvas/canvasElementConstants.ts | 14 + .../toolbox/canvas/canvasElementCssUtils.ts | 26 + .../canvas/canvasElementDefinitions.ts | 128 ++ .../toolbox/canvas/canvasElementDomUtils.ts | 18 + .../toolbox/canvas/canvasElementDraggables.ts | 13 + .../canvas/canvasElementTypeInference.ts | 59 + .../toolbox/canvas/canvasElementTypes.ts | 17 + .../toolbox/canvas/canvasElementUtils.ts | 39 +- .../toolbox/games/GamePromptDialog.tsx | 37 +- .../bookEdit/toolbox/games/GameTool.tsx | 42 +- .../bookEdit/toolbox/games/gameUtilities.tsx | 5 +- .../imageDescription/imageDescription.tsx | 6 +- .../imageDescription/imageDescriptionUtils.ts | 6 +- .../impairmentVisualizer.tsx | 6 +- .../bookEdit/toolbox/motion/motionTool.tsx | 6 +- .../toolbox/talkingBook/talkingBook.ts | 2 +- .../lib/split-pane/split-pane.ts | 2 +- .../pageChooser/PageChooserDialog.tsx | 2 +- 36 files changed, 1709 insertions(+), 947 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/CanvasElementManagerPublicFunctions.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementGeometry.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementPositioning.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/README.md create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementConstants.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementCssUtils.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementDefinitions.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementDomUtils.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementDraggables.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementTypeInference.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementTypes.ts diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts index 77f260cd9971..e98b2f5c63f0 100644 --- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts +++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts @@ -41,7 +41,7 @@ import { kBloomYellow } from "../../bloomMaterialUITheme"; import { RenderRoot } from "./AudioHilitePage"; import { RenderCanvasElementRoot } from "./CanvasElementFormatPage"; import { CanvasElementManager } from "../js/CanvasElementManager"; -import { kCanvasElementSelector } from "../toolbox/canvas/canvasElementUtils"; +import { kCanvasElementSelector } from "../toolbox/canvas/canvasElementConstants"; import { getPageIFrame } from "../../utils/shared"; // Controls the CSS text-align value diff --git a/src/BloomBrowserUI/bookEdit/editViewFrame.ts b/src/BloomBrowserUI/bookEdit/editViewFrame.ts index 0c39afffb399..670d6ee19cef 100644 --- a/src/BloomBrowserUI/bookEdit/editViewFrame.ts +++ b/src/BloomBrowserUI/bookEdit/editViewFrame.ts @@ -61,7 +61,7 @@ export { showRegistrationDialogForEditTab as showRegistrationDialog }; import { showAboutDialog } from "../react_components/aboutDialog"; export { showAboutDialog }; import { reportError } from "../lib/errorHandler"; -import { IToolboxFrameExports } from "./toolbox/toolboxBootstrap"; +import type { IToolboxFrameExports } from "./toolbox/toolboxBootstrap"; import { showCopyrightAndLicenseInfoOrDialog } from "./copyrightAndLicense/CopyrightAndLicenseDialog"; import { showTopicChooserDialog } from "./TopicChooser/TopicChooserDialog"; import * as ReactDOM from "react-dom"; diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx index f74678ae3d05..172e360103e8 100644 --- a/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx +++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx @@ -1,7 +1,7 @@ import { css } from "@emotion/react"; import * as React from "react"; -import { useState, useEffect, Fragment, useRef } from "react"; +import { useState, useEffect, useRef } from "react"; import * as ReactDOM from "react-dom"; import { kBloomBlue, lightTheme } from "../../bloomMaterialUITheme"; import { SvgIconProps } from "@mui/material"; @@ -46,13 +46,15 @@ import { import Menu from "@mui/material/Menu"; import { Divider } from "@mui/material"; import { DuplicateIcon } from "./DuplicateIcon"; +import { getCanvasElementManager } from "../toolbox/canvas/canvasElementUtils"; import { - CanvasElementManager, - isDraggable, kBackgroundImageClass, + kBloomButtonClass, +} from "../toolbox/canvas/canvasElementConstants"; +import { + isDraggable, kDraggableIdAttribute, - theOneCanvasElementManager, -} from "./CanvasElementManager"; +} from "../toolbox/canvas/canvasElementDraggables"; import { copySelection, GetEditor, pasteClipboard } from "./bloomEditing"; import { BloomTooltip } from "../../react_components/BloomToolTip"; import { useL10n } from "../../react_components/l10nHooks"; @@ -60,14 +62,19 @@ import { CogIcon } from "./CogIcon"; import { MissingMetadataIcon } from "./MissingMetadataIcon"; import { FillSpaceIcon } from "./FillSpaceIcon"; import { kBloomDisabledOpacity } from "../../utils/colorUtils"; -import { Span } from "../../react_components/l10nComponents"; import AudioRecording from "../toolbox/talkingBook/audioRecording"; import { getAudioSentencesOfVisibleEditables } from "bloom-player"; import { GameType, getGameType } from "../toolbox/games/GameInfo"; import { setGeneratedDraggableId } from "../toolbox/canvas/CanvasElementItem"; import { editLinkGrid } from "./linkGrid"; import { showLinkTargetChooserDialog } from "../../react_components/LinkTargetChooser/LinkTargetChooserDialogLauncher"; -import { kBloomButtonClass } from "../toolbox/canvas/canvasElementUtils"; +import { CanvasElementType } from "../toolbox/canvas/canvasElementTypes"; +import { + CanvasElementMenuSection, + CanvasElementToolbarButton, + canvasElementDefinitions, +} from "../toolbox/canvas/canvasElementDefinitions"; +import { inferCanvasElementType } from "../toolbox/canvas/canvasElementTypeInference"; interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { subMenu?: ILocalizableMenuItemProps[]; @@ -76,9 +83,9 @@ interface IMenuItemWithSubmenu extends ILocalizableMenuItemProps { // These names are not quite consistent, but the behaviors we want to control are currently // specific to navigation buttons, while the class name is meant to cover buttons in general. // Eventually we may need a way to distinguish buttons used for navigation from other buttons. -function isNavigationButton(canvasElement: HTMLElement) { - return canvasElement.classList.contains(kBloomButtonClass); -} +const isNavigationButtonType = ( + canvasElementType: CanvasElementType, +): boolean => canvasElementType.startsWith("navigation-"); // 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 @@ -96,16 +103,54 @@ const CanvasElementContextControls: React.FunctionComponent<{ setMenuOpen: (open: boolean) => void; menuAnchorPosition?: { left: number; top: number }; }> = (props) => { + const canvasElementManager = getCanvasElementManager(); + const imgContainer = props.canvasElement.getElementsByClassName(kImageContainerClass)[0]; const hasImage = !!imgContainer; const hasText = props.canvasElement.getElementsByClassName("bloom-editable").length > 0; + const editable = props.canvasElement.getElementsByClassName( + "bloom-editable bloom-visibility-code-on", + )[0] as HTMLElement | undefined; + const langName = editable?.getAttribute("data-languagetipcontent"); const linkGrid = props.canvasElement.getElementsByClassName( "bloom-link-grid", )[0] as HTMLElement | undefined; const isLinkGrid = !!linkGrid; - const isNavButton = isNavigationButton(props.canvasElement); + const inferredCanvasElementType = inferCanvasElementType( + props.canvasElement, + ); + if (!inferredCanvasElementType) { + const canvasElementId = props.canvasElement.getAttribute("id"); + const canvasElementClasses = props.canvasElement.getAttribute("class"); + throw new Error( + `inferCanvasElementType() returned undefined for a selected canvas element${canvasElementId ? ` id='${canvasElementId}'` : ""}${canvasElementClasses ? ` (class='${canvasElementClasses}')` : ""}.`, + ); + } + + if ( + !Object.prototype.hasOwnProperty.call( + canvasElementDefinitions, + inferredCanvasElementType, + ) + ) { + throw new Error( + `Canvas element type '${inferredCanvasElementType}' is not registered in canvasElementDefinitions.`, + ); + } + + const canvasElementType: CanvasElementType = inferredCanvasElementType; + const isNavButton = isNavigationButtonType(canvasElementType); + + const allowedMenuSections = new Set( + canvasElementDefinitions[canvasElementType].menuSections, + ); + const isMenuSectionAllowed = ( + section: CanvasElementMenuSection, + ): boolean => { + return allowedMenuSections.has(section); + }; const rectangles = props.canvasElement.getElementsByClassName("bloom-rectangle"); // This is only used by the menu option that toggles it. If the menu stayed up, we would need a state @@ -121,9 +166,6 @@ const CanvasElementContextControls: React.FunctionComponent<{ "bloom-videoContainer", )[0]; const hasVideo = !!videoContainer; - const video = videoContainer?.getElementsByTagName("video")[0]; - const videoSource = video?.getElementsByTagName("source")[0]; - const videoAlreadyChosen = !!videoSource?.getAttribute("src"); const isPlaceHolder = hasImage && isPlaceHolderImage(img?.getAttribute("src")); const missingMetadata = @@ -136,7 +178,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ // or some other code somewhere is doing it when we choose a menu item. So we tell the CanvasElementManager // to ignore focus changes while the menu is open. if (open) { - CanvasElementManager.ignoreFocusChanges = true; + canvasElementManager?.setIgnoreFocusChanges?.(true); } props.setMenuOpen(open); // Setting ignoreFocusChanges to false immediately after closing the menu doesn't work, @@ -146,14 +188,15 @@ const CanvasElementContextControls: React.FunctionComponent<{ // a dialog opened by the menu command closes. See BL-14123. if (!open) { setTimeout(() => { - if (launchingDialog) - CanvasElementManager.skipNextFocusChange = true; - CanvasElementManager.ignoreFocusChanges = false; + canvasElementManager?.setIgnoreFocusChanges?.( + false, + launchingDialog, + ); }, 0); } }; - const menuEl = useRef(); + const menuEl = useRef(null); const noneLabel = useL10n("None", "EditTab.Toolbox.DragActivity.None", ""); const aRecordingLabel = useL10n("A Recording", "ARecording", ""); @@ -169,7 +212,9 @@ const CanvasElementContextControls: React.FunctionComponent<{ HTMLElement | undefined >(); // After deleting a draggable, we may get rendered again, and page will be null. - const page = props.canvasElement.closest(".bloom-page") as HTMLElement; + const page = props.canvasElement.closest( + ".bloom-page", + ) as HTMLElement | null; useEffect(() => { if (!currentDraggableTargetId) { setCurrentDraggableTarget(undefined); @@ -183,7 +228,7 @@ const CanvasElementContextControls: React.FunctionComponent<{ ); // We need to re-evaluate when changing pages, it's possible the initially selected item // on a new page has the same currentDraggableTargetId. - }, [currentDraggableTargetId]); + }, [currentDraggableTargetId, page]); // The audio menu item states the audio will play when the item is touched. // That isn't true yet outside of games, so don't show it. @@ -213,9 +258,64 @@ const CanvasElementContextControls: React.FunctionComponent<{ "EditTab.Image.BackgroundImage", ); const canExpandBackgroundImage = - theOneCanvasElementManager?.canExpandToFillSpace(); + canvasElementManager?.canExpandToFillSpace(); + + const showMissingMetadataButton = hasImage && missingMetadata; + const showChooseImageButton = hasImage; + const showPasteImageButton = hasImage; + const showFormatButton = !!editable; + const showChooseVideoButtons = hasVideo; + const showExpandToFillSpaceButton = isBackgroundImage; + + const canModifyImage = + !!imgContainer && + !imgContainer.classList.contains("bloom-unmodifiable-image") && + !!img; + + const allowWholeElementCommandsSection = isMenuSectionAllowed( + "wholeElementCommands", + ); + const allowDuplicateMenu = + allowWholeElementCommandsSection && + !isLinkGrid && + !isBackgroundImage && + !isSpecialGameElementSelected; + const allowDuplicateToolbar = + !isLinkGrid && !isBackgroundImage && !isSpecialGameElementSelected; + const showDeleteMenuItem = allowWholeElementCommandsSection && !isLinkGrid; + const showDeleteToolbarButton = + !isLinkGrid && !isSpecialGameElementSelected; + + interface IToolbarItem { + key: string; + node: React.ReactNode; + isSpacer?: boolean; + } + + const normalizeToolbarItems = (items: IToolbarItem[]): IToolbarItem[] => { + const normalized: IToolbarItem[] = []; + items.forEach((item) => { + if (item.isSpacer) { + if (normalized.length === 0) { + return; + } + if (normalized[normalized.length - 1].isSpacer) { + return; + } + } + normalized.push(item); + }); + while ( + normalized.length > 0 && + normalized[normalized.length - 1].isSpacer + ) { + normalized.pop(); + } + return normalized; + }; const canToggleDraggability = + page !== null && isInDraggableGame && getGameType(activityType, page) !== GameType.DragSortSentence && // wrong and correct view items cannot be made draggable @@ -257,9 +357,470 @@ const CanvasElementContextControls: React.FunctionComponent<{ return null; } - let menuOptions: IMenuItemWithSubmenu[] = []; + const runMetadataDialog = () => { + if (!props.canvasElement) return; + if (!imgContainer) return; + showCopyrightAndLicenseDialog( + getImageUrlFromImageContainer(imgContainer as HTMLElement), + ); + }; + + const urlMenuItems: IMenuItemWithSubmenu[] = []; + const videoMenuItems: IMenuItemWithSubmenu[] = []; + const imageMenuItems: IMenuItemWithSubmenu[] = []; + const audioMenuItems: IMenuItemWithSubmenu[] = []; + const bubbleMenuItems: IMenuItemWithSubmenu[] = []; + const textMenuItems: IMenuItemWithSubmenu[] = []; + const wholeElementCommandsMenuItems: IMenuItemWithSubmenu[] = []; + + let deleteEnabled = true; + if (isBackgroundImage) { + // We can't delete the placeholder (or if there isn't an img, somehow) + deleteEnabled = hasRealImage(img); + } else if (isSpecialGameElementSelected) { + // Don't allow deleting the single drag item in a sentence drag game. + deleteEnabled = false; + } + + type CanvasElementCommandId = Exclude; + + const makeMenuItem = (props: { + l10nId: string; + english: string; + onClick: () => void; + icon: React.ReactNode; + disabled?: boolean; + featureName?: string; + }): IMenuItemWithSubmenu => { + return { + l10nId: props.l10nId, + english: props.english, + onClick: props.onClick, + icon: props.icon, + disabled: props.disabled, + featureName: props.featureName, + }; + }; + + const makeToolbarButton = (props: { + key: string; + tipL10nKey: string; + icon: React.FunctionComponent; + onClick: () => void; + relativeSize?: number; + disabled?: boolean; + }): IToolbarItem => { + return { + key: props.key, + node: ( + + ), + }; + }; + + const canvasElementCommands: Record< + CanvasElementCommandId, + { + getToolbarItem: () => IToolbarItem | undefined; + getMenuItem?: () => IMenuItemWithSubmenu | undefined; + } + > = { + setDestination: { + getToolbarItem: () => { + if (!isNavButton) return undefined; + return makeToolbarButton({ + key: "setDestination", + tipL10nKey: "EditTab.Toolbox.CanvasTool.ClickToSetLinkDest", + icon: LinkIcon, + relativeSize: 0.8, + onClick: () => setLinkDestination(), + }); + }, + getMenuItem: () => { + if (!isNavButton) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Toolbox.CanvasTool.SetDest", + english: "Set Destination", + onClick: () => setLinkDestination(), + icon: , + featureName: "canvas", + }); + }, + }, + chooseVideo: { + getToolbarItem: () => { + if (!showChooseVideoButtons || !videoContainer) + return undefined; + return makeToolbarButton({ + key: "chooseVideo", + tipL10nKey: "EditTab.Toolbox.ComicTool.Options.ChooseVideo", + icon: SearchIcon, + onClick: () => doVideoCommand(videoContainer, "choose"), + }); + }, + getMenuItem: () => { + if (!hasVideo) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Toolbox.ComicTool.Options.ChooseVideo", + english: "Choose Video from your Computer...", + onClick: () => { + doVideoCommand(videoContainer, "choose"); + setMenuOpen(false, true); + }, + icon: , + }); + }, + }, + recordVideo: { + getToolbarItem: () => { + if (!showChooseVideoButtons || !videoContainer) + return undefined; + return makeToolbarButton({ + key: "recordVideo", + tipL10nKey: + "EditTab.Toolbox.ComicTool.Options.RecordYourself", + icon: CircleIcon, + relativeSize: 0.8, + onClick: () => doVideoCommand(videoContainer, "record"), + }); + }, + getMenuItem: () => { + if (!hasVideo) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Toolbox.ComicTool.Options.RecordYourself", + english: "Record yourself...", + onClick: () => { + setMenuOpen(false, true); + doVideoCommand(videoContainer, "record"); + }, + icon: , + }); + }, + }, + chooseImage: { + getToolbarItem: () => { + if (!showChooseImageButton || !canModifyImage) return undefined; + return makeToolbarButton({ + key: "chooseImage", + tipL10nKey: "EditTab.Image.ChooseImage", + icon: SearchIcon, + onClick: () => + doImageCommand(img as HTMLImageElement, "change"), + }); + }, + getMenuItem: () => { + if (!canModifyImage) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Image.ChooseImage", + english: "Choose image from your computer...", + onClick: () => { + doImageCommand(img as HTMLImageElement, "change"); + setMenuOpen(false, true); + }, + icon: , + }); + }, + }, + pasteImage: { + getToolbarItem: () => { + if (!showPasteImageButton || !canModifyImage) return undefined; + return makeToolbarButton({ + key: "pasteImage", + tipL10nKey: "EditTab.Image.PasteImage", + icon: PasteIcon, + relativeSize: 0.9, + onClick: () => + doImageCommand(img as HTMLImageElement, "paste"), + }); + }, + getMenuItem: () => { + if (!canModifyImage) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Image.PasteImage", + english: "Paste image", + onClick: () => + doImageCommand(img as HTMLImageElement, "paste"), + icon: , + }); + }, + }, + missingMetadata: { + getToolbarItem: () => { + if (!showMissingMetadataButton) return undefined; + return makeToolbarButton({ + key: "missingMetadata", + tipL10nKey: "EditTab.Image.EditMetadataOverlay", + icon: MissingMetadataIcon, + onClick: () => runMetadataDialog(), + }); + }, + getMenuItem: () => { + if (!canModifyImage) return undefined; + const realImagePresent = hasRealImage(img); + return makeMenuItem({ + l10nId: "EditTab.Image.EditMetadataOverlay", + english: "Set Image Information...", + onClick: () => { + setMenuOpen(false, true); + runMetadataDialog(); + }, + disabled: !realImagePresent, + icon: , + }); + }, + }, + expandToFillSpace: { + getToolbarItem: () => { + if (!showExpandToFillSpaceButton) return undefined; + return makeToolbarButton({ + key: "expandToFillSpace", + tipL10nKey: "EditTab.Toolbox.ComicTool.Options.FillSpace", + icon: FillSpaceIcon, + disabled: !canExpandBackgroundImage, + onClick: () => + canvasElementManager?.expandImageToFillSpace(), + }); + }, + getMenuItem: () => { + if (!isBackgroundImage) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Toolbox.ComicTool.Options.FillSpace", + english: "Fit Space", + onClick: () => + canvasElementManager?.expandImageToFillSpace(), + disabled: !canExpandBackgroundImage, + icon: ( + + ), + }); + }, + }, + format: { + getToolbarItem: () => { + if (!showFormatButton) return undefined; + return makeToolbarButton({ + key: "format", + tipL10nKey: "EditTab.Toolbox.ComicTool.Options.Format", + icon: CogIcon, + relativeSize: 0.8, + onClick: () => { + if (!editable) return; + GetEditor().runFormatDialog(editable); + }, + }); + }, + }, + duplicate: { + getToolbarItem: () => { + if (!allowDuplicateToolbar) return undefined; + return makeToolbarButton({ + key: "duplicate", + tipL10nKey: "EditTab.Toolbox.ComicTool.Options.Duplicate", + icon: DuplicateIcon, + relativeSize: 0.9, + onClick: () => { + if (!props.canvasElement) return; + makeDuplicateOfDragBubble(); + }, + }); + }, + getMenuItem: () => { + if (!allowDuplicateMenu) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Toolbox.ComicTool.Options.Duplicate", + english: "Duplicate", + onClick: () => { + if (!props.canvasElement) return; + makeDuplicateOfDragBubble(); + }, + icon: , + }); + }, + }, + delete: { + getToolbarItem: () => { + if (!showDeleteToolbarButton) return undefined; + return makeToolbarButton({ + key: "delete", + tipL10nKey: "Common.Delete", + icon: DeleteIcon, + disabled: !deleteEnabled, + onClick: () => + canvasElementManager?.deleteCurrentCanvasElement(), + }); + }, + getMenuItem: () => { + if (!showDeleteMenuItem) return undefined; + return makeMenuItem({ + l10nId: "Common.Delete", + english: "Delete", + disabled: !deleteEnabled, + onClick: () => + canvasElementManager?.deleteCurrentCanvasElement?.(), + icon: , + }); + }, + }, + linkGridChooseBooks: { + getToolbarItem: () => { + if (!isLinkGrid || !linkGrid) return undefined; + return { + key: "linkGridChooseBooks", + node: ( + <> + { + editLinkGrid(linkGrid); + }} + /> + { + editLinkGrid(linkGrid); + }} + > + {chooseBooksLabel} + + + ), + }; + }, + getMenuItem: () => { + if (!isLinkGrid || !linkGrid) return undefined; + return makeMenuItem({ + l10nId: "EditTab.Toolbox.CanvasTool.LinkGrid.ChooseBooks", + english: "Choose books...", + onClick: () => { + setMenuOpen(false, true); + editLinkGrid(linkGrid); + }, + icon: , + }); + }, + }, + }; + + if (isMenuSectionAllowed("url")) { + const setDestMenuItem = + canvasElementCommands.setDestination.getMenuItem?.(); + if (setDestMenuItem) { + urlMenuItems.push(setDestMenuItem); + } + } + + if (hasVideo) { + const chooseVideoMenuItem = + canvasElementCommands.chooseVideo.getMenuItem?.(); + if (chooseVideoMenuItem) { + videoMenuItems.push(chooseVideoMenuItem); + } + const recordVideoMenuItem = + canvasElementCommands.recordVideo.getMenuItem?.(); + if (recordVideoMenuItem) { + videoMenuItems.push(recordVideoMenuItem); + } + videoMenuItems.push( + { + l10nId: "EditTab.Toolbox.ComicTool.Options.PlayEarlier", + english: "Play Earlier", + onClick: () => { + doVideoCommand(videoContainer, "playEarlier"); + }, + icon: , + disabled: !findPreviousVideoContainer(videoContainer), + }, + { + l10nId: "EditTab.Toolbox.ComicTool.Options.PlayLater", + english: "Play Later", + onClick: () => { + doVideoCommand(videoContainer, "playLater"); + }, + icon: , + disabled: !findNextVideoContainer(videoContainer), + }, + ); + } + + if (hasImage && canModifyImage) { + const chooseImageMenuItem = + canvasElementCommands.chooseImage.getMenuItem?.(); + if (chooseImageMenuItem) { + imageMenuItems.push(chooseImageMenuItem); + } + const pasteImageMenuItem = + canvasElementCommands.pasteImage.getMenuItem?.(); + if (pasteImageMenuItem) { + imageMenuItems.push(pasteImageMenuItem); + } + const realImagePresent = hasRealImage(img); + imageMenuItems.push({ + l10nId: "EditTab.Image.CopyImage", + english: "Copy image", + onClick: () => doImageCommand(img as HTMLImageElement, "copy"), + icon: , + disabled: !realImagePresent, + }); + const metadataMenuItem = + canvasElementCommands.missingMetadata.getMenuItem?.(); + if (metadataMenuItem) { + imageMenuItems.push(metadataMenuItem); + } + + const isCropped = !!(img as HTMLElement | undefined)?.style?.width; + imageMenuItems.push({ + l10nId: "EditTab.Image.Reset", + english: "Reset Image", + onClick: () => { + getCanvasElementManager()?.resetCropping(); + }, + disabled: !isCropped, + icon: ( + + ), + }); + } + + const expandToFillSpaceMenuItem = + canvasElementCommands.expandToFillSpace.getMenuItem?.(); + if (expandToFillSpaceMenuItem) { + imageMenuItems.push(expandToFillSpaceMenuItem); + } + + if (canChooseAudioForElement) { + audioMenuItems.push( + hasText + ? getAudioMenuItemForTextItem(textHasAudio, setMenuOpen) + : getAudioMenuItemForImage( + imageSound, + setImageSound, + setMenuOpen, + ), + ); + } + if (hasRectangle) { - menuOptions.splice(0, 0, { + textMenuItems.push({ l10nId: "EditTab.Toolbox.ComicTool.Options.FillBackground", english: "Fill Background", onClick: () => { @@ -272,16 +833,16 @@ const CanvasElementContextControls: React.FunctionComponent<{ ), }); } - if (hasText && !isInDraggableGame && !isNavButton) { - menuOptions.splice(0, 0, { + if (isMenuSectionAllowed("bubble") && hasText && !isInDraggableGame) { + bubbleMenuItems.push({ l10nId: "EditTab.Toolbox.ComicTool.Options.AddChildBubble", english: "Add Child Bubble", - onClick: theOneCanvasElementManager?.addChildCanvasElement, + onClick: () => canvasElementManager?.addChildCanvasElement?.(), }); } if (canToggleDraggability) { addMenuItemForTogglingDraggability( - menuOptions, + textMenuItems, props.canvasElement, currentDraggableTarget, setCurrentDraggableTarget, @@ -289,118 +850,55 @@ const CanvasElementContextControls: React.FunctionComponent<{ } if (currentDraggableTargetId) { addMenuItemsForDraggable( - menuOptions, + textMenuItems, props.canvasElement, currentDraggableTargetId, currentDraggableTarget, setCurrentDraggableTarget, ); } - if (canChooseAudioForElement) { - const audioMenuItem = hasText - ? getAudioMenuItemForTextItem(textHasAudio, setMenuOpen) - : getAudioMenuItemForImage(imageSound, setImageSound, setMenuOpen); - menuOptions.push(divider); - menuOptions.push(audioMenuItem); - } - if (hasImage) { - const canModifyImage = !imgContainer.classList.contains( - "bloom-unmodifiable-image", - ); - if (canModifyImage) - addImageMenuOptions( - menuOptions, - props.canvasElement, - img, - setMenuOpen, - ); - } - if (hasVideo) { - addVideoMenuItems(menuOptions, videoContainer, setMenuOpen); + const linkGridChooseBooksMenuItem = + canvasElementCommands.linkGridChooseBooks.getMenuItem?.(); + if (linkGridChooseBooksMenuItem) { + textMenuItems.push(linkGridChooseBooksMenuItem); } - if (isLinkGrid) { - // For link grids, add edit and delete options in the menu - menuOptions.push({ - l10nId: "EditTab.Toolbox.CanvasTool.LinkGrid.ChooseBooks", - english: "Choose books...", - onClick: () => { - if (!linkGrid) return; - editLinkGrid(linkGrid); - }, - icon: , - }); - menuOptions.push({ - l10nId: "Common.Delete", - english: "Delete", - onClick: theOneCanvasElementManager?.deleteCurrentCanvasElement, - icon: , - }); + const duplicateMenuItem = canvasElementCommands.duplicate.getMenuItem?.(); + if (duplicateMenuItem) { + wholeElementCommandsMenuItems.push(duplicateMenuItem); } - menuOptions.push(divider); - - if (!isBackgroundImage && !isSpecialGameElementSelected && !isLinkGrid) { - menuOptions.push({ - l10nId: "EditTab.Toolbox.ComicTool.Options.Duplicate", - english: "Duplicate", - onClick: () => { - if (!props.canvasElement) return; - makeDuplicateOfDragBubble(); - }, - icon: , - }); + const deleteMenuItem = canvasElementCommands.delete.getMenuItem?.(); + if (deleteMenuItem) { + wholeElementCommandsMenuItems.push(deleteMenuItem); } - let deleteEnabled = true; - if (isBackgroundImage) { - const fillItem = { - l10nId: "EditTab.Toolbox.ComicTool.Options.FillSpace", - english: "Fit Space", - onClick: () => theOneCanvasElementManager?.expandImageToFillSpace(), - disabled: !canExpandBackgroundImage, - icon: ( - - ), - }; - let index = menuOptions.findIndex( - (option) => option.l10nId === "EditTab.Image.Reset", - ); - if (index < 0) { - index = menuOptions.indexOf(divider); - } - menuOptions.splice(index, 0, fillItem); - - // we can't delete the placeholder (or if there isn't an img, somehow) - deleteEnabled = hasRealImage(img); - } else if (isSpecialGameElementSelected || isLinkGrid) { - deleteEnabled = false; // don't allow deleting the single drag item in a sentence drag game or link grids + if (editable) { + addTextMenuItems(textMenuItems, editable, props.canvasElement); } - // last one - if (!isLinkGrid) { - menuOptions.push({ - l10nId: "Common.Delete", - english: "Delete", - disabled: !deleteEnabled, - onClick: theOneCanvasElementManager?.deleteCurrentCanvasElement, - icon: , - }); - } - if (isNavButton) { - menuOptions.splice(0, 0, { - l10nId: "EditTab.Toolbox.CanvasTool.SetDest", - english: "Set Destination", - onClick: () => setLinkDestination(), - icon: , - featureName: "canvas", - }); - } + const orderedMenuSections: Array< + [CanvasElementMenuSection, IMenuItemWithSubmenu[]] + > = [ + ["url", urlMenuItems], + ["video", videoMenuItems], + ["image", imageMenuItems], + ["audio", audioMenuItems], + ["bubble", bubbleMenuItems], + ["text", textMenuItems], + ["wholeElementCommands", wholeElementCommandsMenuItems], + ]; + const menuOptions = joinMenuSectionsWithSingleDividers( + orderedMenuSections + .filter(([section, items]) => { + if (items.length === 0) { + return false; + } + return isMenuSectionAllowed(section); + }) + .map((entry) => entry[1]), + ); const handleMenuButtonMouseDown = (e: React.MouseEvent) => { // This prevents focus leaving the text box. e.preventDefault(); @@ -412,31 +910,40 @@ const CanvasElementContextControls: React.FunctionComponent<{ e.stopPropagation(); setMenuOpen(true); // Review: better on mouse down? But then the mouse up may be missed, if the menu is on top... }; - const editable = props.canvasElement.getElementsByClassName( - "bloom-editable bloom-visibility-code-on", - )[0] as HTMLElement; - const langName = editable?.getAttribute("data-languagetipcontent"); - // and these for text boxes - if (editable) { - addTextMenuItems(menuOptions, editable, props.canvasElement); - } + // editable and langName are computed earlier, but keep them here for the UI below. - const runMetadataDialog = () => { - if (!props.canvasElement) return; - if (!imgContainer) return; - showCopyrightAndLicenseDialog( - getImageUrlFromImageContainer(imgContainer as HTMLElement), - ); + const maxMenuWidth = 260; + + const getSpacerToolbarItem = (index: number): IToolbarItem => { + return { + key: `spacer-${index}`, + isSpacer: true, + node: ( +
+ ), + }; }; - // I don't particularly like this, but the logic of when to add items is - // so convoluted with most things being added at the beginning of the list instead - // the end, that it is almost impossible to reason about. It would be great to - // give it a more linear flow, but we're not taking that on just before releasing 6.2a. - // But this is also future-proof. - menuOptions = cleanUpDividers(menuOptions); + const getToolbarItemForButton = ( + button: CanvasElementToolbarButton, + index: number, + ): IToolbarItem | undefined => { + if (button === "spacer") { + return getSpacerToolbarItem(index); + } + const command = canvasElementCommands[button as CanvasElementCommandId]; + return command.getToolbarItem(); + }; - const maxMenuWidth = 260; + const toolbarItems = normalizeToolbarItems( + canvasElementDefinitions[canvasElementType].toolbarButtons + .map((button, index) => getToolbarItemForButton(button, index)) + .filter((item): item is IToolbarItem => !!item), + ); return ( @@ -483,183 +990,11 @@ const CanvasElementContextControls: React.FunctionComponent<{ } `} > - {isLinkGrid && ( - <> - { - if (!linkGrid) return; - editLinkGrid(linkGrid); - }} - /> - { - if (!linkGrid) return; - editLinkGrid(linkGrid); - }} - > - {chooseBooksLabel} - - - )} - {isNavButton && ( - - )} - {hasImage && ( - - { - // Want an attention-grabbing version of set metadata if there is none.) - missingMetadata && !isNavButton && ( - runMetadataDialog()} - /> - ) - } - { - // Choose image is only a LIKELY choice if we don't yet have one. - // (or if it's a background image...not sure why, except otherwise - // the toolbar might not have any icons for a background image.) - (isPlaceHolder || isBackgroundImage) && ( - { - if (!props.canvasElement) return; - const imgContainer = - props.canvasElement.getElementsByClassName( - kImageContainerClass, - )[0] as HTMLElement; - if (!imgContainer) return; - doImageCommand( - imgContainer.getElementsByTagName( - "img", - )[0] as HTMLImageElement, - "change", - ); - }} - /> - ) - } - {(isPlaceHolder || isBackgroundImage) && ( - { - if (!props.canvasElement) return; - const imgContainer = - props.canvasElement.getElementsByClassName( - kImageContainerClass, - )[0] as HTMLElement; - if (!imgContainer) return; - doImageCommand( - imgContainer.getElementsByTagName( - "img", - )[0] as HTMLImageElement, - "paste", - ); - }} - > - )} - - )} - {editable && !isNavButton && ( - { - if (!props.canvasElement) return; - GetEditor().runFormatDialog(editable); - }} - /> - )} - {hasVideo && !videoAlreadyChosen && ( - - - doVideoCommand(videoContainer, "choose") - } - /> - - doVideoCommand(videoContainer, "record") - } - /> - - )} - {(!(hasImage && isPlaceHolder) && - !editable && - !(hasVideo && !videoAlreadyChosen)) || ( - // Add a spacer if there is any button before these -
- )} - {!hasVideo && - !isBackgroundImage && - !isSpecialGameElementSelected && - !isLinkGrid && ( - { - if (!props.canvasElement) return; - makeDuplicateOfDragBubble(); - }} - /> - )} - { - // Not sure of the reasoning here, since we do have a way to 'delete' a background image, - // not by removing the canvas element but by setting the image back to a placeholder. - // But the mockup in BL-14069 definitely doesn't have it. - isBackgroundImage || - isSpecialGameElementSelected || - isLinkGrid || ( - { - if (!props.canvasElement) return; - theOneCanvasElementManager?.deleteCurrentCanvasElement(); - }} - /> - ) - } - {isBackgroundImage && ( - { - if (!props.canvasElement) return; - theOneCanvasElementManager?.expandImageToFillSpace(); - }} - /> - )} + {toolbarItems.map((item) => ( + + {item.node} + + ))}