import { ActiveSelection, Canvas, Ellipse, Rect, Textbox, } from "fabric"; import { action, computed, makeObservable, observable } from "mobx"; import { FabricObjectAdapter } from "./FabricObjectAdapter"; import { FontInfo } from "./types/types"; import { FabricObjectPropertyList, FabricObjectPropertyType, FabricObjectPropertyValueType } from "./types/WrapperFabricType"; import InputColor from "./UserFirendlyInput/Color"; import InputNumber from "./UserFirendlyInput/Number"; import InputRange from "./UserFirendlyInput/Range"; import InputSelect from "./UserFirendlyInput/Select"; type toolListType = "select" | "rect" | "elipse" | "text" type objMoveType = 1 | 2 | -1 | -2 type objPositionType = "left" | "right" | "top" | "bottom" | "horizontally" | "vertically" type availablePropertyType = { name: string, inputType: FabricObjectPropertyType extends { type: infer V } ? V : never, property: FabricObjectPropertyType, value: FabricObjectPropertyValueType, onChange: (value: any) => void, UIComponent: React.ReactNode } export class ReactFabricStore { private fonts: FontInfo[] private cloneObjRef: { current: any } = { current: "" }; private isDrawing = false private drawColor = "black" /** @type Canvas * Provides the FabricCanvas instance for better control */ public _: Canvas; /** * List of property values for the currently selected object(s) */ @observable public accessor propertyValueList: FabricObjectPropertyList = {} /** * Currently selected drawing tool */ @observable public accessor selectedTool: toolListType /** * List of available tools and properties that can be accessed * Contains object arrays with: * - name: Name of the tool * - action: Function to invoke * - UIComponent: Default UI component */ @computed public get availableTools() { return { alignmentTools: [ { name: "Align left", action: () => this.alignObject("left"), UIComponent: }, { name: "Align horizontally", action: () => this.alignObject("horizontally"), UIComponent: }, { name: "Align right", action: () => this.alignObject("right"), UIComponent: }, { name: "Align top", action: () => this.alignObject("top"), UIComponent: }, { name: "Align vertically", action: () => this.alignObject("vertically"), UIComponent: }, { name: "Align bottom", action: () => this.alignObject("bottom"), UIComponent: }, ], positionTools: [ { action: () => this.moveObject(2), name: "Bring to front", UIComponent: }, { action: () => this.moveObject(1), name: "Bring Forward", UIComponent: }, { action: () => this.moveObject(-1), name: "Send backward", UIComponent: }, { action: () => this.moveObject(-2), name: "Send to back", UIComponent: }, { action: () => this.deleteActiveElement(), name: "Delete", UIComponent: }, ], creationTools: [ { action: () => this.selectTool("select"), name: "Select", UIComponent: }, { action: () => this.selectTool("rect"), name: "Rectangle", UIComponent: }, { action: () => this.selectTool("elipse"), name: "Elipse", UIComponent: }, { action: () => this.selectTool("text"), name: "Text", UIComponent: }, ] } } /** * List of available properties that can be modified for selected objects * Contains objects with: * - name: Property name * - inputType: Type of input control * - property: Property configuration * - value: Current value * - onChange: Handler for value changes * - UIComponent: Default UI component */ @computed public get availableProperties() { const availablePropertyList = []; // set canvas Width availablePropertyList.push({ inputType: "number", name: "canvasWidth", // TODO : make sure it works onChange: (value: number) => { if (this._.lowerCanvasEl) this._.setDimensions({ width: value }) this._.renderAll() }, property: { type: "number", value: this._.getWidth(), step: 5 }, value: this._.getWidth(), UIComponent:
{ if (this._.lowerCanvasEl) this._.setDimensions({ width: value }) this._.renderAll() }} />
}) // set Canvas Height availablePropertyList.push({ inputType: "number", name: "canvasHeight", // TODO : make sure it works onChange: (value: number) => { this._.setDimensions({ height: value }) this._.renderAll() }, property: { type: "number", value: this._.getHeight(), step: 5 }, value: this._.getHeight(), UIComponent:
{ if (this._.lowerCanvasEl) this._.setDimensions({ height: value }) this._.renderAll() }} />
}) // set Canvas Background availablePropertyList.push({ inputType: "color", name: "CanvasBackground", onChange: (value: string) => { this._.set("backgroundColor", value) this._.renderAll() }, property: { type: "color", value: this._.get("backgroundColor") }, value: this._.get("backgroundColor"), UIComponent:
{ this._.set("backgroundColor", value) this._.renderAll() }} />
}) availablePropertyList.push(...Object.keys(this.propertyValueList).map((property: string): availablePropertyType => { return { name: property, inputType: "number", property: this.propertyValueList[property], value: this.propertyValueList[property].value, onChange: (value: any) => { this.updatePropertyValue(property, value) }, UIComponent:
{this.propertyValueList[property].type === "number" && this.updatePropertyValue(property, value)} />} {this.propertyValueList[property].type === "range" && this.updatePropertyValue(property, value)} />} {this.propertyValueList[property].type === "color" && this.updatePropertyValue(property, value)} />} {this.propertyValueList[property].type === "enum" && ({ value: val, displayValue: val })) || []} value={{ value: this.propertyValueList[property].enum?.[0] || "", displayValue: this.propertyValueList[property].enum?.[0] || "", }} onChange={(value) => this.updatePropertyValue(property, value.value)} />} {this.propertyValueList[property].type === "font" && ({ value: font.name, displayValue: font.name })))} value={{ value: this.propertyValueList[property].value, displayValue: this.propertyValueList[property].value, }} onChange={(value) => this.updatePropertyValue(property, value.value)} />}
} })) return availablePropertyList; } constructor({ fabricCanvasInstance, fontList }: { fabricCanvasInstance: Canvas, fontList: FontInfo[] }) { // initialize the base state this.selectedTool = "select" // base canvas instance this._ = fabricCanvasInstance this.fonts = fontList // listening events this._.on("selection:cleared", this.updateSelectedObjPropertyList.bind(this)) this._.on("selection:created", this.updateSelectedObjPropertyList.bind(this)) this._.on("selection:updated", this.updateSelectedObjPropertyList.bind(this)) this._.on("object:modified", this.updateSelectedObjPropertyList.bind(this)) let obj, origX: number, origY: number; // Handle mouse click to create rectangle this._.on('mouse:down', (options) => { if (this.selectedTool === "select") { this.cloneObjRef.current = this._.getActiveObjects() return }; this.isDrawing = true if (options.e) { const pointer = this._.getPointer(options.e); origX = pointer.x origY = pointer.y if (this.selectedTool == "rect") obj = new Rect({ left: origX, top: origY, fill: this.drawColor, stroke: "red", strokeWidth: 1, borderColor: "blue", width: 0, height: 0, selectable: true, originX: "left", originY: "top", // strokeWidth: 1, }); else if (this.selectedTool == "elipse") obj = new Ellipse({ left: origX, top: origY, fill: this.drawColor, // Default fill color stroke: "red", strokeWidth: 1, borderColor: "blue", rx: 0, ry: 0, selectable: true, originX: "left", originY: "top", // strokeWidth: 1, }); else obj = new Textbox("", { left: origX, top: origY, textFill: this.drawColor, // Default fill color // stroke: "red", // strokeWidth: 1, // borderColor: "blue", selectable: true, originX: "left", originY: "top", // strokeWidth: 1, // cli width: 0, height: 0, fontSize: 20, lockScalingY: true, }); obj.set("wrap", "char") obj.set({ "strokeUniform": true }); this._.add(obj); this._.setActiveObject(obj); } this._.renderAll() }); this._.on('mouse:move', (options) => { // console.log(options.e.altKey, options.e.repeat) let canvasWidth = this._.getWidth(), canvasHeight = this._.getHeight() if (!this.isDrawing) return; // console.log(options); const pointer = this._.getPointer(options.e); let x = pointer.x, y = pointer.y; if (x > canvasWidth || y > canvasHeight || x < 0 || y < 0) return const activeObject = this._.getActiveObject(); if (activeObject) { if (this.selectedTool == "rect") activeObject.set({ width: Math.abs(pointer.x - origX), height: Math.abs(pointer.y - origY), left: Math.min(origX, pointer.x), top: Math.min(origY, pointer.y) }); else if (this.selectedTool == "elipse") activeObject.set({ rx: Math.abs(pointer.x - origX), ry: Math.abs(pointer.y - origY), left: Math.min(origX, pointer.x), top: Math.min(origY, pointer.y) }); else activeObject.set({ width: Math.abs(pointer.x - origX) }) activeObject.setCoords(); this._.renderAll(); } }); this._.on('mouse:up', (_) => { this.isDrawing = false if (this.selectedTool == "text") { const activeObject = this._.getActiveObject(); if (activeObject) (activeObject as Textbox).enterEditing() } this.selectTool("select") this._.renderAll() }); this._?.on("object:rotating", (e) => { if (e.e.shiftKey) { const obj = e.target; const angle = obj.angle; // Snap the angle to the nearest multiple of 45 const snappedAngle = Math.round(angle / 45) * 45; if (obj.originX !== "center") obj.set('originX', "center"); if (obj.originY !== "center") obj.set('originY', "center"); console.log(snappedAngle); if (obj.angle !== snappedAngle) obj.set('angle', snappedAngle); this._.renderAll(); } }) makeObservable(this) } private alignObject(val: objPositionType) { const activeObjects = this._.getActiveObjects(); if (activeObjects.length < 1) return; this._.discardActiveObject() activeObjects.forEach(activeObject => { activeObject.set("zoomX", 1) activeObject.set("zoomY", 1) switch (val) { case "left": activeObject.set("originX", "left") activeObject.setX(0) break; case "right": activeObject.set("originX", "left") activeObject.setX(this._.width - activeObject.width * activeObject.scaleX) break; case "top": activeObject.set("originY", "top") activeObject.setY(0) break; case "bottom": activeObject.set("originY", "top") activeObject.setY(this._.height - activeObject.height * activeObject.scaleY) break; case "horizontally": this._.centerObjectH(activeObject) break; case "vertically": this._.centerObjectV(activeObject) break; } }) this._.setActiveObject(new ActiveSelection(activeObjects)) this._.renderAll(); } private moveObject(val: objMoveType) { const activeObject = this._.getActiveObject(); if (!activeObject) return; switch (val) { case 2: this._.bringObjectToFront(activeObject) break; case 1: this._.bringObjectForward(activeObject) break; case -1: this._.sendObjectBackwards(activeObject) break; case -2: this._.sendObjectToBack(activeObject) break; } this._.renderAll(); } /** * Selects the active drawing tool * @param tool - Tool to select (select, rect, elipse, text) */ @action selectTool(tool: toolListType) { this.selectedTool = tool } /** * Deletes the currently selected element(s) from the canvas * can be used with delete button */ private deleteActiveElement() { this._.getActiveObjects() .forEach((obj) => { this._.remove(obj) }) this._.discardActiveObject() this.updateSelectedObjPropertyList() this._.renderAll() } /** * Extracts only the keys common to all objects, with values from the last object. * @param data Array of value objects. * @returns Object with only common keys and last object's full value. */ private extractCommonKeyFullValues(data: FabricObjectPropertyList[]): FabricObjectPropertyList { if (data.length === 0) return {}; // Step 1: Find common keys const commonKeys = Object.keys(data[0]).filter(key => data.every(obj => key in obj) ); // Step 2: Build the result using values from the last object const last = data[data.length - 1]; const result: FabricObjectPropertyList = {}; for (const key of commonKeys) { result[key] = last[key]; } return result; } /** * Updates the property list * method invoked when selected objects change using event listener */ @action private updateSelectedObjPropertyList() { this.propertyValueList = {} if (!this._) { this.propertyValueList = {} return } const objs = this._.getActiveObjects() if (objs.length == 0) { this.propertyValueList = {} return } const objOptions = objs.map(v => FabricObjectAdapter.createAdapter(v.type, v).getObjectValues()) const finalPropertyList = this.extractCommonKeyFullValues(objOptions) this.propertyValueList = finalPropertyList } /** * Updates a property value for the selected object(s) * @param propertyKey - Name of the property to update * @param value - New value to set */ @action updatePropertyValue = (propertyKey: string, value: any) => { if (!this._) return; const selectedObj = this._.getActiveObjects() selectedObj.forEach(obj => { const objAdapter = FabricObjectAdapter.createAdapter(obj.type, obj) objAdapter.propertyListMap[propertyKey](value) // obj.set("hasBorders", false); obj.setCoords() }) this.propertyValueList = { ...this.propertyValueList, [propertyKey]: { ...this.propertyValueList[propertyKey], value } } this._.renderAll() } /** * Exports the canvas state as JSON * @returns JSON string representation of the canvas */ exportJSON(): string { return this._.toJSON() } /** * Imports a canvas state from JSON * @param json - JSON string to import */ importJSON(json: string) { return this._.loadFromJSON(json, () => { this._.renderAll() }) } }