diff --git a/frontend/e2e/letter.spec.ts b/frontend/e2e/letter.spec.ts index dc1e3e5..7f528c0 100644 --- a/frontend/e2e/letter.spec.ts +++ b/frontend/e2e/letter.spec.ts @@ -64,7 +64,9 @@ test.describe("Letter Drafting (Real Backend)", () => { await expect(page.getByTestId("opening-draft-overlay")).toBeHidden(); // Check recipient - await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName); + await expect(page.getByTestId("recipient-input")).toHaveValue( + recipientName, + ); // Check canvas content // We wait for the content to appear in the textarea. diff --git a/frontend/e2e/utils/auth.ts b/frontend/e2e/utils/auth.ts index 3e92f04..c968aec 100644 --- a/frontend/e2e/utils/auth.ts +++ b/frontend/e2e/utils/auth.ts @@ -1,7 +1,7 @@ import { expect, type Page } from "@playwright/test"; import pino from "pino"; -import { MailpitHelper } from "./mailpit"; import { handleWelcomeLetter } from "./envelope"; +import { MailpitHelper } from "./mailpit"; const logger = pino({ transport: { diff --git a/frontend/e2e/utils/envelope.ts b/frontend/e2e/utils/envelope.ts index 85aae9c..edfcecb 100644 --- a/frontend/e2e/utils/envelope.ts +++ b/frontend/e2e/utils/envelope.ts @@ -1,4 +1,4 @@ -import { type Page, expect } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import pino from "pino"; const logger = pino({ @@ -22,7 +22,9 @@ export async function revealEnvelope(page: Page) { await page.getByTestId("wax-seal").click(); // Click letter to reveal - await page.getByTestId("envelope-letter").click({ position: { x: 30, y: 15 } }); + await page + .getByTestId("envelope-letter") + .click({ position: { x: 30, y: 15 } }); } /** diff --git a/frontend/package.json b/frontend/package.json index d04a182..aee0d20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,70 +1,70 @@ { - "name": "frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b & vite build", - "build:prod": "vite build --mode production", - "lint": "biome lint --write ./src", - "format": "biome format --write ./src", - "check": "biome check --write ./src", - "check-all": "biome check --write .", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008" - }, - "dependencies": { - "@fontsource-variable/fraunces": "^5.2.9", - "@fontsource-variable/josefin-slab": "^5.2.7", - "@fontsource-variable/jost": "^5.2.8", - "@fontsource-variable/playwrite-hr-lijeva": "^5.2.7", - "@fontsource/architects-daughter": "^5.2.7", - "@fontsource/kavivanar": "^5.2.8", - "@fontsource/knewave": "^5.2.7", - "@fontsource/redacted-script": "^5.2.8", - "@fontsource/space-mono": "^5.2.9", - "@hookform/resolvers": "^5.2.2", - "@phosphor-icons/react": "^2.1.10", - "@tailwindcss/vite": "^4.2.2", - "axios": "^1.15.0", - "daisyui": "^5.5.19", - "fabric": "^7.2.0", - "idb": "^8.0.3", - "lenis": "^1.3.23", - "motion": "^12.38.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-hook-form": "^7.72.1", - "react-router-dom": "^7.14.0", - "tailwindcss": "^4.2.2", - "zod": "^4.3.6", - "zustand": "^5.0.12" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.11", - "@playwright/test": "^1.59.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.6.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-basic-ssl": "^2.3.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.4", - "dotenv": "^17.4.2", - "fake-indexeddb": "^6.2.5", - "jsdom": "^29.0.2", - "msw": "^2.13.2", - "pino": "^10.3.1", - "pino-pretty": "^13.1.3", - "typescript": "~6.0.2", - "vite": "^8.0.4", - "vitest": "^4.1.4" - } + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b & vite build", + "build:prod": "vite build --mode production", + "lint": "biome lint --write ./src", + "format": "biome format --write ./src", + "check": "biome check --write ./src", + "check-all": "biome check --write .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008" + }, + "dependencies": { + "@fontsource-variable/fraunces": "^5.2.9", + "@fontsource-variable/josefin-slab": "^5.2.7", + "@fontsource-variable/jost": "^5.2.8", + "@fontsource-variable/playwrite-hr-lijeva": "^5.2.7", + "@fontsource/architects-daughter": "^5.2.7", + "@fontsource/kavivanar": "^5.2.8", + "@fontsource/knewave": "^5.2.7", + "@fontsource/redacted-script": "^5.2.8", + "@fontsource/space-mono": "^5.2.9", + "@hookform/resolvers": "^5.2.2", + "@phosphor-icons/react": "^2.1.10", + "@tailwindcss/vite": "^4.2.2", + "axios": "^1.15.0", + "daisyui": "^5.5.19", + "fabric": "^7.2.0", + "idb": "^8.0.3", + "lenis": "^1.3.23", + "motion": "^12.38.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-hook-form": "^7.72.1", + "react-router-dom": "^7.14.0", + "tailwindcss": "^4.2.2", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.4", + "dotenv": "^17.4.2", + "fake-indexeddb": "^6.2.5", + "jsdom": "^29.0.2", + "msw": "^2.13.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "typescript": "~6.0.2", + "vite": "^8.0.4", + "vitest": "^4.1.4" + } } diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest index 45dc8a2..fa99de7 100644 --- a/frontend/public/site.webmanifest +++ b/frontend/public/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/frontend/src/components/editor/ComposeCanvas.tsx b/frontend/src/components/editor/ComposeCanvas.tsx index 167f2e8..3f653d1 100644 --- a/frontend/src/components/editor/ComposeCanvas.tsx +++ b/frontend/src/components/editor/ComposeCanvas.tsx @@ -15,406 +15,408 @@ const DEFAULT_FONT_FAMILY = "Fraunces Variable"; const DEFAULT_FONT_COLOR = "#000"; export interface FabricObjectJSON { - type: string; - name?: string; - top: number; - left: number; - width: number; - height: number; + type: string; + name?: string; + top: number; + left: number; + width: number; + height: number; - [key: string]: unknown; + [key: string]: unknown; } export interface FabricImageJSON extends FabricObjectJSON { - type: "Image"; - src: string; - _customRawFile?: File; + type: "Image"; + src: string; + _customRawFile?: File; } export interface CanvasJSON { - objects: (FabricObjectJSON | FabricImageJSON)[]; - canvasWidth?: number; - canvasHeight?: number; + objects: (FabricObjectJSON | FabricImageJSON)[]; + canvasWidth?: number; + canvasHeight?: number; } export interface CanvasStyle { - fontFamily: string; - fontColor: string; + fontFamily: string; + fontColor: string; } export type CanvasTools = { - addImage: (url: string, file: File) => void; - getData: () => CanvasJSON; - getImages: () => { src: string; file: File }[]; - loadData: (data: CanvasJSON) => Promise; - getStyle: () => CanvasStyle; + addImage: (url: string, file: File) => void; + getData: () => CanvasJSON; + getImages: () => { src: string; file: File }[]; + loadData: (data: CanvasJSON) => Promise; + getStyle: () => CanvasStyle; }; export interface FabricImageWithFile extends fabric.FabricImage { - _customRawFile: File; + _customRawFile: File; } // 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) // over the last saved canvas size. const applyResponsiveViewport = ( - canvas: fabric.Canvas, - wrapper: HTMLDivElement, - logicalWidth: number, - logicalHeight: number, + canvas: fabric.Canvas, + wrapper: HTMLDivElement, + logicalWidth: number, + logicalHeight: number, ) => { - const physicalWidth = wrapper.clientWidth || logicalWidth; - const zoomMultiplier = physicalWidth / logicalWidth; - const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier); + const physicalWidth = wrapper.clientWidth || logicalWidth; + const zoomMultiplier = physicalWidth / logicalWidth; + const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier); - canvas.setDimensions({ - width: physicalWidth, - height: physicalHeight, - }); + canvas.setDimensions({ + width: physicalWidth, + height: physicalHeight, + }); - wrapper.style.height = `${physicalHeight}px`; - canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]); - canvas.requestRenderAll(); + wrapper.style.height = `${physicalHeight}px`; + canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]); + canvas.requestRenderAll(); }; // 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 :) const measureLogicalContentHeight = ( - canvas: fabric.Canvas, - minimumHeight = DEFAULT_LOGICAL_HEIGHT, + canvas: fabric.Canvas, + minimumHeight = DEFAULT_LOGICAL_HEIGHT, ) => { - const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => { - const top = currObj.top; - const height = currObj.getScaledHeight(); - return Math.max(maxHeight, top + height); - }, 0); + const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => { + const top = currObj.top; + const height = currObj.getScaledHeight(); + return Math.max(maxHeight, top + height); + }, 0); - return Math.max(minimumHeight, maxBottom + PAD); + return Math.max(minimumHeight, maxBottom + PAD); }; const DEFAULT_INIT_TEXT = "Take a deep breath..."; interface ComposeCanvasProps { - readOnly?: boolean; - initialData?: CanvasJSON | null; - style?: CanvasStyle; - ref?: React.Ref; + readOnly?: boolean; + initialData?: CanvasJSON | null; + style?: CanvasStyle; + ref?: React.Ref; } export function ComposeCanvas({ - readOnly = false, - initialData = null, - style, - ref, + readOnly = false, + initialData = null, + style, + ref, }: ComposeCanvasProps) { - // wrapper is the parent div box - const wrapperRef = useRef(null); - const canvasRef = useRef(null); - const fabricRef = useRef(null); + // wrapper is the parent div box + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const fabricRef = useRef(null); - const textboxRef = useRef(null); - const deferredDataRef = useRef(null); - const logicalSizeRef = useRef({ - 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; - textboxRef.current?.initDimensions(); - - 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, - splitByGrapheme: false, - 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); - } - }); - - for (const img of canvas.getObjects("Image")) { - img.set({ - hasControls: !readOnly, - hasBorders: !readOnly, - }); - } - - // NOTE: fabric refreshes fonts once the textbox is rendered after initial focus - await document.fonts.ready; - textbox.set("dirty", true); - 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 getInitialWidth = async () => { - if (!wrapperRef.current) return BASE_WIDTH; - let width = wrapperRef.current.clientWidth; - if (width === 0) { - await new Promise((resolve) => requestAnimationFrame(resolve)); - width = wrapperRef.current?.clientWidth || BASE_WIDTH; - } - return width; - }; - - const initResizeOberver = () => { - if (!wrapperRef.current) return null; - const observer = new ResizeObserver(() => { - const nextWidth = wrapperRef.current?.clientWidth; - if (!nextWidth || nextWidth === lastWidth) return; - lastWidth = nextWidth; - syncViewport(); - }); - observer.observe(wrapperRef.current); - return observer; - }; - - 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; - - const width = await getInitialWidth(); - - // init the fabric instance - const canvas = new fabric.Canvas(canvasRef.current, { - width, + const textboxRef = useRef(null); + const deferredDataRef = useRef(null); + const logicalSizeRef = useRef({ + width: BASE_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"; + // re-calculates height based on content and applies the zoom transform + const syncViewport = useCallback(() => { + if (!(fabricRef.current && wrapperRef.current)) return; + textboxRef.current?.initDimensions(); - 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 - // so we retry that stashed render right after the init - if (deferredDataRef.current) { - await loadContent(deferredDataRef.current); - deferredDataRef.current = null; - } + fabricRef.current.requestRenderAll(); + }, [initialData]); - // auto window resizing based width - lastWidth = wrapperRef.current.clientWidth; - resizeObserver = initResizeOberver(); - }; + // 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; - initCanvas().then(); + fabricRef.current.setActiveObject(textbox); + textbox.enterEditing(); - return () => { - isMounted = false; - resizeObserver?.disconnect(); - fabricRef.current?.dispose(); - fabricRef.current = null; - textboxRef.current = null; - }; - }, [initialData, loadContent, readOnly, syncViewport]); + // move the cursor to the end of the text + const textLength = textbox.text?.length ?? 0; + textbox.selectionStart = textLength; + textbox.selectionEnd = textLength; - // 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; + fabricRef.current.requestRenderAll(); + }, + [readOnly], + ); - 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); + const loadContent = useCallback( + async (data: CanvasJSON | null) => { + const canvas = fabricRef.current; + const wrapper = wrapperRef.current; + if (!(canvas && wrapper)) return; - fabricRef.current?.add(img); - fabricRef.current?.setActiveObject(img); + // clean the canvas everytime and set fresh + canvas.clear(); + let textbox: fabric.Textbox | null = null; - syncViewport(); - // clean up memory - URL.revokeObjectURL(url); - }); - }, + // 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, + }; - getData: () => { - if (!fabricRef.current) return { objects: [] }; - syncViewport(); + 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, + 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; - json.canvasWidth = logicalSizeRef.current.width; - json.canvasHeight = logicalSizeRef.current.height; - return json; - }, + if (!textbox) return; - getImages: () => { - if (!fabricRef.current) return []; - const images = fabricRef.current.getObjects( - "Image", - ) as FabricImageWithFile[]; - return images.map((img) => ({ - src: img.getSrc(), - file: img._customRawFile, - })); - }, + // readonly contraints applicable for post seal view + textbox.selectable = !readOnly; + textbox.evented = !readOnly; + textbox.editable = !readOnly; + textbox.hasBorders = false; - 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); - }, + textboxRef.current = textbox; - getStyle: () => { - const textBox = textboxRef.current; + // observe and auto-resize the canvas height whenever typed + textbox.on("changed", syncViewport); - return { - fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY, - fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR, - }; - }, - })); + // 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); + } + }); - return ( -
- -
- ); + for (const img of canvas.getObjects("Image")) { + img.set({ + hasControls: !readOnly, + hasBorders: !readOnly, + }); + } + + // NOTE: fabric refreshes fonts once the textbox is rendered after initial focus + await document.fonts.ready; + textbox.set("dirty", true); + 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 getInitialWidth = async () => { + if (!wrapperRef.current) return BASE_WIDTH; + let width = wrapperRef.current.clientWidth; + if (width === 0) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + width = wrapperRef.current?.clientWidth || BASE_WIDTH; + } + return width; + }; + + const initResizeOberver = () => { + if (!wrapperRef.current) return null; + const observer = new ResizeObserver(() => { + const nextWidth = wrapperRef.current?.clientWidth; + if (!nextWidth || nextWidth === lastWidth) return; + lastWidth = nextWidth; + syncViewport(); + }); + observer.observe(wrapperRef.current); + return observer; + }; + + 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; + + const width = await getInitialWidth(); + + // 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; + } + + if (!(isMounted && wrapperRef.current)) return; + + // auto window resizing based width + lastWidth = wrapperRef.current.clientWidth; + resizeObserver = initResizeOberver(); + }; + + 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); + + 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 ( +
+ +
+ ); } ComposeCanvas.displayName = "ComposeCanvas"; diff --git a/frontend/src/components/reader/ShareModal.tsx b/frontend/src/components/reader/ShareModal.tsx index 14a719c..44fcd34 100644 --- a/frontend/src/components/reader/ShareModal.tsx +++ b/frontend/src/components/reader/ShareModal.tsx @@ -68,7 +68,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) { -
+
{ - error?: boolean; + extends React.InputHTMLAttributes { + error?: boolean; } export function PasswordInput({ - className, - error, - ...props + className, + error, + ...props }: PasswordInputProps) { - const [showPassword, setShowPassword] = useState(false); + const [showPassword, setShowPassword] = useState(false); - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/frontend/src/components/ui/Saajan.tsx b/frontend/src/components/ui/Saajan.tsx index 62b3d06..b6c0cb6 100644 --- a/frontend/src/components/ui/Saajan.tsx +++ b/frontend/src/components/ui/Saajan.tsx @@ -45,7 +45,7 @@ export default function Saajan({ message, position = "right" }: SaajanProps) { saajan
diff --git a/frontend/src/index.css b/frontend/src/index.css index a35eb7f..1ecefeb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -66,7 +66,7 @@ } .glass-card { - @apply bg-glass-bg max-w-xs md:max-w-sm backdrop-blur-xl border border-neutral-content/10 shadow-warm rounded-xl m-4; + @apply bg-glass-bg max-w-xs md:max-w-sm backdrop-blur-xl border border-neutral-content/10 shadow-warm rounded-xl m-2 md:m-4; } .ul-wavy { diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 53cd4ba..6898b63 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,10 +1,10 @@ import { InfoIcon } from "@phosphor-icons/react"; import { ReactLenis } from "lenis/react"; import { - motion, - useMotionValueEvent, - useScroll, - useTransform, + motion, + useMotionValueEvent, + useScroll, + useTransform, } from "motion/react"; import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -19,382 +19,382 @@ import "@fontsource/space-mono/index.css"; import "@fontsource/architects-daughter/index.css"; export default function Home() { - const sectionContainer1 = useRef(null); - const { scrollYProgress } = useScroll({ - target: sectionContainer1, - }); - const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true); - const [flapOpen, setFlapOpen] = useState(false); - const [recipient, setRecipient] = useState("someone dear"); - const [ignite, setIgnite] = useState(false); + const sectionContainer1 = useRef(null); + const { scrollYProgress } = useScroll({ + target: sectionContainer1, + }); + const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true); + const [flapOpen, setFlapOpen] = useState(false); + const [recipient, setRecipient] = useState("someone dear"); + const [ignite, setIgnite] = useState(false); - const navigate = useNavigate(); + const navigate = useNavigate(); - useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => { - if (latestScrollValue > 0.54) { - setFlapOpen(false); - } else { - setFlapOpen(true); - } - if (latestScrollValue <= 0.6) { - setIsEnvelopeFlipped(true); - } else { - setIsEnvelopeFlipped(false); - } - if (latestScrollValue > 0.68) { - setRecipient("future me"); - } else { - setRecipient("someone dear"); - } - if (latestScrollValue > 0.77) { - setIgnite(true); - } else { - setIgnite(false); - } - }); + useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => { + if (latestScrollValue > 0.54) { + setFlapOpen(false); + } else { + setFlapOpen(true); + } + if (latestScrollValue <= 0.6) { + setIsEnvelopeFlipped(true); + } else { + setIsEnvelopeFlipped(false); + } + if (latestScrollValue > 0.68) { + setRecipient("future me"); + } else { + setRecipient("someone dear"); + } + if (latestScrollValue > 0.77) { + setIgnite(true); + } else { + setIgnite(false); + } + }); - return ( - -
-
- {/* Intro */} - -

- You've been carrying something -

- - unsaid - -
+ return ( + +
+
+ {/* Intro */} + +

+ You've been carrying something +

+ + unsaid + +
- -
- and that's okay... -
-
- {/* pi. ku. */} - - - - is a{" "} - - safe space - - ,
- - where you can - -
-
+ +
+ and that's okay... +
+
+ {/* pi. ku. */} + + + + is a{" "} + + safe space + + ,
+ + where you can + +
+
-
- - pen down your unsaid words into  - - letters - - . - - {/* Seal */} - - seal it  - - secure - -   and  - - private - - . - - {/* Send / vault */} - - send it to  - - someone dear - - - -   or  - - - yourself in the future - - . - - - {/* Burn */} - - and even burn it -   to release the burden. - - {/* Outro */} - - You've been carrying it long enough. - - {/* CTA */} - - - - -
+
+ + pen down your unsaid words into  + + letters + + . + + {/* Seal */} + + seal it  + + secure + +   and  + + private + + . + + {/* Send / vault */} + + send it to  + + someone dear + + + +   or  + + + yourself in the future + + . + + + {/* Burn */} + + and even burn it +   to release the burden. + + {/* Outro */} + + You've been carrying it long enough. + + {/* CTA */} + + + + +
-
- -
-
-
- letter +
+ +
+
+
+ letter +
+
+
+ {/* Envelope */} + + { }} + isFlip={isEnvelopeFlipped} + openFlap={flapOpen} + /> + + {/* Saajan */} + + + + {/* Orb */} + +
+
-
- - {/* Envelope */} - - {}} - isFlip={isEnvelopeFlipped} - openFlap={flapOpen} - /> - - {/* Saajan */} - - - - {/* Orb */} - -
-
-
-
-
- ); +
+
+ ); } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 98b319f..e304194 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -30,7 +30,7 @@ export default function Login() { const { setAuthStore } = useAuth(); const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime); const [saajanMessage, setSaajanMessage] = useState( - "I was wondering when you'd return.", + "I was wondering, if you'd ever return.", ); const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER; @@ -82,9 +82,12 @@ export default function Login() { {!showWelcome && } {showWelcome && }
-
+

-   Enter Archive +  Unlock Archive

{apiError && ( diff --git a/frontend/src/pages/Reader.test.tsx b/frontend/src/pages/Reader.test.tsx index ec19415..8b57826 100644 --- a/frontend/src/pages/Reader.test.tsx +++ b/frontend/src/pages/Reader.test.tsx @@ -8,6 +8,27 @@ import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; import Reader from "./Reader"; +vi.mock("../components/reader/EnvelopeReveal", () => ({ + EnvelopeReveal: ({ + recipient, + onRevealComplete, + }: { + recipient?: string; + onRevealComplete: () => void; + }) => ( +
+
{recipient}
+ +
+ ), +})); + const API_URL = import.meta.env.VITE_API_URL; // Fabric.js needs to know when fonts are loaded @@ -16,6 +37,13 @@ Object.defineProperty(document, "fonts", { configurable: true, }); +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = MockResizeObserver; + describe("Reader Page", () => { let masterKey: CryptoKey; let utils: CryptoUtils; @@ -47,7 +75,7 @@ describe("Reader Page", () => { }); it("should load and decrypt the letter when a valid key is provided and display the envelope", async () => { - const mockPublicId = "test-uuid"; + const mockPublicId = "41123-e2c3-f2115"; const letterContent = JSON.stringify({ objects: [] }); const metadata = { recipient: "Guest" }; // simulate guest @@ -139,3 +167,93 @@ describe("Reader Page", () => { ); }); }); + +describe("Reader Page - (write a letter) CTA", () => { + let masterKey: CryptoKey; + let utils: CryptoUtils; + + beforeEach(async () => { + vi.clearAllMocks(); + + utils = new CryptoUtils(); + await utils.initialize(); + const bundle = await CryptoUtils.deriveKeyBundle("password", "salt"); + masterKey = bundle.masterKey; + + vi.stubGlobal("location", { + hash: "", + href: "http://localhost/", + }); + }); + + it("should display CTA for guest and navigate to homepage", async () => { + const mockPublicId = "41123-e2c3-f2115"; + const letterContent = JSON.stringify({ objects: [] }); + const metadata = { recipient: "Guest" }; + useKeyStore.setState({ masterKey: null }); + const encryptedLetter = await utils.encryptLetter(letterContent, masterKey); + const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey); + const sharingKey = encryptedLetter.sharingKey as string; + server.use( + http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { + return HttpResponse.json({ + encrypted_content: encryptedLetter.encrypted_content, + encrypted_metadata: encryptedMetadata.encrypted_content, + encrypted_dek: encryptedLetter.encrypted_dek, + images: [], + }); + }), + ); + render( + + + } /> + Home Page
} + /> + + , + ); + + const revealBtn = await screen.findByTestId("reveal-button"); + revealBtn.click(); + const ctaBtn = await screen.findByTestId("reader-cta-btn"); + ctaBtn.click(); + + expect(ctaBtn).toHaveTextContent(/write a letter/i); + expect(await screen.findByTestId("home-page")).toBeInTheDocument(); + }); + + it("should not display the CTA for the author of the letter", async () => { + const mockPublicId = "41123-e2c3-f2115"; + const letterContent = JSON.stringify({ objects: [] }); + const metadata = { recipient: "Guest" }; + useKeyStore.setState({ masterKey }); + const encryptedLetter = await utils.encryptLetter(letterContent, masterKey); + const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey); + server.use( + http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { + return HttpResponse.json({ + encrypted_content: encryptedLetter.encrypted_content, + encrypted_metadata: encryptedMetadata.encrypted_content, + encrypted_dek: encryptedLetter.encrypted_dek, + images: [], + }); + }), + ); + render( + + + } /> + + , + ); + + const revealBtn = await screen.findByTestId("reveal-button"); + revealBtn.click(); + await screen.findByTestId("envelope-recipient"); + + expect(screen.queryByTestId("reader-cta-btn")).toBeNull(); + }); +}); diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 290d7d5..555934b 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -21,7 +21,7 @@ import { PostActionOverlay } from "../components/reader/PostActionOverlay"; import { ShareModal } from "../components/reader/ShareModal"; import { LogModal } from "../components/ui/LogModal"; import { endpoints } from "../config/endpoints"; -import { PATHS } from "../config/routes"; +import { PATHS, ROUTES } from "../config/routes"; import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; import { formatDate } from "../utils/dateFormat"; @@ -114,27 +114,29 @@ export default function Reader() { images: LetterImageData[], encrypted_dek: string, cryptoUtils: CryptoUtils, - ) => { - if (!images?.length) return; + ): Promise => { + if (!images?.length) return canvasData; const isShared = !!sharingKey; try { if (isShared) { - await decryptCanvasImagesWithSharingKey( - canvasData, - images, - sharingKey, - cryptoUtils, - ); - } else { - await decryptCanvasImages( - canvasData, - images, - encrypted_dek, - // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true - masterKey!, - cryptoUtils, - ); + const { canvasDataWithDecryptedImages } = + await decryptCanvasImagesWithSharingKey( + canvasData, + images, + sharingKey, + cryptoUtils, + ); + return canvasDataWithDecryptedImages; } + const { canvasDataWithDecryptedImages } = await decryptCanvasImages( + canvasData, + images, + encrypted_dek, + // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true + masterKey!, + cryptoUtils, + ); + return canvasDataWithDecryptedImages; } catch (err) { setLogTrace({ message: @@ -142,6 +144,7 @@ export default function Reader() { log: err instanceof Error ? err.message : "Unknown error", type: "WARN", }); + return canvasData; } }; @@ -187,8 +190,13 @@ export default function Reader() { ); const canvasData: CanvasJSON = JSON.parse(decryptedContent); - await decryptImages(canvasData, images, encrypted_dek, cryptoUtils); - setDecryptedCanvasData(canvasData); + const decryptedCanvasData = await decryptImages( + canvasData, + images, + encrypted_dek, + cryptoUtils, + ); + setDecryptedCanvasData(decryptedCanvasData); }; const processLetterData = async (data: LetterResponseData) => { @@ -319,6 +327,19 @@ export default function Reader() { )} + {revealState === "REVEALED" && !isAuthor && ( + + )} + {shareLink && ( )} @@ -360,8 +381,8 @@ export default function Reader() { )} -