diff --git a/package.json b/package.json index 7eef0f7d6..be7be4188 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/ts/image-occlusion/MaskEditor.svelte b/ts/image-occlusion/MaskEditor.svelte index e14b46aed..724d00385 100644 --- a/ts/image-occlusion/MaskEditor.svelte +++ b/ts/image-occlusion/MaskEditor.svelte @@ -13,24 +13,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - +
@@ -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 { diff --git a/ts/image-occlusion/Toolbar.svelte b/ts/image-occlusion/Toolbar.svelte index c0f0cb9fb..3c50e26f0 100644 --- a/ts/image-occlusion/Toolbar.svelte +++ b/ts/image-occlusion/Toolbar.svelte @@ -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"; - };
@@ -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 { - tool.action(instance); + tool.action(canvas); }} /> {/if} diff --git a/ts/image-occlusion/mask-editor.ts b/ts/image-occlusion/mask-editor.ts index eb1521ba5..c0884bcf0 100644 --- a/ts/image-occlusion/mask-editor.ts +++ b/ts/image-occlusion/mask-editor.ts @@ -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 => { @@ -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 => { @@ -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; diff --git a/ts/image-occlusion/shapes/to-cloze.ts b/ts/image-occlusion/shapes/to-cloze.ts index ac0aeaf08..8b0b53bf4 100644 --- a/ts/image-occlusion/shapes/to-cloze.ts +++ b/ts/image-occlusion/shapes/to-cloze.ts @@ -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; } diff --git a/ts/image-occlusion/store.ts b/ts/image-occlusion/store.ts index ed867186b..5847250dd 100644 --- a/ts/image-occlusion/store.ts +++ b/ts/image-occlusion/store.ts @@ -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 diff --git a/ts/image-occlusion/tools/add-from-cloze.ts b/ts/image-occlusion/tools/add-from-cloze.ts index b35603e62..ddd6c96fd 100644 --- a/ts/image-occlusion/tools/add-from-cloze.ts +++ b/ts/image-occlusion/tools/add-from-cloze.ts @@ -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); diff --git a/ts/image-occlusion/tools/api.ts b/ts/image-occlusion/tools/api.ts index 737895f76..bc3942891 100644 --- a/ts/image-occlusion/tools/api.ts +++ b/ts/image-occlusion/tools/api.ts @@ -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 { diff --git a/ts/image-occlusion/tools/from-shapes.ts b/ts/image-occlusion/tools/from-shapes.ts index 132b3d180..61fa00c2f 100644 --- a/ts/image-occlusion/tools/from-shapes.ts +++ b/ts/image-occlusion/tools/from-shapes.ts @@ -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); }); diff --git a/ts/image-occlusion/tools/lib.ts b/ts/image-occlusion/tools/lib.ts index 968dbce6c..598eb1a24 100644 --- a/ts/image-occlusion/tools/lib.ts +++ b/ts/image-occlusion/tools/lib.ts @@ -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"); +}; diff --git a/ts/image-occlusion/tools/more-tools.ts b/ts/image-occlusion/tools/more-tools.ts index 8e78abb8b..b378ba0b7 100644 --- a/ts/image-occlusion/tools/more-tools.ts +++ b/ts/image-occlusion/tools/more-tools.ts @@ -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 = [ { diff --git a/ts/image-occlusion/tools/tool-cursor.ts b/ts/image-occlusion/tools/tool-cursor.ts new file mode 100644 index 000000000..4e9ebe17f --- /dev/null +++ b/ts/image-occlusion/tools/tool-cursor.ts @@ -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; + } + }); +}; diff --git a/ts/image-occlusion/tools/tool-ellipse.ts b/ts/image-occlusion/tools/tool-ellipse.ts index 7d82608e2..d9bf11614 100644 --- a/ts/image-occlusion/tools/tool-ellipse.ts +++ b/ts/image-occlusion/tools/tool-ellipse.ts @@ -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; } diff --git a/ts/image-occlusion/tools/tool-polygon.ts b/ts/image-occlusion/tools/tool-polygon.ts index 62c683f2c..fc6535195 100644 --- a/ts/image-occlusion/tools/tool-polygon.ts +++ b/ts/image-occlusion/tools/tool-polygon.ts @@ -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 => { diff --git a/ts/image-occlusion/tools/tool-rect.ts b/ts/image-occlusion/tools/tool-rect.ts index eea6e6d37..02400be15 100644 --- a/ts/image-occlusion/tools/tool-rect.ts +++ b/ts/image-occlusion/tools/tool-rect.ts @@ -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; } diff --git a/ts/image-occlusion/tools/tool-text.ts b/ts/image-occlusion/tools/tool-text.ts index bfdc8f8b7..11ac762ae 100644 --- a/ts/image-occlusion/tools/tool-text.ts +++ b/ts/image-occlusion/tools/tool-text.ts @@ -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); }); diff --git a/ts/image-occlusion/tools/tool-zoom.ts b/ts/image-occlusion/tools/tool-zoom.ts new file mode 100644 index 000000000..1760f3d24 --- /dev/null +++ b/ts/image-occlusion/tools/tool-zoom.ts @@ -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); +}; diff --git a/yarn.lock b/yarn.lock index 610901843..020748fcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"