@cosmicdrift/kumiko-document-designer 0.0.0-stage → 0.363.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 +24 -0
- package/dist/constants.js +33 -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 +670 -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/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 +170 -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 +142 -0
- package/dist/web/layout-editor.d.ts +2 -0
- package/dist/web/layout-editor.js +41 -0
- package/dist/web/layout-html.d.ts +7 -0
- package/dist/web/layout-html.js +81 -0
- package/dist/web/layout-rails.d.ts +18 -0
- package/dist/web/layout-rails.js +107 -0
- package/dist/web/layout-types.d.ts +62 -0
- package/dist/web/layout-types.js +1 -0
- package/dist/web/layout-users.d.ts +3 -0
- package/dist/web/layout-users.js +8 -0
- package/dist/web/mail-dialogs.d.ts +29 -0
- package/dist/web/mail-dialogs.js +15 -0
- package/dist/web/mail-draft.d.ts +17 -0
- package/dist/web/mail-draft.js +132 -0
- package/dist/web/mail-edit-center.d.ts +17 -0
- package/dist/web/mail-edit-center.js +11 -0
- package/dist/web/mail-editor-body.d.ts +18 -0
- package/dist/web/mail-editor-body.js +193 -0
- package/dist/web/mail-editor.d.ts +2 -0
- package/dist/web/mail-editor.js +71 -0
- package/dist/web/mail-footer.d.ts +21 -0
- package/dist/web/mail-footer.js +44 -0
- package/dist/web/mail-header.d.ts +23 -0
- package/dist/web/mail-header.js +16 -0
- package/dist/web/mail-left-rail.d.ts +18 -0
- package/dist/web/mail-left-rail.js +53 -0
- package/dist/web/mail-origin-bar.d.ts +17 -0
- package/dist/web/mail-origin-bar.js +84 -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 +12 -0
- package/dist/web/mail-preview-side.js +48 -0
- package/dist/web/mail-preview.d.ts +20 -0
- package/dist/web/mail-preview.js +43 -0
- package/dist/web/mail-right-rail.d.ts +19 -0
- package/dist/web/mail-right-rail.js +47 -0
- package/dist/web/mail-types.d.ts +112 -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 +13 -0
- package/dist/web/origin-bar.js +82 -0
- package/dist/web/overrides-drawer.d.ts +9 -0
- package/dist/web/overrides-drawer.js +19 -0
- package/dist/web/overview-row.d.ts +21 -0
- package/dist/web/overview-row.js +47 -0
- package/dist/web/page-canvas.d.ts +18 -0
- package/dist/web/page-canvas.js +38 -0
- package/dist/web/pdf-meta.d.ts +23 -0
- package/dist/web/pdf-meta.js +101 -0
- package/dist/web/pdf-preview.d.ts +4 -0
- package/dist/web/pdf-preview.js +99 -0
- package/dist/web/pdf-template-editor.d.ts +8 -0
- package/dist/web/pdf-template-editor.js +326 -0
- package/dist/web/placeholder-panel.d.ts +21 -0
- package/dist/web/placeholder-panel.js +35 -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 +18 -0
- package/dist/web/rail.js +14 -0
- package/dist/web/settings-panel.d.ts +17 -0
- package/dist/web/settings-panel.js +25 -0
- package/dist/web/status-label.d.ts +7 -0
- package/dist/web/status-label.js +18 -0
- package/dist/web/template-rows.d.ts +9 -0
- package/dist/web/template-rows.js +34 -0
- package/dist/web/types.d.ts +103 -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 +15 -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-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 +20 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
4
|
+
import { useMutation, usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { EmptyState, LoadingState } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
const DIFF_MARK = {
|
|
8
|
+
same: " ",
|
|
9
|
+
added: "+",
|
|
10
|
+
removed: "-",
|
|
11
|
+
};
|
|
12
|
+
const DIFF_TONE = {
|
|
13
|
+
same: "text-muted-foreground",
|
|
14
|
+
added: "bg-status-ok-surface text-status-ok",
|
|
15
|
+
removed: "bg-status-bad-surface text-status-bad",
|
|
16
|
+
};
|
|
17
|
+
function metaValueText(value) {
|
|
18
|
+
if (value === undefined || value === null)
|
|
19
|
+
return "–";
|
|
20
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
export function DiffPanel({ tenantId }) {
|
|
23
|
+
const t = useTranslation();
|
|
24
|
+
const { data, loading } = useQuery(TemplateResolverQueries.diffToSystem, {
|
|
25
|
+
id: tenantId,
|
|
26
|
+
});
|
|
27
|
+
if (loading || data === null)
|
|
28
|
+
return _jsx(LoadingState, {});
|
|
29
|
+
if (data.identical)
|
|
30
|
+
return _jsx(EmptyState, { title: t("documentDesigner.editor.diff.identical") });
|
|
31
|
+
// Diff blocks have no identity beyond their position.
|
|
32
|
+
const entries = data.blocks.map((block, position) => ({ block, key: `block-${position}` }));
|
|
33
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("ul", { className: "flex flex-col gap-1", "data-testid": "document-designer-diff", children: entries.map(({ block, key }) => (_jsxs("li", { "data-change": block.change, className: `whitespace-pre-wrap rounded px-2 py-1 font-mono text-xs ${DIFF_TONE[block.change]}`, children: [DIFF_MARK[block.change], " ", block.content] }, key))) }), data.meta.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1", "data-testid": "document-designer-diff-meta", children: data.meta.map((entry) => (_jsxs("li", { className: "rounded bg-muted px-2 py-1 font-mono text-xs", children: [entry.key, ": ", metaValueText(entry.system), " \u2192 ", metaValueText(entry.tenant)] }, entry.key))) }))] }));
|
|
34
|
+
}
|
|
35
|
+
function originText(t, args) {
|
|
36
|
+
const { isSystemAdmin, source, kind, userCount, overrides } = args;
|
|
37
|
+
const systemVersion = source.system?.version ?? 0;
|
|
38
|
+
if (isSystemAdmin) {
|
|
39
|
+
if (overrides === null)
|
|
40
|
+
return t("documentDesigner.editor.origin.platformPlain", { version: systemVersion });
|
|
41
|
+
return overrides.totalTenants === null
|
|
42
|
+
? t("documentDesigner.editor.origin.platformCount", {
|
|
43
|
+
version: systemVersion,
|
|
44
|
+
active: overrides.activeOverrides,
|
|
45
|
+
})
|
|
46
|
+
: t("documentDesigner.editor.origin.platformOfTotal", {
|
|
47
|
+
version: systemVersion,
|
|
48
|
+
active: overrides.activeOverrides,
|
|
49
|
+
total: overrides.totalTenants,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const usesKey = kind === "mail-layout" ? "mails" : "pdfs";
|
|
53
|
+
if (source.tenant !== null) {
|
|
54
|
+
return t(`documentDesigner.editor.origin.tenant.${usesKey}`, {
|
|
55
|
+
version: systemVersion,
|
|
56
|
+
count: userCount,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return source.system === null
|
|
60
|
+
? t("documentDesigner.editor.origin.none")
|
|
61
|
+
: t("documentDesigner.editor.origin.system");
|
|
62
|
+
}
|
|
63
|
+
export function OriginBar({ isSystemAdmin, source, kind, userCount, overrides, onReset, }) {
|
|
64
|
+
const t = useTranslation();
|
|
65
|
+
const { Button, Dialog, Drawer, Modal, Text } = usePrimitives();
|
|
66
|
+
const [diffOpen, setDiffOpen] = useState(false);
|
|
67
|
+
const [resetOpen, setResetOpen] = useState(false);
|
|
68
|
+
const resetToSystem = useMutation(TemplateResolverHandlers.resetToSystem);
|
|
69
|
+
const { tenant, system } = source;
|
|
70
|
+
const canDiff = tenant !== null && system?.contentFormat === "html";
|
|
71
|
+
const canReset = tenant !== null && system !== null && !isSystemAdmin;
|
|
72
|
+
const diffTitle = t("documentDesigner.editor.diff.title");
|
|
73
|
+
const diffBody = tenant === null ? null : _jsx(DiffPanel, { tenantId: tenant.id });
|
|
74
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border bg-muted px-4 py-2.5", "data-testid": "document-designer-origin-bar", children: [_jsx(Text, { children: originText(t, { isSystemAdmin, source, kind, userCount, overrides }) }), _jsx("div", { className: "grow" }), canDiff && (_jsx(Button, { variant: "link", onClick: () => setDiffOpen(true), testId: "document-designer-diff-open", children: t("documentDesigner.editor.diff.open") })), canReset && (_jsx(Button, { variant: "link", onClick: () => setResetOpen(true), testId: "document-designer-reset-open", children: t("documentDesigner.editor.reset.open") })), diffOpen &&
|
|
75
|
+
(Drawer === undefined ? (_jsx(Modal, { open: true, onOpenChange: setDiffOpen, title: diffTitle, children: diffBody })) : (_jsx(Drawer, { open: true, onOpenChange: setDiffOpen, title: diffTitle, children: diffBody }))), _jsx(Dialog, { open: resetOpen, onOpenChange: setResetOpen, title: t("documentDesigner.editor.reset.title"), description: t("documentDesigner.editor.reset.description"), confirmLabel: t("documentDesigner.editor.reset.confirm"), variant: "danger", onConfirm: async () => {
|
|
76
|
+
if (tenant === null)
|
|
77
|
+
return;
|
|
78
|
+
const result = await resetToSystem.mutate({ id: tenant.id });
|
|
79
|
+
if (result.isSuccess)
|
|
80
|
+
onReset();
|
|
81
|
+
} })] }));
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type TenantOverrides } from "./types.js";
|
|
3
|
+
export type OverridesDrawerProps = {
|
|
4
|
+
readonly open: boolean;
|
|
5
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
6
|
+
readonly slug: string;
|
|
7
|
+
readonly overrides: TenantOverrides | null;
|
|
8
|
+
};
|
|
9
|
+
export declare function OverridesDrawer({ open, onOpenChange, slug, overrides, }: OverridesDrawerProps): ReactNode;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { 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 { 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 })] }));
|
|
9
|
+
}
|
|
10
|
+
export function OverridesDrawer({ open, onOpenChange, slug, overrides, }) {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
// Only asked while the drawer is open; the answer is not needed for the editor itself.
|
|
13
|
+
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
|
+
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}`))) }))] })] }) }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
readonly systemVersion: number | null;
|
|
17
|
+
readonly modifiedAt: string;
|
|
18
|
+
readonly usedBy: readonly OverviewUsageView[];
|
|
19
|
+
readonly usedByCount: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function parseOverviewRow(row: Readonly<Record<string, unknown>>): OverviewRowView | null;
|
|
@@ -0,0 +1,47 @@
|
|
|
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, 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
|
+
systemVersion: numberOr(systemVersion, null),
|
|
43
|
+
modifiedAt: stringOr(modifiedAt, ""),
|
|
44
|
+
usedBy: usages,
|
|
45
|
+
usedByCount: numberOr(usedByCount, usages.length),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
};
|
|
18
|
+
export declare function PageCanvas(props: PageCanvasProps): ReactNode;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { DocumentContentEditor, PDF_DOCUMENT_BLOCKS, } 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
|
+
const DEFAULT_MARGIN_MM = 15;
|
|
11
|
+
// Page width in CSS px at 96 dpi, as a class so no inline width is needed.
|
|
12
|
+
const PAGE_WIDTH_CLASSES = {
|
|
13
|
+
"A4-portrait": "w-[794px]",
|
|
14
|
+
"A4-landscape": "w-[1123px]",
|
|
15
|
+
"Letter-portrait": "w-[816px]",
|
|
16
|
+
"Letter-landscape": "w-[1056px]",
|
|
17
|
+
};
|
|
18
|
+
function RegionBlock({ html, systemLabels, caption, }) {
|
|
19
|
+
if (html.trim() === "")
|
|
20
|
+
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 })] }));
|
|
22
|
+
}
|
|
23
|
+
export function PageCanvas(props) {
|
|
24
|
+
const t = useTranslation();
|
|
25
|
+
const { meta } = props;
|
|
26
|
+
const showRegions = meta.layoutHeaderFooter && props.regions !== null;
|
|
27
|
+
const caption = t("documentDesigner.canvas.fromLayout", { layout: props.layoutName });
|
|
28
|
+
// CSS variables instead of a style prop: margins are user data, classes are static.
|
|
29
|
+
const applyMargins = useCallback((element) => {
|
|
30
|
+
if (element === null)
|
|
31
|
+
return;
|
|
32
|
+
for (const side of MARGIN_SIDES) {
|
|
33
|
+
const mm = meta.margins[side] === "" ? DEFAULT_MARGIN_MM : meta.margins[side];
|
|
34
|
+
element.style.setProperty(`--page-margin-${side}`, `${Math.round(mm * MM_TO_PX)}px`);
|
|
35
|
+
}
|
|
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 }) })] }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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;
|
|
6
|
+
export type MarginSide = "top" | "right" | "bottom" | "left";
|
|
7
|
+
export declare const MARGIN_SIDES: readonly ["top", "right", "bottom", "left"];
|
|
8
|
+
export type PdfMetaDraft = {
|
|
9
|
+
readonly layoutSlug: string;
|
|
10
|
+
readonly pageFormat: PageFormat;
|
|
11
|
+
readonly orientation: Orientation;
|
|
12
|
+
readonly printBackground: boolean;
|
|
13
|
+
readonly layoutHeaderFooter: boolean;
|
|
14
|
+
readonly margins: Readonly<Record<MarginSide, number | "">>;
|
|
15
|
+
readonly fileName: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly author: string;
|
|
18
|
+
};
|
|
19
|
+
export declare const EMPTY_PDF_META: PdfMetaDraft;
|
|
20
|
+
/** Reads the stored meta JSON string; unknown or malformed parts fall back to the defaults. */
|
|
21
|
+
export declare function parsePdfMeta(raw: string | null): PdfMetaDraft;
|
|
22
|
+
/** Builds the meta object for the server; defaults and empty fields are left out. */
|
|
23
|
+
export declare function serializePdfMeta(draft: PdfMetaDraft): Record<string, unknown>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
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
|
+
export const MARGIN_SIDES = [
|
|
9
|
+
"top",
|
|
10
|
+
"right",
|
|
11
|
+
"bottom",
|
|
12
|
+
"left",
|
|
13
|
+
];
|
|
14
|
+
export const EMPTY_PDF_META = {
|
|
15
|
+
layoutSlug: "",
|
|
16
|
+
pageFormat: "A4",
|
|
17
|
+
orientation: "portrait",
|
|
18
|
+
printBackground: true,
|
|
19
|
+
layoutHeaderFooter: true,
|
|
20
|
+
margins: { top: "", right: "", bottom: "", left: "" },
|
|
21
|
+
fileName: "",
|
|
22
|
+
title: "",
|
|
23
|
+
author: "",
|
|
24
|
+
};
|
|
25
|
+
function stringOr(value, fallback) {
|
|
26
|
+
return typeof value === "string" ? value : fallback;
|
|
27
|
+
}
|
|
28
|
+
function booleanOr(value, fallback) {
|
|
29
|
+
return typeof value === "boolean" ? value : fallback;
|
|
30
|
+
}
|
|
31
|
+
function marginOf(margins, side) {
|
|
32
|
+
const value = propertyOf(margins, side);
|
|
33
|
+
return typeof value === "number" ? value : "";
|
|
34
|
+
}
|
|
35
|
+
/** Reads the stored meta JSON string; unknown or malformed parts fall back to the defaults. */
|
|
36
|
+
export function parsePdfMeta(raw) {
|
|
37
|
+
if (raw === null)
|
|
38
|
+
return EMPTY_PDF_META;
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return EMPTY_PDF_META;
|
|
45
|
+
}
|
|
46
|
+
if (!isPlainObject(parsed))
|
|
47
|
+
return EMPTY_PDF_META;
|
|
48
|
+
const pageFormat = propertyOf(parsed, "pageFormat");
|
|
49
|
+
const orientation = propertyOf(parsed, "orientation");
|
|
50
|
+
const margins = propertyOf(parsed, "margins");
|
|
51
|
+
const metadata = propertyOf(parsed, "metadata");
|
|
52
|
+
return {
|
|
53
|
+
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,
|
|
56
|
+
printBackground: booleanOr(parsed["printBackground"], true),
|
|
57
|
+
layoutHeaderFooter: booleanOr(parsed["layoutHeaderFooter"], true),
|
|
58
|
+
margins: {
|
|
59
|
+
top: marginOf(margins, "top"),
|
|
60
|
+
right: marginOf(margins, "right"),
|
|
61
|
+
bottom: marginOf(margins, "bottom"),
|
|
62
|
+
left: marginOf(margins, "left"),
|
|
63
|
+
},
|
|
64
|
+
fileName: stringOr(parsed["fileName"], ""),
|
|
65
|
+
title: stringOr(propertyOf(metadata, "title"), ""),
|
|
66
|
+
author: stringOr(propertyOf(metadata, "author"), ""),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function hasAnyMargin(margins) {
|
|
70
|
+
return MARGIN_SIDES.some((side) => margins[side] !== "");
|
|
71
|
+
}
|
|
72
|
+
/** Builds the meta object for the server; defaults and empty fields are left out. */
|
|
73
|
+
export function serializePdfMeta(draft) {
|
|
74
|
+
const meta = {};
|
|
75
|
+
if (draft.layoutSlug !== "")
|
|
76
|
+
meta["layoutSlug"] = draft.layoutSlug;
|
|
77
|
+
if (draft.pageFormat !== "A4")
|
|
78
|
+
meta["pageFormat"] = draft.pageFormat;
|
|
79
|
+
if (draft.orientation !== "portrait")
|
|
80
|
+
meta["orientation"] = draft.orientation;
|
|
81
|
+
if (!draft.printBackground)
|
|
82
|
+
meta["printBackground"] = false;
|
|
83
|
+
if (!draft.layoutHeaderFooter)
|
|
84
|
+
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
|
+
}
|
|
91
|
+
if (draft.fileName.trim() !== "")
|
|
92
|
+
meta["fileName"] = draft.fileName.trim();
|
|
93
|
+
const metadata = {};
|
|
94
|
+
if (draft.title.trim() !== "")
|
|
95
|
+
metadata["title"] = draft.title.trim();
|
|
96
|
+
if (draft.author.trim() !== "")
|
|
97
|
+
metadata["author"] = draft.author.trim();
|
|
98
|
+
if (Object.keys(metadata).length > 0)
|
|
99
|
+
meta["metadata"] = metadata;
|
|
100
|
+
return meta;
|
|
101
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 } 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 document = await pdfjs.getDocument({
|
|
27
|
+
data: decodeBase64(pdfBase64),
|
|
28
|
+
}).promise;
|
|
29
|
+
return { ok: true, document };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { ok: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// The promise must be stable across renders for use(); only the latest PDF is kept.
|
|
36
|
+
function documentFor(pdfBase64) {
|
|
37
|
+
if (cachedKey !== pdfBase64 || cachedLoad === null) {
|
|
38
|
+
cachedKey = pdfBase64;
|
|
39
|
+
cachedLoad = loadDocument(pdfBase64);
|
|
40
|
+
}
|
|
41
|
+
return cachedLoad;
|
|
42
|
+
}
|
|
43
|
+
function PageView({ document, pageNumber, }) {
|
|
44
|
+
const t = useTranslation();
|
|
45
|
+
const page = use(pageFor(document, pageNumber));
|
|
46
|
+
const viewport = page.getViewport({ scale: RENDER_SCALE });
|
|
47
|
+
const mountCanvas = useCallback((canvas) => {
|
|
48
|
+
if (canvas === null)
|
|
49
|
+
return;
|
|
50
|
+
const task = page.render({ canvas, viewport });
|
|
51
|
+
task.promise.catch(() => undefined);
|
|
52
|
+
return () => task.cancel();
|
|
53
|
+
}, [page, viewport]);
|
|
54
|
+
const mountTextLayer = useCallback((container) => {
|
|
55
|
+
if (container === null)
|
|
56
|
+
return;
|
|
57
|
+
container.style.setProperty("--total-scale-factor", String(RENDER_SCALE));
|
|
58
|
+
container.style.setProperty("--scale-factor", String(RENDER_SCALE));
|
|
59
|
+
let layer = null;
|
|
60
|
+
void import("pdfjs-dist").then(({ TextLayer }) => {
|
|
61
|
+
const textLayer = new TextLayer({
|
|
62
|
+
textContentSource: page.streamTextContent(),
|
|
63
|
+
container,
|
|
64
|
+
viewport,
|
|
65
|
+
});
|
|
66
|
+
layer = textLayer;
|
|
67
|
+
return textLayer.render().catch(() => undefined);
|
|
68
|
+
});
|
|
69
|
+
return () => layer?.cancel();
|
|
70
|
+
}, [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 }) })] }));
|
|
72
|
+
}
|
|
73
|
+
const pageCache = new WeakMap();
|
|
74
|
+
function pageFor(document, pageNumber) {
|
|
75
|
+
let pages = pageCache.get(document);
|
|
76
|
+
if (pages === undefined) {
|
|
77
|
+
pages = new Map();
|
|
78
|
+
pageCache.set(document, pages);
|
|
79
|
+
}
|
|
80
|
+
let page = pages.get(pageNumber);
|
|
81
|
+
if (page === undefined) {
|
|
82
|
+
page = document.getPage(pageNumber);
|
|
83
|
+
pages.set(pageNumber, page);
|
|
84
|
+
}
|
|
85
|
+
return page;
|
|
86
|
+
}
|
|
87
|
+
function Pages({ pdfBase64 }) {
|
|
88
|
+
const t = useTranslation();
|
|
89
|
+
const loaded = use(documentFor(pdfBase64));
|
|
90
|
+
if (!loaded.ok) {
|
|
91
|
+
return _jsx("p", { className: "text-sm text-status-bad", children: t("documentDesigner.preview.renderFailed") });
|
|
92
|
+
}
|
|
93
|
+
const pageNumbers = Array.from({ length: loaded.document.numPages }, (_, index) => index + 1);
|
|
94
|
+
return (_jsx(_Fragment, { children: pageNumbers.map((pageNumber) => (_jsx(Suspense, { fallback: null, children: _jsx(PageView, { document: loaded.document, pageNumber: pageNumber }) }, pageNumber))) }));
|
|
95
|
+
}
|
|
96
|
+
export function PdfPreview({ pdfBase64 }) {
|
|
97
|
+
const t = useTranslation();
|
|
98
|
+
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 }) }) }));
|
|
99
|
+
}
|
|
@@ -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;
|