refactor: move font imports to compose canvas for syncing across reader and editor pages

This commit is contained in:
me
2026-05-06 20:49:13 +05:30
parent 8449377b6d
commit 8af9eab6ea
2 changed files with 335 additions and 325 deletions
+332 -318
View File
@@ -2,6 +2,12 @@ import * as fabric from "fabric";
import type * as React from "react"; import type * as React from "react";
import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
const PAD = 36; const PAD = 36;
const BASE_WIDTH = 680; const BASE_WIDTH = 680;
const DEFAULT_LOGICAL_HEIGHT = 900; const DEFAULT_LOGICAL_HEIGHT = 900;
@@ -9,385 +15,393 @@ const DEFAULT_FONT_FAMILY = "Playfair Display Variable";
const DEFAULT_FONT_COLOR = "#000"; const DEFAULT_FONT_COLOR = "#000";
export interface FabricObjectJSON { export interface FabricObjectJSON {
type: string; type: string;
name?: string; name?: string;
top: number; top: number;
left: number; left: number;
width: number; width: number;
height: number; height: number;
[key: string]: unknown; [key: string]: unknown;
} }
export interface FabricImageJSON extends FabricObjectJSON { export interface FabricImageJSON extends FabricObjectJSON {
type: "Image"; type: "Image";
src: string; src: string;
_customRawFile?: File; _customRawFile?: File;
} }
export interface CanvasJSON { export interface CanvasJSON {
objects: (FabricObjectJSON | FabricImageJSON)[]; objects: (FabricObjectJSON | FabricImageJSON)[];
canvasWidth?: number; canvasWidth?: number;
canvasHeight?: number; canvasHeight?: number;
} }
export interface CanvasStyle { export interface CanvasStyle {
fontFamily: string; fontFamily: string;
fontColor: string; fontColor: string;
} }
export type CanvasTools = { export type CanvasTools = {
addImage: (url: string, file: File) => void; addImage: (url: string, file: File) => void;
getData: () => CanvasJSON; getData: () => CanvasJSON;
getImages: () => { src: string; file: File }[]; getImages: () => { src: string; file: File }[];
loadData: (data: CanvasJSON) => Promise<void>; loadData: (data: CanvasJSON) => Promise<void>;
getStyle: () => CanvasStyle; getStyle: () => CanvasStyle;
}; };
export interface FabricImageWithFile extends fabric.FabricImage { export interface FabricImageWithFile extends fabric.FabricImage {
_customRawFile: File; _customRawFile: File;
} }
// NOTE: We use the same canvasData to render on both mobile and desktop viewports. // NOTE: We use the same canvasData to render on both mobile and desktop viewports.
// Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up) // Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
// over the last saved canvas size. // over the last saved canvas size.
const applyResponsiveViewport = ( const applyResponsiveViewport = (
canvas: fabric.Canvas, canvas: fabric.Canvas,
wrapper: HTMLDivElement, wrapper: HTMLDivElement,
logicalWidth: number, logicalWidth: number,
logicalHeight: number, logicalHeight: number,
) => { ) => {
const physicalWidth = wrapper.clientWidth || logicalWidth; const physicalWidth = wrapper.clientWidth || logicalWidth;
const zoomMultiplier = physicalWidth / logicalWidth; const zoomMultiplier = physicalWidth / logicalWidth;
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier); const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
canvas.setDimensions({ canvas.setDimensions({
width: physicalWidth, width: physicalWidth,
height: physicalHeight, height: physicalHeight,
}); });
wrapper.style.height = `${physicalHeight}px`; wrapper.style.height = `${physicalHeight}px`;
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]); canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
canvas.requestRenderAll(); canvas.requestRenderAll();
}; };
// to find the maximum height of the content to dynamically resize the canvas // to find the maximum height of the content to dynamically resize the canvas
// would've been wayyy easier only if canvas supported fit-content like CSS property :) // would've been wayyy easier only if canvas supported fit-content like CSS property :)
const measureLogicalContentHeight = ( const measureLogicalContentHeight = (
canvas: fabric.Canvas, canvas: fabric.Canvas,
minimumHeight = DEFAULT_LOGICAL_HEIGHT, minimumHeight = DEFAULT_LOGICAL_HEIGHT,
) => { ) => {
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => { const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
const top = currObj.top; const top = currObj.top;
const height = currObj.getScaledHeight(); const height = currObj.getScaledHeight();
return Math.max(maxHeight, top + height); return Math.max(maxHeight, top + height);
}, 0); }, 0);
return Math.max(minimumHeight, maxBottom + PAD); return Math.max(minimumHeight, maxBottom + PAD);
}; };
const DEFAULT_INIT_TEXT = "Take a deep breath..."; const DEFAULT_INIT_TEXT = "Take a deep breath...";
interface ComposeCanvasProps { interface ComposeCanvasProps {
readOnly?: boolean; readOnly?: boolean;
initialData?: CanvasJSON | null; initialData?: CanvasJSON | null;
style?: CanvasStyle; style?: CanvasStyle;
ref?: React.Ref<CanvasTools>; ref?: React.Ref<CanvasTools>;
} }
export function ComposeCanvas({ export function ComposeCanvas({
readOnly = false, readOnly = false,
initialData = null, initialData = null,
style, style,
ref, ref,
}: ComposeCanvasProps) { }: ComposeCanvasProps) {
// wrapper is the parent div box // wrapper is the parent div box
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 deferredDataRef = useRef<CanvasJSON | null>(null); const deferredDataRef = useRef<CanvasJSON | null>(null);
const logicalSizeRef = useRef({ const logicalSizeRef = useRef({
width: BASE_WIDTH, width: BASE_WIDTH,
height: DEFAULT_LOGICAL_HEIGHT,
});
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return;
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
minHeight,
);
applyResponsiveViewport(
fabricRef.current,
wrapperRef.current,
logicalSizeRef.current.width,
logicalSizeRef.current.height,
);
fabricRef.current.requestRenderAll();
}, [initialData]);
// auto focus the cursor into the main textbox no matter the latest element added
const focusTextbox = useCallback(
(textbox: fabric.Textbox) => {
if (readOnly || !fabricRef.current) return;
fabricRef.current.setActiveObject(textbox);
textbox.enterEditing();
// move the cursor to the end of the text
const textLength = textbox.text?.length ?? 0;
textbox.selectionStart = textLength;
textbox.selectionEnd = textLength;
fabricRef.current.requestRenderAll();
},
[readOnly],
);
const loadContent = useCallback(
async (data: CanvasJSON | null) => {
const canvas = fabricRef.current;
const wrapper = wrapperRef.current;
if (!(canvas && wrapper)) return;
// clean the canvas everytime and set fresh
canvas.clear();
let textbox: fabric.Textbox | null = null;
// restore logical size from prev saved data if available (in case of existing letter)
logicalSizeRef.current = {
width: data?.canvasWidth ?? BASE_WIDTH,
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
};
if (data?.objects?.length) {
await canvas.loadFromJSON(data);
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
} else {
// Create a fresh letter if no data exists
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5,
// NOTE: splitByGrapheme is required for word wrap and re-low
// but fabric asks to disable this for clear font?? So we disable it for read view
splitByGrapheme: !readOnly,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
noScaleCache: false,
});
canvas.add(textbox);
}
if (!textbox) return;
// readonly contraints applicable for post seal view
textbox.selectable = !readOnly;
textbox.evented = !readOnly;
textbox.editable = !readOnly;
textbox.hasBorders = false;
textboxRef.current = textbox;
// observe and auto-resize the canvas height whenever typed
textbox.on("changed", syncViewport);
// trapping the focus into the textbox wherever clicked on canvas (except images)
canvas.on("mouse:down", (e) => {
if (!e.target || e.target === textbox) {
focusTextbox(textbox);
}
});
syncViewport();
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
// otherwise it goes to the front
if (!readOnly) {
setTimeout(() => focusTextbox(textbox), 200);
}
},
[readOnly, syncViewport, focusTextbox],
);
useEffect(() => {
if (style && textboxRef.current) {
const textBox = textboxRef.current;
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
textBox.fill = style.fontColor || textBox.fill;
syncViewport();
}
}, [style, syncViewport]);
useEffect(() => {
let isMounted = true;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height: DEFAULT_LOGICAL_HEIGHT, height: DEFAULT_LOGICAL_HEIGHT,
selection: !readOnly, });
preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
objectCaching: false,
});
// remove default fabric background to let our CSS show through // re-calculates height based on content and applies the zoom transform
// TODO: provision custom bg (color in scope, but how does img fit?) const syncViewport = useCallback(() => {
const wrapperEl = canvas.getElement().parentElement; if (!(fabricRef.current && wrapperRef.current)) return;
if (wrapperEl) wrapperEl.style.background = "transparent";
fabricRef.current = canvas; const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
minHeight,
);
await loadContent(initialData); applyResponsiveViewport(
fabricRef.current,
wrapperRef.current,
logicalSizeRef.current.width,
logicalSizeRef.current.height,
);
// sometimes loadData() may be called before the canvas finished the init render fabricRef.current.requestRenderAll();
// so we retry that stashed render right after the init }, [initialData]);
if (deferredDataRef.current) {
await loadContent(deferredDataRef.current);
deferredDataRef.current = null;
}
// auto window resizing based width // auto focus the cursor into the main textbox no matter the latest element added
lastWidth = wrapperRef.current.clientWidth; const focusTextbox = useCallback(
resizeObserver = new ResizeObserver(() => { (textbox: fabric.Textbox) => {
const nextWidth = wrapperRef.current?.clientWidth; if (readOnly || !fabricRef.current) return;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
resizeObserver.observe(wrapperRef.current!);
};
initCanvas().then(); fabricRef.current.setActiveObject(textbox);
textbox.enterEditing();
return () => { // move the cursor to the end of the text
isMounted = false; const textLength = textbox.text?.length ?? 0;
resizeObserver?.disconnect(); textbox.selectionStart = textLength;
fabricRef.current?.dispose(); textbox.selectionEnd = textLength;
fabricRef.current = null;
textboxRef.current = null;
};
}, [initialData, loadContent, readOnly, syncViewport]);
// WHY?: fabric doesn't work like react with state and props based optimized re-renders. fabricRef.current.requestRenderAll();
// everytime we there's a change in the data, we should force the render, },
// so we let the parent Editor component take control of this. [readOnly],
useImperativeHandle(ref, () => ({ );
addImage: (url: string, file: File) => {
if (!fabricRef.current) return;
fabric.FabricImage.fromURL(url).then((img) => { const loadContent = useCallback(
img.scaleToWidth(Math.min(300, img.width)); async (data: CanvasJSON | null) => {
img.set({ const canvas = fabricRef.current;
originX: "left", const wrapper = wrapperRef.current;
originY: "top", if (!(canvas && wrapper)) return;
left: PAD,
top: PAD,
noScaleCache: false,
objectCaching: false,
// WHY?: after image object clean-up, its src becomes local blob://
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
_customRawFile: file,
} as Partial<FabricImageWithFile>);
fabricRef.current?.add(img); // clean the canvas everytime and set fresh
fabricRef.current?.setActiveObject(img); canvas.clear();
let textbox: fabric.Textbox | null = null;
syncViewport(); // restore logical size from prev saved data if available (in case of existing letter)
// clean up memory logicalSizeRef.current = {
URL.revokeObjectURL(url); width: data?.canvasWidth ?? BASE_WIDTH,
}); height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
}, };
getData: () => { if (data?.objects?.length) {
if (!fabricRef.current) return { objects: [] }; await canvas.loadFromJSON(data);
syncViewport(); textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
} else {
// Create a fresh letter if no data exists
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
noScaleCache: false,
});
canvas.add(textbox);
}
const json = fabricRef.current.toJSON() as CanvasJSON; if (!textbox) return;
json.canvasWidth = logicalSizeRef.current.width;
json.canvasHeight = logicalSizeRef.current.height;
return json;
},
getImages: () => { // readonly contraints applicable for post seal view
if (!fabricRef.current) return []; textbox.selectable = !readOnly;
const images = fabricRef.current.getObjects( textbox.evented = !readOnly;
"Image", textbox.editable = !readOnly;
) as FabricImageWithFile[]; textbox.hasBorders = false;
return images.map((img) => ({
src: img.getSrc(),
file: img._customRawFile,
}));
},
loadData: async (data: CanvasJSON) => { textboxRef.current = textbox;
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
if (!fabricRef.current) {
deferredDataRef.current = data;
return;
}
await loadContent(data);
},
getStyle: () => { // observe and auto-resize the canvas height whenever typed
const textBox = textboxRef.current; textbox.on("changed", syncViewport);
return { // trapping the focus into the textbox wherever clicked on canvas (except images)
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY, canvas.on("mouse:down", (e) => {
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR, if (!e.target || e.target === textbox) {
}; focusTextbox(textbox);
}, }
})); });
return ( for (const img of canvas.getObjects("Image")) {
<div img.set({
ref={wrapperRef} hasControls: !readOnly,
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text" hasBorders: !readOnly,
> });
<canvas }
ref={canvasRef}
className="absolute top-0 left-0" // NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
style={{ background: "transparent" }} await document.fonts.ready;
/> textbox.set("dirty", true);
</div> syncViewport();
);
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
// otherwise it goes to the front
if (!readOnly) {
setTimeout(() => focusTextbox(textbox), 200);
}
},
[readOnly, syncViewport, focusTextbox],
);
useEffect(() => {
if (style && textboxRef.current) {
const textBox = textboxRef.current;
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
textBox.fill = style.fontColor || textBox.fill;
syncViewport();
}
}, [style, syncViewport]);
useEffect(() => {
let isMounted = true;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height: DEFAULT_LOGICAL_HEIGHT,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
objectCaching: false,
});
// remove default fabric background to let our CSS show through
// TODO: provision custom bg (color in scope, but how does img fit?)
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
fabricRef.current = canvas;
await loadContent(initialData);
// sometimes loadData() may be called before the canvas finished the init render
// so we retry that stashed render right after the init
if (deferredDataRef.current) {
await loadContent(deferredDataRef.current);
deferredDataRef.current = null;
}
// auto window resizing based width
lastWidth = wrapperRef.current.clientWidth;
resizeObserver = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
resizeObserver.observe(wrapperRef.current!);
};
initCanvas().then();
return () => {
isMounted = false;
resizeObserver?.disconnect();
fabricRef.current?.dispose();
fabricRef.current = null;
textboxRef.current = null;
};
}, [initialData, loadContent, readOnly, syncViewport]);
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
// everytime we there's a change in the data, we should force the render,
// so we let the parent Editor component take control of this.
useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => {
if (!fabricRef.current) return;
fabric.FabricImage.fromURL(url).then((img) => {
img.scaleToWidth(Math.min(300, img.width));
img.set({
originX: "left",
originY: "top",
left: PAD,
top: PAD,
noScaleCache: false,
objectCaching: false,
// WHY?: after image object clean-up, its src becomes local blob://
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
_customRawFile: file,
} as Partial<FabricImageWithFile>);
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
syncViewport();
// clean up memory
URL.revokeObjectURL(url);
});
},
getData: () => {
if (!fabricRef.current) return { objects: [] };
syncViewport();
const json = fabricRef.current.toJSON() as CanvasJSON;
json.canvasWidth = logicalSizeRef.current.width;
json.canvasHeight = logicalSizeRef.current.height;
return 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 canvas isn't ready yet, stash the data and let the useEffect pick it up
if (!fabricRef.current) {
deferredDataRef.current = data;
return;
}
await loadContent(data);
},
getStyle: () => {
const textBox = textboxRef.current;
return {
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
};
},
}));
return (
<div
ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
>
<canvas
ref={canvasRef}
className="absolute top-0 left-0"
style={{ background: "transparent" }}
/>
</div>
);
} }
ComposeCanvas.displayName = "ComposeCanvas"; ComposeCanvas.displayName = "ComposeCanvas";
+3 -7
View File
@@ -34,12 +34,6 @@ import { CryptoUtils } from "../utils/crypto";
import { formatRelativeDate } from "../utils/dateFormat"; import { formatRelativeDate } from "../utils/dateFormat";
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR"; type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
const OVERLAY_FADE_MS = 250; const OVERLAY_FADE_MS = 250;
@@ -268,7 +262,9 @@ export default function Editor() {
await cryptoUtils.initialize(); await cryptoUtils.initialize();
try { try {
const canvasData = canvasRef.current?.getData() || { objects: [] }; const canvasData = (await canvasRef.current?.getData()) || {
objects: [],
};
const canvasImages = canvasRef.current?.getImages() || []; const canvasImages = canvasRef.current?.getImages() || [];
const { encryptedImageFiles, encryptedCanvasData } = const { encryptedImageFiles, encryptedCanvasData } =