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) => {