import * as fabric from "fabric"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, } from "react"; const PAD = 36; const BASE_WIDTH = 680; 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 { objects: (FabricObjectJSON | FabricImageJSON)[]; canvasWidth?: number; canvasHeight?: number; } export type CanvasTools = { addImage: (url: string, file: File) => void; getData: () => CanvasJSON; getJsonData: () => string; getImages: () => { src: string; file: File }[]; loadData: (data: CanvasJSON) => Promise; }; 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 => { 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 = (): fabric.Textbox => { return new fabric.Textbox("Take a deep breath...", { name: "main-textbox", originX: "left", originY: "top", left: PAD, top: PAD, width: BASE_WIDTH - PAD * 2, fontSize: 18, 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 scale = fCanvas.viewportTransform?.[0] || 1; const neededLogicalHeight = textbox.top + textbox.height + PAD; const currentLogicalHeight = fCanvas.height / scale; if (neededLogicalHeight > currentLogicalHeight) { const newPhysicalHeight = (neededLogicalHeight + PAD) * scale; fCanvas.setDimensions({ height: newPhysicalHeight }); wrapper.style.height = `${newPhysicalHeight}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?: CanvasJSON | null } >(({ readOnly = false, initialData = null }, ref) => { const wrapperRef = useRef(null); const canvasRef = useRef(null); const fabricRef = useRef(null); const textboxRef = useRef(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, containerWidth: number, ): Promise => { // Always establish the scale relative to BASE_WIDTH const scale = containerWidth / BASE_WIDTH; canvas.setViewportTransform([scale, 0, 0, scale, 0, 0]); 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(); canvas.add(textbox); return textbox; }, [readOnly], ); useEffect(() => { let isMounted = true; let canvas: fabric.Canvas | null = null; const init = async () => { await document.fonts.ready; if (!(wrapperRef.current && canvasRef.current && isMounted)) return; const finalWidth = await waitForLayout(wrapperRef.current); if (!(isMounted && canvasRef.current)) return; const initialHeight = Math.max( wrapperRef.current.clientHeight || 900, 600, ); canvas = initializeCanvas( canvasRef.current, finalWidth, initialHeight, readOnly, ); fabricRef.current = canvas; const textbox = await loadContent(canvas, initialData, finalWidth); if (textbox) { textboxRef.current = textbox; setupTextboxInteractions(canvas, textbox); } canvas.renderAll(); }; init(); return () => { isMounted = false; canvas?.dispose(); fabricRef.current = null; textboxRef.current = null; }; }, [initialData, readOnly, setupTextboxInteractions, loadContent]); useImperativeHandle(ref, () => ({ addImage: (url: string, file: File) => { if (!fabricRef.current) return; fabric.FabricImage.fromURL(url).then((img) => { img.scaleToWidth(300); img.set({ _customRawFile: file, left: PAD, top: PAD, } as Partial); fabricRef.current?.add(img); fabricRef.current?.setActiveObject(img); fabricRef.current?.requestRenderAll(); URL.revokeObjectURL(url); }); }, getData: () => { if (!fabricRef.current) return { objects: [] }; const json = fabricRef.current.toJSON() as CanvasJSON; json.canvasWidth = BASE_WIDTH; json.canvasHeight = fabricRef.current.getHeight() / (fabricRef.current.viewportTransform?.[3] || 1); return json; }, getJsonData: () => { if (!fabricRef.current) return ""; return JSON.stringify(fabricRef.current.toJSON()); }, getImages: () => { if (!fabricRef.current) return []; const images = fabricRef.current.getObjects( "Image", ) as FabricImageWithFile[]; return images.map((img) => ({ src: img.getSrc(), file: img._customRawFile, })); }, loadData: async (data: CanvasJSON) => { if (!(fabricRef.current && wrapperRef.current)) return; const width = wrapperRef.current.clientWidth; const textbox = await loadContent(fabricRef.current, data, width); if (textbox) { textboxRef.current = textbox; setupTextboxInteractions(fabricRef.current, textbox); } fabricRef.current.renderAll(); }, })); return (
); }); ComposeCanvas.displayName = "ComposeCanvas";