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-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?

View file

@ -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" => {

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 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_ };

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 { 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
});
</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}
{@const active = activeTool == tool.id}
<IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}"
{iconSize}
class="tool-icon-button {active ? 'active-tool' : ''} {tool.id}"
iconSize={iconSize * (tool["iconSizeMult"] ?? 1)}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
active={activeTool === tool.id}
{active}
on:click={() => {
activeTool = tool.id;
handleToolChanges(activeTool);
handleToolChanges(activeTool, true);
}}
>
<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}
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;

View file

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

View file

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

View file

@ -19,11 +19,12 @@ export class Text extends Shape {
text = "",
scaleX = 1,
scaleY = 1,
fill = TEXT_COLOR,
fontSize,
...rest
}: ConstructorParams<Text> = {}) {
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 }),
};
}

View file

@ -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) {

View file

@ -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";

View file

@ -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"];

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, {
fill: SHAPE_MASK_COLOR,
fill: polygon.fill ?? SHAPE_MASK_COLOR,
objectCaching: false,
stroke: BORDER_COLOR,
strokeWidth: 1,