fixes: remove unfinished shapes, remove selectable and make shapes remain inside canvas (#2809)

* remove unfinished polygon and remove selectable for shapes in polygon mode

* make group and polygon position remain inside canvas area

* click through transparent area in grouped object

* add some shortcuts for basic usages

* tools button icon in center & switch mode border

* fix load svg image

* basic rtl support, panzoom have issues in rtl mode

* better zoom option both in ltr and rtl

* handle zoom event in mask editor

* add h button to handle toggle mask

* add more mime type

* use capital M (shift+m) for toggle mask

* allow io shortcuts in mask editor only

* make other shapes also remain in canvas bound area

* better zoom implementation, zoom from center
add zoom to resize event listener

* add a border to corner to handle blend of control

* add refresh button to go to  selection menu

* add tooltip to shortcuts and also add shortcut for other tools

* make opacity remain in same state when toggled on

* opacity for group/ungroup objects

* update shortcuts implementation
This commit is contained in:
Mani 2023-11-24 12:06:40 +08:00 committed by GitHub
parent 24e5912448
commit be1f889211
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 545 additions and 170 deletions

View file

@ -93,6 +93,8 @@ editing-image-occlusion-ellipse-tool = Ellipse
editing-image-occlusion-polygon-tool = Polygon
editing-image-occlusion-text-tool = Text
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?
## You don't need to translate these strings, as they will be replaced with different ones soon.

View file

@ -74,6 +74,7 @@ message GetImageOcclusionNoteResponse {
string header = 3;
string back_extra = 4;
repeated string tags = 5;
string image_file_name = 6;
}
oneof value {

View file

@ -110,6 +110,12 @@ impl Collection {
if self.is_image_file(&final_path)? {
cloze_note.image_data = read_file(&final_path)?;
cloze_note.image_file_name = final_path
.file_name()
.or_not_found("expected filename")?
.to_str()
.unwrap()
.to_string();
}
Ok(cloze_note)

View file

@ -388,13 +388,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ImageOcclusionPicker from "image-occlusion/ImageOcclusionPicker.svelte";
import type { IOMode } from "image-occlusion/lib";
import { exportShapesToClozeDeletions } from "image-occlusion/shapes/to-cloze";
import { hideAllGuessOne, ioMaskEditorVisible } from "image-occlusion/store";
import {
hideAllGuessOne,
ioImageLoadedStore,
ioMaskEditorVisible,
} from "image-occlusion/store";
import { mathjaxConfig } from "../editable/mathjax-element";
import CollapseLabel from "./CollapseLabel.svelte";
import * as oldEditorAdapter from "./old-editor-adapter";
let isIOImageLoaded = false;
$: isIOImageLoaded = false;
$: ioImageLoadedStore.set(isIOImageLoaded);
let imageOcclusionMode: IOMode | undefined;
let ioFields = new ImageOcclusionFieldIndexes({});
@ -456,6 +461,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function resetIOImageLoaded() {
isIOImageLoaded = false;
globalThis.canvas.clear();
globalThis.canvas = undefined;
const page = document.querySelector(".image-occlusion");
if (page) {
page.remove();
@ -791,6 +797,7 @@ the AddCards dialog) should be implemented in the user of this component.
}
:global(.top-tool-bar-container .icon-button) {
height: 36px !important;
line-height: 1;
}
:global(.image-occlusion .tool-bar-container) {
top: unset !important;

View file

@ -7,14 +7,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ButtonGroup from "components/ButtonGroup.svelte";
import DynamicallySlottable from "components/DynamicallySlottable.svelte";
import IconButton from "components/IconButton.svelte";
import { ioMaskEditorVisible } from "image-occlusion/store";
import { ioImageLoadedStore, ioMaskEditorVisible } from "image-occlusion/store";
import ButtonGroupItem, {
createProps,
setSlotHostContext,
updatePropsList,
} from "../../components/ButtonGroupItem.svelte";
import { mdiViewDashboard } from "./icons";
import { mdiTableRefresh, mdiViewDashboard } from "./icons";
export let api = {};
</script>
@ -39,6 +39,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{@html mdiViewDashboard}
</IconButton>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
id="io-reset-btn"
disabled={!$ioImageLoadedStore}
on:click={() => {
if (confirm(tr.editingImageOcclusionConfirmReset())) {
globalThis.resetIOImageLoaded();
} else {
return;
}
}}
tooltip={tr.editingImageOcclusionReset()}
>
{@html mdiTableRefresh}
</IconButton>
</ButtonGroupItem>
</DynamicallySlottable>
</ButtonGroup>

View file

@ -12,6 +12,7 @@ export { default as superscriptIcon } from "@mdi/svg/svg/format-superscript.svg"
export { default as functionIcon } from "@mdi/svg/svg/function-variant.svg";
export { default as paperclipIcon } from "@mdi/svg/svg/paperclip.svg";
export { default as mdiRefresh } from "@mdi/svg/svg/refresh.svg";
export { default as mdiTableRefresh } from "@mdi/svg/svg/table-refresh.svg";
export { default as mdiViewDashboard } from "@mdi/svg/svg/view-dashboard.svg";
export { default as eraserIcon } from "bootstrap-icons/icons/eraser.svg";
export { default as justifyFullIcon } from "bootstrap-icons/icons/justify.svg";

View file

@ -26,6 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} from "./mask-editor";
import Toolbar from "./Toolbar.svelte";
import { MaskEditorAPI } from "./tools/api";
import { setCenterXForZoom } from "./tools/lib";
export let mode: IOMode;
const iconSize = 80;
@ -57,8 +58,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
minZoom: 0.1,
zoomDoubleClickSpeed: 1,
smoothScroll: false,
transformOrigin: { x: 0.5, y: 0.5 },
});
instance.pause();
globalThis.panzoom = instance;
if (mode.kind == "add") {
setupMaskEditor(mode.imagePath, instance, onChange, onImageLoaded).then(
@ -78,12 +81,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onMount(() => {
window.addEventListener("resize", () => {
setCanvasZoomRatio(canvas, instance);
setCenterXForZoom(canvas);
});
});
onDestroy(() => {
window.removeEventListener("resize", () => {
setCanvasZoomRatio(canvas, instance);
setCenterXForZoom(canvas);
});
});
</script>
@ -109,10 +114,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-bottom: 100px;
}
:global([dir="rtl"]) .editor-main {
left: 2px;
right: 36px;
}
.editor-container {
width: 100%;
height: 100%;
position: relative;
direction: ltr;
}
#image {

View file

@ -3,15 +3,20 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { directionKey } from "@tslib/context-keys";
import * as tr from "@tslib/ftl";
import { getPlatformString } from "@tslib/shortcuts";
import DropdownItem from "components/DropdownItem.svelte";
import IconButton from "components/IconButton.svelte";
import Popover from "components/Popover.svelte";
import Shortcut from "components/Shortcut.svelte";
import WithFloating from "components/WithFloating.svelte";
import { getContext, onMount } from "svelte";
import type { Readable } from "svelte/store";
import { mdiEye, mdiFormatAlignCenter, mdiSquare, mdiViewDashboard } from "./icons";
import { emitChangeSignal } from "./MaskEditor.svelte";
import { hideAllGuessOne } from "./store";
import { hideAllGuessOne, ioMaskEditorVisible } from "./store";
import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index";
import { makeMaskTransparent } from "./tools/lib";
import { enableSelectable, stopDraw } from "./tools/lib";
@ -21,7 +26,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
groupUngroupTools,
zoomTools,
} from "./tools/more-tools";
import { toggleTranslucentKeyCombination } from "./tools/shortcuts";
import { tools } from "./tools/tool-buttons";
import { removeUnfinishedPolygon } from "./tools/tool-polygon";
import { undoRedoTools, undoStack } from "./tools/tool-undo-redo";
export let canvas;
@ -32,6 +39,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let leftPos = 82;
let maksOpacity = false;
let showFloating = false;
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
document.addEventListener("click", (event) => {
const upperCanvas = document.querySelector(".upper-canvas");
@ -40,10 +48,77 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
});
// handle zoom event when mouse scroll and ctrl key are hold for panzoom
let clicked = false;
let dbclicked = false;
let move = false;
let wheel = false;
onMount(() => {
window.addEventListener("mousedown", (event) => {
if (event.ctrlKey) {
clicked = true;
}
});
window.addEventListener("mouseup", (event) => {
if (event.ctrlKey) {
clicked = false;
}
});
window.addEventListener("mousemove", (event) => {
if (event.ctrlKey) {
move = true;
}
});
window.addEventListener("wheel", (event) => {
if (event.ctrlKey) {
wheel = true;
}
});
window.addEventListener("dblclick", (event) => {
if (event.ctrlKey) {
dbclicked = true;
}
});
window.addEventListener("keyup", (event) => {
if (event.key == "Control") {
clicked = false;
move = false;
wheel = false;
dbclicked = false;
}
});
window.addEventListener("keydown", (event) => {
if (event.key == "Control") {
clicked = false;
move = false;
wheel = false;
dbclicked = false;
}
});
window.addEventListener("keydown", (event) => {
if (event.key == "Control" && activeTool != "magnify") {
instance.resume();
}
});
window.addEventListener("keyup", (event) => {
if (event.key == "Control" && activeTool != "magnify") {
instance.pause();
}
});
window.addEventListener("wheel", () => {
if (clicked && move && wheel && !dbclicked) {
enableMagnify();
}
});
});
// handle tool changes after initialization
$: if (instance && canvas) {
disableFunctions();
enableSelectable(canvas, true);
// remove unfinished polygon when switching to other tools
removeUnfinishedPolygon(canvas);
switch (activeTool) {
case "magnify":
@ -77,6 +152,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$hideAllGuessOne = occlusionType === "all";
emitChangeSignal();
}
const enableMagnify = () => {
disableFunctions();
enableSelectable(canvas, false);
instance.resume();
activeTool = "magnify";
};
</script>
<div class="tool-bar-container">
@ -84,7 +165,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}"
{iconSize}
tooltip={tool.tooltip()}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
active={activeTool === tool.id}
on:click={() => {
activeTool = tool.id;
@ -92,157 +173,215 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{@html tool.icon}
</IconButton>
{#if $ioMaskEditorVisible}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
activeTool = tool.id;
}}
/>
{/if}
{/each}
</div>
<div class="top-tool-bar-container">
<WithFloating
show={showFloating}
closeOnInsideClick
inline
on:close={() => (showFloating = false)}
>
<IconButton
class="top-tool-icon-button right-border-radius dropdown-tool-mode"
slot="reference"
tooltip={tr.editingImageOcclusionMode()}
{iconSize}
on:click={() => (showFloating = !showFloating)}
<div dir={$direction}>
<div class="top-tool-bar-container">
<WithFloating
show={showFloating}
closeOnInsideClick
inline
on:close={() => (showFloating = false)}
>
{@html $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'}"
class="top-tool-icon-button border-radius dropdown-tool-mode"
slot="reference"
tooltip={tr.editingImageOcclusionMode()}
{iconSize}
on:click={tool.action}
tooltip={tool.tooltip()}
disabled={tool.name === "undo"
? !$undoStack.undoable
: !$undoStack.redoable}
on:click={() => (showFloating = !showFloating)}
>
{@html tool.icon}
{@html $hideAllGuessOne ? mdiViewDashboard : mdiSquare}
</IconButton>
{/each}
</div>
<!-- zoom tools -->
<div class="tool-button-container">
{#each zoomTools as tool}
<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}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
disabled={tool.name === "undo"
? !$undoStack.undoable
: !$undoStack.redoable}
>
{@html tool.icon}
</IconButton>
{#if $ioMaskEditorVisible}
<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(instance);
}}
>
{@html tool.icon}
</IconButton>
{#if $ioMaskEditorVisible}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(instance);
}}
/>
{/if}
{/each}
</div>
<div class="tool-button-container">
<!-- opacity tools -->
<IconButton
class="top-tool-icon-button {tool.name === 'zoomOut'
? 'left-border-radius'
: ''} {tool.name === 'zoomReset' ? 'right-border-radius' : ''}"
class="top-tool-icon-button left-border-radius"
{iconSize}
tooltip={tool.tooltip()}
tooltip={tr.editingImageOcclusionToggleTranslucent()}
on:click={() => {
tool.action(instance);
maksOpacity = !maksOpacity;
makeMaskTransparent(canvas, maksOpacity);
}}
>
{@html tool.icon}
{@html mdiEye}
</IconButton>
{/each}
</div>
{#if $ioMaskEditorVisible}
<Shortcut
keyCombination={toggleTranslucentKeyCombination}
on:action={() => {
maksOpacity = !maksOpacity;
makeMaskTransparent(canvas, maksOpacity);
}}
/>
{/if}
<div class="tool-button-container">
<!-- opacity tools -->
<IconButton
class="top-tool-icon-button left-border-radius"
{iconSize}
tooltip={tr.editingImageOcclusionToggleTranslucent()}
on:click={() => {
maksOpacity = !maksOpacity;
makeMaskTransparent(canvas, maksOpacity);
}}
>
{@html mdiEye}
</IconButton>
<!-- 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);
}}
>
{@html tool.icon}
</IconButton>
{#if $ioMaskEditorVisible}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(canvas);
emitChangeSignal();
}}
/>
{/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);
emitChangeSignal();
}}
>
{@html tool.icon}
</IconButton>
{#if $ioMaskEditorVisible}
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(canvas);
emitChangeSignal();
}}
/>
{/if}
{/each}
<!-- cursor tools -->
{#each deleteDuplicateTools as tool}
<IconButton
class="top-tool-icon-button {tool.name === 'duplicate'
? 'right-border-radius'
: ''}"
class="top-tool-icon-button dropdown-tool right-border-radius"
{iconSize}
tooltip={tool.tooltip()}
on:click={() => {
tool.action(canvas);
tooltip={tr.editingImageOcclusionAlignment()}
on:click={(e) => {
showAlignTools = !showAlignTools;
leftPos = e.pageX - 100;
}}
>
{@html tool.icon}
{@html mdiFormatAlignCenter}
</IconButton>
{/each}
</div>
</div>
<div class="tool-button-container">
<!-- group & ungroup tools -->
{#each groupUngroupTools as tool}
<div class:show={showAlignTools} class="dropdown-content" style="left:{leftPos}px;">
{#each alignTools as alignTool}
<IconButton
class="top-tool-icon-button {tool.name === 'group'
? 'left-border-radius'
: ''}"
class="top-tool-icon-button"
{iconSize}
tooltip={tool.tooltip()}
tooltip="{alignTool.tooltip()} ({getPlatformString(
alignTool.shortcut,
)})"
on:click={() => {
tool.action(canvas);
emitChangeSignal();
alignTool.action(canvas);
}}
>
{@html tool.icon}
{@html alignTool.icon}
</IconButton>
{#if $ioMaskEditorVisible}
<Shortcut
keyCombination={alignTool.shortcut}
on:action={() => {
alignTool.action(canvas);
}}
/>
{/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;
}}
>
{@html 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()}
on:click={() => {
alignTool.action(canvas);
}}
>
{@html alignTool.icon}
</IconButton>
{/each}
</div>
<style>
.top-tool-bar-container {
display: flex;
@ -252,6 +391,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
margin-top: 2px;
}
:global([dir="rtl"] .top-tool-bar-container) {
margin-left: unset;
margin-right: 28px;
}
.undo-redo-button {
margin-right: 2px;
display: flex;
@ -267,10 +411,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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;
@ -290,7 +446,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
.show {
display: block;
display: flex;
}
::-webkit-scrollbar {
@ -311,6 +467,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-bottom: 100px;
}
:global([dir="rtl"] .tool-bar-container) {
left: unset;
right: 2px;
}
:global(.tool-icon-button) {
border: unset;
display: block;

View file

@ -12,7 +12,13 @@ import { optimumCssSizeForCanvas } from "./canvas-scale";
import { notesDataStore, tagsWritable, zoomResetValue } from "./store";
import Toast from "./Toast.svelte";
import { addShapesToCanvasFromCloze } from "./tools/add-from-cloze";
import { enableSelectable, moveShapeToCanvasBoundaries } from "./tools/lib";
import {
enableSelectable,
makeShapeRemainInCanvas,
moveShapeToCanvasBoundaries,
setCenterXForZoom,
zoomReset,
} from "./tools/lib";
import { modifiedPolygon } from "./tools/tool-polygon";
import { undoStack } from "./tools/tool-undo-redo";
import type { Size } from "./types";
@ -33,7 +39,7 @@ export const setupMaskEditor = async (
// get image width and height
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(imageData.data!);
image.src = getImageData(imageData.data!, path);
image.onload = function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
canvas.setWidth(size.width);
@ -73,7 +79,7 @@ export const setupMaskEditorForEdit = async (
// get image width and height
const image = document.getElementById("image") as HTMLImageElement;
image.style.visibility = "hidden";
image.src = getImageData(clozeNote.imageData!);
image.src = getImageData(clozeNote.imageData!, clozeNote.imageFileName!);
image.onload = function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
canvas.setWidth(size.width);
@ -100,12 +106,19 @@ function initCanvas(onChange: () => void): fabric.Canvas {
tagsWritable.set([]);
globalThis.canvas = canvas;
undoStack.setCanvas(canvas);
// find object per-pixel basis rather than according to bounding box,
// allow click through transparent area
canvas.perPixelTargetFind = true;
// Disable uniform scaling
canvas.uniformScaling = false;
canvas.uniScaleKey = "none";
// disable rotation globally
delete fabric.Object.prototype.controls.mtr;
// add a border to corner to handle blend of control
fabric.Object.prototype.cornerStyle = "circle";
fabric.Object.prototype.cornerStrokeColor = "#000000";
moveShapeToCanvasBoundaries(canvas);
makeShapeRemainInCanvas(canvas);
canvas.on("object:modified", (evt) => {
if (evt.target instanceof fabric.Polygon) {
modifiedPolygon(canvas, evt.target);
@ -114,12 +127,25 @@ function initCanvas(onChange: () => void): fabric.Canvas {
onChange();
});
canvas.on("object:removed", onChange);
setCenterXForZoom(canvas);
return canvas;
}
const getImageData = (imageData): string => {
const getImageData = (imageData, path): string => {
const b64encoded = protoBase64.enc(imageData);
return "data:image/png;base64," + b64encoded;
const extension = path.split(".").pop();
const mimeTypes = {
"jpg": "jpeg",
"jpeg": "jpeg",
"gif": "gif",
"svg": "svg+xml",
"webp": "webp",
"avif": "avif",
"png": "png",
};
const type = mimeTypes[extension] || "png";
return `data:image/${type};base64,${b64encoded}`;
};
export const setCanvasZoomRatio = (
@ -130,7 +156,7 @@ export const setCanvasZoomRatio = (
const zoomRatioH = (innerHeight - 100) / canvas.height!;
const zoomRatio = zoomRatioW < zoomRatioH ? zoomRatioW : zoomRatioH;
zoomResetValue.set(zoomRatio);
instance.zoomAbs(0, 0, zoomRatio);
zoomReset(instance);
};
const addClozeNotesToTextEditor = (header: string, backExtra: string, tags: string[]) => {
@ -164,7 +190,7 @@ function containerSize(): Size {
export async function resetIOImage(path: string, onImageLoaded: (event: ImageLoadedEvent) => void) {
const imageData = await getImageForOcclusion({ path });
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(imageData.data!);
image.src = getImageData(imageData.data!, path);
const canvas = globalThis.canvas;
image.onload = function() {

View file

@ -154,6 +154,12 @@ function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOptions): vo
} else {
button.style.display = "none";
}
window.addEventListener("keydown", (event) => {
if (event.key === "M") {
toggleMasks();
}
});
}
drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes);

View file

@ -5,7 +5,6 @@ import type { Canvas, Object as FabricObject } from "fabric";
import { fabric } from "fabric";
import { cloneDeep } from "lodash-es";
import { makeMaskTransparent } from "../tools/lib";
import type { Size } from "../types";
import type { Shape, ShapeOrShapes } from "./base";
import { Ellipse } from "./ellipse";
@ -36,7 +35,6 @@ export function exportShapesToClozeDeletions(occludeInactive: boolean): {
*/
export function baseShapesFromFabric(): ShapeOrShapes[] {
const canvas = globalThis.canvas as Canvas;
makeMaskTransparent(canvas, false);
const activeObject = canvas.getActiveObject();
const selectionContainingMultipleObjects = activeObject instanceof fabric.ActiveSelection
&& (activeObject.size() > 1)

View file

@ -13,3 +13,9 @@ export const tagsWritable = writable([""]);
export const ioMaskEditorVisible = writable(true);
// it store hide all or hide one mode
export const hideAllGuessOne = writable(true);
// store initial value of x for zoom reset
export const zoomResetX = writable(0);
// ioImageLoadedStore is used to store the image loaded event
export const ioImageLoadedStore = writable(false);
// store opacity state of objects in canvas
export const opacityStateStore = writable(false);

View file

@ -5,7 +5,7 @@ import type fabric from "fabric";
import type { PanZoom } from "panzoom";
import { get } from "svelte/store";
import { zoomResetValue } from "../store";
import { opacityStateStore, zoomResetValue, zoomResetX } from "../store";
export const SHAPE_MASK_COLOR = "#ffeba2";
export const BORDER_COLOR = "#212121";
@ -59,8 +59,14 @@ export const groupShapes = (canvas: fabric.Canvas): void => {
return;
}
canvas.getActiveObject().toGroup();
const activeObject = canvas.getActiveObject();
const items = activeObject.getObjects();
items.forEach((item) => {
item.set({ opacity: 1 });
});
activeObject.toGroup().set({
opacity: get(opacityStateStore) ? 0.4 : 1,
});
redraw(canvas);
};
@ -78,6 +84,7 @@ export const unGroupShapes = (canvas: fabric.Canvas): void => {
canvas.remove(group);
items.forEach((item) => {
item.set({ opacity: get(opacityStateStore) ? 0.4 : 1 });
canvas.add(item);
});
@ -85,16 +92,35 @@ export const unGroupShapes = (canvas: fabric.Canvas): void => {
};
export const zoomIn = (instance: PanZoom): void => {
instance.smoothZoom(0, 0, 1.25);
const center = getCanvasCenter();
instance.smoothZoom(center.x, center.y, 1.25);
};
export const zoomOut = (instance: PanZoom): void => {
instance.smoothZoom(0, 0, 0.5);
const center = getCanvasCenter();
instance.smoothZoom(center.x, center.y, 0.8);
};
export const zoomReset = (instance: PanZoom): void => {
instance.moveTo(0, 0);
instance.smoothZoomAbs(0, 0, get(zoomResetValue));
setCenterXForZoom(globalThis.canvas);
instance.moveTo(get(zoomResetX), 0);
instance.smoothZoomAbs(get(zoomResetX), 0, get(zoomResetValue));
};
export const getCanvasCenter = () => {
const canvas = globalThis.canvas.getElement();
const rect = canvas.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
return { x: centerX, y: centerY };
};
export const setCenterXForZoom = (canvas: fabric.Canvas) => {
const editor = document.querySelector(".editor-main")!;
const editorWidth = editor.clientWidth;
const canvasWidth = canvas.getElement().offsetWidth;
const centerX = editorWidth / 2 - canvasWidth / 2;
zoomResetX.set(centerX);
};
const copyItem = (canvas: fabric.Canvas): void => {
@ -146,6 +172,7 @@ export const makeMaskTransparent = (
canvas: fabric.Canvas,
opacity = false,
): void => {
opacityStateStore.set(opacity);
const objects = canvas.getObjects();
objects.forEach((object) => {
object.set({
@ -261,3 +288,33 @@ export const redraw = (canvas: fabric.Canvas): void => {
export const clear = (canvas: fabric.Canvas): void => {
canvas.clear();
};
export const makeShapeRemainInCanvas = (canvas: fabric.Canvas) => {
canvas.on("object:moving", function(e) {
const obj = e.target;
if (obj.getScaledHeight() > obj.canvas.height || obj.getScaledWidth() > obj.canvas.width) {
return;
}
obj.setCoords();
if (obj.getBoundingRect().top < 0 || obj.getBoundingRect().left < 0) {
obj.top = Math.max(obj.top, obj.top - obj.getBoundingRect().top);
obj.left = Math.max(obj.left, obj.left - obj.getBoundingRect().left);
}
if (
obj.getBoundingRect().top + obj.getBoundingRect().height > obj.canvas.height
|| obj.getBoundingRect().left + obj.getBoundingRect().width > obj.canvas.width
) {
obj.top = Math.min(
obj.top,
obj.canvas.height - obj.getBoundingRect().height + obj.top - obj.getBoundingRect().top,
);
obj.left = Math.min(
obj.left,
obj.canvas.width - obj.getBoundingRect().width + obj.left - obj.getBoundingRect().left,
);
}
});
};

View file

@ -19,6 +19,21 @@ import {
mdiZoomReset,
} from "../icons";
import { deleteItem, duplicateItem, groupShapes, unGroupShapes, zoomIn, zoomOut, zoomReset } from "./lib";
import {
alignBottomKeyCombination,
alignHorizontalCenterKeyCombination,
alignLeftKeyCombination,
alignRightKeyCombination,
alignTopKeyCombination,
alignVerticalCenterKeyCombination,
deleteKeyCombination,
duplicateKeyCombination,
groupKeyCombination,
ungroupKeyCombination,
zoomInKeyCombination,
zoomOutKeyCombination,
zoomResetKeyCombination,
} from "./shortcuts";
import {
alignBottom,
alignHorizontalCenter,
@ -34,12 +49,14 @@ export const groupUngroupTools = [
icon: mdiGroup,
action: groupShapes,
tooltip: tr.editingImageOcclusionGroup,
shortcut: groupKeyCombination,
},
{
name: "ungroup",
icon: mdiUngroup,
action: unGroupShapes,
tooltip: tr.editingImageOcclusionUngroup,
shortcut: ungroupKeyCombination,
},
];
@ -49,12 +66,14 @@ export const deleteDuplicateTools = [
icon: mdiDeleteOutline,
action: deleteItem,
tooltip: tr.editingImageOcclusionDelete,
shortcut: deleteKeyCombination,
},
{
name: "duplicate",
icon: mdiCopy,
action: duplicateItem,
tooltip: tr.editingImageOcclusionDuplicate,
shortcut: duplicateKeyCombination,
},
];
@ -64,18 +83,21 @@ export const zoomTools = [
icon: mdiZoomOut,
action: zoomOut,
tooltip: tr.editingImageOcclusionZoomOut,
shortcut: zoomOutKeyCombination,
},
{
name: "zoomIn",
icon: mdiZoomIn,
action: zoomIn,
tooltip: tr.editingImageOcclusionZoomIn,
shortcut: zoomInKeyCombination,
},
{
name: "zoomReset",
icon: mdiZoomReset,
action: zoomReset,
tooltip: tr.editingImageOcclusionZoomReset,
shortcut: zoomResetKeyCombination,
},
];
@ -85,35 +107,41 @@ export const alignTools = [
icon: mdiAlignHorizontalLeft,
action: alignLeft,
tooltip: tr.editingImageOcclusionAlignLeft,
shortcut: alignLeftKeyCombination,
},
{
id: 2,
icon: mdiAlignHorizontalCenter,
action: alignHorizontalCenter,
tooltip: tr.editingImageOcclusionAlignHCenter,
shortcut: alignHorizontalCenterKeyCombination,
},
{
id: 3,
icon: mdiAlignHorizontalRight,
action: alignRight,
tooltip: tr.editingImageOcclusionAlignRight,
shortcut: alignRightKeyCombination,
},
{
id: 4,
icon: mdiAlignVerticalTop,
action: alignTop,
tooltip: tr.editingImageOcclusionAlignTop,
shortcut: alignTopKeyCombination,
},
{
id: 5,
icon: mdiAlignVerticalCenter,
action: alignVerticalCenter,
tooltip: tr.editingImageOcclusionAlignVCenter,
shortcut: alignVerticalCenterKeyCombination,
},
{
id: 6,
icon: mdiAlignVerticalBottom,
action: alignBottom,
tooltip: tr.editingImageOcclusionAlignBottom,
shortcut: alignBottomKeyCombination,
},
];

View file

@ -0,0 +1,25 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const cursorKeyCombination = "Control+S";
export const rectangleKeyCombination = "Control+R";
export const ellipseKeyCombination = "Control+E";
export const polygonKeyCombination = "Control+P";
export const textKeyCombination = "Control+T";
export const magnifyKeyCombination = "Control+M";
export const undoKeyCombination = "Control+Z";
export const redoKeyCombination = "Control+Y";
export const zoomOutKeyCombination = "Control+-";
export const zoomInKeyCombination = "Control++";
export const zoomResetKeyCombination = "Control+F";
export const toggleTranslucentKeyCombination = "Control+O";
export const deleteKeyCombination = "Delete";
export const duplicateKeyCombination = "Control+C";
export const groupKeyCombination = "Control+G";
export const ungroupKeyCombination = "Control+U";
export const alignLeftKeyCombination = "Control+Shift+L";
export const alignHorizontalCenterKeyCombination = "Control+Shift+H";
export const alignRightKeyCombination = "Control+Shift+R";
export const alignTopKeyCombination = "Control+Shift+T";
export const alignVerticalCenterKeyCombination = "Control+Shift+V";
export const alignBottomKeyCombination = "Control+Shift+B";

View file

@ -11,36 +11,50 @@ import {
mdiTextBox,
mdiVectorPolygonVariant,
} from "../icons";
import {
cursorKeyCombination,
ellipseKeyCombination,
magnifyKeyCombination,
polygonKeyCombination,
rectangleKeyCombination,
textKeyCombination,
} from "./shortcuts";
export const tools = [
{
id: "cursor",
icon: mdiCursorDefaultOutline,
tooltip: tr.editingImageOcclusionSelectTool,
shortcut: cursorKeyCombination,
},
{
id: "magnify",
icon: mdiMagnifyScan,
tooltip: tr.editingImageOcclusionZoomTool,
shortcut: magnifyKeyCombination,
},
{
id: "draw-rectangle",
icon: mdiRectangleOutline,
tooltip: tr.editingImageOcclusionRectangleTool,
shortcut: rectangleKeyCombination,
},
{
id: "draw-ellipse",
icon: mdiEllipseOutline,
tooltip: tr.editingImageOcclusionEllipseTool,
shortcut: ellipseKeyCombination,
},
{
id: "draw-polygon",
icon: mdiVectorPolygonVariant,
tooltip: tr.editingImageOcclusionPolygonTool,
shortcut: polygonKeyCombination,
},
{
id: "draw-text",
icon: mdiTextBox,
tooltip: tr.editingImageOcclusionTextTool,
shortcut: textKeyCombination,
},
];

View file

@ -2,6 +2,8 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { opacityStateStore } from "image-occlusion/store";
import { get } from "svelte/store";
import { BORDER_COLOR, SHAPE_MASK_COLOR, stopDraw } from "./lib";
import { undoStack } from "./tool-undo-redo";
@ -37,6 +39,7 @@ export const drawEllipse = (canvas: fabric.Canvas): void => {
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
opacity: get(opacityStateStore) ? 0.4 : 1,
});
canvas.add(ellipse);
});
@ -70,20 +73,6 @@ export const drawEllipse = (canvas: fabric.Canvas): void => {
ellipse.set({ originY: "top" });
}
// do not draw outside of canvas
if (x < ellipse.strokeWidth) {
rx = (origX + ellipse.strokeWidth + 0.5) / 2;
}
if (y < ellipse.strokeWidth) {
ry = (origY + ellipse.strokeWidth + 0.5) / 2;
}
if (x >= canvas.width - ellipse.strokeWidth) {
rx = (canvas.width - origX) / 2 - ellipse.strokeWidth + 0.5;
}
if (y > canvas.height - ellipse.strokeWidth) {
ry = (canvas.height - origY) / 2 - ellipse.strokeWidth + 0.5;
}
ellipse.set({ rx: rx, ry: ry });
canvas.renderAll();

View file

@ -2,7 +2,9 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { opacityStateStore } from "image-occlusion/store";
import type { PanZoom } from "panzoom";
import { get } from "svelte/store";
import { BORDER_COLOR, SHAPE_MASK_COLOR } from "./lib";
import { undoStack } from "./tool-undo-redo";
@ -15,6 +17,12 @@ let drawMode = false;
let zoomValue = 1;
export const drawPolygon = (canvas: fabric.Canvas, panzoom: PanZoom): void => {
// remove selectable for shapes
canvas.discardActiveObject();
canvas.forEachObject(function(o) {
o.selectable = false;
});
canvas.selectionColor = "rgba(0, 0, 0, 0)";
canvas.on("mouse:down", function(options) {
try {
@ -184,6 +192,7 @@ const generatePolygon = (canvas: fabric.Canvas, pointsList): void => {
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
opacity: get(opacityStateStore) ? 0.4 : 1,
});
if (polygon.width > 5 && polygon.height > 5) {
canvas.add(polygon);
@ -214,8 +223,25 @@ export const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon):
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
opacity: get(opacityStateStore) ? 0.4 : 1,
});
canvas.remove(polygon);
canvas.add(polygon1);
};
export const removeUnfinishedPolygon = (canvas: fabric.Canvas): void => {
canvas.remove(activeShape).remove(activeLine);
pointsList.forEach((point) => {
canvas.remove(point);
});
linesList.forEach((line) => {
canvas.remove(line);
});
activeLine = null;
activeShape = null;
linesList = [];
pointsList = [];
drawMode = false;
canvas.selection = true;
};

View file

@ -2,6 +2,8 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { opacityStateStore } from "image-occlusion/store";
import { get } from "svelte/store";
import { BORDER_COLOR, SHAPE_MASK_COLOR, stopDraw } from "./lib";
import { undoStack } from "./tool-undo-redo";
@ -38,6 +40,7 @@ export const drawRectangle = (canvas: fabric.Canvas): void => {
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
opacity: get(opacityStateStore) ? 0.4 : 1,
});
canvas.add(rect);
});
@ -47,8 +50,8 @@ export const drawRectangle = (canvas: fabric.Canvas): void => {
return;
}
const pointer = canvas.getPointer(o.e);
let x = pointer.x;
let y = pointer.y;
const x = pointer.x;
const y = pointer.y;
if (x < origX) {
rect.set({ originX: "right" });
@ -62,20 +65,6 @@ export const drawRectangle = (canvas: fabric.Canvas): void => {
rect.set({ originY: "top" });
}
// do not draw outside of canvas
if (x < rect.strokeWidth) {
x = -rect.strokeWidth + 0.5;
}
if (y < rect.strokeWidth) {
y = -rect.strokeWidth + 0.5;
}
if (x >= canvas.width - rect.strokeWidth) {
x = canvas.width - rect.strokeWidth + 0.5;
}
if (y >= canvas.height - rect.strokeWidth) {
y = canvas.height - rect.strokeWidth + 0.5;
}
rect.set({
width: Math.abs(x - rect.left),
height: Math.abs(y - rect.top),

View file

@ -2,6 +2,8 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { opacityStateStore } from "image-occlusion/store";
import { get } from "svelte/store";
import { enableUniformScaling, stopDraw, TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from "./lib";
import { undoStack } from "./tool-undo-redo";
@ -27,6 +29,7 @@ export const drawText = (canvas: fabric.Canvas): void => {
fontFamily: TEXT_FONT_FAMILY,
backgroundColor: TEXT_BACKGROUND_COLOR,
padding: TEXT_PADDING,
opacity: get(opacityStateStore) ? 0.4 : 1,
});
enableUniformScaling(canvas, text);
canvas.add(text);

View file

@ -7,6 +7,7 @@ import { writable } from "svelte/store";
import { mdiRedo, mdiUndo } from "../icons";
import { emitChangeSignal } from "../MaskEditor.svelte";
import { redoKeyCombination, undoKeyCombination } from "./shortcuts";
/**
* Undo redo for rectangle and ellipse handled here,
@ -133,11 +134,13 @@ export const undoRedoTools = [
icon: mdiUndo,
action: () => undoStack.undo(),
tooltip: tr.undoUndo,
shortcut: undoKeyCombination,
},
{
name: "redo",
icon: mdiRedo,
action: () => undoStack.redo(),
tooltip: tr.undoRedo,
shortcut: redoKeyCombination,
},
];