Anki/ts/routes/image-occlusion/Toolbar.svelte
llama 174b199164
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
2025-06-04 11:45:34 +07:00

610 lines
18 KiB
Svelte

<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import { directionKey } from "@tslib/context-keys";
import { on } from "@tslib/events";
import { getPlatformString } from "@tslib/shortcuts";
import type { Callback } from "@tslib/typing";
import { singleCallback } from "@tslib/typing";
import { getContext, onDestroy, onMount } from "svelte";
import { writable, type Readable } from "svelte/store";
import DropdownItem from "$lib/components/DropdownItem.svelte";
import Icon from "$lib/components/Icon.svelte";
import IconButton from "$lib/components/IconButton.svelte";
import {
mdiEye,
mdiFormatAlignCenter,
mdiSquare,
mdiViewDashboard,
} from "$lib/components/icons";
import Popover from "$lib/components/Popover.svelte";
import Shortcut from "$lib/components/Shortcut.svelte";
import WithFloating from "$lib/components/WithFloating.svelte";
import {
hideAllGuessOne,
ioMaskEditorVisible,
textEditingState,
saveNeededStore,
opacityStateStore,
} from "./store";
import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index";
import { makeMaskTransparent, SHAPE_MASK_COLOR } from "./tools/lib";
import { enableSelectable, stopDraw } from "./tools/lib";
import {
alignTools,
deleteDuplicateTools,
groupUngroupTools,
zoomTools,
} from "./tools/more-tools";
import { toggleTranslucentKeyCombination } from "./tools/shortcuts";
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";
import {
disablePan,
disableZoom,
enablePan,
enableZoom,
onWheelDrag,
onWheelDragX,
} from "./tools/tool-zoom";
import { fillMask } from "./tools/tool-fill";
export let canvas;
export let iconSize;
export let activeTool: ActiveTool = "cursor";
let showAlignTools = false;
let leftPos = 82;
let maskOpacity = false;
let showFloating = false;
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
// handle zoom event when mouse scroll and ctrl key are hold for panzoom
let spaceClicked = false;
let controlClicked = false;
let shiftClicked = false;
let move = false;
const spaceKey = " ";
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");
if (event.target == upperCanvas) {
showAlignTools = false;
}
}
function onMousemove() {
if (spaceClicked || move) {
disableFunctions();
enablePan(canvas);
}
}
function onMouseup() {
if (spaceClicked) {
spaceClicked = false;
}
if (move) {
move = false;
}
}
function onKeyup(event: KeyboardEvent) {
if (
event.key === spaceKey ||
event.key === controlKey ||
event.key === shiftKey
) {
spaceClicked = false;
controlClicked = false;
shiftClicked = false;
move = false;
disableFunctions();
handleToolChanges(activeTool);
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === spaceKey) {
spaceClicked = true;
}
if (event.key === controlKey) {
controlClicked = true;
}
if (event.key === shiftKey) {
shiftClicked = true;
}
}
function onWheel(event: WheelEvent) {
// allow scroll in fields, when mask editor hidden
if (!$ioMaskEditorVisible) {
return;
}
if (event.ctrlKey) {
controlClicked = true;
}
if (event.shiftKey) {
shiftClicked = true;
}
event.preventDefault();
if (controlClicked) {
disableFunctions();
enableZoom(canvas);
return;
}
if (shiftClicked) {
onWheelDragX(canvas, event);
return;
}
onWheelDrag(canvas, event);
}
// initializes lastPosX and lastPosY because it is undefined in touchmove event
function onTouchstart(event: TouchEvent) {
const canvas = globalThis.canvas;
canvas.lastPosX = event.touches[0].clientX;
canvas.lastPosY = event.touches[0].clientY;
}
// initializes lastPosX and lastPosY because it is undefined before mousemove event
function onMousemoveDocument(event: MouseEvent) {
if (spaceClicked) {
canvas.lastPosX = event.clientX;
canvas.lastPosY = event.clientY;
}
}
const handleToolChanges = (newActiveTool: ActiveTool, clicked: boolean = false) => {
disableFunctions();
enableSelectable(canvas, true);
// remove unfinished polygon when switching to other tools
removeUnfinishedPolygon(canvas);
switch (newActiveTool) {
case "cursor":
drawCursor(canvas);
break;
case "draw-rectangle":
drawRectangle(canvas);
break;
case "draw-ellipse":
drawEllipse(canvas);
break;
case "draw-polygon":
drawPolygon(canvas);
break;
case "draw-text":
drawText(canvas, () => {
activeTool = "cursor";
handleToolChanges(activeTool);
});
break;
case "fill-mask":
if (clicked) {
colourRef?.click();
}
fillMask(canvas, colour);
break;
}
};
// handle tool changes after initialization
$: if (canvas) {
handleToolChanges(activeTool);
}
const disableFunctions = () => {
stopDraw(canvas);
disableZoom(canvas);
disablePan(canvas);
};
function changeOcclusionType(occlusionType: "all" | "one"): void {
$hideAllGuessOne = occlusionType === "all";
saveNeededStore.set(true);
}
onMount(() => {
opacityStateStore.set(maskOpacity);
removeHandlers = singleCallback(
on(document, "click", onClick),
on(window, "mousemove", onMousemove),
on(window, "mouseup", onMouseup),
on(window, "keyup", onKeyup),
on(window, "keydown", onKeydown),
on(window, "wheel", onWheel, { passive: false }),
on(document, "touchstart", onTouchstart),
on(document, "mousemove", onMousemoveDocument),
);
});
onDestroy(() => {
removeHandlers();
});
</script>
<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 {active ? 'active-tool' : ''} {tool.id}"
iconSize={iconSize * (tool["iconSizeMult"] ?? 1)}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
{active}
on:click={() => {
activeTool = tool.id;
handleToolChanges(activeTool, true);
}}
>
<Icon icon={tool.icon} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
activeTool = tool.id;
handleToolChanges(activeTool, true);
}}
/>
{/if}
{/each}
</div>
<div dir={$direction}>
<div class="top-tool-bar-container">
<WithFloating
show={showFloating}
closeOnInsideClick
inline
on:close={() => (showFloating = false)}
>
<IconButton
class="top-tool-icon-button border-radius dropdown-tool-mode"
slot="reference"
tooltip={tr.editingImageOcclusionMode()}
{iconSize}
on:click={() => (showFloating = !showFloating)}
>
<Icon icon={$hideAllGuessOne ? mdiViewDashboard : mdiSquare} />
</IconButton>
<Popover slot="floating">
<DropdownItem
active={$hideAllGuessOne}
on:click={() => changeOcclusionType("all")}
>
<span>{tr.notetypesHideAllGuessOne()}</span>
</DropdownItem>
<DropdownItem
active={!$hideAllGuessOne}
on:click={() => changeOcclusionType("one")}
>
<span>{tr.notetypesHideOneGuessOne()}</span>
</DropdownItem>
</Popover>
</WithFloating>
<!-- undo & redo tools -->
<div class="undo-redo-button">
{#each undoRedoTools as tool}
<IconButton
class="top-tool-icon-button {tool.name === 'undo'
? 'left-border-radius'
: 'right-border-radius'}"
{iconSize}
on:click={() => {
tool.action();
handleToolChanges(activeTool);
}}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
disabled={tool.name === "undo"
? !$undoStack.undoable
: !$undoStack.redoable}
>
<Icon icon={tool.icon} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut keyCombination={tool.shortcut} on:action={tool.action} />
{/if}
{/each}
</div>
<!-- zoom tools -->
<div class="tool-button-container">
{#each zoomTools as tool}
<IconButton
class="top-tool-icon-button {tool.name === 'zoomOut'
? 'left-border-radius'
: ''} {tool.name === 'zoomReset' ? 'right-border-radius' : ''}"
{iconSize}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
on:click={() => {
tool.action(canvas);
}}
>
<Icon icon={tool.icon} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(canvas);
}}
/>
{/if}
{/each}
</div>
<div class="tool-button-container">
<!-- opacity tools -->
<IconButton
class="top-tool-icon-button left-border-radius"
{iconSize}
tooltip="{tr.editingImageOcclusionToggleTranslucent()} ({getPlatformString(
toggleTranslucentKeyCombination,
)})"
on:click={() => {
maskOpacity = !maskOpacity;
makeMaskTransparent(canvas, maskOpacity);
}}
>
<Icon icon={mdiEye} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut
keyCombination={toggleTranslucentKeyCombination}
on:action={() => {
maskOpacity = !maskOpacity;
makeMaskTransparent(canvas, maskOpacity);
}}
/>
{/if}
<!-- cursor tools -->
{#each deleteDuplicateTools as tool}
<IconButton
class="top-tool-icon-button {tool.name === 'duplicate'
? 'right-border-radius'
: ''}"
{iconSize}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
on:click={() => {
tool.action(canvas);
undoStack.onObjectModified();
}}
>
<Icon icon={tool.icon} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(canvas);
saveNeededStore.set(true);
}}
/>
{/if}
{/each}
</div>
<div class="tool-button-container">
<!-- group & ungroup tools -->
{#each groupUngroupTools as tool}
<IconButton
class="top-tool-icon-button {tool.name === 'group'
? 'left-border-radius'
: ''}"
{iconSize}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
on:click={() => {
tool.action(canvas);
undoStack.onObjectModified();
}}
>
<Icon icon={tool.icon} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(canvas);
saveNeededStore.set(true);
}}
/>
{/if}
{/each}
<IconButton
class="top-tool-icon-button dropdown-tool right-border-radius"
{iconSize}
tooltip={tr.editingImageOcclusionAlignment()}
on:click={(e) => {
showAlignTools = !showAlignTools;
leftPos = e.pageX - 100;
}}
>
<Icon icon={mdiFormatAlignCenter} />
</IconButton>
</div>
</div>
<div class:show={showAlignTools} class="dropdown-content" style="left:{leftPos}px;">
{#each alignTools as alignTool}
<IconButton
class="top-tool-icon-button"
{iconSize}
tooltip="{alignTool.tooltip()} ({getPlatformString(
alignTool.shortcut,
)})"
on:click={() => {
alignTool.action(canvas);
undoStack.onObjectModified();
}}
>
<Icon icon={alignTool.icon} />
</IconButton>
{#if $ioMaskEditorVisible && !$textEditingState}
<Shortcut
keyCombination={alignTool.shortcut}
on:action={() => {
alignTool.action(canvas);
}}
/>
{/if}
{/each}
</div>
</div>
<style>
.top-tool-bar-container {
display: flex;
overflow-y: scroll;
z-index: 99;
margin-left: 106px;
margin-top: 2px;
}
:global([dir="rtl"] .top-tool-bar-container) {
margin-left: unset;
margin-right: 28px;
}
.undo-redo-button {
margin-right: 2px;
display: flex;
}
.tool-button-container {
margin-left: 2px;
margin-right: 2px;
display: flex;
}
:global(.left-border-radius) {
border-radius: 5px 0 0 5px !important;
}
:global([dir="rtl"] .left-border-radius) {
border-radius: 0 5px 5px 0 !important;
}
:global(.right-border-radius) {
border-radius: 0 5px 5px 0 !important;
}
:global([dir="rtl"] .right-border-radius) {
border-radius: 5px 0 0 5px !important;
}
:global(.border-radius) {
border-radius: 5px !important;
}
:global(.top-tool-icon-button) {
border: unset;
display: inline;
width: 32px;
height: 32px;
margin: unset;
padding: 6px !important;
font-size: 16px !important;
}
:global(.top-tool-icon-button:active) {
background: var(--highlight-bg) !important;
}
.dropdown-content {
display: none;
position: absolute;
z-index: 100;
top: 40px;
margin-top: 1px;
}
.show {
display: table;
}
::-webkit-scrollbar {
width: 0.1em !important;
height: 0.1em !important;
}
.tool-bar-container {
position: fixed;
top: 42px;
left: 2px;
height: 100%;
border-right: 1px solid var(--border);
overflow-y: auto;
width: 32px;
z-index: 99;
background: var(--canvas-elevated);
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;
}
:global(.tool-icon-button) {
border: unset;
display: block;
width: 32px;
height: 32px;
margin: unset;
padding: 6px !important;
}
:global(.active-tool) {
color: white !important;
background: var(--button-primary-bg) !important;
}
:global(.icon-border-radius) {
border-radius: 5px !important;
}
:global(.dropdown-tool-mode) {
height: 38px !important;
display: inline;
}
</style>