diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index a41c2d01df2e..92acec8a47a4 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -50,6 +50,11 @@ ID: EditTab.Image.EditWithAI Context-menu item on an image in the Edit tab; opens the AI image editor in an overlay. The "..." indicates further UI will open, as in the sibling item "Choose image from your computer...". + + Canvas Background + ID: AiImageEditor.SlotLabel.CanvasBackground + Names one picture of a page that has several, shown over the picture in the AI image editor's strip of the book's images. This is the picture behind the page, which other pictures sit on top of. The code puts the page name in front of it, giving e.g. "Page 3 - Canvas Background". + No Indent ID: EditTab.TextContextMenu.NoIndent diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md b/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md index 27526329719a..cd368ee3423a 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/AGENTS.md @@ -19,7 +19,7 @@ stops the wrong half ending up in the wrong bundle: | `aiImageEditorOverlay.ts` | **top window** (workspace root) | `workspaceBundle.openAiImageEditor` | | `aiImageEditorPageCommands.ts` | **page iframe** | `editablePageBundle` (`launchAiImageEditor`, `applyAiImageEditorReplacements`) | | `aiImageEditorShared.ts` | either — pure, no DOM, no api calls | — | -| `aiImageEditorSlotMatching.ts`, `aiImageEditorImageFormats.ts` | either — pure | — | +| `aiImageEditorImageFormats.ts` | either — pure | — | So: diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts index 72f3249a286a..2c5b9b7b860f 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.test.ts @@ -6,13 +6,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; // Two things this half is responsible for, both of which used to be tangled up with the // live page and are pinned here: // -// - The edit target. C# hands over the page id and file name of the image the user +// - The edit target. C# hands over the page id and slot index 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. +// uses that to fill the "Image to Edit" slot (BL-16682). +// - Not saving after a commit. The current-page swaps touch only the LIVE DOM and register +// an image undo there, so saving now would reload the page frame and discard that undo +// (the same reasoning as BL-16330 for an ordinary image change). const post = vi.fn(); const postJson = vi.fn(); @@ -37,7 +36,6 @@ vi.mock("../js/workspaceFrames", () => ({ import { openAiImageEditor } from "./aiImageEditorOverlay"; -const kSaveEvent = "common/saveChangesAndRethinkPageEvent"; const kEditorUrl = "http://localhost:8089/bloom/aiImageEditor/index.html"; const kPageId = "page1"; const kImageFile = "old.png"; @@ -45,7 +43,7 @@ const kImageFile = "old.png"; // Opens the overlay as C# does, and answers the launch request as C# would. Returns the // handles a test needs, with the overlay up and the AI Image Editor about to be sent its `init`. const openAgainstABookWithOneImage = ( - target = { pageId: kPageId, imageFileName: kImageFile }, + target = { pageId: kPageId, slotIndex: 0 }, bookImages: Array<{ id: string; src: string; isPlaceholder?: boolean }> = [ { id: `${kPageId}:0`, @@ -175,12 +173,13 @@ describe("aiImageEditorOverlay: the edit target", () => { expect(payload.selectedBookImageId).toBe(`${kPageId}:0`); }); - test("an image the saved book doesn't have leaves the target unset", () => { - // Sanity check on the matching: the book image list names old.png, so a click on - // some other file must not silently select old.png. + test("a slot the saved book doesn't offer leaves the target unset", () => { + // C# leaves a slot out when it holds a picture the editor cannot open, so a page + // can hold slots the list does not name. Naming one anyway would send the editor an + // id it knows nothing about; leaving it unset is what the editor understands. const { iframe, postFromEditor } = openAgainstABookWithOneImage({ pageId: kPageId, - imageFileName: "somethingElse.png", + slotIndex: 3, }); const payload = getInitPayloadSentToEditor(iframe, postFromEditor); @@ -190,7 +189,7 @@ describe("aiImageEditorOverlay: the edit target", () => { test("a matching slot on a different page is not selected", () => { const { iframe, postFromEditor } = openAgainstABookWithOneImage( - { pageId: "page2", imageFileName: kImageFile }, + { pageId: "page2", slotIndex: 0 }, [ { id: `${kPageId}:0`, @@ -204,11 +203,18 @@ describe("aiImageEditorOverlay: the edit target", () => { expect(payload.selectedBookImageId).toBeUndefined(); }); - test("an empty placeholder slot is not preloaded as the target", () => { - // There is nothing to edit, and the placeholder graphic isn't a real raster image. + test("an empty placeholder slot becomes the target too (BL-16744)", () => { + // The user launched on an empty slot to create an image for it, so that slot is + // the target. Withholding it made the editor fall back to the first image of the + // book (usually the front cover), which is not what the user clicked. + const kCoverId = "cover:0"; const { iframe, postFromEditor } = openAgainstABookWithOneImage( - { pageId: kPageId, imageFileName: "placeHolder.png" }, + { pageId: kPageId, slotIndex: 0 }, [ + { + id: kCoverId, + src: "http://localhost:8089/bloom/book/cover.png", + }, { id: `${kPageId}:0`, src: "http://localhost:8089/bloom/book/placeHolder.png", @@ -219,12 +225,43 @@ describe("aiImageEditorOverlay: the edit target", () => { const payload = getInitPayloadSentToEditor(iframe, postFromEditor); - expect(payload.selectedBookImageId).toBeUndefined(); + // Sanity: the cover comes first in the list, so a fallback would have picked it. + expect(payload.selectedBookImageId).not.toBe(kCoverId); + expect(payload.selectedBookImageId).toBe(`${kPageId}:0`); + }); + + test("the SECOND of two empty slots is the target when that is the one clicked (BL-16744)", () => { + // Both empty slots show placeHolder.png, so nothing about the picture could tell + // them apart. The page frame numbered the slot; without that the editor opened on + // slot 0 and the created image landed in the wrong box. + const { iframe, postFromEditor } = openAgainstABookWithOneImage( + { pageId: kPageId, slotIndex: 1 }, + [ + { + id: `${kPageId}:0`, + src: "http://localhost:8089/bloom/book/placeHolder.png", + isPlaceholder: true, + }, + { + id: `${kPageId}:1`, + src: "http://localhost:8089/bloom/book/placeHolder.png", + isPlaceholder: true, + }, + ], + ); + + const payload = getInitPayloadSentToEditor(iframe, postFromEditor); + + expect(payload.selectedBookImageId).toBe(`${kPageId}:1`); }); }); -describe("aiImageEditorOverlay: saving the live page after a commit", () => { - test("a successful commit closes the overlay and saves at once", () => { +describe("aiImageEditorOverlay: the live page is NOT saved after a commit", () => { + // A current-page swap registers an image undo in the page frame, and a save would + // reload that frame and discard the undo (BL-16330's reasoning for ordinary image + // changes). So the overlay must never post the save event: the page saves by the + // normal mechanisms when the user moves on, and every launch saves first. + test("a successful commit closes the overlay without saving", () => { const { postFromEditor } = openAgainstABookWithOneImage(); commitAndReplyFromHost(postFromEditor, true); @@ -233,30 +270,23 @@ describe("aiImageEditorOverlay: saving the live page after a commit", () => { // assertions below aren't just watching a no-op. expect(applyAiImageEditorReplacements).toHaveBeenCalledTimes(1); expect(document.getElementById("ai-image-editor-overlay")).toBeNull(); - expect(postThatMightNavigate).toHaveBeenCalledTimes(1); - expect(postThatMightNavigate).toHaveBeenCalledWith(kSaveEvent); + expect(postThatMightNavigate).not.toHaveBeenCalled(); }); - test("a partial failure keeps the overlay up AND still saves what landed", () => { + test("a partial failure keeps the overlay up, still without saving", () => { const { closeButton, postFromEditor } = openAgainstABookWithOneImage(); commitAndReplyFromHost(postFromEditor, false); - // The overlay stays up so the user can read the error about the slot that failed — - // and, unlike when this code lived in the page frame, saving now does not endanger - // it, so the swap that did land is persisted immediately rather than held hostage - // until the user closes the overlay. + // The overlay stays up so the user can read the error about the slot that failed. expect( document.getElementById("ai-image-editor-overlay"), ).not.toBeNull(); - expect(postThatMightNavigate).toHaveBeenCalledTimes(1); - expect(postThatMightNavigate).toHaveBeenCalledWith(kSaveEvent); + expect(postThatMightNavigate).not.toHaveBeenCalled(); - // The ✕ still works after that save, because these controls belong to the top - // window, not to the page frame the save reloaded. closeButton.click(); expect(document.getElementById("ai-image-editor-overlay")).toBeNull(); - expect(postThatMightNavigate).toHaveBeenCalledTimes(1); + expect(postThatMightNavigate).not.toHaveBeenCalled(); }); test("a commit that changed nothing on this page never saves", () => { @@ -298,16 +328,15 @@ describe("aiImageEditorOverlay: saving the live page after a commit", () => { expect(ack.ok).toBe(false); expect(ack.error).toContain("Only 1 of 2"); expect(ack.error).toContain("kaboom"); - // What did land still gets saved. - expect(postThatMightNavigate).toHaveBeenCalledWith(kSaveEvent); + expect(postThatMightNavigate).not.toHaveBeenCalled(); postMessageToEditor.mockRestore(); }); test("an all-off-page commit succeeds even if the page frame is unreachable", () => { - // The page frame is briefly null while it reloads — which this feature's own - // post-commit save causes. Asking for it when the commit has nothing to do on the - // open page reported an error for images C# had in fact replaced and saved, and - // invited a retry that would redo them and orphan the files. + // The page frame is briefly null while it reloads (e.g. from the save at launch). + // Asking for it when the commit has nothing to do on the open page reported an + // error for images C# had in fact replaced and saved, and invited a retry that + // would redo them and orphan the files. getEditablePageBundleExports.mockReturnValue(null); const { iframe, postFromEditor } = openAgainstABookWithOneImage(); const postMessageToEditor = vi.spyOn( @@ -511,12 +540,13 @@ describe("aiImageEditorOverlay: analytics", () => { }); expect(abandonedEvents()).toHaveLength(0); - // And the swap that landed on the page is still saved. Answering an AI Image Editor that has - // gone away used to throw from inside postMessage, which skipped everything after it - // in the finally block -- including this save, losing the user's picture. - expect(postThatMightNavigate).toHaveBeenCalledWith( - "common/saveChangesAndRethinkPageEvent", - ); + // And the commit was still counted. Answering an AI Image Editor that has gone away + // used to throw from inside postMessage, which skipped everything after it in the + // finally block -- including this count, losing the user's pictures from the totals. + expect(closedEvents()).toHaveLength(1); + // The swap on the page being edited is NOT saved here; the normal page save keeps it, + // which is what leaves the picture undoable (BL-16744). + expect(postThatMightNavigate).not.toHaveBeenCalled(); }); test("closing while a commit is in flight DOES report a cancel if the commit then fails", () => { diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.ts index 68028a98e07d..818f2158547a 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.ts +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorOverlay.ts @@ -32,13 +32,11 @@ import { post, postJson, - postThatMightNavigate, trackChangePicture, trackEvent, } from "../../utils/bloomApi"; import { getEditablePageBundleExports } from "../js/workspaceFrames"; import { - fileNameOf, IAiImageEditorApplyOutcome, IAiImageEditorCommitResult, IAiImageEditorTarget, @@ -139,22 +137,25 @@ export function openAiImageEditor(target: IAiImageEditorTarget): void { // id wrangling here anymore. // Identify the image the user right-clicked so the AI Image Editor can open with it - // already in the "Image to Edit" slot. We match by page + filename rather than DOM - // ordinal, because the live page has extra injected UI images that would throw - // positional indices off. - const clickedMatch = - target.pageId && target.imageFileName - ? (launchData.bookImages ?? []).find( - (bi) => - bi.id.startsWith(target.pageId + ":") && - fileNameOf(bi.src) === target.imageFileName, - ) - : undefined; - // Don't preload an empty placeholder slot into the edit target — there's - // nothing to edit, and its placeholder graphic isn't a real raster image. - const selectedBookImageId = clickedMatch?.isPlaceholder - ? undefined - : clickedMatch?.id; + // already in the "Image to Edit" slot. The page frame numbered the slot it was + // clicked on, and C# builds each book image's id from the same numbering, so naming + // the clicked one is just building that id. + // + // An empty placeholder slot is named like any other (BL-16744). It used to be + // withheld, on the grounds that an empty slot has nothing to edit — but the AI + // image editor answers a missing selectedBookImageId by targeting the FIRST image + // of the book, which is normally the front cover. So withholding it aimed the user + // at the cover when they had asked for an empty slot on some other page. The editor + // reads isPlaceholder on the named slot and, for an empty one, puts nothing in its + // "Image to Edit" panel and opens its "Create an Image" tool instead; it keeps the + // slot so the created image can be committed straight into it. That behavior + // arrived in bloom-ai-image-tools 0.1.6. + const clickedId = target.pageId + ":" + target.slotIndex; + const selectedBookImageId = (launchData.bookImages ?? []).some( + (bi) => bi.id === clickedId, + ) + ? clickedId + : undefined; const initPayload = { ...launchData, @@ -501,6 +502,7 @@ export function openAiImageEditor(target: IAiImageEditorTarget): void { // apply fails) so its overlay can't hang. let finalOk = false; let message: string | undefined; + // Outside the try because the finally block reports it. let currentPageApplied = 0; try { // Only involve the page frame when this commit actually has a @@ -541,26 +543,19 @@ export function openAiImageEditor(target: IAiImageEditorTarget): void { : String(e)); } finally { ackEditor(finalOk, message); - // changeImageByElement only mutated the LIVE page DOM; - // unlike the off-page slots (which C# saved), a - // current-page swap is not otherwise persisted. Save + - // rethink the page so the saved DOM matches the live one: - // otherwise a second commit in this same session would - // read its oldSrc from a saved page still showing the - // pre-edit image and match nothing ("0 of N could be - // updated"). Mirrors doVideoCommand's save after - // updateVideoInContainer. - // - // We can save right now, even with the overlay still up, - // precisely because this overlay lives in the top window: - // the page reload underneath it leaves its controls alone. - // (currentPageApplied is what the page frame says landed, - // so a failure part way through still saves the rest.) - if (currentPageApplied > 0) { - postThatMightNavigate( - "common/saveChangesAndRethinkPageEvent", - ); - } + // Deliberately NO save here. A current-page swap lives in + // the live page DOM only, like an image pasted or chosen + // from the gallery, and is saved the same way: by the + // normal page save when the user moves on. Saving now + // would reload the page frame, and the reload would + // discard the image undo the swap just registered — the + // whole reason ordinary image changes don't save either + // (BL-16330). Later sessions still read a fresh book DOM, + // because every launch saves first (HandleSaveThenLaunch); + // a retry from THIS still-open overlay reads stale oldSrc + // for the slots that landed, which the page frame handles + // by remembering the elements it already swapped (see + // applyAiImageEditorReplacements). noteCommitSettled(); // Now, and only now, is the applied count a fact. Counted from // C#'s own results for the other pages, plus what the page frame diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.test.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.test.ts index ec5b77b6d5cd..30a981b1d7dd 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.test.ts +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.test.ts @@ -6,11 +6,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; // The menu command deliberately does NOT open the editor. Everything C# tells the editor // about the book is read from the SAVED book DOM, so an image the user just added — which // lives only in the live page — wouldn't be there (BL-16682). The command therefore reports -// the clicked image and asks C# to save the page first; C# opens the overlay itself, in the -// top window. +// which slot was clicked and asks C# to save the page first; C# opens the overlay itself, in +// the top window. +// +// A slot is an image container, and its index among the page's image containers is its whole +// identity. C# numbers the same containers on the saved page (SelectImageSlotsOnPage), so an +// index means the same thing on both sides. These tests are mostly about that agreement. const postJson = vi.fn(); const changeImageByElement = vi.fn(); +const setActiveElementToClosest = vi.fn(); vi.mock("../../utils/bloomApi", () => ({ postJson: (...args: unknown[]) => postJson(...args), @@ -20,12 +25,15 @@ vi.mock("../js/bloomEditing", () => ({ changeImageByElement: (...args: unknown[]) => changeImageByElement(...args), })); +vi.mock("../js/canvasElementManager/CanvasElementManager", () => ({ + theOneCanvasElementManager: { + setActiveElementToClosest: (...args: unknown[]) => + setActiveElementToClosest(...args), + }, +})); + vi.mock("../js/bloomImages", () => ({ - getImageUrlFromImageContainer: (container: HTMLElement) => - container.getAttribute("data-url") ?? "", - // The matcher compares filenames off the live elements; in the real thing this reads - // an or a background-image url, which for our fixture is just the src. - GetRawImageUrl: (element: HTMLElement) => element.getAttribute("src") ?? "", + kImageContainerClass: "bloom-imageContainer", })); import { @@ -35,19 +43,26 @@ import { const kPageId = "page1"; +// A page whose slots hold the given files, in order. Each slot is an image container inside +// a canvas element, which is how a real page holds a picture. const makePageWithImages = (...fileNames: string[]) => { document.body.innerHTML = `
${fileNames .map( (name) => - `
`, + `
`, ) .join("")}
`; return Array.from(document.querySelectorAll("img")) as HTMLImageElement[]; }; +const containers = () => + Array.from( + document.querySelectorAll(".bloom-imageContainer"), + ) as HTMLElement[]; + // A current-page commit result for slot `ordinal`, replacing `oldSrc` with `newSrc`. const currentPageResult = ( ordinal: number, @@ -77,26 +92,105 @@ describe("aiImageEditorPageCommands: the menu command", () => { expect(postJson).toHaveBeenCalledTimes(1); expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { - // Only the file name travels: the save reloads this frame, so a live element + // Only plain data travels: the save reloads this frame, so a live element // reference could not survive the round trip. C# adds the page id. - imageFileName: "old.png", + slotIndex: 0, + }); + }); + + test("numbers the slot that was clicked, not the picture it shows (BL-16744)", () => { + // Every empty slot shows placeHolder.png, so nothing about the picture could say + // which slot the user clicked. The index can, and it says so whatever the pictures + // are: here the same file twice. + const [first, second] = makePageWithImages( + "placeHolder.png", + "placeHolder.png", + ); + + launchAiImageEditor(second, undefined); + expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { + slotIndex: 1, + }); + + // Sanity: the other slot of the same pair is a different index. + postJson.mockClear(); + launchAiImageEditor(first, undefined); + expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { + slotIndex: 0, + }); + }); + + test("the clicked container may be given instead of the img", () => { + // canvasControlRegistry passes both when it has both. Either must number the same + // slot, because they are the same slot. + makePageWithImages("a.png", "b.png"); + const [, secondContainer] = containers(); + + launchAiImageEditor( + secondContainer.querySelector("img") as HTMLImageElement, + secondContainer, + ); + + expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { + slotIndex: 1, + }); + }); + + test("a branding image does not shift the index", () => { + // Branding, license and QR-code images are not in image containers, so they are not + // slots at all — which is why neither side needs a list of them. C# will not offer + // one and cannot overwrite one. + document.body.innerHTML = ` +
+ +
+
+
`; + const images = Array.from( + document.querySelectorAll("img"), + ) as HTMLImageElement[]; + + launchAiImageEditor(images[2], undefined); + + expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { + slotIndex: 1, + }); + }); + + test("a control Bloom injects into the live page does not shift the index", () => { + // Injected controls live in the live page only — the save strips them — so the + // saved page C# numbers has none of them. + document.body.innerHTML = ` +
+
+
+
+
`; + const images = Array.from( + document.querySelectorAll("img"), + ) as HTMLImageElement[]; + + launchAiImageEditor(images[2], undefined); + + expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { + slotIndex: 1, }); }); - test("prefers the image container's url over the img src", () => { - // An image container's url is the authoritative one (it may be a background-image - // rather than an ), which is why the command asks for it when there is one. - makePageWithImages("ignored.png"); - const container = document.querySelector( - ".bloom-canvas-element", - ) as HTMLElement; - container.setAttribute("data-url", "fromContainer.png"); - const img = document.querySelector("img") as HTMLImageElement; + test("every slot counts, whatever picture it shows", () => { + // The index counts slots, not pictures of one name. A slot C# declines to offer — + // an svg it cannot open, say — still holds its place in the numbering on both + // sides, which is what lets this side number slots without knowing C#'s rules. + const images = makePageWithImages( + "placeHolder.png", + "photo.svg", + "placeHolder.png", + ); - launchAiImageEditor(img, container); + launchAiImageEditor(images[2], undefined); expect(postJson).toHaveBeenCalledWith("aiImageEditor/saveThenLaunch", { - imageFileName: "fromContainer.png", + slotIndex: 2, }); }); }); @@ -104,10 +198,11 @@ describe("aiImageEditorPageCommands: the menu command", () => { describe("aiImageEditorPageCommands: applying current-page replacements", () => { beforeEach(() => { changeImageByElement.mockClear(); + setActiveElementToClosest.mockClear(); document.body.innerHTML = ""; }); - test("swaps the matching image and reports it applied", () => { + test("swaps the image of the named slot, undoably, and reports it applied", () => { const [img] = makePageWithImages("old.png"); const outcome = applyAiImageEditorReplacements([ @@ -116,11 +211,96 @@ describe("aiImageEditorPageCommands: applying current-page replacements", () => expect(outcome).toEqual({ applied: 1, expected: 1 }); expect(changeImageByElement).toHaveBeenCalledTimes(1); + // The img, not the container: changeImageInfo paints a background image on anything + // that is not an , which would leave the img showing the old picture. expect(changeImageByElement.mock.calls[0][0]).toBe(img); expect(changeImageByElement.mock.calls[0][1]).toMatchObject({ src: "ai-image1.png", - undoable: "false", + // Registers an image undo, like a pasted image; that is why the overlay must + // not save afterwards (the reload would discard the undo stack). + undoable: "true", }); + // The swapped slot becomes the active element, or canUndoImageOperation would + // refuse to offer the undo until the user clicked the image. + expect(setActiveElementToClosest).toHaveBeenCalledWith(img); + }); + + test("a slot with no img of its own is swapped on the container", () => { + // A slot can wear its picture as a background image instead of holding an img. + document.body.innerHTML = ` +
+
+
`; + const [container] = containers(); + + const outcome = applyAiImageEditorReplacements([ + currentPageResult(0, "old.png", "ai-image1.png"), + ]); + + expect(outcome).toEqual({ applied: 1, expected: 1 }); + expect(changeImageByElement.mock.calls[0][0]).toBe(container); + }); + + test("a retry re-targets the same slot, though it no longer shows what C# read", () => { + // After a partial failure the overlay stays up and nothing was saved, so a retry's + // oldSrc (read from the saved page) still names the pre-swap file. The index does + // not care, which is the point: nothing here compares pictures. + const images = makePageWithImages( + "placeHolder.png", + "placeHolder.png", + "placeHolder.png", + ); + applyAiImageEditorReplacements([ + currentPageResult(2, "placeHolder.png", "ai-image1.png"), + ]); + images[2].setAttribute("src", "ai-image1.png"); + changeImageByElement.mockClear(); + + const outcome = applyAiImageEditorReplacements([ + currentPageResult(2, "placeHolder.png", "ai-image2.png"), + ]); + + expect(outcome).toEqual({ applied: 1, expected: 1 }); + expect(changeImageByElement.mock.calls[0][0]).toBe(images[2]); + }); + + test("a lone swap lands on the slot its ordinal names (BL-16744)", () => { + // Every empty slot shows placeHolder.png. A commit for the third of them must not + // land on the first, which is where a filename-only match always put it. + const images = makePageWithImages( + "placeHolder.png", + "placeHolder.png", + "placeHolder.png", + ); + + const outcome = applyAiImageEditorReplacements([ + currentPageResult(2, "placeHolder.png", "ai-image1.png"), + ]); + + expect(outcome).toEqual({ applied: 1, expected: 1 }); + expect(changeImageByElement).toHaveBeenCalledTimes(1); + expect(changeImageByElement.mock.calls[0][0]).toBe(images[2]); + }); + + test("a control Bloom injects into the live page does not shift the ordinal", () => { + // Injected controls are in the live page only; the ordinal counts the saved page's + // slots, which have none of them. + document.body.innerHTML = ` +
+
+
+
+
`; + const images = Array.from( + document.querySelectorAll("img"), + ) as HTMLImageElement[]; + + const outcome = applyAiImageEditorReplacements([ + currentPageResult(1, "placeHolder.png", "ai-image1.png"), + ]); + + expect(outcome).toEqual({ applied: 1, expected: 1 }); + expect(changeImageByElement.mock.calls[0][0]).toBe(images[2]); }); test("ignores results for other pages and results that failed", () => { @@ -138,7 +318,7 @@ describe("aiImageEditorPageCommands: applying current-page replacements", () => expect(changeImageByElement).not.toHaveBeenCalled(); }); - test("reports a shortfall when a slot cannot be matched", () => { + test("reports a shortfall when the page has no such slot", () => { // The caller turns applied < expected into "Only N of M ... could be updated", so // this has to be counted honestly rather than reported as success. makePageWithImages("old.png"); diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.ts index e80e57599907..05a91128f072 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.ts +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorPageCommands.ts @@ -12,14 +12,10 @@ // getEditablePageBundleExports().applyAiImageEditorReplacements(). import { postJson } from "../../utils/bloomApi"; -import { - getImageUrlFromImageContainer, - GetRawImageUrl, -} from "../js/bloomImages"; +import { kImageContainerClass } from "../js/bloomImages"; import { changeImageByElement } from "../js/bloomEditing"; -import { matchReplacementsToElements } from "./aiImageEditorSlotMatching"; +import { theOneCanvasElementManager } from "../js/canvasElementManager/CanvasElementManager"; import { - fileNameOf, IAiImageEditorApplyOutcome, IAiImageEditorCommitResult, isCurrentPageSwap, @@ -34,20 +30,48 @@ import { // changeImage/changeImageByElement deliberately do not save (BL-16330). Launching against // that stale DOM opened the editor with an empty "Image to Edit" slot (BL-16682). So we // hand the clicked image to C#, which saves the page and then opens the overlay itself -// (AiImageEditorApi.HandleSaveThenLaunch). Only the file name travels: saving reloads this +// (AiImageEditorApi.HandleSaveThenLaunch). Only the slot index travels: saving reloads this // frame, so a live element reference would not survive. export function launchAiImageEditor( img: HTMLImageElement, imgContainer: HTMLElement | undefined, ): void { - const clickedUrl = imgContainer - ? getImageUrlFromImageContainer(imgContainer) - : img?.getAttribute("src"); postJson("aiImageEditor/saveThenLaunch", { - imageFileName: fileNameOf(clickedUrl), + slotIndex: slotIndexOnPage(imgContainer ?? img), }); } +// Numbers this page's image slots the way C# does (SelectImageSlotsOnPage in +// AiImageEditorApi.cs): its image containers, in document order. An image container is +// exactly what a user may replace, so the branding, license and QR-code images, which live +// outside any container, are not slots at all. +// +// The index IS the slot's identity — it is the "{pageId}:{ordinal}" ordinal C# builds — so the +// two lists have to hold the same containers. Bloom injects controls into the live page that +// no saved book has, and the save strips them (Cleanup in bloomEditing.ts), so those are the +// one thing to leave out here. +function slotIndexOnPage(clicked: HTMLElement | undefined): number { + if (!clicked) return 0; + const pageRoot = clicked.closest(".bloom-page") ?? document; + const slots = Array.from( + pageRoot.querySelectorAll("." + kImageContainerClass), + ).filter((el) => !el.closest(".bloom-ui")); + const index = slots.findIndex( + (el) => el === clicked || el.contains(clicked) || clicked.contains(el), + ); + return index < 0 ? 0 : index; +} + +// The element of a slot that carries the picture: the container's own img, or the container +// itself when it wears the picture as a background image. Mirrors GetImageElementOfSlot in +// AiImageEditorApi.cs. It matters here because changeImageInfo sets a background image on +// anything that is not an , so handing it a container that holds an img would leave the +// img untouched and paint the new picture behind it. +function imageElementOfSlot(slot: HTMLElement): HTMLElement { + const img = Array.from(slot.children).find((c) => c.tagName === "IMG"); + return (img as HTMLElement) ?? slot; +} + function asMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } @@ -57,6 +81,13 @@ function asMessage(e: unknown): string { // use Bloom's changeImageByElement() here. Returns how many swaps landed and how many were // asked for, so the caller can report a partial failure honestly. // +// Each swap registers an image undo, like a pasted image or a picture chosen from the +// gallery, so Ctrl+Z puts the old image back. That works because nothing here saves the +// page: the save's reload would throw the live undo stack away (the same reasoning as +// BL-16330 for ordinary image changes). The page is saved by the normal mechanisms when the +// user moves on, and every editor launch saves first (see HandleSaveThenLaunch), so later +// sessions still read a fresh book DOM. +// // Called from the top window, so it must not assume anything about who is calling: `results` // has crossed a frame boundary but is plain data, and everything it touches is this // document. @@ -67,29 +98,28 @@ export function applyAiImageEditorReplacements( if (toApply.length === 0) return { applied: 0, expected: 0 }; const pageRoot = (document.querySelector(".bloom-page") as HTMLElement) || document; - // Look up the page's image-bearing elements once, not per replacement. - const candidates = Array.from( - pageRoot.querySelectorAll('img, [style*="background-image"]'), - ); - // A page can have several slots sharing the same source (e.g. multiple empty - // placeholders). matchReplacementsToElements consumes each matched element once so - // distinct replacements land on distinct elements instead of collapsing onto the first - // match, and applies in slot (ordinal) order. We match by filename, not full src, so a - // cache-busting query string or path prefix on the live element doesn't cause a silent - // miss. oldSrc arrives from C# already decoded; the live srcs are encoded, so - // fileNameOf normalizes both sides. - const pairs = matchReplacementsToElements( - toApply, - (r) => parseInt((r.incomingId ?? "").split(":").pop() ?? "", 10) || 0, - (r) => fileNameOf(r.oldSrc, false), - candidates as HTMLElement[], - (el) => fileNameOf(GetRawImageUrl(el)), - ); - // Count as we go rather than from pairs.length at the end: if a swap throws, the ones - // already made are in the live DOM and the caller still has to know to save them. + // The page's slots, numbered as slotIndexOnPage numbers them and as C# numbers them + // (SelectImageSlotsOnPage): the image containers, less the controls Bloom injects into + // the live page. The ordinal in a replacement's "{pageId}:{ordinal}" is an index into + // this list, and that index is the whole of a slot's identity — nothing here compares + // file names, because two slots can honestly show the same file (every empty slot shows + // placeHolder.png) and a slot we already swapped no longer shows what C# read. + const slots = Array.from( + pageRoot.querySelectorAll("." + kImageContainerClass), + ).filter((el) => !el.closest(".bloom-ui")) as HTMLElement[]; + // Count as we go rather than at the end: if a swap throws, the ones already made are in + // the live DOM and the caller still has to know to save them. A replacement whose slot + // this page does not have is left out, which the caller sees as applied < expected. let applied = 0; try { - pairs.forEach(({ replacement: r, element: target }) => { + toApply.forEach((r) => { + const slot = + slots[ + parseInt((r.incomingId ?? "").split(":").pop() ?? "", 10) || + 0 + ]; + if (!slot) return; + const target = imageElementOfSlot(slot); changeImageByElement(target, { src: r.newSrc as string, // Take the credits from C#, which read them off the new image file. @@ -101,11 +131,15 @@ export function applyAiImageEditorReplacements( creator: r.creator ?? "", copyright: r.copyright ?? "", license: r.license ?? "", - // The AI commit applies replacements book-wide in C# (saved directly, not - // undoable), so don't register a separate per-image undo for the - // current-page piece. - undoable: "false", + // Register an image undo for each swap, like any other image change + // (see the header comment). + undoable: "true", }); + // Make the swapped slot the active element. canUndoImageOperation only + // offers the undo while an image container is active, and after the launch + // saved and reloaded this page nothing is — so without this, Ctrl+Z right + // after the editor closes would do nothing until the user clicked the image. + theOneCanvasElementManager.setActiveElementToClosest(target); applied++; }); } catch (e) { diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorShared.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorShared.ts index 459162d750e6..03ef366c7e97 100644 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorShared.ts +++ b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorShared.ts @@ -10,7 +10,13 @@ // match the click against the book image list. export interface IAiImageEditorTarget { pageId: string; - imageFileName: string; + // Which image slot of that page the user clicked, as its index among the page's image + // containers in document order. That index is the slot's whole identity: it is the + // ordinal in the book image's "{pageId}:{ordinal}" id, so the overlay names the clicked + // image by building that id rather than by looking for a file name. A file name could + // never have said which slot was clicked anyway, since every empty slot shows + // placeHolder.png and one photo can be used twice on a page. + slotIndex: number; } // One entry of aiImageEditor/commit's reply. The ones flagged isCurrentPage are the slots @@ -53,22 +59,3 @@ export interface IAiImageEditorApplyOutcome { expected: number; error?: string; } - -// Pull the file name off an image url. `encoded` says whether the url is percent-encoded: -// live DOM srcs and host-served URLs are, but oldSrc in commit results arrives from C# -// already decoded (PathOnly.NotEncoded) — decoding it again corrupts (or throws on) -// filenames containing a literal '%'. On a failed decode fall back to the raw name rather -// than "", so an oddly-encoded src degrades to a possible mismatch instead of matching -// nothing ever. -export function fileNameOf( - url?: string | null, - encoded: boolean = true, -): string { - const raw = (url ?? "").split("?")[0].split("/").pop() ?? ""; - if (!encoded) return raw; - try { - return decodeURIComponent(raw); - } catch { - return raw; - } -} diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorSlotMatching.test.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorSlotMatching.test.ts deleted file mode 100644 index 9a753120224c..000000000000 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorSlotMatching.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { matchReplacementsToElements } from "./aiImageEditorSlotMatching"; - -// Unit tests for the current-page slot matcher used by the AI image editor's commit (see -// aiImageEditorSlotMatching.ts and canvasControlRegistry.ts editWithAi). The tricky case is a page -// with several image slots that share a filename: distinct replacements must land on distinct -// elements, in slot (ordinal) order, not all collapse onto the first same-filename element. - -// A minimal stand-in for a replacement and a live page element, so the test needs no DOM. -interface Repl { - incomingId: string; // "{pageId}:{ordinal}" - oldSrc: string; // filename the replacement wants to land on - newSrc: string; // the replacement image (only used to identify the pair here) -} -interface El { - filename: string; // filename the live element currently shows - tag: string; // just a label so assertions can name the element -} - -const ordinalOf = (r: Repl) => - parseInt(r.incomingId.split(":").pop() ?? "", 10) || 0; - -// Run the matcher with the Repl/El accessors wired up the way the real caller does. -function match(replacements: Repl[], candidates: El[]) { - return matchReplacementsToElements( - replacements, - ordinalOf, - (r) => r.oldSrc, - candidates, - (e) => e.filename, - ); -} - -describe("matchReplacementsToElements", () => { - test("matches a single replacement to the element with the same filename", () => { - const els: El[] = [ - { filename: "a.png", tag: "A" }, - { filename: "b.png", tag: "B" }, - ]; - const result = match( - [{ incomingId: "p:1", oldSrc: "b.png", newSrc: "new-b.png" }], - els, - ); - - expect(result).toHaveLength(1); - expect(result[0].element.tag).toBe("B"); - expect(result[0].replacement.newSrc).toBe("new-b.png"); - }); - - test("two same-filename slots get distinct elements, paired by ordinal order", () => { - // Two placeholder slots that both currently show placeholder.png. The ordinal-0 - // replacement must take the first placeholder element and ordinal-1 the second — - // neither collapsing onto the same element. - const els: El[] = [ - { filename: "placeholder.png", tag: "first" }, - { filename: "placeholder.png", tag: "second" }, - ]; - // Deliberately pass them out of ordinal order to prove the matcher sorts. - const result = match( - [ - { - incomingId: "p:1", - oldSrc: "placeholder.png", - newSrc: "gen-1.png", - }, - { - incomingId: "p:0", - oldSrc: "placeholder.png", - newSrc: "gen-0.png", - }, - ], - els, - ); - - expect(result).toHaveLength(2); - // Applied in ascending ordinal order: ordinal 0 first, ordinal 1 second. - expect(result[0].replacement.newSrc).toBe("gen-0.png"); - expect(result[0].element.tag).toBe("first"); - expect(result[1].replacement.newSrc).toBe("gen-1.png"); - expect(result[1].element.tag).toBe("second"); - // Sanity: the two replacements did NOT land on the same element. - expect(result[0].element).not.toBe(result[1].element); - }); - - test("a replacement with no filename match is skipped, others still match", () => { - const els: El[] = [{ filename: "a.png", tag: "A" }]; - const result = match( - [ - { - incomingId: "p:0", - oldSrc: "missing.png", - newSrc: "gen-miss.png", - }, - { incomingId: "p:1", oldSrc: "a.png", newSrc: "gen-a.png" }, - ], - els, - ); - - expect(result).toHaveLength(1); - expect(result[0].replacement.newSrc).toBe("gen-a.png"); - expect(result[0].element.tag).toBe("A"); - }); - - test("more same-filename replacements than elements: extras drop out, no reuse", () => { - const els: El[] = [{ filename: "dup.png", tag: "only" }]; - const result = match( - [ - { incomingId: "p:0", oldSrc: "dup.png", newSrc: "gen-0.png" }, - { incomingId: "p:1", oldSrc: "dup.png", newSrc: "gen-1.png" }, - ], - els, - ); - - // Only one element exists, so only the first (ordinal 0) replacement lands; the - // second finds no unused same-filename element and is omitted rather than reusing. - expect(result).toHaveLength(1); - expect(result[0].replacement.newSrc).toBe("gen-0.png"); - }); - - test("does not mutate the caller's replacements array order", () => { - const replacements: Repl[] = [ - { incomingId: "p:2", oldSrc: "a.png", newSrc: "n2.png" }, - { incomingId: "p:0", oldSrc: "a.png", newSrc: "n0.png" }, - ]; - const els: El[] = [ - { filename: "a.png", tag: "A0" }, - { filename: "a.png", tag: "A2" }, - ]; - match(replacements, els); - - // The matcher sorts a copy; the original array keeps its input order. - expect(replacements[0].incomingId).toBe("p:2"); - expect(replacements[1].incomingId).toBe("p:0"); - }); -}); diff --git a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorSlotMatching.ts b/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorSlotMatching.ts deleted file mode 100644 index 4cddcc55b5e7..000000000000 --- a/src/BloomBrowserUI/bookEdit/aiImageEditor/aiImageEditorSlotMatching.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Pure slot-matching helper for applying AI-editor replacements to the currently-open page. -// -// When the AI commit returns replacements for the page the user is looking at, the front-end -// has to pair each replacement with the right live image element. Matching is by filename -// (a cache-busting query string or path prefix on the live element would defeat a full-src -// compare), but a single page can have several image slots that share the same filename — -// e.g. two empty placeholders, or the same photo used twice. Filename alone can't tell those -// apart, so we (a) apply in slot order — the ordinal in each replacement's "{pageId}:{n}" id -// counts the saved page's image holders in document order, which is the live candidates' -// order too — and (b) consume each element at most once, so distinct replacements land on -// distinct elements instead of all collapsing onto the first same-filename match. -// -// This is factored out of canvasControlRegistry.ts's editWithAi command so the pairing logic -// can be unit-tested without a DOM or the changeImage side effects; the caller supplies the -// ordinal/filename accessors and performs the actual image swap on the returned pairs. - -export interface IReplacementMatch { - replacement: TReplacement; - element: TElement; -} - -/** - * Pairs replacements to candidate elements by filename, in ascending ordinal order, using each - * candidate at most once. A replacement with no filename match (given the still-unused - * candidates) is skipped and simply omitted from the result. - * - * @param replacements the current-page replacements to place - * @param ordinalOf extracts a replacement's slot ordinal (used only to order placement) - * @param wantedFilenameOf the filename a replacement wants to land on (from its oldSrc) - * @param candidates the live page's image-bearing elements, in document order - * @param candidateFilenameOf the filename currently shown by a candidate element - * @returns one {replacement, element} pair per successfully matched replacement, in the order - * they were applied (ascending ordinal) - */ -export function matchReplacementsToElements( - replacements: TReplacement[], - ordinalOf: (replacement: TReplacement) => number, - wantedFilenameOf: (replacement: TReplacement) => string, - candidates: TElement[], - candidateFilenameOf: (element: TElement) => string, -): Array> { - const used = new Set(); - const matches: Array> = []; - [...replacements] - .sort((a, b) => ordinalOf(a) - ordinalOf(b)) - .forEach((replacement) => { - const wanted = wantedFilenameOf(replacement); - const element = candidates.find( - (candidate) => - !used.has(candidate) && - candidateFilenameOf(candidate) === wanted, - ); - if (element === undefined) { - return; - } - used.add(element); - matches.push({ replacement, element }); - }); - return matches; -} diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/buildCanvasElementControlRegistryContext.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/buildCanvasElementControlRegistryContext.ts index 9f4598a21322..1ffa58a95a9d 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/buildCanvasElementControlRegistryContext.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/buildCanvasElementControlRegistryContext.ts @@ -174,6 +174,8 @@ export const buildCanvasElementControlRegistryContext = ( elementType, hasImage, hasRealImage: hasRealImage(img ?? undefined), + isPlaceholderImage: + hasImage && isPlaceHolderImage(img?.getAttribute("src")), hasVideo, hasPreviousVideoContainer: videoContainer ? !!findPreviousVideoContainer(videoContainer) diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.test.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.test.ts index 7b1e8f4215ef..dc0a3a81ad2f 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.test.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.test.ts @@ -5,8 +5,9 @@ import { IControlAvailability, IControlContext } from "./canvasControlTypes"; // Unit tests for the `editWithAi` availability rule (see canvasControlAvailabilityRules.ts). // This is the gating logic behind the "Edit with AI..." image menu item: it must stay hidden -// until the experimental feature is on, and be disabled unless there is a real, modifiable -// image whose format the editor can actually edit. The rule is a pure function of +// until the experimental feature is on, and be disabled unless the user can modify the slot +// and the slot is either an empty placeholder (the editor can create an image for it) or a +// real image whose format the editor can actually edit. The rule is a pure function of // IControlContext, so we exercise it directly rather than driving the whole menu. // A context with every flag off/neutral. Each test flips only the flags the rule reads, so a @@ -17,6 +18,7 @@ function makeCtx(overrides: Partial): IControlContext { aiImageEditingAvailable: false, hasImage: false, hasRealImage: false, + isPlaceholderImage: false, canModifyImage: false, imageIsAiEditableFormat: false, }; @@ -90,13 +92,60 @@ describe("imageAvailabilityRules.editWithAi", () => { }); }); - describe("enabled = hasRealImage && canModifyImage && imageIsAiEditableFormat", () => { - test("disabled when there is only a placeholder (no real image)", () => { + describe("enabled = canModifyImage && (placeholder || real && editable format)", () => { + test("enabled on an empty placeholder slot, so a new image can be created for it", () => { + // BL-16744. The editor's "create" tools need no source image, so an empty + // slot is a valid thing to launch on. expect( evaluate( rule.enabled, makeCtx({ hasRealImage: false, + isPlaceholderImage: true, + canModifyImage: true, + imageIsAiEditableFormat: true, + }), + ), + ).toBe(true); + }); + + test("enabled on a placeholder even though nothing examines its format", () => { + // The user is going to make a new image, not edit placeHolder.png, so the + // format check must not gate the placeholder case. + expect( + evaluate( + rule.enabled, + makeCtx({ + hasRealImage: false, + isPlaceholderImage: true, + canModifyImage: true, + imageIsAiEditableFormat: false, + }), + ), + ).toBe(true); + }); + + test("disabled on a placeholder the user may not modify", () => { + expect( + evaluate( + rule.enabled, + makeCtx({ + hasRealImage: false, + isPlaceholderImage: true, + canModifyImage: false, + imageIsAiEditableFormat: true, + }), + ), + ).toBe(false); + }); + + test("disabled when the image is neither real nor a placeholder (a broken image)", () => { + expect( + evaluate( + rule.enabled, + makeCtx({ + hasRealImage: false, + isPlaceholderImage: false, canModifyImage: true, imageIsAiEditableFormat: true, }), diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.ts index 25e363508888..1517e15d941b 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlAvailabilityRules.ts @@ -34,14 +34,20 @@ export const imageAvailabilityRules: AvailabilityRulesMap = { }, editWithAi: { // Only offered when the AI Image Editing experimental feature is turned on. - // The AI Image Editor needs a real raster image to work on, the user must be - // allowed to modify it, and its format must be one the editor can actually edit - // (so it stays disabled for e.g. an svg the editor can't open). + // Two cases are allowed, and the user must be able to modify the image in both: + // - An empty placeholder slot. The editor's "create" tools make an image with + // no source, so an empty slot is a perfectly good thing to launch on + // (BL-16744). Its format is not examined: the user is going to make a new + // image, not edit placeHolder.png. + // - A real raster image whose format the editor can actually open, so the item + // stays disabled for e.g. an svg. + // A broken image (hasImage, but neither real nor a placeholder) stays disabled: + // there is nothing to edit and nothing the user asked to fill. visible: (ctx) => ctx.aiImageEditingAvailable && ctx.hasImage, enabled: (ctx) => - ctx.hasRealImage && ctx.canModifyImage && - ctx.imageIsAiEditableFormat, + (ctx.isPlaceholderImage || + (ctx.hasRealImage && ctx.imageIsAiEditableFormat)), }, missingMetadata: { surfacePolicy: { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTypes.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTypes.ts index 390c1322cbda..60b1131c902c 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTypes.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlTypes.ts @@ -116,6 +116,10 @@ export interface IControlContext { elementType: CanvasElementType; hasImage: boolean; hasRealImage: boolean; + // True when the image slot is an empty placeholder (it shows placeHolder.png). + // Distinct from !hasRealImage, which is also true for an image that failed to + // load: an empty slot is a normal state a user can fill, a broken image is not. + isPlaceholderImage: boolean; hasVideo: boolean; hasPreviousVideoContainer: boolean; hasNextVideoContainer: boolean; diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index d54a06dcec83..cba633e38e9f 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -145,7 +145,7 @@ "@types/react-transition-group": "4.4.1", "@use-it/event-listener": "0.1.7", "axios": "0.21.1", - "bloom-ai-image-tools": "github:BloomBooks/bloom-ai-image-tools#dist-v0.1.5", + "bloom-ai-image-tools": "github:BloomBooks/bloom-ai-image-tools#dist-v0.1.7", "bloom-image-gallery": "github:BloomBooks/bloom-image-gallery", "bloom-player": "2.20.1-alpha.6", "calculate-aspect-ratio": "0.1.3", diff --git a/src/BloomBrowserUI/pnpm-lock.yaml b/src/BloomBrowserUI/pnpm-lock.yaml index 955291a79380..938f9bf12454 100644 --- a/src/BloomBrowserUI/pnpm-lock.yaml +++ b/src/BloomBrowserUI/pnpm-lock.yaml @@ -85,8 +85,8 @@ importers: specifier: 0.21.1 version: 0.21.1 bloom-ai-image-tools: - specifier: github:BloomBooks/bloom-ai-image-tools#dist-v0.1.5 - version: https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/bcf08003b52c204cb42535040b39118c4725e01e + specifier: github:BloomBooks/bloom-ai-image-tools#dist-v0.1.7 + version: https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/a24e636bd05a943b1b909811210c9803be091494 bloom-image-gallery: specifier: github:BloomBooks/bloom-image-gallery version: https://codeload.github.com/BloomBooks/bloom-image-gallery/tar.gz/e376463bcd21b1558750570b63269a2e133b46c0(@types/react@18.3.31)(supports-color@5.5.0) @@ -4049,14 +4049,14 @@ packages: integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, } - bloom-ai-image-tools@https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/bcf08003b52c204cb42535040b39118c4725e01e: + bloom-ai-image-tools@https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/a24e636bd05a943b1b909811210c9803be091494: resolution: { gitHosted: true, - integrity: sha512-HDrS02dOijc4H48OvrXUC5wFkhB7ZhTswIx9ywfZTq9hxFHd03t2R0j8ZyxbPmG8785lnpfoEOuuQkxswW5+5w==, - tarball: https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/bcf08003b52c204cb42535040b39118c4725e01e, + integrity: sha512-F67/eh54qujL/f1XtFVfJSwiMeiVr2OsRZHpnlyfhh49g7drMaXGs0cjTBhhDYVMiA+O4UFaACN58wrFMTGyCA==, + tarball: https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/a24e636bd05a943b1b909811210c9803be091494, } - version: 0.1.5 + version: 0.1.7 bloom-image-gallery@https://codeload.github.com/BloomBooks/bloom-image-gallery/tar.gz/e376463bcd21b1558750570b63269a2e133b46c0: resolution: @@ -13532,7 +13532,7 @@ snapshots: file-uri-to-path: 1.0.0 optional: true - bloom-ai-image-tools@https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/bcf08003b52c204cb42535040b39118c4725e01e: + bloom-ai-image-tools@https://codeload.github.com/BloomBooks/bloom-ai-image-tools/tar.gz/a24e636bd05a943b1b909811210c9803be091494: {} bloom-image-gallery@https://codeload.github.com/BloomBooks/bloom-image-gallery/tar.gz/e376463bcd21b1558750570b63269a2e133b46c0(@types/react@18.3.31)(supports-color@5.5.0): diff --git a/src/BloomExe/web/controllers/AiImageEditorApi.cs b/src/BloomExe/web/controllers/AiImageEditorApi.cs index 5b897660190d..c77c796080bb 100644 --- a/src/BloomExe/web/controllers/AiImageEditorApi.cs +++ b/src/BloomExe/web/controllers/AiImageEditorApi.cs @@ -10,6 +10,7 @@ using Bloom.Edit; using Bloom.ImageProcessing; using Bloom.SafeXml; +using L10NSharp; using Newtonsoft.Json; using SIL.Core.ClearShare; using SIL.IO; @@ -277,14 +278,20 @@ internal static string GetLinkedEditorUrlOverride() /// The image the user right-clicked, as the page frame sends it to /// and as we hand it back to the browser once the page - /// has been saved. See IAiImageEditorTarget in aiImageEditorShared.ts. The page frame sends only - /// the file name; we fill in the page id, since we are the ones who know which page we + /// has been saved. See IAiImageEditorTarget in aiImageEditorShared.ts. The page frame sends + /// the slot index; we fill in the page id, since we are the ones who know which page we /// saved, and the overlay (in the top window) has no page DOM of its own to read it from. /// private class SaveThenLaunchRequest { - public string imageFileName { get; set; } public string pageId { get; set; } + + /// Which image slot of that page the user clicked, as its index among the + /// page's image containers in document order. The page frame counts them, because + /// only it can see which slot the user clicked; we just carry it back to the + /// overlay, where it names the book image "{pageId}:{slotIndex}". + /// + public int slotIndex { get; set; } } /// @@ -327,7 +334,7 @@ private void HandleSaveThenLaunch(ApiRequest request) { // Must be read before SaveThen: by the time our callbacks run the request is complete. // Deliberately unguarded: the only caller is launchAiImageEditor in - // aiImageEditorPageCommands.ts, which always sends {imageFileName}, so a parse failure means + // 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 // than a generic "invalid payload" that says nothing (see AGENTS.md, "Don't be overly // defensive about error handling"). @@ -883,15 +890,40 @@ out int ordinal } /// - /// True if an image-bearing element is one the user is allowed to replace. Branding, - /// license, and QR-code images are never user-changeable, so they are excluded both - /// from the list offered to the AI image editor and from being overwritten at commit. + /// The image slots of one page, in document order: its image containers. A slot's index + /// in this list is its whole identity, and it is what "{pageId}:{ordinal}" holds. /// Internal for testing. + /// + /// An image container is exactly what a user may replace, which is why nothing here + /// filters. The branding, license and QR-code images live outside any container, so + /// they are not slots and cannot be edited or overwritten. + /// + /// slotIndexOnPage in aiImageEditorPageCommands.ts numbers the same containers on the live + /// page, so the index the page frame sends at launch means the same thing here. It has + /// one exclusion this does not need: Bloom injects controls into the live page, and a + /// save strips them, so they are never in the DOM we read. + /// + internal static SafeXmlElement[] SelectImageSlotsOnPage(SafeXmlElement page) => + page.SafeSelectNodes( + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' " + + HtmlDom.kImageContainerClass + + " ')]" + ) + .OfType() + .ToArray(); + + /// + /// The element of a slot that carries the picture: the container's own img, or the + /// container itself when it wears the picture as a background image. Null when the slot + /// holds neither, which a slot with no picture at all can do. /// - internal static bool IsUserChangeableImageElement(SafeXmlElement element) => - !element.HasClass("branding") - && !element.HasClass("licenseImage") - && !element.HasClass("bloom-qrcode"); + internal static SafeXmlElement GetImageElementOfSlot(SafeXmlElement slot) + { + var img = slot.SelectSingleNode("./img") as SafeXmlElement; + if (img != null) + return img; + return (slot.GetAttribute("style") ?? "").Contains("background-image") ? slot : null; + } /// /// Locates the bytes for a history result by id. The AI image editor may store a @@ -1000,6 +1032,119 @@ internal class ImageCreditAttributes public string license { get; set; } } + /// + /// What to call a page when naming one of its image slots to the user: "Page 3" for a + /// numbered page, or the page's own name (e.g. "Front Cover") for front or back matter. + /// Null when the page says neither, in which case the slot goes unlabelled. + /// In the user interface language. + /// + /// + /// Deliberately not HtmlDom.GetNumberOrLabelOfPageWhereElementLives: that returns the + /// number bare, with no "Page", and answers "unknown" for a page whose + /// data-page-number attribute is missing rather than empty, which HtmlDom itself can + /// produce (see BL-12903). "unknown" would be worse than no label at all. + /// + internal static string GetPageNameForImageSlotLabel(SafeXmlElement page) + { + // Back matter pages do carry page numbers, but they are clearer by name. + var number = page.GetAttribute("data-page-number"); + if (!string.IsNullOrWhiteSpace(number) && !HtmlDom.IsBackMatterPage(page)) + // "Page" plus the number, rather than a "Page {0}" string of our own: the + // word already exists in our strings, and a translator meeting a bare format + // string has less to go on than the word itself. + return LocalizationManager.GetString("ReaderSetup.PageHeader", "Page") + + " " + + number; + + var label = page.SelectSingleNode("./div[@class='pageLabel']")?.InnerText.Trim(); + if (string.IsNullOrEmpty(label)) + return null; + // Page labels are localized under a dynamic id built from the English label, the + // same way the page list in the Edit tab does it. + return LocalizationManager.GetString("TemplateBooks.PageLabel." + label, label); + } + + /// + /// Names one image slot for the user. A page with a single image slot needs no more than + /// the page name. A page with several says which slot this is: "Page 3 - Canvas + /// Background" for the canvas background image, "Page 3 - Image 1" for the pictures on + /// top of it. Every empty slot looks the same in the AI image editor, so this label is + /// the only thing that tells two of them apart (BL-16744). + /// + /// from , may be null + /// true for the canvas background image + /// + /// the 1-based position of this slot among the page's images, counting every slot that is + /// not being named as the canvas background. A page with several canvases names none of + /// them, so there every slot is counted. Ignored when isCanvasBackground is true. + /// + /// how many image slots this page offers in all + internal static string BuildImageSlotLabel( + string pageName, + bool isCanvasBackground, + int imageNumber, + int slotCount + ) + { + // The only picture on the page. There is nothing to tell it apart from. + if (slotCount < 2) + return pageName; + + var whichSlot = isCanvasBackground + ? LocalizationManager.GetString( + "AiImageEditor.SlotLabel.CanvasBackground", + "Canvas Background" + ) + : LocalizationManager.GetString("EditTab.CustomPage.Image", "Image") + + " " + + imageNumber; + if (string.IsNullOrEmpty(pageName)) + return whichSlot; + // The separator is not localizable. It carries no meaning to translate, and a + // string of nothing but two placeholders and a dash gives a translator no context. + return pageName + " - " + whichSlot; + } + + /// + /// Names every image slot of one page, in the order the page offers them. Kept in one + /// place because a slot's name depends on what else the page holds, not just on itself. + /// + /// from , may be null + /// + /// for each slot of the page, in order, whether it is the background image of a canvas + /// + internal static List BuildImageSlotLabelsForPage( + string pageName, + IReadOnlyList isCanvasBackground + ) + { + // "Canvas Background" names a slot only when the page has exactly one canvas. A + // Picture Dictionary page has six, so six background images; calling them all + // "Canvas Background" would be six identical labels, which is the very confusion + // the label exists to remove. There, plain numbering tells them apart. + var nameTheBackground = isCanvasBackground.Count(background => background) == 1; + + var labels = new List(); + var imageNumber = 0; + foreach (var background in isCanvasBackground) + { + // The background is named, not numbered, so the numbering of the pictures on + // top of it starts at 1 whether or not the page has a background. + var nameAsBackground = background && nameTheBackground; + if (!nameAsBackground) + imageNumber++; + labels.Add( + BuildImageSlotLabel( + pageName, + nameAsBackground, + imageNumber, + isCanvasBackground.Count + ) + ); + } + return labels; + } + /// /// Enumerates every image the user is allowed to change across the whole book — all /// pages including front cover and xmatter, including empty placeholder slots — @@ -1020,17 +1165,27 @@ private List EnumerateBookImages(Bloom.Book.Book book) var pageId = page.GetAttribute("id"); if (string.IsNullOrEmpty(pageId) || !SafeId.IsMatch(pageId)) continue; - var pageLabel = page.GetAttribute("data-page-number"); - - var holders = HtmlDom.SelectChildImgAndBackgroundImageElements(page); - // Ordinal is the index within the full holder list so that commit can - // re-find the element deterministically; the branding/license skip below - // only affects which slots we offer, not the indexing. - for (var ordinal = 0; ordinal < holders.Length; ordinal++) + var pageName = GetPageNameForImageSlotLabel(page); + + var slots = SelectImageSlotsOnPage(page); + // The slots this page offers, gathered before any is added to the result, + // because a slot's label depends on how many the page has: a page with one + // image says just "Page 2", a page with more says "Page 2 - Image 1" and so on. + var slotsOnThisPage = + new List<( + string id, + string src, + bool isPlaceholder, + bool isCanvasBackground, + ImageCredits credits + )>(); + // Ordinal is the index within the full slot list, so a slot we decline to offer + // below still holds its place. That is what lets the page frame send an index it + // worked out for itself, without knowing which slots we kept. + for (var ordinal = 0; ordinal < slots.Length; ordinal++) { - if (!(holders[ordinal] is SafeXmlElement element)) - continue; - if (!IsUserChangeableImageElement(element)) + var element = GetImageElementOfSlot(slots[ordinal]); + if (element == null) continue; var relativePath = HtmlDom.GetImageElementUrl(element).PathOnly.NotEncoded; @@ -1044,22 +1199,44 @@ private List EnumerateBookImages(Bloom.Book.Book book) if (!IsImageFileName(relativePath)) continue; - images.Add( - new - { - id = pageId + ":" + ordinal, - src = (folderAsUrlPrefix + "/" + relativePath).ToLocalhost(), - pageLabel = string.IsNullOrEmpty(pageLabel) ? null : pageLabel, + slotsOnThisPage.Add( + ( + id: pageId + ":" + ordinal, + src: (folderAsUrlPrefix + "/" + relativePath).ToLocalhost(), // The AI image editor shows its own placeholder graphic for empty // slots rather than trying to load the (book-less) // placeHolder.png. - isPlaceholder = ImageUtils.IsPlaceholderImageFilename(relativePath), + isPlaceholder: ImageUtils.IsPlaceholderImageFilename(relativePath), + // A bloom-canvas can hold one background image with pictures on + // top of it. The background is worth naming as such, because the + // user thinks of it as the page's picture, not as "image 1". + isCanvasBackground: HtmlDom.IsBackgroundImage(element), // The image's current credits, so a result derived from it can // carry (or amend) them. The AI image editor owns the credit // *decision* and hands back whatever it chose on commit; Bloom // only embeds that into the file. Null when the image has no // usable metadata. - credits = GetCreditsForImageFile(book.FolderPath, relativePath), + credits: GetCreditsForImageFile(book.FolderPath, relativePath) + ) + ); + } + + // Now that the whole page is known, name its slots and add them. + var labels = BuildImageSlotLabelsForPage( + pageName, + slotsOnThisPage.Select(slot => slot.isCanvasBackground).ToList() + ); + for (var i = 0; i < slotsOnThisPage.Count; i++) + { + var slot = slotsOnThisPage[i]; + images.Add( + new + { + id = slot.id, + src = slot.src, + pageLabel = labels[i], + isPlaceholder = slot.isPlaceholder, + credits = slot.credits, } ); } @@ -1339,22 +1516,18 @@ out SafeXmlElement pageForDataDivSync return false; } - var holders = HtmlDom.SelectChildImgAndBackgroundImageElements(page); - if (ordinal < 0 || ordinal >= holders.Length) + var slots = SelectImageSlotsOnPage(page); + if (ordinal < 0 || ordinal >= slots.Length) { error = "Image index out of range"; return false; } - if (!(holders[ordinal] is SafeXmlElement element)) + var element = GetImageElementOfSlot(slots[ordinal]); + if (element == null) { error = "Image element not found"; return false; } - if (!IsUserChangeableImageElement(element)) - { - error = "Image is not user-changeable"; - return false; - } isCurrentPage = pageId == currentPageId; oldSrc = HtmlDom.GetImageElementUrl(element).PathOnly.NotEncoded; diff --git a/src/BloomTests/web/controllers/AiImageEditorApiTests.cs b/src/BloomTests/web/controllers/AiImageEditorApiTests.cs index 952e439e1c1e..b4db47dfda01 100644 --- a/src/BloomTests/web/controllers/AiImageEditorApiTests.cs +++ b/src/BloomTests/web/controllers/AiImageEditorApiTests.cs @@ -9,6 +9,7 @@ using Bloom; using Bloom.Book; using Bloom.ImageProcessing; +using Bloom.SafeXml; using Bloom.web.controllers; using NUnit.Framework; using SIL.Code; @@ -1231,55 +1232,109 @@ out var ordinal } // ------------------------------------------------------------------ - // IsUserChangeableImageElement: branding/license/QR images are off-limits. + // SelectImageSlotsOnPage: a page's slots are its image containers, in document + // order. The index of a slot in that list is its whole identity, and the page frame + // works out the same index for itself (slotIndexOnPage in aiEditorPageCommands.ts), + // so these two numberings have to agree. // ------------------------------------------------------------------ - private static Bloom.SafeXml.SafeXmlElement MakeImgWithClass(string className) + private static SafeXmlElement MakePageWithBody(string bodyOfPage) { - var classAttr = className == null ? "" : $" class='{className}'"; var dom = new HtmlDom( $@"
- + {bodyOfPage}
" ); - return (Bloom.SafeXml.SafeXmlElement)dom.RawDom.SelectSingleNode("//img"); + return (SafeXmlElement)dom.RawDom.SelectSingleNode("//div[@id='page1']"); } [Test] - public void IsUserChangeableImageElement_PlainImage_IsChangeable() + public void SelectImageSlotsOnPage_ReturnsContainersInDocumentOrder() { + var page = MakePageWithBody( + @"
+
" + ); + + var slots = AiImageEditorApi.SelectImageSlotsOnPage(page); + + Assert.That(slots.Length, Is.EqualTo(2)); + Assert.That( + AiImageEditorApi.GetImageElementOfSlot(slots[0]).GetAttribute("src"), + Is.EqualTo("first.png") + ); Assert.That( - AiImageEditorApi.IsUserChangeableImageElement(MakeImgWithClass(null)), - Is.True + AiImageEditorApi.GetImageElementOfSlot(slots[1]).GetAttribute("src"), + Is.EqualTo("second.png") ); } [TestCase("branding")] [TestCase("licenseImage")] [TestCase("bloom-qrcode")] - public void IsUserChangeableImageElement_ProtectedImage_IsNotChangeable(string className) + public void SelectImageSlotsOnPage_ImageOutsideAContainer_IsNotASlot(string className) { + // Branding, license and QR-code images are never in an image container, which is + // why neither side of the bridge needs a list of them: they are not slots, so + // they cannot be offered to the AI image editor or overwritten by a commit. + var page = MakePageWithBody( + $@" +
" + ); + + var slots = AiImageEditorApi.SelectImageSlotsOnPage(page); + + Assert.That(slots.Length, Is.EqualTo(1), $"'{className}' must not be a slot"); Assert.That( - AiImageEditorApi.IsUserChangeableImageElement(MakeImgWithClass(className)), - Is.False, - $"an image with class '{className}' must not be user-changeable" + AiImageEditorApi.GetImageElementOfSlot(slots[0]).GetAttribute("src"), + Is.EqualTo("real.png") ); } [Test] - public void IsUserChangeableImageElement_ProtectedClassAmongOthers_IsNotChangeable() + public void SelectImageSlotsOnPage_ClassAmongOthers_IsStillASlot() { - // The class check must find the protected class even when combined with others. + // The class test must find bloom-imageContainer among other classes, and must + // not match a class that merely contains those characters. + var page = MakePageWithBody( + @"
+
" + ); + + var slots = AiImageEditorApi.SelectImageSlotsOnPage(page); + + Assert.That(slots.Length, Is.EqualTo(1)); Assert.That( - AiImageEditorApi.IsUserChangeableImageElement( - MakeImgWithClass("bloom-imageContainer branding") - ), - Is.False + AiImageEditorApi.GetImageElementOfSlot(slots[0]).GetAttribute("src"), + Is.EqualTo("real.png") ); } + [Test] + public void GetImageElementOfSlot_BackgroundImageSlot_ReturnsTheContainer() + { + // A slot can wear its picture as a background image instead of holding an img. + var page = MakePageWithBody( + @"
" + ); + + var slot = AiImageEditorApi.SelectImageSlotsOnPage(page)[0]; + + Assert.That(AiImageEditorApi.GetImageElementOfSlot(slot), Is.SameAs(slot)); + } + + [Test] + public void GetImageElementOfSlot_SlotWithNoPicture_ReturnsNull() + { + var page = MakePageWithBody(@"
"); + + var slot = AiImageEditorApi.SelectImageSlotsOnPage(page)[0]; + + Assert.That(AiImageEditorApi.GetImageElementOfSlot(slot), Is.Null); + } + // ------------------------------------------------------------------ // EmbedCreditsInNewImageFile: an AI-generated result file has no metadata of its own, // and Bloom rebuilds the data-copyright/creator/license attributes from the file's @@ -1671,7 +1726,7 @@ public void ReadCreditAttributes_MatchesWhatBloomsOwnUpdaterWouldWrite() // (updated by ImageUpdater) and as the next book-up-to-date pass. Pin that // agreement down rather than trusting the two to stay in step by inspection. var name = MakePngWithCredits("pic.png", "Ada Lovelace", "Copyright 1843 Ada"); - var img = MakeImgWithClass(null); // its src is "pic.png", the file we just made + var img = MakePlainImg(); // its src is "pic.png", the file we just made ImageUpdater.UpdateImgMetadataAttributesToMatchImage( _bookFolder.Path, @@ -1688,6 +1743,204 @@ public void ReadCreditAttributes_MatchesWhatBloomsOwnUpdaterWouldWrite() Assert.That(attributes.creator, Is.EqualTo(img.GetAttribute("data-creator"))); Assert.That(attributes.license, Is.EqualTo(img.GetAttribute("data-license"))); } + + // A plain image element, for a test that needs one and nothing around it. + private static SafeXmlElement MakePlainImg() + { + var dom = new HtmlDom( + @" +
+ +
+ " + ); + return (SafeXmlElement)dom.RawDom.SelectSingleNode("//img"); + } + + // The single page of a one-page DOM, as the label helpers take it. + private static SafeXmlElement FirstPageOf(string pageMarkup) + { + var dom = new HtmlDom("" + pageMarkup + ""); + var page = dom + .RawDom.SafeSelectNodes("//div[contains(@class,'bloom-page')]") + .OfType() + .FirstOrDefault(); + if (page == null) + Assert.Fail("the test markup has no bloom-page, so there is nothing to name"); + return page; + } + + [Test] + public void GetPageNameForImageSlotLabel_NumberedPage_SaysPageAndTheNumber() + { + var page = FirstPageOf( + "
" + + "
Basic Text & Picture
" + ); + + // The user thinks in page numbers, not template names, so the number wins over the + // page's own label when the page has one. + Assert.That(AiImageEditorApi.GetPageNameForImageSlotLabel(page), Is.EqualTo("Page 3")); + } + + [Test] + public void GetPageNameForImageSlotLabel_FrontMatter_UsesThePageName() + { + var page = FirstPageOf( + // Bloom writes data-page-number='' on an unnumbered page (BL-7303). + "
" + + "
Front Cover
" + ); + + // Front matter has no page number, so the page's own name is all we can say. It is + // the English name: the AI image editor's user interface is English only. + Assert.That( + AiImageEditorApi.GetPageNameForImageSlotLabel(page), + Is.EqualTo("Front Cover") + ); + } + + [Test] + public void BuildImageSlotLabel_OnePictureOnThePage_JustNamesThePage() + { + // Nothing to tell it apart from, so the page name is enough. This is the common + // case: a full-page picture is one canvas background image and nothing else. + Assert.That( + AiImageEditorApi.BuildImageSlotLabel("Page 3", true, 1, 1), + Is.EqualTo("Page 3") + ); + Assert.That( + AiImageEditorApi.BuildImageSlotLabel("Page 3", false, 1, 1), + Is.EqualTo("Page 3") + ); + } + + [Test] + public void BuildImageSlotLabel_BackgroundAndAPictureOnIt_NamesTheBackgroundAndNumbersTheRest() + { + // Two empty slots on one page show the same graphic in the AI image editor, so + // without these names the user cannot tell which slot is which (BL-16744). + Assert.That( + AiImageEditorApi.BuildImageSlotLabel("Page 1", true, 0, 2), + Is.EqualTo("Page 1 - Canvas Background") + ); + // The pictures on top of the background start at 1, not at 2. + Assert.That( + AiImageEditorApi.BuildImageSlotLabel("Page 1", false, 1, 2), + Is.EqualTo("Page 1 - Image 1") + ); + } + + [Test] + public void BuildImageSlotLabel_TwoPicturesAndNoBackground_NumbersThem() + { + Assert.That( + AiImageEditorApi.BuildImageSlotLabel("Page 3", false, 1, 2), + Is.EqualTo("Page 3 - Image 1") + ); + Assert.That( + AiImageEditorApi.BuildImageSlotLabel("Page 3", false, 2, 2), + Is.EqualTo("Page 3 - Image 2") + ); + } + + [Test] + public void GetPageNameForImageSlotLabel_NoNumberAndNoLabel_SaysNothing() + { + // HtmlDom can leave a page with no data-page-number attribute at all (BL-12903). + // No label is better than a made-up one such as "unknown". + var page = FirstPageOf("
"); + + Assert.That(AiImageEditorApi.GetPageNameForImageSlotLabel(page), Is.Null); + } + + [Test] + public void BuildImageSlotLabel_NoPageName_NamesTheSlotOnly() + { + // Nothing to say about the page, but two slots still have to be told apart. + Assert.That(AiImageEditorApi.BuildImageSlotLabel(null, false, 1, 1), Is.Null); + Assert.That( + AiImageEditorApi.BuildImageSlotLabel(null, false, 2, 2), + Is.EqualTo("Image 2") + ); + Assert.That( + AiImageEditorApi.BuildImageSlotLabel(null, true, 0, 2), + Is.EqualTo("Canvas Background") + ); + } + + [Test] + public void IsBackgroundImage_TellsTheCanvasBackgroundFromAPictureOnTopOfIt() + { + // EnumerateBookImages asks HtmlDom which slot is the canvas background, and labels + // that one "Canvas Background" rather than "Image 1". This test pins the markup + // that question is asked about, so a change to the canvas DOM breaks here. + var page = FirstPageOf( + "
" + + "
" + + "
" + + "
" + + "
" + + "
" + + "
" + ); + var images = HtmlDom + .SelectChildImgAndBackgroundImageElements(page) + .OfType() + .ToList(); + + // Sanity: both pictures are there, in the order the labels will number them. + Assert.That(images.Count, Is.EqualTo(2), "setup"); + Assert.That(images[0].GetAttribute("src"), Is.EqualTo("back.png"), "setup"); + + Assert.That(HtmlDom.IsBackgroundImage(images[0]), Is.True); + Assert.That(HtmlDom.IsBackgroundImage(images[1]), Is.False); + } + + [Test] + public void BuildImageSlotLabelsForPage_BackgroundAndTwoPicturesOnIt_NamesThenNumbers() + { + var labels = AiImageEditorApi.BuildImageSlotLabelsForPage( + "Page 4", + new[] { true, false, false } + ); + + Assert.That( + labels, + Is.EqualTo( + new[] { "Page 4 - Canvas Background", "Page 4 - Image 1", "Page 4 - Image 2" } + ) + ); + } + + [Test] + public void BuildImageSlotLabelsForPage_SeveralCanvasesOnThePage_NumbersThemAll() + { + // A Picture Dictionary page has six canvases, so six background images. Naming + // them all "Canvas Background" would give six identical labels, which is the + // confusion these labels exist to remove (BL-16744). + var labels = AiImageEditorApi.BuildImageSlotLabelsForPage( + "Page 4", + new[] { true, true, true } + ); + + Assert.That( + labels, + Is.EqualTo(new[] { "Page 4 - Image 1", "Page 4 - Image 2", "Page 4 - Image 3" }), + "identical labels would tell the user nothing" + ); + Assert.That(labels.Distinct().Count(), Is.EqualTo(3), "every label must be distinct"); + } + + [Test] + public void BuildImageSlotLabelsForPage_OneCanvasOnly_JustNamesThePage() + { + // The common case: a full-page picture is one canvas background and nothing else. + Assert.That( + AiImageEditorApi.BuildImageSlotLabelsForPage("Page 4", new[] { true }), + Is.EqualTo(new[] { "Page 4" }) + ); + } } ///