diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl
index 64c0db0c1..3aacb9746 100644
--- a/ftl/core/editing.ftl
+++ b/ftl/core/editing.ftl
@@ -96,6 +96,7 @@ editing-image-occlusion-rectangle-tool = Rectangle
editing-image-occlusion-ellipse-tool = Ellipse
editing-image-occlusion-polygon-tool = Polygon
editing-image-occlusion-text-tool = Text
+editing-image-occlusion-fill-tool = Fill with colour
editing-image-occlusion-toggle-mask-editor = Toggle Mask Editor
editing-image-occlusion-reset = Reset Image Occlusion
editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion?
diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs
index 0658b4319..2ba83374f 100644
--- a/rslib/src/image_occlusion/imageocclusion.rs
+++ b/rslib/src/image_occlusion/imageocclusion.rs
@@ -64,19 +64,9 @@ pub fn get_image_cloze_data(text: &str) -> String {
}
for property in occlusion.properties {
match property.name.as_str() {
- "left" => {
+ "left" | "top" | "angle" | "fill" => {
if !property.value.is_empty() {
- result.push_str(&format!("data-left=\"{}\" ", property.value));
- }
- }
- "top" => {
- if !property.value.is_empty() {
- result.push_str(&format!("data-top=\"{}\" ", property.value));
- }
- }
- "angle" => {
- if !property.value.is_empty() {
- result.push_str(&format!("data-angle=\"{}\" ", property.value));
+ result.push_str(&format!("data-{}=\"{}\" ", property.name, property.value));
}
}
"width" => {
diff --git a/ts/lib/components/icons.ts b/ts/lib/components/icons.ts
index 33c6e04cb..ab07cbf17 100644
--- a/ts/lib/components/icons.ts
+++ b/ts/lib/components/icons.ts
@@ -59,6 +59,8 @@ import FormatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?component";
import formatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?url";
import FormatBold_ from "@mdi/svg/svg/format-bold.svg?component";
import formatBold_ from "@mdi/svg/svg/format-bold.svg?url";
+import FormatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?component";
+import formatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?url";
import HighlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?component";
import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url";
import TextColor_ from "@mdi/svg/svg/format-color-text.svg?component";
@@ -264,6 +266,7 @@ export const mdiEllipseOutline = { url: ellipseOutline_, component: EllipseOutli
export const mdiEye = { url: eye_, component: Eye_ };
export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ };
export const mdiFormatBold = { url: formatBold_, component: FormatBold_ };
+export const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ };
export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ };
export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ };
export const mdiGroup = { url: group_, component: Group_ };
diff --git a/ts/routes/image-occlusion/Toolbar.svelte b/ts/routes/image-occlusion/Toolbar.svelte
index 4ff9b5295..8775de936 100644
--- a/ts/routes/image-occlusion/Toolbar.svelte
+++ b/ts/routes/image-occlusion/Toolbar.svelte
@@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Callback } from "@tslib/typing";
import { singleCallback } from "@tslib/typing";
import { getContext, onDestroy, onMount } from "svelte";
- import type { Readable } from "svelte/store";
+ import { writable, type Readable } from "svelte/store";
import DropdownItem from "$lib/components/DropdownItem.svelte";
import Icon from "$lib/components/Icon.svelte";
@@ -33,7 +33,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
opacityStateStore,
} from "./store";
import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index";
- import { makeMaskTransparent } from "./tools/lib";
+ import { makeMaskTransparent, SHAPE_MASK_COLOR } from "./tools/lib";
import { enableSelectable, stopDraw } from "./tools/lib";
import {
alignTools,
@@ -42,7 +42,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
zoomTools,
} from "./tools/more-tools";
import { toggleTranslucentKeyCombination } from "./tools/shortcuts";
- import { tools } from "./tools/tool-buttons";
+ import { tools, type ActiveTool } from "./tools/tool-buttons";
import { drawCursor } from "./tools/tool-cursor";
import { removeUnfinishedPolygon } from "./tools/tool-polygon";
import { undoRedoTools, undoStack } from "./tools/tool-undo-redo";
@@ -54,10 +54,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onWheelDrag,
onWheelDragX,
} from "./tools/tool-zoom";
+ import { fillMask } from "./tools/tool-fill";
export let canvas;
export let iconSize;
- export let activeTool = "cursor";
+ export let activeTool: ActiveTool = "cursor";
let showAlignTools = false;
let leftPos = 82;
let maskOpacity = false;
@@ -72,6 +73,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const controlKey = "Control";
const shiftKey = "Shift";
let removeHandlers: Callback;
+ let colourRef: HTMLInputElement | undefined;
+ const colour = writable(SHAPE_MASK_COLOR);
function onClick(event: MouseEvent) {
const upperCanvas = document.querySelector(".upper-canvas");
@@ -168,7 +171,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
- const handleToolChanges = (newActiveTool: string) => {
+ const handleToolChanges = (newActiveTool: ActiveTool, clicked: boolean = false) => {
disableFunctions();
enableSelectable(canvas, true);
// remove unfinished polygon when switching to other tools
@@ -193,6 +196,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
handleToolChanges(activeTool);
});
break;
+ case "fill-mask":
+ if (clicked) {
+ colourRef?.click();
+ }
+ fillMask(canvas, colour);
+ break;
}
};
@@ -231,16 +240,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
-
+
+
+
($colour = e.currentTarget!.value)}
+/>
+
+
{#each tools as tool}
+ {@const active = activeTool == tool.id}
{
activeTool = tool.id;
- handleToolChanges(activeTool);
+ handleToolChanges(activeTool, true);
}}
>
@@ -250,7 +273,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
keyCombination={tool.shortcut}
on:action={() => {
activeTool = tool.id;
- handleToolChanges(activeTool);
+ handleToolChanges(activeTool, true);
}}
/>
{/if}
@@ -551,6 +574,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-bottom: 100px;
}
+ :global(.fill-mask svg) {
+ fill: var(--fill-tool-colour) !important;
+ stroke: black;
+ stroke-width: 1px;
+ }
+
:global([dir="rtl"] .tool-bar-container) {
left: unset;
right: 2px;
diff --git a/ts/routes/image-occlusion/review.ts b/ts/routes/image-occlusion/review.ts
index 6ddc5e3f2..ae225449e 100644
--- a/ts/routes/image-occlusion/review.ts
+++ b/ts/routes/image-occlusion/review.ts
@@ -217,7 +217,7 @@ function drawShapes(
context,
size,
shape,
- fill: properties.inActiveShapeColor,
+ fill: shape.fill ?? properties.inActiveShapeColor,
stroke: properties.inActiveBorder.color,
strokeWidth: properties.inActiveBorder.width,
});
@@ -358,7 +358,7 @@ function drawShape({
maxWidth + TEXT_PADDING,
totalHeight + TEXT_PADDING,
);
- ctx.fillStyle = "#000";
+ ctx.fillStyle = shape.fill ?? "#000";
for (const line of linePositions) {
ctx.fillText(line.text, line.x, line.y);
}
diff --git a/ts/routes/image-occlusion/shapes/from-cloze.ts b/ts/routes/image-occlusion/shapes/from-cloze.ts
index a59f8f965..0db496740 100644
--- a/ts/routes/image-occlusion/shapes/from-cloze.ts
+++ b/ts/routes/image-occlusion/shapes/from-cloze.ts
@@ -77,6 +77,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
scale: cloze.dataset.scale,
fs: cloze.dataset.fontSize,
angle: cloze.dataset.angle,
+ ...(cloze.dataset.fill == null ? {} : { fill: cloze.dataset.fill }),
};
return buildShape(type, props);
}
diff --git a/ts/routes/image-occlusion/shapes/text.ts b/ts/routes/image-occlusion/shapes/text.ts
index 53ef9e18b..4adb3abbb 100644
--- a/ts/routes/image-occlusion/shapes/text.ts
+++ b/ts/routes/image-occlusion/shapes/text.ts
@@ -19,11 +19,12 @@ export class Text extends Shape {
text = "",
scaleX = 1,
scaleY = 1,
+ fill = TEXT_COLOR,
fontSize,
...rest
}: ConstructorParams = {}) {
super(rest);
- this.fill = TEXT_COLOR;
+ this.fill = fill;
this.text = text;
this.scaleX = scaleX;
this.scaleY = scaleY;
@@ -38,6 +39,7 @@ export class Text extends Shape {
// scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio
scale: floatToDisplay(this.scaleX),
fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined,
+ ...(this.fill === TEXT_COLOR ? {} : { fill: this.fill }),
};
}
diff --git a/ts/routes/image-occlusion/tools/lib.ts b/ts/routes/image-occlusion/tools/lib.ts
index 740f135af..ab410cbad 100644
--- a/ts/routes/image-occlusion/tools/lib.ts
+++ b/ts/routes/image-occlusion/tools/lib.ts
@@ -105,6 +105,21 @@ export const unGroupShapes = (canvas: fabric.Canvas): void => {
redraw(canvas);
};
+/** Check for the target within a (potentially nested) group
+ * NOTE: assumes that masks do not overlap */
+export const findTargetInGroup = (group: fabric.Group, p: fabric.Point): fabric.Object | undefined => {
+ if (!group) { return; }
+ const point = fabric.util.transformPoint(p, fabric.util.invertTransform(group.calcOwnMatrix()));
+ for (const shape of group.getObjects()) {
+ if (shape instanceof fabric.Group) {
+ const ret = findTargetInGroup(shape, point);
+ if (ret) { return ret; }
+ } else if (shape.containsPoint(point)) {
+ return shape;
+ }
+ }
+};
+
const copyItem = (canvas: fabric.Canvas): void => {
const activeObject = canvas.getActiveObject();
if (!activeObject) {
diff --git a/ts/routes/image-occlusion/tools/shortcuts.ts b/ts/routes/image-occlusion/tools/shortcuts.ts
index afa156a85..f233e3222 100644
--- a/ts/routes/image-occlusion/tools/shortcuts.ts
+++ b/ts/routes/image-occlusion/tools/shortcuts.ts
@@ -6,6 +6,7 @@ export const rectangleKeyCombination = "R";
export const ellipseKeyCombination = "E";
export const polygonKeyCombination = "P";
export const textKeyCombination = "T";
+export const fillKeyCombination = "C";
export const magnifyKeyCombination = "M";
export const undoKeyCombination = "Control+Z";
export const redoKeyCombination = "Control+Y";
diff --git a/ts/routes/image-occlusion/tools/tool-buttons.ts b/ts/routes/image-occlusion/tools/tool-buttons.ts
index 8aa6b033b..266c1a5d8 100644
--- a/ts/routes/image-occlusion/tools/tool-buttons.ts
+++ b/ts/routes/image-occlusion/tools/tool-buttons.ts
@@ -6,6 +6,7 @@ import * as tr from "@generated/ftl";
import {
mdiCursorDefaultOutline,
mdiEllipseOutline,
+ mdiFormatColorFill,
mdiRectangleOutline,
mdiTextBox,
mdiVectorPolygonVariant,
@@ -14,6 +15,7 @@ import {
import {
cursorKeyCombination,
ellipseKeyCombination,
+ fillKeyCombination,
polygonKeyCombination,
rectangleKeyCombination,
textKeyCombination,
@@ -50,4 +52,13 @@ export const tools = [
tooltip: tr.editingImageOcclusionTextTool,
shortcut: textKeyCombination,
},
-];
+ {
+ id: "fill-mask",
+ icon: mdiFormatColorFill,
+ iconSizeMult: 1.4,
+ tooltip: tr.editingImageOcclusionFillTool,
+ shortcut: fillKeyCombination,
+ },
+] as const;
+
+export type ActiveTool = typeof tools[number]["id"];
diff --git a/ts/routes/image-occlusion/tools/tool-fill.ts b/ts/routes/image-occlusion/tools/tool-fill.ts
new file mode 100644
index 000000000..97c574313
--- /dev/null
+++ b/ts/routes/image-occlusion/tools/tool-fill.ts
@@ -0,0 +1,28 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import { fabric } from "fabric";
+
+import { get, type Readable } from "svelte/store";
+import { findTargetInGroup, stopDraw } from "./lib";
+import { undoStack } from "./tool-undo-redo";
+
+export const fillMask = (canvas: fabric.Canvas, colourStore: Readable): void => {
+ // remove selectable for shapes
+ canvas.discardActiveObject();
+ canvas.forEachObject(function(o) {
+ o.selectable = false;
+ });
+ canvas.selectionColor = "rgba(0, 0, 0, 0)";
+ stopDraw(canvas);
+
+ canvas.on("mouse:down", function(o) {
+ const target = o.target instanceof fabric.Group
+ ? findTargetInGroup(o.target, canvas.getPointer(o.e) as fabric.Point)
+ : o.target;
+ const colour = get(colourStore);
+ if (!target || target.fill === colour) { return; }
+ target.fill = colour;
+ undoStack.onObjectModified();
+ });
+};
diff --git a/ts/routes/image-occlusion/tools/tool-polygon.ts b/ts/routes/image-occlusion/tools/tool-polygon.ts
index bf6a11896..895cae523 100644
--- a/ts/routes/image-occlusion/tools/tool-polygon.ts
+++ b/ts/routes/image-occlusion/tools/tool-polygon.ts
@@ -223,7 +223,7 @@ export const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon):
});
const polygon1 = new fabric.Polygon(transformedPoints, {
- fill: SHAPE_MASK_COLOR,
+ fill: polygon.fill ?? SHAPE_MASK_COLOR,
objectCaching: false,
stroke: BORDER_COLOR,
strokeWidth: 1,