From 7304c3830366d622c304fcd1faa39ac96afbb8ec Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 18:13:11 +0200 Subject: [PATCH] refactor(web): share page canvas chrome between the resume and letter builders usePageScale, CanvasStatusPill and DocumentMenuTrigger replace code duplicated across both builders, the letter bar and share sheet compile with the React Compiler, and a viewed letter version is fitted with its own page format. --- .../web/src/features/resume/editor/chrome.tsx | 52 +++++- .../$resumeId/-components/document-menu.tsx | 17 +- .../$resumeId/-components/page-canvas.tsx | 149 ++++++++++-------- .../builder/letter/-components/letter-bar.tsx | 41 ++--- .../letter/-components/letter-page.tsx | 30 ++-- .../letter/-components/share-sheet.tsx | 14 +- 6 files changed, 168 insertions(+), 135 deletions(-) diff --git a/apps/web/src/features/resume/editor/chrome.tsx b/apps/web/src/features/resume/editor/chrome.tsx index 8af185f24..55183d4b5 100644 --- a/apps/web/src/features/resume/editor/chrome.tsx +++ b/apps/web/src/features/resume/editor/chrome.tsx @@ -1,3 +1,5 @@ +import type { IconName } from "@reactive-resume/ui/components/icon"; +import type { ComponentProps, ReactNode } from "react"; import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; import { useHotkey } from "@tanstack/react-hotkeys"; @@ -7,6 +9,7 @@ import { buttonVariants } from "@reactive-resume/ui/components/button"; import { Icon } from "@reactive-resume/ui/components/icon"; import { IconButton } from "@reactive-resume/ui/components/icon-button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip"; +import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { cn } from "@reactive-resume/utils/style"; import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "./store"; @@ -30,9 +33,9 @@ export const useIsLandscape = () => ); // Page widths in PDF points; 1pt renders as 1 CSS px at 100%. -export const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const; +const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const; // Horizontal room the canvas keeps around the page: 40px each side, 16px on phones. -export const CANVAS_GUTTER = { wide: 80, narrow: 32 } as const; +const CANVAS_GUTTER = { wide: 80, narrow: 32 } as const; export function useCanvasWidth() { const ref = useRef(null); @@ -49,6 +52,51 @@ export function useCanvasWidth() { return [ref, width] as const; } +/** The canvas ref, the fit scale for the page format, and the scale the page is drawn at (zoom, or fit). */ +export function usePageScale(format: keyof typeof PAGE_WIDTH) { + const zoom = useEditorStore((state) => state.zoom); + const isPhone = useBreakpoint() === "mobile"; + const [canvasRef, canvasWidth] = useCanvasWidth(); + const gutter = isPhone ? CANVAS_GUTTER.narrow : CANVAS_GUTTER.wide; + const fitScale = canvasWidth > 0 ? Math.min(ZOOM_MAX, (canvasWidth - gutter) / PAGE_WIDTH[format]) : 1; + const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom; + + return { canvasRef, fitScale, pageScale }; +} + +type CanvasStatusPillProps = { icon: IconName; children: ReactNode }; + +/** The dark pill above the first page while a version or template is previewed instead of the live document. */ +export function CanvasStatusPill({ icon, children }: CanvasStatusPillProps) { + return ( + + + {children} + + ); +} + +// Base UI's trigger hands its ref, handlers and aria state to the element it renders. +type DocumentMenuTriggerProps = ComponentProps<"button"> & { name: string; isLocked: boolean }; + +/** The document name in the editor bar, as the button that opens the document menu. */ +export function DocumentMenuTrigger({ name, isLocked, ...props }: DocumentMenuTriggerProps) { + return ( + + ); +} + /** Leaving the editor is navigation, so it's a link styled as an icon button. */ export function BackLink() { const label = t`Back to documents`; diff --git a/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx b/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx index 7e4ac70b3..7c49448da 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx @@ -15,6 +15,7 @@ import { Icon } from "@reactive-resume/ui/components/icon"; import { toast } from "@reactive-resume/ui/components/toast"; import { useDialogStore } from "@/dialogs/store"; import { useCurrentBuilderResumeSelector, useCurrentResume, usePatchResume } from "@/features/resume/builder/draft"; +import { DocumentMenuTrigger } from "@/features/resume/editor/chrome"; import { SaveStatus } from "@/features/resume/editor/save-status"; import { useResumeExport } from "@/features/resume/export/use-resume-export"; import { getResumeErrorMessage } from "@/libs/error-message"; @@ -36,21 +37,7 @@ export function DocumentMenu() { return ( <> - - - {name} - {isLocked && } - - - - } - /> + } /> diff --git a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx index 86c28cbe5..29d2f89df 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx @@ -1,4 +1,6 @@ +import type { Template } from "@reactive-resume/schema/templates"; import type { EditorSelection } from "@/features/resume/editor/store"; +import type { VersionSummary } from "@/features/resume/share/format"; import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Plural, Trans } from "@lingui/react/macro"; @@ -11,11 +13,11 @@ import { templates } from "@/dialogs/resume/template/data"; import { useCurrentBuilderResumeSelector, useResumeData } from "@/features/resume/builder/draft"; import { CheckPageLayer, PageViewToggle } from "@/features/resume/editor/check/page-layer"; import { ParserView } from "@/features/resume/editor/check/parser-view"; -import { CANVAS_GUTTER, PAGE_WIDTH, useCanvasWidth, ZoomBar } from "@/features/resume/editor/chrome"; +import { CanvasStatusPill, usePageScale, ZoomBar } from "@/features/resume/editor/chrome"; import { measureOverflow, runFit } from "@/features/resume/editor/design/fit"; import { PageOverlay } from "@/features/resume/editor/page-overlay"; import { markProposals, pendingProposals } from "@/features/resume/editor/proposals/proposals"; -import { useEditorStore, ZOOM_MAX } from "@/features/resume/editor/store"; +import { useEditorStore } from "@/features/resume/editor/store"; import { useEditorMode } from "@/features/resume/editor/use-editor-mode"; import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal"; import { ResumePreview } from "@/features/resume/preview/preview"; @@ -31,7 +33,6 @@ const NONE: readonly never[] = []; export function PageCanvas() { const data = useResumeData(); const format = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.format); - const zoom = useEditorStore((state) => state.zoom); const select = useEditorStore((state) => state.select); const setDrawerOpen = useEditorStore((state) => state.setDrawerOpen); const previewTemplate = useEditorStore((state) => state.previewTemplate); @@ -42,18 +43,12 @@ export function PageCanvas() { const assistantProposals = useEditorStore((state) => (state.assistantOpen ? state.assistantProposals : NONE)); const sheetOpen = useEditorStore((state) => state.shareTab !== null); const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id); - const { i18n } = useLingui(); const rendered = useEditorStore((state) => state.rendered); const setRendered = useEditorStore((state) => state.setRendered); const breakpoint = useBreakpoint(); const [mode] = useEditorMode(); - const [canvasRef, canvasWidth] = useCanvasWidth(); + const { canvasRef, fitScale, pageScale } = usePageScale(format); const isPhone = breakpoint === "mobile"; - const gutter = isPhone ? CANVAS_GUTTER.narrow : CANVAS_GUTTER.wide; - - const fitScale = canvasWidth > 0 ? Math.min(ZOOM_MAX, (canvasWidth - gutter) / PAGE_WIDTH[format]) : 1; - const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom; - const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format]; // History: the picked version is drawn on the page, read-only, until the user restores it or goes back to now. const { data: version } = useQuery({ @@ -123,60 +118,17 @@ export function PageCanvas() { )} pageClassName={cn("rounded-none shadow-page", viewing && "outline-2 outline-ink outline-offset-4")} onRender={setRendered} - renderPageCaption={({ pageNumber }) => - pageNumber === 1 ? ( -
- {viewing ? ( - - - - Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} · - read-only - - - ) : previewTemplate ? ( - - - Previewing {templates[previewTemplate].name} · click to apply - - ) : ( - <> - {pendingOnPage > 0 ? ( - - - - ) : mode === "check" ? ( - Page 1 · {formatLabel} - ) : ( - Page 1 · {formatLabel} · click any line to edit it - )} - {overflow && } - - )} -
- ) : overflow && pageNumber > overflow.authored ? ( - // Content past the authored pages: a dashed warn line at the page boundary. -
- - Page {pageNumber} - -
- ) : ( -
- Page {pageNumber} -
- ) - } + renderPageCaption={({ pageNumber }) => ( + + )} renderPageOverlay={({ pageIndex, pageMap }) => // A version from History is read-only: its lines don't open entries. viewing ? null : mode === "check" ? ( @@ -195,6 +147,75 @@ export function PageCanvas() { ); } +type ResumePageCaptionProps = { + pageNumber: number; + viewing: { createdAt: Date; kind: VersionSummary["kind"]; name: VersionSummary["name"] } | null; + previewTemplate: Template | null; + pendingOnPage: number; + mode: string; + format: "a4" | "letter" | "free-form"; + overflow: ReturnType; +}; + +/** Above the first page: the version or template being previewed, else the page label with proposals and overflow. */ +function ResumePageCaption(props: ResumePageCaptionProps) { + const { pageNumber, viewing, previewTemplate, pendingOnPage, mode, format, overflow } = props; + const { i18n } = useLingui(); + const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format]; + + if (pageNumber === 1) { + return ( +
+ {viewing ? ( + + + Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} · read-only + + + ) : previewTemplate ? ( + + Previewing {templates[previewTemplate].name} · click to apply + + ) : ( + <> + {pendingOnPage > 0 ? ( + + + + ) : mode === "check" ? ( + Page 1 · {formatLabel} + ) : ( + Page 1 · {formatLabel} · click any line to edit it + )} + {overflow && } + + )} +
+ ); + } + + // Content past the authored pages: a dashed warn line at the page boundary. + if (overflow && pageNumber > overflow.authored) { + return ( +
+ + Page {pageNumber} + +
+ ); + } + + return ( +
+ Page {pageNumber} +
+ ); +} + type OverflowChipProps = { authored: number; lines: number | null }; /** "Runs onto page 2 by about 6 lines" with Fit, which tightens the design until it fits (one undo step). */ diff --git a/apps/web/src/routes/builder/letter/-components/letter-bar.tsx b/apps/web/src/routes/builder/letter/-components/letter-bar.tsx index 571a53266..f3b67c6ca 100644 --- a/apps/web/src/routes/builder/letter/-components/letter-bar.tsx +++ b/apps/web/src/routes/builder/letter/-components/letter-bar.tsx @@ -24,7 +24,7 @@ import { AssistantButton } from "@/features/assistant/assistant-button"; import { useLetterWords } from "@/features/letters/compose"; import { createLetterFile, letterFileName } from "@/features/letters/export"; import { useLetterEditorStore } from "@/features/letters/store"; -import { BackLink, DrawerControls } from "@/features/resume/editor/chrome"; +import { BackLink, DocumentMenuTrigger, DrawerControls } from "@/features/resume/editor/chrome"; import { useEditorStore } from "@/features/resume/editor/store"; import { usePrompt } from "@/hooks/use-confirm"; import { getOrpcErrorMessage } from "@/libs/error-message"; @@ -108,10 +108,9 @@ export function useDownloadLetter() { downloadWithAnchor(blob, `${letterFileName(letter, words)}.pdf`); } catch { toast.add({ type: "error", description: t`Could not generate the PDF. Please try again.` }); - } finally { - setBusy(false); - toast.close(toastId); } + setBusy(false); + toast.close(toastId); }; return { run, busy }; @@ -173,18 +172,17 @@ function LetterMenu() { const id = useLetterEditorStore((state) => state.letter?.id ?? ""); const name = useLetterEditorStore((state) => state.letter?.name ?? ""); const isLocked = useLetterEditorStore((state) => state.letter?.isLocked ?? false); - const { edit, change, flush } = useLetterEditorStore.getState(); const ref = { type: "letter" as const, id }; const rename = async () => { const next = await prompt(t`Rename letter`, { defaultValue: name }); const trimmed = next?.trim().slice(0, 100); - if (trimmed && trimmed !== name) edit({ name: trimmed }); + if (trimmed && trimmed !== name) useLetterEditorStore.getState().edit({ name: trimmed }); }; const duplicate = async () => { try { - await flush(); + await useLetterEditorStore.getState().flush(); const copy = await client.coverLetters.duplicate({ id }); void queryClient.invalidateQueries({ queryKey: orpc.documents.key() }); toast.add({ description: t`Duplicated` }); @@ -196,15 +194,18 @@ function LetterMenu() { // Locking moves the letter's revision on, so the letter is read again afterwards. const setLocked = (locked: boolean) => - change(async () => { - await client.documents.setLocked({ ...ref, isLocked: locked }); - return client.coverLetters.getById({ id }); - }).catch(failed); + useLetterEditorStore + .getState() + .change(async () => { + await client.documents.setLocked({ ...ref, isLocked: locked }); + return client.coverLetters.getById({ id }); + }) + .catch(failed); // Undoable, so it doesn't ask first: the letter waits in Trash for 30 days. const trash = async () => { try { - await flush(); + await useLetterEditorStore.getState().flush(); await client.documents.trash(ref); void queryClient.invalidateQueries({ queryKey: orpc.documents.key() }); void navigate({ to: "/dashboard" }); @@ -227,21 +228,7 @@ function LetterMenu() { return ( <> - - - {name} - {isLocked && } - - - - } - /> + } /> void rename()}> diff --git a/apps/web/src/routes/builder/letter/-components/letter-page.tsx b/apps/web/src/routes/builder/letter/-components/letter-page.tsx index 25a8e0a8b..af0b80ed0 100644 --- a/apps/web/src/routes/builder/letter/-components/letter-page.tsx +++ b/apps/web/src/routes/builder/letter/-components/letter-page.tsx @@ -7,16 +7,15 @@ import { useReducedMotion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { coverLetterTextToHtml } from "@reactive-resume/resume/cover-letter"; import { getStateIn } from "@reactive-resume/resume/proposals"; -import { Icon } from "@reactive-resume/ui/components/icon"; import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { cn } from "@reactive-resume/utils/style"; import { templates } from "@/dialogs/resume/template/data"; import { letterPageData, useLetterWords } from "@/features/letters/compose"; import { useLetterEditorStore } from "@/features/letters/store"; import { useLetterMode } from "@/features/letters/use-letter-mode"; -import { CANVAS_GUTTER, PAGE_WIDTH, useCanvasWidth, ZoomBar } from "@/features/resume/editor/chrome"; +import { CanvasStatusPill, usePageScale, ZoomBar } from "@/features/resume/editor/chrome"; import { markChange } from "@/features/resume/editor/proposals/proposals"; -import { useEditorStore, ZOOM_MAX } from "@/features/resume/editor/store"; +import { useEditorStore } from "@/features/resume/editor/store"; import { getScrollBehavior } from "@/features/resume/editor/write/reveal"; import { ResumePreview } from "@/features/resume/preview/preview"; import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format"; @@ -54,14 +53,12 @@ export function LetterPage() { const words = useLetterWords(); const { i18n } = useLingui(); const reducedMotion = useReducedMotion(); - const zoom = useEditorStore((state) => state.zoom); const previewTemplate = useEditorStore((state) => state.previewTemplate); const historyVersionId = useEditorStore((state) => state.historyVersionId); const sheetOpen = useEditorStore((state) => state.shareTab !== null); const rendered = useEditorStore((state) => state.rendered); const setRendered = useEditorStore((state) => state.setRendered); const breakpoint = useBreakpoint(); - const [canvasRef, canvasWidth] = useCanvasWidth(); const { data: version } = useQuery({ ...orpc.coverLetters.getVersion.queryOptions({ @@ -70,6 +67,10 @@ export function LetterPage() { enabled: Boolean(letter) && historyVersionId !== null, }); const viewing = historyVersionId !== null && version?.id === historyVersionId ? version : null; + // A viewed version keeps its own page format. + const { canvasRef, fitScale, pageScale } = usePageScale( + (viewing?.data.style ?? letter?.style)?.metadata.page.format ?? "a4", + ); // Reduced motion puts the whole draft on the page at once. const draftText = @@ -96,9 +97,6 @@ export function LetterPage() { const isPhone = breakpoint === "mobile"; const format = data.metadata.page.format; - const gutter = isPhone ? CANVAS_GUTTER.narrow : CANVAS_GUTTER.wide; - const fitScale = canvasWidth > 0 ? Math.min(ZOOM_MAX, (canvasWidth - gutter) / PAGE_WIDTH[format]) : 1; - const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom; const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format]; // Desktop: the page moves aside so it stays visible beside the Share & export sheet. const shifted = sheetOpen && (breakpoint === "desktop" || breakpoint === "wide"); @@ -129,24 +127,16 @@ export function LetterPage() { pageNumber === 1 ? (
{viewing ? ( - - + Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} · read-only - + ) : previewTemplate ? ( - - + Previewing {templates[previewTemplate].name} · click to apply - + ) : ( Page 1 · {formatLabel} )} diff --git a/apps/web/src/routes/builder/letter/-components/share-sheet.tsx b/apps/web/src/routes/builder/letter/-components/share-sheet.tsx index b06377e13..25077be28 100644 --- a/apps/web/src/routes/builder/letter/-components/share-sheet.tsx +++ b/apps/web/src/routes/builder/letter/-components/share-sheet.tsx @@ -129,18 +129,19 @@ function LetterDownloadTab() { const name = fileName ?? letterFileName(letter, words); const download = async (as: LetterFormat) => { + const bothId = as === "both" ? resumeId : null; + const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf"; + const file = `${sanitizeFileName(name) || letterFileName(letter, words)}${extension}`; setState("busy"); try { - if (as === "both" && resumeId) { - const resume = await client.resume.getById({ id: resumeId }); + if (bothId) { + const resume = await client.resume.getById({ id: bothId }); downloadWithAnchor(await createExportFile(resume, "pdf"), `${getDefaultFileName(resume)}.pdf`); // Two downloads in a row are more reliable a moment apart. await new Promise((resolve) => window.setTimeout(resolve, PAUSE_BETWEEN_FILES_MS)); downloadWithAnchor(await createLetterFile(letter, words, "pdf"), `${letterFileName(letter, words)}.pdf`); toast.add({ description: t`Downloaded your resume and this letter` }); } else if (as !== "both") { - const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf"; - const file = `${sanitizeFileName(name) || letterFileName(letter, words)}${extension}`; downloadWithAnchor(await createLetterFile(letter, words, as), file); toast.add({ description: t`Downloaded ${file}` }); } @@ -192,7 +193,6 @@ function useLetterHistory(open: boolean): HistorySource { }); const refresh = () => queryClient.invalidateQueries({ queryKey: orpc.coverLetters.listVersions.queryKey({ input: { id } }) }); - const { flush, change } = useLetterEditorStore.getState(); return { versions, @@ -201,12 +201,12 @@ function useLetterHistory(open: boolean): HistorySource { nowDetail: t`The letter as it is`, errorMessage: (error) => getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }), save: async (name) => { - await flush(); + await useLetterEditorStore.getState().flush(); await client.coverLetters.createVersion({ id, name }); void refresh(); }, restore: async (versionId) => { - await change(() => client.coverLetters.restoreVersion({ id, versionId })); + await useLetterEditorStore.getState().change(() => client.coverLetters.restoreVersion({ id, versionId })); void refresh(); }, rename: async (versionId, name) => {