Skip to content

Refactor canvas elements: registry-driven controls#7621

Open
hatton wants to merge 91 commits intomasterfrom
BL-15770RefactorCanvas
Open

Refactor canvas elements: registry-driven controls#7621
hatton wants to merge 91 commits intomasterfrom
BL-15770RefactorCanvas

Conversation

@hatton
Copy link
Copy Markdown
Member

@hatton hatton commented Jan 26, 2026

This PR delivers the Canvas refactor to a registry-driven control architecture and carries it through with behavior fixes and expanded end-to-end coverage.

What Changed

  • Introduced a declarative canvas control system for the toolbox panel, mini-toolbar, and context menu.
  • Consolidated command definitions so labels/icons/handlers are sourced from one place, with deterministic section ordering and spacing.
  • Replaced monolithic CanvasElementManager logic with focused modules under js/canvasElementManager (selection UI, pointer/drag interactions, geometry/positioning, clipboard, duplication, alternates, background image handling, editing suspension, etc.).
  • Tightened cross-bundle boundaries (dependency-light constants and bridge utilities) to reduce import-cycle and bundling risk.

Functional Fixes Included

  • Hardened background-image setup/resizing behavior in the refactored path.
  • Improved custom SVG menu icon rendering/alignment in context controls.

Test and Tooling Work

  • Added/expanded dedicated Canvas Playwright coverage with shared fixtures/helpers and broad scenario specs:
    • drag/drop, select/move/resize/crop
    • context toolbar/menu and availability rules
    • clipboard/paste and duplication/child-bubble behavior
    • background/canvas resize interactions
    • regression and shared-mode cleanup checks
  • Added fail-fast checks for non-canvas page context in canvas e2e runs.

This change is Reviewable


Open with Devin

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
@hatton hatton force-pushed the BL-15770RefactorCanvas branch from c0fc612 to e95f491 Compare February 11, 2026 21:51
- Delegate addCanvasElement* to CanvasElementFactories (toolbox drop + templates)\n- Move paste-image flow to CanvasElementClipboard\n- Move duplication + audio file copy helper to CanvasElementDuplication\n- Keep CanvasElementManager as orchestrator; behavior validated with live toolbox→page drag/drop\n- Track checkpoints + line count in REFACTOR_PLAN.md
- Switch CanvasToolControls to import CanvasElementManager/ITextColorInfo as types only\n- Move ITextColorInfo to dependency-light CanvasElementSharedTypes and re-export from CanvasElementManager\n- Update CanvasElementFactories to use shared ITextColorInfo type\n- Mark Phase B4 complete in REFACTOR_PLAN.md
Extract resize/crop/side-handle/move-crop drag logic into CanvasElementHandleDragInteractions and wire CanvasElementManager/SelectionUi to delegate to it.

This keeps CanvasElementManager focused on orchestration and reduces its size.
Move language-alternate behaviors into CanvasElementAlternates and extract draggable/target ordering+cleanup into CanvasElementDraggableIntegration.

CanvasElementManager now delegates to these modules and continues to expose compatibility wrappers for existing callers/tests.
Extract origami splitter drag + comic editing suspend/resume logic into CanvasElementEditingSuspension and delegate from CanvasElementManager.
Extract bloom-canvas size-change child adjustment logic into CanvasElementCanvasResizeAdjustments and delegate from CanvasElementManager.
Backport selected master-side fixes into the refactored canvasElementManager structure while preserving refactor boundaries.
BL-15247, BL-15657, BL-15695, BL-15719, BL-15752, BL-15754, BL-15757, BL-15791, BL-15831

Includes motion-tool guard behavior, splitter interaction suppression, canvas-tool activation flow updates, background image sizing/race fixes, expand-to-fill enablement logic, missing-image metadata handling, and image-fit attribute propagation.

Also exposes requestPageContent delay helpers through editable page exports and adds editablePageUtils helper used by split-pane and manager code.
# Conflicts:
#	src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts
#	src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
#	src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx
#	src/BloomBrowserUI/bookEdit/js/editablePageUtils.ts
#	src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts
#	src/BloomBrowserUI/lib/split-pane/split-pane.ts
devin-ai-integration[bot]

This comment was marked as resolved.

@hatton hatton marked this pull request as ready for review February 13, 2026 23:22
Copilot AI review requested due to automatic review settings February 13, 2026 23:22

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 11, 2026

src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityPresets.ts line 23 at r16 (raw file):

Previously, hatton (John Hatton) wrote…

[GPT-5.4] Addressed. Paste image now checks real clipboard-image availability instead of only the selected element state. I added a new common/clipboardHasImage endpoint, pass hasClipboardImage into the canvas control context when the menu opens, gate pasteImage on that fact, and added a canvas availability regression test for the disabled/enabled states.

John Hatton chose not to take on this issue in this PR.

Comment on lines +232 to 242
private dynamic GetCreativeCommonsInfo(SIL.Core.ClearShare.LicenseInfo ccLicense)
{
dynamic dynamicCcLicense = ccLicense;
return new
{
token = ccLicense.Token,
allowCommercial = ccLicense.CommercialUseAllowed ? "yes" : "no",
allowDerivatives = GetCcDerivativeRulesAsString(ccLicense.DerivativeRule),
intergovernmentalVersion = ccLicense.IntergovernmentalOrganizationQualifier,
token = dynamicCcLicense.Token,
allowCommercial = dynamicCcLicense.CommercialUseAllowed ? "yes" : "no",
allowDerivatives = GetCcDerivativeRulesAsString(dynamicCcLicense.DerivativeRule),
intergovernmentalVersion = dynamicCcLicense.IntergovernmentalOrganizationQualifier,
};
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

John Hatton chose not to take on this issue in this PR.

Copy link
Copy Markdown
Contributor

@JohnThomson JohnThomson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson reviewed 3 files and made 4 comments.
Reviewable status: 20 of 197 files reviewed, 35 unresolved discussions (waiting on hatton).


src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts line 1 at r19 (raw file):

// Cross-frame bridge utilities for canvas element code.

I asked an AI how it would improve naming and code organization, and it's top suggestion was that this file wants a more specific name; it's not really a generic place to put utils. It suggested canvasElementBridge. I see it's not a new file, but while we're moving everything and breaking our history, it might be worth doing.


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManagerPublicFunctions.ts line 5 at r19 (raw file):

//
// This file exists to keep CanvasElementManager.ts smaller and to reduce coupling
// between the page bundle and toolbox UI code.

Seems very similar in purpose to canvasElementUtils (which might become canvasElementBridge). Since it's quite small I'd be inclined to merge them, unless we can improve the names enough to make it clear what functions to look for (and add) in each.


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementCanvasResizeAdjustments.ts line 1 at r19 (raw file):

import { Bubble, Comical, TailSpec } from "comicaljs";

File name is awkward. Just canvasElementResizeAdjustment?


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementCanvasResizeAdjustments.ts line 19 at r19 (raw file):

}

export class CanvasElementCanvasResizeAdjustments {

I don't see why you want a class here. We make one instance of it in one place for the purpose of calling one function in one place...and that function just provides a wrapper so that its three callers can process it as a function of CanvasElementManager rather than importing it directly. Looks to me as if it just adds complexity.
There's no reason for pxToNumber to be a member variable. Why not just import it directly from canvasElementCssUtils? Instead, CanvasElementManager imports it as pxToNumberFromCssUtils, re-wraps it as its own function pxToNumber, and then passes that to the constructor of this object...
adjustBackgroundImageSize is harder to analyze, because it has also become a member function of a new class, so I'd have to analyze whether there's a good reason for that. But I doubt it...again, one instance is created, and the instance variables look like functions that are created to wrap calls to member functions of classes created to wrap other functions...
This is not trivial complexity. For example, suppose I'm reading some code that calls pxToNumber, and want to see what it does. With a direct import, ctrl-click takes me straight there. (I may not even need to go...VS Code might well pop up the comment that describes the function.) With the code your AI has written,

  • ctrl-click takes me to a member variable
  • I will do one search to find what sets that, and get to the constructor
  • I will search for references to the constructor to find the one place it is called
  • I will look at that call and figure out what it passes as the pxToNumber argument
  • I will follow that to find the local function in CAM
  • And then follow that to the function I wanted to see
    That's at least six times as much work! And I very much doubt that it will be easier for an AI to figure out, either.

Unless there's a very good reason, I would be greatly in favor of undoing all this business of wrapping functions in singleton classes. For a long time we've been moving away from creating classes unless they really store important state. We're not in C#, where every function has to belong to some class. Modules provide all the namespace management we need. This feels like a move backwards and to increased complexity.

hatton added 3 commits March 11, 2026 11:00
# Conflicts:
#	src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx
#	src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts
#	src/BloomBrowserUI/bookEdit/js/workspaceFrames.ts
#	src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts
#	src/BloomBrowserUI/bookEdit/toolbox/games/GamePromptDialog.tsx
#	src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx
#	src/BloomBrowserUI/utils/bloomMessageBoxSupport.ts
@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 11, 2026

src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts line 1 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

I asked an AI how it would improve naming and code organization, and it's top suggestion was that this file wants a more specific name; it's not really a generic place to put utils. It suggested canvasElementBridge. I see it's not a new file, but while we're moving everything and breaking our history, it might be worth doing.

[codex] Renamed the toolbox-to-page bridge from canvasElementUtils.ts to canvasElementPageBridge.ts, updated all imports, and tightened the module and README wording so it is explicitly a bridge instead of a catch-all utils file. This is in b20370c.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 11, 2026

src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManagerPublicFunctions.ts line 5 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

Seems very similar in purpose to canvasElementUtils (which might become canvasElementBridge). Since it's quite small I'd be inclined to merge them, unless we can improve the names enough to make it clear what functions to look for (and add) in each.

[codex] I did not merge these two bridge modules, but I did rename them so their responsibilities are explicit: canvasElementPageBridge.ts is the toolbox-to-page bridge, and CanvasElementToolboxBridge.ts is the page-to-toolbox helper module for things like showCanvasTool(). I also updated the module comments and READMEs to explain that split. This is in b20370c.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 11, 2026

src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementCanvasResizeAdjustments.ts line 1 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

File name is awkward. Just canvasElementResizeAdjustment?

[codex] Renamed CanvasElementCanvasResizeAdjustments.ts to CanvasElementResizeAdjustments.ts and updated the manager, README, and spec references to match. This is in b20370c.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 11, 2026

src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementCanvasResizeAdjustments.ts line 19 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

I don't see why you want a class here. We make one instance of it in one place for the purpose of calling one function in one place...and that function just provides a wrapper so that its three callers can process it as a function of CanvasElementManager rather than importing it directly. Looks to me as if it just adds complexity.
There's no reason for pxToNumber to be a member variable. Why not just import it directly from canvasElementCssUtils? Instead, CanvasElementManager imports it as pxToNumberFromCssUtils, re-wraps it as its own function pxToNumber, and then passes that to the constructor of this object...
adjustBackgroundImageSize is harder to analyze, because it has also become a member function of a new class, so I'd have to analyze whether there's a good reason for that. But I doubt it...again, one instance is created, and the instance variables look like functions that are created to wrap calls to member functions of classes created to wrap other functions...
This is not trivial complexity. For example, suppose I'm reading some code that calls pxToNumber, and want to see what it does. With a direct import, ctrl-click takes me straight there. (I may not even need to go...VS Code might well pop up the comment that describes the function.) With the code your AI has written,

  • ctrl-click takes me to a member variable
  • I will do one search to find what sets that, and get to the constructor
  • I will search for references to the constructor to find the one place it is called
  • I will look at that call and figure out what it passes as the pxToNumber argument
  • I will follow that to find the local function in CAM
  • And then follow that to the function I wanted to see
    That's at least six times as much work! And I very much doubt that it will be easier for an AI to figure out, either.

Unless there's a very good reason, I would be greatly in favor of undoing all this business of wrapping functions in singleton classes. For a long time we've been moving away from creating classes unless they really store important state. We're not in C#, where every function has to belong to some class. Modules provide all the namespace management we need. This feels like a move backwards and to increased complexity.

[codex] Removed the singleton-class indirection here. The resize logic now lives in a stateless adjustCanvasElementChildrenIfSizeChanged() module function in CanvasElementResizeAdjustments.ts, and CanvasElementManager now calls it directly with adjustBackgroundImageSize plus the shared pxToNumberFromCssUtils helper instead of constructing a wrapper class instance. This is in b20370c.

Copy link
Copy Markdown
Contributor

@JohnThomson JohnThomson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson made 1 comment.
Reviewable status: 6 of 201 files reviewed, 33 unresolved discussions (waiting on hatton).


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementCanvasResizeAdjustments.ts line 19 at r19 (raw file):

Previously, hatton (John Hatton) wrote…

[codex] Removed the singleton-class indirection here. The resize logic now lives in a stateless adjustCanvasElementChildrenIfSizeChanged() module function in CanvasElementResizeAdjustments.ts, and CanvasElementManager now calls it directly with adjustBackgroundImageSize plus the shared pxToNumberFromCssUtils helper instead of constructing a wrapper class instance. This is in b20370c.

Good start, but we should just import pxToNumber, not require the caller to supply it. There's only one version of this function in our codebase (I hope).
Also, I'm hoping we can get rid of a lot more of these classes that were apparently just made to wrap functions better imported.

Copy link
Copy Markdown
Contributor

@JohnThomson JohnThomson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson made 1 comment.
Reviewable status: 6 of 201 files reviewed, 34 unresolved discussions (waiting on hatton).


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts line 149 at r20 (raw file):

            setupControlFrame: this.setupControlFrame.bind(this),
        });
        this.backgroundImageManager = new CanvasElementBackgroundImageManager({

It appears to me that most of the classes we instantiate here are just wrappers for functions.

  • We started with functions X, Y, and Z in CAM which call P and Q
  • We moved X, Y, and Z to a new file A, and P and Q to other files B and C
  • We should have made the first new file import P and Q, and anything that needs them import X, Y, and Z. If the functions needed access to some state that lives in the CAM singleton, they should make use of a reliable function that finds the one instance (in the right iframe).
  • Instead, we made a class in each new file. X, Y, and Z became public methods of Aclass, and P and Q are now public methods of BClass and CClass.
  • Now, we need an instance of each of these classes, so the methods can be called. So CAM has to make instances of AClass, BClass, and CClass.
  • But, X, Y, and Z still can't call P and Q, because they are member functions of Bclass and CClass, and we don't want every caller creating instances.
  • So class A is made to have member variables that point at functions P and Q
  • And CAM gets new functions P and Q, which just wrap calls to BInstance.P and CInstance.Q
  • And then passes it's own P and Q wrappers to the constructor for A.
    Something that should have been three simple imports has become a mess of classes, member variables, wrapper functions, constructors, and passing functions all over the place.
    Now, most of these methods started out as member functions of CAM. So it will take a bit of fiddling to share them instead using import and export. Maybe, some of the new classes really deserve to become objects that own some of the CAM state. But we should minimize this pattern where, instead of seeing 'this.X' and being able to ctrl-click and go right to the implementation, I have to go a member variable that holds function X, then to the constructor that initializes it, then to the caller of that constructor, then to the local function that wraps a call to a member of another class, and then finally to the code I want to see. That's a huge downgrade of readability and increase in complexity.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 12, 2026

src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts line 149 at r20 (raw file):

Previously, JohnThomson (John Thomson) wrote…

It appears to me that most of the classes we instantiate here are just wrappers for functions.

  • We started with functions X, Y, and Z in CAM which call P and Q
  • We moved X, Y, and Z to a new file A, and P and Q to other files B and C
  • We should have made the first new file import P and Q, and anything that needs them import X, Y, and Z. If the functions needed access to some state that lives in the CAM singleton, they should make use of a reliable function that finds the one instance (in the right iframe).
  • Instead, we made a class in each new file. X, Y, and Z became public methods of Aclass, and P and Q are now public methods of BClass and CClass.
  • Now, we need an instance of each of these classes, so the methods can be called. So CAM has to make instances of AClass, BClass, and CClass.
  • But, X, Y, and Z still can't call P and Q, because they are member functions of Bclass and CClass, and we don't want every caller creating instances.
  • So class A is made to have member variables that point at functions P and Q
  • And CAM gets new functions P and Q, which just wrap calls to BInstance.P and CInstance.Q
  • And then passes it's own P and Q wrappers to the constructor for A.
    Something that should have been three simple imports has become a mess of classes, member variables, wrapper functions, constructors, and passing functions all over the place.
    Now, most of these methods started out as member functions of CAM. So it will take a bit of fiddling to share them instead using import and export. Maybe, some of the new classes really deserve to become objects that own some of the CAM state. But we should minimize this pattern where, instead of seeing 'this.X' and being able to ctrl-click and go right to the implementation, I have to go a member variable that holds function X, then to the constructor that initializes it, then to the caller of that constructor, then to the local function that wraps a call to a member of another class, and then finally to the code I want to see. That's a huge downgrade of readability and increase in complexity.

[GPT-5.4]: I went back and did the broader cleanup. Besides the earlier pxToNumber simplification, CanvasElementSelectionUi.ts and CanvasElementBackgroundImageManager.ts are now direct helper modules used from CanvasElementManager, so there are two fewer constructor-injected wrapper classes / host objects in the path. I left CanvasElementHandleDragInteractions.ts, CanvasElementPointerInteractions.ts, and CanvasElementEditingSuspension.ts as classes because they carry substantial mutable interaction state and event lifecycle, and flattening those looked more likely to make the code harder to follow rather than easier.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 12, 2026

src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementCanvasResizeAdjustments.ts line 19 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

Good start, but we should just import pxToNumber, not require the caller to supply it. There's only one version of this function in our codebase (I hope).
Also, I'm hoping we can get rid of a lot more of these classes that were apparently just made to wrap functions better imported.

[GPT-5.4]: I agreed with the pxToNumber point and fixed that by having CanvasElementResizeAdjustments.ts import it directly. I also followed through on the broader concern instead of stopping there: CanvasElementSelectionUi.ts and CanvasElementBackgroundImageManager.ts are now direct helper modules used from CanvasElementManager, so there are fewer wrapper classes / host objects in the path. I did not flatten the drag/pointer/suspension controllers because those still own significant mutable state and event lifecycle, and flattening them looked more likely to hurt readability than help it.

Copy link
Copy Markdown
Member Author

@hatton hatton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] I changed the registry’s image fallback to use getImageFromCanvasElement(ctx.canvasElement) instead of probing a canvas element through getImageFromContainer(...). That keeps the compatibility behavior but makes the intent explicit at the call site John flagged.

@hatton made 2 comments.
Reviewable status: 6 of 201 files reviewed, 34 unresolved discussions (waiting on JohnThomson).


src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts line 729 at r16 (raw file):

: could this go at the start of the file?

[Hatton] Not necessarily without complicating things. Unlike c#, ts seems to sensitive to order. I don't want to discuss this one any more.

devin-ai-integration[bot]

This comment was marked as resolved.

@hatton
Copy link
Copy Markdown
Member Author

hatton commented Mar 12, 2026

[GPT-5.4] I followed up on the latest remaining bot-reported issue directly in the branch. The recursive EditingModel.GetXmlDocumentForEditScreenWebPage(string, string) overload is gone now: the real implementation was made internal, the duplicate self-recursing overload was removed, and the file validates cleanly locally. The earlier image-fallback concern in this discussion was already addressed with getImageFromCanvasElement(ctx.canvasElement).

hatton added 5 commits April 2, 2026 07:35
# Conflicts:
#	src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx
#	src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts
#	src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
#	src/BloomBrowserUI/bookEdit/toolbox/canvas/CanvasToolControls.tsx
#	src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx
#	src/BloomBrowserUI/package.json
#	src/BloomBrowserUI/yarn.lock
#	src/BloomExe/Edit/EditingModel.cs
#	src/BloomExe/Edit/EditingView.cs
Resolve the canvas refactor merge conflicts by keeping the new canvasElementManager path, adopting master changes for the shared above-page controls path, and preserving canvas tool activation persistence.

Update the robust-file pre-commit hook to fail early with a clear dependency message when required shell tools are missing, and to allow explicitly documented FileStream exceptions via the marker robustfile-hook: allow FileStream. Restore RequestInfo to use the intended shared-delete FileStream helper under that narrow exemption.

Include the formatter-only AppearanceSettingsTests change left by the hook so the worktree stays clean.

Validated with yarn lint, focused vitest runs for canvas manager and page settings, and the robust-file hook check.
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +347 to +357
getString(
`editView/getDataBookValue?lang=${editable.getAttribute("lang")}&dataBook=${fieldType.dataBook}`,
(content) => {
const temp = document.createElement("div");
temp.innerHTML = content || "";
if (temp.textContent.trim() !== "") {
editable.innerHTML = content;
}
},
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing wrapWithRequestPageContentDelay around async DOM mutation in canvasControlTextMenuItems.ts

The new makeFieldTypeMenuItem in canvasControlTextMenuItems.ts uses getString() (an async API call) and then sets editable.innerHTML = content in the callback, without wrapping the operation in wrapWithRequestPageContentDelay. The old code in the deleted CanvasElementContextControls.tsx:988-1024 explicitly wrapped this identical operation with wrapWithRequestPageContentDelay(…, "setCanvasFieldValueFromDataBook"). This violates the repository's src/BloomBrowserUI/AGENTS.md rule: "When code makes changes to the editable page dom using asynchronous operations, it should use wrapWithRequestPageContentDelay." Without this wrapper, a concurrent requestPageContent call could save the page before the async field-type content has been written, losing the user's field-type change.

Prompt for agents
The getString() call at canvasControlTextMenuItems.ts:347-357 asynchronously fetches a data-book value and then mutates editable.innerHTML. The old implementation in the deleted CanvasElementContextControls.tsx wrapped this exact pattern in wrapWithRequestPageContentDelay to prevent page-content save races. Import wrapWithRequestPageContentDelay from bloomEditing.ts and wrap the getString callback's DOM mutation, similar to how the old setEditableContentFromKnownDataBookValueIfAny function did it. The delay ID was "setCanvasFieldValueFromDataBook".
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1353 to +1423
internal HtmlDom GetXmlDocumentForEditScreenWebPage(string pageUrl, string pageListUrl)
{
var path = FileLocationUtilities.GetFileDistributedWithApplication(
Path.Combine(
BloomFileLocator.BrowserRoot,
"bookEdit",
ReactControl.ShouldUseViteDev()
? "WorkspaceRoot.vite-dev.html"
: "WorkspaceRoot.html"
)
);
// {simulatedPageFileInBookFolder} is placed in the template file where we want the source file for the 'page' iframe.
// We don't really make a file for the page, the contents are just saved in our local server.
// But we give it a url that makes it seem to be in the book folder so local urls work.
// See BloomServer.MakeInMemoryHtmlFileInBookFolder() for more details.
var frameText = RobustFile
.ReadAllText(path, Encoding.UTF8)
.Replace("{simulatedPageFileInBookFolder}", pageUrl)
.Replace("{simulatedPageListFile}", pageListUrl);
var dom = new HtmlDom(XmlHtmlConverter.GetXmlDomFromHtml(frameText));

if (_currentlyDisplayedBook.BookInfo.ToolboxIsOpen)
{
// Make the toolbox initially visible.
// What we have to do to accomplish this is pretty non-intuitive. It's a consequence of the way
// the pure-drawer CSS achieves the open/close effect. This input is a check-box, so clicking it
// changes the state of things in a way that all the other CSS can depend on.
var toolboxCheckBox = dom.SelectSingleNode("//input[@id='pure-toggle-right']");
toolboxCheckBox?.SetAttribute("checked", "true");
}

return dom;
}

/// <summary>
/// Return the top-level document that should be displayed in the browser for the current page.
/// </summary>
/// <returns></returns>
public HtmlDom GetXmlDocumentForEditScreenWebPage()
{
var path = FileLocationUtilities.GetFileDistributedWithApplication(
Path.Combine(
BloomFileLocator.BrowserRoot,
"bookEdit",
ReactControl.ShouldUseViteDev()
? "WorkspaceRoot.vite-dev.html"
: "WorkspaceRoot.html"
)
);
// {simulatedPageFileInBookFolder} is placed in the template file where we want the source file for the 'page' iframe.
// We don't really make a file for the page, the contents are just saved in our local server.
// But we give it a url that makes it seem to be in the book folder so local urls work.
// See BloomServer.MakeInMemoryHtmlFileInBookFolder() for more details.
var frameText = RobustFile
.ReadAllText(path, Encoding.UTF8)
.Replace("{simulatedPageFileInBookFolder}", GetUrlForCurrentPage())
.Replace("{simulatedPageListFile}", GetUrlForPageListFile());
var dom = new HtmlDom(XmlHtmlConverter.GetXmlDomFromHtml(frameText));

if (_currentlyDisplayedBook.BookInfo.ToolboxIsOpen)
{
// Make the toolbox initially visible.
// What we have to do to accomplish this is pretty non-intuitive. It's a consequence of the way
// the pure-drawer CSS achieves the open/close effect. This input is a check-box, so clicking it
// changes the state of things in a way that all the other CSS can depend on.
var toolboxCheckBox = dom.SelectSingleNode("//input[@id='pure-toggle-right']");
toolboxCheckBox?.SetAttribute("checked", "true");
}

return dom;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 EditingModel.cs adds two new GetXmlDocumentForEditScreenWebPage overloads that are never called

Two new methods were added to EditingModel.cs (lines 1353-1423): an internal overload taking pageUrl and pageListUrl parameters, and a public parameterless overload. Neither is called anywhere in the codebase (confirmed by grep). They duplicate the logic already in EditingView.cs for constructing the workspace root HTML. These appear to be scaffolding for a future change or were added by mistake. They are dead code and can be removed to avoid confusion.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +167 to +287
function getCanvasElementManagerForE2e(): CanvasElementManager {
if (!theOneCanvasElementManager) {
throw new Error("CanvasElementManager is not available.");
}

return theOneCanvasElementManager;
}

function getCanvasElementsForE2e(): HTMLElement[] {
return Array.from(
document.querySelectorAll(kCanvasElementSelector),
) as HTMLElement[];
}

let originalCanExpandToFillSpaceForE2e: (() => boolean) | undefined;

function e2eSetActiveCanvasElementByIndex(index: number): boolean {
const element = getCanvasElementsForE2e()[index];
if (!element) {
return false;
}

getCanvasElementManagerForE2e().setActiveElement(element);
return true;
}

function e2eSetActivePatriarchBubbleOrFirstCanvasElement(): boolean {
const manager = getCanvasElementManagerForE2e();
const patriarchBubble = manager.getPatriarchBubbleOfActiveElement?.();
const patriarchContent = patriarchBubble?.content as
| HTMLElement
| undefined;
if (patriarchContent) {
manager.setActiveElement(patriarchContent);
return true;
}

const firstCanvasElement = getCanvasElementsForE2e()[0];
if (!firstCanvasElement) {
return false;
}

manager.setActiveElement(firstCanvasElement);
return true;
}

function e2eDeleteLastCanvasElement(): void {
const elements = getCanvasElementsForE2e();
const lastElement = elements[elements.length - 1];
if (!lastElement) {
return;
}

const manager = getCanvasElementManagerForE2e();
manager.setActiveElement(lastElement);
manager.deleteCurrentCanvasElement();
}

function e2eDuplicateActiveCanvasElement(): void {
getCanvasElementManagerForE2e().duplicateCanvasElement();
}

function e2eDeleteActiveCanvasElement(): void {
getCanvasElementManagerForE2e().deleteCurrentCanvasElement();
}

function e2eClearActiveCanvasElement(): void {
getCanvasElementManagerForE2e().setActiveElement(undefined);
}

function e2eSetActiveCanvasElementBackgroundColor(
color: string,
opacity: number,
): void {
getCanvasElementManagerForE2e().setBackgroundColor([color], opacity);
}

function e2eGetActiveCanvasElementStyleSummary(): {
textColor: string;
outerBorderColor: string;
backgroundColors: string[];
} {
const manager = getCanvasElementManagerForE2e();
const textColorInfo = manager.getTextColorInformation?.();
const bubbleSpec = manager.getSelectedItemBubbleSpec?.();

return {
textColor: textColorInfo?.color ?? "",
outerBorderColor: bubbleSpec?.outerBorderColor ?? "",
backgroundColors: bubbleSpec?.backgroundColors ?? [],
};
}

function e2eResetActiveCanvasElementCropping(): void {
getCanvasElementManagerForE2e().resetCropping?.();
}

function e2eCanExpandActiveCanvasElementToFillSpace(): boolean {
return getCanvasElementManagerForE2e().canExpandToFillSpace();
}

function e2eOverrideCanExpandToFillSpace(value: boolean): boolean {
const manager = getCanvasElementManagerForE2e();
if (!originalCanExpandToFillSpaceForE2e) {
originalCanExpandToFillSpaceForE2e =
manager.canExpandToFillSpace.bind(manager);
}

manager.canExpandToFillSpace = () => value;
return true;
}

function e2eClearCanExpandToFillSpaceOverride(): void {
if (!originalCanExpandToFillSpaceForE2e) {
return;
}

const manager = getCanvasElementManagerForE2e();
manager.canExpandToFillSpace = originalCanExpandToFillSpaceForE2e;
originalCanExpandToFillSpaceForE2e = undefined;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 e2e test helpers and page-bundle e2e API surface shipped in production code

The editablePage.ts file now exports ~12 e2e* functions (lines 167-287) that directly manipulate CanvasElementManager internals (delete, duplicate, set active, override canExpandToFillSpace, etc.). These are exposed on the editablePageBundle window object and shipped in production builds. While not a correctness bug, they provide an attack surface for in-page scripts and add dead code to production. Consider gating these behind a build-time flag or environment check so they are stripped from release builds.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

@JohnThomson JohnThomson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson made 2 comments and resolved 31 discussions.
Reviewable status: 4 of 207 files reviewed, 6 unresolved discussions (waiting on hatton).


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts line 75 at r16 (raw file):

Previously, hatton (John Hatton) wrote…

[GPT-5.4] Cleaned this up in the current branch. CanvasElementManager now imports CanvasElementAlternates and CanvasElementPositioning as namespaces and calls those modules directly, instead of aliasing each import and wrapping it back to the original name. That keeps the public CanvasElementManager entry points where we still need them, but removes the rename-back noise you called out.

It fixed some of them, no doubt; but for example I still see seven methods imported with different names from CanvasElementSelectionUi, and at least some of them are then re-wrapped with the original names. Some do more than a completely trivial call of the renamed method, but it still smells. Maybe there's a good reason, but it's too hard to follow the call chain across files in Reviewable.


src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts line 149 at r20 (raw file):

Previously, hatton (John Hatton) wrote…

[GPT-5.4]: I went back and did the broader cleanup. Besides the earlier pxToNumber simplification, CanvasElementSelectionUi.ts and CanvasElementBackgroundImageManager.ts are now direct helper modules used from CanvasElementManager, so there are two fewer constructor-injected wrapper classes / host objects in the path. I left CanvasElementHandleDragInteractions.ts, CanvasElementPointerInteractions.ts, and CanvasElementEditingSuspension.ts as classes because they carry substantial mutable interaction state and event lifecycle, and flattening those looked more likely to make the code harder to follow rather than easier.

Still feels like a mess here. Maybe an interactive review will make sense of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants