From 75dd187442b981a792d0b1e9450b9904634a653d Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Wed, 15 Apr 2026 17:07:35 +0530 Subject: [PATCH] feat: implement read-only mode for sealed letters and handle error boundaries --- frontend/src/components/ui/ComposeCanvas.tsx | 353 ++++++++++++++----- frontend/src/components/ui/LogModal.tsx | 50 +++ frontend/src/components/ui/Navbar.tsx | 1 + frontend/src/pages/Editor.tsx | 186 +++++++--- frontend/src/utils/letterLogic.ts | 72 ++-- 5 files changed, 493 insertions(+), 169 deletions(-) create mode 100644 frontend/src/components/ui/LogModal.tsx diff --git a/frontend/src/components/ui/ComposeCanvas.tsx b/frontend/src/components/ui/ComposeCanvas.tsx index 81ff3d6..c94bc68 100644 --- a/frontend/src/components/ui/ComposeCanvas.tsx +++ b/frontend/src/components/ui/ComposeCanvas.tsx @@ -9,6 +9,7 @@ import { const PAD = 36; const BASE_WIDTH = 680; +const DEFAULT_LOGICAL_HEIGHT = 900; export interface FabricObjectJSON { type: string; @@ -44,9 +45,6 @@ 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 = () => { @@ -58,11 +56,11 @@ const waitForLayout = (wrapper: HTMLDivElement): Promise => { }); }; -/** - * Creates the primary text box for the letter. - */ -const createMainTextbox = (): fabric.Textbox => { - return new fabric.Textbox("Take a deep breath...", { +const createMainTextbox = ( + text: string, + isReadOnly = false, +): fabric.Textbox => { + return new fabric.Textbox(text, { name: "main-textbox", originX: "left", originY: "top", @@ -74,7 +72,9 @@ const createMainTextbox = (): fabric.Textbox => { fontFamily: "Playfair Display Variable", fill: "#000", lineHeight: 1.5, - editable: true, + editable: !isReadOnly, + selectable: false, + evented: !isReadOnly, hasControls: false, hasBorders: false, objectCaching: false, @@ -87,9 +87,6 @@ const createMainTextbox = (): fabric.Textbox => { }); }; -/** - * Fabric.js creates hidden textareas for input. We add aria-labels for accessibility. - */ const fixFabricA11y = () => { const textAreas = document.querySelectorAll( 'textarea[data-fabric="textarea"]', @@ -101,39 +98,6 @@ const fixFabricA11y = () => { } }; -/** - * 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, @@ -147,11 +111,85 @@ const initializeCanvas = ( 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 } @@ -160,47 +198,104 @@ export const ComposeCanvas = forwardRef< 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", () => - handleResize(fCanvas, textbox, wrapperRef.current), - ); + textbox.on("changed", () => { + updateLogicalHeightFromContent(); + }); + fCanvas.on("mouse:down", (opt) => { if (!opt.target || opt.target === textbox) { - focusTextbox(fCanvas, textbox); + focusTextbox(fCanvas, textbox, readOnly); } }); if (!readOnly) { - setTimeout(() => focusTextbox(fCanvas, textbox), 100); + setTimeout(() => { + focusTextbox(fCanvas, textbox, readOnly); + }, 200); } }, - [readOnly], + [readOnly, updateLogicalHeightFromContent], ); const loadContent = useCallback( async ( canvas: fabric.Canvas, data: CanvasJSON | null, - containerWidth: number, + wrapper: HTMLDivElement, ): Promise => { - // Always establish the scale relative to BASE_WIDTH - const scale = containerWidth / BASE_WIDTH; - canvas.setViewportTransform([scale, 0, 0, scale, 0, 0]); + const logicalSize = getLogicalSize(data); + logicalSizeRef.current = logicalSize; - if (data) { + canvas.clear(); + + let textbox: fabric.Textbox | null = null; + + if (data?.objects?.length) { await canvas.loadFromJSON(data); - if (readOnly) { - for (const obj of canvas.getObjects()) { - obj.selectable = false; - obj.evented = false; - } - } - return null; + textbox = findMainTextbox(canvas); + } else { + textbox = createMainTextbox("Take a deep breath...", readOnly); + canvas.add(textbox); } - const textbox = createMainTextbox(); - 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], @@ -209,93 +304,180 @@ export const ComposeCanvas = forwardRef< 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)) return; + if (!(isMounted && canvasRef.current && wrapperRef.current)) return; - const initialHeight = Math.max( - wrapperRef.current.clientHeight || 900, - 600, - ); canvas = initializeCanvas( canvasRef.current, finalWidth, - initialHeight, + DEFAULT_LOGICAL_HEIGHT, readOnly, ); + fabricRef.current = canvas; - const textbox = await loadContent(canvas, initialData, finalWidth); + const textbox = await loadContent( + canvas, + initialData, + wrapperRef.current, + ); if (textbox) { textboxRef.current = textbox; setupTextboxInteractions(canvas, textbox); } - canvas.renderAll(); + + 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, readOnly, setupTextboxInteractions, loadContent]); + }, [ + initialData, + loadContent, + readOnly, + setupTextboxInteractions, + syncViewport, + ]); useImperativeHandle(ref, () => ({ addImage: (url: string, file: File) => { if (!fabricRef.current) return; + fabric.FabricImage.fromURL(url).then((img) => { - img.scaleToWidth(300); + 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); - fabricRef.current?.requestRenderAll(); + + 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 = BASE_WIDTH; - json.canvasHeight = - fabricRef.current.getHeight() / - (fabricRef.current.viewportTransform?.[3] || 1); + json.canvasWidth = logicalSizeRef.current.width; + json.canvasHeight = logicalSizeRef.current.height; + return json; }, + getJsonData: () => { if (!fabricRef.current) return ""; - return JSON.stringify(fabricRef.current.toJSON()); + + 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)) return; - const width = wrapperRef.current.clientWidth; - const textbox = await loadContent(fabricRef.current, data, width); + 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.renderAll(); + + fabricRef.current.requestRenderAll(); + fixFabricA11y(); }, })); @@ -303,7 +485,6 @@ export const ComposeCanvas = forwardRef<
void; +} + +export const LogModal = ({ + message, + log, + onClose, + status, +}: LogModalContent) => { + status === "RESET" ? null : ( +
+
+
+ {status === "WARN" && ( + + )} + {status === "ERROR" && ( + + )} + {message} +
+ Error Stack +
+
+
+              {String(log)}
+            
+
+
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/components/ui/Navbar.tsx b/frontend/src/components/ui/Navbar.tsx index 9526187..eac06c3 100644 --- a/frontend/src/components/ui/Navbar.tsx +++ b/frontend/src/components/ui/Navbar.tsx @@ -12,6 +12,7 @@ export const Navbar = ({ child }: { child?: React.ReactNode }) => { type="button" onClick={() => navigate(ROUTES.DRAWER)} className="group flex items-center gap-2 px-0 hover:bg-transparent cursor-pointer" + aria-label="Open Drawer" >
(navigate); + navigateRef.current = navigate; + const { public_id } = useParams(); const letterIdRef = useRef(public_id ?? ""); + const justSavedRef = useRef(false); + + const [decryptionStatus, setDecryptionStatus] = useState<{ + status: "SUCCESS" | "WARN" | "ERROR" | "RESET"; + message: string; + log: string; + }>({ status: "RESET", message: "", log: "" }); const [isInitialLoading, setIsInitialLoading] = useState(false); const [shareLink, setShareLink] = useState(null); const [lastSaved, setLastSaved] = useState(null); + const [status, setStatus] = useState<"DRAFT" | "SEALED">("DRAFT"); const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false); const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0); @@ -52,6 +68,10 @@ export default function Editor() { useEffect(() => { if (!(public_id && masterKey)) return; + if (justSavedRef.current) { + justSavedRef.current = false; + return; + } const loadExistingLetter = async () => { setIsInitialLoading(true); @@ -62,6 +82,16 @@ export default function Editor() { const letterData = res.data; setLastSaved(formatRelativeDate(new Date(letterData.updated_at))); + setStatus(letterData.status); + + if (letterData.status === "SEALED") { + navigateRef.current(PATHS.read(public_id), { replace: true }); + return; + } + + if (!letterData.encrypted_dek) { + return; + } const metadata = await cryptoUtils.decryptMetadata( { @@ -81,7 +111,7 @@ export default function Editor() { ); const canvasData = JSON.parse(decryptedJsonStr); - await decryptCanvasImages( + const { isDecryptionPartialFailure, error } = await decryptCanvasImages( canvasData, letterData.images ?? [], letterData.encrypted_dek, @@ -90,10 +120,26 @@ export default function Editor() { true, ); - requestAnimationFrame(() => { - canvasRef.current?.loadData(canvasData); - }); + if (isDecryptionPartialFailure) { + setDecryptionStatus({ + status: "WARN", + message: + "Failed to decrypt some elements. Please check the render.", + log: error, + }); + } + + console.log(canvasData); + + if (canvasRef.current) { + await canvasRef.current.loadData(canvasData); + } } catch (_err) { + setDecryptionStatus({ + status: "ERROR", + message: "Failed to decrypt letter. Please try again later.", + log: _err instanceof Error ? _err.message : "Unknown error", + }); } finally { setIsInitialLoading(false); } @@ -147,11 +193,9 @@ export default function Editor() { }; const handleSave = async (status: "SEALED" | "DRAFT"): Promise => { - if (!(public_id || letterIdRef.current)) { - letterIdRef.current = crypto.randomUUID(); - navigate(PATHS.write(letterIdRef.current), { replace: true }); - } else if (public_id) { - letterIdRef.current = public_id; + let targetId = public_id || letterIdRef.current; + if (!targetId) { + targetId = crypto.randomUUID(); } if (saveOverlay === "saving" || !masterKey) return; @@ -184,7 +228,7 @@ export default function Editor() { ); const formData = new FormData(); - formData.append("public_id", letterIdRef.current); + formData.append("public_id", targetId); formData.append("type", "KEPT"); formData.append("status", status); formData.append("encrypted_content", encrypted_letter.encrypted_content); @@ -198,14 +242,21 @@ export default function Editor() { formData.append("image_files", blob, filename); }); - await api.put(`${endpoints.LETTERS}${letterIdRef.current}/`, formData); + await api.put(`${endpoints.LETTERS}${targetId}/`, formData); + justSavedRef.current = true; + + if (!public_id) { + letterIdRef.current = targetId; + navigate(PATHS.write(targetId), { replace: true }); + } setLastSaved(formatRelativeDate(new Date())); + setStatus(status); setLastSavedPulseTick((prev) => prev + 1); if (status === "SEALED" && encrypted_letter.sharingKey) { const link = `${window.location.origin}${PATHS.read( - letterIdRef.current, + targetId, )}#${encrypted_letter.sharingKey}`; setShareLink(link); setShowSaveOverlay(false); @@ -251,6 +302,15 @@ export default function Editor() { />
+ + setDecryptionStatus({ status: "RESET", message: "", log: "" }) + } + /> + {isInitialLoading && (
@@ -382,59 +442,71 @@ export default function Editor() { type="text" placeholder="Someone dear..." value={recipient} + disabled={status === "SEALED"} onChange={(e) => setRecipient(e.target.value)} - className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full" + className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50" />
-
-
- - + {status === "DRAFT" ? ( +
+
+ + +
+ +
+ + +
+ + +
- -
- - -
- - + ) : ( +
+
+ + + Sealed & View Only + +
-
+ )} - +
diff --git a/frontend/src/utils/letterLogic.ts b/frontend/src/utils/letterLogic.ts index 9614a50..0e082a8 100644 --- a/frontend/src/utils/letterLogic.ts +++ b/frontend/src/utils/letterLogic.ts @@ -18,33 +18,47 @@ export async function decryptCanvasImages( masterKey: CryptoKey, cryptoUtils: CryptoUtils, includeRawFile = false, -) { - if (!canvasData?.objects) return; +): Promise<{ isDecryptionPartialFailure: boolean; error: string }> { + if (!canvasData?.objects) + return { isDecryptionPartialFailure: false, error: "" }; + let isDecryptionPartialFailure = false; + let error = ""; const imageMap = new Map( remoteImages.map((img) => [img.file_name, img.file]), ); - for (const obj of canvasData.objects) { - if (obj.type !== "Image") continue; + const decryptionPromises = canvasData.objects.map(async (obj, index) => { + if (obj.type !== "Image") return; const imgObj = obj as FabricImageJSON; - const originalSrc = imgObj.src; - const remoteUrl = imageMap.get(originalSrc); - if (!remoteUrl) continue; + const remoteUrl = imageMap.get(imgObj.src); + if (!remoteUrl) return; - const res = await api.get(remoteUrl, { responseType: "blob" }); - const blobUrl = await cryptoUtils.decryptImage( - res.data, - encrypted_dek, - masterKey, - ); + try { + const res = await api.get(remoteUrl, { responseType: "blob" }); + const originalSrc = imgObj.src; - imgObj.src = blobUrl; + const blobUrl = await cryptoUtils.decryptImage( + res.data, + encrypted_dek, + masterKey, + ); - if (includeRawFile) { - imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc); + imgObj.src = blobUrl; + + if (includeRawFile) { + imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc); + } + } catch (_error) { + delete canvasData.objects[index]; + isDecryptionPartialFailure = true; + error = _error instanceof Error ? _error.message : "Unknown error"; } - } + }); + + await Promise.all(decryptionPromises); + canvasData.objects = canvasData.objects.filter(Boolean); + return { isDecryptionPartialFailure, error }; } export async function decryptCanvasImagesWithSharingKey( @@ -59,19 +73,25 @@ export async function decryptCanvasImagesWithSharingKey( remoteImages.map((img) => [img.file_name, img.file]), ); - for (const obj of canvasData.objects) { - if (obj.type !== "Image") continue; + const decryptionPromises = canvasData.objects.map(async (obj) => { + if (obj.type !== "Image") return; const imgObj = obj as FabricImageJSON; const remoteUrl = imageMap.get(imgObj.src); - if (!remoteUrl) continue; + if (!remoteUrl) return; - const res = await api.get(remoteUrl, { responseType: "blob" }); - imgObj.src = await cryptoUtils.decryptImageWithSharingKey( - res.data, - sharingKey, - ); - } + try { + const res = await api.get(remoteUrl, { responseType: "blob" }); + imgObj.src = await cryptoUtils.decryptImageWithSharingKey( + res.data, + sharingKey, + ); + } catch (_error) { + // Keep original or handle failure + } + }); + + await Promise.all(decryptionPromises); } export async function encryptCanvasImages(