From 3aebf920a65b856c143f00b5e8917eeaf2d81ff4 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Tue, 14 Apr 2026 00:34:43 +0530 Subject: [PATCH] refactor: define explicit TypeScript interfaces for CanvasJSON and implement robust canvas initialization and interaction logic --- frontend/src/components/ui/ComposeCanvas.tsx | 324 ++++++++++++------- frontend/src/pages/Reader.tsx | 19 +- frontend/src/utils/crypto.ts | 4 +- frontend/src/utils/letterLogic.test.ts | 30 +- frontend/src/utils/letterLogic.ts | 56 ++-- 5 files changed, 276 insertions(+), 157 deletions(-) diff --git a/frontend/src/components/ui/ComposeCanvas.tsx b/frontend/src/components/ui/ComposeCanvas.tsx index 1a661e7..72e36c0 100644 --- a/frontend/src/components/ui/ComposeCanvas.tsx +++ b/frontend/src/components/ui/ComposeCanvas.tsx @@ -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; +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; + 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 = (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(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, + width: number, + ): Promise => { + 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 => { - 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); 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< ); }); + ComposeCanvas.displayName = "ComposeCanvas"; diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index ce2f4f8..6ffc9b0 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import { api } from "../api/apiClient"; import { + type CanvasJSON, type CanvasTools, ComposeCanvas, } from "../components/ui/ComposeCanvas"; @@ -10,6 +11,10 @@ import { endpoints } from "../config/endpoints"; import { CryptoUtils } from "../utils/crypto"; import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; +interface LetterMetadata { + recipient?: string; +} + export default function Reader() { const { public_id } = useParams(); const location = useLocation(); @@ -19,8 +24,9 @@ export default function Reader() { const [isDecrypting, setIsDecrypting] = useState(true); const [error, setError] = useState(null); - const [metadata, setMetadata] = useState(null); - const [decryptedCanvasData, setDecryptedCanvasData] = useState(null); + const [metadata, setMetadata] = useState(null); + const [decryptedCanvasData, setDecryptedCanvasData] = + useState(null); useEffect(() => { if (!sharingKey) { @@ -41,13 +47,13 @@ export default function Reader() { encrypted_metadata, sharingKey, ); - setMetadata(decryptedMetadata); + setMetadata(decryptedMetadata as LetterMetadata); const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey( encrypted_content, sharingKey, ); - const json = JSON.parse(decryptedContent); + const json = JSON.parse(decryptedContent) as CanvasJSON; if (images && images.length > 0) { await decryptCanvasImagesWithSharingKey( @@ -59,8 +65,9 @@ export default function Reader() { } setDecryptedCanvasData(json); - } catch (err: any) { - setError(`Failed to load letter: ${err.message || "Unknown error"}`); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setError(`Failed to load letter: ${message}`); } finally { setIsDecrypting(false); } diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index 5998084..52c1681 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -227,7 +227,7 @@ export class CryptoUtils { public async decryptMetadata( encrypted_metadata: EncryptedLetter, masterKey: CryptoKey, - ): Promise> { + ): Promise> { const bytes = await this.openEnvelope( encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_dek, @@ -239,7 +239,7 @@ export class CryptoUtils { public async decryptMetadataWithSharingKey( encrypted_content: string, sharingKey: string, - ): Promise> { + ): Promise> { const bytes = await this.openEnvelopeWithSharingKey( encrypted_content, sharingKey, diff --git a/frontend/src/utils/letterLogic.test.ts b/frontend/src/utils/letterLogic.test.ts index 60d55b9..4398dc5 100644 --- a/frontend/src/utils/letterLogic.test.ts +++ b/frontend/src/utils/letterLogic.test.ts @@ -92,9 +92,24 @@ describe("letterLogic image helpers", () => { describe("decryptCanvasImages", () => { it("should decrypt images and replace src with blob URL", async () => { const canvasData = { + version: "5.3.0", objects: [ - { type: "Image", src: "photo.png.bin" }, - { type: "Textbox", text: "hello" }, + { + type: "Image", + src: "photo.png.bin", + top: 0, + left: 0, + width: 100, + height: 100, + }, + { + type: "Textbox", + text: "hello", + top: 0, + left: 0, + width: 100, + height: 100, + }, ], }; const remoteImages = [ @@ -128,8 +143,17 @@ describe("letterLogic image helpers", () => { it("should include raw file when includeRawFile is true", async () => { const canvasData = { + version: "5.3.0", objects: [ - { type: "Image", src: "photo.png.bin", _customRawFile: null }, + { + type: "Image", + src: "photo.png.bin", + _customRawFile: null, + top: 0, + left: 0, + width: 100, + height: 100, + }, ], }; const remoteImages = [ diff --git a/frontend/src/utils/letterLogic.ts b/frontend/src/utils/letterLogic.ts index 09f2e6f..9614a50 100644 --- a/frontend/src/utils/letterLogic.ts +++ b/frontend/src/utils/letterLogic.ts @@ -1,4 +1,8 @@ import { api } from "../api/apiClient"; +import type { + CanvasJSON, + FabricImageJSON, +} from "../components/ui/ComposeCanvas"; import type { CryptoUtils } from "./crypto"; import { blobUrlToFile } from "./fileUtils"; @@ -8,8 +12,8 @@ export interface CanvasImageRef { } export async function decryptCanvasImages( - canvasData: any, - remoteImages: any[], + canvasData: CanvasJSON, + remoteImages: { file_name: string; file: string }[], encrypted_dek: string, masterKey: CryptoKey, cryptoUtils: CryptoUtils, @@ -23,9 +27,9 @@ export async function decryptCanvasImages( for (const obj of canvasData.objects) { if (obj.type !== "Image") continue; - - const originalFilename = obj.src; - const remoteUrl = imageMap.get(originalFilename); + const imgObj = obj as FabricImageJSON; + const originalSrc = imgObj.src; + const remoteUrl = imageMap.get(originalSrc); if (!remoteUrl) continue; const res = await api.get(remoteUrl, { responseType: "blob" }); @@ -35,17 +39,17 @@ export async function decryptCanvasImages( masterKey, ); - obj.src = blobUrl; + imgObj.src = blobUrl; if (includeRawFile) { - obj._customRawFile = await blobUrlToFile(blobUrl, originalFilename); + imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc); } } } export async function decryptCanvasImagesWithSharingKey( - canvasData: any, - remoteImages: any[], + canvasData: CanvasJSON, + remoteImages: { file_name: string; file: string }[], sharingKey: string, cryptoUtils: CryptoUtils, ) { @@ -58,11 +62,12 @@ export async function decryptCanvasImagesWithSharingKey( for (const obj of canvasData.objects) { if (obj.type !== "Image") continue; - const remoteUrl = imageMap.get(obj.src); + const imgObj = obj as FabricImageJSON; + const remoteUrl = imageMap.get(imgObj.src); if (!remoteUrl) continue; const res = await api.get(remoteUrl, { responseType: "blob" }); - obj.src = await cryptoUtils.decryptImageWithSharingKey( + imgObj.src = await cryptoUtils.decryptImageWithSharingKey( res.data, sharingKey, ); @@ -70,7 +75,7 @@ export async function decryptCanvasImagesWithSharingKey( } export async function encryptCanvasImages( - canvasData: any, + canvasData: CanvasJSON, canvasImages: CanvasImageRef[], masterKey: CryptoKey, cryptoUtils: CryptoUtils, @@ -81,21 +86,24 @@ export async function encryptCanvasImages( for (const img of canvasImages) { if (img.src.endsWith(".bin")) continue; if (!img.file) continue; - - try { - const { filename, encryptedBlob } = await cryptoUtils.encryptImage( - img.file, - masterKey, - ); - filenameMapping.set(img.src, filename); - encryptedFiles.set(filename, encryptedBlob); - } catch (_err) {} + const { filename, encryptedBlob } = await cryptoUtils.encryptImage( + img.file, + masterKey, + ); + filenameMapping.set(img.src, filename); + encryptedFiles.set(filename, encryptedBlob); } if (canvasData?.objects) { - canvasData.objects = canvasData.objects.map((obj: any) => { - if (obj.type === "Image" && filenameMapping.has(obj.src)) { - return { ...obj, src: filenameMapping.get(obj.src) }; + canvasData.objects = canvasData.objects.map((obj) => { + if (obj.type === "Image") { + const imgObj = obj as FabricImageJSON; + if (filenameMapping.has(imgObj.src)) { + return { + ...imgObj, + src: filenameMapping.get(imgObj.src) as string, + }; + } } return obj; });