refactor: define explicit TypeScript interfaces for CanvasJSON and implement robust canvas initialization and interaction logic

This commit is contained in:
ramvignesh-b
2026-04-14 00:34:43 +05:30
parent 5c81d617bd
commit 3aebf920a6
5 changed files with 276 additions and 157 deletions
+188 -108
View File
@@ -1,86 +1,72 @@
import * as fabric from "fabric"; import * as fabric from "fabric";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from "react";
const PAD = 36; const PAD = 36;
type CanvasJSON = ReturnType<fabric.Canvas["toJSON"]>; 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 = { export type CanvasTools = {
addImage: (url: string, file: File) => void; addImage: (url: string, file: File) => void;
getData: () => { objects: CanvasJSON["objects"] }; // no-any hack :/ getData: () => CanvasJSON;
getJsonData: () => string; getJsonData: () => string;
getImages: () => { src: string; file: File }[]; getImages: () => { src: string; file: File }[];
loadData: (data: any) => Promise<void>; loadData: (data: CanvasJSON) => Promise<void>;
}; };
export interface FabricImageWithFile extends fabric.FabricImage { export interface FabricImageWithFile extends fabric.FabricImage {
_customRawFile: File; _customRawFile: File;
} }
export const ComposeCanvas = forwardRef< /**
CanvasTools, * Wait for the container to have a valid width before initializing the canvas.
{ readOnly?: boolean; initialData?: any } */
>(({ readOnly = false, initialData = null }, ref) => { const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(null);
useEffect(() => {
let isMounted = true;
let canvas: fabric.Canvas | null = null;
const init = async () => {
await document.fonts.ready;
const waitForLayout = (): Promise<number> => {
return new Promise((resolve) => { return new Promise((resolve) => {
const check = () => { const check = () => {
const wrapperWidth = wrapperRef.current?.clientWidth || 0; const width = wrapper.clientWidth || 0;
if (wrapperWidth > 0) resolve(wrapperWidth); if (width > 0) resolve(width);
else requestAnimationFrame(check); else requestAnimationFrame(check);
}; };
check(); check();
}); });
}; };
const finalWidth = await waitForLayout(); /**
if (!(isMounted && canvasRef.current && wrapperRef.current)) return; * Creates the primary text box for the letter.
*/
const initialHeight = Math.max( const createMainTextbox = (width: number): fabric.Textbox => {
wrapperRef.current.clientHeight || 900, return new fabric.Textbox("Take a deep breath...", {
600,
);
canvas = new fabric.Canvas(canvasRef.current, {
width: finalWidth,
height: initialHeight,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
});
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", name: "main-textbox",
originX: "left", originX: "left",
originY: "top", originY: "top",
left: PAD, left: PAD,
top: PAD, top: PAD,
width: finalWidth - PAD * 2, width: width - PAD * 2,
fontSize: 16, fontSize: 16,
fontWeight: 500, fontWeight: 500,
fontFamily: "Playfair Display Variable", fontFamily: "Playfair Display Variable",
@@ -95,45 +81,151 @@ export const ComposeCanvas = forwardRef<
lockMovementY: true, lockMovementY: true,
lockScalingX: true, lockScalingX: true,
lockScalingY: true, lockScalingY: true,
lockRotation: true,
}); });
};
textboxRef.current = textbox; /**
canvas.add(textbox); * Fabric.js creates hidden textareas for input. We add aria-labels for accessibility.
*/
textbox.on("changed", () => { const fixFabricA11y = () => {
if (!(canvas && wrapperRef.current)) return; const textAreas = document.querySelectorAll(
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"]', 'textarea[data-fabric="textarea"]',
); );
for (const textArea of hiddenTextareas) { for (const area of textAreas) {
if (!textArea.getAttribute("aria-label")) { if (!area.getAttribute("aria-label")) {
textArea.setAttribute("aria-label", "Canvas text input"); area.setAttribute("aria-label", "Canvas text input");
} }
} }
}, 100); };
canvas.on("mouse:down", (opt) => { /**
if (!opt.target || opt.target === textbox) { * Handle canvas resizing based on textbox content.
canvas?.setActiveObject(textbox); */
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(); textbox.enterEditing();
canvas?.renderAll(); 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?: CanvasJSON | null }
>(({ readOnly = false, initialData = null }, ref) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(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<fabric.Textbox | null> => {
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;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
const finalWidth = await waitForLayout(wrapperRef.current);
if (!(isMounted && canvasRef.current)) return;
const initialHeight = Math.max(
wrapperRef.current.clientHeight || 900,
600,
);
canvas = initializeCanvas(
canvasRef.current,
finalWidth,
initialHeight,
readOnly,
);
fabricRef.current = canvas;
const textbox = await loadContent(canvas, initialData, finalWidth);
if (textbox) {
textboxRef.current = textbox;
setupTextboxInteractions(canvas, textbox);
}
canvas.renderAll();
}; };
init(); init();
@@ -144,7 +236,7 @@ export const ComposeCanvas = forwardRef<
fabricRef.current = null; fabricRef.current = null;
textboxRef.current = null; textboxRef.current = null;
}; };
}, [initialData, readOnly]); }, [initialData, readOnly, setupTextboxInteractions, loadContent]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => { addImage: (url: string, file: File) => {
@@ -155,7 +247,7 @@ export const ComposeCanvas = forwardRef<
_customRawFile: file, _customRawFile: file,
left: PAD, left: PAD,
top: PAD, top: PAD,
}); } as Partial<FabricImageWithFile>);
fabricRef.current?.add(img); fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img); fabricRef.current?.setActiveObject(img);
fabricRef.current?.requestRenderAll(); fabricRef.current?.requestRenderAll();
@@ -163,45 +255,32 @@ export const ComposeCanvas = forwardRef<
}); });
}, },
getData: () => { getData: () => {
if (!fabricRef.current) return { objects: [] }; if (!fabricRef.current) return { version: "", objects: [] };
return fabricRef.current.toJSON(); return fabricRef.current.toJSON() as CanvasJSON;
}, },
getJsonData: () => { getJsonData: () => {
if (!fabricRef.current) return ""; if (!fabricRef.current) return "";
return JSON.stringify(fabricRef.current.toJSON()); // convert to json string return JSON.stringify(fabricRef.current.toJSON());
}, },
getImages: () => { getImages: () => {
if (!fabricRef.current) return []; if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects( const images = fabricRef.current.getObjects(
"Image", "Image",
) as fabric.FabricImage[]; ) as FabricImageWithFile[];
return images.map((img) => ({ return images.map((img) => ({
src: img.getSrc(), src: img.getSrc(),
file: (img as any)._customRawFile, file: img._customRawFile,
})); }));
}, },
loadData: async (data: any) => { loadData: async (data: CanvasJSON) => {
if (!fabricRef.current) return; if (!fabricRef.current) return;
await fabricRef.current.loadFromJSON(data); await fabricRef.current.loadFromJSON(data);
const textboxes = fabricRef.current.getObjects("Textbox");
// find the textbox and restore focus if (textboxes.length > 0) {
const objects = fabricRef.current.getObjects("Textbox"); const textbox = textboxes[0] as fabric.Textbox;
if (objects.length > 0) {
const textbox = objects[0] as fabric.Textbox;
textbox.lockMovementX = true;
textbox.lockMovementY = true;
textbox.hasControls = false;
textbox.hasBorders = false;
textboxRef.current = textbox; textboxRef.current = textbox;
fabricRef.current.setActiveObject(textbox); setupTextboxInteractions(fabricRef.current, textbox);
if (textbox.text) {
// move cursor to end
textbox.selectionStart = textbox.text.length;
textbox.selectionEnd = textbox.text.length;
} }
textbox.enterEditing();
}
fabricRef.current.renderAll(); fabricRef.current.renderAll();
}, },
})); }));
@@ -220,4 +299,5 @@ export const ComposeCanvas = forwardRef<
</div> </div>
); );
}); });
ComposeCanvas.displayName = "ComposeCanvas"; ComposeCanvas.displayName = "ComposeCanvas";
+13 -6
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import { import {
type CanvasJSON,
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/ui/ComposeCanvas"; } from "../components/ui/ComposeCanvas";
@@ -10,6 +11,10 @@ import { endpoints } from "../config/endpoints";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic";
interface LetterMetadata {
recipient?: string;
}
export default function Reader() { export default function Reader() {
const { public_id } = useParams(); const { public_id } = useParams();
const location = useLocation(); const location = useLocation();
@@ -19,8 +24,9 @@ export default function Reader() {
const [isDecrypting, setIsDecrypting] = useState(true); const [isDecrypting, setIsDecrypting] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [metadata, setMetadata] = useState<any>(null); const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
const [decryptedCanvasData, setDecryptedCanvasData] = useState<any>(null); const [decryptedCanvasData, setDecryptedCanvasData] =
useState<CanvasJSON | null>(null);
useEffect(() => { useEffect(() => {
if (!sharingKey) { if (!sharingKey) {
@@ -41,13 +47,13 @@ export default function Reader() {
encrypted_metadata, encrypted_metadata,
sharingKey, sharingKey,
); );
setMetadata(decryptedMetadata); setMetadata(decryptedMetadata as LetterMetadata);
const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey( const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey(
encrypted_content, encrypted_content,
sharingKey, sharingKey,
); );
const json = JSON.parse(decryptedContent); const json = JSON.parse(decryptedContent) as CanvasJSON;
if (images && images.length > 0) { if (images && images.length > 0) {
await decryptCanvasImagesWithSharingKey( await decryptCanvasImagesWithSharingKey(
@@ -59,8 +65,9 @@ export default function Reader() {
} }
setDecryptedCanvasData(json); setDecryptedCanvasData(json);
} catch (err: any) { } catch (err) {
setError(`Failed to load letter: ${err.message || "Unknown error"}`); const message = err instanceof Error ? err.message : "Unknown error";
setError(`Failed to load letter: ${message}`);
} finally { } finally {
setIsDecrypting(false); setIsDecrypting(false);
} }
+2 -2
View File
@@ -227,7 +227,7 @@ export class CryptoUtils {
public async decryptMetadata( public async decryptMetadata(
encrypted_metadata: EncryptedLetter, encrypted_metadata: EncryptedLetter,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<Record<string, any>> { ): Promise<Record<string, unknown>> {
const bytes = await this.openEnvelope( const bytes = await this.openEnvelope(
encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_content,
encrypted_metadata.encrypted_dek, encrypted_metadata.encrypted_dek,
@@ -239,7 +239,7 @@ export class CryptoUtils {
public async decryptMetadataWithSharingKey( public async decryptMetadataWithSharingKey(
encrypted_content: string, encrypted_content: string,
sharingKey: string, sharingKey: string,
): Promise<Record<string, any>> { ): Promise<Record<string, unknown>> {
const bytes = await this.openEnvelopeWithSharingKey( const bytes = await this.openEnvelopeWithSharingKey(
encrypted_content, encrypted_content,
sharingKey, sharingKey,
+27 -3
View File
@@ -92,9 +92,24 @@ describe("letterLogic image helpers", () => {
describe("decryptCanvasImages", () => { describe("decryptCanvasImages", () => {
it("should decrypt images and replace src with blob URL", async () => { it("should decrypt images and replace src with blob URL", async () => {
const canvasData = { const canvasData = {
version: "5.3.0",
objects: [ 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 = [ const remoteImages = [
@@ -128,8 +143,17 @@ describe("letterLogic image helpers", () => {
it("should include raw file when includeRawFile is true", async () => { it("should include raw file when includeRawFile is true", async () => {
const canvasData = { const canvasData = {
version: "5.3.0",
objects: [ 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 = [ const remoteImages = [
+26 -18
View File
@@ -1,4 +1,8 @@
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import type {
CanvasJSON,
FabricImageJSON,
} from "../components/ui/ComposeCanvas";
import type { CryptoUtils } from "./crypto"; import type { CryptoUtils } from "./crypto";
import { blobUrlToFile } from "./fileUtils"; import { blobUrlToFile } from "./fileUtils";
@@ -8,8 +12,8 @@ export interface CanvasImageRef {
} }
export async function decryptCanvasImages( export async function decryptCanvasImages(
canvasData: any, canvasData: CanvasJSON,
remoteImages: any[], remoteImages: { file_name: string; file: string }[],
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
@@ -23,9 +27,9 @@ export async function decryptCanvasImages(
for (const obj of canvasData.objects) { for (const obj of canvasData.objects) {
if (obj.type !== "Image") continue; if (obj.type !== "Image") continue;
const imgObj = obj as FabricImageJSON;
const originalFilename = obj.src; const originalSrc = imgObj.src;
const remoteUrl = imageMap.get(originalFilename); const remoteUrl = imageMap.get(originalSrc);
if (!remoteUrl) continue; if (!remoteUrl) continue;
const res = await api.get(remoteUrl, { responseType: "blob" }); const res = await api.get(remoteUrl, { responseType: "blob" });
@@ -35,17 +39,17 @@ export async function decryptCanvasImages(
masterKey, masterKey,
); );
obj.src = blobUrl; imgObj.src = blobUrl;
if (includeRawFile) { if (includeRawFile) {
obj._customRawFile = await blobUrlToFile(blobUrl, originalFilename); imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc);
} }
} }
} }
export async function decryptCanvasImagesWithSharingKey( export async function decryptCanvasImagesWithSharingKey(
canvasData: any, canvasData: CanvasJSON,
remoteImages: any[], remoteImages: { file_name: string; file: string }[],
sharingKey: string, sharingKey: string,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
) { ) {
@@ -58,11 +62,12 @@ export async function decryptCanvasImagesWithSharingKey(
for (const obj of canvasData.objects) { for (const obj of canvasData.objects) {
if (obj.type !== "Image") continue; 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; if (!remoteUrl) continue;
const res = await api.get(remoteUrl, { responseType: "blob" }); const res = await api.get(remoteUrl, { responseType: "blob" });
obj.src = await cryptoUtils.decryptImageWithSharingKey( imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
res.data, res.data,
sharingKey, sharingKey,
); );
@@ -70,7 +75,7 @@ export async function decryptCanvasImagesWithSharingKey(
} }
export async function encryptCanvasImages( export async function encryptCanvasImages(
canvasData: any, canvasData: CanvasJSON,
canvasImages: CanvasImageRef[], canvasImages: CanvasImageRef[],
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
@@ -81,21 +86,24 @@ export async function encryptCanvasImages(
for (const img of canvasImages) { for (const img of canvasImages) {
if (img.src.endsWith(".bin")) continue; if (img.src.endsWith(".bin")) continue;
if (!img.file) continue; if (!img.file) continue;
try {
const { filename, encryptedBlob } = await cryptoUtils.encryptImage( const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
img.file, img.file,
masterKey, masterKey,
); );
filenameMapping.set(img.src, filename); filenameMapping.set(img.src, filename);
encryptedFiles.set(filename, encryptedBlob); encryptedFiles.set(filename, encryptedBlob);
} catch (_err) {}
} }
if (canvasData?.objects) { if (canvasData?.objects) {
canvasData.objects = canvasData.objects.map((obj: any) => { canvasData.objects = canvasData.objects.map((obj) => {
if (obj.type === "Image" && filenameMapping.has(obj.src)) { if (obj.type === "Image") {
return { ...obj, src: filenameMapping.get(obj.src) }; const imgObj = obj as FabricImageJSON;
if (filenameMapping.has(imgObj.src)) {
return {
...imgObj,
src: filenameMapping.get(imgObj.src) as string,
};
}
} }
return obj; return obj;
}); });