mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
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.
This commit is contained in:
@@ -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<HTMLDivElement>(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 (
|
||||
<span role="status" className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg">
|
||||
<Icon name={icon} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
aria-label={t`Document menu: ${name}`}
|
||||
className="-mx-1.5 flex min-w-0 max-w-[calc(100%+0.75rem)] flex-col items-start rounded-md px-1.5 py-0.5 text-start transition-colors duration-quick hover:bg-hover"
|
||||
>
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-ink text-sm leading-[18px]">{name}</span>
|
||||
{isLocked && <Icon name="lock" size={16} className="text-ink-3" />}
|
||||
<Icon name="expand_more" size={16} className="text-ink-3" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Leaving the editor is navigation, so it's a link styled as an icon button. */
|
||||
export function BackLink() {
|
||||
const label = t`Back to documents`;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Document menu: ${name}`}
|
||||
className="-mx-1.5 flex min-w-0 max-w-[calc(100%+0.75rem)] flex-col items-start rounded-md px-1.5 py-0.5 text-start transition-colors duration-quick hover:bg-hover"
|
||||
>
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-ink text-sm leading-[18px]">{name}</span>
|
||||
{isLocked && <Icon name="lock" size={16} className="text-ink-3" />}
|
||||
<Icon name="expand_more" size={16} className="text-ink-3" />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuTrigger render={<DocumentMenuTrigger name={name} isLocked={isLocked} />} />
|
||||
<DocumentMenuItems onOpenDialog={setDialog} />
|
||||
</DropdownMenu>
|
||||
<SaveStatus />
|
||||
|
||||
@@ -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 ? (
|
||||
<figcaption className="mb-2.5 flex min-h-8 flex-wrap items-center justify-center gap-2.5 text-center font-medium text-ink-3 text-xs">
|
||||
{viewing ? (
|
||||
<span
|
||||
role="status"
|
||||
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
|
||||
>
|
||||
<Icon name="history" size={18} />
|
||||
<Trans>
|
||||
Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} ·
|
||||
read-only
|
||||
</Trans>
|
||||
</span>
|
||||
) : previewTemplate ? (
|
||||
<span
|
||||
role="status"
|
||||
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
|
||||
>
|
||||
<Icon name="visibility" size={18} />
|
||||
<Trans>Previewing {templates[previewTemplate].name} · click to apply</Trans>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{pendingOnPage > 0 ? (
|
||||
<span className="text-accent-text">
|
||||
<Plural
|
||||
value={pendingOnPage}
|
||||
one="# proposed edit on this page · nothing changes until you accept"
|
||||
other="# proposed edits on this page · nothing changes until you accept"
|
||||
/>
|
||||
</span>
|
||||
) : mode === "check" ? (
|
||||
<Trans>Page 1 · {formatLabel}</Trans>
|
||||
) : (
|
||||
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
|
||||
)}
|
||||
{overflow && <OverflowChip {...overflow} />}
|
||||
</>
|
||||
)}
|
||||
</figcaption>
|
||||
) : overflow && pageNumber > overflow.authored ? (
|
||||
// Content past the authored pages: a dashed warn line at the page boundary.
|
||||
<figcaption className="relative mb-2.5 border-warn border-t-[1.5px] border-dashed">
|
||||
<span className="absolute end-0 -top-2.5 rounded bg-sunken px-1.5 font-semibold text-[11px] text-warn-text">
|
||||
<Trans>Page {pageNumber}</Trans>
|
||||
</span>
|
||||
</figcaption>
|
||||
) : (
|
||||
<figcaption className="mb-2.5 text-center font-medium text-ink-3 text-xs">
|
||||
<Trans>Page {pageNumber}</Trans>
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
renderPageCaption={({ pageNumber }) => (
|
||||
<ResumePageCaption
|
||||
pageNumber={pageNumber}
|
||||
viewing={viewing}
|
||||
previewTemplate={previewTemplate}
|
||||
pendingOnPage={pendingOnPage}
|
||||
mode={mode}
|
||||
format={format}
|
||||
overflow={overflow}
|
||||
/>
|
||||
)}
|
||||
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<typeof measureOverflow>;
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<figcaption className="mb-2.5 flex min-h-8 flex-wrap items-center justify-center gap-2.5 text-center font-medium text-ink-3 text-xs">
|
||||
{viewing ? (
|
||||
<CanvasStatusPill icon="history">
|
||||
<Trans>
|
||||
Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} · read-only
|
||||
</Trans>
|
||||
</CanvasStatusPill>
|
||||
) : previewTemplate ? (
|
||||
<CanvasStatusPill icon="visibility">
|
||||
<Trans>Previewing {templates[previewTemplate].name} · click to apply</Trans>
|
||||
</CanvasStatusPill>
|
||||
) : (
|
||||
<>
|
||||
{pendingOnPage > 0 ? (
|
||||
<span className="text-accent-text">
|
||||
<Plural
|
||||
value={pendingOnPage}
|
||||
one="# proposed edit on this page · nothing changes until you accept"
|
||||
other="# proposed edits on this page · nothing changes until you accept"
|
||||
/>
|
||||
</span>
|
||||
) : mode === "check" ? (
|
||||
<Trans>Page 1 · {formatLabel}</Trans>
|
||||
) : (
|
||||
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
|
||||
)}
|
||||
{overflow && <OverflowChip {...overflow} />}
|
||||
</>
|
||||
)}
|
||||
</figcaption>
|
||||
);
|
||||
}
|
||||
|
||||
// Content past the authored pages: a dashed warn line at the page boundary.
|
||||
if (overflow && pageNumber > overflow.authored) {
|
||||
return (
|
||||
<figcaption className="relative mb-2.5 border-warn border-t-[1.5px] border-dashed">
|
||||
<span className="absolute end-0 -top-2.5 rounded bg-sunken px-1.5 font-semibold text-[11px] text-warn-text">
|
||||
<Trans>Page {pageNumber}</Trans>
|
||||
</span>
|
||||
</figcaption>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<figcaption className="mb-2.5 text-center font-medium text-ink-3 text-xs">
|
||||
<Trans>Page {pageNumber}</Trans>
|
||||
</figcaption>
|
||||
);
|
||||
}
|
||||
|
||||
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). */
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Document menu: ${name}`}
|
||||
className="-mx-1.5 flex min-w-0 max-w-[calc(100%+0.75rem)] flex-col items-start rounded-md px-1.5 py-0.5 text-start transition-colors duration-quick hover:bg-hover"
|
||||
>
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-ink text-sm leading-[18px]">{name}</span>
|
||||
{isLocked && <Icon name="lock" size={16} className="text-ink-3" />}
|
||||
<Icon name="expand_more" size={16} className="text-ink-3" />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuTrigger render={<DocumentMenuTrigger name={name} isLocked={isLocked} />} />
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuItem disabled={isLocked} onClick={() => void rename()}>
|
||||
<Icon name="edit" />
|
||||
|
||||
@@ -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 ? (
|
||||
<figcaption className="mb-2.5 flex min-h-8 flex-wrap items-center justify-center gap-2.5 text-center font-medium text-ink-3 text-xs">
|
||||
{viewing ? (
|
||||
<span
|
||||
role="status"
|
||||
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
|
||||
>
|
||||
<Icon name="history" size={18} />
|
||||
<CanvasStatusPill icon="history">
|
||||
<Trans>
|
||||
Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} ·
|
||||
read-only
|
||||
</Trans>
|
||||
</span>
|
||||
</CanvasStatusPill>
|
||||
) : previewTemplate ? (
|
||||
<span
|
||||
role="status"
|
||||
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
|
||||
>
|
||||
<Icon name="visibility" size={18} />
|
||||
<CanvasStatusPill icon="visibility">
|
||||
<Trans>Previewing {templates[previewTemplate].name} · click to apply</Trans>
|
||||
</span>
|
||||
</CanvasStatusPill>
|
||||
) : (
|
||||
<Trans>Page 1 · {formatLabel}</Trans>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user