diff --git a/.claude/skills/run-bloom/ai-image-editor-driving.md b/.claude/skills/run-bloom/ai-image-editor-driving.md
index 9aa5bb9248aa..64d4fc908006 100644
--- a/.claude/skills/run-bloom/ai-image-editor-driving.md
+++ b/.claude/skills/run-bloom/ai-image-editor-driving.md
@@ -62,15 +62,20 @@ Right-click the canvas element → **"Edit with AI…"**. Loading the iframe dir
`aiImageEditorOverlay.ts`'s postMessage handler, so the commit + current-page save path wouldn't run.
**The overlay does not appear on the same tick as the click.** The menu command posts
-`aiImageEditor/saveThenLaunch`, which makes C# save the current page — reloading the `page`
-frame — and then, *once the reloaded page reports back*, call
+`aiImageEditor/saveThenLaunch`, which makes C# save the current page and then call
`workspaceBundle.openAiImageEditor(...)` in the shell (BL-16682; the saved book DOM has to be
-current or the editor opens with an empty "Image to Edit" slot). So the wait spans a whole page
-load: **wait for the overlay**, e.g. `await page.waitForSelector("#ai-image-editor-overlay iframe")`,
-and don't re-use any content-frame handle taken before the click — that frame is gone.
-
-Note also that the overlay itself belongs to the **shell**, not the `page` frame, so it survives
-page reloads: a commit saves the page immediately and the ✕ keeps working across that reload.
+current or the editor opens with an empty "Image to Edit" slot). **Wait for the overlay**, e.g.
+`await page.waitForSelector("#ai-image-editor-overlay iframe")`.
+
+That save used to end in a navigation, so the wait spanned a whole page load and any content-frame
+handle taken before the click was stale. Since BL-13502 a save does not navigate: the page frame
+stays put and its handles stay good. Waiting for the overlay is still the right thing — the save
+and the round trip take a moment — but the page is no longer replaced underneath you. (Continuing
+to wait for a page load that no longer happens is precisely what once left the editor never
+opening; see the comment on `HandleSaveThenLaunch`.)
+
+Note also that the overlay itself belongs to the **shell**, not the `page` frame, so it survives a
+reload when one does happen.
(It does *not* survive a reload of the whole workspace root, which
`EditingView.StartNavigationToEditPage` does once `MemoryUtils.SystemIsShortOfMemory()` — Bloom's
own private bytes past ~2GB, i.e. a long session on a big book. That closes the overlay like any
diff --git a/.prettierignore b/.prettierignore
index 97b97fe00b1a..bd3f6facf240 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -16,3 +16,8 @@ src/BloomBrowserUI/bookEdit/OverflowChecker/*Fixture.html
# leave a dirty working tree, and prettier outright fails on the book .htm files. Leave the
# whole tree alone so the fetched fixtures match what Bloom writes.
output/testing-inputs/
+
+# The overflow checker's layout fixtures: the whitespace between their inline elements is
+# part of what OverflowChecker.browser.spec.ts measures, and prettier's reflow changes it
+# enough to turn its answers around.
+src/BloomBrowserUI/bookEdit/OverflowChecker/*Fixture.html
diff --git a/DistFiles/localization/en/BloomLowPriority.xlf b/DistFiles/localization/en/BloomLowPriority.xlf
index 57c8e0b0ce76..fbf7ee6ec319 100644
--- a/DistFiles/localization/en/BloomLowPriority.xlf
+++ b/DistFiles/localization/en/BloomLowPriority.xlf
@@ -406,6 +406,11 @@
ID: ImageUtils.ShrinkingImagesFailedShown as a toast when the one-time shrinking of an old book's oversized pictures fails. The book still works; the pictures are just left large, and Bloom retries later. "Bloom" is a product name and must not be translated.
+
+ Bloom is saving your book
+ ID: Shell.SavingBeforeWindowsShutsDown
+ Shown by Windows itself, next to Bloom's name on the screen Windows displays when an application is slow to let it shut down, restart or log off. It appears only if Bloom takes more than a few seconds to finish saving. "Bloom" is a product name and must not be translated.
+ {0} used of {1} account creditsID: AiImageEditor.Credits.AccountCreditsUsed
diff --git a/docs/save-without-reload/HANDOFF.md b/docs/save-without-reload/HANDOFF.md
new file mode 100644
index 000000000000..1ecf6861c626
--- /dev/null
+++ b/docs/save-without-reload/HANDOFF.md
@@ -0,0 +1,133 @@
+# BL-13502 handoff: saving a page without reloading it
+
+For an agent continuing PR #8209 (branch `BL-13502-save-without-reload`, base `master`) on another
+machine, most likely to answer a human reviewer. Everything here is either not in the code or easy
+to miss there. Delete this folder before the PR merges.
+
+## Where things are
+
+- **The PR:** https://github.com/BloomBooks/BloomDesktop/pull/8209. Its description (between the
+ `preflight-narrative` markers) is the current summary of what the PR does, why, the risks, e2e
+ coverage and Notion cards. Keep it current if the PR changes.
+- **The card:** https://issues.bloomlibrary.org/youtrack/issue/BL-13502. It carries the tester's
+ notes (a comment marked `test-ideas`).
+- **The design:** `src/BloomExe/Edit/SavingWithoutReloading.md`. Read this first.
+- **The preflight report** (for John, with his decisions so far):
+ https://bloombooks.github.io/dev-process-artifacts/deciders/BloomDesktop-BL-13502-save-without-reload.html,
+ in the `BloomBooks/dev-process-artifacts` repo at
+ `deciders/BloomDesktop-BL-13502-save-without-reload.html`.
+- **The e2e plan** this PR's tests came from: a PR comment,
+ https://github.com/BloomBooks/BloomDesktop/pull/8209#issuecomment-6025653325.
+- **Devin:** https://devinreview.com/BloomBooks/BloomDesktop/pull/8209. Every finding has been
+ mirrored to the PR and answered; nothing is open.
+
+## Decisions John has made (don't reopen them without a reason)
+
+- **Every save uses the snapshot the browser volunteers.** No request carries the page. A
+ snapshot means the page is idle unless it carries `stillBusyWith`.
+- **No retries.** A failed snapshot post is reported to the user, once per page, and is not
+ retried: "Failure should be a crash."
+- **Only the `.bloom-page` element is gathered and sent.** C# wraps it in a trivial body. Nothing
+ reads the body element's attributes.
+- **A click, command or exit within about 25 ms of a keystroke** saves the snapshot from before
+ that keystroke. This is accepted: nobody can click that fast.
+- **The wait for a busy page** is a plain sleep with a two-second cap (`PageSnapshot.WaitUntilIdle`).
+ The log names the work that was still running if the cap is reached.
+- **Attribute order is ignored** when deciding whether a page changed
+ (`SafeXmlNode.GetXmlIgnoringAttributeOrder`). It is a generic XML helper, which is why it lives
+ in `SafeXmlNode`, not `HtmlDom`.
+- **Optional branding images that are missing get no "missing image" alt text**
+ (`isBrandingImage` in `bloomImages.ts`). That text was being saved into book-wide data.
+- **Table editing markup is stripped from the saved copy by Bloom's own copy of bloom-table's
+ list** (`removeTableEditingMarkupFromClone` in `tableEditing.ts`). bloom-table's
+ `removeTableEditingArtifacts` also ends Paint Format and Border Brush mode, which a save that runs
+ on every change must not do, and it doesn't export its markup-only part. A unit test compares the
+ two. If bloom-table ever exports that part, call it instead.
+- **Any request that makes C# save the page and then reload it** must first send the page:
+ `saveChangesAndRethinkPage` or `postAfterSendingSnapshot` in `pageSnapshot.ts`. A feature merged
+ from master that posts such a request directly will lose the last moments of typing. The
+ original-copyright sentence's unlock and relock needed this when master was merged on 10-09.
+- **The flaky test fix is in this PR.** `Xml_DoesNotProvide_ThreadSafety` was flaky on master;
+ John asked for the fix to go in here.
+- **Left alone on purpose:** bloom-player's `prepareActivity` leaves `touch-action: none` on
+ draggables, so playing a game page and then saving writes it into the book. It predates this
+ PR, and the tester's notes say not to report it.
+- **Comments say why the code is as it is now.** No history, except where old books can still
+ contain the older DOM.
+- **Fixed waits in e2e tests** are allowed only for a specific, known interval. The one in use is
+ `waitForPageToSettle`, which waits for the overflow checker's 1000 ms timer and then for the
+ page to have nothing left to send.
+
+## Running things on this branch
+
+- **C#:** build and test through `build/agent-dotnet.sh`, never bare `dotnet`. It builds into an
+ isolated folder.
+- **Bloom:** start it through the run-bloom skill. The launcher script is
+ `.claude/skills/run-bloom/launcherControl.mjs`, not `.github/skills/...`.
+- **E2E tests (`src/BloomE2E`)** run the `Bloom.exe` in `output/Debug`, not the isolated build. So:
+ 1. Rebuild with `launcherControl.mjs --restart --wait-ready`.
+ 2. Quit that Bloom with `launcherControl.mjs --quit-bloom`; it blocks a second Bloom. Vite stays
+ up.
+ 3. Run `node build/get-testing-inputs.mjs` once.
+ 4. Run the specs with `BLOOM_E2E_VITE_PORT=` and
+ `BLOOM_AUTOMATION_MONITOR=headless`:
+ `pnpm exec playwright test tests/ --reporter=line`.
+
+ The fixture refuses a build older than any C# source.
+ After merging master, also run `pnpm run build:pageSizes` in `src/content`. It writes
+ `output/browser/pageSizesLookup.json`, which C# needs (about 30 C# tests fail without it). The
+ full front-end build would make it too, but agents must not run that. The commit hook reformats C# files
+ (csharpier), which can make the build look stale after a commit: rebuild, then rerun.
+- **The new specs:**
+ - `typing-survives-leaving-the-page`
+ - `saved-book-stays-clean`
+ - `change-layout-keeps-typing`
+ - `duplicate-page-many-times`
+
+ They have 16 tests, all passing.
+- **The older specs that touch this code:**
+ - `duplicate-page`
+ - `copy-page`
+ - `workspace-tabs`
+ - `toolbox-tools`
+ - `talking-book-paste`
+ - `rotate-and-flip-images`
+ - `capture-book-page`
+
+ All 28 of their tests pass.
+- **E2E tests that type and then leave the page** need `waitForBloomToHaveTyping` in between, as
+ a person's pause would provide. Master's specs, written before this PR, may not have it; on
+ 10-09, `original-copyright-sentence.spec.ts` needed it.
+- **Quitting Bloom from a test:** `quitAndRestart` in the fixture posts `WM_CLOSE` to Bloom's
+ visible windows. In `--dont-disturb` mode the main window has a hidden owner, so `taskkill`
+ without `/F` and `CloseMainWindow` both miss it.
+- **CI:** the only PR workflow is `pr-automation`, which just starts Devin. On 2026-10-09 GitHub
+ started no run for the pushes that day. If Devin has no job for the head commit, start it by
+ loading the review page (see the `devin-review` skill).
+
+## Notion test cards
+
+Database "Test Case Runs", `38c4bb19-df12-8123-8bc8-e65b962cb12f`. Token: the
+`BLOOM_TESTCASE_NOTION` user environment variable, the secret of the internal integration
+`test-management-access`. Use the REST API directly (Notion-Version `2022-06-28`).
+
+The cards this PR automates are all set to Automation = `PR Pending`:
+
+- #659 Quit Without Losing Changes
+- #663 Clean Saved Book HTML
+- #841 Typing Survives Leaving the Page
+- #842 Looking at Pages Does Not Rewrite the Book
+- #843 Change Layout Keeps Typing
+- #844 Duplicate Page Many Times
+
+When the PR merges, set them to `Automated`. #366 notes that #843 covers part of it. #810's Many
+Times step moved to #844. Ask John before creating or changing cards.
+
+## Conventions for posting
+
+- **Attribution:** comments and replies posted under John's account start with a model tag, e.g.
+ `[Claude Opus 5.5 from John Thomson's machine]`.
+- **Devin:** never post an `@devin` mention.
+- **Human reviewers:** a fix you make at a reviewer's suggestion gets a short reply saying what
+ changed, then the thread is resolved. Disagreeing with a human goes to John first.
+- **Commits** end with the `Co-Authored-By` line for the model in use.
diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts
index 685b5f4c35ba..d073dd471da9 100644
--- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts
+++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts
@@ -44,6 +44,7 @@ import { RenderCanvasElementRoot } from "./CanvasElementFormatPage";
import { CanvasElementManager } from "../js/canvasElementManager/CanvasElementManager";
import { kCanvasElementSelector } from "../toolbox/canvas/canvasElementConstants";
import { getPageIFrame } from "../../utils/shared";
+import { getEditablePageBundleExports } from "../js/workspaceFrames";
// Controls the CSS text-align value
// Note: CSS text-align W3 standard does not specify "start" or "end", but Firefox/Chrome/Edge do support it.
@@ -587,6 +588,15 @@ export default class StyleEditor {
if (styleSheet == null) {
return null;
}
+ if (create) {
+ // A caller asking us to create the rule is about to change it through the CSSOM, which
+ // the page watcher cannot see (see notePageContentMayHaveChanged). Every such change
+ // passes through here. Saying so before the change is fine: the watcher reads the page
+ // after a delay, and the caller's edits are synchronous.
+ // Through the page frame's exports rather than a direct import, because this class is
+ // also used from the toolbox frame, and it is the PAGE frame that watches.
+ getEditablePageBundleExports()?.notePageContentMayHaveChanged();
+ }
let ruleList: CSSRuleList = styleSheet.cssRules;
if (ruleList == null) {
diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md b/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md
index 59bd487b8b61..3f10db88f09a 100644
--- a/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md
+++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md
@@ -38,12 +38,12 @@ So:
is there specifically so the two halves cannot disagree about which commit results belong to the
page being edited.
-The overlay is in the top window because a page save reloads the page iframe, which would tear
-down an overlay hosted there — the same reason the image-gallery and copyright/license dialogs
-live up there (see the comments on those commands in `canvasControlRegistry.ts`). Note that this
-is *not* enough on its own: a save can also reload the whole workspace root, so C# waits for the
-page to come back before opening the overlay. `AiImageEditorApi.HandleSaveThenLaunch` explains
-that in full.
+The overlay is in the top window, like the image-gallery and copyright/license dialogs (see the
+comments on those commands in `canvasControlRegistry.ts`): plenty of operations replace the page
+iframe underneath it. Opening it first saves the page, because everything the editor is told about
+the book is read from the saved file; a save no longer reloads the page (BL-13502), so the editor
+opens as soon as the save returns, and does not open at all if the save did not happen.
+`AiImageEditorApi.HandleSaveThenLaunch` explains that in full.
## Analytics: Bloom forwards the AI Image Editor's events and knows nothing about them
diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts
index f41f0a5cd095..01bf464bcaf7 100644
--- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts
+++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts
@@ -9,9 +9,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
// - The edit target. C# hands over the page id and file name of the image the user
// right-clicked (it survived a page save, which reloaded the page frame), and the overlay
// matches that against the book image list to fill the "Image to Edit" slot (BL-16682).
-// - NOT saving after a commit. A current-page swap lives only in the live page, like a pasted
-// picture, and is saved by the normal page save when the user moves on. Saving at once
-// would reload the page frame and discard the undo the swap registered (BL-16330).
+// - NOT saving after a commit. A current-page swap lives in the live page DOM only, like an
+// image pasted or chosen from the gallery, and is persisted the same way: by the normal page
+// save when the user moves on (BL-16330). A retry from this still-open overlay would read a
+// stale oldSrc, which the page frame handles by remembering what it already swapped rather
+// than by saving here.
const post = vi.fn();
const postJson = vi.fn();
@@ -376,7 +378,7 @@ describe("aiImageEditorOverlay: the live page is NOT saved after a commit", () =
};
expect(ack.ok).toBe(true);
expect(ack.error).toBeUndefined();
- // Nothing landed on this page, so nothing to save.
+ // We never save from here at all -- see the note at the top of this file.
expect(postThatMightNavigate).not.toHaveBeenCalled();
expect(applyAiImageEditorReplacements).not.toHaveBeenCalled();
postMessageToEditor.mockRestore();
@@ -402,7 +404,7 @@ describe("aiImageEditorOverlay: the live page is NOT saved after a commit", () =
expect(ack.ok).toBe(false);
expect(ack.error).toContain("not available");
expect(ack.error).toContain("other pages were made");
- // Nothing landed, so nothing to save.
+ // We never save from here at all -- see the note at the top of this file.
expect(postThatMightNavigate).not.toHaveBeenCalled();
postMessageToEditor.mockRestore();
});
diff --git a/src/BloomBrowserUI/bookEdit/duplicateManyDialog.entry.tsx b/src/BloomBrowserUI/bookEdit/duplicateManyDialog.entry.tsx
deleted file mode 100644
index 31367e91b391..000000000000
--- a/src/BloomBrowserUI/bookEdit/duplicateManyDialog.entry.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import { bootstrapReactComponent } from "../utils/entryPointBootstrap";
-import { DuplicateManyDialog } from "./duplicateManyDialog";
-
-bootstrapReactComponent(DuplicateManyDialog);
diff --git a/src/BloomBrowserUI/bookEdit/duplicateManyDialog.tsx b/src/BloomBrowserUI/bookEdit/duplicateManyDialog.tsx
index 583ffb3bfe17..d9bde147e087 100644
--- a/src/BloomBrowserUI/bookEdit/duplicateManyDialog.tsx
+++ b/src/BloomBrowserUI/bookEdit/duplicateManyDialog.tsx
@@ -3,9 +3,11 @@ import { IBloomDialogEnvironmentParams } from "../react_components/BloomDialog/B
import { useL10n } from "../react_components/l10nHooks";
import { NumberChooserDialog } from "../react_components/numberChooserDialog";
import { postData } from "../utils/bloomApi";
-import { WireUpForWinforms } from "../utils/WireUpWinform";
+import { ShowEditViewDialog } from "./workspaceRoot";
export const DuplicateManyDialog: React.FunctionComponent<{
+ // The page the dialog was opened for. C# duplicates only if it is still the current page.
+ pageId: string;
dialogEnvironment?: IBloomDialogEnvironmentParams;
}> = (props) => {
const title = useL10n(
@@ -26,6 +28,7 @@ export const DuplicateManyDialog: React.FunctionComponent<{
const clickHandler = (value: number) => {
postData("editView/duplicatePageMany", {
numberOfTimes: value,
+ pageId: props.pageId,
});
};
return (
@@ -40,4 +43,16 @@ export const DuplicateManyDialog: React.FunctionComponent<{
);
};
-WireUpForWinforms(DuplicateManyDialog);
+// Shown in the Edit tab, from the page list's context menu, so nothing reaches C# until the user
+// clicks OK. (A C# modal opened from the page list's request would hold the API lock.)
+export function showDuplicateManyDialog(pageId: string) {
+ ShowEditViewDialog(
+ ,
+ );
+}
diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts
index 50331cb1adc7..ccc8bb1300d6 100644
--- a/src/BloomBrowserUI/bookEdit/editablePage.ts
+++ b/src/BloomBrowserUI/bookEdit/editablePage.ts
@@ -17,6 +17,12 @@ import {
} from "./js/canvasElementManager/CanvasElementManager";
import { kCanvasElementSelector } from "./toolbox/canvas/canvasElementConstants";
import { renderDragActivityTabControl } from "./js/AbovePageControls";
+import {
+ getPageLoadId,
+ isSnapshotStreamIdle,
+ notePageContentMayHaveChanged,
+ startWatchingPageForSnapshots,
+} from "./js/pageSnapshot";
import { tableHistoryManager } from "bloom-table";
import {
getCkeditorChangeOrder,
@@ -39,8 +45,20 @@ function getPageId(): string {
// This notification lets the C# know that this partciular page is ready to edit.
// It is important that this does not get pulled into any other compiled bundle,
// since it will generate errors when loaded into any page that does not have a .bloom-page.
+//
+// The load id goes with it: once C# answers, it accepts snapshots only from this load (see
+// getPageLoadId()), so the page snapshot waits for that answer before it starts.
+let pageDomLoadedAnswered: Promise | undefined;
+function tellCSharpPageDomLoaded(): Promise {
+ if (!pageDomLoadedAnswered)
+ pageDomLoadedAnswered = postJson("editView/pageDomLoaded", {
+ pageId: getPageId(),
+ loadId: getPageLoadId(),
+ });
+ return pageDomLoadedAnswered;
+}
document.addEventListener("DOMContentLoaded", () => {
- postString("editView/pageDomLoaded", getPageId());
+ void tellCSharpPageDomLoaded();
});
// This allows strong typing to be done for exported functions.
@@ -54,8 +72,14 @@ document.addEventListener("DOMContentLoaded", () => {
// but I think it is unwise. It is so easy for an extra file to get imported into another bundle,
// and then it will bring this along, with disastrous results.
export interface IPageFrameExports {
- requestPageContent(): void;
+ // The combined "page userCss" string that a save needs, gathered without
+ // disturbing the live page.
+ getPageContentForSaveWhenReady(): Promise;
pageUnloading(): void;
+ // See pageSnapshot.ts.
+ notePageContentMayHaveChanged(): void;
+ // See pageSnapshot.ts. For the e2e suite.
+ isSnapshotStreamIdle(): boolean;
copySelection(): void;
cutSelection(): void;
pasteClipboard(): void;
@@ -121,12 +145,10 @@ export interface IPageFrameExports {
}
// This exports the functions that should be accessible from other IFrames or from C#.
-// For example, workspaceBundle.getEditablePageBundleExports().requestPageContent() can be called.
+// For example, workspaceBundle.getEditablePageBundleExports().pageUnloading() can be called.
import {
- getBodyContentForSavePage,
- requestPageContent,
+ getPageContentForSaveWhenReady,
captureContentForExternalProcessing,
- userStylesheetContent,
pageUnloading,
topBarButtonClick,
copySelection,
@@ -140,9 +162,11 @@ import {
changeImageByElement,
imageOperationCanUndo,
imageOperationUndo,
+} from "./js/bloomEditing";
+import {
addRequestPageContentDelay,
removeRequestPageContentDelay,
-} from "./js/bloomEditing";
+} from "./js/pageContentDelays";
import { showGamePromptDialog } from "./toolbox/games/GameTool";
// Called from the AI Image Editor overlay in the top window, which owns the session but
// cannot touch this page itself; see aiImageEditorPageCommands.ts and aiImageEditorOverlay.ts.
@@ -156,11 +180,11 @@ import type {
} from "./aiImageEditor/aiImageEditorShared";
import type { IPageMetrics } from "./js/imageTargetResolution";
export {
- getBodyContentForSavePage,
- requestPageContent,
+ getPageContentForSaveWhenReady,
captureContentForExternalProcessing,
- userStylesheetContent,
pageUnloading,
+ notePageContentMayHaveChanged,
+ isSnapshotStreamIdle,
topBarButtonClick,
copySelection,
cutSelection,
@@ -182,7 +206,7 @@ export {
getAiImageEditorPageMetrics,
};
import { origamiCanUndo, origamiUndo } from "./js/origami";
-import { postString } from "../utils/bloomApi";
+import { postJson } from "../utils/bloomApi";
export { origamiCanUndo, origamiUndo };
const styleSheets = [
@@ -436,11 +460,11 @@ window["PasteImageCredits"] = () => {
//UnlockOriginalCredits() is called from the hint bubble on the credits page's
//generated original-copyright sentence. See BookCopyrightAndLicense.SetOriginalCopyrightNoticeHint().
window["UnlockOriginalCredits"] = () => {
- EditableDivUtils.unlockOriginalCredits();
+ void EditableDivUtils.unlockOriginalCredits();
};
window["RelockOriginalCredits"] = () => {
- EditableDivUtils.relockOriginalCredits();
+ void EditableDivUtils.relockOriginalCredits();
};
$(document).ready(() => {
@@ -456,6 +480,13 @@ $(document).ready(() => {
// in the live editor, which never reads this flag.
window.__bloomEditablePageReady = true;
+ // Start sending the page's content to C# (see pageSnapshot.ts), but not until C# has accepted
+ // this load, or it would refuse what we send. (The notice normally went at DOMContentLoaded;
+ // asking again just returns that post.)
+ void tellCSharpPageDomLoaded().then(() =>
+ startWatchingPageForSnapshots(getPageContentForSaveWhenReady),
+ );
+
// If the user clicks outside of the page thumbnail context menu, we want to close it.
// Since it is currently a winforms menu, we do that by sending a message
// back to c#-land. We have a similar listener in the pageThumbnailList itself.
@@ -473,11 +504,10 @@ export function SayHello() {
// Legacy global exposure: mimic old webpack window["editablePageBundle"] contract used by other iframes / C#
// NOTE: Keep this as a minimal curated surface: only expose functions intentionally callable cross-frame.
interface EditablePageBundleApi {
- requestPageContent: typeof requestPageContent;
captureContentForExternalProcessing: typeof captureContentForExternalProcessing;
- getBodyContentForSavePage: typeof getBodyContentForSavePage;
- userStylesheetContent: typeof userStylesheetContent;
+ getPageContentForSaveWhenReady: typeof getPageContentForSaveWhenReady;
pageUnloading: typeof pageUnloading;
+ notePageContentMayHaveChanged: typeof notePageContentMayHaveChanged;
copySelection: typeof copySelection;
cutSelection: typeof cutSelection;
pasteClipboard: typeof pasteClipboard;
@@ -512,6 +542,7 @@ interface EditablePageBundleApi {
e2eCanExpandActiveCanvasElementToFillSpace: typeof e2eCanExpandActiveCanvasElementToFillSpace;
e2eOverrideCanExpandToFillSpace: typeof e2eOverrideCanExpandToFillSpace;
e2eClearCanExpandToFillSpaceOverride: typeof e2eClearCanExpandToFillSpaceOverride;
+ isSnapshotStreamIdle: typeof isSnapshotStreamIdle;
SayHello: typeof SayHello;
renderDragActivityTabControl: typeof renderDragActivityTabControl;
showGamePromptDialog: typeof showGamePromptDialog;
@@ -537,8 +568,8 @@ declare global {
// the finished page onto window.__bloomExternalPageContent.
// 3. C# polls window.__bloomExternalPageContent until it is non-empty and reads it back.
//
- // Why globals + polling, rather than posting to the editView/pageContent API the way the live
- // editor's requestPageContent() does:
+ // Why globals + polling, rather than posting to the editView/pageSnapshot API the way the
+ // live editor does:
// - That API feeds the live EditingModel; reusing it off-screen would corrupt the real
// editor's state. We want the same page-cleanup output, delivered out-of-band.
// - C#'s JS runner on this path (RunJavascriptWithStringResult_Sync_Dangerous) is
@@ -551,17 +582,16 @@ declare global {
//
// Step 1's flag: set in $(document).ready below; read in BookProcessor.ProcessOnePage.
__bloomEditablePageReady?: boolean;
- // Step 2/3's mailbox: the combined "bodyuserCss" string, or "ERROR: ".
+ // Step 2/3's mailbox: the combined "pageuserCss" string, or "ERROR: ".
__bloomExternalPageContent?: string;
}
}
window.editablePageBundle = {
- requestPageContent,
captureContentForExternalProcessing,
- getBodyContentForSavePage,
- userStylesheetContent,
+ getPageContentForSaveWhenReady,
pageUnloading,
+ notePageContentMayHaveChanged,
copySelection,
cutSelection,
pasteClipboard,
@@ -596,6 +626,7 @@ window.editablePageBundle = {
e2eCanExpandActiveCanvasElementToFillSpace,
e2eOverrideCanExpandToFillSpace,
e2eClearCanExpandToFillSpaceOverride,
+ isSnapshotStreamIdle,
SayHello,
renderDragActivityTabControl,
showGamePromptDialog,
diff --git a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx
index d0d4a918199d..2635d636068d 100644
--- a/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx
+++ b/src/BloomBrowserUI/bookEdit/js/AbovePageControls.tsx
@@ -50,7 +50,7 @@ export function updateAbovePageControls(
export function resetAbovePageControls(): void {
currentState = defaultState;
// Off-screen (e.g. process-book) there is no workspace frame, so this is a no-op there.
- // (Reached via removeEditingDebris() in the shared extractAndStripPageContentForSave() save path.)
+ // (Reached via pageUnloading() in bloomEditing.ts.)
tryGetWorkspaceBundleExports()?.setToolboxEnabled(true);
const container = document.getElementsByClassName(
diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
index 25582ca732aa..cfee03a93b87 100644
--- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
+++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
@@ -20,8 +20,10 @@ import {
} from "./bloomVideo";
import { SetupWidgetEditing } from "./bloomWidgets";
import { setupOrigami, cleanupOrigami } from "./origami";
-import { SetupTableEditing, TeardownTableEditing } from "./tableEditing";
-import { removeTableEditingArtifacts } from "bloom-table";
+import {
+ SetupTableEditing,
+ removeTableEditingMarkupFromClone,
+} from "./tableEditing";
import theOneLocalizationManager from "../../lib/localizationManager/localizationManager";
import StyleEditor from "../StyleEditor/StyleEditor";
import OverflowChecker from "../OverflowChecker/OverflowChecker";
@@ -29,6 +31,13 @@ import BloomField from "../bloomField/BloomField";
import BloomNotices from "./bloomNotices";
import BloomSourceBubbles from "../sourceBubbles/BloomSourceBubbles";
import BloomHintBubbles from "./BloomHintBubbles";
+import {
+ addRequestPageContentDelay,
+ getActiveDelayIds,
+ removeRequestPageContentDelay,
+ whenNoActiveDelays,
+ wrapWithRequestPageContentDelay,
+} from "./pageContentDelays";
import {
CanvasElementManager,
initializeCanvasElementManager,
@@ -61,7 +70,7 @@ import { showInvisibles, hideInvisibles } from "./showInvisibles";
//import promise = require('es6-promise');
//promise.Promise.polyfill();
import axios from "axios";
-import { post, postBoolean, postJson, postString } from "../../utils/bloomApi";
+import { post, postBoolean, postJson } from "../../utils/bloomApi";
import { showRequestStringDialog } from "../../react_components/RequestStringDialog";
import { hookupLinkHandler } from "../../utils/linkHandler";
@@ -77,6 +86,9 @@ import {
} from "./pageContentCapturePolicy";
import { setupDragActivityTabControl } from "../toolbox/games/GameTool";
import { addScrollbarsToPage, cleanupNiceScroll } from "bloom-player";
+import { removeNiceScrollArtifacts } from "./niceScrollCleanup";
+import { removeEditorChromeFromClone } from "./editorChromeCleanup";
+import { sendSnapshotNow, stopWatchingPageForSnapshots } from "./pageSnapshot";
import { setupBookLinkGrids } from "./linkGrid";
import { fitImageOverTextSplits } from "./autoFitImageOverTextSplits";
import PlaceholderProvider from "./PlaceholderProvider";
@@ -174,6 +186,9 @@ function Cleanup() {
cleanupImages();
cleanupOrigami();
+ // The live page, so we want bloom-player's version: it tears down the niceScroll instances
+ // themselves, not just the traces they leave in the DOM (which is all removeNiceScrollArtifacts
+ // can do, since that has to work on a detached clone).
cleanupNiceScroll();
}
@@ -1353,204 +1368,130 @@ export function localizeCkeditorTooltips(bar: JQuery) {
});
}
-// This is invoked when we are about to change pages.
-function removeEditingDebris() {
- resetAbovePageControls();
- // We are mirroring the Change Layout mode toggle behavior here, in case the user changes
- // pages while the Change Layout mode toggle is on.
+// Take the editing-only markup out of 'clonedPage', the detached copy we are about to save.
+function removeEditingDebrisFromClone(clonedPage: HTMLElement) {
+ // We are mirroring the Change Layout mode toggle behavior here, in case the user saves
+ // while the Change Layout mode toggle is on.
// The DOM here is for just one page, so there's only ever one marginBox.
- const marginBox = document.getElementsByClassName("marginBox")[0];
+ const marginBox = clonedPage.getElementsByClassName("marginBox")[0];
marginBox.classList.remove("origami-layout-mode");
- const textLabels = marginBox.getElementsByClassName("textBox-identifier");
- for (let i = 0; i < textLabels.length; i++) {
- textLabels[i].remove();
- }
- removeTransientVideoTimestampParams(document.body);
- removeTableEditingArtifacts(document);
- TeardownTableEditing(document.body);
- cleanupNiceScroll(); // don't leave the nicescroll debris around
-}
-
-// Delay notification management for requestPageContent
-const activeDelays: string[] = [];
-// Upper bound (not a fixed wait) on how long we wait for in-flight async DOM work
-// (image sizing, canvas-element layout, etc.) to finish before capturing anyway. The
-// wait ends as soon as activeDelays empties, so simple pages are unaffected by this value;
-// it only gives slower computers with complex pages more headroom before we give up.
-const kMaxWaitTimeMs = 4000;
-let requestPageContentTimeout: number | null = null;
-
-// Add a delay notification that will prevent requestPageContent from running immediately.
-// The caller must provide a string ID and pass it to removeRequestPageContentDelay when done.
-// IDs do not need to be unique; the same ID can be added multiple times.
-export function addRequestPageContentDelay(id: string): void {
- activeDelays.push(id);
-}
-
-// Remove a delay notification, allowing requestPageContent to proceed if no other delays are active.
-// If this was the last delay, proceed with requesting page content.
-export function removeRequestPageContentDelay(id: string): void {
- const index = activeDelays.indexOf(id);
- if (index === -1) {
- console.error(
- `removeRequestPageContentDelay: ID "${id}" not found in active delays. Active delays: [${activeDelays.join(
- ", ",
- )}]`,
- );
- return;
- }
- activeDelays.splice(index, 1);
-
- // If there are no more delays, go on and request page content.
- if (activeDelays.length === 0 && requestPageContentTimeout) {
- requestPageContentInternal();
- }
-}
-
-// Wrap a function that returns a promise with delay management.
-// The delay is added before the function is called, and removed when the promise settles (resolves or rejects).
-// This ensures that requestPageContent waits for the async operation to complete before saving the page.
-export async function wrapWithRequestPageContentDelay(
- fn: () => Promise,
- delayId: string,
-): Promise {
- addRequestPageContentDelay(delayId);
- try {
- const result = await fn();
- removeRequestPageContentDelay(delayId);
- return result;
- } catch (error) {
- removeRequestPageContentDelay(delayId);
- throw error;
- }
-}
-
-// This is invoked from C# to get the current page content when we want to save it. It removes markup we don't want to save.
-// Then it calls an API with the information we need to save. This works around the lack of a
-// non-async runJavascript API in WebView2.
-//
-// When other javascript code is doing something that will change the page DOM asynchronously and will also cause the
-// document to be saved, race conditions are possible. In such cases the delay functions above
-// (preferably wrapWithRequestPageContentDelay) should be used to wrap the asynchronous DOM changes to ensure that this
-// function does not return the page content for saving until after the changes have been completed.
-// The current delay mechanism is not designed to handle multiple concurrent requests.
-export function requestPageContent() {
- // Check if there are active delay requests.
- if (activeDelays.length > 0) {
- requestPageContentTimeout = window.setTimeout(() => {
- console.warn(
- `requestPageContent: Maximum wait time (${kMaxWaitTimeMs}ms) exceeded with active delay(s): [${activeDelays.join(
- ", ",
- )}]. Proceeding anyway.`,
- );
- requestPageContentInternal();
- }, kMaxWaitTimeMs);
- } else {
- requestPageContentInternal();
+ for (const textLabel of Array.from(
+ marginBox.getElementsByClassName("textBox-identifier"),
+ )) {
+ textLabel.remove();
}
+ removeTransientVideoTimestampParams(clonedPage);
+ removeTableEditingMarkupFromClone(clonedPage);
+ removeEditorChromeFromClone(clonedPage);
}
-// Run the load-time cleanup and return the page body + user stylesheet combined with the
-// delimiter that C# splits on. Shared by the live save path (requestPageContentInternal)
-// and the off-screen capture path (captureContentForExternalProcessing) so the cleanup steps and the
-// delimiter can't drift between them.
+// Return the page element + user stylesheet combined with the delimiter that C# splits
+// on. Shared by the live editor's gathers (getPageContentForSaveWhenReady) and the off-screen
+// capture path (captureContentForExternalProcessing), so the cleanup steps and the delimiter can't
+// drift between them.
//
-// DESTRUCTIVE READ: this mutates the live DOM as a side effect (removeToolboxMarkup(),
-// removeEditingDebris(), and getBodyContentForSavePage() all strip classes, blur elements, turn off
-// canvas-element editing, and do CKEditor cleanup) and does NOT restore it afterward. Both current
-// callers tolerate this: the live editor re-navigates the page after saving, and the off-screen path
-// navigates to the next page afterwards. Don't call this from a context where the page must stay
-// live and editable afterward.
-function extractAndStripPageContentForSave(): string {
- // Record how much of the page each image slot covers, while the page is still laid out.
- // That is the only record of it: the saved HTML otherwise says nothing about how big
- // anything ends up on screen, so without this the AI image editor could not tell what size
- // an image on any page but the open one ought to be. Never throws out: a missing size hint
- // must not cost the user their page.
- try {
- recordFractionOfPageOnImageSlots(document.body);
- } catch (e) {
- console.error("recordFractionOfPageOnImageSlots failed: ", e);
- }
-
- // The toolbox is in a separate iframe, hence the call to getToolboxBundleExports(). (Off-screen,
- // e.g. process-book, there is no toolbox iframe, so this is a no-op there.)
- getToolboxBundleExports()?.removeToolboxMarkup();
- removeEditingDebris();
- const content = getBodyContentForSavePage();
+// Deliberately NOT exported: every caller should come through getPageContentForSaveWhenReady() (or
+// the off-screen path, which does its own waiting), so that nobody can gather the page while
+// asynchronous work that belongs in it is still running. It is also deliberately synchronous, so
+// that no other event handler can run part way through capturing the page.
+function getPageContentForSave(): string {
+ const content = getPageHtmlForSave();
const userStylesheet = userStylesheetContent();
// (We tossed up whether to use a JSON object instead of a delimiter, but combining two strings is
// simpler: HTML needs escaping to live in JSON, which we'd then have to undo in C#.)
return content + "" + userStylesheet;
}
-function requestPageContentInternal() {
- if (requestPageContentTimeout !== null) {
- clearTimeout(requestPageContentTimeout);
- }
- requestPageContentTimeout = null;
- try {
- postString("editView/pageContent", extractAndStripPageContentForSave());
- } catch (e) {
- postString(
- "editView/pageContent",
- "ERROR: " +
- e.message +
- "\n" +
- e.stack +
- "\n\n" +
- `document ${document ? "exists" : "does not exist"}` +
- "\n" +
- "body.innerHTML: " +
- document?.body?.innerHTML,
- );
- }
+// The way anything outside this file gets the current page's content: wait for any in-flight async
+// DOM work that belongs in the saved page, then gather. Nothing is awaited between the two, so no
+// timer can start new work between our finding the register empty and our reading the page.
+export async function getPageContentForSaveWhenReady(): Promise {
+ await whenNoActiveDelays();
+ return getPageContentForSave();
}
-// Caution: We don't want this to become an async method because we don't want
-// any other event handlers running between cleaning up the page and
-// getting the content to save. (Or think hard before changing that.)
-export function getBodyContentForSavePage() {
- if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(document.body)) {
+// Produce the HTML of the .bloom-page element as it should be saved. All the cleanup is done on a
+// CLONE, so the live page is untouched and stays editable without a reload (BL-13502).
+//
+// Caution: We don't want this to become an async method because we don't want any other event
+// handlers running between cleaning up the page and getting the content to save. (Or think hard
+// before changing that.)
+function getPageHtmlForSave() {
+ const livePage = document.querySelector(".bloom-page");
+ if (!livePage) {
throw new Error(
- "getBodyContentForSavePage(): The page had origami when it loaded, but it doesn't now (check before cleanup). BL-13120",
+ "getPageHtmlForSave(): there is no .bloom-page to save.",
);
}
-
- const canvasElementEditingOn =
- theOneCanvasElementManager.isCanvasElementEditingOn;
- if (canvasElementEditingOn) {
- theOneCanvasElementManager.turnOffCanvasElementEditing();
- }
- // Active element should be forced to blur
- if (document.activeElement instanceof HTMLElement) {
- document.activeElement.blur();
+ if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(livePage)) {
+ throw new Error(
+ "getPageHtmlForSave(): The page had origami when it loaded, but it doesn't now (check before cleanup). BL-13120",
+ );
}
- const editableDivs = (
- Array.from(document.querySelectorAll("div.bloom-editable"))
- );
+ // Deliberately do NOT blur the active element: that would throw the user's cursor out of the
+ // box they are typing in on every snapshot. CKEditor's getData() gives the up-to-date text
+ // without a blur.
- // We don't think we need to create ckEditor bookmarks and restore the selection
- // in this case because we are just saving the page.
- // In fact, it was causing problems when we were using them at one point.
- // (unfortunately, I don't remember what those problems were...).
- const createCkEditorBookMarks = false;
- EditableDivUtils.doCkEditorCleanup(editableDivs, createCkEditorBookMarks);
+ // Only the page element is saved (C# keeps nothing else), so only it is copied and cleaned.
+ // Everything the editor puts outside it -- CKEditor's toolbars, qTip's bubbles, menus, scratch
+ // elements -- never reaches C# at all.
+ const clonedPage = livePage.cloneNode(true) as HTMLElement;
+ cleanCloneOfPageForSave(livePage, clonedPage);
- if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(document.body)) {
+ if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(clonedPage)) {
throw new Error(
- "getBodyContentForSavePage(): The page had origami when it loaded, but it doesn't now (check after cleanup). BL-13120",
+ "getPageHtmlForSave(): The page had origami when it loaded, but it doesn't now (check after cleanup). BL-13120",
);
}
- const result = document.body.innerHTML;
+ return clonedPage.outerHTML;
+}
- if (canvasElementEditingOn) {
- theOneCanvasElementManager.turnOnCanvasElementEditing();
+// Do all the "strip the editing markup" work on 'clonedPage', a detached deep copy of 'livePage'.
+// Nothing here may touch the live page.
+function cleanCloneOfPageForSave(
+ livePage: HTMLElement,
+ clonedPage: HTMLElement,
+) {
+ // Record how much of the page each image slot covers, measured on the live page (the clone
+ // has no layout) and written into the clone. That is the only record of it: the saved HTML
+ // otherwise says nothing about how big anything ends up on screen, so without this the AI
+ // image editor could not tell what size an image on any page but the open one ought to be.
+ // Never throws out: a missing size hint must not cost the user their page.
+ try {
+ recordFractionOfPageOnImageSlots(livePage, clonedPage);
+ } catch (e) {
+ console.error("recordFractionOfPageOnImageSlots failed: ", e);
}
- return result;
+ // CKEditor's cleaned-up text has to be read from the live editors, since the clone has no
+ // editors attached to it (BL-12391, BL-16490).
+ //
+ // This must come BEFORE the tool cleanup below: getData() reports what the live editors hold,
+ // tool markup included, so the tools must clean the text CKEditor gave us. Otherwise a tool
+ // whose cleanup reaches inside an editable (today, the Talking Book tool's phrase-delimiter
+ // spans and audio highlighting) would have its work overwritten.
+ EditableDivUtils.copyCkEditorDataToClone(livePage, clonedPage);
+
+ // The bubble tails Comical draws, and the canvas element state that goes with them. Like
+ // CKEditor, Comical can only produce this from the live editing state, so this reads from the
+ // live page and writes into the clone.
+ //
+ // Only when canvas-element editing is on: otherwise this would write balloon position and tail
+ // data on pages where editing is suspended (the Image Description and Motion tools, a game page
+ // in Play mode), whose balloon data a save must leave as it found it.
+ if (theOneCanvasElementManager.isCanvasElementEditingOn) {
+ theOneCanvasElementManager.prepareCloneOfPageForSave(clonedPage);
+ }
+
+ // The toolbox is in a separate iframe, hence the call to getToolboxBundleExports(). (Off-screen,
+ // e.g. process-book, there is no toolbox iframe, so this is a no-op there.)
+ getToolboxBundleExports()?.removeToolMarkupFromPageClone(clonedPage);
+
+ removeNiceScrollArtifacts(clonedPage);
+
+ removeEditingDebrisFromClone(clonedPage);
}
// Resize each text canvas element (bloom-canvas-element) to fit its content -- growing or shrinking
@@ -1603,24 +1544,24 @@ function resizeCanvasElementsToFitContent(): void {
}
// Used by the off-screen "process whole book" path (C# BookProcessor, driven by the
-// external/process-book API). It gathers the same page content that requestPageContent() would save
-// (via the shared extractAndStripPageContentForSave()), but instead of posting it to the editView/pageContent
-// API (which feeds the LIVE EditingModel and would corrupt the live editor's state), it stashes the
-// combined result on window.__bloomExternalPageContent for the C# caller to poll. Like
-// requestPageContent(), it first waits for any in-flight async DOM work (activeDelays) to finish, so
-// browser-based measurements (image sizing, canvas-element layout, etc.) are complete before we capture
-// the page. Unlike the live save, it is a background job with nobody waiting at the keyboard, so it
-// waits longer (kExternalCaptureMaxWaitMs), and if the one piece of work that must not be captured
-// half-done, the background image conversion, is still pending at the cap, it reports an ERROR instead
-// of capturing (see externalCaptureErrorForPendingWork); the C# caller then fails the page rather than
+// external/process-book API). It gathers the same page content a save would (via the shared
+// getPageContentForSave()), but instead of posting it to an API that feeds the LIVE EditingModel,
+// it stashes the result on window.__bloomExternalPageContent for the C# caller to poll. It first
+// waits on whenNoActiveDelays(), longer than the live editor since nobody is waiting at the
+// keyboard (kExternalCaptureMaxWaitMs), and if the one piece of work that must not be captured
+// half-done, the background image conversion, is still pending at the cap, it reports an ERROR
+// instead of capturing (see externalCaptureErrorForPendingWork); the C# caller then fails the page
+// rather than
// saving a picture that can neither be cropped nor deleted (BL-16870). It also resizes text canvas
-// elements to fit their content (see
-// resizeCanvasElementsToFitContent), since that auto-height adjustment is otherwise deferred on a
-// timer the wait loop does not track.
+// elements to fit their content (see resizeCanvasElementsToFitContent), since that auto-height
+// adjustment is otherwise deferred on a timer the wait loop does not track.
export function captureContentForExternalProcessing(
fitImageTextSplits?: boolean,
): void {
window.__bloomExternalPageContent = undefined;
+ // This off-screen page is a full editing page, so it may have started sending snapshots. C#
+ // refuses them (wrong load id), but they are wasted work.
+ stopWatchingPageForSnapshots();
// Optionally auto-fit image/text origami pages so the fitted split persists into the saved HTML.
// This handles two-pane image-above-text and image-left-of-text (image in the first pane), plus
@@ -1648,44 +1589,29 @@ export function captureContentForExternalProcessing(
}
}
- const start = Date.now();
- const finish = () => {
+ void whenNoActiveDelays(kExternalCaptureMaxWaitMs).then(() => {
+ // If the wait ran out, decide whether what is still pending may be captured half-done.
+ const stillPending = getActiveDelayIds();
+ if (stillPending.length > 0) {
+ const error = externalCaptureErrorForPendingWork(stillPending);
+ if (error) {
+ window.__bloomExternalPageContent = error;
+ return;
+ }
+ }
try {
resizeCanvasElementsToFitContent();
- window.__bloomExternalPageContent =
- extractAndStripPageContentForSave();
+ window.__bloomExternalPageContent = getPageContentForSave();
} catch (e) {
window.__bloomExternalPageContent =
"ERROR: " + (e && e.message) + "\n" + (e && e.stack);
}
- };
- const waitForDelaysThenFinish = () => {
- if (activeDelays.length === 0) {
- finish();
- return;
- }
- if (Date.now() - start > kExternalCaptureMaxWaitMs) {
- const error = externalCaptureErrorForPendingWork(activeDelays);
- if (error) {
- window.__bloomExternalPageContent = error;
- return;
- }
- console.warn(
- `captureContentForExternalProcessing: Maximum wait time (${kExternalCaptureMaxWaitMs}ms) exceeded with active delay(s): [${activeDelays.join(
- ", ",
- )}]. Proceeding anyway.`,
- );
- finish();
- return;
- }
- setTimeout(waitForDelaysThenFinish, 50);
- };
- waitForDelaysThenFinish();
+ });
}
-// Called from C# by a RunJavaScript() in EditingView.CleanHtmlAndCopyToPageDom via
-// workspaceBundle.getEditablePageBundleExports().
-export const userStylesheetContent = () => {
+// The user-defined styles, which travel to C# as the second half of what
+// getPageContentForSave() returns.
+const userStylesheetContent = () => {
const ss = Array.from(document.styleSheets).find(
(s) => s.title === "userModifiedStyles",
) as CSSStyleSheet | undefined;
@@ -1695,12 +1621,29 @@ export const userStylesheetContent = () => {
.join("\n");
};
+// pageUnloading() can be called twice on the same page: leaving the Edit tab runs it (from
+// EditingView.OnHideEditTab), and coming back runs it again from switchContentPage() before the
+// new page replaces this document. A second run detaches the current tool again, and
+// detachToolFromPage() then falsely reports the tool for not calling super.detachFromPage().
+let thisPageHasBeenUnloaded = false;
+
export const pageUnloading = () => {
+ if (thisPageHasBeenUnloaded) return;
+ thisPageHasBeenUnloaded = true;
+ stopWatchingPageForSnapshots();
// It's just possible that 'theOneCanvasElementManager' hasn't been initialized.
// If not, just ignore this, since it's a no-op at this point anyway.
if (theOneCanvasElementManager) {
theOneCanvasElementManager.cleanUp();
}
+ // Shut the open toolbox tool down, releasing whatever it was holding on the page we are leaving
+ // (observers, listeners, any UI it opened such as a colour picker); the counterpart of the
+ // newPageReady() the tool gets for the next page. Saving never touches the live page, so
+ // nothing else detaches the tool, and without this every page change would leak its hooks.
+ getToolboxBundleExports()?.removeToolboxMarkup();
+ // Unmount the React root for the controls above the page and re-enable the toolbox (the
+ // Change Layout toggle disables it).
+ resetAbovePageControls();
};
export function topBarButtonClick(button: { command: string }) {
@@ -2080,6 +2023,24 @@ export function attachToCkEditor(element) {
$("body").addClass("hideAllCKEditors");
const ckedit = CKEDITOR.inline(element);
+ // Until this editor is ready, a gather reads the box from the DOM rather than from getData(),
+ // and they differ: SetupElements puts an empty into an empty editable, which getData()
+ // reports as empty, as the book on disk does. So the delay register holds gathers until
+ // instanceReady; otherwise an untouched page would look changed and an early save would write
+ // into boxes the user left empty.
+ const ckEditorDelayId = "attachToCkEditor " + ckedit.id;
+ addRequestPageContentDelay(ckEditorDelayId);
+ let ckEditorDelayReleased = false;
+ const releaseCkEditorDelay = () => {
+ if (ckEditorDelayReleased) return;
+ ckEditorDelayReleased = true;
+ removeRequestPageContentDelay(ckEditorDelayId);
+ };
+ // (instanceReady is not on CKEditor’s TypeScript type; toolbox.ts declares it the same way.)
+ if ((ckedit as { instanceReady?: boolean }).instanceReady)
+ releaseCkEditorDelay();
+ else ckedit.on("instanceReady", releaseCkEditorDelay);
+
// Record the div of the edit box for use later in positioning the format bar.
mapCkeditDiv[ckedit.id] = element;
diff --git a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts
index c47af7f5d624..0095c37009b5 100644
--- a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts
+++ b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts
@@ -24,11 +24,8 @@ import { farthest } from "../../utils/elementUtils";
import { EditableDivUtils } from "./editableDivUtils";
import { playingBloomGame } from "../toolbox/games/DragActivityTabControl";
import { getWorkspaceBundleExports } from "./workspaceFrames";
-import {
- changeImage,
- IImageInfo,
- wrapWithRequestPageContentDelay,
-} from "./bloomEditing";
+import { changeImage, IImageInfo } from "./bloomEditing";
+import { wrapWithRequestPageContentDelay } from "./pageContentDelays";
import { getCanvasElementManager } from "../toolbox/canvas/canvasElementPageBridge";
import BloomMessageBoxSupport from "../../utils/bloomMessageBoxSupport";
import $ from "jquery";
@@ -1311,9 +1308,22 @@ export function getDisplayNameFromImageUrl(rawImageUrl: string): string {
}
}
+// The same test as EpubMaker.IsBranding in C#: marked as branding itself, or inside a data-book
+// element that holds branding, such as "outside-back-cover-branding-bottom-html".
+export function isBrandingImage(img: HTMLElement): boolean {
+ return (
+ img.classList.contains("branding") ||
+ !!img.closest("[data-book*='branding']")
+ );
+}
+
// Instead of "missing", we want to show it in the right ui language. We also want the text
// to indicate that it might not be missing, just didn't load (this happens on slow machines)
function SetAlternateTextOnImages(element) {
+ // Every branding image is optional, so one that is not there is not worth a message. Its alt
+ // comes from the branding and is book-wide data: changing it here would get saved, and make
+ // every visit to the page look like an edit to the whole book.
+ if (isBrandingImage(element)) return;
const rawImageUrl = GetRawImageUrl(element);
if (rawImageUrl.length > 0) {
if (isPlaceHolderImage(rawImageUrl)) {
diff --git a/src/BloomBrowserUI/bookEdit/js/bloomImagesSpec.ts b/src/BloomBrowserUI/bookEdit/js/bloomImagesSpec.ts
index bfd3db0cc8b6..16817137e9f7 100644
--- a/src/BloomBrowserUI/bookEdit/js/bloomImagesSpec.ts
+++ b/src/BloomBrowserUI/bookEdit/js/bloomImagesSpec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getDisplayNameFromImageUrl,
+ isBrandingImage,
normalizeCoverImageDesignation,
} from "./bloomImages";
@@ -204,3 +205,54 @@ describe("getDisplayNameFromImageUrl", () => {
).toBe("ภาพ.jpg");
});
});
+
+// Branding images get no missing-image alt text, because that alt would be saved into book-wide data.
+describe("isBrandingImage", () => {
+ function imageIn(html: string): HTMLElement {
+ const page = document.createElement("div");
+ page.innerHTML = html;
+ const img = page.querySelector("#img");
+ if (!img) throw new Error("The test markup has no #img");
+ return img as HTMLElement;
+ }
+
+ it("recognises an image marked as branding", () => {
+ expect(
+ isBrandingImage(
+ imageIn(``),
+ ),
+ ).toBe(true);
+ });
+
+ it("recognises an unmarked image inside a branding block", () => {
+ expect(
+ isBrandingImage(
+ imageIn(
+ `
+
+
`,
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it("does not count a picture in the book", () => {
+ expect(
+ isBrandingImage(
+ imageIn(
+ `
`,
+ ),
+ ),
+ ).toBe(false);
+ });
+
+ it("does not count an image in other book-wide data", () => {
+ expect(
+ isBrandingImage(
+ imageIn(
+ `
`,
+ ),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/src/BloomBrowserUI/bookEdit/js/bloomVideo.ts b/src/BloomBrowserUI/bookEdit/js/bloomVideo.ts
index 3a4436980031..c11de7cc2fa3 100644
--- a/src/BloomBrowserUI/bookEdit/js/bloomVideo.ts
+++ b/src/BloomBrowserUI/bookEdit/js/bloomVideo.ts
@@ -1,4 +1,4 @@
-import { postThatMightNavigate } from "../../utils/bloomApi";
+import { saveChangesAndRethinkPage } from "./pageSnapshot";
// The code in this file supports operations on video panels in custom pages (and potentially elsewhere).
// It sets things up for the button (plural eventually) to appear when hovering over the video.
@@ -201,7 +201,7 @@ export function doVideoCommand(
// Makes sure the page gets saved with a reference to the new video,
// and incidentally that everything gets updated to be consistent with the
// new state of things.
- postThatMightNavigate("common/saveChangesAndRethinkPageEvent");
+ void saveChangesAndRethinkPage();
}
});
} else if (command === "record") {
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.test.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.test.ts
new file mode 100644
index 000000000000..c30417c8ec7a
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Every test passes the language explicitly, so the collection settings this module would
+// otherwise consult are never reached.
+import { saveStateOfCanvasElementAsCurrentLangAlternate } from "./CanvasElementAlternates";
+
+const kLang = "xyz";
+
+function makeCanvasElement(dataBubble: string | null): HTMLElement {
+ const el = document.createElement("div");
+ el.className = "bloom-canvas-element";
+ el.setAttribute("style", "left: 10px; top: 20px;");
+ if (dataBubble !== null) el.setAttribute("data-bubble", dataBubble);
+ const editable = document.createElement("div");
+ editable.className = "bloom-editable";
+ editable.setAttribute("lang", kLang);
+ el.appendChild(editable);
+ return el;
+}
+
+function alternateOn(canvasElement: HTMLElement): string | null {
+ return canvasElement
+ .getElementsByClassName("bloom-editable")[0]
+ .getAttribute("data-bubble-alternate");
+}
+
+describe("saveStateOfCanvasElementAsCurrentLangAlternate", () => {
+ beforeEach(() => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ it("records the alternate when the bubble data is readable", () => {
+ // Bloom stores this JSON with backticks standing in for the quotes.
+ const el = makeCanvasElement("{`version`:`1.0`,`tails`:[{`tipX`:1}]}");
+
+ saveStateOfCanvasElementAsCurrentLangAlternate(el, kLang);
+
+ const written = alternateOn(el);
+ expect(written).not.toBeNull();
+ expect(written).toContain("`lang`:`" + kLang + "`");
+ expect(written).toContain("tipX");
+ });
+
+ it("does not lose the whole page when a canvas element has no bubble data", () => {
+ // The real hazard: this runs inside the clone gather, so throwing here does not merely
+ // skip one alternate, it aborts gathering the page -- and then the page cannot be saved at
+ // all. A missing attribute used to throw, because JSON.parse("") is an error.
+ const el = makeCanvasElement(null);
+
+ expect(() =>
+ saveStateOfCanvasElementAsCurrentLangAlternate(el, kLang),
+ ).not.toThrow();
+ expect(alternateOn(el)).toBeNull();
+ });
+
+ it("does not lose the whole page when the bubble data is malformed", () => {
+ const el = makeCanvasElement("{this is not json");
+
+ expect(() =>
+ saveStateOfCanvasElementAsCurrentLangAlternate(el, kLang),
+ ).not.toThrow();
+ expect(alternateOn(el)).toBeNull();
+ });
+
+ it("leaves an existing alternate alone rather than replacing it with an empty one", () => {
+ // Recording an alternate with no tails would claim this language's copy has none, and the
+ // user would lose them on switching to it. Skipping is the conservative choice.
+ const el = makeCanvasElement("{broken");
+ const editable = el.getElementsByClassName("bloom-editable")[0];
+ editable.setAttribute("data-bubble-alternate", "{`lang`:`xyz`}");
+
+ saveStateOfCanvasElementAsCurrentLangAlternate(el, kLang);
+
+ expect(alternateOn(el)).toBe("{`lang`:`xyz`}");
+ });
+});
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts
index bfa40064e2a8..b99a94516bcb 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts
@@ -39,7 +39,23 @@ export const saveStateOfCanvasElementAsCurrentLangAlternate = (
).find((e) => e.getAttribute("lang") === canvasElementLang);
if (editable) {
const bubbleData = canvasElement.getAttribute("data-bubble") ?? "";
- const bubbleDataObj = JSON.parse(bubbleData.replace(/`/g, '"'));
+ // A missing or unreadable data-bubble must not cost the user the whole page: this runs
+ // inside the clone gather, so a throw would abort gathering, and so saving, the page.
+ // (A missing attribute throws too, since JSON.parse("") is an error.) Skipping leaves any
+ // existing alternate alone; recording one with no tails would lose this language's tails.
+ let bubbleDataObj: { tails?: object[] };
+ try {
+ bubbleDataObj = JSON.parse(bubbleData.replace(/`/g, '"'));
+ } catch (e) {
+ console.warn(
+ "Not recording a canvas-element alternate for lang " +
+ canvasElementLang +
+ ": its data-bubble could not be read (" +
+ bubbleData.slice(0, 60) +
+ ")",
+ );
+ return;
+ }
const alternate = {
lang: canvasElementLang,
style: canvasElement.getAttribute("style") ?? "",
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.test.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.test.ts
index 4e7060668456..a0b772d5186b 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.test.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-// A faithful stand-in for the requestPageContent delay bookkeeping in bloomEditing.ts, which we
-// can't import here (it pulls in the whole editing world). What matters for this suite is the
+// A faithful stand-in for the page-content delay register in pageContentDelays.ts, modelling the
+// save it gates. What matters for this suite is the
// ordering the real code has: a save requested while delays are active is captured
// SYNCHRONOUSLY, the instant the last delay is released, before any promise continuation of
// the work that held the delay gets to run. vi.hoisted so the mock factory below (which vitest
@@ -32,7 +32,7 @@ const saveTracker = vi.hoisted(() => ({
},
}));
-vi.mock("../bloomEditing", () => ({
+vi.mock("../pageContentDelays", () => ({
wrapWithRequestPageContentDelay: async (
fn: () => Promise,
_delayId: string,
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.ts
index 51240c582b91..50c5b0e45d38 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.ts
@@ -8,7 +8,7 @@ import {
isPlaceHolderImage,
SetupMetadataButton,
} from "../bloomImages";
-import { wrapWithRequestPageContentDelay } from "../bloomEditing";
+import { wrapWithRequestPageContentDelay } from "../pageContentDelays";
import { getExactClientSize } from "../../../utils/elementUtils";
import type { IImageCropInfo } from "../ImageUndoManager";
import {
@@ -322,7 +322,8 @@ function putBubbleBefore(
const bubble = new Bubble(b as HTMLElement);
const spec = bubble.getBubbleSpec();
// the one previously at minLevel will now be at requiredLevel+1, others higher in same sequence.
- spec.level += requiredLevel - minLevel + 1;
+ // Treat a missing level as 0, as the minLevel computation above does; else this is NaN.
+ spec.level = (spec.level ?? 0) + requiredLevel - minLevel + 1;
bubble.persistBubbleSpec();
});
minLevel = 2;
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBubbleLevelUtils.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBubbleLevelUtils.ts
index 7a849534ca9e..893ce4d7a064 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBubbleLevelUtils.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBubbleLevelUtils.ts
@@ -28,7 +28,8 @@ export const putBubbleBefore = (
const bubble = new Bubble(b as HTMLElement);
const spec = bubble.getBubbleSpec();
// the one previously at minLevel will now be at requiredLevel+1, others higher in same sequence.
- spec.level += requiredLevel - minLevel + 1;
+ // Treat a missing level as 0, as the minLevel computation above does; else this is NaN.
+ spec.level = (spec.level ?? 0) + requiredLevel - minLevel + 1;
bubble.persistBubbleSpec();
});
minLevel = 2;
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.test.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.test.ts
index e9e8a4362c6f..ecba76daf003 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.test.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.test.ts
@@ -35,6 +35,9 @@ vi.mock("../bloomEditing", () => ({
},
),
notifyToolOfChangedImage: vi.fn(),
+}));
+
+vi.mock("../pageContentDelays", () => ({
wrapWithRequestPageContentDelay: vi.fn(),
}));
@@ -64,10 +67,8 @@ vi.mock("../../toolbox/canvas/CanvasElementItem", () => ({
}));
import { SetupMetadataButton } from "../bloomImages";
-import {
- changeImageInfo,
- wrapWithRequestPageContentDelay,
-} from "../bloomEditing";
+import { changeImageInfo } from "../bloomEditing";
+import { wrapWithRequestPageContentDelay } from "../pageContentDelays";
import {
CanvasElementClipboard,
ICanvasElementClipboardHost,
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.ts
index 51bd7bc3a1a2..e88665e37efe 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementClipboard.ts
@@ -8,8 +8,8 @@ import {
kMakeNewCanvasElement,
changeImageInfo,
notifyToolOfChangedImage,
- wrapWithRequestPageContentDelay,
} from "../bloomEditing";
+import { wrapWithRequestPageContentDelay } from "../pageContentDelays";
import {
getBackgroundCanvasElementFromBloomCanvas,
isPlaceHolderImage,
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementFactories.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementFactories.ts
index 4e6021e284b9..5698cde7f4fb 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementFactories.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementFactories.ts
@@ -938,7 +938,8 @@ export class CanvasElementFactories {
bloomCanvas.getElementsByClassName(kCanvasElementClass),
) as HTMLElement[]
).filter((x) => x !== backgroundImage),
- Bubble.getBubbleSpec(backgroundImage).level + 1,
+ // A missing level counts as 0, as everywhere else we do this arithmetic.
+ (Bubble.getBubbleSpec(backgroundImage).level ?? 0) + 1,
);
}
}
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts
index 43b08709a754..668c3556925c 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementManager.ts
@@ -21,11 +21,13 @@ import { getRgbaColorStringFromColorAndOpacity } from "../../../utils/colorUtils
import {
IImageInfo,
SetupElements,
- addRequestPageContentDelay,
attachToCkEditor,
notifyToolOfChangedImage,
- removeRequestPageContentDelay,
} from "../bloomEditing";
+import {
+ addRequestPageContentDelay,
+ removeRequestPageContentDelay,
+} from "../pageContentDelays";
import {
EnableAllImageEditing,
getImageFromCanvasElement,
@@ -2608,6 +2610,52 @@ export class CanvasElementManager {
);
}
+ // Put into 'clonedPage', a detached copy of the live .bloom-page, what
+ // turnOffCanvasElementEditing() would put into the page, leaving the live page still editable.
+ // Only three of the things that method does affect what gets saved:
+ // * Comical's editing