From 8af9eab6ea93ebc38b80503b7267da3efcb78cb0 Mon Sep 17 00:00:00 2001 From: me Date: Wed, 6 May 2026 20:49:13 +0530 Subject: [PATCH] refactor: move font imports to compose canvas for syncing across reader and editor pages --- .../src/components/editor/ComposeCanvas.tsx | 650 +++++++++--------- frontend/src/pages/Editor.tsx | 10 +- 2 files changed, 335 insertions(+), 325 deletions(-) diff --git a/frontend/src/components/editor/ComposeCanvas.tsx b/frontend/src/components/editor/ComposeCanvas.tsx index 5a4a0b3..8075963 100644 --- a/frontend/src/components/editor/ComposeCanvas.tsx +++ b/frontend/src/components/editor/ComposeCanvas.tsx @@ -2,6 +2,12 @@ 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; @@ -9,385 +15,393 @@ 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; + type: string; + name?: string; + top: number; + left: number; + width: number; + height: number; - [key: string]: unknown; + [key: string]: unknown; } export interface FabricImageJSON extends FabricObjectJSON { - type: "Image"; - src: string; - _customRawFile?: File; + type: "Image"; + src: string; + _customRawFile?: File; } export interface CanvasJSON { - objects: (FabricObjectJSON | FabricImageJSON)[]; - canvasWidth?: number; - canvasHeight?: number; + objects: (FabricObjectJSON | FabricImageJSON)[]; + canvasWidth?: number; + canvasHeight?: number; } export interface CanvasStyle { - fontFamily: string; - fontColor: string; + 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; + 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; + _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, + 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); + const physicalWidth = wrapper.clientWidth || logicalWidth; + const zoomMultiplier = physicalWidth / logicalWidth; + const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier); - canvas.setDimensions({ - width: physicalWidth, - height: physicalHeight, - }); + canvas.setDimensions({ + width: physicalWidth, + height: physicalHeight, + }); - wrapper.style.height = `${physicalHeight}px`; - canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]); - canvas.requestRenderAll(); + 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, + 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); + 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); + 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; + readOnly?: boolean; + initialData?: CanvasJSON | null; + style?: CanvasStyle; + ref?: React.Ref; } export function ComposeCanvas({ - readOnly = false, - initialData = null, - style, - ref, + 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); + // 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; - - 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, - // NOTE: splitByGrapheme is required for word wrap and re-low - // but fabric asks to disable this for clear font?? So we disable it for read view - splitByGrapheme: !readOnly, - 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); - } - }); - - 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 initCanvas = async () => { - // HACK: actual font may change the text-width - small ux improvement - await document.fonts.ready; - - if (!(wrapperRef.current && canvasRef.current && isMounted)) return; - - let width = wrapperRef.current.clientWidth; - if (width === 0) { - await new Promise((resolve) => requestAnimationFrame(resolve)); - width = wrapperRef.current?.clientWidth || BASE_WIDTH; - } - - // init the fabric instance - const canvas = new fabric.Canvas(canvasRef.current, { - width, + const textboxRef = useRef(null); + const deferredDataRef = useRef(null); + const logicalSizeRef = useRef({ + width: BASE_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"; + // re-calculates height based on content and applies the zoom transform + const syncViewport = useCallback(() => { + if (!(fabricRef.current && wrapperRef.current)) return; - fabricRef.current = canvas; + const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT; + logicalSizeRef.current.height = measureLogicalContentHeight( + fabricRef.current, + minHeight, + ); - await loadContent(initialData); + applyResponsiveViewport( + fabricRef.current, + wrapperRef.current, + logicalSizeRef.current.width, + logicalSizeRef.current.height, + ); - // 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; - } + fabricRef.current.requestRenderAll(); + }, [initialData]); - // auto window resizing based width - lastWidth = wrapperRef.current.clientWidth; - resizeObserver = new ResizeObserver(() => { - const nextWidth = wrapperRef.current?.clientWidth; - if (!nextWidth || nextWidth === lastWidth) return; - lastWidth = nextWidth; - syncViewport(); - }); - resizeObserver.observe(wrapperRef.current!); - }; + // 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; - initCanvas().then(); + fabricRef.current.setActiveObject(textbox); + textbox.enterEditing(); - return () => { - isMounted = false; - resizeObserver?.disconnect(); - fabricRef.current?.dispose(); - fabricRef.current = null; - textboxRef.current = null; - }; - }, [initialData, loadContent, readOnly, syncViewport]); + // move the cursor to the end of the text + const textLength = textbox.text?.length ?? 0; + textbox.selectionStart = textLength; + textbox.selectionEnd = textLength; - // 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; + fabricRef.current.requestRenderAll(); + }, + [readOnly], + ); - 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); + const loadContent = useCallback( + async (data: CanvasJSON | null) => { + const canvas = fabricRef.current; + const wrapper = wrapperRef.current; + if (!(canvas && wrapper)) return; - fabricRef.current?.add(img); - fabricRef.current?.setActiveObject(img); + // clean the canvas everytime and set fresh + canvas.clear(); + let textbox: fabric.Textbox | null = null; - syncViewport(); - // clean up memory - URL.revokeObjectURL(url); - }); - }, + // 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, + }; - getData: () => { - if (!fabricRef.current) return { objects: [] }; - syncViewport(); + 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); + } - const json = fabricRef.current.toJSON() as CanvasJSON; - json.canvasWidth = logicalSizeRef.current.width; - json.canvasHeight = logicalSizeRef.current.height; - return json; - }, + if (!textbox) return; - getImages: () => { - if (!fabricRef.current) return []; - const images = fabricRef.current.getObjects( - "Image", - ) as FabricImageWithFile[]; - return images.map((img) => ({ - src: img.getSrc(), - file: img._customRawFile, - })); - }, + // readonly contraints applicable for post seal view + textbox.selectable = !readOnly; + textbox.evented = !readOnly; + textbox.editable = !readOnly; + textbox.hasBorders = false; - 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); - }, + textboxRef.current = textbox; - getStyle: () => { - const textBox = textboxRef.current; + // observe and auto-resize the canvas height whenever typed + textbox.on("changed", syncViewport); - return { - fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY, - fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR, - }; - }, - })); + // 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); + } + }); - return ( -
- -
- ); + 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 initCanvas = async () => { + // HACK: actual font may change the text-width - small ux improvement + await document.fonts.ready; + + if (!(wrapperRef.current && canvasRef.current && isMounted)) return; + + let width = wrapperRef.current.clientWidth; + if (width === 0) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + width = wrapperRef.current?.clientWidth || BASE_WIDTH; + } + + // 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 = new ResizeObserver(() => { + const nextWidth = wrapperRef.current?.clientWidth; + if (!nextWidth || nextWidth === lastWidth) return; + lastWidth = nextWidth; + syncViewport(); + }); + resizeObserver.observe(wrapperRef.current!); + }; + + 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"; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 57a29b8..ce96e05 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -34,12 +34,6 @@ import { CryptoUtils } from "../utils/crypto"; import { formatRelativeDate } from "../utils/dateFormat"; import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; -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"; - type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR"; const OVERLAY_FADE_MS = 250; @@ -268,7 +262,9 @@ export default function Editor() { await cryptoUtils.initialize(); try { - const canvasData = canvasRef.current?.getData() || { objects: [] }; + const canvasData = (await canvasRef.current?.getData()) || { + objects: [], + }; const canvasImages = canvasRef.current?.getImages() || []; const { encryptedImageFiles, encryptedCanvasData } =