import * as fabric from "fabric"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, } from "react"; const PAD = 36; const BASE_WIDTH = 680; const DEFAULT_LOGICAL_HEIGHT = 900; 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; } 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(); }); }; const createMainTextbox = ( text: string, isReadOnly = false, ): fabric.Textbox => { return new fabric.Textbox(text, { 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: !isReadOnly, selectable: false, evented: !isReadOnly, hasControls: false, hasBorders: false, objectCaching: false, splitByGrapheme: false, lockMovementX: true, lockMovementY: true, lockScalingX: true, lockScalingY: true, lockRotation: true, }); }; 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"); } } }; 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; }; const getLogicalSize = (data: CanvasJSON | null) => { return { width: data?.canvasWidth ?? BASE_WIDTH, height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT, }; }; const getObjectBottom = (obj: fabric.FabricObject) => { const top = obj.top ?? 0; const height = typeof obj.getScaledHeight === "function" ? obj.getScaledHeight() : (obj.height ?? 0) * (obj.scaleY ?? 1); return top + height; }; const measureLogicalContentHeight = ( canvas: fabric.Canvas, minimumHeight = DEFAULT_LOGICAL_HEIGHT, ) => { const maxBottom = canvas .getObjects() .reduce((max, obj) => Math.max(max, getObjectBottom(obj)), 0); return Math.max(minimumHeight, maxBottom + PAD); }; const applyResponsiveViewport = ( canvas: fabric.Canvas, wrapper: HTMLDivElement, logicalWidth: number, logicalHeight: number, ) => { const physicalWidth = wrapper.clientWidth || logicalWidth; const zoom = physicalWidth / logicalWidth; const physicalHeight = Math.max(1, logicalHeight * zoom); canvas.setDimensions({ width: physicalWidth, height: physicalHeight, }); wrapper.style.height = `${physicalHeight}px`; canvas.setViewportTransform([zoom, 0, 0, zoom, 0, 0]); canvas.requestRenderAll(); }; const focusTextbox = ( fCanvas: fabric.Canvas, textbox: fabric.Textbox, readOnly: boolean, ) => { if (readOnly) return; fCanvas.setActiveObject(textbox); textbox.enterEditing(); const end = textbox.text?.length ?? 0; textbox.selectionStart = end; textbox.selectionEnd = end; fCanvas.requestRenderAll(); fixFabricA11y(); }; const findMainTextbox = (canvas: fabric.Canvas): fabric.Textbox | null => { const textbox = canvas.getObjects("Textbox")[0]; return (textbox as fabric.Textbox) ?? null; }; 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 deferredDataRef = useRef(null); const logicalSizeRef = useRef({ width: BASE_WIDTH, height: DEFAULT_LOGICAL_HEIGHT, }); const syncViewport = useCallback(() => { if (!(fabricRef.current && wrapperRef.current)) return; applyResponsiveViewport( fabricRef.current, wrapperRef.current, logicalSizeRef.current.width, logicalSizeRef.current.height, ); }, []); const updateLogicalHeightFromContent = useCallback(() => { if (!fabricRef.current) return; logicalSizeRef.current.height = measureLogicalContentHeight( fabricRef.current, logicalSizeRef.current.height, ); syncViewport(); }, [syncViewport]); const setupTextboxInteractions = useCallback( (fCanvas: fabric.Canvas, textbox: fabric.Textbox) => { textbox.on("changed", () => { updateLogicalHeightFromContent(); }); fCanvas.on("mouse:down", (opt) => { if (!opt.target || opt.target === textbox) { focusTextbox(fCanvas, textbox, readOnly); } }); if (!readOnly) { setTimeout(() => { focusTextbox(fCanvas, textbox, readOnly); }, 200); } }, [readOnly, updateLogicalHeightFromContent], ); const loadContent = useCallback( async ( canvas: fabric.Canvas, data: CanvasJSON | null, wrapper: HTMLDivElement, ): Promise => { const logicalSize = getLogicalSize(data); logicalSizeRef.current = logicalSize; canvas.clear(); let textbox: fabric.Textbox | null = null; if (data?.objects?.length) { await canvas.loadFromJSON(data); textbox = findMainTextbox(canvas); } else { textbox = createMainTextbox("Take a deep breath...", readOnly); canvas.add(textbox); } if (!textbox) return null; textbox.selectable = !readOnly; textbox.evented = !readOnly; textbox.editable = !readOnly; textbox.hasBorders = false; textbox.lockMovementX = true; textbox.lockMovementY = true; textbox.lockScalingX = true; textbox.lockScalingY = true; textbox.lockRotation = true; logicalSizeRef.current.height = measureLogicalContentHeight( canvas, logicalSize.height, ); applyResponsiveViewport( canvas, wrapper, logicalSizeRef.current.width, logicalSizeRef.current.height, ); if (!(readOnly || data)) { focusTextbox(canvas, textbox, readOnly); } return textbox; }, [readOnly], ); useEffect(() => { let isMounted = true; let canvas: fabric.Canvas | null = null; let resizeObserver: ResizeObserver | null = null; let lastWidth = 0; const init = async () => { await document.fonts.ready; if (!(wrapperRef.current && canvasRef.current && isMounted)) return; const finalWidth = await waitForLayout(wrapperRef.current); if (!(isMounted && canvasRef.current && wrapperRef.current)) return; canvas = initializeCanvas( canvasRef.current, finalWidth, DEFAULT_LOGICAL_HEIGHT, readOnly, ); fabricRef.current = canvas; const textbox = await loadContent( canvas, initialData, wrapperRef.current, ); if (textbox) { textboxRef.current = textbox; setupTextboxInteractions(canvas, textbox); } canvas.requestRenderAll(); fixFabricA11y(); lastWidth = wrapperRef.current.clientWidth; resizeObserver = new ResizeObserver(() => { if (!(fabricRef.current && wrapperRef.current)) return; const nextWidth = wrapperRef.current.clientWidth; if (!nextWidth || nextWidth === lastWidth) return; lastWidth = nextWidth; syncViewport(); }); resizeObserver.observe(wrapperRef.current); if (deferredDataRef.current) { const data = deferredDataRef.current; deferredDataRef.current = null; const textbox = await loadContent(canvas, data, wrapperRef.current); if (textbox) { textboxRef.current = textbox; setupTextboxInteractions(canvas, textbox); } canvas.requestRenderAll(); fixFabricA11y(); } }; init(); return () => { isMounted = false; resizeObserver?.disconnect(); canvas?.dispose(); fabricRef.current = null; textboxRef.current = null; }; }, [ initialData, loadContent, readOnly, setupTextboxInteractions, syncViewport, ]); useImperativeHandle(ref, () => ({ addImage: (url: string, file: File) => { if (!fabricRef.current) return; fabric.FabricImage.fromURL(url).then((img) => { img.scaleToWidth(Math.min(300, img.width)); img.set({ originX: "left", originY: "top", _customRawFile: file, left: PAD, top: PAD, objectCaching: false, } as Partial); fabricRef.current?.add(img); fabricRef.current?.setActiveObject(img); logicalSizeRef.current.height = measureLogicalContentHeight( fabricRef.current, logicalSizeRef.current.height, ); if (wrapperRef.current) { applyResponsiveViewport( fabricRef.current, wrapperRef.current, logicalSizeRef.current.width, logicalSizeRef.current.height, ); } else { fabricRef.current?.requestRenderAll(); } URL.revokeObjectURL(url); }); }, getData: () => { if (!fabricRef.current) return { objects: [] }; logicalSizeRef.current.height = measureLogicalContentHeight( fabricRef.current, logicalSizeRef.current.height, ); const json = fabricRef.current.toJSON() as CanvasJSON; json.canvasWidth = logicalSizeRef.current.width; json.canvasHeight = logicalSizeRef.current.height; return json; }, getJsonData: () => { if (!fabricRef.current) return ""; const json = fabricRef.current.toJSON() as CanvasJSON; json.canvasWidth = logicalSizeRef.current.width; json.canvasHeight = logicalSizeRef.current.height; return JSON.stringify(json); }, 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)) { deferredDataRef.current = data; return; } const textbox = await loadContent( fabricRef.current, data, wrapperRef.current, ); if (textbox) { textboxRef.current = textbox; setupTextboxInteractions(fabricRef.current, textbox); } fabricRef.current.requestRenderAll(); fixFabricA11y(); }, })); return (
); }); ComposeCanvas.displayName = "ComposeCanvas";