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
+267 -86
View File
@@ -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<number> => {
return new Promise((resolve) => {
const check = () => {
@@ -58,11 +56,11 @@ const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
});
};
/**
* 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<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | 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(
(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<fabric.Textbox | null> => {
// 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<FabricImageWithFile>);
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<
<div
ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
style={{ minHeight: "900px" }}
>
<canvas
ref={canvasRef}