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()
})
}
}