fix: blur in io, remove panzoom and use fabricjs for panzoom (#3052)

* fix: blur in io, remove panzoom and use fabricjs for panzoom
- remove panzoom
- implement panzoom using fabricjs
- set background image for canvas
- add bounding rect for canvas
- draw or add point inside in bounding rect
- update zoom tool

* support pinch to zoom on mobile client

* fix lagging of canvas, zoom in draw mode

* panning in touch events
This commit is contained in:
Mani 2024-03-09 18:35:23 +08:00 committed by GitHub
parent f2acf40221
commit ea8f0c1491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 466 additions and 240 deletions

View file

@ -63,7 +63,7 @@
"lodash-es": "^4.17.21",
"marked": "^5.1.0",
"mathjax": "^3.1.2",
"panzoom": "^9.4.3"
"hammerjs": "^2.0.8"
},
"resolutions": {
"canvas": "npm:empty-npm-package"

View file

@ -13,24 +13,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script>
<script lang="ts">
import type { PanZoom } from "panzoom";
import panzoom from "panzoom";
import { createEventDispatcher, onDestroy, onMount } from "svelte";
import type { IOMode } from "./lib";
import {
type ImageLoadedEvent,
setCanvasZoomRatio,
setupMaskEditor,
setupMaskEditorForEdit,
} from "./mask-editor";
import Toolbar from "./Toolbar.svelte";
import { MaskEditorAPI } from "./tools/api";
import { setCenterXForZoom } from "./tools/lib";
import { onResize } from "./tools/tool-zoom";
export let mode: IOMode;
const iconSize = 80;
let instance: PanZoom;
let innerWidth = 0;
const startingTool = mode.kind === "add" ? "draw-rectangle" : "cursor";
$: canvas = null;
@ -51,26 +47,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: $changeSignal, onChange();
function init(node) {
instance = panzoom(node, {
bounds: true,
maxZoom: 3,
minZoom: 0.1,
zoomDoubleClickSpeed: 1,
smoothScroll: false,
transformOrigin: { x: 0.5, y: 0.5 },
});
instance.pause();
globalThis.panzoom = instance;
function init(_node: HTMLDivElement) {
if (mode.kind == "add") {
setupMaskEditor(mode.imagePath, instance, onChange, onImageLoaded).then(
(canvas1) => {
canvas = canvas1;
},
);
setupMaskEditor(mode.imagePath, onChange, onImageLoaded).then((canvas1) => {
canvas = canvas1;
});
} else {
setupMaskEditorForEdit(mode.noteId, instance, onChange, onImageLoaded).then(
setupMaskEditorForEdit(mode.noteId, onChange, onImageLoaded).then(
(canvas1) => {
canvas = canvas1;
},
@ -79,21 +62,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
onMount(() => {
window.addEventListener("resize", () => {
setCanvasZoomRatio(canvas, instance);
setCenterXForZoom(canvas);
});
window.addEventListener("resize", resizeEvent);
});
onDestroy(() => {
window.removeEventListener("resize", () => {
setCanvasZoomRatio(canvas, instance);
setCenterXForZoom(canvas);
});
window.removeEventListener("resize", resizeEvent);
});
const resizeEvent = () => {
onResize(canvas);
};
</script>
<Toolbar {canvas} {instance} {iconSize} activeTool={startingTool} />
<Toolbar {canvas} {iconSize} activeTool={startingTool} />
<div class="editor-main" bind:clientWidth={innerWidth}>
<div class="editor-container" use:init>
<!-- svelte-ignore a11y-missing-attribute -->
@ -111,7 +92,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
right: 2px;
border: 1px solid var(--border);
overflow: auto;
padding-bottom: 100px;
outline: none !important;
}
@ -125,6 +105,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
height: 100%;
position: relative;
direction: ltr;
overflow: hidden;
}
#image {

View file

@ -28,11 +28,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} from "./tools/more-tools";
import { toggleTranslucentKeyCombination } from "./tools/shortcuts";
import { tools } 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 { disableZoom, enableZoom } from "./tools/tool-zoom";
export let canvas;
export let instance;
export let iconSize;
export let activeTool = "cursor";
let showAlignTools = false;
@ -98,32 +99,37 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
window.addEventListener("keydown", (event) => {
if (event.key == "Control" && activeTool != "magnify") {
instance.resume();
stopDraw(canvas);
enableZoom(canvas);
}
});
window.addEventListener("keyup", (event) => {
if (event.key == "Control" && activeTool != "magnify") {
instance.pause();
disableFunctions();
}
});
window.addEventListener("wheel", () => {
if (clicked && move && wheel && !dbclicked) {
enableMagnify();
stopDraw(canvas);
enableZoom(canvas);
}
});
});
// handle tool changes after initialization
$: if (instance && canvas) {
$: if (canvas) {
disableFunctions();
enableSelectable(canvas, true);
// remove unfinished polygon when switching to other tools
removeUnfinishedPolygon(canvas);
switch (activeTool) {
case "cursor":
drawCursor(canvas);
break;
case "magnify":
enableZoom(canvas);
enableSelectable(canvas, false);
instance.resume();
break;
case "draw-rectangle":
drawRectangle(canvas);
@ -132,7 +138,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
drawEllipse(canvas);
break;
case "draw-polygon":
drawPolygon(canvas, instance);
drawPolygon(canvas);
break;
case "draw-text":
drawText(canvas);
@ -143,21 +149,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
const disableFunctions = () => {
instance.pause();
stopDraw(canvas);
canvas.selectionColor = "rgba(100, 100, 255, 0.3)";
disableZoom(canvas);
};
function changeOcclusionType(occlusionType: "all" | "one"): void {
$hideAllGuessOne = occlusionType === "all";
emitChangeSignal();
}
const enableMagnify = () => {
disableFunctions();
enableSelectable(canvas, false);
instance.resume();
activeTool = "magnify";
};
</script>
<div class="tool-bar-container">
@ -250,7 +249,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{iconSize}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
on:click={() => {
tool.action(instance);
tool.action(canvas);
}}
>
{@html tool.icon}
@ -259,7 +258,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Shortcut
keyCombination={tool.shortcut}
on:action={() => {
tool.action(instance);
tool.action(canvas);
}}
/>
{/if}

View file

@ -5,22 +5,16 @@ import { protoBase64 } from "@bufbuild/protobuf";
import { getImageForOcclusion, getImageOcclusionNote } from "@tslib/backend";
import * as tr from "@tslib/ftl";
import { fabric } from "fabric";
import type { PanZoom } from "panzoom";
import { get } from "svelte/store";
import { optimumCssSizeForCanvas } from "./canvas-scale";
import { notesDataStore, tagsWritable, zoomResetValue } from "./store";
import { notesDataStore, tagsWritable } from "./store";
import Toast from "./Toast.svelte";
import { addShapesToCanvasFromCloze } from "./tools/add-from-cloze";
import {
enableSelectable,
makeShapeRemainInCanvas,
moveShapeToCanvasBoundaries,
setCenterXForZoom,
zoomReset,
} from "./tools/lib";
import { enableSelectable, makeShapeRemainInCanvas, moveShapeToCanvasBoundaries } from "./tools/lib";
import { modifiedPolygon } from "./tools/tool-polygon";
import { undoStack } from "./tools/tool-undo-redo";
import { enablePinchZoom, onResize, setCanvasSize } from "./tools/tool-zoom";
import type { Size } from "./types";
export interface ImageLoadedEvent {
@ -30,7 +24,6 @@ export interface ImageLoadedEvent {
export const setupMaskEditor = async (
path: string,
instance: PanZoom,
onChange: () => void,
onImageLoaded: (event: ImageLoadedEvent) => void,
): Promise<fabric.Canvas> => {
@ -42,13 +35,10 @@ export const setupMaskEditor = async (
image.src = getImageData(imageData.data!, path);
image.onload = function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
canvas.setWidth(size.width);
canvas.setHeight(size.height);
image.height = size.height;
image.width = size.width;
setCanvasZoomRatio(canvas, instance);
undoStack.reset();
setCanvasSize(canvas);
onImageLoaded({ path });
setupBoundingBox(canvas, size);
undoStack.reset();
};
return canvas;
@ -56,7 +46,6 @@ export const setupMaskEditor = async (
export const setupMaskEditorForEdit = async (
noteId: number,
instance: PanZoom,
onChange: () => void,
onImageLoaded: (event: ImageLoadedEvent) => void,
): Promise<fabric.Canvas> => {
@ -78,22 +67,17 @@ 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!, clozeNote.imageFileName!);
image.onload = function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
canvas.setWidth(size.width);
canvas.setHeight(size.height);
image.height = size.height;
image.width = size.width;
setCanvasZoomRatio(canvas, instance);
addShapesToCanvasFromCloze(canvas, clozeNote.occlusions);
image.onload = async function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
setCanvasSize(canvas);
const boundingBox = setupBoundingBox(canvas, size);
addShapesToCanvasFromCloze(canvas, boundingBox, clozeNote.occlusions);
enableSelectable(canvas, true);
addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags);
undoStack.reset();
window.requestAnimationFrame(() => {
image.style.visibility = "visible";
onImageLoaded({ noteId: BigInt(noteId) });
});
};
@ -114,13 +98,13 @@ function initCanvas(onChange: () => void): fabric.Canvas {
canvas.uniScaleKey = "none";
// disable rotation globally
delete fabric.Object.prototype.controls.mtr;
// disable object caching
fabric.Object.prototype.objectCaching = false;
// add a border to corner to handle blend of control
fabric.Object.prototype.transparentCorners = false;
fabric.Object.prototype.cornerStyle = "circle";
fabric.Object.prototype.cornerStrokeColor = "#000000";
fabric.Object.prototype.padding = 8;
moveShapeToCanvasBoundaries(canvas);
makeShapeRemainInCanvas(canvas);
canvas.on("object:modified", (evt) => {
if (evt.target instanceof fabric.Polygon) {
modifiedPolygon(canvas, evt.target);
@ -129,10 +113,33 @@ function initCanvas(onChange: () => void): fabric.Canvas {
onChange();
});
canvas.on("object:removed", onChange);
setCenterXForZoom(canvas);
return canvas;
}
const setupBoundingBox = (canvas: fabric.Canvas, size: Size): fabric.Rect => {
const boundingBox = new fabric.Rect({
id: "boundingBox",
fill: "transparent",
width: size.width,
height: size.height,
hasBorders: false,
hasControls: false,
lockMovementX: true,
lockMovementY: true,
selectable: false,
evented: false,
stroke: "red",
});
canvas.add(boundingBox);
onResize(canvas);
makeShapeRemainInCanvas(canvas, boundingBox);
moveShapeToCanvasBoundaries(canvas, boundingBox);
// enable pinch zoom for mobile devices
enablePinchZoom(canvas);
return boundingBox;
};
const getImageData = (imageData, path): string => {
const b64encoded = protoBase64.enc(imageData);
const extension = path.split(".").pop();
@ -150,17 +157,6 @@ const getImageData = (imageData, path): string => {
return `data:image/${type};base64,${b64encoded}`;
};
export const setCanvasZoomRatio = (
canvas: fabric.Canvas,
instance: PanZoom,
): void => {
const zoomRatioW = (innerWidth - 40) / canvas.width!;
const zoomRatioH = (innerHeight - 100) / canvas.height!;
const zoomRatio = zoomRatioW < zoomRatioH ? zoomRatioW : zoomRatioH;
zoomResetValue.set(zoomRatio);
zoomReset(instance);
};
const addClozeNotesToTextEditor = (header: string, backExtra: string, tags: string[]) => {
const noteFieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(
notesDataStore,
@ -195,16 +191,16 @@ export async function resetIOImage(path: string, onImageLoaded: (event: ImageLoa
image.src = getImageData(imageData.data!, path);
const canvas = globalThis.canvas;
image.onload = function() {
image.onload = async function() {
const size = optimumCssSizeForCanvas(
{ width: image.naturalWidth, height: image.naturalHeight },
containerSize(),
);
canvas.setWidth(size.width);
canvas.setHeight(size.height);
image.height = size.height;
image.width = size.width;
image.height = size.height;
setCanvasSize(canvas);
onImageLoaded({ path });
setupBoundingBox(canvas, size);
};
}
globalThis.resetIOImage = resetIOImage;

View file

@ -3,6 +3,7 @@
import type { Canvas, Object as FabricObject } from "fabric";
import { fabric } from "fabric";
import { getBoundingBox } from "image-occlusion/tools/lib";
import { cloneDeep } from "lodash-es";
import type { Size } from "../types";
@ -21,6 +22,14 @@ export function exportShapesToClozeDeletions(occludeInactive: boolean): {
let clozes = "";
let index = 0;
shapes.forEach((shapeOrShapes) => {
// shapes with width or height less than 5 are not valid
if (shapeOrShapes === null) {
return;
}
// if shape is Rect and fill is transparent, skip it
if (shapeOrShapes instanceof Rectangle && shapeOrShapes.fill === "transparent") {
return;
}
clozes += shapeOrShapesToCloze(shapeOrShapes, index, occludeInactive);
if (!(shapeOrShapes instanceof Text)) {
index++;
@ -41,6 +50,7 @@ export function baseShapesFromFabric(): ShapeOrShapes[] {
? activeObject
: null;
const objects = canvas.getObjects() as FabricObject[];
const boundingBox = getBoundingBox();
return objects
.map((object) => {
// If the object is in the active selection containing multiple objects,
@ -48,8 +58,11 @@ export function baseShapesFromFabric(): ShapeOrShapes[] {
const parent = selectionContainingMultipleObjects?.contains(object)
? selectionContainingMultipleObjects
: undefined;
if (object.width < 5 || object.height < 5) {
return null;
}
return fabricObjectToBaseShapeOrShapes(
canvas,
boundingBox,
object,
parent,
);
@ -107,6 +120,10 @@ function fabricObjectToBaseShapeOrShapes(
shape.top = newPosition.y;
}
if (size == undefined) {
size = { width: 0, height: 0 };
}
shape = shape.toNormal(size);
return shape;
}

View file

@ -5,16 +5,12 @@ import { writable } from "svelte/store";
// it stores note's data for generate.ts, when function generate() is called it will be used to generate the note
export const notesDataStore = writable({ id: "", title: "", divValue: "", textareaValue: "" }[0]);
// it stores the value of zoom ratio for canvas
export const zoomResetValue = writable(1);
// it stores the tags for the note in note editor
export const tagsWritable = writable([""]);
// it stores the visibility of mask editor
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

View file

@ -10,13 +10,14 @@ import { redraw } from "./lib";
export const addShapesToCanvasFromCloze = (
canvas: fabric.Canvas,
boundingBox: fabric.Rect,
occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[],
): void => {
for (const shapeOrShapes of extractShapesFromClozedField(occlusions)) {
if (Array.isArray(shapeOrShapes)) {
addShapeGroup(canvas, shapeOrShapes);
addShapeGroup(canvas, boundingBox, shapeOrShapes);
} else {
addShape(canvas, shapeOrShapes);
addShape(canvas, boundingBox, shapeOrShapes);
}
}
redraw(canvas);

View file

@ -27,12 +27,12 @@ export class MaskEditorAPI {
this.canvas = canvas;
}
addShape(shape: Shape): void {
addShape(this.canvas, shape);
addShape(bounding, shape: Shape): void {
addShape(this.canvas, bounding, shape);
}
addShapeGroup(shapes: Shape[]): void {
addShapeGroup(this.canvas, shapes);
addShapeGroup(bounding, shapes: Shape[]): void {
addShapeGroup(this.canvas, bounding, shapes);
}
getClozes(occludeInactive: boolean): ClozeExportResult {

View file

@ -8,9 +8,10 @@ import { addBorder, enableUniformScaling } from "./lib";
export const addShape = (
canvas: fabric.Canvas,
boundingBox: fabric.Rect,
shape: Shape,
): void => {
const fabricShape = shape.toFabric(canvas);
const fabricShape = shape.toFabric(boundingBox);
addBorder(fabricShape);
if (fabricShape.type === "i-text") {
enableUniformScaling(canvas, fabricShape);
@ -20,11 +21,12 @@ export const addShape = (
export const addShapeGroup = (
canvas: fabric.Canvas,
boundingBox: fabric.Rect,
shapes: Shape[],
): void => {
const group = new fabric.Group();
shapes.map((shape) => {
const fabricShape = shape.toFabric(canvas);
const fabricShape = shape.toFabric(boundingBox);
addBorder(fabricShape);
group.addWithUpdate(fabricShape);
});

View file

@ -2,10 +2,9 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import type { PanZoom } from "panzoom";
import { get } from "svelte/store";
import { opacityStateStore, zoomResetValue, zoomResetX } from "../store";
import { opacityStateStore } from "../store";
export const SHAPE_MASK_COLOR = "#ffeba2";
export const BORDER_COLOR = "#212121";
@ -27,6 +26,9 @@ export const enableSelectable = (
): void => {
canvas.selection = select;
canvas.forEachObject(function(o) {
if (o.fill === "transparent") {
return;
}
o.selectable = select;
});
canvas.renderAll();
@ -41,6 +43,7 @@ export const deleteItem = (canvas: fabric.Canvas): void => {
canvas.discardActiveObject().renderAll();
}
}
redraw(canvas);
};
export const duplicateItem = (canvas: fabric.Canvas): void => {
@ -92,38 +95,6 @@ export const unGroupShapes = (canvas: fabric.Canvas): void => {
redraw(canvas);
};
export const zoomIn = (instance: PanZoom): void => {
const center = getCanvasCenter();
instance.smoothZoom(center.x, center.y, 1.25);
};
export const zoomOut = (instance: PanZoom): void => {
const center = getCanvasCenter();
instance.smoothZoom(center.x, center.y, 0.8);
};
export const zoomReset = (instance: PanZoom): void => {
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 => {
if (!canvas.getActiveObject()) {
return;
@ -184,26 +155,26 @@ export const makeMaskTransparent = (
canvas.renderAll();
};
export const moveShapeToCanvasBoundaries = (canvas: fabric.Canvas): void => {
export const moveShapeToCanvasBoundaries = (canvas: fabric.Canvas, boundingBox: fabric.Rect): void => {
canvas.on("object:modified", function(o) {
const activeObject = o.target;
if (!activeObject) {
return;
}
if (activeObject.type === "rect") {
modifiedRectangle(canvas, activeObject);
modifiedRectangle(boundingBox, activeObject);
}
if (activeObject.type === "ellipse") {
modifiedEllipse(canvas, activeObject);
modifiedEllipse(boundingBox, activeObject);
}
if (activeObject.type === "i-text") {
modifiedText(canvas, activeObject);
modifiedText(boundingBox, activeObject);
}
});
};
const modifiedRectangle = (
canvas: fabric.Canvas,
boundingBox: fabric.Rect,
object: fabric.Object,
): void => {
const newWidth = object.width * object.scaleX;
@ -215,11 +186,11 @@ const modifiedRectangle = (
scaleX: 1,
scaleY: 1,
});
setShapePosition(canvas, object);
setShapePosition(boundingBox, object);
};
const modifiedEllipse = (
canvas: fabric.Canvas,
boundingBox: fabric.Rect,
object: fabric.Object,
): void => {
const newRx = object.rx * object.scaleX;
@ -235,15 +206,15 @@ const modifiedEllipse = (
scaleX: 1,
scaleY: 1,
});
setShapePosition(canvas, object);
setShapePosition(boundingBox, object);
};
const modifiedText = (canvas: fabric.Canvas, object: fabric.Object): void => {
setShapePosition(canvas, object);
const modifiedText = (boundingBox: fabric.Rect, object: fabric.Object): void => {
setShapePosition(boundingBox, object);
};
const setShapePosition = (
canvas: fabric.Canvas,
boundingBox: fabric.Rect,
object: fabric.Object,
): void => {
if (object.left < 0) {
@ -252,11 +223,11 @@ const setShapePosition = (
if (object.top < 0) {
object.set({ top: 0 });
}
if (object.left + object.width * object.scaleX + object.strokeWidth > canvas.width) {
object.set({ left: canvas.width - object.width * object.scaleX });
if (object.left + object.width * object.scaleX + object.strokeWidth > boundingBox.width) {
object.set({ left: boundingBox.width - object.width * object.scaleX });
}
if (object.top + object.height * object.scaleY + object.strokeWidth > canvas.height) {
object.set({ top: canvas.height - object.height * object.scaleY });
if (object.top + object.height * object.scaleY + object.strokeWidth > boundingBox.height) {
object.set({ top: boundingBox.height - object.height * object.scaleY });
}
object.setCoords();
};
@ -287,33 +258,25 @@ export const clear = (canvas: fabric.Canvas): void => {
canvas.clear();
};
export const makeShapeRemainInCanvas = (canvas: fabric.Canvas) => {
export const makeShapeRemainInCanvas = (canvas: fabric.Canvas, boundingBox: fabric.Rect) => {
canvas.on("object:moving", function(e) {
const obj = e.target;
if (obj.getScaledHeight() > obj.canvas.height || obj.getScaledWidth() > obj.canvas.width) {
if (obj.getScaledHeight() > boundingBox.height || obj.getScaledWidth() > boundingBox.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);
}
const top = obj.top;
const left = obj.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,
);
}
const topBound = boundingBox.top;
const bottomBound = topBound + boundingBox.height;
const leftBound = boundingBox.left;
const rightBound = leftBound + boundingBox.width;
obj.left = Math.min(Math.max(left, leftBound), rightBound - obj.width);
obj.top = Math.min(Math.max(top, topBound), bottomBound - obj.height);
});
};
@ -325,3 +288,21 @@ export const selectAllShapes = (canvas: fabric.Canvas) => {
canvas.setActiveObject(sel);
redraw(canvas);
};
export const isPointerInBoundingBox = (pointer): boolean => {
const boundingBox = getBoundingBox();
if (
pointer.x < boundingBox.left
|| pointer.x > boundingBox.left + boundingBox.width
|| pointer.y < boundingBox.top
|| pointer.y > boundingBox.top + boundingBox.height
) {
return false;
}
return true;
};
export const getBoundingBox = () => {
const canvas = globalThis.canvas;
return canvas.getObjects().find((obj) => obj.fill === "transparent");
};

View file

@ -19,16 +19,7 @@ import {
mdiZoomOut,
mdiZoomReset,
} from "../icons";
import {
deleteItem,
duplicateItem,
groupShapes,
selectAllShapes,
unGroupShapes,
zoomIn,
zoomOut,
zoomReset,
} from "./lib";
import { deleteItem, duplicateItem, groupShapes, selectAllShapes, unGroupShapes } from "./lib";
import {
alignBottomKeyCombination,
alignHorizontalCenterKeyCombination,
@ -53,6 +44,7 @@ import {
alignTop,
alignVerticalCenter,
} from "./tool-aligns";
import { zoomIn, zoomOut, zoomReset } from "./tool-zoom";
export const groupUngroupTools = [
{

View file

@ -0,0 +1,24 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { fabric } from "fabric";
import { stopDraw } from "./lib";
import { onPinchZoom } from "./tool-zoom";
export const drawCursor = (canvas: fabric.Canvas): void => {
canvas.selectionColor = "rgba(100, 100, 255, 0.3)";
stopDraw(canvas);
canvas.on("mouse:down", function(o) {
if (o.target) {
return;
}
});
canvas.on("mouse:move", function(o) {
if (onPinchZoom(o)) {
return;
}
});
};

View file

@ -5,8 +5,9 @@ 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 { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR, stopDraw } from "./lib";
import { undoStack } from "./tool-undo-redo";
import { onPinchZoom } from "./tool-zoom";
export const drawEllipse = (canvas: fabric.Canvas): void => {
canvas.selectionColor = "rgba(0, 0, 0, 0)";
@ -24,6 +25,11 @@ export const drawEllipse = (canvas: fabric.Canvas): void => {
origX = pointer.x;
origY = pointer.y;
if (!isPointerInBoundingBox(pointer)) {
isDown = false;
return;
}
ellipse = new fabric.Ellipse({
id: "ellipse-" + new Date().getTime(),
left: origX,
@ -45,6 +51,12 @@ export const drawEllipse = (canvas: fabric.Canvas): void => {
});
canvas.on("mouse:move", function(o) {
if (onPinchZoom(o)) {
canvas.remove(ellipse);
canvas.renderAll();
return;
}
if (!isDown) {
return;
}

View file

@ -3,20 +3,19 @@
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 { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR } from "./lib";
import { undoStack } from "./tool-undo-redo";
import { onPinchZoom } from "./tool-zoom";
let activeLine;
let activeShape;
let linesList: fabric.Line = [];
let pointsList: fabric.Circle = [];
let drawMode = false;
let zoomValue = 1;
export const drawPolygon = (canvas: fabric.Canvas, panzoom: PanZoom): void => {
export const drawPolygon = (canvas: fabric.Canvas): void => {
// remove selectable for shapes
canvas.discardActiveObject();
canvas.forEachObject(function(o) {
@ -29,7 +28,7 @@ export const drawPolygon = (canvas: fabric.Canvas, panzoom: PanZoom): void => {
if (options.target && options.target.id === pointsList[0].id) {
generatePolygon(canvas, pointsList);
} else {
addPoint(canvas, options, panzoom);
addPoint(canvas, options);
}
} catch (e) {
// Cannot read properties of undefined (reading 'id')
@ -37,6 +36,12 @@ export const drawPolygon = (canvas: fabric.Canvas, panzoom: PanZoom): void => {
});
canvas.on("mouse:move", function(options) {
// if pinch zoom is active, remove all points and lines
if (onPinchZoom(options)) {
removeUnfinishedPolygon(canvas);
return;
}
if (activeLine && activeLine.class === "line") {
const pointer = canvas.getPointer(options.e);
activeLine.set({
@ -71,15 +76,14 @@ const toggleDrawPolygon = (canvas: fabric.Canvas): void => {
}
};
const addPoint = (canvas: fabric.Canvas, options, panzoom): void => {
zoomValue = panzoom.getTransform().scale;
const addPoint = (canvas: fabric.Canvas, options): void => {
const pointer = canvas.getPointer(options.e);
const origX = pointer.x;
const origY = pointer.y;
const canvasContainer = document.querySelector(".canvas-container")!.getBoundingClientRect()!;
let clientX = options.e.touches ? options.e.touches[0].clientX : options.e.clientX;
let clientY = options.e.touches ? options.e.touches[0].clientY : options.e.clientY;
clientX = (clientX - canvasContainer.left) / zoomValue;
clientY = (clientY - canvasContainer.top) / zoomValue;
if (!isPointerInBoundingBox(pointer)) {
return;
}
const point = new fabric.Circle({
radius: 5,
@ -88,8 +92,8 @@ const addPoint = (canvas: fabric.Canvas, options, panzoom): void => {
strokeWidth: 0.5,
originX: "left",
originY: "top",
left: clientX,
top: clientY,
left: origX,
top: origY,
selectable: false,
hasBorders: false,
hasControls: false,
@ -102,7 +106,7 @@ const addPoint = (canvas: fabric.Canvas, options, panzoom): void => {
});
}
const linePoints = [clientX, clientY, clientX, clientY];
const linePoints = [origX, origY, origX, origY];
const line = new fabric.Line(linePoints, {
strokeWidth: 2,
@ -143,7 +147,7 @@ const addPoint = (canvas: fabric.Canvas, options, panzoom): void => {
activeShape = polygon;
canvas.renderAll();
} else {
const polyPoint = [{ x: clientX, y: clientY }];
const polyPoint = [{ x: origX, y: origY }];
const polygon = new fabric.Polygon(polyPoint, {
stroke: "#333333",
strokeWidth: 1,
@ -166,6 +170,7 @@ const addPoint = (canvas: fabric.Canvas, options, panzoom): void => {
canvas.add(line);
canvas.add(point);
canvas.renderAll();
};
const generatePolygon = (canvas: fabric.Canvas, pointsList): void => {

View file

@ -5,8 +5,9 @@ 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 { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR, stopDraw } from "./lib";
import { undoStack } from "./tool-undo-redo";
import { onPinchZoom } from "./tool-zoom";
export const drawRectangle = (canvas: fabric.Canvas): void => {
canvas.selectionColor = "rgba(0, 0, 0, 0)";
@ -24,6 +25,11 @@ export const drawRectangle = (canvas: fabric.Canvas): void => {
origX = pointer.x;
origY = pointer.y;
if (!isPointerInBoundingBox(pointer)) {
isDown = false;
return;
}
rect = new fabric.Rect({
id: "rect-" + new Date().getTime(),
left: origX,
@ -46,6 +52,12 @@ export const drawRectangle = (canvas: fabric.Canvas): void => {
});
canvas.on("mouse:move", function(o) {
if (onPinchZoom(o)) {
canvas.remove(rect);
canvas.renderAll();
return;
}
if (!isDown) {
return;
}

View file

@ -5,19 +5,34 @@ import { fabric } from "fabric";
import { opacityStateStore, textEditingState } from "image-occlusion/store";
import { get } from "svelte/store";
import { enableUniformScaling, stopDraw, TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from "./lib";
import {
enableUniformScaling,
isPointerInBoundingBox,
stopDraw,
TEXT_BACKGROUND_COLOR,
TEXT_FONT_FAMILY,
TEXT_PADDING,
} from "./lib";
import { undoStack } from "./tool-undo-redo";
import { onPinchZoom } from "./tool-zoom";
export const drawText = (canvas: fabric.Canvas): void => {
canvas.selectionColor = "rgba(0, 0, 0, 0)";
stopDraw(canvas);
let text;
canvas.on("mouse:down", function(o) {
if (o.target) {
return;
}
const pointer = canvas.getPointer(o.e);
const text = new fabric.IText("text", {
if (!isPointerInBoundingBox(pointer)) {
return;
}
text = new fabric.IText("text", {
id: "text-" + new Date().getTime(),
left: pointer.x,
top: pointer.y,
@ -35,10 +50,17 @@ export const drawText = (canvas: fabric.Canvas): void => {
canvas.add(text);
canvas.setActiveObject(text);
undoStack.onObjectAdded(text.id);
text.enterEditing();
text.selectAll();
});
canvas.on("mouse:move", function(o) {
if (onPinchZoom(o)) {
canvas.remove(text);
canvas.renderAll();
return;
}
});
canvas.on("text:editing:entered", function() {
textEditingState.set(true);
});

View file

@ -0,0 +1,212 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// https://codepen.io/amsunny/pen/XWGLxye
// canvas.viewportTransform = [ scaleX, skewX, skewY, scaleY, translateX, translateY ]
import type { fabric } from "fabric";
import Hammer from "hammerjs";
import { getBoundingBox, redraw } from "./lib";
let isDragging = false;
const minScale = 0.5;
const maxScale = 5;
let zoomScale = 1;
export let currentScale = 1;
export const enableZoom = (canvas: fabric.Canvas) => {
canvas.on("mouse:wheel", onMouseWheel);
canvas.on("mouse:down", onMouseDown);
canvas.on("mouse:move", onMouseMove);
canvas.on("mouse:up", onMouseUp);
};
export const disableZoom = (canvas: fabric.Canvas) => {
canvas.off("mouse:wheel", onMouseWheel);
canvas.off("mouse:down", onMouseDown);
canvas.off("mouse:move", onMouseMove);
canvas.off("mouse:up", onMouseUp);
};
export const zoomIn = (canvas: fabric.Canvas): void => {
let zoom = canvas.getZoom();
zoom = Math.min(maxScale, zoom * 1.1);
canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, zoom);
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
};
export const zoomOut = (canvas): void => {
let zoom = canvas.getZoom();
zoom = Math.max(minScale, zoom / 1.1);
canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, zoom / 1.1);
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
};
export const zoomReset = (canvas: fabric.Canvas): void => {
canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, 1);
fitCanvasVptScale(canvas);
constrainBoundsAroundBgImage(canvas);
};
export const enablePinchZoom = (canvas: fabric.Canvas) => {
const hammer = new Hammer(canvas.upperCanvasEl);
hammer.get("pinch").set({ enable: true });
hammer.on("pinchin pinchout", ev => {
currentScale = Math.min(Math.max(minScale, ev.scale * zoomScale), maxScale);
canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, currentScale);
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
});
hammer.on("pinchend pinchcancel", () => {
zoomScale = currentScale;
});
};
export const disablePinchZoom = (canvas: fabric.Canvas) => {
const hammer = new Hammer(canvas.upperCanvasEl);
hammer.get("pinch").set({ enable: false });
hammer.off("pinch pinchmove pinchend pinchcancel");
};
export const onResize = (canvas: fabric.Canvas) => {
setCanvasSize(canvas);
constrainBoundsAroundBgImage(canvas);
fitCanvasVptScale(canvas);
};
const onMouseWheel = (opt) => {
const canvas = globalThis.canvas;
const delta = opt.e.deltaY;
let zoom = canvas.getZoom();
zoom *= 0.999 ** delta;
zoom = Math.max(minScale, Math.min(zoom, maxScale));
canvas.zoomToPoint({ x: opt.pointer.x, y: opt.pointer.y }, zoom);
opt.e.preventDefault();
opt.e.stopPropagation();
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
};
const onMouseDown = (opt) => {
isDragging = true;
const canvas = globalThis.canvas;
canvas.discardActiveObject();
const { e } = opt;
const clientX = e.type === "touchstart" ? e.touches[0].clientX : e.clientX;
const clientY = e.type === "touchstart" ? e.touches[0].clientY : e.clientY;
canvas.lastPosX = clientX;
canvas.lastPosY = clientY;
redraw(canvas);
};
export const onMouseMove = (opt) => {
const canvas = globalThis.canvas;
if (isDragging) {
canvas.discardActiveObject();
if (!canvas.viewportTransform) {
return;
}
// handle pinch zoom and pan for mobile devices
if (onPinchZoom(opt)) {
return;
}
onDrag(canvas, opt);
}
};
// initializes lastPosX and lastPosY because it is undefined in touchmove event
document.addEventListener("touchstart", (e) => {
const canvas = globalThis.canvas;
canvas.lastPosX = e.touches[0].clientX;
canvas.lastPosY = e.touches[0].clientY;
});
export const onPinchZoom = (opt): boolean => {
const { e } = opt;
const canvas = globalThis.canvas;
if ((e.type === "touchmove") && (e.touches.length > 1)) {
onDrag(canvas, opt);
return true;
}
return false;
};
const onDrag = (canvas, opt) => {
const { e } = opt;
const clientX = e.type === "touchmove" ? e.touches[0].clientX : e.clientX;
const clientY = e.type === "touchmove" ? e.touches[0].clientY : e.clientY;
const vpt = canvas.viewportTransform;
vpt[4] += clientX - canvas.lastPosX;
vpt[5] += clientY - canvas.lastPosY;
canvas.lastPosX = clientX;
canvas.lastPosY = clientY;
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
};
const onMouseUp = () => {
isDragging = false;
const canvas = globalThis.canvas;
canvas.setViewportTransform(canvas.viewportTransform);
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
};
export const constrainBoundsAroundBgImage = (canvas: fabric.Canvas) => {
const boundingBox = getBoundingBox();
const ioImage = document.getElementById("image") as HTMLImageElement;
const width = boundingBox.width * canvas.getZoom();
const height = boundingBox.height * canvas.getZoom();
const left = canvas.viewportTransform[4];
const top = canvas.viewportTransform[5];
ioImage.width = width;
ioImage.height = height;
ioImage.style.left = `${left}px`;
ioImage.style.top = `${top}px`;
};
export const setCanvasSize = (canvas: fabric.Canvas) => {
canvas.setHeight(window.innerHeight - 76);
canvas.setWidth(window.innerWidth - 39);
redraw(canvas);
};
const fitCanvasVptScale = (canvas: fabric.Canvas) => {
const boundingBox = getBoundingBox();
const ratio = getScaleRatio(boundingBox);
const vpt = canvas.viewportTransform;
const boundingBoxWidth = boundingBox.width * canvas.getZoom();
const boundingBoxHeight = boundingBox.height * canvas.getZoom();
const center = canvas.getCenter();
const translateX = center.left - (boundingBoxWidth / 2);
const translateY = center.top - (boundingBoxHeight / 2);
vpt[0] = ratio;
vpt[3] = ratio;
vpt[4] = Math.max(1, translateX);
vpt[5] = Math.max(1, translateY);
canvas.setViewportTransform(canvas.viewportTransform);
constrainBoundsAroundBgImage(canvas);
redraw(canvas);
};
const getScaleRatio = (boundingBox: fabric.Rect) => {
const h1 = boundingBox.height;
const w1 = boundingBox.width;
const h2 = innerHeight - 79;
const w2 = innerWidth - 42;
return Math.min(w2 / w1, h2 / h1);
};

View file

@ -1406,13 +1406,6 @@ ajv@^6.12.4:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
amator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/amator/-/amator-1.1.0.tgz#08c6b60bc93aec2b61bbfc0c4d677d30323cc0f1"
integrity sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==
dependencies:
bezier-easing "^2.0.3"
ansi-escapes@^4.2.1:
version "4.3.2"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@ -1657,11 +1650,6 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
bezier-easing@^2.0.3:
version "2.1.0"
resolved "https://registry.yarnpkg.com/bezier-easing/-/bezier-easing-2.1.0.tgz#c04dfe8b926d6ecaca1813d69ff179b7c2025d86"
integrity sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
@ -3038,6 +3026,11 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
hammerjs@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1"
integrity sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"
@ -4170,11 +4163,6 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
ngraph.events@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/ngraph.events/-/ngraph.events-1.2.2.tgz#3ceb92d676a04a4e7ce60a09fa8e17a4f0346d7f"
integrity sha512-JsUbEOzANskax+WSYiAPETemLWYXmixuPAlmZmhIbIj6FH/WDgEGCGnRwUQBK0GjOnVm8Ui+e5IJ+5VZ4e32eQ==
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@ -4332,15 +4320,6 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
panzoom@^9.4.3:
version "9.4.3"
resolved "https://registry.yarnpkg.com/panzoom/-/panzoom-9.4.3.tgz#195c4031bb643f2e6c42f1de0ca87cc10e224042"
integrity sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==
dependencies:
amator "^1.1.0"
ngraph.events "^1.2.2"
wheel "^1.0.0"
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@ -5389,11 +5368,6 @@ whatwg-url@^11.0.0:
tr46 "^3.0.0"
webidl-conversions "^7.0.0"
wheel@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wheel/-/wheel-1.0.0.tgz#6cf46e06a854181adb8649228077f8b0d5c574ce"
integrity sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"