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
This commit is contained in:
llama 2025-06-04 12:45:34 +08:00 committed by GitHub
parent 27c1ed1899
commit 174b199164
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 109 additions and 28 deletions

View file

@ -96,6 +96,7 @@ editing-image-occlusion-rectangle-tool = Rectangle
editing-image-occlusion-ellipse-tool = Ellipse editing-image-occlusion-ellipse-tool = Ellipse
editing-image-occlusion-polygon-tool = Polygon editing-image-occlusion-polygon-tool = Polygon
editing-image-occlusion-text-tool = Text 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-toggle-mask-editor = Toggle Mask Editor
editing-image-occlusion-reset = Reset Image Occlusion editing-image-occlusion-reset = Reset Image Occlusion
editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion? editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion?

View file

@ -64,19 +64,9 @@ pub fn get_image_cloze_data(text: &str) -> String {
} }
for property in occlusion.properties { for property in occlusion.properties {
match property.name.as_str() { match property.name.as_str() {
"left" => { "left" | "top" | "angle" | "fill" => {
if !property.value.is_empty() { if !property.value.is_empty() {
result.push_str(&format!("data-left=\"{}\" ", property.value)); result.push_str(&format!("data-{}=\"{}\" ", property.name, 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));
} }
} }
"width" => { "width" => {

View file

@ -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 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?component";
import formatBold_ from "@mdi/svg/svg/format-bold.svg?url"; 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?component";
import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url"; import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url";
import TextColor_ from "@mdi/svg/svg/format-color-text.svg?component"; 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 mdiEye = { url: eye_, component: Eye_ };
export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ }; export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ };
export const mdiFormatBold = { url: formatBold_, component: FormatBold_ }; export const mdiFormatBold = { url: formatBold_, component: FormatBold_ };
export const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ };
export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ }; export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ };
export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ }; export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ };
export const mdiGroup = { url: group_, component: Group_ }; export const mdiGroup = { url: group_, component: Group_ };

View file

@ -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 type { Callback } from "@tslib/typing";
import { singleCallback } from "@tslib/typing"; import { singleCallback } from "@tslib/typing";
import { getContext, onDestroy, onMount } from "svelte"; 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 DropdownItem from "$lib/components/DropdownItem.svelte";
import Icon from "$lib/components/Icon.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, opacityStateStore,
} from "./store"; } from "./store";
import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index"; 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 { enableSelectable, stopDraw } from "./tools/lib";
import { import {
alignTools, alignTools,
@ -42,7 +42,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
zoomTools, zoomTools,
} from "./tools/more-tools"; } from "./tools/more-tools";
import { toggleTranslucentKeyCombination } from "./tools/shortcuts"; 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 { drawCursor } from "./tools/tool-cursor";
import { removeUnfinishedPolygon } from "./tools/tool-polygon"; import { removeUnfinishedPolygon } from "./tools/tool-polygon";
import { undoRedoTools, undoStack } from "./tools/tool-undo-redo"; 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, onWheelDrag,
onWheelDragX, onWheelDragX,
} from "./tools/tool-zoom"; } from "./tools/tool-zoom";
import { fillMask } from "./tools/tool-fill";
export let canvas; export let canvas;
export let iconSize; export let iconSize;
export let activeTool = "cursor"; export let activeTool: ActiveTool = "cursor";
let showAlignTools = false; let showAlignTools = false;
let leftPos = 82; let leftPos = 82;
let maskOpacity = false; 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 controlKey = "Control";
const shiftKey = "Shift"; const shiftKey = "Shift";
let removeHandlers: Callback; let removeHandlers: Callback;
let colourRef: HTMLInputElement | undefined;
const colour = writable(SHAPE_MASK_COLOR);
function onClick(event: MouseEvent) { function onClick(event: MouseEvent) {
const upperCanvas = document.querySelector(".upper-canvas"); 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(); disableFunctions();
enableSelectable(canvas, true); enableSelectable(canvas, true);
// remove unfinished polygon when switching to other tools // 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); handleToolChanges(activeTool);
}); });
break; 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
}); });
</script> </script>
<div class="tool-bar-container"> <datalist id="colour-palette">
<option value={SHAPE_MASK_COLOR}></option>
</datalist>
<input
type="color"
bind:this={colourRef}
style:display="none"
list="colour-palette"
value={SHAPE_MASK_COLOR}
on:input={(e) => ($colour = e.currentTarget!.value)}
/>
<div class="tool-bar-container" style:--fill-tool-colour={$colour}>
{#each tools as tool} {#each tools as tool}
{@const active = activeTool == tool.id}
<IconButton <IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}" class="tool-icon-button {active ? 'active-tool' : ''} {tool.id}"
{iconSize} iconSize={iconSize * (tool["iconSizeMult"] ?? 1)}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})" tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
active={activeTool === tool.id} {active}
on:click={() => { on:click={() => {
activeTool = tool.id; activeTool = tool.id;
handleToolChanges(activeTool); handleToolChanges(activeTool, true);
}} }}
> >
<Icon icon={tool.icon} /> <Icon icon={tool.icon} />
@ -250,7 +273,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
keyCombination={tool.shortcut} keyCombination={tool.shortcut}
on:action={() => { on:action={() => {
activeTool = tool.id; activeTool = tool.id;
handleToolChanges(activeTool); handleToolChanges(activeTool, true);
}} }}
/> />
{/if} {/if}
@ -551,6 +574,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-bottom: 100px; padding-bottom: 100px;
} }
:global(.fill-mask svg) {
fill: var(--fill-tool-colour) !important;
stroke: black;
stroke-width: 1px;
}
:global([dir="rtl"] .tool-bar-container) { :global([dir="rtl"] .tool-bar-container) {
left: unset; left: unset;
right: 2px; right: 2px;

View file

@ -217,7 +217,7 @@ function drawShapes(
context, context,
size, size,
shape, shape,
fill: properties.inActiveShapeColor, fill: shape.fill ?? properties.inActiveShapeColor,
stroke: properties.inActiveBorder.color, stroke: properties.inActiveBorder.color,
strokeWidth: properties.inActiveBorder.width, strokeWidth: properties.inActiveBorder.width,
}); });
@ -358,7 +358,7 @@ function drawShape({
maxWidth + TEXT_PADDING, maxWidth + TEXT_PADDING,
totalHeight + TEXT_PADDING, totalHeight + TEXT_PADDING,
); );
ctx.fillStyle = "#000"; ctx.fillStyle = shape.fill ?? "#000";
for (const line of linePositions) { for (const line of linePositions) {
ctx.fillText(line.text, line.x, line.y); ctx.fillText(line.text, line.x, line.y);
} }

View file

@ -77,6 +77,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
scale: cloze.dataset.scale, scale: cloze.dataset.scale,
fs: cloze.dataset.fontSize, fs: cloze.dataset.fontSize,
angle: cloze.dataset.angle, angle: cloze.dataset.angle,
...(cloze.dataset.fill == null ? {} : { fill: cloze.dataset.fill }),
}; };
return buildShape(type, props); return buildShape(type, props);
} }

View file

@ -19,11 +19,12 @@ export class Text extends Shape {
text = "", text = "",
scaleX = 1, scaleX = 1,
scaleY = 1, scaleY = 1,
fill = TEXT_COLOR,
fontSize, fontSize,
...rest ...rest
}: ConstructorParams<Text> = {}) { }: ConstructorParams<Text> = {}) {
super(rest); super(rest);
this.fill = TEXT_COLOR; this.fill = fill;
this.text = text; this.text = text;
this.scaleX = scaleX; this.scaleX = scaleX;
this.scaleY = scaleY; 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 // scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio
scale: floatToDisplay(this.scaleX), scale: floatToDisplay(this.scaleX),
fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined, fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined,
...(this.fill === TEXT_COLOR ? {} : { fill: this.fill }),
}; };
} }

View file

@ -105,6 +105,21 @@ export const unGroupShapes = (canvas: fabric.Canvas): void => {
redraw(canvas); 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 copyItem = (canvas: fabric.Canvas): void => {
const activeObject = canvas.getActiveObject(); const activeObject = canvas.getActiveObject();
if (!activeObject) { if (!activeObject) {

View file

@ -6,6 +6,7 @@ export const rectangleKeyCombination = "R";
export const ellipseKeyCombination = "E"; export const ellipseKeyCombination = "E";
export const polygonKeyCombination = "P"; export const polygonKeyCombination = "P";
export const textKeyCombination = "T"; export const textKeyCombination = "T";
export const fillKeyCombination = "C";
export const magnifyKeyCombination = "M"; export const magnifyKeyCombination = "M";
export const undoKeyCombination = "Control+Z"; export const undoKeyCombination = "Control+Z";
export const redoKeyCombination = "Control+Y"; export const redoKeyCombination = "Control+Y";

View file

@ -6,6 +6,7 @@ import * as tr from "@generated/ftl";
import { import {
mdiCursorDefaultOutline, mdiCursorDefaultOutline,
mdiEllipseOutline, mdiEllipseOutline,
mdiFormatColorFill,
mdiRectangleOutline, mdiRectangleOutline,
mdiTextBox, mdiTextBox,
mdiVectorPolygonVariant, mdiVectorPolygonVariant,
@ -14,6 +15,7 @@ import {
import { import {
cursorKeyCombination, cursorKeyCombination,
ellipseKeyCombination, ellipseKeyCombination,
fillKeyCombination,
polygonKeyCombination, polygonKeyCombination,
rectangleKeyCombination, rectangleKeyCombination,
textKeyCombination, textKeyCombination,
@ -50,4 +52,13 @@ export const tools = [
tooltip: tr.editingImageOcclusionTextTool, tooltip: tr.editingImageOcclusionTextTool,
shortcut: textKeyCombination, shortcut: textKeyCombination,
}, },
]; {
id: "fill-mask",
icon: mdiFormatColorFill,
iconSizeMult: 1.4,
tooltip: tr.editingImageOcclusionFillTool,
shortcut: fillKeyCombination,
},
] as const;
export type ActiveTool = typeof tools[number]["id"];

View file

@ -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<string>): 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();
});
};

View file

@ -223,7 +223,7 @@ export const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon):
}); });
const polygon1 = new fabric.Polygon(transformedPoints, { const polygon1 = new fabric.Polygon(transformedPoints, {
fill: SHAPE_MASK_COLOR, fill: polygon.fill ?? SHAPE_MASK_COLOR,
objectCaching: false, objectCaching: false,
stroke: BORDER_COLOR, stroke: BORDER_COLOR,
strokeWidth: 1, strokeWidth: 1,