refactor: define explicit TypeScript interfaces for CanvasJSON and implement robust canvas initialization and interaction logic

This commit is contained in:
ramvignesh-b
2026-04-14 00:34:43 +05:30
parent 5c81d617bd
commit 3aebf920a6
5 changed files with 276 additions and 157 deletions
+202 -122
View File
@@ -1,139 +1,231 @@
import * as fabric from "fabric";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from "react";
const PAD = 36;
type CanvasJSON = ReturnType<fabric.Canvas["toJSON"]>;
export interface FabricObjectJSON {
type: string;
name?: string;
top: number;
left: number;
width: number;
height: number;
[key: string]: unknown;
}
export interface FabricImageJSON extends FabricObjectJSON {
type: "Image";
src: string;
_customRawFile?: File;
}
export interface CanvasJSON {
version: string;
objects: (FabricObjectJSON | FabricImageJSON)[];
}
export type CanvasTools = {
addImage: (url: string, file: File) => void;
getData: () => { objects: CanvasJSON["objects"] }; // no-any hack :/
getData: () => CanvasJSON;
getJsonData: () => string;
getImages: () => { src: string; file: File }[];
loadData: (data: any) => Promise<void>;
loadData: (data: CanvasJSON) => Promise<void>;
};
export interface FabricImageWithFile extends fabric.FabricImage {
_customRawFile: File;
}
/**
* Wait for the container to have a valid width before initializing the canvas.
*/
const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
return new Promise((resolve) => {
const check = () => {
const width = wrapper.clientWidth || 0;
if (width > 0) resolve(width);
else requestAnimationFrame(check);
};
check();
});
};
/**
* Creates the primary text box for the letter.
*/
const createMainTextbox = (width: number): fabric.Textbox => {
return new fabric.Textbox("Take a deep breath...", {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: width - PAD * 2,
fontSize: 16,
fontWeight: 500,
fontFamily: "Playfair Display Variable",
fill: "#000",
lineHeight: 1.5,
editable: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
});
};
/**
* Fabric.js creates hidden textareas for input. We add aria-labels for accessibility.
*/
const fixFabricA11y = () => {
const textAreas = document.querySelectorAll(
'textarea[data-fabric="textarea"]',
);
for (const area of textAreas) {
if (!area.getAttribute("aria-label")) {
area.setAttribute("aria-label", "Canvas text input");
}
}
};
/**
* Handle canvas resizing based on textbox content.
*/
const handleResize = (
fCanvas: fabric.Canvas,
textbox: fabric.Textbox,
wrapper: HTMLDivElement | null,
) => {
if (!wrapper) return;
const neededHeight = textbox.top + textbox.height + PAD;
if (neededHeight > fCanvas.height) {
const newH = neededHeight + PAD;
fCanvas.setDimensions({ height: newH });
wrapper.style.height = `${newH}px`;
}
};
/**
* Setup focus and editing for the textbox.
*/
const focusTextbox = (fCanvas: fabric.Canvas, textbox: fabric.Textbox) => {
fCanvas.setActiveObject(textbox);
textbox.enterEditing();
fCanvas.renderAll();
fixFabricA11y();
};
/**
* Static canvas creation helper to avoid component dependency issues.
*/
const initializeCanvas = (
el: HTMLCanvasElement,
width: number,
height: number,
readOnly: boolean,
) => {
const canvas = new fabric.Canvas(el, {
width,
height,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
});
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
return canvas;
};
export const ComposeCanvas = forwardRef<
CanvasTools,
{ readOnly?: boolean; initialData?: any }
{ readOnly?: boolean; initialData?: CanvasJSON | null }
>(({ readOnly = false, initialData = null }, ref) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(null);
const setupTextboxInteractions = useCallback(
(fCanvas: fabric.Canvas, textbox: fabric.Textbox) => {
textbox.on("changed", () =>
handleResize(fCanvas, textbox, wrapperRef.current),
);
fCanvas.on("mouse:down", (opt) => {
if (!opt.target || opt.target === textbox) {
focusTextbox(fCanvas, textbox);
}
});
if (!readOnly) {
setTimeout(() => focusTextbox(fCanvas, textbox), 100);
}
},
[readOnly],
);
const loadContent = useCallback(
async (
canvas: fabric.Canvas,
data: CanvasJSON | null,
width: number,
): Promise<fabric.Textbox | null> => {
if (data) {
await canvas.loadFromJSON(data);
if (readOnly) {
for (const obj of canvas.getObjects()) {
obj.selectable = false;
obj.evented = false;
}
}
return null;
}
const textbox = createMainTextbox(width);
canvas.add(textbox);
return textbox;
},
[readOnly],
);
useEffect(() => {
let isMounted = true;
let canvas: fabric.Canvas | null = null;
const init = async () => {
await document.fonts.ready;
const waitForLayout = (): Promise<number> => {
return new Promise((resolve) => {
const check = () => {
const wrapperWidth = wrapperRef.current?.clientWidth || 0;
if (wrapperWidth > 0) resolve(wrapperWidth);
else requestAnimationFrame(check);
};
check();
});
};
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
const finalWidth = await waitForLayout();
if (!(isMounted && canvasRef.current && wrapperRef.current)) return;
const finalWidth = await waitForLayout(wrapperRef.current);
if (!(isMounted && canvasRef.current)) return;
const initialHeight = Math.max(
wrapperRef.current.clientHeight || 900,
600,
);
canvas = new fabric.Canvas(canvasRef.current, {
width: finalWidth,
height: initialHeight,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
});
canvas = initializeCanvas(
canvasRef.current,
finalWidth,
initialHeight,
readOnly,
);
fabricRef.current = canvas;
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
if (initialData) {
await canvas.loadFromJSON(initialData);
if (readOnly) {
for (const obj of canvas.getObjects()) {
obj.selectable = false;
obj.evented = false;
}
}
canvas.renderAll();
} else {
const textbox = new fabric.Textbox("Take a deep breath...", {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: finalWidth - PAD * 2,
fontSize: 16,
fontWeight: 500,
fontFamily: "Playfair Display Variable",
fill: "#000",
lineHeight: 1.5,
editable: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
});
const textbox = await loadContent(canvas, initialData, finalWidth);
if (textbox) {
textboxRef.current = textbox;
canvas.add(textbox);
textbox.on("changed", () => {
if (!(canvas && wrapperRef.current)) return;
const neededHeight = textbox.top + textbox.height + PAD;
if (neededHeight > canvas.height) {
const newH = neededHeight + PAD;
canvas.setDimensions({ height: newH });
wrapperRef.current.style.height = `${newH}px`;
}
});
setTimeout(() => {
if (!isMounted) return;
canvas?.setActiveObject(textbox);
textbox.enterEditing();
canvas?.renderAll();
const hiddenTextareas = document.querySelectorAll(
'textarea[data-fabric="textarea"]',
);
for (const textArea of hiddenTextareas) {
if (!textArea.getAttribute("aria-label")) {
textArea.setAttribute("aria-label", "Canvas text input");
}
}
}, 100);
canvas.on("mouse:down", (opt) => {
if (!opt.target || opt.target === textbox) {
canvas?.setActiveObject(textbox);
textbox.enterEditing();
canvas?.renderAll();
}
});
setupTextboxInteractions(canvas, textbox);
}
canvas.renderAll();
};
init();
@@ -144,7 +236,7 @@ export const ComposeCanvas = forwardRef<
fabricRef.current = null;
textboxRef.current = null;
};
}, [initialData, readOnly]);
}, [initialData, readOnly, setupTextboxInteractions, loadContent]);
useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => {
@@ -155,7 +247,7 @@ export const ComposeCanvas = forwardRef<
_customRawFile: file,
left: PAD,
top: PAD,
});
} as Partial<FabricImageWithFile>);
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
fabricRef.current?.requestRenderAll();
@@ -163,45 +255,32 @@ export const ComposeCanvas = forwardRef<
});
},
getData: () => {
if (!fabricRef.current) return { objects: [] };
return fabricRef.current.toJSON();
if (!fabricRef.current) return { version: "", objects: [] };
return fabricRef.current.toJSON() as CanvasJSON;
},
getJsonData: () => {
if (!fabricRef.current) return "";
return JSON.stringify(fabricRef.current.toJSON()); // convert to json string
return JSON.stringify(fabricRef.current.toJSON());
},
getImages: () => {
if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects(
"Image",
) as fabric.FabricImage[];
) as FabricImageWithFile[];
return images.map((img) => ({
src: img.getSrc(),
file: (img as any)._customRawFile,
file: img._customRawFile,
}));
},
loadData: async (data: any) => {
loadData: async (data: CanvasJSON) => {
if (!fabricRef.current) return;
await fabricRef.current.loadFromJSON(data);
// find the textbox and restore focus
const objects = fabricRef.current.getObjects("Textbox");
if (objects.length > 0) {
const textbox = objects[0] as fabric.Textbox;
textbox.lockMovementX = true;
textbox.lockMovementY = true;
textbox.hasControls = false;
textbox.hasBorders = false;
const textboxes = fabricRef.current.getObjects("Textbox");
if (textboxes.length > 0) {
const textbox = textboxes[0] as fabric.Textbox;
textboxRef.current = textbox;
fabricRef.current.setActiveObject(textbox);
if (textbox.text) {
// move cursor to end
textbox.selectionStart = textbox.text.length;
textbox.selectionEnd = textbox.text.length;
}
textbox.enterEditing();
setupTextboxInteractions(fabricRef.current, textbox);
}
fabricRef.current.renderAll();
},
}));
@@ -220,4 +299,5 @@ export const ComposeCanvas = forwardRef<
</div>
);
});
ComposeCanvas.displayName = "ComposeCanvas";