diff --git a/DistFiles/localization/en/BloomMediumPriority.xlf b/DistFiles/localization/en/BloomMediumPriority.xlf
index 281cad055862..e197f14e0026 100644
--- a/DistFiles/localization/en/BloomMediumPriority.xlf
+++ b/DistFiles/localization/en/BloomMediumPriority.xlf
@@ -463,6 +463,25 @@
Drag any of these onto a canvas:
ID: EditTab.Toolbox.CanvasTool.DragInstructions2
+
+ Image Fit
+ ID: EditTab.Toolbox.CanvasTool.ImageFit
+
+
+ Fit with Margin
+ ID: EditTab.Toolbox.CanvasTool.ImageFit.Margin
+ Leave a margin around the image within the button.
+
+
+ Fit to Edge
+ ID: EditTab.Toolbox.CanvasTool.ImageFit.FitToEdge
+ Fill up the button with the image, leaving a margin on the sides as needed.
+
+
+ Fill
+ Fill up the button with the image, leaving no margin, cropping if necessary
+ ID: EditTab.Toolbox.CanvasTool.ImageFit.Fill
+
Choose books...
ID: EditTab.Toolbox.CanvasTool.LinkGrid.ChooseBooks
diff --git a/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts b/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts
index f17932a41bbe..56bcfa5d516e 100644
--- a/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts
+++ b/src/BloomBrowserUI/bookEdit/js/CanvasElementManager.ts
@@ -59,6 +59,9 @@ import {
kBloomCanvasClass,
kBloomCanvasSelector,
kBloomButtonClass,
+ kImageFitModeAttribute,
+ kImageFitModeContainValue,
+ kImageFitModeCoverValue,
} from "../toolbox/canvas/canvasElementUtils";
import OverflowChecker from "../OverflowChecker/OverflowChecker";
import theOneLocalizationManager from "../../lib/localizationManager/localizationManager";
@@ -5835,6 +5838,16 @@ export class CanvasElementManager {
patriarchDuplicateElement.classList.add("bloom-gif");
if (sourceElement.classList.contains(kBloomButtonClass))
patriarchDuplicateElement.classList.add(kBloomButtonClass);
+ const imageFitMode = sourceElement.getAttribute(kImageFitModeAttribute);
+ if (
+ imageFitMode === kImageFitModeCoverValue ||
+ imageFitMode === kImageFitModeContainValue
+ ) {
+ patriarchDuplicateElement.setAttribute(
+ kImageFitModeAttribute,
+ imageFitMode,
+ );
+ }
// copy any data-sound
const sourceDataSound = sourceElement.getAttribute("data-sound");
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts
index 79c36f80575f..5b28003d2709 100644
--- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts
+++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasElementUtils.ts
@@ -11,6 +11,9 @@ export const kHasCanvasElementClass = "bloom-has-canvas-element";
export const kBloomCanvasClass = "bloom-canvas";
export const kBloomCanvasSelector = `.${kBloomCanvasClass}`;
export const kBloomButtonClass = "bloom-canvas-button";
+export const kImageFitModeAttribute = "data-image-fit";
+export const kImageFitModeContainValue = "contain";
+export const kImageFitModeCoverValue = "cover";
// Enhance: we could reduce cross-bundle dependencies by separately defining the CanvasElementManager interface
// and just importing that here.
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx
index c9075643dc81..ef0fde0f9dc5 100644
--- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx
+++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasTool.tsx
@@ -48,6 +48,9 @@ import {
import {
getCanvasElementManager,
kBloomButtonClass,
+ kImageFitModeAttribute,
+ kImageFitModeContainValue,
+ kImageFitModeCoverValue,
} from "./canvasElementUtils";
import { deselectVideoContainers } from "../../js/videoUtils";
import { CanvasElementKeyHints } from "./CanvasElementKeyHints";
@@ -63,6 +66,23 @@ import { TriangleCollapse } from "../../../react_components/TriangleCollapse";
import { BloomTooltip } from "../../../react_components/BloomToolTip";
import { text } from "stream/consumers";
+const kImageFillModePaddedValue = "padded";
+type ImageFillMode =
+ | typeof kImageFillModePaddedValue
+ | typeof kImageFitModeContainValue
+ | typeof kImageFitModeCoverValue;
+
+const getImageFillModeForElement = (element: HTMLElement): ImageFillMode => {
+ const currentFillMode = element.getAttribute(kImageFitModeAttribute);
+ if (
+ currentFillMode === kImageFitModeContainValue ||
+ currentFillMode === kImageFitModeCoverValue
+ ) {
+ return currentFillMode;
+ }
+ return kImageFillModePaddedValue;
+};
+
const CanvasToolControls: React.FunctionComponent = () => {
const l10nPrefix = "ColorPicker.";
type CanvasElementType = "text" | "image" | "video" | undefined;
@@ -77,6 +97,9 @@ const CanvasToolControls: React.FunctionComponent = () => {
const [showTailChecked, setShowTailChecked] = useState(false);
const [isRoundedCornersChecked, setIsRoundedCornersChecked] =
useState(false);
+ const [imageFillMode, setImageFillMode] = useState(
+ kImageFillModePaddedValue,
+ );
const [isXmatter, setIsXmatter] = useState(true);
// This 'counter' increments on new page ready so we can re-check if the book is locked.
const [pageRefreshIndicator, setPageRefreshIndicator] = useState(0);
@@ -85,16 +108,22 @@ const CanvasToolControls: React.FunctionComponent = () => {
const [isStyleSelectOpen, setIsStyleSelectOpen] = useState(false);
const [isOutlineColorSelectOpen, setIsOutlineColorSelectOpen] =
useState(false);
- function openStyleSelect() {
+ const [isImageFillSelectOpen, setIsImageFillSelectOpen] = useState(false);
+ const openStyleSelect = () => {
setIsStyleSelectOpen(true);
// Make sure we don't leave the select open when the tool closes.
callWhenFocusLost(() => setIsStyleSelectOpen(false));
- }
- function openOutlineColorSelect() {
+ };
+ const openOutlineColorSelect = () => {
setIsOutlineColorSelectOpen(true);
// Make sure we don't leave the select open when the tool closes.
callWhenFocusLost(() => setIsOutlineColorSelectOpen(false));
- }
+ };
+ const openImageFillSelect = () => {
+ setIsImageFillSelectOpen(true);
+ // Make sure we don't leave the select open when the tool closes.
+ callWhenFocusLost(() => setIsImageFillSelectOpen(false));
+ };
// Calls to useL10n
const deleteTooltip = useL10n("Delete", "Common.Delete");
@@ -205,6 +234,7 @@ const CanvasToolControls: React.FunctionComponent = () => {
const canvasElementManager = getCanvasElementManager();
setCanvasElementType(getBubbleType(canvasElementManager));
+ setImageFillMode(getImageFillModeForElement(currentBubble.content));
if (canvasElementManager) {
// Get the current canvas element's textColor and set it
const canvasElementTextColorInformation: ITextColorInfo =
@@ -219,6 +249,7 @@ const CanvasToolControls: React.FunctionComponent = () => {
}
} else {
setCanvasElementType(undefined);
+ setImageFillMode(kImageFillModePaddedValue);
}
}, [currentBubble]);
@@ -436,6 +467,27 @@ const CanvasToolControls: React.FunctionComponent = () => {
const bubble = canvasElementManager.getPatriarchBubbleOfActiveElement();
setCurrentBubble(bubble);
};
+ const handleImageFillChanged = (event) => {
+ const newMode = event.target.value as ImageFillMode;
+ setImageFillMode(newMode);
+ const activeElement = getCanvasElementManager()?.getActiveElement();
+ if (!activeElement) {
+ return;
+ }
+ if (newMode === kImageFitModeCoverValue) {
+ activeElement.setAttribute(
+ kImageFitModeAttribute,
+ kImageFitModeCoverValue,
+ );
+ } else if (newMode === kImageFitModeContainValue) {
+ activeElement.setAttribute(
+ kImageFitModeAttribute,
+ kImageFitModeContainValue,
+ );
+ } else {
+ activeElement.removeAttribute(kImageFitModeAttribute);
+ }
+ };
// Callback when outline color of the bubble is changed
const handleOutlineColorChanged = (event) => {
@@ -576,6 +628,52 @@ const CanvasToolControls: React.FunctionComponent = () => {
/>
);
+ const imageFillControl = (
+
+
+
+ Image Fit
+
+
+
+
+
+
+ );
const textColorControl = (
@@ -595,6 +693,9 @@ const CanvasToolControls: React.FunctionComponent = () => {
const activeElement = canvasElementManager?.getActiveElement();
const isButton =
activeElement?.classList.contains(kBloomButtonClass) ?? false;
+ const hasImage =
+ (activeElement?.getElementsByClassName("bloom-imageContainer")
+ ?.length ?? 0) > 0;
const hasText =
(activeElement?.getElementsByClassName("bloom-translationGroup")
?.length ?? 0) > 0;
@@ -625,6 +726,7 @@ const CanvasToolControls: React.FunctionComponent = () => {
<>
{hasText && textColorControl}
{backgroundColorControl}
+ {hasImage && imageFillControl}
>
);
switch (canvasElementType) {
diff --git a/src/content/bookLayout/canvasElement.less b/src/content/bookLayout/canvasElement.less
index bc22089479cd..d69d0c8036ba 100644
--- a/src/content/bookLayout/canvasElement.less
+++ b/src/content/bookLayout/canvasElement.less
@@ -357,6 +357,54 @@
}
}
+.bloom-page .bloom-canvas-button[data-image-fit="contain"],
+.bloom-page .bloom-canvas-button[data-image-fit="cover"] {
+ padding: 0;
+ overflow: hidden;
+}
+
+.bloom-page
+ .bloom-canvas-button[data-image-fit="contain"]
+ .bloom-imageContainer,
+.bloom-page .bloom-canvas-button[data-image-fit="cover"] .bloom-imageContainer {
+ img,
+ video {
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ }
+}
+
+.bloom-page
+ .bloom-canvas-button[data-image-fit="contain"]
+ .bloom-imageContainer {
+ img,
+ video {
+ object-fit: contain;
+ }
+}
+
+.bloom-page .bloom-canvas-button[data-image-fit="cover"] .bloom-imageContainer {
+ img,
+ video {
+ object-fit: cover;
+ }
+}
+
+// In contain/cover modes, move padding from the button to the text group so
+// the image can reach the edge while labels keep their spacing.
+.bloom-page
+ .bloom-canvas-button:is(
+ [data-image-fit="contain"],
+ [data-image-fit="cover"]
+ ):has(> .bloom-translationGroup)
+ .bloom-translationGroup {
+ padding: 0.6em;
+ width: 100%;
+ box-sizing: border-box;
+}
+
.bloom-page .bloom-canvas-button {
display: inline-flex;
// for when it has both image and text