@cosmicdrift/kumiko-document-designer 0.363.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.js +4 -0
  3. package/dist/i18n.js +144 -50
  4. package/dist/web/cells.js +1 -1
  5. package/dist/web/editor-header.d.ts +37 -0
  6. package/dist/web/editor-header.js +43 -0
  7. package/dist/web/editor-shell.d.ts +40 -0
  8. package/dist/web/editor-shell.js +36 -0
  9. package/dist/web/i18n.js +35 -59
  10. package/dist/web/layout-editor-body.d.ts +2 -2
  11. package/dist/web/layout-editor-body.js +94 -49
  12. package/dist/web/layout-editor.js +8 -6
  13. package/dist/web/layout-html.d.ts +2 -0
  14. package/dist/web/layout-html.js +13 -1
  15. package/dist/web/layout-meta.d.ts +29 -0
  16. package/dist/web/layout-meta.js +32 -0
  17. package/dist/web/layout-rails.d.ts +15 -5
  18. package/dist/web/layout-rails.js +61 -68
  19. package/dist/web/layout-types.d.ts +4 -0
  20. package/dist/web/layout-users.d.ts +1 -0
  21. package/dist/web/layout-users.js +4 -1
  22. package/dist/web/mail-dialogs.d.ts +1 -6
  23. package/dist/web/mail-dialogs.js +2 -2
  24. package/dist/web/mail-draft.d.ts +8 -6
  25. package/dist/web/mail-draft.js +33 -11
  26. package/dist/web/mail-edit-center.d.ts +3 -1
  27. package/dist/web/mail-edit-center.js +5 -4
  28. package/dist/web/mail-editor-body.d.ts +0 -1
  29. package/dist/web/mail-editor-body.js +57 -41
  30. package/dist/web/mail-editor.js +2 -5
  31. package/dist/web/mail-footer.d.ts +3 -14
  32. package/dist/web/mail-footer.js +12 -13
  33. package/dist/web/mail-left-rail.d.ts +9 -4
  34. package/dist/web/mail-left-rail.js +18 -15
  35. package/dist/web/mail-missing-locale.d.ts +10 -0
  36. package/dist/web/mail-missing-locale.js +45 -0
  37. package/dist/web/mail-origin-bar.d.ts +3 -5
  38. package/dist/web/mail-origin-bar.js +26 -50
  39. package/dist/web/mail-preview-side.d.ts +12 -5
  40. package/dist/web/mail-preview-side.js +14 -5
  41. package/dist/web/mail-preview.d.ts +9 -5
  42. package/dist/web/mail-preview.js +24 -13
  43. package/dist/web/mail-right-rail.js +23 -13
  44. package/dist/web/mail-types.d.ts +6 -1
  45. package/dist/web/mail-versions.js +3 -3
  46. package/dist/web/origin-bar.d.ts +11 -2
  47. package/dist/web/origin-bar.js +46 -24
  48. package/dist/web/overrides-drawer.d.ts +3 -2
  49. package/dist/web/overrides-drawer.js +87 -11
  50. package/dist/web/overview-row.d.ts +2 -0
  51. package/dist/web/overview-row.js +2 -1
  52. package/dist/web/page-canvas.d.ts +1 -0
  53. package/dist/web/page-canvas.js +23 -13
  54. package/dist/web/pdf-footer.d.ts +13 -0
  55. package/dist/web/pdf-footer.js +34 -0
  56. package/dist/web/pdf-left-rail.d.ts +9 -0
  57. package/dist/web/pdf-left-rail.js +16 -0
  58. package/dist/web/pdf-meta.d.ts +3 -5
  59. package/dist/web/pdf-meta.js +10 -16
  60. package/dist/web/pdf-preview.js +21 -8
  61. package/dist/web/pdf-template-editor.js +90 -76
  62. package/dist/web/rail.d.ts +13 -1
  63. package/dist/web/rail.js +15 -7
  64. package/dist/web/relative-time.d.ts +1 -0
  65. package/dist/web/relative-time.js +18 -0
  66. package/dist/web/settings-panel.d.ts +11 -5
  67. package/dist/web/settings-panel.js +24 -13
  68. package/dist/web/status-label.d.ts +7 -0
  69. package/dist/web/status-label.js +8 -0
  70. package/dist/web/types.d.ts +23 -0
  71. package/dist/web/use-insert-targets.d.ts +1 -1
  72. package/dist/web/use-mail-persistence.d.ts +0 -1
  73. package/dist/web/use-mail-persistence.js +1 -1
  74. package/dist/web/use-own-address.d.ts +5 -0
  75. package/dist/web/use-own-address.js +14 -0
  76. package/package.json +8 -8
  77. package/src/changes.json +24 -0
  78. package/dist/web/mail-header.d.ts +0 -23
  79. package/dist/web/mail-header.js +0 -16
  80. package/dist/web/placeholder-panel.d.ts +0 -21
  81. package/dist/web/placeholder-panel.js +0 -35
@@ -1,38 +1,48 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
- import { DocumentContentEditor, PDF_DOCUMENT_BLOCKS, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
+ import { DocumentContentEditor, PDF_DEFAULT_MARGIN_MM, PDF_DOCUMENT_BLOCKS, PDF_FOOTER_MARGIN_MM, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
4
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
5
5
  import { cn } from "@cosmicdrift/kumiko-renderer-web";
6
6
  import { useCallback } from "react";
7
7
  import { LayoutHtml } from "./layout-html.js";
8
8
  import { MARGIN_SIDES } from "./pdf-meta.js";
9
9
  const MM_TO_PX = 96 / 25.4;
10
- const DEFAULT_MARGIN_MM = 15;
11
- // Page width in CSS px at 96 dpi, as a class so no inline width is needed.
10
+ // Page width in CSS px at 96 dpi as a cap; the page shrinks with a narrow column instead of scrolling sideways.
12
11
  const PAGE_WIDTH_CLASSES = {
13
- "A4-portrait": "w-[794px]",
14
- "A4-landscape": "w-[1123px]",
15
- "Letter-portrait": "w-[816px]",
16
- "Letter-landscape": "w-[1056px]",
12
+ "A4-portrait": "max-w-[794px]",
13
+ "A4-landscape": "max-w-[1123px]",
14
+ "Letter-portrait": "max-w-[816px]",
15
+ "Letter-landscape": "max-w-[1056px]",
17
16
  };
18
- function RegionBlock({ html, systemLabels, caption, }) {
17
+ function RegionBlock({ html, systemLabels, resourceSrc, caption, }) {
19
18
  if (html.trim() === "")
20
19
  return null;
21
- return (_jsxs("div", { className: "relative border border-dashed border-border bg-muted/40 p-2 text-xs text-muted-foreground", children: [_jsx("span", { className: "absolute right-1 top-0 text-[10px] uppercase tracking-wide", children: caption }), _jsx(LayoutHtml, { html: html, systemLabels: systemLabels })] }));
20
+ return (_jsxs("div", { className: "relative border border-dashed border-border bg-muted/40 p-2 pt-4 text-xs text-muted-foreground", children: [_jsx("span", { className: "absolute left-2 top-0.5 text-[10px] uppercase tracking-wide", children: caption }), _jsx(LayoutHtml, { html: html, systemLabels: systemLabels, ...(resourceSrc !== undefined && { resourceSrc }) })] }));
21
+ }
22
+ // The letterhead is a band across the whole paper, outside the page margins, like on printed letter paper.
23
+ function HeaderBand({ html, systemLabels, resourceSrc, caption, }) {
24
+ return (_jsxs("div", { className: "relative border-b border-border bg-muted px-[8%] pb-4 pt-6 text-xs text-foreground [&_td:last-child]:whitespace-nowrap", "data-testid": "document-designer-letterhead", children: [_jsx("span", { className: "absolute right-[8%] top-2 text-[10px] uppercase tracking-wide text-muted-foreground", children: caption }), _jsx(LayoutHtml, { html: html, systemLabels: systemLabels, ...(resourceSrc !== undefined && { resourceSrc }) })] }));
22
25
  }
23
26
  export function PageCanvas(props) {
24
27
  const t = useTranslation();
25
28
  const { meta } = props;
26
29
  const showRegions = meta.layoutHeaderFooter && props.regions !== null;
27
- const caption = t("documentDesigner.canvas.fromLayout", { layout: props.layoutName });
30
+ // The letterhead band sits in the top margin, so the content starts right below it.
31
+ const showLetterhead = showRegions && props.regions !== null && props.regions.header.trim() !== "";
32
+ // Same fallback as the PDF renderer: a footer region reserves a taller bottom margin (the header band replaces the top margin on the canvas).
33
+ const hasFooter = showRegions && props.regions !== null && props.regions.footer.trim() !== "";
34
+ const fromLayout = t("documentDesigner.canvas.fromLayout", { layout: props.layoutName });
35
+ const regionCaption = (region) => `${t(`documentDesigner.editor.region.${region}`)} · ${fromLayout}`;
28
36
  // CSS variables instead of a style prop: margins are user data, classes are static.
29
37
  const applyMargins = useCallback((element) => {
30
38
  if (element === null)
31
39
  return;
32
40
  for (const side of MARGIN_SIDES) {
33
- const mm = meta.margins[side] === "" ? DEFAULT_MARGIN_MM : meta.margins[side];
41
+ const fallbackMm = side === "bottom" && hasFooter ? PDF_FOOTER_MARGIN_MM : PDF_DEFAULT_MARGIN_MM;
42
+ const mm = meta.margins[side] === "" ? fallbackMm : meta.margins[side];
34
43
  element.style.setProperty(`--page-margin-${side}`, `${Math.round(mm * MM_TO_PX)}px`);
35
44
  }
36
- }, [meta.margins]);
37
- return (_jsxs("div", { className: "flex flex-col items-center gap-2 overflow-auto bg-muted p-6", children: [_jsxs("div", { ref: applyMargins, "data-testid": "document-designer-page", className: cn("flex min-h-[1000px] max-w-none flex-col bg-background shadow-md", "pt-(--page-margin-top) pr-(--page-margin-right) pb-(--page-margin-bottom) pl-(--page-margin-left)", PAGE_WIDTH_CLASSES[`${meta.pageFormat}-${meta.orientation}`]), children: [showRegions && props.regions !== null && (_jsx(RegionBlock, { html: props.regions.header, systemLabels: props.systemLabels, caption: caption })), _jsx("div", { className: "min-h-0 flex-1 py-2", children: _jsx(DocumentContentEditor, { id: "document-designer-content", value: props.content, onChange: props.onContentChange, variables: props.variableNames, readOnly: false, blocks: PDF_DOCUMENT_BLOCKS, variableLabels: props.variableLabels, systemVariables: props.systemVariables, resourcePreviews: props.resourcePreviews, onEditorReady: props.onEditorReady }) }), showRegions && props.regions !== null && (_jsxs(_Fragment, { children: [_jsx(RegionBlock, { html: props.regions.signature, systemLabels: props.systemLabels, caption: caption }), _jsx(RegionBlock, { html: props.regions.footer, systemLabels: props.systemLabels, caption: caption })] }))] }), _jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.canvas.caption", { format: meta.pageFormat }) })] }));
45
+ }, [meta.margins, hasFooter]);
46
+ const renderPaper = (editable) => (_jsxs("div", { className: "flex w-full flex-col items-center gap-2", children: [_jsxs("div", { "data-testid": "document-designer-page", className: cn("flex min-h-[1000px] w-full flex-col bg-card shadow-md", PAGE_WIDTH_CLASSES[`${meta.pageFormat}-${meta.orientation}`]), children: [showLetterhead && props.regions !== null && (_jsx(HeaderBand, { html: props.regions.header, systemLabels: props.systemLabels, resourceSrc: props.resourceSrc, caption: fromLayout })), _jsxs("div", { ref: applyMargins, className: cn("flex min-h-0 flex-1 flex-col pr-(--page-margin-right) pb-(--page-margin-bottom) pl-(--page-margin-left)", !showLetterhead && "pt-(--page-margin-top)"), children: [_jsxs("div", { className: "relative my-2 min-h-0 flex-1 border border-dashed border-primary/40 p-2 pt-5", "data-testid": "document-designer-content-area", children: [_jsx("span", { className: "absolute left-2 top-0.5 text-[10px] uppercase tracking-wide text-primary", children: t("documentDesigner.editor.region.content") }), editable] }), showRegions && props.regions !== null && (_jsxs(_Fragment, { children: [_jsx(RegionBlock, { html: props.regions.signature, systemLabels: props.systemLabels, resourceSrc: props.resourceSrc, caption: regionCaption("signature") }), _jsx(RegionBlock, { html: props.regions.footer, systemLabels: props.systemLabels, resourceSrc: props.resourceSrc, caption: regionCaption("footer") })] }))] })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.canvas.caption", { format: meta.pageFormat }) })] }));
47
+ return (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", "data-testid": "pdf-editor-center", children: _jsx(DocumentContentEditor, { id: "document-designer-content", value: props.content, onChange: props.onContentChange, variables: props.variableNames, readOnly: false, blocks: PDF_DOCUMENT_BLOCKS, variableLabels: props.variableLabels, systemVariables: props.systemVariables, resourcePreviews: props.resourcePreviews, chrome: "canvas", renderPaper: renderPaper, onEditorReady: props.onEditorReady }) }));
38
48
  }
@@ -0,0 +1,13 @@
1
+ import type { FooterTone } from "./editor-shell.js";
2
+ import type { ValidationResult } from "./types.js";
3
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
4
+ export type GeneratedProbe = {
5
+ readonly pages: number;
6
+ readonly sizeBytes: number;
7
+ readonly durationMs: number;
8
+ };
9
+ export declare function pdfFooterMessage(t: Translate, validation: ValidationResult, probe: GeneratedProbe | null): {
10
+ readonly tone: FooterTone;
11
+ readonly text: string;
12
+ };
13
+ export {};
@@ -0,0 +1,34 @@
1
+ function formatSize(bytes) {
2
+ return bytes < 1024 * 1024
3
+ ? `${Math.max(1, Math.round(bytes / 1024))} KB`
4
+ : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
5
+ }
6
+ // One sentence like the mail footer: missing required placeholders first, then other blocking
7
+ // issues, then warnings, else "ready" plus the last test run.
8
+ export function pdfFooterMessage(t, validation, probe) {
9
+ if (validation.missingRequired.length > 0) {
10
+ const names = validation.missingRequired
11
+ .map((name) => validation.variables[name]?.label ?? name)
12
+ .join(", ");
13
+ return { tone: "bad", text: t("documentDesigner.validation.missingRequired", { name: names }) };
14
+ }
15
+ const blocking = [...validation.issues, ...validation.publishIssues][0];
16
+ if (blocking !== undefined) {
17
+ const translated = t(blocking.i18nKey, blocking.params);
18
+ return { tone: "bad", text: translated === blocking.i18nKey ? blocking.code : translated };
19
+ }
20
+ const warning = validation.warnings[0];
21
+ if (warning !== undefined) {
22
+ const translated = t(warning.i18nKey, { detail: warning.detail });
23
+ return { tone: "warn", text: translated === warning.i18nKey ? warning.code : translated };
24
+ }
25
+ const ok = t("documentDesigner.validation.ok");
26
+ if (probe === null)
27
+ return { tone: "ok", text: ok };
28
+ const lastProbe = t("documentDesigner.validation.lastProbe", {
29
+ pages: probe.pages,
30
+ size: formatSize(probe.sizeBytes),
31
+ seconds: (probe.durationMs / 1000).toFixed(1),
32
+ });
33
+ return { tone: "ok", text: `${ok} ${lastProbe}` };
34
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+ export declare function visibleSystemPlaceholders(names: readonly string[]): readonly string[];
3
+ export declare function PdfResourceSection({ resourceKeys, canUpload, layoutName, onInsert, onUpload, }: {
4
+ readonly resourceKeys: readonly string[];
5
+ readonly canUpload: boolean;
6
+ readonly layoutName: string;
7
+ readonly onInsert: (key: string) => void;
8
+ readonly onUpload: (file: File) => Promise<void>;
9
+ }): ReactNode;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import { UploadZone } from "@cosmicdrift/kumiko-renderer-web";
5
+ import { RailField, RailHint, RailRow, RailSection } from "./rail.js";
6
+ // unsubscribeUrl belongs to mail; the PDF designer never offers it.
7
+ const HIDDEN_SYSTEM_PLACEHOLDERS = new Set(["unsubscribeUrl"]);
8
+ export function visibleSystemPlaceholders(names) {
9
+ return names.filter((name) => !HIDDEN_SYSTEM_PLACEHOLDERS.has(name));
10
+ }
11
+ const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
12
+ export function PdfResourceSection({ resourceKeys, canUpload, layoutName, onInsert, onUpload, }) {
13
+ const t = useTranslation();
14
+ const { Button } = usePrimitives();
15
+ return (_jsxs(RailSection, { title: t("documentDesigner.resources.title"), testId: "pdf-editor-resources", children: [resourceKeys.length === 0 && _jsx(RailRow, { label: t("documentDesigner.resources.empty") }), resourceKeys.map((key) => (_jsx(RailRow, { testId: `pdf-resource-${key}`, symbol: "\u25A3", label: key, onPress: () => onInsert(key) }, key))), _jsx(RailField, { children: canUpload ? (_jsx(UploadZone, { multiple: false, accept: IMAGE_TYPES, title: t("documentDesigner.resources.upload"), onUpload: onUpload, testId: "document-designer-upload" })) : (_jsx(Button, { variant: "secondary", size: "sm", disabled: true, children: t("documentDesigner.resources.upload") })) }), !canUpload && _jsx(RailHint, { children: t("documentDesigner.resources.saveFirst") }), _jsx(RailHint, { children: t("documentDesigner.placeholders.layoutHint", { layout: layoutName }) })] }));
16
+ }
@@ -1,8 +1,6 @@
1
- export declare const PAGE_FORMATS: readonly ["A4", "Letter"];
2
- export declare const ORIENTATIONS: readonly ["portrait", "landscape"];
3
- export type PageFormat = (typeof PAGE_FORMATS)[number];
4
- export type Orientation = (typeof ORIENTATIONS)[number];
5
- export declare const DEFAULT_MARGIN_MM = 15;
1
+ import { PDF_ORIENTATIONS, PDF_PAGE_FORMATS } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
+ export type PageFormat = (typeof PDF_PAGE_FORMATS)[number];
3
+ export type Orientation = (typeof PDF_ORIENTATIONS)[number];
6
4
  export type MarginSide = "top" | "right" | "bottom" | "left";
7
5
  export declare const MARGIN_SIDES: readonly ["top", "right", "bottom", "left"];
8
6
  export type PdfMetaDraft = {
@@ -1,10 +1,6 @@
1
1
  // @runtime client
2
+ import { PDF_ORIENTATIONS, PDF_PAGE_FORMATS, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
3
  import { isOneOf, isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
3
- export const PAGE_FORMATS = ["A4", "Letter"];
4
- export const ORIENTATIONS = ["portrait", "landscape"];
5
- // Mirrors the renderer's fallback margin; used to complete a partly filled set,
6
- // because the server schema only accepts all four sides together.
7
- export const DEFAULT_MARGIN_MM = 15;
8
4
  export const MARGIN_SIDES = [
9
5
  "top",
10
6
  "right",
@@ -51,8 +47,8 @@ export function parsePdfMeta(raw) {
51
47
  const metadata = propertyOf(parsed, "metadata");
52
48
  return {
53
49
  layoutSlug: stringOr(parsed["layoutSlug"], ""),
54
- pageFormat: isOneOf(PAGE_FORMATS, pageFormat) ? pageFormat : EMPTY_PDF_META.pageFormat,
55
- orientation: isOneOf(ORIENTATIONS, orientation) ? orientation : EMPTY_PDF_META.orientation,
50
+ pageFormat: isOneOf(PDF_PAGE_FORMATS, pageFormat) ? pageFormat : EMPTY_PDF_META.pageFormat,
51
+ orientation: isOneOf(PDF_ORIENTATIONS, orientation) ? orientation : EMPTY_PDF_META.orientation,
56
52
  printBackground: booleanOr(parsed["printBackground"], true),
57
53
  layoutHeaderFooter: booleanOr(parsed["layoutHeaderFooter"], true),
58
54
  margins: {
@@ -66,9 +62,6 @@ export function parsePdfMeta(raw) {
66
62
  author: stringOr(propertyOf(metadata, "author"), ""),
67
63
  };
68
64
  }
69
- function hasAnyMargin(margins) {
70
- return MARGIN_SIDES.some((side) => margins[side] !== "");
71
- }
72
65
  /** Builds the meta object for the server; defaults and empty fields are left out. */
73
66
  export function serializePdfMeta(draft) {
74
67
  const meta = {};
@@ -82,12 +75,13 @@ export function serializePdfMeta(draft) {
82
75
  meta["printBackground"] = false;
83
76
  if (!draft.layoutHeaderFooter)
84
77
  meta["layoutHeaderFooter"] = false;
85
- if (hasAnyMargin(draft.margins)) {
86
- meta["margins"] = Object.fromEntries(MARGIN_SIDES.map((side) => [
87
- side,
88
- draft.margins[side] === "" ? DEFAULT_MARGIN_MM : draft.margins[side],
89
- ]));
90
- }
78
+ // Unset sides stay out so the renderer's per-side fallbacks (header/footer margins) still apply.
79
+ const setMargins = MARGIN_SIDES.flatMap((side) => {
80
+ const mm = draft.margins[side];
81
+ return mm === "" ? [] : [[side, mm]];
82
+ });
83
+ if (setMargins.length > 0)
84
+ meta["margins"] = Object.fromEntries(setMargins);
91
85
  if (draft.fileName.trim() !== "")
92
86
  meta["fileName"] = draft.fileName.trim();
93
87
  const metadata = {};
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  // @runtime client
3
3
  /// <reference path="./pdfjs-worker.d.ts" />
4
4
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
5
- import { Suspense, use, useCallback } from "react";
5
+ import { Suspense, use, useCallback, useMemo } from "react";
6
6
  // pdf.js runs in the main thread ("fake worker"): the worker module is bundled
7
7
  // with the app and handed over through globalThis.pdfjsWorker, so no worker or
8
8
  // CDN URL is ever fetched (self-hosting and CSP stay intact).
@@ -23,10 +23,8 @@ async function loadDocument(pdfBase64) {
23
23
  import("pdfjs-dist/build/pdf.worker.min.mjs"),
24
24
  ]);
25
25
  Object.assign(globalThis, { pdfjsWorker: worker });
26
- const document = await pdfjs.getDocument({
27
- data: decodeBase64(pdfBase64),
28
- }).promise;
29
- return { ok: true, document };
26
+ const loadingTask = pdfjs.getDocument({ data: decodeBase64(pdfBase64) });
27
+ return { ok: true, document: await loadingTask.promise, loadingTask };
30
28
  }
31
29
  catch {
32
30
  return { ok: false };
@@ -35,15 +33,21 @@ async function loadDocument(pdfBase64) {
35
33
  // The promise must be stable across renders for use(); only the latest PDF is kept.
36
34
  function documentFor(pdfBase64) {
37
35
  if (cachedKey !== pdfBase64 || cachedLoad === null) {
36
+ const previous = cachedLoad;
38
37
  cachedKey = pdfBase64;
39
38
  cachedLoad = loadDocument(pdfBase64);
39
+ void previous?.then((loaded) => {
40
+ if (loaded.ok)
41
+ void loaded.loadingTask.destroy();
42
+ });
40
43
  }
41
44
  return cachedLoad;
42
45
  }
43
46
  function PageView({ document, pageNumber, }) {
44
47
  const t = useTranslation();
45
48
  const page = use(pageFor(document, pageNumber));
46
- const viewport = page.getViewport({ scale: RENDER_SCALE });
49
+ // Stable identity: the mount callbacks below depend on it, and a new viewport per render re-mounts canvas and text layer.
50
+ const viewport = useMemo(() => page.getViewport({ scale: RENDER_SCALE }), [page]);
47
51
  const mountCanvas = useCallback((canvas) => {
48
52
  if (canvas === null)
49
53
  return;
@@ -57,7 +61,13 @@ function PageView({ document, pageNumber, }) {
57
61
  container.style.setProperty("--total-scale-factor", String(RENDER_SCALE));
58
62
  container.style.setProperty("--scale-factor", String(RENDER_SCALE));
59
63
  let layer = null;
64
+ let cancelled = false;
60
65
  void import("pdfjs-dist").then(({ TextLayer }) => {
66
+ // skip: cleanup ran before pdfjs finished loading
67
+ if (cancelled)
68
+ return undefined;
69
+ // TextLayer.cancel() keeps rendered spans; a re-mount must not append a second layer.
70
+ container.replaceChildren();
61
71
  const textLayer = new TextLayer({
62
72
  textContentSource: page.streamTextContent(),
63
73
  container,
@@ -66,9 +76,12 @@ function PageView({ document, pageNumber, }) {
66
76
  layer = textLayer;
67
77
  return textLayer.render().catch(() => undefined);
68
78
  });
69
- return () => layer?.cancel();
79
+ return () => {
80
+ cancelled = true;
81
+ layer?.cancel();
82
+ };
70
83
  }, [page, viewport]);
71
- return (_jsxs("figure", { className: "m-0 flex flex-col items-center gap-2", "data-testid": `pdf-page-${pageNumber}`, children: [_jsxs("div", { className: "relative w-fit bg-background shadow-md", children: [_jsx("canvas", { ref: mountCanvas, width: viewport.width, height: viewport.height }), _jsx("div", { ref: mountTextLayer, className: "absolute inset-0 overflow-hidden leading-none [&_span]:absolute [&_span]:origin-top-left [&_span]:cursor-text [&_span]:whitespace-pre [&_span]:text-transparent [&_span::selection]:bg-primary/30" })] }), _jsx("figcaption", { className: "text-xs text-muted-foreground", children: t("documentDesigner.preview.pageOf", { page: pageNumber, pages: document.numPages }) })] }));
84
+ return (_jsxs("figure", { className: "m-0 flex flex-col items-center gap-2", "data-testid": `pdf-page-${pageNumber}`, children: [_jsxs("div", { className: "relative w-fit bg-card shadow-md", children: [_jsx("canvas", { ref: mountCanvas, width: viewport.width, height: viewport.height }), _jsx("div", { ref: mountTextLayer, className: "absolute inset-0 overflow-hidden leading-none [&_span]:absolute [&_span]:origin-top-left [&_span]:cursor-text [&_span]:whitespace-pre [&_span]:text-transparent [&_span::selection]:bg-primary/30" })] }), _jsx("figcaption", { className: "text-xs text-muted-foreground", children: t("documentDesigner.preview.pageOf", { page: pageNumber, pages: document.numPages }) })] }));
72
85
  }
73
86
  const pageCache = new WeakMap();
74
87
  function pageFor(document, pageNumber) {
@@ -3,36 +3,47 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
4
  import { useDispatcher, usePrimitives, useQuery, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
5
5
  import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
6
- import { ErrorState, LoadingState, ModeSwitch, StatusBadge, uploadFileRef, } from "@cosmicdrift/kumiko-renderer-web";
7
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { ErrorState, LoadingState, uploadFileRef } from "@cosmicdrift/kumiko-renderer-web";
7
+ import { useEffect, useMemo, useRef, useState } from "react";
8
+ import { BRANDING_QUERY_QN } from "../constants.js";
9
+ import { EditorHeader, EditorLocaleSwitch, EditorModeSwitch, originOf, } from "./editor-header.js";
10
+ import { EditorBody, EditorFooter, EditorFrame, EditorInfoBar } from "./editor-shell.js";
8
11
  import { describeError, isAlreadyActive } from "./errors.js";
12
+ import { fileUrl } from "./file-name.js";
13
+ import { systemLabelMap, variableLabelMap } from "./mail-draft.js";
14
+ import { PlaceholderSection, SystemPlaceholderSection } from "./mail-left-rail.js";
9
15
  import { OverridesDrawer } from "./overrides-drawer.js";
10
16
  import { PageCanvas } from "./page-canvas.js";
17
+ import { pdfFooterMessage } from "./pdf-footer.js";
18
+ import { PdfResourceSection, visibleSystemPlaceholders } from "./pdf-left-rail.js";
11
19
  import { EMPTY_PDF_META, parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
12
20
  import { PdfPreview } from "./pdf-preview.js";
13
- import { PlaceholderPanel, visibleSystemPlaceholders, } from "./placeholder-panel.js";
14
21
  import { SettingsPanel } from "./settings-panel.js";
22
+ import { draftBesideActiveSentence } from "./status-label.js";
15
23
  import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-rows.js";
16
24
  import { DEFAULT_LAYOUT_SLUG, DOCUMENT_PDF_KIND, PDF_LAYOUT_KIND, } from "./types.js";
25
+ import { useInsertTargets } from "./use-insert-targets.js";
26
+ import { useOverviewRows } from "./use-overview-rows.js";
17
27
  const SETTLE_MS = 500;
18
28
  const NO_VARIABLES = {};
19
- const SYSTEM_LABEL_NAMES = ["tenantName", "imprint", "signature", "page", "pages"];
20
- // Page numbers share one row in the panel; "pages" is inserted with "page".
21
- const MERGED_SYSTEM_PLACEHOLDERS = new Set(["pages"]);
29
+ // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
30
+ function RunOnMount({ run }) {
31
+ useEffect(() => {
32
+ run();
33
+ }, [run]);
34
+ return null;
35
+ }
22
36
  const draftFromDetail = (detail) => ({
23
37
  content: detail.content,
24
38
  meta: parsePdfMeta(detail.meta),
25
39
  });
26
40
  const snapshotOf = (draft) => JSON.stringify({ content: draft.content, meta: serializePdfMeta(draft.meta) });
27
- function formatSize(bytes) {
28
- return bytes < 1024 * 1024
29
- ? `${Math.max(1, Math.round(bytes / 1024))} KB`
30
- : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
31
- }
32
41
  export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChange, }) {
33
42
  const t = useTranslation();
34
43
  const dispatcher = useDispatcher();
35
44
  const { Button, Banner, Dialog } = usePrimitives();
45
+ const insert = useInsertTargets();
46
+ const overview = useOverviewRows(DOCUMENT_PDF_KIND);
36
47
  const roles = useUserRoles();
37
48
  const scope = roles?.includes("SystemAdmin") === true ? "system" : "tenant";
38
49
  const rowsQuery = useQuery(TemplateResolverQueries.list, {
@@ -61,11 +72,12 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
61
72
  clearTimeout(settleTimer.current);
62
73
  }, []);
63
74
  const [overridesOpen, setOverridesOpen] = useState(false);
64
- const editorHandle = useRef(null);
75
+ const [draftEpoch, setDraftEpoch] = useState(0);
65
76
  const [mode, setMode] = useState("edit");
66
77
  const [busy, setBusy] = useState(null);
67
78
  const [actionError, setActionError] = useState(null);
68
79
  const [generated, setGenerated] = useState(null);
80
+ const autoPrintedFor = useRef(null);
69
81
  const [pendingLocale, setPendingLocale] = useState(null);
70
82
  const variableSchema = useMemo(() => parseJsonObject(detail?.variableSchema ?? null), [detail?.variableSchema]);
71
83
  const settledMeta = current === null ? null : serializePdfMeta(current.settled.meta);
@@ -90,35 +102,25 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
90
102
  variableSchema,
91
103
  }, { enabled: detail !== null });
92
104
  const layout = previewQuery.error === null ? (previewQuery.data?.layout ?? null) : null;
93
- const layoutsQuery = useQuery(TemplateResolverQueries.list, {
94
- kind: PDF_LAYOUT_KIND,
95
- locale,
96
- });
97
- const layoutSlugs = [
98
- ...new Set((layoutsQuery.data ?? []).map((layoutRow) => layoutRow.slug)),
99
- ].sort();
105
+ const layouts = useOverviewRows(PDF_LAYOUT_KIND);
106
+ // The built-in "logo" resource of a layout is the tenant's branding logo.
107
+ const branding = useQuery(BRANDING_QUERY_QN, {});
108
+ const brandingLogoFile = branding.data?.logoFile ?? "";
100
109
  const statusQuery = useQuery(RendererPuppeteerClientQueries.status, {});
101
110
  const pdfAvailable = statusQuery.error === null && statusQuery.data?.available === true;
102
111
  const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
103
112
  const showOrigin = scope === "system";
104
113
  const overridesQuery = useQuery(TemplateResolverQueries.tenantOverrides, { slug, kind: DOCUMENT_PDF_KIND, locale }, { enabled: showOrigin && detail !== null });
105
114
  const variables = validation?.variables ?? NO_VARIABLES;
106
- const systemLabel = useCallback((name) => {
107
- const label = t(`documentDesigner.system.${name}`);
108
- return label === `documentDesigner.system.${name}` ? name : label;
109
- }, [t]);
110
- const variableLabels = useMemo(() => Object.fromEntries(Object.entries(variables).flatMap(([name, info]) => info.label === undefined ? [] : [[name, info.label]])), [variables]);
111
- const systemLabels = useMemo(() => Object.fromEntries(SYSTEM_LABEL_NAMES.map((name) => [name, systemLabel(name)])), [systemLabel]);
112
115
  const systemVariables = useMemo(() => visibleSystemPlaceholders(validation?.systemPlaceholders ?? []), [validation?.systemPlaceholders]);
116
+ const systemLabels = useMemo(() => systemLabelMap(t, systemVariables), [t, systemVariables]);
117
+ const variableLabels = useMemo(() => ({ ...systemLabels, ...variableLabelMap(variables) }), [systemLabels, variables]);
113
118
  const variableNames = useMemo(() => Object.keys(variables), [variables]);
114
119
  const linkedResources = detail?.linkedResources ?? null;
115
120
  // The editor shows resource images from the stored copies; no extra request is made here.
116
121
  const resourcePreviews = useMemo(() => Object.fromEntries(Object.entries(parseJsonObject(linkedResources)).flatMap(([key, fileRefId]) => typeof fileRefId === "string"
117
122
  ? [[key, { src: `/api/files/${encodeURIComponent(fileRefId)}`, caption: key }]]
118
123
  : [])), [linkedResources]);
119
- const onEditorReady = useCallback((handle) => {
120
- editorHandle.current = handle;
121
- }, []);
122
124
  if (rowsQuery.loading && rowsQuery.data === null)
123
125
  return _jsx(LoadingState, {});
124
126
  if (rowsQuery.error !== null)
@@ -143,6 +145,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
143
145
  if (settleTimer.current !== null)
144
146
  clearTimeout(settleTimer.current);
145
147
  setState({ key: source.id, draft, settled: draft, snapshot: snapshotOf(draft) });
148
+ setDraftEpoch((epoch) => epoch + 1);
146
149
  setActionError(null);
147
150
  }
148
151
  async function reload() {
@@ -220,10 +223,11 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
220
223
  setActionError(describeError(t, result.error));
221
224
  setBusy(null);
222
225
  }
223
- function switchMode(next) {
224
- setMode(next);
225
- if (next === "preview" && pdfAvailable && !generatedFresh && busy === null)
226
- void generatePdf();
226
+ function printOnce() {
227
+ if (current === null || autoPrintedFor.current === snapshotOf(current.draft))
228
+ return;
229
+ autoPrintedFor.current = snapshotOf(current.draft);
230
+ void generatePdf();
227
231
  }
228
232
  async function uploadResource(file) {
229
233
  const sourceFileRefId = await uploadFileRef(file);
@@ -243,53 +247,63 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
243
247
  else
244
248
  onLocaleChange?.(next);
245
249
  }
246
- const used = validation?.used ?? [];
247
- const missingRequired = validation?.missingRequired ?? [];
248
- const layoutName = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
249
- const systemNames = visibleSystemPlaceholders(validation?.systemPlaceholders ?? []).filter((name) => !MERGED_SYSTEM_PLACEHOLDERS.has(name));
250
- const systemRows = systemNames.map((name) => ({
251
- name,
252
- label: systemLabel(name),
253
- status: used.includes(name)
254
- ? "used"
255
- : layout !== null &&
256
- (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`))
257
- ? "layout"
258
- : "unused",
259
- }));
260
- const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
261
250
  const canPublish = validation?.canPublish === true;
262
- const issueTexts = [...(validation?.issues ?? []), ...(validation?.publishIssues ?? [])].map((issue) => t(issue.i18nKey, issue.params));
251
+ const layoutSlugShown = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
252
+ const layoutName = layouts.rows.find((row) => row.slug === layoutSlugShown)?.title ?? layoutSlugShown;
253
+ const layoutResourceSrc = (key) => key === "logo" && brandingLogoFile !== "" ? fileUrl(brandingLogoFile) : undefined;
254
+ const placeholderSource = validation === undefined || validation === null
255
+ ? null
256
+ : {
257
+ ...validation,
258
+ systemPlaceholders: visibleSystemPlaceholders(validation.systemPlaceholders),
259
+ };
260
+ const layoutFills = (name) => layout !== null &&
261
+ (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`));
262
+ const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
263
263
  const resourceKeys = Object.keys(parseJsonObject(detail.linkedResources));
264
- const actions = (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", disabled: !dirty || busy !== null, onClick: () => resetDraft(detail), children: t("documentDesigner.actions.discard") }), _jsx(Button, { variant: "secondary", disabled: busy !== null || validation?.canSave === false, loading: busy === "save", onClick: () => void save(), children: t("documentDesigner.actions.save") }), _jsx(Button, { variant: "primary", disabled: busy !== null || !canPublish, loading: busy === "publish", onClick: () => void publish(), children: t("documentDesigner.actions.publish") })] }));
265
- return (_jsxs("div", { className: "flex flex-col gap-3", "data-testid": "pdf-template-editor", children: [_jsxs("header", { className: "flex flex-wrap items-center gap-3", children: [_jsxs("nav", { className: "text-sm text-muted-foreground", children: [scope === "system" ? `${t("documentDesigner.breadcrumb.platform")} › ` : "", t("documentDesigner.breadcrumb.templates"), " \u203A", " ", _jsx("span", { className: "text-foreground", children: slug })] }), _jsx(StatusBadge, { tone: detail.status === "active" ? "ok" : "warn", children: t(detail.status === "active"
266
- ? "documentDesigner.status.activeVersion"
267
- : "documentDesigner.status.draftVersion", { version: detail.version }) }), _jsx(StatusBadge, { tone: "muted", children: t(detail.scope === "system"
268
- ? "documentDesigner.origin.system"
269
- : "documentDesigner.origin.tenant") }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.locale"), value: locale, onChange: requestLocale, options: (locales.length > 0 ? locales : [locale]).map((value) => ({
270
- value,
271
- label: value.toUpperCase(),
272
- })) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.mode.label"), variant: "pill", value: mode, onChange: switchMode, options: [
273
- { value: "edit", label: t("documentDesigner.mode.edit") },
274
- { value: "preview", label: t("documentDesigner.mode.preview") },
275
- ] }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") }), actions] })] }), showOrigin && detail.scope === "system" && (_jsx(Banner, { variant: "info", actions: _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(Button, { variant: "link", onClick: () => void exportBundle(), children: t("documentDesigner.originBar.export") }), _jsx(Button, { variant: "link", onClick: () => setOverridesOpen(true), children: t("documentDesigner.originBar.showOverrides") })] }), children: overridesQuery.data?.totalTenants == null
276
- ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
277
- : t("documentDesigner.originBar.withTotal", {
278
- count: overrideCount,
279
- total: overridesQuery.data.totalTenants,
280
- }) })), _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, overrides: overridesQuery.data }), !pdfAvailable && statusQuery.loading === false && (_jsx(Banner, { variant: "warning", children: t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })), actionError !== null && _jsx(Banner, { variant: "error", children: actionError }), _jsxs("div", { className: "grid grid-cols-[260px_minmax(0,1fr)_300px] items-start gap-4", children: [_jsx(PlaceholderPanel, { variables: variables, used: used, missingRequired: missingRequired, systemRows: systemRows, resourceKeys: resourceKeys, canUpload: detail.scope === scope, layoutName: layoutName, onInsert: (name) => editorHandle.current?.insertPlaceholder(name), onInsertResource: (key) => editorHandle.current?.insertResource(key), onUpload: uploadResource }), mode === "edit" ? (_jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, resourcePreviews: resourcePreviews, onEditorReady: onEditorReady })) : generated !== null && generatedFresh ? (_jsx(PdfPreview, { pdfBase64: generated.result.pdfBase64 })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 bg-muted p-6", "data-testid": "document-designer-preview-stale", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
264
+ const overviewRow = overview.rows.find((candidate) => candidate.slug === slug);
265
+ const title = overviewRow?.title ?? slug;
266
+ const systemVersion = overviewRow?.systemVersion ?? null;
267
+ const vocabulary = {
268
+ names: variableNames,
269
+ labels: variableLabels,
270
+ system: systemVariables,
271
+ };
272
+ const footer = validation === undefined || validation === null
273
+ ? null
274
+ : pdfFooterMessage(t, validation, generated === null
275
+ ? null
276
+ : {
277
+ pages: generated.result.pageCount,
278
+ sizeBytes: generated.result.sizeBytes,
279
+ durationMs: generated.result.durationMs,
280
+ });
281
+ const showOverrideLinks = showOrigin && detail.scope === "system";
282
+ const originText = showOverrideLinks
283
+ ? overridesQuery.data?.totalTenants == null
284
+ ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
285
+ : t("documentDesigner.originBar.withTotal", {
286
+ count: overrideCount,
287
+ total: overridesQuery.data.totalTenants,
288
+ })
289
+ : detail.scope === "system"
290
+ ? t("documentDesigner.mail.origin.system", { version: detail.version })
291
+ : systemVersion === null
292
+ ? t("documentDesigner.mail.origin.tenantOnly")
293
+ : t("documentDesigner.mail.origin.overrides", { version: systemVersion });
294
+ const besideActiveSentence = draftBesideActiveSentence(t, detail);
295
+ const pdfUnavailableNote = !pdfAvailable && statusQuery.loading === false;
296
+ return (_jsxs(EditorFrame, { testId: "pdf-template-editor", children: [_jsx(EditorHeader, { title: title, stored: { status: detail.status, version: detail.version }, actions: {
297
+ busy: busy === "save",
298
+ canSave: busy === null && validation?.canSave !== false,
299
+ canPublish: busy === null && canPublish,
300
+ onSave: () => void save(),
301
+ onPublish: () => void publish(),
302
+ }, children: _jsx(EditorModeSwitch, { testIdPrefix: "pdf", mode: mode, onMode: setMode }) }), _jsxs(EditorInfoBar, { tone: pdfUnavailableNote ? "warn" : detail.scope === "tenant" ? "active" : "muted", testId: "pdf-origin-bar", origin: originOf(detail.scope === "system", systemVersion !== null), controls: _jsxs(_Fragment, { children: [_jsx(EditorLocaleSwitch, { testIdPrefix: "pdf", locales: locales.length > 0 ? locales : [locale], locale: locale, onLocale: requestLocale }), mode === "preview" && (_jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void generatePdf(), testId: "pdf-generate-header", children: t("documentDesigner.actions.generatePdf") }))] }), overlays: _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, locale: locale, overrides: overridesQuery.data }), links: showOverrideLinks && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "link", onClick: () => void exportBundle(), children: t("documentDesigner.originBar.export") }), _jsx(Button, { variant: "link", onClick: () => setOverridesOpen(true), children: t("documentDesigner.originBar.showOverrides") })] })), children: [originText, besideActiveSentence !== null && ` ${besideActiveSentence}`, pdfUnavailableNote && (_jsxs(_Fragment, { children: [" ", _jsx("span", { "data-testid": "pdf-unavailable", children: unavailableMessage === ""
303
+ ? t("documentDesigner.pdfUnavailableNoReason")
304
+ : t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })] }))] }), _jsx(EditorBody, { left: mode === "edit" ? (_jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf" }), _jsx(SystemPlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf", isLayoutOwned: layoutFills }), _jsx(PdfResourceSection, { resourceKeys: resourceKeys, canUpload: detail.scope === scope, layoutName: layoutName, onInsert: insert.insertResource, onUpload: uploadResource })] })) : undefined, center: mode === "edit" ? (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), children: _jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, resourceSrc: layoutResourceSrc, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, resourcePreviews: resourcePreviews, onEditorReady: insert.register("body") }) })) : (_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: generated !== null && generatedFresh ? (_jsx(PdfPreview, { pdfBase64: generated.result.pdfBase64 })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 p-6", "data-testid": "document-designer-preview-stale", children: [pdfAvailable && busy === null && _jsx(RunOnMount, { run: printOnce }), _jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
281
305
  ? t("documentDesigner.preview.loading")
282
- : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") })] })), _jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => updateDraft({ ...current.draft, meta }), layoutSlugs: layoutSlugs, locale: locale, version: {
283
- version: detail.version,
284
- status: detail.status,
285
- modifiedAt: detail.modifiedAt,
286
- } })] }), _jsxs("footer", { className: "flex flex-wrap items-start justify-between gap-3 border-t border-border pt-3", children: [_jsxs("ul", { className: "m-0 flex list-none flex-col gap-1 p-0 text-sm", "data-testid": "document-designer-validation", children: [missingRequired.length === 0 && validation !== undefined && validation !== null && (_jsx("li", { className: "text-status-ok", children: t("documentDesigner.validation.ok") })), missingRequired.map((name) => (_jsx("li", { className: "text-status-bad", children: t("documentDesigner.validation.missingRequired", {
287
- name: variables[name]?.label ?? name,
288
- }) }, name))), issueTexts.map((text) => (_jsx("li", { className: "text-status-bad", children: text }, text))), (validation?.warnings ?? []).map((warning) => (_jsx("li", { className: "text-status-warn", children: t(warning.i18nKey, { detail: warning.detail }) }, `${warning.code}:${warning.detail}`))), generated !== null && (_jsx("li", { className: "text-muted-foreground", children: t("documentDesigner.validation.lastProbe", {
289
- pages: generated.result.pageCount,
290
- size: formatSize(generated.result.sizeBytes),
291
- seconds: (generated.result.durationMs / 1000).toFixed(1),
292
- }) }))] }), _jsx("div", { className: "flex items-center gap-2", children: actions })] }), _jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
306
+ : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") })] })) })), right: _jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => updateDraft({ ...current.draft, meta }), layouts: layouts.rows, locale: locale, version: { id: detail.id, version: detail.version, status: detail.status }, vocabulary: vocabulary, insert: insert, draftKey: `${current.key}:${draftEpoch}` }) }), _jsx(EditorFooter, { testIdPrefix: "pdf-editor", message: footer, saveError: actionError, modifiedAt: detail.modifiedAt ?? null, dirty: dirty, busy: busy === "save", canSave: busy === null && validation?.canSave !== false, canPublish: busy === null && canPublish, onDiscard: () => resetDraft(detail), onSave: () => void save(), onPublish: () => void publish() }), _jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
293
307
  if (!open)
294
308
  setPendingLocale(null);
295
309
  }, title: t("documentDesigner.localeDialog.title"), description: t("documentDesigner.localeDialog.description"), confirmLabel: t("documentDesigner.localeDialog.save"), cancelLabel: t("documentDesigner.localeDialog.cancel"), onConfirm: async () => {
@@ -5,10 +5,21 @@ export declare function RailSection({ title, action, testId, children, }: {
5
5
  readonly testId?: string;
6
6
  readonly children: ReactNode;
7
7
  }): ReactNode;
8
- export declare function RailRow({ symbol, label, trailing, bad, testId, onPress, }: {
8
+ export declare function RailField({ children }: {
9
+ readonly children: ReactNode;
10
+ }): ReactNode;
11
+ declare const TRAILING_TONE: {
12
+ readonly ok: "text-status-ok";
13
+ readonly bad: "text-status-bad";
14
+ readonly muted: "text-muted-foreground";
15
+ };
16
+ export declare function RailRow({ symbol, label, required, trailing, trailingTone, action, bad, testId, onPress, }: {
9
17
  readonly symbol?: string;
10
18
  readonly label: string;
19
+ readonly required?: boolean;
11
20
  readonly trailing?: ReactNode;
21
+ readonly trailingTone?: keyof typeof TRAILING_TONE;
22
+ readonly action?: ReactNode;
12
23
  readonly bad?: boolean;
13
24
  readonly testId?: string;
14
25
  readonly onPress?: () => void;
@@ -16,3 +27,4 @@ export declare function RailRow({ symbol, label, trailing, bad, testId, onPress,
16
27
  export declare function RailHint({ children }: {
17
28
  readonly children: ReactNode;
18
29
  }): ReactNode;
30
+ export {};
package/dist/web/rail.js CHANGED
@@ -1,14 +1,22 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
- import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
4
3
  import { cn } from "@cosmicdrift/kumiko-renderer-web";
5
4
  export function RailSection({ title, action, testId, children, }) {
6
- return (_jsxs("section", { className: "flex flex-col gap-1", "data-testid": testId, children: [_jsxs("div", { className: "flex items-center gap-2 pt-3 pb-1", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: title }), _jsx("div", { className: "grow" }), action] }), children] }));
5
+ return (_jsxs("section", { className: "flex flex-col border-b pb-1", "data-testid": testId, children: [_jsxs("div", { className: "flex items-center gap-2 px-3.5 pt-2.5 pb-1", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-muted-foreground", children: title }), _jsx("div", { className: "grow" }), action] }), children] }));
7
6
  }
8
- export function RailRow({ symbol, label, trailing, bad, testId, onPress, }) {
9
- const { Button } = usePrimitives();
10
- return (_jsxs("div", { className: cn("flex items-center gap-2 rounded px-2 py-1", bad === true && "bg-status-bad-surface text-status-bad"), "data-testid": testId, "data-bad": bad === true ? "true" : undefined, children: [symbol !== undefined && (_jsx("span", { className: "w-4 shrink-0 text-center text-xs text-muted-foreground", children: symbol })), _jsx("span", { className: "min-w-0 grow truncate text-sm", children: onPress === undefined ? (label) : (_jsx(Button, { variant: "link", onClick: onPress, ...(testId !== undefined && { testId: `${testId}-insert` }), children: label })) }), trailing !== undefined && (_jsx("span", { className: "min-w-0 max-w-[60%] truncate text-right text-xs text-muted-foreground", title: typeof trailing === "string" ? trailing : undefined, children: trailing }))] }));
7
+ export function RailField({ children }) {
8
+ return _jsx("div", { className: "flex flex-col gap-3 px-3.5 py-2", children: children });
9
+ }
10
+ const TRAILING_TONE = {
11
+ ok: "text-status-ok",
12
+ bad: "text-status-bad",
13
+ muted: "text-muted-foreground",
14
+ };
15
+ export function RailRow({ symbol, label, required, trailing, trailingTone = "muted", action, bad, testId, onPress, }) {
16
+ const content = (_jsxs(_Fragment, { children: [symbol !== undefined && (_jsx("span", { className: "inline-flex h-[18px] w-[22px] shrink-0 items-center justify-center rounded-sm bg-muted font-mono text-xs text-muted-foreground", children: symbol })), _jsxs("span", { className: cn("min-w-0 grow break-words text-sm", trailing !== undefined && "max-w-[65%] shrink-0", bad === true && "text-status-bad"), children: [label, required === true && _jsx("span", { className: "font-semibold text-status-bad", children: " *" })] }), trailing !== undefined && (_jsx("span", { className: cn("min-w-0 truncate text-right text-xs", bad === true ? "text-status-bad" : TRAILING_TONE[trailingTone]), title: typeof trailing === "string" ? trailing : undefined, children: trailing }))] }));
17
+ const rowClass = "flex min-h-10 min-w-0 grow items-center gap-2 px-3.5 text-left";
18
+ return (_jsxs("div", { className: cn("flex items-center border-b", bad === true && "bg-status-bad-surface"), "data-testid": testId, "data-bad": bad === true ? "true" : undefined, children: [onPress === undefined ? (_jsx("div", { className: rowClass, children: content })) : (_jsx("button", { type: "button", onClick: onPress, className: cn(rowClass, "hover:bg-muted"), ...(testId !== undefined && { "data-testid": `${testId}-insert` }), children: content })), action !== undefined && _jsx("div", { className: "shrink-0 pr-2", children: action })] }));
11
19
  }
12
20
  export function RailHint({ children }) {
13
- return _jsx("p", { className: "px-2 pt-1 text-xs text-muted-foreground", children: children });
21
+ return _jsx("p", { className: "px-3.5 pt-1 pb-2 text-xs text-muted-foreground", children: children });
14
22
  }