diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b4fd02..7a79f2c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,12 @@ "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", - "eslint.validate": ["javascript", "javascriptreact", "typescript"] + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript" + ], + "[sql]": { + "editor.defaultFormatter": "adpyke.vscode-sql-formatter" + } } \ No newline at end of file diff --git a/example/src/App.tsx b/example/src/App.tsx index a16ef86..5707507 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -5,7 +5,7 @@ import "./App.css"; function App() { return ( <> - + ); } diff --git a/src/components/Board/Board.tsx b/src/components/Board/Board.tsx index ffa5dde..2336477 100644 --- a/src/components/Board/Board.tsx +++ b/src/components/Board/Board.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { fabric } from "fabric"; import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react"; import { CanvasObject } from "./types"; +import * as fabricUtils from "../../fabricUtils"; export type BoardProps = { primary?: boolean; @@ -29,6 +30,9 @@ export type BoardActions = { resetZoom: () => void; deleteSelectedObjects: () => void; downloadImage: () => void; + drawPolygon: () => void; + randomAction1: () => void; + randomAction2: () => void; }; type CanvasAnnotationState = { @@ -36,6 +40,9 @@ type CanvasAnnotationState = { lastPosX: number; lastPosY: number; isDragging?: boolean; + drawingPolygon?: boolean; + lastClickCoords?: { x: number; y: number }; + polygonPoints?: { x: number; y: number }[]; }; const Board = React.forwardRef( @@ -65,85 +72,62 @@ const Board = React.forwardRef( onResetZoom?.(); }, deleteSelectedObjects() { - editor?.deleteSelected(); - const activeObjects = editor?.canvas.getActiveObjects(); - if (activeObjects) { - activeObjects.forEach((activeObject) => { - editor?.canvas.remove(activeObject); - }); - editor?.canvas.discardActiveObject(); + const canvas = editor?.canvas; + if (canvas) fabricUtils.deleteSelected(canvas); + }, + drawPolygon() { + setDrawingPolygon(!drawingPolygon); + }, + 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; } + editor?.canvas.add(line); + }, + randomAction2() { + console.log("randomAction2"); }, downloadImage() { - // 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); - }; + fabricUtils.canvasImageDownload(image); }, })); const { editor, onReady } = useFabricJSEditor(); + const [drawingPolygon, setDrawingPolygon] = useState(false); + const [currentZoom, setCurrentZoom] = useState( initialStatus?.currentZoom || 100, ); @@ -151,12 +135,12 @@ const Board = React.forwardRef( const [scaleRatio, setScaleRation] = useState( initialStatus?.scaleRatio || 100, ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [imageSize, setImageSize] = useState({ width: 0, height: 0, }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [draggingEnabled, setDraggingEnabled] = useState( initialStatus?.draggingEnabled || false, ); @@ -207,6 +191,7 @@ const Board = React.forwardRef( editor.canvas.on( "mouse:wheel", function (this: CanvasAnnotationState, opt) { + if (this.drawingPolygon) return; const delta = opt.e.deltaY; let zoom = editor.canvas.getZoom(); zoom *= 0.999 ** delta; @@ -230,9 +215,43 @@ const Board = React.forwardRef( this.selection = false; this.lastPosX = evt.clientX; this.lastPosY = evt.clientY; + this.drawingPolygon = drawingPolygon; + + // 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); + } else { + this.polygonPoints = [this.lastClickCoords]; + } - const pointer = editor.canvas.getPointer(evt); - console.log(`x: ${pointer.x} y: ${pointer.y}`); + if (this.polygonPoints?.length === 4) { + setDrawingPolygon(false); + return; + } + + // 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); + // } + } opt.e.preventDefault(); opt.e.stopPropagation(); }, @@ -251,6 +270,28 @@ const Board = React.forwardRef( this.lastPosX = e.clientX; this.lastPosY = e.clientY; } + } else if (this.drawingPolygon) { + const pointer = editor?.canvas.getPointer(opt.e); + + const polygonId = "polygonId"; + const previousPolygon = fabricUtils.findObjectByName( + editor.canvas, + polygonId, + ); + + if (previousPolygon) + fabricUtils.deleteObject(editor.canvas, previousPolygon); + + // Polygon "clicked" points with the cursor current pointer + const polygonPoints = + this.polygonPoints?.concat({ x: pointer.x, y: pointer.y }) ?? []; + + const newPolygon = fabricUtils.createPolygon({ + name: polygonId, + points: polygonPoints, + isPolyline: true, + }); + editor.canvas.add(newPolygon); } opt.e.preventDefault(); @@ -294,7 +335,15 @@ const Board = React.forwardRef( // }); editor.canvas.renderAll(); - }, [primary, draggingEnabled, editor, image, onLoadedImage, onZoomChange]); + }, [ + primary, + draggingEnabled, + editor, + image, + onLoadedImage, + onZoomChange, + drawingPolygon, + ]); // Update zoom parent value useEffect(() => { @@ -316,37 +365,23 @@ const Board = React.forwardRef( return { x, y }; }; + const canvas = editor?.canvas; + if (!canvas) return; + // Clear all objects from canvas - editor?.canvas?.getObjects().forEach((o) => editor?.canvas?.remove(o)); - editor?.canvas?.discardActiveObject(); - editor?.canvas?.renderAll(); + fabricUtils.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: 0.3, + strokeWidth: 1, // TODO: Change here! }); - editor?.canvas.add(polygon); + canvas.add(polygon); } }, [editor?.canvas, imageSize.height, imageSize.width, items, scaleRatio]); - // const onAddRectangle = () => { - // // editor?.addRectangle(); - // const rect = new fabric.Rect({ - // name: "MERDAS", - // left: 0, - // top: 0, - // originX: "left", - // originY: "top", - // width: 100, - // height: 100, - // fill: "rgba(255,127,39,1)", - // selectable: true, - // visible: true, - // }); - // const renderIcon = ( // ctx: CanvasRenderingContext2D, // left: number, @@ -368,11 +403,6 @@ const Board = React.forwardRef( // ctx.restore(); // }; - // const onDelete = () => { - // editor?.deleteSelected(); - // return true; - // }; - // rect.controls = { // onDelete: new fabric.Control({ // x: 0.5, diff --git a/src/components/Board/__docs__/Example.tsx b/src/components/Board/__docs__/Example.tsx index 48373d2..b55f58f 100644 --- a/src/components/Board/__docs__/Example.tsx +++ b/src/components/Board/__docs__/Example.tsx @@ -17,9 +17,16 @@ const Example: FC = ({ primary = true, items, image }) => { + + +
= ({ primary = true, items, image }) => { Current zoom: {currentZoom}
+
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 new file mode 100644 index 0000000..9c93685 --- /dev/null +++ b/src/fabricUtils.ts @@ -0,0 +1,324 @@ +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; + }; +};