@cosmicdrift/kumiko-document-designer 0.0.0-stage → 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.
- package/LICENSE +57 -0
- package/README.md +37 -2
- package/dist/constants.d.ts +27 -0
- package/dist/constants.js +37 -0
- package/dist/feature.d.ts +2 -0
- package/dist/feature.js +85 -0
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +764 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/list-definition.d.ts +6 -0
- package/dist/list-definition.js +109 -0
- package/dist/web/cells.d.ts +10 -0
- package/dist/web/cells.js +87 -0
- package/dist/web/client-plugin.d.ts +12 -0
- package/dist/web/client-plugin.js +43 -0
- package/dist/web/compose-layout.d.ts +6 -0
- package/dist/web/compose-layout.js +13 -0
- package/dist/web/editor-header.d.ts +37 -0
- package/dist/web/editor-header.js +43 -0
- package/dist/web/editor-shell.d.ts +40 -0
- package/dist/web/editor-shell.js +36 -0
- package/dist/web/errors.d.ts +6 -0
- package/dist/web/errors.js +24 -0
- package/dist/web/field-issue-text.d.ts +4 -0
- package/dist/web/field-issue-text.js +12 -0
- package/dist/web/file-name.d.ts +2 -0
- package/dist/web/file-name.js +17 -0
- package/dist/web/i18n.d.ts +2 -0
- package/dist/web/i18n.js +146 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +7 -0
- package/dist/web/layout-editor-body.d.ts +14 -0
- package/dist/web/layout-editor-body.js +187 -0
- package/dist/web/layout-editor.d.ts +2 -0
- package/dist/web/layout-editor.js +43 -0
- package/dist/web/layout-html.d.ts +9 -0
- package/dist/web/layout-html.js +93 -0
- package/dist/web/layout-meta.d.ts +29 -0
- package/dist/web/layout-meta.js +32 -0
- package/dist/web/layout-rails.d.ts +28 -0
- package/dist/web/layout-rails.js +100 -0
- package/dist/web/layout-types.d.ts +66 -0
- package/dist/web/layout-types.js +1 -0
- package/dist/web/layout-users.d.ts +4 -0
- package/dist/web/layout-users.js +11 -0
- package/dist/web/mail-dialogs.d.ts +24 -0
- package/dist/web/mail-dialogs.js +15 -0
- package/dist/web/mail-draft.d.ts +19 -0
- package/dist/web/mail-draft.js +154 -0
- package/dist/web/mail-edit-center.d.ts +19 -0
- package/dist/web/mail-edit-center.js +12 -0
- package/dist/web/mail-editor-body.d.ts +17 -0
- package/dist/web/mail-editor-body.js +209 -0
- package/dist/web/mail-editor.d.ts +2 -0
- package/dist/web/mail-editor.js +68 -0
- package/dist/web/mail-footer.d.ts +10 -0
- package/dist/web/mail-footer.js +43 -0
- package/dist/web/mail-left-rail.d.ts +23 -0
- package/dist/web/mail-left-rail.js +56 -0
- package/dist/web/mail-missing-locale.d.ts +10 -0
- package/dist/web/mail-missing-locale.js +45 -0
- package/dist/web/mail-origin-bar.d.ts +15 -0
- package/dist/web/mail-origin-bar.js +60 -0
- package/dist/web/mail-pdf-link-modal.d.ts +11 -0
- package/dist/web/mail-pdf-link-modal.js +54 -0
- package/dist/web/mail-preview-side.d.ts +19 -0
- package/dist/web/mail-preview-side.js +57 -0
- package/dist/web/mail-preview.d.ts +24 -0
- package/dist/web/mail-preview.js +54 -0
- package/dist/web/mail-right-rail.d.ts +19 -0
- package/dist/web/mail-right-rail.js +57 -0
- package/dist/web/mail-types.d.ts +117 -0
- package/dist/web/mail-types.js +1 -0
- package/dist/web/mail-upload-modal.d.ts +16 -0
- package/dist/web/mail-upload-modal.js +56 -0
- package/dist/web/mail-versions.d.ts +6 -0
- package/dist/web/mail-versions.js +43 -0
- package/dist/web/origin-bar.d.ts +22 -0
- package/dist/web/origin-bar.js +104 -0
- package/dist/web/overrides-drawer.d.ts +10 -0
- package/dist/web/overrides-drawer.js +95 -0
- package/dist/web/overview-row.d.ts +23 -0
- package/dist/web/overview-row.js +48 -0
- package/dist/web/page-canvas.d.ts +19 -0
- package/dist/web/page-canvas.js +48 -0
- package/dist/web/pdf-footer.d.ts +13 -0
- package/dist/web/pdf-footer.js +34 -0
- package/dist/web/pdf-left-rail.d.ts +9 -0
- package/dist/web/pdf-left-rail.js +16 -0
- package/dist/web/pdf-meta.d.ts +21 -0
- package/dist/web/pdf-meta.js +95 -0
- package/dist/web/pdf-preview.d.ts +4 -0
- package/dist/web/pdf-preview.js +112 -0
- package/dist/web/pdf-template-editor.d.ts +8 -0
- package/dist/web/pdf-template-editor.js +340 -0
- package/dist/web/preview-record-picker.d.ts +12 -0
- package/dist/web/preview-record-picker.js +7 -0
- package/dist/web/rail.d.ts +30 -0
- package/dist/web/rail.js +22 -0
- package/dist/web/relative-time.d.ts +1 -0
- package/dist/web/relative-time.js +18 -0
- package/dist/web/settings-panel.d.ts +23 -0
- package/dist/web/settings-panel.js +36 -0
- package/dist/web/status-label.d.ts +14 -0
- package/dist/web/status-label.js +26 -0
- package/dist/web/template-rows.d.ts +9 -0
- package/dist/web/template-rows.js +34 -0
- package/dist/web/types.d.ts +126 -0
- package/dist/web/types.js +3 -0
- package/dist/web/use-insert-targets.d.ts +9 -0
- package/dist/web/use-insert-targets.js +17 -0
- package/dist/web/use-mail-persistence.d.ts +14 -0
- package/dist/web/use-mail-persistence.js +49 -0
- package/dist/web/use-overview-rows.d.ts +7 -0
- package/dist/web/use-overview-rows.js +19 -0
- package/dist/web/use-own-address.d.ts +5 -0
- package/dist/web/use-own-address.js +14 -0
- package/dist/web/use-settled-value.d.ts +2 -0
- package/dist/web/use-settled-value.js +18 -0
- package/dist/web/use-test-mail.d.ts +15 -0
- package/dist/web/use-test-mail.js +27 -0
- package/dist/web/used-by-label.d.ts +9 -0
- package/dist/web/used-by-label.js +32 -0
- package/package.json +63 -4
- package/src/changes.json +44 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { TemplateResolverQueries } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
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 }))] }));
|
|
23
|
+
}
|
|
24
|
+
export function OverridesDrawer({ open, onOpenChange, slug, locale, overrides, }) {
|
|
25
|
+
const t = useTranslation();
|
|
26
|
+
const { DataTable } = usePrimitives();
|
|
27
|
+
const [view, setView] = useState(null);
|
|
28
|
+
// Only asked while the drawer is open; the answer is not needed for the editor itself.
|
|
29
|
+
const usage = useQuery(TemplateResolverQueries.usage, { slug, kind: DOCUMENT_PDF_KIND }, { enabled: open });
|
|
30
|
+
const usedBy = usage.data?.usedBy ?? [];
|
|
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}`))) }))] })] })) }) }));
|
|
95
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TemplateEditorKind } from "../constants.js";
|
|
2
|
+
export type OverviewUsageView = {
|
|
3
|
+
readonly feature: string;
|
|
4
|
+
readonly notification: string;
|
|
5
|
+
};
|
|
6
|
+
export type OverviewRowView = {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly layoutSlug: string | null;
|
|
10
|
+
readonly slug: string;
|
|
11
|
+
readonly kind: TemplateEditorKind;
|
|
12
|
+
readonly origin: "tenant" | "system";
|
|
13
|
+
readonly locales: readonly string[];
|
|
14
|
+
readonly status: "draft" | "active" | "archived";
|
|
15
|
+
readonly version: number;
|
|
16
|
+
/** An open draft beside the served version, never another tenant's. */
|
|
17
|
+
readonly draftVersion: number | null;
|
|
18
|
+
readonly systemVersion: number | null;
|
|
19
|
+
readonly modifiedAt: string;
|
|
20
|
+
readonly usedBy: readonly OverviewUsageView[];
|
|
21
|
+
readonly usedByCount: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function parseOverviewRow(row: Readonly<Record<string, unknown>>): OverviewRowView | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function isTemplateKind(value) {
|
|
5
|
+
return (value === "mail" ||
|
|
6
|
+
value === "document-pdf" ||
|
|
7
|
+
value === "mail-layout" ||
|
|
8
|
+
value === "pdf-layout");
|
|
9
|
+
}
|
|
10
|
+
function isStatus(value) {
|
|
11
|
+
return value === "draft" || value === "active" || value === "archived";
|
|
12
|
+
}
|
|
13
|
+
function usageViewOf(value) {
|
|
14
|
+
if (!isRecord(value))
|
|
15
|
+
return null;
|
|
16
|
+
const { feature, notification } = value;
|
|
17
|
+
return typeof feature === "string" && typeof notification === "string"
|
|
18
|
+
? { feature, notification }
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
|
|
22
|
+
const numberOr = (value, fallback) => typeof value === "number" ? value : fallback;
|
|
23
|
+
export function parseOverviewRow(row) {
|
|
24
|
+
const { id, title, layoutSlug, slug, kind, origin, locales, status, version, draftVersion, systemVersion, modifiedAt, usedBy, usedByCount, } = row;
|
|
25
|
+
if (typeof slug !== "string" || !isTemplateKind(kind) || !isStatus(status))
|
|
26
|
+
return null;
|
|
27
|
+
if (origin !== "tenant" && origin !== "system")
|
|
28
|
+
return null;
|
|
29
|
+
const usages = Array.isArray(usedBy) ? usedBy.flatMap((u) => usageViewOf(u) ?? []) : [];
|
|
30
|
+
return {
|
|
31
|
+
id: stringOr(id, `${kind}|${slug}`),
|
|
32
|
+
title: stringOr(title, slug),
|
|
33
|
+
layoutSlug: typeof layoutSlug === "string" ? layoutSlug : null,
|
|
34
|
+
slug,
|
|
35
|
+
kind,
|
|
36
|
+
origin,
|
|
37
|
+
locales: Array.isArray(locales)
|
|
38
|
+
? locales.filter((l) => typeof l === "string")
|
|
39
|
+
: [],
|
|
40
|
+
status,
|
|
41
|
+
version: numberOr(version, 0),
|
|
42
|
+
draftVersion: numberOr(draftVersion, null),
|
|
43
|
+
systemVersion: numberOr(systemVersion, null),
|
|
44
|
+
modifiedAt: stringOr(modifiedAt, ""),
|
|
45
|
+
usedBy: usages,
|
|
46
|
+
usedByCount: numberOr(usedByCount, usages.length),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type DocumentEditorHandle, type DocumentResourcePreview } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { type PdfMetaDraft } from "./pdf-meta.js";
|
|
4
|
+
import type { LayoutRegionsHtml } from "./types.js";
|
|
5
|
+
export type PageCanvasProps = {
|
|
6
|
+
readonly meta: PdfMetaDraft;
|
|
7
|
+
readonly content: string;
|
|
8
|
+
readonly onContentChange: (value: string) => void;
|
|
9
|
+
readonly regions: LayoutRegionsHtml | null;
|
|
10
|
+
readonly layoutName: string;
|
|
11
|
+
readonly variableLabels: Readonly<Record<string, string>>;
|
|
12
|
+
readonly systemVariables: readonly string[];
|
|
13
|
+
readonly systemLabels: Readonly<Record<string, string>>;
|
|
14
|
+
readonly variableNames: readonly string[];
|
|
15
|
+
readonly resourcePreviews: Readonly<Record<string, DocumentResourcePreview>>;
|
|
16
|
+
readonly onEditorReady: (handle: DocumentEditorHandle) => void;
|
|
17
|
+
readonly resourceSrc?: (key: string) => string | undefined;
|
|
18
|
+
};
|
|
19
|
+
export declare function PageCanvas(props: PageCanvasProps): ReactNode;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { DocumentContentEditor, PDF_DEFAULT_MARGIN_MM, PDF_DOCUMENT_BLOCKS, PDF_FOOTER_MARGIN_MM, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
4
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { useCallback } from "react";
|
|
7
|
+
import { LayoutHtml } from "./layout-html.js";
|
|
8
|
+
import { MARGIN_SIDES } from "./pdf-meta.js";
|
|
9
|
+
const MM_TO_PX = 96 / 25.4;
|
|
10
|
+
// Page width in CSS px at 96 dpi as a cap; the page shrinks with a narrow column instead of scrolling sideways.
|
|
11
|
+
const PAGE_WIDTH_CLASSES = {
|
|
12
|
+
"A4-portrait": "max-w-[794px]",
|
|
13
|
+
"A4-landscape": "max-w-[1123px]",
|
|
14
|
+
"Letter-portrait": "max-w-[816px]",
|
|
15
|
+
"Letter-landscape": "max-w-[1056px]",
|
|
16
|
+
};
|
|
17
|
+
function RegionBlock({ html, systemLabels, resourceSrc, caption, }) {
|
|
18
|
+
if (html.trim() === "")
|
|
19
|
+
return null;
|
|
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 }) })] }));
|
|
25
|
+
}
|
|
26
|
+
export function PageCanvas(props) {
|
|
27
|
+
const t = useTranslation();
|
|
28
|
+
const { meta } = props;
|
|
29
|
+
const showRegions = meta.layoutHeaderFooter && props.regions !== null;
|
|
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}`;
|
|
36
|
+
// CSS variables instead of a style prop: margins are user data, classes are static.
|
|
37
|
+
const applyMargins = useCallback((element) => {
|
|
38
|
+
if (element === null)
|
|
39
|
+
return;
|
|
40
|
+
for (const side of MARGIN_SIDES) {
|
|
41
|
+
const fallbackMm = side === "bottom" && hasFooter ? PDF_FOOTER_MARGIN_MM : PDF_DEFAULT_MARGIN_MM;
|
|
42
|
+
const mm = meta.margins[side] === "" ? fallbackMm : meta.margins[side];
|
|
43
|
+
element.style.setProperty(`--page-margin-${side}`, `${Math.round(mm * MM_TO_PX)}px`);
|
|
44
|
+
}
|
|
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 }) }));
|
|
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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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];
|
|
4
|
+
export type MarginSide = "top" | "right" | "bottom" | "left";
|
|
5
|
+
export declare const MARGIN_SIDES: readonly ["top", "right", "bottom", "left"];
|
|
6
|
+
export type PdfMetaDraft = {
|
|
7
|
+
readonly layoutSlug: string;
|
|
8
|
+
readonly pageFormat: PageFormat;
|
|
9
|
+
readonly orientation: Orientation;
|
|
10
|
+
readonly printBackground: boolean;
|
|
11
|
+
readonly layoutHeaderFooter: boolean;
|
|
12
|
+
readonly margins: Readonly<Record<MarginSide, number | "">>;
|
|
13
|
+
readonly fileName: string;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly author: string;
|
|
16
|
+
};
|
|
17
|
+
export declare const EMPTY_PDF_META: PdfMetaDraft;
|
|
18
|
+
/** Reads the stored meta JSON string; unknown or malformed parts fall back to the defaults. */
|
|
19
|
+
export declare function parsePdfMeta(raw: string | null): PdfMetaDraft;
|
|
20
|
+
/** Builds the meta object for the server; defaults and empty fields are left out. */
|
|
21
|
+
export declare function serializePdfMeta(draft: PdfMetaDraft): Record<string, unknown>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { PDF_ORIENTATIONS, PDF_PAGE_FORMATS, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
3
|
+
import { isOneOf, isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
export const MARGIN_SIDES = [
|
|
5
|
+
"top",
|
|
6
|
+
"right",
|
|
7
|
+
"bottom",
|
|
8
|
+
"left",
|
|
9
|
+
];
|
|
10
|
+
export const EMPTY_PDF_META = {
|
|
11
|
+
layoutSlug: "",
|
|
12
|
+
pageFormat: "A4",
|
|
13
|
+
orientation: "portrait",
|
|
14
|
+
printBackground: true,
|
|
15
|
+
layoutHeaderFooter: true,
|
|
16
|
+
margins: { top: "", right: "", bottom: "", left: "" },
|
|
17
|
+
fileName: "",
|
|
18
|
+
title: "",
|
|
19
|
+
author: "",
|
|
20
|
+
};
|
|
21
|
+
function stringOr(value, fallback) {
|
|
22
|
+
return typeof value === "string" ? value : fallback;
|
|
23
|
+
}
|
|
24
|
+
function booleanOr(value, fallback) {
|
|
25
|
+
return typeof value === "boolean" ? value : fallback;
|
|
26
|
+
}
|
|
27
|
+
function marginOf(margins, side) {
|
|
28
|
+
const value = propertyOf(margins, side);
|
|
29
|
+
return typeof value === "number" ? value : "";
|
|
30
|
+
}
|
|
31
|
+
/** Reads the stored meta JSON string; unknown or malformed parts fall back to the defaults. */
|
|
32
|
+
export function parsePdfMeta(raw) {
|
|
33
|
+
if (raw === null)
|
|
34
|
+
return EMPTY_PDF_META;
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return EMPTY_PDF_META;
|
|
41
|
+
}
|
|
42
|
+
if (!isPlainObject(parsed))
|
|
43
|
+
return EMPTY_PDF_META;
|
|
44
|
+
const pageFormat = propertyOf(parsed, "pageFormat");
|
|
45
|
+
const orientation = propertyOf(parsed, "orientation");
|
|
46
|
+
const margins = propertyOf(parsed, "margins");
|
|
47
|
+
const metadata = propertyOf(parsed, "metadata");
|
|
48
|
+
return {
|
|
49
|
+
layoutSlug: stringOr(parsed["layoutSlug"], ""),
|
|
50
|
+
pageFormat: isOneOf(PDF_PAGE_FORMATS, pageFormat) ? pageFormat : EMPTY_PDF_META.pageFormat,
|
|
51
|
+
orientation: isOneOf(PDF_ORIENTATIONS, orientation) ? orientation : EMPTY_PDF_META.orientation,
|
|
52
|
+
printBackground: booleanOr(parsed["printBackground"], true),
|
|
53
|
+
layoutHeaderFooter: booleanOr(parsed["layoutHeaderFooter"], true),
|
|
54
|
+
margins: {
|
|
55
|
+
top: marginOf(margins, "top"),
|
|
56
|
+
right: marginOf(margins, "right"),
|
|
57
|
+
bottom: marginOf(margins, "bottom"),
|
|
58
|
+
left: marginOf(margins, "left"),
|
|
59
|
+
},
|
|
60
|
+
fileName: stringOr(parsed["fileName"], ""),
|
|
61
|
+
title: stringOr(propertyOf(metadata, "title"), ""),
|
|
62
|
+
author: stringOr(propertyOf(metadata, "author"), ""),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Builds the meta object for the server; defaults and empty fields are left out. */
|
|
66
|
+
export function serializePdfMeta(draft) {
|
|
67
|
+
const meta = {};
|
|
68
|
+
if (draft.layoutSlug !== "")
|
|
69
|
+
meta["layoutSlug"] = draft.layoutSlug;
|
|
70
|
+
if (draft.pageFormat !== "A4")
|
|
71
|
+
meta["pageFormat"] = draft.pageFormat;
|
|
72
|
+
if (draft.orientation !== "portrait")
|
|
73
|
+
meta["orientation"] = draft.orientation;
|
|
74
|
+
if (!draft.printBackground)
|
|
75
|
+
meta["printBackground"] = false;
|
|
76
|
+
if (!draft.layoutHeaderFooter)
|
|
77
|
+
meta["layoutHeaderFooter"] = false;
|
|
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);
|
|
85
|
+
if (draft.fileName.trim() !== "")
|
|
86
|
+
meta["fileName"] = draft.fileName.trim();
|
|
87
|
+
const metadata = {};
|
|
88
|
+
if (draft.title.trim() !== "")
|
|
89
|
+
metadata["title"] = draft.title.trim();
|
|
90
|
+
if (draft.author.trim() !== "")
|
|
91
|
+
metadata["author"] = draft.author.trim();
|
|
92
|
+
if (Object.keys(metadata).length > 0)
|
|
93
|
+
meta["metadata"] = metadata;
|
|
94
|
+
return meta;
|
|
95
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
/// <reference path="./pdfjs-worker.d.ts" />
|
|
4
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { Suspense, use, useCallback, useMemo } from "react";
|
|
6
|
+
// pdf.js runs in the main thread ("fake worker"): the worker module is bundled
|
|
7
|
+
// with the app and handed over through globalThis.pdfjsWorker, so no worker or
|
|
8
|
+
// CDN URL is ever fetched (self-hosting and CSP stay intact).
|
|
9
|
+
const RENDER_SCALE = 96 / 72;
|
|
10
|
+
let cachedKey = null;
|
|
11
|
+
let cachedLoad = null;
|
|
12
|
+
function decodeBase64(base64) {
|
|
13
|
+
const binary = atob(base64);
|
|
14
|
+
const bytes = new Uint8Array(binary.length);
|
|
15
|
+
for (let index = 0; index < binary.length; index++)
|
|
16
|
+
bytes[index] = binary.charCodeAt(index);
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
async function loadDocument(pdfBase64) {
|
|
20
|
+
try {
|
|
21
|
+
const [pdfjs, worker] = await Promise.all([
|
|
22
|
+
import("pdfjs-dist"),
|
|
23
|
+
import("pdfjs-dist/build/pdf.worker.min.mjs"),
|
|
24
|
+
]);
|
|
25
|
+
Object.assign(globalThis, { pdfjsWorker: worker });
|
|
26
|
+
const loadingTask = pdfjs.getDocument({ data: decodeBase64(pdfBase64) });
|
|
27
|
+
return { ok: true, document: await loadingTask.promise, loadingTask };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { ok: false };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// The promise must be stable across renders for use(); only the latest PDF is kept.
|
|
34
|
+
function documentFor(pdfBase64) {
|
|
35
|
+
if (cachedKey !== pdfBase64 || cachedLoad === null) {
|
|
36
|
+
const previous = cachedLoad;
|
|
37
|
+
cachedKey = pdfBase64;
|
|
38
|
+
cachedLoad = loadDocument(pdfBase64);
|
|
39
|
+
void previous?.then((loaded) => {
|
|
40
|
+
if (loaded.ok)
|
|
41
|
+
void loaded.loadingTask.destroy();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return cachedLoad;
|
|
45
|
+
}
|
|
46
|
+
function PageView({ document, pageNumber, }) {
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
const page = use(pageFor(document, pageNumber));
|
|
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]);
|
|
51
|
+
const mountCanvas = useCallback((canvas) => {
|
|
52
|
+
if (canvas === null)
|
|
53
|
+
return;
|
|
54
|
+
const task = page.render({ canvas, viewport });
|
|
55
|
+
task.promise.catch(() => undefined);
|
|
56
|
+
return () => task.cancel();
|
|
57
|
+
}, [page, viewport]);
|
|
58
|
+
const mountTextLayer = useCallback((container) => {
|
|
59
|
+
if (container === null)
|
|
60
|
+
return;
|
|
61
|
+
container.style.setProperty("--total-scale-factor", String(RENDER_SCALE));
|
|
62
|
+
container.style.setProperty("--scale-factor", String(RENDER_SCALE));
|
|
63
|
+
let layer = null;
|
|
64
|
+
let cancelled = false;
|
|
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();
|
|
71
|
+
const textLayer = new TextLayer({
|
|
72
|
+
textContentSource: page.streamTextContent(),
|
|
73
|
+
container,
|
|
74
|
+
viewport,
|
|
75
|
+
});
|
|
76
|
+
layer = textLayer;
|
|
77
|
+
return textLayer.render().catch(() => undefined);
|
|
78
|
+
});
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
layer?.cancel();
|
|
82
|
+
};
|
|
83
|
+
}, [page, viewport]);
|
|
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 }) })] }));
|
|
85
|
+
}
|
|
86
|
+
const pageCache = new WeakMap();
|
|
87
|
+
function pageFor(document, pageNumber) {
|
|
88
|
+
let pages = pageCache.get(document);
|
|
89
|
+
if (pages === undefined) {
|
|
90
|
+
pages = new Map();
|
|
91
|
+
pageCache.set(document, pages);
|
|
92
|
+
}
|
|
93
|
+
let page = pages.get(pageNumber);
|
|
94
|
+
if (page === undefined) {
|
|
95
|
+
page = document.getPage(pageNumber);
|
|
96
|
+
pages.set(pageNumber, page);
|
|
97
|
+
}
|
|
98
|
+
return page;
|
|
99
|
+
}
|
|
100
|
+
function Pages({ pdfBase64 }) {
|
|
101
|
+
const t = useTranslation();
|
|
102
|
+
const loaded = use(documentFor(pdfBase64));
|
|
103
|
+
if (!loaded.ok) {
|
|
104
|
+
return _jsx("p", { className: "text-sm text-status-bad", children: t("documentDesigner.preview.renderFailed") });
|
|
105
|
+
}
|
|
106
|
+
const pageNumbers = Array.from({ length: loaded.document.numPages }, (_, index) => index + 1);
|
|
107
|
+
return (_jsx(_Fragment, { children: pageNumbers.map((pageNumber) => (_jsx(Suspense, { fallback: null, children: _jsx(PageView, { document: loaded.document, pageNumber: pageNumber }) }, pageNumber))) }));
|
|
108
|
+
}
|
|
109
|
+
export function PdfPreview({ pdfBase64 }) {
|
|
110
|
+
const t = useTranslation();
|
|
111
|
+
return (_jsx("div", { className: "flex flex-col items-center gap-6 overflow-auto bg-muted p-6", "data-testid": "document-designer-pdf-preview", children: _jsx(Suspense, { fallback: _jsx("p", { className: "text-sm text-muted-foreground", children: t("documentDesigner.preview.loading") }), children: _jsx(Pages, { pdfBase64: pdfBase64 }) }) }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type PdfTemplateEditorProps = {
|
|
3
|
+
readonly slug: string;
|
|
4
|
+
/** Template locale; defaults to "de", or the first locale the slug has. */
|
|
5
|
+
readonly locale?: string;
|
|
6
|
+
readonly onLocaleChange?: (locale: string) => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChange, }: PdfTemplateEditorProps): ReactNode;
|