diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf index 92acec8a47a4..e3268cf64c1f 100644 --- a/DistFiles/localization/en/BloomMediumPriority.xlf +++ b/DistFiles/localization/en/BloomMediumPriority.xlf @@ -60,6 +60,11 @@ ID: EditTab.TextContextMenu.NoIndent Item on the menu you get by right-clicking a paragraph of text in the Edit tab. It is a checkable on/off command that removes the first-line indent from just that one paragraph, overriding the indent that the paragraph's style gives it. Typically used on a paragraph continued from the previous page. + + Add Image + ID: EditTab.InlineImage.AddImage + Command in the menu you get by right-clicking a block of text in the Edit tab. It puts a picture inside the text block, with the text wrapping around it (like an inline image in Word). + Choose... diff --git a/src/BloomBrowserUI/bookEdit/css/editMode.less b/src/BloomBrowserUI/bookEdit/css/editMode.less index cc876174e752..800e766b5cb6 100644 --- a/src/BloomBrowserUI/bookEdit/css/editMode.less +++ b/src/BloomBrowserUI/bookEdit/css/editMode.less @@ -1719,7 +1719,11 @@ canvas.moving { } } -#canvas-element-context-controls { +// The bar under a selected canvas element, and the same bar under a selected inline image +// (a picture inside a text block). Both hold the same React component, so they need the +// same box; each has its own id because each is put up and taken down by its own code. +#canvas-element-context-controls, +#inline-image-context-controls { position: absolute; transform-origin: top left; &.moving { @@ -1856,3 +1860,160 @@ svg.bloom-videoControl { left: 50%; transform: translate(-50%, -50%); } + +// Inline (Word-style) images: an image docked inside a bloom-editable that the text +// wraps around. The layout rules live in content/bookLayout/inlineImages.less; these are +// only the edit-time affordances. (The resize handles the interaction layer adds when an +// inline image is selected are styled in the section further down.) +.bloom-editable .bloom-inlineImage { + // The wrapper is contenteditable=false, so make it clear it is an object you act on + // rather than text you type in. + cursor: pointer; + + // Bloom's paragraphs are position:relative, and a positioned element later in the DOM + // paints -- and therefore hit-tests -- ABOVE a float. Without a stacking level of our + // own, every click "on" the image actually lands on the following paragraph's invisible + // full-width box, which makes the image unselectable (verified live over CDP with + // elementsFromPoint). Edit-time only: nothing needs to click the image in a published + // book, and text never paints over the float either way. + position: relative; + z-index: 1; + + // THE IMAGE'S BOX, as custom properties, for anything that must sit exactly on the + // image rather than on the wrapper: the wrapper's own box is NOT the image's box (the + // vertical offset is transparent padding at the top, and in the middle band the + // wrapper spans the whole editable with a narrower image centered in it). Both the + // resize-handle frame and the placeholder flower below consume these, so they cannot + // drift apart. + --inline-image-box-top: 0px; + --inline-image-box-inset: 0px; + // Only the three floating docks put the offset padding at the top of the wrapper. The + // bottom dock ignores --inline-image-offset, so its image box starts at the top. + &.bloom-inlineImageLeft, + &.bloom-inlineImageRight, + &.bloom-inlineImageMiddle { + --inline-image-box-top: var(--inline-image-offset, 0px); + } + // In the middle band the image is centered at --inline-image-width of a full-width + // wrapper, so half of what is left over on each side is wrapper rather than image. + &.bloom-inlineImageMiddle { + --inline-image-box-inset: calc( + (100% - var(--inline-image-width, 40%)) / 2 + ); + } + + // Draw the usual flower placeholder until a real image has been chosen -- on the + // IMAGE's box, not the wrapper's, or the flower floats up into the offset padding + // where the text flows, disconnected from the handles (John, live testing). + &:has(img[src*="placeHolder.png"]) { + // There is no real placeHolder.png file to load, so hide the broken-image icon; + // but we still need the img's *box*: its width plus --inline-image-aspect-ratio + // is the only thing giving the image area a height for the flower to be drawn in. + img[src*="placeHolder.png"] { + display: block; + visibility: hidden; + } + + &::after { + content: ""; + position: absolute; + top: var(--inline-image-box-top); + bottom: 0; + left: var(--inline-image-box-inset); + right: var(--inline-image-box-inset); + background-image: @image-placeholder; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + // Behind the resize handles (the wrapper has z-index:1, so it is a stacking + // context of its own and -1 cannot escape it), and invisible to the mouse. + z-index: -1; + pointer-events: none; + } + } + + // No outline on the wrapper, hovered or selected: the wrapper's box includes the + // transparent offset padding above the image, so an outline runs all the way to the top + // of the text block when the image is dragged down (John, live testing). Selection is + // shown by the corner handles, which sit on the IMAGE box. + &.bloom-inlineImage-selected { + // Dragging it somewhere else is the main thing you do with a selected inline image. + cursor: move; + // The handle frame below is positioned against this box. + position: relative; + } +} + +// INLINE IMAGE SELECTION HANDLES (added by bookEdit/js/inlineImageInteractions.ts) +// Everything in this section is edit-time only: the elements involved are bloom-ui, so +// Cleanup() takes them out before the page is saved and the sync that copies a wrapper to +// the other languages' editables leaves them out of the copy. +@inline-image-handle-size: 10px; +// The handles sit fully INSIDE the image box. Straddling the corners (the usual editor +// look) pokes them past the wrapper's edge, and on a right-docked image that is past the +// editable's edge too, which produced a horizontal scrollbar on the whole text block +// (John, live testing). Inside loses nothing: the corner is still obvious and grabbable. +@inline-image-handle-nudge: 0px; + +// The frame the four resize handles hang off. It sits on the IMAGE's box, which the +// wrapper publishes as --inline-image-box-top/-inset (see the wrapper section above; the +// placeholder flower uses the same values, so the two cannot drift apart). +.bloom-ui-inlineImage-handle-frame { + position: absolute; + top: var(--inline-image-box-top, 0px); + bottom: 0; + left: var(--inline-image-box-inset, 0px); + right: var(--inline-image-box-inset, 0px); + // The frame is only a coordinate system. A press on it belongs to the image (which means + // a drag); only the handles themselves take one back, below. + pointer-events: none; +} + +.bloom-ui-inlineImage-handle { + position: absolute; + width: @inline-image-handle-size; + height: @inline-image-handle-size; + box-sizing: border-box; + background-color: white; + border: 1px solid @bloom-blue; + pointer-events: auto; // see the frame above + + &.bloom-ui-inlineImage-handle-nw { + top: @inline-image-handle-nudge; + left: @inline-image-handle-nudge; + cursor: nwse-resize; + } + &.bloom-ui-inlineImage-handle-ne { + top: @inline-image-handle-nudge; + right: @inline-image-handle-nudge; + cursor: nesw-resize; + } + &.bloom-ui-inlineImage-handle-sw { + bottom: @inline-image-handle-nudge; + left: @inline-image-handle-nudge; + cursor: nesw-resize; + } + &.bloom-ui-inlineImage-handle-se { + bottom: @inline-image-handle-nudge; + right: @inline-image-handle-nudge; + cursor: nwse-resize; + } +} + +// While an inline image is being dragged or resized, the pointer moving across the block +// would otherwise also sweep out a text selection. The class goes on the body, which is +// above the bloom-page and so is never part of what gets saved. +body.bloom-inlineImage-dragging .bloom-editable { + user-select: none; +} + +// Like the other edit-time guides (see the .bloom-page:hover section near the top of this +// file), the resize handles disappear when the mouse leaves the page, so the user sees the +// page as it will print -- even while the image is still selected. The exception is an +// active drag/resize: pointer capture lets the pointer wander off the page mid-gesture, +// and the handles vanishing mid-resize is disorienting. +body:not(.bloom-inlineImage-dragging) + .bloom-page:not(:hover) + .bloom-ui-inlineImage-handle-frame { + display: none; +} diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 30ab1554988c..6fa8ed496d24 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -19,6 +19,22 @@ import { SetupVideoEditing, } from "./bloomVideo"; import { SetupWidgetEditing } from "./bloomWidgets"; +import { + clearInlineImageSelection, + clearInlineImageUndoState, + inlineImageCanUndo, + inlineImageUndo, + commitInlineImageUndoForImageChange, + handleInlineImageChanged, + kInlineImageClass, + prepareInlineImageUndoForImageChange, + setupInlineImages, +} from "./inlineImages"; +import { + cleanupInlineImageInteractions, + adjustInlineImageOffsetsIfBlockSizeChanged, + setupInlineImageInteractions, +} from "./inlineImageInteractions"; import { setupOrigami, cleanupOrigami } from "./origami"; import theOneLocalizationManager from "../../lib/localizationManager/localizationManager"; import StyleEditor from "../StyleEditor/StyleEditor"; @@ -167,6 +183,9 @@ function Cleanup() { cleanupImages(); cleanupOrigami(); cleanupNiceScroll(); + // The inline image handles are bloom-ui and have gone already, but the class marking an + // inline image as selected sits on the wrapper itself, which is saved content. + cleanupInlineImageInteractions(); } //add a delete button which shows up when you hover @@ -455,7 +474,16 @@ export function changeImage(imageInfo: IImageInfo) { ); } if (imageInfo.undoable === "true") { - prepareUndoForImageOperation(imgOrImageContainer); + // An inline image keeps its own undo stack, because undoing it means restoring the + // wrapper in every language's editable, not just this img's src. It says so by + // returning true, and then the image-operation layer must stay out of it. + if (!prepareInlineImageUndoForImageChange(imgOrImageContainer)) { + prepareUndoForImageOperation(imgOrImageContainer); + } + } else if (imgOrImageContainer.closest("." + kInlineImageClass)) { + // Parallel to the clearImageOperationUndoState() above: a change we can't undo must + // not leave older inline-image snapshots reachable behind it. + clearInlineImageUndoState(); } changeImageInfo(imgOrImageContainer, imageInfo); // id is just a temporary expedient to find the right image easily in this method. @@ -463,7 +491,9 @@ export function changeImage(imageInfo: IImageInfo) { theOneCanvasElementManager.updateCanvasElementForChangedImage( imgOrImageContainer, ); - commitPendingImageOperationUndo(imgOrImageContainer); + if (!commitInlineImageUndoForImageChange(imgOrImageContainer)) { + commitPendingImageOperationUndo(imgOrImageContainer); + } notifyToolOfChangedImage(); } @@ -479,14 +509,21 @@ export function changeImageByElement( if (imageInfo.undoable !== "true") { clearImageOperationUndoState(); } + // See changeImage for why an inline image takes its undo into its own hands here. if (imageInfo.undoable === "true") { - prepareUndoForImageOperation(imgOrImageContainer); + if (!prepareInlineImageUndoForImageChange(imgOrImageContainer)) { + prepareUndoForImageOperation(imgOrImageContainer); + } + } else if (imgOrImageContainer.closest("." + kInlineImageClass)) { + clearInlineImageUndoState(); } changeImageInfo(imgOrImageContainer, imageInfo as IImageInfo); theOneCanvasElementManager.updateCanvasElementForChangedImage( imgOrImageContainer, ); - commitPendingImageOperationUndo(imgOrImageContainer); + if (!commitInlineImageUndoForImageChange(imgOrImageContainer)) { + commitPendingImageOperationUndo(imgOrImageContainer); + } notifyToolOfChangedImage(); } @@ -536,6 +573,14 @@ export function changeImageInfo( imgOrImageContainer.setAttribute("data-creator", imageInfo.creator); imgOrImageContainer.setAttribute("data-license", imageInfo.license); + // An inline image lives inside a bloom-editable, and every language's editable in the + // translation group holds its own copy of it, so a new picture has to be pushed out to + // the siblings. (Both changeImage and changeImageByElement come through here, so this is + // the one place that needs to know.) + if (imgOrImageContainer.closest("." + kInlineImageClass)) { + handleInlineImageChanged(imgOrImageContainer); + } + const page = imgOrImageContainer.closest( ".bloom-page", ) as HTMLElement | null; @@ -569,6 +614,18 @@ export function SetupElements( CanvasElementManager.recordInitialZoom(container); SetupImagesInContainer(container); + // Inline images are not images as far as SetupImagesInContainer is concerned (they are + // not in a bloom-canvas or bloom-imageContainer), so they get their own setup: make the + // per-language copies agree, and watch for the images to load. + setupInlineImages(container); + // ...and their own interaction layer: the right-click menu that adds and removes them, + // selecting one, dragging it to another dock, and resizing it. + setupInlineImageInteractions(container); + // An inline image's offset is an absolute distance, so a block that has changed size since + // it was written -- another page size, another layout for the page, a pane dragged in Change + // Layout -- needs it re-measured, or the text after the picture is pushed off the end of the + // block. After setupInlineImages, which is what makes the languages' copies agree. + adjustInlineImageOffsetsIfBlockSizeChanged(container); SetupVideoEditing(container); SetupWidgetEditing(container); @@ -1210,13 +1267,12 @@ export function bootstrap() { // configure ckeditor if (typeof CKEDITOR === "undefined") return; // this happens during unit testing - if ($(this).find(".bloom-canvas").length) { - // We would *like* to wire up ckeditor, but would need to get it to stop interfering - // with the embedded image. See https://silbloom.myjetbrains.com/youtrack/issue/BL-3125. - // Currently this is only possible in the grade 4 Uganda books by SIL-LEAD. - // So for now, we just going to say that you don't get ckeditor inside fields that have an embedded image. - return; - } + // There used to be a guard here that skipped attaching ckeditor at all if the page had + // an embedded image in a text field (BL-3125, the SIL-LEAD grade 4 Uganda books). It + // never did anything: this is module scope, so `this` was not a page and the jQuery set + // was always empty. Removed along with the assumption behind it -- a contenteditable=false + // island inside a ckeditor-managed field is fine (the format cog has always been one), + // which is what inline images rely on. // Attach ckeditor to the fields that can have styled editable text. // (See comment above on ckeditableSelector for what fields those are.) @@ -1333,6 +1389,9 @@ function removeEditingDebris() { textLabels[i].remove(); } removeTransientVideoTimestampParams(document.body); + // A picture that is selected when the page is saved would otherwise carry that class into + // the book's HTML, and from there into spreadsheet exports and published books. + clearInlineImageSelection(document.body); cleanupNiceScroll(); // don't leave the nicescroll debris around } @@ -2043,6 +2102,31 @@ export function attachToCkEditor(element) { } }); + // Ctrl+Z has to reach the inline-image undo stack, which the top-bar Undo button reaches + // through workspaceRoot.handleUndo. Nothing else binds the key: it arrives in the page and + // ckeditor's undo plugin runs it as the "undo" command. That command restores the saved HTML + // of ONE editable, and an inline image exists once per language in the group, so letting it + // have the key put the focused block's copy back and left the others -- including the lang="z" + // prototype a language added later is built from -- at the geometry the person had just + // undone. Measured: 40% restored in "en" while another copy stayed at 47.7%. + // + // So we take the command when this layer owns the moment, and cancel ckeditor's. The gate is + // the same one handleUndo consults, and it says yes only when an inline image is the active + // thing in the group being restored, so ordinary typing keeps its ctrl+z. + ckedit.on( + "beforeCommandExec", + (evt) => { + if (evt.data.name !== "undo") return; + if (!inlineImageCanUndo()) return; + inlineImageUndo(); + evt.cancel(); + }, + // Ahead of the undo plugin's own listeners, so the snapshot machinery does not run. + null, + null, + 1, + ); + // hide the toolbar when ckeditor starts ckedit.on("instanceReady", (evt) => { const editor = evt["editor"]; diff --git a/src/BloomBrowserUI/bookEdit/js/inlineImageInteractions.test.ts b/src/BloomBrowserUI/bookEdit/js/inlineImageInteractions.test.ts new file mode 100644 index 000000000000..7c447a20b896 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/inlineImageInteractions.test.ts @@ -0,0 +1,1507 @@ +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; + +// Whether AI image editing is turned on is asked of the api in three ways here: +// setupInlineImageInteractions calls getFeatureStatusAsync, and the toolbar component the +// tests below put up uses both hooks. There is no api in these tests, so every one of those +// requests would fail on the network and the menu's contents would depend on when that failure +// landed. They are answered here instead: off, which is what the menu tests expect ("Edit with +// AI" absent). Every export the component might reach for has to be here -- a module mock +// replaces the whole module, and a missing name throws inside React's render. +vi.mock("../../react_components/featureStatus", () => ({ + getFeatureStatusAsync: () => Promise.resolve(undefined), + useGetFeatureStatus: () => undefined, + useGetFeatureAvailabilityMessage: () => "", + openBloomSubscriptionSettings: () => {}, +})); +import { + getTestRoot, + cleanTestRoot, + removeTestRoot, +} from "../../utils/testHelper"; +import { + getInlineImage, + getInlineImageInEditable, + getInlineImages, + handleInlineImageChanged, + inlineImageCanUndo, + inlineImageUndo, + insertInlineImage, + kInlineImageBottomClass, + kInlineImageClass, + kInlineImageIdAttr, + kInlineImageLeftClass, + kInlineImageMiddleClass, + kInlineImageOffsetBasedOnAttr, + kInlineImageRightClass, + kInlineImageSelectedClass, + recordInlineImageUndoPoint, + setInlineImageDock, + syncInlineImagesFromEditable, +} from "./inlineImages"; +import { + adjustInlineImageOffsetsIfBlockSizeChanged, + buildInlineImageMenuItems, + cleanupInlineImageInteractions, + clampInlineImageOffset, + computeInlineImageOffsetForNewBlockHeight, + clampInlineImageWidthPercent, + computeBlockContentBox, + computeInlineImageDragScrollLayoutPx, + computeViewportPxPerLayoutPx, + shouldRevertInlineImageMove, + computeInlineImageClusterIndex, + computeInlineImageDock, + computeInlineImageWidthPercent, + deselectAllInlineImages, + getInlineImageActionTarget, + getInlineImageDock, + getInlineImageHandleHorizontalSign, + getInlineImageMenuItemsForClick, + kInlineImageContextControlsId, + kInlineImageHandleClass, + kInlineImageHandleFrameClass, + kMaxInlineImageWidthPercent, + kMinInlineImageWidthPercent, + selectInlineImage, + setupInlineImageInteractions, +} from "./inlineImageInteractions"; + +// jsdom reports every element's box as empty, so a real drag cannot be checked for where it +// put the image; the arithmetic that decides that lives in the pure functions these tests +// aim at. The DOM tests cover selection and which commands a right-click offers where. + +// jsdom implements none of the pointer-capture API. This stub records who holds what, which is +// what the drag tests check; it does not route events by capture, which is the part jsdom could +// not do anyway and the part only a real browser (the e2e suite) can exercise. +const capturedPointers = new Map(); +Element.prototype.setPointerCapture = function (pointerId: number) { + capturedPointers.set(this, pointerId); +}; +Element.prototype.releasePointerCapture = function (pointerId: number) { + if (capturedPointers.get(this) === pointerId) capturedPointers.delete(this); +}; +Element.prototype.hasPointerCapture = function (pointerId: number) { + return capturedPointers.get(this) === pointerId; +}; + +// jsdom has no PointerEvent, but a listener registered for "pointerdown" fires for any event +// of that type, and MouseEvent carries the button and the coordinates this module reads. It +// does not carry a pointerId, and the capture the gesture takes needs one, so it is added +// afterwards (MouseEvent would ignore it as an option). +const kTestPointerId = 7; +// A second finger, for the test about one gesture's events reaching another's state. +const kOtherTestPointerId = 9; +function pointerEvent( + type: string, + clientX: number, + clientY: number, + pointerId: number = kTestPointerId, +): MouseEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + clientX, + clientY, + }); + Object.defineProperty(event, "pointerId", { value: pointerId }); + return event; +} + +// A block 300px wide and 200px tall at the origin, so that thirds land on round numbers +// (100 and 200) and the bottom fifth starts at y=160. +const kEditableBox = { left: 0, top: 0, width: 300, height: 200 }; +// The dock of a position depends on how tall the image is, because the bottom dock starts +// where the band can no longer fit the image inside the block's content. +const kImageHeightViewportPx = 40; + +let pageCounter = 0; + +// Builds a page with one translation group, one editable per entry. Same shape as +// inlineImages.test.ts, since the undo layer keys on the page id. +function makeTranslationGroup( + editables: { lang: string; classes?: string; content?: string }[], + options?: { insideCanvasElement?: boolean; groupClasses?: string }, +): HTMLElement { + const root = getTestRoot(); + const groupHtml = + `
` + + editables + .map( + (e) => + `
${ + e.content ?? "" + }
`, + ) + .join("") + + `
`; + root.innerHTML = + `
` + + (options?.insideCanvasElement + ? `
${groupHtml}
` + : groupHtml) + + `
`; + return root.querySelector("#group") as HTMLElement; +} + +const makeSimpleGroup = (options?: { + insideCanvasElement?: boolean; + groupClasses?: string; +}) => + makeTranslationGroup( + [ + { + lang: "en", + classes: "bloom-content1 bloom-visibility-code-on", + content: "

English

", + }, + { lang: "fr", content: "

French

" }, + ], + options, + ); + +const editableFor = (group: HTMLElement, lang: string) => + group.querySelector(`[lang="${lang}"]`) as HTMLElement; + +describe("inlineImageInteractions", () => { + beforeEach(() => { + cleanTestRoot(); + cleanupInlineImageInteractions(); + capturedPointers.clear(); + }); + afterAll(removeTestRoot); + + describe("computeInlineImageDock", () => { + it("switches dock at the thirds of the block's width", () => { + const dockAt = (x: number) => + computeInlineImageDock( + { x, y: 10 }, + kEditableBox, + kImageHeightViewportPx, + ); + expect(dockAt(1)).toBe(kInlineImageLeftClass); + expect(dockAt(99)).toBe(kInlineImageLeftClass); + // Exactly on a boundary belongs to the band, not to the side it came from. + expect(dockAt(100)).toBe(kInlineImageMiddleClass); + expect(dockAt(150)).toBe(kInlineImageMiddleClass); + expect(dockAt(200)).toBe(kInlineImageMiddleClass); + expect(dockAt(201)).toBe(kInlineImageRightClass); + expect(dockAt(299)).toBe(kInlineImageRightClass); + }); + + it("keeps the dock of a position beyond the sides of the block", () => { + expect( + computeInlineImageDock( + { x: -500, y: 10 }, + kEditableBox, + kImageHeightViewportPx, + ), + ).toBe(kInlineImageLeftClass); + expect( + computeInlineImageDock( + { x: 900, y: 10 }, + kEditableBox, + kImageHeightViewportPx, + ), + ).toBe(kInlineImageRightClass); + }); + + it("hands the middle third to the bottom dock exactly where the band can no longer fit the image", () => { + // The block's content ends at 200 and the image is 40 tall, so the band can hold + // it while its center is above 180 and not a pixel lower. Every position above + // that is the band's, which is the point: the band's position down the block is a + // distance, and a person moving the picture down expects to be able to stop + // anywhere (John: "it seems like I should be able to put it vertically anywhere I + // want"). This used to be the bottom FIFTH of the block, which in an overflowing + // block was several lines of unreachable positions. + expect( + computeInlineImageDock( + { x: 150, y: 179 }, + kEditableBox, + kImageHeightViewportPx, + ), + ).toBe(kInlineImageMiddleClass); + expect( + computeInlineImageDock( + { x: 150, y: 180 }, + kEditableBox, + kImageHeightViewportPx, + ), + ).toBe(kInlineImageBottomClass); + // A taller image runs out of room higher up, since it is the image's BOTTOM that + // has to stay inside the content. + expect( + computeInlineImageDock({ x: 150, y: 150 }, kEditableBox, 100), + ).toBe(kInlineImageBottomClass); + // The side docks win all the way down, so an image can be parked in a lower + // corner (with clear:both zones stealing the whole strip, the corners were + // unreachable). + expect( + computeInlineImageDock( + { x: 20, y: 199 }, + kEditableBox, + kImageHeightViewportPx, + ), + ).toBe(kInlineImageLeftClass); + expect( + computeInlineImageDock( + { x: 280, y: 199 }, + kEditableBox, + kImageHeightViewportPx, + ), + ).toBe(kInlineImageRightClass); + }); + + it("docks at the bottom for a position below the block altogether, whatever the horizontal position", () => { + [20, 150, 280].forEach((x) => { + expect( + computeInlineImageDock( + { x, y: 5000 }, + kEditableBox, + kImageHeightViewportPx, + ), + `x=${x} below the block`, + ).toBe(kInlineImageBottomClass); + }); + }); + + it("answers with the band for a block that has no width", () => { + expect( + computeInlineImageDock( + { x: 0, y: 0 }, + { left: 0, top: 0, width: 0, height: 0 }, + 0, + ), + ).toBe(kInlineImageBottomClass); + expect( + computeInlineImageDock( + { x: 0, y: -10 }, + { left: 0, top: 0, width: 0, height: 0 }, + 0, + ), + ).toBe(kInlineImageMiddleClass); + }); + }); + + // The numbers here were measured in a running Bloom, on the page that produced the + // report: an A4 page whose text nearly filled it, with an image parked near the bottom, + // changed to A6 portrait. The text no longer fits the smaller page, so the block + // scrolls: its rectangle is 532 screen pixels tall and holds 484 layout pixels (the page + // is drawn at 110%), while the text it contains is 841 layout pixels tall. + const kA6Report = { + visibleBoxViewportPx: { + left: 71, + top: 87, + width: 353, + height: 532.159, + }, + clientHeightLayoutPx: 484, + scrollHeightLayoutPx: 841, + // The image had been given an offset of 661 layout pixels on the A4 page, which puts + // it below everything the smaller page can show at once. + imageCenterYViewportPxWhenScrolledToTop: 888, + // The ball picture was 40% of a 353-pixel block wide, and about this tall on screen. + imageHeightViewportPx: 163, + }; + + describe("computeBlockContentBox", () => { + it("gives a block that fits its text its own rectangle", () => { + const box = { left: 71, top: 87, width: 353, height: 200 }; + expect(computeBlockContentBox(box, 200, 200, 0)).toEqual(box); + }); + + it("reaches past the bottom of a block whose text overflows", () => { + const content = computeBlockContentBox( + kA6Report.visibleBoxViewportPx, + kA6Report.clientHeightLayoutPx, + kA6Report.scrollHeightLayoutPx, + 0, + ); + expect(content.top).toBe(87); + // 841 layout pixels of text, drawn at 110%. + expect(Math.round(content.height)).toBe(925); + expect(Math.round(content.top + content.height)).toBe(1012); + }); + + it("follows the block as it is scrolled, so the content keeps one position", () => { + const scrolled = computeBlockContentBox( + kA6Report.visibleBoxViewportPx, + kA6Report.clientHeightLayoutPx, + kA6Report.scrollHeightLayoutPx, + 324.667, + ); + expect(Math.round(scrolled.top)).toBe(-270); + expect(Math.round(scrolled.top + scrolled.height)).toBe(655); + }); + + it("returns the rectangle unchanged when nothing is laid out (jsdom)", () => { + const box = { left: 0, top: 0, width: 0, height: 0 }; + expect(computeBlockContentBox(box, 0, 0, 0)).toEqual(box); + }); + }); + + describe("computeInlineImageDragScrollLayoutPx", () => { + // A block 200 screen pixels tall starting at 100, drawn at 110%. + const visible = { left: 0, top: 100, width: 300, height: 200 }; + const scale = 1.1; + + it("asks for no scroll while the picture is showing", () => { + expect( + computeInlineImageDragScrollLayoutPx( + { left: 0, top: 150, width: 60, height: 50 }, + visible, + scale, + ), + ).toBe(0); + }); + + it("scrolls down by exactly what hangs below the block, in layout pixels", () => { + // Bottom at 320, which is 20 screen pixels below the block's 300. + expect( + computeInlineImageDragScrollLayoutPx( + { left: 0, top: 270, width: 60, height: 50 }, + visible, + scale, + ), + ).toBeCloseTo(20 / 1.1); + }); + + it("scrolls up by exactly what is above the block", () => { + // Top at 85, which is 15 screen pixels above the block's 100. + expect( + computeInlineImageDragScrollLayoutPx( + { left: 0, top: 85, width: 60, height: 50 }, + visible, + scale, + ), + ).toBeCloseTo(-15 / 1.1); + }); + + it("shows the bottom of a picture too tall for the block", () => { + const wanted = computeInlineImageDragScrollLayoutPx( + { left: 0, top: 90, width: 60, height: 300 }, + visible, + scale, + ); + expect(wanted).toBeGreaterThan(0); + }); + + it("asks for nothing where nothing is laid out (jsdom)", () => { + expect( + computeInlineImageDragScrollLayoutPx( + { left: 0, top: 0, width: 0, height: 0 }, + { left: 0, top: 0, width: 0, height: 0 }, + 0, + ), + ).toBe(0); + }); + }); + + describe("shouldRevertInlineImageMove", () => { + it("undoes a move that pushed a fitting block into overflow", () => { + expect(shouldRevertInlineImageMove(0, 40)).toBe(true); + }); + + it("keeps a move that left a fitting block fitting", () => { + expect(shouldRevertInlineImageMove(0, 0)).toBe(false); + }); + + it("ignores a pixel of layout noise", () => { + expect(shouldRevertInlineImageMove(0, 1)).toBe(false); + }); + + it("keeps every move in a block whose text already overflowed", () => { + // The bug this rule was rewritten for: at offset 680 in the developer's A6 block + // the text already needed 344 layout pixels more than the block had, and dragging + // the image UP asks the text below it for more room still. Under the old rule that + // added overflow, so the move was undone -- every time, in both directions, which + // is what "the image is just stuck there" was. + expect(shouldRevertInlineImageMove(344, 381)).toBe(false); + expect(shouldRevertInlineImageMove(344, 344)).toBe(false); + expect(shouldRevertInlineImageMove(344, 300)).toBe(false); + }); + }); + + describe("computeViewportPxPerLayoutPx", () => { + it("is the ratio of the screen rectangle to the laid-out height", () => { + expect( + computeViewportPxPerLayoutPx( + kA6Report.visibleBoxViewportPx, + kA6Report.clientHeightLayoutPx, + ), + ).toBeCloseTo(1.0995, 4); + }); + + it("is 1 when there is nothing to measure", () => { + expect( + computeViewportPxPerLayoutPx( + { left: 0, top: 0, width: 0, height: 0 }, + 0, + ), + ).toBe(1); + }); + }); + + // The report: "I added an image to the bottom right-hand corner of an A4 portrait page + // that was pretty full of text. I then changed the page layout to be A6 portrait, the + // text now requires scrolling. I was not able to reposition that image anymore. It was + // just stuck there." + describe("an image in a block whose text overflows can still be dragged", () => { + it("does not read the image as being below the block", () => { + const asShown = computeInlineImageDock( + { + x: 350, + y: kA6Report.imageCenterYViewportPxWhenScrolledToTop, + }, + kA6Report.visibleBoxViewportPx, + kA6Report.imageHeightViewportPx, + ); + // What the block's rectangle says, and the whole of the reported bug: the image + // sits below the bottom of what the small page shows, so every drag of it asked + // for the bottom dock -- which does not fit in a block that is already too + // small, so every move was undone and the image never went anywhere. + expect(asShown).toBe(kInlineImageBottomClass); + + const contentBox = computeBlockContentBox( + kA6Report.visibleBoxViewportPx, + kA6Report.clientHeightLayoutPx, + kA6Report.scrollHeightLayoutPx, + 0, + ); + expect( + computeInlineImageDock( + { + x: 350, + y: kA6Report.imageCenterYViewportPxWhenScrolledToTop, + }, + contentBox, + kA6Report.imageHeightViewportPx, + ), + "The image is inside the block's text, so a drag there is an ordinary move, " + + "not a request for the bottom dock.", + ).toBe(kInlineImageRightClass); + }); + + it("leaves room below the image to drag it into", () => { + const wrapperBottomViewportPx = 900; // the wrapper's bottom edge, on the screen + const currentOffsetLayoutPx = 661; + const roomByRectangle = + kA6Report.visibleBoxViewportPx.top + + kA6Report.visibleBoxViewportPx.height - + wrapperBottomViewportPx; + // Measured against the rectangle, the image is 281 pixels PAST the limit, so + // every drag clamped it back up to the fold. + expect(Math.round(roomByRectangle)).toBe(-281); + expect( + clampInlineImageOffset( + currentOffsetLayoutPx + roomByRectangle, + currentOffsetLayoutPx + roomByRectangle, + ), + ).toBe(380); + + const contentBox = computeBlockContentBox( + kA6Report.visibleBoxViewportPx, + kA6Report.clientHeightLayoutPx, + kA6Report.scrollHeightLayoutPx, + 0, + ); + const viewportPxPerLayoutPx = computeViewportPxPerLayoutPx( + kA6Report.visibleBoxViewportPx, + kA6Report.clientHeightLayoutPx, + ); + const roomByContent = + (contentBox.top + contentBox.height - wrapperBottomViewportPx) / + viewportPxPerLayoutPx; + expect(roomByContent).toBeGreaterThan(0); + expect( + clampInlineImageOffset( + currentOffsetLayoutPx + 20, + currentOffsetLayoutPx + roomByContent, + ), + "The image is inside the text, so it can still be pushed further down.", + ).toBe(681); + }); + }); + + describe("computeInlineImageClusterIndex", () => { + it("counts how many neighbors sit at or above the target", () => { + expect(computeInlineImageClusterIndex(50, [])).toBe(0); + expect(computeInlineImageClusterIndex(50, [100, 300])).toBe(0); + expect(computeInlineImageClusterIndex(150, [100, 300])).toBe(1); + expect(computeInlineImageClusterIndex(500, [100, 300])).toBe(2); + // A tie belongs after the neighbor already at that height. + expect(computeInlineImageClusterIndex(100, [100, 300])).toBe(1); + }); + }); + + describe("clampInlineImageOffset", () => { + it("never goes above the top of the block", () => { + expect(clampInlineImageOffset(-1)).toBe(0); + expect(clampInlineImageOffset(-9999)).toBe(0); + expect(clampInlineImageOffset(0)).toBe(0); + }); + + it("rounds to whole pixels", () => { + expect(clampInlineImageOffset(12.4)).toBe(12); + expect(clampInlineImageOffset(12.6)).toBe(13); + }); + + it("stops at the maximum when there is one", () => { + expect(clampInlineImageOffset(500, 200)).toBe(200); + expect(clampInlineImageOffset(150, 200)).toBe(150); + }); + + it("pins the image to the top when the maximum is zero or negative (image fills the block)", () => { + expect(clampInlineImageOffset(150, 0)).toBe(0); + expect(clampInlineImageOffset(150, -5)).toBe(0); + }); + + it("applies no maximum when none is given (no box to measure against)", () => { + expect(clampInlineImageOffset(150)).toBe(150); + }); + }); + + describe("computeInlineImageOffsetForNewBlockHeight", () => { + it("keeps the picture the same share of the way down a shorter block", () => { + // Two thirds of the way down a 900px block is two thirds of the way down a 300px one. + expect( + computeInlineImageOffsetForNewBlockHeight(600, 900, 300), + ).toBe(200); + }); + + it("keeps the same share of the way down a taller block", () => { + expect( + computeInlineImageOffsetForNewBlockHeight(200, 300, 900), + ).toBe(600); + }); + + it("leaves an offset alone when the block is the size it was measured against", () => { + expect( + computeInlineImageOffsetForNewBlockHeight(431, 773, 773), + ).toBe(431); + }); + + it("rounds to whole pixels, like every other offset", () => { + expect( + computeInlineImageOffsetForNewBlockHeight(431, 773, 516), + ).toBe(288); + }); + + it("leaves the offset alone where there is no height to work from", () => { + // Nothing is laid out (jsdom), or no baseline was ever recorded. + expect(computeInlineImageOffsetForNewBlockHeight(431, 0, 516)).toBe( + 431, + ); + expect(computeInlineImageOffsetForNewBlockHeight(431, 773, 0)).toBe( + 431, + ); + }); + + it("never puts the picture above the top of the block", () => { + expect( + computeInlineImageOffsetForNewBlockHeight(-5, 773, 516), + ).toBe(0); + }); + }); + + describe("width clamping", () => { + it("keeps a width inside the usable range", () => { + expect(clampInlineImageWidthPercent(0)).toBe( + kMinInlineImageWidthPercent, + ); + expect(clampInlineImageWidthPercent(-40)).toBe( + kMinInlineImageWidthPercent, + ); + expect(clampInlineImageWidthPercent(1000)).toBe( + kMaxInlineImageWidthPercent, + ); + expect(clampInlineImageWidthPercent(42.5)).toBe(42.5); + }); + + it("turns a corner drag into a percentage of the block's width", () => { + // 120px of a 300px block is 40%; dragging an east corner 30px right adds 10%. + expect(computeInlineImageWidthPercent(120, 30, 1, 300)).toBe(50); + // The same movement on a west corner is inward, so it shrinks. + expect(computeInlineImageWidthPercent(120, 30, -1, 300)).toBe(30); + // Dragging a west corner outward (leftward) grows it. + expect(computeInlineImageWidthPercent(120, -30, -1, 300)).toBe(50); + }); + + it("rounds a width to one decimal place", () => { + // 121px of 300 is 40.333...% + expect(computeInlineImageWidthPercent(121, 0, 1, 300)).toBe(40.3); + }); + + it("clamps a drag that would make the image unusably wide or narrow", () => { + expect(computeInlineImageWidthPercent(120, 9999, 1, 300)).toBe( + kMaxInlineImageWidthPercent, + ); + expect(computeInlineImageWidthPercent(120, 9999, -1, 300)).toBe( + kMinInlineImageWidthPercent, + ); + }); + + it("grows on an outward drag for every corner", () => { + expect(getInlineImageHandleHorizontalSign("ne")).toBe(1); + expect(getInlineImageHandleHorizontalSign("se")).toBe(1); + expect(getInlineImageHandleHorizontalSign("nw")).toBe(-1); + expect(getInlineImageHandleHorizontalSign("sw")).toBe(-1); + }); + }); + + describe("getInlineImageActionTarget", () => { + it("offers adding an image in an eligible empty block", () => { + const group = makeSimpleGroup(); + const editable = editableFor(group, "en"); + const target = getInlineImageActionTarget( + editable.querySelector("p") as HTMLElement, + ); + expect(target.kind).toBe("add"); + if (target.kind !== "add") return; + expect(target.translationGroup).toBe(group); + expect(target.editable).toBe(editable); + }); + + it("offers nothing for a block inside a canvas element", () => { + const group = makeSimpleGroup({ insideCanvasElement: true }); + // Sanity check: the same block outside a canvas element would be eligible. + expect( + getInlineImageActionTarget( + editableFor(group, "en").querySelector("p") as HTMLElement, + ).kind, + ).toBe("none"); + }); + + it("offers nothing inside an image description", () => { + // An image description IS a translation group, and it lives inside the + // bloom-canvas but not inside a canvas element, so neither of the exclusions + // above reaches it. It is not a place for a picture: it is what a reader hears + // in place of one. + const group = makeSimpleGroup({ + groupClasses: "bloom-imageDescription", + }); + expect( + getInlineImageActionTarget( + editableFor(group, "en").querySelector("p") as HTMLElement, + ).kind, + ).toBe("none"); + }); + + it("offers nothing for a bloom-editable that is not a child of a translation group", () => { + const root = getTestRoot(); + root.innerHTML = `

loose

`; + expect( + getInlineImageActionTarget( + root.querySelector("p") as HTMLElement, + ).kind, + ).toBe("none"); + }); + + it("offers nothing outside any block", () => { + const group = makeSimpleGroup(); + expect(getInlineImageActionTarget(group).kind).toBe("none"); + expect(getInlineImageActionTarget(undefined).kind).toBe("none"); + }); + + it("offers the image's own commands when the wrapper is pointed at", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const target = getInlineImageActionTarget( + wrapper.querySelector("img") as HTMLElement, + ); + expect(target.kind).toBe("existing"); + if (target.kind !== "existing") return; + expect(target.wrapper).toBe(wrapper); + expect(target.editable).toBe(editableFor(group, "en")); + }); + + it("still offers adding in the text of a block whose group already has an image", () => { + const group = makeSimpleGroup(); + insertInlineImage(group); + // There is no limit on inline images per group; each add appends a new one. + expect( + getInlineImageActionTarget( + editableFor(group, "en").querySelector("p") as HTMLElement, + ).kind, + ).toBe("add"); + // ...from a sibling language's block too. + expect( + getInlineImageActionTarget( + editableFor(group, "fr").querySelector("p") as HTMLElement, + ).kind, + ).toBe("add"); + }); + }); + + describe("buildInlineImageMenuItems", () => { + it("offers only Add Image where there is no image", () => { + const group = makeSimpleGroup(); + const items = buildInlineImageMenuItems( + getInlineImageActionTarget( + editableFor(group, "en").querySelector("p") as HTMLElement, + ), + ); + expect(items.map((i) => i.l10nId)).toEqual([ + "EditTab.InlineImage.AddImage", + ]); + }); + + it("offers the standard image menu plus Delete for an existing image", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const items = buildInlineImageMenuItems( + getInlineImageActionTarget(wrapper), + ); + // The registry's "image" section, filtered by its normal availability rules: + // "Expand image to fill space" is for background images only, and Become + // Background / Use for book thumbnail are excluded for an image inside a text + // block. "Edit with AI" is behind a feature flag that is off here. Then a + // divider and Delete. + expect(items.map((i) => i.l10nId)).toEqual([ + "EditTab.Image.EditMetadataOverlay", + "EditTab.Image.ChooseImage", + "EditTab.Image.CopyImage", + "EditTab.Image.PasteImage", + "EditTab.Image.Reset", + "EditTab.Image.Transparency", + "-", + "Common.Delete", + ]); + // A new inline image holds a placeholder: no credits to edit, nothing to copy, + // and no transparency of a real picture to control... + const byId = (id: string) => items.find((i) => i.l10nId === id)!; + expect(byId("EditTab.Image.EditMetadataOverlay").disabled).toBe( + true, + ); + expect(byId("EditTab.Image.CopyImage").disabled).toBe(true); + expect(byId("EditTab.Image.Transparency").disabled).toBe(true); + // ...but choosing a picture is exactly what a placeholder is waiting for. + expect(byId("EditTab.Image.ChooseImage").disabled).toBeFalsy(); + // Transparency is a submenu, as on the canvas element menu. + expect( + byId("EditTab.Image.Transparency").subMenu?.length, + ).toBeGreaterThan(0); + }); + + it("enables the picture-dependent commands once a real picture is in place", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + wrapper.querySelector("img")!.setAttribute("src", "flower.jpg"); + const items = buildInlineImageMenuItems( + getInlineImageActionTarget(wrapper), + ); + const byId = (id: string) => items.find((i) => i.l10nId === id)!; + expect(byId("EditTab.Image.EditMetadataOverlay").disabled).toBe( + false, + ); + expect(byId("EditTab.Image.CopyImage").disabled).toBe(false); + expect(byId("EditTab.Image.Transparency").disabled).toBe(false); + }); + + it("offers nothing where there is nothing to act on", () => { + expect(buildInlineImageMenuItems({ kind: "none" })).toEqual([]); + }); + + it("a standard command acts on the clicked image and syncs the other languages", async () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + wrapper.querySelector("img")!.setAttribute("src", "flower.jpg"); + const items = buildInlineImageMenuItems( + getInlineImageActionTarget(wrapper), + ); + const transparency = items.find( + (i) => i.l10nId === "EditTab.Image.Transparency", + )!; + const transparent = transparency.subMenu!.find( + (s) => s.l10nId === "EditTab.Image.Transparency.Transparent", + )!; + // Sanity check: the command finds its image through the registry's own + // plumbing (getImage), which must cope with the inline wrapper's shape -- + // an img that is a direct child, with no bloom-imageContainer. + expect( + wrapper + .querySelector("img")! + .classList.contains("bloom-transparent"), + ).toBe(false); + + (transparent.onClick as () => void)(); + + // It acted on the clicked copy... (the command is synchronous, but the sync that + // follows it awaits it first, so this is the state to wait for rather than a delay + // to sit through) + await vi.waitFor(() => + expect( + wrapper + .querySelector("img")! + .classList.contains("bloom-transparent"), + ).toBe(true), + ); + // ...and the follow-up sync stamped the result onto the other language. + const frenchWrapper = getInlineImageInEditable( + editableFor(group, "fr"), + )!; + expect( + frenchWrapper + .querySelector("img")! + .classList.contains("bloom-transparent"), + ).toBe(true); + }); + + it("undoes a command that mutates the image in place", async () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + (wrapper.querySelector("img") as HTMLImageElement).src = + "flower.jpg"; + // Right-clicking is what selects it, and the undo layer's gate is the selection. + const items = getInlineImageMenuItemsForClick( + wrapper.querySelector("img") as HTMLElement, + ); + const transparency = items.find( + (i) => i.l10nId === "EditTab.Image.Transparency", + )!; + const transparent = transparency.subMenu!.find( + (i) => i.l10nId === "EditTab.Image.Transparency.Transparent", + )!; + (transparent.onClick as () => void)(); + await vi.waitFor(() => + expect( + wrapper + .querySelector("img")! + .classList.contains("bloom-transparent"), + ).toBe(true), + ); + + inlineImageUndo(); + + // The undo has to take back the transparency, not the insert that put the picture + // there: the commands come from the canvas-element registry, which knows nothing + // about this undo layer, so without an undo point of their own the last thing + // recorded is the insert, and ctrl+z makes the picture disappear. + const restored = getInlineImage(group); + expect( + restored, + "expected the picture to still be there", + ).not.toBeNull(); + expect( + restored! + .querySelector("img")! + .classList.contains("bloom-transparent"), + ).toBe(false); + }); + + it("deselects the image when the right-click landed on the text instead", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + // Sanity check. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + + getInlineImageMenuItemsForClick( + editableFor(group, "en").querySelector("p") as HTMLElement, + ); + + // Leaving it selected says the commands the person is now looking at apply to the + // picture, which they do not -- and it keeps ctrl+z routed to the inline-image + // undo layer when what they meant was the text. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + false, + ); + }); + + it("selects the image a right-click landed on, so its commands have a subject", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + // Sanity check. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + false, + ); + + const items = getInlineImageMenuItemsForClick( + wrapper.querySelector("img") as HTMLElement, + ); + + expect(items.length).toBeGreaterThan(0); + // The undo layer's gate is the selection, so this is not merely cosmetic. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + }); + + it("selects nothing for a click that offers no commands", () => { + const group = makeSimpleGroup(); + insertInlineImage(group); + // A click outside any editable (here, the group itself) offers nothing. + const items = getInlineImageMenuItemsForClick(group); + expect(items).toEqual([]); + expect( + document.querySelector("." + kInlineImageSelectedClass), + ).toBeNull(); + }); + + it("Delete removes just the clicked image, in every language", () => { + const group = makeSimpleGroup(); + const first = insertInlineImage(group); + const second = insertInlineImage(group); + const firstId = first.getAttribute(kInlineImageIdAttr); + const secondId = second.getAttribute(kInlineImageIdAttr); + // Sanity: two distinct images, each present in both languages. + expect(firstId).toBeTruthy(); + expect(secondId).toBeTruthy(); + expect(firstId).not.toBe(secondId); + expect( + document.querySelectorAll( + `[${kInlineImageIdAttr}="${firstId}"]`, + ).length, + ).toBe(2); + + const items = buildInlineImageMenuItems( + getInlineImageActionTarget(first), + ); + const remove = items.find((i) => i.l10nId === "Common.Delete"); + expect(remove, "expected a Delete item").toBeTruthy(); + // Actually invoke the command (a gap a real runtime break slipped through once). + (remove!.onClick as () => void)(); + + expect( + document.querySelectorAll( + `[${kInlineImageIdAttr}="${firstId}"]`, + ).length, + ).toBe(0); + // The other image survives in both languages. + expect( + document.querySelectorAll( + `[${kInlineImageIdAttr}="${secondId}"]`, + ).length, + ).toBe(2); + }); + + // The bar of buttons is rendered into a div on the body, not inside the wrapper, so + // deleting the picture does not take it with it: it stayed on screen offering + // commands ("Choose image", "Copy image"...) for a picture that was gone. + it("Delete takes the bar of buttons down with the image", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const items = getInlineImageMenuItemsForClick(wrapper); + // Sanity check: the right-click selected it, which is what puts the bar up. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + expect( + document.getElementById(kInlineImageContextControlsId), + "expected the bar to be up before the delete", + ).not.toBeNull(); + + const remove = items.find((i) => i.l10nId === "Common.Delete"); + (remove!.onClick as () => void)(); + + expect( + document.getElementById(kInlineImageContextControlsId), + ).toBeNull(); + expect( + document.querySelector("." + kInlineImageSelectedClass), + ).toBeNull(); + }); + }); + + // jsdom measures every box as empty, so these stub the two measurements this function + // reads. That is enough for what is being checked: which changes it decides to act on. + describe("adjustInlineImageOffsetsIfBlockSizeChanged", () => { + // Give an editable a size jsdom will report. + const setBlockSize = ( + editable: HTMLElement, + widthLayoutPx: number, + heightLayoutPx: number, + ) => { + Object.defineProperty(editable, "clientWidth", { + value: widthLayoutPx, + configurable: true, + }); + Object.defineProperty(editable, "clientHeight", { + value: heightLayoutPx, + configurable: true, + }); + }; + + // A width change on its own used to be ignored -- and then the new width was recorded + // as the baseline, so it could never be noticed afterwards either. Splitting a text box + // in Change Layout does exactly this: same height, less width, so the same text wraps + // into more lines and what follows the picture can run off the end of the block. + it("acts on a block that changed only in width", () => { + const group = makeSimpleGroup(); + const english = editableFor(group, "en"); + const french = editableFor(group, "fr"); + insertInlineImage(group); + setBlockSize(english, 300, 200); + adjustInlineImageOffsetsIfBlockSizeChanged(group); + // Sanity check: the baseline has been recorded, and both copies agree. + const baselineAttr = kInlineImageOffsetBasedOnAttr; + expect( + getInlineImageInEditable(english)!.getAttribute(baselineAttr), + ).toBe("300,200"); + // Make the sibling's copy differ, so that a sync is visible. + getInlineImageInEditable(french)!.setAttribute( + baselineAttr, + "stale", + ); + + setBlockSize(english, 150, 200); + adjustInlineImageOffsetsIfBlockSizeChanged(group); + + expect( + getInlineImageInEditable(english)!.getAttribute(baselineAttr), + ).toBe("150,200"); + // The sync at the end of the function only runs when it decided something changed. + expect( + getInlineImageInEditable(french)!.getAttribute(baselineAttr), + ).toBe("150,200"); + }); + + it("leaves a block whose size has not changed alone", () => { + const group = makeSimpleGroup(); + const english = editableFor(group, "en"); + const french = editableFor(group, "fr"); + insertInlineImage(group); + setBlockSize(english, 300, 200); + adjustInlineImageOffsetsIfBlockSizeChanged(group); + const baselineAttr = kInlineImageOffsetBasedOnAttr; + getInlineImageInEditable(french)!.setAttribute( + baselineAttr, + "untouched", + ); + + adjustInlineImageOffsetsIfBlockSizeChanged(group); + + expect( + getInlineImageInEditable(french)!.getAttribute(baselineAttr), + ).toBe("untouched"); + }); + }); + + // inlineImages.ts replaces wrappers behind our back in two cases, and tells us about each + // with an event, because in both the selection (which is the inline-image undo layer's + // gate) needs re-asserting and the handles live on an element that may be gone. + describe("reacting to inlineImages.ts", () => { + it("puts the handles back on the wrapper an undo restored", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + // A dock change, recorded so that undo has something to put back. + recordInlineImageUndoPoint(group); + setInlineImageDock(wrapper, kInlineImageBottomClass); + syncInlineImagesFromEditable(editableFor(group, "en")); + + expect(inlineImageUndo()).toBe(true); + + // Undo rebuilds the wrapper from serialized markup, so this is a new element... + const restored = getInlineImageInEditable(editableFor(group, "en")); + expect(restored, "expected a restored wrapper").not.toBeNull(); + expect(restored).not.toBe(wrapper); + expect(getInlineImageDock(restored!)).toBe(kInlineImageRightClass); + // ...which inlineImages.ts marks as selected, and which therefore needs handles: + // serialized markup cannot carry them, since they are bloom-ui. + expect( + restored!.classList.contains(kInlineImageSelectedClass), + ).toBe(true); + expect( + restored!.querySelector("." + kInlineImageHandleFrameClass), + "expected the handles to be rebuilt", + ).not.toBeNull(); + }); + + // Nothing else tells the undo layer that the person has typed. Its own content + // comparison misses an edit that undid itself -- a word typed and deleted again -- + // and CKEditor holds undo points for both halves of that, so ctrl+z would take back + // the picture while there was still text editing in front of it. + it("tells the undo layer when the person types in the block", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + // Sanity check: the insert is undoable until the person edits. + expect(inlineImageCanUndo()).toBe(true); + + // Stands in for the page's CKEditor taking an undo point for the typing: `index` is + // its own name for where it stands in its stack of them, and the report only defers + // to typing CKEditor is actually holding. + const undoManager = { undoable: () => true, index: 0 }; + (globalThis as unknown as { CKEDITOR?: unknown }).CKEDITOR = { + currentInstance: { undoManager }, + }; + editableFor(group, "en").dispatchEvent( + new Event("input", { bubbles: true }), + ); + undoManager.index = 1; + + expect(inlineImageCanUndo()).toBe(false); + delete (globalThis as unknown as { CKEDITOR?: unknown }).CKEDITOR; + }); + + // The toolbar is a div on the body, so it does not go anywhere when undo replaces the + // wrapper it was built for. Rebuilding it is what keeps its commands about a picture + // that is still in the document. + it("rebuilds the toolbar for the wrapper an undo restored", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + recordInlineImageUndoPoint(group); + setInlineImageDock(wrapper, kInlineImageBottomClass); + syncInlineImagesFromEditable(editableFor(group, "en")); + const barBuiltForTheOldWrapper = document.getElementById( + kInlineImageContextControlsId, + ); + // Sanity check: selecting the picture really did put a bar up. + expect(barBuiltForTheOldWrapper).not.toBeNull(); + + expect(inlineImageUndo()).toBe(true); + + const bar = document.getElementById(kInlineImageContextControlsId); + expect(bar, "expected the toolbar to still be up").not.toBeNull(); + expect(bar).not.toBe(barBuiltForTheOldWrapper); + }); + + // Undoing an insert takes the picture away, so there is nothing for the bar to be + // about and nowhere for it to be. + it("takes the toolbar down when the undo left no picture", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + selectInlineImage(insertInlineImage(group)); + // Sanity check: a picture, selected, with its bar up. + expect( + document.getElementById(kInlineImageContextControlsId), + ).not.toBeNull(); + + expect(inlineImageUndo()).toBe(true); + + expect(getInlineImages(group).length).toBe(0); + expect( + document.getElementById(kInlineImageContextControlsId), + ).toBeNull(); + }); + + it("re-asserts the selection when a new picture arrives", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const img = wrapper.querySelector("img") as HTMLElement; + img.setAttribute("src", "flower.jpg"); + // The trip out to the image chooser can leave the focus back in the text, which + // is what dropping the selection looks like. + deselectAllInlineImages(document); + + handleInlineImageChanged(img); + + // Without this the change (and the insert that led to it) would not be undoable. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + expect( + wrapper.querySelector("." + kInlineImageHandleFrameClass), + ).not.toBeNull(); + }); + }); + + describe("selection", () => { + it("marks the wrapper and gives it four resize handles", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + // Sanity check: nothing is selected until we say so. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + false, + ); + + selectInlineImage(wrapper); + + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + const frame = wrapper.querySelector( + "." + kInlineImageHandleFrameClass, + ) as HTMLElement; + expect(frame, "expected a handle frame").not.toBeNull(); + // Everything the interaction layer adds inside the wrapper has to be bloom-ui, or + // it would be saved and replicated to the other languages. + expect(frame.classList.contains("bloom-ui")).toBe(true); + expect( + frame.querySelectorAll("." + kInlineImageHandleClass).length, + ).toBe(4); + }); + + it("does not add a second set of handles when selected again", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + selectInlineImage(wrapper); + expect( + wrapper.querySelectorAll("." + kInlineImageHandleFrameClass) + .length, + ).toBe(1); + }); + + it("selects only one image at a time", () => { + const group = makeSimpleGroup(); + insertInlineImage(group); + const english = getInlineImageInEditable( + editableFor(group, "en"), + ) as HTMLElement; + const french = getInlineImageInEditable( + editableFor(group, "fr"), + ) as HTMLElement; + selectInlineImage(english); + expect(english.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + + selectInlineImage(french); + + expect(english.classList.contains(kInlineImageSelectedClass)).toBe( + false, + ); + expect( + english.querySelector("." + kInlineImageHandleFrameClass), + ).toBeNull(); + expect(french.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + }); + + it("takes the marker and the handles off again on deselect", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + + deselectAllInlineImages(document); + + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + false, + ); + expect( + wrapper.querySelector("." + kInlineImageHandleFrameClass), + ).toBeNull(); + }); + + it("puts the toolbar up on select and takes it down on deselect", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + // Sanity check: no toolbar before anything is selected. + expect( + document.getElementById(kInlineImageContextControlsId), + ).toBeNull(); + + selectInlineImage(wrapper); + + const bar = document.getElementById(kInlineImageContextControlsId); + expect( + bar, + "expected the toolbar under the picture", + ).not.toBeNull(); + // It lives on the body, above the page, so the page save never sees it. + expect(bar!.parentElement).toBe(document.body); + expect(bar!.closest(".bloom-page")).toBeNull(); + + deselectAllInlineImages(document); + + expect( + document.getElementById(kInlineImageContextControlsId), + ).toBeNull(); + }); + + it("clears the selection for the page-save path", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + selectInlineImage(wrapper); + document.body.classList.add("bloom-inlineImage-dragging"); + + cleanupInlineImageInteractions(); + + // The class is on the wrapper, which IS saved, so this is the one that matters. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + false, + ); + expect( + wrapper.querySelector("." + kInlineImageHandleFrameClass), + ).toBeNull(); + expect( + document.body.classList.contains("bloom-inlineImage-dragging"), + ).toBe(false); + }); + }); + + describe("getInlineImageDock", () => { + it("reads the dock a wrapper is in", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + // A new inline image is docked right. + expect(getInlineImageDock(wrapper)).toBe(kInlineImageRightClass); + wrapper.classList.remove(kInlineImageRightClass); + wrapper.classList.add(kInlineImageBottomClass); + expect(getInlineImageDock(wrapper)).toBe(kInlineImageBottomClass); + }); + + it("falls back to the dock a new image gets when the markup has none", () => { + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + wrapper.classList.remove(kInlineImageRightClass); + expect(getInlineImageDock(wrapper)).toBe(kInlineImageRightClass); + }); + }); + + // A drag touches only the local wrapper while it is in progress and stamps the result onto + // the other languages once, at the end -- otherwise every mouse move would rewrite every + // language's copy. These drive the module's own listeners through a whole gesture. Where + // the drag ends up is not the point (with every box empty, jsdom's answer to "which third + // is this?" is always the band); how many times the other language's copy got rewritten + // is, and each sync replaces that copy wholesale, so the replacements can be counted. + describe("a whole drag gesture", () => { + // Counts how many times the given editable's inline image has been replaced since the + // last call. takeRecords is synchronous, so this needs no waiting. + function makeWrapperReplacementCounter(editable: HTMLElement): { + count: () => number; + stop: () => void; + } { + const observer = new MutationObserver(() => { + // Nothing to do on delivery; the records are collected by count() below. + }); + observer.observe(editable, { childList: true }); + return { + count: () => + observer + .takeRecords() + .filter((record) => + Array.from(record.removedNodes).some((node) => + (node as HTMLElement).classList?.contains( + kInlineImageClass, + ), + ), + ).length, + stop: () => observer.disconnect(), + }; + } + + it("rewrites the other languages' copies once, at the end", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const img = wrapper.querySelector("img") as HTMLElement; + const frenchEditable = editableFor(group, "fr"); + const french = makeWrapperReplacementCounter(frenchEditable); + + img.dispatchEvent(pointerEvent("pointerdown", 200, 100)); + // Pressing on the image selects it, and changes nothing else. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + expect(french.count()).toBe(0); + + // Well past the click threshold, and repeatedly. + document.dispatchEvent(pointerEvent("pointermove", 160, 80)); + document.dispatchEvent(pointerEvent("pointermove", 120, 60)); + document.dispatchEvent(pointerEvent("pointermove", 100, 50)); + // Sanity check: the drag really did move the image... + expect(getInlineImageDock(wrapper)).toBe(kInlineImageMiddleClass); + // ...but only in the block being dragged in. A live preview is local. + expect(french.count()).toBe(0); + + document.dispatchEvent(pointerEvent("pointerup", 100, 50)); + + // At least one: since the sync matches copies up by id and keeps the cluster in + // order, it may legitimately rewrite a sibling's wrapper more than once in a single + // pass, so the exact count is not a proxy for "how many syncs". What matters is + // that it happened here and not above. + expect(french.count()).toBeGreaterThan(0); + const frenchWrapper = getInlineImageInEditable(frenchEditable); + expect( + frenchWrapper, + "expected French to still have a copy", + ).not.toBeNull(); + expect(getInlineImageDock(frenchWrapper!)).toBe( + kInlineImageMiddleClass, + ); + // The copy the user dragged is still the canonical one. + expect(getInlineImage(group)).toBe(wrapper); + french.stop(); + }); + + // The pointer listeners are on the page's document, so a release over the toolbox, + // over Bloom's own furniture, or outside the window never reaches onPointerEnd, and + // the gesture never ends. Capturing the pointer is what makes the browser deliver + // that pointerup here anyway; jsdom does not route by capture, so what a unit test + // can check is that the capture is taken, and given back at the end. + it("captures the pointer, so a release anywhere still ends the gesture", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const img = wrapper.querySelector("img") as HTMLElement; + // Sanity check: nothing holds a capture before the press. + expect(capturedPointers.size).toBe(0); + + img.dispatchEvent(pointerEvent("pointerdown", 200, 100)); + + expect(capturedPointers.get(wrapper)).toBe(kTestPointerId); + + document.dispatchEvent(pointerEvent("pointerup", 100, 50)); + + expect(capturedPointers.has(wrapper)).toBe(false); + }); + + // The listeners are on the document, so they see every pointer, and the browser + // delivers a second one's events even while the first holds a capture. Acting on them + // ends the drag the person is in the middle of, at whatever position the other pointer + // reports -- a stray touch of a laptop's trackpad is enough. + it("ignores a second pointer while a gesture is under way", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const img = wrapper.querySelector("img") as HTMLElement; + + img.dispatchEvent(pointerEvent("pointerdown", 200, 100)); + document.dispatchEvent(pointerEvent("pointermove", 160, 80)); + document.dispatchEvent(pointerEvent("pointermove", 100, 50)); + // Sanity check: the drag is under way and has moved the image. + expect(getInlineImageDock(wrapper)).toBe(kInlineImageMiddleClass); + + document.dispatchEvent( + pointerEvent("pointermove", 250, 190, kOtherTestPointerId), + ); + document.dispatchEvent( + pointerEvent("pointerup", 250, 190, kOtherTestPointerId), + ); + + // Still the first pointer's gesture, still where it left the image. + expect(capturedPointers.get(wrapper)).toBe(kTestPointerId); + expect(getInlineImageDock(wrapper)).toBe(kInlineImageMiddleClass); + + // ...and it is the first pointer's own release that ends it. + document.dispatchEvent(pointerEvent("pointerup", 100, 50)); + expect(capturedPointers.has(wrapper)).toBe(false); + }); + + it("changes nothing for a press that never became a drag", () => { + setupInlineImageInteractions(getTestRoot()); + const group = makeSimpleGroup(); + const wrapper = insertInlineImage(group); + const img = wrapper.querySelector("img") as HTMLElement; + const french = makeWrapperReplacementCounter( + editableFor(group, "fr"), + ); + + img.dispatchEvent(pointerEvent("pointerdown", 200, 100)); + // A click wobbles by a pixel or two; that is not a drag. + document.dispatchEvent(pointerEvent("pointermove", 201, 101)); + document.dispatchEvent(pointerEvent("pointerup", 201, 101)); + + expect(french.count()).toBe(0); + expect(getInlineImageDock(wrapper)).toBe(kInlineImageRightClass); + // It is still a click, so the image ends up selected. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + french.stop(); + }); + }); +}); diff --git a/src/BloomBrowserUI/bookEdit/js/inlineImageInteractions.ts b/src/BloomBrowserUI/bookEdit/js/inlineImageInteractions.ts new file mode 100644 index 000000000000..b42a5602a5e2 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/inlineImageInteractions.ts @@ -0,0 +1,1747 @@ +// Inline (Word-style) images: the edit-time interaction layer. inlineImages.ts owns what an +// inline image IS -- the markup, the geometry custom properties, keeping every language's +// copy in step, and undo. This file owns what the user DOES to one: the right-click menu +// that adds and removes it, selecting it, dragging it between docks, and resizing it. +// +// Two things shape the code here: +// +// 1. Every gesture ends in the same three steps, because of how inline images are stored: +// commit the undo point, stamp the new geometry onto the other languages' copies, and +// re-check overflow (an image that just got bigger can push the text past the bottom of +// the block). During a gesture we touch only the local wrapper, so the preview is cheap; +// the sync happens once, at the end. See commitInlineImageChange. +// +// 2. All the listeners are on the document, not on the wrappers. Wrapper elements are +// replaced out from under us as a matter of course -- a sync stamps fresh copies onto the +// sibling editables, an undo rebuilds one from serialized markup -- so a handler bound to +// a particular element would quietly stop working. Everything here hit-tests from the +// event's target instead. +// +// The interesting arithmetic (which dock a position means, how far down the image has been +// dragged, how wide a resize has made it) is in pure exported functions, which is where the +// tests are aimed; jsdom has no layout, so the gestures themselves are not unit-testable. +// +// The commands reach the user through the text block's right-click menu, which is not ours: +// bookEdit/textContextMenu/TextContextMenu.tsx (BL-16649) owns it and also carries paragraph +// commands like "No Indent". It asks getInlineImageMenuItemsForClick what we have to offer for +// the click it is handling. So this module has no contextmenu listener of its own -- two +// handlers for one event on the same elements would fight, and that menu stops propagation +// once it decides to act. +import * as React from "react"; +import { default as AddImageIcon } from "@mui/icons-material/AddPhotoAlternateOutlined"; +import { default as DeleteIcon } from "@mui/icons-material/DeleteOutline"; +import { getFeatureStatusAsync } from "../../react_components/featureStatus"; +import OverflowChecker from "../OverflowChecker/OverflowChecker"; +import { kCanvasElementSelector } from "../toolbox/canvas/canvasElementConstants"; +import { buildCanvasElementControlRegistryContext } from "../toolbox/canvas/buildCanvasElementControlRegistryContext"; +import { imageAvailabilityRules } from "../toolbox/canvas/canvasControlAvailabilityRules"; +import { getMenuSections } from "../toolbox/canvas/canvasControlResolution"; +import { + ICanvasElementControlConfiguration, + IControlContext, + IControlMenuRow, + IControlRuntime, +} from "../toolbox/canvas/canvasControlTypes"; +import { + convertControlMenuRows, + IMenuItemWithSubmenu, + joinMenuSectionsWithSingleDividers, +} from "./canvasElementManager/canvasControlMenuRendering"; +import { + CanvasElementContextControls, + IControlsForNonCanvasObject, +} from "./canvasElementManager/CanvasElementContextControls"; +import { renderRoot } from "../../utils/reactRender"; +import { + commitPendingInlineImageUndo, + getEditables, + getFirstVisibleEditable, + getInlineImageOffsetBaseline, + getInlineImagesInEditable, + getTranslationGroupsWithInlineImages, + InlineImageDock, + insertInlineImage, + kInlineImageBottomClass, + kInlineImageChangedEvent, + kInlineImageClass, + kInlineImageDockClasses, + kInlineImageLeftClass, + kInlineImageMiddleClass, + kInlineImageOffsetVar, + kInlineImageRightClass, + kInlineImagesRestoredEvent, + kInlineImageSelectedClass, + kInlineImageWidthVar, + noteInlineImageBlockWasEdited, + prepareInlineImageUndo, + recordInlineImageOffsetBaseline, + removeInlineImage, + setInlineImageDock, + syncInlineImagesFromEditable, +} from "./inlineImages"; + +// The four resize handles, and the frame they hang off. Both are bloom-ui, so Cleanup() +// strips them before the page is saved and syncInlineImagesFromEditable leaves them out of +// the copies it stamps onto the other languages. +export const kInlineImageHandleFrameClass = "bloom-ui-inlineImage-handle-frame"; +export const kInlineImageHandleClass = "bloom-ui-inlineImage-handle"; + +// Set on the body (which is above the bloom-page, so never saved) for the duration of a +// drag or resize, to stop the gesture from also sweeping out a text selection. +export const kInlineImageDraggingClass = "bloom-inlineImage-dragging"; + +// Compass directions, matching the per-corner CSS in editMode.less. +const kInlineImageHandleCorners = ["nw", "ne", "sw", "se"] as const; +export type InlineImageHandleCorner = + (typeof kInlineImageHandleCorners)[number]; +const kInlineImageCornerAttribute = "data-inline-image-corner"; + +// A wider image than this leaves no room for text to wrap; a narrower one is too small to +// be worth wrapping around. Percentages of the editable's width. +export const kMinInlineImageWidthPercent = 10; +export const kMaxInlineImageWidthPercent = 95; + +// A click wobbles by a pixel or two. Below this the gesture is a click, and nothing is +// mutated and no undo point recorded. +const kDragThresholdViewportPx = 3; + +/** Just the parts of a DOMRect this module needs, so that callers can supply plain numbers. */ +export interface IBox { + left: number; + top: number; + width: number; + height: number; +} + +/** + * What the user's pointer is over, as far as inline images are concerned: + * - "existing": an inline image, which can be changed, documented or removed; + * - "add": a text block eligible for inline images (there is no limit on how many); + * - "none": anywhere else, which offers no inline-image commands at all. + * + * A block is eligible if it is a bloom-editable directly inside a translation group and is + * not inside a canvas element (those have their own context menu, which already knows about + * their images). The commands for an existing image belong to the image itself. + */ +export type InlineImageActionTarget = + | { kind: "none" } + | { kind: "add"; translationGroup: HTMLElement; editable: HTMLElement } + | { + kind: "existing"; + translationGroup: HTMLElement; + editable: HTMLElement; + wrapper: HTMLElement; + }; + +/** See InlineImageActionTarget. Takes the element the user pointed at. */ +/** + * Whether this is a field whose content Bloom stores for itself and writes back out, rather than + * one that simply lives on the page where the person typed it. An inline image cannot go in one. + * + * A data-book field is stored once in the data div, as InnerXml, and written into EVERY element + * carrying the same key (BookData's GatherDataItemsFromXElement and SetNodeXml). Front and back + * matter is not even kept where it is shown: BringXmatterHtmlUpToDate deletes and re-injects those + * pages, so the data div is the only thing that survives. Measured on the cover title, a picture + * put there left the book's STORED TITLE holding the wrapper's markup -- and that title is what + * names the book in the collection, in the title bar, and in AllTitles -- with four copies of the + * wrapper in the file for the one picture, and a bloom-contentNational2 class stamped onto it. + * A field with data-textonly="true" is worse still: BookData assigns InnerText to itself, which + * discards the picture outright. + * + * Bloom's own way to put a picture on a cover is a canvas element, which is stored on the page. + * So the command is not offered here. Nothing decided that it should be: the two exclusions above + * (a canvas element, and an editable that is not a group's own child) happened to leave it open. + */ +function isFieldBloomWritesItself(editable: HTMLElement): boolean { + if (editable.hasAttribute("data-book")) return true; + const page = editable.closest(".bloom-page"); + return !!page?.hasAttribute("data-xmatter-page"); +} + +export function getInlineImageActionTarget( + element: HTMLElement | undefined | null, +): InlineImageActionTarget { + if (!element) return { kind: "none" }; + const editable = element.closest(".bloom-editable") as HTMLElement | null; + if (!editable) return { kind: "none" }; + // The editables of a group are its direct children, so anything else is some other kind + // of bloom-editable (a source bubble's clone, for instance) and not ours to act on. + const translationGroup = editable.parentElement; + if (!translationGroup?.classList.contains("bloom-translationGroup")) + return { kind: "none" }; + if (editable.closest(kCanvasElementSelector)) return { kind: "none" }; + if (isFieldBloomWritesItself(editable)) return { kind: "none" }; + // An image description is a translation group in its own right, sitting in the + // bloom-canvas but outside any canvas element, so neither exclusion above catches it. + // It is what a reader hears in place of the picture, so a picture in it makes no sense + // -- and it is not shown in the book, so one put there could not be seen or removed. + if (translationGroup.classList.contains("bloom-imageDescription")) + return { kind: "none" }; + const wrapper = element.closest( + "." + kInlineImageClass, + ) as HTMLElement | null; + if (wrapper) + return { kind: "existing", translationGroup, editable, wrapper }; + // There is no limit on how many inline images a block can hold, so "add" is offered + // whether or not the group already has some; each insert appends a new image with its + // own identity. + return { kind: "add", translationGroup, editable }; +} + +/** + * Which dock a position calls for. The position is where the IMAGE is (or would be), not + * where the cursor is -- see the grab offsets in IInlineImageDragState for why. Crossing a + * third of the block's width switches between left, the middle band, and right. + * + * The bottom dock takes over in the MIDDLE third at exactly the point where the band gives + * out: the band's offset is clamped so that the whole wrapper stays inside the block's + * content (see the maximum in continueDrag), so a position that would put the image's bottom + * past the end of the content is not a place the band can go, and is read as asking for the + * dock below the text. Anywhere below the block altogether is the bottom dock whatever the + * horizontal position. + * + * Two things this must not do, both found in live testing: + * - claim the lower corners. In the outer thirds the side docks win all the way down, or an + * image cannot be parked in a lower corner at all. + * - claim a zone measured as a share of the block. It was the bottom fifth, and in a block + * whose text overflows a fifth is a large distance -- five lines of the reported A6 page. + * Every band position in it turned into the bottom dock, so the person could put the + * picture at the bottom or five lines higher and nowhere in between (John: "it seems like + * I should be able to put it vertically anywhere I want"). + */ +export function computeInlineImageDock( + imageCenterViewportPx: { x: number; y: number }, + editableContentBoxViewportPx: IBox, + imageHeightViewportPx: number, +): InlineImageDock { + // A box with no width says nothing about thirds; the band is the neutral answer. + const fraction = + editableContentBoxViewportPx.width > 0 + ? (imageCenterViewportPx.x - editableContentBoxViewportPx.left) / + editableContentBoxViewportPx.width + : 0.5; + const contentBottomViewportPx = + editableContentBoxViewportPx.top + editableContentBoxViewportPx.height; + if (imageCenterViewportPx.y >= contentBottomViewportPx) + return kInlineImageBottomClass; + const inMiddleThird = fraction >= 1 / 3 && fraction <= 2 / 3; + if ( + inMiddleThird && + imageCenterViewportPx.y + imageHeightViewportPx / 2 >= + contentBottomViewportPx + ) + return kInlineImageBottomClass; + if (fraction < 1 / 3) return kInlineImageLeftClass; + if (fraction > 2 / 3) return kInlineImageRightClass; + return kInlineImageMiddleClass; +} + +/** + * The whole of what a text block holds, in viewport pixels, which is what a drag has to + * measure against. It is NOT the block's rectangle: a block too small for its text scrolls, + * so its rectangle shows only a window onto the content, and the part of the text below the + * window is a real place the user can put an image. + * + * Everything the drag reads from the pointer is in viewport pixels, and the block's own + * scroll measurements are in layout pixels, which differ whenever the page is zoomed. The + * rectangle and clientHeight measure the same edge-to-edge distance, so their ratio is the + * page's scale, and no caller has to know the zoom. + * + * A block that fits its text returns its own rectangle, so nothing changes in the ordinary + * case. Pass a clientHeight of zero (jsdom, where nothing is laid out) to get the rectangle + * back unchanged. + */ +export function computeBlockContentBox( + visibleBoxViewportPx: IBox, + clientHeightLayoutPx: number, + scrollHeightLayoutPx: number, + scrollTopLayoutPx: number, +): IBox { + if (!(clientHeightLayoutPx > 0)) return visibleBoxViewportPx; + const viewportPxPerLayoutPx = + visibleBoxViewportPx.height / clientHeightLayoutPx; + return { + left: visibleBoxViewportPx.left, + width: visibleBoxViewportPx.width, + top: + visibleBoxViewportPx.top - + scrollTopLayoutPx * viewportPxPerLayoutPx, + height: scrollHeightLayoutPx * viewportPxPerLayoutPx, + }; +} + +/** + * How far the block has to scroll, in layout pixels, to bring a dragged picture back inside + * the part of the block that is on the screen. Positive scrolls the text up (showing more of + * what follows), negative scrolls it down, zero when the picture is already showing. + * + * A block too small for its text scrolls, and the offset clamp only keeps the picture inside + * the block's CONTENT, so dragging downwards walks the picture into text that is not on the + * screen and the person loses sight of the thing they are moving (John, live testing: "when + * scrolling is needed, the scrolling doesn't follow the drag of the image. So if you drag the + * image off the top or the bottom, you can't see it anymore"). Scrolling by exactly the + * amount that sticks out follows the drag instead of running ahead of it. + * + * The bottom edge wins when the picture is taller than the window, since a picture that big + * cannot be shown whole and the drag is heading downwards. + */ +export function computeInlineImageDragScrollLayoutPx( + imageBoxViewportPx: IBox, + visibleBoxViewportPx: IBox, + viewportPxPerLayoutPx: number, +): number { + // Nothing is laid out (jsdom), so there is no screen for anything to be off. + if (!(viewportPxPerLayoutPx > 0)) return 0; + const belowViewportPx = + imageBoxViewportPx.top + + imageBoxViewportPx.height - + (visibleBoxViewportPx.top + visibleBoxViewportPx.height); + if (belowViewportPx > 0) return belowViewportPx / viewportPxPerLayoutPx; + const aboveViewportPx = visibleBoxViewportPx.top - imageBoxViewportPx.top; + if (aboveViewportPx > 0) return -aboveViewportPx / viewportPxPerLayoutPx; + return 0; +} + +/** + * Whether a move has to be undone because it left the text with nowhere to go: it added + * scroll overflow to a block that fitted before the gesture began. + * + * A block that ALREADY overflowed is exempt, and that exemption is the whole point of this + * being a named rule. Moving an image anywhere changes how much room the text needs -- an + * image higher up displaces more text below it -- so in a block whose text does not fit, + * a plain "this move added overflow" test vetoes most moves, including every move back up, + * and the image cannot be repositioned at all (John, live testing: an image at a large + * offset in an A6 block was frozen, every upward drag undone). Such a block is already + * showing Bloom's overflow warning, so the person has been told; what keeps the image + * somewhere reachable is the offset clamp, which holds the whole wrapper inside the + * block's content, not this rule. + * + * The one pixel of slack absorbs sub-pixel layout noise, which would otherwise read as + * overflow that the move caused. + */ +export function shouldRevertInlineImageMove( + startOverflowLayoutPx: number, + currentOverflowLayoutPx: number, +): boolean { + if (startOverflowLayoutPx > 0) return false; + return currentOverflowLayoutPx > startOverflowLayoutPx + 1; +} + +/** + * How much bigger a viewport distance is than the layout distance it stands for, which is + * the page's zoom. Offsets are written in layout pixels (the custom property is used inside + * the scaled page), while a drag measures in viewport pixels, so every distance taken from + * the pointer is divided by this before it becomes an offset. Returns 1 when there is + * nothing to measure (jsdom). + */ +export function computeViewportPxPerLayoutPx( + visibleBoxViewportPx: IBox, + clientHeightLayoutPx: number, +): number { + if (!(clientHeightLayoutPx > 0) || !(visibleBoxViewportPx.height > 0)) + return 1; + return visibleBoxViewportPx.height / clientHeightLayoutPx; +} + +/** + * The value to write to --inline-image-offset: whole pixels, never negative (the image + * cannot sit above the top of its block), and no further than the given maximum, which is + * "how far down can the image start and still fit inside the block". A maximum at or below + * zero is a real answer -- the image already fills the block, so it stays pinned to the + * top. Pass undefined when there is no box to measure against (a degenerate layout), and + * only the lower bound applies. + */ +export function clampInlineImageOffset( + offsetLayoutPx: number, + maxLayoutPx?: number, +): number { + const rounded = Math.round(offsetLayoutPx); + // Negatives and NaN both land here. + if (!(rounded > 0)) return 0; + if (maxLayoutPx !== undefined) + return Math.min(rounded, Math.max(0, Math.round(maxLayoutPx))); + return rounded; +} + +/** + * Where an offset measured against a block of one height belongs in a block of another, as a + * share of the height: a picture two thirds of the way down its text stays two thirds of the way + * down. Whole pixels, never negative, and unchanged when either height is unusable (nothing is + * laid out, or no baseline was recorded). + * + * Proportion is the whole of the intent, and it is deliberately not the whole of the fix: the + * text beside a float rewraps when the block changes width, so a proportional offset can still + * leave the block holding more text than it can show. The caller takes that back afterwards + * (adjustInlineImageOffsetsIfBlockSizeChanged), which is the invariant a drag already keeps. + */ +export function computeInlineImageOffsetForNewBlockHeight( + offsetLayoutPx: number, + oldBlockHeightLayoutPx: number, + newBlockHeightLayoutPx: number, +): number { + if (!(oldBlockHeightLayoutPx > 0) || !(newBlockHeightLayoutPx > 0)) + return clampInlineImageOffset(offsetLayoutPx); + return clampInlineImageOffset( + (offsetLayoutPx * newBlockHeightLayoutPx) / oldBlockHeightLayoutPx, + ); +} + +/** Keeps a width within the range that leaves both the image and the text usable. */ +export function clampInlineImageWidthPercent(percent: number): number { + return Math.min( + kMaxInlineImageWidthPercent, + Math.max(kMinInlineImageWidthPercent, percent), + ); +} + +/** + * The width (as a percentage of the editable) that dragging a corner handle has reached. + * Only the horizontal movement counts: the wrapper's aspect ratio decides the height, so + * pulling a corner sideways is the whole gesture. horizontalSign says which way is bigger + * for the corner being dragged (see getInlineImageHandleHorizontalSign). + * Assumes a positive editableWidthPx; startResize does not begin a resize without one. + */ +export function computeInlineImageWidthPercent( + startWidthViewportPx: number, + deltaXViewportPx: number, + horizontalSign: number, + editableWidthViewportPx: number, +): number { + const widthViewportPx = + startWidthViewportPx + horizontalSign * deltaXViewportPx; + const percent = (widthViewportPx / editableWidthViewportPx) * 100; + // One decimal is finer than a pixel on any block we lay out, and keeps the style + // attribute -- which is saved, and stamped onto every language's copy -- tidy. + return clampInlineImageWidthPercent(Math.round(percent * 10) / 10); +} + +/** + * Which direction makes the image bigger when this corner is dragged: outward. Dragging the + * right-hand corners right, or the left-hand corners left, grows it, whichever dock the + * image is in. + */ +export function getInlineImageHandleHorizontalSign( + corner: InlineImageHandleCorner, +): number { + return corner === "ne" || corner === "se" ? 1 : -1; +} + +/** The dock an existing wrapper is in. */ +export function getInlineImageDock(wrapper: HTMLElement): InlineImageDock { + const found = kInlineImageDockClasses.find((dockClass) => + wrapper.classList.contains(dockClass), + ) as InlineImageDock | undefined; + // The fallback matches what a new inline image gets (see makeInlineImageWrapper); a + // wrapper with no dock class at all could only come from hand-edited HTML. + return found ?? kInlineImageRightClass; +} + +/** + * Makes this inline image the selected object: the wrapper gets the marker class (which + * draws the outline and the move cursor, and is what tells the undo layer that an inline + * image is the active thing), and the resize handles appear on it. Only one inline image is + * ever selected, so this deselects any other first. + */ +export function selectInlineImage(wrapper: HTMLElement): void { + deselectAllInlineImages(wrapper.ownerDocument); + wrapper.classList.add(kInlineImageSelectedClass); + addHandles(wrapper); + showInlineImageContextControls(wrapper); +} + +/** + * Drops the selection: no outline, no handles. The marker class matters here beyond + * appearances, because the wrapper it sits on is real saved content -- see + * cleanupInlineImageInteractions. + */ +export function deselectAllInlineImages(doc: Document): void { + Array.from(doc.querySelectorAll("." + kInlineImageSelectedClass)).forEach( + (wrapper) => wrapper.classList.remove(kInlineImageSelectedClass), + ); + Array.from( + doc.querySelectorAll("." + kInlineImageHandleFrameClass), + ).forEach((frame) => frame.remove()); + removeInlineImageContextControls(doc); +} + +/** + * Installs the inline-image interaction listeners on the page's document. Called from + * SetupElements, right after setupInlineImages; safe to call again for a container added + * later, since the listeners are per document and installed once. + */ +// How many times the fit pass may reduce an offset. Each pass takes back exactly the amount by +// which the block now overflows, so one is normally enough; the rest are for the case where +// reducing the offset rewraps the text beside the float and changes the amount again. +const kMaxOffsetFitPasses = 4; + +/** + * Re-measures every inline image's offset for the block it now finds itself in, and records the + * new baseline. Call it at page setup, after the per-language copies have been made to agree. + * + * WHY. The offset is an absolute distance (see kInlineImageOffsetBasedOnAttr), and it is written + * only by a drag -- which refuses any move that would leave the block holding more text than it + * can show. Nothing re-measures it when the block itself changes size, so drawing the book at a + * shorter page size reaches exactly the state the drag refuses to create: the picture stays the + * same distance below the start of the text, and the lines that follow it are pushed off the end + * of the block. Bloom does not even warn, since it treats a block it has allowed to scroll as + * not overflowing. Measured on an A5 Portrait page with the picture near the bottom of the text, + * changing the book to A5 Landscape pushed eleven lines off the end. + * + * Only the editable the reader sees is laid out (the others are display:none and measure zero), + * so that one is re-measured and syncInlineImagesFromEditable carries the result to the rest. + */ +export function adjustInlineImageOffsetsIfBlockSizeChanged( + container: HTMLElement, +): void { + getTranslationGroupsWithInlineImages(container).forEach((group) => { + const editable = getFirstVisibleEditable(group); + if (!editable || !(editable.clientHeight > 0)) return; + const wrappers = getInlineImagesInEditable(editable); + const baselines = wrappers.map((wrapper) => + getInlineImageOffsetBaseline(wrapper), + ); + // An image with no baseline is one saved before Bloom recorded it: there is nothing to + // re-measure from, so it keeps the offset it has and we record where it stands now. + // + // Width counts as much as height. Only the height goes into re-computing the offset + // (the offset is a vertical distance), but a narrower block wraps the same text into + // more lines, so the text below the picture can now run off the end -- and since we + // re-record the baseline below either way, a width change we did not act on here would + // become the new baseline and never be noticed again. + const changed = wrappers.some( + (wrapper, i) => + baselines[i] !== undefined && + (baselines[i]!.heightLayoutPx !== editable.clientHeight || + baselines[i]!.widthLayoutPx !== editable.clientWidth), + ); + wrappers.forEach((wrapper, i) => { + const baseline = baselines[i]; + if (!baseline) return; + setInlineImageOffset( + wrapper, + computeInlineImageOffsetForNewBlockHeight( + getInlineImageOffsetLayoutPx(wrapper), + baseline.heightLayoutPx, + editable.clientHeight, + ), + ); + }); + if (changed) fitInlineImageOffsetsToBlock(editable, wrappers); + wrappers.forEach((wrapper) => + recordInlineImageOffsetBaseline(wrapper, editable), + ); + if (changed) syncInlineImagesFromEditable(editable); + }); +} + +// Takes back as much offset as the block now overflows by, from the bottom-most picture that has +// any to give. That is the one holding the text down: the offset is space above a picture, so the +// text that follows the lowest picture is what has gone off the end of the block. Reducing to +// zero and still overflowing is a block with more text than it can hold whatever the pictures +// do, which is the person's own doing and not ours to correct -- the same answer a drag gives +// when the maximum it may use is zero. +function fitInlineImageOffsetsToBlock( + editable: HTMLElement, + wrappers: HTMLElement[], +): void { + for (let pass = 0; pass < kMaxOffsetFitPasses; pass++) { + const overflowLayoutPx = editable.scrollHeight - editable.clientHeight; + if (overflowLayoutPx <= 0) return; + const wrapper = [...wrappers] + .reverse() + .find((each) => getInlineImageOffsetLayoutPx(each) > 0); + if (!wrapper) return; + setInlineImageOffset( + wrapper, + clampInlineImageOffset( + getInlineImageOffsetLayoutPx(wrapper) - overflowLayoutPx, + ), + ); + } +} + +function setInlineImageOffset( + wrapper: HTMLElement, + offsetLayoutPx: number, +): void { + wrapper.style.setProperty(kInlineImageOffsetVar, `${offsetLayoutPx}px`); +} + +export function setupInlineImageInteractions(container: HTMLElement): void { + // A gesture cannot survive a re-setup: its state points at elements that may be gone. + stopEdgeScrollTimer(dragState); + releaseGesturePointer(resizeState ?? dragState); + dragState = undefined; + resizeState = undefined; + const doc = container.ownerDocument; + if (documentsWithInlineImageListeners.has(doc)) return; + documentsWithInlineImageListeners.add(doc); + // Capture phase: the editables are managed by CKEditor, and these gestures have to reach + // us whether or not something closer to the target has opinions about them. + doc.addEventListener("pointerdown", onPointerDown, true); + doc.addEventListener("mousedown", onMouseDown, true); + doc.addEventListener("focusin", onFocusIn); + // Whose ctrl+z is it? The undo layer compares the block's content with its snapshot, but + // that cannot see an edit that undid itself -- a word typed and deleted again -- while + // CKEditor holds undo points for both halves of it. So the page reports the typing + // itself. "input" covers every way text arrives, including paste and CKEditor's own + // commands, and it bubbles out of the editable. + doc.addEventListener("input", onInput); + // Two things inlineImages.ts does behind our back, both of which leave the selection + // needing attention. Both events bubble, so one listener at the document covers the page. + doc.addEventListener(kInlineImagesRestoredEvent, onInlineImagesRestored); + doc.addEventListener(kInlineImageChangedEvent, onInlineImageChanged); + // See aiImageEditingIsAvailable: fetched here because the menu is composed + // synchronously at right-click time, long after this resolves. + void getFeatureStatusAsync("AiImageEditing").then((status) => { + aiImageEditingIsAvailable = status?.visible ?? false; + }); +} + +function onInput(event: Event): void { + const editable = (event.target as HTMLElement | null)?.closest( + ".bloom-editable", + ) as HTMLElement | null; + if (editable) noteInlineImageBlockWasEdited(editable); +} + +/** + * Takes the edit-time selection state off the inline images, for the page-save path + * (Cleanup in bloomEditing.ts). The handles are bloom-ui and would be removed anyway, but + * the selected class is on the wrapper itself, which IS saved, so it has to come off here. + */ +export function cleanupInlineImageInteractions(): void { + stopEdgeScrollTimer(dragState); + releaseGesturePointer(resizeState ?? dragState); + dragState = undefined; + resizeState = undefined; + document.body.classList.remove(kInlineImageDraggingClass); + deselectAllInlineImages(document); +} + +// --- the commands ------------------------------------------------------------ + +// An existing inline image gets the STANDARD image menu: the same "image" section of +// canvasControlRegistry the canvas element menu draws from, resolved through the same +// availability rules, so an image offers the same commands with the same wording wherever +// the user meets one. This configuration says only what is different here, which is what +// cannot apply to an image living inside a text block. ("Expand image to fill space" needs +// no entry: its normal rule already limits it to background images.) +const inlineImageControlConfiguration: ICanvasElementControlConfiguration = { + // Not really a canvas element, but "image" is the truth about what the commands act on, + // and nothing in menu resolution consults the type. + type: "image", + menuSections: ["image"], + // The same toolbar an image on a canvas gets (imageCanvasElementControls), so that a + // picture offers the same buttons wherever the user meets one. "expandToFillSpace" needs + // no exclusion here: its own rule already limits it to background images. + toolbar: [ + "missingMetadata", + "chooseImage", + "pasteImage", + "expandToFillSpace", + "spacer", + "delete", + ], + toolPanel: [], + availabilityRules: { + ...imageAvailabilityRules, + // Both of these turn the image into canvas furniture (the page's background image, + // the book-thumbnail source), which an image inside a text block cannot become. + becomeBackground: "exclude", + imageFieldType: "exclude", + // Duplicating means duplicating a canvas element, which this is not. Adding a second + // picture to the block is Add Image on the text's own menu. + duplicate: "exclude", + }, +}; + +// Whether the experimental "Edit with AI" feature is on, which the availability rules need +// synchronously at right-click time; the status lives on the C# side, so it is fetched at +// page setup and remembered. +let aiImageEditingIsAvailable = false; + +/** What a menu item calls to dismiss the menu it is on. See IControlRuntime.closeMenu. */ +export type CloseMenuFunction = (launchingDialog?: boolean) => void; + +/** + * The commands to offer for what the user right-clicked, in the shape TextContextMenu + * renders. For an existing image this is the standard image menu (see + * inlineImageControlConfiguration above) plus Delete; for an eligible text block it is + * Add Image. closeMenu is how the commands dismiss the menu they are chosen from; it + * defaults to a no-op for tests that only inspect the items. + */ +export function buildInlineImageMenuItems( + target: InlineImageActionTarget, + closeMenu: CloseMenuFunction = () => {}, +): IMenuItemWithSubmenu[] { + if (target.kind === "add") { + return [ + { + l10nId: "EditTab.InlineImage.AddImage", + english: "Add Image", + icon: React.createElement(AddImageIcon, null), + onClick: () => { + closeMenu(); + addInlineImage(target.translationGroup); + }, + }, + ]; + } + if (target.kind === "existing") { + const runtime: IControlRuntime = { closeMenu }; + const ctx: IControlContext = { + ...buildCanvasElementControlRegistryContext(target.wrapper), + aiImageEditingAvailable: aiImageEditingIsAvailable, + }; + const imageSections = getMenuSections( + inlineImageControlConfiguration, + ctx, + runtime, + ).map((section) => + convertControlMenuRows( + withInlineImageSync( + section + .map((item) => item.menuRow) + .filter((row): row is IControlMenuRow => !!row), + target, + ), + ctx, + runtime, + ), + ); + return joinMenuSectionsWithSingleDividers([ + ...imageSections, + [ + // The same words and icon as the canvas element menu's Delete, but the + // action is ours: deleting an inline image means removing THIS image's + // copy from every language's editable, which the canvas-element manager + // behind the registry's delete knows nothing about. + { + l10nId: "Common.Delete", + english: "Delete", + icon: React.createElement(DeleteIcon, null), + onClick: () => { + closeMenu(); + removeInlineImageCommand(target.wrapper); + }, + }, + ], + ]); + } + return []; +} + +// The registry's commands were written for canvas element images, so they mutate only the +// img they are given -- which for an inline image is one language's copy. Ending every +// command with a sync stamps whatever it did onto the other languages' copies and re-checks +// overflow, exactly like the end of a drag. Commands that change the picture itself go out +// through changeImageInfo, which already syncs (see handleInlineImageChanged), so for them +// this is a harmless second pass over unchanged markup; the ones that mutate the img in +// place (the transparency submenu) have only this. +function withInlineImageSync( + rows: IControlMenuRow[], + target: InlineImageActionTarget & { kind: "existing" }, +): IControlMenuRow[] { + return rows.map((row) => ({ + ...row, + subMenuItems: row.subMenuItems + ? withInlineImageSync(row.subMenuItems, target) + : undefined, + onSelect: async (rowCtx, rowRuntime) => { + // The registry's commands know nothing about this undo layer, so the undo point + // has to be taken here or the last thing recorded stays whatever put the picture + // there -- and ctrl+z after making an image transparent removed the image. + // Prepared and committed rather than recorded straight, because the command may + // be asynchronous and may end up changing nothing. + prepareInlineImageUndo(target.wrapper); + await row.onSelect(rowCtx, rowRuntime); + commitPendingInlineImageUndo(target.wrapper); + syncInlineImagesFromEditable(target.editable); + refreshOverflow(target.translationGroup); + }, + })); +} + +// Adds an inline image to the block, leaving it selected and holding a placeholder. It +// deliberately does NOT go on to open the image chooser: the user's next move is often to put +// the image where they want it rather than to pick a picture, and a dialog that opens itself +// takes that choice away. The picture is chosen later, from the same menu's "Change image". +// insertInlineImage records its own undo point and puts a copy in every editable of the group, +// so there is nothing to sync here. +function addInlineImage(translationGroup: HTMLElement): void { + const wrapper = insertInlineImage(translationGroup); + selectInlineImage(wrapper); + refreshOverflow(translationGroup); +} + +// removeInlineImage records its own undo point and clears THIS image's copy (matched by its +// identity attribute) out of every language, leaving any other inline images alone -- and +// leaving them WHERE THEY ARE: removing an image above another frees the space it cleared, +// which would otherwise make the lower one spring upward (John: moving one image must not +// move the others). +function removeInlineImageCommand(wrapper: HTMLElement): void { + const translationGroup = wrapper.closest( + ".bloom-translationGroup", + ) as HTMLElement; + const editable = wrapper.closest(".bloom-editable") as HTMLElement; + const survivors = getFloatingWrappersIn(editable).filter( + (other) => other !== wrapper, + ); + const keptTops = new Map( + survivors.map((other) => [other, getImageBox(other).top]), + ); + removeInlineImage(wrapper); + // The bar of buttons is a div on the body (see kInlineImageContextControlsId), so it does + // not go with the picture: without this it stays on screen, under a picture that is no + // longer there, offering commands for it. Undoing the delete does not need the selection + // -- inlineImageCanUndo recognizes the deleted-image case from the caret instead. + deselectAllInlineImages(editable.ownerDocument); + if (editable.getBoundingClientRect().height > 0) { + const viewportPxPerLayoutPx = + getViewportPxPerLayoutPxOfEditable(editable); + for (let pass = 0; pass < 2; pass++) { + survivors.forEach((other) => { + if (!other.isConnected) return; + const wanted = keptTops.get(other); + if (wanted === undefined) return; + const current = getImageBox(other).top; + if (Math.abs(wanted - current) <= 1) return; + nudgeInlineImageOffset( + other, + wanted - current, + viewportPxPerLayoutPx, + ); + }); + } + // The offset corrections above happened in this editable; the other languages + // get the same values. + syncInlineImagesFromEditable(editable); + } + refreshOverflow(translationGroup); +} + +// --- the toolbar ------------------------------------------------------------- + +// The bar of buttons under the selected picture. It is the very component a canvas element +// uses, given this module's own control configuration and menu, so the buttons, their icons +// and their wording are the same ones an image has anywhere else in Bloom. +// +// It is rendered into a div of our own on the body, which is above the bloom-page and so is +// never saved, and never seen by the page-save cleanup. The canvas element's bar has a div +// of its own in the same place; two ids, because each is put up and taken down by its own +// code, and one taking down the other's would be a hard bug to see. +export const kInlineImageContextControlsId = "inline-image-context-controls"; + +// How far below the picture the bar sits, matching the canvas element's bar. +const kInlineImageContextControlsGapLayoutPx = 11; + +// The bar is centered in a box this wide, which is wider than the bar ever is. The canvas +// element's bar is centered the same way. +const kInlineImageContextControlsBoxWidthLayoutPx = 300; + +// Set on the bar while a drag or a resize is running, so it does not follow the picture +// around. Same name as the canvas element's, and the same rule in editMode.less. +const kMovingClass = "moving"; + +/** + * Puts the toolbar under this picture, or moves it there when it is already up. Called + * whenever an inline image becomes the selected object, which is the moment the user + * expects the buttons: right after Add Image, and on a click on the picture. + */ +export function showInlineImageContextControls(wrapper: HTMLElement): void { + const doc = wrapper.ownerDocument; + let root = doc.getElementById(kInlineImageContextControlsId); + if (!root) { + root = doc.createElement("div"); + root.setAttribute("id", kInlineImageContextControlsId); + doc.body.appendChild(root); + } + renderInlineImageContextControls(wrapper, false); +} + +// The same render with the menu open or closed, which is how the "..." button opens its own +// menu (the component asks its parent to re-render it in the state it wants). +function renderInlineImageContextControls( + wrapper: HTMLElement, + menuOpen: boolean, +): void { + const root = wrapper.ownerDocument.getElementById( + kInlineImageContextControlsId, + ); + if (!root) return; + renderRoot( + React.createElement(CanvasElementContextControls, { + canvasElement: wrapper, + menuOpen, + setMenuOpen: (open: boolean) => + renderInlineImageContextControls(wrapper, open), + controlsForNonCanvasObject: buildInlineImageControls(wrapper), + }), + root, + ); + positionInlineImageContextControls(wrapper); +} + +// What the shared bar needs in order to be about an inline image rather than a canvas +// element: which controls to offer, the menu the picture's right-click gives, how to delete +// it, and the sync every command has to end with. +function buildInlineImageControls( + wrapper: HTMLElement, +): IControlsForNonCanvasObject { + const target = getInlineImageActionTarget(wrapper); + return { + configuration: inlineImageControlConfiguration, + menuItems: buildInlineImageMenuItems(target, () => + renderInlineImageContextControls(wrapper, false), + ), + contextAdditions: { + aiImageEditingAvailable: aiImageEditingIsAvailable, + deleteThisObject: () => removeInlineImageCommand(wrapper), + }, + afterToolbarCommand: () => { + if (target.kind !== "existing") return; + syncInlineImagesFromEditable(target.editable); + refreshOverflow(target.translationGroup); + // A command can change the picture's shape, so the bar has to be put back under + // it. It can also delete it, and then there is nothing to be under. + if (wrapper.isConnected) + positionInlineImageContextControls(wrapper); + }, + }; +} + +/** + * Centers the toolbar under the picture. The bar is not inside the scaled page, so it is + * given the page's own transform; without that it would be drawn at 100% over a page drawn + * at some other zoom. + */ +export function positionInlineImageContextControls(wrapper: HTMLElement): void { + const doc = wrapper.ownerDocument; + const root = doc.getElementById(kInlineImageContextControlsId); + if (!root) return; + const scalingContainer = doc.getElementById("page-scaling-container"); + root.style.transform = scalingContainer?.style.transform ?? ""; + const image = getImageBox(wrapper); + const editable = wrapper.closest(".bloom-editable") as HTMLElement | null; + const viewportPxPerLayoutPx = editable + ? getViewportPxPerLayoutPxOfEditable(editable) + : 1; + root.style.left = + image.left + + doc.defaultView!.scrollX + + image.width / 2 - + (kInlineImageContextControlsBoxWidthLayoutPx / 2) * + viewportPxPerLayoutPx + + "px"; + root.style.top = + image.top + + doc.defaultView!.scrollY + + image.height + + kInlineImageContextControlsGapLayoutPx + + "px"; + root.style.width = kInlineImageContextControlsBoxWidthLayoutPx + "px"; +} + +/** Takes the toolbar down. Part of dropping the selection. */ +export function removeInlineImageContextControls(doc: Document): void { + doc.getElementById(kInlineImageContextControlsId)?.remove(); +} + +// Hides the bar for the length of a gesture and puts it back, under wherever the picture +// ended up. A bar that jumped along with a drag would be in the way of the drag. +function setInlineImageContextControlsMoving( + doc: Document, + moving: boolean, +): void { + doc.getElementById(kInlineImageContextControlsId)?.classList.toggle( + kMovingClass, + moving, + ); +} + +// --- keeping the selection honest when inlineImages.ts replaces things ------- + +/** + * What inline images contribute to the text block's right-click menu: the commands for what + * was clicked, or an empty list if there are none to offer there. TextContextMenu calls this + * for every right-click it sees, and an empty list is how it learns we are not interested. + * + * Selecting an existing image as a side effect is deliberate -- it is what makes the commands + * visibly apply to something, and what keeps ctrl+z routed to the inline-image undo layer, + * whose gate is the selection. Hence "ForClick": call it once per right-click, not on every + * render of the menu. + */ +export function getInlineImageMenuItemsForClick( + clickedElement: HTMLElement | undefined | null, + closeMenu: CloseMenuFunction = () => {}, +): IMenuItemWithSubmenu[] { + const target = getInlineImageActionTarget(clickedElement); + if (target.kind === "existing") selectInlineImage(target.wrapper); + // A right-click anywhere else is not about the picture, and leaving one selected says the + // commands on show apply to it. It also keeps ctrl+z routed to the inline-image undo + // layer, whose gate is the selection, when the person means the text they just clicked in. + else if (clickedElement) + deselectAllInlineImages(clickedElement.ownerDocument); + return buildInlineImageMenuItems(target, closeMenu); +} + +// Undo replaces every wrapper in the group with a fresh element built from serialized markup, +// which cannot carry bloom-ui children. inlineImages.ts puts the selected class back on the +// restored copy in the editable that had it, and that wrapper needs the whole of the selection +// UI re-derived from it: the handles, which cannot have survived, and the toolbar, which is a +// div on the body that was built for -- and still holds -- the element undo has just replaced. +// Left alone it offers Choose image, Copy image and Delete for something detached from the +// document, and sits wherever the old picture used to be. +// +// When nothing comes back selected the toolbar has to come DOWN. That is what undoing an +// insert looks like: the picture the bar belongs to is the one undo took away. +function onInlineImagesRestored(event: Event): void { + const translationGroup = event.target as HTMLElement | null; + if (!translationGroup) return; + const selected = translationGroup.querySelector( + "." + kInlineImageClass + "." + kInlineImageSelectedClass, + ) as HTMLElement | null; + if (selected) selectInlineImage(selected); + else deselectAllInlineImages(translationGroup.ownerDocument); +} + +// A new picture has arrived in an inline image. The trip out to the image chooser can leave +// the focus back in the text, which would have dropped the selection, and the change (and the +// insert that may have led to it) is only undoable while the image is selected. So re-assert +// it; the wrapper element itself survives a change, so this is the same one the user chose for. +function onInlineImageChanged(event: Event): void { + const wrapper = (event as CustomEvent).detail as HTMLElement | undefined; + if (wrapper) selectInlineImage(wrapper); +} + +// --- selection and gestures -------------------------------------------------- + +interface IInlineImageDragState { + wrapper: HTMLElement; + editable: HTMLElement; + // Measured once, at the start: switching to the bottom dock re-lays out the block, and + // thresholds that moved around underneath the gesture would be unusable. This is the + // block's CONTENT box (computeBlockContentBox), not its rectangle, so that the part of + // an overflowing block's text that is scrolled out of sight is still somewhere the + // image can be dragged to. + editableContentBoxViewportPx: IBox; + // Viewport pixels per layout pixel; see computeViewportPxPerLayoutPx. + viewportPxPerLayoutPx: number; + // Where the image's center was in relation to the pointer when the drag began. The dock + // follows the image, not the cursor: without this, grabbing a wide image near one edge + // would re-dock it before it had moved at all. + grabOffsetXViewportPx: number; + grabOffsetYViewportPx: number; + startXViewportPx: number; + startYViewportPx: number; + startOffsetLayoutPx: number; + dock: InlineImageDock; + started: boolean; + // Every OTHER floating image's absolute image-box top at drag start: moving one + // image must not move the others, so they are held at these positions on every move + // of the drag. + neighborImageTopsViewportPx: Map; + // The block's scroll overflow before the drag began. The fit test at the end of each + // move is "did this move ADD overflow", measured on the whole block, because the + // dragged image can fit while having pushed a NEIGHBOR out (a full-width band + // crossing another image's level displaces it -- floats cannot overlap). + startScrollOverflowLayoutPx: number; + // The last place the pointer was, so that the edge-scroll timer can re-apply the move + // the person is already making without a fresh pointer event. + lastPointViewportPx: { x: number; y: number }; + edgeScrollTimerId: number | undefined; + // The pointer this gesture captured; see captureGesturePointer. + pointerId: number; +} + +interface IInlineImageResizeState { + wrapper: HTMLElement; + editable: HTMLElement; + startXViewportPx: number; + startWidthViewportPx: number; + editableWidthViewportPx: number; + horizontalSign: number; + started: boolean; + // The pointer this gesture captured; see captureGesturePointer. + pointerId: number; +} + +let dragState: IInlineImageDragState | undefined; +let resizeState: IInlineImageResizeState | undefined; +let documentWithPointerListeners: Document | undefined; +const documentsWithInlineImageListeners = new WeakSet(); + +function onPointerDown(event: PointerEvent): void { + if (event.button !== 0) return; // the right button belongs to onContextMenu + const target = event.target as HTMLElement | null; + if (!target) return; + // A press outside the page -- in our own menu, or the editing furniture around the page + // -- must not drop the selection, or choosing a command would deselect the very image + // the command is about. + if (!target.closest(".bloom-page")) return; + const handle = target.closest( + "." + kInlineImageHandleClass, + ) as HTMLElement | null; + if (handle) { + startResize(event, handle); + return; + } + const wrapper = target.closest( + "." + kInlineImageClass, + ) as HTMLElement | null; + if (!wrapper) { + deselectAllInlineImages(target.ownerDocument); + return; + } + const editable = wrapper.closest(".bloom-editable") as HTMLElement | null; + if (!editable) return; + selectInlineImage(wrapper); + startDrag(event, wrapper, editable); +} + +// Clicking the image selects the image; it must not also put the caret in the text or start +// sweeping out a selection, which is what the browser does with a press inside a +// contenteditable. Cancelling pointerdown is not a reliable way to stop that, so we cancel +// the mousedown as well. +function onMouseDown(event: MouseEvent): void { + if (event.button !== 0) return; + const target = event.target as HTMLElement | null; + if (target?.closest("." + kInlineImageClass)) event.preventDefault(); +} + +// The caret going back into the text ends the image's turn as the selected object. Focus +// landing in our own menu (which is outside the page) is not that. +function onFocusIn(event: FocusEvent): void { + const target = event.target as HTMLElement | null; + if (!target?.closest(".bloom-page")) return; + if (target.closest("." + kInlineImageClass)) return; + // Clicking the wrapper (contenteditable=false) inevitably lands keyboard focus on the + // editable that CONTAINS it, a beat after pointerdown selected it. That focus change is + // a side effect of the selecting click, not the caret returning to the text, so it must + // not drop the selection (a press on the text itself deselects in onPointerDown instead). + // Without this, the first click on an image always self-cancels and only a second click + // sticks (verified live over CDP against WebView2). + const selected = target.ownerDocument.querySelector( + "." + kInlineImageSelectedClass, + ); + if (selected && target.contains(selected)) return; + deselectAllInlineImages(target.ownerDocument); +} + +/** + * Makes the wrapper the sole destination of everything this pointer does from now until it is + * let go, wherever it goes in the meantime. + * + * Both gestures have to end even when the button is released somewhere our listeners cannot + * see it. addPointerListeners puts them on the PAGE's document, so a pointerup delivered to + * another document -- the toolbox iframe -- or to Bloom's own window furniture, or outside the + * window altogether, never reaches onPointerEnd. And then the gesture simply does not end: the + * edge-scroll timer goes on carrying the picture from a pointer position nothing is updating, + * the body keeps the dragging class so text cannot be selected, the toolbar stays hidden, and + * neither the sync to the other languages nor the undo point ever happens. Capturing the + * pointer is what makes the browser deliver that pointerup here regardless. + * + * No other Bloom gesture spans a document boundary, which is why nothing else has needed one. + */ +function captureGesturePointer(wrapper: HTMLElement, pointerId: number): void { + wrapper.setPointerCapture(pointerId); +} + +/** + * Gives the capture back. Every path that abandons a gesture comes through here, including the + * two that throw the state away without a pointerup (a re-setup and the page-save cleanup): + * a wrapper still holding a capture would go on swallowing the pointer. + */ +function releaseGesturePointer( + state: { wrapper: HTMLElement; pointerId: number } | undefined, +): void { + if (!state) return; + if (state.wrapper.hasPointerCapture(state.pointerId)) + state.wrapper.releasePointerCapture(state.pointerId); +} + +function startDrag( + event: PointerEvent, + wrapper: HTMLElement, + editable: HTMLElement, +): void { + const imageBox = getImageBox(wrapper); + const visibleBoxViewportPx = getBox(editable); + dragState = { + wrapper, + editable, + editableContentBoxViewportPx: computeBlockContentBox( + visibleBoxViewportPx, + editable.clientHeight, + editable.scrollHeight, + editable.scrollTop, + ), + viewportPxPerLayoutPx: computeViewportPxPerLayoutPx( + visibleBoxViewportPx, + editable.clientHeight, + ), + grabOffsetXViewportPx: + imageBox.left + imageBox.width / 2 - event.clientX, + grabOffsetYViewportPx: + imageBox.top + imageBox.height / 2 - event.clientY, + startXViewportPx: event.clientX, + startYViewportPx: event.clientY, + startOffsetLayoutPx: getInlineImageOffsetLayoutPx(wrapper), + dock: getInlineImageDock(wrapper), + started: false, + neighborImageTopsViewportPx: new Map( + getFloatingWrappersIn(editable) + .filter((other) => other !== wrapper) + .map((other) => [other, getImageBox(other).top]), + ), + startScrollOverflowLayoutPx: + editable.scrollHeight - editable.clientHeight, + lastPointViewportPx: { x: event.clientX, y: event.clientY }, + edgeScrollTimerId: undefined, + pointerId: event.pointerId, + }; + addPointerListeners(editable.ownerDocument); + captureGesturePointer(wrapper, event.pointerId); + startEdgeScrollTimer(dragState); +} + +// How often the block scrolls on while the picture is held against one of its edges. +const kEdgeScrollIntervalMs = 50; + +// Re-applies the move the person is already making, so that holding the pointer still against +// an edge goes on carrying the picture through the text. There are no more pointer events to +// drive it, and a drag that only acted on movement would stop the moment the person held the +// mouse where they wanted it -- which at an edge is exactly what they do. +// +// It re-applies the whole move rather than only scrolling when the picture has left the screen, +// which deadlocks: the picture only leaves the screen because the offset moved it there, and the +// offset only moves when the move is applied. That left a picture dragged to the top edge +// resting a line and a half below the start of the text, with nothing able to shift it. +// Re-applying is harmless where nothing has to change: the offset is computed from where the +// picture should end up, so a second application of the same pointer position asks for the +// position it is already in. +function startEdgeScrollTimer(state: IInlineImageDragState): void { + const view = state.editable.ownerDocument.defaultView; + if (!view) return; + state.edgeScrollTimerId = view.setInterval(() => { + if (dragState !== state || !state.started) return; + continueDrag(state, state.lastPointViewportPx); + }, kEdgeScrollIntervalMs); +} + +/** + * Stops the edge-scroll interval of a drag that is over. Every place that abandons dragState has + * to come through here: the interval holds its own reference to the state and would go on + * re-applying the move, from a pointer position nothing is updating any more, against elements + * that a page reload may have replaced. + */ +function stopEdgeScrollTimer(state: IInlineImageDragState | undefined): void { + if (state?.edgeScrollTimerId === undefined) return; + state.editable.ownerDocument.defaultView?.clearInterval( + state.edgeScrollTimerId, + ); + state.edgeScrollTimerId = undefined; +} + +function continueDrag( + state: IInlineImageDragState, + pointViewportPx: { x: number; y: number }, +): void { + state.lastPointViewportPx = pointViewportPx; + if ( + !beginGestureIfMoved( + state, + Math.abs(pointViewportPx.x - state.startXViewportPx), + Math.abs(pointViewportPx.y - state.startYViewportPx), + ) + ) + return; + // Snapshot of the start-of-move state, which fit inside the block (each move either + // ends fitting or reverts to this, so by induction every move starts from a fitting + // arrangement). The next sibling pins the wrapper's cluster position; it is stable + // during a gesture, since only the dragged wrapper moves in the DOM. + const previousDock = state.dock; + const previousOffsetLayoutPx = getInlineImageOffsetLayoutPx(state.wrapper); + const previousNextSibling = state.wrapper.nextElementSibling; + const imageCenterViewportPx = { + x: pointViewportPx.x + state.grabOffsetXViewportPx, + y: pointViewportPx.y + state.grabOffsetYViewportPx, + }; + const dock = computeInlineImageDock( + imageCenterViewportPx, + state.editableContentBoxViewportPx, + getImageBox(state.wrapper).height, + ); + // jsdom reports every box as empty; there we keep the simple delta arithmetic the + // gesture tests exercise and skip the geometry that needs real layout. + const degenerate = !(state.editableContentBoxViewportPx.height > 0); + const blockBottomViewportPx = + state.editableContentBoxViewportPx.top + + state.editableContentBoxViewportPx.height; + if (dock !== previousDock) { + // setInlineImageDock also moves the wrapper between the leading and trailing + // clusters, which is the only DOM difference between the bottom dock and the others. + setInlineImageDock(state.wrapper, dock); + state.dock = dock; + } + if (dock === kInlineImageBottomClass) { + // The bottom dock is in normal flow at the end of the block, so it has no offset; + // the value stays in the style attribute, ready for when the image is dragged + // back up. + } else { + // Where the top of the IMAGE should end up, from the pointer and the grab offsets. + const targetTopViewportPx = + imageCenterViewportPx.y - getImageBox(state.wrapper).height / 2; + // DOM order within the floating cluster is the images' vertical order (each float + // starts below the earlier ones it must clear), so dragging an image above a + // neighbor has to reorder them -- that is what frees the space ABOVE an image + // whose offset padding otherwise fills its column from the top, and what lets + // several images share one side (John, live testing). + if (!degenerate) reorderInFloatingCluster(state, targetTopViewportPx); + // A dock switch or reorder changes where the NEIGHBORS start; hold them at their + // drag-start positions before measuring anything for this wrapper. + if (!degenerate) restoreNeighborImagePositions(state); + // The maximum keeps the whole wrapper (offset padding + image) inside the block: + // an offset that pushes the image past the bottom makes the block scroll (John, + // live testing). The offset is measured from where the float NATURALLY starts + // (below any earlier float it clears), so the room left is computed from the + // wrapper's live rendered bottom: however far that sits above the block's bottom + // is how much further the current offset may grow. + let maxLayoutPx: number | undefined; + const currentOffsetLayoutPx = getInlineImageOffsetLayoutPx( + state.wrapper, + ); + if (!degenerate) { + const wrapperBottomViewportPx = + state.wrapper.getBoundingClientRect().bottom; + maxLayoutPx = + currentOffsetLayoutPx + + (blockBottomViewportPx - wrapperBottomViewportPx) / + state.viewportPxPerLayoutPx; + } + // In a real layout the offset is target-based (where should the image's top be, + // given where it is right now), which stays correct across reorders and reflows. + // The degenerate branch adds a viewport distance to a layout offset without + // dividing, which is right only because it runs where nothing is laid out and so + // nothing is scaled: in jsdom one viewport pixel IS one layout pixel. + const offsetLayoutPx = clampInlineImageOffset( + degenerate + ? state.startOffsetLayoutPx + + (pointViewportPx.y - state.startYViewportPx) + : currentOffsetLayoutPx + + (targetTopViewportPx - getImageBox(state.wrapper).top) / + state.viewportPxPerLayoutPx, + maxLayoutPx, + ); + state.wrapper.style.setProperty( + kInlineImageOffsetVar, + `${offsetLayoutPx}px`, + ); + if (!degenerate) { + // The maximum above was measured before this move's offset was applied, so a + // fast move can land a few pixels long; take back any remainder. + const overViewportPx = + state.wrapper.getBoundingClientRect().bottom - + blockBottomViewportPx; + if (overViewportPx > 0) { + state.wrapper.style.setProperty( + kInlineImageOffsetVar, + `${clampInlineImageOffset(offsetLayoutPx - overViewportPx / state.viewportPxPerLayoutPx)}px`, + ); + } + } + } + if (degenerate) return; + // Whatever this move did, the OTHER images stay exactly where the user put them -- + // on every move, not just at the end (John). + restoreNeighborImagePositions(state); + // FIT OR REVERT. With the neighbors held in place, either the whole block still fits + // (no NEW scroll overflow -- measured on the block, not just this wrapper, because a + // move can fit the dragged image while pushing a neighbor out) or this move went + // somewhere with no room: a full side, the bottom dock of a full block, or a band + // crossing another image's level. Then the whole move is undone -- dock, cluster + // position, offset -- returning to the start-of-move arrangement, which fit. Nothing + // may hang below the block, where it scrolls the text and cannot even be clicked. + if ( + shouldRevertInlineImageMove( + state.startScrollOverflowLayoutPx, + state.editable.scrollHeight - state.editable.clientHeight, + ) + ) { + state.editable.insertBefore(state.wrapper, previousNextSibling); + setInlineImageDock(state.wrapper, previousDock); + state.wrapper.style.setProperty( + kInlineImageOffsetVar, + `${previousOffsetLayoutPx}px`, + ); + state.dock = previousDock; + restoreNeighborImagePositions(state); + } + scrollBlockToKeepDraggedImageInView(state); +} + +// Scrolls the block so that the picture being dragged stays on the screen, and slides the +// gesture's remembered geometry by however far the block actually scrolled. +function scrollBlockToKeepDraggedImageInView( + state: IInlineImageDragState, +): void { + const wantedLayoutPx = computeInlineImageDragScrollLayoutPx( + getImageBox(state.wrapper), + getBox(state.editable), + state.viewportPxPerLayoutPx, + ); + if (wantedLayoutPx === 0) return; + const beforeLayoutPx = state.editable.scrollTop; + state.editable.scrollTop = beforeLayoutPx + wantedLayoutPx; + const movedLayoutPx = state.editable.scrollTop - beforeLayoutPx; + if (movedLayoutPx === 0) return; // the text is already at that end + // Everything the gesture measured, it measured against the screen: where the block's + // content begins and ends, and where each neighbor image was left. The content has just + // slid under all of it, so those positions slide the same way, or the docks would be + // judged against a stale block and the neighbors held at stale places. The offset is not + // among them: it is written in the content's own terms, so scrolling does not touch it, + // and the next move raises it to bring the picture back to the pointer -- which is how a + // drag at the edge goes on moving the picture down the text. + const movedViewportPx = movedLayoutPx * state.viewportPxPerLayoutPx; + state.editableContentBoxViewportPx = { + ...state.editableContentBoxViewportPx, + top: state.editableContentBoxViewportPx.top - movedViewportPx, + }; + for (const [wrapper, topViewportPx] of state.neighborImageTopsViewportPx) + state.neighborImageTopsViewportPx.set( + wrapper, + topViewportPx - movedViewportPx, + ); +} + +/** + * Where in the floating cluster an image whose image-box top will be targetTop belongs: + * after every image whose own image-box top is at or above it. Exported for tests. + */ +export function computeInlineImageClusterIndex( + targetTopViewportPx: number, + otherImageTopsViewportPx: number[], +): number { + return otherImageTopsViewportPx.filter((top) => top <= targetTopViewportPx) + .length; +} + +// The floating (non-bottom) inline images of this editable, in DOM order, which is also +// their vertical order since each one starts below the earlier floats it has to clear. +function getFloatingWrappersIn(editable: HTMLElement): HTMLElement[] { + return Array.from( + editable.querySelectorAll( + `:scope > .${kInlineImageClass}:not(.${kInlineImageBottomClass})`, + ), + ) as HTMLElement[]; +} + +// Sets a neighbor's offset so its image lands deltaPx from where it is now (clamped at +// its natural start). The distance is measured on the screen, so it is divided by the +// page's scale to become the layout pixels the offset is written in. +function nudgeInlineImageOffset( + wrapper: HTMLElement, + deltaViewportPx: number, + viewportPxPerLayoutPx: number, +): void { + wrapper.style.setProperty( + kInlineImageOffsetVar, + `${clampInlineImageOffset(getInlineImageOffsetLayoutPx(wrapper) + deltaViewportPx / viewportPxPerLayoutPx)}px`, + ); +} + +// The page's scale as measured on the block an image lives in. For callers that hold no +// drag state of their own; a drag measures it once and keeps it. +function getViewportPxPerLayoutPxOfEditable(editable: HTMLElement): number { + return computeViewportPxPerLayoutPx( + getBox(editable), + editable.clientHeight, + ); +} + +// Moves the dragged wrapper to the cluster position its target vertical position calls +// for. Purely a DOM move: the neighbors this displaces are held in place separately by +// restoreNeighborImagePositions, which runs on every move of the drag. +function reorderInFloatingCluster( + state: IInlineImageDragState, + targetTopViewportPx: number, +): void { + const floats = getFloatingWrappersIn(state.editable); + if (floats.length < 2) return; + const currentIndex = floats.indexOf(state.wrapper); + if (currentIndex < 0) return; + const others = floats.filter((w) => w !== state.wrapper); + const desiredIndex = computeInlineImageClusterIndex( + targetTopViewportPx, + others.map((other) => getImageBox(other).top), + ); + if (desiredIndex === currentIndex) return; + state.editable.insertBefore( + state.wrapper, + others[desiredIndex] ?? + others[others.length - 1].nextElementSibling ?? + null, + ); +} + +function startResize(event: PointerEvent, handle: HTMLElement): void { + const wrapper = handle.closest( + "." + kInlineImageClass, + ) as HTMLElement | null; + const editable = wrapper?.closest(".bloom-editable") as HTMLElement | null; + if (!wrapper || !editable) return; + // In viewport pixels, like the image width and the pointer deltas it is compared with: + // clientWidth is in layout pixels, which are smaller than viewport pixels whenever the + // page is zoomed, and mixing the two made every resize off by the zoom. + const editableWidthViewportPx = + editable.clientWidth * + computeViewportPxPerLayoutPx(getBox(editable), editable.clientHeight); + // With no width to be a percentage of, a resize could only write a nonsense number. + if (editableWidthViewportPx <= 0) return; + const corner = (handle.getAttribute(kInlineImageCornerAttribute) ?? + "se") as InlineImageHandleCorner; + resizeState = { + wrapper, + editable, + startXViewportPx: event.clientX, + startWidthViewportPx: getImageBox(wrapper).width, + editableWidthViewportPx, + horizontalSign: getInlineImageHandleHorizontalSign(corner), + started: false, + pointerId: event.pointerId, + }; + // Grabbing a handle is not the start of a text selection. + event.preventDefault(); + addPointerListeners(editable.ownerDocument); + captureGesturePointer(wrapper, event.pointerId); +} + +function continueResize( + state: IInlineImageResizeState, + event: PointerEvent, +): void { + if ( + !beginGestureIfMoved( + state, + Math.abs(event.clientX - state.startXViewportPx), + 0, + ) + ) + return; + const percent = computeInlineImageWidthPercent( + state.startWidthViewportPx, + event.clientX - state.startXViewportPx, + state.horizontalSign, + state.editableWidthViewportPx, + ); + state.wrapper.style.setProperty(kInlineImageWidthVar, `${percent}%`); +} + +// A press that hasn't travelled far enough is still a click, so nothing is mutated and no +// undo point recorded until it has. The undo point is prepared rather than recorded, since +// even a real drag may be abandoned; endGesture commits it. Returns whether the caller +// should go on to apply this move. +function beginGestureIfMoved( + state: { wrapper: HTMLElement; started: boolean }, + absDeltaX: number, + absDeltaY: number, +): boolean { + if (state.started) return true; + if ( + absDeltaX < kDragThresholdViewportPx && + absDeltaY < kDragThresholdViewportPx + ) + return false; + state.started = true; + prepareInlineImageUndo(state.wrapper); + state.wrapper.ownerDocument.body.classList.add(kInlineImageDraggingClass); + setInlineImageContextControlsMoving(state.wrapper.ownerDocument, true); + return true; +} + +// The listeners are on the document, so they see every pointer, and the browser goes on +// delivering a second one's events while the first holds its capture. A second finger on a +// touch screen, or a stray brush of a trackpad during a mouse drag, would otherwise move the +// picture to wherever that pointer is and end the gesture there. +function isThisGesturesPointer(event: PointerEvent): boolean { + const state = resizeState ?? dragState; + return !!state && state.pointerId === event.pointerId; +} + +function onPointerMove(event: PointerEvent): void { + if (!isThisGesturesPointer(event)) return; + if (resizeState) continueResize(resizeState, event); + else if (dragState) + continueDrag(dragState, { x: event.clientX, y: event.clientY }); +} + +// Both gestures end the same way, including a cancelled one: whatever was applied to the +// wrapper is on the screen, so it had better be replicated and undoable. +function onPointerEnd(event: PointerEvent): void { + if (!isThisGesturesPointer(event)) return; + const drag = dragState; + const state = resizeState ?? dragState; + resizeState = undefined; + dragState = undefined; + stopEdgeScrollTimer(drag); + releaseGesturePointer(state); + removePointerListeners(); + if (!state) return; + state.wrapper.ownerDocument.body.classList.remove( + kInlineImageDraggingClass, + ); + setInlineImageContextControlsMoving(state.wrapper.ownerDocument, false); + if (!state.started) return; // it was a click: nothing changed, nothing prepared + if (drag && drag === state) { + restoreNeighborImagePositions(drag); + normalizeFloatingClusterOrder( + drag.editable, + drag.editableContentBoxViewportPx, + drag.viewportPxPerLayoutPx, + ); + } + commitInlineImageChange(state.wrapper, state.editable); + // The picture is somewhere else now, so the bar goes with it. + positionInlineImageContextControls(state.wrapper); +} + +// Rewrites the floating cluster's DOM order to match the images' visual order, keeping +// every image exactly where it is. DOM order is what each float clears past, so an order +// that contradicts the visual order (which can arrive from a book edited before this +// rule, or from insertions) quietly limits where the images can go; a drag is the moment +// the user is rearranging things, so its end is the moment to straighten this out. +function normalizeFloatingClusterOrder( + editable: HTMLElement, + editableContentBoxViewportPx: IBox, + viewportPxPerLayoutPx: number, +): void { + if (!(editableContentBoxViewportPx.height > 0)) return; // no real layout to measure (jsdom) + const floats = getFloatingWrappersIn(editable); + if (floats.length < 2) return; + const wantedTops = new Map(floats.map((w) => [w, getImageBox(w).top])); + const sorted = [...floats].sort( + (a, b) => wantedTops.get(a)! - wantedTops.get(b)!, + ); + if (sorted.every((w, i) => w === floats[i])) return; + const anchor = floats[floats.length - 1].nextElementSibling; + sorted.forEach((w) => editable.insertBefore(w, anchor)); + // The reordering changed what each float clears, so put them all back where they + // were; two passes, since correcting an earlier float shifts the later ones. + for (let pass = 0; pass < 2; pass++) { + getFloatingWrappersIn(editable).forEach((w) => { + const wanted = wantedTops.get(w); + if (wanted === undefined) return; + const current = getImageBox(w).top; + if (Math.abs(wanted - current) <= 1) return; + nudgeInlineImageOffset(w, wanted - current, viewportPxPerLayoutPx); + }); + } +} + +// Puts every image the drag did NOT move back exactly where it was when the drag began, +// as far as the new geometry allows (an image cannot rise above its natural start, so a +// neighbor whose old spot is now occupied lands as close below it as floats permit). Two +// passes, because correcting an earlier float shifts where the later ones start. +function restoreNeighborImagePositions(state: IInlineImageDragState): void { + if (state.editableContentBoxViewportPx.height <= 0) return; // no real layout to measure (jsdom) + for (let pass = 0; pass < 2; pass++) { + getFloatingWrappersIn(state.editable).forEach((wrapper) => { + if (wrapper === state.wrapper) return; + const wantedTop = state.neighborImageTopsViewportPx.get(wrapper); + if (wantedTop === undefined) return; + const currentTop = getImageBox(wrapper).top; + if (Math.abs(wantedTop - currentTop) <= 1) return; + nudgeInlineImageOffset( + wrapper, + wantedTop - currentTop, + state.viewportPxPerLayoutPx, + ); + }); + } +} + +// The end of any completed change to an inline image's geometry. +function commitInlineImageChange( + wrapper: HTMLElement, + editable: HTMLElement, +): void { + commitPendingInlineImageUndo(wrapper); + // Every image in the block, not just the dragged one: a move nudges its neighbors' offsets + // too, and all of them were measured against the block as it is now. + getInlineImagesInEditable(editable).forEach((each) => + recordInlineImageOffsetBaseline(each, editable), + ); + syncInlineImagesFromEditable(editable); + OverflowChecker.AdjustSizeOrMarkOverflowSoon(editable); +} + +function addPointerListeners(doc: Document): void { + removePointerListeners(); + documentWithPointerListeners = doc; + // On the document, in the capture phase, so a gesture that wanders off the image -- or + // off the page -- still gets its moves and its end. + doc.addEventListener("pointermove", onPointerMove, true); + doc.addEventListener("pointerup", onPointerEnd, true); + doc.addEventListener("pointercancel", onPointerEnd, true); +} + +function removePointerListeners(): void { + const doc = documentWithPointerListeners; + if (!doc) return; + documentWithPointerListeners = undefined; + doc.removeEventListener("pointermove", onPointerMove, true); + doc.removeEventListener("pointerup", onPointerEnd, true); + doc.removeEventListener("pointercancel", onPointerEnd, true); +} + +// --- internals --------------------------------------------------------------- + +function addHandles(wrapper: HTMLElement): void { + if (wrapper.querySelector(":scope > ." + kInlineImageHandleFrameClass)) + return; + const doc = wrapper.ownerDocument; + const frame = doc.createElement("div"); + frame.className = "bloom-ui " + kInlineImageHandleFrameClass; + kInlineImageHandleCorners.forEach((corner) => { + const handle = doc.createElement("div"); + handle.className = `bloom-ui ${kInlineImageHandleClass} ${kInlineImageHandleClass}-${corner}`; + handle.setAttribute(kInlineImageCornerAttribute, corner); + frame.appendChild(handle); + }); + wrapper.appendChild(frame); +} + +const getImageOf = (wrapper: HTMLElement): HTMLElement | undefined => + (wrapper.querySelector("img") as HTMLElement | null) ?? undefined; + +// The image's box, which is not the wrapper's: the vertical offset is transparent padding at +// the top of the wrapper, and the middle band is a full-width wrapper around a narrower +// image. +function getImageBox(wrapper: HTMLElement): IBox { + return getBox(getImageOf(wrapper) ?? wrapper); +} + +function getBox(element: HTMLElement): IBox { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} + +function getInlineImageOffsetLayoutPx(wrapper: HTMLElement): number { + const value = parseFloat( + wrapper.style.getPropertyValue(kInlineImageOffsetVar), + ); + return Number.isNaN(value) ? 0 : value; +} + +// Adding or removing an image changes how much room the text has, in every language. +function refreshOverflow(translationGroup: HTMLElement): void { + getEditables(translationGroup).forEach((editable) => + OverflowChecker.AdjustSizeOrMarkOverflowSoon(editable), + ); +} diff --git a/src/BloomBrowserUI/bookEdit/textContextMenu/TextContextMenu.tsx b/src/BloomBrowserUI/bookEdit/textContextMenu/TextContextMenu.tsx index b22f96a75dc2..f2673c636efd 100644 --- a/src/BloomBrowserUI/bookEdit/textContextMenu/TextContextMenu.tsx +++ b/src/BloomBrowserUI/bookEdit/textContextMenu/TextContextMenu.tsx @@ -1,17 +1,19 @@ -import { css } from "@emotion/react"; - import * as React from "react"; import Menu from "@mui/material/Menu"; +import { Divider } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; import { lightTheme } from "../../bloomMaterialUITheme"; import { LocalizableSelectableMenuItem } from "../../react_components/localizableMenuItem"; +import { + contextMenuCss, + renderContextMenuItems, +} from "../js/canvasElementManager/canvasControlMenuRendering"; import { renderRoot } from "../../utils/reactRender"; +import { canToggleNoIndent, isNoIndentOn, toggleNoIndent } from "./noIndent"; import { - canToggleNoIndent, - findParagraphForTextContextMenu, - isNoIndentOn, - toggleNoIndent, -} from "./noIndent"; + getTextContextMenuContent, + ITextContextMenuContent, +} from "./textContextMenuContent"; // The context menu for a right-click on the text of an ordinary text box in the edit view // (BL-16649). Text inside a canvas element gets CanvasElementContextControls' menu instead; @@ -21,19 +23,40 @@ import { // CanvasElementContextControls. Open state lives in renderTextContextMenu (below) rather // than in the component, so that the component can be re-rendered for a new paragraph // without carrying over stale state. -const TextContextMenu: React.FunctionComponent<{ +// +// It carries two kinds of command, because a right-click in a text box can mean two things. +// One is a command on the paragraph clicked ("No Indent"). The other is a command on the +// inline (Word-style) image of the text box: adding one, or -- when the click landed on the +// image itself -- the standard image menu (the same commands a canvas element image offers). +// Which of them apply to a given click is getTextContextMenuContent's decision, not this +// component's. + +// "No Indent" acts on one paragraph, so it is offered only when the right-click was in one +// (a click on an inline image is not). Its own logic is paragraph-shaped -- there is nothing +// for isNoIndentOn or canToggleNoIndent to answer without one -- so the item is left out +// altogether in that case rather than shown disabled. +const NoIndentMenuItem: React.FunctionComponent<{ paragraph: HTMLElement; + onDone: () => void; +}> = (props) => ( + { + toggleNoIndent(props.paragraph); + props.onDone(); + }} + /> +); + +const TextContextMenu: React.FunctionComponent<{ + content: ITextContextMenuContent; open: boolean; setOpen: (open: boolean) => void; anchorPosition: { left: number; top: number }; }> = (props) => { - const noIndentIsOn = isNoIndentOn(props.paragraph); - - const handleNoIndentClick = () => { - toggleNoIndent(props.paragraph); - props.setOpen(false); - }; - return ( - + {props.content.paragraph && ( + props.setOpen(false)} + /> + )} + {props.content.paragraph && + props.content.inlineImageItems.length > 0 && ( + + )} + {/* Each item closes the menu itself, through the closeMenu the content was + built with (see setupTextContextMenu) -- the standard image commands + decide for themselves when, because a dialog-launching command must + close with dialog-aware focus handling before its dialog arrives. */} + {renderContextMenuItems(props.content.inlineImageItems)} ); @@ -75,11 +99,11 @@ const kTextContextMenuRootId = "text-context-menu"; // per container for exactly that. Its mount is asynchronous, which is fine here -- nothing // reads the menu's DOM after the call. function renderTextContextMenu( - paragraph: HTMLElement, + pageDocument: Document, + content: ITextContextMenuContent, open: boolean, anchorPosition: { left: number; top: number }, ) { - const pageDocument = paragraph.ownerDocument; let root = pageDocument.getElementById(kTextContextMenuRootId); if (!root) { root = pageDocument.createElement("div"); @@ -92,11 +116,16 @@ function renderTextContextMenu( } renderRoot( - renderTextContextMenu(paragraph, newOpen, anchorPosition) + renderTextContextMenu( + pageDocument, + content, + newOpen, + anchorPosition, + ) } />, root, @@ -112,13 +141,20 @@ export function setupTextContextMenu(): void { // Ctrl+right-click is reserved for the WebView2 developer menu; see // WebView2Browser.ContextMenuRequested. if (event.ctrlKey) return; - const paragraph = findParagraphForTextContextMenu(event.target); - if (!paragraph) return; + const anchorPosition = { left: event.clientX, top: event.clientY }; + // The menu items dismiss the menu through this. It has to exist before the content + // that captures it, and the content it re-renders is the content being built, hence + // the two-step wiring. + let content: ITextContextMenuContent | undefined; + const closeMenu = () => { + if (content) + renderTextContextMenu(document, content, false, anchorPosition); + }; + content = getTextContextMenuContent(event.target, closeMenu); + // Nothing to offer: leave the event alone so WebView2's own menu still appears. + if (!content) return; event.preventDefault(); event.stopPropagation(); - renderTextContextMenu(paragraph, true, { - left: event.clientX, - top: event.clientY, - }); + renderTextContextMenu(document, content, true, anchorPosition); }); } diff --git a/src/BloomBrowserUI/bookEdit/textContextMenu/textContextMenuContent.ts b/src/BloomBrowserUI/bookEdit/textContextMenu/textContextMenuContent.ts new file mode 100644 index 000000000000..899593f3b6ec --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/textContextMenu/textContextMenuContent.ts @@ -0,0 +1,47 @@ +// What the text context menu should offer for a given right-click. Kept out of the React +// component, in the same spirit as noIndent.ts, so that the decision can be unit tested +// against a plain DOM. + +import { IMenuItemWithSubmenu } from "../js/canvasElementManager/canvasControlMenuRendering"; +import { + CloseMenuFunction, + getInlineImageMenuItemsForClick, +} from "../js/inlineImageInteractions"; +import { findParagraphForTextContextMenu } from "./noIndent"; + +export interface ITextContextMenuContent { + // The paragraph the paragraph-level commands act on, or undefined when the right-click was + // not in a paragraph. An inline image is the case that arises: it sits among the + // paragraphs of the text box, never inside one. + paragraph?: HTMLElement; + // What inline images contribute for this click; empty when they have nothing to offer. + // For an existing image this is the standard image menu (dividers and submenus + // included), which is why the shape is richer than plain ILocalizableMenuItemProps. + inlineImageItems: IMenuItemWithSubmenu[]; +} + +/** + * Decides what a right-click should put on the text context menu, or returns undefined if the + * menu should not open at all -- in which case the caller must leave the event alone, so that + * whatever else would handle it (WebView2's own menu) still can. + * + * Call this once per right-click, not once per render of the menu: working out the inline + * image's commands also selects the image they will act on. See + * getInlineImageMenuItemsForClick. + * + * closeMenu is what the inline-image commands call to dismiss the menu (the paragraph + * commands close through the component instead); the no-op default is for tests that only + * inspect the items. + */ +export function getTextContextMenuContent( + target: EventTarget | null, + closeMenu: CloseMenuFunction = () => {}, +): ITextContextMenuContent | undefined { + const paragraph = findParagraphForTextContextMenu(target); + const inlineImageItems = getInlineImageMenuItemsForClick( + target instanceof HTMLElement ? target : undefined, + closeMenu, + ); + if (!paragraph && inlineImageItems.length === 0) return undefined; + return { paragraph, inlineImageItems }; +} diff --git a/src/BloomBrowserUI/bookEdit/textContextMenu/textContextMenuContentSpec.ts b/src/BloomBrowserUI/bookEdit/textContextMenu/textContextMenuContentSpec.ts new file mode 100644 index 000000000000..1c64115e14ba --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/textContextMenu/textContextMenuContentSpec.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getTextContextMenuContent } from "./textContextMenuContent"; +import { + getInlineImageInEditable, + insertInlineImage, + kInlineImageSelectedClass, +} from "../js/inlineImages"; + +// Which commands a right-click puts on the text context menu. Two sources feed it -- the +// paragraph command ("No Indent") and whatever the text box's inline image has to offer -- so +// these tests are as much about what is NOT offered where. + +// An ordinary text box with two paragraphs, a second text box to add an inline image to, and a +// canvas element that also contains text, so we can check which right-clicks the menu claims. +// Same shape as noIndentSpec.ts, with the data-page-id the inline-image undo layer keys on. +function setupPage(): void { + document.body.innerHTML = ` +
+
+
+

First paragraph with emphasis

+

Second paragraph

+
+
+

Premier paragraphe

+
+
+
+
+
+
+

Canvas text

+
+
+
+
+
Not in a text box at all
+
`; +} + +function element(id: string): HTMLElement { + const result = document.getElementById(id); + if (!result) + throw new Error(`test setup is broken: no element with id ${id}`); + return result; +} + +const l10nIdsOf = ( + content: ReturnType, +): string[] => (content?.inlineImageItems ?? []).map((item) => item.l10nId!); + +describe("getTextContextMenuContent", () => { + beforeEach(setupPage); + afterEach(() => (document.body.innerHTML = "")); + + it("offers the paragraph command and Add Image for a plain paragraph", () => { + const content = getTextContextMenuContent(element("first")); + + expect(content, "expected the menu to open").toBeTruthy(); + // The paragraph is what "No Indent" needs; without it that command is not offered. + expect(content!.paragraph).toBe(element("first")); + expect(l10nIdsOf(content)).toEqual(["EditTab.InlineImage.AddImage"]); + }); + + it("finds the enclosing paragraph when the click is on something inside it", () => { + const content = getTextContextMenuContent(element("emphasis")); + expect(content!.paragraph).toBe(element("first")); + }); + + it("offers the paragraph command and Add Image once the text box has an image", () => { + insertInlineImage(element("plainGroup")); + + const content = getTextContextMenuContent(element("first")); + + // Still a menu, and still No Indent... + expect(content!.paragraph).toBe(element("first")); + // ...and since there is no limit on inline images per text box, adding another is + // still offered; the commands for an existing image belong to a click on that image. + expect(l10nIdsOf(content)).toEqual(["EditTab.InlineImage.AddImage"]); + }); + + it("offers the image's own commands for a click on the image", () => { + const wrapper = insertInlineImage(element("plainGroup")); + + const content = getTextContextMenuContent( + wrapper.querySelector("img") as HTMLElement, + ); + + expect(content, "expected the menu to open on the image").toBeTruthy(); + // No paragraph: the image sits among the paragraphs, not inside one. So "No Indent", + // which has nothing to act on, is not offered. + expect(content!.paragraph).toBeUndefined(); + // The standard image menu (same registry as the canvas element menu, filtered by + // the normal availability rules), then a divider and Delete. + expect(l10nIdsOf(content)).toEqual([ + "EditTab.Image.EditMetadataOverlay", + "EditTab.Image.ChooseImage", + "EditTab.Image.CopyImage", + "EditTab.Image.PasteImage", + "EditTab.Image.Reset", + "EditTab.Image.Transparency", + "-", + "Common.Delete", + ]); + // Deciding the commands also selects the image they act on, which is what the + // inline-image undo layer gates ctrl+z on. + expect(wrapper.classList.contains(kInlineImageSelectedClass)).toBe( + true, + ); + }); + + it("offers nothing for a paragraph inside a canvas element", () => { + // Sanity check: it really is a paragraph of a bloom-editable, so only the + // canvas-element test can be what rejects it. + expect( + element("canvasParagraph").closest(".bloom-editable"), + ).toBeTruthy(); + expect( + getTextContextMenuContent(element("canvasParagraph")), + ).toBeUndefined(); + }); + + it("offers nothing for an inline image inside a canvas element", () => { + const canvasGroup = element("canvasParagraph").closest( + ".bloom-translationGroup", + ) as HTMLElement; + const wrapper = insertInlineImage(canvasGroup); + // Canvas elements have their own context menu, which knows about their images. + expect( + getTextContextMenuContent(wrapper.querySelector("img")), + ).toBeUndefined(); + }); + + it("offers nothing outside a text box", () => { + expect(getTextContextMenuContent(element("notText"))).toBeUndefined(); + }); + + it("offers Add Image in the empty space of a text box, where there is no paragraph", () => { + // Adding an image is a command on the whole text box, not on a paragraph, so it is + // offered anywhere in the box -- including the space below the last line, which is + // often most of a box. This is the one place the menu now opens where the + // paragraph-only rule would not have opened it. + const content = getTextContextMenuContent(element("ordinaryText")); + + expect(content, "expected the menu to open").toBeTruthy(); + // Nothing for "No Indent" to act on, so it is not offered. + expect(content!.paragraph).toBeUndefined(); + expect(l10nIdsOf(content)).toEqual(["EditTab.InlineImage.AddImage"]); + }); + + it("offers Add Image in the empty space of a text box that already has an image", () => { + insertInlineImage(element("plainGroup")); + // There is no limit on inline images per box, so there is still something to add; + // an existing image's own commands are reached by clicking that image. + const content = getTextContextMenuContent(element("ordinaryText")); + expect(content, "expected the menu to open").toBeTruthy(); + expect(content!.paragraph).toBeUndefined(); + expect(l10nIdsOf(content)).toEqual(["EditTab.InlineImage.AddImage"]); + }); + + it("offers nothing for a non-element target", () => { + expect(getTextContextMenuContent(null)).toBeUndefined(); + expect(getTextContextMenuContent(document)).toBeUndefined(); + }); + + it("offers Add Image in a hidden language's block too", () => { + // Every language's editable carries its own copy of the image, so the command belongs + // on any of the text box's blocks, not only the visible one. + const content = getTextContextMenuContent(element("frenchFirst")); + expect(content!.paragraph).toBe(element("frenchFirst")); + expect(l10nIdsOf(content)).toEqual(["EditTab.InlineImage.AddImage"]); + }); + + it("leaves the image unselected when it offers nothing", () => { + insertInlineImage(element("plainGroup")); + getTextContextMenuContent(element("notText")); + expect( + getInlineImageInEditable( + element("ordinaryText"), + )?.classList.contains(kInlineImageSelectedClass), + ).toBe(false); + }); +});