feat: implement read-only mode for sealed letters and handle error boundaries

This commit is contained in:
ramvignesh-b
2026-04-15 17:07:35 +05:30
parent fd64578a17
commit 75dd187442
5 changed files with 493 additions and 169 deletions
+266 -85
View File
@@ -9,6 +9,7 @@ import {
const PAD = 36; const PAD = 36;
const BASE_WIDTH = 680; const BASE_WIDTH = 680;
const DEFAULT_LOGICAL_HEIGHT = 900;
export interface FabricObjectJSON { export interface FabricObjectJSON {
type: string; type: string;
@@ -44,9 +45,6 @@ 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> => { const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
return new Promise((resolve) => { return new Promise((resolve) => {
const check = () => { const check = () => {
@@ -58,11 +56,11 @@ const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
}); });
}; };
/** const createMainTextbox = (
* Creates the primary text box for the letter. text: string,
*/ isReadOnly = false,
const createMainTextbox = (): fabric.Textbox => { ): fabric.Textbox => {
return new fabric.Textbox("Take a deep breath...", { return new fabric.Textbox(text, {
name: "main-textbox", name: "main-textbox",
originX: "left", originX: "left",
originY: "top", originY: "top",
@@ -74,7 +72,9 @@ const createMainTextbox = (): fabric.Textbox => {
fontFamily: "Playfair Display Variable", fontFamily: "Playfair Display Variable",
fill: "#000", fill: "#000",
lineHeight: 1.5, lineHeight: 1.5,
editable: true, editable: !isReadOnly,
selectable: false,
evented: !isReadOnly,
hasControls: false, hasControls: false,
hasBorders: false, hasBorders: false,
objectCaching: 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 fixFabricA11y = () => {
const textAreas = document.querySelectorAll( const textAreas = document.querySelectorAll(
'textarea[data-fabric="textarea"]', '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 = ( const initializeCanvas = (
el: HTMLCanvasElement, el: HTMLCanvasElement,
width: number, width: number,
@@ -147,11 +111,85 @@ const initializeCanvas = (
preserveObjectStacking: true, preserveObjectStacking: true,
allowTouchScrolling: true, allowTouchScrolling: true,
}); });
const wrapperEl = canvas.getElement().parentElement; const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent"; if (wrapperEl) wrapperEl.style.background = "transparent";
return canvas; 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< export const ComposeCanvas = forwardRef<
CanvasTools, CanvasTools,
{ readOnly?: boolean; initialData?: CanvasJSON | null } { readOnly?: boolean; initialData?: CanvasJSON | null }
@@ -160,47 +198,104 @@ export const ComposeCanvas = forwardRef<
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 deferredDataRef = useRef<CanvasJSON | null>(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( const setupTextboxInteractions = useCallback(
(fCanvas: fabric.Canvas, textbox: fabric.Textbox) => { (fCanvas: fabric.Canvas, textbox: fabric.Textbox) => {
textbox.on("changed", () => textbox.on("changed", () => {
handleResize(fCanvas, textbox, wrapperRef.current), updateLogicalHeightFromContent();
); });
fCanvas.on("mouse:down", (opt) => { fCanvas.on("mouse:down", (opt) => {
if (!opt.target || opt.target === textbox) { if (!opt.target || opt.target === textbox) {
focusTextbox(fCanvas, textbox); focusTextbox(fCanvas, textbox, readOnly);
} }
}); });
if (!readOnly) { if (!readOnly) {
setTimeout(() => focusTextbox(fCanvas, textbox), 100); setTimeout(() => {
focusTextbox(fCanvas, textbox, readOnly);
}, 200);
} }
}, },
[readOnly], [readOnly, updateLogicalHeightFromContent],
); );
const loadContent = useCallback( const loadContent = useCallback(
async ( async (
canvas: fabric.Canvas, canvas: fabric.Canvas,
data: CanvasJSON | null, data: CanvasJSON | null,
containerWidth: number, wrapper: HTMLDivElement,
): Promise<fabric.Textbox | null> => { ): Promise<fabric.Textbox | null> => {
// Always establish the scale relative to BASE_WIDTH const logicalSize = getLogicalSize(data);
const scale = containerWidth / BASE_WIDTH; logicalSizeRef.current = logicalSize;
canvas.setViewportTransform([scale, 0, 0, scale, 0, 0]);
if (data) { canvas.clear();
let textbox: fabric.Textbox | null = null;
if (data?.objects?.length) {
await canvas.loadFromJSON(data); await canvas.loadFromJSON(data);
if (readOnly) { textbox = findMainTextbox(canvas);
for (const obj of canvas.getObjects()) { } else {
obj.selectable = false; textbox = createMainTextbox("Take a deep breath...", readOnly);
obj.evented = false;
}
}
return null;
}
const textbox = createMainTextbox();
canvas.add(textbox); 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; return textbox;
}, },
[readOnly], [readOnly],
@@ -209,93 +304,180 @@ export const ComposeCanvas = forwardRef<
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
let canvas: fabric.Canvas | null = null; let canvas: fabric.Canvas | null = null;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
const init = async () => { const init = async () => {
await document.fonts.ready; await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return; if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
const finalWidth = await waitForLayout(wrapperRef.current); 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( canvas = initializeCanvas(
canvasRef.current, canvasRef.current,
finalWidth, finalWidth,
initialHeight, DEFAULT_LOGICAL_HEIGHT,
readOnly, readOnly,
); );
fabricRef.current = canvas; fabricRef.current = canvas;
const textbox = await loadContent(canvas, initialData, finalWidth); const textbox = await loadContent(
canvas,
initialData,
wrapperRef.current,
);
if (textbox) { if (textbox) {
textboxRef.current = textbox; textboxRef.current = textbox;
setupTextboxInteractions(canvas, 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(); init();
return () => { return () => {
isMounted = false; isMounted = false;
resizeObserver?.disconnect();
canvas?.dispose(); canvas?.dispose();
fabricRef.current = null; fabricRef.current = null;
textboxRef.current = null; textboxRef.current = null;
}; };
}, [initialData, readOnly, setupTextboxInteractions, loadContent]); }, [
initialData,
loadContent,
readOnly,
setupTextboxInteractions,
syncViewport,
]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => { addImage: (url: string, file: File) => {
if (!fabricRef.current) return; if (!fabricRef.current) return;
fabric.FabricImage.fromURL(url).then((img) => { fabric.FabricImage.fromURL(url).then((img) => {
img.scaleToWidth(300); img.scaleToWidth(Math.min(300, img.width));
img.set({ img.set({
originX: "left",
originY: "top",
_customRawFile: file, _customRawFile: file,
left: PAD, left: PAD,
top: PAD, top: PAD,
objectCaching: false,
} as Partial<FabricImageWithFile>); } as Partial<FabricImageWithFile>);
fabricRef.current?.add(img); fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img); fabricRef.current?.setActiveObject(img);
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(); fabricRef.current?.requestRenderAll();
}
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
}, },
getData: () => { getData: () => {
if (!fabricRef.current) return { objects: [] }; if (!fabricRef.current) return { objects: [] };
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
logicalSizeRef.current.height,
);
const json = fabricRef.current.toJSON() as CanvasJSON; const json = fabricRef.current.toJSON() as CanvasJSON;
json.canvasWidth = BASE_WIDTH; json.canvasWidth = logicalSizeRef.current.width;
json.canvasHeight = json.canvasHeight = logicalSizeRef.current.height;
fabricRef.current.getHeight() /
(fabricRef.current.viewportTransform?.[3] || 1);
return json; return json;
}, },
getJsonData: () => { getJsonData: () => {
if (!fabricRef.current) return ""; 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: () => { getImages: () => {
if (!fabricRef.current) return []; if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects( const images = fabricRef.current.getObjects(
"Image", "Image",
) as FabricImageWithFile[]; ) as FabricImageWithFile[];
return images.map((img) => ({ return images.map((img) => ({
src: img.getSrc(), src: img.getSrc(),
file: img._customRawFile, file: img._customRawFile,
})); }));
}, },
loadData: async (data: CanvasJSON) => { loadData: async (data: CanvasJSON) => {
if (!(fabricRef.current && wrapperRef.current)) return; if (!(fabricRef.current && wrapperRef.current)) {
const width = wrapperRef.current.clientWidth; deferredDataRef.current = data;
const textbox = await loadContent(fabricRef.current, data, width); return;
}
const textbox = await loadContent(
fabricRef.current,
data,
wrapperRef.current,
);
if (textbox) { if (textbox) {
textboxRef.current = textbox; textboxRef.current = textbox;
setupTextboxInteractions(fabricRef.current, textbox); setupTextboxInteractions(fabricRef.current, textbox);
} }
fabricRef.current.renderAll();
fabricRef.current.requestRenderAll();
fixFabricA11y();
}, },
})); }));
@@ -303,7 +485,6 @@ export const ComposeCanvas = forwardRef<
<div <div
ref={wrapperRef} ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text" className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
style={{ minHeight: "900px" }}
> >
<canvas <canvas
ref={canvasRef} ref={canvasRef}
+50
View File
@@ -0,0 +1,50 @@
import { WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react";
interface LogModalContent {
status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
message: string;
log: string;
onClose: () => void;
}
export const LogModal = ({
message,
log,
onClose,
status,
}: LogModalContent) => {
status === "RESET" ? null : (
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box bg-transparent border-none shadow-none relative">
<div
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
>
{status === "WARN" && (
<WarningIcon className="text-warning" size={16} weight="bold" />
)}
{status === "ERROR" && (
<XCircleIcon className="text-error" size={16} weight="bold" />
)}
{message}
<div className="divider text-primary-content text-xs uppercase tracking-widest">
Error Stack
</div>
<div className="mockup-code bg-base-100 text-error w-full">
<pre>
<code>{String(log)}</code>
</pre>
</div>
<form method="dialog">
<button
type="button"
onClick={onClose}
className="btn btn-sm btn-circle btn-ghost absolute right-6 top-6"
>
<XIcon size={6} weight="bold" />
</button>
</form>
</div>
</div>
</div>
);
};
+1
View File
@@ -12,6 +12,7 @@ export const Navbar = ({ child }: { child?: React.ReactNode }) => {
type="button" type="button"
onClick={() => navigate(ROUTES.DRAWER)} onClick={() => navigate(ROUTES.DRAWER)}
className="group flex items-center gap-2 px-0 hover:bg-transparent cursor-pointer" className="group flex items-center gap-2 px-0 hover:bg-transparent cursor-pointer"
aria-label="Open Drawer"
> >
<div className="p-1.5 rounded-full bg-base-content/5 transition-colors group-hover:bg-primary/10"> <div className="p-1.5 rounded-full bg-base-content/5 transition-colors group-hover:bg-primary/10">
<ArrowArcLeftIcon <ArrowArcLeftIcon
+86 -14
View File
@@ -9,13 +9,18 @@ import {
XIcon, XIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import {
type NavigateFunction,
useNavigate,
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import { import {
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/ui/ComposeCanvas"; } from "../components/ui/ComposeCanvas";
import DateDisplay from "../components/ui/DateDisplay"; import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal";
import { Navbar } from "../components/ui/Navbar"; import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
@@ -32,12 +37,23 @@ const ERROR_VISIBLE_MS = 2400;
export default function Editor() { export default function Editor() {
const navigate = useNavigate(); const navigate = useNavigate();
const navigateRef = useRef<NavigateFunction>(navigate);
navigateRef.current = navigate;
const { public_id } = useParams(); const { public_id } = useParams();
const letterIdRef = useRef<string>(public_id ?? ""); const letterIdRef = useRef<string>(public_id ?? "");
const justSavedRef = useRef<boolean>(false);
const [decryptionStatus, setDecryptionStatus] = useState<{
status: "SUCCESS" | "WARN" | "ERROR" | "RESET";
message: string;
log: string;
}>({ status: "RESET", message: "", log: "" });
const [isInitialLoading, setIsInitialLoading] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null); const [shareLink, setShareLink] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null); const [lastSaved, setLastSaved] = useState<string | null>(null);
const [status, setStatus] = useState<"DRAFT" | "SEALED">("DRAFT");
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false); const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0); const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
@@ -52,6 +68,10 @@ export default function Editor() {
useEffect(() => { useEffect(() => {
if (!(public_id && masterKey)) return; if (!(public_id && masterKey)) return;
if (justSavedRef.current) {
justSavedRef.current = false;
return;
}
const loadExistingLetter = async () => { const loadExistingLetter = async () => {
setIsInitialLoading(true); setIsInitialLoading(true);
@@ -62,6 +82,16 @@ export default function Editor() {
const letterData = res.data; const letterData = res.data;
setLastSaved(formatRelativeDate(new Date(letterData.updated_at))); 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( const metadata = await cryptoUtils.decryptMetadata(
{ {
@@ -81,7 +111,7 @@ export default function Editor() {
); );
const canvasData = JSON.parse(decryptedJsonStr); const canvasData = JSON.parse(decryptedJsonStr);
await decryptCanvasImages( const { isDecryptionPartialFailure, error } = await decryptCanvasImages(
canvasData, canvasData,
letterData.images ?? [], letterData.images ?? [],
letterData.encrypted_dek, letterData.encrypted_dek,
@@ -90,10 +120,26 @@ export default function Editor() {
true, true,
); );
requestAnimationFrame(() => { if (isDecryptionPartialFailure) {
canvasRef.current?.loadData(canvasData); 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) { } catch (_err) {
setDecryptionStatus({
status: "ERROR",
message: "Failed to decrypt letter. Please try again later.",
log: _err instanceof Error ? _err.message : "Unknown error",
});
} finally { } finally {
setIsInitialLoading(false); setIsInitialLoading(false);
} }
@@ -147,11 +193,9 @@ export default function Editor() {
}; };
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => { const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
if (!(public_id || letterIdRef.current)) { let targetId = public_id || letterIdRef.current;
letterIdRef.current = crypto.randomUUID(); if (!targetId) {
navigate(PATHS.write(letterIdRef.current), { replace: true }); targetId = crypto.randomUUID();
} else if (public_id) {
letterIdRef.current = public_id;
} }
if (saveOverlay === "saving" || !masterKey) return; if (saveOverlay === "saving" || !masterKey) return;
@@ -184,7 +228,7 @@ export default function Editor() {
); );
const formData = new FormData(); const formData = new FormData();
formData.append("public_id", letterIdRef.current); formData.append("public_id", targetId);
formData.append("type", "KEPT"); formData.append("type", "KEPT");
formData.append("status", status); formData.append("status", status);
formData.append("encrypted_content", encrypted_letter.encrypted_content); formData.append("encrypted_content", encrypted_letter.encrypted_content);
@@ -198,14 +242,21 @@ export default function Editor() {
formData.append("image_files", blob, filename); 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())); setLastSaved(formatRelativeDate(new Date()));
setStatus(status);
setLastSavedPulseTick((prev) => prev + 1); setLastSavedPulseTick((prev) => prev + 1);
if (status === "SEALED" && encrypted_letter.sharingKey) { if (status === "SEALED" && encrypted_letter.sharingKey) {
const link = `${window.location.origin}${PATHS.read( const link = `${window.location.origin}${PATHS.read(
letterIdRef.current, targetId,
)}#${encrypted_letter.sharingKey}`; )}#${encrypted_letter.sharingKey}`;
setShareLink(link); setShareLink(link);
setShowSaveOverlay(false); setShowSaveOverlay(false);
@@ -251,6 +302,15 @@ export default function Editor() {
/> />
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative"> <section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative">
<LogModal
status={decryptionStatus.status}
message={decryptionStatus.message}
log={decryptionStatus.log}
onClose={() =>
setDecryptionStatus({ status: "RESET", message: "", log: "" })
}
/>
{isInitialLoading && ( {isInitialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm"> <div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
@@ -382,13 +442,15 @@ export default function Editor() {
type="text" type="text"
placeholder="Someone dear..." placeholder="Someone dear..."
value={recipient} value={recipient}
disabled={status === "SEALED"}
onChange={(e) => setRecipient(e.target.value)} 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"
/> />
</div> </div>
<DateDisplay /> <DateDisplay />
</div> </div>
{status === "DRAFT" ? (
<div <div
id="writer-toolbar" id="writer-toolbar"
className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6" className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
@@ -433,8 +495,18 @@ export default function Editor() {
</button> </button>
</div> </div>
</div> </div>
) : (
<div className="flex items-center justify-center mb-8 h-14">
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
<LockIcon size={14} weight="fill" />
<span className="text-[10px] uppercase tracking-widest font-bold">
Sealed & View Only
</span>
</div>
</div>
)}
<ComposeCanvas ref={canvasRef} /> <ComposeCanvas ref={canvasRef} readOnly={status === "SEALED"} />
</div> </div>
</section> </section>
</> </>
+30 -10
View File
@@ -18,21 +18,26 @@ export async function decryptCanvasImages(
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
includeRawFile = false, includeRawFile = false,
) { ): Promise<{ isDecryptionPartialFailure: boolean; error: string }> {
if (!canvasData?.objects) return; if (!canvasData?.objects)
return { isDecryptionPartialFailure: false, error: "" };
let isDecryptionPartialFailure = false;
let error = "";
const imageMap = new Map( const imageMap = new Map(
remoteImages.map((img) => [img.file_name, img.file]), remoteImages.map((img) => [img.file_name, img.file]),
); );
for (const obj of canvasData.objects) { const decryptionPromises = canvasData.objects.map(async (obj, index) => {
if (obj.type !== "Image") continue; if (obj.type !== "Image") return;
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
const originalSrc = imgObj.src; const remoteUrl = imageMap.get(imgObj.src);
const remoteUrl = imageMap.get(originalSrc); if (!remoteUrl) return;
if (!remoteUrl) continue;
try {
const res = await api.get(remoteUrl, { responseType: "blob" }); const res = await api.get(remoteUrl, { responseType: "blob" });
const originalSrc = imgObj.src;
const blobUrl = await cryptoUtils.decryptImage( const blobUrl = await cryptoUtils.decryptImage(
res.data, res.data,
encrypted_dek, encrypted_dek,
@@ -44,7 +49,16 @@ export async function decryptCanvasImages(
if (includeRawFile) { if (includeRawFile) {
imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc); 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( export async function decryptCanvasImagesWithSharingKey(
@@ -59,19 +73,25 @@ export async function decryptCanvasImagesWithSharingKey(
remoteImages.map((img) => [img.file_name, img.file]), remoteImages.map((img) => [img.file_name, img.file]),
); );
for (const obj of canvasData.objects) { const decryptionPromises = canvasData.objects.map(async (obj) => {
if (obj.type !== "Image") continue; if (obj.type !== "Image") return;
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
const remoteUrl = imageMap.get(imgObj.src); const remoteUrl = imageMap.get(imgObj.src);
if (!remoteUrl) continue; if (!remoteUrl) return;
try {
const res = await api.get(remoteUrl, { responseType: "blob" }); const res = await api.get(remoteUrl, { responseType: "blob" });
imgObj.src = await cryptoUtils.decryptImageWithSharingKey( imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
res.data, res.data,
sharingKey, sharingKey,
); );
} catch (_error) {
// Keep original or handle failure
} }
});
await Promise.all(decryptionPromises);
} }
export async function encryptCanvasImages( export async function encryptCanvasImages(