import * as fabric from "fabric"; import type * as React from "react"; import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; import "@fontsource/kavivanar/index.css"; import "@fontsource/space-mono/index.css"; import "@fontsource/cutive-mono/index.css"; import "@fontsource/architects-daughter/index.css"; import "@fontsource/redacted-script/index.css"; const PAD = 36; const BASE_WIDTH = 680; const DEFAULT_LOGICAL_HEIGHT = 900; const DEFAULT_FONT_FAMILY = "Playfair Display Variable"; const DEFAULT_FONT_COLOR = "#000"; 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 interface CanvasStyle { fontFamily: string; fontColor: string; } export type CanvasTools = { addImage: (url: string, file: File) => void; getData: () => CanvasJSON; getImages: () => { src: string; file: File }[]; loadData: (data: CanvasJSON) => Promise; getStyle: () => CanvasStyle; }; export interface FabricImageWithFile extends fabric.FabricImage { _customRawFile: File; } // NOTE: We use the same canvasData to render on both mobile and desktop viewports. // Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up) // over the last saved canvas size. const applyResponsiveViewport = ( canvas: fabric.Canvas, wrapper: HTMLDivElement, logicalWidth: number, logicalHeight: number, ) => { const physicalWidth = wrapper.clientWidth || logicalWidth; const zoomMultiplier = physicalWidth / logicalWidth; const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier); canvas.setDimensions({ width: physicalWidth, height: physicalHeight, }); wrapper.style.height = `${physicalHeight}px`; canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]); canvas.requestRenderAll(); }; // to find the maximum height of the content to dynamically resize the canvas // would've been wayyy easier only if canvas supported fit-content like CSS property :) const measureLogicalContentHeight = ( canvas: fabric.Canvas, minimumHeight = DEFAULT_LOGICAL_HEIGHT, ) => { const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => { const top = currObj.top; const height = currObj.getScaledHeight(); return Math.max(maxHeight, top + height); }, 0); return Math.max(minimumHeight, maxBottom + PAD); }; const DEFAULT_INIT_TEXT = "Take a deep breath..."; interface ComposeCanvasProps { readOnly?: boolean; initialData?: CanvasJSON | null; style?: CanvasStyle; ref?: React.Ref; } export function ComposeCanvas({ readOnly = false, initialData = null, style, ref, }: ComposeCanvasProps) { // wrapper is the parent div box 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, }); // re-calculates height based on content and applies the zoom transform const syncViewport = useCallback(() => { if (!(fabricRef.current && wrapperRef.current)) return; textboxRef.current?.initDimensions(); const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT; logicalSizeRef.current.height = measureLogicalContentHeight( fabricRef.current, minHeight, ); applyResponsiveViewport( fabricRef.current, wrapperRef.current, logicalSizeRef.current.width, logicalSizeRef.current.height, ); fabricRef.current.requestRenderAll(); }, [initialData]); // auto focus the cursor into the main textbox no matter the latest element added const focusTextbox = useCallback( (textbox: fabric.Textbox) => { if (readOnly || !fabricRef.current) return; fabricRef.current.setActiveObject(textbox); textbox.enterEditing(); // move the cursor to the end of the text const textLength = textbox.text?.length ?? 0; textbox.selectionStart = textLength; textbox.selectionEnd = textLength; fabricRef.current.requestRenderAll(); }, [readOnly], ); const loadContent = useCallback( async (data: CanvasJSON | null) => { const canvas = fabricRef.current; const wrapper = wrapperRef.current; if (!(canvas && wrapper)) return; // clean the canvas everytime and set fresh canvas.clear(); let textbox: fabric.Textbox | null = null; // restore logical size from prev saved data if available (in case of existing letter) logicalSizeRef.current = { width: data?.canvasWidth ?? BASE_WIDTH, height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT, }; if (data?.objects?.length) { await canvas.loadFromJSON(data); textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox; } else { // Create a fresh letter if no data exists textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, { name: "main-textbox", originX: "left", originY: "top", left: PAD, top: PAD, width: BASE_WIDTH - PAD * 2, fontSize: 18, fontWeight: 500, fontFamily: DEFAULT_FONT_FAMILY, fill: DEFAULT_FONT_COLOR, lineHeight: 1.5, splitByGrapheme: false, lockMovementX: true, lockMovementY: true, lockScalingX: true, lockScalingY: true, lockRotation: true, hasControls: false, hasBorders: false, objectCaching: false, noScaleCache: false, }); canvas.add(textbox); } if (!textbox) return; // readonly contraints applicable for post seal view textbox.selectable = !readOnly; textbox.evented = !readOnly; textbox.editable = !readOnly; textbox.hasBorders = false; textboxRef.current = textbox; // observe and auto-resize the canvas height whenever typed textbox.on("changed", syncViewport); // trapping the focus into the textbox wherever clicked on canvas (except images) canvas.on("mouse:down", (e) => { if (!e.target || e.target === textbox) { focusTextbox(textbox); } }); for (const img of canvas.getObjects("Image")) { img.set({ hasControls: !readOnly, hasBorders: !readOnly, }); } // NOTE: fabric refreshes fonts once the textbox is rendered after initial focus await document.fonts.ready; textbox.set("dirty", true); syncViewport(); // Hack: Fabric needs a small initial delay to mount before it will accept focus. // otherwise it goes to the front if (!readOnly) { setTimeout(() => focusTextbox(textbox), 200); } }, [readOnly, syncViewport, focusTextbox], ); useEffect(() => { if (style && textboxRef.current) { const textBox = textboxRef.current; textBox.fontFamily = style.fontFamily || textBox.fontFamily; textBox.fill = style.fontColor || textBox.fill; syncViewport(); } }, [style, syncViewport]); useEffect(() => { let isMounted = true; let resizeObserver: ResizeObserver | null = null; let lastWidth = 0; const getInitialWidth = async () => { if (!wrapperRef.current) return BASE_WIDTH; let width = wrapperRef.current.clientWidth; if (width === 0) { await new Promise((resolve) => requestAnimationFrame(resolve)); width = wrapperRef.current?.clientWidth || BASE_WIDTH; } return width; }; const initResizeOberver = () => { if (!wrapperRef.current) return null; const observer = new ResizeObserver(() => { const nextWidth = wrapperRef.current?.clientWidth; if (!nextWidth || nextWidth === lastWidth) return; lastWidth = nextWidth; syncViewport(); }); observer.observe(wrapperRef.current); return observer; }; const initCanvas = async () => { // HACK: actual font may change the text-width - small ux improvement await document.fonts.ready; if (!(wrapperRef.current && canvasRef.current && isMounted)) return; const width = await getInitialWidth(); // init the fabric instance const canvas = new fabric.Canvas(canvasRef.current, { width, height: DEFAULT_LOGICAL_HEIGHT, selection: !readOnly, preserveObjectStacking: true, allowTouchScrolling: true, enableRetinaScaling: true, objectCaching: false, }); // remove default fabric background to let our CSS show through // TODO: provision custom bg (color in scope, but how does img fit?) const wrapperEl = canvas.getElement().parentElement; if (wrapperEl) wrapperEl.style.background = "transparent"; fabricRef.current = canvas; await loadContent(initialData); // sometimes loadData() may be called before the canvas finished the init render // so we retry that stashed render right after the init if (deferredDataRef.current) { await loadContent(deferredDataRef.current); deferredDataRef.current = null; } // auto window resizing based width lastWidth = wrapperRef.current.clientWidth; resizeObserver = initResizeOberver(); }; initCanvas().then(); return () => { isMounted = false; resizeObserver?.disconnect(); fabricRef.current?.dispose(); fabricRef.current = null; textboxRef.current = null; }; }, [initialData, loadContent, readOnly, syncViewport]); // WHY?: fabric doesn't work like react with state and props based optimized re-renders. // everytime we there's a change in the data, we should force the render, // so we let the parent Editor component take control of this. 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", left: PAD, top: PAD, noScaleCache: false, objectCaching: false, // WHY?: after image object clean-up, its src becomes local blob:// // but browser won't let us parse this blob:// into file afterwards. so we hold a local copy _customRawFile: file, } as Partial); fabricRef.current?.add(img); fabricRef.current?.setActiveObject(img); syncViewport(); // clean up memory URL.revokeObjectURL(url); }); }, getData: () => { if (!fabricRef.current) return { objects: [] }; syncViewport(); const json = fabricRef.current.toJSON() as CanvasJSON; json.canvasWidth = logicalSizeRef.current.width; json.canvasHeight = logicalSizeRef.current.height; return 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 canvas isn't ready yet, stash the data and let the useEffect pick it up if (!fabricRef.current) { deferredDataRef.current = data; return; } await loadContent(data); }, getStyle: () => { const textBox = textboxRef.current; return { fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY, fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR, }; }, })); return (
); } ComposeCanvas.displayName = "ComposeCanvas";