@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,105 @@
1
+ import type { LeaveTarget } from "./mail-dialogs.tsx";
2
+ import { type MailEditorBodyProps } from "./mail-editor-logic.ts";
3
+ import type { MailDraft, MailValidation, PickedValues } from "./mail-types.ts";
4
+ import type { MailServices, MailUiState } from "./use-mail-editor-parts.ts";
5
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
6
+ export type MailModelInput = {
7
+ readonly props: MailEditorBodyProps;
8
+ readonly t: Translate;
9
+ readonly ui: MailUiState;
10
+ readonly picked: PickedValues | null;
11
+ readonly setPicked: (next: PickedValues | null) => void;
12
+ readonly services: MailServices;
13
+ readonly validation: MailValidation | null;
14
+ readonly variableSchema: Readonly<Record<string, unknown>>;
15
+ readonly pickedVariables: PickedValues | null;
16
+ readonly locked: boolean;
17
+ readonly dirty: boolean;
18
+ };
19
+ export declare function buildMailModel(input: MailModelInput): {
20
+ update: (patch: Partial<MailDraft>) => void;
21
+ reset: () => void;
22
+ saveAndReload: (publish: boolean) => Promise<void>;
23
+ perform: (target: LeaveTarget) => void;
24
+ leave: (target: LeaveTarget) => void;
25
+ leaveSave: (target: LeaveTarget) => Promise<void>;
26
+ removeResource: (key: string) => Promise<void>;
27
+ sendTestMail: () => void;
28
+ ui: {
29
+ baseline: MailDraft;
30
+ draft: MailDraft;
31
+ setDraft: import("react").Dispatch<import("react").SetStateAction<MailDraft>>;
32
+ settled: MailDraft;
33
+ settle: (next: MailDraft) => void;
34
+ mode: import("./editor-header.tsx").EditorMode;
35
+ setMode: import("react").Dispatch<import("react").SetStateAction<import("./editor-header.tsx").EditorMode>>;
36
+ creating: boolean;
37
+ setCreating: import("react").Dispatch<import("react").SetStateAction<boolean>>;
38
+ dialog: import("./mail-dialogs.tsx").ConfirmDialog;
39
+ setDialog: import("react").Dispatch<import("react").SetStateAction<import("./mail-dialogs.tsx").ConfirmDialog>>;
40
+ uploadOpen: boolean;
41
+ setUploadOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
42
+ pdfOpen: boolean;
43
+ setPdfOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
44
+ resourceError: string | null;
45
+ setResourceError: import("react").Dispatch<import("react").SetStateAction<string | null>>;
46
+ resetError: string | null;
47
+ setResetError: import("react").Dispatch<import("react").SetStateAction<string | null>>;
48
+ editorEpoch: number;
49
+ setEditorEpoch: import("react").Dispatch<import("react").SetStateAction<number>>;
50
+ includeAttachments: boolean;
51
+ setIncludeAttachments: import("react").Dispatch<import("react").SetStateAction<boolean>>;
52
+ prefixSubject: boolean;
53
+ setPrefixSubject: import("react").Dispatch<import("react").SetStateAction<boolean>>;
54
+ };
55
+ services: {
56
+ insert: import("./use-insert-targets.ts").InsertTargets;
57
+ persistence: import("./use-mail-persistence.ts").MailPersistence;
58
+ testMail: import("./use-test-mail.ts").TestMail;
59
+ own: import("./use-own-address.ts").OwnAddress;
60
+ filesAvailable: boolean;
61
+ resourceRemove: import("@cosmicdrift/kumiko-renderer").UseMutationResult<unknown>;
62
+ layouts: import("./use-overview-rows.ts").OverviewRows;
63
+ pdfTemplates: import("./use-overview-rows.ts").OverviewRows;
64
+ };
65
+ validation: MailValidation | null;
66
+ picked: Readonly<Record<string, unknown>> | null;
67
+ setPicked: (next: PickedValues | null) => void;
68
+ pickedVariables: Readonly<Record<string, unknown>> | null;
69
+ variableSchema: Readonly<Record<string, unknown>>;
70
+ locked: boolean;
71
+ dirty: boolean;
72
+ busy: boolean;
73
+ canSave: boolean;
74
+ canPublish: boolean;
75
+ resources: readonly import("./mail-types.ts").LinkedResourceView[];
76
+ pdfTitles: Map<string, string>;
77
+ uploadTemplateId: string | null;
78
+ takenKeys: string[];
79
+ attachments: ({
80
+ readonly kind: "file";
81
+ readonly key: string;
82
+ readonly fileRefId: string;
83
+ readonly fileName: string;
84
+ } | {
85
+ readonly kind: "pdf";
86
+ readonly key: string;
87
+ readonly templateSlug: string;
88
+ readonly fileName: string;
89
+ })[];
90
+ resourcePreviews: {
91
+ [k: string]: {
92
+ src: string;
93
+ caption: string;
94
+ };
95
+ };
96
+ railValidation: MailValidation | null;
97
+ vocabulary: import("./mail-edit-center.tsx").EditorVocabulary;
98
+ footer: {
99
+ readonly tone: "bad" | "warn" | "ok";
100
+ readonly text: string;
101
+ } | null;
102
+ fieldErrors: Partial<Record<"previewText" | "subject", string>>;
103
+ };
104
+ export type MailEditorModel = ReturnType<typeof buildMailModel>;
105
+ export {};
@@ -0,0 +1,114 @@
1
+ import { dispatcherErrorText } from "@cosmicdrift/kumiko-renderer";
2
+ import { parseLinkedResources } from "./mail-draft.js";
3
+ import { mailSaveFlags, railValidationOf, resourcePreviewsOfMail, testMailPayload, vocabularyOf, } from "./mail-editor-logic.js";
4
+ import { footerMessage, metaFieldMessages } from "./mail-footer.js";
5
+ import { templateTitleText } from "./used-by-label.js";
6
+ function mailActions(input) {
7
+ const { props, ui, services } = input;
8
+ const { draft, baseline, settle } = ui;
9
+ const { persistence } = services;
10
+ const update = (patch) => {
11
+ const next = { ...draft, ...patch };
12
+ ui.setDraft(next);
13
+ settle(next);
14
+ };
15
+ const reset = () => {
16
+ ui.setDraft(baseline);
17
+ settle(baseline);
18
+ ui.setEditorEpoch((epoch) => epoch + 1);
19
+ persistence.clearError();
20
+ };
21
+ const saveAndReload = async (publish) => {
22
+ if ((await persistence.save(draft, publish)) !== "failed")
23
+ await props.onReload();
24
+ };
25
+ const perform = (target) => props.onLocaleChange(target.locale);
26
+ const leave = (target) => {
27
+ // skip: the target locale is already open
28
+ if (target.locale === props.locale)
29
+ return;
30
+ if (input.dirty)
31
+ ui.setDialog({ kind: "leave", target });
32
+ else
33
+ perform(target);
34
+ };
35
+ const leaveSave = async (target) => {
36
+ if ((await persistence.save(draft, false)) === "saved")
37
+ perform(target);
38
+ };
39
+ return { update, reset, saveAndReload, perform, leave, leaveSave };
40
+ }
41
+ function resourceActions(input, uploadTemplateId) {
42
+ const { props, t, ui, services } = input;
43
+ const removeResource = async (key) => {
44
+ // skip: only tenant rows own removable resources
45
+ if (uploadTemplateId === null)
46
+ return;
47
+ ui.setResourceError(null);
48
+ const result = await services.resourceRemove.mutate({ templateId: uploadTemplateId, key });
49
+ if (result.isSuccess)
50
+ await props.onRefreshRow();
51
+ else
52
+ ui.setResourceError(dispatcherErrorText(result.error, t));
53
+ };
54
+ const sendTestMail = () => {
55
+ void services.testMail.send(testMailPayload({
56
+ slug: props.slug,
57
+ locale: props.locale,
58
+ draft: ui.draft,
59
+ variableSchema: input.variableSchema,
60
+ includeAttachments: ui.includeAttachments,
61
+ prefixSubject: ui.prefixSubject,
62
+ pickedVariables: input.pickedVariables,
63
+ }));
64
+ };
65
+ return { removeResource, sendTestMail };
66
+ }
67
+ export function buildMailModel(input) {
68
+ const { props, t, ui, services, validation, locked } = input;
69
+ const { row } = props;
70
+ const busy = services.persistence.busy;
71
+ const { canSave, canPublish } = mailSaveFlags({
72
+ locked,
73
+ busy,
74
+ validation,
75
+ dirty: input.dirty,
76
+ creating: ui.creating,
77
+ row,
78
+ });
79
+ const resources = parseLinkedResources(row?.linkedResources);
80
+ const pdfTitles = new Map(services.pdfTemplates.rows.map((template) => [
81
+ template.slug,
82
+ templateTitleText(t, template.title),
83
+ ]));
84
+ const uploadTemplateId = row?.scope === "tenant" ? row.id : null;
85
+ const footer = locked || validation === undefined || validation === null
86
+ ? null
87
+ : footerMessage(t, validation, ui.mode === "preview" && services.testMail.availability.canSend);
88
+ return {
89
+ ui,
90
+ services,
91
+ validation,
92
+ picked: input.picked,
93
+ setPicked: input.setPicked,
94
+ pickedVariables: input.pickedVariables,
95
+ variableSchema: input.variableSchema,
96
+ locked,
97
+ dirty: input.dirty,
98
+ busy,
99
+ canSave,
100
+ canPublish,
101
+ ...mailActions(input),
102
+ ...resourceActions(input, uploadTemplateId),
103
+ resources,
104
+ pdfTitles,
105
+ uploadTemplateId,
106
+ takenKeys: resources.map((resource) => resource.key),
107
+ attachments: resources.filter((resource) => resource.kind !== "image"),
108
+ resourcePreviews: resourcePreviewsOfMail(resources, t),
109
+ railValidation: railValidationOf(locked, validation),
110
+ vocabulary: vocabularyOf(t, validation),
111
+ footer,
112
+ fieldErrors: locked ? {} : metaFieldMessages(t, validation),
113
+ };
114
+ }
@@ -0,0 +1,11 @@
1
+ import type { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ import type { MailEditorBodyProps } from "./mail-editor-logic.ts";
4
+ import type { MailEditorModel } from "./mail-editor-model.ts";
5
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
6
+ type Banner = ReturnType<typeof usePrimitives>["Banner"];
7
+ export declare function renderMailControls(props: MailEditorBodyProps, m: MailEditorModel): ReactNode;
8
+ export declare function renderMailBanners(Banner: Banner, t: Translate, m: MailEditorModel): ReactNode;
9
+ export declare function renderMailEditBody(props: MailEditorBodyProps, m: MailEditorModel): ReactNode;
10
+ export declare function renderMailModals(props: MailEditorBodyProps, m: MailEditorModel): ReactNode;
11
+ export {};
@@ -0,0 +1,51 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { EditorLocaleSwitch } from "./editor-header.js";
3
+ import { EditorBody } from "./editor-shell.js";
4
+ import { MailEditCenter } from "./mail-edit-center.js";
5
+ import { PlaceholderSection, ResourceSection, SystemPlaceholderSection, } from "./mail-left-rail.js";
6
+ import { MissingLocaleCanvas } from "./mail-missing-locale.js";
7
+ import { PdfLinkModal } from "./mail-pdf-link-modal.js";
8
+ import { AttachmentSection, ChromeSection, PropertiesSection, UsageSection, } from "./mail-right-rail.js";
9
+ import { UploadModal } from "./mail-upload-modal.js";
10
+ import { VersionsSection } from "./mail-versions.js";
11
+ export function renderMailControls(props, m) {
12
+ return (_jsx(EditorLocaleSwitch, { testIdPrefix: "mail", locales: props.locales, locale: props.locale, onLocale: (next) => m.leave({ kind: "locale", locale: next }) }));
13
+ }
14
+ export function renderMailBanners(Banner, t, m) {
15
+ const { testMail } = m.services;
16
+ const { resetError, resourceError } = m.ui;
17
+ return (_jsxs(_Fragment, { children: [testMail.state.kind === "sent" && (_jsx(Banner, { variant: "primary", testId: "mail-test-sent", children: t("documentDesigner.mail.testMail.sent", { subject: testMail.state.subject }) })), testMail.state.kind === "error" && (_jsx(Banner, { variant: "error", testId: "mail-test-error", children: testMail.state.text })), resetError !== null && (_jsx(Banner, { variant: "error", testId: "mail-reset-error", children: resetError })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError }))] }));
18
+ }
19
+ function renderLeftRail(m) {
20
+ const { insert, filesAvailable } = m.services;
21
+ const { setUploadOpen, setDialog } = m.ui;
22
+ return (_jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: m.railValidation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: m.railValidation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: m.resources, pdfTitles: m.pdfTitles, canUpload: m.uploadTemplateId !== null, filesAvailable: filesAvailable, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }));
23
+ }
24
+ function renderCenterCanvas(props, m) {
25
+ if (m.locked) {
26
+ return (_jsx(MissingLocaleCanvas, { slug: props.slug, locale: props.locale, fallbackLocale: props.fallbackLocale, copyScope: props.isSystemAdmin ? "system" : "tenant", onCreateBlank: () => m.ui.setCreating(true), onCopied: () => void props.onReload() }));
27
+ }
28
+ return (_jsx(MailEditCenter, { draft: m.ui.draft, vocabulary: m.vocabulary, resourcePreviews: m.resourcePreviews, insert: m.services.insert, readOnly: false, fieldErrors: m.fieldErrors, onChange: m.update }, m.ui.editorEpoch));
29
+ }
30
+ function renderRightRail(props, m) {
31
+ const { row } = props;
32
+ return (_jsxs(_Fragment, { children: [_jsx(PropertiesSection, { slug: props.slug, draft: m.ui.draft, layouts: m.services.layouts.rows, onChange: m.update }), _jsx(ChromeSection, { draft: m.ui.draft, vocabulary: m.vocabulary, insert: m.services.insert, onChange: m.update }), _jsx(AttachmentSection, { attachments: m.attachments, pdfTitles: m.pdfTitles, canEdit: m.uploadTemplateId !== null, onAddPdf: () => m.ui.setPdfOpen(true), onRemove: (key) => m.ui.setDialog({ kind: "remove", key }) }), _jsx(UsageSection, { slug: props.slug }), _jsx(VersionsSection, { rowId: row?.id ?? null, currentVersion: row?.version ?? 0, currentStatus: row?.status ?? "draft" })] }));
33
+ }
34
+ export function renderMailEditBody(props, m) {
35
+ return (_jsx(EditorBody, { left: renderLeftRail(m), center: renderCenterCanvas(props, m), right: renderRightRail(props, m) }));
36
+ }
37
+ export function renderMailModals(props, m) {
38
+ const { uploadTemplateId, takenKeys } = m;
39
+ if (uploadTemplateId === null)
40
+ return false; // skip: only tenant rows accept uploads
41
+ const { insert, pdfTemplates } = m.services;
42
+ return (_jsxs(_Fragment, { children: [_jsx(UploadModal, { open: m.ui.uploadOpen, onOpenChange: m.ui.setUploadOpen, target: {
43
+ templateId: uploadTemplateId,
44
+ takenKeys,
45
+ onUploaded: (uploaded) => {
46
+ if (uploaded.purpose === "image")
47
+ insert.insertResource(uploaded.key);
48
+ void props.onRefreshRow();
49
+ },
50
+ } }), _jsx(PdfLinkModal, { open: m.ui.pdfOpen, onOpenChange: m.ui.setPdfOpen, templates: pdfTemplates.rows, variableLabels: m.vocabulary.labels, templateId: uploadTemplateId, takenKeys: takenKeys, onLinked: () => void props.onRefreshRow() })] }));
51
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from "react";
2
+ import type { MailEditorBodyProps } from "./mail-editor-logic.ts";
3
+ import type { MailEditorModel } from "./mail-editor-model.ts";
4
+ import { MailPreviewMode } from "./mail-preview.tsx";
5
+ export declare function renderMailPreview(props: MailEditorBodyProps, m: MailEditorModel, controls: ReactNode, origin: React.ComponentProps<typeof MailPreviewMode>["origin"]): ReactNode;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { previewPayload } from "./mail-editor-logic.js";
3
+ import { MailPreviewMode } from "./mail-preview.js";
4
+ export function renderMailPreview(props, m, controls, origin) {
5
+ const { ui, services } = m;
6
+ const { testMail } = services;
7
+ return (_jsx(MailPreviewMode, { payload: previewPayload({
8
+ slug: props.slug,
9
+ locale: props.locale,
10
+ settled: ui.settled,
11
+ variableSchema: m.variableSchema,
12
+ pickedVariables: m.pickedVariables,
13
+ }), picked: m.picked, onPick: m.setPicked, validation: m.validation ?? null, slug: props.slug, locale: props.locale, fromName: ui.settled.senderName, own: services.own, origin: origin, controls: controls, testMail: {
14
+ availability: testMail.availability,
15
+ pending: testMail.pending,
16
+ onSend: m.sendTestMail,
17
+ includeAttachments: ui.includeAttachments,
18
+ onIncludeAttachments: ui.setIncludeAttachments,
19
+ prefixSubject: ui.prefixSubject,
20
+ onPrefixSubject: ui.setPrefixSubject,
21
+ } }));
22
+ }
@@ -0,0 +1,27 @@
1
+ import type { useDispatcher } from "@cosmicdrift/kumiko-renderer";
2
+ import { type Draft, type EditorState } from "./pdf-editor-draft.ts";
3
+ import { type TemplateDetail } from "./types.ts";
4
+ import type { PdfEditorState } from "./use-pdf-editor-state.ts";
5
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
6
+ export type PdfActionsInput = {
7
+ readonly s: PdfEditorState;
8
+ readonly t: Translate;
9
+ readonly dispatcher: ReturnType<typeof useDispatcher>;
10
+ readonly slug: string;
11
+ readonly current: EditorState;
12
+ readonly detail: TemplateDetail;
13
+ readonly dirty: boolean;
14
+ readonly onLocaleChange: ((locale: string) => void) | undefined;
15
+ };
16
+ export declare function buildPdfActions(input: PdfActionsInput): {
17
+ updateDraft: (next: Draft) => void;
18
+ resetDraft: (source: TemplateDetail) => void;
19
+ save: () => Promise<boolean>;
20
+ publish: () => Promise<void>;
21
+ generatePdf: () => Promise<void>;
22
+ printOnce: () => void;
23
+ uploadResource: (file: File) => Promise<void>;
24
+ requestLocale: (next: string) => void;
25
+ };
26
+ export type PdfActions = ReturnType<typeof buildPdfActions>;
27
+ export {};
@@ -0,0 +1,146 @@
1
+ import { TemplateResolverHandlers } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
+ import { uploadFileRef } from "@cosmicdrift/kumiko-renderer/web";
3
+ import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
4
+ import { describeError, isAlreadyActive } from "./errors.js";
5
+ import { draftFromDetail, SETTLE_MS, snapshotOf, } from "./pdf-editor-draft.js";
6
+ import { serializePdfMeta } from "./pdf-meta.js";
7
+ import { DOCUMENT_PDF_KIND, } from "./types.js";
8
+ function draftActions(input) {
9
+ const { s } = input;
10
+ const { setState } = s.doc;
11
+ const { settleTimer } = s;
12
+ function updateDraft(next) {
13
+ setState((previous) => (previous === null ? previous : { ...previous, draft: next }));
14
+ if (settleTimer.current !== null)
15
+ clearTimeout(settleTimer.current);
16
+ settleTimer.current = setTimeout(() => {
17
+ setState((previous) => previous === null ? previous : { ...previous, settled: previous.draft });
18
+ }, SETTLE_MS);
19
+ }
20
+ function resetDraft(source) {
21
+ const draft = draftFromDetail(source);
22
+ if (settleTimer.current !== null)
23
+ clearTimeout(settleTimer.current);
24
+ setState({ key: source.id, draft, settled: draft, snapshot: snapshotOf(draft) });
25
+ s.ui.setDraftEpoch((epoch) => epoch + 1);
26
+ s.ui.setActionError(null);
27
+ }
28
+ return { updateDraft, resetDraft };
29
+ }
30
+ function writeActions(input) {
31
+ const { s, t, dispatcher, slug, current, detail, dirty } = input;
32
+ const { locale, rowsQuery, detailQuery, setState } = s.doc;
33
+ const { variableSchema, validationQuery } = s.server;
34
+ const { scope } = s.host;
35
+ const { setBusy, setActionError } = s.ui;
36
+ async function reload() {
37
+ await Promise.all([rowsQuery.refetch(), detailQuery.refetch(), validationQuery.refetch()]);
38
+ }
39
+ async function persist() {
40
+ const { draft } = current;
41
+ const parentTemplateId = detail?.scope === "system" ? detail.id : detail?.parentTemplateId;
42
+ const result = await dispatcher.write(scope === "system"
43
+ ? TemplateResolverHandlers.upsertSystem
44
+ : TemplateResolverHandlers.upsertTenant, {
45
+ slug,
46
+ kind: DOCUMENT_PDF_KIND,
47
+ locale,
48
+ content: draft.content,
49
+ contentFormat: "html",
50
+ variableSchema,
51
+ meta: serializePdfMeta(draft.meta),
52
+ ...(scope === "tenant" && parentTemplateId != null && { parentTemplateId }),
53
+ });
54
+ if (!result.isSuccess) {
55
+ setActionError(describeError(t, result.error));
56
+ return null;
57
+ }
58
+ setState((previous) => previous === null ? previous : { ...previous, snapshot: snapshotOf(draft) });
59
+ return result.data;
60
+ }
61
+ async function save() {
62
+ setBusy("save");
63
+ setActionError(null);
64
+ const saved = await persist();
65
+ if (saved !== null)
66
+ await reload();
67
+ setBusy(null);
68
+ return saved !== null;
69
+ }
70
+ async function publish() {
71
+ setBusy("publish");
72
+ setActionError(null);
73
+ // A system save activates immediately; a tenant row needs a save first when it
74
+ // does not exist yet or carries unsaved edits, then an explicit publish.
75
+ const needsSave = dirty || detail?.scope === "system";
76
+ const saved = needsSave ? await persist() : null;
77
+ const saveFailed = needsSave && saved === null;
78
+ // upsertSystem already activates, so only a tenant row needs the explicit publish.
79
+ const alreadyActivatedBySave = scope === "system" && saved !== null;
80
+ const templateId = saved?.id ?? detail?.id ?? null;
81
+ if (templateId !== null && !saveFailed && !alreadyActivatedBySave) {
82
+ const result = await dispatcher.write(scope === "system"
83
+ ? TemplateResolverHandlers.publishSystem
84
+ : TemplateResolverHandlers.publish, { id: templateId });
85
+ if (!result.isSuccess && !isAlreadyActive(result.error))
86
+ setActionError(describeError(t, result.error));
87
+ }
88
+ await reload();
89
+ setBusy(null);
90
+ }
91
+ return { save, publish };
92
+ }
93
+ function pdfActions(input) {
94
+ const { s, t, dispatcher, slug, current, detail, dirty, onLocaleChange } = input;
95
+ const { locale, detailQuery } = s.doc;
96
+ const { variableSchema } = s.server;
97
+ const { setMode, setBusy, setActionError, setGenerated, autoPrintedFor, setPendingLocale } = s.ui;
98
+ async function generatePdf() {
99
+ setMode("preview");
100
+ setBusy("pdf");
101
+ setActionError(null);
102
+ const forSnapshot = snapshotOf(current.draft);
103
+ const result = await dispatcher.query(RendererPuppeteerClientQueries.previewPdf, {
104
+ slug,
105
+ locale,
106
+ content: current.draft.content,
107
+ meta: serializePdfMeta(current.draft.meta),
108
+ variableSchema,
109
+ });
110
+ if (result.isSuccess)
111
+ setGenerated({ forSnapshot, result: result.data });
112
+ else
113
+ setActionError(describeError(t, result.error));
114
+ setBusy(null);
115
+ }
116
+ function printOnce() {
117
+ // skip: this draft was already printed
118
+ if (autoPrintedFor.current === snapshotOf(current.draft))
119
+ return;
120
+ autoPrintedFor.current = snapshotOf(current.draft);
121
+ void generatePdf();
122
+ }
123
+ async function uploadResource(file) {
124
+ const sourceFileRefId = await uploadFileRef(file);
125
+ const result = await dispatcher.write(TemplateResolverHandlers.resourceUpload, {
126
+ templateId: detail?.id,
127
+ sourceFileRefId,
128
+ });
129
+ if (!result.isSuccess)
130
+ throw new Error(describeError(t, result.error));
131
+ await detailQuery.refetch();
132
+ }
133
+ function requestLocale(next) {
134
+ // skip: the requested locale is already open
135
+ if (next === locale)
136
+ return;
137
+ if (dirty)
138
+ setPendingLocale(next);
139
+ else
140
+ onLocaleChange?.(next);
141
+ }
142
+ return { generatePdf, printOnce, uploadResource, requestLocale };
143
+ }
144
+ export function buildPdfActions(input) {
145
+ return { ...draftActions(input), ...writeActions(input), ...pdfActions(input) };
146
+ }
@@ -0,0 +1,6 @@
1
+ import type { ReactNode } from "react";
2
+ import type { PdfRenderContext } from "./pdf-editor-context.ts";
3
+ export declare function renderPdfHeader(c: PdfRenderContext): ReactNode;
4
+ export declare function renderPdfInfoBar(c: PdfRenderContext): ReactNode;
5
+ export declare function renderPdfFooter(c: PdfRenderContext): ReactNode;
6
+ export declare function renderPdfLocaleDialog(c: PdfRenderContext): ReactNode;
@@ -0,0 +1,60 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { EditorHeader, EditorLocaleSwitch, EditorModeSwitch, originOf } from "./editor-header.js";
3
+ import { EditorFooter, EditorInfoBar } from "./editor-shell.js";
4
+ import { OverridesDrawer } from "./overrides-drawer.js";
5
+ export function renderPdfHeader(c) {
6
+ const { s, view, detail, actions } = c;
7
+ const { busy, mode, setMode } = s.ui;
8
+ return (_jsx(EditorHeader, { title: view.title, stored: { status: detail.status, version: detail.version }, actions: {
9
+ busy: busy === "save",
10
+ canSave: busy === null && s.server.validation?.canSave !== false,
11
+ canPublish: busy === null && view.canPublish,
12
+ onSave: () => void actions.save(),
13
+ onPublish: () => void actions.publish(),
14
+ }, children: _jsx(EditorModeSwitch, { testIdPrefix: "pdf", mode: mode, onMode: setMode }) }));
15
+ }
16
+ function renderInfoControls(c) {
17
+ const { t, Button, s, view, actions } = c;
18
+ const { busy, mode } = s.ui;
19
+ const { locale, locales } = s.doc;
20
+ return (_jsxs(_Fragment, { children: [_jsx(EditorLocaleSwitch, { testIdPrefix: "pdf", locales: locales.length > 0 ? locales : [locale], locale: locale, onLocale: actions.requestLocale }), mode === "preview" && (_jsx(Button, { variant: "secondary", disabled: !view.pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void actions.generatePdf(), testId: "pdf-generate-header", children: t("documentDesigner.actions.generatePdf") }))] }));
21
+ }
22
+ function renderOverrideLinks(c) {
23
+ const { t, Button, s, view } = c;
24
+ return (view.showOverrideLinks && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "link", onClick: () => void c.exportBundle(), children: t("documentDesigner.originBar.export") }), _jsx(Button, { variant: "link", onClick: () => s.ui.setOverridesOpen(true), children: t("documentDesigner.originBar.showOverrides") })] })));
25
+ }
26
+ function renderUnavailableNote(c) {
27
+ const { t, view } = c;
28
+ return (view.pdfUnavailableNote && (_jsxs(_Fragment, { children: [" ", _jsx("span", { "data-testid": "pdf-unavailable", children: view.unavailableMessage === ""
29
+ ? t("documentDesigner.pdfUnavailableNoReason")
30
+ : t("documentDesigner.pdfUnavailable", { message: view.unavailableMessage }) })] })));
31
+ }
32
+ export function renderPdfInfoBar(c) {
33
+ const { s, view, detail, slug } = c;
34
+ const { overridesOpen, setOverridesOpen } = s.ui;
35
+ return (_jsxs(EditorInfoBar, { tone: view.pdfUnavailableNote ? "warn" : detail.scope === "tenant" ? "active" : "muted", testId: "pdf-origin-bar", origin: originOf(detail.scope === "system", view.systemVersion !== null), controls: renderInfoControls(c), overlays: _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, locale: s.doc.locale, overrides: s.server.overridesQuery.data }), links: renderOverrideLinks(c), children: [view.originText, view.besideActiveSentence !== null && ` ${view.besideActiveSentence}`, renderUnavailableNote(c)] }));
36
+ }
37
+ export function renderPdfFooter(c) {
38
+ const { s, view, detail, actions } = c;
39
+ const { busy } = s.ui;
40
+ return (_jsx(EditorFooter, { testIdPrefix: "pdf-editor", message: view.footer, saveError: s.ui.actionError, modifiedAt: detail.modifiedAt ?? null, dirty: view.dirty, busy: busy === "save", canSave: busy === null && s.server.validation?.canSave !== false, canPublish: busy === null && view.canPublish, onDiscard: () => actions.resetDraft(detail), onSave: () => void actions.save(), onPublish: () => void actions.publish() }));
41
+ }
42
+ export function renderPdfLocaleDialog(c) {
43
+ const { t, Button, Dialog, s, actions, detail, onLocaleChange } = c;
44
+ const { pendingLocale, setPendingLocale } = s.ui;
45
+ return (_jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
46
+ if (!open)
47
+ setPendingLocale(null);
48
+ }, title: t("documentDesigner.localeDialog.title"), description: t("documentDesigner.localeDialog.description"), confirmLabel: t("documentDesigner.localeDialog.save"), cancelLabel: t("documentDesigner.localeDialog.cancel"), onConfirm: async () => {
49
+ const target = pendingLocale;
50
+ if (target !== null && (await actions.save()))
51
+ onLocaleChange?.(target);
52
+ setPendingLocale(null);
53
+ }, children: _jsx(Button, { variant: "danger-ghost", onClick: () => {
54
+ const target = pendingLocale;
55
+ setPendingLocale(null);
56
+ actions.resetDraft(detail);
57
+ if (target !== null)
58
+ onLocaleChange?.(target);
59
+ }, children: t("documentDesigner.localeDialog.discard") }) }));
60
+ }
@@ -0,0 +1,22 @@
1
+ import type { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { PdfActions } from "./pdf-editor-actions.ts";
3
+ import type { EditorState } from "./pdf-editor-draft.ts";
4
+ import type { PdfView } from "./pdf-editor-view.ts";
5
+ import type { TemplateDetail } from "./types.ts";
6
+ import type { PdfEditorState } from "./use-pdf-editor-state.ts";
7
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
8
+ type Primitives = ReturnType<typeof usePrimitives>;
9
+ export type PdfRenderContext = {
10
+ readonly t: Translate;
11
+ readonly Button: Primitives["Button"];
12
+ readonly Dialog: Primitives["Dialog"];
13
+ readonly slug: string;
14
+ readonly s: PdfEditorState;
15
+ readonly actions: PdfActions;
16
+ readonly view: PdfView;
17
+ readonly current: EditorState;
18
+ readonly detail: TemplateDetail;
19
+ readonly onLocaleChange: ((locale: string) => void) | undefined;
20
+ readonly exportBundle: () => Promise<void>;
21
+ };
22
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { MailVariable } from "./mail-types.ts";
2
+ import { type PdfMetaDraft } from "./pdf-meta.ts";
3
+ import type { PdfPreviewResult, TemplateDetail, VariableInfo } from "./types.ts";
4
+ export declare const SETTLE_MS = 500;
5
+ export declare const NO_VARIABLES: Readonly<Record<string, VariableInfo>>;
6
+ export type Draft = {
7
+ readonly content: string;
8
+ readonly meta: PdfMetaDraft;
9
+ };
10
+ export type EditorState = {
11
+ readonly key: string;
12
+ readonly draft: Draft;
13
+ readonly settled: Draft;
14
+ readonly snapshot: string;
15
+ };
16
+ export type GeneratedPdf = {
17
+ readonly forSnapshot: string;
18
+ readonly result: PdfPreviewResult;
19
+ };
20
+ export declare const draftFromDetail: (detail: TemplateDetail) => Draft;
21
+ export declare const snapshotOf: (draft: Draft) => string;
22
+ export declare function imageLabelSource(variables: Readonly<Record<string, VariableInfo>>): Readonly<Record<string, MailVariable>>;
23
+ export declare function textVariablesOf(variables: Readonly<Record<string, VariableInfo>>): Readonly<Record<string, MailVariable>>;
@@ -0,0 +1,33 @@
1
+ import { parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
2
+ export const SETTLE_MS = 500;
3
+ export const NO_VARIABLES = {};
4
+ export const draftFromDetail = (detail) => ({
5
+ content: detail.content,
6
+ meta: parsePdfMeta(detail.meta),
7
+ });
8
+ export const snapshotOf = (draft) => JSON.stringify({ content: draft.content, meta: serializePdfMeta(draft.meta) });
9
+ // Only the label of an images variable is needed, for the Images block and its picker.
10
+ export function imageLabelSource(variables) {
11
+ const result = {};
12
+ for (const [name, info] of Object.entries(variables)) {
13
+ if (info.type === "images" && info.label !== undefined) {
14
+ result[name] = { type: "text", label: info.label };
15
+ }
16
+ }
17
+ return result;
18
+ }
19
+ export function textVariablesOf(variables) {
20
+ const result = {};
21
+ for (const [name, info] of Object.entries(variables)) {
22
+ if (info.type === "images")
23
+ continue;
24
+ const { item, ...rest } = info;
25
+ const columns = Object.entries(item ?? {}).flatMap(([column, def]) => def.type === "images" ? [] : [[column, { ...def, type: def.type }]]);
26
+ result[name] = {
27
+ ...rest,
28
+ type: info.type,
29
+ ...(item !== undefined && { item: Object.fromEntries(columns) }),
30
+ };
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,6 @@
1
+ import { type ReactNode } from "react";
2
+ import type { PdfRenderContext } from "./pdf-editor-context.ts";
3
+ export declare function renderPdfLeftRail(c: PdfRenderContext): ReactNode;
4
+ export declare function renderPdfEditCanvas(c: PdfRenderContext): ReactNode;
5
+ export declare function renderPdfPreviewPane(c: PdfRenderContext): ReactNode;
6
+ export declare function renderPdfSettings(c: PdfRenderContext): ReactNode;