mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: define explicit TypeScript interfaces for CanvasJSON and implement robust canvas initialization and interaction logic
This commit is contained in:
@@ -1,139 +1,231 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the container to have a valid width before initializing the canvas.
|
||||||
|
*/
|
||||||
|
const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
|
||||||
|
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<
|
export const ComposeCanvas = forwardRef<
|
||||||
CanvasTools,
|
CanvasTools,
|
||||||
{ readOnly?: boolean; initialData?: any }
|
{ readOnly?: boolean; initialData?: CanvasJSON | null }
|
||||||
>(({ readOnly = false, initialData = null }, ref) => {
|
>(({ readOnly = false, initialData = null }, ref) => {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||||
const textboxRef = useRef<fabric.Textbox | 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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
let canvas: fabric.Canvas | null = null;
|
let canvas: fabric.Canvas | null = null;
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
const waitForLayout = (): Promise<number> => {
|
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||||
return new Promise((resolve) => {
|
|
||||||
const check = () => {
|
|
||||||
const wrapperWidth = wrapperRef.current?.clientWidth || 0;
|
|
||||||
if (wrapperWidth > 0) resolve(wrapperWidth);
|
|
||||||
else requestAnimationFrame(check);
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalWidth = await waitForLayout();
|
const finalWidth = await waitForLayout(wrapperRef.current);
|
||||||
if (!(isMounted && canvasRef.current && wrapperRef.current)) return;
|
if (!(isMounted && canvasRef.current)) return;
|
||||||
|
|
||||||
const initialHeight = Math.max(
|
const initialHeight = Math.max(
|
||||||
wrapperRef.current.clientHeight || 900,
|
wrapperRef.current.clientHeight || 900,
|
||||||
600,
|
600,
|
||||||
);
|
);
|
||||||
|
canvas = initializeCanvas(
|
||||||
canvas = new fabric.Canvas(canvasRef.current, {
|
canvasRef.current,
|
||||||
width: finalWidth,
|
finalWidth,
|
||||||
height: initialHeight,
|
initialHeight,
|
||||||
selection: !readOnly,
|
readOnly,
|
||||||
preserveObjectStacking: true,
|
);
|
||||||
allowTouchScrolling: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
fabricRef.current = canvas;
|
fabricRef.current = canvas;
|
||||||
|
|
||||||
const wrapperEl = canvas.getElement().parentElement;
|
const textbox = await loadContent(canvas, initialData, finalWidth);
|
||||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
if (textbox) {
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
textboxRef.current = textbox;
|
textboxRef.current = textbox;
|
||||||
canvas.add(textbox);
|
setupTextboxInteractions(canvas, 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
|
||||||
try {
|
img.file,
|
||||||
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
|
masterKey,
|
||||||
img.file,
|
);
|
||||||
masterKey,
|
filenameMapping.set(img.src, filename);
|
||||||
);
|
encryptedFiles.set(filename, encryptedBlob);
|
||||||
filenameMapping.set(img.src, filename);
|
|
||||||
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;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user