@cosmicdrift/kumiko-document-designer 0.389.0 → 0.390.1

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 (42) hide show
  1. package/dist/web/layout-editor-body.d.ts +3 -14
  2. package/dist/web/layout-editor-body.js +25 -172
  3. package/dist/web/layout-editor-center.d.ts +7 -0
  4. package/dist/web/layout-editor-center.js +23 -0
  5. package/dist/web/layout-editor-logic.d.ts +97 -0
  6. package/dist/web/layout-editor-logic.js +143 -0
  7. package/dist/web/layout-editor-paper.d.ts +4 -0
  8. package/dist/web/layout-editor-paper.js +24 -0
  9. package/dist/web/mail-editor-body.d.ts +4 -16
  10. package/dist/web/mail-editor-body.js +27 -218
  11. package/dist/web/mail-editor-logic.d.ts +70 -0
  12. package/dist/web/mail-editor-logic.js +81 -0
  13. package/dist/web/mail-editor-model.d.ts +105 -0
  14. package/dist/web/mail-editor-model.js +114 -0
  15. package/dist/web/mail-editor-render.d.ts +11 -0
  16. package/dist/web/mail-editor-render.js +51 -0
  17. package/dist/web/mail-preview-render.d.ts +5 -0
  18. package/dist/web/mail-preview-render.js +22 -0
  19. package/dist/web/pdf-editor-actions.d.ts +27 -0
  20. package/dist/web/pdf-editor-actions.js +146 -0
  21. package/dist/web/pdf-editor-chrome.d.ts +6 -0
  22. package/dist/web/pdf-editor-chrome.js +60 -0
  23. package/dist/web/pdf-editor-context.d.ts +22 -0
  24. package/dist/web/pdf-editor-context.js +1 -0
  25. package/dist/web/pdf-editor-draft.d.ts +23 -0
  26. package/dist/web/pdf-editor-draft.js +33 -0
  27. package/dist/web/pdf-editor-panes.d.ts +6 -0
  28. package/dist/web/pdf-editor-panes.js +40 -0
  29. package/dist/web/pdf-editor-view.d.ts +52 -0
  30. package/dist/web/pdf-editor-view.js +89 -0
  31. package/dist/web/pdf-template-editor.d.ts +1 -1
  32. package/dist/web/pdf-template-editor.js +40 -342
  33. package/dist/web/use-layout-editor-state.d.ts +62 -0
  34. package/dist/web/use-layout-editor-state.js +155 -0
  35. package/dist/web/use-mail-editor-parts.d.ts +42 -0
  36. package/dist/web/use-mail-editor-parts.js +74 -0
  37. package/dist/web/use-mail-editor-state.d.ts +89 -0
  38. package/dist/web/use-mail-editor-state.js +55 -0
  39. package/dist/web/use-pdf-editor-state.d.ts +77 -0
  40. package/dist/web/use-pdf-editor-state.js +161 -0
  41. package/package.json +6 -6
  42. package/src/changes.json +18 -0
@@ -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" }));
@@ -0,0 +1,62 @@
1
+ import { type EditorTab, type Region, type Translate } from "./layout-editor-logic.ts";
2
+ import type { BrandingView, LayoutKind, LayoutSourceView, LayoutValidation, TenantOverrideCounts } from "./layout-types.ts";
3
+ import type { OverviewRowView } from "./overview-row.ts";
4
+ export type LayoutEditorBodyProps = {
5
+ readonly kind: LayoutKind;
6
+ readonly slug: string;
7
+ readonly isSystemAdmin: boolean;
8
+ readonly source: LayoutSourceView;
9
+ readonly overviewRows: readonly OverviewRowView[];
10
+ readonly usedByCount: number;
11
+ readonly overrides: TenantOverrideCounts | null;
12
+ readonly onKindChange: (kind: LayoutKind) => void;
13
+ readonly onReload: () => Promise<void>;
14
+ };
15
+ export declare function useLayoutEditorState(props: LayoutEditorBodyProps, t: Translate): {
16
+ isMail: boolean;
17
+ showMjmlTab: boolean;
18
+ tab: EditorTab;
19
+ setTab: import("react").Dispatch<import("react").SetStateAction<EditorTab>>;
20
+ header: string;
21
+ setHeader: import("react").Dispatch<import("react").SetStateAction<string>>;
22
+ signature: string;
23
+ setSignature: import("react").Dispatch<import("react").SetStateAction<string>>;
24
+ footer: string;
25
+ setFooter: import("react").Dispatch<import("react").SetStateAction<string>>;
26
+ mjml: string;
27
+ setMjml: import("react").Dispatch<import("react").SetStateAction<string>>;
28
+ appearance: import("./layout-meta.ts").LayoutAppearance;
29
+ setAppearance: import("react").Dispatch<import("react").SetStateAction<import("./layout-meta.ts").LayoutAppearance>>;
30
+ activeRegion: Region;
31
+ setActiveRegion: import("react").Dispatch<import("react").SetStateAction<Region>>;
32
+ toolbarHost: HTMLElement | null;
33
+ setToolbarHost: import("react").Dispatch<import("react").SetStateAction<HTMLElement | null>>;
34
+ pendingKind: LayoutKind | null;
35
+ setPendingKind: import("react").Dispatch<import("react").SetStateAction<LayoutKind | null>>;
36
+ saveError: string | null;
37
+ setSaveError: import("react").Dispatch<import("react").SetStateAction<string | null>>;
38
+ branding: import("@cosmicdrift/kumiko-renderer").UseQueryResult<BrandingView>;
39
+ mjmlMode: boolean;
40
+ appearanceFromMjml: boolean;
41
+ dirty: boolean;
42
+ checked: LayoutValidation | null;
43
+ shownRow: import("./layout-types.ts").LayoutSourceRow | null;
44
+ userCount: number;
45
+ placeholders: readonly string[];
46
+ placeholderLabels: Readonly<Record<string, string>>;
47
+ requestKind: (next: LayoutKind) => void;
48
+ save: (publish: boolean) => Promise<void>;
49
+ busy: boolean;
50
+ canSave: boolean;
51
+ canPublish: boolean;
52
+ footerMessage: import("./layout-editor-logic.ts").FooterMessage;
53
+ accentColor: string;
54
+ showAccentLine: boolean;
55
+ blocks: ("align" | "button" | "divider" | "image" | "pageBreak" | "repeat" | "table")[];
56
+ resourcePreviews: Readonly<Record<string, {
57
+ readonly src: string;
58
+ readonly caption: string;
59
+ }>> | undefined;
60
+ discard: () => void;
61
+ };
62
+ export type LayoutEditorModel = ReturnType<typeof useLayoutEditorState>;
@@ -0,0 +1,155 @@
1
+ import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
+ import { useMutation, useQuery } from "@cosmicdrift/kumiko-renderer";
3
+ import { useState } from "react";
4
+ import { BRANDING_QUERY_QN } from "./constants.js";
5
+ import { accentLineShown, appearanceFlags, footerMessageOf, initialTab, layoutBlocks, layoutContent, layoutDirty, layoutSaveFlags, resourcePreviewsOf, saveLayout, shownRowOf, } from "./layout-editor-logic.js";
6
+ import { appearanceOf } from "./layout-meta.js";
7
+ import { systemLabelMap } from "./mail-draft.js";
8
+ import { useDebouncedValue } from "./use-debounced-value.js";
9
+ // Server-side validation compiles the whole layout (mjml), so wait for a pause in typing.
10
+ const VALIDATE_DEBOUNCE_MS = 400;
11
+ export function useLayoutEditorState(props, t) {
12
+ const { kind, slug, isSystemAdmin, source, usedByCount, onKindChange, onReload } = props;
13
+ const isMail = kind === "mail-layout";
14
+ const showMjmlTab = isSystemAdmin && source.mjmlSource !== null;
15
+ const [tab, setTab] = useState(initialTab(showMjmlTab, source));
16
+ const [header, setHeader] = useState(source.regions.header);
17
+ const [signature, setSignature] = useState(source.regions.signature);
18
+ const [footer, setFooter] = useState(source.regions.footer);
19
+ const [mjml, setMjml] = useState(source.mjmlSource ?? "");
20
+ const storedAppearance = appearanceOf(source.meta);
21
+ const [appearance, setAppearance] = useState(storedAppearance);
22
+ const [activeRegion, setActiveRegion] = useState("header");
23
+ const [toolbarHost, setToolbarHost] = useState(null);
24
+ const [pendingKind, setPendingKind] = useState(null);
25
+ const [saveError, setSaveError] = useState(null);
26
+ const upsertTenant = useMutation(TemplateResolverHandlers.upsertTenant);
27
+ const upsertSystem = useMutation(TemplateResolverHandlers.upsertSystem);
28
+ const publishSystem = useMutation(TemplateResolverHandlers.publishSystem);
29
+ const branding = useQuery(BRANDING_QUERY_QN, {});
30
+ const mjmlMode = tab === "mjml";
31
+ const { appearanceFromMjml, appearanceEditable, appearanceValid } = appearanceFlags({
32
+ isMail,
33
+ mjmlMode,
34
+ source,
35
+ appearance,
36
+ });
37
+ const dirty = layoutDirty({
38
+ mjmlMode,
39
+ mjml,
40
+ header,
41
+ signature,
42
+ footer,
43
+ appearance,
44
+ storedAppearance,
45
+ appearanceEditable,
46
+ source,
47
+ });
48
+ const content = layoutContent({ mjmlMode, mjml, header, signature, footer, isMail });
49
+ const debouncedContent = useDebouncedValue(content, VALIDATE_DEBOUNCE_MS);
50
+ const validation = useQuery(TemplateResolverQueries.validate, {
51
+ slug,
52
+ kind,
53
+ contentFormat: mjmlMode ? "mjml" : "html",
54
+ content: debouncedContent,
55
+ locale: source.locale,
56
+ scope: isSystemAdmin ? "system" : "tenant",
57
+ });
58
+ const checked = validation.data;
59
+ const shownRow = shownRowOf(isSystemAdmin, source);
60
+ const userCount = usedByCount;
61
+ const placeholders = checked?.systemPlaceholders ?? [];
62
+ const placeholderLabels = systemLabelMap(t, placeholders);
63
+ const requestKind = (next) => {
64
+ // skip: the requested kind is already open
65
+ if (next === kind)
66
+ return;
67
+ if (dirty)
68
+ setPendingKind(next);
69
+ else
70
+ onKindChange(next);
71
+ };
72
+ const save = (publish) => saveLayout({
73
+ publish,
74
+ kind,
75
+ slug,
76
+ source,
77
+ content,
78
+ mjmlMode,
79
+ isMail,
80
+ isSystemAdmin,
81
+ appearanceEditable,
82
+ appearance,
83
+ t,
84
+ upsertTenant,
85
+ upsertSystem,
86
+ publishSystem,
87
+ setSaveError,
88
+ onReload,
89
+ });
90
+ const busy = upsertTenant.pending || upsertSystem.pending || publishSystem.pending;
91
+ const { canSave, canPublish } = layoutSaveFlags({
92
+ dirty,
93
+ checked,
94
+ appearanceValid,
95
+ busy,
96
+ shownRow,
97
+ });
98
+ const footerMessage = footerMessageOf({ t, checked, appearanceValid, isMail, userCount });
99
+ const accentColor = branding.data?.accentColor ?? "";
100
+ const showAccentLine = accentLineShown({ isMail, appearanceEditable, appearance, accentColor });
101
+ const blocks = layoutBlocks(isMail);
102
+ const resourcePreviews = resourcePreviewsOf(branding.data, isMail, t);
103
+ const discard = () => {
104
+ setHeader(source.regions.header);
105
+ setSignature(source.regions.signature);
106
+ setFooter(source.regions.footer);
107
+ setMjml(source.mjmlSource ?? "");
108
+ setAppearance(storedAppearance);
109
+ setSaveError(null);
110
+ };
111
+ return {
112
+ isMail,
113
+ showMjmlTab,
114
+ tab,
115
+ setTab,
116
+ header,
117
+ setHeader,
118
+ signature,
119
+ setSignature,
120
+ footer,
121
+ setFooter,
122
+ mjml,
123
+ setMjml,
124
+ appearance,
125
+ setAppearance,
126
+ activeRegion,
127
+ setActiveRegion,
128
+ toolbarHost,
129
+ setToolbarHost,
130
+ pendingKind,
131
+ setPendingKind,
132
+ saveError,
133
+ setSaveError,
134
+ branding,
135
+ mjmlMode,
136
+ appearanceFromMjml,
137
+ dirty,
138
+ checked,
139
+ shownRow,
140
+ userCount,
141
+ placeholders,
142
+ placeholderLabels,
143
+ requestKind,
144
+ save,
145
+ busy,
146
+ canSave,
147
+ canPublish,
148
+ footerMessage,
149
+ accentColor,
150
+ showAccentLine,
151
+ blocks,
152
+ resourcePreviews,
153
+ discard,
154
+ };
155
+ }