@cosmicdrift/kumiko-document-designer 0.388.0 → 0.390.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 (53) hide show
  1. package/dist/feature.js +11 -3
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/list-definition.d.ts +3 -1
  5. package/dist/list-definition.js +10 -3
  6. package/dist/web/client-plugin.js +6 -2
  7. package/dist/web/constants.d.ts +1 -0
  8. package/dist/web/constants.js +1 -0
  9. package/dist/web/i18n.js +1 -0
  10. package/dist/web/layout-editor-body.d.ts +3 -14
  11. package/dist/web/layout-editor-body.js +25 -172
  12. package/dist/web/layout-editor-center.d.ts +7 -0
  13. package/dist/web/layout-editor-center.js +23 -0
  14. package/dist/web/layout-editor-logic.d.ts +97 -0
  15. package/dist/web/layout-editor-logic.js +143 -0
  16. package/dist/web/layout-editor-paper.d.ts +4 -0
  17. package/dist/web/layout-editor-paper.js +24 -0
  18. package/dist/web/layout-editor.d.ts +3 -1
  19. package/dist/web/layout-editor.js +7 -3
  20. package/dist/web/mail-editor-body.d.ts +4 -16
  21. package/dist/web/mail-editor-body.js +27 -204
  22. package/dist/web/mail-editor-logic.d.ts +70 -0
  23. package/dist/web/mail-editor-logic.js +81 -0
  24. package/dist/web/mail-editor-model.d.ts +105 -0
  25. package/dist/web/mail-editor-model.js +114 -0
  26. package/dist/web/mail-editor-render.d.ts +11 -0
  27. package/dist/web/mail-editor-render.js +51 -0
  28. package/dist/web/mail-preview-render.d.ts +5 -0
  29. package/dist/web/mail-preview-render.js +22 -0
  30. package/dist/web/pdf-editor-actions.d.ts +27 -0
  31. package/dist/web/pdf-editor-actions.js +146 -0
  32. package/dist/web/pdf-editor-chrome.d.ts +6 -0
  33. package/dist/web/pdf-editor-chrome.js +60 -0
  34. package/dist/web/pdf-editor-context.d.ts +22 -0
  35. package/dist/web/pdf-editor-context.js +1 -0
  36. package/dist/web/pdf-editor-draft.d.ts +23 -0
  37. package/dist/web/pdf-editor-draft.js +33 -0
  38. package/dist/web/pdf-editor-panes.d.ts +6 -0
  39. package/dist/web/pdf-editor-panes.js +40 -0
  40. package/dist/web/pdf-editor-view.d.ts +52 -0
  41. package/dist/web/pdf-editor-view.js +89 -0
  42. package/dist/web/pdf-template-editor.d.ts +1 -1
  43. package/dist/web/pdf-template-editor.js +40 -342
  44. package/dist/web/use-layout-editor-state.d.ts +62 -0
  45. package/dist/web/use-layout-editor-state.js +155 -0
  46. package/dist/web/use-mail-editor-parts.d.ts +42 -0
  47. package/dist/web/use-mail-editor-parts.js +74 -0
  48. package/dist/web/use-mail-editor-state.d.ts +89 -0
  49. package/dist/web/use-mail-editor-state.js +55 -0
  50. package/dist/web/use-pdf-editor-state.d.ts +77 -0
  51. package/dist/web/use-pdf-editor-state.js +161 -0
  52. package/package.json +6 -6
  53. package/src/changes.json +30 -0
@@ -0,0 +1,40 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PdfPreview } from "@cosmicdrift/kumiko-renderer/web";
3
+ import { useEffect } from "react";
4
+ import { PlaceholderSection, SystemPlaceholderSection } from "./mail-left-rail.js";
5
+ import { PageCanvas } from "./page-canvas.js";
6
+ import { PdfResourceSection } from "./pdf-left-rail.js";
7
+ import { SettingsPanel } from "./settings-panel.js";
8
+ // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
9
+ function RunOnMount({ run }) {
10
+ useEffect(() => {
11
+ run();
12
+ }, [run]);
13
+ return null;
14
+ }
15
+ export function renderPdfLeftRail(c) {
16
+ const { s, view, detail, actions } = c;
17
+ const { insert, filesAvailable, scope } = s.host;
18
+ return (_jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: view.placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf" }), _jsx(SystemPlaceholderSection, { validation: view.placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf", isLayoutOwned: view.layoutFills }), _jsx(PdfResourceSection, { resourceKeys: view.resourceKeys, canUpload: detail.scope === scope, filesAvailable: filesAvailable, layoutName: view.layoutName, onInsert: insert.insertResource, onUpload: actions.uploadResource })] }));
19
+ }
20
+ export function renderPdfEditCanvas(c) {
21
+ const { s, view, current, actions } = c;
22
+ const { insert } = s.host;
23
+ const { vocabulary } = s;
24
+ return (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), children: _jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => actions.updateDraft({ ...current.draft, content }), regions: s.server.layout?.regions ?? null, layoutName: view.layoutName, resourceSrc: view.layoutResourceSrc, variableLabels: vocabulary.variableLabels, systemVariables: vocabulary.systemVariables, systemLabels: vocabulary.systemLabels, variableNames: vocabulary.variableNames, imageVariables: vocabulary.imageVariables, resourcePreviews: vocabulary.resourcePreviews, onEditorReady: insert.register("body") }) }));
25
+ }
26
+ function renderStalePreview(c) {
27
+ const { t, Button, s, view, actions } = c;
28
+ const { busy } = s.ui;
29
+ return (_jsxs("div", { className: "flex flex-col items-start gap-3 p-6", "data-testid": "document-designer-preview-stale", children: [view.pdfAvailable && busy === null && _jsx(RunOnMount, { run: actions.printOnce }), _jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
30
+ ? t("documentDesigner.preview.loading")
31
+ : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !view.pdfAvailable || busy !== null, onClick: () => void actions.generatePdf(), children: t("documentDesigner.actions.generatePdf") })] }));
32
+ }
33
+ export function renderPdfPreviewPane(c) {
34
+ const { generated } = c.s.ui;
35
+ return (_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: generated !== null && c.view.generatedFresh ? (_jsx(PdfPreview, { source: { base64: generated.result.pdfBase64 }, testId: "document-designer-pdf-preview" })) : (renderStalePreview(c)) }));
36
+ }
37
+ export function renderPdfSettings(c) {
38
+ const { s, view, current, detail, actions } = c;
39
+ return (_jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => actions.updateDraft({ ...current.draft, meta }), layouts: s.server.layouts.rows, locale: s.doc.locale, version: { id: detail.id, version: detail.version, status: detail.status }, vocabulary: view.vocabulary, insert: s.host.insert, draftKey: `${current.key}:${s.ui.draftEpoch}` }));
40
+ }
@@ -0,0 +1,52 @@
1
+ import type { EditorState } from "./pdf-editor-draft.ts";
2
+ import { type TemplateDetail } from "./types.ts";
3
+ import type { PdfEditorState } from "./use-pdf-editor-state.ts";
4
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
5
+ export declare function buildPdfView(input: {
6
+ readonly t: Translate;
7
+ readonly s: PdfEditorState;
8
+ readonly slug: string;
9
+ readonly current: EditorState;
10
+ readonly detail: TemplateDetail;
11
+ }): {
12
+ dirty: boolean;
13
+ generatedFresh: boolean;
14
+ canPublish: boolean;
15
+ pdfAvailable: boolean;
16
+ unavailableMessage: string;
17
+ pdfUnavailableNote: boolean;
18
+ layoutName: string;
19
+ layoutResourceSrc: (key: string) => string | undefined;
20
+ placeholderSource: {
21
+ canSave: boolean;
22
+ canPublish: boolean;
23
+ issues: readonly import("@cosmicdrift/kumiko-framework/errors").FieldIssue[];
24
+ publishIssues: readonly import("@cosmicdrift/kumiko-framework/errors").FieldIssue[];
25
+ used: readonly string[];
26
+ unknown: readonly string[];
27
+ missingRequired: readonly string[];
28
+ warnings: readonly import("./types.ts").ValidationWarning[];
29
+ variables: Readonly<Record<string, import("./mail-types.ts").MailVariable>>;
30
+ systemPlaceholders: readonly string[];
31
+ } | null;
32
+ layoutFills: (name: string) => boolean;
33
+ resourceKeys: string[];
34
+ title: string;
35
+ systemVersion: number | null;
36
+ vocabulary: {
37
+ names: string[];
38
+ labels: {
39
+ [x: string]: string;
40
+ };
41
+ system: readonly string[];
42
+ };
43
+ footer: {
44
+ readonly tone: import("./editor-shell.tsx").FooterTone;
45
+ readonly text: string;
46
+ } | null;
47
+ showOverrideLinks: boolean;
48
+ originText: string;
49
+ besideActiveSentence: string | null;
50
+ };
51
+ export type PdfView = ReturnType<typeof buildPdfView>;
52
+ export {};
@@ -0,0 +1,89 @@
1
+ import { fileUrl } from "./file-name.js";
2
+ import { snapshotOf } from "./pdf-editor-draft.js";
3
+ import { pdfFooterMessage } from "./pdf-footer.js";
4
+ import { visibleSystemPlaceholders } from "./pdf-left-rail.js";
5
+ import { draftBesideActiveSentence } from "./status-label.js";
6
+ import { parseJsonObject } from "./template-rows.js";
7
+ import { DEFAULT_LAYOUT_SLUG } from "./types.js";
8
+ import { templateTitle, templateTitleText } from "./used-by-label.js";
9
+ function originTextOf(t, s, detail, systemVersion, showOverrideLinks) {
10
+ const overrides = s.server.overridesQuery.data;
11
+ if (showOverrideLinks) {
12
+ const overrideCount = (overrides?.activeOverrides ?? 0) + (overrides?.draftOverrides ?? 0);
13
+ return overrides?.totalTenants == null
14
+ ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
15
+ : t("documentDesigner.originBar.withTotal", {
16
+ count: overrideCount,
17
+ total: overrides.totalTenants,
18
+ });
19
+ }
20
+ if (detail.scope === "system")
21
+ return t("documentDesigner.mail.origin.system", { version: detail.version });
22
+ return systemVersion === null
23
+ ? t("documentDesigner.mail.origin.tenantOnly")
24
+ : t("documentDesigner.mail.origin.overrides", { version: systemVersion });
25
+ }
26
+ function footerOf(t, s) {
27
+ const { validation } = s.server;
28
+ const { generated } = s.ui;
29
+ if (validation === undefined || validation === null)
30
+ return null; // skip: nothing validated yet
31
+ return pdfFooterMessage(t, validation, generated === null
32
+ ? null
33
+ : {
34
+ pages: generated.result.pageCount,
35
+ sizeBytes: generated.result.sizeBytes,
36
+ durationMs: generated.result.durationMs,
37
+ });
38
+ }
39
+ function layoutNameOf(t, s, layoutSlugShown) {
40
+ const layoutTitle = s.server.layouts.rows.find((row) => row.slug === layoutSlugShown)?.title;
41
+ return layoutTitle === undefined ? layoutSlugShown : templateTitleText(t, layoutTitle);
42
+ }
43
+ function placeholderSourceOf(s) {
44
+ const { validation } = s.server;
45
+ return validation === undefined || validation === null
46
+ ? null
47
+ : {
48
+ ...validation,
49
+ variables: s.vocabulary.variables,
50
+ systemPlaceholders: visibleSystemPlaceholders(validation.systemPlaceholders),
51
+ };
52
+ }
53
+ export function buildPdfView(input) {
54
+ const { t, s, slug, current, detail } = input;
55
+ const { layout, validation, statusQuery, brandingLogoFile } = s.server;
56
+ const { generated } = s.ui;
57
+ const pdfAvailable = statusQuery.error === null && statusQuery.data?.available === true;
58
+ const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
59
+ const layoutSlugShown = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
60
+ const layoutName = layoutNameOf(t, s, layoutSlugShown);
61
+ const overviewRow = s.host.overview.rows.find((candidate) => candidate.slug === slug);
62
+ const systemVersion = overviewRow?.systemVersion ?? null;
63
+ const showOverrideLinks = s.server.showOrigin && detail.scope === "system";
64
+ return {
65
+ dirty: snapshotOf(current.draft) !== current.snapshot,
66
+ generatedFresh: generated !== null && generated.forSnapshot === snapshotOf(current.draft),
67
+ canPublish: validation?.canPublish === true,
68
+ pdfAvailable,
69
+ unavailableMessage,
70
+ pdfUnavailableNote: !pdfAvailable && statusQuery.loading === false,
71
+ layoutName,
72
+ layoutResourceSrc: (key) => key === "logo" && brandingLogoFile !== "" ? fileUrl(brandingLogoFile) : undefined,
73
+ placeholderSource: placeholderSourceOf(s),
74
+ layoutFills: (name) => layout !== null &&
75
+ (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`)),
76
+ resourceKeys: Object.keys(parseJsonObject(detail.linkedResources)),
77
+ title: overviewRow === undefined ? slug : templateTitle(t, overviewRow),
78
+ systemVersion,
79
+ vocabulary: {
80
+ names: s.vocabulary.variableNames,
81
+ labels: s.vocabulary.variableLabels,
82
+ system: s.vocabulary.systemVariables,
83
+ },
84
+ footer: footerOf(t, s),
85
+ showOverrideLinks,
86
+ originText: originTextOf(t, s, detail, systemVersion, showOverrideLinks),
87
+ besideActiveSentence: draftBesideActiveSentence(t, detail),
88
+ };
89
+ }
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  export type PdfTemplateEditorProps = {
3
3
  readonly slug: string;
4
4
  /** Template locale; defaults to "de", or the first locale the slug has. */
@@ -1,159 +1,21 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
- import { useDispatcher, usePrimitives, useQuery, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
4
- import { ErrorState, LoadingState, PdfPreview, uploadFileRef, } from "@cosmicdrift/kumiko-renderer/web";
5
- import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
6
- import { useEffect, useMemo, useRef, useState } from "react";
7
- import { BRANDING_QUERY_QN } from "./constants.js";
8
- import { EditorHeader, EditorLocaleSwitch, EditorModeSwitch, originOf, } from "./editor-header.js";
9
- import { EditorBody, EditorFooter, EditorFrame, EditorInfoBar } from "./editor-shell.js";
10
- import { describeError, isAlreadyActive } from "./errors.js";
11
- import { fileUrl } from "./file-name.js";
12
- import { systemLabelMap, variableLabelMap } from "./mail-draft.js";
13
- import { PlaceholderSection, SystemPlaceholderSection } from "./mail-left-rail.js";
14
- import { OverridesDrawer } from "./overrides-drawer.js";
15
- import { PageCanvas } from "./page-canvas.js";
16
- import { pdfFooterMessage } from "./pdf-footer.js";
17
- import { PdfResourceSection, visibleSystemPlaceholders } from "./pdf-left-rail.js";
18
- import { EMPTY_PDF_META, parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
19
- import { SettingsPanel } from "./settings-panel.js";
20
- import { draftBesideActiveSentence } from "./status-label.js";
21
- import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-rows.js";
22
- import { DEFAULT_LAYOUT_SLUG, DOCUMENT_PDF_KIND, PDF_LAYOUT_KIND, } from "./types.js";
23
- import { useFilesCapability } from "./use-files-capability.js";
24
- import { useInsertTargets } from "./use-insert-targets.js";
25
- import { useOverviewRows } from "./use-overview-rows.js";
26
- import { templateTitle, templateTitleText } from "./used-by-label.js";
27
- const SETTLE_MS = 500;
28
- const NO_VARIABLES = {};
29
- // Only the label of an images variable is needed, for the Images block and its picker.
30
- function imageLabelSource(variables) {
31
- const result = {};
32
- for (const [name, info] of Object.entries(variables)) {
33
- if (info.type === "images" && info.label !== undefined) {
34
- result[name] = { type: "text", label: info.label };
35
- }
36
- }
37
- return result;
38
- }
39
- function textVariablesOf(variables) {
40
- const result = {};
41
- for (const [name, info] of Object.entries(variables)) {
42
- if (info.type === "images")
43
- continue;
44
- const { item, ...rest } = info;
45
- const columns = Object.entries(item ?? {}).flatMap(([column, def]) => def.type === "images" ? [] : [[column, { ...def, type: def.type }]]);
46
- result[name] = {
47
- ...rest,
48
- type: info.type,
49
- ...(item !== undefined && { item: Object.fromEntries(columns) }),
50
- };
51
- }
52
- return result;
53
- }
54
- // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
55
- function RunOnMount({ run }) {
56
- useEffect(() => {
57
- run();
58
- }, [run]);
59
- return null;
60
- }
61
- const draftFromDetail = (detail) => ({
62
- content: detail.content,
63
- meta: parsePdfMeta(detail.meta),
64
- });
65
- const snapshotOf = (draft) => JSON.stringify({ content: draft.content, meta: serializePdfMeta(draft.meta) });
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { TemplateResolverQueries } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import { ErrorState, LoadingState } from "@cosmicdrift/kumiko-renderer/web";
5
+ import { EditorBody, EditorFrame } from "./editor-shell.js";
6
+ import { describeError } from "./errors.js";
7
+ import { buildPdfActions } from "./pdf-editor-actions.js";
8
+ import { renderPdfFooter, renderPdfHeader, renderPdfInfoBar, renderPdfLocaleDialog, } from "./pdf-editor-chrome.js";
9
+ import { renderPdfEditCanvas, renderPdfLeftRail, renderPdfPreviewPane, renderPdfSettings, } from "./pdf-editor-panes.js";
10
+ import { buildPdfView } from "./pdf-editor-view.js";
11
+ import { DOCUMENT_PDF_KIND } from "./types.js";
12
+ import { usePdfEditorState } from "./use-pdf-editor-state.js";
66
13
  export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChange, }) {
67
14
  const t = useTranslation();
68
15
  const dispatcher = useDispatcher();
69
16
  const { Button, Banner, Dialog } = usePrimitives();
70
- const insert = useInsertTargets();
71
- const filesAvailable = useFilesCapability();
72
- const overview = useOverviewRows(DOCUMENT_PDF_KIND);
73
- const roles = useUserRoles();
74
- const scope = roles?.includes("SystemAdmin") === true ? "system" : "tenant";
75
- const rowsQuery = useQuery(TemplateResolverQueries.list, {
76
- kind: DOCUMENT_PDF_KIND,
77
- });
78
- const rows = rowsQuery.data ?? [];
79
- const locales = localesOf(rows, slug);
80
- const locale = resolveLocale(requestedLocale, locales);
81
- const row = pickRow(rows, slug, locale, scope);
82
- const detailQuery = useQuery(TemplateResolverQueries.findById, { id: row?.id ?? "" }, { enabled: row !== undefined });
83
- const detail = detailQuery.data !== null &&
84
- detailQuery.data?.slug === slug &&
85
- detailQuery.data.locale === locale
86
- ? detailQuery.data
87
- : null;
88
- const [state, setState] = useState(null);
89
- if (detail !== null && (state === null || state.key !== detail.id)) {
90
- const draft = draftFromDetail(detail);
91
- setState({ key: detail.id, draft, settled: draft, snapshot: snapshotOf(draft) });
92
- }
93
- const current = state !== null && detail !== null && state.key === detail.id ? state : null;
94
- const settleTimer = useRef(null);
95
- // A pending settle must not fire into an unmounted editor.
96
- useEffect(() => () => {
97
- if (settleTimer.current !== null)
98
- clearTimeout(settleTimer.current);
99
- }, []);
100
- const [overridesOpen, setOverridesOpen] = useState(false);
101
- const [draftEpoch, setDraftEpoch] = useState(0);
102
- const [mode, setMode] = useState("edit");
103
- const [busy, setBusy] = useState(null);
104
- const [actionError, setActionError] = useState(null);
105
- const [generated, setGenerated] = useState(null);
106
- const autoPrintedFor = useRef(null);
107
- const [pendingLocale, setPendingLocale] = useState(null);
108
- const variableSchema = useMemo(() => parseJsonObject(detail?.variableSchema ?? null), [detail?.variableSchema]);
109
- const settledMeta = current === null ? null : serializePdfMeta(current.settled.meta);
110
- const validationQuery = useQuery(TemplateResolverQueries.validate, {
111
- slug,
112
- kind: DOCUMENT_PDF_KIND,
113
- contentFormat: "html",
114
- content: current?.settled.content ?? "",
115
- meta: settledMeta ?? {},
116
- variableSchema,
117
- locale,
118
- scope,
119
- }, { enabled: current !== null });
120
- const validation = validationQuery.data;
121
- const layoutSlug = current?.settled.meta.layoutSlug ?? "";
122
- const previewQuery = useQuery(TemplateResolverQueries.preview, {
123
- slug,
124
- kind: DOCUMENT_PDF_KIND,
125
- locale,
126
- content: detail?.content ?? "",
127
- meta: layoutSlug === "" ? {} : { layoutSlug },
128
- variableSchema,
129
- }, { enabled: detail !== null });
130
- const layout = previewQuery.error === null ? (previewQuery.data?.layout ?? null) : null;
131
- const layouts = useOverviewRows(PDF_LAYOUT_KIND);
132
- // The built-in "logo" resource of a layout is the tenant's branding logo.
133
- const branding = useQuery(BRANDING_QUERY_QN, {});
134
- const brandingLogoFile = branding.data?.logoFile ?? "";
135
- const statusQuery = useQuery(RendererPuppeteerClientQueries.status, {});
136
- const pdfAvailable = statusQuery.error === null && statusQuery.data?.available === true;
137
- const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
138
- const showOrigin = scope === "system";
139
- const overridesQuery = useQuery(TemplateResolverQueries.tenantOverrides, { slug, kind: DOCUMENT_PDF_KIND, locale }, { enabled: showOrigin && detail !== null });
140
- const allVariables = validation?.variables ?? NO_VARIABLES;
141
- // Photos are record data, never an inline placeholder: images variables only feed the Images block.
142
- const variables = useMemo(() => textVariablesOf(allVariables), [allVariables]);
143
- const imageVariables = useMemo(() => Object.keys(allVariables).filter((name) => allVariables[name]?.type === "images"), [allVariables]);
144
- const systemVariables = useMemo(() => visibleSystemPlaceholders(validation?.systemPlaceholders ?? []), [validation?.systemPlaceholders]);
145
- const systemLabels = useMemo(() => systemLabelMap(t, systemVariables), [t, systemVariables]);
146
- const variableLabels = useMemo(() => ({
147
- ...systemLabels,
148
- ...variableLabelMap(t, variables),
149
- ...variableLabelMap(t, imageLabelSource(allVariables)),
150
- }), [systemLabels, variables, allVariables, t]);
151
- const variableNames = useMemo(() => Object.keys(variables), [variables]);
152
- const linkedResources = detail?.linkedResources ?? null;
153
- // The editor shows resource images from the stored copies; no extra request is made here.
154
- const resourcePreviews = useMemo(() => Object.fromEntries(Object.entries(parseJsonObject(linkedResources)).flatMap(([key, fileRefId]) => typeof fileRefId === "string"
155
- ? [[key, { src: `/api/files/${encodeURIComponent(fileRefId)}`, caption: key }]]
156
- : [])), [linkedResources]);
17
+ const s = usePdfEditorState({ slug, requestedLocale }, t);
18
+ const { rowsQuery, row, current, detail, detailQuery } = s.doc;
157
19
  if (rowsQuery.loading && rowsQuery.data === null)
158
20
  return _jsx(LoadingState, {});
159
21
  if (rowsQuery.error !== null)
@@ -163,196 +25,32 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
163
25
  if (current === null || detail === null) {
164
26
  return detailQuery.error !== null ? _jsx(ErrorState, { error: detailQuery.error }) : _jsx(LoadingState, {});
165
27
  }
166
- const dirty = snapshotOf(current.draft) !== current.snapshot;
167
- const generatedFresh = generated !== null && generated.forSnapshot === snapshotOf(current.draft);
168
- function updateDraft(next) {
169
- setState((previous) => (previous === null ? previous : { ...previous, draft: next }));
170
- if (settleTimer.current !== null)
171
- clearTimeout(settleTimer.current);
172
- settleTimer.current = setTimeout(() => {
173
- setState((previous) => previous === null ? previous : { ...previous, settled: previous.draft });
174
- }, SETTLE_MS);
175
- }
176
- function resetDraft(source) {
177
- const draft = draftFromDetail(source);
178
- if (settleTimer.current !== null)
179
- clearTimeout(settleTimer.current);
180
- setState({ key: source.id, draft, settled: draft, snapshot: snapshotOf(draft) });
181
- setDraftEpoch((epoch) => epoch + 1);
182
- setActionError(null);
183
- }
184
- async function reload() {
185
- await Promise.all([rowsQuery.refetch(), detailQuery.refetch(), validationQuery.refetch()]);
186
- }
187
- async function persist() {
188
- const draft = current?.draft ?? { content: "", meta: EMPTY_PDF_META };
189
- const parentTemplateId = detail?.scope === "system" ? detail.id : detail?.parentTemplateId;
190
- const result = await dispatcher.write(scope === "system"
191
- ? TemplateResolverHandlers.upsertSystem
192
- : TemplateResolverHandlers.upsertTenant, {
193
- slug,
194
- kind: DOCUMENT_PDF_KIND,
195
- locale,
196
- content: draft.content,
197
- contentFormat: "html",
198
- variableSchema,
199
- meta: serializePdfMeta(draft.meta),
200
- ...(scope === "tenant" && parentTemplateId != null && { parentTemplateId }),
201
- });
202
- if (!result.isSuccess) {
203
- setActionError(describeError(t, result.error));
204
- return null;
205
- }
206
- setState((previous) => previous === null ? previous : { ...previous, snapshot: snapshotOf(draft) });
207
- return result.data;
208
- }
209
- async function save() {
210
- setBusy("save");
211
- setActionError(null);
212
- const saved = await persist();
213
- if (saved !== null)
214
- await reload();
215
- setBusy(null);
216
- return saved !== null;
217
- }
218
- async function publish() {
219
- setBusy("publish");
220
- setActionError(null);
221
- // A system save activates immediately; a tenant row needs a save first when it
222
- // does not exist yet or carries unsaved edits, then an explicit publish.
223
- const needsSave = dirty || detail?.scope === "system";
224
- const saved = needsSave ? await persist() : null;
225
- const saveFailed = needsSave && saved === null;
226
- // upsertSystem already activates, so only a tenant row needs the explicit publish.
227
- const alreadyActivatedBySave = scope === "system" && saved !== null;
228
- const templateId = saved?.id ?? detail?.id ?? null;
229
- if (templateId !== null && !saveFailed && !alreadyActivatedBySave) {
230
- const result = await dispatcher.write(scope === "system"
231
- ? TemplateResolverHandlers.publishSystem
232
- : TemplateResolverHandlers.publish, { id: templateId });
233
- if (!result.isSuccess && !isAlreadyActive(result.error))
234
- setActionError(describeError(t, result.error));
235
- }
236
- await reload();
237
- setBusy(null);
238
- }
239
- async function generatePdf() {
240
- if (current === null)
241
- return;
242
- setMode("preview");
243
- setBusy("pdf");
244
- setActionError(null);
245
- const forSnapshot = snapshotOf(current.draft);
246
- const result = await dispatcher.query(RendererPuppeteerClientQueries.previewPdf, {
247
- slug,
248
- locale,
249
- content: current.draft.content,
250
- meta: serializePdfMeta(current.draft.meta),
251
- variableSchema,
252
- });
253
- if (result.isSuccess)
254
- setGenerated({ forSnapshot, result: result.data });
255
- else
256
- setActionError(describeError(t, result.error));
257
- setBusy(null);
258
- }
259
- function printOnce() {
260
- if (current === null || autoPrintedFor.current === snapshotOf(current.draft))
261
- return;
262
- autoPrintedFor.current = snapshotOf(current.draft);
263
- void generatePdf();
264
- }
265
- async function uploadResource(file) {
266
- const sourceFileRefId = await uploadFileRef(file);
267
- const result = await dispatcher.write(TemplateResolverHandlers.resourceUpload, {
268
- templateId: detail?.id,
269
- sourceFileRefId,
270
- });
271
- if (!result.isSuccess)
272
- throw new Error(describeError(t, result.error));
273
- await detailQuery.refetch();
274
- }
275
- function requestLocale(next) {
276
- if (next === locale)
277
- return;
278
- if (dirty)
279
- setPendingLocale(next);
280
- else
281
- onLocaleChange?.(next);
282
- }
283
- const canPublish = validation?.canPublish === true;
284
- const layoutSlugShown = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
285
- const layoutTitle = layouts.rows.find((row) => row.slug === layoutSlugShown)?.title;
286
- const layoutName = layoutTitle === undefined ? layoutSlugShown : templateTitleText(t, layoutTitle);
287
- const layoutResourceSrc = (key) => key === "logo" && brandingLogoFile !== "" ? fileUrl(brandingLogoFile) : undefined;
288
- const placeholderSource = validation === undefined || validation === null
289
- ? null
290
- : {
291
- ...validation,
292
- variables,
293
- systemPlaceholders: visibleSystemPlaceholders(validation.systemPlaceholders),
294
- };
295
- const layoutFills = (name) => layout !== null &&
296
- (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`));
297
- const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
298
- const resourceKeys = Object.keys(parseJsonObject(detail.linkedResources));
299
- const overviewRow = overview.rows.find((candidate) => candidate.slug === slug);
300
- const title = overviewRow === undefined ? slug : templateTitle(t, overviewRow);
301
- const systemVersion = overviewRow?.systemVersion ?? null;
302
- const vocabulary = {
303
- names: variableNames,
304
- labels: variableLabels,
305
- system: systemVariables,
28
+ const view = buildPdfView({ t, s, slug, current, detail });
29
+ const actions = buildPdfActions({
30
+ s,
31
+ t,
32
+ dispatcher,
33
+ slug,
34
+ current,
35
+ detail,
36
+ dirty: view.dirty,
37
+ onLocaleChange,
38
+ });
39
+ const context = {
40
+ t,
41
+ Button,
42
+ Dialog,
43
+ slug,
44
+ s,
45
+ actions,
46
+ view,
47
+ current,
48
+ detail,
49
+ onLocaleChange,
50
+ exportBundle,
306
51
  };
307
- const footer = validation === undefined || validation === null
308
- ? null
309
- : pdfFooterMessage(t, validation, generated === null
310
- ? null
311
- : {
312
- pages: generated.result.pageCount,
313
- sizeBytes: generated.result.sizeBytes,
314
- durationMs: generated.result.durationMs,
315
- });
316
- const showOverrideLinks = showOrigin && detail.scope === "system";
317
- const originText = showOverrideLinks
318
- ? overridesQuery.data?.totalTenants == null
319
- ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
320
- : t("documentDesigner.originBar.withTotal", {
321
- count: overrideCount,
322
- total: overridesQuery.data.totalTenants,
323
- })
324
- : detail.scope === "system"
325
- ? t("documentDesigner.mail.origin.system", { version: detail.version })
326
- : systemVersion === null
327
- ? t("documentDesigner.mail.origin.tenantOnly")
328
- : t("documentDesigner.mail.origin.overrides", { version: systemVersion });
329
- const besideActiveSentence = draftBesideActiveSentence(t, detail);
330
- const pdfUnavailableNote = !pdfAvailable && statusQuery.loading === false;
331
- return (_jsxs(EditorFrame, { testId: "pdf-template-editor", children: [_jsx(EditorHeader, { title: title, stored: { status: detail.status, version: detail.version }, actions: {
332
- busy: busy === "save",
333
- canSave: busy === null && validation?.canSave !== false,
334
- canPublish: busy === null && canPublish,
335
- onSave: () => void save(),
336
- onPublish: () => void publish(),
337
- }, children: _jsx(EditorModeSwitch, { testIdPrefix: "pdf", mode: mode, onMode: setMode }) }), _jsxs(EditorInfoBar, { tone: pdfUnavailableNote ? "warn" : detail.scope === "tenant" ? "active" : "muted", testId: "pdf-origin-bar", origin: originOf(detail.scope === "system", systemVersion !== null), controls: _jsxs(_Fragment, { children: [_jsx(EditorLocaleSwitch, { testIdPrefix: "pdf", locales: locales.length > 0 ? locales : [locale], locale: locale, onLocale: requestLocale }), mode === "preview" && (_jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void generatePdf(), testId: "pdf-generate-header", children: t("documentDesigner.actions.generatePdf") }))] }), overlays: _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, locale: locale, overrides: overridesQuery.data }), links: showOverrideLinks && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "link", onClick: () => void exportBundle(), children: t("documentDesigner.originBar.export") }), _jsx(Button, { variant: "link", onClick: () => setOverridesOpen(true), children: t("documentDesigner.originBar.showOverrides") })] })), children: [originText, besideActiveSentence !== null && ` ${besideActiveSentence}`, pdfUnavailableNote && (_jsxs(_Fragment, { children: [" ", _jsx("span", { "data-testid": "pdf-unavailable", children: unavailableMessage === ""
338
- ? t("documentDesigner.pdfUnavailableNoReason")
339
- : t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })] }))] }), _jsx(EditorBody, { left: mode === "edit" ? (_jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf" }), _jsx(SystemPlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf", isLayoutOwned: layoutFills }), _jsx(PdfResourceSection, { resourceKeys: resourceKeys, canUpload: detail.scope === scope, filesAvailable: filesAvailable, layoutName: layoutName, onInsert: insert.insertResource, onUpload: uploadResource })] })) : undefined, center: mode === "edit" ? (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), children: _jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, resourceSrc: layoutResourceSrc, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, imageVariables: imageVariables, resourcePreviews: resourcePreviews, onEditorReady: insert.register("body") }) })) : (_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: generated !== null && generatedFresh ? (_jsx(PdfPreview, { source: { base64: generated.result.pdfBase64 }, testId: "document-designer-pdf-preview" })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 p-6", "data-testid": "document-designer-preview-stale", children: [pdfAvailable && busy === null && _jsx(RunOnMount, { run: printOnce }), _jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
340
- ? t("documentDesigner.preview.loading")
341
- : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") })] })) })), right: _jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => updateDraft({ ...current.draft, meta }), layouts: layouts.rows, locale: locale, version: { id: detail.id, version: detail.version, status: detail.status }, vocabulary: vocabulary, insert: insert, draftKey: `${current.key}:${draftEpoch}` }) }), _jsx(EditorFooter, { testIdPrefix: "pdf-editor", message: footer, saveError: actionError, modifiedAt: detail.modifiedAt ?? null, dirty: dirty, busy: busy === "save", canSave: busy === null && validation?.canSave !== false, canPublish: busy === null && canPublish, onDiscard: () => resetDraft(detail), onSave: () => void save(), onPublish: () => void publish() }), _jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
342
- if (!open)
343
- setPendingLocale(null);
344
- }, title: t("documentDesigner.localeDialog.title"), description: t("documentDesigner.localeDialog.description"), confirmLabel: t("documentDesigner.localeDialog.save"), cancelLabel: t("documentDesigner.localeDialog.cancel"), onConfirm: async () => {
345
- const target = pendingLocale;
346
- if (target !== null && (await save()))
347
- onLocaleChange?.(target);
348
- setPendingLocale(null);
349
- }, children: _jsx(Button, { variant: "danger-ghost", onClick: () => {
350
- const target = pendingLocale;
351
- setPendingLocale(null);
352
- resetDraft(detail);
353
- if (target !== null)
354
- onLocaleChange?.(target);
355
- }, children: t("documentDesigner.localeDialog.discard") }) })] }));
52
+ const editing = s.ui.mode === "edit";
53
+ return (_jsxs(EditorFrame, { testId: "pdf-template-editor", children: [renderPdfHeader(context), renderPdfInfoBar(context), _jsx(EditorBody, { left: editing ? renderPdfLeftRail(context) : undefined, center: editing ? renderPdfEditCanvas(context) : renderPdfPreviewPane(context), right: renderPdfSettings(context) }), renderPdfFooter(context), renderPdfLocaleDialog(context)] }));
356
54
  async function exportBundle() {
357
55
  const result = await dispatcher.query(TemplateResolverQueries.exportBundle, {
358
56
  slug,
@@ -360,7 +58,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
360
58
  scope: "system",
361
59
  });
362
60
  if (!result.isSuccess) {
363
- setActionError(describeError(t, result.error));
61
+ s.ui.setActionError(describeError(t, result.error));
364
62
  return;
365
63
  }
366
64
  const url = URL.createObjectURL(new Blob([JSON.stringify(result.data, null, 2)], { type: "application/json" }));