@cosmicdrift/kumiko-document-designer 0.363.0 → 0.365.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 (90) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.js +4 -0
  3. package/dist/i18n.js +208 -50
  4. package/dist/web/cells.js +4 -3
  5. package/dist/web/editor-header.d.ts +37 -0
  6. package/dist/web/editor-header.js +43 -0
  7. package/dist/web/editor-locale.d.ts +1 -0
  8. package/dist/web/editor-locale.js +15 -0
  9. package/dist/web/editor-shell.d.ts +40 -0
  10. package/dist/web/editor-shell.js +36 -0
  11. package/dist/web/i18n.js +35 -59
  12. package/dist/web/layout-editor-body.d.ts +2 -2
  13. package/dist/web/layout-editor-body.js +101 -52
  14. package/dist/web/layout-editor.js +14 -7
  15. package/dist/web/layout-html.d.ts +2 -0
  16. package/dist/web/layout-html.js +13 -1
  17. package/dist/web/layout-meta.d.ts +29 -0
  18. package/dist/web/layout-meta.js +32 -0
  19. package/dist/web/layout-rails.d.ts +15 -5
  20. package/dist/web/layout-rails.js +61 -68
  21. package/dist/web/layout-types.d.ts +4 -0
  22. package/dist/web/layout-users.d.ts +1 -0
  23. package/dist/web/layout-users.js +4 -1
  24. package/dist/web/mail-dialogs.d.ts +1 -6
  25. package/dist/web/mail-dialogs.js +2 -2
  26. package/dist/web/mail-draft.d.ts +8 -6
  27. package/dist/web/mail-draft.js +45 -11
  28. package/dist/web/mail-edit-center.d.ts +3 -1
  29. package/dist/web/mail-edit-center.js +5 -4
  30. package/dist/web/mail-editor-body.d.ts +0 -1
  31. package/dist/web/mail-editor-body.js +60 -45
  32. package/dist/web/mail-editor.js +9 -14
  33. package/dist/web/mail-footer.d.ts +3 -14
  34. package/dist/web/mail-footer.js +12 -13
  35. package/dist/web/mail-left-rail.d.ts +9 -4
  36. package/dist/web/mail-left-rail.js +18 -15
  37. package/dist/web/mail-missing-locale.d.ts +10 -0
  38. package/dist/web/mail-missing-locale.js +45 -0
  39. package/dist/web/mail-origin-bar.d.ts +3 -5
  40. package/dist/web/mail-origin-bar.js +26 -50
  41. package/dist/web/mail-preview-side.d.ts +12 -5
  42. package/dist/web/mail-preview-side.js +14 -5
  43. package/dist/web/mail-preview.d.ts +9 -5
  44. package/dist/web/mail-preview.js +24 -13
  45. package/dist/web/mail-right-rail.d.ts +8 -0
  46. package/dist/web/mail-right-rail.js +65 -14
  47. package/dist/web/mail-types.d.ts +10 -1
  48. package/dist/web/mail-upload-modal.js +12 -2
  49. package/dist/web/mail-versions.js +3 -3
  50. package/dist/web/origin-bar.d.ts +11 -2
  51. package/dist/web/origin-bar.js +46 -24
  52. package/dist/web/overrides-drawer.d.ts +3 -2
  53. package/dist/web/overrides-drawer.js +87 -11
  54. package/dist/web/overview-row.d.ts +2 -0
  55. package/dist/web/overview-row.js +2 -1
  56. package/dist/web/page-canvas.d.ts +2 -1
  57. package/dist/web/page-canvas.js +23 -13
  58. package/dist/web/pdf-footer.d.ts +13 -0
  59. package/dist/web/pdf-footer.js +34 -0
  60. package/dist/web/pdf-left-rail.d.ts +9 -0
  61. package/dist/web/pdf-left-rail.js +16 -0
  62. package/dist/web/pdf-meta.d.ts +3 -5
  63. package/dist/web/pdf-meta.js +10 -16
  64. package/dist/web/pdf-preview.js +21 -8
  65. package/dist/web/pdf-template-editor.js +91 -76
  66. package/dist/web/rail.d.ts +13 -1
  67. package/dist/web/rail.js +15 -7
  68. package/dist/web/relative-time.d.ts +1 -0
  69. package/dist/web/relative-time.js +18 -0
  70. package/dist/web/settings-panel.d.ts +11 -5
  71. package/dist/web/settings-panel.js +24 -13
  72. package/dist/web/status-label.d.ts +7 -0
  73. package/dist/web/status-label.js +8 -0
  74. package/dist/web/types.d.ts +23 -0
  75. package/dist/web/use-debounced-value.d.ts +1 -0
  76. package/dist/web/use-debounced-value.js +11 -0
  77. package/dist/web/use-insert-targets.d.ts +2 -2
  78. package/dist/web/use-insert-targets.js +4 -1
  79. package/dist/web/use-mail-persistence.d.ts +2 -2
  80. package/dist/web/use-mail-persistence.js +4 -4
  81. package/dist/web/use-own-address.d.ts +5 -0
  82. package/dist/web/use-own-address.js +14 -0
  83. package/dist/web/used-by-label.d.ts +1 -0
  84. package/dist/web/used-by-label.js +11 -0
  85. package/package.json +8 -8
  86. package/src/changes.json +48 -0
  87. package/dist/web/mail-header.d.ts +0 -23
  88. package/dist/web/mail-header.js +0 -16
  89. package/dist/web/placeholder-panel.d.ts +0 -21
  90. package/dist/web/placeholder-panel.js +0 -35
@@ -1,19 +1,95 @@
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
3
  import { TemplateResolverQueries } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
- import { useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
- import { Drawer } from "@cosmicdrift/kumiko-renderer-web";
6
- import { DOCUMENT_PDF_KIND } from "./types.js";
7
- function BreakdownRow({ label, value }) {
8
- return (_jsxs("li", { className: "flex items-center justify-between gap-4 text-sm", children: [_jsx("span", { children: label }), _jsx("span", { className: "tabular-nums", children: value })] }));
4
+ import { formatWhen, usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import { Drawer, EmptyState, LoadingState } from "@cosmicdrift/kumiko-renderer-web";
6
+ import { useMemo, useState } from "react";
7
+ import { DiffView } from "./origin-bar.js";
8
+ import { DOCUMENT_PDF_KIND, } from "./types.js";
9
+ const DRAWER_WIDTH = 960;
10
+ function TenantDetail({ slug, locale, view, onBack, }) {
11
+ const t = useTranslation();
12
+ const { Button } = usePrimitives();
13
+ const detail = useQuery(TemplateResolverQueries.tenantOverrides, {
14
+ slug,
15
+ kind: DOCUMENT_PDF_KIND,
16
+ locale,
17
+ diffTenantId: view.row.tenantId,
18
+ });
19
+ return (_jsxs("div", { className: "flex flex-col gap-3", "data-testid": "document-designer-overrides-detail", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "m-0 text-sm font-semibold", children: t("documentDesigner.overrides.detail.version", {
20
+ tenant: view.row.tenantName,
21
+ version: view.row.version,
22
+ }) }), _jsx(Button, { variant: "link", onClick: onBack, testId: "document-designer-overrides-back", children: t("documentDesigner.overrides.back") })] }), detail.data?.diff == null ? (_jsx(LoadingState, {})) : (_jsx(DiffView, { data: detail.data.diff, mode: view.mode }))] }));
9
23
  }
10
- export function OverridesDrawer({ open, onOpenChange, slug, overrides, }) {
24
+ export function OverridesDrawer({ open, onOpenChange, slug, locale, overrides, }) {
11
25
  const t = useTranslation();
26
+ const { DataTable } = usePrimitives();
27
+ const [view, setView] = useState(null);
12
28
  // Only asked while the drawer is open; the answer is not needed for the editor itself.
13
29
  const usage = useQuery(TemplateResolverQueries.usage, { slug, kind: DOCUMENT_PDF_KIND }, { enabled: open });
14
- const active = overrides?.activeOverrides ?? 0;
15
- const draft = overrides?.draftOverrides ?? 0;
16
- const total = overrides?.totalTenants ?? null;
17
30
  const usedBy = usage.data?.usedBy ?? [];
18
- return (_jsx(Drawer, { open: open, onOpenChange: onOpenChange, title: t("documentDesigner.overrides.title"), testId: "document-designer-overrides-drawer", children: _jsxs("div", { className: "flex flex-col gap-6", children: [_jsx("section", { className: "flex flex-col gap-2", children: _jsxs("ul", { className: "m-0 flex list-none flex-col gap-1 p-0", children: [_jsx(BreakdownRow, { label: t("documentDesigner.overrides.active"), value: active }), _jsx(BreakdownRow, { label: t("documentDesigner.overrides.draftOnly"), value: draft }), total !== null && (_jsx(BreakdownRow, { label: t("documentDesigner.overrides.none"), value: Math.max(0, total - active - draft) })), total !== null && (_jsx(BreakdownRow, { label: t("documentDesigner.overrides.total"), value: total }))] }) }), _jsxs("section", { className: "flex flex-col gap-2", children: [_jsx("h3", { className: "m-0 text-sm font-semibold", children: t("documentDesigner.overrides.usedBy") }), usedBy.length === 0 ? (_jsx("p", { className: "m-0 text-sm text-muted-foreground", children: t("documentDesigner.overrides.usedByEmpty") })) : (_jsx("ul", { className: "m-0 flex list-none flex-col gap-1 p-0 font-mono text-xs", children: usedBy.map((entry) => (_jsx("li", { children: entry.qualifiedName }, `${entry.qualifiedName}:${entry.role}`))) }))] })] }) }));
31
+ const tenantRows = overrides?.rows ?? [];
32
+ const columns = useMemo(() => [
33
+ {
34
+ field: "tenantName",
35
+ label: t("documentDesigner.overrides.column.tenant"),
36
+ type: "text",
37
+ sortable: false,
38
+ },
39
+ {
40
+ field: "status",
41
+ label: t("documentDesigner.overrides.column.status"),
42
+ type: "select",
43
+ sortable: false,
44
+ optionLabels: {
45
+ active: t("documentDesigner.overrides.status.active"),
46
+ draft: t("documentDesigner.overrides.status.draft"),
47
+ },
48
+ optionTones: { active: "ok", draft: "warn" },
49
+ },
50
+ {
51
+ field: "versionLabel",
52
+ label: t("documentDesigner.overrides.column.version"),
53
+ type: "text",
54
+ sortable: false,
55
+ },
56
+ {
57
+ field: "changed",
58
+ label: t("documentDesigner.overrides.column.changed"),
59
+ type: "text",
60
+ sortable: false,
61
+ },
62
+ ], [t]);
63
+ const rows = useMemo(() => tenantRows.map((row) => ({
64
+ id: row.tenantId,
65
+ values: {
66
+ tenantName: row.tenantName,
67
+ status: row.status,
68
+ versionLabel: `v${row.version}`,
69
+ changed: formatWhen(row.changedAt),
70
+ },
71
+ })), [tenantRows]);
72
+ const openRow = (row, mode) => {
73
+ const found = tenantRows.find((candidate) => candidate.tenantId === row.id);
74
+ if (found !== undefined)
75
+ setView({ row: found, mode });
76
+ };
77
+ return (_jsx(Drawer, { open: open, onOpenChange: (next) => {
78
+ if (!next)
79
+ setView(null);
80
+ onOpenChange(next);
81
+ }, title: t("documentDesigner.overrides.title"), width: DRAWER_WIDTH, testId: "document-designer-overrides-drawer", children: _jsx("div", { className: "flex flex-col gap-5", children: view !== null ? (_jsx(TenantDetail, { slug: slug, locale: locale, view: view, onBack: () => setView(null) })) : (_jsxs(_Fragment, { children: [tenantRows.length > 0 && (_jsx("p", { className: "m-0 text-sm text-muted-foreground", "data-testid": "overrides-summary", children: t("documentDesigner.overrides.summary", { count: tenantRows.length }) })), tenantRows.length === 0 ? (_jsx(EmptyState, { title: t("documentDesigner.overrides.empty") })) : (_jsx(DataTable, { columns: columns, rows: rows, rowActionMode: "inline", rowActions: [
82
+ {
83
+ id: "diff",
84
+ label: t("documentDesigner.overrides.action.diff"),
85
+ style: "secondary",
86
+ onTrigger: (row) => openRow(row, "diff"),
87
+ },
88
+ {
89
+ id: "open",
90
+ label: t("documentDesigner.overrides.action.open"),
91
+ style: "secondary",
92
+ onTrigger: (row) => openRow(row, "full"),
93
+ },
94
+ ], testId: "document-designer-overrides-table" })), _jsxs("section", { className: "flex flex-col gap-2", children: [_jsx("h3", { className: "m-0 text-sm font-semibold", children: t("documentDesigner.overrides.usedBy") }), usedBy.length === 0 ? (_jsx("p", { className: "m-0 text-sm text-muted-foreground", children: t("documentDesigner.overrides.usedByEmpty") })) : (_jsx("ul", { className: "m-0 flex list-none flex-col gap-1 p-0 font-mono text-xs", children: usedBy.map((entry) => (_jsx("li", { children: entry.qualifiedName }, `${entry.qualifiedName}:${entry.role}`))) }))] })] })) }) }));
19
95
  }
@@ -13,6 +13,8 @@ export type OverviewRowView = {
13
13
  readonly locales: readonly string[];
14
14
  readonly status: "draft" | "active" | "archived";
15
15
  readonly version: number;
16
+ /** An open draft beside the served version, never another tenant's. */
17
+ readonly draftVersion: number | null;
16
18
  readonly systemVersion: number | null;
17
19
  readonly modifiedAt: string;
18
20
  readonly usedBy: readonly OverviewUsageView[];
@@ -21,7 +21,7 @@ function usageViewOf(value) {
21
21
  const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
22
22
  const numberOr = (value, fallback) => typeof value === "number" ? value : fallback;
23
23
  export function parseOverviewRow(row) {
24
- const { id, title, layoutSlug, slug, kind, origin, locales, status, version, systemVersion, modifiedAt, usedBy, usedByCount, } = row;
24
+ const { id, title, layoutSlug, slug, kind, origin, locales, status, version, draftVersion, systemVersion, modifiedAt, usedBy, usedByCount, } = row;
25
25
  if (typeof slug !== "string" || !isTemplateKind(kind) || !isStatus(status))
26
26
  return null;
27
27
  if (origin !== "tenant" && origin !== "system")
@@ -39,6 +39,7 @@ export function parseOverviewRow(row) {
39
39
  : [],
40
40
  status,
41
41
  version: numberOr(version, 0),
42
+ draftVersion: numberOr(draftVersion, null),
42
43
  systemVersion: numberOr(systemVersion, null),
43
44
  modifiedAt: stringOr(modifiedAt, ""),
44
45
  usedBy: usages,
@@ -13,6 +13,7 @@ export type PageCanvasProps = {
13
13
  readonly systemLabels: Readonly<Record<string, string>>;
14
14
  readonly variableNames: readonly string[];
15
15
  readonly resourcePreviews: Readonly<Record<string, DocumentResourcePreview>>;
16
- readonly onEditorReady: (handle: DocumentEditorHandle) => void;
16
+ readonly onEditorReady: (handle: DocumentEditorHandle | null) => void;
17
+ readonly resourceSrc?: (key: string) => string | undefined;
17
18
  };
18
19
  export declare function PageCanvas(props: PageCanvasProps): ReactNode;
@@ -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) {