Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions DistFiles/localization/en/BloomMediumPriority.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
<note>ID: EditTab.TextContextMenu.NoIndent</note>
<note>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.</note>
</trans-unit>
<trans-unit id="EditTab.InlineImage.AddImage" translate="no">
<source xml:lang="en">Add Image</source>
<note>ID: EditTab.InlineImage.AddImage</note>
<note>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).</note>
</trans-unit>
Comment thread
hatton marked this conversation as resolved.
<!-- Drag Activity Tool -->
<trans-unit id="EditTab.Toolbox.DragActivity.ChooseSound">
<source xml:lang="en">Choose...</source>
Expand Down
163 changes: 162 additions & 1 deletion src/BloomBrowserUI/bookEdit/css/editMode.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
106 changes: 95 additions & 11 deletions src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -455,15 +474,26 @@ 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.
imgOrImageContainer.removeAttribute("id");
theOneCanvasElementManager.updateCanvasElementForChangedImage(
imgOrImageContainer,
);
commitPendingImageOperationUndo(imgOrImageContainer);
if (!commitInlineImageUndoForImageChange(imgOrImageContainer)) {
commitPendingImageOperationUndo(imgOrImageContainer);
}
notifyToolOfChangedImage();
}

Expand All @@ -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();
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
hatton marked this conversation as resolved.
// ...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);
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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);
Comment thread
hatton marked this conversation as resolved.
cleanupNiceScroll(); // don't leave the nicescroll debris around
}

Expand Down Expand Up @@ -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"];
Expand Down
Loading