diff --git a/README.md b/README.md index d27d42d..18e04ce 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ Powered by FabricJS canvas at its core, this component empowers users to seamles - [X] Annotations on image - [ ] Bounding Box Annotation -- [ ] Point and Polygon Annotation -- [ ] Image zoom and drag +- [X] Polygon Annotation +- [X] Image zoom and drag +- [ ] Highlight by ID - [ ] Categorize annotations with colors and label ![Screenshot of Annotator](docs/annotations-board.png) diff --git a/package-lock.json b/package-lock.json index 9593c4c..9199644 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "fabric": "^5.3.0", - "fabricjs-react": "^1.2.2" + "fabricjs-react": "^1.2.2", + "uuid": "^9.0.1" }, "devDependencies": { "@chromatic-com/storybook": "^1.3.1", @@ -18204,7 +18205,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 97b0e5d..d10cd0d 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ ], "dependencies": { "fabric": "^5.3.0", - "fabricjs-react": "^1.2.2" + "fabricjs-react": "^1.2.2", + "uuid": "^9.0.1" } } diff --git a/src/components/Board/Board.tsx b/src/components/Board/Board.tsx index 2336477..905a9ff 100644 --- a/src/components/Board/Board.tsx +++ b/src/components/Board/Board.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useState } from "react"; import { fabric } from "fabric"; +import { v4 as uuidv4 } from "uuid"; import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react"; import { CanvasObject } from "./types"; -import * as fabricUtils from "../../fabricUtils"; +import * as fabricUtils from "../../fabric/utils"; +import * as fabricActions from "../../fabric/actions"; +import * as fabricTypes from "../../fabric/types"; export type BoardProps = { - primary?: boolean; items: CanvasObject[]; image: { name: string; src: string }; initialStatus?: { @@ -30,25 +32,13 @@ export type BoardActions = { resetZoom: () => void; deleteSelectedObjects: () => void; downloadImage: () => void; - drawPolygon: () => void; - randomAction1: () => void; - randomAction2: () => void; -}; - -type CanvasAnnotationState = { - selection?: boolean; - lastPosX: number; - lastPosY: number; - isDragging?: boolean; - drawingPolygon?: boolean; - lastClickCoords?: { x: number; y: number }; - polygonPoints?: { x: number; y: number }[]; + drawObject: (type?: "rectangle" | "polygon") => void; + retrieveObjects: () => CanvasObject[]; }; const Board = React.forwardRef( ( { - primary = true, image, initialStatus, items, @@ -73,61 +63,58 @@ const Board = React.forwardRef( }, deleteSelectedObjects() { const canvas = editor?.canvas; - if (canvas) fabricUtils.deleteSelected(canvas); - }, - drawPolygon() { - setDrawingPolygon(!drawingPolygon); + if (canvas) fabricActions.deleteSelected(canvas); }, - randomAction1() { - const line = new fabric.Polygon( - [ - { x: 40, y: 40 }, - { x: 120, y: 120 }, - ], - { - stroke: "red", - fill: undefined, - strokeWidth: 1, - selectable: true, - hasBorders: true, - hasControls: true, - cornerStyle: "rect", - cornerColor: "rgba(113, 113, 117, 0.5)", - objectCaching: false, - }, - ); - const controls = line.points?.reduce<{ - [key: string]: fabric.Control; - }>((acc, _point, index) => { - acc["p" + index] = new fabricUtils.CustomControl( - { - positionHandler: fabricUtils.polygonPositionHandler, - actionHandler: fabricUtils.anchorWrapper( - index > 0 ? index - 1 : line.points!.length - 1, - fabricUtils.actionHandler, - ), - actionName: "modifyPolygon", - }, - index, - ); - return acc; - }, {}); - if (controls) { - line.controls = controls; + drawObject(type?: "rectangle" | "polygon") { + const isDrawing = !drawingObject?.isDrawing; + if (isDrawing) { + const polygonId = uuidv4(); + setDrawingObject({ + id: polygonId, + type: type ?? "polygon", + isDrawing: true, + points: [], + }); + } else { + resetDrawingObject(); } - editor?.canvas.add(line); - }, - randomAction2() { - console.log("randomAction2"); }, downloadImage() { - fabricUtils.canvasImageDownload(image); + fabricActions.canvasImageDownload(image); + }, + retrieveObjects: () => { + const canvas = editor?.canvas; + if (canvas) { + const customObjects = + fabricUtils.retrieveObjects(canvas); + if (!customObjects) return []; + return customObjects.map((co) => { + const updatedCoordPoints = fabricUtils.pointsInCanvas(co); + const updatedCoords = updatedCoordPoints.map((p) => + fabricUtils.toOriginalCoord({ + cInfo: { + width: canvas.getWidth(), + height: canvas.getHeight(), + }, + iInfo: imageSize, + coord: p, + scaleRatio, + }), + ); + return { + id: co.name!, + category: "TODO_category", + color: "TODO_color", + value: "TODO_value", + coords: updatedCoords, + }; + }); + } + return []; }, })); const { editor, onReady } = useFabricJSEditor(); - const [drawingPolygon, setDrawingPolygon] = useState(false); - const [currentZoom, setCurrentZoom] = useState( initialStatus?.currentZoom || 100, ); @@ -145,6 +132,22 @@ const Board = React.forwardRef( initialStatus?.draggingEnabled || false, ); + const [drawingObject, setDrawingObject] = useState< + NonNullable + >({ isDrawing: false, type: "polygon", points: [] }); + + const resetDrawingObject = () => { + const state: fabricTypes.CanvasAnnotationState["drawingObject"] = { + isDrawing: false, + type: "polygon", + points: [], + }; + ( + editor?.canvas as unknown as fabricTypes.CanvasAnnotationState + ).drawingObject = state; + setDrawingObject(state); + }; + useEffect(() => { const parentCanvasElement = document.getElementById( "react-annotator-canvas", @@ -188,10 +191,11 @@ const Board = React.forwardRef( { selectable: false }, ); + // On Wheel interation/move editor.canvas.on( "mouse:wheel", - function (this: CanvasAnnotationState, opt) { - if (this.drawingPolygon) return; + function (this: fabricTypes.CanvasAnnotationState, opt) { + // if (this.drawingObject?.isDrawing) return; const delta = opt.e.deltaY; let zoom = editor.canvas.getZoom(); zoom *= 0.999 ** delta; @@ -207,142 +211,222 @@ const Board = React.forwardRef( }, ); + // On Mouse right click (down) editor.canvas.on( "mouse:down", - function (this: CanvasAnnotationState, opt) { + function (this: fabricTypes.CanvasAnnotationState, opt) { const evt = opt.e; this.isDragging = draggingEnabled; this.selection = false; this.lastPosX = evt.clientX; this.lastPosY = evt.clientY; - this.drawingPolygon = drawingPolygon; + this.drawingObject = drawingObject; // Extract coords for polygon drawing const pointer = editor?.canvas.getPointer(opt.e); const lastClickCoords = { x: pointer.x, y: pointer.y }; this.lastClickCoords = lastClickCoords; - // Add all polygon points to be later drawn - if (drawingPolygon) { - if (this.polygonPoints) { - this.polygonPoints?.push(this.lastClickCoords); + if (drawingObject.isDrawing && drawingObject.type === "polygon") { + // Retrive the existing polygon + + const polygon = + fabricUtils.findObjectByName( + editor.canvas, + drawingObject.id, + ); + // Delete previously created polygon (if exists) + if (polygon) editor.canvas.remove(polygon); + + const hasClickedOnInitialPoint = (p?: fabricTypes.CustomObject) => { + if (p === undefined) return false; + // const collisionPoint: string | undefined = undefined; + const initialPoint = p.oCoords["p0"]; + + if (initialPoint) { + const { tl, tr, bl, br } = initialPoint.corner; + // We need to ignore the zoom in order to obtain the accurate coordinates + const zoomedPointer = editor?.canvas.getPointer(opt.e, true); + return fabricUtils.isCoordInsideCoords(zoomedPointer, { + tl, + tr, + bl, + br, + }); + } + return false; + }; + + const isInitialPoint = hasClickedOnInitialPoint(polygon); + + // Update drawing points of polygon + const newPoints = isInitialPoint + ? drawingObject.points + : drawingObject.points.concat(lastClickCoords); + + // Draw a new polygon from scratch + const newPolygon = fabricUtils.createControllableCustomObject( + isInitialPoint ? fabric.Polygon : fabric.Polyline, + newPoints, + { name: drawingObject.id }, + ); + + if (isInitialPoint) { + resetDrawingObject(); } else { - this.polygonPoints = [this.lastClickCoords]; + setDrawingObject({ + ...drawingObject, + points: newPoints, + }); } - if (this.polygonPoints?.length === 4) { - setDrawingPolygon(false); - return; - } + // Add object to canvas and set it as ACTIVE + editor.canvas.add(newPolygon); + editor.canvas.setActiveObject(newPolygon); + } else if ( + this.drawingObject?.isDrawing && + this.drawingObject.type === "rectangle" + ) { + console.log("Draw Rectangle - BEGIN"); + } + }, + ); - // Draw the polygon with the existing coords - // if (this.polygonPoints?.length ?? 0 >= 2) { - // const polygonId = "polygonId"; - // const previousPolygon = editor.canvas - // .getObjects() - // .find((o) => o.name === polygonId); - - // if (previousPolygon) editor.canvas.remove(previousPolygon); - // const newPolygon = new fabric.Polygon(this.polygonPoints ?? [], { - // name: polygonId, - // fill: undefined, - // stroke: "red", - // strokeWidth: 2, - // }); - // editor.canvas.add(newPolygon); - // } + // On Mouse right click (up) + editor.canvas.on( + "mouse:up", + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function (this: fabricTypes.CanvasAnnotationState, _opt) { + if (this.isDragging) { + // Rese the viewport + editor.canvas.zoomToPoint( + { x: _opt.e.offsetX, y: _opt.e.offsetY }, + editor.canvas.getZoom(), + ); + } + this.isDragging = false; + this.selection = true; + + // Disable drawing when it's a rectangle on mouse up + if ( + this.drawingObject?.isDrawing && + this.drawingObject.type === "rectangle" + ) { + console.log("Draw Rectangle - DOWN"); + resetDrawingObject(); } - opt.e.preventDefault(); - opt.e.stopPropagation(); }, ); + // On Mouse free moving on canvas editor.canvas.on( "mouse:move", - function (this: CanvasAnnotationState, opt) { - if (this.isDragging) { + function (this: fabricTypes.CanvasAnnotationState, opt) { + const isDrawingObject = this.drawingObject?.isDrawing; + const drawingObjectType = this.drawingObject?.type; + const pointer = editor?.canvas.getPointer(opt.e); + + if (this.isDragging && !isDrawingObject) { const e = opt.e; const vpt = editor.canvas.viewportTransform; if (vpt) { vpt[4] += e.clientX - this.lastPosX; vpt[5] += e.clientY - this.lastPosY; - editor.canvas.requestRenderAll(); this.lastPosX = e.clientX; this.lastPosY = e.clientY; + editor.canvas.requestRenderAll(); } - } else if (this.drawingPolygon) { - const pointer = editor?.canvas.getPointer(opt.e); + } - const polygonId = "polygonId"; - const previousPolygon = fabricUtils.findObjectByName( + if (isDrawingObject && drawingObjectType === "polygon") { + const newPoints = drawingObject.points.concat({ + x: pointer.x, + y: pointer.y, + }); + + const polygon = fabricUtils.findObjectByName( editor.canvas, - polygonId, + drawingObject.id, ); - if (previousPolygon) - fabricUtils.deleteObject(editor.canvas, previousPolygon); + if (polygon) editor.canvas.remove(polygon); - // Polygon "clicked" points with the cursor current pointer - const polygonPoints = - this.polygonPoints?.concat({ x: pointer.x, y: pointer.y }) ?? []; + // Draw a new polygon from scratch + const newPolygon = fabricUtils.createControllableCustomObject( + fabric.Polyline, + newPoints, + { name: drawingObject.id }, + ); - const newPolygon = fabricUtils.createPolygon({ - name: polygonId, - points: polygonPoints, - isPolyline: true, - }); + // Add object to canvas and set it as ACTIVE editor.canvas.add(newPolygon); - } + editor.canvas.setActiveObject(newPolygon); + } else if (isDrawingObject && drawingObjectType === "rectangle") { + const rectangle = fabricUtils.findObjectByName( + editor.canvas, + drawingObject.id, + ); - opt.e.preventDefault(); - opt.e.stopPropagation(); + if (rectangle) editor.canvas.remove(rectangle); + + if (!this.lastClickCoords) return; + + const newPoints = [ + { x: this.lastClickCoords?.x, y: this.lastClickCoords?.y }, + { x: pointer.x, y: this.lastClickCoords?.y }, + { x: pointer.x, y: pointer.y }, + { x: this.lastClickCoords?.x, y: pointer.y }, + ]; + // Draw a new rectangle from scratch + const newRectangle = fabricUtils.createControllableCustomObject( + fabric.Polygon, + newPoints, + { name: drawingObject.id }, + true, + ); + + // // Add object to canvas and set it as ACTIVE + editor.canvas.add(newRectangle); + editor.canvas.setActiveObject(newRectangle); + } }, ); - editor.canvas.on("mouse:up", function (this: CanvasAnnotationState, opt) { - this.isDragging = false; - this.selection = true; - - opt.e.preventDefault(); - opt.e.stopPropagation(); - }); - // Selected Objects editor.canvas.on( "selection:created", - function (this: CanvasAnnotationState, opt) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function (this: fabricTypes.CanvasAnnotationState, _opt) { // console.log("SELECTED! ", opt.selected?.[0]); - - opt.e.preventDefault(); - opt.e.stopPropagation(); }, ); - // editor.canvas.on("mouse:over", function (this: CanvasAnnotationState, opt) { - // opt.target?.set("fill", "green"); - // editor.canvas.renderAll(); - - // opt.e.preventDefault(); - // opt.e.stopPropagation(); - // }); - - // editor.canvas.on("mouse:out", function (this: CanvasAnnotationState, opt) { - // opt.target?.set("fill", "rgba(255,127,39,1)"); - // editor.canvas.renderAll(); - - // opt.e.preventDefault(); - // opt.e.stopPropagation(); - // }); + // Objects Moving + editor.canvas.on( + "object:modified", + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function (this: fabricTypes.CanvasAnnotationState, _opt) { + const obj = _opt.target as fabricTypes.CustomObject | undefined; + if (obj) { + console.log(`Object ["${obj.name}"] modified event`); + } + }, + ); editor.canvas.renderAll(); + + // TODO: Need to verify this + // Clear all canvas events when the status changes. + return () => { + editor.canvas.off(); + }; }, [ - primary, draggingEnabled, editor, image, onLoadedImage, onZoomChange, - drawingPolygon, + drawingObject, ]); // Update zoom parent value @@ -352,35 +436,34 @@ const Board = React.forwardRef( // Load Initial items useEffect(() => { - const toScaledCoord = (coord: { x: number; y: number }) => { - const height = editor?.canvas.getHeight() ?? 1; - const width = editor?.canvas.getWidth() ?? 1; - const x = - width / 2 - (imageSize.width * scaleRatio) / 2 + coord.x * scaleRatio; - const y = - height / 2 - - (imageSize.height * scaleRatio) / 2 + - coord.y * scaleRatio; - - return { x, y }; - }; - const canvas = editor?.canvas; if (!canvas) return; // Clear all objects from canvas - fabricUtils.deleteAll(editor?.canvas); + fabricActions.deleteAll(editor?.canvas); for (const item of items) { - const polygon = new fabric.Polygon(item.coords.map(toScaledCoord), { - name: `ID_${item.id}`, - fill: undefined, - stroke: "red", - strokeWidth: 1, // TODO: Change here! - }); + const scaledCoords = item.coords.map((p) => + fabricUtils.toScaledCoord({ + cInfo: { width: canvas.getWidth(), height: canvas.getHeight() }, + iInfo: { + width: imageSize.width, + height: imageSize.height, + }, + coord: p, + scaleRatio, + }), + ); + + const polygon = fabricUtils.createControllableCustomObject( + fabric.Polygon, + scaledCoords, + { name: `ID_${item.id}` }, + scaledCoords.length === 4, // Is a rectangle + ); canvas.add(polygon); } - }, [editor?.canvas, imageSize.height, imageSize.width, items, scaleRatio]); + }, [editor?.canvas, imageSize.width, imageSize.height, items, scaleRatio]); // const renderIcon = ( // ctx: CanvasRenderingContext2D, diff --git a/src/components/Board/__docs__/Board.mdx b/src/components/Board/__docs__/Board.mdx index ef422fd..ebacc13 100644 --- a/src/components/Board/__docs__/Board.mdx +++ b/src/components/Board/__docs__/Board.mdx @@ -22,7 +22,6 @@ const Example = () => { const ref = React.createRef(); return ( ; export const Main: Story = { args: { - primary: true, image: { name: "holder-min", src: "holder-min.jpg" }, items: ITEMS, }, diff --git a/src/components/Board/__docs__/Example.tsx b/src/components/Board/__docs__/Example.tsx index b55f58f..2adbff6 100644 --- a/src/components/Board/__docs__/Example.tsx +++ b/src/components/Board/__docs__/Example.tsx @@ -1,10 +1,12 @@ import React, { FC, useState } from "react"; import Board, { BoardActions, BoardProps } from "../Board"; -const Example: FC = ({ primary = true, items, image }) => { +const Example: FC = ({ items, image }) => { const ref = React.createRef(); const [toggleStatus, setToggleStatus] = useState(false); + const [isDrawingPolygon, setIsDrawingPolygon] = useState(false); + const [isDrawingRectangle, setIsDrawingRectangle] = useState(false); const [currentZoom, setCurrentZoom] = useState(); return ( @@ -17,15 +19,32 @@ const Example: FC = ({ primary = true, items, image }) => { - + + - -
@@ -48,7 +67,6 @@ const Example: FC = ({ primary = true, items, image }) => { > setToggleStatus(s)} diff --git a/src/fabric/actions.ts b/src/fabric/actions.ts new file mode 100644 index 0000000..6d2f941 --- /dev/null +++ b/src/fabric/actions.ts @@ -0,0 +1,126 @@ +import { findObjectByName } from "./utils"; + +/** + * + * Deletes an object + * + * @param canvas html canvas to look for the object + * @param obj object to delete + */ +export const deleteObject = (canvas: fabric.Canvas, obj: fabric.Object) => { + if (obj) canvas.remove(obj); +}; + +/** + * + * Deletes all objects from the canvas + * + * @param canvas fabricjs HTML canvas + */ +export const deleteAll = (canvas: fabric.Canvas) => { + canvas?.getObjects().forEach((o) => canvas?.remove(o)); + canvas?.discardActiveObject(); + canvas?.renderAll(); +}; + +/** + * + * Deletes all selected objects from the canvas + * + * @param canvas fabricjs HTML canvas + */ +export const deleteSelected = (canvas: fabric.Canvas) => { + const activeObjects = canvas.getActiveObjects(); + if (activeObjects) { + activeObjects.forEach((activeObject) => { + canvas.remove(activeObject); + }); + canvas.discardActiveObject(); + } +}; + +/** + * + * Deletes an object by its name + * + * @param canvas html canvas to look for the object + * @param name object identifier + */ +export const deleteObjectByName = (canvas: fabric.Canvas, name: string) => { + const obj = findObjectByName(canvas, name); + if (obj) canvas.remove(obj); +}; + +/** + * + * Applies an input image to a canvas and downloads the image from it. (TODO: later on it will be downloaded with the annotations if specified) + * + * @param image properties and resources + */ +export const canvasImageDownload = (image: { name: string; src: string }) => { + // Create a temporary canvas to compose original image and annotations + const tempCanvas = document.createElement("canvas"); + const tempCtx = tempCanvas.getContext("2d")!; + + // Get the original image data from the canvas + const originalImageSrc = image.src; // Provide the path to your original image + const originalImage = new Image(); + originalImage.src = originalImageSrc; + + // Wait for the original image to load before composing + originalImage.onload = function () { + // Set the size of the temporary canvas to match the original image + tempCanvas.width = originalImage.width; + tempCanvas.height = originalImage.height; + + // Draw the original image onto the temporary canvas + tempCtx.drawImage(originalImage, 0, 0); + + // Get the Fabric.js canvas instance + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + // const canvas = editor?.canvas!; + // const fabricCanvas = canvas.getObjects(); + // console.log(fabricCanvas[0]); + + // items.forEach((item) => { + // const polygon = new fabric.Polygon(item.coords, { + // name: `ID_${item.id}`, + // fill: undefined, + // stroke: "red", + // strokeWidth: 1, + // }); + // // tempCtx.save(); + // polygon.render(tempCtx); + // // tempCtx.restore(); + // }); + + // Loop through all objects on the Fabric.js canvas and draw them onto the temporary canvas + // fabricCanvas.forEach((obj) => { + // const scaleFactorX = tempCanvas.width / canvas.width!; + // const scaleFactorY = tempCanvas.height / canvas.height!; + + // console.log({ scaleFactorX, scaleFactorY }); + + // // Adjust top and left positions based on the scale + // const left = obj.left! * scaleFactorX; + // const top = obj.top! * scaleFactorY; + + // tempCtx.save(); + // tempCtx.translate(0, 0); + // tempCtx.scale(scaleFactorX, scaleFactorY); + // obj.render(tempCtx); + // tempCtx.restore(); + // }); + + // Convert the composed image on the temporary canvas to a data URL + const composedDataURL = tempCanvas.toDataURL("image/png"); + + // Create a temporary anchor element + const link = document.createElement("a"); + link.href = composedDataURL; + link.download = image.name; // Set the desired filename + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; +}; diff --git a/src/fabric/const.ts b/src/fabric/const.ts new file mode 100644 index 0000000..44dc27d --- /dev/null +++ b/src/fabric/const.ts @@ -0,0 +1,12 @@ +export const DEFAULT_POLYLINE_OPTIONS: fabric.IPolylineOptions = { + stroke: "red", + fill: "rgba(255, 99, 71, 0.2)", + strokeWidth: 1, + selectable: true, + hasBorders: false, + hasControls: true, + cornerStyle: "circle", + cornerColor: "rgba(113, 113, 117, 0.5)", + objectCaching: false, + strokeUniform: true, +}; diff --git a/src/fabric/controls/CustomControl.ts b/src/fabric/controls/CustomControl.ts new file mode 100644 index 0000000..3f60f9f --- /dev/null +++ b/src/fabric/controls/CustomControl.ts @@ -0,0 +1,20 @@ +import { fabric } from "fabric"; + +/** + * Custom FabricJS Control class with extra pointIndex + */ +class CustomControl extends fabric.Control { + // Add an extra field pointIndex + pointIndex: number; + + // Override the constructor to include the new field + constructor(options: Partial, pointIndex: number) { + // Call the constructor of the base class + super(options); + + // Initialize the new field + this.pointIndex = pointIndex; + } +} + +export default CustomControl; diff --git a/src/fabric/types.ts b/src/fabric/types.ts new file mode 100644 index 0000000..be8b75d --- /dev/null +++ b/src/fabric/types.ts @@ -0,0 +1,57 @@ +// Types from FabricJS + +import { Transform } from "fabric/fabric-impl"; + +export type TMat2D = [ + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, +]; + +export type CustomObject = fabric.Object & { + __corner?: string; + pathOffset?: { x: number; y: number }; + points?: fabric.Point[]; + oCoords: { + [key: string]: + | { + x: number; + y: number; + corner: { + tl: fabric.Point; + tr: fabric.Point; + bl: fabric.Point; + br: fabric.Point; + }; + } + | undefined; + }; + setPositionByOrigin?: ( + pos: fabric.Point, + originX: number, + originY: number, + ) => void; + _setPositionDimensions?: (o: unknown) => unknown; // TODO: Understand why this needs to be here. Migrate to 'setDimensions'. +}; + +export type CustomTransform = Transform & { + target: CustomObject; +}; + +export type CanvasAnnotationState = { + selection?: boolean; + lastPosX: number; + lastPosY: number; + isDragging?: boolean; + lastClickCoords?: { x: number; y: number }; + + drawingObject?: { + id?: string; + type: "polygon" | "rectangle"; + isDrawing: boolean; + points: { x: number; y: number }[]; + }; +}; diff --git a/src/fabric/utils.ts b/src/fabric/utils.ts new file mode 100644 index 0000000..0b8d4f6 --- /dev/null +++ b/src/fabric/utils.ts @@ -0,0 +1,321 @@ +import { fabric } from "fabric"; +import * as fabricTypes from "./types"; +import { IPolylineOptions } from "fabric/fabric-impl"; +import CustomControl from "./controls/CustomControl"; +import { DEFAULT_POLYLINE_OPTIONS } from "./const"; + +/** + * + * Transforms a coordinate into the original coordinate from input + * + * @param cInfo canvas width and height + * @param iInfo image width and height + * @param coord coord to transform + * @param scaleRatio current zoom/scale + * @returns + */ +export const toOriginalCoord = ({ + cInfo, + iInfo, + coord, + scaleRatio, +}: { + cInfo: { + width: number; + height: number; + }; + iInfo: { + width: number; + height: number; + }; + coord: fabric.Point; + scaleRatio: number; +}) => { + const unscaledX = + (coord.x - cInfo.width / 2 + (iInfo.width * scaleRatio) / 2) / scaleRatio; + const unscaledY = + (coord.y - cInfo.height / 2 + (iInfo.height * scaleRatio) / 2) / scaleRatio; + + return { x: unscaledX, y: unscaledY }; +}; + +/** + * + * Transforms a coordinate into a canvas-scaled coordinate, making it compatible for use within the canvas. + * + * @param cInfo canvas width and height + * @param iInfo image width and height + * @param coord coord to transform + * @param scaleRatio current zoom/scale + * @returns + */ +export const toScaledCoord = ({ + cInfo, + iInfo, + coord, + scaleRatio, +}: { + cInfo: { + width: number; + height: number; + }; + iInfo: { + width: number; + height: number; + }; + coord: { x: number; y: number }; + scaleRatio: number; +}) => { + const x = + cInfo.width / 2 - (iInfo.width * scaleRatio) / 2 + coord.x * scaleRatio; + const y = + cInfo.height / 2 - (iInfo.height * scaleRatio) / 2 + coord.y * scaleRatio; + + return { x, y }; +}; + +/** + * + * Retrieves an object points in the canvas + * + * @param obj custom object + * @returns + */ +export const pointsInCanvas = (obj?: fabricTypes.CustomObject) => { + if (!obj) return []; + return ( + obj.points?.map((p) => { + const matrix = obj.calcOwnMatrix(); + const minX = Math.min(...obj.points!.map((_p) => _p.x)); + const minY = Math.min(...obj.points!.map((_p) => _p.y)); + const tmpPoint = new fabric.Point( + p.x - minX - obj.width! / 2, + p.y - minY - obj.height! / 2, + ); + return fabric.util.transformPoint(tmpPoint, matrix); + }) ?? [] + ); +}; + +/** + * + * Retrieves all available objects in canvas + * + * @param canvas html canvas to look for the object + * @returns + */ +export const retrieveObjects = ( + canvas: fabric.Canvas, +) => { + const obj = canvas.getObjects(); + return obj as T[] | undefined; +}; + +/** + * + * Finds an object in a canvas according to its name + * + * @param canvas html canvas to look for the object + * @param name object identifier + * @returns + */ +export const findObjectByName = ( + canvas: fabric.Canvas, + name?: string, +): T | undefined => { + if (name === undefined) return undefined; + const obj = canvas.getObjects().find((o) => o.name === name); + return obj as T | undefined; +}; + +/** + * + * @param points array of points to create the polygon + * @param options options of the polygon (fill, stroke, controls..) + * @returns + */ +export const createControllableCustomObject = < + T extends fabric.Polygon | fabric.Polyline, +>( + FabricObj: new ( + points: Array<{ x: number; y: number }>, + options?: IPolylineOptions, + ) => T, + points: { x: number; y: number }[], + options?: IPolylineOptions, + isRectangle?: boolean, +) => { + const _options: fabric.IPolylineOptions = Object.assign( + DEFAULT_POLYLINE_OPTIONS, + options, + ); + + const controllableObject = new FabricObj(points, _options); + if (isRectangle !== true && (controllableObject.points?.length ?? 0) > 0) { + const controls = controllableObject.points?.reduce<{ + [key: string]: CustomControl; + }>((acc, _point, index) => { + acc["p" + index] = new CustomControl( + { + positionHandler: polygonPositionHandler, + actionHandler: anchorWrapper( + index > 0 ? index - 1 : controllableObject.points!.length - 1, + actionHandler, + ), + actionName: "modifyPolygon", + }, + index, + ); + return acc; + }, {}); + if (controls && Object.keys(controls).length > 0) + controllableObject.controls = controls; + } + return controllableObject as unknown as fabricTypes.CustomObject; // TODO: Maybe we can do this in a better way +}; + +/** + * Define a function that can locate the controls. + * This function will be used both for drawing and for interaction. + * More info: http://fabricjs.com/custom-controls-polygon + */ +export const polygonPositionHandler = function ( + this: CustomControl, + _dim: { x: number; y: number }, + _finalMatrix: fabricTypes.TMat2D, + fabricObject: fabric.Polyline, +) { + const x = + fabricObject!.points![this!.pointIndex!].x - fabricObject.pathOffset.x; + const y = + fabricObject!.points![this!.pointIndex!].y - fabricObject.pathOffset.y; + + // Ignore transformation if object doesn't exist + if (!fabricObject?.canvas?.viewportTransform) { + console.log(`Ignore the transformation! [objectId: ${fabricObject.name}]`); + return new fabric.Point(0, 0); + } + return fabric.util.transformPoint( + new fabric.Point(x, y), + fabric.util.multiplyTransformMatrices( + fabricObject.canvas.viewportTransform, + fabricObject.calcTransformMatrix(), + ), + ); +}; + +/** + * More info: http://fabricjs.com/custom-controls-polygon + */ +export const getObjectSizeWithStroke = (object: fabric.Object) => { + const stroke = new fabric.Point( + object.strokeUniform ? 1 / object.scaleX! : 1, + object.strokeUniform ? 1 / object.scaleY! : 1, + ).multiply(object.strokeWidth!); + return new fabric.Point(object.width! + stroke.x, object.height! + stroke.y); +}; + +/** + * Define a function that will define what the control does + * This function will be called on every mouse move after a control has been clicked and is being dragged. + * The function receive as argument the mouse event, the current trasnform object and the current position in canvas coordinate + * transform.target is a reference to the current object being transformed, + * + * More info: http://fabricjs.com/custom-controls-polygon + */ +export const actionHandler = ( + _eventData: MouseEvent, + transform: fabricTypes.CustomTransform, + x: number, + y: number, +) => { + const polygon = transform.target; + const currentControl = polygon.controls[polygon.__corner!] as CustomControl; + const mouseLocalPosition = polygon.toLocalPoint( + new fabric.Point(x, y), + "center", + "center", + ); + const polygonBaseSize = getObjectSizeWithStroke(polygon); + const size = polygon._getTransformedDimensions(0, 0); + const finalPointPosition = { + x: + (mouseLocalPosition.x * polygonBaseSize.x) / size.x + + polygon.pathOffset!.x, + y: + (mouseLocalPosition.y * polygonBaseSize.y) / size.y + + polygon.pathOffset!.y, + }; + polygon.points![currentControl.pointIndex] = new fabric.Point( + finalPointPosition.x, + finalPointPosition.y, + ); + return true; +}; + +/** + * Define a function that can keep the polygon in the same position when we change its width/height/top/left. + * + * More info: http://fabricjs.com/custom-controls-polygon + */ +export const anchorWrapper = ( + anchorIndex: number, + fn: ( + eventData: MouseEvent, + transform: fabricTypes.CustomTransform, + x: number, + y: number, + ) => boolean, +) => { + return function ( + eventData: MouseEvent, + transform: fabricTypes.CustomTransform, + x: number, + y: number, + ) { + const fabricObject = transform.target; + + // Ensure object has points and pathoffset + if (!fabricObject.points || !fabricObject.pathOffset) return false; + + const point = new fabric.Point( + fabricObject.points[anchorIndex].x - fabricObject.pathOffset.x, + fabricObject.points[anchorIndex].y - fabricObject.pathOffset.y, + ); + const absolutePoint = fabric.util.transformPoint( + point, + fabricObject.calcTransformMatrix(), + ); + const actionPerformed = fn(eventData, transform, x, y); + fabricObject._setPositionDimensions?.({}); // TODO: Understand why this needs to be here. Migrate to 'setDimensions'. + const polygonBaseSize = getObjectSizeWithStroke(fabricObject); + const newX = + (fabricObject.points[anchorIndex].x - fabricObject.pathOffset.x) / + polygonBaseSize.x; + const newY = + (fabricObject.points[anchorIndex].y - fabricObject.pathOffset.y) / + polygonBaseSize.y; + fabricObject.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5); + return actionPerformed; + }; +}; + +export const isCoordInsideCoords = ( + point: { x: number; y: number }, + vertices: { + tl: { x: number; y: number }; + tr: { x: number; y: number }; + bl: { x: number; y: number }; + br: { x: number; y: number }; + }, +) => { + const { tl, tr, bl } = vertices; + let isInside = false; + + // Check if the point is inside the rectangle formed by the vertices + if (point.x > tl.x && point.x < tr.x && point.y > tl.y && point.y < bl.y) { + isInside = true; + } + + return isInside; +}; diff --git a/src/fabricTypes.ts b/src/fabricTypes.ts deleted file mode 100644 index f931e8e..0000000 --- a/src/fabricTypes.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Types from FabricJS - -import { Transform } from "fabric/fabric-impl"; - -export type TMat2D = [ - a: number, - b: number, - c: number, - d: number, - e: number, - f: number, -]; - -export type CustomTransform = Transform & { - target: fabric.Object & { - __corner?: string; - pathOffset?: { x: number; y: number }; - points?: fabric.Point[]; - setPositionByOrigin?: ( - pos: fabric.Point, - originX: number, - originY: number, - ) => void; - _setPositionDimensions?: (o: unknown) => unknown; // TODO: Understand why this needs to be here. Migrate to 'setDimensions'. - }; -}; diff --git a/src/fabricUtils.ts b/src/fabricUtils.ts deleted file mode 100644 index 9c93685..0000000 --- a/src/fabricUtils.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { fabric } from "fabric"; -import * as fabricTypes from "./fabricTypes"; - -export const toPolygon = (object: fabric.Polyline) => { - return new fabric.Polygon(object.points!, { - name: object.name, - fill: object.fill, - stroke: object.stroke, - strokeWidth: object.strokeWidth, - hasBorders: object.hasBorders, - hasControls: object.hasControls, - }); -}; - -/** - * - * Deletes all objects from the canvas - * - * @param canvas fabricjs HTML canvas - */ -export const deleteAll = (canvas: fabric.Canvas) => { - canvas?.getObjects().forEach((o) => canvas?.remove(o)); - canvas?.discardActiveObject(); - canvas?.renderAll(); -}; - -/** - * - * Deletes all selected objects from the canvas - * - * @param canvas fabricjs HTML canvas - */ -export const deleteSelected = (canvas: fabric.Canvas) => { - const activeObjects = canvas.getActiveObjects(); - if (activeObjects) { - activeObjects.forEach((activeObject) => { - canvas.remove(activeObject); - }); - canvas.discardActiveObject(); - } -}; - -/** - * - * Applies an input image to a canvas and downloads the image from it. (TODO: later on it will be downloaded with the annotations if specified) - * - * @param image properties and resources - */ -export const canvasImageDownload = (image: { name: string; src: string }) => { - // Create a temporary canvas to compose original image and annotations - const tempCanvas = document.createElement("canvas"); - const tempCtx = tempCanvas.getContext("2d")!; - - // Get the original image data from the canvas - const originalImageSrc = image.src; // Provide the path to your original image - const originalImage = new Image(); - originalImage.src = originalImageSrc; - - // Wait for the original image to load before composing - originalImage.onload = function () { - // Set the size of the temporary canvas to match the original image - tempCanvas.width = originalImage.width; - tempCanvas.height = originalImage.height; - - // Draw the original image onto the temporary canvas - tempCtx.drawImage(originalImage, 0, 0); - - // Get the Fabric.js canvas instance - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - // const canvas = editor?.canvas!; - // const fabricCanvas = canvas.getObjects(); - // console.log(fabricCanvas[0]); - - // items.forEach((item) => { - // const polygon = new fabric.Polygon(item.coords, { - // name: `ID_${item.id}`, - // fill: undefined, - // stroke: "red", - // strokeWidth: 1, - // }); - // // tempCtx.save(); - // polygon.render(tempCtx); - // // tempCtx.restore(); - // }); - - // Loop through all objects on the Fabric.js canvas and draw them onto the temporary canvas - // fabricCanvas.forEach((obj) => { - // const scaleFactorX = tempCanvas.width / canvas.width!; - // const scaleFactorY = tempCanvas.height / canvas.height!; - - // console.log({ scaleFactorX, scaleFactorY }); - - // // Adjust top and left positions based on the scale - // const left = obj.left! * scaleFactorX; - // const top = obj.top! * scaleFactorY; - - // tempCtx.save(); - // tempCtx.translate(0, 0); - // tempCtx.scale(scaleFactorX, scaleFactorY); - // obj.render(tempCtx); - // tempCtx.restore(); - // }); - - // Convert the composed image on the temporary canvas to a data URL - const composedDataURL = tempCanvas.toDataURL("image/png"); - - // Create a temporary anchor element - const link = document.createElement("a"); - link.href = composedDataURL; - link.download = image.name; // Set the desired filename - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; -}; - -/** - * - * Finds an object in a canvas according to its name - * - * @param canvas html canvas to look for the object - * @param name object identifier - * @returns - */ -export const findObjectByName = (canvas: fabric.Canvas, name: string) => { - return canvas.getObjects().find((o) => o.name === name); -}; - -/** - * - * Deletes an object by its name - * - * @param canvas html canvas to look for the object - * @param name object identifier - */ -export const deleteObjectByName = (canvas: fabric.Canvas, name: string) => { - const obj = findObjectByName(canvas, name); - if (obj) canvas.remove(obj); -}; - -/** - * - * Deletes an object - * - * @param canvas html canvas to look for the object - * @param obj object to delete - */ -export const deleteObject = (canvas: fabric.Canvas, obj: fabric.Object) => { - if (obj) canvas.remove(obj); -}; - -/** - * - * @param name name of the object - * @param points array of points to create the polygon - * @param options options of the polygon (fill, stroke, controls..) - * @param isPolyline if it should create a the polygon as a polyline - * @returns - */ -export const createPolygon = ({ - name, - points, - options = { - fill: "rgba(255,0,0,0.4)", - stroke: "red", - strokeWidth: 2, - hasBorders: false, - hasControls: false, - }, - isPolyline = false, -}: { - name: string; - points: { x: number; y: number }[]; - options?: { - fill?: string; - stroke?: string; - strokeWidth?: number; - hasBorders?: boolean; - hasControls?: boolean; - }; - isPolyline?: boolean; -}) => { - if (isPolyline) { - return new fabric.Polyline(points, { - name, - ...options, - }); - } - return new fabric.Polygon(points, { - name, - ...options, - }); -}; - -/** - * Custom FabricJS Control class with extra pointIndex - */ -export class CustomControl extends fabric.Control { - // Add an extra field pointIndex - pointIndex: number; - - // Override the constructor to include the new field - constructor(options: Partial, pointIndex: number) { - // Call the constructor of the base class - super(options); - - // Initialize the new field - this.pointIndex = pointIndex; - } -} - -/** - * Define a function that can locate the controls. - * This function will be used both for drawing and for interaction. - * More info: http://fabricjs.com/custom-controls-polygon - */ -export const polygonPositionHandler = function ( - this: CustomControl, - _dim: { x: number; y: number }, - _finalMatrix: fabricTypes.TMat2D, - fabricObject: fabric.Polyline, -) { - const x = - fabricObject!.points![this!.pointIndex!].x - fabricObject.pathOffset.x; - const y = - fabricObject!.points![this!.pointIndex!].y - fabricObject.pathOffset.y; - return fabric.util.transformPoint( - new fabric.Point(x, y), - fabric.util.multiplyTransformMatrices( - fabricObject!.canvas!.viewportTransform!, - fabricObject.calcTransformMatrix(), - ), - ); -}; - -/** - * More info: http://fabricjs.com/custom-controls-polygon - */ -export const getObjectSizeWithStroke = (object: fabric.Object) => { - const stroke = new fabric.Point( - object.strokeUniform ? 1 / object.scaleX! : 1, - object.strokeUniform ? 1 / object.scaleY! : 1, - ).multiply(object.strokeWidth!); - return new fabric.Point(object.width! + stroke.x, object.height! + stroke.y); -}; - -/** - * Define a function that will define what the control does - * This function will be called on every mouse move after a control has been clicked and is being dragged. - * The function receive as argument the mouse event, the current trasnform object and the current position in canvas coordinate - * transform.target is a reference to the current object being transformed, - * - * More info: http://fabricjs.com/custom-controls-polygon - */ -export const actionHandler = ( - _eventData: MouseEvent, - transform: fabricTypes.CustomTransform, - x: number, - y: number, -) => { - const polygon = transform.target; - const currentControl = polygon.controls[polygon.__corner!] as CustomControl; - const mouseLocalPosition = polygon.toLocalPoint( - new fabric.Point(x, y), - "center", - "center", - ); - const polygonBaseSize = getObjectSizeWithStroke(polygon); - const size = polygon._getTransformedDimensions(0, 0); - const finalPointPosition = { - x: - (mouseLocalPosition.x * polygonBaseSize.x) / size.x + - polygon.pathOffset!.x, - y: - (mouseLocalPosition.y * polygonBaseSize.y) / size.y + - polygon.pathOffset!.y, - }; - polygon.points![currentControl.pointIndex] = new fabric.Point( - finalPointPosition.x, - finalPointPosition.y, - ); - return true; -}; - -/** - * Define a function that can keep the polygon in the same position when we change its width/height/top/left. - * - * More info: http://fabricjs.com/custom-controls-polygon - */ -export const anchorWrapper = ( - anchorIndex: number, - fn: ( - eventData: MouseEvent, - transform: fabricTypes.CustomTransform, - x: number, - y: number, - ) => boolean, -) => { - return function ( - eventData: MouseEvent, - transform: fabricTypes.CustomTransform, - x: number, - y: number, - ) { - const fabricObject = transform.target; - const points = fabricObject.points!; - const pathOffset = fabricObject.pathOffset!; - const point = new fabric.Point( - points[anchorIndex].x - pathOffset.x, - points[anchorIndex].y - pathOffset.y, - ); - const absolutePoint = fabric.util.transformPoint( - point, - fabricObject.calcTransformMatrix(), - ); - const actionPerformed = fn(eventData, transform, x, y); - fabricObject._setPositionDimensions?.({}); // TODO: Understand why this needs to be here. Migrate to 'setDimensions'. - const polygonBaseSize = getObjectSizeWithStroke(fabricObject); - const newX = (points[anchorIndex].x - pathOffset.x) / polygonBaseSize.x; - const newY = (points[anchorIndex].y - pathOffset.y) / polygonBaseSize.y; - fabricObject.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5); - return actionPerformed; - }; -}; diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..f286e9a --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; +import { isCoordInsideCoords } from "../src/fabric/utils"; + +const vertices1 = { + tl: { x: 40, y: 40 }, + tr: { x: 80, y: 40 }, + bl: { x: 40, y: 80 }, + br: { x: 80, y: 80 }, +}; + +const vertices2 = { + tl: { + x: 598.2890624998763, + y: 92.49999999987634, + }, + tr: { + x: 611.2890625001237, + y: 92.49999999987634, + }, + bl: { + x: 598.2890624998763, + y: 105.50000000012366, + }, + br: { + x: 611.2890625001237, + y: 105.50000000012366, + }, +}; + +describe("Point inside Coords", () => { + test("Outside coords", () => { + expect(isCoordInsideCoords({ x: 61, y: 25.5 }, vertices1)).toBeFalsy(); + expect(isCoordInsideCoords({ x: 94, y: 58.5 }, vertices1)).toBeFalsy(); + expect(isCoordInsideCoords({ x: 60, y: 84.5 }, vertices1)).toBeFalsy(); + expect(isCoordInsideCoords({ x: 31, y: 56.5 }, vertices1)).toBeFalsy(); + }); + + test("Inside coords", () => { + expect(isCoordInsideCoords({ x: 49, y: 46.5 }, vertices1)).toBeTruthy(); + expect(isCoordInsideCoords({ x: 61, y: 56.5 }, vertices1)).toBeTruthy(); + expect( + isCoordInsideCoords({ x: 607.7890625, y: 98 }, vertices2), + ).toBeTruthy(); + }); +});