From 174b1991644d829c3d78d0155979ceaeda9c471e Mon Sep 17 00:00:00 2001 From: llama Date: Wed, 4 Jun 2025 12:45:34 +0800 Subject: [PATCH] Add IO mask colour fill tool (#4048) * add fill tool * add fill tool logic * open colour picker on fill tool activation * refactor/add fill attr to io clozes * fill masks in editor * fill text and inactive masks in reviewer * fix lint * remove debug option --- ftl/core/editing.ftl | 1 + rslib/src/image_occlusion/imageocclusion.rs | 14 +---- ts/lib/components/icons.ts | 3 ++ ts/routes/image-occlusion/Toolbar.svelte | 51 +++++++++++++++---- ts/routes/image-occlusion/review.ts | 4 +- .../image-occlusion/shapes/from-cloze.ts | 1 + ts/routes/image-occlusion/shapes/text.ts | 4 +- ts/routes/image-occlusion/tools/lib.ts | 15 ++++++ ts/routes/image-occlusion/tools/shortcuts.ts | 1 + .../image-occlusion/tools/tool-buttons.ts | 13 ++++- ts/routes/image-occlusion/tools/tool-fill.ts | 28 ++++++++++ .../image-occlusion/tools/tool-polygon.ts | 2 +- 12 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 ts/routes/image-occlusion/tools/tool-fill.ts 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,