From 0b614d874f5d268c8e4bf27223005d4346192ae6 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Thu, 3 Sep 2026 16:21:24 -0500 Subject: [PATCH 01/31] Save a page without reloading it, and let the browser volunteer the page (BL-13502) Saving the page being edited used to destroy it. The gather stripped the editing markup out of the live DOM, so the only way to carry on was to reload the page from the book -- which is why saving flashed, lost the cursor, and could not be done casually. And because C# had to ASK the browser for the page and wait for the answer on a separate API call, every operation that needed a save first had to be split into a "before" and an "after" around that gap, with states in the editing state machine to sit in while it waited. Two changes remove all of that. **The gather works on a clone.** getBodyContentForSavePage clones the body and does every bit of the cleanup on the copy, so when it returns, the live page has not been touched and is still editable. CKEditor's text and Comical's bubble data have to be read from the live editors, so those are copied INTO the clone rather than cleaned in place -- which reverses the old order, and means the tools now clean the text CKEditor gave us instead of CKEditor cleaning what the tools left. **The browser volunteers the page instead of being asked for it.** A MutationObserver watches the page, and 25ms after it settles the browser posts the current content to C#, which just stores the string (PageSnapshot.cs). A save then takes it synchronously. So: - the round trip is gone: RequestBrowserToSave, ReceivePageContent, the editView/pageContent API and requestPageContent() in the browser; - with nothing to wait for, the states that existed to wait are gone too -- SavePending and SavedAndStripped, and everything that served them. The state machine is 728 lines down to ~435, with three states instead of five; - leaving the Edit tab and closing the collection became straight-line code, and closing no longer cancels the close and re-issues it after the save; - SaveThen is now MergeCurrentPageThenSave, and its action is changeBookBeforeWriting, because that is where it runs: after the page is merged into the book DOM and before the book is written. That middle slot is why it is still an action rather than straight-line code -- putting the caller's work after the save would mean either two disk writes or a duplicated page that misses the last thing typed. Its fallback argument is now optional; fourteen of twenty callers had nothing to say and passed `() => { }`. What makes "C# has no snapshot" safely mean "no unsaved changes" is that we only post when the page's SAVED FORM changed. That took some doing, because much of what a page does while it loads is not the user editing it: - the gather strips the editing chrome that C# would discard anyway -- bloom-ui elements, CKEditor's toolbars and qTip's bubbles (20KB of a 26KB page, and restless: a bubble slides into place, changing its inline style several times a second), the cke_ classes, and qTip's data-hasqtip/aria-describedby, whose numbering changes between runs; - paper.js stamps a fresh GUID into the ids of the SVG Comical draws, so every page with a speech bubble looked edited on every visit. Those ids are dropped: nothing references them and they are not even unique; - the snapshot's baseline waits for CKEditor, because until an editor is ready we cannot read the true text of its box, and SetupElements' placeholder

differs from what CKEditor reports for an empty box; - and #measureTextDiv, a scratch element that any save could always have written into the book, is removed. Even so, the browser is not the authority on whether anything changed, because it would have to predict our processing. Book.UpdateDomFromEditedPage now reports anythingChanged, comparing the page as the book has it against the page after ProcessPageAfterEditing and SetImageAltAttrsFromDescriptions have had it. When nothing differs, nothing is written. The browser-side comparison remains, but as an optimisation rather than the thing correctness rests on. Measured, opening every page of a book and touching nothing: 15 snapshot posts before this work, 2 after -- and those 2 are a real page change (see below). Typing costs one post per keystroke, ~0.4ms to gather and ~49ms to reach C#. Two things this deletes rather than replaces, both from BL-16766, which fixed "the user clicked a tab twice and the second click found the first click's save still out with the browser": the state machine's DeferUntilSaveCompletes and TabChangedDetails.StartTheChangeOver. Neither case can arise when a save finishes inside the call that asks for it. Verified by hand: double-clicking the Collection or Publish tab with unsaved changes saves them and switches once. Also removed: UpdateUI, which called WorkspaceView.SetTabsEnabled(true) on every transition. That is a single shared flag, not a count, and a modal dialog or a running publish holds the tabs locked with it -- so an editing transition while one of those was up silently unlocked them. Known and NOT fixed here: on the front cover and title page the bloom-padForOverflow title flips between padding-bottom 0px and 3px from one load to the next, which also moves the cover image. It is a real change to the page, so reporting it is correct; the instability is in the measurement (OverflowChecker -> getDescentMeasurementsOfBox) and predates this work. It deserves its own ticket. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/run-bloom/benchPageChange.mjs | 176 +++++ .claude/skills/run-bloom/benchSaveGather.mjs | 75 ++ .../aiImageEditorOverlay.test.ts | 23 +- src/BloomBrowserUI/bookEdit/editablePage.ts | 40 +- .../bookEdit/js/bloomEditing.ts | 410 +++++----- src/BloomBrowserUI/bookEdit/js/bloomImages.ts | 7 +- src/BloomBrowserUI/bookEdit/js/bloomVideo.ts | 4 +- .../CanvasElementBackgroundImageManager.ts | 6 +- .../CanvasElementBubbleLevelUtils.ts | 4 +- .../CanvasElementClipboard.test.ts | 9 +- .../CanvasElementClipboard.ts | 2 +- .../CanvasElementFactories.ts | 3 +- .../CanvasElementManager.ts | 54 +- .../CanvasElementResizeAdjustments.ts | 11 +- .../bookEdit/js/editableDivUtils.ts | 43 + .../bookEdit/js/editorChromeCleanup.spec.ts | 155 ++++ .../bookEdit/js/editorChromeCleanup.ts | 90 +++ .../bookEdit/js/niceScrollCleanup.spec.ts | 136 ++++ .../bookEdit/js/niceScrollCleanup.ts | 104 +++ src/BloomBrowserUI/bookEdit/js/origami.ts | 7 +- .../bookEdit/js/pageContentDelays.spec.ts | 155 ++++ .../bookEdit/js/pageContentDelays.ts | 101 +++ .../bookEdit/js/pageSnapshot.spec.ts | 265 +++++++ .../bookEdit/js/pageSnapshot.ts | 217 +++++ .../pageThumbnailList/currentPageContent.ts | 64 ++ .../pageControls/pageControls.tsx | 18 +- .../pageThumbnailList/pageThumbnailList.tsx | 71 +- .../canvas/canvasControlTextMenuItems.ts | 9 +- .../bookEdit/toolbox/canvas/canvasTool.tsx | 1 + .../toolbox/canvas/customXmatterPage.tsx | 6 +- .../bookEdit/toolbox/games/GameTool.tsx | 11 +- .../imageDescription/imageDescription.tsx | 20 +- .../imageDescription/imageDescriptionUtils.ts | 14 +- .../impairmentVisualizer.tsx | 25 +- .../bookEdit/toolbox/motion/motionTool.tsx | 35 +- .../decodableReader/decodableReaderTool.tsx | 10 + .../leveledReader/leveledReaderTool.tsx | 10 + .../readers/removeReaderMarkup.spec.ts | 73 ++ .../toolbox/readers/removeReaderMarkup.ts | 31 + .../toolbox/signLanguage/signLanguageTool.tsx | 1 + .../toolbox/talkingBook/IAudioRecorder.ts | 1 + .../toolbox/talkingBook/audioRecording.ts | 103 ++- .../toolbox/talkingBook/audioRecordingSpec.ts | 182 ++++- .../toolbox/talkingBook/talkingBookTool.tsx | 29 +- .../bookEdit/toolbox/toolbox.ts | 62 +- .../bookEdit/toolbox/toolboxBootstrap.ts | 9 +- .../bookEdit/toolbox/toolboxGlobals.d.ts | 1 + .../toolbox/toolboxToolReactAdaptor.tsx | 28 +- src/BloomBrowserUI/utils/bloomApi.ts | 11 +- src/BloomExe/Book/Book.cs | 34 +- src/BloomExe/Book/BookProcessor.cs | 7 +- src/BloomExe/Edit/EditingModel.cs | 741 +++++++++++------- src/BloomExe/Edit/EditingStateMachine.cs | 616 ++++++--------- src/BloomExe/Edit/EditingView.cs | 18 + src/BloomExe/Edit/PageControlsApi.cs | 6 +- src/BloomExe/Edit/PageListController.cs | 23 +- src/BloomExe/Edit/PageSnapshot.cs | 86 ++ src/BloomExe/Edit/PageThumbnailList.cs | 65 +- src/BloomExe/Edit/SavingWithoutReloading.md | 526 +++++++++++++ src/BloomExe/Edit/ToolboxView.cs | 6 + src/BloomExe/Event.cs | 67 +- src/BloomExe/Shell.cs | 139 ++-- src/BloomExe/Workspace/WorkspaceView.cs | 22 +- src/BloomExe/web/PageListApi.cs | 61 +- .../web/controllers/AddOrChangePageApi.cs | 5 +- .../web/controllers/AiImageEditorApi.cs | 30 +- src/BloomExe/web/controllers/ApiRequest.cs | 14 + .../web/controllers/CopyrightAndLicenseApi.cs | 2 +- .../web/controllers/EditingViewApi.cs | 49 +- .../web/controllers/SignLanguageApi.cs | 5 +- src/BloomTests/Book/BookTests.cs | 109 +++ .../Edit/EditingStateMachineTests.cs | 705 ++++++++++++----- 72 files changed, 4853 insertions(+), 1405 deletions(-) create mode 100644 .claude/skills/run-bloom/benchPageChange.mjs create mode 100644 .claude/skills/run-bloom/benchSaveGather.mjs create mode 100644 src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.spec.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.spec.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/pageContentDelays.spec.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/pageContentDelays.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts create mode 100644 src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts create mode 100644 src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.spec.ts create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.ts create mode 100644 src/BloomExe/Edit/PageSnapshot.cs create mode 100644 src/BloomExe/Edit/SavingWithoutReloading.md diff --git a/.claude/skills/run-bloom/benchPageChange.mjs b/.claude/skills/run-bloom/benchPageChange.mjs new file mode 100644 index 000000000000..9145587ea535 --- /dev/null +++ b/.claude/skills/run-bloom/benchPageChange.mjs @@ -0,0 +1,176 @@ +// Benchmark a real page change end to end, from outside Bloom, so the same harness can be +// run before and after the pageClicked change and the numbers compared. +// +// Phases (all observed over CDP; no instrumentation added to Bloom): +// click -> we dispatch a real click on the page-list thumbnail +// pageClicked -> POST pageList/pageClicked completes +// pageContent -> POST editView/pageContent completes (the browser has handed C# the +// outgoing page's content: this is the round trip the change removes) +// domLoaded -> POST editView/pageDomLoaded fires (the NEW page's DOM is up) +// editable -> the new page reports its id with CKEditor attached (usable) +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +// Find playwright through the repo's own copy, locating the repo from where THIS script lives +// (.claude/skills/run-bloom/) rather than a hard-coded path -- otherwise it only runs on the +// machine it was written on. +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); +const r = createRequire( + path.join( + repoRoot, + "src/BloomBrowserUI/react_components/component-tester/package.json", + ), +); +const { chromium } = r("playwright"); +const sleep = (ms) => new Promise((x) => setTimeout(x, ms)); + +const PAGES = [ + { id: "e9f55da7-b76d-4178-aa66-b062d744c6c0", label: "Basic Text & Image" }, + { id: "6799f146-e29d-4521-89d3-c1192ab606b4", label: "Title Page" }, +]; +const ITERATIONS = Number(process.argv[2] ?? 8); + +// The launcher picks the CDP port; pass it in when it is not the usual one. +const cdpPort = process.env.BLOOM_CDP_PORT ?? 8091; +const b = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); +const shell = b + .contexts() + .flatMap((c) => c.pages()) + .find( + (p) => + p.url().includes("/bloom/") && !p.url().startsWith("devtools://"), + ); +const listFrame = () => shell.frames().find((f) => f.name() === "pageList"); +const pageFrame = () => shell.frames().find((f) => f.name() === "page"); + +let marks = {}; +const stamp = (name) => { + if (marks[name] === undefined) marks[name] = Date.now(); +}; +shell.on("response", (res) => { + const u = res.url(); + if (!u.includes("/bloom/api/")) return; + if (u.includes("pageList/pageClicked")) stamp("pageClicked"); + else if (u.includes("editView/pageContent")) stamp("pageContent"); + else if (u.includes("editView/savePageInPlace")) stamp("savePageInPlace"); + else if (u.includes("editView/pageDomLoaded")) stamp("domLoaded"); +}); + +const waitForPage = async (wantId, deadlineMs = 25000) => { + const start = Date.now(); + while (Date.now() - start < deadlineMs) { + const f = pageFrame(); + if (f) { + try { + const ok = await f.evaluate((wantId) => { + const p = document.querySelector(".bloom-page"); + if (!p || p.getAttribute("id") !== wantId) return false; + const eds = Array.from( + document.querySelectorAll("div.bloom-editable"), + ); + // "usable" = at least one editor attached (every page here has editable text) + return eds.some((d) => !!d.bloomCkEditor); + }, wantId); + if (ok) return Date.now(); + } catch { + /* frame swapping */ + } + } + await sleep(20); + } + return null; +}; + +const clickPage = async (id) => { + return listFrame().evaluate((id) => { + const item = document.querySelector(`.gridItem[id="${id}"]`); + if (!item) return "no gridItem " + id; + const cover = item.querySelector(".invisibleThumbnailCover") || item; + cover.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + view: window, + }), + ); + return "ok"; + }, id); +}; + +const currentPageId = async () => { + const f = pageFrame(); + if (!f) return null; + try { + return await f.evaluate( + () => + document.querySelector(".bloom-page")?.getAttribute("id") ?? + null, + ); + } catch { + return null; + } +}; + +// Settle before timing anything. Never assume which page Bloom starts on: each iteration below +// targets whichever of the two we are NOT currently on, so every timed click is a real change. +// (Clicking the page we are already on is not a no-op we can wait for -- and a click that lands +// while a navigation is still in flight is silently dropped, because SaveThen's "not in a state +// to save" fallback for pageClicked does nothing at all.) +await sleep(1500); + +const rows = []; +for (let i = 0; i < ITERATIONS; i++) { + const from = await currentPageId(); + const target = PAGES.find((p) => p.id !== from) ?? PAGES[0]; + marks = {}; + const t0 = Date.now(); + const clicked = await clickPage(target.id); + if (clicked !== "ok") { + console.log("CLICK FAILED:", clicked); + break; + } + const tEditable = await waitForPage(target.id); + if (!tEditable) { + console.log("TIMED OUT waiting for", target.label); + break; + } + rows.push({ + to: target.label, + pageClicked: marks.pageClicked ? marks.pageClicked - t0 : null, + pageContent: marks.pageContent ? marks.pageContent - t0 : null, + savePageInPlace: marks.savePageInPlace + ? marks.savePageInPlace - t0 + : null, + domLoaded: marks.domLoaded ? marks.domLoaded - t0 : null, + editable: tEditable - t0, + }); + await sleep(1200); // let things quiesce between runs +} + +const median = (xs) => { + const v = xs + .filter((x) => x !== null && x !== undefined) + .sort((a, b) => a - b); + if (!v.length) return null; + return v.length % 2 + ? v[(v.length - 1) / 2] + : Math.round((v[v.length / 2 - 1] + v[v.length / 2]) / 2); +}; + +console.log("\nper-change timings (ms from click):"); +for (const row of rows) console.log(" " + JSON.stringify(row)); +console.log("\nMEDIANS over " + rows.length + " changes:"); +for (const k of [ + "pageClicked", + "pageContent", + "savePageInPlace", + "domLoaded", + "editable", +]) { + const m = median(rows.map((x) => x[k])); + console.log(` ${k.padEnd(16)} ${m === null ? "(never seen)" : m + " ms"}`); +} +await b.close(); diff --git a/.claude/skills/run-bloom/benchSaveGather.mjs b/.claude/skills/run-bloom/benchSaveGather.mjs new file mode 100644 index 000000000000..9c481eb40609 --- /dev/null +++ b/.claude/skills/run-bloom/benchSaveGather.mjs @@ -0,0 +1,75 @@ +// Decompose the save round trip: how much of it is real work (gathering the page content in +// the browser, and C# writing it) versus the overhead of C# having to ASK the browser and +// wait for an HTTP callback -- which is the only part removing the round trip can save. +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +// Find playwright through the repo's own copy, locating the repo from where THIS script lives +// (.claude/skills/run-bloom/) rather than a hard-coded path -- otherwise it only runs on the +// machine it was written on. +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); +const r = createRequire( + path.join( + repoRoot, + "src/BloomBrowserUI/react_components/component-tester/package.json", + ), +); +const { chromium } = r("playwright"); +const sleep = (ms) => new Promise((x) => setTimeout(x, ms)); + +// The launcher picks the CDP port; pass it in when it is not the usual one. +const cdpPort = process.env.BLOOM_CDP_PORT ?? 8091; +const b = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); +const shell = b + .contexts() + .flatMap((c) => c.pages()) + .find( + (p) => + p.url().includes("/bloom/") && !p.url().startsWith("devtools://"), + ); +const frame = () => shell.frames().find((f) => f.name() === "page"); + +const res = await frame().evaluate(async () => { + const ex = window.editablePageBundle; + const gather = []; + const save = []; + let size = 0; + // warm up + await ex.getPageContentForSaveWhenReady(); + for (let i = 0; i < 15; i++) { + const t = performance.now(); + // Includes the (normally zero) wait for in-flight page changes to settle, because that + // is what a real save pays: see whenNoActiveDelays in bookEdit/js/pageContentDelays.ts. + const s = await ex.getPageContentForSaveWhenReady(); + gather.push(performance.now() - t); + size = s.length; + } + for (let i = 0; i < 8; i++) { + const t = performance.now(); + await ex.savePageWithoutReloading(); + save.push(performance.now() - t); + } + const med = (a) => { + const v = [...a].sort((x, y) => x - y); + return ( + Math.round( + (v.length % 2 + ? v[(v.length - 1) / 2] + : (v[v.length / 2 - 1] + v[v.length / 2]) / 2) * 10, + ) / 10 + ); + }; + return { + pageId: document.querySelector(".bloom-page")?.getAttribute("id"), + contentBytes: size, + gatherMedianMs: med(gather), + gatherAllMs: gather.map((x) => Math.round(x * 10) / 10), + saveRoundTripMedianMs: med(save), + saveAllMs: save.map((x) => Math.round(x)), + }; +}); +console.log(JSON.stringify(res, null, 1)); +await b.close(); diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts index a2e1e9c55adc..9fedddeb27cb 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts @@ -9,13 +9,16 @@ 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). -// - Saving after a commit. The current-page swaps only touched the LIVE DOM, so unless we -// save, a second commit in the same session would read its oldSrc from a saved page still -// showing the pre-edit image and match nothing. Because this overlay lives in the top -// window, we can save immediately: the page reload underneath leaves its controls alone. +// - 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. The savePageWithoutReloading mock below exists to assert that we +// never call it. const post = vi.fn(); const postJson = vi.fn(); +const savePageWithoutReloading = vi.fn(); const postThatMightNavigate = vi.fn(); const trackEvent = vi.fn(); const trackChangePicture = vi.fn(); @@ -148,6 +151,9 @@ const commitAndReplyFromHost = ( beforeEach(() => { post.mockClear(); postJson.mockClear(); + savePageWithoutReloading.mockClear(); + // It answers whether C# actually saved; the overlay chains onto that to complain if not. + savePageWithoutReloading.mockResolvedValue(true); postThatMightNavigate.mockClear(); trackEvent.mockClear(); trackChangePicture.mockClear(); @@ -158,6 +164,7 @@ beforeEach(() => { }); getEditablePageBundleExports.mockReturnValue({ applyAiImageEditorReplacements, + savePageWithoutReloading, }); delete (window as Window & { __bloomAiImageEditorCleanup?: () => void }) .__bloomAiImageEditorCleanup; @@ -361,8 +368,8 @@ 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. - expect(postThatMightNavigate).not.toHaveBeenCalled(); + // We never save from here at all -- see the note at the top of this file. + expect(savePageWithoutReloading).not.toHaveBeenCalled(); expect(applyAiImageEditorReplacements).not.toHaveBeenCalled(); postMessageToEditor.mockRestore(); }); @@ -387,8 +394,8 @@ 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. - expect(postThatMightNavigate).not.toHaveBeenCalled(); + // We never save from here at all -- see the note at the top of this file. + expect(savePageWithoutReloading).not.toHaveBeenCalled(); postMessageToEditor.mockRestore(); }); }); diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts index c88eafcf3748..2e60837e89fd 100644 --- a/src/BloomBrowserUI/bookEdit/editablePage.ts +++ b/src/BloomBrowserUI/bookEdit/editablePage.ts @@ -17,6 +17,7 @@ import { } from "./js/canvasElementManager/CanvasElementManager"; import { kCanvasElementSelector } from "./toolbox/canvas/canvasElementConstants"; import { renderDragActivityTabControl } from "./js/AbovePageControls"; +import { startWatchingPageForSnapshots } from "./js/pageSnapshot"; function getPageId(): string { const page = document.querySelector(".bloom-page"); @@ -48,7 +49,13 @@ 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; + // Gather the current page's content and have C# save it, without the page being reloaded + // afterwards. + // Resolves false if C# declined to save; see savePageWithoutReloading in bloomEditing.ts. + savePageWithoutReloading(): Promise; + // The combined "body userCss" string that a save needs, gathered without + // disturbing the live page. + getPageContentForSaveWhenReady(): Promise; pageUnloading(): void; copySelection(): void; cutSelection(): void; @@ -110,12 +117,11 @@ 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().savePageWithoutReloading() can be called. import { - getBodyContentForSavePage, - requestPageContent, + getPageContentForSaveWhenReady, + savePageWithoutReloading, captureContentForExternalProcessing, - userStylesheetContent, pageUnloading, topBarButtonClick, copySelection, @@ -129,9 +135,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. @@ -141,10 +149,9 @@ import type { IAiImageEditorCommitResult, } from "./aiImageEditor/aiImageEditorShared"; export { - getBodyContentForSavePage, - requestPageContent, + getPageContentForSaveWhenReady, + savePageWithoutReloading, captureContentForExternalProcessing, - userStylesheetContent, pageUnloading, topBarButtonClick, copySelection, @@ -383,6 +390,11 @@ $(document).ready(() => { // in the live editor, which never reads this flag. window.__bloomEditablePageReady = true; + // Start volunteering the page's content to C# whenever it changes and settles, so a save never + // has to ask for it and wait. See pageSnapshot.ts. Deliberately after bootstrap(), so the + // load-time fix-ups it applies are not themselves reported as the user's changes. + 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. @@ -400,10 +412,9 @@ 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; + savePageWithoutReloading: typeof savePageWithoutReloading; captureContentForExternalProcessing: typeof captureContentForExternalProcessing; - getBodyContentForSavePage: typeof getBodyContentForSavePage; - userStylesheetContent: typeof userStylesheetContent; + getPageContentForSaveWhenReady: typeof getPageContentForSaveWhenReady; pageUnloading: typeof pageUnloading; copySelection: typeof copySelection; cutSelection: typeof cutSelection; @@ -479,10 +490,9 @@ declare global { } window.editablePageBundle = { - requestPageContent, + savePageWithoutReloading, captureContentForExternalProcessing, - getBodyContentForSavePage, - userStylesheetContent, + getPageContentForSaveWhenReady, pageUnloading, copySelection, cutSelection, diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 30ab1554988c..dbaa0f82a3c3 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -27,6 +27,12 @@ import BloomField from "../bloomField/BloomField"; import BloomNotices from "./bloomNotices"; import BloomSourceBubbles from "../sourceBubbles/BloomSourceBubbles"; import BloomHintBubbles from "./BloomHintBubbles"; +import { + addRequestPageContentDelay, + removeRequestPageContentDelay, + whenNoActiveDelays, + wrapWithRequestPageContentDelay, +} from "./pageContentDelays"; import { CanvasElementManager, initializeCanvasElementManager, @@ -58,8 +64,14 @@ import { showInvisibles, hideInvisibles } from "./showInvisibles"; //promise may be needed to run tests with phantomjs //import promise = require('es6-promise'); //promise.Promise.polyfill(); -import axios from "axios"; -import { post, postBoolean, postJson, postString } from "../../utils/bloomApi"; +import axios, { AxiosResponse } from "axios"; +import { + post, + postBoolean, + postJson, + postString, + postThatMightNavigate, +} from "../../utils/bloomApi"; import { showRequestStringDialog } from "../../react_components/RequestStringDialog"; import { hookupLinkHandler } from "../../utils/linkHandler"; @@ -71,6 +83,9 @@ import { ckeditableSelector } from "../../utils/shared"; import { EditableDivUtils } from "./editableDivUtils"; import { setupDragActivityTabControl } from "../toolbox/games/GameTool"; import { addScrollbarsToPage, cleanupNiceScroll } from "bloom-player"; +import { removeNiceScrollArtifacts } from "./niceScrollCleanup"; +import { removeEditorChromeFromClone } from "./editorChromeCleanup"; +import { stopWatchingPageForSnapshots } from "./pageSnapshot"; import { setupBookLinkGrids } from "./linkGrid"; import { fitImageOverTextSplits } from "./autoFitImageOverTextSplits"; import PlaceholderProvider from "./PlaceholderProvider"; @@ -166,6 +181,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(); } @@ -1320,117 +1338,56 @@ 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 out of the copy we are about to save the editing-only markup that the C# save pipeline +// does NOT already strip for us. (It removes anything with class bloom-ui or ui-resizable-handle +// and any cke_* classes: see HtmlDom.ProcessPageAfterEditing. It also keeps only the .bloom-page +// div, so nothing outside that div matters either.) +// +// This works entirely on 'cloneOfBody', a detached copy of the live body, so the live page is +// untouched and remains editable. Compare the old removeEditingDebris(), which did this to the +// live DOM and so forced a page reload after every save. +// +// Note that there is deliberately nothing here corresponding to the old call to +// resetAbovePageControls(): the above-page controls are a bloom-ui element that lives outside the +// .bloom-page div, so they are never saved. Unmounting them belongs to leaving the page, and is +// now done in pageUnloading(). +function removeEditingDebrisFromClone(cloneOfBody: 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 = cloneOfBody.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); - 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; + for (const textLabel of Array.from( + marginBox.getElementsByClassName("textBox-identifier"), + )) { + textLabel.remove(); } + // The scratch element measureText.ts appends to the body to measure text with. It is hidden, + // it is transient (a timer removes it), and it is not part of the page -- but the gather + // clones the whole body, so a save that happens while it is there writes it into the book. + // The window is real: it is created while text is being fitted, which is exactly when the + // user is typing, and a save right after typing is the commonest save there is. + // + // Found by the page-snapshot experiment, which gathers far more often than a save does and so + // caught it in the act (see SavingWithoutReloading.md). It is not new: any save has always + // been able to pick it up. + cloneOfBody.querySelector("#measureTextDiv")?.remove(); + removeTransientVideoTimestampParams(cloneOfBody); + removeEditorChromeFromClone(cloneOfBody); } -// 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. +// Return the page body + user stylesheet combined with the delimiter that C# splits +// on. Shared by the live save path (requestPageContentInternal), the save-without-reloading path +// (savePageWithoutReloading), and the off-screen capture path (captureContentForExternalProcessing), +// so the cleanup steps and the delimiter can't drift between them. // -// 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(); - } -} - -// 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. +// Deliberately NOT exported: every caller should come through getPageContentForSaveWhenReady() (or +// one of the two paths above, which do their 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. // -// 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 -// uses a fresh disposable browser per page. Don't call this from a context where the page must stay -// live and editable afterward. -function extractAndStripPageContentForSave(): string { - // 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(); +// This leaves the live page fully editable: see getBodyContentForSavePage. +function getPageContentForSave(): string { const content = getBodyContentForSavePage(); const userStylesheet = userStylesheetContent(); // (We tossed up whether to use a JSON object instead of a delimiter, but combining two strings is @@ -1438,73 +1395,142 @@ function extractAndStripPageContentForSave(): string { 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. This is what the page list's commands use +// (see collectCurrentPageContent in pageThumbnailList/currentPageContent.ts) to send the content +// along with a request that will make C# save it. +// +// Note the gather happens in the continuation of the await, with nothing awaited in between, 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(); +} + +// Gather the current page's content and ask C# to save it into the book, WITHOUT the page being +// reloaded afterwards. This lets Javascript initiate a save at a point of its own choosing (e.g. +// before some operation that needs the book on disk to be up to date) and simply carry on editing +// the same page. +// +// There used to be a counterpart, requestPageContent(), for the other direction: C# starting a save +// and waiting for the browser to answer on editView/pageContent. Nothing asks any more -- the +// browser volunteers the page as it is edited (see pageSnapshot.ts), so C# already has it. +// +// Resolves TRUE once the book DOM has been updated and written to disk, and FALSE if C# declined +// to save -- the user may have started changing pages, or an external process may have replaced +// the book on disk. Callers that save so that the file will match the page they are about to work +// from must check: carrying on after a refused save means reading a file that does not say what +// they think it says. +export async function savePageWithoutReloading(): Promise { + const content = await getPageContentForSaveWhenReady(); + const response = await postString("editView/savePageInPlace", content); + // C# sends this as JSON, so axios normally hands us a real boolean. Accept the string too: + // "did we save?" is not worth making dependent on the reply's content type, and getting it + // wrong the other way would have the AI image editor cry failure after every good save. + const data = (response as AxiosResponse | void)?.data; + return data === true || data === "true"; } -// 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() { +// Save the page and have C# rebuild it from the updated book DOM. Unlike +// savePageWithoutReloading(), the page IS reloaded, and for these callers that is the point rather +// than a cost: they have restructured the page in ways that have never been through SetupElements +// (a new origami layout, an imported video, a translation group replaced by a derived field), and +// the reload is what runs the page's setup over the result. +// +// What has gone is the round trip. Sending the content with the request means C# no longer has to +// ask us for it and wait for the answer on a separate API before it can do anything. See +// EditingModel.SavePageAndReloadIt. +// +// The post itself might navigate this very frame out from under us, hence postThatMightNavigate. +export async function saveChangesAndRethinkPage(): Promise { + await postThatMightNavigate( + "common/saveChangesAndRethinkPageEvent", + await getPageContentForSaveWhenReady(), + ); +} + +// Produce the HTML of the current page as it should be saved: a copy of the body with all the +// editing-only markup taken out. +// +// NON-DESTRUCTIVE (BL-13502). We clone the body and do every bit of the cleanup on the CLONE, so +// when we return, the live page has not been touched at all and is still editable. That is what +// allows a Save that does not have to be followed by reloading the page. +// +// 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 getBodyContentForSavePage() { if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(document.body)) { throw new Error( "getBodyContentForSavePage(): The page had origami when it loaded, but it doesn't now (check before cleanup). BL-13120", ); } - const canvasElementEditingOn = - theOneCanvasElementManager.isCanvasElementEditingOn; - if (canvasElementEditingOn) { - theOneCanvasElementManager.turnOffCanvasElementEditing(); - } - // Active element should be forced to blur - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - const editableDivs = ( - Array.from(document.querySelectorAll("div.bloom-editable")) - ); + // Note: unlike the older, destructive version of this code we deliberately do NOT blur the + // active element. Blurring was harmless when the page was about to be reloaded anyway, but now + // that we save without reloading, it would throw the user's cursor out of the box they are + // typing in on every save. We get the up-to-date text from CKEditor's getData() instead, which + // does not need the box to be blurred. - // 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); + const cloneOfBody = document.body.cloneNode(true) as HTMLElement; + cleanCloneOfBodyForSave(cloneOfBody); - if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(document.body)) { + if (hadOrigamiWhenWeLoadedThePage && !hasOrigami(cloneOfBody)) { throw new Error( "getBodyContentForSavePage(): The page had origami when it loaded, but it doesn't now (check after cleanup). BL-13120", ); } - const result = document.body.innerHTML; + return cloneOfBody.innerHTML; +} - if (canvasElementEditingOn) { - theOneCanvasElementManager.turnOnCanvasElementEditing(); +// Do all the "strip the editing markup" work on 'cloneOfBody', a detached deep copy of the live +// document.body. Nothing here may touch the live page. +function cleanCloneOfBodyForSave(cloneOfBody: HTMLElement) { + // 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 necessarily happens BEFORE the tool cleanup below, which is the opposite of the order + // the old destructive code used (it detached the tool from the live page and then asked + // CKEditor for the result). We can't do it that way any more: getData() can only report what + // the live editors hold, and the live page must keep its tool markup. So the tools clean the + // text CKEditor gave us, instead of CKEditor cleaning the text the tools left behind. + // + // That order matters to any tool whose cleanup reaches INSIDE an editable, because whatever it + // did there would be overwritten if the CKEditor copy came afterwards. Today that is only the + // Talking Book tool (the phrase-delimiter spans and the audio highlighting). The reader tools + // used to be in that category, but no longer are: their word and sentence highlighting is now + // painted with the CSS Custom Highlight API and puts nothing in the text, so all they clean is + // a class on the page div. + EditableDivUtils.copyCkEditorDataToClone(document.body, cloneOfBody); + + // 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 actually on, which is the guard the old destructive code + // had: it reached this work through `if (canvasElementEditingOn) turnOffCanvasElementEditing()`. + // Doing it unconditionally 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) -- pages whose + // balloon data a save used to leave exactly as it found it. + if (theOneCanvasElementManager.isCanvasElementEditingOn) { + theOneCanvasElementManager.prepareCloneOfBodyForSave(cloneOfBody); } - return result; + // 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.) + const clonedPage = cloneOfBody.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement; + if (clonedPage) { + getToolboxBundleExports()?.removeToolMarkupFromPageClone(clonedPage); + } + + // The scroll bars an overflowing text box gets. Note that this takes the whole body: niceScroll + // puts its rails on the nearest positioned ancestor, which may or may not be inside the page. + removeNiceScrollArtifacts(cloneOfBody); + + removeEditingDebrisFromClone(cloneOfBody); } // Resize each text canvas element (bloom-canvas-element) to fit its content -- growing or shrinking @@ -1560,10 +1586,10 @@ function resizeCanvasElementsToFitContent(): void { // 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, up to -// kMaxWaitTimeMs, so browser-based measurements (image sizing, canvas-element layout, etc.) are complete -// before we capture the page. It also resizes text canvas elements to fit their content (see +// combined result on window.__bloomExternalPageContent for the C# caller to poll. Like every other +// gathering path it goes through whenNoActiveDelays() first, so browser-based measurements (image +// sizing, canvas-element layout, etc.) are complete before we capture the page. 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. export function captureContentForExternalProcessing( @@ -1597,30 +1623,22 @@ export function captureContentForExternalProcessing( } } - const start = Date.now(); - const finish = () => { + void whenNoActiveDelays().then(() => { 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 || Date.now() - start > kMaxWaitTimeMs) { - finish(); - } else { - 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. (This used to say it was called from C# by a RunJavaScript in +// EditingView.CleanHtmlAndCopyToPageDom; that method is long gone, and nothing outside this file +// calls this now.) +const userStylesheetContent = () => { const ss = Array.from(document.styleSheets).find( (s) => s.title === "userModifiedStyles", ) as CSSStyleSheet | undefined; @@ -1631,11 +1649,27 @@ export const userStylesheetContent = () => { }; export const pageUnloading = () => { + // Stop volunteering snapshots of a page that is going away. C# clears its copy when it starts + // navigating, so anything we sent after that would be for a page nobody is on. See + // pageSnapshot.ts. + 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. This releases whatever it was holding on the page we are + // leaving -- observers, listeners, and any UI it had opened such as a colour picker -- and it + // is the counterpart of the newPageReady() the tool gets for the page we are going to. + // + // Like resetAbovePageControls() below, this used to happen as a side effect of saving, because + // gathering the page content began by detaching the tool from the live page. A save no longer + // touches the live page, so without this nothing detaches the tool at all, and every page + // change leaks another page's worth of the tool's hooks. + getToolboxBundleExports()?.removeToolboxMarkup(); + // Unmount the React root for the controls above the page and re-enable the toolbox (the + // Change Layout toggle disables it). Same story as above: it used to ride along with the save. + resetAbovePageControls(); }; export function topBarButtonClick(button: { command: string }) { @@ -1967,6 +2001,32 @@ export function attachToCkEditor(element) { $("body").addClass("hideAllCKEditors"); const ckedit = CKEDITOR.inline(element); + // Until this editor is ready, we cannot read the true saved text of the box it owns: + // copyCkEditorDataToClone gets the text from the live editors, and before instanceReady there + // is no editor to ask, so the gather reports whatever is in the DOM instead. That is not the + // same thing. SetupElements puts an empty

into an empty editable; CKEditor’s getData() + // reports the box as empty, which is what the book on disk says. So a gather taken in this + // window differs from one taken just after it, for every empty box on the page. + // + // That difference is what made a page nobody had touched decide it had unsaved changes: the + // page snapshot’s baseline is taken as soon as the delay register is clear, which used to be + // before any editor was ready. Registering here (and releasing at instanceReady) puts CKEditor + // attachment under the same gate as image sizing and the other load-time work that finishes + // asynchronously -- which is exactly what the register is for, and it means an early SAVE gets + // the real text too, instead of writing

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 d59b2e41297f..49ee252aec71 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts @@ -22,11 +22,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"; diff --git a/src/BloomBrowserUI/bookEdit/js/bloomVideo.ts b/src/BloomBrowserUI/bookEdit/js/bloomVideo.ts index e93ed298dea5..6ddc1a7cc5ab 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 "./bloomEditing"; // 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. @@ -196,7 +196,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/CanvasElementBackgroundImageManager.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBackgroundImageManager.ts index 70a5fc379a6a..a3de38f0c749 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 { @@ -277,7 +277,9 @@ 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, exactly as the minLevel computation above does. (Before + // comicaljs 0.4.x we could not see that level is optional, and a missing one made this 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..f886a4c5631c 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBubbleLevelUtils.ts +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementBubbleLevelUtils.ts @@ -28,7 +28,9 @@ 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, exactly as the minLevel computation above does. (Before + // comicaljs 0.4.x we could not see that level is optional, and a missing one made this 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 49b4e40b37fa..082fdd9c912d 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 410007b90d54..001422567ccd 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 b9ea73d5d9cb..b93069d58204 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementFactories.ts +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementFactories.ts @@ -874,7 +874,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 a8165a66fe6b..dfb97b45b436 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, @@ -2355,6 +2357,54 @@ export class CanvasElementManager { ); } + // The save-a-page-without-reloading counterpart of turnOffCanvasElementEditing(): put into + // 'cloneOfBody' -- a detached copy of the live document.body -- everything that turning canvas + // element editing off would have put into the page, and leave the live page still being edited. + // + // Only three of the things turnOffCanvasElementEditing() does affect what gets saved: + // * Comical converts its editing into the that draws the bubble tails without + // Javascript. exportSvgToCopiesOfParents does that into the copy while leaving the live + // paper projects alone (comicaljs 0.4.1; before that there was only the destructive + // stopEditing()). + // * The current canvas element positions are recorded as the alternate for the current + // language. That is pure attribute manipulation -- it reads style and data-bubble and + // writes data-bubble-alternate -- so it works on a detached clone, which has no layout. + // * The bloom-focusedCanvasElement class comes off. Nothing else strips it: it is not a + // bloom-ui element, so the C# save pipeline would keep it. + // The rest is live-only: the control frame is a bloom-ui element (so C# discards it anyway), + // EnableAllImageEditing only adds bloom-ui buttons back to the live page, and the listener + // removal has no bearing on the HTML. + public prepareCloneOfBodyForSave(cloneOfBody: HTMLElement): void { + const liveBloomCanvases = this.getAllBloomCanvasesOnPage(); + const clonedBloomCanvases = Array.from( + cloneOfBody.getElementsByClassName(kBloomCanvasClass), + ) as HTMLElement[]; + if (liveBloomCanvases.length !== clonedBloomCanvases.length) { + throw new Error( + `prepareCloneOfBodyForSave(): the clone has ${clonedBloomCanvases.length} bloom-canvases but the live page has ${liveBloomCanvases.length}. The clone must be an untouched copy of the live page.`, + ); + } + + Comical.exportSvgToCopiesOfParents( + liveBloomCanvases.map((liveBloomCanvas, index) => [ + liveBloomCanvas, + clonedBloomCanvases[index], + ]), + ); + + clonedBloomCanvases.forEach((clonedBloomCanvas) => + this.saveCurrentCanvasElementStateAsCurrentLangAlternate( + clonedBloomCanvas, + ), + ); + + Array.from( + cloneOfBody.getElementsByClassName("bloom-focusedCanvasElement"), + ).forEach((element) => + element.classList.remove("bloom-focusedCanvasElement"), + ); + } + public cleanUp(): void { // We used to close a WebSocket here; saving the hook in case we need it someday. } diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementResizeAdjustments.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementResizeAdjustments.ts index 5a58121ce008..ec994e816328 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementResizeAdjustments.ts +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementResizeAdjustments.ts @@ -155,10 +155,13 @@ export function adjustCanvasElementChildrenIfSizeChanged( let newChildHeight = child.clientHeight; let reposition = true; const bubbleSpec = Bubble.getBubbleSpec(child); - needComicalUpdate = - needComicalUpdate || - (!!bubbleSpec.tails && bubbleSpec.tails.length > 0) || - bubbleSpec.spec !== "none"; + // This used to end with `|| bubbleSpec.spec !== "none"`. BubbleSpec has no `spec` member — + // it has `style` — so that term was always true, and this has in fact always been set for + // every child. Until comicaljs 0.4.x a broken import in its .d.ts files typed BubbleSpec as + // `any`, which is why the compiler never objected. Keeping the behavior we have actually + // been shipping rather than quietly changing it to `style` while bumping a dependency; + // whether it SHOULD test style is a separate question. See Edit/SavingWithoutReloading.md. + needComicalUpdate = true; if ( Array.from(child.children).some( (c: HTMLElement) => diff --git a/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts b/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts index b7feafb5da25..620ea1940637 100644 --- a/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts +++ b/src/BloomBrowserUI/bookEdit/js/editableDivUtils.ts @@ -388,6 +388,49 @@ export class EditableDivUtils { return bookmarksForEachEditable; } + // The non-destructive counterpart of doCkEditorCleanup(). Instead of writing CKEditor's + // cleaned-up data back into the LIVE editable divs (which disturbs the running editors and is + // one of the reasons the old save path had to reload the page afterwards), this reads the data + // from the live editors and writes it into the corresponding divs of a detached CLONE of the + // page. The live page is left completely alone. + // liveRoot and cloneRoot must be a live element and a deep clone of it, so that the Nth + // div.bloom-editable in each corresponds; we throw if they have drifted apart. + // See doCkEditorCleanup for why we want getData() rather than the raw innerHTML (BL-12391), + // and removeCkEditorFillingChars for the stray filling char case (BL-16490). + public static copyCkEditorDataToClone( + liveRoot: HTMLElement, + cloneRoot: HTMLElement, + ): void { + const liveDivs = Array.from( + liveRoot.querySelectorAll("div.bloom-editable"), + ); + const cloneDivs = Array.from( + cloneRoot.querySelectorAll("div.bloom-editable"), + ); + if (liveDivs.length !== cloneDivs.length) { + throw new Error( + `copyCkEditorDataToClone(): the clone has ${cloneDivs.length} bloom-editables but the live page has ${liveDivs.length}. The clone must be an untouched copy of the live page.`, + ); + } + liveDivs.forEach((liveDiv, index) => { + const ckeditorOfThisBox = (liveDiv).bloomCkEditor; + if (!ckeditorOfThisBox) { + return; // no editor attached (e.g. an invisible language), so nothing to clean. + } + const ckEditorData = EditableDivUtils.removeCkEditorFillingChars( + ckeditorOfThisBox.getData(), + ); + // Same test as doCkEditorCleanup: only bother when getData() actually differs from + // what is in the DOM. + if (ckEditorData !== liveDiv.innerHTML) { + this.safelyReplaceContentWithCkEditorData( + cloneDivs[index], + ckEditorData, + ); + } + }); + } + // public for unit testing public static safelyReplaceContentWithCkEditorData( div: HTMLDivElement, diff --git a/src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.spec.ts b/src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.spec.ts new file mode 100644 index 000000000000..8bfc013d198e --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.spec.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { removeEditorChromeFromClone } from "./editorChromeCleanup"; + +// A clone of the body as it looks once the editor has finished waking up on an xmatter page: the +// page itself, plus everything CKEditor and qTip added around and inside it. Compare the same page +// as it sits on disk, which is just the .bloom-page div. +function makeClonedBodyWithChrome(): HTMLElement { + const body = document.createElement("div"); // stands in for the cloned document.body + body.innerHTML = ` +
Change Layout
+
+
+
+ +
+ +
+
+
+

The title

+
+
+
+
+
+
+
+ Bold +
+
+
Book title in Temein
+
`; + return body; +} + +describe("removeEditorChromeFromClone", () => { + it("removes the chrome and leaves the page itself alone", () => { + const body = makeClonedBodyWithChrome(); + + // Sanity check the fixture really is in the "editor is running" state, so that a test + // which passes because the chrome was never there cannot masquerade as a passing test. + expect(body.querySelectorAll(".bloom-ui").length).toBe(3); + expect(body.querySelector("#cke_editor1")).not.toBeNull(); + expect(body.querySelector("div.qtip")).not.toBeNull(); + expect(body.querySelector(".ui-resizable-handle")).not.toBeNull(); + + removeEditorChromeFromClone(body); + + expect(body.querySelectorAll(".bloom-ui").length).toBe(0); + expect(body.querySelector("#cke_editor1")).toBeNull(); + expect(body.querySelector("div.qtip")).toBeNull(); + expect(body.querySelector(".ui-resizable-handle")).toBeNull(); + expect(body.querySelector(".cke_widget_wrapper")).toBeNull(); + + // The page's own content survives untouched. + const page = body.querySelector(".bloom-page")!; + expect(page).not.toBeNull(); + expect(page.querySelector("img")!.getAttribute("src")).toBe( + "cover.jpg", + ); + expect(page.querySelector(".bloom-editable p")!.textContent).toBe( + "The title", + ); + }); + + it("strips cke_ classes but keeps the classes that mean something to Bloom", () => { + const body = makeClonedBodyWithChrome(); + const editableBefore = body.querySelector(".bloom-editable")!; + expect(editableBefore.className).toContain("cke_editable"); + + removeEditorChromeFromClone(body); + + const editable = body.querySelector(".bloom-editable")!; + expect(editable.className).toBe("bloom-editable normal-style"); + }); + + it("removes the class attribute entirely when only cke_ classes were on it", () => { + const body = document.createElement("div"); + body.innerHTML = `
x
`; + + removeEditorChromeFromClone(body); + + const span = body.querySelector("span")!; + expect(span).not.toBeNull(); // it is kept; only its class goes + expect(span.hasAttribute("class")).toBe(false); + }); + + it("removes qTip's bookkeeping attributes, which otherwise churn between runs", () => { + const body = makeClonedBodyWithChrome(); + expect(body.querySelectorAll("[data-hasqtip]").length).toBe(2); + + removeEditorChromeFromClone(body); + + expect(body.querySelectorAll("[data-hasqtip]").length).toBe(0); + expect(body.querySelectorAll("[aria-describedby]").length).toBe(0); + }); + + it("keeps an aria-describedby that is not qTip's", () => { + const body = document.createElement("div"); + body.innerHTML = `
`; + + removeEditorChromeFromClone(body); + + expect( + body.querySelector("img")!.getAttribute("aria-describedby"), + ).toBe("figdesc7"); + }); + + it("drops the regenerated ids from Comical's SVG but keeps the SVG itself", () => { + // The SVG is saved on purpose -- it is what draws the bubbles for a reader that has no + // Comical -- but paper.js stamps a fresh GUID into its ids on every redraw, which made + // every page with a bubble look edited on every visit. + const body = document.createElement("div"); + body.innerHTML = `
+ + + +
`; + expect(body.querySelectorAll("svg.comical-generated [id]").length).toBe( + 2, + ); + + removeEditorChromeFromClone(body); + + const svg = body.querySelector("svg.comical-generated")!; + expect(svg).not.toBeNull(); // the drawing itself must survive + expect(svg.querySelectorAll("[id]").length).toBe(0); + // and the geometry, which is the part that actually means something, is untouched + expect(svg.querySelectorAll("path").length).toBe(2); + expect(svg.querySelector("path")!.getAttribute("d")).toBe( + "M-3,328v-331h475v331z", + ); + }); + + it("leaves ids alone on an svg that is not Comical's", () => { + const body = document.createElement("div"); + body.innerHTML = `
`; + + removeEditorChromeFromClone(body); + + expect(body.querySelector("#keep-me")).not.toBeNull(); + }); + + it("does not remove CKEditor bookmark spans, whose ids also start with cke_", () => { + const body = document.createElement("div"); + body.innerHTML = `

ab

`; + + removeEditorChromeFromClone(body); + + expect(body.querySelector("#cke_bm_71S")).not.toBeNull(); + expect(body.querySelector("p")!.textContent).toBe("ab"); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.ts b/src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.ts new file mode 100644 index 000000000000..6434be2aeb9e --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/editorChromeCleanup.ts @@ -0,0 +1,90 @@ +// Strip, from a CLONE of the editing page, the chrome that only exists because the page is being +// edited -- so that what we hand C# is the page, not the editor. +// +// Everything here is something HtmlDom.ProcessPageAfterEditing already removes on the C# side, and +// still does; this is not a replacement for it. What it changes is what we SEND, and that matters +// because of how the page snapshot decides to send anything at all: it posts whenever the gathered +// string differs from the last one it sent (see pageSnapshot.ts). Chrome in that string therefore +// made pages look edited when nobody had touched them -- C# would hold a snapshot, conclude there +// were unsaved changes, and save on the way out. Measured on one book, this is the difference +// between six of its eight pages re-saving themselves on every visit and none of them doing so. +// +// The offenders, in the order they were found: +// * CKEditor’s toolbars and qTip’s bubbles, which those libraries append to the document body. +// Big (they were 20 KB of a 26 KB page) and restless: a bubble fades in and slides into place, +// so its inline style changes several times a second while it appears. +// * bloom-ui elements inside the page -- the image buttons, the format cog. +// * the cke_ classes CKEditor puts on each editable as it attaches. +// * qTip’s bookkeeping attributes. These are the ones that churn between RUNS rather than +// within one: the number in "qtip-0" is handed out in the order the bubbles happen to be +// created, so it rarely matches the number the box was saved with. BloomHintBubbles has long +// noted the wart -- "we unfortunately save in the file the qtip attributes that get added like +// aria-describedby=qtip-0 and has-qtip=true" -- and BookData._attributesNotToCopy already +// refuses to copy them into the data div, calling them "junk that gets left behind by UI". +// +// This is also the cleanup EditingModel.GetCleanCurrentPageFromBodyAndCss asks for in its +// "Enhance: it would be nice if ALL the cleanup happened in one place, probably the Javascript +// method that retrieves the page content". +// +// Nothing here may touch the live page; the caller passes a detached deep copy of document.body. +export function removeEditorChromeFromClone(cloneOfBody: HTMLElement) { + for (const element of Array.from( + cloneOfBody.querySelectorAll(".bloom-ui, .ui-resizable-handle"), + )) { + element.remove(); + } + + // CKEditor’s floating toolbars and qTip’s bubbles. Matching CKEditor by the "cke" class + // rather than the id, because ids beginning "cke_" are also used for bookmark spans INSIDE the + // text, which must not be removed here. bloomQtipUtils.cleanupBubbles() removes the same + // div.qtip elements from the live page. + for (const element of Array.from( + cloneOfBody.querySelectorAll(".cke, div.qtip"), + )) { + element.remove(); + } + + // Only qtip-* values, so that an aria-describedby someone put there on purpose survives. + for (const element of Array.from( + cloneOfBody.querySelectorAll( + "[aria-describedby], [data-hasqtip], [ariasecondary-describedby]", + ), + )) { + if (element.getAttribute("aria-describedby")?.startsWith("qtip-")) + element.removeAttribute("aria-describedby"); + if ( + element + .getAttribute("ariasecondary-describedby") + ?.startsWith("qtip-") + ) + element.removeAttribute("ariasecondary-describedby"); + element.removeAttribute("data-hasqtip"); + } + + // The ids paper.js leaves on the SVG Comical draws for the speech bubbles. Unlike everything + // else here this markup IS saved -- the SVG is what draws the bubbles in the reader, which has + // no Comical to redraw them -- but the ids are regenerated with a fresh GUID every time the + // SVG is, so an otherwise identical redraw produced a different page and any page with a + // bubble looked edited on every visit, forever. On one test book that was five or six + // snapshots per page visit, all of them this. + // + // Safe to drop rather than stabilise: nothing inside the SVG references them (no url(#...), + // no href="#..."), the GUID appears nowhere else in the page, and they are not even unique -- + // "...outlineShape 1 1" occurs twice in one SVG. They are debris, not identifiers. + for (const element of Array.from( + cloneOfBody.querySelectorAll("svg.comical-generated [id]"), + )) { + element.removeAttribute("id"); + } + + // The classes CKEditor adds to each editable it attaches to (cke_editable, cke_focus, ...). + for (const element of Array.from( + cloneOfBody.querySelectorAll("[class*='cke_']"), + )) { + const kept = Array.from(element.classList).filter( + (c) => !c.startsWith("cke_"), + ); + if (kept.length === 0) element.removeAttribute("class"); + else element.setAttribute("class", kept.join(" ")); + } +} diff --git a/src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.spec.ts b/src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.spec.ts new file mode 100644 index 000000000000..0b308e504841 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { removeNiceScrollArtifacts } from "./niceScrollCleanup"; + +// A translationGroup whose editable has been given a niceScroll, in the state bloom-player's +// addScrollbarsToPage() and niceScroll between them leave it: the alignment class moved aside to +// its "-removed" marker, inline styles on the editable, and a rail (with its cursor inside) +// inserted into the nearest positioned ancestor. +function makeScrolledPage(): HTMLElement { + const body = document.createElement("div"); // stands in for the cloned document.body + body.innerHTML = ` +
+
+
+
+
+

Some text that overflows.

+
+
+
+
+
+
+
+
`; + return body; +} + +describe("removeNiceScrollArtifacts", () => { + let body: HTMLElement; + beforeEach(() => { + body = makeScrolledPage(); + }); + + it("sanity check: the test page starts out with all the artifacts", () => { + expect(body.querySelectorAll(".nicescroll-rails").length).toBe(1); + expect(body.querySelectorAll(".nicescroll-cursors").length).toBe(1); + expect( + body.querySelectorAll(".bloom-vertical-align-center-removed") + .length, + ).toBe(1); + expect( + body.querySelector(".bloom-editable")!.style.overflowY, + ).toBe("hidden"); + }); + + it("removes the rails and the cursors niceScroll inserted", () => { + removeNiceScrollArtifacts(body); + + expect(body.querySelectorAll(".nicescroll-rails").length).toBe(0); + expect(body.querySelectorAll(".nicescroll-cursors").length).toBe(0); + }); + + it("puts back the vertical alignment class, so we don't save the page having lost it", () => { + removeNiceScrollArtifacts(body); + + const group = body.querySelector(".bloom-translationGroup")!; + expect(group.classList.contains("bloom-vertical-align-center")).toBe( + true, + ); + expect( + group.classList.contains("bloom-vertical-align-center-removed"), + ).toBe(false); + }); + + it("puts back bloom-vertical-align-bottom too", () => { + const group = body.querySelector(".bloom-translationGroup")!; + group.classList.remove("bloom-vertical-align-center-removed"); + group.classList.add("bloom-vertical-align-bottom-removed"); + + removeNiceScrollArtifacts(body); + + expect(group.classList.contains("bloom-vertical-align-bottom")).toBe( + true, + ); + expect( + group.classList.contains("bloom-vertical-align-bottom-removed"), + ).toBe(false); + }); + + it("removes the scrolling-bubble class added to a canvas element's editable", () => { + const editable = body.querySelector(".bloom-editable")!; + editable.classList.add("scrolling-bubble"); + + removeNiceScrollArtifacts(body); + + expect(editable.classList.contains("scrolling-bubble")).toBe(false); + }); + + it("clears the inline styles niceScroll leaves, and the empty style attribute with them", () => { + removeNiceScrollArtifacts(body); + + const editable = body.querySelector(".bloom-editable")!; + expect(editable.style.overflowY).toBe(""); + expect(editable.style.overflowX).toBe(""); + expect(editable.style.outline).toBe(""); + expect(editable.style.width).toBe(""); + expect(editable.hasAttribute("style")).toBe(false); + }); + + it("keeps other inline styles on a box niceScroll did touch", () => { + const editable = body.querySelector(".bloom-editable")!; + editable.style.color = "red"; + + removeNiceScrollArtifacts(body); + + expect(editable.style.color).toBe("red"); + expect(editable.style.overflowY).toBe(""); + }); + + it("leaves alone an inline width on a box niceScroll never touched", () => { + // No inline overflow-y, so this box was never given a niceScroll and its width is the + // author's, not niceScroll's Chrome workaround. + const editable = body.querySelector(".bloom-editable")!; + editable.setAttribute("style", "width: 200px"); + + removeNiceScrollArtifacts(body); + + expect(editable.style.width).toBe("200px"); + }); + + it("does nothing to a page that never had scroll bars", () => { + const untouched = document.createElement("div"); + untouched.innerHTML = ` +
+
+

Short.

+
+
`; + const before = untouched.innerHTML; + + removeNiceScrollArtifacts(untouched); + + expect(untouched.innerHTML).toBe(before); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.ts b/src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.ts new file mode 100644 index 000000000000..c0df30f9d3e6 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/niceScrollCleanup.ts @@ -0,0 +1,104 @@ +import { kSelectorForPotentialNiceScrollElements } from "bloom-player"; + +// The classes niceScroll gives the elements it inserts. Each rail contains a cursor (its word for +// the thumb); we list both so a stray one can't survive. +const kNiceScrollInsertedElementSelector = + ".nicescroll-rails, .nicescroll-cursors"; + +// The alignment classes bloom-player's addScrollbarsToPage() takes off a translationGroup before +// applying niceScroll, leaving a "-removed" marker in their place so they can be restored. +const kVerticalAlignClassesRemovedForNiceScroll = [ + "bloom-vertical-align-center", + "bloom-vertical-align-bottom", +]; + +/** + * Undo, within 'root', everything that giving an overflowing text box a scroll bar did to the page, + * so that none of it gets saved into the book. + * + * The point of this existing at all — bloom-player already has cleanupNiceScroll() — is that this + * works on any root, including a DETACHED CLONE of the page. bloom-player's version can only work + * on the live page, because it does the job by asking each live niceScroll instance to remove + * itself. Doing that on every save meant tearing the scroll bars off the page the user was looking + * at and building them again (see getBodyContentForSavePage in bloomEditing.ts). + * + * There are three kinds of leftovers: + * + * 1. The elements niceScroll inserts: a .nicescroll-rails div (vertical, plus a horizontal one if + * needed), each containing a .nicescroll-cursors div. It appends them to the nearest positioned + * or scrollable ancestor and falls back to the body. Bloom pages do contain absolutely + * positioned ancestors (origami split-pane components, image-description groups), so they can + * land inside the .bloom-page div; when there is no such ancestor they go on the body instead. + * We are given the whole body, so we catch them either way. + * + * 2. Classes that addScrollbarsToPage() changed, because niceScroll does not work with the + * display:flex our vertical alignment implies: it moves bloom-vertical-align-center / + * bloom-vertical-align-bottom aside to a "-removed" marker on the translationGroup, and adds + * scrolling-bubble to a canvas element's editable. This is the part that matters most — + * saving a page in that state would silently lose the user's vertical alignment choice. + * + * 3. Inline styles niceScroll sets on the box it scrolls: overflow-x and overflow-y (hidden), + * outline (none, on webkit), and a pixel width (part of a Chrome scrollbar workaround, which it + * tries but does not always manage to undo — BL-14052). Those three are exactly what + * bloom-player's cleanup clears after asking niceScroll to remove itself, i.e. the ones + * niceScroll sets without recording so that it can restore them. + * (It can also set position:relative on the scrolled element, but only when it was created with + * a wrapper — the two-argument niceScroll() form — which bloom-player does not use, so that + * case cannot arise here.) + */ +export function removeNiceScrollArtifacts(root: HTMLElement): void { + for (const inserted of Array.from( + root.querySelectorAll(kNiceScrollInsertedElementSelector), + )) { + inserted.remove(); + } + + for (const alignClass of kVerticalAlignClassesRemovedForNiceScroll) { + const removedMarker = alignClass + "-removed"; + // getElementsByClassName is live, and we are about to remove the very class it selects on, + // so take a copy first. + for (const translationGroup of Array.from( + root.getElementsByClassName(removedMarker), + )) { + translationGroup.classList.remove(removedMarker); + translationGroup.classList.add(alignClass); + } + } + + for (const scrollingBubble of Array.from( + root.getElementsByClassName("scrolling-bubble"), + )) { + scrollingBubble.classList.remove("scrolling-bubble"); + } + + for (const scrollBox of Array.from( + root.querySelectorAll( + kSelectorForPotentialNiceScrollElements, + ), + )) { + // An inline overflow-y is niceScroll's fingerprint: it is the first thing it sets on a box + // it is going to scroll, and nothing in Bloom sets one. Checking for it means we can't + // blank an inline width that really was the author's on a box niceScroll never touched. + // (bloom-player's cleanup clears all three unconditionally; it can afford to, because it + // only reaches boxes that had a live niceScroll instance.) + if (!scrollBox.style.overflowY) { + continue; + } + // Naming the longhands explicitly rather than clearing the "overflow" shorthand: whether + // clearing a shorthand takes its longhands with it varies between CSSOM implementations + // (jsdom, where our tests run, does not do it). + for (const property of [ + "overflow", + "overflow-x", + "overflow-y", + "outline", + "width", + ]) { + scrollBox.style.removeProperty(property); + } + if (!scrollBox.getAttribute("style")) { + // Don't leave an empty style attribute behind in the saved HTML. + scrollBox.removeAttribute("style"); + } + } +} diff --git a/src/BloomBrowserUI/bookEdit/js/origami.ts b/src/BloomBrowserUI/bookEdit/js/origami.ts index 84708ef39f77..7cb628e1f008 100644 --- a/src/BloomBrowserUI/bookEdit/js/origami.ts +++ b/src/BloomBrowserUI/bookEdit/js/origami.ts @@ -1,8 +1,9 @@ -import { SetupImage } from "./bloomImages"; +import { SetupImage } from "./bloomImages"; import { kBloomCanvasClass } from "../toolbox/canvas/canvasElementPageBridge"; import "../../lib/split-pane/split-pane.js"; import TextBoxProperties from "../TextBoxProperties/TextBoxProperties"; -import { post, postThatMightNavigate } from "../../utils/bloomApi"; +import { post } from "../../utils/bloomApi"; +import { saveChangesAndRethinkPage } from "./bloomEditing"; import { theOneCanvasElementManager } from "./canvasElementManager/CanvasElementManager"; import { getFeatureStatusAsync } from "../../react_components/featureStatus"; import $ from "jquery"; @@ -190,7 +191,7 @@ function changeLayoutModeToggleClickHandler() { const toggleTransitionLength = 450; setTimeout(() => { $("html").off("keydown.origami"); - postThatMightNavigate("common/saveChangesAndRethinkPageEvent"); + void saveChangesAndRethinkPage(); }, toggleTransitionLength); } } diff --git a/src/BloomBrowserUI/bookEdit/js/pageContentDelays.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageContentDelays.spec.ts new file mode 100644 index 000000000000..95cc3d81a659 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageContentDelays.spec.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + addRequestPageContentDelay, + getActiveDelayIdsForTesting, + kMaxWaitTimeMs, + removeRequestPageContentDelay, + whenNoActiveDelays, + wrapWithRequestPageContentDelay, +} from "./pageContentDelays"; + +// The gate that keeps a save from reading a page that is still being changed. Everything that +// gathers page content waits on whenNoActiveDelays(), so if this is wrong, half-finished work +// (an image still being sized, a paste still in progress) gets written into the user's book. + +// Has the promise settled? Attaches a callback and then lets the microtask queue drain, which is +// enough for a promise that is already resolved (or resolves synchronously from a call we just +// made) and not enough for one still waiting on a timer. +const isResolved = async (p: Promise): Promise => { + let resolved = false; + void p.then(() => { + resolved = true; + }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + return resolved; +}; + +describe("pageContentDelays", () => { + beforeEach(() => { + vi.useFakeTimers(); + // Sanity check: nothing left over from another test, or the assertions below are meaningless. + expect(getActiveDelayIdsForTesting()).toEqual([]); + }); + + afterEach(() => { + vi.useRealTimers(); + if (getActiveDelayIdsForTesting().length) + throw new Error( + "test leaked delays: " + + getActiveDelayIdsForTesting().join(", "), + ); + }); + + it("resolves immediately when nothing is registered", async () => { + expect(await isResolved(whenNoActiveDelays())).toBe(true); + }); + + it("waits while work is registered, and resolves when the last of it finishes", async () => { + addRequestPageContentDelay("sizingAnImage"); + addRequestPageContentDelay("fittingACanvasElement"); + const gate = whenNoActiveDelays(); + + expect(await isResolved(gate)).toBe(false); + + removeRequestPageContentDelay("sizingAnImage"); + expect(await isResolved(gate)).toBe(false); // one still outstanding + + removeRequestPageContentDelay("fittingACanvasElement"); + expect(await isResolved(gate)).toBe(true); + }); + + it("counts repeats of the same id separately", async () => { + // The same operation can legitimately be in flight twice (two images sizing at once). + addRequestPageContentDelay("sizingAnImage"); + addRequestPageContentDelay("sizingAnImage"); + const gate = whenNoActiveDelays(); + + removeRequestPageContentDelay("sizingAnImage"); + expect(await isResolved(gate)).toBe(false); + + removeRequestPageContentDelay("sizingAnImage"); + expect(await isResolved(gate)).toBe(true); + }); + + it("releases every waiter, not just the first", async () => { + addRequestPageContentDelay("work"); + const first = whenNoActiveDelays(); + const second = whenNoActiveDelays(); + + removeRequestPageContentDelay("work"); + + expect(await isResolved(first)).toBe(true); + expect(await isResolved(second)).toBe(true); + }); + + it("gives up after the maximum wait rather than blocking the save forever", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + addRequestPageContentDelay("workThatNeverFinishes"); + const gate = whenNoActiveDelays(); + + await vi.advanceTimersByTimeAsync(kMaxWaitTimeMs - 1); + expect(await isResolved(gate)).toBe(false); + + await vi.advanceTimersByTimeAsync(2); + expect(await isResolved(gate)).toBe(true); + expect(warn).toHaveBeenCalled(); + expect(warn.mock.calls[0][0]).toContain("workThatNeverFinishes"); + + warn.mockRestore(); + removeRequestPageContentDelay("workThatNeverFinishes"); // tidy up for afterEach + }); + + it("does not fire the timeout warning for a wait that finished normally", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + addRequestPageContentDelay("work"); + const gate = whenNoActiveDelays(); + removeRequestPageContentDelay("work"); + await gate; + + // Well past the deadline: the timeout must have been cleared, not merely ignored. + await vi.advanceTimersByTimeAsync(kMaxWaitTimeMs * 2); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("wrapWithRequestPageContentDelay holds the gate for the whole operation", async () => { + let releaseTheWork: (() => void) | undefined; + const work = new Promise((r) => (releaseTheWork = r)); + + const wrapped = wrapWithRequestPageContentDelay(() => work, "theWork"); + const gate = whenNoActiveDelays(); + expect(getActiveDelayIdsForTesting()).toEqual(["theWork"]); + expect(await isResolved(gate)).toBe(false); + + releaseTheWork!(); + await wrapped; + + expect(await isResolved(gate)).toBe(true); + expect(getActiveDelayIdsForTesting()).toEqual([]); + }); + + it("wrapWithRequestPageContentDelay releases the gate even when the work throws", async () => { + await expect( + wrapWithRequestPageContentDelay( + () => Promise.reject(new Error("the work failed")), + "theWork", + ), + ).rejects.toThrow("the work failed"); + + // The point: a failed operation must not block every save from now on. + expect(getActiveDelayIdsForTesting()).toEqual([]); + expect(await isResolved(whenNoActiveDelays())).toBe(true); + }); + + it("complains about, and ignores, a removal of something never registered", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + addRequestPageContentDelay("realWork"); + + removeRequestPageContentDelay("neverRegistered"); + + expect(error).toHaveBeenCalled(); + expect(getActiveDelayIdsForTesting()).toEqual(["realWork"]); + error.mockRestore(); + removeRequestPageContentDelay("realWork"); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/js/pageContentDelays.ts b/src/BloomBrowserUI/bookEdit/js/pageContentDelays.ts new file mode 100644 index 000000000000..a6a1f78b2aef --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageContentDelays.ts @@ -0,0 +1,101 @@ +// The register of asynchronous work that must finish before the page can be saved, and the gate +// every page-content-gathering path waits on. +// +// The problem it solves: saving means reading the page's DOM, and quite a lot of the editor changes +// that DOM asynchronously -- sizing an image, fitting a canvas element's background, pasting from +// the clipboard, building a custom xmatter page. Read the page while one of those is half done and +// that is what gets written into the user's book. +// +// So any code doing such work registers here for its duration (preferably via +// wrapWithRequestPageContentDelay, which cannot forget to deregister), and every route that gathers +// page content goes through whenNoActiveDelays() first: +// - the C#-initiated save (requestPageContent in bloomEditing.ts). This is the route the register +// really exists for: C# picks the moment, so in-flight work has no other way to hold it off. +// - the browser-initiated ones (getPageContentForSaveWhenReady, used by savePageWithoutReloading +// and by the page list's commands, via collectCurrentPageContent). Javascript could in +// principle await its own work instead, but it cannot know about work someone else started, so +// it waits here too. That also means the *command* does not begin -- C# is not asked to +// duplicate or delete a page until the page has settled. +// - the off-screen book processor (captureContentForExternalProcessing). + +// Upper bound (not a fixed wait) on how long we wait for in-flight async DOM work to finish before +// gathering anyway. The wait ends as soon as the register empties, so simple pages are unaffected +// by this value; it only gives slower computers with complex pages more headroom before we give up. +export const kMaxWaitTimeMs = 4000; + +const activeDelays: string[] = []; + +// Callbacks waiting for activeDelays to empty; see whenNoActiveDelays(). +const delayWaiters: (() => void)[] = []; + +// Register asynchronous work whose results belong in the saved page. The caller must pass the same +// id to removeRequestPageContentDelay when the work finishes -- see wrapWithRequestPageContentDelay, +// which does that for you. IDs do not need to be unique; the same ID can be added multiple times. +export function addRequestPageContentDelay(id: string): void { + activeDelays.push(id); +} + +// Deregister work, releasing anyone waiting if this was the last of it. +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 (activeDelays.length === 0) { + // Take the list before calling anyone, so that a waiter which starts new work (and so + // registers a new delay) does not get released a second time by that work finishing. + delayWaiters.splice(0).forEach((release) => release()); + } +} + +// Run some asynchronous work with its delay registered for the duration, whether it succeeds or +// throws. Prefer this to the add/remove pair: a delay that is never removed blocks every save for +// kMaxWaitTimeMs and then gets overridden anyway. +export async function wrapWithRequestPageContentDelay( + fn: () => Promise, + delayId: string, +): Promise { + addRequestPageContentDelay(delayId); + try { + return await fn(); + } finally { + removeRequestPageContentDelay(delayId); + } +} + +// Resolves once no registered work is outstanding: immediately if there is none, otherwise as soon +// as the last of it finishes, and after kMaxWaitTimeMs regardless -- saving a slightly stale page +// beats not saving at all, so we warn and go on rather than block the user forever. +export function whenNoActiveDelays(): Promise { + if (activeDelays.length === 0) return Promise.resolve(); + return new Promise((resolve) => { + let timeout: number | undefined; + const release = () => { + if (timeout !== undefined) window.clearTimeout(timeout); + resolve(); + }; + delayWaiters.push(release); + timeout = window.setTimeout(() => { + console.warn( + `Waited the maximum ${kMaxWaitTimeMs}ms for in-flight page changes [${activeDelays.join( + ", ", + )}]. Gathering the page content anyway.`, + ); + const index = delayWaiters.indexOf(release); + if (index >= 0) delayWaiters.splice(index, 1); + resolve(); + }, kMaxWaitTimeMs); + }); +} + +// For tests and diagnostics only: what is currently registered. +export function getActiveDelayIdsForTesting(): string[] { + return [...activeDelays]; +} diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts new file mode 100644 index 000000000000..0970da047cdd --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + startWatchingPageForSnapshots, + stopWatchingPageForSnapshots, + quietMsForTests, +} from "./pageSnapshot"; + +const posted: Array<{ url: string; body: string }> = []; + +// Lets a test hold a POST open, to check that a second one never starts alongside it. +let postHook: (() => Promise) | undefined; + +vi.mock("../../utils/bloomApi", () => ({ + postString: (url: string, body: string) => { + posted.push({ url, body }); + return postHook ? postHook() : Promise.resolve(); + }, +})); + +// The page as the gather would report it. Tests change this to simulate the user editing. +let contentToReport = ""; +const gather = () => Promise.resolve(contentToReport); + +function setUpPage(pageId = "page-1") { + document.body.innerHTML = `

hello

`; +} + +function changeThePage(text: string) { + document.querySelector(".bloom-page p")!.textContent = text; +} + +// startWatching... reads the page once to learn what "unchanged" looks like after loading has +// finished. Nothing is posted until that has resolved. +async function letTheBaselineSettle() { + await vi.runAllTicks(); + await Promise.resolve(); +} + +// A MutationObserver delivers its callback in a microtask, and the module then waits kQuietMs. +// This walks both forward. +async function letTheSnapshotHappen() { + await Promise.resolve(); // let the observer fire + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + await Promise.resolve(); // the gather's await + await Promise.resolve(); // the post's await +} + +describe("pageSnapshot", () => { + beforeEach(() => { + vi.useFakeTimers(); + posted.length = 0; + contentToReport = ""; + postHook = undefined; + setUpPage(); + }); + + afterEach(() => { + stopWatchingPageForSnapshots(); + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("posts nothing for a page the user never changes", async () => { + contentToReport = "the untouched page"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + vi.advanceTimersByTime(quietMsForTests * 5); + await vi.runAllTicks(); + + expect( + posted.length, + "a page nobody edited must produce no snapshot, so that C# can tell 'nothing to save' from 'not asked yet'", + ).toBe(0); + }); + + it("does not treat the page finishing loading as an edit", async () => { + // Loading is not over when we start watching: image sizing and canvas-element layout + // complete afterwards and mutate the page. The observer cannot tell those from the user, + // so the baseline has to. Without it the real app posted a snapshot for every page opened, + // which would have made "no snapshot" meaningless on the C# side. + contentToReport = "the settled page"; + startWatchingPageForSnapshots(gather); + + changeThePage("a late load-time fix-up"); + await letTheBaselineSettle(); + changeThePage("and another"); + await letTheSnapshotHappen(); + + expect( + posted.length, + "mutations that do not change the page's saved form are not edits", + ).toBe(0); + }); + + it("posts the content, with the page id, once the page has been changed and settles", async () => { + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + contentToReport = "edited content"; + changeThePage("goodbye"); + await letTheSnapshotHappen(); + + expect(posted.length).toBe(1); + expect(posted[0].body).toBe("edited content"); + expect(posted[0].url).toContain("editView/pageSnapshot"); + expect(posted[0].url).toContain("pageId=page-1"); + }); + + it("does not post again when the content has not actually changed", async () => { + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + contentToReport = "same every time"; + changeThePage("a"); + await letTheSnapshotHappen(); + expect(posted.length, "sanity: the first change posts").toBe(1); + + // Tools constantly add and remove editing decorations, which the gather strips. Those + // mutations must not produce a stream of identical posts. + changeThePage("b"); + await letTheSnapshotHappen(); + + expect(posted.length).toBe(1); + }); + + it("stops posting once the page is unloaded", async () => { + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + contentToReport = "first"; + changeThePage("a"); + await letTheSnapshotHappen(); + expect(posted.length, "sanity: it was posting before we stopped").toBe( + 1, + ); + + stopWatchingPageForSnapshots(); + contentToReport = "second"; + changeThePage("b"); + await letTheSnapshotHappen(); + + expect(posted.length).toBe(1); + }); + + it("waits for the page to be quiet rather than posting per change", async () => { + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + contentToReport = "typed a word"; + + // Three changes in quick succession, as typing produces. + changeThePage("a"); + await Promise.resolve(); + vi.advanceTimersByTime(quietMsForTests / 4); + changeThePage("ab"); + await Promise.resolve(); + vi.advanceTimersByTime(quietMsForTests / 4); + changeThePage("abc"); + await letTheSnapshotHappen(); + + expect( + posted.length, + "the debounce should collapse a burst of changes into one snapshot", + ).toBe(1); + expect(posted[0].body).toBe("typed a word"); + }); + + it("never has two posts in flight at once", async () => { + // HTTP does not promise that two outstanding POSTs arrive in the order they were sent, so + // an older snapshot could land after a newer one and C# would keep the older content. That + // needs a machine slow enough for a post to still be in flight when the next keystroke's + // snapshot comes round -- so it must be enforced, not left to timing. + let inFlight = 0; + let maxInFlight = 0; + let releasePost: () => void = () => {}; + postHook = () => + new Promise((resolve) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + releasePost = () => { + inFlight--; + resolve(); + }; + }); + + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + contentToReport = "first"; + changeThePage("a"); + await Promise.resolve(); + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + await Promise.resolve(); + expect(inFlight, "sanity: a post is outstanding").toBe(1); + + // More edits arrive while that post is still outstanding. + contentToReport = "second"; + changeThePage("b"); + await Promise.resolve(); + vi.advanceTimersByTime(quietMsForTests * 3); + await vi.runAllTicks(); + await Promise.resolve(); + + expect( + maxInFlight, + "a second post must not start while one is outstanding", + ).toBe(1); + + // Once it completes, the newer content still gets sent. + releasePost(); + await vi.runAllTicks(); + await Promise.resolve(); + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + await Promise.resolve(); + releasePost(); + await vi.runAllTicks(); + await Promise.resolve(); + + expect( + posted.map((p) => p.body), + "the later edit must still reach C#, just after the first post finished", + ).toEqual(["first", "second"]); + }); + + it("takes another snapshot when the page changes while one is being gathered", async () => { + let release: (value: string) => void = () => {}; + let gatherCount = 0; + const slowGather = () => { + gatherCount++; + return new Promise((resolve) => { + release = resolve; + }); + }; + startWatchingPageForSnapshots(slowGather); + + // The first gather is the baseline; let it finish. + expect(gatherCount, "sanity: the baseline gather started").toBe(1); + release("baseline"); + await letTheBaselineSettle(); + + changeThePage("a"); + await Promise.resolve(); + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + expect(gatherCount, "sanity: a snapshot gather started").toBe(2); + + // While that gather is outstanding, the user types again. That change is not in what the + // gather is about to hand us, so it must not be silently dropped. + changeThePage("b"); + await Promise.resolve(); + + release("first content"); + await vi.runAllTicks(); + await Promise.resolve(); + await Promise.resolve(); + expect(posted.length, "sanity: the first gather posted").toBe(1); + + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + expect( + gatherCount, + "the change that landed mid-gather must trigger another snapshot, not be dropped", + ).toBe(3); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts new file mode 100644 index 000000000000..1544dcb7a5d8 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -0,0 +1,217 @@ +import { postString } from "../../utils/bloomApi"; + +// Keep C# supplied with the current content of the page being edited, so that a save never has to +// ask for it and wait. +// +// The old arrangement was a round trip: C# wanted the page, told the browser to send it, and then +// had to have somewhere to wait until the answer arrived on a separate API call. That wait is what +// the editing state machine's SavePending state exists for, and it is why everything that has to +// save first -- leaving the Edit tab, closing the collection, a page-list command -- had to be +// split into a "before" and an "after" around an asynchronous gap. +// +// Since BL-13502 gathering the page is cheap (~0.7 ms) and does not touch the live page at all, so +// the browser can simply volunteer it: after any change that settles, post the current content. +// C# stores the string (see PageSnapshot.cs) and a save then takes it synchronously. +// +// What makes this safe to rely on is that we post only when the page's SAVED FORM has actually +// changed, so "no snapshot" on the C# side means "no unsaved changes" rather than "we have not +// been told yet". Two things are needed for that, and neither is optional: +// +// * A baseline taken once the page has finished loading. Loading is not over when bootstrap() +// returns -- image sizing and canvas layout finish afterwards and mutate the page -- so without +// one, every page posts a snapshot seconds after opening even if nobody touches it. +// * Comparing each gather against the last thing we sent. Tools constantly add and remove editing +// decorations, which the gather strips anyway, so without this they produce a stream of +// identical posts. + +const kApi = "editView/pageSnapshot"; + +// How long the page must be quiet before we take a snapshot. +// +// This is small on purpose, and the size of it decides how much typing an exit could lose. What +// it has to buy is coalescing: measured on a real page, ONE keystroke produces about nine +// MutationObserver batches, because CKEditor does a lot of DOM work per key. 25 ms collapses those +// into a single gather, and no lower value would buy anything more -- below about 25 ms the lag is +// dominated by the POST, not by us. +// +// Measured on a 26 KB page (see Edit/SavingWithoutReloading.md): +// gather 0.4 ms median (0.2 - 1.6) +// keystroke -> C# has the content ~49 ms (25 debounce + gather + POST) +// posts while typing one per keystroke +// +// The cost of being this eager is one POST per keystroke instead of one per pause, and one extra +// snapshot per page visit (a short debounce catches the page mid-settle as well as settled). Both +// are cheap: the gather is off the critical path at 0.4 ms, the POST goes to localhost and C# +// only stores the string, replacing the last one. +const kQuietMs = 25; + +let observer: MutationObserver | undefined; +let timer: number | undefined; +let lastPosted: string | undefined; +let pageIdBeingWatched: string | undefined; +// How we read the page. Passed in by the caller rather than imported, so this module does not +// depend on bloomEditing (which depends on it, for the teardown) -- and so a test can drive it +// without a real page. +let gatherPageContent: (() => Promise) | undefined; +// Until the post-load baseline is in, we do not know which of the mutations we are seeing are the +// page finishing loading and which are the user, so we hold off posting. See startWatching... +let baselineTaken = false; +// True while a gather-and-post is under way. See takeSnapshot: overlapping posts could arrive out +// of order, which would let an older snapshot overwrite a newer one on the C# side. +let busy = false; +// Bumped every time a change arrives. The async gather checks it afterwards, so a change that +// lands while we were gathering schedules another pass instead of being lost. +let changeCount = 0; + +function currentPageId(): string | undefined { + return document.querySelector(".bloom-page")?.id || undefined; +} + +async function takeSnapshot(): Promise { + const pageId = pageIdBeingWatched; + if (!pageId || !gatherPageContent) return; + if (!baselineTaken) { + // The page is still finishing loading. Come back once we know what "unchanged" looks like. + scheduleSnapshot(); + return; + } + // Only ever one gather-and-post at a time. + // + // Two would be a correctness bug, not just waste: HTTP does not promise that two outstanding + // POSTs arrive in the order they were sent, so an OLDER snapshot could land after a newer one + // and C# would keep the older content -- silently dropping the newest edits. It takes a slow + // enough machine, or a big enough page, for a post to still be in flight when the next + // keystroke's snapshot comes round, which is exactly the case this has to survive. + // + // Returning here loses nothing: the run that is already going re-schedules if anything changed + // while it worked, and it reads changeCount after it finishes, so it sees those changes. The + // effect on a slow machine is that snapshots coalesce by themselves rather than piling up. + if (busy) return; + busy = true; + const countWhenStarted = changeCount; + try { + // Waits for any in-flight work that belongs in the page (see pageContentDelays), then + // reads the page the same way a real save does, so a snapshot can never differ from what + // a save would have produced at the same moment. + const content = await gatherPageContent(); + + // The page may have been unloaded, or navigated, while we were waiting. + if (pageIdBeingWatched !== pageId) return; + + if (content !== lastPosted) { + lastPosted = content; + await postString( + `${kApi}?pageId=${encodeURIComponent(pageId)}`, + content, + ); + } + } finally { + busy = false; + } + // Something changed while we were gathering or posting: that change is not in what we just + // sent, so go round again. + if (changeCount !== countWhenStarted) scheduleSnapshot(); +} + +function scheduleSnapshot(): void { + if (timer !== undefined) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = undefined; + void takeSnapshot(); + }, kQuietMs); +} + +function noteChange(): void { + changeCount++; + scheduleSnapshot(); +} + +/** + * Start watching the page that has just become editable. Safe to call again; it restarts on the + * new page. + */ +export function startWatchingPageForSnapshots( + gather: () => Promise, +): void { + stopWatchingPageForSnapshots(); + const pageId = currentPageId(); + if (!pageId) return; // no page to watch (e.g. the off-screen capture path) + gatherPageContent = gather; + pageIdBeingWatched = pageId; + lastPosted = undefined; + changeCount = 0; + baselineTaken = false; + + // Take a baseline of the page as it ends up once it has finished loading, and treat that as + // "already sent". Without it every page posts a snapshot within a second of being opened, even + // if the user never touches it -- because loading is not finished when bootstrap() returns. + // Image sizing and canvas-element layout complete asynchronously afterwards and mutate the + // page, and the observer cannot tell those from the user's own edits. + // + // That mattered: it broke the property C# depends on, that no snapshot means no unsaved + // changes. (Found by watching the real app: a page nobody had touched posted one anyway.) + // + // The gather waits for the delay register -- but that is NOT enough to make this the settled + // page, and measurement says so: the baseline still differs from the settled content, and the + // one snapshot an untouched page posts is byte-identical to the settled page. At the moment + // we run, the asynchronous fix-ups have not registered their delays yet, so the register is + // empty and the gather returns immediately. + // + // Deliberately NOT "fixed" by delaying the baseline until the page is quiet. That would make + // the baseline include any edit the user managed in the meantime, and since load-time + // settling is indistinguishable from typing, we would then have no way to tell we owed C# a + // snapshot of it -- trading a harmless duplicate for a lost edit. One post per page visit, + // carrying exactly what a save would have written, is the better end of that trade. + void gather().then( + (baseline) => { + if (pageIdBeingWatched !== pageId) return; // moved on while we waited + lastPosted = baseline; + baselineTaken = true; + // If the page changed while we were taking the baseline, that change may or may not + // be in it; go round again rather than assume. + if (changeCount > 0) scheduleSnapshot(); + }, + () => { + if (pageIdBeingWatched !== pageId) return; + // We could not read the page. Fail towards reporting too much rather than too little: + // an extra snapshot costs a redundant save, a missing one costs the user's typing. + lastPosted = undefined; + baselineTaken = true; + scheduleSnapshot(); + }, + ); + + // A MutationObserver rather than input/keyup handlers, because plenty of what changes a page + // never goes through a keyboard event: a tool rewriting the markup, a canvas element being + // dragged, an image being replaced, a paste. Anything that changes the DOM is a change we owe + // C# a snapshot of. + observer = new MutationObserver(noteChange); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + }); +} + +/** + * Stop watching, and forget what we last sent. Called from pageUnloading(). + */ +export function stopWatchingPageForSnapshots(): void { + observer?.disconnect(); + observer = undefined; + if (timer !== undefined) { + window.clearTimeout(timer); + timer = undefined; + } + pageIdBeingWatched = undefined; + lastPosted = undefined; + gatherPageContent = undefined; + baselineTaken = false; + busy = false; +} + +/** + * Exported for tests: the interval the page must be quiet before a snapshot is taken. + */ +export const quietMsForTests = kQuietMs; diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts b/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts new file mode 100644 index 000000000000..4bcd493dde77 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts @@ -0,0 +1,64 @@ +import { getEditablePageBundleExports } from "../js/workspaceFrames"; + +// Collect the content of the page the user is currently editing, to send along with a request +// that will make C# save it. +// +// C# has to save the current page before it can change pages, duplicate one, delete one, and so +// on. Sending the content with the request lets it do all of that in one step. Otherwise it has +// to ask the browser for the content and wait for the answer to arrive on a separate API, and +// while it waits it is in a state where a further request of the same kind is silently thrown +// away. (See EditingModel.SavePageInPlaceThen.) +// +// This is async because it must NOT read the page while asynchronous work whose results belong in +// the saved page is still running -- image sizing, canvas-element fitting, a clipboard paste. That +// is what the delay register in bloomEditing.ts tracks, and awaiting getPageContentForSaveWhenReady +// is how we stay behind it. The gathering itself is still cheap (well under a millisecond: it works +// on a clone and does no layout), and the wait is normally zero, and capped either way. +// +// Because the whole command waits on this, the command cannot start mid-change either: C# is not +// asked to duplicate, delete or reorder anything until the page has settled. +// +// If we cannot collect it we return undefined and leave it out of the request; C# then falls back +// to asking. That is the honest thing to do for the cases where there is nothing to collect (no +// page loaded yet) or where the page is in a state we should not be reading (mid-navigation), +// rather than sending something half-formed: this content is about to be written into the user's +// book. +// +// The promise we await belongs to the PAGE frame. If that frame navigates while we are waiting, +// its timers and microtask queue go with it and the promise simply never settles -- and since the +// whole command is waiting on us, the command would be dropped without a trace, which is worse +// than doing it without the content. So we give up after a while and let C# ask for the content +// the old way. The timer is ours, in this frame, precisely so that it survives the page frame +// going away. +const kGiveUpWaitingMs = 6000; // comfortably past the page frame's own 4s cap + +export async function collectCurrentPageContent( + whatFor: string, +): Promise { + try { + const content = + getEditablePageBundleExports()?.getPageContentForSaveWhenReady(); + if (!content) return undefined; + let giveUp: number | undefined; + const abandoned = new Promise((resolve) => { + giveUp = window.setTimeout(() => { + console.warn( + `gave up waiting for the current page's content for ${whatFor} (the page frame ` + + `may have navigated away mid-wait); C# will ask the page frame for it instead.`, + ); + resolve(undefined); + }, kGiveUpWaitingMs); + }); + try { + return await Promise.race([content, abandoned]); + } finally { + if (giveUp !== undefined) window.clearTimeout(giveUp); + } + } catch (error) { + console.warn( + `could not collect the current page's content for ${whatFor}; C# will ask the page frame for it instead.`, + error, + ); + return undefined; + } +} diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageControls/pageControls.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageControls/pageControls.tsx index 67dc65fe01c8..06abf7b6cc0e 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageControls/pageControls.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageControls/pageControls.tsx @@ -4,6 +4,7 @@ import { renderRoot } from "../../../utils/reactRender"; import BloomButton from "../../../react_components/bloomButton"; import WebSocketManager from "../../../utils/WebSocketManager"; import { confirmRemovePage } from "../confirmRemovePage"; +import { collectCurrentPageContent } from "../currentPageContent"; import "./pageControls.less"; import "errorHandler"; @@ -20,6 +21,14 @@ import "errorHandler"; const kPageControlsContext = "pageThumbnailList-pageControls"; +// Duplicating or deleting a page makes C# save the current page first, so send its content along +// and save it the round trip of asking us for it. Note this waits for any in-flight change to the +// page to settle before it posts, so the command does not start mid-change either. See +// collectCurrentPageContent(). +async function postPageControlCommand(endpoint: string) { + postThatMightNavigate(endpoint, await collectCurrentPageContent(endpoint)); +} + interface IPageControlsState { canAddState: boolean; canDuplicateState: boolean; @@ -113,8 +122,11 @@ class PageControls extends React.Component { l10nKey="EditTab.DuplicatePageButton" l10nComment="Button that tells Bloom to duplicate the currently selected page." data-testid="duplicate-page-button" - clickApiEndpoint="edit/pageControls/duplicatePage" - mightNavigate={true} + onClick={() => + postPageControlCommand( + "edit/pageControls/duplicatePage", + ) + } enabledImageFile="/bloom/bookEdit/pageThumbnailList/pageControls/duplicatePage.svg" disabledImageFile="/bloom/bookEdit/pageThumbnailList/pageControls/duplicatePageDisabled.svg" hasText={false} @@ -128,7 +140,7 @@ class PageControls extends React.Component { enabled={this.state.canDeleteState} onClick={() => confirmRemovePage(() => - postThatMightNavigate( + postPageControlCommand( "edit/pageControls/deletePage", ), ) diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx index bc6320c35b9f..324e3ae188ff 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/pageThumbnailList.tsx @@ -28,6 +28,7 @@ import { postString, useApiData, } from "../../utils/bloomApi"; +import { collectCurrentPageContent } from "./currentPageContent"; import { PageThumbnail } from "./PageThumbnail"; import LazyLoad, { forceCheck } from "react-lazyload"; import { useL10n } from "../../react_components/l10nHooks"; @@ -702,10 +703,7 @@ const PageList: React.FunctionComponent<{ initialPageLayout: string }> = ( const pageElt = e.currentTarget.closest("[id]")!; const pageId = pageElt.getAttribute("id"); const caption = pageElt.getAttribute("data-caption"); - postJson("pageList/pageClicked", { - pageId, - detail: caption, - }); + postPageClicked(pageId!, caption ?? ""); } } }; @@ -817,10 +815,15 @@ const PageList: React.FunctionComponent<{ initialPageLayout: string }> = ( closeContextMenuOnBlurCleanupRef.current = undefined; const pageId = contextMenuPoint.pageId; - const postCommand = () => + // Most of these commands (duplicate, copy, paste, remove) have to save the current page + // first, so send its content along. See collectCurrentPageContent(). + const postCommand = async () => postJson("pageList/contextMenuItemClicked", { pageId, commandId, + pageContent: await collectCurrentPageContent( + `the ${commandId} command`, + ), }); if (commandId === "removePage") { confirmRemovePage(postCommand); @@ -1057,16 +1060,35 @@ function onDragStop( // the page clicked. (Note however that this seems to get fired on any click, // even just closing a popup menu, so it's possible that we might get more // click events than we really want.) - postJson("pageList/pageClicked", { - pageId: movedPageId, - detail: "unknown", - }); + postPageClicked(movedPageId, "unknown"); return; } // Needs more smarts if we ever do other than two columns. const newIndex = newItem.y * 2 + newItem.x; - postJson("pageList/pageMoved", { movedPageId, newIndex }); + // Moving a page saves the current one first; see collectCurrentPageContent(). + void collectCurrentPageContent("the page move").then((pageContent) => + postJson("pageList/pageMoved", { + movedPageId, + newIndex, + pageContent, + }), + ); +} + +// Tell C# the user picked a page, sending the CURRENT page's content along with the click so it +// can save the page we are leaving in the same step. See collectCurrentPageContent(). +async function postPageClicked( + pageId: string, + detail: string, + onSuccess?: () => void, +): Promise { + const pageContent = await collectCurrentPageContent("the page change"); + postJson( + "pageList/pageClicked", + { pageId, detail, pageContent }, + onSuccess, + ); } function ContinueAutomatedPageClicking( @@ -1082,22 +1104,15 @@ function ContinueAutomatedPageClicking( "** pageThumbnailList: user initiated Automated Page Clicking test function", ); } - postJson( - "pageList/pageClicked", - { - pageId: pagesRemaining[0].key, - detail: pagesRemaining[0].caption, - }, - () => { - const remaining = pagesRemaining.slice(1); - if (remaining.length > 0) - window.setTimeout( - () => { - ContinueAutomatedPageClicking(remaining, count + 1); - }, - 8 * 1000, // leave time for the browser to redraw - ); - else window.alert("Done with automated page clicking"); - }, - ); + postPageClicked(pagesRemaining[0].key, pagesRemaining[0].caption, () => { + const remaining = pagesRemaining.slice(1); + if (remaining.length > 0) + window.setTimeout( + () => { + ContinueAutomatedPageClicking(remaining, count + 1); + }, + 8 * 1000, // leave time for the browser to redraw + ); + else window.alert("Done with automated page clicking"); + }); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTextMenuItems.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTextMenuItems.ts index de31057c1a97..66a82dde27be 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTextMenuItems.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTextMenuItems.ts @@ -5,8 +5,9 @@ import * as React from "react"; import { default as CheckIcon } from "@mui/icons-material/Check"; -import { get, postThatMightNavigate } from "../../../utils/bloomApi"; -import { wrapWithRequestPageContentDelay } from "../../js/bloomEditing"; +import { get } from "../../../utils/bloomApi"; +import { saveChangesAndRethinkPage } from "../../js/bloomEditing"; +import { wrapWithRequestPageContentDelay } from "../../js/pageContentDelays"; import { getCanvasElementManager } from "./canvasElementPageBridge"; import { IControlContext, IControlMenuCommandRow } from "./canvasControlTypes"; @@ -388,9 +389,7 @@ export function makeFieldTypeMenuItem( translationGroup, ); translationGroup.remove(); - postThatMightNavigate( - "common/saveChangesAndRethinkPageEvent", - ); + void saveChangesAndRethinkPage(); return; } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx index d3d6f2588cef..37436338330d 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx @@ -64,6 +64,7 @@ export class CanvasTool extends ToolboxToolReactAdaptor { } public detachFromPage() { + super.detachFromPage(); // this tool has no removeToolMarkup work, but see ITool.detachFromPage const canvasElementManager = getCanvasElementManager(); if (canvasElementManager) { // For now we are leaving canvas element editing on, because even with the toolbox hidden, diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx index 23a110a078eb..36a2d400d0f0 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/customXmatterPage.tsx @@ -14,10 +14,8 @@ import { import { ensureFieldFitsOnCustomPage } from "./derivedFieldFitting"; import { getAsync, postData, postString } from "../../../utils/bloomApi"; import { Bubble, BubbleSpec } from "comicaljs"; -import { - recomputeSourceBubblesForPage, - wrapWithRequestPageContentDelay, -} from "../../js/bloomEditing"; +import { recomputeSourceBubblesForPage } from "../../js/bloomEditing"; +import { wrapWithRequestPageContentDelay } from "../../js/pageContentDelays"; import { updateAbovePageControls } from "../../js/AbovePageControls"; import BloomSourceBubbles from "../../sourceBubbles/BloomSourceBubbles"; import { ILanguageNameValues } from "../../bookAndPageSettings/FieldVisibilityGroup"; diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx index 4d47fc9688a3..6b6bf011b838 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx @@ -1837,11 +1837,12 @@ export class GameTool extends ToolboxToolReactAdaptor { } } - public detachFromPage() { - const page = GameTool.getBloomPage(); - if (page) { - undoPrepareActivity(page); - } + // While the user is on the Play tab, prepareActivity() has put the page into play mode; that + // markup must not be saved. undoPrepareActivity() is pure DOM surgery on the page element it is + // given, so the save path can run it on a clone and leave the live page in play mode, while + // detachFromPage (inherited) runs the very same thing on the live page. + public removeToolMarkup(pageOrClone: HTMLElement): void { + undoPrepareActivity(pageOrClone); } } export function playSound( diff --git a/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescription.tsx b/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescription.tsx index 1939e861c08e..11b03ca546bb 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescription.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescription.tsx @@ -13,8 +13,8 @@ import { Link } from "../../../react_components/link"; import { ToolBottomHelpLink } from "../../../react_components/ToolBottomHelpLink"; import { BloomCheckbox } from "../../../react_components/BloomCheckBox"; import { - hideImageDescriptions, showImageDescriptions, + unwrapDescribedImages, } from "./imageDescriptionUtils"; import { getCanvasElementManager } from "../canvas/canvasElementPageBridge"; import { kBloomCanvasClass } from "../canvas/canvasElementConstants"; @@ -357,11 +357,23 @@ export class ImageDescriptionAdapter extends ToolboxToolReactAdaptor { ); } + // The only thing this tool adds inside the page that would otherwise be saved is the + // bloom-describedImage wrapper. (The bloom-showImageDescriptions class is on the body, which is + // outside the page div we save, so it belongs in detachFromPage below.) + public removeToolMarkup(pageOrClone: HTMLElement): void { + unwrapDescribedImages(pageOrClone); + } + public detachFromPage() { - const page = ToolBox.getPage(); - if (page) { - hideImageDescriptions(page); + const bodyOfPageIframe = ToolBox.getPage(); + if (!bodyOfPageIframe) { + return; } + // Removing the class and the wrappers must both happen before we resume comic editing; + // resume may not work right while the extra wrapper is present. + bodyOfPageIframe.classList.remove("bloom-showImageDescriptions"); + super.detachFromPage(); // removeToolMarkup: unwraps the bloom-describedImage wrappers + getCanvasElementManager()?.resumeComicEditing(); } public isExperimental(): boolean { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescriptionUtils.ts b/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescriptionUtils.ts index 6449a57af919..fff9309275be 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescriptionUtils.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/imageDescription/imageDescriptionUtils.ts @@ -32,14 +32,22 @@ export function hideImageDescriptions(bodyOfPageIframe: HTMLElement) { // removing the class and wrapper should be done first; resume may not work // right while the extra wrapper is present. bodyOfPageIframe.classList.remove("bloom-showImageDescriptions"); - // unwrap the contents of each bloom-describedImage + unwrapDescribedImages(bodyOfPageIframe); + canvasElementManager?.resumeComicEditing(); +} + +// Undo the bloom-describedImage wrapper that showImageDescriptions() adds around the non-description +// contents of each bloom-canvas. This is the only part of hideImageDescriptions() that changes markup +// which would otherwise be saved, so it is also the only part the save path needs. It touches nothing +// but the DOM under 'root', so it is safe to run on a detached clone of the page (which is how the +// save path uses it: see removeMarkupFromPageClone in the tools that show image descriptions). +export function unwrapDescribedImages(root: HTMLElement) { for (const describedImage of Array.from( - bodyOfPageIframe.getElementsByClassName("bloom-describedImage"), + root.getElementsByClassName("bloom-describedImage"), )) { for (const child of Array.from(describedImage.children)) { describedImage.parentElement!.appendChild(child); } describedImage.remove(); } - canvasElementManager?.resumeComicEditing(); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/impairmentVisualizer/impairmentVisualizer.tsx b/src/BloomBrowserUI/bookEdit/toolbox/impairmentVisualizer/impairmentVisualizer.tsx index 011d7772007c..59eaf9c55a05 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/impairmentVisualizer/impairmentVisualizer.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/impairmentVisualizer/impairmentVisualizer.tsx @@ -189,17 +189,20 @@ export class ImpairmentVisualizerControls extends React.Component< } } - public static removeImpairmentVisualizerMarkup() { - const page = ToolboxToolReactAdaptor.getPage(); - if (!page || !page.ownerDocument) return; - ImpairmentVisualizerControls.removeColorBlindnessMarkup(page); - const body = page.ownerDocument.body; + // The classes that drive the cataract and colour-blindness filters live on the page iframe's + // body, which is outside the .bloom-page div and so is never saved. That makes this live-page + // work, unlike removeColorBlindnessMarkup: see ImpairmentVisualizerAdaptor.detachFromPage. + public static removeSimulationClassesFromBody() { + const body = ToolboxToolReactAdaptor.getPage(); + if (!body) return; body.classList.remove("simulateColorBlindness"); body.classList.remove("simulateCataracts"); } // Caller is responsible for guarding against a null page parameter. - private static removeColorBlindnessMarkup(page: HTMLElement) { + // Public because it is also the tool's ITool.removeToolMarkup implementation, which the save + // path runs on a clone of the page. + public static removeColorBlindnessMarkup(page: HTMLElement) { [].slice .call(page.getElementsByClassName("ui-cbOverlay")) .map((x) => x.parentElement.removeChild(x)); @@ -359,8 +362,16 @@ export class ImpairmentVisualizerAdaptor extends ToolboxToolReactAdaptor { this.controlsElement.updateSimulations(undefined); } + // The colour-blindness overlays are the only markup this tool puts inside the page div. (The + // simulateColorBlindness/simulateCataracts classes go on the body, which we never save, so + // removing those is left to removeImpairmentVisualizerMarkup, below.) + public removeToolMarkup(pageOrClone: HTMLElement): void { + ImpairmentVisualizerControls.removeColorBlindnessMarkup(pageOrClone); + } + public detachFromPage() { - ImpairmentVisualizerControls.removeImpairmentVisualizerMarkup(); + super.detachFromPage(); // removeToolMarkup: the overlays + ImpairmentVisualizerControls.removeSimulationClassesFromBody(); } public isExperimental(): boolean { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/motion/motionTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/motion/motionTool.tsx index 2589f48d2188..c4deeb192526 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/motion/motionTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/motion/motionTool.tsx @@ -279,25 +279,34 @@ export class MotionTool extends ToolboxToolReactAdaptor { this.setupResizeObserver(); } + // The start/end rectangles are the one bit of this tool's editing markup that isn't bloom-ui, + // so they would be saved if we didn't take them out. (Their positions are already stored in the + // bloom-canvas's data-initialrect/data-finalrect attributes by updateDataAttributes(), so + // removing the rectangles loses nothing.) We also drop the audio highlight this tool's preview + // leaves behind. + public removeToolMarkup(pageOrClone: HTMLElement): void { + pageOrClone.querySelector("#animationStart")?.remove(); + pageOrClone.querySelector("#animationEnd")?.remove(); + MotionTool.removeCurrentAudioMarkup(pageOrClone); + } + public detachFromPage() { + // This must come first: while a preview is playing, the rectangles have been moved into the + // animation canvas, and cleanupAnimation() is what puts the page back together. if (this.rootControl.state.playing) { this.rootControl.setState({ playing: false }); window.clearTimeout(this.stopPreviewTimeout); this.cleanupAnimation(); } - const page = this.getPage(); - if (page) { - this.removeElt(page.getElementById("animationStart")); - this.removeElt(page.getElementById("animationEnd")); - } + super.detachFromPage(); // removeToolMarkup: the rectangles and the audio highlight + // enhance: if more than one image...do what?? const bloomCanvasToAnimate = this.getBloomCanvasToAnimate(); if (!bloomCanvasToAnimate) { return; } EnableImageEditing(bloomCanvasToAnimate); - this.removeCurrentAudioMarkup(); if (this.observer) { this.observer.disconnect(); } @@ -306,13 +315,11 @@ export class MotionTool extends ToolboxToolReactAdaptor { } } - private removeCurrentAudioMarkup(): void { - const page = this.getPage(); - if (!page) return; - const currentAudioElts = page.getElementsByClassName("ui-audioCurrent"); - if (currentAudioElts.length) { - currentAudioElts[0].classList.remove("ui-audioCurrent"); - } + // Static, and taking the root to work in, so that removeToolMarkup() can use it on a clone. + private static removeCurrentAudioMarkup(pageOrClone: ParentNode): void { + pageOrClone + .querySelector(".ui-audioCurrent") + ?.classList.remove("ui-audioCurrent"); } public id(): string { @@ -903,7 +910,7 @@ export class MotionTool extends ToolboxToolReactAdaptor { if (this.narrationPlayer) { this.narrationPlayer.stopListen(); } - this.removeCurrentAudioMarkup(); + MotionTool.removeCurrentAudioMarkup(page); // stop background music this.getPlayer().pause(); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/readers/decodableReader/decodableReaderTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/readers/decodableReader/decodableReaderTool.tsx index 2097265ea23f..e94de735a626 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/readers/decodableReader/decodableReaderTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/readers/decodableReader/decodableReaderTool.tsx @@ -2,6 +2,7 @@ import ToolboxToolReactAdaptor from "../../toolboxToolReactAdaptor"; import { DecodableReaderToolControls } from "./DecodableReaderToolControls"; import { beginInitializeDecodableReaderTool } from "../readerTools"; import { getTheOneReaderToolsModel, MarkupType } from "../readerToolsModel"; +import { removeReaderMarkup } from "../removeReaderMarkup"; import { get } from "../../../../utils/bloomApi"; import { isReaderToolEnabledOnCurrentPage } from "../readerToolPageState"; import { renderRoot } from "../../../../utils/reactRender"; @@ -36,7 +37,16 @@ export class DecodableReaderTool extends ToolboxToolReactAdaptor { // usually updateMarkup will do this, unless we are coming from showTool model.doMarkup(); } + // Take our markup off the page we are about to save (a clone), or off the live page when we + // are being detached from it. See removeReaderMarkup. + public removeToolMarkup(pageOrClone: HTMLElement): void { + removeReaderMarkup(pageOrClone); + } + public detachFromPage(): void { + super.detachFromPage(); // takes the markup off the live page + // ...and this stops it coming back: it also resets the model so that further typing is + // not marked up. getTheOneReaderToolsModel().setMarkupType(0); } public updateMarkup() { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/readers/leveledReader/leveledReaderTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/readers/leveledReader/leveledReaderTool.tsx index 17c434c65f0f..b47ba4f0ed5b 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/readers/leveledReader/leveledReaderTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/readers/leveledReader/leveledReaderTool.tsx @@ -4,6 +4,7 @@ import ToolboxToolReactAdaptor from "../../toolboxToolReactAdaptor"; import { isReaderToolEnabledOnCurrentPage } from "../readerToolPageState"; import { beginInitializeLeveledReaderTool } from "../readerTools"; import { getTheOneReaderToolsModel } from "../readerToolsModel"; +import { removeReaderMarkup } from "../removeReaderMarkup"; import { LeveledReaderToolControls } from "./LeveledReaderToolControls"; import $ from "jquery"; @@ -86,9 +87,18 @@ export class LeveledReaderTool extends ToolboxToolReactAdaptor { model.doMarkup(); } + // Take our markup off the page we are about to save (a clone), or off the live page when we + // are being detached from it. See removeReaderMarkup. + public removeToolMarkup(pageOrClone: HTMLElement): void { + removeReaderMarkup(pageOrClone); + } + // this function removes all markup from a page when either that page has been // closed or the tool has been closed. public detachFromPage(): void { + super.detachFromPage(); // takes the markup off the live page + // ...and this stops it coming back: it also resets the model so that further typing is + // not marked up. getTheOneReaderToolsModel().setMarkupType(0); } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.spec.ts b/src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.spec.ts new file mode 100644 index 000000000000..56f2bb7a08ea --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { removeReaderMarkup } from "./removeReaderMarkup"; + +// The reader tools mark a page that has more text on it than the level allows. That marking is an +// editing aid and must not reach the user's book. Since a save now works from a clone, the cleanup +// has to be something we can point at an element rather than something that reaches into the live +// page frame. + +const pageWith = (inner: string, pageClasses = "bloom-page") => { + const page = document.createElement("div"); + page.className = pageClasses; + page.innerHTML = inner; + return page; +}; + +describe("removeReaderMarkup", () => { + it("takes the too-much-text marking off the page itself", () => { + const page = pageWith( + "

text

", + "bloom-page page-too-many-words-or-sentences", + ); + expect( + page.classList.contains("page-too-many-words-or-sentences"), + ).toBe(true); // sanity + + removeReaderMarkup(page); + + expect( + page.classList.contains("page-too-many-words-or-sentences"), + ).toBe(false); + expect(page.classList.contains("bloom-page")).toBe(true); + }); + + it("takes it off a page nested inside what it is given", () => { + // The save path hands us a clone of the body, so the marked div is a descendant. + const body = document.createElement("div"); + body.appendChild( + pageWith("

x

", "bloom-page page-too-many-words-or-sentences"), + ); + + removeReaderMarkup(body); + + expect( + body.querySelectorAll(".page-too-many-words-or-sentences").length, + ).toBe(0); + }); + + it("leaves the text alone", () => { + // The tools' word and sentence highlighting is painted with the CSS Custom Highlight API, + // so there is nothing of theirs inside the text to clean up -- and nothing here may + // disturb what the user actually wrote. + const html = + '

Just text, with a | in it.

'; + const page = pageWith( + html, + "bloom-page page-too-many-words-or-sentences", + ); + + removeReaderMarkup(page); + + expect(page.innerHTML).toBe(html); + }); + + it("leaves a page that was never marked alone", () => { + const html = '

Just text.

'; + const page = pageWith(html); + + removeReaderMarkup(page); + + expect(page.className).toBe("bloom-page"); + expect(page.innerHTML).toBe(html); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.ts b/src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.ts new file mode 100644 index 000000000000..88e2abdb86e9 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/readers/removeReaderMarkup.ts @@ -0,0 +1,31 @@ +// Take the decodable/leveled reader tools' editing markup off a page — either the live one, when +// the tool is being detached, or the clone we are about to save. +// +// There is exactly one thing to remove: the class the tools put on the .bloom-page div to mark a +// page as having more text on it than the level allows. It is an editing aid and must not be +// stored in the user's book. +// +// Nothing has to be done inside the text itself. The tools' word- and sentence-level highlighting +// is drawn with the CSS Custom Highlight API, which paints ranges without touching the DOM, and +// the hover tip is `bloom-ui`, which the C# save pipeline discards. (Older versions of the markup +// code did wrap each sentence/word/grapheme in a span, which is why removeSynphonyMarkup() still +// unwraps those; the only place that still produces them is the Reader Setup dialog's own word +// list, which is never part of a book.) +// +// removeSynphonyMarkup() cannot do this job in any case, because it reaches into the live page +// frame by id rather than working on an element it is given, so it can only ever clean the page +// the user is looking at. That was fine when saving destroyed the live page anyway; now that we +// save from a clone (BL-13502), the cleanup has to be element-scoped, which is what this is. + +const kTooMuchStuffOnPageClass = "page-too-many-words-or-sentences"; + +export function removeReaderMarkup(pageOrClone: HTMLElement): void { + // The class lives on the .bloom-page div, which may be the element we were given (when a tool + // is detached from the live page) or inside it (when we are cleaning a clone of the body). + if (pageOrClone.classList.contains(kTooMuchStuffOnPageClass)) + pageOrClone.classList.remove(kTooMuchStuffOnPageClass); + for (const marked of Array.from( + pageOrClone.getElementsByClassName(kTooMuchStuffOnPageClass), + )) + marked.classList.remove(kTooMuchStuffOnPageClass); +} diff --git a/src/BloomBrowserUI/bookEdit/toolbox/signLanguage/signLanguageTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/signLanguage/signLanguageTool.tsx index 76a0e64d7d67..01f93a47708a 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/signLanguage/signLanguageTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/signLanguage/signLanguageTool.tsx @@ -1037,6 +1037,7 @@ export class SignLanguageTool extends ToolboxToolReactAdaptor { } public detachFromPage() { + super.detachFromPage(); // this tool has no removeToolMarkup work, but see ITool.detachFromPage this.reactControls.leaveCurrentVideoContext(); // Decided NOT to remove bloom-selected here. It's harmless (only the edit stylesheet // does anything with it) and leaving it allows us to keep the same one selected diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts index 341595c94fc4..51bb5c92bad5 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts @@ -14,6 +14,7 @@ export interface IAudioRecorder { setRecordingMode(recordingMode: RecordingMode): Promise; handleImportRecordingClick(): void; removeRecordingSetup: () => void; + undoHighlightingFixes: (pageOrClone: ParentNode) => void; getUpdateMarkupAction: () => Promise<() => void>; setupForRecordingAsync: () => Promise; handleToolHiding: () => void; diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts index 1199dff5aaac..dadf81534b85 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts @@ -105,6 +105,11 @@ const kEnableHighlightClass = "ui-enableHighlight"; // For example, some elements have highlighting prevented at this level // because its content has been broken into child elements, only some of which show the highlight const kDisableHighlightClass = "ui-disableHighlight"; +// Stamped on the ui-enableHighlight spans that fixHighlighting() creates, so undoHighlightingFixes +// can take out OUR spans and leave alone any the book itself contains. An attribute, not a +// JS-side record of the elements, because the undo also has to work on a CLONE of the page, whose +// elements are different objects. +const kTempHighlightAttr = "data-bloom-temp-highlight"; const kAudioSentence = "audio-sentence"; // Even though these can now encompass more than strict sentences, we continue to use this class name for backwards compatability reasons const kAudioSentenceClassSelector = "." + kAudioSentence; const kBloomEditableTextBoxClass = "bloom-editable"; @@ -4997,14 +5002,14 @@ export default class AudioRecording implements IAudioRecorder { ); if (containsNonHighlightText) { - if (!this.nodesToRestoreAfterPlayEnded.has(element.id)) { - // Note: The map could already have the id if you do Play -> Pause -> Play - // We want the modifications to exist during the Pause period, - // and we want the original innerHTML to win, so that's why we need to check - // if the ID exists already and avoid overwriting it. - this.nodesToRestoreAfterPlayEnded.set( + // Remember that we touched this one, and whether the no-highlight class was + // ours to remove -- a book can carry that class itself, and then it is not + // ours to take off. Keep the FIRST answer: on Play -> Pause -> Play the class + // is present the second time round because WE added it. + if (!this.elementsWeFixedHighlightingIn.has(element.id)) { + this.elementsWeFixedHighlightingIn.set( element.id, - element.innerHTML, + !element.classList.contains(kDisableHighlightClass), ); } @@ -5134,27 +5139,87 @@ export default class AudioRecording implements IAudioRecorder { private makeHighlightedSpan(textContent: string) { const newSpan = document.createElement("span"); newSpan.classList.add(kEnableHighlightClass); + newSpan.setAttribute(kTempHighlightAttr, "true"); newSpan.appendChild(document.createTextNode(textContent)); return newSpan; } - private nodesToRestoreAfterPlayEnded = new Map(); + // The audio spans fixHighlighting() has modified in this session, by id, each mapped to + // whether WE added kDisableHighlightClass to it (as opposed to the book already having it). + // Deliberately not a snapshot of what was in them -- see undoHighlightingFixes. + private elementsWeFixedHighlightingIn = new Map(); + + /** + * Take the temporary highlight-segment markup fixHighlighting() added back out, under + * 'pageOrClone'. The caller chooses what to apply it to: the live page (when the page is going + * away, via revertFixHighlighting) or a clone of it (when we are saving the page the user is + * still working on). + * + * This UNDOES the transformation rather than restoring a snapshot of what the element held + * beforehand, and that distinction matters: the user can type into a text box while its audio + * is playing, and playback is exactly when these fixes are in place. Replaying a snapshot taken + * when playback started would throw that typing away -- silently, and into the saved book + * (BL-13502). Unwrapping only what we added cannot: anything else in the element is left where + * it is, including text that arrived after the fix, and including the phrase-delimiter spans + * that removeToolMarkup wraps around a "|" just before calling us. + * + * Scoped to the elements we actually fixed, for the same reason the snapshot version was: an + * older book can legitimately contain ui-enableHighlight spans of its own (HtmlDom.cs even + * generates user-style rules targeting them), and a save must not quietly strip those. + * + * Purely DOM, and idempotent: undoing on a clone leaves the live page's fixes in place, ready + * to be undone again for the next save. See ITool.removeToolMarkup. + */ + public undoHighlightingFixes(pageOrClone: ParentNode) { + this.elementsWeFixedHighlightingIn.forEach( + (weAddedTheNoHighlightClass, id) => { + // Deliberately NOT `querySelector(\`#${id}\`)`. An id that is not a valid CSS + // identifier -- a legacy one starting with a digit, say -- makes that form THROW, and + // this now runs during every save's clone cleanup, where a throw would abort the whole + // page gather and we would post an error string instead of the user's page. Comparing + // the property cannot throw whatever the id looks like. + const element = Array.from( + pageOrClone.querySelectorAll("[id]"), + ).find((candidate) => candidate.id === id); + if (!element) { + console.warn("Can't find element " + id); + return; + } + // Unwrap the spans we wrapped runs of text in: put each one's children back where it + // was and drop it. Only OURS -- selected by the marker fixHighlighting stamps on them + // -- because a book can legitimately contain ui-enableHighlight spans of its own, even + // nested inside a sentence the tool has touched, and a save must not strip those. + for (const span of Array.from( + element.querySelectorAll( + `span.${kEnableHighlightClass}[${kTempHighlightAttr}]`, + ), + )) { + const parent = span.parentNode; + if (!parent) continue; + while (span.firstChild) + parent.insertBefore(span.firstChild, span); + parent.removeChild(span); + // Rejoin the text nodes that leaves adjacent, so the result has the shape the text + // had before rather than a run of separate nodes. + parent.normalize(); + } + // And the class we put on the audio span itself to stop the whole thing highlighting + // -- but only if it was ours to put there. + if (weAddedTheNoHighlightClass) + element.classList.remove(kDisableHighlightClass); + }, + ); + } /** * This function will undo in BloomDesktop the modifications made by fixHighlighting() */ public revertFixHighlighting() { - this.nodesToRestoreAfterPlayEnded.forEach((htmlToRestore, id) => { - const pageDocBody = this.getPageDocBody(); - const element = pageDocBody?.querySelector(`#${id}`); - if (element) { - element.innerHTML = htmlToRestore; - element.classList.remove(kDisableHighlightClass); - } else { - console.warn("Can't find element " + id); - } - }); - this.nodesToRestoreAfterPlayEnded.clear(); + const pageDocBody = this.getPageDocBody(); + if (pageDocBody) { + this.undoHighlightingFixes(pageDocBody); + } + this.elementsWeFixedHighlightingIn.clear(); this.refreshAudioTextHighlights(); } } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts index 29ff89db35a9..c9eb28c4946f 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts @@ -2341,6 +2341,170 @@ describe("audio recording tests", () => { expect(colorSpans[4].innerText).toBe("Three"); }); + describe("- undoHighlightingFixes()", () => { + // It takes fixHighlighting()'s temporary markup back out -- off the live page when the page + // is going away, and off the CLONE we are about to save while the user goes on editing. + // It undoes the transformation rather than restoring a snapshot of what the element held + // before, and that is the point: a save can happen while audio is playing, which is exactly + // when these fixes are in place, so by then the user may have typed. A snapshot would throw + // that typing away, silently, into the saved book (BL-13502). + const fixedUpBox = () => { + SetupIFrameFromHtml( + '

One Two    Three

', + ); + const box1 = getFrameElementById("page", "box1")!; + // The SAME recorder must do the fixing and the undoing, as in production (both go + // through the one theOneAudioRecorder): it only undoes in elements it knows it fixed. + const recording = new AudioRecording(); + recording.fixHighlighting(box1); + const span = box1.querySelector("span")!; + // Sanity: the fix really did happen, so the assertions below aren't watching a no-op. + expect( + span.classList.contains("ui-disableHighlight"), + "test setup: fixHighlighting should have marked the span", + ).toBe(true); + expect( + span.querySelectorAll("span.ui-enableHighlight").length, + "test setup: fixHighlighting should have wrapped the text runs", + ).toBeGreaterThan(0); + // fixHighlighting only re-wraps; it does not change the text. So this is what the + // text should still read after the undo. (Captured rather than written out, because + // the fixture's runs of   are not the plain spaces they look like.) + const textBefore = span.textContent; + return { box1, span, recording, textBefore }; + }; + + // Every test below asserts the undo actually happened, not merely that something survived + // it -- otherwise an undo that did nothing at all would pass most of them. + const expectUndone = (span: Element) => { + expect( + span.querySelectorAll("span.ui-enableHighlight").length, + "the highlight-run spans should be gone", + ).toBe(0); + expect( + span.classList.contains("ui-disableHighlight"), + "the no-highlight marking should be gone", + ).toBe(false); + }; + + it("puts the text back the way it was", () => { + const { box1, span, recording, textBefore } = fixedUpBox(); + + recording.undoHighlightingFixes(box1); + + expectUndone(span); + expect(span.textContent).toBe(textBefore); + // Rejoined, not left as the several adjacent text nodes unwrapping produces. + expect(span.childNodes.length).toBe(1); + }); + + it("keeps text typed while the audio was playing", () => { + const { box1, span, recording } = fixedUpBox(); + span.appendChild(document.createTextNode(" typed later")); + + recording.undoHighlightingFixes(box1); + + expectUndone(span); + expect(span.textContent).toContain("typed later"); + }); + + it("keeps text typed inside one of the highlight runs", () => { + const { box1, span, recording } = fixedUpBox(); + const firstRun = span.querySelector("span.ui-enableHighlight")!; + firstRun.textContent = firstRun.textContent + " inserted"; + + recording.undoHighlightingFixes(box1); + + expectUndone(span); + expect(span.textContent).toContain("inserted"); + }); + + it("leaves the phrase-delimiter spans alone", () => { + // removeToolMarkup enshrouds the vertical bars BEFORE calling us, so its work has to + // survive -- restoring a snapshot would have wiped it out and the bars would show. + const { box1, span, recording } = fixedUpBox(); + const marker = document.createElement("span"); + marker.classList.add("bloom-audio-split-marker"); + marker.textContent = "|"; + span.appendChild(marker); + + recording.undoHighlightingFixes(box1); + + expectUndone(span); + expect( + span.querySelectorAll("span.bloom-audio-split-marker").length, + ).toBe(1); + }); + + it("leaves alone a book's own highlight span nested inside a sentence it fixed", () => { + // The tighter version of the test below. Scoping by element is not enough: a book can + // carry ui-enableHighlight markup of its own INSIDE a sentence the tool has touched, + // and a save must not quietly strip that out of the file. + const { box1, span, recording } = fixedUpBox(); + const fromTheBook = box1.ownerDocument.createElement("span"); + fromTheBook.classList.add("ui-enableHighlight"); + fromTheBook.textContent = "the book's own"; + span.appendChild(fromTheBook); + + recording.undoHighlightingFixes(box1); + + expect( + span.querySelectorAll("span.ui-enableHighlight").length, + "the book's own span should survive", + ).toBe(1); + expect(span.textContent).toContain("the book's own"); + }); + + it("leaves the no-highlight class alone when the book already had it", () => { + SetupIFrameFromHtml( + '

One Two    Three

', + ); + const box1 = getFrameElementById("page", "box1")!; + const recording = new AudioRecording(); + recording.fixHighlighting(box1); + + recording.undoHighlightingFixes(box1); + + expect( + box1 + .querySelector("span")! + .classList.contains("ui-disableHighlight"), + "a class the book brought is not ours to remove", + ).toBe(true); + }); + + it("leaves alone highlight spans it did not put there", () => { + // An older book can legitimately carry ui-enableHighlight spans of its own (HtmlDom.cs + // even generates user-style rules targeting them); a save must not quietly strip those + // just because the Talking Book tool happens to be open. + const { box1, recording } = fixedUpBox(); + const other = box1.ownerDocument.createElement("span"); + other.id = "notOneOfOurs"; + other.innerHTML = + 'from the book'; + box1.appendChild(other); + + recording.undoHighlightingFixes(box1); + + expect( + other.querySelectorAll("span.ui-enableHighlight").length, + "a span we never fixed should be left alone", + ).toBe(1); + }); + + it("can be run more than once", () => { + // Every save undoes on a clone; the live page's fixes stay, to be undone again later. + const { box1, span, recording } = fixedUpBox(); + + recording.undoHighlightingFixes(box1); + const afterFirst = span.textContent; + recording.undoHighlightingFixes(box1); + + expectUndone(span); + expect(span.textContent).toBe(afterFirst); + }); + }); + describe("- fixHighlighting()", () => { const scenarios: ("Check" | "Listen to whole page")[] = [ "Check", @@ -2382,7 +2546,7 @@ describe("audio recording tests", () => { ).toBe(true); expect(childSpan.innerHTML).toBe( - 'One Two  Three   Four    End', + 'One Two  Three   Four    End', ); }); @@ -2401,8 +2565,8 @@ describe("audio recording tests", () => { expect(box1.innerHTML).toBe( "

" + 'One Two  End1.' + - 'Three   End2.' + - 'Four    Five     End3.' + + 'Three   End2.' + + 'Four    Five     End3.' + "

", ); }); @@ -2439,8 +2603,8 @@ describe("audio recording tests", () => { expect(box1.innerHTML).toBe( "

" + 'One Two  End1.' + - 'Three   End2.' + - 'Four    Five     End3.' + + 'Three   End2.' + + 'Four    Five     End3.' + "

", ); }); @@ -2459,7 +2623,7 @@ describe("audio recording tests", () => { // Verification expect(box1.innerHTML).toBe( "

" + - 'Three   End2.' + + 'Three   End2.' + "

", ); }); @@ -2480,7 +2644,7 @@ describe("audio recording tests", () => { // Verification expect(box1.innerHTML).toBe( "

" + - 'Three\u200B \u200BEnd2.' + + 'Three\u200B \u200BEnd2.' + "

", ); }); @@ -3121,7 +3285,7 @@ function getExpectedResultForComplexHtmlFromUser() {

-

              Mientras navegaban,                               Jesús se quedó                             profundamente dormido.

-

         ​ ​   ​ De pronto, una gran                            tormenta se desató. 

+

              Mientras navegaban,                               Jesús se quedó                             profundamente dormido.

+

         ​ ​   ​ De pronto, una gran                            tormenta se desató. 

`; } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/talkingBookTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/talkingBookTool.tsx index 18d48657b367..619d5412a3c4 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/talkingBookTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/talkingBookTool.tsx @@ -1,5 +1,6 @@ -import { hideImageDescriptions } from "../imageDescription/imageDescriptionUtils"; +import { unwrapDescribedImages } from "../imageDescription/imageDescriptionUtils"; import { kBloomCanvasClass } from "../canvas/canvasElementConstants"; +import { getCanvasElementManager } from "../canvas/canvasElementPageBridge"; import { beginLoadSynphonySettings } from "../readers/readerTools"; import { getTheOneToolbox } from "../toolbox"; import { ToolBox } from "../toolbox"; @@ -136,18 +137,34 @@ export default class TalkingBookTool extends ToolboxToolReactAdaptor { } } + // The markup this tool adds that would otherwise reach the saved HTML: the + // bloom-describedImage wrappers, the visible "|" phrase-delimiter spans, and (while audio is + // playing or paused) the highlight-segment spans fixHighlighting() inserts. Everything else + // removeRecordingSetup() deals with is either bloom-ui (the playback-order controls, the + // recording icon), not in the DOM at all (the ::highlight registry), or purely tool state, so + // it lives in detachFromPage below. + public removeToolMarkup(pageOrClone: HTMLElement): void { + unwrapDescribedImages(pageOrClone); + TalkingBookTool.enshroudPhraseDelimiters(pageOrClone); + getAudioRecorder()?.undoHighlightingFixes(pageOrClone); + } + public detachFromPage() { const audioRecorder = getAudioRecorder(); // not quite sure how this can be called when never initialized, but if // we don't have the object we certainly can't use it. if (audioRecorder) { + // Live-only: takes down the playback-order UI and resets the tool's own state. It also + // calls revertFixHighlighting(), which does the same DOM restoration removeToolMarkup() + // does and then clears the record of it, so the super call below finds nothing left. audioRecorder.removeRecordingSetup(); } - const page = ToolBox.getPage(); - if (page) { - hideImageDescriptions(page); - TalkingBookTool.enshroudPhraseDelimiters(page); - } + // The rest is what hideImageDescriptions() used to do for us here: the + // bloom-showImageDescriptions class is on the body, which is outside the page div that + // removeToolMarkup() gets, and comic editing must not resume until the wrappers are gone. + ToolBox.getPage()?.classList.remove("bloom-showImageDescriptions"); + super.detachFromPage(); + getCanvasElementManager()?.resumeComicEditing(); } // Called whenever the user edits text. diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts index 5b90c7331def..6adb17a6d1bf 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolbox.ts @@ -100,8 +100,32 @@ export interface ITool { // To guard against certain race conditions, we currently call this again after 600ms. Tools should // allow for this possibility and not repeat any work that was already done. newPageReady(); - detachFromPage(); // called when a page is going away AND before hideTool + // Remove from 'pageOrClone' the markup this tool adds for editing that must not be saved. + // THE SAME METHOD IS USED TWO WAYS, which is why the parameter is named as it is: + // * on every save, with a detached CLONE of the .bloom-page div, so we can save clean HTML + // while the user goes on editing the real page (see getPageContentForSave in + // bloomEditing.ts); + // * on the live .bloom-page div when the page is going away, from detachFromPage(). + // So it must be pure DOM surgery inside 'pageOrClone': it may not reach out to the live + // document, and it may not change this tool's own state (that would be wrong on a save, when + // the tool is still running). Anything live-only — observers, React state, re-enabling image + // editing, clearing caches — belongs in detachFromPage() instead. + // Leave it as the inherited no-op if the markup this tool adds is all either marked bloom-ui or + // ui-resizable-handle, or is a cke_* class, or lives outside the .bloom-page div: the C# save + // pipeline already discards all of those (see HtmlDom.ProcessPageAfterEditing). But make that a + // deliberate decision, not an omission. + removeToolMarkup(pageOrClone: HTMLElement): void; + // Called when a page is going away AND before hideTool. ToolboxToolReactAdaptor's + // implementation calls removeToolMarkup() on the live page, so a tool that has nothing + // live-only to do needs only removeToolMarkup(). OVERRIDE THIS ONLY TO ADD live-only teardown, + // and be sure to call super.detachFromPage() at the point where the markup should come off; + // detachCurrentTool() complains to the console if you forget. + detachFromPage(): void; id(): string; // without trailing "Tool"! + // True if the last call to detachFromPage() reached ToolboxToolReactAdaptor's implementation, + // i.e. removeToolMarkup() was run on the live page. Only detachCurrentTool() should use this; + // it is how we notice a tool that overrode detachFromPage() and forgot to call super. + didRemoveToolMarkupWhileDetaching(): boolean; hasRestoredSettings: boolean; isAlwaysEnabled(): boolean; isExperimental(): boolean; @@ -390,7 +414,7 @@ export class ToolBox { } this.doWhenClosingTool = []; if (currentTool && isToolInitialized(currentTool)) { - currentTool.detachFromPage(); + detachToolFromPage(currentTool); } } // A list of tasks to do when the current tool is closed. This is currently used to @@ -807,7 +831,21 @@ function detachCurrentTool() { } else if (currentTool && isToolInitialized(currentTool)) { // If the toolbox is not available, we still may be able to detach the current tool. // This is what we used to do before we had some extra behavior in the toolbox. - currentTool.detachFromPage(); + detachToolFromPage(currentTool); + } +} + +// Detach one tool from the live page, and complain if it overrode detachFromPage() without calling +// super.detachFromPage(). That mistake is easy to make and its symptom is remote: the tool's markup +// stays on the page and gets saved into the book, but only sometimes and only for that tool. Since +// this runs while we are changing pages, we report rather than throw — losing the page change would +// be worse than the stale markup we are warning about. +function detachToolFromPage(tool: ITool): void { + tool.detachFromPage(); + if (!tool.didRemoveToolMarkupWhileDetaching()) { + console.error( + `${tool.id()}Tool.detachFromPage() did not call super.detachFromPage(), so its removeToolMarkup() never ran on the live page. See ITool.detachFromPage.`, + ); } } @@ -1136,12 +1174,26 @@ function restoreToolboxSettingsWhenPageReady(settings: ToolboxSettings) { }); } -// Remove any markup the toolbox is inserting. Called by a RunJavaScript() in EditingView -// before saving the page. +// Remove any markup the toolbox is inserting. Called when the page is going away (or the tool is +// being switched); it detaches the current tool from the live page, which leaves the page unusable +// for further editing. export function removeToolboxMarkup() { detachCurrentTool(); } +// Strip from 'pageClone' — a detached clone of the page div — any markup the current tool added for +// editing that must not be saved. This runs the very same ITool.removeToolMarkup() that +// detachFromPage() runs on the live page, so the two can't drift apart; the difference is only in +// what we hand it. Everything else about detaching (the doWhenClosingTool tasks that close popups +// and dialogs, each tool's live-only teardown) is deliberately skipped: the user is still on this +// page and still using this tool. +// Called (via the toolbox bundle exports) from the page iframe's getPageContentForSave(). +export function removeToolMarkupFromPageClone(pageClone: HTMLElement): void { + if (currentTool && isToolInitialized(currentTool)) { + currentTool.removeToolMarkup(pageClone); + } +} + function switchTool(newToolName: string): void { // Have Bloom remember which tool is active. (Might be none) postString("editView/saveToolboxSetting", "current\t" + newToolName); diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolboxBootstrap.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolboxBootstrap.ts index d7db8cc854c8..98b152955133 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolboxBootstrap.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolboxBootstrap.ts @@ -4,6 +4,7 @@ import { getTheOneToolbox, applyToolboxStateToUpdatedPage, removeToolboxMarkup, + removeToolMarkupFromPageClone, scheduleMarkupUpdateAfterPaste, updateMarkupAfterUndoOrRedo, } from "./toolbox"; @@ -52,13 +53,18 @@ export interface IToolboxFrameExports { applyToolboxStateToPage(): void; removeToolboxMarkup(): void; + removeToolMarkupFromPageClone(pageClone: HTMLElement): void; setActiveDragActivityTab(tab: number): void; getTheOneAudioRecorderForExportOnly(): IAudioRecorder; simulateBlurOnPageFrameMouseDown(): void; } // each of these exports shows up under this window's toolboxBundle object (see workspaceFrames.ts) -export { removeToolboxMarkup, setActiveDragActivityTab }; +export { + removeToolboxMarkup, + removeToolMarkupFromPageClone, + setActiveDragActivityTab, +}; export { showSetupDialog, initializeReaderSetupDialog, @@ -141,6 +147,7 @@ const toolboxBundle: ToolboxBundleApi = { updateMarkupAfterUndoOrRedo, applyToolboxStateToPage, removeToolboxMarkup, + removeToolMarkupFromPageClone, showSetupDialog, initializeReaderSetupDialog, closeSetupDialog, diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolboxGlobals.d.ts b/src/BloomBrowserUI/bookEdit/toolbox/toolboxGlobals.d.ts index 5a8316ce19a6..5b3526115acf 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolboxGlobals.d.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolboxGlobals.d.ts @@ -27,6 +27,7 @@ declare global { updateMarkupAfterUndoOrRedo: unknown; applyToolboxStateToPage: unknown; removeToolboxMarkup: unknown; + removeToolMarkupFromPageClone: unknown; showSetupDialog: unknown; initializeReaderSetupDialog: unknown; closeSetupDialog: unknown; diff --git a/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx b/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx index 5f282eda3943..d27bf12d05a1 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/toolboxToolReactAdaptor.tsx @@ -57,11 +57,37 @@ export default abstract class ToolboxToolReactAdaptor return false; } public newPageReady() {} - public detachFromPage() {} + // Most tools' editing markup is either marked bloom-ui (which the C# save pipeline strips) or + // lives outside the page div (which is never saved), so they have nothing to remove. See + // ITool.removeToolMarkup for what to do if yours does. + public removeToolMarkup(_pageOrClone: HTMLElement): void {} public configureElements(_container: HTMLElement) {} public finishToolLocalization(_pane: HTMLElement) {} /* eslint-enable @typescript-eslint/no-empty-function */ + private removedToolMarkupWhileDetaching = false; + + /// Take this tool's markup off the live page. A tool that has nothing live-only to clean up + /// needs only to implement removeToolMarkup(); it gets this for free, and the save path gets + /// the identical cleanup by calling the same method on a clone. If you do override this to add + /// live-only teardown, call super.detachFromPage() at the point where the markup should come + /// off — see ITool.detachFromPage. + public detachFromPage(): void { + this.removedToolMarkupWhileDetaching = true; + const bloomPage = ToolboxToolReactAdaptor.getBloomPage(); + if (bloomPage) { + this.removeToolMarkup(bloomPage); + } + } + + // See ITool.didRemoveToolMarkupWhileDetaching. Reading it also resets it, so that each detach + // is judged on its own. + public didRemoveToolMarkupWhileDetaching(): boolean { + const result = this.removedToolMarkupWhileDetaching; + this.removedToolMarkupWhileDetaching = false; + return result; + } + public static getPageFrame(): HTMLIFrameElement { return parent.window.document.getElementById( "page", diff --git a/src/BloomBrowserUI/utils/bloomApi.ts b/src/BloomBrowserUI/utils/bloomApi.ts index 82ec01847c26..6f0c876df452 100644 --- a/src/BloomBrowserUI/utils/bloomApi.ts +++ b/src/BloomBrowserUI/utils/bloomApi.ts @@ -652,11 +652,18 @@ export function post( // If we one day need to do this with a callback, we will need to think very // hard about possible exceptions during the callback (and the possibility // that the callback is somehow messed up by the page reloading). -export function postThatMightNavigate(urlSuffix: string) { +// The optional value is sent as the body, as text/plain, exactly as postString() does. It is +// there for commands that send the current page's content along so C# can save it without a +// round trip (see collectCurrentPageContent in pageThumbnailList/currentPageContent.ts). +export function postThatMightNavigate(urlSuffix: string, value?: string) { + const config = + value === undefined + ? undefined + : { headers: { "Content-Type": "text/plain" } }; // The internal catch should suppress any errors. In case that fails (which it has), passing // false to wrapAxios further suppresses any error reporting. return wrapAxios( - axios.post(getBloomApiPrefix() + urlSuffix).catch(), + axios.post(getBloomApiPrefix() + urlSuffix, value, config).catch(), false, ); } diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 5cc505b05bc2..8c9aca6935aa 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -4200,10 +4200,21 @@ public void InsertFullBleedMarkup(SafeXmlElement body) /// Return true if needToDoFullSave is true, or if this method discovers another reason we need to do a full save. /// Returns as an out param the page element from the book's dom that got modified. /// + /// False if what the browser sent turns out to say exactly + /// what the book already says, so this page gives us nothing to write. It reports only on + /// the data passed in; a caller that knows of a change elsewhere must account for that + /// itself. This is the definitive test for the data itself: + /// it is made AFTER our own processing of what we received (ProcessPageAfterEditing strips + /// the editing markup, SetImageAltAttrsFromDescriptions fills in alt text), so it asks the + /// only question that matters -- did the book actually change? -- rather than whether the + /// incoming string differed. The browser cannot answer that for us: it would have to + /// predict this processing, and a copy of these rules living over there is a copy that can + /// drift. See BL-13502. public bool UpdateDomFromEditedPage( HtmlDom editedPageDom, out SafeXmlElement pageToSaveToDisk, - bool needToDoFullSave = true + bool needToDoFullSave, + out bool anythingChanged ) { // This is needed if the user did some ChangeLayout (origami) manipulation. This will populate new @@ -4217,9 +4228,17 @@ public bool UpdateDomFromEditedPage( string pageId = pageFromEditedDom.GetAttribute("id"); pageToSaveToDisk = GetPageFromStorage(pageId); + // Remember the page as the book currently has it, so that once we have processed what + // the browser sent we can see whether it actually said anything new. OuterXml rather + // than InnerXml because ProcessPageAfterEditing writes the page div’s own class, lang + // and style attributes too. + var pageAsTheBookHadIt = pageToSaveToDisk.OuterXml; + HtmlDom.ProcessPageAfterEditing(pageToSaveToDisk, pageFromEditedDom); HtmlDom.SetImageAltAttrsFromDescriptions(pageToSaveToDisk, Language1Tag); + var pageChanged = pageToSaveToDisk.OuterXml != pageAsTheBookHadIt; + // The main condition for being able to just write the page is that no shareable data on the // page changed during editing. If that's so we can skip this step. if (needToDoFullSave) @@ -4241,6 +4260,13 @@ public bool UpdateDomFromEditedPage( //Debug.WriteLine("Incoming User Modified Styles: " + userModifiedStyles.OuterXml); } + + // Deliberately NOT including needToDoFullSave: that says how WIDE a save has to be if + // there is one (whether the change is confined to this page), not whether anything + // changed -- and it defaults to true. A caller that knows something outside this page + // wants saving has to say so itself; EditingModel does. + anythingChanged = pageChanged || stylesChanged; + return needToDoFullSave || stylesChanged; } @@ -4255,9 +4281,13 @@ public void SavePage(HtmlDom editedPageDom, bool needToDoFullSave = true) var reallyNeedFullSave = UpdateDomFromEditedPage( editedPageDom, out SafeXmlElement pageToSaveToDisk, - needToDoFullSave + needToDoFullSave, + out var anythingChanged ); + if (!anythingChanged) + return; // what the browser sent says exactly what the book already said + SavePageToDisk(pageToSaveToDisk, reallyNeedFullSave); } catch (Exception error) diff --git a/src/BloomExe/Book/BookProcessor.cs b/src/BloomExe/Book/BookProcessor.cs index cc4bb01ab822..8097250a7278 100644 --- a/src/BloomExe/Book/BookProcessor.cs +++ b/src/BloomExe/Book/BookProcessor.cs @@ -267,7 +267,12 @@ bool fitImageTextSplits // Force a full update so shared/derived data (titles, metadata) is sucked in, matching // what the live editor does when leaving a page that changed such data. We delay the // actual write to disk until a single Book.Save() after all pages are processed. - book.UpdateDomFromEditedPage(editedDom, out _, needToDoFullSave: true); + book.UpdateDomFromEditedPage( + editedDom, + out _, + needToDoFullSave: true, + anythingChanged: out _ + ); } /// diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index 86a906d6a121..3fdb0567b9e5 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -46,6 +46,13 @@ public class EditingModel // page is discarded in favor of the new on-disk content rather than clobbering it. private bool _reloadFromDiskOnLeavingEditTab; + /// + /// What the browser last told us the edited page contains, volunteered rather than asked + /// for. See PageSnapshot: this is what lets a save take the current page synchronously + /// instead of asking the browser and waiting. + /// + private readonly PageSnapshot _pageSnapshot = new PageSnapshot(); + public bool Visible; private Book.Book _currentlyDisplayedBook; private Book.Book _bookForToolboxContent; @@ -59,9 +66,6 @@ public class EditingModel // This event fires after the EditingModel has finished responding to a PageSelection change. internal event EventHandler PageSelectModelChangesComplete; - // These variables are not thread-safe. Access only on UI thread. - internal bool InProcessOfSaving => _stateMachine.SavePending; - // Perhaps a bit hack-ish, but this causes a full save to be done when our datadiv has been modified // but it's not obvious from the dataset changes. If we make new 'data-derived' divs someday, changing them // must set this flag to ensure the information gets saved properly. @@ -120,25 +124,11 @@ ITemplateFinder sourceCollectionsList { StartNavigationToEditPage(CurrentBook.GetPage(pageId)); }, - //requestPageSave, - (string pageId) => - { - RequestBrowserToSave(); - }, // updateBookWithPageContent (string pageId, string pageContentData) => UpdateBookDomFromBrowserPageContent(pageContentData), // saveBook - () => - { - if (_modifiedPageElement == null) - return; - - CurrentBook.SavePageToDisk(_modifiedPageElement, _nextSaveMustBeFull); - _nextSaveMustBeFull = false; - _pageHasUnsavedDataDerivedChange = false; - PageTemplatesApi.LastSaveTime = DateTime.Now; - }, + SaveBookToDisk, // hidePage () => { @@ -146,8 +136,7 @@ ITemplateFinder sourceCollectionsList { _view.OnHideEditTab(); } - }, - enableStateTransitions: (enabled) => _view?.WorkspaceView?.SetTabsEnabled(enabled) + } ); bookSelection.SelectionChanged += OnBookSelectionChanged; @@ -190,35 +179,10 @@ ITemplateFinder sourceCollectionsList { if (Visible) { - // We want to save any changes, and they ought to be fully saved before we shut down the program. - // To that end we normally set Delayed to indicate that we take responsibility for doing the caller-supplied - // action that continues the shutdown process (after the Save completes). - // If we can't initiate a Save, we'll just let the shutdown proceed (leave Delayed false). - // Review: should we warn the user? Displaying UI while the user is tying to close the program is - // generally a bad idea, but we may have failed to save some changes. On the other hand, - // the only likely reason for this is that the program is in a bad state, probably from a previously - // reported error. - args.Delayed = true; - SaveThen( - () => - { - // We are setting skipSaveToDisk true so that we can do it ourselves here BEFORE - // the postponed work, which is going to shut everything down and would prevent - // the normal automatic save-to-disk from working. - // If the save failed before this action gets called, Delayed is true, and PostponedWork - // doesn't get done at all. This typically means the collection will not close. - // However, FailureAction should be called in this case which allows closing the collection - // to try again. If we do try again and the same page fails again, the state machine will - // call this action anyway. So, finally PostponedWork will get called and we can close the collection. - CurrentBook.Save(); - CurrentBook.RecordPendingCreatedHistoryEvent(); - args.PostponedWork(); - return null; - }, - doIfNotInRightStateToSave: () => args.Delayed = false, // go ahead and quit now - skipSaveToDisk: true, - failureAction: args.FailureAction - ); + // Synchronous: the browser has already given us the page, so shutting down has + // nothing to wait for. See SaveEverythingBeforeClosing, which is also where the + // reasons this used to be asynchronous are written down. + SaveEverythingBeforeClosing(); } }); localizationChangedEvent.Subscribe(o => @@ -232,14 +196,11 @@ ITemplateFinder sourceCollectionsList //shown so the view has never been full constructed, so we're not in a good state to do a refresh if (Visible) { - SaveThen( - () => - { - _view.UpdatePageList(false); - return _pageSelection.CurrentSelection.Id; - }, - () => { } // wrong state, I think there's nothing we can safely do. - ); + MergeCurrentPageThenSave(() => + { + _view.UpdatePageList(false); + return _pageSelection.CurrentSelection.Id; + }); } }); _contentLanguages = new List(); @@ -265,8 +226,12 @@ ITemplateFinder sourceCollectionsList /// Receives a string (which comes from the browser) that combines the body of the document of the page /// being edited with the CSS that defines the user-defined styles. It updates the current book DOM /// to match whatever the browser has. - /// Enhance: ideally we would use a mutation observer so the browser knows whether anything needs saving, - /// and this method would get something indicating it doesn't need to save if that's so. + /// + /// The browser now does watch the page with a MutationObserver (see pageSnapshot.ts), so a null + /// here already means "the user has changed nothing on this page". But that is only ever an + /// optimisation, because the browser cannot know what our own processing will make of what it + /// sends -- it would have to predict ProcessPageAfterEditing. Whether the book really changed is + /// decided here, by Book.UpdateDomFromEditedPage's anythingChanged. See BL-13502. /// public void UpdateBookDomFromBrowserPageContent(string pageContentData) { @@ -384,7 +349,7 @@ private void OnTabAboutToChange(TabChangedDetails details) if (details.FromTab == Workspace.WorkspaceTab.edit) { // Leaving the tab means no page will load to run whatever was queued for the next - // page load (see RunAfterNextPageLoad) — and it was queued for the page we are + // page load (see RunAfterNextPageLoad) - and it was queued for the page we are // leaving, so it must not spring to life if the user comes back to that page later. _doAfterNextPageLoad = null; @@ -395,88 +360,34 @@ private void OnTabAboutToChange(TabChangedDetails details) var reloadFromDiskInsteadOfSaving = _reloadFromDiskOnLeavingEditTab; _reloadFromDiskOnLeavingEditTab = false; - SaveThen( - () => - { - // We are setting skipSaveToDisk true so that we can do it ourselves here BEFORE - // the postponed work, which is going to shut everything down and would prevent - // the normal automatic save-to-disk from working. - if (reloadFromDiskInsteadOfSaving) - { - // Discard the page content just gathered into the in-memory DOM; disk wins. - CurrentBook?.ReloadFromDisk(null); - // Force OnBecomeVisible to re-display from the freshly-loaded book if the - // user returns to the Edit tab. - _currentlyDisplayedBook = null; - } - else - CurrentBook?.Save(); // we need it all the way saved before completing the tab change - // This bizarre behavior prevents BL-2313 and related problems. - // For some reason I cannot discover, switching tabs when focus is in the Browser window - // causes Bloom to get deactivated, which prevents various controls from working. - // Moreover, it seems (BL-2329) that if the user types Alt-F4 while whatever-it-is is active, - // things get into a very bad state indeed. So arrange to re-activate ourselves as soon as the dust settles. - _oldActiveForm = Form.ActiveForm; - Application.Idle += ReactivateFormOnIdle; - details.CompleteTheChange?.Invoke(); - return null; // leaving this tab, show blank page - }, - () => - { - // We get here when we could not start a save, so we're in Navigating, - // SavePending or SavedAndStripped. (We shouldn't be in NoPage while in the - // edit tab, but if we somehow are, we take the branch above; and if we're - // Editing we take the branch above too.) - // - // We do ask for the tabs to be disabled while saving, but that doesn't take - // effect soon enough to stop a second click on a tab, so SavePending really - // does happen here — that was BL-16766. See WorkspaceView.SetTabsEnabled. - // - // Navigating: we clicked the Edit tab and then immediately something else, - // or clicked another tab during the fraction of a second while Bloom is - // navigating to a new page after doing some command. Abort the navigate, - // then go ahead. Earlier versions of Bloom had a Debug guard against - // reaching this state, but it happened often enough to be annoying, and the - // recovery code here seems to work adequately. In particlar, we seem to get - // here after a Javascript error has been reported, and raising an exception - // here tends to interfere with reporting the error we really want to see. - if (StateMachine.Navigating) - { - StateMachine.ToNoPage(); - } - if (reloadFromDiskInsteadOfSaving) - { - // We reached the fallback because we couldn't take over the save (e.g. a - // save was already in flight: we're in SavePending, waiting on the browser). - // Tell that in-flight save to discard its content, so when it completes it - // doesn't merge the edits we're throwing away and write them back over what - // the external process just put on disk. - StateMachine.DiscardInFlightSave(); - CurrentBook?.ReloadFromDisk(null); - _currentlyDisplayedBook = null; - } - // If we are here because a save is still in flight (someone else started - // it, and the browser has not yet handed back the page content), we must - // not let the tab change go ahead now: the tab-changed event would ask the - // state machine to empty the page, which throws while a save is pending, - // and would leave the workspace half switched between the two tabs - // (BL-16766). Wait for the save to finish and then start the tab change - // over from the beginning. - // Note that the retry sees reloadFromDiskInsteadOfSaving as false, because - // this attempt consumed the flag — so it takes the ordinary Save() branch - // above rather than the reload branch. That is correct: the reload has - // already happened, just above, and the discarded save cannot have merged - // anything into the DOM, so the DOM still matches what the external process - // wrote and saving it writes that same content back. There is also no - // second in-flight save for the retry to discard. - if (StateMachine.DeferUntilSaveCompletes(details.StartTheChangeOver)) - return; - _oldActiveForm = Form.ActiveForm; - Application.Idle += ReactivateFormOnIdle; - details.CompleteTheChange?.Invoke(); - }, - skipSaveToDisk: true - ); + // All of this is synchronous now. It used to be a save-then-do-this with two branches -- one + // for the save it started, one for "we were in the wrong state to save" -- because + // the save had to ask the browser for the page and wait. The browser volunteers the + // page as it is edited (see PageSnapshot), so we simply write it and go. + if (reloadFromDiskInsteadOfSaving) + { + // Discard the page the user was editing; what the external process wrote wins. + CurrentBook?.ReloadFromDisk(null); + // Force OnBecomeVisible to re-display from the freshly-loaded book if the user + // returns to the Edit tab. + _currentlyDisplayedBook = null; + } + else + { + SaveCurrentPageAndBook(); + } + + // Show nothing in the editor. We have just saved, so there is nothing to lose. + _stateMachine.ToNoPageHavingSaved(); + + // This bizarre behavior prevents BL-2313 and related problems. + // For some reason I cannot discover, switching tabs when focus is in the Browser window + // causes Bloom to get deactivated, which prevents various controls from working. + // Moreover, it seems (BL-2329) that if the user types Alt-F4 while whatever-it-is is active, + // things get into a very bad state indeed. So arrange to re-activate ourselves as soon as the dust settles. + _oldActiveForm = Form.ActiveForm; + Application.Idle += ReactivateFormOnIdle; + details.CompleteTheChange?.Invoke(); } else { @@ -527,9 +438,9 @@ BookSelectionChangedEventArgs bookSelectionChangedEventArgs } } - internal void OnDuplicatePage() + internal void OnDuplicatePage(string pageContentFromBrowser = null) { - DuplicatePage(_pageSelection.CurrentSelection); + DuplicatePage(_pageSelection.CurrentSelection, pageContentFromBrowser); } internal void DuplicateManyPages(IPage page) @@ -545,9 +456,9 @@ internal void DuplicateManyPages(IPage page) } } - internal void DuplicatePage(IPage page) + internal void DuplicatePage(IPage page, string pageContentFromBrowser = null) { - DuplicatePageInternal(page); + DuplicatePageInternal(page, 1, pageContentFromBrowser); } /// @@ -565,12 +476,16 @@ public void DuplicatePageManyTimes(int numberOfTimes) DuplicatePageInternal(_pageSelection.CurrentSelection, numberOfTimes); } - private void DuplicatePageInternal(IPage page, int numberOfTimesToDuplicate = 1) + private void DuplicatePageInternal( + IPage page, + int numberOfTimesToDuplicate = 1, + string pageContentFromBrowser = null + ) { // NB: though there is an api call to do this, it isn't currently used, so we have to measure here. var countString = numberOfTimesToDuplicate.ToString(); var newPageId = page.Id; // error fallback - SaveThen( + MergeCurrentPageThenSave( () => { using (PerformanceMeasurement.Global.Measure("Duplicate page")) @@ -604,28 +519,23 @@ private void DuplicatePageInternal(IPage page, int numberOfTimesToDuplicate = 1) } return newPageId; }, - () => { }, // wrong state, do nothing - forceFullSave: true + forceFullSave: true, + pageContentFromBrowser: pageContentFromBrowser ); } - internal void OnDeletePage() + internal void OnDeletePage(string pageContentFromBrowser = null) { - DeletePage(_pageSelection.CurrentSelection); + DeletePage(_pageSelection.CurrentSelection, pageContentFromBrowser); } - internal void DeletePage(IPage page) + internal void DeletePage(IPage page, string pageContentFromBrowser = null) { // This can only be called on the UI thread in response to a user button click. - // If that ever changed we might need to arrange locking for access to InProcessOfSaving and _tasksToDoAfterSaving. Debug.Assert(!_view.InvokeRequired); - if (InProcessOfSaving) - { - // Somehow (BL-431) it's possible that a Save is still in progress when we start executing a delete page. - // If this happens, just abort the delete. - return; - } - SaveThen( + // There used to be a guard here against a save still being in progress (BL-431). A save + // now finishes inside the call that asks for it, so there is no such window. + MergeCurrentPageThenSave( () => { try @@ -646,8 +556,8 @@ internal void DeletePage(IPage page) return page.Id; // stay on this page. } }, - () => { }, // wrong state, do nothing - forceFullSave: true + forceFullSave: true, + pageContentFromBrowser: pageContentFromBrowser ); } @@ -689,11 +599,21 @@ private void OnRelocatePage(RelocatePageInfo info) } /// - /// This is used both to insert pages from the AddPageDialog, and also "paste page" + /// The event handler form of InsertPage, for the AddPageDialog's InsertPage event. The + /// dialog is a separate window, so it has no way to hand us the current page's content; + /// "paste page" calls InsertPage directly and can. /// private void OnInsertPage(object page, PageInsertEventArgs e) { - SaveThen( + InsertPage(page, e, null); + } + + /// + /// This is used both to insert pages from the AddPageDialog, and also "paste page" + /// + private void InsertPage(object page, PageInsertEventArgs e, string pageContentFromBrowser) + { + MergeCurrentPageThenSave( () => { // there might be unsaved changes in the current page from before we clicked Add Page var newPageId = CurrentBook.InsertPageAfter( @@ -741,8 +661,8 @@ private void OnInsertPage(object page, PageInsertEventArgs e) Logger.WriteEvent("InsertTemplatePage"); return newPageId; }, - () => { }, // wrong state, do nothing - forceFullSave: true + forceFullSave: true, + pageContentFromBrowser: pageContentFromBrowser ); } @@ -860,35 +780,32 @@ public IEnumerable GetSizeAndOrientationChoices() public void SetLayout(Layout layout) { - SaveThen( - () => + MergeCurrentPageThenSave(() => + { + var pageId = _pageSelection.CurrentSelection.Id; + var changedOrientation = + CurrentBook.GetLayout().SizeAndOrientation.IsLandScape + != layout.SizeAndOrientation.IsLandScape; + CurrentBook.SetLayout(layout); + if (changedOrientation) { - var pageId = _pageSelection.CurrentSelection.Id; - var changedOrientation = - CurrentBook.GetLayout().SizeAndOrientation.IsLandScape - != layout.SizeAndOrientation.IsLandScape; - CurrentBook.SetLayout(layout); - if (changedOrientation) - { - // We need to update the xmatter, since this process selects images to display based on orientation. - // (Here we need to do it even if we already brought this book up to date when it was selected.) - CurrentBook.BringBookUpToDate(new NullProgress()); - // That wrecks everything. In particular guids stored in Page objects are obsolete. - // Simulate switching to collection mode, force discarding everything problematic, and reinitialize. - _view.OnVisibleChanged(false); - _currentlyDisplayedBook = null; - _previouslySelectedPage = null; - _view.OnVisibleChanged(true); - // If the Add Page dialog is open, we can still change layout. The OnVisibleChanged calls close the dialog, - // but can leave the PageListView disabled. See https://issues.bloomlibrary.org/youtrack/issue/BL-6554. - _view.SetModalState(false); - } - CurrentBook.PrepareForEditing(); - _view.UpdatePageList(true); //counting on this to redo the thumbnails - return pageId; - }, - () => { } // wrong state, do nothing - ); + // We need to update the xmatter, since this process selects images to display based on orientation. + // (Here we need to do it even if we already brought this book up to date when it was selected.) + CurrentBook.BringBookUpToDate(new NullProgress()); + // That wrecks everything. In particular guids stored in Page objects are obsolete. + // Simulate switching to collection mode, force discarding everything problematic, and reinitialize. + _view.OnVisibleChanged(false); + _currentlyDisplayedBook = null; + _previouslySelectedPage = null; + _view.OnVisibleChanged(true); + // If the Add Page dialog is open, we can still change layout. The OnVisibleChanged calls close the dialog, + // but can leave the PageListView disabled. See https://issues.bloomlibrary.org/youtrack/issue/BL-6554. + _view.SetModalState(false); + } + CurrentBook.PrepareForEditing(); + _view.UpdatePageList(true); //counting on this to redo the thumbnails + return pageId; + }); } /// @@ -904,18 +821,15 @@ public void ContentLanguagesSelectionChanged() // The language choice is saved in the data-div, so we must do a full save even if this // page doesn't contain anything else that has non-local effects. _nextSaveMustBeFull = true; - SaveThen( - () => - { - CurrentBook.PrepareForEditing(); - _view.UpdatePageList(true); //counting on this to redo the thumbnails + MergeCurrentPageThenSave(() => + { + CurrentBook.PrepareForEditing(); + _view.UpdatePageList(true); //counting on this to redo the thumbnails - Logger.WriteEvent("ChangingContentLanguages"); - BloomAnalytics.Track("Change Content Languages"); - return _pageSelection.CurrentSelection.Id; - }, - () => { } // wrong state, do nothing - ); + Logger.WriteEvent("ChangingContentLanguages"); + BloomAnalytics.Track("Change Content Languages"); + return _pageSelection.CurrentSelection.Id; + }); } // Get current MultilingualContentLanguage settings based on what's been recently checked/unchecked. @@ -1059,6 +973,10 @@ public void ReloadCurrentBookDiscardingEdits() /// void StartNavigationToEditPage(IPage page) { + // The page we may have a snapshot of is going away, and whatever the save just wrote + // into the book DOM is now the truth. Holding on to it would let a later visit to the + // same page re-apply content from the previous visit. See PageSnapshot.Clear. + _pageSnapshot.Clear(); try { if (page == null) @@ -1622,17 +1540,14 @@ private void EnsureLevelAttrCorrect() _currentlyDisplayedBook.BookInfo.MetaData.LeveledReaderLevel.ToString(); if (correctLevel != currentLevel) { - SaveThen( - () => - { - _currentlyDisplayedBook.OurHtmlDom.Body.SetAttribute( - "data-leveledreaderlevel", - correctLevel - ); - return _pageSelection.CurrentSelection.Id; - }, - () => { } // wrong state, do nothing - ); + MergeCurrentPageThenSave(() => + { + _currentlyDisplayedBook.OurHtmlDom.Body.SetAttribute( + "data-leveledreaderlevel", + correctLevel + ); + return _pageSelection.CurrentSelection.Id; + }); } } @@ -1660,15 +1575,32 @@ private void EnsureLevelAttrCorrect() // var idOfFirstPageInTemplateBook = CurrentBook.FindTemplateBook().GetPageByIndex(0).Id; // if (AddNewPageBasedOnTemplate(idOfFirstPageInTemplateBook)) /// - /// Save all the changes to the current page, then reload it (thus restoring any UI stuff that - /// was stripped out by the Save). + /// Save all the changes to the current page, then reload it. + /// + /// The reload used to be needed just to restore the UI markup the Save stripped out. That + /// is no longer true (BL-13502), but it is still doing a second job for these callers, and + /// that is why it stays: the page has to be rebuilt from the book DOM either because C# + /// just changed it (a new topic in the data div, new book settings) or because the browser + /// created elements that have never been through SetupElements (a new origami layout, an + /// imported video, a translation group replaced by a derived field). + /// + /// pageContentFromBrowser, when the caller was able to send it, removes the round trip: + /// we save and navigate in one step rather than asking the browser for the content and + /// waiting for it on another API. Callers that have no browser request to carry it (the + /// PageRefreshEvent handlers) leave it null and get the old path. /// - internal void SavePageAndReloadIt(bool forceFullSave = false) + internal void SavePageAndReloadIt( + bool forceFullSave = false, + string pageContentFromBrowser = null + ) { if (CannotSavePage()) return; _nextSaveMustBeFull |= forceFullSave; - SaveThen(() => _pageSelection.CurrentSelection.Id, () => { }); + MergeCurrentPageThenSave( + () => _pageSelection.CurrentSelection.Id, + pageContentFromBrowser: pageContentFromBrowser + ); } //invoked from TopicChooserDialog.tsx via API @@ -1683,7 +1615,9 @@ internal void SetTopic(string englishTopicAsKey) internal void SavePageAndReloadIt(ApiRequest request) { - SavePageAndReloadIt(); + // The browser sends the current page's content with this request when it can; see + // saveChangesAndRethinkPage() in bloomEditing.ts. + SavePageAndReloadIt(pageContentFromBrowser: request.GetPageContentFromBrowserOrNull()); request.PostSucceeded(); } @@ -1705,60 +1639,243 @@ private bool CannotSavePage() private bool _nextSaveMustBeFull; // review: store in state machine? /// - /// Request the needed data to do a save, then when the in-memory DOM has been updated from the browser, - /// call doBeforeSaveToDisk. Its return value is a page ID to navigate to afterward (or null to show a - /// blank screen when leaving the edit tab). Unless skipSaveToDisk is true, the book is then saved to - /// disk. If doAfterSaveToDisk is provided, it is called after the disk save and before navigation — - /// useful for blocking UI (e.g. a modal dialog) that needs up-to-date files on disk. - /// (It is only called if skipSaveToDisk is false and the save succeeds.) - /// If we are not in the right state to save, doIfNotInRightStateToSave() is called instead. It is - /// deliberately not optional so callers think about what to do in that case. + /// Fold the current page's edits into the book, let the caller change the book, then write + /// it once and go to the page the caller names. All synchronous. + /// + /// The three steps happen in that order, and the order is the point: + /// + /// 1. the page the user was editing is merged into the book DOM, so that + /// 2. changeBookBeforeWriting sees those edits (duplicating a page has to copy what the + /// user just typed, not what was on disk), and it returns the id of the page to show + /// next -- or null to leave the editor blank, which is how leaving the Edit tab works; + /// 3. the book is written to disk ONCE, covering both the merge and the change, and we + /// navigate to that page. + /// + /// That middle slot is why this takes an action rather than simply returning: the caller's + /// work belongs between the merge and the write, not after the save. + /// + /// This was called SaveThen, from when it meant "ask the browser for the page, and when it + /// eventually answers, do this". The browser now volunteers the page as it is edited (see + /// PageSnapshot), so there is nothing to wait for and nothing happens "then". /// - /// If you are doing this in an API handler, remember that you must retrieve any data in - /// the request before calling SaveThen. The Request object can't be used inside doBeforeSaveToDisk, - /// since by then the request has been marked completed. - public void SaveThen( - Func doBeforeSaveToDisk, - Action doIfNotInRightStateToSave, + /// Runs between the merge and the write. Returns the + /// page to show next, or null for a blank editor. + /// Called INSTEAD of everything above when we could not + /// start at all -- the user may have begun changing pages, or this may be a nested request + /// arriving from inside another one's changeBookBeforeWriting. Most callers have nothing + /// useful to do then and omit it; the ones that do are finishing something they had already + /// started, like clearing a dialog's spinner. + /// The current page's content, when the request that + /// got us here brought it along (see getPageContentForSaveWhenReady() in the browser). + /// Otherwise we use whatever the browser last volunteered; a null snapshot is a positive + /// statement that the page has not changed since it loaded, not "we do not know". + public void MergeCurrentPageThenSave( + Func changeBookBeforeWriting, + Action ifNotInAStateToSave = null, bool forceFullSave = false, - bool skipSaveToDisk = false, - Action failureAction = null, - Action doAfterSaveToDisk = null + string pageContentFromBrowser = null ) { + var outcome = SavePageInPlaceThen( + pageContentFromBrowser ?? CurrentPageSnapshotOrNull, + changeBookBeforeWriting, + forceFullSave + ); + // Declined is the only outcome where nothing at all happened -- changeBookBeforeWriting + // has NOT run -- so it is the only one where the caller's fallback is the right + // response. Failed means the action may already have run (running it again would + // duplicate or delete a second page), and Refused means an external process has + // replaced the book and this page must not be written at all. See InPlaceSaveOutcome. + if (outcome == InPlaceSaveOutcome.Declined) + ifNotInAStateToSave?.Invoke(); + } + + /// + /// Called by the editView/pageSnapshot API when the browser's idle task volunteers the + /// current content of the page. All we do is remember it; see PageSnapshot for why. + /// + public void ReceivePageSnapshot(string pageId, string pageContentData) + { + _pageSnapshot.Set(pageId, pageContentData); + } + + /// + /// The current page's content as the browser last reported it, or null if the page has not + /// been changed since it loaded. + /// + /// Null genuinely means "nothing to save" rather than "ask the browser": the editing page + /// posts a snapshot after any change that settles, so a page with no snapshot has had no + /// change to record. That is what lets a caller which used to start an asynchronous save + /// just take the content and get on with it. + /// + public string CurrentPageSnapshotOrNull => + _pageSnapshot.GetFor(_pageSelection?.CurrentSelection?.Id); + + /// + /// Get the edited page and the book onto disk, synchronously, on the way out of the + /// program or the collection. + /// + /// This exists so that shutting down does not have to wait for anything. It used to: the + /// save had to ask the browser for the page and wait for the answer on another API call, + /// so the collection-closing event grew a whole "I'll finish this later, you carry on" + /// protocol (Delayed / PostponedWork / FailureAction), and Shell.OnClosing had to cancel + /// the close and re-issue it once the save finished. The browser now volunteers the page + /// as it goes (see PageSnapshot), so there is nothing left to wait for. + /// + /// A null snapshot means the page has not been changed since it loaded, so there is + /// nothing to merge -- but we still write the book, because other things (a page added, + /// a page deleted) may be sitting in the DOM unwritten. + /// + /// The one thing this cannot do is save a change made in the last few tens of + /// milliseconds, which the browser has not posted yet. See "The freshness window" in + /// SavingWithoutReloading.md for why that is the accepted trade. + /// + public bool SaveCurrentPageAndBook() + { + if (CannotSavePage()) + return false; + if (_reloadFromDiskOnLeavingEditTab) + { + // An external process replaced the book on disk and we are discarding the user's + // page in favour of what it wrote. Writing now would clobber exactly what that + // guard exists to protect. Same rule as SavePageInPlaceThen's Refused outcome. + // (Leaving the Edit tab clears the flag and handles that case itself, so this is + // for the other callers.) + return false; + } + UpdateBookDomFromBrowserPageContent(CurrentPageSnapshotOrNull); + CurrentBook.Save(); + return true; + } + + /// + /// As SaveCurrentPageAndBook, plus the book-created history entry that only belongs at the + /// end of a session. + /// + public void SaveEverythingBeforeClosing() + { + if (SaveCurrentPageAndBook()) + CurrentBook.RecordPendingCreatedHistoryEvent(); + } + + /// + /// Write out whatever UpdateBookDomFromBrowserPageContent() put into the book DOM: either just + /// the one page that changed, or the whole book if something shared changed. + /// This is the state machine's saveBook action, and also the second half of SavePageInPlace, + /// so both routes make exactly the same decisions. + /// + private void SaveBookToDisk() + { + if (_modifiedPageElement == null) + return; + + CurrentBook.SavePageToDisk(_modifiedPageElement, _nextSaveMustBeFull); + _nextSaveMustBeFull = false; + _pageHasUnsavedDataDerivedChange = false; + PageTemplatesApi.LastSaveTime = DateTime.Now; + } + + /// + /// Save the current page from content the browser has ALREADY gathered — the combined + /// "body <SPLIT-DATA> userCss" string that getPageContentForSave() produces — and leave the + /// browser showing that same page, still editable. + /// + /// This is the Javascript-initiated counterpart of MergeCurrentPageThenSave(). The + /// difference is only in what happens afterwards: that one navigates to a page the caller + /// names, because its callers are changing which pages exist; this one leaves the browser + /// showing the same page, still editable, which is possible at all because the gather no + /// longer strips the live page of the markup that makes it editable (BL-13502). + /// + /// It deliberately goes through the same two steps as MergeCurrentPageThenSave — first + /// UpdateBookDomFromBrowserPageContent(), then SaveBookToDisk() — so the same logic decides + /// whether the change is confined to this page or has to be propagated across the book + /// (see NeedToDoFullSave and Book.UpdateDomFromEditedPage). + /// + /// Returns false, having done nothing, if we are not in a position to save. That is a normal + /// outcome, not an error: the user may have started changing pages, or an external process may + /// have replaced the book on disk. + /// + public bool SavePageInPlace(string pageContentData, bool forceFullSave = false) + { + if (CannotSavePage() || !_havePageToSave) + return false; + // An external process has overwritten the book on disk and we are about to discard this + // page in favor of what it wrote; saving now would clobber that. (SavePageInPlaceThen + // refuses for the same reason, which covers the MergeCurrentPageThenSave path.) + if (_reloadFromDiskOnLeavingEditTab) + return false; + _nextSaveMustBeFull |= forceFullSave; if ( - !_stateMachine.ToSavePending( - doBeforeSaveToDisk, - saveActionHandlesSaveBook: skipSaveToDisk, - failureAction, - doAfterSaveToDisk + !_stateMachine.ToSavedInPlace( + pageContentData, + e => + ErrorReport.NotifyUserOfProblem( + e, + LocalizationManager.GetString( + "Errors.CouldNotSavePage", + "Bloom had trouble saving a page. Please report the problem to us. Then quit Bloom, run it again, and check to see if the page you just edited is missing anything. Sorry!" + ) + ) ) ) - doIfNotInRightStateToSave(); - } + return false; - // Send a request to the browser to send us the page content so we can save it. - private void RequestBrowserToSave() - { - Logger.WriteMinorEvent("EditingModel.RequestSave() starting"); - // show the saving message to the user - _webSocketServer.SendString("pageThumbnailList", "saving", ""); - // review do we really need to be checking to see if things are loaded? If they are not, then there is nothing to save, and this doesn't thow. - var script = $"workspaceBundle.getEditablePageBundleExports().requestPageContent()"; - // Fire-and-forget: this just asks the browser to send us the page content. The browser - // responds asynchronously by calling the ReceivePageContent API (which drives the state - // machine on to ToSavedAndStripped), so there is nothing here to wait for. - _view.Browser.RunJavascriptFireAndForget(script); + // What we just saved is the new baseline for deciding whether the NEXT save has changed + // anything the rest of the book shares. (For the MergeCurrentPageThenSave path, the navigation that + // follows a save does this, in EditingView.StartNavigationToEditPage.) + SaveStateForFullSaveDecision(); + // Likewise, the page list would normally be refreshed as part of navigating. + _view?.UpdateThumbnailAsync(_pageSelection.CurrentSelection); + return true; } /// - /// Called by an API from JavaScript code invoked by RequestBrowserToSave, this receives the body and user-defined - /// styles of the current page and saves them to the book DOM. + /// The body of MergeCurrentPageThenSave: merge pageContentData into the book, run + /// changeBookBeforeWriting (which may change the book, and returns the id of the page to + /// show next), write the book to disk, and navigate there — all synchronously, before we + /// return. + /// + /// Returns Declined, having done nothing at all, if we were not in a position to save -- + /// which is the only outcome where the caller's fallback is the right response. If it + /// returns Failed, changeBookBeforeWriting may already have run and changed the book, so + /// treating it as "nothing happened" would run it a second time. See InPlaceSaveOutcome. + /// + /// Private because MergeCurrentPageThenSave is the way in: it supplies the page content, + /// falling back to the snapshot when the caller had none, so no caller has to get that + /// right. /// - public void ReceivePageContent(string pageContentData) + private InPlaceSaveOutcome SavePageInPlaceThen( + string pageContentData, + Func changeBookBeforeWriting, + bool forceFullSave = false + ) { - _stateMachine.ToSavedAndStripped(pageContentData); + if (CannotSavePage() || !_havePageToSave) + return InPlaceSaveOutcome.Declined; + // See SavePageInPlace: an external process has replaced the book on disk, so this + // page's content must not be written over what it wrote. Refused, NOT Declined -- + // Declined would send the caller to the ask-the-browser path, which has no such guard + // and would write the page anyway. + if (_reloadFromDiskOnLeavingEditTab) + return InPlaceSaveOutcome.Refused; + + _nextSaveMustBeFull |= forceFullSave; + // Unlike SavePageInPlace there is nothing to do afterwards on success: we do NOT + // refresh the full-save baseline or the thumbnail, because navigating does both for + // us, in EditingView.StartNavigationToEditPage, which this has already started. + return _stateMachine.ToSavedInPlaceThenNavigating( + pageContentData, + changeBookBeforeWriting, + e => + ErrorReport.NotifyUserOfProblem( + e, + LocalizationManager.GetString( + "Errors.CouldNotSavePage", + "Bloom had trouble saving a page. Please report the problem to us. Then quit Bloom, run it again, and check to see if the page you just edited is missing anything. Sorry!" + ) + ) + ); } private SafeXmlElement _modifiedPageElement; @@ -1810,11 +1927,24 @@ public void UpdateBookDomFromBrowserPageContent(SafeXmlDocument docFromBrowser) //OK, looks safe, time to save. var editedDom = new HtmlDom(docFromBrowser); var newPageData = GetPageData(editedDom.RawDom); + // True when something OUTSIDE the page HTML we are about to hand over wants saving: a + // data-derived value some dialog changed, altered feature requirements, or a caller + // that explicitly forced a full save. The page content test below cannot see any of + // those, so they have to be kept separately. + var somethingElseNeedsSaving = _nextSaveMustBeFull || NeedToDoFullSave(newPageData); + _nextSaveMustBeFull = CurrentBook.UpdateDomFromEditedPage( editedDom, out _modifiedPageElement, - _nextSaveMustBeFull || NeedToDoFullSave(newPageData) + somethingElseNeedsSaving, + out var anythingChanged ); + + // The page says exactly what the book already said and nothing else is outstanding, so + // there is nothing to write. A null _modifiedPageElement is how SaveBookToDisk is + // already told there is nothing to save. + if (!anythingChanged && !somethingElseNeedsSaving) + _modifiedPageElement = null; } // If we return 'true', we need to do a complete book save, otherwise we'll just save this page. @@ -2058,15 +2188,12 @@ public void ChangeBookLicenseMetaData(Metadata metadata) // For Edit tab: if (Visible) { - SaveThen( - () => - { - CurrentBook.SetMetadata(metadata); - _pageHasUnsavedDataDerivedChange = true; - return _pageSelection.CurrentSelection.Id; - }, - () => { } // wrong state, do nothing - ); + MergeCurrentPageThenSave(() => + { + CurrentBook.SetMetadata(metadata); + _pageHasUnsavedDataDerivedChange = true; + return _pageSelection.CurrentSelection.Id; + }); } else { @@ -2107,20 +2234,45 @@ public bool GetClipboardHasPage() return _pageDivFromCopyPage != null; } - public void CopyPage(IPage page) + public void CopyPage(IPage page, string pageContentFromBrowser = null) { - // need to preserve any typing they've done but not yet saved (BL-4512) - SaveThen( + // We have to clone the page div so that if the user changes the page after doing the + // copy, when they paste they get the page as it was, not as it is now. And we have to + // save first, or the clone would miss any typing they have done but not yet saved + // (BL-4512). + Action takeTheSnapshot = () => + { + _pageDivFromCopyPage = (SafeXmlElement)page.GetDivNodeForThisPage().CloneNode(true); + _bookPathFromCopyPage = page.Book.GetPathHtmlFile(); + }; + + // In practice the page being copied is ALWAYS the selected one: the page list only + // opens its context menu on the selected page (see openContextMenu in + // pageThumbnailList.tsx, which bails unless pageId === selectedPageId), and the menu + // button is only rendered there. So we take the in-place branch and copying a page + // does not reload it -- which is the win here. + // + // The navigating branch is kept as a safety net rather than dead weight, because the + // copied page MUST end up selected: a later Paste inserts after the current selection + // (see DeterminePageWhichWouldPrecedeNextInsertion), so were that guarantee ever + // relaxed, copying without selecting would drop the pasted copy somewhere the user + // did not ask for. + var copyingTheSelectedPage = _pageSelection.CurrentSelection?.Id == page.Id; + if ( + copyingTheSelectedPage + && pageContentFromBrowser != null + && SavePageInPlace(pageContentFromBrowser, forceFullSave: true) + ) + { + takeTheSnapshot(); + return; + } + MergeCurrentPageThenSave( () => { - // We have to clone this so that if the user changes the page after doing the copy, - // when they paste they get the page as it was, not as it is now. - _pageDivFromCopyPage = (SafeXmlElement) - page.GetDivNodeForThisPage().CloneNode(true); - _bookPathFromCopyPage = page.Book.GetPathHtmlFile(); + takeTheSnapshot(); return page.Id; }, - () => { }, // wrong state, do nothing forceFullSave: true ); } @@ -2129,7 +2281,7 @@ public void CopyPage(IPage page) /// Paste the previously saved _pageDivFromCopyPage as a new page. /// /// This is NOT the page we are to paste! - public void PastePage(IPage pageToPasteAfter) + public void PastePage(IPage pageToPasteAfter, string pageContentFromBrowser = null) { var templateBook = pageToPasteAfter.Book; // default is to assume it's from the same book bool fromAnotherBook = templateBook.GetPathHtmlFile() != _bookPathFromCopyPage; @@ -2152,7 +2304,8 @@ public void PastePage(IPage pageToPasteAfter) "not used", x => _pageDivFromCopyPage ); - OnInsertPage(pageForPasting, new PageInsertEventArgs(false)); // false => don't need analytics on use of template pages + // false => don't need analytics on use of template pages + InsertPage(pageForPasting, new PageInsertEventArgs(false), pageContentFromBrowser); } public void AdjustPageZoom(int delta) @@ -2214,7 +2367,7 @@ public void HandlePageDomLoadedEvent(string pageId) /// This exists for callers that must save the current page before doing something in the /// browser that needs the saved book DOM to be up to date. Saving strips the live page, so /// it always ends by re-navigating to it (see EditingStateMachine) — which means - /// SaveThen's own doAfterSaveToDisk is too early for such a caller: it runs before that + /// MergeCurrentPageThenSave is too early for such a caller: it returns before that /// navigation, so the browser code it started would be torn down. Waiting for the page to /// come back is the only safe point. AiImageEditorApi.HandleSaveThenLaunch is the caller /// this was written for (BL-16682). @@ -2296,9 +2449,9 @@ public void StartUpdatingAllPages() if (Visible) { // We are already in the Edit tab. Kick off the chain by navigating to the first page. - // (SaveThen saves whatever page is currently showing, then navigates.) + // (MergeCurrentPageThenSave saves whatever page is showing, then navigates.) var firstPageId = _pageUpdateOrder[0]; - SaveThen(() => firstPageId, () => FinishUpdatingAllPages()); + MergeCurrentPageThenSave(() => firstPageId, () => FinishUpdatingAllPages()); } else { @@ -2324,19 +2477,17 @@ private void AdvanceUpdatingAllPages(string loadedPageId) // Save the page we just visited (persisting the edit-tab setup that ran on it) and // move on. Reusing the normal save-then-navigate cycle means each page gets exactly // the treatment it would if the user clicked it in the Edit tab. - SaveThen(() => nextPageId, () => FinishUpdatingAllPages()); + MergeCurrentPageThenSave(() => nextPageId, () => FinishUpdatingAllPages()); } else { // We just visited the last page. Save it, then return to the Collection tab. We - // navigate to a blank page (returning null) because we are about to leave the Edit - // tab anyway. Switching tabs is deferred to after the save completes so we don't - // re-enter the state machine while it is still unwinding this save. - SaveThen( - () => null, - () => FinishUpdatingAllPages(), - doAfterSaveToDisk: () => _view.BeginInvoke((Action)FinishUpdatingAllPages) - ); + // show a blank page because we are about to leave the Edit tab anyway. Switching + // tabs is still deferred to the next message, so we don't re-enter the state + // machine from inside this call. + SaveCurrentPageAndBook(); + _stateMachine.ToNoPageHavingSaved(); + _view.BeginInvoke((Action)FinishUpdatingAllPages); } } diff --git a/src/BloomExe/Edit/EditingStateMachine.cs b/src/BloomExe/Edit/EditingStateMachine.cs index 6272e4168d8d..16b177161492 100644 --- a/src/BloomExe/Edit/EditingStateMachine.cs +++ b/src/BloomExe/Edit/EditingStateMachine.cs @@ -1,134 +1,169 @@ using System; using System.Diagnostics; -using L10NSharp; -using SIL.Code; -using SIL.Reporting; -// The states that EditingModel can be in -// Diagram: https://www.tldraw.com/r/WDLCDLfNbcDZW1kSXZVli?v=-441,-130,2813,1522&p=page +// The states the Edit tab can be in. +// +// There used to be five. SavePending and SavedAndStripped existed only to be somewhere to wait +// while the browser was asked for the page and answered on another API; the browser now volunteers +// it (see PageSnapshot), so a save finishes inside the call that asks for it. Note that the diagram +// at https://www.tldraw.com/r/WDLCDLfNbcDZW1kSXZVli?v=-441,-130,2813,1522&p=page still shows the +// old five and has not been redrawn. public enum State { NoPage, Navigating, Editing, - SavePending, +} - // The page has been saved; in the process, we stripped various UI elements from it, - // so it's not in a valid state for editing. We hope to fix this one day (BL-13502). - // In the meantime, to make sure we don't forget to load up some page in a valid state, - // the action that always goes along with a switch to this state returns the ID of a page we - // should navigate to next. - SavedAndStripped, +/// +/// What an attempt at an in-place save actually did. The point of the distinction is the third +/// case: a caller that has a fallback (MergeCurrentPageThenSave) must only use it when nothing +/// happened, because its changeBookBeforeWriting is usually not something you can afford to do +/// twice -- running it again would duplicate or delete a second page. +/// +public enum InPlaceSaveOutcome +{ + // We were not in a state to save, so nothing was written and changeBookBeforeWriting did NOT run. + // A normal outcome, not an error: the user may have started changing pages. The caller is free + // to fall back to its own alternative. + Declined, + + // Saved, and (for the ...ThenNavigating form) on the way to the next page. + Saved, + + // We started and something threw. The browser's content may already be in the book DOM and + // changeBookBeforeWriting may have run and changed the book. The failure has been reported to the + // user; the caller must NOT fall back, or the action happens twice. + Failed, + + // We MUST not write this page at all -- an external process has replaced the book on disk and + // the user's page is about to be discarded in favour of what it wrote. Nothing was written and + // changeBookBeforeWriting did NOT run, exactly as for Declined; the difference is that the + // caller must NOT treat it as "nothing happened and I may try another way", because trying + // again would write the page and clobber the other program's work. Refused and Declined look + // alike and mean opposite things, which is why they are separate values rather than one + // "didn't save". + Refused, } /// -/// A state machine to help us reason about the possible states of the editing model, -/// manage the valid transitions between them, and ensure that we don't attempt invalid ones. +/// Keeps track of what the Edit tab is doing -- showing nothing, loading a page, or editing one -- +/// and refuses transitions that do not make sense from where it is. +/// +/// It used to do more. While a save meant asking the browser for the page and waiting for the +/// answer on another API, this was where we waited: two further states existed for that, and the +/// work a caller wanted done afterwards was parked here until the answer came. None of that +/// remains. +/// +/// What is left is the guarding, and each guard protects something real rather than this class's +/// own consistency: +/// +/// - you cannot navigate away from, or blank, a page that is being edited, because its unsaved +/// edits would go with it. ToNoPageHavingSaved is how a caller that HAS saved says so. +/// - a "page finished loading" notification for a page we are no longer going to is ignored, +/// since those arrive asynchronously and can be late. +/// - a save arriving while a page is still loading is declined: there is no settled page to save. +/// - a save requested from inside another save's own action is declined rather than re-entered. +/// Reordering a page does exactly this, by changing the page selection (see +/// _runningSaveInPlaceAction). /// public class EditingStateMachine { - private Func _doBeforeSaveToDisk; // returns pageId - private Action _failureAction; - private Action _doAfterSaveToDisk; private State _currentState; private string _pageId; private string _pageIdWeFailedToSave; private Action _navigate; // arg is (pageId) - private Action _requestPageSave; // arg is (pageId) - private Action _updateBookWithPageContents; // args are (pageId, pageContentData) private Action _saveBook; - private bool _saveActionHandlesSaveBook; - - // When set, the in-flight save (we are in SavePending, waiting for the browser to return the - // page content) will be discarded on completion rather than merged into the DOM and written to - // disk. See DiscardInFlightSave. - private bool _discardInFlightSave; - // Work that arrived while a save was in flight and could not be done then. It runs once that - // save has completed and we are back in a state that allows transitions. - // See DeferUntilSaveCompletes. - private Action _workToDoAfterInFlightSave; + // Set only while ToSavedInPlaceThenNavigating is running its changeBookBeforeWriting. In that window + // the browser's content is already in the book DOM, so ToNavigating's "cannot navigate while + // editing" guard does not apply -- there are no unsaved changes left to lose. Some actions do + // navigate: relocating a page raises RelocatePageEvent, and EditingModel.OnRelocatePage + // refreshes the display of the page whose HTML (side, page number) just changed. Under the old + // asynchronous flow that was legal because the action ran in a state of its own. + private bool _runningSaveInPlaceAction; private Action _hidePage; - private Action _enableStateTransitions; // arg is (enabled) - /// - /// Set up a state machine. It must be passed six actions: + /// Set up a state machine. It must be passed four actions: /// /// Called to start navigation to another (or the same) page. String is page ID. - /// Called to initiate getting the page contents. String is page ID. /// Called with page ID and pageContentData to update the main DOM with current page content /// Called to save the current state of the DOM to disk. /// Called to make the transition to NoPage (when edit tab is hidden). - /// Called to ask the UI to stop offering actions that would result in new state - /// transitions, because they are not valid in SavePending or SavedAndStripped. It is only a request, and does not - /// take effect soon enough to stop a click already on its way (see WorkspaceView.SetTabsEnabled and BL-16766), so - /// every transition must still refuse or defer an invalid request when one arrives. public EditingStateMachine( Action navigate, - Action requestPageSave, Action updateBookWithPageContents, Action saveBook, - Action hidePage, - Action enableStateTransitions + Action hidePage ) { _currentState = State.NoPage; _navigate = navigate; - _requestPageSave = requestPageSave; _updateBookWithPageContents = updateBookWithPageContents; _saveBook = saveBook; _hidePage = hidePage; - _enableStateTransitions = enableStateTransitions; } - private void UpdateUI() + /// + /// Leave the editor showing nothing, when the caller has ALREADY written the page and the + /// book, synchronously, itself. Used when the user leaves the Edit tab. + /// + /// This exists because ToNoPage refuses to go straight from Editing: that guard is there to + /// stop us abandoning a page whose edits have not been saved. Here they have been -- the + /// browser volunteered the page and the caller merged and wrote it before calling (see + /// PageSnapshot) -- so there is nothing for the guard to protect, and saying so explicitly is + /// better than the caller pretending to be a save-in-place action. + /// + public bool ToNoPageHavingSaved() { - _enableStateTransitions( - _currentState != State.SavePending && _currentState != State.SavedAndStripped - ); + if (_currentState == State.Editing) + { + LogTransition("empty page (already saved)", null); + _hidePage(); + _currentState = State.NoPage; + return true; + } + // Anything else -- mid-navigation, or already blank -- ToNoPage already handles. + return ToNoPage(); } /// - /// Go to the state where we have no page loaded (switching to another tab). + /// Go to the state where we have no page loaded (switching to another tab). Refuses to abandon + /// a page that is being edited; ToNoPageHavingSaved is the way past that for a caller that has + /// already saved. /// public bool ToNoPage() { - try + switch (_currentState) { - switch (_currentState) - { - case State.NoPage: - LogIgnore("empty page"); - return true; - case State.Navigating: - LogShortcut("empty page"); - _hidePage(); - _currentState = State.NoPage; - return true; - case State.Editing: - LogError("empty page"); - throw new InvalidOperationException("Cannot empty page while editing."); - case State.SavePending: - // Review - LogError("empty page"); - throw new InvalidOperationException("Cannot empty page while saving"); - case State.SavedAndStripped: + case State.NoPage: + LogIgnore("empty page"); + return true; + case State.Navigating: + LogShortcut("empty page"); + _hidePage(); + _currentState = State.NoPage; + return true; + case State.Editing: + if (_runningSaveInPlaceAction) + { + // See _runningSaveInPlaceAction: we have just saved, so the guard below + // (which is about losing unsaved edits) has nothing to protect. This is + // the "action returned null, leave the editor blank" case. LogTransition("empty page", null); _hidePage(); _currentState = State.NoPage; return true; - default: - throw new InvalidOperationException( - "Unknown state In emptyPage(): " + _currentState.ToString() - ); - } - } - finally - { - UpdateUI(); + } + LogError("empty page"); + throw new InvalidOperationException("Cannot empty page while editing."); + default: + throw new InvalidOperationException( + "Unknown state in ToNoPage(): " + _currentState.ToString() + ); } } @@ -137,12 +172,6 @@ public bool ToNoPage() /// public bool Navigating => _currentState == State.Navigating; - /// - /// True if we have initiated saving a page, but not yet received the html and user styles - /// from the browser. - /// - public bool SavePending => _currentState == State.SavePending; - /// /// True if a page is loaded and being edited, so that a save (and anything that starts with /// one, such as duplicating or deleting the page) will be acted on rather than ignored. @@ -155,42 +184,36 @@ public bool ToNoPage() /// public bool ToNavigating(string pageId) { - try + switch (_currentState) { - switch (_currentState) - { - case State.NoPage: + case State.NoPage: + StartNavigating(pageId); + return true; + case State.Navigating: + if (_pageId == pageId) + { + LogIgnore("navigate"); + return true; // we're already headed there + } + else + { StartNavigating(pageId); return true; - case State.Navigating: - if (_pageId == pageId) - { - LogIgnore("navigate"); - return true; // we're already headed there - } - else - { - StartNavigating(pageId); - return true; - } - case State.Editing: - LogError("navigate"); - throw new InvalidOperationException("Cannot navigate while editing"); - case State.SavePending: - LogIgnore("navigate"); - return false; - case State.SavedAndStripped: + } + case State.Editing: + if (_runningSaveInPlaceAction) + { + // See _runningSaveInPlaceAction: we have just saved, so the guard below + // (which is about losing unsaved edits) has nothing to protect. StartNavigating(pageId); return true; - default: - throw new InvalidOperationException( - "Unknown state in ToNavigating(): " + _currentState.ToString() - ); - } - } - finally - { - UpdateUI(); + } + LogError("navigate"); + throw new InvalidOperationException("Cannot navigate while editing"); + default: + throw new InvalidOperationException( + "Unknown state in ToNavigating(): " + _currentState.ToString() + ); } } @@ -207,304 +230,185 @@ private void StartNavigating(string pageId) /// public bool ToEditing(string pageId) { - try + switch (_currentState) { - switch (_currentState) - { - case State.Navigating: - if (_pageId == pageId) - { - LogTransition("editing", pageId); - _currentState = State.Editing; - return true; - } - else - { - LogIgnore("edit"); - return false; - } - default: + case State.Navigating: + if (_pageId == pageId) + { + LogTransition("editing", pageId); + _currentState = State.Editing; + return true; + } + else + { LogIgnore("edit"); return false; - } - } - finally - { - UpdateUI(); + } + default: + LogIgnore("edit"); + return false; } } - private void DoPostSaveAction( - string pageContentData, - Func doBeforeSaveToDisk, - Action failureAction = null, - Action doAfterSaveToDisk = null - ) - { - // If an external process overwrote the book on disk while this save was in flight, we are - // intentionally discarding the gathered page content (see DiscardInFlightSave): don't merge - // it into the DOM and don't write it to disk below, or we'd clobber what that process wrote. - var discard = _discardInFlightSave; - _discardInFlightSave = false; - try - { - if (pageContentData != null && !discard) - { - if (pageContentData.StartsWith("ERROR:")) - throw new ApplicationException(pageContentData); // This is caught immediately below. We want that error handling for this case. - _updateBookWithPageContents(_pageId, pageContentData); - _pageIdWeFailedToSave = null; - } - else - { - // We're in the no page state (or discarding), and there's nothing to save. - } - } - catch (Exception e) - { - // This prevents us from reporting the same error over and over again, which would also prevent the user from doing anything, including closing Bloom. - if (_pageId != _pageIdWeFailedToSave) - { - _pageIdWeFailedToSave = _pageId; - - var msg = LocalizationManager.GetString( - "Errors.CouldNotSavePage", - "Bloom had trouble saving a page. Please report the problem to us. Then quit Bloom, run it again, and check to see if the page you just edited is missing anything. Sorry!" - ); - ErrorReport.NotifyUserOfProblem(e, msg); - - failureAction?.Invoke(); - - // We must not get stuck in the SavedAndStripped state, so we'll navigate to the page - // we were on before the save. - ToNavigating(_pageId); - return; - } - } - - string pageId = _pageId; - try - { - pageId = doBeforeSaveToDisk(); - } - catch (Exception) - { - // We must not get stuck in the SavedAndStripped state, so we'll navigate to the page - // we were on before the save. - ToNavigating(pageId); - throw; - } - - try - { - if (_saveActionHandlesSaveBook) - { - _saveActionHandlesSaveBook = false; - } - else if (!discard) - { - _saveBook(); - doAfterSaveToDisk?.Invoke(); - } - } - // I'm not sure what should happen if we get an exception in _saveBook, - // but we definitely don't want to get stuck in the SavedAndStripped state, - // so for now we'll do whatever we were planning to do if it succeeded. - finally - { - if (pageId != null) - ToNavigating(pageId); - else - ToNoPage(); - } - } - - /// - /// Start saving the current page. When we get the page content and update the main HTML DOM with it, - /// doBeforeSaveToDisk will be called. Then, unless saveActionHandlesSaveBook is passed as true, - /// we will call saveBook, saving the changes to disk. If doAfterSaveToDisk is provided, it is called - /// after the disk save. Finally, we navigate to the page whose ID is returned by doBeforeSaveToDisk. - /// (This is convenient, and also ensures that we don't leave a page in the stripped state.) - /// - public bool ToSavePending( - Func doBeforeSaveToDisk, - bool saveActionHandlesSaveBook = false, - Action failureAction = null, - Action doAfterSaveToDisk = null - ) + public bool ToSavedInPlace(string pageContentData, Action reportFailure) { try { switch (_currentState) { - case State.NoPage: - _saveActionHandlesSaveBook = saveActionHandlesSaveBook; - DoPostSaveAction(null, doBeforeSaveToDisk, failureAction, doAfterSaveToDisk); - return true; case State.Editing: - _saveActionHandlesSaveBook = saveActionHandlesSaveBook; - _doBeforeSaveToDisk = doBeforeSaveToDisk; - _failureAction = failureAction; - _doAfterSaveToDisk = doAfterSaveToDisk; - LogTransition("savePending", null); - _currentState = State.SavePending; - _requestPageSave(_pageId); + LogTransition("saved in place", _pageId); + if (pageContentData.StartsWith("ERROR:")) + throw new ApplicationException(pageContentData); + _updateBookWithPageContents(_pageId, pageContentData); + _pageIdWeFailedToSave = null; + _saveBook(); return true; - + case State.NoPage: case State.Navigating: - case State.SavePending: - case State.SavedAndStripped: - LogIgnore("save"); + LogIgnore("save in place"); return false; default: throw new InvalidOperationException( - "Unknown state In ToSavePending(): " + _currentState.ToString() + "Unknown state In ToSavedInPlace(): " + _currentState.ToString() ); } } - finally + catch (Exception e) { - UpdateUI(); - } - } - - /// - /// If a save is in flight (we are in SavePending, having asked the browser for the current page's - /// content but not yet received it), arrange for that save's completion to throw the content away - /// rather than merging it into the book DOM or writing it to disk. Used when an external process - /// has overwritten the book on disk and we are intentionally discarding the user's unsaved edits - /// (see EditingModel.ReloadCurrentBookDiscardingEdits). Without this, the in-flight save would - /// finish after we reload and clobber the external process's content on disk. - /// Returns true if a save was actually in flight (so the discard will take effect). - /// - public bool DiscardInFlightSave() - { - if (_currentState != State.SavePending) + // We don't have to navigate to get out of an invalid state: we never left Editing, and + // the browser still has the intact page. So all we owe the user is the report, and the + // caller a 'false'. We report only once per page, so that a page which fails every time + // does not lock the user out of Bloom. + if (_pageId != _pageIdWeFailedToSave) + { + _pageIdWeFailedToSave = _pageId; + reportFailure(e); + } return false; - _discardInFlightSave = true; - return true; + } } /// - /// For a caller that could not start a save (ToSavePending returned false) because one is - /// already in flight, and whose work is not safe to do until that save has finished. If a save - /// really is in flight, is remembered and run when the save completes, - /// and this returns true — the caller must then do nothing else. Otherwise it returns false and - /// the caller must get on with its own work. + /// Save the current page from the content we have for it, optionally change the book in some + /// way, then go to whichever page changeBookBeforeWriting names. Editing -> Navigating in one + /// step, because there is nothing to wait for: the browser volunteers the page as it is edited + /// (see PageSnapshot), so we already have it. /// - /// Leaving the Edit tab is the case this exists for (BL-16766): the user clicked another tab - /// twice in quick succession, and the second click found the first click's save still waiting - /// on the browser for the page content. Pressing on with the tab change then reached - /// ToNoPage() while still in SavePending, which throws, and left the workspace half switched - /// between the two tabs. + /// changeBookBeforeWriting runs after the browser's content has been merged into the book DOM + /// and before the book is written to disk + /// (so a page it duplicates or deletes already reflects the user's latest edits), and it + /// returns the id of the page to show afterwards. For a caller that only wants to change pages + /// it is simply () => theNewPageId. It is allowed to navigate (see _runningSaveInPlaceAction); + /// if it does, the navigation we do afterwards to its returned page simply supersedes it, or is + /// ignored if it is to the same page. /// - /// Only one piece of deferred work is kept: a later request supersedes an earlier one, since it - /// is the more recent thing the user asked for. + /// If it fails we report it and do NOT navigate: doing so would throw away the edits we failed + /// to save, and we are not in a broken state we have to escape, since the browser still has the + /// page intact and editable. Note the difference between the two failure-ish outcomes -- see + /// InPlaceSaveOutcome, and be careful to preserve it: Declined means the action never ran and + /// the caller may fall back, whereas Failed means it may have run already and the caller must + /// not run it again. /// - public bool DeferUntilSaveCompletes(Action work) - { - // No save to wait for, or nothing to do: the caller must handle it itself. - if (_currentState != State.SavePending || work == null) - return false; - _workToDoAfterInFlightSave = work; - return true; - } - - /// - /// Source: API call providing content of current page will request this after saving and before executing pending action - /// (e.g. changing pages) - /// - public bool ToSavedAndStripped(string pageContentData) - { - // This is the only way out of SavePending, so it is where anything that had to wait for the - // in-flight save gets its turn (see DeferUntilSaveCompletes). It runs after the whole save, - // including the post-save action, so that we are in a state that allows transitions again; - // and in a finally, because a save that fails must not swallow a pending tab change. - try - { - return ToSavedAndStrippedFromSavePending(pageContentData); - } - finally - { - var deferredWork = _workToDoAfterInFlightSave; - _workToDoAfterInFlightSave = null; - deferredWork?.Invoke(); - } - } - - private bool ToSavedAndStrippedFromSavePending(string pageContentData) + public InPlaceSaveOutcome ToSavedInPlaceThenNavigating( + string pageContentData, + Func changeBookBeforeWriting, + Action reportFailure + ) { try { switch (_currentState) { - case State.SavePending: - Guard.AgainstNull(_doBeforeSaveToDisk, "doBeforeSaveToDisk"); - LogTransition("saved and stripped", null); - _currentState = State.SavedAndStripped; - DoPostSaveAction( - pageContentData, - _doBeforeSaveToDisk, - _failureAction, - _doAfterSaveToDisk - ); - _doBeforeSaveToDisk = null; - _failureAction = null; - _doAfterSaveToDisk = null; - return true; + case State.Editing: + if (_runningSaveInPlaceAction) + { + // We are inside a save's own action, which is allowed to do things that + // normally start a save -- changing the page selection does, via + // PageListController.OnPageSelectedChanged. There is nothing for a second + // save to do: the content is already merged and _saveBook() is about to + // run. Accepting it would re-enter this method and run the whole thing + // again, including the caller's action. + // + // This guard used to live on the transition that asked the browser for + // the page, because that is where a nested save landed while such a path + // existed. There isn't one, so nested saves arrive here instead. + LogIgnore("save in place then navigate"); + return InPlaceSaveOutcome.Declined; + } + LogTransition("saved in place, then navigating", _pageId); + // Null means the page has not been changed since it loaded, so there is + // nothing to merge -- but the action still has to run and the book still has + // to be written, because the action itself (duplicating a page, say) is a + // change. See PageSnapshot: a page nobody edited never produces a snapshot, + // which is exactly how we know there is nothing to merge rather than that we + // have not been told yet. + if (pageContentData != null) + { + if (pageContentData.StartsWith("ERROR:")) + throw new ApplicationException(pageContentData); + _updateBookWithPageContents(_pageId, pageContentData); + } + _pageIdWeFailedToSave = null; + RunActionThenSaveAndNavigate(changeBookBeforeWriting); + return InPlaceSaveOutcome.Saved; case State.NoPage: + // There is no browser content to merge, but the action can still change the + // book (it may duplicate or delete a page), and that has to reach disk just + // the same: run the action, save the book, then navigate. + RunActionThenSaveAndNavigate(changeBookBeforeWriting); + return InPlaceSaveOutcome.Saved; case State.Navigating: - case State.Editing: - case State.SavedAndStripped: - LogError("ToSavedAndStripped"); - return false; + LogIgnore("save in place then navigate"); + return InPlaceSaveOutcome.Declined; default: throw new InvalidOperationException( - "Unknown state In ToSavedAndStripped(): " + _currentState.ToString() + "Unknown state In ToSavedInPlaceThenNavigating(): " + + _currentState.ToString() ); } } - finally + catch (Exception e) { - UpdateUI(); + if (_pageId != _pageIdWeFailedToSave) + { + _pageIdWeFailedToSave = _pageId; + reportFailure(e); + } + return InPlaceSaveOutcome.Failed; } } /// - /// Various (and growing) list of Javascript methods that gather the html to save and call Api:______(html-to-save, post-save-action) - /// Untested since we don't have any such methods yet. + /// The middle of ToSavedInPlaceThenNavigating, from the point where the browser's content is + /// safely in the book DOM: run the caller's action, write the book, and go to the page the + /// action named. Separated out only so that _runningSaveInPlaceAction is obviously scoped to + /// the action, and obviously cleared even if it throws. /// - public bool ToSavedAndStripped(Func postSaveAction, string pageContentOrNull = null) + private void RunActionThenSaveAndNavigate(Func changeBookBeforeWriting) { + _runningSaveInPlaceAction = true; try { - switch (_currentState) + var pageIdToGoTo = changeBookBeforeWriting(); + _saveBook(); + if (pageIdToGoTo == null) { - case State.Editing: - Guard.AssertThat( - _doBeforeSaveToDisk == null, - "stored postSaveAction should be null, we're going to use the parameter instead." - ); - Guard.AgainstNull(postSaveAction, "postSaveAction"); - LogTransition("saved and stripped", null); - _currentState = State.SavedAndStripped; - DoPostSaveAction(pageContentOrNull, postSaveAction); - return true; - case State.NoPage: - case State.Navigating: - case State.SavePending: - case State.SavedAndStripped: - LogError("ToSavedAndStripped"); - return false; - default: - throw new InvalidOperationException( - "Unknown state In ToSavedAndStripped(): " + _currentState.ToString() - ); + // The contract: the action returns null to say "leave the editor blank" (which + // is how leaving the Edit tab saves). Trying to navigate to no page would just + // leave a broken editor. + ToNoPage(); + return; } + // Via ToNavigating rather than StartNavigating so that an action which already + // navigated to this very page (as relocating one does) is not made to do it twice. + // While _runningSaveInPlaceAction is set, ToNavigating accepts being called from + // Editing, which is the state we are still in if the action did not navigate. + ToNavigating(pageIdToGoTo); } finally { - UpdateUI(); + _runningSaveInPlaceAction = false; } } diff --git a/src/BloomExe/Edit/EditingView.cs b/src/BloomExe/Edit/EditingView.cs index 0ce1581e4b58..017484fe9fd1 100644 --- a/src/BloomExe/Edit/EditingView.cs +++ b/src/BloomExe/Edit/EditingView.cs @@ -410,6 +410,24 @@ public void OnVisibleChanged(bool visible) /// public void OnHideEditTab() { + // Run the page frame's leaving-the-page teardown. Changing pages gets this via + // switchContentPage in workspaceRoot.ts, but leaving the tab does not unload or + // re-navigate the page frame, so nothing there fires and the page we are leaving keeps + // everything the editor had hung on it: the open toolbox tool with its observers and + // any window it had opened, the controls above the page, and the canvas-element + // machinery. Symptoms are a pop-up left on screen behind the new tab, and the toolbox + // staying switched off if the user left with Change Layout on. + // + // This used to happen for free: leaving the tab performs a save, and a save used to + // begin by stripping the live page. That coupling is what BL-13502 removed. + // + // Note we are called from the state machine's transition to NoPage, i.e. AFTER the page + // content has been captured and saved. That matters, because this changes the live + // page, and doing it earlier would put the teardown back into the save path. + _mainBrowser?.RunJavascriptFireAndForget( + "workspaceBundle.getEditablePageBundleExports()?.pageUnloading();" + ); + // Tells the model to prepare for possibly changing the current book, which // currently requires reloading the toolbox. _model.ClearBookForToolboxContent(); diff --git a/src/BloomExe/Edit/PageControlsApi.cs b/src/BloomExe/Edit/PageControlsApi.cs index bc80de1462a3..231708b4f888 100644 --- a/src/BloomExe/Edit/PageControlsApi.cs +++ b/src/BloomExe/Edit/PageControlsApi.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Globalization; using Bloom.Api; @@ -70,7 +70,7 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) kApiUrlPart + "duplicatePage", request => { - _editingModel.OnDuplicatePage(); + _editingModel.OnDuplicatePage(request.GetPageContentFromBrowserOrNull()); request.PostSucceeded(); }, true @@ -83,7 +83,7 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) request => { // The browser side has already confirmed with the user (BL-16421). - _editingModel.OnDeletePage(); + _editingModel.OnDeletePage(request.GetPageContentFromBrowserOrNull()); request.PostSucceeded(); }, true diff --git a/src/BloomExe/Edit/PageListController.cs b/src/BloomExe/Edit/PageListController.cs index 9b2be121eb49..fd517a121b8f 100644 --- a/src/BloomExe/Edit/PageListController.cs +++ b/src/BloomExe/Edit/PageListController.cs @@ -40,13 +40,22 @@ private void OnPageSelectedChanged(object page, EventArgs e) { if (page == null) return; - if (!_dontForwardSelectionEvent) - { - // The only necessary action after saving is to navigate to the desired page. - // This is achieved by returning the right ID in the trivial doAfterSaving function - // passed as the first argument to SaveThen. - _model.SaveThen(() => (page as Page).Id, () => { }); - } + if (_dontForwardSelectionEvent) + return; + + var pageId = (page as Page).Id; + + // The only necessary action after saving is to go to the desired page, which is what + // returning its ID from the first argument achieves. + // + // When the click brought the outgoing page's content with it, SaveThen saves and goes + // in one step, so we never enter SavePending -- the state in which a further page click + // would be silently discarded. When it didn't, SaveThen asks the browser as it always + // did. + _model.MergeCurrentPageThenSave( + () => pageId, + pageContentFromBrowser: (e as PageSelectedChangedEventArgs)?.PageContentFromBrowser + ); } public void SetBook(Book.Book book) //review: could do this instead by giving this class the bookselection object diff --git a/src/BloomExe/Edit/PageSnapshot.cs b/src/BloomExe/Edit/PageSnapshot.cs new file mode 100644 index 000000000000..6e88ee9bcd23 --- /dev/null +++ b/src/BloomExe/Edit/PageSnapshot.cs @@ -0,0 +1,86 @@ +using System; + +namespace Bloom.Edit +{ + /// + /// The most recent copy of the page being edited that the BROWSER volunteered, rather than one + /// C# asked for and waited on. + /// + /// This exists to remove the round trip at the heart of saving. Historically, when C# wanted + /// the current page it had to ask the browser (RequestBrowserToSave) and wait for the answer to + /// arrive on a separate API call (editView/pageContent) — which is why saving needed states to + /// wait in, and why anything that had to save first (leaving the Edit tab, closing the + /// collection, a page-list command) had to be chopped into "before" and "after" halves around + /// an asynchronous gap. + /// + /// Gathering the page is now cheap (~0.7 ms) and, since BL-13502, has no effect on the live + /// page at all. So the browser can simply keep C# supplied: an idle task in the editing page + /// posts the current content whenever the page has settled after a change. C# then already has + /// what a save needs, and can take it synchronously. + /// + /// Two properties matter and are the reason this is a class rather than two fields: + /// + /// 1. A snapshot belongs to ONE page. Content for a page we are no longer on must never be + /// written; ask for it by page id and you cannot get someone else's. + /// 2. NO snapshot means NO unsaved changes, not "we do not know". The browser posts only after + /// something has actually changed the page, so a page the user merely looked at never + /// produces one — and there is then genuinely nothing to save. Navigation clears it, so a + /// page revisited later starts empty again rather than re-applying what it had last time. + /// + public class PageSnapshot + { + private readonly object _lock = new object(); + private string _pageId; + private string _content; + + /// + /// Record what the browser says the page currently contains. Called from the API handler, + /// which deliberately does not take the server's sync lock — this only stores a string, and + /// making the editor wait on a save in order to report its own content would defeat the + /// point. + /// + public void Set(string pageId, string content) + { + if (string.IsNullOrEmpty(pageId)) + throw new ArgumentException( + "A snapshot must say which page it is for", + nameof(pageId) + ); + lock (_lock) + { + _pageId = pageId; + _content = content; + } + } + + /// + /// The content the browser last volunteered for this page, or null if it has not changed + /// since it was loaded (or the snapshot belongs to a different page). Null means "nothing + /// to save", NOT "go and ask the browser". + /// + public string GetFor(string pageId) + { + if (string.IsNullOrEmpty(pageId)) + return null; + lock (_lock) + { + return _pageId == pageId ? _content : null; + } + } + + /// + /// Forget everything. Called when we start navigating: the page we had a snapshot of is + /// going away, and the copy in the book DOM (which the save just wrote) is now the truth. + /// Without this, coming back to the same page later could re-apply content from the + /// previous visit over what is actually in the book. + /// + public void Clear() + { + lock (_lock) + { + _pageId = null; + _content = null; + } + } + } +} diff --git a/src/BloomExe/Edit/PageThumbnailList.cs b/src/BloomExe/Edit/PageThumbnailList.cs index 06eb94464aff..48c5769e7c98 100644 --- a/src/BloomExe/Edit/PageThumbnailList.cs +++ b/src/BloomExe/Edit/PageThumbnailList.cs @@ -12,6 +12,21 @@ namespace Bloom.Edit { + /// + /// Carries the outgoing page's content along with a page-selection event, for the case where + /// the browser sent it with the click. Without it we would have to ask the browser for the + /// content and wait for the answer on another API before we could change pages. + /// + public class PageSelectedChangedEventArgs : EventArgs + { + public PageSelectedChangedEventArgs(string pageContentFromBrowser) + { + PageContentFromBrowser = pageContentFromBrowser; + } + + public string PageContentFromBrowser { get; } + } + /// /// Handle a list of page thumbnails (the left column in Edit mode) using an iframe configured by /// pageThumbnailList.pug to load the React component specified in pageThumbnailList.tsx. @@ -88,7 +103,7 @@ public PageThumbnailList() _baseHtml = ReactControl.ReplaceViteDevOrigin(_baseHtml); } - private void InvokePageSelectedChanged(IPage page) + private void InvokePageSelectedChanged(IPage page, string pageContentFromBrowser = null) { EventHandler handler = PageSelectedChanged; if ( @@ -97,7 +112,12 @@ private void InvokePageSelectedChanged(IPage page) page != null ) { - handler(page, null); + handler( + page, + pageContentFromBrowser == null + ? null + : new PageSelectedChangedEventArgs(pageContentFromBrowser) + ); } } @@ -201,10 +221,15 @@ private List UpdateItemsInternal(IEnumerable pages) return result.ToList(); } - internal void PageClicked(IPage page) + /// + /// The user clicked a page in the list. pageContentFromBrowser, when the page list managed + /// to collect it, is the current page's content, so we can save it without asking the + /// browser for it and waiting; null means fall back to that older route. + /// + internal void PageClicked(IPage page, string pageContentFromBrowser = null) { if (Enabled) - InvokePageSelectedChanged(page); + InvokePageSelectedChanged(page, pageContentFromBrowser); } /// @@ -241,7 +266,17 @@ internal bool IsContextMenuCommandEnabled(IPage page, string commandId) } } - internal void ExecuteContextMenuCommand(IPage page, string commandId) + /// + /// Run one of the thumbnail context menu's commands. pageContentFromBrowser, when the page + /// list was able to collect it, is the current page's content; the commands that have to + /// save the current page first can then do so without asking the browser for it and waiting + /// (see EditingModel.SavePageInPlaceThen). + /// + internal void ExecuteContextMenuCommand( + IPage page, + string commandId, + string pageContentFromBrowser = null + ) { if (!IsContextMenuCommandEnabled(page, commandId)) return; @@ -249,20 +284,20 @@ internal void ExecuteContextMenuCommand(IPage page, string commandId) switch (commandId) { case "duplicatePage": - Model.DuplicatePage(page); + Model.DuplicatePage(page, pageContentFromBrowser); break; case "duplicatePageManyTimes": Model.DuplicateManyPages(page); break; case "copyPage": - Model.CopyPage(page); + Model.CopyPage(page, pageContentFromBrowser); break; case "pastePage": - Model.PastePage(page); + Model.PastePage(page, pageContentFromBrowser); break; case "removePage": // The browser side has already confirmed with the user (BL-16421). - Model.DeletePage(page); + Model.DeletePage(page, pageContentFromBrowser); break; case "chooseDifferentLayout": Model.GetEditingBrowser().Focus(); @@ -277,7 +312,11 @@ internal void ExecuteContextMenuCommand(IPage page, string commandId) // This gets invoked by Javascript (via the PageListApi) when it determines that a particular page has been moved. // newIndex is the (zero-based) index that the page is moving to // in the whole list of pages, including the placeholder. - internal void PageMoved(IPage movedPage, int newPageIndex) + internal void PageMoved( + IPage movedPage, + int newPageIndex, + string pageContentFromBrowser = null + ) { // accounts for placeholder. // Enhance: may not be needed in single-column mode, if we ever restore that. @@ -293,7 +332,7 @@ internal void PageMoved(IPage movedPage, int newPageIndex) WebSocketServer.SendString("pageThumbnailList", "pageListNeedsReset", ""); return; } - Model.SaveThen( + Model.MergeCurrentPageThenSave( () => { var relocatePageInfo = new RelocatePageInfo(movedPage, newPageIndex); @@ -302,8 +341,8 @@ internal void PageMoved(IPage movedPage, int newPageIndex) PageSelectedChanged(movedPage, new EventArgs()); return movedPage.Id; }, - () => { }, // wrong state, do nothing - forceFullSave: true + forceFullSave: true, + pageContentFromBrowser: pageContentFromBrowser ); } diff --git a/src/BloomExe/Edit/SavingWithoutReloading.md b/src/BloomExe/Edit/SavingWithoutReloading.md new file mode 100644 index 000000000000..e370485f0246 --- /dev/null +++ b/src/BloomExe/Edit/SavingWithoutReloading.md @@ -0,0 +1,526 @@ +# Saving a page without reloading it — what it enables + +## A note on shape: this branch has to survive a long wait + +It will not merge for a while (it is too big a change to risk in the current release), so it is +written to be cheap to merge later rather than to be the most direct expression of each change. +Two rules follow from that, and they are worth keeping if you add to it: + +- **New behaviour goes in new files.** `pageContentDelays.ts`, `niceScrollCleanup.ts`, + `currentPageContent.ts`, `EditingStateMachine`'s new transitions, and the tests for all of them + are additions rather than edits. A new file cannot conflict with anything. +- **Don't reshape existing code to add to it.** What conflicts is a *changed* line, not an added + one — so an extra argument on a call beats hoisting its lambda into a named local, even when the + named local reads a little better on its own. That single choice took `EditingModel.cs` from 130 + changed lines to 37 and removed every reindentation. + +## What changed + +Historically, gathering the current page's content for a save **wrecked the live page**. The +browser stripped the editing markup out of the real DOM (detached the toolbox tool, unmounted the +above-page controls, removed the origami layout mode and text-box labels, killed the niceScroll +bars, and rewrote every `bloom-editable`'s `innerHTML` with CKEditor's cleaned-up data). The page +that was left could be saved but not edited, which is exactly what the `SavedAndStripped` state in +`EditingStateMachine` records, and why **every** save had to end by navigating to some page +(BL-13502). + +That is no longer true: + +- `getBodyContentForSavePage()` (`bookEdit/js/bloomEditing.ts`) now **clones** the body and does all + the stripping on the clone. **Nothing at all is done to the live page** — it is not touched, so + there is nothing to put back. +- Canvas-element editing is no longer turned off and on around the save. `turnOffCanvasElementEditing()` + did three things that affect what gets saved, and `CanvasElementManager.prepareCloneOfBodyForSave()` + now does all three against the clone: Comical's bubble-tail `` (via + `Comical.exportSvgToCopiesOfParents`, added in comicaljs 0.4.1 as the non-destructive counterpart of + `stopEditing()`), the canvas element positions recorded as the current language's alternate (pure + attribute manipulation, so a clone with no layout is fine), and the `bloom-focusedCanvasElement` + class. The rest of what that method does is live-only: the control frame is `bloom-ui` so C# + discards it anyway, `EnableAllImageEditing` only puts `bloom-ui` buttons back, and the listener + removal has no bearing on the HTML. +- CKEditor's cleaned-up text is read from the live editors and written into the clone + (`EditableDivUtils.copyCkEditorDataToClone`) rather than written back over the live editors. +- The scroll bars are cleaned off the clone by our own `removeNiceScrollArtifacts` + (`bookEdit/js/niceScrollCleanup.ts`) instead of by asking the live niceScroll instances to remove + themselves, so a save no longer disturbs the scroll bars the user is looking at. It handles the + inserted rails/cursors, the alignment classes `addScrollbarsToPage()` moved aside (the part that + would otherwise have been real data loss), and the three inline styles niceScroll sets without + recording. The live page still uses bloom-player's `cleanupNiceScroll()` at page setup, since + only that can tear down the instances themselves. +- `ITool` gained **one** new method, `removeToolMarkup(pageOrClone)`, which is used two ways rather + than duplicated: the save path calls it on a clone of the `.bloom-page` div, and + `ToolboxToolReactAdaptor.detachFromPage()` calls it on the live one. A tool with nothing + live-only to clean up implements only `removeToolMarkup` and gets both behaviours; a tool that + does (observers, React state, re-enabling image editing) overrides `detachFromPage` and calls + `super.detachFromPage()` at the point where the markup should come off. `detachCurrentTool()` + logs a console error if an override forgets that `super` call, because the symptom otherwise + shows up much later as tool markup saved into the book. +- We no longer blur the active element while saving, so the user's cursor stays where it was. + +On top of that: + +- `EditingStateMachine.ToSavedInPlace(pageContentData, reportFailure)` — a save that begins and ends + in `Editing`. No `SavePending` wait, no `SavedAndStripped`, no navigation. +- `EditingStateMachine.ToSavedInPlaceThenNavigating(pageContentData, doBeforeSaveToDisk, + reportFailure)` — the same thing for a request that also has to *change* something and then show + another page. `doBeforeSaveToDisk` plays exactly the role it plays in `ToSavePending`: it runs + after the browser's content is in the book DOM and before the book is written to disk, and + returns the page to go to. So the whole `SavePending → SavedAndStripped → Navigating` sequence + collapses into one `Editing → Navigating` step. +- **`EditingModel.SaveThen(..., pageContentFromBrowser)`** — the way in. Given the content it does + the whole save here and now (privately, via `SavePageInPlaceThen`); without it, or if we turn out + not to be in a state to save, it asks the browser exactly as it always did. So a caller opts in + by passing one more argument and needs to know nothing else: in particular it does not have to + know that only a `Declined` outcome may fall back, which is the rule that, got wrong, deletes a + page twice. Both routes reuse `UpdateBookDomFromBrowserPageContent()` and `SaveBookToDisk()`, so + they make exactly the same "just this page vs. full book save" decision. +- `EditingModel.SavePageInPlace(pageContentData)` — save and stay put, for the one caller that + wants no navigation at all (Copy Page). +- API `editView/savePageInPlace`, called by `savePageWithoutReloading()` in `bloomEditing.ts`. The + reply is not sent until the save has finished, so Javascript can `await` it. + +### What has been converted so far + +Everything the **page list frame** initiates. `collectCurrentPageContent()` +(`pageThumbnailList/currentPageContent.ts`) gathers the editable page's content — it can, because +`getEditablePageBundleExports()` reaches across frames — and every one of these sends it along with +its request: + +| Command | Was | Is now | +| --- | --- | --- | +| clicking a page thumbnail | `SaveThen` round trip, then navigate | same `SaveThen`, given the content | +| Duplicate Page (button and context menu) | `SaveThen` round trip, then duplicate, then navigate | ditto | +| Delete Page (button and context menu) | ditto | ditto | +| Paste Page (context menu) | ditto | ditto | +| dragging a page to a new position | ditto | ditto | +| Change Layout, import a video, convert a field to a derived one | `SaveThen` round trip, then reload the page | ditto — and they keep the reload, which is doing a second job for them (§1) | +| **Copy Page** (context menu) | `SaveThen` round trip **and a reload of the page being copied** | `SavePageInPlace` — no navigation at all | + +Copy Page is the first of these to lose its reload entirely: copying doesn't change the page you +are looking at, so with the content in hand there is nothing left to navigate to. The others still +navigate, because they are *going somewhere* (the new page, the next page, the moved page); what +they lose is the round trip, and with it the `SavePending` window in which a second command is +silently dropped. + +Not converted, because the request comes from a separate dialog window that cannot reach the page +frame: Add Page (`AddPageDialog`) and Duplicate Many Times (`duplicateManyDlgBundle`). Both still +use `SaveThen`, which is why it has to stay. + +Everything below is the inventory of what else could be converted, and what that would let us +delete. + +## Why the reload was expensive, not just ugly + +A save-then-reload costs a full page teardown and rebuild: regenerate the page DOM in C#, navigate +the browser, re-run `SetupElements` over every element, re-attach CKEditor to every editable, +re-run the toolbox's `newPageReady` for the current tool, re-measure and re-fit images, re-add +scroll bars. It also throws away everything transient: the cursor position and selection, the +active canvas element and its control frame, scroll position, the Play/Start tab a game page was +on, an in-progress audio playback. Almost every "flicker" complaint about the Edit tab traces back +to a save. + +--- + +## 1. Round trips that collapse into one call + +These are places where Javascript wants "make sure the book on disk is current, then do X". Today +each is: JS posts to an API → C# calls `SaveThen` → C# asks the browser for the content → the +browser answers on a *different* API → the state machine runs the pending action → C# navigates → +the page reloads. Four hops and a reload, to do something the browser could have asked for +directly. + +| Caller | Today | Could become | +| --- | --- | --- | +| ~~`origami.ts`, `bloomVideo.ts`, `canvasControlTextMenuItems.ts`~~ | — | **Done**: all three now call `saveChangesAndRethinkPage()` (`bloomEditing.ts`), which sends the content with the post. They **keep** the reload — see below; what went is the round trip. | +| `EditingViewApi` `editView/setTopic` → `SavePageAndReloadIt()` | Save + full reload to show a changed data-div value | Could carry the content too — the topic chooser runs in the workspace root, so it can reach the page frame. It would have to change from a plain post string to JSON, and it is also used from the Publish tab where there is no editable page at all (the collect just returns nothing and it falls back, which is fine). Small win, so not done yet. | +| `EditingModel.SavePageAndReloadIt` from `PageRefreshEvent` | Save + reload | Stays on `SaveThen`: these are raised inside C# (book settings, and `EditingModel` itself), so there is no browser request to carry the content. | + +### The three converted ones keep their reload, and that is right + +`common/saveChangesAndRethinkPageEvent` reads as "save this page, then show it again", and the +original reason for showing it again — restoring the UI markup the save stripped — is gone. But the +reload is doing a **second** job for these three callers, which is why they keep it: each has just +restructured the page into a state that has never been through `SetupElements` (a new origami +layout, an imported video, a translation group replaced by a derived field), and the reload is what +runs the page's setup over the result. This is the `customXmatterPage` lesson (below) applied +before making the mistake rather than after. + +So what they lost is the four-hop round trip, not the reload. Verified by driving Change Layout +mode on and off in a real book: `editView/pageContent` never fires, the page reloads and comes back +fully alive (CKEditor attached, canvas elements present), and text typed but not saved before the +toggle is in the file on disk afterwards. + +`postThatMightNavigate` itself exists (`utils/bloomApi.ts`) only because the post's own page is +about to be navigated out from under it, so the network error has to be swallowed. Calls that stop +navigating can use plain `post`/`postString` and get their errors reported again. + +### Before converting any of these: the reload may be doing a second job + +Check what the caller has just done to the live DOM, because a reload does not only recover from +the old destructive save — it also re-runs the page's whole setup (`SetupElements`, re-attaching +CKEditor to every editable, the toolbox's `newPageReady`, image sizing, scroll bars). A caller that +restructured the page may be relying on that without saying so. + +This is not hypothetical. `customXmatterPage.tsx` posts `editView/jumpToPage` with its **own** +page id — asking to "jump" to where it already is, purely to get a save — right after +`convertXmatterPageToCustom()` rebuilds the cover into canvas elements. Converting it to +`savePageWithoutReloading()` looked ideal on paper (it even fixes a real bug: that handler replies +before the save has happened, so the `await` does not mean what it appears to). But driven live, +the converted cover came back with CKEditor attached to **0 of its 12** editables, where the +standard cover has 9: `convertXmatterPageToCustom()` never attaches editors to the elements it +creates, and the reload had been quietly covering for that. Reverted. + +So: convert, then *drive the real UI* and check the page is still fully alive — editors attached, +tool markup present, images sized. Neither the unit tests nor the typecheck will tell you. + +### What the round trip actually costs — measured, before changing anything + +Do not do this work for speed. Measured on a running Bloom (7-page book, 25 KB page), driving real +thumbnail clicks and watching the API traffic from outside: +`.claude/skills/run-bloom/benchPageChange.mjs` and `benchSaveGather.mjs`. + +| Phase of a page change | median ms from click | +| --- | --- | +| `pageList/pageClicked` acked | 19 | +| `editView/pageContent` complete (old page saved, navigation kicked off) | 167 | +| new page's DOM loaded | 748 | +| new page **editable** | 793 | + +And separately: **gathering the page content in the browser takes 0.7 ms** (median of 15, on 25 KB +of HTML), while a *complete* direct save — `savePageWithoutReloading()`, i.e. gather + POST + merge ++ write to disk + reply — takes **92 ms**. + +So the round trip's whole purpose is to fetch something that costs 0.7 ms to produce. Its window +(19→167 ms) is at most ~56 ms more than doing the same save directly, and even that overstates it, +because the `editView/pageContent` handler also kicks off the navigation before it replies. The +genuinely removable part is the C#→WebView2 dispatch and scheduling: **30–50 ms out of ~790, i.e. +4–6%**. Roughly 80% of a page change is building and setting up the NEW page, which none of this +touches; deleting the save phase entirely would still cap the win at ~21%. The overhead is also +roughly constant while the disk write and page setup grow with the book, so it gets relatively +smaller on real books, not larger. + +The reason to make these changes is the simplification below — fewer hops, fewer states, fewer +things that can interleave — with a small speed bonus, not the other way round. + +Note the existing `PerformanceMeasurement.Measure("Select Page")` in `HandlePageClickedRequest` +cannot answer this: it wraps only the *initiation* (`SaveThen` returns as soon as the browser has +been asked), and it ignores nested measurements, so it cannot be subdivided either. + +## 2. The delay register — now the one gate, not a `requestPageContent` detail + +`addRequestPageContentDelay` / `removeRequestPageContentDelay` / +`wrapWithRequestPageContentDelay` exist because **C# picks the moment to capture the page**, so any +asynchronous DOM work in flight has to register itself and hold the capture off — with a 4-second +cap after which we capture anyway and warn. There are ~10 call sites (image sizing, canvas +background image fitting, clipboard paste, custom xmatter pages, the image gallery dialog…), plus a +rule in `src/BloomBrowserUI/AGENTS.md` telling reviewers to check for it. + +None of that can go while C# still initiates saves. What has changed is that a +**browser**-initiated save is just as capable of catching the page mid-change, and the first +version of this work did exactly that: `collectCurrentPageContent()` gathered synchronously, +straight past the register. A page click landing while an image was still being sized would have +written the half-sized page into the book. + +So the register moved out of `bloomEditing.ts` into its own module, +`bookEdit/js/pageContentDelays.ts`, and gained `whenNoActiveDelays()` — the single gate that +**every** route now waits on: + +| Route | Used by | +| --- | --- | +| `requestPageContent()` | the C#-initiated save; the reason the register exists | +| `getPageContentForSaveWhenReady()` | `savePageWithoutReloading()`, and the page list's commands via `collectCurrentPageContent()` | +| `captureContentForExternalProcessing()` | the off-screen book processor | + +The synchronous `getPageContentForSave()` is no longer exported from the module or across frames, +so there is no longer a way to gather the page without passing the gate. And because the page +list's commands await it, the *command* does not start either: C# is not asked to duplicate, +delete or reorder anything until the page has settled. `pageContentDelays.spec.ts` covers the +waiting, the release, the cap, and that a failed operation cannot leave the gate stuck shut. + +The gate also stopped polling. It used to be two mechanisms — a timeout that `requestPageContent` +armed and `removeRequestPageContentDelay` fired early, plus a separate 50ms poll loop in the +off-screen path. Now removing the last delay releases the waiters directly. + +Javascript-initiated saves could in principle just `await` their own async work instead of using +the register at all — but they cannot know about work someone *else* started, so they wait here +too. Each converted caller is still one fewer place that has to remember the rule. + +## 3. `SaveThen`'s awkward shape + +`SaveThen(doBeforeSaveToDisk, doIfNotInRightStateToSave, forceFullSave, skipSaveToDisk, +failureAction, doAfterSaveToDisk)` has six parameters, four of them callbacks, because the work has +to be chopped into pieces that run at different points of an asynchronous state machine. The +remark on it — *"if you are doing this in an API handler, remember that you must retrieve any data +in the request before calling SaveThen; the Request object can't be used inside +doBeforeSaveToDisk, since by then the request has been marked completed"* — is a direct symptom. + +There are 20 call sites. Several are pure "save, then go to this page": + +- `PageListController.cs:48` — `SaveThen(() => page.Id, () => { })` +- `EditingViewApi.cs:299` — `SaveThen(() => pageId, () => { })` +- `EditingModel.SavePageAndReloadIt` — `SaveThen(() => CurrentSelection.Id, () => { })` + +If the browser sends the page content **with** the request that needs a save, the handler no longer +has to be chopped up around an asynchronous wait. The shape the converted ones use: + +``` +// TS +postThatMightNavigate("edit/pageControls/duplicatePage", + await collectCurrentPageContent("the duplicate command")); +// C# +_editingModel.OnDuplicatePage(request.GetPageContentFromBrowserOrNull()); +// ...which ends up at SaveThen(..., pageContentFromBrowser: content) +``` + +For a handler that has no reason to navigate at all, `SavePageInPlace` is even plainer — save, +do the thing, reply — which is what removes the `doIfNotInRightStateToSave` callback (the handler +can just check the return value), the `doAfterSaveToDisk` callback, and the "don't touch the +request afterwards" hazard. + +Two of the six parameters are there for one caller each and would go away with them: +`skipSaveToDisk` (`collectionClosingEvent` and `OnTabAboutToChange`, which both want to do their own +`CurrentBook.Save()` before some postponed work) and `doAfterSaveToDisk` +(`WorkspaceView.cs:1742` and `CopyrightAndLicenseApi.cs`, which need up-to-date files on disk +before showing a blocking dialog). + +## 4. The websocket dance in the copyright dialog + +`EditingModel.NotifyCopyrightPushedToAllImages` exists, with an explanatory comment, purely because +*"we can't signal completion from the POST response itself, which returns as soon as the save is +initiated, well before the asynchronous post-save action runs."* With `editView/savePageInPlace` +the POST response **is** the completion signal, so this whole websocket event +(`kCopyrightWebSocketEventId_PushedToAllImages`, its sender, and its listener in +`CopyrightAndLicenseDialog.tsx`) can go once that flow is converted. + +## 5. State-machine surface + +If the C#-initiated save ever disappears entirely, these go with it: + +- the `SavePending` and `SavedAndStripped` states, and their `ToSavePending` / + `ToSavedAndStripped` transitions (two overloads); +- `DiscardInFlightSave()` and `_discardInFlightSave`, which exist only because a save can be in + flight for an unbounded time; +- `RequestBrowserToSave()` and the `editView/pageContent` API; +- `enableStateTransitions` — the tab strip is disabled during `SavePending`/`SavedAndStripped` + precisely because the page is unusable during them. An in-place save is synchronous; there is no + window to disable anything in. +- `NavigatingSoSuspendSaving`, and the various "a Save is still in progress, abort" guards such as + `EditingModel.cs:601`. + +That is a long way off — `OnTabAboutToChange` and `collectionClosingEvent` legitimately have to +start a save from C# — but each converted caller shrinks the surface. + +## 6. Smaller things + +- ~~**`getBodyContentForSavePage` is exported cross-frame but nothing calls it.**~~ Done: both it + and `userStylesheetContent` (whose comment still claimed it was *"Called from C# by a + RunJavaScript() in EditingView.CleanHtmlAndCopyToPageDom"*, a method that no longer exists) are + now private to `bloomEditing.ts`. +- **The off-screen book processor** (`BookProcessor.cs`) polls `window.__bloomExternalPageContent` + because there is no live `EditingModel` for the callback API. Now that content-gathering is + side-effect-free, `getPageContentForSave()` can be called directly and its value returned by + `RunJavascriptWithStringResult`, once the caller can wait for the `activeDelays` loop. (Not + urgent; the polling works.) +- **Thumbnail updates.** `SavePageInPlace` refreshes the current page's thumbnail because the + navigation that used to follow a save did it (`EditingView.StartNavigationToEditPage`). If saves + become frequent, that wants debouncing. + +--- + +## Risks to watch when converting callers + +- **niceScroll cleanup is our own code now.** `removeNiceScrollArtifacts` knows what niceScroll and + bloom-player's `addScrollbarsToPage()` leave behind rather than asking them to undo it, so a + change at either end could leave something in the saved page. `niceScrollCleanup.spec.ts` pins + the current expectations, and the module comment records where each item comes from. +- **comicaljs 0.4.1 is required**, for `Comical.exportSvgToCopiesOfParents`. Note that moving from + 0.3.106 to 0.4.x also surfaces four pre-existing type errors in `canvasElementManager/`: 0.3.106's + declarations import `from "bubbleSpec"` (a bare specifier TypeScript cannot resolve), so + `BubbleSpec` silently degraded to `any` in Bloom; 0.4.x emits correct relative imports and the real + types finally apply. They are unrelated to this work but must be fixed to pin 0.4.1. The most + interesting is `CanvasElementResizeAdjustments.ts:161`, `bubbleSpec.spec !== "none"` — `BubbleSpec` + has no `spec` member, so that comparison is always true and a Comical update is forced every time. +- **"We didn't save" and "we tried and failed" are different answers, and the difference is a + page.** `SavePageInPlaceThen` returns `InPlaceSaveOutcome`, and only `Declined` — which + guarantees `doBeforeSaveToDisk` never ran — permits falling back to asking the browser. This is + not hypothetical: the first version returned a plain bool, and when relocating a page threw part + way through, the caller read it as "not saved" and relocated the page a **second** time. + `EditingStateMachineTests` pins all three outcomes. That rule now lives in exactly one place, + inside `SaveThen`, which is the main reason `SavePageInPlaceThen` is private: no caller can get + it wrong because no caller has to know about it. +- **The action is allowed to navigate.** Under `SaveThen` it ran in `SavedAndStripped`, where + `ToNavigating` is legal; it now runs in `Editing`, where `ToNavigating` throws. Relocating a page + does navigate (`OnRelocatePage` refreshes the page whose side and number just changed), so + `_runningSaveInPlaceAction` relaxes that guard for the duration of the action — safely, because + by then the browser's content is already in the book DOM and there is nothing left to lose. Our + own navigation afterwards supersedes the action's, or is ignored when it is to the same page. +- **The context menu runs its command ~100ms after the click** (`HandleContextMenuItemClickedRequest` + defers it so the menu can close). The content we save is therefore gathered slightly *earlier* + than the old path gathered it — at click time rather than 100ms later. Nothing a user can type + into fits in that window, but it is a real difference. +- **Not blurring.** The old code blurred the active element before capturing. If any code relies on + a blur handler to normalize text before it is saved, that normalization no longer happens on save. + CKEditor's `getData()` gives us current text either way, so this is about side effects, not text. +- **`ui-audioCurrent`.** The Talking Book tool deliberately leaves its highlight class on the live + page (BL-15300), so it can reach the saved HTML; `BookData.cs:2091` already defends against that. + Unchanged by this work, but worth knowing when reading the clone-cleanup code. + +--- + +# The page snapshot: removing the round trip altogether + +Branch `BL-13502-page-snapshot`, exploratory. The idea: instead of C# asking the browser for the +page and waiting, the **browser volunteers** it. An idle task in the editing page posts the current +content whenever the page has settled after a change (`pageSnapshot.ts`); C# stores the string +(`PageSnapshot.cs`, one new API `editView/pageSnapshot`); and a save then takes it synchronously. + +## It works + +Driven against a real book: typing in a text box, then clicking another page, saves the typing to +disk and the traffic is **only `pageList/pageClicked`** — `editView/pageContent` never fires. That +is the round trip gone for the path that matters most. + +`SaveThen` now falls back to the snapshot whenever a caller did not bring content of its own, so +every caller that used to go the long way gets the short one for free. + +## Two things that are not what we hoped + +**1. A page nobody touched still posts snapshots — three of them, in the first ~6 seconds.** + +The first version posted one for *every* page opened, which would have made "no snapshot" mean +nothing at all. The cause is that loading is not finished when `bootstrap()` returns: image sizing +and canvas-element layout complete asynchronously and mutate the page, and a `MutationObserver` +cannot tell those from the user. Taking a **baseline** once the page has settled fixes most of it, +and is why `startWatchingPageForSnapshots` gathers once before it starts posting. + +Chased down, because the three turned out to be two different things. + +**Two of them were a real bug, and not one this branch introduced.** Capturing the actual bodies +showed the first and third were byte-identical and the middle one 235 characters longer; the extra +was `
`, the hidden scratch element `utils/measureText.ts` appends to the +body to measure text with. It is transient (a timer removes it) and it is not part of the page — +but the gather clones the whole body, and `removeEditingDebrisFromClone` did not strip it. **So a +save landing while it exists writes it into the book.** That window is not exotic: the div is +created while text is being fitted, i.e. while the user is typing, and a save right after typing is +the commonest save there is. Now stripped in the clone cleanup. The snapshot only found it because +it gathers far more often than a save does. + +**The third is benign, and deliberately left alone.** With that fixed, an untouched page posts +exactly one snapshot, and it is byte-identical to the settled page. The cause is that the baseline +is taken before the page has finished settling: at that moment the asynchronous fix-ups have not +registered their delays yet, so `whenNoActiveDelays()` returns at once. + +Delaying the baseline until the page is quiet would remove it, and would be a bad trade. The +baseline would then include any edit the user managed in the meantime, and because load-time +settling is indistinguishable from typing, we would have no way to know we still owed C# a snapshot +of it — swapping a harmless duplicate for a lost edit. One post per page visit, carrying exactly +what a save would have written, is the better end of that trade. + +So the residual cost is one redundant store per page visit. Nothing extra reaches the disk: C# +only writes when a save actually happens. + +## The freshness window, measured + +The debounce decides how far behind the live page C# can be, and therefore how much typing an exit +could lose. It started at 400 ms, which was picked without measuring. Measured on a real page +(26 KB of HTML): + +| | | +| --- | --- | +| One gather | **0.4 ms** median (0.2–1.6) | +| MutationObserver batches produced by ONE keystroke | **~8.9** | +| Keystroke → C# has the content, at 25 ms debounce | **~49 ms** | +| Snapshot posts while typing, at 25 ms | one per keystroke | +| Snapshot posts per visit to an untouched page, at 25 ms | 2 (was 1 at 400 ms) | + +The nine batches per keystroke are why a debounce is still wanted at all — CKEditor does a lot of +DOM work per key, and without one we would gather nine times per character. 25 ms collapses them +into a single gather. Going lower buys almost nothing: below ~25 ms the lag is dominated by the +POST, not by us. + +So the exposure is **~50 ms, not 400 ms**, and the residual risk is at most the last character — +and only if the exit arrived within 50 ms of a keystroke, which is shorter than the hand movement +that triggers an exit. The cost is one POST per keystroke rather than one per typing pause (26 KB +to localhost; C# stores the string, replacing the previous one) and one extra snapshot per page +visit, because a short debounce catches the page mid-settle as well as settled. + +### On a slower machine + +This machine is faster than many Bloom runs on, so the numbers above are the optimistic end. +Measured again under CDP CPU throttling, same page: + +| CPU | gather, median | gather, worst | snapshot posts per keystroke | +| --- | --- | --- | --- | +| 1× | 0.5 ms | 1.0 ms | 1.00 | +| 4× | 1.8 ms | 3.2 ms | 0.91 | +| 8× | 3.9 ms | 7.3 ms | 0.91 | + +The gather scales about linearly with CPU, as expected. The interesting column is the last one: +**the number of snapshots per keystroke does not grow as the machine slows — it falls slightly.** +Slower processing spreads a keystroke's DOM work out, so more of it lands inside one debounce +window, and the serialization in `takeSnapshot` (only one gather-and-post at a time) coalesces the +rest. The design degrades by taking *fewer, later* snapshots rather than by piling up. + +So at 8× slower the cost is about 0.9 × 3.9 ms ≈ 3.5 ms of main-thread work per keystroke, against +a keystroke interval of at least ~110 ms. A single gather stays inside one 16.7 ms frame even at 8×. + +Two limits worth stating rather than discovering later. The gather also scales with **page size**, +and only a 26 KB page was measured; a much heavier page (many canvas elements) costs proportionally +more, and 8× slow together with a 100 KB page would put a gather near a frame. And these are +throttled-CPU figures, not a real slow machine — throttling does not reproduce slow disk or memory +pressure. + +None of that argues for a longer debounce, which would cost every user a bigger loss window to buy +something the coalescing already provides. + +### Why that matters for exit + +`Shell.OnClosing` currently cancels the close (`e.Cancel = true`), starts a save, and calls +`Close()` again when it finishes — with `_startedClosingEvent` / `_finishedClosingEvent` guarding +the re-entry. All of that exists for one reason: the save could not complete synchronously, +because it had to ask the browser and wait. + +A save that takes the snapshot IS synchronous. That makes the whole dance unnecessary: save, then +let the close proceed. What has to be accepted in exchange is that quitting could lose the last +~50 ms of typing rather than being guaranteed fresh. + +### Would observing `.bloom-page` instead of the body be better? + +It would have hidden the `measureTextDiv` bug rather than exposing it, and it would be unsound: +the gather clones the whole `document.body`, so a change outside `.bloom-page` can still alter what +gets saved. An observer narrower than the thing being gathered can miss a real change. If the two +are ever narrowed, they must be narrowed together. + +**2. The state machine does not go away. It shrinks to one caller.** + +A snapshot is up to `kQuietMs` plus a gather behind the live page. That is fine for a page click, +which cannot happen within a few hundred milliseconds of a keystroke. It is **not** fine for the +two saves that can: + +- **leaving the Edit tab** — a tab click, possibly right after typing; +- **closing the collection** — `Shell.OnClosing`, i.e. the window's X button, Alt+F4, or the OS + shutting Bloom down. + +Both would lose the last fraction of a second of typing if they read a snapshot. So both keep +asking the browser, and therefore `SavePending`, `SavedAndStripped`, `RequestBrowserToSave` and +`editView/pageContent` all survive to serve them. + +They are excluded automatically, because both are the only callers that pass `skipSaveToDisk`. +That is convenient rather than principled — see the comment in `SaveThen`. + +Of the two, only the tab change could be converted: the tab strip lives in the workspace root, +which *can* reach the page frame (that is how the page list collects content). The window close +genuinely cannot start in Typescript — it arrives as a WinForms message, and `OnClosing` has to +cancel the close, save, and close again. It could keep one narrow round trip of its own, but that +is the state machine's waiting states surviving for a single caller rather than disappearing. + +The C#-side alternative is not available: the only synchronous way to read Javascript is +`RunJavascriptWithStringResult_Sync_Dangerous`, which pumps the message loop, and Bloom has been +deliberately retreating from it (see `OffScreenBrowser`, `PublishHelper`). + +## So: worth doing? + +The round trip disappears from every ordinary editing action, which is real. But "the state machine +goes away" is not on offer without either accepting that quitting Bloom can lose the last few +hundred milliseconds of typing, or keeping a round trip for that one path. The honest shape is +"one waiting state, one caller" rather than none. diff --git a/src/BloomExe/Edit/ToolboxView.cs b/src/BloomExe/Edit/ToolboxView.cs index 6ad4e5f388d1..965ae4a805a6 100644 --- a/src/BloomExe/Edit/ToolboxView.cs +++ b/src/BloomExe/Edit/ToolboxView.cs @@ -28,6 +28,12 @@ namespace Bloom.Edit /// ToolBox.registerTool(new MyWonderfulTool()); /// - should implement makeRootElement() to create one div, the react root. /// - the returned root should already have been passed to ReactDOM.render(). + /// - if the tool adds markup to the page for editing that should not be saved into the book, + /// implement removeToolMarkup(pageOrClone). That one method is used BOTH to clean the copy + /// we save (on every save, while the user keeps editing) and to clean the live page when + /// it goes away, so it must be pure DOM surgery inside the element it is handed. Put + /// live-only teardown in detachFromPage(), which must then call super.detachFromPage(). + /// See the comments on ITool in toolbox.ts. /// - Make a new xlf entry with ID EditTab.Toolbox.{UCToolId}.Heading, /// where UCToolId is the capitalized version of your tool Id, e.g., "Music". /// We currently assume the default English value of this will be UCToolId Tool, e.g., "Music Tool" diff --git a/src/BloomExe/Event.cs b/src/BloomExe/Event.cs index d550130de0c9..5cc636cdeb48 100644 --- a/src/BloomExe/Event.cs +++ b/src/BloomExe/Event.cs @@ -90,32 +90,22 @@ public class TabChangedDetails public WorkspaceTab? FromTab; public WorkspaceTab? ToTab; - // The two ways a subscriber can hand the tab change back to us. Exactly one of them is - // used, according to what the subscriber is able to do: + // How a subscriber hands the tab change back to us: it calls CompleteTheChange, either + // before returning if it had nothing to do first, or once it has saved. // - // nothing to do first -> CompleteTheChange, before returning - // I must save first -> CompleteTheChange, once my save has finished - // a save is already running -> StartTheChangeOver, once that save has finished + // There used to be a second action, StartTheChangeOver, for a subscriber that could neither + // proceed nor finish because it was waiting on a save begun by something else -- an earlier + // click on a tab whose save was still out with the browser (BL-16766). Saving no longer + // waits for anything, so a subscriber is never in that position, and the case is gone. // - // The last case is the one that needs the second action: the subscriber can neither let - // the change proceed nor take responsibility for finishing it, because the save it is - // waiting on belongs to something else (typically an earlier click on a tab). See BL-16766. - // - // This is a bit of a kludge. It works partly because there is currently only one subscriber, - // so there is no ambiguity about who should do this, or how we know when all the subscribers - // are done. If we ever have more than one subscriber, we'll need to do something more sophisticated. + // This works partly because there is currently only one subscriber, so there is no ambiguity + // about who should call it, or about how we know all the subscribers are done. If we ever + // have more than one, we'll need something more sophisticated. // Actually switches the tab: everything WorkspaceView.ChangeTab held back until a // subscriber said it was safe. Call this EXACTLY ONCE — it raises the tab-changed event // and records the new tab as current. public Action CompleteTheChange; - - // Abandons this attempt and asks for the whole tab change to be made afresh later, from the - // top of WorkspaceView.ChangeTab. Because it starts over, it re-checks everything, so it is - // safe to call whenever the way is clear — including when it turns out to be unnecessary, - // in which case it does nothing at all because the tab we wanted is already current. - // Null if the raiser has nothing to redo. - public Action StartTheChangeOver; } /// @@ -142,27 +132,15 @@ public CreateFromSourceBookCommand() : base("CreateFromSourceBookCommand", LoggingLevel.Major) { } } - public class CollectionClosingArgs - { - // May be executed by one subscriber when it is safe to do so, typically in response to an event - // after returning from the event handler. A subscriber that wants to do this should set Delayed to true. - // This is a bit of a kludge. It works partly because there is currently only one subscriber - // that needs to postpone work until after the event handler has returned, - // so there is no ambiguity about who should do this. The tricky thing was to make sure - // all subscribers get to do their thing when we don't need to delay (when not in Edit tab). - // Hence Delayed and the override of Raise. - public Action PostponedWork; - - // If a subscriber sets this to true, it takes responsibility for calling PostponedWork - // at some later time. If no one does, Raise() will call it after all subscribers have been called. - // At most one subscriber should set this to true. Review: should we enforce this? - public bool Delayed; - - // If something goes wrong (typically, we failed to save the current page due to a bug), this should be called. - // Typically, postponed work will not happen, so the collection closing will also not happen. - // We are currently using this to reset a flag in Shell.OnClosing so we can try again. - public Action FailureAction; - } + /// + /// Nothing to say beyond "the collection is closing". This used to carry a PostponedWork / + /// Delayed / FailureAction protocol so that a subscriber could say "I will finish this later, + /// you carry on" — its own comment called it a bit of a kludge — and it existed for exactly + /// one subscriber, EditingModel, because saving the page being edited meant asking the browser + /// and waiting. That save is synchronous now (see PageSnapshot), so subscribers simply do their + /// work and return. + /// + public class CollectionClosingArgs { } /// /// called when the user is quiting or changing to another collection @@ -171,15 +149,6 @@ public class CollectionClosing : Event { public CollectionClosing() : base("CollectionClosing", LoggingLevel.Major) { } - - public override void Raise(CollectionClosingArgs descriptor) - { - base.Raise(descriptor); - if (!descriptor.Delayed) - { - descriptor.PostponedWork?.Invoke(); - } - } } public class EditBookCommand : Event diff --git a/src/BloomExe/Shell.cs b/src/BloomExe/Shell.cs index fed313bf7b55..7e159d89736a 100644 --- a/src/BloomExe/Shell.cs +++ b/src/BloomExe/Shell.cs @@ -196,101 +196,70 @@ private void NotifyDpiChanged() _workspaceView.Invalidate(true); } - public bool AppIsShuttingDown => _startedClosingEvent || _finishedClosingEvent; + public bool AppIsShuttingDown => _closing; - private bool _startedClosingEvent; - private bool _finishedClosingEvent; + private bool _closing; protected override void OnClosing(CancelEventArgs e) { - // We want to get everything saved (under the old collection name, if we are changing the name and restarting). - // This is tricky because we may need to save current changes to a book we are editing, and this - // involves an inherently asynchronous process (thanks to WebView2). We tried endless ways to - // wait for the data we need from the page we're editing, and nothing worked reliably. - // If we go ahead and close the Shell, the message we eventually get on our API with the data to save - // tries to use Invoke on the Shell to get on the UI thread, but the Shell is already disposed. - // So, the first time OnClosing is called, we raise an event that will do the saving, and cancel - // the close. In case the user manages to click the Close button again before the saving is done, - // we set a flag to say it is in progress, so that we can ignore any subsequent OnClosing events - // until we are done saving. When we ARE done saving, we set a flag to say so, and then call Close() - // to actually get the window closed. - if (_finishedClosingEvent) - { - base.OnClosing(e); - return; - } - - if (_startedClosingEvent) - { - e.Cancel = true; - return; - } - + // Everything here is synchronous, which it did not used to be. Saving the page being + // edited meant asking the browser for it and waiting for the answer on another API + // call, and that could not be done from inside OnClosing: if we let the close proceed, + // the reply arrived to a disposed Shell. So this cancelled the close, kicked off the + // save, and called Close() again when it finished -- with two flags to swallow the + // clicks the user got in meanwhile, and a FailureAction to unstick things when the save + // failed and left Bloom unclosable. + // + // None of that is needed now: the browser volunteers the page as it is edited (see + // PageSnapshot), so EditingModel already has what it needs and the closing event + // returns with the book on disk. + _closing = true; Logger.WriteMinorEvent("starting to shut Bloom down"); - _startedClosingEvent = true; + _collectionClosingEvent.Raise(new CollectionClosingArgs()); - _collectionClosingEvent.Raise( - new CollectionClosingArgs() + if ( + !string.IsNullOrEmpty(_nameToChangeCollectionUponClosing) + && _nameToChangeCollectionUponClosing != _collectionSettings.CollectionName + && UserWantsToOpeReopenProject + ) + { + // Without checking and resetting this flag, Linux endlessly spawns new instances. Apparently the Mono runtime + // calls OnClosing again as a result of calling Program.RestartBloom() which calls Application..Exit(). + UserWantsToOpeReopenProject = false; + //Actually restart Bloom with a parameter requesting this name change. It's way more likely to succeed + //when this run isn't holding onto anything. + try { - PostponedWork = () => - { - if ( - !string.IsNullOrEmpty(_nameToChangeCollectionUponClosing) - && _nameToChangeCollectionUponClosing - != _collectionSettings.CollectionName - && UserWantsToOpeReopenProject + var existingDirectoryPath = Path.GetDirectoryName( + _collectionSettings.SettingsFilePath + ); + var parentDirectory = Path.GetDirectoryName(existingDirectoryPath); + var newDirectoryPath = Path.Combine( + parentDirectory, + _nameToChangeCollectionUponClosing + ); + + Program.RestartBloom( + true, + string.Format( + "--rename \"{0}\" \"{1}\" ", + existingDirectoryPath, + newDirectoryPath ) - { - // Without checking and resetting this flag, Linux endlessly spawns new instances. Apparently the Mono runtime - // calls OnClosing again as a result of calling Program.RestartBloom() which calls Application..Exit(). - UserWantsToOpeReopenProject = false; - //Actually restart Bloom with a parameter requesting this name change. It's way more likely to succeed - //when this run isn't holding onto anything. - try - { - var existingDirectoryPath = Path.GetDirectoryName( - _collectionSettings.SettingsFilePath - ); - var parentDirectory = Path.GetDirectoryName(existingDirectoryPath); - var newDirectoryPath = Path.Combine( - parentDirectory, - _nameToChangeCollectionUponClosing - ); - - Program.RestartBloom( - true, - string.Format( - "--rename \"{0}\" \"{1}\" ", - existingDirectoryPath, - newDirectoryPath - ) - ); - } - catch (Exception error) - { - SIL.Reporting.ErrorReport.NotifyUserOfProblem( - error, - "Sorry, Bloom failed to even prepare for the rename of the project to '{0}'", - _nameToChangeCollectionUponClosing - ); - } - } - - _finishedClosingEvent = true; - Logger.WriteMinorEvent("closing the Shell"); - Close(); - }, - FailureAction = () => - { - // We didn't want a second attempt at saving if the user clicks the close box while we are - // still trying to save after the first click on Close. But if the first attempt fails, - // we don't want to stay in a state where all attempts to close the program are ignored. - _startedClosingEvent = false; - }, + ); } - ); - e.Cancel = true; + catch (Exception error) + { + SIL.Reporting.ErrorReport.NotifyUserOfProblem( + error, + "Sorry, Bloom failed to even prepare for the rename of the project to '{0}'", + _nameToChangeCollectionUponClosing + ); + } + } + + Logger.WriteMinorEvent("closing the Shell"); base.OnClosing(e); } diff --git a/src/BloomExe/Workspace/WorkspaceView.cs b/src/BloomExe/Workspace/WorkspaceView.cs index 8707ea3666c2..de16026990a6 100644 --- a/src/BloomExe/Workspace/WorkspaceView.cs +++ b/src/BloomExe/Workspace/WorkspaceView.cs @@ -1576,10 +1576,6 @@ private void ChangeTab(IBloomTabArea view) } // TODO-WV2: Can we clear the cache in WV2? Do we need to? }, - // Starting over means re-running this whole method, so the "already on the - // desired tab" check at the top makes it a no-op if some other path has - // meanwhile switched to the tab we wanted. See BL-16766. - StartTheChangeOver = () => ChangeTab(view), } ); } @@ -1840,19 +1836,13 @@ private void StartProblemReport(object sender, EventArgs e) { if (InEditMode) { - _editingView.Model.SaveThen( - () => _editingView.Model.CurrentPage.Id, - ReportAndLogProblem, // wrong state: show dialog without saving - doAfterSaveToDisk: () => - { - ReportAndLogProblem(); - } - ); - } - else - { - ReportAndLogProblem(); + // Get the latest edits into the report. Synchronous now (see PageSnapshot), so + // this is just "save, then show the dialog" -- it used to need the dialog + // packaged as an action to run whenever the save eventually finished, and a + // second copy of it for the case where no save could be started. + _editingView.Model.SaveCurrentPageAndBook(); } + ReportAndLogProblem(); } catch { diff --git a/src/BloomExe/web/PageListApi.cs b/src/BloomExe/web/PageListApi.cs index 043ec190579b..ccfaec5812c5 100644 --- a/src/BloomExe/web/PageListApi.cs +++ b/src/BloomExe/web/PageListApi.cs @@ -105,19 +105,30 @@ private void HandlePageClickedRequest(ApiRequest request) { var requestData = DynamicJson.Parse(request.RequiredPostJson()); string pageId = requestData.pageId; + // The page list sends the current page's content with the click when it can, so we can + // save it without asking the browser and waiting. It is absent when there is no page + // to collect from, or collecting threw; then we fall back to asking (see + // PageListController.OnPageSelectedChanged). + string pageContent = requestData.IsDefined("pageContent") + ? requestData.pageContent + : null; var shiftIsDown = (Control.ModifierKeys & Keys.Shift) == Keys.Shift; var label = shiftIsDown ? "Select Page (SHIFT)" : "Select Page"; - using (PerformanceMeasurement.Global?.Measure(label, requestData.detail ?? "")) + // Note this only measures getting the change under way; with the content in hand that + // is now most of the work, but the new page still has to be built and displayed. + using ( + PerformanceMeasurement.Global?.Measure( + label, + requestData.IsDefined("detail") ? requestData.detail : "" + ) + ) { - //using (PerformanceMeasurement.Global.Measure(label, requestData.detail ?? "")) - //{ IPage page = PageFromId(pageId); - //} if (page != null) - PageList.PageClicked(page); + PageList.PageClicked(page, pageContent); } request.PostSucceeded(); @@ -139,15 +150,36 @@ private void HandleContextMenuItemClickedRequest(ApiRequest request) var requestData = DynamicJson.Parse(request.RequiredPostJson()); string pageId = requestData.pageId; string commandId = requestData.commandId; + // See HandlePageClickedRequest: sent when the page list could collect it, so that the + // commands which save the current page first need not ask the browser and wait. + string pageContent = requestData.IsDefined("pageContent") + ? requestData.pageContent + : null; IPage page = PageFromId(pageId); if (page != null) { - // Execute the command asynchronously after a short delay - // The discard operator _ indicates we're intentionally not awaiting this + // The command must not run inline: "Duplicate Many Times" and "Choose Different + // Layout" open MODAL dialogs whose content this same server has to serve, and this + // handler holds the API sync lock until it returns. Running them here would + // deadlock. + // + // The short delay before queueing is deliberate, and is the easy thing to remove by + // mistake. Returning from this handler is not enough on its own: the server thread + // releases the sync lock a moment AFTER we return, while the UI thread is already + // free to pump whatever we queued -- so a dialog could ask for its content while + // the lock is still held. The delay makes that ordering certain rather than merely + // likely. (Removing it during BL-13502 is what brought this to light; the reason + // had never been written down.) + // + // The cost is a small window in which typing would miss the page snapshot that + // came with this request. That is a trade made knowingly: a lost keystroke is + // recoverable, a hung Bloom is not. + // + // The discard operator _ indicates we're intentionally not awaiting this. _ = Task.Run(async () => { - await Task.Delay(100); // 100ms delay to let the UI respond + await Task.Delay(100); // Execute on the UI thread using the form's synchronization context var form = Shell.GetShellOrOtherOpenForm(); @@ -158,7 +190,11 @@ private void HandleContextMenuItemClickedRequest(ApiRequest request) { try { - PageList.ExecuteContextMenuCommand(page, commandId); + PageList.ExecuteContextMenuCommand( + page, + commandId, + pageContent + ); } catch (Exception ex) { @@ -174,7 +210,6 @@ private void HandleContextMenuItemClickedRequest(ApiRequest request) }); } - // Return success immediately without waiting for the command to execute request.PostSucceeded(); } @@ -184,7 +219,11 @@ private void HandlePageMovedRequest(ApiRequest request) string newPageId = requestData.movedPageId; IPage movedPage = PageFromId(newPageId); int newIndex = Convert.ToInt32(requestData.newIndex); // Should come as int, but automatic JSON parsing doesn't know this - PageList.PageMoved(movedPage, newIndex); + // See HandlePageClickedRequest. + string pageContent = requestData.IsDefined("pageContent") + ? requestData.pageContent + : null; + PageList.PageMoved(movedPage, newIndex, pageContent); request.PostSucceeded(); } diff --git a/src/BloomExe/web/controllers/AddOrChangePageApi.cs b/src/BloomExe/web/controllers/AddOrChangePageApi.cs index 2d80fc77378d..56619db3d741 100644 --- a/src/BloomExe/web/controllers/AddOrChangePageApi.cs +++ b/src/BloomExe/web/controllers/AddOrChangePageApi.cs @@ -84,7 +84,7 @@ private void HandleChangeLayout(ApiRequest request) if (templatePage == null) return; var pageId = _pageSelection.CurrentSelection.Id; - _editingModel.SaveThen( + _editingModel.MergeCurrentPageThenSave( () => { CopyVideoPlaceHolderIfNeeded(templatePage); @@ -103,8 +103,7 @@ private void HandleChangeLayout(ApiRequest request) ); return pageId; - }, - () => { } // wrong state, do nothing + } ); request.PostSucceeded(); } diff --git a/src/BloomExe/web/controllers/AiImageEditorApi.cs b/src/BloomExe/web/controllers/AiImageEditorApi.cs index bb5866435df6..28dff974dbed 100644 --- a/src/BloomExe/web/controllers/AiImageEditorApi.cs +++ b/src/BloomExe/web/controllers/AiImageEditorApi.cs @@ -376,21 +376,21 @@ private void HandleSaveThenLaunch(ApiRequest request) OpenEditorInBrowser(payload); }); - model.SaveThen( - () => pageId, - doIfNotInRightStateToSave: () => - { - // No save was attempted and nothing failed. Every state that refuses one — a - // save already in flight, mid-navigation, saved-and-stripped — is on its way to - // a page load, and by then that other save will have brought the DOM up to date. - bookDomIsSound = true; - }, - // Reached only when the save actually got to disk. Checking it this way, rather - // than cancelling from failureAction, covers more: failureAction is not called when - // _saveBook() itself throws, which is precisely the disk-full case, nor on the - // deliberate _discardInFlightSave path. - doAfterSaveToDisk: () => bookDomIsSound = true - ); + // Saving is synchronous now (see PageSnapshot), so "did the book actually reach disk?" + // is simply "did this return without throwing" -- which is what we need to know before + // reading image sources back out of the file. A refusal to save (mid-navigation, say) + // is not a problem: those states are on their way to a page load which brings the DOM + // up to date anyway. Only an actual failure, such as the disk being full, is. + try + { + model.SaveCurrentPageAndBook(); + bookDomIsSound = true; + } + catch (Exception) + { + bookDomIsSound = false; + throw; + } request.PostSucceeded(); } diff --git a/src/BloomExe/web/controllers/ApiRequest.cs b/src/BloomExe/web/controllers/ApiRequest.cs index f93efc788ec8..350edc1193c1 100644 --- a/src/BloomExe/web/controllers/ApiRequest.cs +++ b/src/BloomExe/web/controllers/ApiRequest.cs @@ -521,6 +521,20 @@ public string GetPostStringOrNull(bool unescape = true) return _requestInfo.GetPostString(unescape); } + /// + /// The current page's content, for a request whose whole body is that content because the + /// browser sent it along so we can save the page without asking for it and waiting (see + /// getPageContentForSaveWhenReady() in bloomEditing.ts and EditingModel.SavePageInPlaceThen). + /// Null if it was not sent, in which case the handler must fall back to SaveThen. + /// + /// Deliberately not unescaped: this is page HTML, and unescaping it would corrupt it. + /// + public string GetPageContentFromBrowserOrNull() + { + var content = GetPostStringOrNull(unescape: false); + return string.IsNullOrEmpty(content) ? null : content; + } + /// /// Get an enum value of type T that was passed as application/json /// diff --git a/src/BloomExe/web/controllers/CopyrightAndLicenseApi.cs b/src/BloomExe/web/controllers/CopyrightAndLicenseApi.cs index 740bcd854dc6..9d0e9cad90bb 100644 --- a/src/BloomExe/web/controllers/CopyrightAndLicenseApi.cs +++ b/src/BloomExe/web/controllers/CopyrightAndLicenseApi.cs @@ -187,7 +187,7 @@ private void HandleImageCopyrightAndLicense(ApiRequest request) // The dialog's "Copy to all other images in the book" button sets this. // (We no longer pop up a question asking whether to copy to all images.) bool applyToAllImages = request.GetParamOrNull("applyToAllImages") == "true"; - View.Model.SaveThen( + View.Model.MergeCurrentPageThenSave( () => { // Saved DOM must be up to date with possibly new imageUrl try diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index 0935efc49de0..f687633d91f3 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -42,16 +42,50 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) HandleSaveToolboxSetting, true ); + // (editView/pageContent used to live here: the browser's answer to a save that C# had + // started. Nothing asks any more -- the browser volunteers the page as it is edited, + // via editView/pageSnapshot below.) + // Save the current page from content the browser gathered on its own initiative, without + // reloading the page. Unlike editView/pageContent (which is the browser answering a save + // that C# started, and always ends in a navigation), this lets Javascript save whenever it + // needs the book on disk to be current and then simply carry on editing the same page. + // The reply is not sent until the save is finished, so Javascript can await it. + // + // It answers whether the save actually happened. It can decline -- the user may have + // started changing pages, or an external process may have replaced the book on disk -- + // and a caller that carries on regardless would be working from a file that does not + // say what it thinks it says. That is not hypothetical: the AI Image Editor saves so + // that the file matches the page it is about to read image sources from. apiHandler.RegisterEndpointHandler( - "editView/pageContent", + "editView/savePageInPlace", request => { var pageContentData = request.RequiredPostString(unescape: false); - View.Model.ReceivePageContent(pageContentData); + request.ReplyWithBoolean(View.Model.SavePageInPlace(pageContentData)); + }, + true, // updates the book DOM, writes files, and refreshes the page list: UI thread + true + ); + // The browser volunteering the current content of the page it is editing, so that a + // later save does not have to ask for it and wait. All this does is remember the + // string; see PageSnapshot for what it is for and why "no snapshot" means "nothing to + // save" rather than "go and ask". + // + // Deliberately NOT on the UI thread and NOT synchronized: it only stores a string (the + // store does its own locking), and an idle task reporting what the editor contains has + // no business queueing behind a save, or blocking one. Making it wait would reintroduce + // in one place exactly the coupling this removes everywhere else. + apiHandler.RegisterEndpointHandler( + "editView/pageSnapshot", + request => + { + var pageId = request.RequiredParam("pageId"); + var pageContentData = request.RequiredPostString(unescape: false); + View.Model.ReceivePageSnapshot(pageId, pageContentData); request.PostSucceeded(); }, - true, - true // review. + false, + false ); apiHandler.RegisterEndpointHandler("editView/setTopic", HandleSetTopic, true); apiHandler.RegisterEndpointHandler( @@ -266,7 +300,7 @@ private void HandleSetCustomPageLayout(ApiRequest request) } request.ReplyWithText("true"); - View.Model.SaveThen( + View.Model.MergeCurrentPageThenSave( () => { if (switchingToCustom) @@ -315,8 +349,7 @@ private void HandleSetCustomPageLayout(ApiRequest request) } return pageId; - }, - () => { } + } ); } @@ -324,7 +357,7 @@ private void HandleJumpToPage(ApiRequest request) { var pageId = request.GetPostStringOrNull(); request.PostSucceeded(); - View.Model.SaveThen(() => pageId, () => { }); + View.Model.MergeCurrentPageThenSave(() => pageId, () => { }); } /// diff --git a/src/BloomExe/web/controllers/SignLanguageApi.cs b/src/BloomExe/web/controllers/SignLanguageApi.cs index af66f6411e38..2a9f7d436e23 100644 --- a/src/BloomExe/web/controllers/SignLanguageApi.cs +++ b/src/BloomExe/web/controllers/SignLanguageApi.cs @@ -1043,7 +1043,7 @@ public void CheckForChangedVideoOnActivate(object sender, EventArgs eventArgs) // We might modify the current page, but the user may also have modified it // without doing anything to cause a Save before the deactivate. So save their // changes before we go to work on it. - Model.SaveThen( + Model.MergeCurrentPageThenSave( () => { foreach (var videoPath in filesModifiedSinceDeactivate) @@ -1079,8 +1079,7 @@ public void CheckForChangedVideoOnActivate(object sender, EventArgs eventArgs) // Likewise, this is probably overkill, but it's a probably-rare case. View.UpdateAllThumbnails(); return _pageSelection.CurrentSelection.Id; - }, - () => { } // wrong state, do nothing + } ); } diff --git a/src/BloomTests/Book/BookTests.cs b/src/BloomTests/Book/BookTests.cs index 1b8a7f681634..4989db80f8b1 100644 --- a/src/BloomTests/Book/BookTests.cs +++ b/src/BloomTests/Book/BookTests.cs @@ -789,10 +789,119 @@ public void SavePage_ChangeMade_StorageToldToSave() { var book = CreateBook(); var dom = book.GetEditableHtmlDomForPage(book.GetPages().First()); + // This test used to hand the page back exactly as it came and still expect a write. + // Since BL-13502 a save that would not change the book does not happen at all, so it + // has to make the change its name always claimed it made. + var textarea = + dom.SelectSingleNodeHonoringDefaultNS("//textarea[@id='1']") as SafeXmlElement; + Assert.That(textarea, Is.Not.Null, "test setup: expected the first page's textarea"); + Assert.That( + textarea.InnerText, + Is.EqualTo("tree"), + "test setup: expected the unedited value" + ); + textarea.InnerText = "changed by the test"; + book.SavePage(dom); + _storage.Verify(s => s.Save(), Times.AtLeastOnce()); } + [Test] + public void SavePage_SecondSaveWithNothingChanged_StorageNotToldToSave() + { + // The user-visible rule (BL-13502): opening a page and changing nothing must not write + // the book. The FIRST save is allowed to write, because opening a page can legitimately + // normalise it -- filling in editables for the collection's languages, say. What must + // not happen is that it keeps needing to be saved every time afterwards. + var book = CreateBook(); + + book.SavePage(book.GetEditableHtmlDomForPage(book.GetPages().First())); + Assert.That( + _storage.Invocations.Any(i => i.Method.Name == nameof(IBookStorage.Save)), + Is.True, + "test setup: expected the first, normalising save to write; if it did not, the " + + "second save proving nothing about being skipped" + ); + _storage.Invocations.Clear(); + + book.SavePage(book.GetEditableHtmlDomForPage(book.GetPages().First())); + + _storage.Verify(s => s.Save(), Times.Never()); + } + + [Test] + public void UpdateDomFromEditedPage_PageOpenedAgainAndNotEdited_ReportsNothingChanged() + { + // The invariant the whole "no snapshot means no unsaved changes" idea rests on: our own + // save processing has to be a FIXED POINT on content that has already been through it. + // If ProcessPageAfterEditing (or SetImageAltAttrsFromDescriptions, or the user-style + // handling) altered already-saved content even slightly, every page would report a + // change every time it was opened, however well the browser behaved, and a book would + // rewrite itself forever just from being looked at. See BL-13502. + // + // The first pass is allowed to change things -- that is a page being brought up to + // date. It is the second that has to be quiet. + var book = CreateBook(); + var pageCount = book.GetPages().Count(); + + for (var index = 0; index < pageCount; index++) + { + // GetEditableHtmlDomForPage is what the browser is handed; giving it straight back + // stands for a user who opened the page and touched nothing. + var firstPage = book.GetPages().ElementAt(index); + book.UpdateDomFromEditedPage( + book.GetEditableHtmlDomForPage(firstPage), + out _, + needToDoFullSave: false, + out _ + ); + + var secondPage = book.GetPages().ElementAt(index); + book.UpdateDomFromEditedPage( + book.GetEditableHtmlDomForPage(secondPage), + out _, + needToDoFullSave: false, + out var changedOnSecondVisit + ); + + Assert.That( + changedOnSecondVisit, + Is.False, + $"Page {secondPage.Id} reported a change on being opened a second time and not " + + "edited. Our processing of it is not stable, so it would re-save itself " + + "every time anyone looked at it." + ); + } + } + + [Test] + public void UpdateDomFromEditedPage_PageActuallyEdited_ReportsChanged() + { + // Guards the test above: if anythingChanged were simply always false, it would pass and + // mean nothing. + var book = CreateBook(); + var page = book.GetPages().First(); + + // Settle the page first, so what we measure is our edit and not the tidy-up. + book.UpdateDomFromEditedPage( + book.GetEditableHtmlDomForPage(page), + out _, + needToDoFullSave: false, + out _ + ); + + var dom = book.GetEditableHtmlDomForPage(book.GetPages().First()); + var textarea = + dom.SelectSingleNodeHonoringDefaultNS("//textarea[@id='1']") as SafeXmlElement; + Assert.That(textarea, Is.Not.Null, "test setup: expected the first page's textarea"); + textarea.InnerText = "changed by the test"; + + book.UpdateDomFromEditedPage(dom, out _, needToDoFullSave: false, out var changed); + + Assert.That(changed, Is.True); + } + [Test] public void SavePage_ChangeMadeToSrcOfImg_StorageUpdated() { diff --git a/src/BloomTests/Edit/EditingStateMachineTests.cs b/src/BloomTests/Edit/EditingStateMachineTests.cs index f6464ced70f0..b042db7ef73f 100644 --- a/src/BloomTests/Edit/EditingStateMachineTests.cs +++ b/src/BloomTests/Edit/EditingStateMachineTests.cs @@ -1,280 +1,589 @@ using System; using System.Collections.Generic; +using Bloom.Edit; using NUnit.Framework; namespace BloomTests.Edit { /// - /// Tests of EditingStateMachine, the class that decides which editing transitions are legal. - /// These drive it through its public API with recording stubs for the six actions it needs, - /// the same way EditingModel wires it up in production. + /// Tests for EditingStateMachine.ToSavedInPlace, the transition that saves the current page + /// from content the browser gathered on its own initiative and stays in Editing (no stripped + /// page to recover from, so no navigation afterwards). See EditingModel.SavePageInPlace. /// [TestFixture] public class EditingStateMachineTests { - private EditingStateMachine _machine; - private List _actions; - private string _navigatedTo; - private string _saveRequestedFor; + private List _navigatedTo; + private List _updatedWith; + private int _saveBookCount; + private List _reportedFailures; + private EditingStateMachine _stateMachine; [SetUp] public void Setup() { - _actions = new List(); - _navigatedTo = null; - _saveRequestedFor = null; - _machine = new EditingStateMachine( - navigate: pageId => - { - _navigatedTo = pageId; - _actions.Add("navigate:" + pageId); - }, - requestPageSave: pageId => - { - _saveRequestedFor = pageId; - _actions.Add("requestPageSave:" + pageId); - }, - updateBookWithPageContents: (pageId, content) => - _actions.Add("updateBook:" + pageId), - saveBook: () => _actions.Add("saveBook"), - hidePage: () => _actions.Add("hidePage"), - enableStateTransitions: enabled => _actions.Add("enableTransitions:" + enabled) + _navigatedTo = new List(); + _updatedWith = new List(); + _saveBookCount = 0; + _reportedFailures = new List(); + _stateMachine = new EditingStateMachine( + navigate: pageId => _navigatedTo.Add(pageId), + updateBookWithPageContents: (_, data) => _updatedWith.Add(data), + saveBook: () => _saveBookCount++, + hidePage: () => { } + ); + } + + private void GoToEditing(string pageId) + { + Assert.That( + _stateMachine.ToNavigating(pageId), + Is.True, + "test setup: should be able to start navigating" + ); + Assert.That( + _stateMachine.ToEditing(pageId), + Is.True, + "test setup: should be able to get to Editing" + ); + } + + private bool SaveInPlace(string content) + { + return _stateMachine.ToSavedInPlace(content, e => _reportedFailures.Add(e)); + } + + private InPlaceSaveOutcome SaveInPlaceThenGoTo(string content, string pageId) + { + return SaveInPlaceThenDoAndGoTo(content, () => pageId); + } + + private InPlaceSaveOutcome SaveInPlaceThenDoAndGoTo( + string content, + Func doBeforeSaveToDisk + ) + { + return _stateMachine.ToSavedInPlaceThenNavigating( + content, + doBeforeSaveToDisk, + e => _reportedFailures.Add(e) + ); + } + + [Test] + public void ToSavedInPlace_WhileEditing_UpdatesDomAndSavesWithoutNavigating() + { + GoToEditing("page1"); + _navigatedTo.Clear(); // the navigation that got us here is not what we're testing + + Assert.That(SaveInPlace("bodycss"), Is.True); + + Assert.That(_updatedWith, Is.EqualTo(new[] { "bodycss" })); + Assert.That(_saveBookCount, Is.EqualTo(1)); + Assert.That(_navigatedTo, Is.Empty, "an in-place save must not navigate"); + Assert.That(_reportedFailures, Is.Empty); + } + + [Test] + public void ToSavedInPlace_Twice_BothSaveBecauseWeStayInEditing() + { + GoToEditing("page1"); + + Assert.That(SaveInPlace("first"), Is.True); + Assert.That( + SaveInPlace("second"), + Is.True, + "the first in-place save should have left us in Editing" ); + + Assert.That(_updatedWith, Is.EqualTo(new[] { "first", "second" })); + Assert.That(_saveBookCount, Is.EqualTo(2)); } - /// - /// Get to the state a user is in while editing a page: navigation finished, no save yet. - /// - private void GetToEditing(string pageId) + [Test] + public void ToSavedInPlace_WhileNavigating_DoesNothing() { - Assert.That(_machine.ToNavigating(pageId), Is.True, "test setup: should navigate"); + Assert.That(_stateMachine.ToNavigating("page1"), Is.True); + + Assert.That(SaveInPlace("bodycss"), Is.False); + + Assert.That(_updatedWith, Is.Empty); + Assert.That(_saveBookCount, Is.EqualTo(0)); + Assert.That(_reportedFailures, Is.Empty, "not being ready to save is not a failure"); + } + + [Test] + public void ToSavedInPlace_BrowserReportedError_ReportsAndSavesNothing() + { + GoToEditing("page1"); + _navigatedTo.Clear(); + + Assert.That(SaveInPlace("ERROR: something went wrong in the browser"), Is.False); + + Assert.That(_updatedWith, Is.Empty, "we must not put an error message in the book"); + Assert.That(_saveBookCount, Is.EqualTo(0)); + Assert.That(_reportedFailures.Count, Is.EqualTo(1)); Assert.That( _navigatedTo, - Is.EqualTo(pageId), - "test setup: navigation should have been started" + Is.Empty, + "we are still in Editing with a good page, so there is nothing to recover from" ); - Assert.That(_machine.ToEditing(pageId), Is.True, "test setup: should reach Editing"); - Assert.That(_machine.SavePending, Is.False, "test setup: no save in flight yet"); } - /// - /// Start the save that leaving the Edit tab does: it returns null from - /// doBeforeSaveToDisk, meaning "don't navigate to another page, we're leaving". - /// - private bool StartSaveForLeavingEditTab(Action postponedWork) + [Test] + public void ToSavedInPlace_RepeatedFailureOnSamePage_ReportsOnlyOnce() { - return _machine.ToSavePending( - () => - { - postponedWork?.Invoke(); - return null; // leaving this tab, show blank page - }, - saveActionHandlesSaveBook: true + GoToEditing("page1"); + + SaveInPlace("ERROR: first try"); + SaveInPlace("ERROR: second try"); + + Assert.That( + _reportedFailures.Count, + Is.EqualTo(1), + "a page that always fails must not lock the user out with repeated dialogs" + ); + } + + [Test] + public void ToSavedInPlace_FailureOnDifferentPage_ReportsAgain() + { + GoToEditing("page1"); + SaveInPlace("ERROR: first page"); + Assert.That(_reportedFailures.Count, Is.EqualTo(1), "test setup"); + + // The only way out of Editing is through a save, so save-and-navigate to another page. + Assert.That( + SaveInPlaceThenGoTo("content", "page2"), + Is.EqualTo(InPlaceSaveOutcome.Saved), + "test setup" ); + Assert.That(_stateMachine.ToEditing("page2"), Is.True, "test setup"); + + SaveInPlace("ERROR: second page"); + + Assert.That(_reportedFailures.Count, Is.EqualTo(2)); } - /// - /// What the browser sends back to ReceivePageContent. The state machine only passes it - /// on to updateBookWithPageContents, so any non-null string will do here. - /// - private const string kPageContentFromBrowser = "
"; - - /// - /// The invariant behind BL-16766: while we are waiting for the browser to hand back the - /// page content, emptying the page would throw away the user's edits, so the state - /// machine refuses. Anything on the path from a tab change to ToNoPage has to respect - /// this rather than let the exception reach the user as an error report. - /// [Test] - public void ToNoPage_WhileSaveInFlight_Throws() + public void ToSavedInPlace_AfterFailingThenSucceeding_ReportsAgainIfItFailsAgain() { - GetToEditing("page1"); - Assert.That(StartSaveForLeavingEditTab(null), Is.True); - Assert.That(_machine.SavePending, Is.True, "test setup: save should be in flight"); + GoToEditing("page1"); + SaveInPlace("ERROR: first try"); + Assert.That(_reportedFailures.Count, Is.EqualTo(1), "test setup"); + + Assert.That(SaveInPlace("good content"), Is.True); + SaveInPlace("ERROR: later try"); - var error = Assert.Throws(() => _machine.ToNoPage()); - Assert.That(error.Message, Does.Contain("Cannot empty page while saving")); + Assert.That( + _reportedFailures.Count, + Is.EqualTo(2), + "a successful save should clear the 'already reported' memory" + ); } - /// - /// BL-16766: the user clicked the Collection tab twice in quick succession. The first - /// click started a save; the second arrived while that save was still in flight, so it - /// could not start one of its own. Rather than pressing on with the tab change — which - /// crashed in ToNoPage and left the workspace half-switched — it must be able to wait for - /// the in-flight save to finish. - /// + // ToSavedInPlaceThenNavigating: what a page click does when the click brought the outgoing + // page's content with it. See EditingModel.SaveThen's pageContentFromBrowser. + [Test] - public void DeferUntilSaveCompletes_SaveInFlight_RunsTheWorkOnceTheSaveIsDone() + public void ToSavedInPlaceThenNavigating_WhileEditing_SavesThenGoesToTheOtherPage() { - GetToEditing("page1"); - var tabChanges = 0; - Assert.That(StartSaveForLeavingEditTab(() => tabChanges++), Is.True); - Assert.That(_machine.SavePending, Is.True, "test setup: save should be in flight"); - Assert.That(tabChanges, Is.EqualTo(0), "test setup: nothing has switched tabs yet"); + GoToEditing("page1"); + _navigatedTo.Clear(); - // The second click. It cannot start a save of its own... Assert.That( - StartSaveForLeavingEditTab(() => tabChanges++), - Is.False, - "a second save must not start while one is in flight" + SaveInPlaceThenGoTo("bodycss", "page2"), + Is.EqualTo(InPlaceSaveOutcome.Saved) ); - // ...so it asks to be called back instead. - var deferredRuns = 0; - Assert.That(_machine.DeferUntilSaveCompletes(() => deferredRuns++), Is.True); + + Assert.That(_updatedWith, Is.EqualTo(new[] { "bodycss" })); + Assert.That(_saveBookCount, Is.EqualTo(1)); Assert.That( - deferredRuns, - Is.EqualTo(0), - "the deferred work must not run while the save is still in flight" + _navigatedTo, + Is.EqualTo(new[] { "page2" }), + "should have gone to the clicked page, in the same step" + ); + Assert.That(_reportedFailures, Is.Empty); + } + + [Test] + public void ToSavedInPlaceThenNavigating_LandsInAStateThatCanAcceptTheNextPageClick() + { + // The bug this avoids: while in SavePending, a further page click is silently dropped. + GoToEditing("page1"); + Assert.That( + SaveInPlaceThenGoTo("content", "page2"), + Is.EqualTo(InPlaceSaveOutcome.Saved) ); - // The browser finally hands back the page content, completing the first save. - Assert.That(_machine.ToSavedAndStripped(kPageContentFromBrowser), Is.True); + // Finish arriving, then click again, as an impatient user would. + Assert.That(_stateMachine.ToEditing("page2"), Is.True); + _navigatedTo.Clear(); - Assert.That(tabChanges, Is.EqualTo(1), "the first click's tab change should have run"); - Assert.That(deferredRuns, Is.EqualTo(1), "the deferred work should have run"); - Assert.That(_machine.SavePending, Is.False, "the save should be finished"); - // And by now emptying the page is legal, so the deferred tab change can complete. - Assert.DoesNotThrow(() => _machine.ToNoPage()); + Assert.That( + SaveInPlaceThenGoTo("more content", "page3"), + Is.EqualTo(InPlaceSaveOutcome.Saved) + ); + Assert.That(_navigatedTo, Is.EqualTo(new[] { "page3" })); } - /// - /// Only one piece of deferred work is kept; a later request supersedes an earlier one, - /// because it represents what the user most recently asked for. - /// [Test] - public void DeferUntilSaveCompletes_CalledTwice_RunsOnlyTheLastRequest() + public void ToSavedInPlaceThenNavigating_WhileNavigating_DoesNothing() { - GetToEditing("page1"); - Assert.That(StartSaveForLeavingEditTab(null), Is.True); - var firstRuns = 0; - var secondRuns = 0; - Assert.That(_machine.DeferUntilSaveCompletes(() => firstRuns++), Is.True); - Assert.That(_machine.DeferUntilSaveCompletes(() => secondRuns++), Is.True); + Assert.That(_stateMachine.ToNavigating("page1"), Is.True); + _navigatedTo.Clear(); - _machine.ToSavedAndStripped(kPageContentFromBrowser); + Assert.That( + SaveInPlaceThenGoTo("content", "page2"), + Is.EqualTo(InPlaceSaveOutcome.Declined) + ); - Assert.That(firstRuns, Is.EqualTo(0), "the superseded request should not run"); - Assert.That(secondRuns, Is.EqualTo(1)); + Assert.That(_updatedWith, Is.Empty); + Assert.That(_saveBookCount, Is.EqualTo(0)); + Assert.That(_navigatedTo, Is.Empty); } - /// - /// With no save in flight there is nothing to wait for, so the caller is told to get on - /// with its work itself. - /// [Test] - public void DeferUntilSaveCompletes_NoSaveInFlight_DoesNotDefer() + public void ToSavedInPlaceThenNavigating_FromNoPage_JustGoesThere() { - GetToEditing("page1"); - var runs = 0; - Assert.That(_machine.DeferUntilSaveCompletes(() => runs++), Is.False); - Assert.That(runs, Is.EqualTo(0), "it should not run the work either"); + // Nothing to save, but the click still means "show me that page". + Assert.That( + SaveInPlaceThenGoTo("content", "page2"), + Is.EqualTo(InPlaceSaveOutcome.Saved) + ); + + Assert.That(_updatedWith, Is.Empty, "there was no page to save"); + Assert.That(_navigatedTo, Is.EqualTo(new[] { "page2" })); } - /// - /// A caller with nothing to retry (OpenSpecificCollection raises the tab-about-to-change - /// event with no postponed work) is not made to wait. - /// [Test] - public void DeferUntilSaveCompletes_NothingToRetry_DoesNotDefer() + public void ToSavedInPlaceThenNavigating_FromNoPage_StillWritesWhatTheActionDid() { - GetToEditing("page1"); - Assert.That(StartSaveForLeavingEditTab(null), Is.True); - Assert.That(_machine.SavePending, Is.True, "test setup: save should be in flight"); - Assert.That(_machine.DeferUntilSaveCompletes(null), Is.False); + // There is no browser content to merge here, but the action can still change the book + // -- deleting a page, say -- and that has to reach disk. The request-the-content path + // (ToSavePending -> DoPostSaveAction) saves in this case, so this must too. + Assert.That( + SaveInPlaceThenDoAndGoTo("content", () => "page2"), + Is.EqualTo(InPlaceSaveOutcome.Saved) + ); + + Assert.That( + _saveBookCount, + Is.EqualTo(1), + "whatever the action changed must still be written to disk" + ); } - /// - /// BL-16766 end to end: replays the sequence in the crash report through the calls - /// production makes — EditingModel.OnTabAboutToChange's two branches, and the ToNoPage() - /// that WorkspaceView's postponed work reaches via EditingView.OnVisibleChanged(false). - /// Before the fix the second click threw "Cannot empty page while saving" from inside the - /// postponed work, which is exactly where the reported stack trace ends. - /// [Test] - public void TwoRequestsToLeaveEditTab_SecondArrivesDuringTheFirstsSave_ChangesTabOnce() + public void ToSavedInPlaceThenNavigating_SaveFails_ReportsAndStaysPut() { - var tabChanges = 0; - var currentTab = "edit"; // WorkspaceView._previouslySelectedTabArea - - // WorkspaceView.ChangeTab's CompleteTheChange: it raises the tab-changed event, which - // reaches EditingView.OnVisibleChanged(false), and only then records the new tab. - Action postponedWorkOfTabChange = () => - { - tabChanges++; - _machine.ToNoPage(); - currentTab = "collection"; - }; - - // WorkspaceView.ChangeTab, including the EditingModel.OnTabAboutToChange handler it - // raises. Assigned rather than declared so that the fallback can pass it as - // details.StartTheChangeOver, which is how WorkspaceView supplies it. - Action clickTheCollectionTab = null; - clickTheCollectionTab = () => - { - if (currentTab == "collection") - return; // "Already on the desired tab: nothing to do." - if (StartSaveForLeavingEditTab(postponedWorkOfTabChange)) - return; - // the fallback: doIfNotInRightStateToSave - if (_machine.Navigating) - _machine.ToNoPage(); - if (_machine.DeferUntilSaveCompletes(clickTheCollectionTab)) - return; - postponedWorkOfTabChange(); - }; - - GetToEditing("page1"); - - clickTheCollectionTab(); - Assert.That(_machine.SavePending, Is.True, "test setup: save should be in flight"); + GoToEditing("page1"); + _navigatedTo.Clear(); + Assert.That( - _saveRequestedFor, - Is.EqualTo("page1"), - "test setup: the browser should have been asked for the page content" + SaveInPlaceThenGoTo("ERROR: the browser could not gather it", "page2"), + Is.EqualTo(InPlaceSaveOutcome.Failed), + "Failed, not Declined: the caller must not fall back and run the action again" ); - Assert.That(tabChanges, Is.EqualTo(0), "test setup: the tab cannot change yet"); - // The user clicks it again before the browser has answered. - Assert.DoesNotThrow(() => clickTheCollectionTab()); + Assert.That(_saveBookCount, Is.EqualTo(0)); + Assert.That(_reportedFailures.Count, Is.EqualTo(1)); Assert.That( - tabChanges, - Is.EqualTo(0), - "the second click must not switch tabs while the save is in flight" + _navigatedTo, + Is.Empty, + "going on to the clicked page would silently discard the edits we failed to save" ); + } + + // The doBeforeSaveToDisk form: what duplicate/delete/paste/move page do, now that the page + // list sends the current page's content with the command. The action has to see the user's + // latest edits (so it must run AFTER the browser's content goes into the book DOM) and its + // work has to reach disk (so it must run BEFORE the book is written). + // See EditingModel.SavePageInPlaceThen. + + [Test] + public void ToSavedInPlaceThenNavigating_RunsTheActionBetweenTheDomUpdateAndTheDiskSave() + { + GoToEditing("page1"); + _navigatedTo.Clear(); + var domUpdatesWhenActionRan = -1; + var saveBookCountWhenActionRan = -1; - // The browser hands back the page content, completing the save. - _machine.ToSavedAndStripped(kPageContentFromBrowser); + var result = SaveInPlaceThenDoAndGoTo( + "bodycss", + () => + { + domUpdatesWhenActionRan = _updatedWith.Count; + saveBookCountWhenActionRan = _saveBookCount; + return "theDuplicatedPage"; + } + ); - Assert.That(_actions, Does.Contain("updateBook:page1"), "the page should be saved"); + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Saved)); Assert.That( - tabChanges, + domUpdatesWhenActionRan, Is.EqualTo(1), - "the tab should have changed exactly once, when the save finished" + "the action must see the edits the browser just sent us" + ); + Assert.That( + saveBookCountWhenActionRan, + Is.EqualTo(0), + "the action must run before the disk save, so what it does gets written too" ); - Assert.That(_machine.SavePending, Is.False); + Assert.That(_saveBookCount, Is.EqualTo(1), "and the disk save must still happen"); + Assert.That(_navigatedTo, Is.EqualTo(new[] { "theDuplicatedPage" })); } - /// - /// The deferred work must run even if completing the save fails, or a tab click could be - /// swallowed for the rest of the session. - /// [Test] - public void DeferUntilSaveCompletes_SaveCompletionThrows_StillRunsTheWork() + public void ToSavedInPlaceThenNavigating_WrongState_DoesNotRunTheAction() { - GetToEditing("page1"); + Assert.That(_stateMachine.ToNavigating("page1"), Is.True, "test setup"); + var actionRan = false; + + var result = SaveInPlaceThenDoAndGoTo( + "content", + () => + { + actionRan = true; + return "page2"; + } + ); + + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Declined)); + Assert.That( + actionRan, + Is.False, + "the caller falls back to SaveThen when we Decline, so the action must not have " + + "happened already -- it would then happen twice" + ); + } + + [Test] + public void ToSavedInPlaceThenNavigating_SaveFails_DoesNotRunTheAction() + { + GoToEditing("page1"); + var actionRan = false; + + var result = SaveInPlaceThenDoAndGoTo( + "ERROR: the browser could not gather it", + () => + { + actionRan = true; + return "page2"; + } + ); + + Assert.That( + result, + Is.EqualTo(InPlaceSaveOutcome.Failed), + "Failed, not Declined -- see the next test for why the difference matters" + ); + Assert.That( + actionRan, + Is.False, + "deleting or duplicating a page we failed to save would act on stale content" + ); + } + + [Test] + public void ToSavedInPlaceThenNavigating_ActionThrows_ReportsFailedSoTheCallerWillNotRetry() + { + // Found live: relocating a page threw part way through, the caller read the result as + // "not saved, fall back to SaveThen", and the page got relocated a SECOND time. An + // action that has already changed the book must never be offered to the fallback. + GoToEditing("page1"); + var timesActionRan = 0; + + var result = SaveInPlaceThenDoAndGoTo( + "good content", + () => + { + timesActionRan++; + throw new ApplicationException("the action blew up after changing the book"); + } + ); + + Assert.That(timesActionRan, Is.EqualTo(1), "test setup: the action should have run"); Assert.That( - _machine.ToSavePending(() => throw new ApplicationException("save failed")), - Is.True + result, + Is.EqualTo(InPlaceSaveOutcome.Failed), + "Declined here would invite the caller to run the action a second time" ); - var deferredRuns = 0; - Assert.That(_machine.DeferUntilSaveCompletes(() => deferredRuns++), Is.True); + Assert.That(_reportedFailures.Count, Is.EqualTo(1)); + } - Assert.Throws(() => - _machine.ToSavedAndStripped(kPageContentFromBrowser) + [Test] + public void ToSavedInPlaceThenNavigating_ActionNavigatesToTheSamePage_DoesNotNavigateTwice() + { + // Found live: relocating a page raises RelocatePageEvent, and OnRelocatePage refreshes + // the display of the page whose HTML just changed -- i.e. the action navigates. That + // used to throw "Cannot navigate while editing", because unlike the old SaveThen flow + // (which ran the action in SavedAndStripped) we are still in Editing. It is safe here: + // the browser's content is already in the book DOM, so there is nothing left to lose. + GoToEditing("page1"); + _navigatedTo.Clear(); + + var result = SaveInPlaceThenDoAndGoTo( + "good content", + () => + { + _stateMachine.ToNavigating("theMovedPage"); + return "theMovedPage"; + } ); - Assert.That(deferredRuns, Is.EqualTo(1)); - Assert.That(_machine.SavePending, Is.False); + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Saved)); + Assert.That(_reportedFailures, Is.Empty, "an action that navigates is legal here"); + Assert.That(_saveBookCount, Is.EqualTo(1)); + Assert.That( + _navigatedTo, + Is.EqualTo(new[] { "theMovedPage" }), + "the action's navigation and ours are to the same page, so it should happen once" + ); + } + + [Test] + public void ToSavedInPlaceThenNavigating_ActionNavigatesElsewhere_OurTargetWins() + { + GoToEditing("page1"); + _navigatedTo.Clear(); + + var result = SaveInPlaceThenDoAndGoTo( + "good content", + () => + { + _stateMachine.ToNavigating("somewhereTheActionWanted"); + return "whereWeSaidToGo"; + } + ); + + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Saved)); + Assert.That( + _navigatedTo, + Is.EqualTo(new[] { "somewhereTheActionWanted", "whereWeSaidToGo" }), + "the page the action named is where we must end up" + ); + } + + [Test] + public void ToSavedInPlaceThenNavigating_ActionAsksForAnotherSave_IgnoresItAndStillNavigates() + { + // An action is allowed to do things that would normally start a save -- changing the + // page selection does, via PageListController.OnPageSelectedChanged. There is nothing + // for that save to do: the content is already merged and the book is about to be + // written. Accepting it would re-enter the whole save, including this action. + GoToEditing("page1"); + _navigatedTo.Clear(); + var nestedSaveAccepted = true; + + var result = SaveInPlaceThenDoAndGoTo( + "good content", + () => + { + nestedSaveAccepted = + _stateMachine.ToSavedInPlaceThenNavigating( + "content from the nested save", + () => "pageTheNestedSaveWanted", + e => _reportedFailures.Add(e) + ) != InPlaceSaveOutcome.Declined; + return "whereWeSaidToGo"; + } + ); + + Assert.That( + nestedSaveAccepted, + Is.False, + "a save requested from inside the action should be refused" + ); + Assert.That( + _updatedWith, + Is.EqualTo(new[] { "good content" }), + "and the nested save must not have merged its content on top of ours" + ); + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Saved)); + Assert.That(_saveBookCount, Is.EqualTo(1), "the book is written exactly once"); + Assert.That( + _navigatedTo, + Is.EqualTo(new[] { "whereWeSaidToGo" }), + "the page we promised to go to must still be shown" + ); + } + + [Test] + public void ToSavedInPlaceThenNavigating_NothingToMerge_StillRunsTheActionAndWritesTheBook() + { + // Null content means the page has not been changed since it loaded (see PageSnapshot: + // a page nobody edited never produces a snapshot). There is nothing to merge, but the + // action is itself a change to the book -- duplicating a page, say -- so it must still + // run, the book must still be written, and we must still go where it says. + GoToEditing("page1"); + _navigatedTo.Clear(); + var actionRan = false; + + var result = SaveInPlaceThenDoAndGoTo( + null, + () => + { + actionRan = true; + return "page2"; + } + ); + + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Saved)); + Assert.That(actionRan, Is.True, "the action must run even with nothing to merge"); + Assert.That( + _updatedWith, + Is.Empty, + "there was no page content, so nothing should have been merged into the book DOM" + ); + Assert.That(_saveBookCount, Is.EqualTo(1), "the book must still be written"); + Assert.That(_navigatedTo, Is.EqualTo(new[] { "page2" })); + } + + [Test] + public void ToSavedInPlaceThenNavigating_ActionReturnsNull_LeavesTheEditorBlank() + { + // SaveThen's contract: returning null from the action means "show a blank screen", + // which is how leaving the edit tab saves. DoPostSaveAction honours it, so this must + // too -- trying to navigate to no page would leave a broken editor. + GoToEditing("page1"); + _navigatedTo.Clear(); + + var result = SaveInPlaceThenDoAndGoTo("good content", () => null); + + Assert.That(result, Is.EqualTo(InPlaceSaveOutcome.Saved)); + Assert.That(_saveBookCount, Is.EqualTo(1), "it must still write the book"); + Assert.That(_navigatedTo, Is.Empty, "there is no page to go to"); + Assert.That( + _stateMachine.ToNavigating("page2"), + Is.True, + "we should be in NoPage, from which navigating is allowed again" + ); + } + + [Test] + public void ToNoPage_WhileEditingAndNoSaveInPlaceUnderWay_StillThrows() + { + // As with ToNavigating, the relaxation must be scoped to the action. + GoToEditing("page1"); + + Assert.Throws( + () => _stateMachine.ToNoPage(), + "emptying an unsaved page must still be refused" + ); + } + + [Test] + public void ToNavigating_WhileEditingAndNoSaveInPlaceUnderWay_StillThrows() + { + // The relaxation above must be scoped to the action; the ordinary guard against + // leaving a page with unsaved edits has to stay. + GoToEditing("page1"); + + Assert.Throws( + () => _stateMachine.ToNavigating("page2"), + "navigating away from an unsaved page must still be refused" + ); } } } From 2773e50bf3ec96ddda26f7575c21913269d53036 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Thu, 3 Sep 2026 16:58:27 -0500 Subject: [PATCH 02/31] Fix three ways the new save path could lose work, found in review (BL-13502) **The AI Image Editor stopped opening at all.** HandleSaveThenLaunch queued the open for the next page load, because saving always ended in a navigation. This branch made saving synchronous and navigation-free, so no page load ever came and the queued action sat there until the user changed pages, at which point it saw a different page id and returned. Right-click -> AI Image Editor saved the page and then did nothing, silently. It now opens straight after the save returns, which is what the whole point of the change allows. EditingModel.RunAfterNextPageLoad had no other caller and is gone with it. **A command whose page had nothing to merge wrote nothing to disk.** SaveBookToDisk was gated on _modifiedPageElement, which is only ever set by the merge -- and this branch also nulls it to mean "nothing changed, do not write". Those are different questions, and conflating them lost changes: Add Page, Duplicate, Change Layout and the rest make their change in their own action, so on a page the user never touched there is no snapshot to merge, no modified page element, and the write was skipped entirely. The change survived in memory and was picked up by the next full save, so it looked fine until a crash. The two questions are now separate, and the case where only the action can have changed the book forces a full save, since we cannot know which pages it touched. **A snapshot that failed to post counted as sent.** lastPosted was assigned before awaiting the post, so a failed post was remembered as delivered and never retried, leaving C# holding pre-failure content -- the next save would then write that, losing everything typed since, not just the last keystroke. It is now assigned after the post resolves. **And a gather that threw was completely silent.** Gathering can throw by design (the BL-13120 origami guard, a missing marginBox, the canvas-element count checks). The rejection escaped into an unhandled promise rejection, and the global handler for those is commented out in lib/errorHandler.ts -- so C# concluded there was nothing to save and the page's edits went quietly. Before this branch the same failure came back as "Bloom had trouble saving a page". It is now reported, once per page, and retried on the next change. Two tests cover the snapshot pair: a failed post is offered again rather than counted as sent, and a gather that throws reports once and posts nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/js/pageSnapshot.spec.ts | 48 ++++++++++ .../bookEdit/js/pageSnapshot.ts | 31 ++++++- src/BloomExe/Edit/EditingModel.cs | 89 +++++++------------ .../web/controllers/AiImageEditorApi.cs | 85 +++++------------- 4 files changed, 130 insertions(+), 123 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts index 0970da047cdd..8df113eefa50 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -10,6 +10,11 @@ const posted: Array<{ url: string; body: string }> = []; // Lets a test hold a POST open, to check that a second one never starts alongside it. let postHook: (() => Promise) | undefined; +const reported: string[] = []; +vi.mock("../../lib/errorHandler", () => ({ + reportError: (message: string) => reported.push(message), +})); + vi.mock("../../utils/bloomApi", () => ({ postString: (url: string, body: string) => { posted.push({ url, body }); @@ -50,6 +55,7 @@ describe("pageSnapshot", () => { beforeEach(() => { vi.useFakeTimers(); posted.length = 0; + reported.length = 0; contentToReport = ""; postHook = undefined; setUpPage(); @@ -262,4 +268,46 @@ describe("pageSnapshot", () => { "the change that landed mid-gather must trigger another snapshot, not be dropped", ).toBe(3); }); + it("does not treat a failed post as sent, so the content is offered again", async () => { + // Recording it as sent before the post resolved would mean C# never got this content and + // we never tried again -- the next save would then write what C# still held, losing + // everything typed since. + contentToReport = "first"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + postHook = () => Promise.reject(new Error("network gone")); + contentToReport = "second"; + changeThePage("second"); + await letTheSnapshotHappen(); + expect(posted.map((p) => p.body)).toEqual(["second"]); + + // The post failed, so the same content must still be offered on the next attempt. + postHook = undefined; + changeThePage("second again"); + await letTheSnapshotHappen(); + expect(posted.map((p) => p.body)).toEqual(["second", "second"]); + }); + + it("reports a gather that throws, once per page, instead of losing the edits silently", async () => { + // If the gather throws and nobody says so, C# concludes there is nothing to save and the + // user's work disappears without a word. + contentToReport = "fine"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + const exploding = () => Promise.reject(new Error("no marginBox")); + stopWatchingPageForSnapshots(); + startWatchingPageForSnapshots(exploding); + await letTheBaselineSettle(); + + changeThePage("one"); + await letTheSnapshotHappen(); + changeThePage("two"); + await letTheSnapshotHappen(); + + expect(reported.length).toBe(1); + expect(reported[0]).toContain("could not keep track of your changes"); + expect(posted.length).toBe(0); + }); }); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index 1544dcb7a5d8..2f88a05a4f1d 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -1,4 +1,5 @@ import { postString } from "../../utils/bloomApi"; +import { reportError } from "../../lib/errorHandler"; // Keep C# supplied with the current content of the page being edited, so that a save never has to // ask for it and wait. @@ -62,6 +63,9 @@ let busy = false; // Bumped every time a change arrives. The async gather checks it afterwards, so a change that // lands while we were gathering schedules another pass instead of being lost. let changeCount = 0; +// The page we have already complained about, so that a page which fails every time reports once +// rather than on every keystroke. +let pageWeReportedAFailureFor: string | undefined; function currentPageId(): string | undefined { return document.querySelector(".bloom-page")?.id || undefined; @@ -99,12 +103,36 @@ async function takeSnapshot(): Promise { if (pageIdBeingWatched !== pageId) return; if (content !== lastPosted) { - lastPosted = content; await postString( `${kApi}?pageId=${encodeURIComponent(pageId)}`, content, ); + // Only once the post has actually resolved. Recording it before would mean that a + // post which failed still counted as sent: we would never retry it, and C# would go + // on holding the content from before the failure -- so the next save would write + // that, losing everything typed since, not merely the latest keystroke. + lastPosted = content; } + } catch (error) { + // Gathering the page can legitimately throw -- the BL-13120 origami guard, a missing + // marginBox, the canvas-element count checks -- and so can the post. Either way this is + // the one failure the whole design cannot afford to be quiet about: C# concludes "no + // snapshot, so nothing to save", and the user's edits are dropped without a word. (The + // global unhandledrejection handler is commented out in lib/errorHandler.ts, so nothing + // else would report it.) Before BL-13502 the equivalent failure came back through the + // state machine as "Bloom had trouble saving a page"; this keeps that promise. + // + // Once per page: a page that fails will fail again on the very next keystroke. + if (pageWeReportedAFailureFor !== pageId) { + pageWeReportedAFailureFor = pageId; + reportError( + "Bloom could not keep track of your changes to this page: " + + (error instanceof Error ? error.message : String(error)), + error instanceof Error ? error.stack : undefined, + ); + } + // Try again on the next change: a transient failure should not stop us for good. + scheduleSnapshot(); } finally { busy = false; } @@ -141,6 +169,7 @@ export function startWatchingPageForSnapshots( lastPosted = undefined; changeCount = 0; baselineTaken = false; + pageWeReportedAFailureFor = undefined; // Take a baseline of the page as it ends up once it has finished loading, and treat that as // "already sent". Without it every page posts a snapshot within a second of being opened, even diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index 3fdb0567b9e5..cceb9cf93ced 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -348,11 +348,6 @@ private void OnTabAboutToChange(TabChangedDetails details) { if (details.FromTab == Workspace.WorkspaceTab.edit) { - // Leaving the tab means no page will load to run whatever was queued for the next - // page load (see RunAfterNextPageLoad) - and it was queued for the page we are - // leaving, so it must not spring to life if the user comes back to that page later. - _doAfterNextPageLoad = null; - // When an external tool has overwritten the current book on disk (see // ReloadCurrentBookDiscardingEdits), we are leaving the Edit tab specifically to // discard the unsaved page. In that case reload from disk instead of saving, so the @@ -1759,17 +1754,21 @@ public void SaveEverythingBeforeClosing() } /// - /// Write out whatever UpdateBookDomFromBrowserPageContent() put into the book DOM: either just - /// the one page that changed, or the whole book if something shared changed. + /// Write out whatever the book DOM has gained that is not on disk yet: either just the one + /// page that changed, or the whole book if something shared changed, or the caller's action + /// changed something we cannot pin to a single page. /// This is the state machine's saveBook action, and also the second half of SavePageInPlace, /// so both routes make exactly the same decisions. /// private void SaveBookToDisk() { - if (_modifiedPageElement == null) + if (!_bookDomHasUnwrittenChanges) return; + // A null page element with a full save is fine -- Book.SavePageToDisk only uses the + // element for the single-page fast path. CurrentBook.SavePageToDisk(_modifiedPageElement, _nextSaveMustBeFull); + _bookDomHasUnwrittenChanges = false; _nextSaveMustBeFull = false; _pageHasUnsavedDataDerivedChange = false; PageTemplatesApi.LastSaveTime = DateTime.Now; @@ -1861,6 +1860,19 @@ private InPlaceSaveOutcome SavePageInPlaceThen( return InPlaceSaveOutcome.Refused; _nextSaveMustBeFull |= forceFullSave; + + // With no content to merge, the caller's action is the only thing that can change the + // book -- and adding, duplicating or relaying out a page certainly does. We cannot know + // which pages it will touch, so the write must happen and must be a full one; the + // per-page fast path needs a modified page element we do not have. (Leaving this to + // _modifiedPageElement meant the action ran and nothing was written at all: the change + // lived only in memory until some later full save happened to pick it up.) + if (pageContentData == null) + { + _bookDomHasUnwrittenChanges = true; + _nextSaveMustBeFull = true; + } + // Unlike SavePageInPlace there is nothing to do afterwards on success: we do NOT // refresh the full-save baseline or the thumbnail, because navigating does both for // us, in EditingView.StartNavigationToEditPage, which this has already started. @@ -1880,6 +1892,14 @@ private InPlaceSaveOutcome SavePageInPlaceThen( private SafeXmlElement _modifiedPageElement; + // Whether the in-memory book has a change we have not yet written to disk. This is + // deliberately NOT the same question as "which page element changed" + // (_modifiedPageElement), and conflating the two lost changes: a command whose page had + // nothing to merge -- Add Page or Change Layout on a page the user never touched -- makes + // its change entirely in its own action, so there is no modified page element to point at, + // yet the book certainly needs writing. + private bool _bookDomHasUnwrittenChanges; + /// /// Receives a DOM (derived the browser) that combines the body of the document of the page /// being edited with the CSS that defines the user-defined styles. It updates the current book DOM @@ -1941,10 +1961,11 @@ out var anythingChanged ); // The page says exactly what the book already said and nothing else is outstanding, so - // there is nothing to write. A null _modifiedPageElement is how SaveBookToDisk is - // already told there is nothing to save. + // there is nothing to write. if (!anythingChanged && !somethingElseNeedsSaving) _modifiedPageElement = null; + else + _bookDomHasUnwrittenChanges = true; } // If we return 'true', we need to do a complete book save, otherwise we'll just save this page. @@ -2340,16 +2361,6 @@ public UrlPathString AddWidgetFilesToBookFolder(string fullWidgetPath) public void HandlePageDomLoadedEvent(string pageId) { var nowEditing = _stateMachine.ToEditing(pageId); - if (nowEditing) - { - // Run whatever was queued for "the browser has a page again" (see - // RunAfterNextPageLoad). Taken and cleared before invoking, so it fires at most - // once even if it throws, and so an action that queues another one works. - // Before AdvanceUpdatingAllPages, which may navigate straight off this page. - var afterPageLoad = _doAfterNextPageLoad; - _doAfterNextPageLoad = null; - afterPageLoad?.Invoke(pageId); - } // If we are in the middle of the "Update Book" per-page pass, a page finishing loading // (which means the edit-tab page setup code has run on it) is our cue to save it and // move on to the next page. See StartUpdatingAllPages(). @@ -2357,44 +2368,6 @@ public void HandlePageDomLoadedEvent(string pageId) AdvanceUpdatingAllPages(pageId); } - // The one action queued by RunAfterNextPageLoad, or null. - private Action _doAfterNextPageLoad; - - /// - /// Arrange for to run the next time a page finishes loading in - /// the browser, passing it that page's id. - /// - /// This exists for callers that must save the current page before doing something in the - /// browser that needs the saved book DOM to be up to date. Saving strips the live page, so - /// it always ends by re-navigating to it (see EditingStateMachine) — which means - /// MergeCurrentPageThenSave is too early for such a caller: it returns before that - /// navigation, so the browser code it started would be torn down. Waiting for the page to - /// come back is the only safe point. AiImageEditorApi.HandleSaveThenLaunch is the caller - /// this was written for (BL-16682). - /// - /// Note that "torn down" is not limited to the page iframe, which is why this cannot be - /// worked around by putting the browser code somewhere higher up. - /// EditingView.StartNavigationToEditPage picks one of three routes, and the third reloads - /// the whole workspace root document. In practice that route is reached when - /// MemoryUtils.SystemIsShortOfMemory() — which is Bloom's OWN private bytes past ~2GB, so - /// the ordinary state of a long editing session on a big book, and exactly what the full - /// reload exists to recover from. (Its other trigger, _changingUiLanguage, appears - /// unreachable from the edit tab today: everything that sets it — choosing a UI language, - /// toggling unapproved translations — reopens the project or restarts Bloom first. Don't - /// rely on that; the memory condition alone is enough.) So no browser-side state at all is - /// guaranteed to survive the navigation that ends a save; only C#-side state like this is. - /// - /// Only one action is held; queueing a second replaces the first, and passing null cancels. - /// The page that loads next is not necessarily the one the caller was on (the user may have - /// navigated, or the save may have failed), so callers that care must check the id they are - /// given. Leaving the Edit tab drops it (see OnTabAboutToChange), since no page would load - /// to run it and the caller's page is no longer on screen. - /// - public void RunAfterNextPageLoad(Action action) - { - _doAfterNextPageLoad = action; - } - // Fields supporting the "Update Book" per-page pass (see StartUpdatingAllPages()). // _updatingAllPages is true while we are visiting and saving every page in turn. // _pageUpdateOrder is the list of page IDs to visit (in book order); _pagesRemainingToUpdate diff --git a/src/BloomExe/web/controllers/AiImageEditorApi.cs b/src/BloomExe/web/controllers/AiImageEditorApi.cs index 28dff974dbed..fb8fb3ff42f3 100644 --- a/src/BloomExe/web/controllers/AiImageEditorApi.cs +++ b/src/BloomExe/web/controllers/AiImageEditorApi.cs @@ -307,33 +307,22 @@ private class SaveThenLaunchRequest /// made the current page's commit results describe an image the live page no longer shows, /// and left blind to a file the live page uses. /// - /// Two separate things follow from the fact that saving always ends in a navigation. + /// WHERE the overlay lives: in the top window, not in the page iframe — like the + /// image-gallery and copyright/license commands (see aiImageEditorOverlay.ts and the + /// comments on those commands in canvasControlRegistry.ts). Plenty of other operations + /// still replace the page iframe underneath it. /// - /// WHERE the overlay lives: not in the page iframe, which that navigation replaces every - /// time — hence the top window, like the image-gallery and copyright/license commands (see - /// aiImageEditorOverlay.ts and the comments on those commands in canvasControlRegistry.ts). - /// - /// WHEN we open it: once the browser has a page again, via - /// EditingModel.RunAfterNextPageLoad. Opening from SaveThen's doAfterSaveToDisk directly is - /// tempting, since the top window is alive at that moment — but that moment is immediately - /// before the navigation, and the navigation is not always confined to the page iframe. - /// EditingView.StartNavigationToEditPage reloads the whole workspace root when - /// MemoryUtils.SystemIsShortOfMemory(), which is Bloom's own private bytes past ~2GB — - /// the ordinary state of a long editing session on a big book. Opening from - /// doAfterSaveToDisk there meant the page saved correctly and the AI Image Editor never appeared, - /// with no message: openAiImageEditor doesn't even build the overlay synchronously; it - /// POSTs launch first and builds it in the reply, a whole round trip after the reload - /// began. Waiting for the page load costs nothing and is immune to all three routes. - /// doAfterSaveToDisk is still where we ASK for that, though — see the body — because it - /// only runs when the save actually reached disk, which is how a failed save leaves the - /// editor closed instead of opening it on a book DOM we know to be stale. - /// - /// To see that failure for yourself, temporarily make ShouldDoFullReload() return true — - /// its own comment invites exactly this — rather than trying to grow Bloom past 2GB. + /// WHEN we open it: immediately after the save returns. This used to queue itself for the + /// next page load via EditingModel.RunAfterNextPageLoad, because saving always ended in a + /// navigation which replaced the page iframe — and, when Bloom was short of memory, + /// reloaded the whole workspace root with it (EditingView.StartNavigationToEditPage), so + /// opening any earlier meant the page saved and the editor never appeared. Since BL-13502 + /// a save does not navigate at all, so there is no page load to wait for: continuing to + /// wait for one is what made the editor never open. /// private void HandleSaveThenLaunch(ApiRequest request) { - // Must be read before SaveThen: by the time our callbacks run the request is complete. + // Must be read before we save: by the time we are done the request is complete. // Deliberately unguarded: the only caller is launchAiImageEditor in // aiImageEditorPageCommands.ts, which always sends {slotIndex}, so a parse failure means // we broke our own contract and we want to hear about it with the real exception rather @@ -350,47 +339,15 @@ private void HandleSaveThenLaunch(ApiRequest request) } payload.pageId = pageId; - // Ask NOW to be opened on the next page load, and record separately whether the book - // DOM turned out to be sound. Both halves matter, for different reasons. - // - // Asking now, rather than from the callbacks below: OnTabAboutToChange discards the - // queued request when the user leaves the Edit tab. If we only queued it later, from - // doAfterSaveToDisk, that discard could run first — on a save still in flight — and we - // would then re-arm behind it, so the AI Image Editor sprang open when the user came back to - // that page. (Devin caught that; queueing up front puts the discard reliably after us.) - var bookDomIsSound = false; - model.RunAfterNextPageLoad(loadedPageId => - { - // Not the page we saved: the user navigated meanwhile, so the image we were asked - // to edit isn't there to edit. - if (loadedPageId != pageId) - return; - // The save was attempted and failed. Leave the AI Image Editor closed: the book DOM is - // still stale, so we would be opening it on exactly the out-of-date data this - // endpoint exists to prevent, and a commit from there would call book.Save() again - // on top of whatever went wrong (disk full, a corrupt image, out of memory). The - // user is not left wondering — EditingStateMachine has already shown them "Bloom - // had trouble saving a page...". (JohnThomson raised this in review.) - if (!bookDomIsSound) - return; - OpenEditorInBrowser(payload); - }); - - // Saving is synchronous now (see PageSnapshot), so "did the book actually reach disk?" - // is simply "did this return without throwing" -- which is what we need to know before - // reading image sources back out of the file. A refusal to save (mid-navigation, say) - // is not a problem: those states are on their way to a page load which brings the DOM - // up to date anyway. Only an actual failure, such as the disk being full, is. - try - { - model.SaveCurrentPageAndBook(); - bookDomIsSound = true; - } - catch (Exception) - { - bookDomIsSound = false; - throw; - } + // Save before opening, because everything the editor is told about the book is read + // from the saved DOM. Saving is synchronous now (see PageSnapshot), so "did the book + // actually reach disk?" is simply "did this return without throwing". If it throws we + // never reach the open below, which is what we want: opening on a book DOM we know to + // be stale is exactly what this endpoint exists to prevent, and the user has already + // been shown "Bloom had trouble saving a page...". + model.SaveCurrentPageAndBook(); + + OpenEditorInBrowser(payload); request.PostSucceeded(); } From eb1a8ecfb1434e483192b13a6e84f55f1fda607c Mon Sep 17 00:00:00 2001 From: John Thomson Date: Thu, 3 Sep 2026 17:19:30 -0500 Subject: [PATCH 03/31] Do not open the AI editor on a save that did not happen (BL-13502) Two follow-ups from Devin's review of the previous commit. **A refused save still opened the AI Image Editor.** SaveCurrentPageAndBook returns false when it declines -- we are mid-navigation, or an external program has replaced the book on disk and its content must not be overwritten -- and the return value was ignored. The editor reads the book from disk, so opening after a save that did not happen edits stale images and commits against them. Under the previous commit's flow this could not be seen, because the open waited for a page load that a refused save never produced; now that the open follows immediately, it needs saying. The request now fails with a message instead. **The snapshot's busy lock could be released by the wrong run.** takeSnapshot cleared `busy` in a finally without checking that it still owned it, so a run left over from a page that had been unloaded could unlock a newer page's in-flight post, allowing two at once -- the single thing that flag exists to prevent. Not reachable today: startWatchingPageForSnapshots has one caller, in $(document).ready, so each page load is a fresh document with its own module state. But the module says it is safe to restart, and this is what makes that true. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/js/pageSnapshot.ts | 8 ++++++- .../web/controllers/AiImageEditorApi.cs | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index 2f88a05a4f1d..c6b323a28911 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -134,7 +134,13 @@ async function takeSnapshot(): Promise { // Try again on the next change: a transient failure should not stop us for good. scheduleSnapshot(); } finally { - busy = false; + // Only release the lock if we are still the run that took it. If the page was unloaded + // and another started while we were awaiting, this run belongs to the old page, and + // clearing the flag here would unlock the NEW page's in-flight post -- allowing two at + // once, which is the one thing the flag exists to prevent. Not reachable today, because + // each page load is a fresh document with its own module state, but the module claims to + // be safe to restart and this is what makes that true. + if (pageIdBeingWatched === pageId) busy = false; } // Something changed while we were gathering or posting: that change is not in what we just // sent, so go round again. diff --git a/src/BloomExe/web/controllers/AiImageEditorApi.cs b/src/BloomExe/web/controllers/AiImageEditorApi.cs index fb8fb3ff42f3..c785e52c501c 100644 --- a/src/BloomExe/web/controllers/AiImageEditorApi.cs +++ b/src/BloomExe/web/controllers/AiImageEditorApi.cs @@ -340,12 +340,24 @@ private void HandleSaveThenLaunch(ApiRequest request) payload.pageId = pageId; // Save before opening, because everything the editor is told about the book is read - // from the saved DOM. Saving is synchronous now (see PageSnapshot), so "did the book - // actually reach disk?" is simply "did this return without throwing". If it throws we - // never reach the open below, which is what we want: opening on a book DOM we know to - // be stale is exactly what this endpoint exists to prevent, and the user has already - // been shown "Bloom had trouble saving a page...". - model.SaveCurrentPageAndBook(); + // from the saved DOM. Saving is synchronous now (see PageSnapshot), so the answer is + // available right here: it throws if the save went wrong (the user has then already + // been shown "Bloom had trouble saving a page..."), and returns false if it declined + // to save at all -- we are mid-navigation, or an external program has replaced the + // book on disk and its content must not be overwritten. + // + // Either way we must NOT open: the whole point of saving first is that the editor + // reads the book from disk, so opening after a save that did not happen would edit + // stale images and commit against them. Under the old flow this could not arise -- + // the open waited for a page load that a refused save never produced -- so it needs + // saying now that the open follows immediately. + if (!model.SaveCurrentPageAndBook()) + { + request.Failed( + "Bloom could not save the page, so the AI Image Editor was not opened." + ); + return; + } OpenEditorInBrowser(payload); From 243bee38a510a9abdb02f66d759cb8f2f35b8db8 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Thu, 3 Sep 2026 18:38:11 -0500 Subject: [PATCH 04/31] Stamp each snapshot with the page load it belongs to (BL-13502) The snapshot endpoint is deliberately unsynchronised -- a keystroke has no business queueing behind a save -- so a post sent moments before a navigation can be processed after C# has cleared the snapshot for it. Matching on the page id was not enough to catch that, because reloading the SAME page keeps the id: Change Layout, importing a video and changing the topic all rebuild a page under its own id, and a snapshot of the pre-reload page could then be merged over what the reload built. Each page load now mints an id (a module-level constant in pageSnapshot.ts, which is exactly right: the page frame gets a fresh document, and so a fresh module, on every load). The browser sends it both when the page reports itself ready and with every snapshot, and C# keeps only the snapshots that carry the load it is currently showing. Starting a navigation forgets the id along with the snapshot, so nothing that arrives during the gap can quietly refill what we just cleared. Refusing has to be visible, though, or the fix would introduce a worse bug than it cures. The two APIs are not ordered against each other -- the ready notification goes to the UI thread and the snapshot does not -- so a snapshot can genuinely arrive first and be refused. Dropping it silently would leave C# with nothing to save while the browser had recorded it as delivered, which is precisely the kind of quiet loss the rest of this work removes. So the endpoint now answers whether it took the content, and the browser offers it again if not. Three tests: every post carries the load id, a refused snapshot is offered again rather than counted as sent, and (from before) a failed post is not counted as sent either. Co-Authored-By: Claude Opus 5 (1M context) --- src/BloomBrowserUI/bookEdit/editablePage.ts | 9 +- .../bookEdit/js/pageSnapshot.spec.ts | 45 +++++++- .../bookEdit/js/pageSnapshot.ts | 44 ++++++-- src/BloomExe/Edit/EditingModel.cs | 39 ++++++- .../web/controllers/EditingViewApi.cs | 102 +++++++++--------- 5 files changed, 174 insertions(+), 65 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts index 2e60837e89fd..ae37d8a04da1 100644 --- a/src/BloomBrowserUI/bookEdit/editablePage.ts +++ b/src/BloomBrowserUI/bookEdit/editablePage.ts @@ -17,7 +17,10 @@ import { } from "./js/canvasElementManager/CanvasElementManager"; import { kCanvasElementSelector } from "./toolbox/canvas/canvasElementConstants"; import { renderDragActivityTabControl } from "./js/AbovePageControls"; -import { startWatchingPageForSnapshots } from "./js/pageSnapshot"; +import { + getPageLoadId, + startWatchingPageForSnapshots, +} from "./js/pageSnapshot"; function getPageId(): string { const page = document.querySelector(".bloom-page"); @@ -35,7 +38,9 @@ function getPageId(): string { // 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. document.addEventListener("DOMContentLoaded", () => { - postString("editView/pageDomLoaded", getPageId()); + // The load id goes with it: from here until the next page reports ready, C# accepts snapshots + // only from this load. See getPageLoadId(). + postString("editView/pageDomLoaded", getPageId() + " " + getPageLoadId()); }); // This allows strong typing to be done for exported functions. diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts index 8df113eefa50..b7af02a879a8 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -3,6 +3,7 @@ import { startWatchingPageForSnapshots, stopWatchingPageForSnapshots, quietMsForTests, + getPageLoadId, } from "./pageSnapshot"; const posted: Array<{ url: string; body: string }> = []; @@ -10,6 +11,10 @@ const posted: Array<{ url: string; body: string }> = []; // Lets a test hold a POST open, to check that a second one never starts alongside it. let postHook: (() => Promise) | undefined; +// What C# answers. `{ data: false }` is a refusal: the snapshot was for a page load it is not +// showing, so the browser must not count it as delivered. +let postReply: unknown = undefined; + const reported: string[] = []; vi.mock("../../lib/errorHandler", () => ({ reportError: (message: string) => reported.push(message), @@ -18,7 +23,7 @@ vi.mock("../../lib/errorHandler", () => ({ vi.mock("../../utils/bloomApi", () => ({ postString: (url: string, body: string) => { posted.push({ url, body }); - return postHook ? postHook() : Promise.resolve(); + return postHook ? postHook() : Promise.resolve(postReply); }, })); @@ -58,6 +63,7 @@ describe("pageSnapshot", () => { reported.length = 0; contentToReport = ""; postHook = undefined; + postReply = undefined; setUpPage(); }); @@ -310,4 +316,41 @@ describe("pageSnapshot", () => { expect(reported[0]).toContain("could not keep track of your changes"); expect(posted.length).toBe(0); }); + it("stamps every post with the id of this page load", async () => { + // C# refuses a snapshot that does not carry the load it is currently showing, so that a + // post overtaking a reload of the same page cannot be merged over what the reload built. + contentToReport = "before"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + contentToReport = "after"; + changeThePage("after"); + await letTheSnapshotHappen(); + + expect(posted.length).toBe(1); + expect(posted[0].url).toContain( + "loadId=" + encodeURIComponent(getPageLoadId()), + ); + expect(getPageLoadId()).not.toBe(""); + }); + + it("offers the content again when C# refuses the snapshot", async () => { + // C# refuses anything from a page load it is not showing. Because the snapshot endpoint is + // not ordered against the "page is ready" one, a snapshot can genuinely arrive first and be + // refused; counting it as delivered would leave C# with nothing to save. + contentToReport = "first"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + postReply = { data: false }; // refused + contentToReport = "typed"; + changeThePage("typed"); + await letTheSnapshotHappen(); + expect(posted.map((p) => p.body)).toEqual(["typed"]); + + // Refused, so the very same content must be offered again rather than treated as sent. + postReply = { data: true }; + await letTheSnapshotHappen(); + expect(posted.map((p) => p.body)).toEqual(["typed", "typed"]); + }); }); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index c6b323a28911..6dd21cc1d6b6 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -27,6 +27,27 @@ import { reportError } from "../../lib/errorHandler"; const kApi = "editView/pageSnapshot"; +// Identifies THIS load of THIS page, so C# can tell our snapshots from those of a load it has +// already moved on from. A module-level constant is exactly the right scope: the page frame gets a +// fresh document, and so a fresh module, on every page load. +// +// It exists because the snapshot endpoint is deliberately unsynchronised (a keystroke has no +// business queueing behind a save), so a post sent moments before a navigation can be processed +// after C# has cleared the snapshot for it. Moving to a DIFFERENT page was harmless -- the stale +// entry is filed under a page id nobody asks about again -- but reloading the SAME page is not: +// Change Layout, importing a video and changing the topic all rebuild the page under its own id, +// and a snapshot of the pre-reload page would then be merged over what the reload built. +const pageLoadId = + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10); + +/** + * Identifies this load of this page. Sent with the "page is ready" notification and with every + * snapshot, so C# can ignore anything from a load it has superseded. + */ +export function getPageLoadId(): string { + return pageLoadId; +} + // How long the page must be quiet before we take a snapshot. // // This is small on purpose, and the size of it decides how much typing an exit could lose. What @@ -103,14 +124,25 @@ async function takeSnapshot(): Promise { if (pageIdBeingWatched !== pageId) return; if (content !== lastPosted) { - await postString( - `${kApi}?pageId=${encodeURIComponent(pageId)}`, + const reply = await postString( + `${kApi}?pageId=${encodeURIComponent(pageId)}&loadId=${encodeURIComponent( + pageLoadId, + )}`, content, ); - // Only once the post has actually resolved. Recording it before would mean that a - // post which failed still counted as sent: we would never retry it, and C# would go - // on holding the content from before the failure -- so the next save would write - // that, losing everything typed since, not merely the latest keystroke. + // C# refuses a snapshot from a load it is not showing -- including in the moment + // before this page has reported itself ready, since the two APIs are not ordered with + // respect to each other. A refusal is not a failure, but it does mean C# does not have + // this content, so we must not record it as sent and must offer it again. + const accepted = + (reply as { data?: boolean | string } | void)?.data !== false; + if (!accepted) { + scheduleSnapshot(); + return; + } + // Only once the post has actually resolved AND been taken. Recording it earlier would + // mean content C# never received still counted as sent: we would never retry it, and + // the next save would write what C# still held, losing everything typed since. lastPosted = content; } } catch (error) { diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index cceb9cf93ced..1508948c2b04 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -971,7 +971,12 @@ void StartNavigationToEditPage(IPage page) // The page we may have a snapshot of is going away, and whatever the save just wrote // into the book DOM is now the truth. Holding on to it would let a later visit to the // same page re-apply content from the previous visit. See PageSnapshot.Clear. + // + // Forgetting the load id along with it is what makes that stick: until the incoming + // page reports itself ready, every snapshot that arrives belongs to the load we are + // leaving, and is refused rather than quietly refilling what we just cleared. _pageSnapshot.Clear(); + _currentPageLoadId = null; try { if (page == null) @@ -1685,13 +1690,34 @@ public void MergeCurrentPageThenSave( ifNotInAStateToSave?.Invoke(); } + // The load of the page we are currently showing, as the browser identified it when it + // reported the page ready (see getPageLoadId() in pageSnapshot.ts). Null between starting a + // navigation and the new page reporting in, which is exactly the window in which no + // snapshot should be believed. + private string _currentPageLoadId; + /// - /// Called by the editView/pageSnapshot API when the browser's idle task volunteers the - /// current content of the page. All we do is remember it; see PageSnapshot for why. + /// Called by the editView/pageSnapshot API when the browser volunteers the current content + /// of the page. All we do is remember it; see PageSnapshot for why. + /// + /// A snapshot is ignored unless it comes from the load of the page we are currently + /// showing. The endpoint is deliberately unsynchronised, so a post sent moments before a + /// navigation can arrive after we cleared the snapshot for it. Matching on the page id + /// alone is not enough, because reloading the SAME page keeps the id: Change Layout, + /// importing a video and changing the topic all rebuild the page under it, and a snapshot + /// of the pre-reload page would then be merged over what the reload built. /// - public void ReceivePageSnapshot(string pageId, string pageContentData) - { + /// False if we did not take it, so the browser knows not to count it as + /// delivered. That matters most in the moment before a page has reported itself ready: the + /// snapshot endpoint is not synchronised and the ready notification is, so a snapshot can + /// genuinely arrive first. Dropping it silently would leave C# with nothing to save while + /// the browser believed it had told us. + public bool ReceivePageSnapshot(string pageId, string loadId, string pageContentData) + { + if (_currentPageLoadId == null || loadId != _currentPageLoadId) + return false; // a load we have moved on from, or one not yet registered _pageSnapshot.Set(pageId, pageContentData); + return true; } /// @@ -2358,8 +2384,11 @@ public UrlPathString AddWidgetFilesToBookFolder(string fullWidgetPath) return WidgetHelper.AddWidgetFilesToBookFolder(CurrentBook.FolderPath, fullWidgetPath); } - public void HandlePageDomLoadedEvent(string pageId) + public void HandlePageDomLoadedEvent(string pageId, string loadId = null) { + // From now until the next page reports itself loaded, this is the only load whose + // snapshots we will accept. See ReceivePageSnapshot. + _currentPageLoadId = loadId; var nowEditing = _stateMachine.ToEditing(pageId); // If we are in the middle of the "Update Book" per-page pass, a page finishing loading // (which means the edit-tab page setup code has run on it) is our cue to save it and diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index f687633d91f3..c85bfbc7293c 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -80,9 +80,11 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) request => { var pageId = request.RequiredParam("pageId"); + var loadId = request.GetParamOrNull("loadId"); var pageContentData = request.RequiredPostString(unescape: false); - View.Model.ReceivePageSnapshot(pageId, pageContentData); - request.PostSucceeded(); + request.ReplyWithBoolean( + View.Model.ReceivePageSnapshot(pageId, loadId, pageContentData) + ); }, false, false @@ -300,57 +302,52 @@ private void HandleSetCustomPageLayout(ApiRequest request) } request.ReplyWithText("true"); - View.Model.MergeCurrentPageThenSave( - () => + View.Model.MergeCurrentPageThenSave(() => + { + if (switchingToCustom) + pageElt.AddClass("bloom-customLayout"); + else + pageElt.RemoveClass("bloom-customLayout"); + // We must capture these from the saved page before typically replacing that with a different + // page element. + var backgroundAudio = pageElt.GetAttribute(HtmlDom.musicAttrName); + var backgroundAudioVolume = pageElt.GetAttribute(HtmlDom.musicVolumeName); + // Bring everything up to date consistent with the new + // state. Might be enough just do the BookData update. + book.EnsureUpToDateMemory(new NullProgress()); + // Toggling between custom and standard layout can replace the xMatter page HTML, + // so reapply branding QR-code HTML adjustments for the current book settings. + // This should not need to regenerate the QR code file. + book.UpdateQrCodeHtmlForCurrentSettings(updateQrCodeFileEvenIfItExists: false); + + if ( + shouldRemoveCustomLayoutDataWhenSwitchingToStandard + && !string.IsNullOrWhiteSpace(customLayoutId) + ) + { + book.BookData.RemoveAllFormsAndDataDivChildrenForDataBook(customLayoutId); + } + + var updatedPageElt = book.GetPage(pageId)?.GetDivNodeForThisPage(); + if (updatedPageElt != null) { - if (switchingToCustom) - pageElt.AddClass("bloom-customLayout"); + if (string.IsNullOrEmpty(backgroundAudio)) + updatedPageElt.RemoveAttribute(HtmlDom.musicAttrName); else - pageElt.RemoveClass("bloom-customLayout"); - // We must capture these from the saved page before typically replacing that with a different - // page element. - var backgroundAudio = pageElt.GetAttribute(HtmlDom.musicAttrName); - var backgroundAudioVolume = pageElt.GetAttribute(HtmlDom.musicVolumeName); - // Bring everything up to date consistent with the new - // state. Might be enough just do the BookData update. - book.EnsureUpToDateMemory(new NullProgress()); - // Toggling between custom and standard layout can replace the xMatter page HTML, - // so reapply branding QR-code HTML adjustments for the current book settings. - // This should not need to regenerate the QR code file. - book.UpdateQrCodeHtmlForCurrentSettings(updateQrCodeFileEvenIfItExists: false); - - if ( - shouldRemoveCustomLayoutDataWhenSwitchingToStandard - && !string.IsNullOrWhiteSpace(customLayoutId) - ) - { - book.BookData.RemoveAllFormsAndDataDivChildrenForDataBook(customLayoutId); - } + updatedPageElt.SetAttribute(HtmlDom.musicAttrName, backgroundAudio); - var updatedPageElt = book.GetPage(pageId)?.GetDivNodeForThisPage(); - if (updatedPageElt != null) - { - if (string.IsNullOrEmpty(backgroundAudio)) - updatedPageElt.RemoveAttribute(HtmlDom.musicAttrName); - else - updatedPageElt.SetAttribute(HtmlDom.musicAttrName, backgroundAudio); - - if (string.IsNullOrEmpty(backgroundAudioVolume)) - updatedPageElt.RemoveAttribute(HtmlDom.musicVolumeName); - else - updatedPageElt.SetAttribute( - HtmlDom.musicVolumeName, - backgroundAudioVolume - ); - - // Keep the same invariant we enforce elsewhere. - if (string.IsNullOrEmpty(backgroundAudio)) - updatedPageElt.RemoveAttribute(HtmlDom.musicVolumeName); - } + if (string.IsNullOrEmpty(backgroundAudioVolume)) + updatedPageElt.RemoveAttribute(HtmlDom.musicVolumeName); + else + updatedPageElt.SetAttribute(HtmlDom.musicVolumeName, backgroundAudioVolume); - return pageId; + // Keep the same invariant we enforce elsewhere. + if (string.IsNullOrEmpty(backgroundAudio)) + updatedPageElt.RemoveAttribute(HtmlDom.musicVolumeName); } - ); + + return pageId; + }); } private void HandleJumpToPage(ApiRequest request) @@ -661,9 +658,12 @@ private void HandleGetColorsUsedInBookCanvasElements(ApiRequest request) private void HandlePageDomLoaded(ApiRequest request) { - // we collect and pass on the pageId for bookkeeping purposes - var pageId = request.RequiredPostString(); - View.Model.HandlePageDomLoadedEvent(pageId); + // The browser sends " "; the load id identifies this particular load of + // the page, so that snapshots from a load we have moved on from can be ignored. + var parts = request.RequiredPostString().Split(' '); + var pageId = parts[0]; + var loadId = parts.Length > 1 ? parts[1] : null; + View.Model.HandlePageDomLoadedEvent(pageId, loadId); request.PostSucceeded(); } From 683b373ad68f5e1d58b55575a7b6370b9f460271 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Thu, 3 Sep 2026 18:41:01 -0500 Subject: [PATCH 05/31] Correct comments that still promise the removed ask-the-browser path (BL-13502) From the Devin triage: several comments still told the reader that when the browser cannot supply the page content, C# "falls back to asking" for it. That path was deleted in this branch -- C# uses the snapshot the browser last volunteered -- so the comments were pointing a future reader at machinery that no longer exists, which is exactly the sort of thing that misdirects a fix. Corrected in currentPageContent.ts (three places, including the give-up timer, which now correctly says the command goes ahead on the snapshot C# already holds), PageListController, and noIndent.ts. Comments that describe the old design in the past tense are deliberately left alone; they are history, not instructions. Also settled by this pass, with no change needed, so that the next reader does not re-investigate them: - The NoPage branch of ToSavedInPlaceThenNavigating does write the book: it goes through RunActionThenSaveAndNavigate, which calls _saveBook(). - savePageWithoutReloading already accepts either a JSON boolean or the string "true", so a text/plain reply does not make a good save look like a failure. - workspaceRoot does export getToolboxBundleExports, so the script C# injects has what it relies on. - Copy Page still leaves the copied page selected: the in-place branch is only taken when the page being copied IS the selection, and the navigating branch returns its id. Co-Authored-By: Claude Opus 5 (1M context) --- .../pageThumbnailList/currentPageContent.ts | 24 +++++++++---------- .../bookEdit/textContextMenu/noIndent.ts | 4 ++-- src/BloomExe/Edit/PageListController.cs | 9 +++---- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts b/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts index 4bcd493dde77..450e6da55504 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts @@ -4,10 +4,10 @@ import { getEditablePageBundleExports } from "../js/workspaceFrames"; // that will make C# save it. // // C# has to save the current page before it can change pages, duplicate one, delete one, and so -// on. Sending the content with the request lets it do all of that in one step. Otherwise it has -// to ask the browser for the content and wait for the answer to arrive on a separate API, and -// while it waits it is in a state where a further request of the same kind is silently thrown -// away. (See EditingModel.SavePageInPlaceThen.) +// on. Sending the content with the request lets it do that from the freshest possible copy: C# +// otherwise uses the last snapshot the browser volunteered, which can be up to the debounce +// interval old (see pageSnapshot.ts). Bloom used to have to ASK the browser and wait for the answer +// on a separate API, and that is what this replaced; there is no longer any such wait. // // This is async because it must NOT read the page while asynchronous work whose results belong in // the saved page is still running -- image sizing, canvas-element fitting, a clipboard paste. That @@ -18,18 +18,18 @@ import { getEditablePageBundleExports } from "../js/workspaceFrames"; // Because the whole command waits on this, the command cannot start mid-change either: C# is not // asked to duplicate, delete or reorder anything until the page has settled. // -// If we cannot collect it we return undefined and leave it out of the request; C# then falls back -// to asking. That is the honest thing to do for the cases where there is nothing to collect (no -// page loaded yet) or where the page is in a state we should not be reading (mid-navigation), -// rather than sending something half-formed: this content is about to be written into the user's -// book. +// If we cannot collect it we return undefined and leave it out of the request; C# then uses the +// snapshot the browser last volunteered. That is the honest thing to do for the cases where there +// is nothing to collect (no page loaded yet) or where the page is in a state we should not be +// reading (mid-navigation), rather than sending something half-formed: this content is about to be +// written into the user's book. // // The promise we await belongs to the PAGE frame. If that frame navigates while we are waiting, // its timers and microtask queue go with it and the promise simply never settles -- and since the // whole command is waiting on us, the command would be dropped without a trace, which is worse -// than doing it without the content. So we give up after a while and let C# ask for the content -// the old way. The timer is ours, in this frame, precisely so that it survives the page frame -// going away. +// than doing it without the content. So we give up after a while and let the command go ahead on +// the snapshot C# already holds. The timer is ours, in this frame, precisely so that it survives +// the page frame going away. const kGiveUpWaitingMs = 6000; // comfortably past the page frame's own 4s cap export async function collectCurrentPageContent( diff --git a/src/BloomBrowserUI/bookEdit/textContextMenu/noIndent.ts b/src/BloomBrowserUI/bookEdit/textContextMenu/noIndent.ts index 7f74477934dd..2c3cac0e1813 100644 --- a/src/BloomBrowserUI/bookEdit/textContextMenu/noIndent.ts +++ b/src/BloomBrowserUI/bookEdit/textContextMenu/noIndent.ts @@ -47,6 +47,6 @@ export function canToggleNoIndent(paragraph: HTMLElement): boolean { /** Turn "No Indent" on or off for this one paragraph. */ export function toggleNoIndent(paragraph: HTMLElement): void { paragraph.classList.toggle(kNoIndentClass); - // The class is plain content markup inside the bloom-editable, so it is saved with the - // page the next time Bloom asks the browser for the page content; nothing to post here. + // The class is plain content markup inside the bloom-editable, so the page snapshot picks it + // up like any other edit and it is saved with the page; nothing to post here. } diff --git a/src/BloomExe/Edit/PageListController.cs b/src/BloomExe/Edit/PageListController.cs index fd517a121b8f..417db84dc30c 100644 --- a/src/BloomExe/Edit/PageListController.cs +++ b/src/BloomExe/Edit/PageListController.cs @@ -48,10 +48,11 @@ private void OnPageSelectedChanged(object page, EventArgs e) // The only necessary action after saving is to go to the desired page, which is what // returning its ID from the first argument achieves. // - // When the click brought the outgoing page's content with it, SaveThen saves and goes - // in one step, so we never enter SavePending -- the state in which a further page click - // would be silently discarded. When it didn't, SaveThen asks the browser as it always - // did. + // The click usually brings the outgoing page's content with it, which is the freshest + // copy there is; when it does not, MergeCurrentPageThenSave uses the snapshot the + // browser last volunteered. Either way the save and the move happen in one step, with + // nothing to wait for in between -- which is what stopped a second page click being + // silently discarded. _model.MergeCurrentPageThenSave( () => pageId, pageContentFromBrowser: (e as PageSelectedChangedEventArgs)?.PageContentFromBrowser From 19d1cecb520c49ec93da8b741a853f028008a4ca Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 11:26:15 -0500 Subject: [PATCH 06/31] Tear a page down once, not twice, when leaving and returning to the Edit tab (BL-13502) From the Devin triage. pageUnloading() can be asked for twice on the same page document: leaving the Edit tab runs it (EditingView.OnHideEditTab, since nothing navigates the page frame then), and coming back re-navigates that frame, which runs it again from switchContentPage() before the new page replaces the document. The second run is not harmless. detachCurrentTool() does not forget the current tool after detaching it, so it detaches again -- and the second detach usually does not reach removeToolMarkup(), which makes detachToolFromPage() report the tool to the console for "forgetting" to call super.detachFromPage(). That accusation is false, and it points the reader at a tool that is behaving perfectly well. It now runs once per page document. Also settled by this pass, with no change needed. Recording them so the next reader does not spend the time again: - The above-page controls ARE unmounted when leaving the Edit tab: pageUnloading() calls resetAbovePageControls() along with the tool detach. - undoHighlightingFixes does not use an unescaped `#id` selector; it compares the id property precisely so a legacy id cannot throw and abort the page gather, and says so in a comment. - It also does not strip ui-disableHighlight from elements it did not add it to: fixHighlighting records, per element and keeping the first answer, whether the class was ours, and the undo honours that. - No stray Comical editing reaches the saved book -- verified in a real book's .htm, where there are none, only the exported . - Canvas-element alternates are NOT recorded when canvas editing is off; the work is guarded on isCanvasElementEditingOn. - Awaiting postThatMightNavigate cannot produce an unhandled rejection: wrapAxios catches it and returns normally when reporting is off. The silence there is deliberate, because a post that navigates away looks like a failure. - The page-list commands' wait on a promise owned by the page frame already has its own 6s give-up timer, in this frame, for exactly the case where that frame goes away. Co-Authored-By: Claude Opus 5 (1M context) --- src/BloomBrowserUI/bookEdit/js/bloomEditing.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index dbaa0f82a3c3..ff640a5de803 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -1648,7 +1648,22 @@ const userStylesheetContent = () => { .join("\n"); }; +// Whether this page has already been torn down. A page document only ever goes away once, but +// pageUnloading() can be ASKED for twice on the same one: leaving the Edit tab runs it (from +// EditingView.OnHideEditTab, since nothing navigates the page frame then), and coming back +// re-navigates that frame, which runs it again from switchContentPage() before the new page +// replaces this document. +// +// The second run is not harmless. detachCurrentTool() does not forget the current tool after +// detaching it, so it detaches again -- and the second detach usually does not reach +// removeToolMarkup(), which makes detachToolFromPage() report the tool for "forgetting" to call +// super.detachFromPage(). That accusation is false, and it points at a tool that is behaving +// perfectly well. +let thisPageHasBeenUnloaded = false; + export const pageUnloading = () => { + if (thisPageHasBeenUnloaded) return; + thisPageHasBeenUnloaded = true; // Stop volunteering snapshots of a page that is going away. C# clears its copy when it starts // navigating, so anything we sent after that would be for a page nobody is on. See // pageSnapshot.ts. From 256f49faf7b0f180f5fbdcdff98b178194ebc6fa Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 11:40:02 -0500 Subject: [PATCH 07/31] Never let one bad canvas element cost the user the whole page (BL-13502) From the Devin triage, and the most consequential thing it found. saveStateOfCanvasElementAsCurrentLangAlternate did JSON.parse on a canvas element's data-bubble with no guard. A missing attribute was the likelier way in, because the `?? ""` above it turns "no attribute" into JSON.parse(""), which throws. Malformed data does the same. That throw is not confined to one alternate: this runs inside the clone gather, so it aborts gathering the PAGE. The page then cannot be saved at all -- and since the browser now gathers after every change rather than only when saving, it would fail over and over. A guard now skips that one element instead. Skipping rather than recording an alternate with no tails is the conservative choice: an empty one would claim that language's copy has no tails and lose them when the user switched to it. Four tests cover it. Also assessed in this pass, with no change needed: - niceScroll's fingerprint (an inline overflow-y) really is unique to it: nothing in Bloom sets one on page content. The other overflow-y hits are local variables and emotion class rules, neither of which produces an inline style. - GameTool's undoPrepareActivity is safe on a detached clone. Decompiled from bloom-player: it is pure DOM surgery -- removeEventListener, removeAttribute, node removal -- with no offsetWidth/getBoundingClientRect/getComputedStyle in its body. Its one ancestor query, .swiper-slide, is bloom-player's carousel and is absent in the editor, so it returns null on the live page too. - The live page no longer having its canvas-element alternates refreshed on save does not matter: the only consumer, adjustCanvasElementsForCurrentLanguage, runs during page setup, and everything that changes the content languages navigates, so it always reads them from the saved book. - The rethink-page handler doing the save and navigation under the API sync lock does not deadlock: starting a navigation does not wait for the page to load, so the lock is released well before that page's own API calls arrive. - AGENTS.md is right to say new work targets Version6.5; this branch is the stated exception, confirmed by the developer. Residual, assessed but not proven either way, and worth a reviewer's eye: - pageUnloading is sent to the browser fire-and-forget while ClearBookForToolboxContent runs synchronously after it, so the ordering of the tool detach against the toolbox being cleared is not guaranteed. - The clone cleanup records canvas alternates BEFORE removing the tool's markup, the reverse of the old order. It matters only if a tool's markup can change a canvas element's style or tails, which is plausible for the game tool but which I could not demonstrate. Co-Authored-By: Claude Opus 5 (1M context) --- .../CanvasElementAlternates.test.ts | 76 +++++++++++++++++++ .../CanvasElementAlternates.ts | 24 +++++- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.test.ts 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..8fc1893b45fe 100644 --- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts +++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementAlternates.ts @@ -39,7 +39,29 @@ 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 canvas element with no data-bubble at all, or one we cannot read, must not cost the + // user the whole page. This runs inside the clone gather, so a throw here does not just + // skip one alternate: it aborts gathering the page, which means the page cannot be saved + // and -- since the browser now gathers after every change, not only when saving -- says so + // over and over. Note that the ?? "" above makes a MISSING attribute throw too, because + // JSON.parse("") is an error; that is the likelier of the two ways in. + // + // Skipping leaves any alternate already on the editable alone, which is the conservative + // choice: recording one with no tails would claim this language's copy has none, and lose + // the tails when the user switches to it. + 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") ?? "", From 261b38575dfa56d52ff64e703148b098601738b5 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 12:25:38 -0500 Subject: [PATCH 08/31] Let PageSnapshot decide which snapshots to believe (BL-13502) The rule that a snapshot is only kept if it comes from the load of the page we are showing lived in EditingModel, spread across a field it set and a check it made, and it was wrong: HandlePageDomLoadedEvent adopted the load id before deciding whether the notification was one we accepted. A "page is ready" notification from a page we had already left could therefore make us refuse every snapshot the user's actual page sent -- and since a refusal makes the browser offer the content again rather than give up, it stayed refused. We would hold nothing, and quitting would write nothing: not the last keystroke lost, but everything since the page loaded. PageSnapshot now owns the rule. AcceptSnapshotsFromLoad names the load whose snapshots count, Set returns false for anything else, and Clear forgets both the content and the load. EditingModel calls AcceptSnapshotsFromLoad only for a notification the state machine accepted. That also makes the ordering testable without an EditingModel, which is the point of the new PageSnapshotTests: snapshots arriving before the page reports ready, from a page we have left, and from before a reload of the same page, plus the late-notification case above. Co-Authored-By: Claude Opus 5 (1M context) --- src/BloomExe/Edit/PageSnapshot.cs | 46 +++++++- src/BloomTests/Edit/PageSnapshotTests.cs | 137 +++++++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/BloomTests/Edit/PageSnapshotTests.cs diff --git a/src/BloomExe/Edit/PageSnapshot.cs b/src/BloomExe/Edit/PageSnapshot.cs index 6e88ee9bcd23..bae397d7e86b 100644 --- a/src/BloomExe/Edit/PageSnapshot.cs +++ b/src/BloomExe/Edit/PageSnapshot.cs @@ -33,13 +33,29 @@ public class PageSnapshot private string _pageId; private string _content; + // The page LOAD whose snapshots we are willing to believe, as the browser identified it + // (getPageLoadId() in pageSnapshot.ts). Null between starting a navigation and the incoming + // page reporting itself ready, which is exactly the window in which no snapshot should be + // believed. + // + // The page id alone is not enough. Reloading the SAME page keeps it -- Change Layout, + // importing a video and changing the topic all rebuild a page under its own id -- so + // without this a snapshot posted moments before such a reload could be merged over what the + // reload built. + private string _loadWeAccept; + /// /// Record what the browser says the page currently contains. Called from the API handler, /// which deliberately does not take the server's sync lock — this only stores a string, and /// making the editor wait on a save in order to report its own content would defeat the /// point. /// - public void Set(string pageId, string content) + /// False if the snapshot is from a load we are not showing, so the browser knows + /// not to count it as delivered and offers it again. Silently dropping it would leave us + /// with nothing to save while the browser believed it had told us -- and the browser can + /// legitimately be early, because the snapshot API is not ordered against the notification + /// that a page has loaded. + public bool Set(string pageId, string loadId, string content) { if (string.IsNullOrEmpty(pageId)) throw new ArgumentException( @@ -48,8 +64,31 @@ public void Set(string pageId, string content) ); lock (_lock) { + if (_loadWeAccept == null || loadId != _loadWeAccept) + return false; _pageId = pageId; _content = content; + return true; + } + } + + /// + /// Believe snapshots from this page load, and no other, until the next navigation or the + /// next call here. + /// + /// Only ever called for a "page is ready" notification we ACCEPTED, i.e. one for the page + /// we are now editing. Those notifications arrive asynchronously, so one from a page we + /// have already left can turn up late; adopting its id would make us refuse every snapshot + /// the page the user is actually on sends, and because a refused snapshot is offered again + /// rather than dropped, it would go on refusing. We would then hold nothing for that page, + /// and leaving the tab or quitting would write nothing -- losing not the last keystroke but + /// everything since the page loaded. + /// + public void AcceptSnapshotsFromLoad(string loadId) + { + lock (_lock) + { + _loadWeAccept = loadId; } } @@ -80,6 +119,11 @@ public void Clear() { _pageId = null; _content = null; + // Forgetting which load we believe is what makes the clearing stick: until the + // incoming page reports itself ready, every snapshot that arrives belongs to the + // load we are leaving, and is refused rather than quietly refilling what we just + // cleared. + _loadWeAccept = null; } } } diff --git a/src/BloomTests/Edit/PageSnapshotTests.cs b/src/BloomTests/Edit/PageSnapshotTests.cs new file mode 100644 index 000000000000..07fcaa7b0f17 --- /dev/null +++ b/src/BloomTests/Edit/PageSnapshotTests.cs @@ -0,0 +1,137 @@ +using Bloom.Edit; +using NUnit.Framework; + +namespace BloomTests.Edit +{ + /// + /// Tests of PageSnapshot, which holds the copy of the page the BROWSER volunteered and decides + /// which such copies to believe. + /// + /// The interesting cases are all about ordering. The endpoint that receives a snapshot is + /// deliberately not synchronised (a keystroke has no business queueing behind a save) and is + /// not ordered against the notification that a page has finished loading, so snapshots can and + /// do arrive early, late, and from pages we have already left. + /// + [TestFixture] + public class PageSnapshotTests + { + private PageSnapshot _snapshot; + + [SetUp] + public void Setup() + { + _snapshot = new PageSnapshot(); + } + + /// The normal sequence: we navigate, the page reports itself loaded, then it sends content. + private void ArriveAtPage(string loadId) + { + _snapshot.Clear(); // navigation starts + _snapshot.AcceptSnapshotsFromLoad(loadId); // the page reports ready + } + + [Test] + public void Set_FromTheLoadWeAreShowing_IsKept() + { + ArriveAtPage("load-1"); + + Assert.That(_snapshot.Set("page-A", "load-1", "typed"), Is.True); + Assert.That(_snapshot.GetFor("page-A"), Is.EqualTo("typed")); + } + + [Test] + public void Set_BeforeThePageReportsReady_IsRefusedRatherThanKept() + { + // The browser can genuinely be first: its two calls are not ordered against each other. + // Refusing tells it to offer the content again; keeping it would file content under a + // load we know nothing about. + _snapshot.Clear(); + + Assert.That(_snapshot.Set("page-A", "load-1", "typed"), Is.False); + Assert.That(_snapshot.GetFor("page-A"), Is.Null); + } + + [Test] + public void Set_FromALoadWeHaveLeft_IsRefused() + { + ArriveAtPage("load-1"); + Assert.That(_snapshot.Set("page-A", "load-1", "first"), Is.True, "test setup"); + + ArriveAtPage("load-2"); // moved to another page + + Assert.That(_snapshot.Set("page-A", "load-1", "late"), Is.False); + Assert.That( + _snapshot.GetFor("page-A"), + Is.Null, + "the stale post must not refill what the navigation cleared" + ); + } + + [Test] + public void Set_AfterReloadingTheSamePage_RefusesThePreReloadContent() + { + // The case the load id exists for. Change Layout, importing a video and changing the + // topic all rebuild the page under its OWN id, so matching on the page id alone would + // let content from before the reload be merged over what the reload built. + ArriveAtPage("load-1"); + Assert.That(_snapshot.Set("page-A", "load-1", "before"), Is.True, "test setup"); + + ArriveAtPage("load-2"); // same page, rebuilt + + Assert.That(_snapshot.Set("page-A", "load-1", "before"), Is.False); + Assert.That(_snapshot.GetFor("page-A"), Is.Null); + + Assert.That(_snapshot.Set("page-A", "load-2", "after"), Is.True); + Assert.That(_snapshot.GetFor("page-A"), Is.EqualTo("after")); + } + + [Test] + public void AcceptSnapshotsFromLoad_LateNotificationDoesNotDisableTheCurrentPage() + { + // This is the ordering that mattered most, and the one that was wrong: a "page is + // ready" notification from a page we had already left arriving after the current page's + // own. If we adopt its id, every snapshot the user's actual page sends is refused -- + // and since a refusal makes the browser retry rather than give up, it stays refused. We + // would hold nothing, and quitting would write nothing: not the last keystroke lost, + // but everything since the page loaded. + // + // EditingModel is what enforces this, by only calling us for a notification it + // accepted; this test pins the consequence so the rule cannot be quietly dropped. + ArriveAtPage("load-2"); // we are on the second page + Assert.That(_snapshot.Set("page-B", "load-2", "typing"), Is.True, "test setup"); + + // The stale notification for the page we left must NOT be handed to us. If it were: + _snapshot.AcceptSnapshotsFromLoad("load-1"); + Assert.That( + _snapshot.Set("page-B", "load-2", "more typing"), + Is.False, + "this is what going wrong looks like -- the live page can no longer be saved" + ); + } + + [Test] + public void GetFor_AnotherPage_IsNull() + { + ArriveAtPage("load-1"); + _snapshot.Set("page-A", "load-1", "typed"); + + Assert.That(_snapshot.GetFor("page-B"), Is.Null); + } + + [Test] + public void Clear_ForgetsBothTheContentAndTheLoadWeBelieve() + { + ArriveAtPage("load-1"); + _snapshot.Set("page-A", "load-1", "typed"); + + _snapshot.Clear(); + + Assert.That(_snapshot.GetFor("page-A"), Is.Null); + Assert.That( + _snapshot.Set("page-A", "load-1", "typed again"), + Is.False, + "after a navigation we believe nothing until the next page reports ready" + ); + } + } +} From 6092b8835c996df6567e5610e997f8e5a935e64c Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 12:25:55 -0500 Subject: [PATCH 09/31] Write the book when the command changed it and the page did not (BL-13502) Skipping a write when nothing changed is what makes opening a page and touching nothing cost nothing. But the flag that decides it was set only by merging the page, and a page-list command runs its own action AFTER that merge. So a command used on a page the user had not typed on left the book looking clean at the very moment its action made it dirty, and SaveBookToDisk returned without writing: the action ran, the screen showed the result, and nothing reached disk until some later full save happened to pick it up. Changing the page size or orientation, choosing a different layout for a page, setting the copyright and licence, changing the levelled-reader level and re-reading a sign-language video from disk all went that way. Duplicate, Delete and Add Page did not, because they already asked for a full save. MergeCurrentPageThenSave cannot see inside the action it is given, so the action now says whether it changes the book -- and says so by default. That is the safe direction: a caller that changes the book and does not say so has its change written nowhere, whereas one that says so needlessly costs a write. Only the callers whose action merely names the page to go to next opt out, clicking a page thumbnail being the important one, since not writing an untouched page is the whole point. The parameter that used to carry this for two callers was called forceFullSave, which described the effect rather than the question, so it is now actionChangesTheBook. A test pins the default: flipping it would bring back every one of the cases above at once, silently. Co-Authored-By: Claude Opus 5 (1M context) --- src/BloomExe/Edit/EditingModel.cs | 115 +++++++++++------- src/BloomExe/Edit/PageListController.cs | 4 + src/BloomExe/Edit/PageThumbnailList.cs | 1 - .../web/controllers/EditingViewApi.cs | 7 +- src/BloomTests/Edit/EditingModelTests.cs | 39 ++++++ 5 files changed, 120 insertions(+), 46 deletions(-) diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index 1508948c2b04..ed083d47646d 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -196,11 +196,15 @@ ITemplateFinder sourceCollectionsList //shown so the view has never been full constructed, so we're not in a good state to do a refresh if (Visible) { - MergeCurrentPageThenSave(() => - { - _view.UpdatePageList(false); - return _pageSelection.CurrentSelection.Id; - }); + MergeCurrentPageThenSave( + () => + { + _view.UpdatePageList(false); + return _pageSelection.CurrentSelection.Id; + }, + // Refreshing the thumbnails changes nothing in the book. + actionChangesTheBook: false + ); } }); _contentLanguages = new List(); @@ -514,7 +518,6 @@ private void DuplicatePageInternal( } return newPageId; }, - forceFullSave: true, pageContentFromBrowser: pageContentFromBrowser ); } @@ -551,7 +554,6 @@ internal void DeletePage(IPage page, string pageContentFromBrowser = null) return page.Id; // stay on this page. } }, - forceFullSave: true, pageContentFromBrowser: pageContentFromBrowser ); } @@ -656,7 +658,6 @@ private void InsertPage(object page, PageInsertEventArgs e, string pageContentFr Logger.WriteEvent("InsertTemplatePage"); return newPageId; }, - forceFullSave: true, pageContentFromBrowser: pageContentFromBrowser ); } @@ -976,7 +977,6 @@ void StartNavigationToEditPage(IPage page) // page reports itself ready, every snapshot that arrives belongs to the load we are // leaving, and is refused rather than quietly refilling what we just cleared. _pageSnapshot.Clear(); - _currentPageLoadId = null; try { if (page == null) @@ -1599,6 +1599,10 @@ internal void SavePageAndReloadIt( _nextSaveMustBeFull |= forceFullSave; MergeCurrentPageThenSave( () => _pageSelection.CurrentSelection.Id, + // The action just names the page to come back to. Whatever our caller changed + // before getting here has already said so -- forceFullSave above, or + // _pageHasUnsavedDataDerivedChange, which the merge reads. + actionChangesTheBook: false, pageContentFromBrowser: pageContentFromBrowser ); } @@ -1665,6 +1669,12 @@ private bool CannotSavePage() /// arriving from inside another one's changeBookBeforeWriting. Most callers have nothing /// useful to do then and omit it; the ones that do are finishing something they had already /// started, like clearing a dialog's spinner. + /// Whether changeBookBeforeWriting changes the book. + /// We cannot see inside it, so it has to say. It defaults to TRUE because that is the safe + /// answer: a caller that changes the book and does not say so has its change written + /// nowhere, whereas one that says so needlessly costs a write. Only the callers whose + /// action merely names the page to go to next -- clicking a thumbnail is the important one, + /// and the whole point of not saving an untouched page -- pass false. /// The current page's content, when the request that /// got us here brought it along (see getPageContentForSaveWhenReady() in the browser). /// Otherwise we use whatever the browser last volunteered; a null snapshot is a positive @@ -1672,14 +1682,14 @@ private bool CannotSavePage() public void MergeCurrentPageThenSave( Func changeBookBeforeWriting, Action ifNotInAStateToSave = null, - bool forceFullSave = false, + bool actionChangesTheBook = true, string pageContentFromBrowser = null ) { var outcome = SavePageInPlaceThen( pageContentFromBrowser ?? CurrentPageSnapshotOrNull, changeBookBeforeWriting, - forceFullSave + actionChangesTheBook ); // Declined is the only outcome where nothing at all happened -- changeBookBeforeWriting // has NOT run -- so it is the only one where the caller's fallback is the right @@ -1690,12 +1700,6 @@ public void MergeCurrentPageThenSave( ifNotInAStateToSave?.Invoke(); } - // The load of the page we are currently showing, as the browser identified it when it - // reported the page ready (see getPageLoadId() in pageSnapshot.ts). Null between starting a - // navigation and the new page reporting in, which is exactly the window in which no - // snapshot should be believed. - private string _currentPageLoadId; - /// /// Called by the editView/pageSnapshot API when the browser volunteers the current content /// of the page. All we do is remember it; see PageSnapshot for why. @@ -1714,10 +1718,7 @@ public void MergeCurrentPageThenSave( /// the browser believed it had told us. public bool ReceivePageSnapshot(string pageId, string loadId, string pageContentData) { - if (_currentPageLoadId == null || loadId != _currentPageLoadId) - return false; // a load we have moved on from, or one not yet registered - _pageSnapshot.Set(pageId, pageContentData); - return true; + return _pageSnapshot.Set(pageId, loadId, pageContentData); } /// @@ -1873,7 +1874,7 @@ public bool SavePageInPlace(string pageContentData, bool forceFullSave = false) private InPlaceSaveOutcome SavePageInPlaceThen( string pageContentData, Func changeBookBeforeWriting, - bool forceFullSave = false + bool actionChangesTheBook ) { if (CannotSavePage() || !_havePageToSave) @@ -1885,15 +1886,25 @@ private InPlaceSaveOutcome SavePageInPlaceThen( if (_reloadFromDiskOnLeavingEditTab) return InPlaceSaveOutcome.Refused; - _nextSaveMustBeFull |= forceFullSave; - - // With no content to merge, the caller's action is the only thing that can change the - // book -- and adding, duplicating or relaying out a page certainly does. We cannot know - // which pages it will touch, so the write must happen and must be a full one; the - // per-page fast path needs a modified page element we do not have. (Leaving this to - // _modifiedPageElement meant the action ran and nothing was written at all: the change - // lived only in memory until some later full save happened to pick it up.) - if (pageContentData == null) + // The caller's action runs between the merge and the write, and adding, duplicating, + // deleting or relaying out a page certainly changes the book. We cannot know which + // pages such an action touches, so the write must happen and must be a full one; the + // per-page fast path needs a modified page element we do not have. + // + // Merging the page cannot decide this for us, and that is the trap: the merge marks the + // book dirty only when the page's own content differs from what the book already holds, + // so a command used on a page the user never edited leaves the book looking clean at + // the very moment the action is about to make it dirty -- and SaveBookToDisk then + // returns without writing: the action ran, the screen showed the result, and nothing + // reached disk until some later full save happened to pick it up. Changing the page + // size or orientation, choosing a different layout for a page, setting the copyright + // and licence, and re-reading a sign-language video from disk all went that way when + // used on a page the user had not typed on. + // + // Null content says the same thing from the other direction: there is no merge at all, + // so nothing else can mark the book dirty. (It also means the page has not changed + // since it loaded -- see PageSnapshot -- rather than that we have not been told yet.) + if (actionChangesTheBook || pageContentData == null) { _bookDomHasUnwrittenChanges = true; _nextSaveMustBeFull = true; @@ -2314,14 +2325,11 @@ public void CopyPage(IPage page, string pageContentFromBrowser = null) takeTheSnapshot(); return; } - MergeCurrentPageThenSave( - () => - { - takeTheSnapshot(); - return page.Id; - }, - forceFullSave: true - ); + MergeCurrentPageThenSave(() => + { + takeTheSnapshot(); + return page.Id; + }); } /// @@ -2386,10 +2394,19 @@ public UrlPathString AddWidgetFilesToBookFolder(string fullWidgetPath) public void HandlePageDomLoadedEvent(string pageId, string loadId = null) { - // From now until the next page reports itself loaded, this is the only load whose - // snapshots we will accept. See ReceivePageSnapshot. - _currentPageLoadId = loadId; var nowEditing = _stateMachine.ToEditing(pageId); + // Adopt the load id ONLY for a notification we accepted, which means it is for the page + // we are now editing. These arrive asynchronously, so one from a page we have already + // moved on from can turn up late; taking its id would make us refuse every snapshot the + // page the user is actually on sends, and since a refused snapshot is retried rather + // than dropped, it would go on refusing. C# would then hold nothing for that page, and + // leaving the tab or quitting would write nothing -- losing not the last keystroke but + // everything since the page loaded. (Devin caught this.) + // + // From here until the next page reports itself loaded, this is the only load whose + // snapshots we accept. See ReceivePageSnapshot. + if (nowEditing) + _pageSnapshot.AcceptSnapshotsFromLoad(loadId); // If we are in the middle of the "Update Book" per-page pass, a page finishing loading // (which means the edit-tab page setup code has run on it) is our cue to save it and // move on to the next page. See StartUpdatingAllPages(). @@ -2453,7 +2470,13 @@ public void StartUpdatingAllPages() // We are already in the Edit tab. Kick off the chain by navigating to the first page. // (MergeCurrentPageThenSave saves whatever page is showing, then navigates.) var firstPageId = _pageUpdateOrder[0]; - MergeCurrentPageThenSave(() => firstPageId, () => FinishUpdatingAllPages()); + MergeCurrentPageThenSave( + () => firstPageId, + () => FinishUpdatingAllPages(), + // The action only names the page to show; FinishUpdatingAllPages is the + // could-not-save fallback, not the change. + actionChangesTheBook: false + ); } else { @@ -2479,7 +2502,11 @@ private void AdvanceUpdatingAllPages(string loadedPageId) // Save the page we just visited (persisting the edit-tab setup that ran on it) and // move on. Reusing the normal save-then-navigate cycle means each page gets exactly // the treatment it would if the user clicked it in the Edit tab. - MergeCurrentPageThenSave(() => nextPageId, () => FinishUpdatingAllPages()); + MergeCurrentPageThenSave( + () => nextPageId, + () => FinishUpdatingAllPages(), + actionChangesTheBook: false + ); } else { diff --git a/src/BloomExe/Edit/PageListController.cs b/src/BloomExe/Edit/PageListController.cs index 417db84dc30c..d73b13f0760d 100644 --- a/src/BloomExe/Edit/PageListController.cs +++ b/src/BloomExe/Edit/PageListController.cs @@ -55,6 +55,10 @@ private void OnPageSelectedChanged(object page, EventArgs e) // silently discarded. _model.MergeCurrentPageThenSave( () => pageId, + // Clicking a thumbnail changes nothing in the book: the action just names the page + // to go to. This is the case the whole "do not write a page nobody edited" + // optimisation exists for, so it must not claim the book changed. + actionChangesTheBook: false, pageContentFromBrowser: (e as PageSelectedChangedEventArgs)?.PageContentFromBrowser ); } diff --git a/src/BloomExe/Edit/PageThumbnailList.cs b/src/BloomExe/Edit/PageThumbnailList.cs index 48c5769e7c98..96952d7400c9 100644 --- a/src/BloomExe/Edit/PageThumbnailList.cs +++ b/src/BloomExe/Edit/PageThumbnailList.cs @@ -341,7 +341,6 @@ internal void PageMoved( PageSelectedChanged(movedPage, new EventArgs()); return movedPage.Id; }, - forceFullSave: true, pageContentFromBrowser: pageContentFromBrowser ); } diff --git a/src/BloomExe/web/controllers/EditingViewApi.cs b/src/BloomExe/web/controllers/EditingViewApi.cs index c85bfbc7293c..59f587a00c7b 100644 --- a/src/BloomExe/web/controllers/EditingViewApi.cs +++ b/src/BloomExe/web/controllers/EditingViewApi.cs @@ -354,7 +354,12 @@ private void HandleJumpToPage(ApiRequest request) { var pageId = request.GetPostStringOrNull(); request.PostSucceeded(); - View.Model.MergeCurrentPageThenSave(() => pageId, () => { }); + View.Model.MergeCurrentPageThenSave( + () => pageId, + () => { }, + // The action only names the page to go to. + actionChangesTheBook: false + ); } /// diff --git a/src/BloomTests/Edit/EditingModelTests.cs b/src/BloomTests/Edit/EditingModelTests.cs index 6706f0badb2e..416dd86797a8 100644 --- a/src/BloomTests/Edit/EditingModelTests.cs +++ b/src/BloomTests/Edit/EditingModelTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Reflection; using Bloom; using Bloom.Edit; using Bloom.SafeXml; @@ -37,6 +38,44 @@ private static string FindSrcMatchedFor(string wantedFileName, params string[] s return found?.GetAttribute("src"); } + /// + /// MergeCurrentPageThenSave cannot see inside the action it is given, so the action has to + /// declare whether it changes the book -- and the DEFAULT has to be "yes". + /// + /// This is not a style preference. Getting it wrong in the "yes" direction costs a write + /// that was not needed. Getting it wrong in the "no" direction means the action runs, the + /// screen shows the result, and nothing is written: the merge marks the book dirty only + /// when the page's own content changed, so a command used on a page the user never typed on + /// leaves the book looking clean at the moment the action makes it dirty. That is how + /// changing the page size, choosing a different layout and setting the copyright quietly + /// failed to reach disk. Flipping this default would bring all of that back at once, in + /// every caller, silently -- hence a test on the default itself. + /// + [Test] + public void MergeCurrentPageThenSave_ActionChangesTheBook_DefaultsToTrue() + { + var parameter = typeof(EditingModel) + .GetMethod(nameof(EditingModel.MergeCurrentPageThenSave)) + .GetParameters() + .SingleOrDefault(p => p.Name == "actionChangesTheBook"); + + Assert.That( + parameter, + Is.Not.Null, + "test setup: MergeCurrentPageThenSave should still have an actionChangesTheBook parameter" + ); + Assert.That( + parameter.HasDefaultValue, + Is.True, + "actionChangesTheBook should be optional, so that a new caller gets the safe answer without thinking about it" + ); + Assert.That( + parameter.DefaultValue, + Is.True, + "actionChangesTheBook must default to TRUE: a caller that changes the book and does not say so has its change written nowhere" + ); + } + [Test] public void ImgWithSrcXPath_PlainSrc_Matches() { From 880d056fade91bb341d6b628e45b515841a4095a Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 12:26:13 -0500 Subject: [PATCH 10/31] Stop watching the page while a game is being played (BL-13502) Gathering the page for a save is meant to be invisible: we clone the body and clean the clone. One thing in it is not. The clone is handed to the current toolbox tool to take its markup off, and the game tool does that by calling bloom-player's undoPrepareActivity() -- which does not confine itself to the element it is given. prepareActivity() recorded the LIVE draggables and where they started, and undoing restores those elements whatever element it is passed, as well as pausing whatever sound is playing. On the live page that is exactly what leaving the Play tab wants. On a clone taken while the user is still playing it snaps their dragged items back to the start. And since the browser volunteers a snapshot whenever the page changes, and dragging an item changes the page, every drag undid itself about 25ms later: a drag activity could not be played in the editor at all. So the page frame now stops volunteering snapshots while the game tool is in its Play tab, and starts again when it leaves. Nothing is lost by that: nothing that happens in play mode belongs in the book, an explicit save gathers directly rather than through the snapshot, and anything that did change while we were not watching is picked up by the snapshot taken on resuming. undoPrepareActivityContract.spec.ts pins the dependency's behaviour rather than ours, so that if a bloom-player bump ever confines undoPrepareActivity to the element it is given, we find out and can drop the workaround. Co-Authored-By: Claude Opus 5 (1M context) --- src/BloomBrowserUI/bookEdit/editablePage.ts | 8 ++ .../bookEdit/js/pageSnapshot.ts | 39 ++++++++++ .../bookEdit/toolbox/games/GameTool.tsx | 11 +++ .../games/undoPrepareActivityContract.spec.ts | 77 +++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/games/undoPrepareActivityContract.spec.ts diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts index ae37d8a04da1..0e1c03eba8e3 100644 --- a/src/BloomBrowserUI/bookEdit/editablePage.ts +++ b/src/BloomBrowserUI/bookEdit/editablePage.ts @@ -19,6 +19,7 @@ import { kCanvasElementSelector } from "./toolbox/canvas/canvasElementConstants" import { renderDragActivityTabControl } from "./js/AbovePageControls"; import { getPageLoadId, + setSnapshotsSuspended, startWatchingPageForSnapshots, } from "./js/pageSnapshot"; @@ -62,6 +63,10 @@ export interface IPageFrameExports { // disturbing the live page. getPageContentForSaveWhenReady(): Promise; pageUnloading(): void; + // Stop/start volunteering snapshots of the page. The game tool uses this while the page is in + // its Play tab; see setSnapshotsSuspended in js/pageSnapshot.ts for why gathering is not free + // there. + setSnapshotsSuspended(reason: string | undefined): void; copySelection(): void; cutSelection(): void; pasteClipboard(): void; @@ -158,6 +163,7 @@ export { savePageWithoutReloading, captureContentForExternalProcessing, pageUnloading, + setSnapshotsSuspended, topBarButtonClick, copySelection, cutSelection, @@ -421,6 +427,7 @@ interface EditablePageBundleApi { captureContentForExternalProcessing: typeof captureContentForExternalProcessing; getPageContentForSaveWhenReady: typeof getPageContentForSaveWhenReady; pageUnloading: typeof pageUnloading; + setSnapshotsSuspended: typeof setSnapshotsSuspended; copySelection: typeof copySelection; cutSelection: typeof cutSelection; pasteClipboard: typeof pasteClipboard; @@ -499,6 +506,7 @@ window.editablePageBundle = { captureContentForExternalProcessing, getPageContentForSaveWhenReady, pageUnloading, + setSnapshotsSuspended, copySelection, cutSelection, pasteClipboard, diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index 6dd21cc1d6b6..85aa947b254e 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -87,6 +87,38 @@ let changeCount = 0; // The page we have already complained about, so that a page which fails every time reports once // rather than on every keystroke. let pageWeReportedAFailureFor: string | undefined; +// Set while the page is in a mode where gathering it would disturb what the user is doing. See +// setSnapshotsSuspended. +let suspendedFor: string | undefined; + +/** + * Stop volunteering snapshots, or start again. Pass a short reason to suspend, undefined to resume. + * + * We gather by cloning the body and cleaning the CLONE, so gathering is normally invisible. There + * is one exception, and it is the reason this exists: the toolbox tool is asked to take its markup + * off the clone, and the game tool does that by calling bloom-player's undoPrepareActivity(), which + * is NOT confined to the element it is given -- it restores the positions bloom-player recorded + * when play mode began, on the LIVE elements, whichever element we hand it. + * + * On the live page that is exactly right, and it is what leaving the Play tab does. On a clone it + * is destructive: it snaps the items the user has dragged back to where they started. Since we + * gather whenever the page changes, and dragging an item changes the page, the game became + * unplayable in the editor -- every drag undid itself a moment later. + * + * Suspending loses nothing. Nothing that happens in play mode belongs in the book, an explicit + * save still gathers directly rather than through us, and any change made while we were suspended + * is picked up by the snapshot we take on resuming. + */ +export function setSnapshotsSuspended(reason: string | undefined): void { + suspendedFor = reason; + if (reason) { + if (timer !== undefined) window.clearTimeout(timer); + timer = undefined; + } else { + // Whatever changed while we were suspended still owes C# a snapshot. + scheduleSnapshot(); + } +} function currentPageId(): string | undefined { return document.querySelector(".bloom-page")?.id || undefined; @@ -95,6 +127,8 @@ function currentPageId(): string | undefined { async function takeSnapshot(): Promise { const pageId = pageIdBeingWatched; if (!pageId || !gatherPageContent) return; + // Not while gathering would disturb the live page; resuming takes one. + if (suspendedFor) return; if (!baselineTaken) { // The page is still finishing loading. Come back once we know what "unchanged" looks like. scheduleSnapshot(); @@ -180,6 +214,7 @@ async function takeSnapshot(): Promise { } function scheduleSnapshot(): void { + if (suspendedFor) return; // setSnapshotsSuspended takes one when it resumes if (timer !== undefined) window.clearTimeout(timer); timer = window.setTimeout(() => { timer = undefined; @@ -208,6 +243,10 @@ export function startWatchingPageForSnapshots( changeCount = 0; baselineTaken = false; pageWeReportedAFailureFor = undefined; + // Deliberately NOT clearing suspendedFor: page setup can put a game page straight into its + // Play tab (the tab is remembered per page), and that suspension may well be set before we + // are started. Each page load is a fresh document, and so a fresh copy of this module, so + // there is nothing to inherit from the page we left. // Take a baseline of the page as it ends up once it has finished loading, and treat that as // "already sent". Without it every page posts a snapshot within a second of being opened, even diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx index 6b6bf011b838..88aa03e9063f 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/GameTool.tsx @@ -1995,6 +1995,14 @@ export function setActiveDragActivityTab(tab: number) { const canvasElementManager = getCanvasElementManager(); if (effectiveTab === playTabIndex) { canvasElementManager!.suspendComicEditing("forGamePlayMode"); + // Stop the page frame volunteering snapshots while we are in play mode. Gathering the page + // asks the current tool to take its markup off the CLONE, and ours does that by calling + // undoPrepareActivity() -- which does not confine itself to the element it is given: it + // puts the LIVE draggables back where prepareActivity() found them. Gathering happens + // whenever the page changes, and dragging an item changes the page, so without this every + // drag undid itself a moment later and the game could not be played. Nothing that happens + // in play mode belongs in the book, so there is nothing to lose by not watching. + pageFrameExports?.setSnapshotsSuspended("game play mode"); // Enhance: perhaps the next/prev page buttons could do something even here? // If so, would we want them to work only in TryIt mode, or always? prepareActivity(page, (_next) => { @@ -2038,6 +2046,9 @@ export function setActiveDragActivityTab(tab: number) { //Slider: wrapper?.removeEventListener("click", designTimeClickOnSlider); } else { undoPrepareActivity(page); + // Here undoPrepareActivity IS being given the live page, which is what it expects, so the + // page is now genuinely out of play mode and safe to gather again. + pageFrameExports?.setSnapshotsSuspended(undefined); canvasElementManager?.resumeComicEditing(); canvasElementManager?.checkActiveElementIsVisible(); //Slider: wrapper?.addEventListener("click", designTimeClickOnSlider); diff --git a/src/BloomBrowserUI/bookEdit/toolbox/games/undoPrepareActivityContract.spec.ts b/src/BloomBrowserUI/bookEdit/toolbox/games/undoPrepareActivityContract.spec.ts new file mode 100644 index 000000000000..0c2cab1da395 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/games/undoPrepareActivityContract.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { prepareActivity, undoPrepareActivity } from "bloom-player"; + +// Pins the one thing about bloom-player that the game tool's save path has to work around. +// +// GameTool.removeToolMarkup() is handed a CLONE of the page when Bloom gathers the page to save +// it, and calls undoPrepareActivity() on it to take play-mode markup off that copy. That reads as +// if it could not affect the live page. It can: prepareActivity() records the live draggables and +// where they started in bloom-player's own module state, and undoPrepareActivity() restores THOSE +// elements, whatever element it is given. +// +// That is right when it is given the live page (leaving the Play tab), and destructive when it is +// given a clone while the user is still playing -- it snaps their dragged items back. Since Bloom +// gathers the page whenever the page changes, and dragging changes the page, that made a drag +// activity unplayable in the editor. The fix is to stop gathering while in play mode +// (setSnapshotsSuspended in bookEdit/js/pageSnapshot.ts). +// +// So this is a dependency test, not a test of our own code: if a bloom-player bump ever confines +// undoPrepareActivity to the element it is given, the first test here fails, and that is the +// signal that the suspension can go. +describe("bloom-player's undoPrepareActivity, as the save path depends on it", () => { + const authoredLeft = "99px"; + const authoredTop = "88px"; + + let livePage: HTMLElement; + let liveDraggable: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ` +
+
+
`; + livePage = document.getElementsByClassName( + "bloom-page", + )[0] as HTMLElement; + liveDraggable = document.querySelector( + "[data-draggable-id]", + ) as HTMLElement; + + prepareActivity(livePage, () => { + /* nothing to do */ + }); + + // The user drags the item somewhere and it lands on its target. + liveDraggable.style.left = "5px"; + liveDraggable.style.top = "6px"; + liveDraggable.classList.add("bloom-draggedToTarget"); + + // Sanity check: the drag really did move it, so a restored position below means + // undoPrepareActivity moved it back rather than that it never moved. + expect(liveDraggable.style.left).toBe("5px"); + expect(liveDraggable.classList.contains("bloom-draggedToTarget")).toBe( + true, + ); + }); + + it("undoes the LIVE page even when it is given only a clone", () => { + undoPrepareActivity(livePage.cloneNode(true) as HTMLElement); + + expect(liveDraggable.style.left).toBe(authoredLeft); + expect(liveDraggable.style.top).toBe(authoredTop); + expect(liveDraggable.classList.contains("bloom-draggedToTarget")).toBe( + false, + ); + }); + + it("puts the live page back where play mode found it when given the live page", () => { + undoPrepareActivity(livePage); + + expect(liveDraggable.style.left).toBe(authoredLeft); + expect(liveDraggable.style.top).toBe(authoredTop); + expect(liveDraggable.classList.contains("bloom-draggedToTarget")).toBe( + false, + ); + }); +}); From a88a269695c947f74717681c6b76f15cf2f7188d Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 12:26:37 -0500 Subject: [PATCH 11/31] Say when a save could not read the page, and stop the docs misdirecting (BL-13502) saveChangesAndRethinkPage awaits the gather, and every caller does `void saveChangesAndRethinkPage()`, so a gather that threw became an unhandled rejection -- and the global handler for those is commented out, so it was silent. The user would be left looking at a restructured page (a new origami layout, a video they had just imported) that was never saved and never rebuilt, with no hint that anything had gone wrong. It now reports the failure, the same way pageSnapshot.ts does for the failure the design cannot afford to be quiet about. (The post itself cannot reject: wrapAxios swallows it.) Two comments in currentPageContent.ts still told the reader that missing content makes C# ask the browser for it. That path is gone; C# saves the last snapshot the browser volunteered. SavingWithoutReloading.md ended with the analysis written before any of this was built, which said the state machine would keep a waiting state and a round trip for leaving the Edit tab and closing the collection. Measuring the debounce is what changed that answer, and both were converted. The exit section now says what shutdown actually does and what it gives up -- the last ~50ms of typing, because there is no mechanism left that could re-read the page at close time -- rather than implying the content is gathered afresh. The note at the top says plainly that the earlier sections are the reasoning that led here, not a description of the current code. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/js/bloomEditing.ts | 21 ++++- .../pageThumbnailList/currentPageContent.ts | 4 +- src/BloomExe/Edit/SavingWithoutReloading.md | 83 ++++++++++--------- 3 files changed, 64 insertions(+), 44 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index ff640a5de803..cc40b7069fff 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -25,6 +25,7 @@ import StyleEditor from "../StyleEditor/StyleEditor"; import OverflowChecker from "../OverflowChecker/OverflowChecker"; import BloomField from "../bloomField/BloomField"; import BloomNotices from "./bloomNotices"; +import { reportError } from "../../lib/errorHandler"; import BloomSourceBubbles from "../sourceBubbles/BloomSourceBubbles"; import BloomHintBubbles from "./BloomHintBubbles"; import { @@ -1442,10 +1443,28 @@ export async function savePageWithoutReloading(): Promise { // EditingModel.SavePageAndReloadIt. // // The post itself might navigate this very frame out from under us, hence postThatMightNavigate. +// +// Every caller does `void saveChangesAndRethinkPage()`, so nothing here may reject. The post +// cannot (wrapAxios swallows the rejection), but the gather can -- and a rejection nobody catches +// is silent, because the global unhandledrejection handler is commented out in lib/errorHandler.ts. +// The user would be left looking at a restructured page -- a new origami layout, a video they just +// imported -- that was never saved and never rebuilt, with no hint that anything went wrong. So we +// say so, the same way pageSnapshot.ts does for the failure it cannot afford to be quiet about. export async function saveChangesAndRethinkPage(): Promise { + let content: string; + try { + content = await getPageContentForSaveWhenReady(); + } catch (error) { + reportError( + "Bloom could not save your changes to this page: " + + (error instanceof Error ? error.message : String(error)), + error instanceof Error ? error.stack : undefined, + ); + return; + } await postThatMightNavigate( "common/saveChangesAndRethinkPageEvent", - await getPageContentForSaveWhenReady(), + content, ); } diff --git a/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts b/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts index 450e6da55504..46c0c8325bfa 100644 --- a/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts +++ b/src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts @@ -44,7 +44,7 @@ export async function collectCurrentPageContent( giveUp = window.setTimeout(() => { console.warn( `gave up waiting for the current page's content for ${whatFor} (the page frame ` + - `may have navigated away mid-wait); C# will ask the page frame for it instead.`, + `may have navigated away mid-wait); C# will save the last snapshot the browser volunteered.`, ); resolve(undefined); }, kGiveUpWaitingMs); @@ -56,7 +56,7 @@ export async function collectCurrentPageContent( } } catch (error) { console.warn( - `could not collect the current page's content for ${whatFor}; C# will ask the page frame for it instead.`, + `could not collect the current page's content for ${whatFor}; C# will save the last snapshot the browser volunteered.`, error, ); return undefined; diff --git a/src/BloomExe/Edit/SavingWithoutReloading.md b/src/BloomExe/Edit/SavingWithoutReloading.md index e370485f0246..85b944ceebff 100644 --- a/src/BloomExe/Edit/SavingWithoutReloading.md +++ b/src/BloomExe/Edit/SavingWithoutReloading.md @@ -1,18 +1,14 @@ # Saving a page without reloading it — what it enables -## A note on shape: this branch has to survive a long wait +## A note on shape: this document is part history -It will not merge for a while (it is too big a change to risk in the current release), so it is -written to be cheap to merge later rather than to be the most direct expression of each change. -Two rules follow from that, and they are worth keeping if you add to it: - -- **New behaviour goes in new files.** `pageContentDelays.ts`, `niceScrollCleanup.ts`, - `currentPageContent.ts`, `EditingStateMachine`'s new transitions, and the tests for all of them - are additions rather than edits. A new file cannot conflict with anything. -- **Don't reshape existing code to add to it.** What conflicts is a *changed* line, not an added - one — so an extra argument on a call beats hoisting its lambda into a named local, even when the - named local reads a little better on its own. That single choice took `EditingModel.cs` from 130 - changed lines to 37 and removed every reindentation. +The sections up to "The page snapshot" were written *before* the snapshot existed, while the branch +was still converting callers one at a time and waiting on a round trip. They are kept because they +record why each caller was converted (or deliberately not), and what the round trip actually cost, +measured. Read them as the reasoning that led here, not as a description of the current code: what +they call `SaveThen` is now `EditingModel.MergeCurrentPageThenSave`, and the waiting states they +discuss no longer exist. Where an earlier section and a later one disagree, the later one is what +was built. ## What changed @@ -473,16 +469,22 @@ pressure. None of that argues for a longer debounce, which would cost every user a bigger loss window to buy something the coalescing already provides. -### Why that matters for exit +### Why that mattered for exit + +`Shell.OnClosing` used to cancel the close (`e.Cancel = true`), start a save, and call `Close()` +again once the save came back — with `_startedClosingEvent` / `_finishedClosingEvent` guarding the +re-entry. All of that existed for one reason: the save could not complete synchronously, because it +had to ask the browser and wait for the answer on another API call. -`Shell.OnClosing` currently cancels the close (`e.Cancel = true`), starts a save, and calls -`Close()` again when it finishes — with `_startedClosingEvent` / `_finishedClosingEvent` guarding -the re-entry. All of that exists for one reason: the save could not complete synchronously, -because it had to ask the browser and wait. +A save that takes the snapshot **is** synchronous, so the dance is gone: `OnClosing` saves and lets +the close proceed. Nothing waits on the browser at shutdown, and so nothing can hang there. -A save that takes the snapshot IS synchronous. That makes the whole dance unnecessary: save, then -let the close proceed. What has to be accepted in exchange is that quitting could lose the last -~50 ms of typing rather than being guaranteed fresh. +What is accepted in exchange is stated plainly, because it is the one guarantee this design gives +up: the content written at exit is the last snapshot the browser posted, which is up to ~50 ms +behind the live page. Quitting within 50 ms of a keystroke can therefore miss that keystroke. It is +not "the page is re-read at close time" — there is no mechanism left that could re-read it. The +window is shorter than the hand movement that reaches for the X, and testing has not managed to hit +it, but it is a trade rather than an oversight. ### Would observing `.bloom-page` instead of the body be better? @@ -491,36 +493,35 @@ the gather clones the whole `document.body`, so a change outside `.bloom-page` c gets saved. An observer narrower than the thing being gathered can miss a real change. If the two are ever narrowed, they must be narrowed together. -**2. The state machine does not go away. It shrinks to one caller.** +**2. The two saves that can follow a keystroke were expected to keep the round trip. They did not.** A snapshot is up to `kQuietMs` plus a gather behind the live page. That is fine for a page click, -which cannot happen within a few hundred milliseconds of a keystroke. It is **not** fine for the -two saves that can: +which cannot happen within a few hundred milliseconds of a keystroke. The doubt was about the two +saves that can: - **leaving the Edit tab** — a tab click, possibly right after typing; - **closing the collection** — `Shell.OnClosing`, i.e. the window's X button, Alt+F4, or the OS shutting Bloom down. -Both would lose the last fraction of a second of typing if they read a snapshot. So both keep -asking the browser, and therefore `SavePending`, `SavedAndStripped`, `RequestBrowserToSave` and -`editView/pageContent` all survive to serve them. - -They are excluded automatically, because both are the only callers that pass `skipSaveToDisk`. -That is convenient rather than principled — see the comment in `SaveThen`. +The plan was to let those two keep asking the browser, which would have kept `SavePending`, +`SavedAndStripped`, `RequestBrowserToSave` and the `editView/pageContent` API alive to serve them, +and left the state machine at "one waiting state, one caller" rather than none. -Of the two, only the tab change could be converted: the tab strip lives in the workspace root, -which *can* reach the page frame (that is how the page list collects content). The window close -genuinely cannot start in Typescript — it arrives as a WinForms message, and `OnClosing` has to -cancel the close, save, and close again. It could keep one narrow round trip of its own, but that -is the state machine's waiting states surviving for a single caller rather than disappearing. +Measuring the debounce is what changed the answer. At 400 ms the round trip was clearly worth +keeping for those two; at 25 ms the freshness window is ~50 ms, which is below the time it takes to +move a hand from the keyboard to a tab or a close button. So both were converted after all: the +waiting states, `RequestBrowserToSave` and `editView/pageContent` are gone, and both paths are now +straight-line code. The state machine keeps only the states it needs to know *which page is being +edited and whether it is safe to act on it* — it no longer arbitrates a wait. -The C#-side alternative is not available: the only synchronous way to read Javascript is -`RunJavascriptWithStringResult_Sync_Dangerous`, which pumps the message loop, and Bloom has been +The C#-side alternative was never available in any case: the only synchronous way to read Javascript +is `RunJavascriptWithStringResult_Sync_Dangerous`, which pumps the message loop, and Bloom has been deliberately retreating from it (see `OffScreenBrowser`, `PublishHelper`). -## So: worth doing? +## The verdict -The round trip disappears from every ordinary editing action, which is real. But "the state machine -goes away" is not on offer without either accepting that quitting Bloom can lose the last few -hundred milliseconds of typing, or keeping a round trip for that one path. The honest shape is -"one waiting state, one caller" rather than none. +The round trip is gone from every save, including the two that were expected to keep it, and with it +the asynchronous machinery that existed only to wait: two state-machine states, the ask-the-browser +API and its browser half, and the shutdown kludge that cancelled the user's quit and re-issued it. +The price is a single, bounded one — the last ~50 ms of typing at exit — and it is paid only by the +exit path, not by the ordinary saves that happen all day. From b131a1596f27e8c2c15eb5b9dc94ea765bbd7dad Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 12:37:51 -0500 Subject: [PATCH 12/31] Do not count a snapshot as delivered when the post failed (BL-13502) postString goes through wrapAxios, which turns a rejected request into a resolved promise carrying nothing. So a failed post is indistinguishable from a successful one except that no response comes back -- and we were reading only `.data`, which took that for an acceptance. The content was recorded as sent and never offered again, and the next save would write what C# still held, losing everything typed since the snapshot before it. Delivery now requires an actual response that is not a refusal. The retry for both cases, and for a gather that throws, moved to a slower timer: nothing the user did causes any of them, so there is nothing to be responsive to, and retrying every 25ms against a server that is not answering would spin. A real change still reschedules at the normal interval and overtakes it. The existing test missed this because it modelled a failed post as a REJECTED promise, which is the one thing postString never gives us; and every other test's post resolved with nothing, which now means "not delivered", so the mock's successful reply had to become what C# really sends. The new test uses the realistic shape. Found by Devin. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/js/pageSnapshot.spec.ts | 53 ++++++++++++++++--- .../bookEdit/js/pageSnapshot.ts | 45 +++++++++++----- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts index b7af02a879a8..b11d14531bc7 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -3,17 +3,21 @@ import { startWatchingPageForSnapshots, stopWatchingPageForSnapshots, quietMsForTests, + retryMsForTests, getPageLoadId, } from "./pageSnapshot"; const posted: Array<{ url: string; body: string }> = []; // Lets a test hold a POST open, to check that a second one never starts alongside it. -let postHook: (() => Promise) | undefined; +let postHook: (() => Promise) | undefined; -// What C# answers. `{ data: false }` is a refusal: the snapshot was for a page load it is not -// showing, so the browser must not count it as delivered. -let postReply: unknown = undefined; +// What C# answers. A real post resolves to the axios response, and this endpoint answers with a +// boolean, so `{ data: true }` is an ordinary success. `{ data: false }` is a refusal: the snapshot +// was for a page load it is not showing. And `undefined` -- no response at all -- is what a FAILED +// post looks like, because postString goes through wrapAxios, which turns a rejected request into a +// resolved promise carrying nothing. +let postReply: unknown = { data: true }; const reported: string[] = []; vi.mock("../../lib/errorHandler", () => ({ @@ -46,6 +50,14 @@ async function letTheBaselineSettle() { await Promise.resolve(); } +// Walks the slower timer used to offer content again when C# did not take it. +async function letTheRetryHappen() { + vi.advanceTimersByTime(retryMsForTests); + await vi.runAllTicks(); + await Promise.resolve(); // the gather's await + await Promise.resolve(); // the post's await +} + // A MutationObserver delivers its callback in a microtask, and the module then waits kQuietMs. // This walks both forward. async function letTheSnapshotHappen() { @@ -63,7 +75,7 @@ describe("pageSnapshot", () => { reported.length = 0; contentToReport = ""; postHook = undefined; - postReply = undefined; + postReply = { data: true }; setUpPage(); }); @@ -184,12 +196,12 @@ describe("pageSnapshot", () => { let maxInFlight = 0; let releasePost: () => void = () => {}; postHook = () => - new Promise((resolve) => { + new Promise((resolve) => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); releasePost = () => { inFlight--; - resolve(); + resolve({ data: true }); // an ordinary successful post }; }); @@ -349,8 +361,33 @@ describe("pageSnapshot", () => { expect(posted.map((p) => p.body)).toEqual(["typed"]); // Refused, so the very same content must be offered again rather than treated as sent. + // Nothing the user did caused the refusal, so the retry is on the slower timer. postReply = { data: true }; - await letTheSnapshotHappen(); + await letTheRetryHappen(); expect(posted.map((p) => p.body)).toEqual(["typed", "typed"]); }); + + it("does not treat a post that failed outright as sent", async () => { + // The realistic shape of a failed post, and the one that nearly slipped through: postString + // goes through wrapAxios, which swallows the rejection and resolves with NOTHING. So a + // failed post is indistinguishable from a successful one except that no response comes + // back -- and reading only `.data` took that for an acceptance. The content was then + // recorded as sent and never offered again, and the next save wrote what C# still held. + contentToReport = "first"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + postReply = undefined; // the post failed; wrapAxios gives us nothing + contentToReport = "typed"; + changeThePage("typed"); + await letTheSnapshotHappen(); + expect(posted.map((p) => p.body)).toEqual(["typed"]); + + postReply = { data: true }; + await letTheRetryHappen(); + expect( + posted.map((p) => p.body), + "content C# never received must be offered again, not counted as sent", + ).toEqual(["typed", "typed"]); + }); }); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index 85aa947b254e..95b89608aa10 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -67,6 +67,12 @@ export function getPageLoadId(): string { // only stores the string, replacing the last one. const kQuietMs = 25; +// How long to wait before offering the content again when C# did not take it -- a refusal, or a +// post that failed. Longer than the debounce on purpose: nothing the user did causes these, so +// there is nothing to be responsive to, and a 25ms retry against a server that is not answering +// would be a busy loop. A real change reschedules at kQuietMs and so overtakes this. +const kRetryAfterFailedPostMs = 1000; + let observer: MutationObserver | undefined; let timer: number | undefined; let lastPosted: string | undefined; @@ -164,14 +170,26 @@ async function takeSnapshot(): Promise { )}`, content, ); - // C# refuses a snapshot from a load it is not showing -- including in the moment - // before this page has reported itself ready, since the two APIs are not ordered with - // respect to each other. A refusal is not a failure, but it does mean C# does not have - // this content, so we must not record it as sent and must offer it again. - const accepted = - (reply as { data?: boolean | string } | void)?.data !== false; - if (!accepted) { - scheduleSnapshot(); + // Two different things can mean C# does not have this content, and both must count as + // NOT sent: + // + // * C# refused it, answering false. It refuses a snapshot from a load it is not + // showing, including in the moment before this page has reported itself ready, since + // the two APIs are not ordered with respect to each other. A refusal is not a + // failure; it just means try again. + // * The POST failed and we got no answer at all. postString goes through wrapAxios, + // which turns a rejected request into a resolved promise carrying nothing -- so a + // failed post looks exactly like a successful one apart from the missing response. + // Reading only `.data` would therefore take a failure for an acceptance, record the + // content as sent, and never offer it again; the next save would write what C# still + // held, losing everything typed since the snapshot before. + const response = reply as { data?: boolean | string } | void; + const delivered = !!response && response.data !== false; + if (!delivered) { + // Slower than the normal debounce: if the server is not answering, retrying every + // 25ms would spin. Any change the user makes reschedules at the normal interval, + // so this only governs how fast we retry when nothing else is happening. + scheduleSnapshot(kRetryAfterFailedPostMs); return; } // Only once the post has actually resolved AND been taken. Recording it earlier would @@ -197,8 +215,10 @@ async function takeSnapshot(): Promise { error instanceof Error ? error.stack : undefined, ); } - // Try again on the next change: a transient failure should not stop us for good. - scheduleSnapshot(); + // Try again: a transient failure should not stop us for good. At the slower interval, + // for the same reason as a post C# did not take -- a page that fails to gather fails + // again immediately, and retrying every 25ms would spin. + scheduleSnapshot(kRetryAfterFailedPostMs); } finally { // Only release the lock if we are still the run that took it. If the page was unloaded // and another started while we were awaiting, this run belongs to the old page, and @@ -213,13 +233,13 @@ async function takeSnapshot(): Promise { if (changeCount !== countWhenStarted) scheduleSnapshot(); } -function scheduleSnapshot(): void { +function scheduleSnapshot(delayMs: number = kQuietMs): void { if (suspendedFor) return; // setSnapshotsSuspended takes one when it resumes if (timer !== undefined) window.clearTimeout(timer); timer = window.setTimeout(() => { timer = undefined; void takeSnapshot(); - }, kQuietMs); + }, delayMs); } function noteChange(): void { @@ -321,3 +341,4 @@ export function stopWatchingPageForSnapshots(): void { * Exported for tests: the interval the page must be quiet before a snapshot is taken. */ export const quietMsForTests = kQuietMs; +export const retryMsForTests = kRetryAfterFailedPostMs; From aaa2aaf87136cca800e0760c41faba9b3cc7c0b1 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 12:51:41 -0500 Subject: [PATCH 13/31] Bound the retries after a failed snapshot post (BL-13502) Retrying a failed post every second was itself a defect: wrapAxios reports every failed request, so a server that had stopped answering would put an error in front of the user once a second for as long as they stayed on the page. A refusal and a failure are now retried differently, because only one of them is loud. A refusal costs nothing and ends by itself the moment the page reports ready, so we keep offering at a steady second. A failure backs off -- 1s, 2s, 4s, 8s -- and then we stop asking on our own. The content is still not recorded as sent, so the next thing the user changes offers it again, which is how a recovering server gets it; and the case that would otherwise be lost, a failure followed by no further typing and then a quit, is by then several retries old. A gather that throws no longer schedules a retry at all. The gather is deterministic: a page that fails to gather fails again immediately, so a timer would only repeat the report we go to some trouble to make just once. Found by Devin, in the fix for its previous finding. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/js/pageSnapshot.spec.ts | 37 ++++++++++++ .../bookEdit/js/pageSnapshot.ts | 59 ++++++++++++++----- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts index b11d14531bc7..827febbe94b8 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -390,4 +390,41 @@ describe("pageSnapshot", () => { "content C# never received must be offered again, not counted as sent", ).toEqual(["typed", "typed"]); }); + + it("stops retrying a failing post rather than reporting an error for ever", async () => { + // Every failed post is reported to the user by wrapAxios. Retrying on a timer therefore + // cannot go on indefinitely, or a server that has stopped answering puts a dialog in front + // of the user for as long as they stay on the page. We back off and give up; the content + // is still not recorded as sent, so the next thing the user changes offers it again. + contentToReport = "first"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + postReply = undefined; // every post fails + contentToReport = "typed"; + changeThePage("typed"); + await letTheSnapshotHappen(); + expect(posted.length, "sanity: the first attempt happened").toBe(1); + + // Walk well past every backoff step (1s, 2s, 4s, 8s) and then some. + for (let i = 0; i < 12; i++) { + vi.advanceTimersByTime(60000); + await vi.runAllTicks(); + await Promise.resolve(); + await Promise.resolve(); + } + expect( + posted.length, + "the retries must be bounded, not one a second for ever", + ).toBeLessThanOrEqual(5); + const attemptsBeforeGivingUp = posted.length; + + // Giving up is not giving in: the next real change offers the content again. + postReply = { data: true }; + contentToReport = "typed more"; + changeThePage("typed more"); + await letTheSnapshotHappen(); + expect(posted.length).toBe(attemptsBeforeGivingUp + 1); + expect(posted[posted.length - 1].body).toBe("typed more"); + }); }); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index 95b89608aa10..b3a452af4fa4 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -67,11 +67,15 @@ export function getPageLoadId(): string { // only stores the string, replacing the last one. const kQuietMs = 25; -// How long to wait before offering the content again when C# did not take it -- a refusal, or a -// post that failed. Longer than the debounce on purpose: nothing the user did causes these, so -// there is nothing to be responsive to, and a 25ms retry against a server that is not answering -// would be a busy loop. A real change reschedules at kQuietMs and so overtakes this. -const kRetryAfterFailedPostMs = 1000; +// How long to wait before offering the content again when C# did not take it. Longer than the +// debounce on purpose: nothing the user did causes these, so there is nothing to be responsive to, +// and a 25ms retry against a server that is not answering would be a busy loop. A real change +// reschedules at kQuietMs and so overtakes this. +const kRetryAfterRefusalMs = 1000; + +// How many times to keep offering content after a post that FAILED (as opposed to one C# refused). +// Every failed attempt shows the user an error, so this cannot go on for ever; see takeSnapshot. +const kMaxFailedPostRetries = 4; let observer: MutationObserver | undefined; let timer: number | undefined; @@ -93,6 +97,8 @@ let changeCount = 0; // The page we have already complained about, so that a page which fails every time reports once // rather than on every keystroke. let pageWeReportedAFailureFor: string | undefined; +// How many posts in a row have failed outright. Governs the backoff, and how soon we stop asking. +let consecutiveFailedPosts = 0; // Set while the page is in a mode where gathering it would disturb what the user is doing. See // setSnapshotsSuspended. let suspendedFor: string | undefined; @@ -183,15 +189,36 @@ async function takeSnapshot(): Promise { // Reading only `.data` would therefore take a failure for an acceptance, record the // content as sent, and never offer it again; the next save would write what C# still // held, losing everything typed since the snapshot before. + // + // They are retried differently, because only one of them is loud. A refusal costs + // nothing and ends by itself the moment the page reports ready, so we simply keep + // offering. A failure is reported to the user by wrapAxios on every attempt, so a + // server that is not answering would put a dialog in front of the user every second + // for as long as they stayed on the page. That one backs off and gives up on its own. const response = reply as { data?: boolean | string } | void; - const delivered = !!response && response.data !== false; - if (!delivered) { - // Slower than the normal debounce: if the server is not answering, retrying every - // 25ms would spin. Any change the user makes reschedules at the normal interval, - // so this only governs how fast we retry when nothing else is happening. - scheduleSnapshot(kRetryAfterFailedPostMs); + const refused = !!response && response.data === false; + const failed = !response; + if (refused) { + consecutiveFailedPosts = 0; + scheduleSnapshot(kRetryAfterRefusalMs); + return; + } + if (failed) { + consecutiveFailedPosts++; + if (consecutiveFailedPosts <= kMaxFailedPostRetries) { + // 1s, 2s, 4s... so a long outage is quiet rather than a dialog a second. + scheduleSnapshot( + kRetryAfterRefusalMs * + Math.pow(2, consecutiveFailedPosts - 1), + ); + } + // Past that we stop asking on our own. lastPosted is still not set, so the very + // next thing the user changes offers this content again -- which is how a + // recovering server gets it, and the case we would otherwise lose (a failure + // followed by nothing at all, then a quit) is already several retries old. return; } + consecutiveFailedPosts = 0; // Only once the post has actually resolved AND been taken. Recording it earlier would // mean content C# never received still counted as sent: we would never retry it, and // the next save would write what C# still held, losing everything typed since. @@ -215,10 +242,9 @@ async function takeSnapshot(): Promise { error instanceof Error ? error.stack : undefined, ); } - // Try again: a transient failure should not stop us for good. At the slower interval, - // for the same reason as a post C# did not take -- a page that fails to gather fails - // again immediately, and retrying every 25ms would spin. - scheduleSnapshot(kRetryAfterFailedPostMs); + // Try again on the next change. Not on a timer: the gather is deterministic, so a page + // that failed to gather fails again immediately, and a timer would just repeat the report + // we have carefully arranged to make only once. } finally { // Only release the lock if we are still the run that took it. If the page was unloaded // and another started while we were awaiting, this run belongs to the old page, and @@ -263,6 +289,7 @@ export function startWatchingPageForSnapshots( changeCount = 0; baselineTaken = false; pageWeReportedAFailureFor = undefined; + consecutiveFailedPosts = 0; // Deliberately NOT clearing suspendedFor: page setup can put a game page straight into its // Play tab (the tab is remembered per page), and that suspension may well be set before we // are started. Each page load is a fresh document, and so a fresh copy of this module, so @@ -341,4 +368,4 @@ export function stopWatchingPageForSnapshots(): void { * Exported for tests: the interval the page must be quiet before a snapshot is taken. */ export const quietMsForTests = kQuietMs; -export const retryMsForTests = kRetryAfterFailedPostMs; +export const retryMsForTests = kRetryAfterRefusalMs; From 7ec0789a46f611c2125cf2bfbdf30fea1c41abdc Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 13:02:51 -0500 Subject: [PATCH 14/31] Keep offering a snapshot C# has not got, and say so only once (BL-13502) The previous commit stopped retrying a failed post after a few attempts, to stop the user being shown an error a second. That traded one defect for another: while the browser holds content C# has not got, quitting writes what C# still holds, so a server that recovers after the retries ran out loses whatever was typed before it broke. The two are only in tension because the request layer decides when to speak. So it no longer does: snapshot posts go through a new postStringQuietly, and pageSnapshot reports the failure itself, once per page. Retrying can then go on as long as it needs to -- backing off 1s, 2s, 4s up to 30s and staying there -- and a server that comes back gets the content with no further typing. A gather that throws now takes the same path. It is deterministic and will usually fail again, but that costs nothing now that it is silent after the first report, and it covers a failure that turns out to have depended on something transient in the page. Both halves are pinned by the test: it must keep offering, it must back off, and the user must be told exactly once. Found by Devin, on both sides -- it flagged the flooding, and then flagged the lost edits in the fix for it. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/js/pageSnapshot.spec.ts | 52 +++++++----- .../bookEdit/js/pageSnapshot.ts | 84 ++++++++++++------- src/BloomBrowserUI/utils/bloomApi.ts | 18 ++++ 3 files changed, 104 insertions(+), 50 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts index 827febbe94b8..8a09a19b29c2 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -15,7 +15,7 @@ let postHook: (() => Promise) | undefined; // What C# answers. A real post resolves to the axios response, and this endpoint answers with a // boolean, so `{ data: true }` is an ordinary success. `{ data: false }` is a refusal: the snapshot // was for a page load it is not showing. And `undefined` -- no response at all -- is what a FAILED -// post looks like, because postString goes through wrapAxios, which turns a rejected request into a +// post looks like, because postStringQuietly goes through wrapAxios, which turns a rejected request into a // resolved promise carrying nothing. let postReply: unknown = { data: true }; @@ -25,7 +25,7 @@ vi.mock("../../lib/errorHandler", () => ({ })); vi.mock("../../utils/bloomApi", () => ({ - postString: (url: string, body: string) => { + postStringQuietly: (url: string, body: string) => { posted.push({ url, body }); return postHook ? postHook() : Promise.resolve(postReply); }, @@ -368,7 +368,7 @@ describe("pageSnapshot", () => { }); it("does not treat a post that failed outright as sent", async () => { - // The realistic shape of a failed post, and the one that nearly slipped through: postString + // The realistic shape of a failed post, and the one that nearly slipped through: the post // goes through wrapAxios, which swallows the rejection and resolves with NOTHING. So a // failed post is indistinguishable from a successful one except that no response comes // back -- and reading only `.data` took that for an acceptance. The content was then @@ -391,11 +391,14 @@ describe("pageSnapshot", () => { ).toEqual(["typed", "typed"]); }); - it("stops retrying a failing post rather than reporting an error for ever", async () => { - // Every failed post is reported to the user by wrapAxios. Retrying on a timer therefore - // cannot go on indefinitely, or a server that has stopped answering puts a dialog in front - // of the user for as long as they stay on the page. We back off and give up; the content - // is still not recorded as sent, so the next thing the user changes offers it again. + it("keeps offering a failing post, backing off, and tells the user only once", async () => { + // Two things have to be true at the same time here, and they pull against each other. + // + // We must not stop retrying: while C# has not got this content, quitting writes what it + // still holds, so a server that comes back must be given the content even if the user + // never types again. But we must also not report the failure on every attempt, or an + // outage puts an error in front of the user again and again. Hence a quiet post and one + // report per page. contentToReport = "first"; startWatchingPageForSnapshots(gather); await letTheBaselineSettle(); @@ -406,25 +409,34 @@ describe("pageSnapshot", () => { await letTheSnapshotHappen(); expect(posted.length, "sanity: the first attempt happened").toBe(1); - // Walk well past every backoff step (1s, 2s, 4s, 8s) and then some. - for (let i = 0; i < 12; i++) { - vi.advanceTimersByTime(60000); + // A minute of outage, walked in 10s steps. + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(10000); await vi.runAllTicks(); await Promise.resolve(); await Promise.resolve(); } expect( posted.length, - "the retries must be bounded, not one a second for ever", - ).toBeLessThanOrEqual(5); - const attemptsBeforeGivingUp = posted.length; + "it must keep offering rather than give up", + ).toBeGreaterThan(1); + expect( + posted.length, + "backing off: a minute of outage must not mean a minute of attempts", + ).toBeLessThan(12); + expect( + reported.length, + "the user must be told once, not once per attempt", + ).toBe(1); - // Giving up is not giving in: the next real change offers the content again. + // When the server comes back, the content gets there with no further typing. postReply = { data: true }; - contentToReport = "typed more"; - changeThePage("typed more"); - await letTheSnapshotHappen(); - expect(posted.length).toBe(attemptsBeforeGivingUp + 1); - expect(posted[posted.length - 1].body).toBe("typed more"); + const attemptsWhileDown = posted.length; + vi.advanceTimersByTime(60000); + await vi.runAllTicks(); + await Promise.resolve(); + await Promise.resolve(); + expect(posted.length).toBe(attemptsWhileDown + 1); + expect(posted[posted.length - 1].body).toBe("typed"); }); }); diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index b3a452af4fa4..332f3c9136ee 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -1,4 +1,4 @@ -import { postString } from "../../utils/bloomApi"; +import { postStringQuietly } from "../../utils/bloomApi"; import { reportError } from "../../lib/errorHandler"; // Keep C# supplied with the current content of the page being edited, so that a save never has to @@ -73,9 +73,12 @@ const kQuietMs = 25; // reschedules at kQuietMs and so overtakes this. const kRetryAfterRefusalMs = 1000; -// How many times to keep offering content after a post that FAILED (as opposed to one C# refused). -// Every failed attempt shows the user an error, so this cannot go on for ever; see takeSnapshot. -const kMaxFailedPostRetries = 4; +// A run of failures backs off from kRetryAfterRefusalMs up to this. We never give up: the browser +// holding content C# has not got is exactly the state that loses the user's work at exit, so it +// has to keep offering until something takes it. What made giving up look attractive was the +// noise, and that is dealt with separately -- the post is made quietly and we report once per +// page, rather than once per attempt. +const kMaxRetryMs = 30000; let observer: MutationObserver | undefined; let timer: number | undefined; @@ -132,6 +135,32 @@ export function setSnapshotsSuspended(reason: string | undefined): void { } } +// Tell the user, at most once for this page. Reporting is the whole reason a snapshot post is +// made quietly (see postStringQuietly): so that WE decide when to speak, rather than the request +// layer speaking on every attempt. +function reportFailureOncePerPage( + pageId: string, + message: string, + stack: string | undefined, +): void { + if (pageWeReportedAFailureFor === pageId) return; + pageWeReportedAFailureFor = pageId; + reportError(message, stack); +} + +// Offer the content again after a failure, backing off 1s, 2s, 4s... to kMaxRetryMs and then +// staying there. Never gives up: while C# has not got this content, quitting writes what it still +// holds. A change the user makes reschedules at kQuietMs and overtakes this. +function retryAfterFailure(): void { + consecutiveFailedPosts++; + scheduleSnapshot( + Math.min( + kRetryAfterRefusalMs * Math.pow(2, consecutiveFailedPosts - 1), + kMaxRetryMs, + ), + ); +} + function currentPageId(): string | undefined { return document.querySelector(".bloom-page")?.id || undefined; } @@ -170,7 +199,7 @@ async function takeSnapshot(): Promise { if (pageIdBeingWatched !== pageId) return; if (content !== lastPosted) { - const reply = await postString( + const reply = await postStringQuietly( `${kApi}?pageId=${encodeURIComponent(pageId)}&loadId=${encodeURIComponent( pageLoadId, )}`, @@ -183,7 +212,7 @@ async function takeSnapshot(): Promise { // showing, including in the moment before this page has reported itself ready, since // the two APIs are not ordered with respect to each other. A refusal is not a // failure; it just means try again. - // * The POST failed and we got no answer at all. postString goes through wrapAxios, + // * The POST failed and we got no answer at all. The post goes through wrapAxios, // which turns a rejected request into a resolved promise carrying nothing -- so a // failed post looks exactly like a successful one apart from the missing response. // Reading only `.data` would therefore take a failure for an acceptance, record the @@ -204,18 +233,12 @@ async function takeSnapshot(): Promise { return; } if (failed) { - consecutiveFailedPosts++; - if (consecutiveFailedPosts <= kMaxFailedPostRetries) { - // 1s, 2s, 4s... so a long outage is quiet rather than a dialog a second. - scheduleSnapshot( - kRetryAfterRefusalMs * - Math.pow(2, consecutiveFailedPosts - 1), - ); - } - // Past that we stop asking on our own. lastPosted is still not set, so the very - // next thing the user changes offers this content again -- which is how a - // recovering server gets it, and the case we would otherwise lose (a failure - // followed by nothing at all, then a quit) is already several retries old. + reportFailureOncePerPage( + pageId, + "Bloom could not keep track of your changes to this page: the request to save them did not get through.", + undefined, + ); + retryAfterFailure(); return; } consecutiveFailedPosts = 0; @@ -233,18 +256,19 @@ async function takeSnapshot(): Promise { // else would report it.) Before BL-13502 the equivalent failure came back through the // state machine as "Bloom had trouble saving a page"; this keeps that promise. // - // Once per page: a page that fails will fail again on the very next keystroke. - if (pageWeReportedAFailureFor !== pageId) { - pageWeReportedAFailureFor = pageId; - reportError( - "Bloom could not keep track of your changes to this page: " + - (error instanceof Error ? error.message : String(error)), - error instanceof Error ? error.stack : undefined, - ); - } - // Try again on the next change. Not on a timer: the gather is deterministic, so a page - // that failed to gather fails again immediately, and a timer would just repeat the report - // we have carefully arranged to make only once. + // Once per page: a page that fails will fail again on the very next keystroke, and we + // also retry on a timer, so without this the same error would be put in front of the user + // over and over. + reportFailureOncePerPage( + pageId, + "Bloom could not keep track of your changes to this page: " + + (error instanceof Error ? error.message : String(error)), + error instanceof Error ? error.stack : undefined, + ); + // Keep offering, on the same backoff as a failed post. A gather is deterministic, so this + // will usually fail the same way -- but it costs no further reports now, and if the + // failure did depend on something transient in the page, this is what recovers from it. + retryAfterFailure(); } finally { // Only release the lock if we are still the run that took it. If the page was unloaded // and another started while we were awaiting, this run belongs to the old page, and diff --git a/src/BloomBrowserUI/utils/bloomApi.ts b/src/BloomBrowserUI/utils/bloomApi.ts index 6f0c876df452..77a6c8c60a0c 100644 --- a/src/BloomBrowserUI/utils/bloomApi.ts +++ b/src/BloomBrowserUI/utils/bloomApi.ts @@ -581,6 +581,23 @@ export async function getWithConfigAsync( } export function postString(urlSuffix: string, value: string) { + return postStringInternal(urlSuffix, value, true); +} + +/** + * postString for a caller that reports failures itself. + * + * The difference is only in who tells the user. wrapAxios reports every rejection, which is right + * for a post made once in response to something the user did. It is wrong for one that is retried + * on a timer: the user would be shown the same error again on every attempt for as long as the + * failure lasted. A caller that retries should use this and report once. See pageSnapshot.ts, + * which is why it exists. + */ +export function postStringQuietly(urlSuffix: string, value: string) { + return postStringInternal(urlSuffix, value, false); +} + +function postStringInternal(urlSuffix: string, value: string, report: boolean) { // Match post(): unit tests should not hit Bloom backend endpoints. const isTest = typeof process !== "undefined" && process.env.NODE_ENV === "test"; @@ -594,6 +611,7 @@ export function postString(urlSuffix: string, value: string) { "Content-Type": "text/plain", }, }), + report, ); } From e3c9d4333300fe4706a5d000f7223c05c049eb30 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Fri, 4 Sep 2026 13:23:27 -0500 Subject: [PATCH 15/31] Notice a style change, and know when play mode is really over (BL-13502) Two more from Devin, both of them ways the browser could hold content C# never learned about -- which is the one thing this design cannot afford. **Style-only edits could disappear.** The gathered content includes the user's own style definitions, but the watcher is a MutationObserver on the body, and the style editor changes those definitions through the CSSOM -- setProperty, deleteRule, insertRule -- which mutates no DOM node anywhere. So changing a style's size, spacing or colour without touching the text produced no snapshot at all, and leaving the Edit tab or quitting wrote the styles as they were. There is one place every such change passes through, GetRuleForStyle with create set, and it now tells the page frame. Saying so needlessly costs nothing: an unchanged page produces no post, which is what lets the hook be placed generously rather than exactly. **Leaving Play by switching tools disabled saving for the rest of the page.** Snapshots are suspended while the game tool is in its Play tab, and resumed when it leaves -- but the user can leave Play by switching to another tool, which detaches the game tool straight from play mode without going near the tab code. Nothing on that page would then be volunteered again. The resume now lives in removeToolMarkup, where play mode actually ends, and it tells the live page from a save's clone by whether the element is still in the document. Co-Authored-By: Claude Opus 5 (1M context) --- .../bookEdit/StyleEditor/StyleEditor.ts | 17 ++++++++ src/BloomBrowserUI/bookEdit/editablePage.ts | 7 ++++ .../bookEdit/js/pageSnapshot.spec.ts | 40 +++++++++++++++++++ .../bookEdit/js/pageSnapshot.ts | 19 +++++++++ .../bookEdit/toolbox/games/GameTool.tsx | 18 +++++++-- .../games/undoPrepareActivityContract.spec.ts | 27 +++++++++++++ 6 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts index e52de8566019..2eb3ff282ceb 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,22 @@ export default class StyleEditor { if (styleSheet == null) { return null; } + if (create) { + // A caller asking us to create the rule means it is about to change it, and every + // change we make to these styles goes through the CSSOM -- setProperty, deleteRule, + // insertRule -- which mutates no DOM node. So the page watcher, which is a + // MutationObserver, cannot see it, and a formatting change that leaves the text alone + // would never be volunteered to C#: leaving the Edit tab or quitting would write the + // styles as they were. This is the one place every such change passes through. + // + // It is deliberately said BEFORE the change rather than after: the watcher waits a + // moment before reading the page, and the caller's edits are synchronous, so they are + // in by the time it looks. Saying so needlessly costs nothing -- an unchanged page + // produces no post. + // Through the page frame's exports rather than a direct import, because this class + // is used from the toolbox frame as well, and it is the PAGE frame that watches. + getEditablePageBundleExports()?.notePageContentMayHaveChanged(); + } let ruleList: CSSRuleList = styleSheet.cssRules; if (ruleList == null) { diff --git a/src/BloomBrowserUI/bookEdit/editablePage.ts b/src/BloomBrowserUI/bookEdit/editablePage.ts index 0e1c03eba8e3..a2bc90b65880 100644 --- a/src/BloomBrowserUI/bookEdit/editablePage.ts +++ b/src/BloomBrowserUI/bookEdit/editablePage.ts @@ -19,6 +19,7 @@ import { kCanvasElementSelector } from "./toolbox/canvas/canvasElementConstants" import { renderDragActivityTabControl } from "./js/AbovePageControls"; import { getPageLoadId, + notePageContentMayHaveChanged, setSnapshotsSuspended, startWatchingPageForSnapshots, } from "./js/pageSnapshot"; @@ -67,6 +68,9 @@ export interface IPageFrameExports { // its Play tab; see setSnapshotsSuspended in js/pageSnapshot.ts for why gathering is not free // there. setSnapshotsSuspended(reason: string | undefined): void; + // Say that the saved form of the page may have changed in a way the page watcher cannot see -- + // the user's style definitions, which are changed through the CSSOM and mutate no DOM node. + notePageContentMayHaveChanged(): void; copySelection(): void; cutSelection(): void; pasteClipboard(): void; @@ -164,6 +168,7 @@ export { captureContentForExternalProcessing, pageUnloading, setSnapshotsSuspended, + notePageContentMayHaveChanged, topBarButtonClick, copySelection, cutSelection, @@ -428,6 +433,7 @@ interface EditablePageBundleApi { getPageContentForSaveWhenReady: typeof getPageContentForSaveWhenReady; pageUnloading: typeof pageUnloading; setSnapshotsSuspended: typeof setSnapshotsSuspended; + notePageContentMayHaveChanged: typeof notePageContentMayHaveChanged; copySelection: typeof copySelection; cutSelection: typeof cutSelection; pasteClipboard: typeof pasteClipboard; @@ -507,6 +513,7 @@ window.editablePageBundle = { getPageContentForSaveWhenReady, pageUnloading, setSnapshotsSuspended, + notePageContentMayHaveChanged, copySelection, cutSelection, pasteClipboard, diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts index 8a09a19b29c2..565e09b2aeee 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { + notePageContentMayHaveChanged, startWatchingPageForSnapshots, stopWatchingPageForSnapshots, quietMsForTests, @@ -346,6 +347,45 @@ describe("pageSnapshot", () => { expect(getPageLoadId()).not.toBe(""); }); + it("posts when told the content changed in a way it cannot observe", async () => { + // The user's style definitions are gathered too, but they are changed through the CSSOM -- + // setProperty, deleteRule, insertRule -- which mutates no DOM node, so a MutationObserver + // cannot see it. Changing a style's size or colour without touching the text would + // otherwise produce no snapshot at all, and leaving the tab would write the old styles. + contentToReport = "first"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + expect(posted.length, "sanity: nothing posted yet").toBe(0); + + // The style editor changed a rule. Nothing in the page changed. + contentToReport = "first, but with bigger type"; + notePageContentMayHaveChanged(); + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + await Promise.resolve(); + await Promise.resolve(); + + expect(posted.map((p) => p.body)).toEqual([ + "first, but with bigger type", + ]); + }); + + it("posts nothing when told of a change that turns out not to be one", async () => { + // Callers are told to err towards saying so, which is only safe because an unchanged page + // costs nothing. + contentToReport = "first"; + startWatchingPageForSnapshots(gather); + await letTheBaselineSettle(); + + notePageContentMayHaveChanged(); + vi.advanceTimersByTime(quietMsForTests); + await vi.runAllTicks(); + await Promise.resolve(); + await Promise.resolve(); + + expect(posted.length).toBe(0); + }); + it("offers the content again when C# refuses the snapshot", async () => { // C# refuses anything from a page load it is not showing. Because the snapshot endpoint is // not ordered against the "page is ready" one, a snapshot can genuinely arrive first and be diff --git a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts index 332f3c9136ee..43c3ed60d774 100644 --- a/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts +++ b/src/BloomBrowserUI/bookEdit/js/pageSnapshot.ts @@ -297,6 +297,25 @@ function noteChange(): void { scheduleSnapshot(); } +/** + * Tell the watcher that the saved form of the page may have changed in a way it cannot see. + * + * The MutationObserver covers everything in the body, which is nearly all of what we gather. It + * does NOT cover the user's own style definitions: those are gathered too (see + * getPageContentForSave), they live in a