@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -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,10 +24,12 @@ 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,
28
31
  List,
32
+ ListChecks,
29
33
  ListOrdered,
30
34
  Minus,
31
35
  MousePointerClick,
@@ -34,7 +38,8 @@ import {
34
38
  Table as TableIcon,
35
39
  Underline as UnderlineIcon,
36
40
  } from "lucide-react";
37
- import { type ReactNode, useEffect, useMemo, useState } from "react";
41
+ import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
42
+ import { createPortal } from "react-dom";
38
43
  import {
39
44
  DOCUMENT_TEXT_ALIGNMENTS,
40
45
  type DocumentTextAlignment,
@@ -43,7 +48,11 @@ import {
43
48
  isDocumentReferenceName,
44
49
  isDocumentResourceKey,
45
50
  } from "../document-schema.js";
46
- import { createDocumentEditorExtensions } from "./document-editor-extensions.js";
51
+ import { parseDocumentStepsInput } from "../document-steps.js";
52
+ import {
53
+ createDocumentEditorExtensions,
54
+ DOCUMENT_BLOCK_ATTRIBUTE,
55
+ } from "./document-editor-extensions.js";
47
56
  import {
48
57
  type DocumentEditorProps,
49
58
  type DocumentResourcePreview,
@@ -63,13 +72,17 @@ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>
63
72
  .join("\n");
64
73
  }
65
74
 
66
- type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align";
75
+ type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align" | "steps";
76
+
77
+ // Blocks that stay in the canvas toolbar; the rest moves into the overflow menu.
78
+ const CANVAS_INLINE_BLOCKS: readonly DocumentToolbarBlock[] = ["image", "table"];
67
79
 
68
80
  const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
69
81
  image: "image",
70
82
  button: "button",
71
83
  repeat: "repeat",
72
84
  align: "align",
85
+ steps: "steps",
73
86
  };
74
87
 
75
88
  const ALIGNMENT_ICONS: Record<DocumentTextAlignment, ReactNode> = {
@@ -137,6 +150,66 @@ type ParagraphStyle = (typeof PARAGRAPH_STYLES)[number];
137
150
 
138
151
  const HEADING_LEVELS = { "1": 1, "2": 2, "3": 3 } as const;
139
152
 
153
+ const PLACEHOLDER_HREF = /^\{\{([^{}]+)\}\}$/;
154
+
155
+ function buttonTargetLabel(href: string, labels: Readonly<Record<string, string>>): string {
156
+ const name = PLACEHOLDER_HREF.exec(href)?.[1];
157
+ if (name === undefined) return href;
158
+ return Object.hasOwn(labels, name) ? (labels[name] ?? name) : name;
159
+ }
160
+
161
+ // The bubble menu registers its plugin once per mount, so a rebuilt editor needs a fresh mount.
162
+ const editorMountKeys = new WeakMap<Editor, number>();
163
+ let lastMountKey = 0;
164
+ function mountKeyOf(editor: Editor): number {
165
+ const known = editorMountKeys.get(editor);
166
+ if (known !== undefined) return known;
167
+ lastMountKey += 1;
168
+ const created = lastMountKey;
169
+ editorMountKeys.set(editor, created);
170
+ return created;
171
+ }
172
+
173
+ // Board .hb: a small bar that straddles the block's top frame line on the right; above a table it sits fully
174
+ // outside so the header row stays readable.
175
+ const BUBBLE_BAR_CLASS =
176
+ "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]";
177
+
178
+ function bubbleOffset(editor: Editor): (args: { rects: { floating: { height: number } } }) => {
179
+ mainAxis: number;
180
+ crossAxis: number;
181
+ } {
182
+ return ({ rects }) => ({
183
+ mainAxis: findRepeatTable(editor) !== null ? 2 : -rects.floating.height / 2,
184
+ crossAxis: -8,
185
+ });
186
+ }
187
+
188
+ const BUBBLE_MENU_PLUGIN_KEY = "documentBlockOptions";
189
+
190
+ // The options bar belongs to the block frame, not to the caret inside it.
191
+ function selectedBlockReference(editor: Editor): {
192
+ getBoundingClientRect: () => DOMRect;
193
+ getClientRects: () => DOMRect[];
194
+ contextElement: Element;
195
+ } | null {
196
+ const { node } = editor.view.domAtPos(editor.state.selection.from);
197
+ const start = node instanceof Element ? node : node.parentElement;
198
+ const block = start?.closest(`[${DOCUMENT_BLOCK_ATTRIBUTE}], table`);
199
+ if (block === null || block === undefined) return null;
200
+ return {
201
+ getBoundingClientRect: () => block.getBoundingClientRect(),
202
+ getClientRects: () => [block.getBoundingClientRect()],
203
+ contextElement: block,
204
+ };
205
+ }
206
+
207
+ // The bubble is first measured before React has filled it, so it sits at the wrong place until a second pass.
208
+ function repositionBubble(editor: Editor): void {
209
+ if (editor.isDestroyed) return;
210
+ editor.view.dispatch(editor.state.tr.setMeta(BUBBLE_MENU_PLUGIN_KEY, "updatePosition"));
211
+ }
212
+
140
213
  function isParagraphStyle(value: string): value is ParagraphStyle {
141
214
  return isOneOf(PARAGRAPH_STYLES, value);
142
215
  }
@@ -245,6 +318,9 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
245
318
  const [buttonText, setButtonText] = useState(() =>
246
319
  t("template-resolver.documentEditor.buttonDefaultText"),
247
320
  );
321
+ const [stepsText, setStepsText] = useState(() =>
322
+ t("template-resolver.documentEditor.stepsDefault"),
323
+ );
248
324
  const [chosenName, setChosenName] = useState("");
249
325
  const [error, setError] = useState<string | null>(null);
250
326
 
@@ -266,6 +342,15 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
266
342
  const chain = editor.chain().focus();
267
343
  if (!editor.state.selection.empty) chain.setLink({ href }).run();
268
344
  else if (editor.isActive("link")) chain.extendMarkRange("link").setLink({ href }).run();
345
+ else if (isDocumentPlaceholderHref(href))
346
+ // Placeholders are never inserted as {{name}} text; the link shows the variable's value.
347
+ chain
348
+ .insertContent({
349
+ type: "variable",
350
+ attrs: { name: href.slice(2, -2) },
351
+ marks: [{ type: "link", attrs: { href } }],
352
+ })
353
+ .run();
269
354
  else
270
355
  chain
271
356
  .insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] })
@@ -285,7 +370,17 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
285
370
  insertAndClose({ type: "resource", attrs: { resource, alt: alt || null } });
286
371
  };
287
372
 
288
- const needsVariables = kind === "button" || kind === "repeat" || kind === "placeholder";
373
+ const submitSteps = (): void => {
374
+ const steps = parseDocumentStepsInput(stepsText);
375
+ if (steps === null) {
376
+ setError(t("template-resolver.documentEditor.invalidSteps"));
377
+ return;
378
+ }
379
+ insertAndClose({ type: "steps", attrs: { variable: selectedName, steps } });
380
+ };
381
+
382
+ const needsVariables =
383
+ kind === "button" || kind === "repeat" || kind === "placeholder" || kind === "steps";
289
384
  const noVariables = needsVariables && selectable.length === 0;
290
385
  const idPrefix = `${editorId}-panel`;
291
386
 
@@ -388,6 +483,29 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
388
483
  onChange={setChosenName}
389
484
  />
390
485
  )}
486
+ {kind === "steps" && !noVariables && (
487
+ <>
488
+ <NameSelect
489
+ id={`${idPrefix}-steps-variable`}
490
+ label={t("template-resolver.documentEditor.stepsVariable")}
491
+ names={selectable}
492
+ value={selectedName}
493
+ onChange={setChosenName}
494
+ />
495
+ <Field
496
+ id={`${idPrefix}-steps-list`}
497
+ label={t("template-resolver.documentEditor.stepsList")}
498
+ >
499
+ <Input
500
+ kind="text"
501
+ id={`${idPrefix}-steps-list`}
502
+ name="steps"
503
+ value={stepsText}
504
+ onChange={setStepsText}
505
+ />
506
+ </Field>
507
+ </>
508
+ )}
391
509
  {error !== null && (
392
510
  <p role="alert" className="text-sm text-destructive">
393
511
  {error}
@@ -416,6 +534,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
416
534
  })}
417
535
  {...(kind === "repeat" &&
418
536
  !noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) })}
537
+ {...(kind === "steps" && !noVariables && { onInsert: submitSteps })}
419
538
  />
420
539
  </div>
421
540
  </div>
@@ -434,19 +553,25 @@ export default function DocumentTiptapEditor({
434
553
  resourcePreviews = NO_PREVIEWS,
435
554
  chrome = "default",
436
555
  toolbar = "full",
556
+ renderPaper,
557
+ toolbarHost,
437
558
  onEditorReady,
438
559
  }: DocumentEditorProps): ReactNode {
439
560
  const t = useTranslation();
440
- const { Button, Field, Input } = usePrimitives();
561
+ const { ActionOverflowMenu, Button, Field, Input } = usePrimitives();
441
562
  const [openPanel, setOpenPanel] = useState<PanelKind | null>(null);
563
+ const [canvasScroller, setCanvasScroller] = useState<HTMLElement | null>(null);
442
564
  const [showHtml, setShowHtml] = useState(false);
443
565
  const canvas = chrome === "canvas";
444
566
  const compact = toolbar === "compact" && !canvas;
567
+ const hosted = toolbarHost !== undefined;
445
568
 
446
569
  const placeholderLabel = t("template-resolver.documentEditor.placeholder");
447
570
  const imageLabel = t("template-resolver.documentEditor.image");
571
+ const imageBlockLabel = t("template-resolver.documentEditor.imageBlock");
448
572
  const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
449
573
  const buttonLabel = t("template-resolver.documentEditor.button");
574
+ const stepsLabel = t("template-resolver.documentEditor.steps");
450
575
  const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
451
576
  const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
452
577
  const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
@@ -457,6 +582,8 @@ export default function DocumentTiptapEditor({
457
582
  image: imageLabel,
458
583
  pageBreak: pageBreakLabel,
459
584
  button: buttonLabel,
585
+ steps: stepsLabel,
586
+ imageBlock: imageBlockLabel,
460
587
  variableLabels: stableLabels,
461
588
  systemVariables: stableSystem,
462
589
  resourcePreviews: stablePreviews,
@@ -465,8 +592,10 @@ export default function DocumentTiptapEditor({
465
592
  [
466
593
  placeholderLabel,
467
594
  imageLabel,
595
+ imageBlockLabel,
468
596
  pageBreakLabel,
469
597
  buttonLabel,
598
+ stepsLabel,
470
599
  stableLabels,
471
600
  stableSystem,
472
601
  stablePreviews,
@@ -487,6 +616,7 @@ export default function DocumentTiptapEditor({
487
616
  immediatelyRender: false,
488
617
  editorProps: { attributes: { id } },
489
618
  onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
619
+ onDestroy: () => onEditorReady?.(null),
490
620
  onUpdate: ({ editor: e }) => onChange(e.getHTML()),
491
621
  },
492
622
  [extensions],
@@ -528,27 +658,27 @@ export default function DocumentTiptapEditor({
528
658
 
529
659
  // The entry loads asynchronously, so `value` arrives after useEditor read
530
660
  // its initial content; the guard keeps in-flight typing from being clobbered.
661
+ // When `extensions` change, useEditor's own effect destroys this render's
662
+ // editor earlier in the same commit (a late validate result in the layout
663
+ // editor does that); reading it then throws and unmounts the whole app. The
664
+ // replacement editor re-renders and syncs `value` itself.
531
665
  // kumiko-lint-ignore no-raw-hooks DOM integration: syncs the controlled value into the imperative tiptap editor
532
666
  useEffect(() => {
533
- if (editor && value !== editor.getHTML()) {
667
+ if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
534
668
  editor.commands.setContent(value, { emitUpdate: false });
535
669
  }
536
670
  }, [value, editor]);
537
671
 
538
672
  // kumiko-lint-ignore no-raw-hooks DOM integration: tiptap exposes editability only imperatively via setEditable
539
673
  useEffect(() => {
540
- editor?.setEditable(!readOnly);
674
+ // setEditable emits "update" by default, which would report the mount as an edit.
675
+ if (editor && !editor.isDestroyed) editor.setEditable(!readOnly, false);
541
676
  }, [readOnly, editor]);
542
677
 
678
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
543
679
  if (!editor)
544
680
  return (
545
- <TextareaContentEditor
546
- id={id}
547
- value={value}
548
- onChange={onChange}
549
- variables={variables}
550
- readOnly={readOnly}
551
- />
681
+ <TextareaContentEditor id={id} value={value} onChange={onChange} variables={[]} readOnly />
552
682
  );
553
683
 
554
684
  const togglePanel = (kind: PanelKind): void =>
@@ -584,6 +714,7 @@ export default function DocumentTiptapEditor({
584
714
  repeat: { label: t("template-resolver.documentEditor.repeat"), icon: <Repeat size={16} /> },
585
715
  pageBreak: { label: pageBreakLabel, icon: <Scissors size={16} /> },
586
716
  align: { label: t("template-resolver.documentEditor.align"), icon: <AlignLeft size={16} /> },
717
+ steps: { label: stepsLabel, icon: <ListChecks size={16} /> },
587
718
  };
588
719
 
589
720
  const repeatTable = activeState?.repeatTable ?? null;
@@ -592,6 +723,11 @@ export default function DocumentTiptapEditor({
592
723
  ? (variableLabels[repeatTable.lists[0] ?? ""] ?? repeatTable.lists[0])
593
724
  : undefined;
594
725
 
726
+ const repeatBarLabel =
727
+ repeatListLabel === undefined
728
+ ? t("template-resolver.documentEditor.repeat")
729
+ : t("template-resolver.documentEditor.repeatBarList", { list: repeatListLabel });
730
+
595
731
  const paragraphStyleLabel = t("template-resolver.documentEditor.paragraphStyle");
596
732
  const styleOptions = [
597
733
  { value: "paragraph", label: t("template-resolver.documentEditor.paragraph") },
@@ -600,17 +736,18 @@ export default function DocumentTiptapEditor({
600
736
  { value: "3", label: t("template-resolver.documentEditor.heading3") },
601
737
  ];
602
738
 
603
- // Canvas chrome shows plain text buttons instead of icons.
739
+ // Canvas chrome is one flat row: unframed ghost buttons, glyph plus text for the longer names.
604
740
  const iconButton = (
605
741
  label: string,
606
742
  icon: ReactNode,
607
743
  isActive: boolean,
608
744
  onClick: () => void,
745
+ text?: string,
609
746
  ): ReactNode =>
610
747
  canvas ? (
611
748
  <Button
612
749
  type="button"
613
- variant={isActive ? "secondary" : "ghost"}
750
+ variant="ghost"
614
751
  size="sm"
615
752
  pressed={isActive}
616
753
  disabled={readOnly}
@@ -619,6 +756,7 @@ export default function DocumentTiptapEditor({
619
756
  title={label}
620
757
  >
621
758
  {icon}
759
+ {text}
622
760
  </Button>
623
761
  ) : (
624
762
  <Button
@@ -646,6 +784,7 @@ export default function DocumentTiptapEditor({
646
784
  readonly label: string;
647
785
  readonly icon: ReactNode;
648
786
  readonly showIcon: boolean;
787
+ readonly trailingIcon?: ReactNode;
649
788
  readonly expanded?: boolean;
650
789
  readonly onClick: () => void;
651
790
  }): ReactNode => {
@@ -664,112 +803,205 @@ export default function DocumentTiptapEditor({
664
803
  <Button key={spec.key} {...shared} size="sm">
665
804
  {spec.showIcon && spec.icon}
666
805
  {spec.label}
806
+ {spec.trailingIcon}
667
807
  </Button>
668
808
  );
669
809
  };
670
810
 
671
- return (
811
+ const editorContent = (
812
+ <EditorContent
813
+ editor={editor}
814
+ className={cn(
815
+ !canvas && "min-h-40 px-3 py-2 text-base outline-none md:text-sm",
816
+ canvas && "text-sm",
817
+ "[&_.ProseMirror-focused_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror-focused_.ProseMirror-selectednode]:ring-primary",
818
+ hosted
819
+ ? "[&_.ProseMirror]:outline-none"
820
+ : "[&_.ProseMirror]:min-h-40 [&_.ProseMirror]:outline-none",
821
+ "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6",
822
+ "[&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold [&_h3]:text-base [&_h3]:font-bold",
823
+ "[&_a]:underline [&_a]:text-primary",
824
+ "[&_strong]:font-bold [&_em]:italic [&_u]:underline",
825
+ canvas
826
+ ? "[&_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"
827
+ : "[&_table]:my-2",
828
+ "[&_table]:w-full [&_table]:border-collapse",
829
+ "[&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-1.5 [&_td>p]:m-0 [&_th>p]:m-0",
830
+ "[&_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",
831
+ "[&_tr[data-each]>td]:bg-muted/50",
832
+ "[&_[data-align=left]]:text-left [&_[data-align=center]]:text-center [&_[data-align=right]]:text-right [&_[data-align=justify]]:text-justify",
833
+ "[&_p[data-size=small]]:text-xs [&_p[data-size=small]]:text-muted-foreground",
834
+ "[&_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",
835
+ "[&_hr]:my-3 [&_hr]:border-input",
836
+ "[&_.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)]",
837
+ )}
838
+ />
839
+ );
840
+
841
+ // The canvas toolbar never scrolls sideways: the rarer blocks and the HTML view sit in the menu.
842
+ const menuBlocks =
843
+ canvas && ActionOverflowMenu !== undefined
844
+ ? blocks.filter((block) => !CANVAS_INLINE_BLOCKS.includes(block))
845
+ : [];
846
+ const inlineBlocks = blocks.filter((block) => !menuBlocks.includes(block));
847
+ const overflowItems: readonly ActionMenuItemSpec[] = [
848
+ ...menuBlocks.map((block) => ({
849
+ id: block,
850
+ label: blockButtons[block].label,
851
+ disabled: readOnly,
852
+ onSelect: () => runBlock(block),
853
+ })),
854
+ ...(canvas && !hosted
855
+ ? [
856
+ {
857
+ id: "html",
858
+ label: t("template-resolver.documentEditor.showHtml"),
859
+ onSelect: () => setShowHtml((current) => !current),
860
+ },
861
+ ]
862
+ : []),
863
+ ];
864
+
865
+ const toolbarBar = (
672
866
  <div
673
- className={cn("rounded-md border border-input", canvas ? "bg-background" : "bg-transparent")}
867
+ className={cn(
868
+ "flex items-center border-b border-input",
869
+ canvas
870
+ ? "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"
871
+ : "flex-wrap gap-1 p-1",
872
+ )}
674
873
  >
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(),
874
+ <div
875
+ // kumiko-lint-ignore raw-classname the toolbar paragraph select sits flush in the editor toolbar; ComboboxInput has no flat trigger variant
876
+ className={cn(
877
+ !canvas && (compact ? "w-24" : "w-40"),
878
+ canvas &&
879
+ "w-auto shrink-0 [&_button[aria-haspopup=listbox]]:border-transparent [&_button[aria-haspopup=listbox]]:bg-transparent [&_button[aria-haspopup=listbox]]:shadow-none",
708
880
  )}
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) =>
881
+ >
882
+ <Field id={`${id}-paragraph-style`} label={paragraphStyleLabel} hideLabel>
883
+ <Input
884
+ kind="select"
885
+ id={`${id}-paragraph-style`}
886
+ name="paragraph-style"
887
+ value={activeState?.blockStyle ?? "paragraph"}
888
+ onChange={setBlockStyle}
889
+ options={styleOptions}
890
+ display="dropdown"
891
+ disabled={readOnly}
892
+ />
893
+ </Field>
894
+ </div>
895
+ <Separator compact={compact || canvas} />
896
+ {iconButton(
897
+ t("kumiko.contentEditor.bold"),
898
+ markContent("B", <BoldIcon size={16} />, "font-bold"),
899
+ activeState?.bold ?? false,
900
+ () => editor.chain().focus().toggleBold().run(),
901
+ )}
902
+ {iconButton(
903
+ t("kumiko.contentEditor.italic"),
904
+ markContent("I", <ItalicIcon size={16} />, "italic"),
905
+ activeState?.italic ?? false,
906
+ () => editor.chain().focus().toggleItalic().run(),
907
+ )}
908
+ {iconButton(
909
+ t("template-resolver.documentEditor.underline"),
910
+ markContent("U", <UnderlineIcon size={16} />, "underline"),
911
+ activeState?.underline ?? false,
912
+ () => editor.chain().focus().toggleUnderline().run(),
913
+ )}
914
+ <Separator compact={compact || canvas} />
915
+ {iconButton(
916
+ t("kumiko.contentEditor.bulletList"),
917
+ <List size={16} />,
918
+ activeState?.bulletList ?? false,
919
+ () => editor.chain().focus().toggleBulletList().run(),
920
+ canvas ? undefined : t("template-resolver.documentEditor.list"),
921
+ )}
922
+ {iconButton(
923
+ t("kumiko.contentEditor.orderedList"),
924
+ <ListOrdered size={16} />,
925
+ activeState?.orderedList ?? false,
926
+ () => editor.chain().focus().toggleOrderedList().run(),
927
+ )}
928
+ {iconButton(
929
+ t("template-resolver.documentEditor.link"),
930
+ <LinkIcon size={16} />,
931
+ activeState?.link ?? false,
932
+ () => togglePanel("link"),
933
+ )}
934
+ <Separator compact={compact || canvas} />
935
+ {labelledButton({
936
+ label: t("template-resolver.documentEditor.insertPlaceholder"),
937
+ icon: <Braces size={16} />,
938
+ showIcon: true,
939
+ trailingIcon: canvas ? <ChevronDown size={12} /> : undefined,
940
+ expanded: openPanel === "placeholder",
941
+ onClick: () => togglePanel("placeholder"),
942
+ })}
943
+ {inlineBlocks.length > 0 && <Separator compact={compact || canvas} />}
944
+ {inlineBlocks.map((block) =>
945
+ canvas ? (
946
+ <Fragment key={block}>
947
+ {iconButton(blockButtons[block].label, blockButtons[block].icon, false, () =>
948
+ runBlock(block),
949
+ )}
950
+ </Fragment>
951
+ ) : (
738
952
  labelledButton({
739
953
  key: block,
740
954
  label: blockButtons[block].label,
741
955
  icon: blockButtons[block].icon,
742
- showIcon: !canvas,
956
+ showIcon: true,
743
957
  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}
958
+ })
959
+ ),
960
+ )}
961
+ {overflowItems.length > 0 && ActionOverflowMenu !== undefined && (
962
+ <span className="ml-auto shrink-0">
963
+ <ActionOverflowMenu
964
+ items={overflowItems}
965
+ label={t("template-resolver.documentEditor.more")}
966
+ testId={`${id}-toolbar-more`}
967
+ />
968
+ </span>
969
+ )}
970
+ </div>
971
+ );
972
+
973
+ const bubbleMenu = (
974
+ <BubbleMenu
975
+ key={mountKeyOf(editor)}
976
+ pluginKey={BUBBLE_MENU_PLUGIN_KEY}
977
+ editor={editor}
978
+ options={{
979
+ strategy: "fixed",
980
+ placement: "top-end",
981
+ offset: bubbleOffset(editor),
982
+ scrollTarget: canvasScroller ?? window,
983
+ onShow: () => requestAnimationFrame(() => repositionBubble(editor)),
984
+ }}
985
+ getReferencedVirtualElement={() => selectedBlockReference(editor)}
986
+ shouldShow={({ editor: e, element }) =>
987
+ // A leading image is selected by default; the bubble belongs to a deliberate selection.
988
+ (e.isFocused || element.contains(document.activeElement)) &&
989
+ (e.isActive("resource") || e.isActive("button") || findRepeatTable(e) !== null)
990
+ }
991
+ >
992
+ {selectedBlock !== null ? (
993
+ <div className={BUBBLE_BAR_CLASS} data-testid="document-block-options">
994
+ <span className="px-1.5 text-[10px] text-muted-foreground">
995
+ {selectedBlock.type === "resource"
996
+ ? t("template-resolver.documentEditor.resourceKey")
997
+ : t("template-resolver.documentEditor.buttonTarget")}
998
+ {": "}
999
+ {selectedBlock.type === "button" ? (
1000
+ <strong>{buttonTargetLabel(selectedBlock.detail, variableLabels)}</strong>
1001
+ ) : (
1002
+ <code>{selectedBlock.detail}</code>
1003
+ )}
770
1004
  </span>
771
- <code className="text-xs text-muted-foreground">{selectedBlock.detail}</code>
772
- <span className="flex-1" />
773
1005
  <Button
774
1006
  type="button"
775
1007
  size="sm"
@@ -781,25 +1013,72 @@ export default function DocumentTiptapEditor({
781
1013
  {t("template-resolver.documentEditor.removeBlock")}
782
1014
  </Button>
783
1015
  </div>
1016
+ ) : (
1017
+ repeatTable !== null && (
1018
+ <div data-testid="document-repeat-bar" className={BUBBLE_BAR_CLASS}>
1019
+ <span className="px-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
1020
+ {repeatBarLabel}
1021
+ </span>
1022
+ <Button
1023
+ type="button"
1024
+ variant="secondary"
1025
+ size="sm"
1026
+ pressed={repeatTable.headerRepeats}
1027
+ onClick={() => toggleRepeatHeader(editor)}
1028
+ >
1029
+ {t("template-resolver.documentEditor.repeatHeader")}
1030
+ </Button>
1031
+ </div>
1032
+ )
784
1033
  )}
785
- {canvas && showHtml && (
1034
+ </BubbleMenu>
1035
+ );
1036
+
1037
+ const insertPanel = openPanel !== null && !readOnly && (
1038
+ <InsertPanel
1039
+ key={openPanel}
1040
+ editor={editor}
1041
+ editorId={id}
1042
+ kind={openPanel}
1043
+ variables={variables}
1044
+ onClose={() => setOpenPanel(null)}
1045
+ />
1046
+ );
1047
+
1048
+ if (hosted) {
1049
+ return (
1050
+ <>
1051
+ {toolbarHost !== null &&
1052
+ createPortal(
1053
+ <>
1054
+ {toolbarBar}
1055
+ {insertPanel}
1056
+ </>,
1057
+ toolbarHost,
1058
+ )}
1059
+ {!readOnly && bubbleMenu}
1060
+ {editorContent}
1061
+ </>
1062
+ );
1063
+ }
1064
+
1065
+ return (
1066
+ <div
1067
+ className={cn(
1068
+ canvas ? "flex min-h-0 flex-1 flex-col" : "rounded-md border border-input bg-transparent",
1069
+ )}
1070
+ >
1071
+ {!hosted && toolbarBar}
1072
+ {canvas && !readOnly && bubbleMenu}
1073
+ {canvas && !hosted && showHtml && (
786
1074
  <section aria-label={t("template-resolver.documentEditor.htmlSource")}>
787
1075
  <pre className="h-40 overflow-auto whitespace-pre-wrap break-all border-b border-input bg-muted p-2 font-mono text-xs">
788
1076
  {activeState?.html ?? ""}
789
1077
  </pre>
790
1078
  </section>
791
1079
  )}
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 && (
1080
+ {!hosted && insertPanel}
1081
+ {repeatTable !== null && !readOnly && !canvas && (
803
1082
  <div
804
1083
  data-testid="document-repeat-bar"
805
1084
  className="flex flex-wrap items-center gap-3 border-b border-input bg-muted/40 px-2 py-1 text-xs"
@@ -818,32 +1097,13 @@ export default function DocumentTiptapEditor({
818
1097
  >
819
1098
  {t("template-resolver.documentEditor.repeatHeader")}
820
1099
  </Button>
821
- <span className="text-muted-foreground">
822
- {t("template-resolver.documentEditor.keepRowsTogether")}
823
- </span>
824
1100
  </div>
825
1101
  )}
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
- />
1102
+ <div
1103
+ ref={setCanvasScroller}
1104
+ className={cn(canvas && "min-h-0 flex-1 overflow-auto bg-muted px-8 py-6")}
1105
+ >
1106
+ {canvas && renderPaper !== undefined ? renderPaper(editorContent) : editorContent}
847
1107
  </div>
848
1108
  </div>
849
1109
  );