@cosmicdrift/kumiko-samples 0.363.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -6,6 +6,7 @@
6
6
  // never as `{{name}}` text, so the sanitizer's schema keeps them.
7
7
 
8
8
  import {
9
+ type ActionMenuItemSpec,
9
10
  TextareaContentEditor,
10
11
  usePrimitives,
11
12
  useTranslation,
@@ -15,6 +16,7 @@ import { cn } from "@cosmicdrift/kumiko-renderer-web";
15
16
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
16
17
  import type { Editor, JSONContent } from "@tiptap/core";
17
18
  import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
19
+ import { BubbleMenu } from "@tiptap/react/menus";
18
20
  import {
19
21
  AlignCenter,
20
22
  AlignJustify,
@@ -22,6 +24,7 @@ import {
22
24
  AlignRight,
23
25
  Bold as BoldIcon,
24
26
  Braces,
27
+ ChevronDown,
25
28
  Image as ImageIcon,
26
29
  Italic as ItalicIcon,
27
30
  Link as LinkIcon,
@@ -34,7 +37,8 @@ import {
34
37
  Table as TableIcon,
35
38
  Underline as UnderlineIcon,
36
39
  } from "lucide-react";
37
- import { type ReactNode, useEffect, useMemo, useState } from "react";
40
+ import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
41
+ import { createPortal } from "react-dom";
38
42
  import {
39
43
  DOCUMENT_TEXT_ALIGNMENTS,
40
44
  type DocumentTextAlignment,
@@ -43,7 +47,10 @@ import {
43
47
  isDocumentReferenceName,
44
48
  isDocumentResourceKey,
45
49
  } from "../document-schema.js";
46
- import { createDocumentEditorExtensions } from "./document-editor-extensions.js";
50
+ import {
51
+ createDocumentEditorExtensions,
52
+ DOCUMENT_BLOCK_ATTRIBUTE,
53
+ } from "./document-editor-extensions.js";
47
54
  import {
48
55
  type DocumentEditorProps,
49
56
  type DocumentResourcePreview,
@@ -65,6 +72,9 @@ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>
65
72
 
66
73
  type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align";
67
74
 
75
+ // Blocks that stay in the canvas toolbar; the rest moves into the overflow menu.
76
+ const CANVAS_INLINE_BLOCKS: readonly DocumentToolbarBlock[] = ["image", "table"];
77
+
68
78
  const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
69
79
  image: "image",
70
80
  button: "button",
@@ -137,6 +147,66 @@ type ParagraphStyle = (typeof PARAGRAPH_STYLES)[number];
137
147
 
138
148
  const HEADING_LEVELS = { "1": 1, "2": 2, "3": 3 } as const;
139
149
 
150
+ const PLACEHOLDER_HREF = /^\{\{([^{}]+)\}\}$/;
151
+
152
+ function buttonTargetLabel(href: string, labels: Readonly<Record<string, string>>): string {
153
+ const name = PLACEHOLDER_HREF.exec(href)?.[1];
154
+ if (name === undefined) return href;
155
+ return Object.hasOwn(labels, name) ? (labels[name] ?? name) : name;
156
+ }
157
+
158
+ // The bubble menu registers its plugin once per mount, so a rebuilt editor needs a fresh mount.
159
+ const editorMountKeys = new WeakMap<Editor, number>();
160
+ let lastMountKey = 0;
161
+ function mountKeyOf(editor: Editor): number {
162
+ const known = editorMountKeys.get(editor);
163
+ if (known !== undefined) return known;
164
+ lastMountKey += 1;
165
+ const created = lastMountKey;
166
+ editorMountKeys.set(editor, created);
167
+ return created;
168
+ }
169
+
170
+ // Board .hb: a small bar that straddles the block's top frame line on the right; above a table it sits fully
171
+ // outside so the header row stays readable.
172
+ const BUBBLE_BAR_CLASS =
173
+ "flex w-max items-center gap-1 rounded border border-input bg-popover p-px text-xs [&_button]:h-5 [&_button]:px-1.5 [&_button]:text-[10px]";
174
+
175
+ function bubbleOffset(editor: Editor): (args: { rects: { floating: { height: number } } }) => {
176
+ mainAxis: number;
177
+ crossAxis: number;
178
+ } {
179
+ return ({ rects }) => ({
180
+ mainAxis: findRepeatTable(editor) !== null ? 2 : -rects.floating.height / 2,
181
+ crossAxis: -8,
182
+ });
183
+ }
184
+
185
+ const BUBBLE_MENU_PLUGIN_KEY = "documentBlockOptions";
186
+
187
+ // The options bar belongs to the block frame, not to the caret inside it.
188
+ function selectedBlockReference(editor: Editor): {
189
+ getBoundingClientRect: () => DOMRect;
190
+ getClientRects: () => DOMRect[];
191
+ contextElement: Element;
192
+ } | null {
193
+ const { node } = editor.view.domAtPos(editor.state.selection.from);
194
+ const start = node instanceof Element ? node : node.parentElement;
195
+ const block = start?.closest(`[${DOCUMENT_BLOCK_ATTRIBUTE}], table`);
196
+ if (block === null || block === undefined) return null;
197
+ return {
198
+ getBoundingClientRect: () => block.getBoundingClientRect(),
199
+ getClientRects: () => [block.getBoundingClientRect()],
200
+ contextElement: block,
201
+ };
202
+ }
203
+
204
+ // The bubble is first measured before React has filled it, so it sits at the wrong place until a second pass.
205
+ function repositionBubble(editor: Editor): void {
206
+ if (editor.isDestroyed) return;
207
+ editor.view.dispatch(editor.state.tr.setMeta(BUBBLE_MENU_PLUGIN_KEY, "updatePosition"));
208
+ }
209
+
140
210
  function isParagraphStyle(value: string): value is ParagraphStyle {
141
211
  return isOneOf(PARAGRAPH_STYLES, value);
142
212
  }
@@ -266,6 +336,15 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
266
336
  const chain = editor.chain().focus();
267
337
  if (!editor.state.selection.empty) chain.setLink({ href }).run();
268
338
  else if (editor.isActive("link")) chain.extendMarkRange("link").setLink({ href }).run();
339
+ else if (isDocumentPlaceholderHref(href))
340
+ // Placeholders are never inserted as {{name}} text; the link shows the variable's value.
341
+ chain
342
+ .insertContent({
343
+ type: "variable",
344
+ attrs: { name: href.slice(2, -2) },
345
+ marks: [{ type: "link", attrs: { href } }],
346
+ })
347
+ .run();
269
348
  else
270
349
  chain
271
350
  .insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] })
@@ -434,17 +513,22 @@ export default function DocumentTiptapEditor({
434
513
  resourcePreviews = NO_PREVIEWS,
435
514
  chrome = "default",
436
515
  toolbar = "full",
516
+ renderPaper,
517
+ toolbarHost,
437
518
  onEditorReady,
438
519
  }: DocumentEditorProps): ReactNode {
439
520
  const t = useTranslation();
440
- const { Button, Field, Input } = usePrimitives();
521
+ const { ActionOverflowMenu, Button, Field, Input } = usePrimitives();
441
522
  const [openPanel, setOpenPanel] = useState<PanelKind | null>(null);
523
+ const [canvasScroller, setCanvasScroller] = useState<HTMLElement | null>(null);
442
524
  const [showHtml, setShowHtml] = useState(false);
443
525
  const canvas = chrome === "canvas";
444
526
  const compact = toolbar === "compact" && !canvas;
527
+ const hosted = toolbarHost !== undefined;
445
528
 
446
529
  const placeholderLabel = t("template-resolver.documentEditor.placeholder");
447
530
  const imageLabel = t("template-resolver.documentEditor.image");
531
+ const imageBlockLabel = t("template-resolver.documentEditor.imageBlock");
448
532
  const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
449
533
  const buttonLabel = t("template-resolver.documentEditor.button");
450
534
  const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
@@ -457,6 +541,7 @@ export default function DocumentTiptapEditor({
457
541
  image: imageLabel,
458
542
  pageBreak: pageBreakLabel,
459
543
  button: buttonLabel,
544
+ imageBlock: imageBlockLabel,
460
545
  variableLabels: stableLabels,
461
546
  systemVariables: stableSystem,
462
547
  resourcePreviews: stablePreviews,
@@ -465,6 +550,7 @@ export default function DocumentTiptapEditor({
465
550
  [
466
551
  placeholderLabel,
467
552
  imageLabel,
553
+ imageBlockLabel,
468
554
  pageBreakLabel,
469
555
  buttonLabel,
470
556
  stableLabels,
@@ -592,6 +678,11 @@ export default function DocumentTiptapEditor({
592
678
  ? (variableLabels[repeatTable.lists[0] ?? ""] ?? repeatTable.lists[0])
593
679
  : undefined;
594
680
 
681
+ const repeatBarLabel =
682
+ repeatListLabel === undefined
683
+ ? t("template-resolver.documentEditor.repeat")
684
+ : t("template-resolver.documentEditor.repeatBarList", { list: repeatListLabel });
685
+
595
686
  const paragraphStyleLabel = t("template-resolver.documentEditor.paragraphStyle");
596
687
  const styleOptions = [
597
688
  { value: "paragraph", label: t("template-resolver.documentEditor.paragraph") },
@@ -600,17 +691,18 @@ export default function DocumentTiptapEditor({
600
691
  { value: "3", label: t("template-resolver.documentEditor.heading3") },
601
692
  ];
602
693
 
603
- // Canvas chrome shows plain text buttons instead of icons.
694
+ // Canvas chrome is one flat row: unframed ghost buttons, glyph plus text for the longer names.
604
695
  const iconButton = (
605
696
  label: string,
606
697
  icon: ReactNode,
607
698
  isActive: boolean,
608
699
  onClick: () => void,
700
+ text?: string,
609
701
  ): ReactNode =>
610
702
  canvas ? (
611
703
  <Button
612
704
  type="button"
613
- variant={isActive ? "secondary" : "ghost"}
705
+ variant="ghost"
614
706
  size="sm"
615
707
  pressed={isActive}
616
708
  disabled={readOnly}
@@ -619,6 +711,7 @@ export default function DocumentTiptapEditor({
619
711
  title={label}
620
712
  >
621
713
  {icon}
714
+ {text}
622
715
  </Button>
623
716
  ) : (
624
717
  <Button
@@ -646,6 +739,7 @@ export default function DocumentTiptapEditor({
646
739
  readonly label: string;
647
740
  readonly icon: ReactNode;
648
741
  readonly showIcon: boolean;
742
+ readonly trailingIcon?: ReactNode;
649
743
  readonly expanded?: boolean;
650
744
  readonly onClick: () => void;
651
745
  }): ReactNode => {
@@ -664,112 +758,205 @@ export default function DocumentTiptapEditor({
664
758
  <Button key={spec.key} {...shared} size="sm">
665
759
  {spec.showIcon && spec.icon}
666
760
  {spec.label}
761
+ {spec.trailingIcon}
667
762
  </Button>
668
763
  );
669
764
  };
670
765
 
671
- return (
766
+ const editorContent = (
767
+ <EditorContent
768
+ editor={editor}
769
+ className={cn(
770
+ !canvas && "min-h-40 px-3 py-2 text-base outline-none md:text-sm",
771
+ canvas && "text-sm",
772
+ "[&_.ProseMirror-focused_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror-focused_.ProseMirror-selectednode]:ring-primary",
773
+ hosted
774
+ ? "[&_.ProseMirror]:outline-none"
775
+ : "[&_.ProseMirror]:min-h-40 [&_.ProseMirror]:outline-none",
776
+ "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6",
777
+ "[&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold [&_h3]:text-base [&_h3]:font-bold",
778
+ "[&_a]:underline [&_a]:text-primary",
779
+ "[&_strong]:font-bold [&_em]:italic [&_u]:underline",
780
+ canvas
781
+ ? "[&_table]:mb-2 [&_table]:mt-6 [&_.ProseMirror>:first-child_table]:mt-0 [&_.ProseMirror>table:first-child]:mt-0 [&_td:last-child>p[data-size=small][data-align=right]]:whitespace-nowrap"
782
+ : "[&_table]:my-2",
783
+ "[&_table]:w-full [&_table]:border-collapse",
784
+ "[&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-1.5 [&_td>p]:m-0 [&_th>p]:m-0",
785
+ "[&_th]:border-b [&_th]:border-border [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-xs [&_th]:font-medium [&_th]:text-muted-foreground",
786
+ "[&_tr[data-each]>td]:bg-muted/50",
787
+ "[&_[data-align=left]]:text-left [&_[data-align=center]]:text-center [&_[data-align=right]]:text-right [&_[data-align=justify]]:text-justify",
788
+ "[&_p[data-size=small]]:text-xs [&_p[data-size=small]]:text-muted-foreground",
789
+ "[&_a[data-button]]:inline-block [&_a[data-button]]:rounded-full [&_a[data-button]]:bg-primary [&_a[data-button]]:px-4 [&_a[data-button]]:py-1.5 [&_a[data-button]]:text-primary-foreground [&_a[data-button]]:no-underline",
790
+ "[&_hr]:my-3 [&_hr]:border-input",
791
+ "[&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:h-0 [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:content-[attr(data-placeholder)]",
792
+ )}
793
+ />
794
+ );
795
+
796
+ // The canvas toolbar never scrolls sideways: the rarer blocks and the HTML view sit in the menu.
797
+ const menuBlocks =
798
+ canvas && ActionOverflowMenu !== undefined
799
+ ? blocks.filter((block) => !CANVAS_INLINE_BLOCKS.includes(block))
800
+ : [];
801
+ const inlineBlocks = blocks.filter((block) => !menuBlocks.includes(block));
802
+ const overflowItems: readonly ActionMenuItemSpec[] = [
803
+ ...menuBlocks.map((block) => ({
804
+ id: block,
805
+ label: blockButtons[block].label,
806
+ disabled: readOnly,
807
+ onSelect: () => runBlock(block),
808
+ })),
809
+ ...(canvas && !hosted
810
+ ? [
811
+ {
812
+ id: "html",
813
+ label: t("template-resolver.documentEditor.showHtml"),
814
+ onSelect: () => setShowHtml((current) => !current),
815
+ },
816
+ ]
817
+ : []),
818
+ ];
819
+
820
+ const toolbarBar = (
672
821
  <div
673
- className={cn("rounded-md border border-input", canvas ? "bg-background" : "bg-transparent")}
822
+ className={cn(
823
+ "flex items-center border-b border-input",
824
+ canvas
825
+ ? "min-h-9 flex-nowrap gap-0 overflow-hidden bg-background px-1.5 [&>*]:shrink-0 [&_button]:h-7 [&_button]:gap-1 [&_button]:px-1 [&_button]:text-xs"
826
+ : "flex-wrap gap-1 p-1",
827
+ )}
674
828
  >
675
- <div className="flex flex-wrap items-center gap-1 border-b border-input p-1">
676
- <div className={compact ? "w-24" : "w-40"}>
677
- <Field id={`${id}-paragraph-style`} label={paragraphStyleLabel} hideLabel>
678
- <Input
679
- kind="select"
680
- id={`${id}-paragraph-style`}
681
- name="paragraph-style"
682
- value={activeState?.blockStyle ?? "paragraph"}
683
- onChange={setBlockStyle}
684
- options={styleOptions}
685
- display="dropdown"
686
- disabled={readOnly}
687
- />
688
- </Field>
689
- </div>
690
- <Separator compact={compact} />
691
- {iconButton(
692
- t("kumiko.contentEditor.bold"),
693
- markContent("B", <BoldIcon size={16} />, "font-bold"),
694
- activeState?.bold ?? false,
695
- () => editor.chain().focus().toggleBold().run(),
696
- )}
697
- {iconButton(
698
- t("kumiko.contentEditor.italic"),
699
- markContent("I", <ItalicIcon size={16} />, "italic"),
700
- activeState?.italic ?? false,
701
- () => editor.chain().focus().toggleItalic().run(),
702
- )}
703
- {iconButton(
704
- t("template-resolver.documentEditor.underline"),
705
- markContent("U", <UnderlineIcon size={16} />, "underline"),
706
- activeState?.underline ?? false,
707
- () => editor.chain().focus().toggleUnderline().run(),
829
+ <div
830
+ // kumiko-lint-ignore raw-classname the toolbar paragraph select sits flush in the editor toolbar; ComboboxInput has no flat trigger variant
831
+ className={cn(
832
+ !canvas && (compact ? "w-24" : "w-40"),
833
+ canvas &&
834
+ "w-auto shrink-0 [&_button[aria-haspopup=listbox]]:border-transparent [&_button[aria-haspopup=listbox]]:bg-transparent [&_button[aria-haspopup=listbox]]:shadow-none",
708
835
  )}
709
- <Separator compact={compact} />
710
- {iconButton(
711
- t("kumiko.contentEditor.bulletList"),
712
- <List size={16} />,
713
- activeState?.bulletList ?? false,
714
- () => editor.chain().focus().toggleBulletList().run(),
715
- )}
716
- {iconButton(
717
- t("kumiko.contentEditor.orderedList"),
718
- <ListOrdered size={16} />,
719
- activeState?.orderedList ?? false,
720
- () => editor.chain().focus().toggleOrderedList().run(),
721
- )}
722
- {iconButton(
723
- t("template-resolver.documentEditor.link"),
724
- <LinkIcon size={16} />,
725
- activeState?.link ?? false,
726
- () => togglePanel("link"),
727
- )}
728
- <Separator compact={compact} />
729
- {labelledButton({
730
- label: t("template-resolver.documentEditor.insertPlaceholder"),
731
- icon: <Braces size={16} />,
732
- showIcon: true,
733
- expanded: openPanel === "placeholder",
734
- onClick: () => togglePanel("placeholder"),
735
- })}
736
- {blocks.length > 0 && <Separator compact={compact} />}
737
- {blocks.map((block) =>
836
+ >
837
+ <Field id={`${id}-paragraph-style`} label={paragraphStyleLabel} hideLabel>
838
+ <Input
839
+ kind="select"
840
+ id={`${id}-paragraph-style`}
841
+ name="paragraph-style"
842
+ value={activeState?.blockStyle ?? "paragraph"}
843
+ onChange={setBlockStyle}
844
+ options={styleOptions}
845
+ display="dropdown"
846
+ disabled={readOnly}
847
+ />
848
+ </Field>
849
+ </div>
850
+ <Separator compact={compact || canvas} />
851
+ {iconButton(
852
+ t("kumiko.contentEditor.bold"),
853
+ markContent("B", <BoldIcon size={16} />, "font-bold"),
854
+ activeState?.bold ?? false,
855
+ () => editor.chain().focus().toggleBold().run(),
856
+ )}
857
+ {iconButton(
858
+ t("kumiko.contentEditor.italic"),
859
+ markContent("I", <ItalicIcon size={16} />, "italic"),
860
+ activeState?.italic ?? false,
861
+ () => editor.chain().focus().toggleItalic().run(),
862
+ )}
863
+ {iconButton(
864
+ t("template-resolver.documentEditor.underline"),
865
+ markContent("U", <UnderlineIcon size={16} />, "underline"),
866
+ activeState?.underline ?? false,
867
+ () => editor.chain().focus().toggleUnderline().run(),
868
+ )}
869
+ <Separator compact={compact || canvas} />
870
+ {iconButton(
871
+ t("kumiko.contentEditor.bulletList"),
872
+ <List size={16} />,
873
+ activeState?.bulletList ?? false,
874
+ () => editor.chain().focus().toggleBulletList().run(),
875
+ canvas ? undefined : t("template-resolver.documentEditor.list"),
876
+ )}
877
+ {iconButton(
878
+ t("kumiko.contentEditor.orderedList"),
879
+ <ListOrdered size={16} />,
880
+ activeState?.orderedList ?? false,
881
+ () => editor.chain().focus().toggleOrderedList().run(),
882
+ )}
883
+ {iconButton(
884
+ t("template-resolver.documentEditor.link"),
885
+ <LinkIcon size={16} />,
886
+ activeState?.link ?? false,
887
+ () => togglePanel("link"),
888
+ )}
889
+ <Separator compact={compact || canvas} />
890
+ {labelledButton({
891
+ label: t("template-resolver.documentEditor.insertPlaceholder"),
892
+ icon: <Braces size={16} />,
893
+ showIcon: true,
894
+ trailingIcon: canvas ? <ChevronDown size={12} /> : undefined,
895
+ expanded: openPanel === "placeholder",
896
+ onClick: () => togglePanel("placeholder"),
897
+ })}
898
+ {inlineBlocks.length > 0 && <Separator compact={compact || canvas} />}
899
+ {inlineBlocks.map((block) =>
900
+ canvas ? (
901
+ <Fragment key={block}>
902
+ {iconButton(blockButtons[block].label, blockButtons[block].icon, false, () =>
903
+ runBlock(block),
904
+ )}
905
+ </Fragment>
906
+ ) : (
738
907
  labelledButton({
739
908
  key: block,
740
909
  label: blockButtons[block].label,
741
910
  icon: blockButtons[block].icon,
742
- showIcon: !canvas,
911
+ showIcon: true,
743
912
  onClick: () => runBlock(block),
744
- }),
745
- )}
746
- {canvas && (
747
- <>
748
- <span className="flex-1" />
749
- <Button
750
- type="button"
751
- variant={flatVariant}
752
- size="sm"
753
- expanded={showHtml}
754
- onClick={() => {
755
- setShowHtml((current) => !current);
756
- }}
757
- >
758
- {t("template-resolver.documentEditor.showHtml")}
759
- </Button>
760
- </>
761
- )}
762
- </div>
763
- {canvas && selectedBlock !== null && !readOnly && (
764
- <div className="flex items-center gap-2 border-b border-input bg-muted/40 px-2 py-1 text-sm">
765
- <span className="text-muted-foreground">
766
- {t("template-resolver.documentEditor.selectedBlock")}
767
- </span>
768
- <span className="font-medium">
769
- {selectedBlock.type === "resource" ? imageLabel : buttonLabel}
913
+ })
914
+ ),
915
+ )}
916
+ {overflowItems.length > 0 && ActionOverflowMenu !== undefined && (
917
+ <span className="ml-auto shrink-0">
918
+ <ActionOverflowMenu
919
+ items={overflowItems}
920
+ label={t("template-resolver.documentEditor.more")}
921
+ testId={`${id}-toolbar-more`}
922
+ />
923
+ </span>
924
+ )}
925
+ </div>
926
+ );
927
+
928
+ const bubbleMenu = (
929
+ <BubbleMenu
930
+ key={mountKeyOf(editor)}
931
+ pluginKey={BUBBLE_MENU_PLUGIN_KEY}
932
+ editor={editor}
933
+ options={{
934
+ strategy: "fixed",
935
+ placement: "top-end",
936
+ offset: bubbleOffset(editor),
937
+ scrollTarget: canvasScroller ?? window,
938
+ onShow: () => requestAnimationFrame(() => repositionBubble(editor)),
939
+ }}
940
+ getReferencedVirtualElement={() => selectedBlockReference(editor)}
941
+ shouldShow={({ editor: e, element }) =>
942
+ // A leading image is selected by default; the bubble belongs to a deliberate selection.
943
+ (e.isFocused || element.contains(document.activeElement)) &&
944
+ (e.isActive("resource") || e.isActive("button") || findRepeatTable(e) !== null)
945
+ }
946
+ >
947
+ {selectedBlock !== null ? (
948
+ <div className={BUBBLE_BAR_CLASS} data-testid="document-block-options">
949
+ <span className="px-1.5 text-[10px] text-muted-foreground">
950
+ {selectedBlock.type === "resource"
951
+ ? t("template-resolver.documentEditor.resourceKey")
952
+ : t("template-resolver.documentEditor.buttonTarget")}
953
+ {": "}
954
+ {selectedBlock.type === "button" ? (
955
+ <strong>{buttonTargetLabel(selectedBlock.detail, variableLabels)}</strong>
956
+ ) : (
957
+ <code>{selectedBlock.detail}</code>
958
+ )}
770
959
  </span>
771
- <code className="text-xs text-muted-foreground">{selectedBlock.detail}</code>
772
- <span className="flex-1" />
773
960
  <Button
774
961
  type="button"
775
962
  size="sm"
@@ -781,25 +968,72 @@ export default function DocumentTiptapEditor({
781
968
  {t("template-resolver.documentEditor.removeBlock")}
782
969
  </Button>
783
970
  </div>
971
+ ) : (
972
+ repeatTable !== null && (
973
+ <div data-testid="document-repeat-bar" className={BUBBLE_BAR_CLASS}>
974
+ <span className="px-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
975
+ {repeatBarLabel}
976
+ </span>
977
+ <Button
978
+ type="button"
979
+ variant="secondary"
980
+ size="sm"
981
+ pressed={repeatTable.headerRepeats}
982
+ onClick={() => toggleRepeatHeader(editor)}
983
+ >
984
+ {t("template-resolver.documentEditor.repeatHeader")}
985
+ </Button>
986
+ </div>
987
+ )
784
988
  )}
785
- {canvas && showHtml && (
989
+ </BubbleMenu>
990
+ );
991
+
992
+ const insertPanel = openPanel !== null && !readOnly && (
993
+ <InsertPanel
994
+ key={openPanel}
995
+ editor={editor}
996
+ editorId={id}
997
+ kind={openPanel}
998
+ variables={variables}
999
+ onClose={() => setOpenPanel(null)}
1000
+ />
1001
+ );
1002
+
1003
+ if (hosted) {
1004
+ return (
1005
+ <>
1006
+ {toolbarHost !== null &&
1007
+ createPortal(
1008
+ <>
1009
+ {toolbarBar}
1010
+ {insertPanel}
1011
+ </>,
1012
+ toolbarHost,
1013
+ )}
1014
+ {!readOnly && bubbleMenu}
1015
+ {editorContent}
1016
+ </>
1017
+ );
1018
+ }
1019
+
1020
+ return (
1021
+ <div
1022
+ className={cn(
1023
+ canvas ? "flex min-h-0 flex-1 flex-col" : "rounded-md border border-input bg-transparent",
1024
+ )}
1025
+ >
1026
+ {!hosted && toolbarBar}
1027
+ {canvas && !readOnly && bubbleMenu}
1028
+ {canvas && !hosted && showHtml && (
786
1029
  <section aria-label={t("template-resolver.documentEditor.htmlSource")}>
787
1030
  <pre className="h-40 overflow-auto whitespace-pre-wrap break-all border-b border-input bg-muted p-2 font-mono text-xs">
788
1031
  {activeState?.html ?? ""}
789
1032
  </pre>
790
1033
  </section>
791
1034
  )}
792
- {openPanel !== null && !readOnly && (
793
- <InsertPanel
794
- key={openPanel}
795
- editor={editor}
796
- editorId={id}
797
- kind={openPanel}
798
- variables={variables}
799
- onClose={() => setOpenPanel(null)}
800
- />
801
- )}
802
- {repeatTable !== null && !readOnly && (
1035
+ {!hosted && insertPanel}
1036
+ {repeatTable !== null && !readOnly && !canvas && (
803
1037
  <div
804
1038
  data-testid="document-repeat-bar"
805
1039
  className="flex flex-wrap items-center gap-3 border-b border-input bg-muted/40 px-2 py-1 text-xs"
@@ -818,32 +1052,13 @@ export default function DocumentTiptapEditor({
818
1052
  >
819
1053
  {t("template-resolver.documentEditor.repeatHeader")}
820
1054
  </Button>
821
- <span className="text-muted-foreground">
822
- {t("template-resolver.documentEditor.keepRowsTogether")}
823
- </span>
824
1055
  </div>
825
1056
  )}
826
- <div className={cn(canvas && "flex justify-center bg-muted p-6")}>
827
- <EditorContent
828
- editor={editor}
829
- className={cn(
830
- canvas
831
- ? "w-[600px] max-w-full bg-background px-10 py-7 text-sm border border-input"
832
- : "min-h-40 px-3 py-2 text-base outline-none md:text-sm",
833
- "[&_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror-selectednode]:ring-primary",
834
- "[&_.ProseMirror]:min-h-40 [&_.ProseMirror]:outline-none",
835
- "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6",
836
- "[&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold [&_h3]:text-base [&_h3]:font-bold",
837
- "[&_a]:underline [&_a]:text-primary",
838
- "[&_strong]:font-bold [&_em]:italic [&_u]:underline",
839
- "[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse",
840
- "[&_td]:border [&_td]:border-input [&_td]:p-1 [&_th]:border [&_th]:border-input [&_th]:bg-muted [&_th]:p-1 [&_th]:text-left",
841
- "[&_tr[data-each]]:bg-muted",
842
- "[&_a[data-button]]:inline-block [&_a[data-button]]:rounded-full [&_a[data-button]]:bg-primary [&_a[data-button]]:px-4 [&_a[data-button]]:py-1.5 [&_a[data-button]]:text-primary-foreground [&_a[data-button]]:no-underline",
843
- "[&_hr]:my-3 [&_hr]:border-input",
844
- "[&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:h-0 [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:content-[attr(data-placeholder)]",
845
- )}
846
- />
1057
+ <div
1058
+ ref={setCanvasScroller}
1059
+ className={cn(canvas && "min-h-0 flex-1 overflow-auto bg-muted px-8 py-6")}
1060
+ >
1061
+ {canvas && renderPaper !== undefined ? renderPaper(editorContent) : editorContent}
847
1062
  </div>
848
1063
  </div>
849
1064
  );
@@ -1,5 +1,6 @@
1
1
  // @runtime client
2
2
  import type { ContentEditorProps } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
3
4
 
4
5
  export type DocumentToolbarBlock =
5
6
  | "image"
@@ -39,6 +40,11 @@ export type DocumentEditorProps = ContentEditorProps & {
39
40
  readonly chrome?: DocumentEditorChrome;
40
41
  /** "compact" renders the placeholder and block buttons icon-only with a tooltip, for narrow columns. Default "full". */
41
42
  readonly toolbar?: "full" | "compact";
43
+ /** Canvas only: wraps the editable area in a caller-owned page (letterhead, margins) instead of the default mail paper. */
44
+ readonly renderPaper?: (editable: ReactNode) => ReactNode;
45
+ /** Canvas only: renders the toolbar (and its insert panel) into this element instead of above the editor;
46
+ * null renders none. Lets several region editors share one toolbar that follows the focused one. */
47
+ readonly toolbarHost?: HTMLElement | null;
42
48
  /** Called with the handle once the editor exists and with null when it is destroyed. */
43
49
  readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
44
50
  };
@@ -59,3 +65,8 @@ export const PDF_DOCUMENT_BLOCKS = [
59
65
  "repeat",
60
66
  "pageBreak",
61
67
  ] as const satisfies readonly DocumentToolbarBlock[];
68
+
69
+ /** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
70
+ export function documentBlocksForKind(kind: string | undefined): readonly DocumentToolbarBlock[] {
71
+ return kind === "document-pdf" ? PDF_DOCUMENT_BLOCKS : MAIL_DOCUMENT_BLOCKS;
72
+ }