@cosmicdrift/kumiko-samples 0.362.0 → 0.363.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 (252) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +17 -6
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
  5. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
  6. package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
  7. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
  8. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +14 -0
  10. package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
  13. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
  14. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
  15. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
  17. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -12
  18. package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
  20. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
  21. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
  22. package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
  23. package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
  24. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
  25. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
  26. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
  27. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
  28. package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
  29. package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +38 -44
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +115 -0
  33. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
  34. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +30 -30
  35. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
  36. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
  37. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -2
  38. package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
  39. package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
  40. package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
  41. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -2
  42. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
  43. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +3 -6
  44. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  45. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
  46. package/packages/bundled-features/src/channel-email/changes.json +19 -0
  47. package/packages/bundled-features/src/channel-email/email-channel.ts +122 -29
  48. package/packages/bundled-features/src/channel-email/feature.ts +6 -3
  49. package/packages/bundled-features/src/channel-email/index.ts +7 -1
  50. package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
  51. package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
  52. package/packages/bundled-features/src/channel-email/types.ts +23 -0
  53. package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
  54. package/packages/bundled-features/src/config/handlers/set.write.ts +1 -5
  55. package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
  56. package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
  58. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  59. package/packages/bundled-features/src/data-retention/index.ts +1 -0
  60. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
  61. package/packages/bundled-features/src/delivery/attachment-ref.ts +84 -0
  62. package/packages/bundled-features/src/delivery/changes.json +48 -0
  63. package/packages/bundled-features/src/delivery/channel-context.ts +129 -4
  64. package/packages/bundled-features/src/delivery/constants.ts +2 -0
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
  66. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  67. package/packages/bundled-features/src/delivery/index.ts +17 -2
  68. package/packages/bundled-features/src/delivery/jobs.ts +48 -5
  69. package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
  70. package/packages/bundled-features/src/delivery/testing.ts +9 -1
  71. package/packages/bundled-features/src/delivery/types.ts +28 -18
  72. package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
  73. package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
  74. package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
  75. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
  76. package/packages/bundled-features/src/file-foundation/index.ts +7 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +86 -0
  78. package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
  79. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
  80. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
  82. package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
  83. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
  85. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
  89. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
  90. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
  91. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
  92. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +40 -25
  93. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
  94. package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
  95. package/packages/bundled-features/src/ledger/recurring.ts +1 -3
  96. package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
  97. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  98. package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
  99. package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
  100. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
  101. package/packages/bundled-features/src/page-render/branding.ts +3 -0
  102. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  103. package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
  104. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
  105. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
  106. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
  107. package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
  108. package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
  109. package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
  110. package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
  111. package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
  112. package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
  113. package/packages/bundled-features/src/renderer-foundation/types.ts +132 -35
  114. package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
  115. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
  116. package/packages/bundled-features/src/sessions/feature.ts +2 -4
  117. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
  118. package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
  119. package/packages/bundled-features/src/shared/index.ts +6 -0
  120. package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
  121. package/packages/bundled-features/src/shared/mail-template-body.ts +25 -0
  122. package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
  123. package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
  124. package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
  125. package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
  126. package/packages/bundled-features/src/template-resolver/README.md +63 -7
  127. package/packages/bundled-features/src/template-resolver/api.ts +83 -8
  128. package/packages/bundled-features/src/template-resolver/changes.json +118 -0
  129. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +214 -0
  130. package/packages/bundled-features/src/template-resolver/constants.ts +39 -11
  131. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
  132. package/packages/bundled-features/src/template-resolver/document-schema.ts +323 -0
  133. package/packages/bundled-features/src/template-resolver/feature.ts +56 -6
  134. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
  135. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
  136. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +142 -0
  137. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +72 -0
  138. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +83 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -3
  140. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
  141. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
  142. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +108 -0
  143. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +5 -7
  144. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +250 -0
  145. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
  146. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
  147. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
  148. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
  149. package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
  150. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
  151. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +106 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
  153. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +184 -0
  155. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +183 -0
  156. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +55 -0
  157. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +57 -10
  158. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
  159. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +15 -76
  160. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +17 -0
  161. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
  162. package/packages/bundled-features/src/template-resolver/index.ts +100 -0
  163. package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
  164. package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
  165. package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
  166. package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
  167. package/packages/bundled-features/src/template-resolver/qualified-names.ts +30 -0
  168. package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
  169. package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
  170. package/packages/bundled-features/src/template-resolver/seeding.ts +147 -17
  171. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
  172. package/packages/bundled-features/src/template-resolver/table.ts +14 -0
  173. package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
  174. package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
  175. package/packages/bundled-features/src/template-resolver/template-meta.ts +129 -0
  176. package/packages/bundled-features/src/template-resolver/template-preview.ts +573 -0
  177. package/packages/bundled-features/src/template-resolver/template-resources.ts +74 -0
  178. package/packages/bundled-features/src/template-resolver/template-usage.ts +48 -0
  179. package/packages/bundled-features/src/template-resolver/template-validation.ts +229 -0
  180. package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
  181. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +9 -6
  182. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +162 -0
  183. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +28 -0
  184. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +850 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +61 -0
  186. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +100 -0
  188. package/packages/bundled-features/src/template-resolver/web/index.ts +19 -0
  189. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
  190. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +169 -0
  191. package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
  193. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
  194. package/packages/bundled-features/src/tenant/seeding.ts +41 -7
  195. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
  196. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
  197. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
  198. package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
  199. package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
  200. package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
  201. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
  202. package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
  203. package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
  204. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
  205. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
  206. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
  207. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  208. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
  209. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  210. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  211. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
  212. package/packages/bundled-features/src/waitlist/changes.json +7 -0
  213. package/packages/bundled-features/src/waitlist/feature.ts +17 -0
  214. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
  215. package/packages/bundled-features/src/waitlist/mail.ts +69 -18
  216. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +1 -2
  217. package/samples/apps/ui-walkthrough/package.json +2 -0
  218. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
  219. package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
  220. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +154 -0
  221. package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
  222. package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
  223. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +354 -0
  224. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +68 -0
  225. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
  226. package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
  227. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
  228. package/samples/apps/ui-walkthrough/src/run-config.ts +37 -1
  229. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
  230. package/samples/apps/use-all-bundled/feature-manifest.json +115 -20
  231. package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
  232. package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
  233. package/samples/apps/use-all-bundled/package.json +2 -0
  234. package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
  235. package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  237. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
  238. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
  239. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
  240. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
  241. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
  242. package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
  243. package/samples/recipes/delivery-notifications/README.md +26 -25
  244. package/samples/recipes/delivery-notifications/package.json +2 -1
  245. package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
  246. package/samples/recipes/seed-migration/README.md +1 -1
  247. package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
  248. package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
  249. package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
  250. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
  251. package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
  252. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
@@ -0,0 +1,850 @@
1
+ // @runtime client
2
+ //
3
+ // tiptap-backed implementation behind DocumentContentEditor's lazy() boundary —
4
+ // never import this file statically, tiptap must only load when a "document"
5
+ // collection is rendered. Variables are inserted as `variable` nodes only,
6
+ // never as `{{name}}` text, so the sanitizer's schema keeps them.
7
+
8
+ import {
9
+ TextareaContentEditor,
10
+ usePrimitives,
11
+ useTranslation,
12
+ VariableChips,
13
+ } from "@cosmicdrift/kumiko-renderer";
14
+ import { cn } from "@cosmicdrift/kumiko-renderer-web";
15
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
16
+ import type { Editor, JSONContent } from "@tiptap/core";
17
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
18
+ import {
19
+ AlignCenter,
20
+ AlignJustify,
21
+ AlignLeft,
22
+ AlignRight,
23
+ Bold as BoldIcon,
24
+ Braces,
25
+ Image as ImageIcon,
26
+ Italic as ItalicIcon,
27
+ Link as LinkIcon,
28
+ List,
29
+ ListOrdered,
30
+ Minus,
31
+ MousePointerClick,
32
+ Repeat,
33
+ Scissors,
34
+ Table as TableIcon,
35
+ Underline as UnderlineIcon,
36
+ } from "lucide-react";
37
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
38
+ import {
39
+ DOCUMENT_TEXT_ALIGNMENTS,
40
+ type DocumentTextAlignment,
41
+ isAllowedDocumentLinkHref,
42
+ isDocumentPlaceholderHref,
43
+ isDocumentReferenceName,
44
+ isDocumentResourceKey,
45
+ } from "../document-schema.js";
46
+ import { createDocumentEditorExtensions } from "./document-editor-extensions.js";
47
+ import {
48
+ type DocumentEditorProps,
49
+ type DocumentResourcePreview,
50
+ type DocumentToolbarBlock,
51
+ MAIL_DOCUMENT_BLOCKS,
52
+ } from "./document-toolbar-blocks.js";
53
+ import { createEditorHandle } from "./editor-handle.js";
54
+ import { useStableByKey } from "./use-stable-by-key.js";
55
+
56
+ const NO_LABELS: Readonly<Record<string, string>> = {};
57
+ const NO_NAMES: readonly string[] = [];
58
+ const NO_PREVIEWS: Readonly<Record<string, DocumentResourcePreview>> = {};
59
+
60
+ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>): string {
61
+ return Object.entries(previews)
62
+ .map(([key, { src, caption }]) => `${key}|${src.length}|${src.slice(-24)}|${caption ?? ""}`)
63
+ .join("\n");
64
+ }
65
+
66
+ type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align";
67
+
68
+ const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
69
+ image: "image",
70
+ button: "button",
71
+ repeat: "repeat",
72
+ align: "align",
73
+ };
74
+
75
+ const ALIGNMENT_ICONS: Record<DocumentTextAlignment, ReactNode> = {
76
+ left: <AlignLeft size={16} />,
77
+ center: <AlignCenter size={16} />,
78
+ right: <AlignRight size={16} />,
79
+ justify: <AlignJustify size={16} />,
80
+ };
81
+
82
+ const ALIGNMENT_LABEL_KEYS: Record<DocumentTextAlignment, string> = {
83
+ left: "template-resolver.documentEditor.alignLeft",
84
+ center: "template-resolver.documentEditor.alignCenter",
85
+ right: "template-resolver.documentEditor.alignRight",
86
+ justify: "template-resolver.documentEditor.alignJustify",
87
+ };
88
+
89
+ type RepeatTableState = {
90
+ readonly lists: readonly string[];
91
+ readonly headerRepeats: boolean;
92
+ readonly firstRowPos: number;
93
+ };
94
+
95
+ // print-document moves the leading rows flagged repeatHeader into thead, so the
96
+ // toggle reads and writes the table's first row.
97
+ function findRepeatTable(editor: Editor): RepeatTableState | null {
98
+ const { $from } = editor.state.selection;
99
+ for (let depth = $from.depth; depth > 0; depth--) {
100
+ const node = $from.node(depth);
101
+ if (node.type.name !== "table") continue;
102
+ const lists: string[] = [];
103
+ node.forEach((row) => {
104
+ const each: unknown = row.attrs["each"];
105
+ if (typeof each === "string" && each !== "" && !lists.includes(each)) lists.push(each);
106
+ });
107
+ if (lists.length === 0) return null;
108
+ return {
109
+ lists,
110
+ headerRepeats: node.firstChild?.attrs["repeatHeader"] === true,
111
+ firstRowPos: $from.start(depth),
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function toggleRepeatHeader(editor: Editor): void {
118
+ const found = findRepeatTable(editor);
119
+ if (found === null) return;
120
+ editor
121
+ .chain()
122
+ .focus()
123
+ .command(({ tr }) => {
124
+ const row = tr.doc.nodeAt(found.firstRowPos);
125
+ if (row === null) return false;
126
+ tr.setNodeMarkup(found.firstRowPos, undefined, {
127
+ ...row.attrs,
128
+ repeatHeader: !found.headerRepeats,
129
+ });
130
+ return true;
131
+ })
132
+ .run();
133
+ }
134
+
135
+ const PARAGRAPH_STYLES = ["paragraph", "1", "2", "3"] as const;
136
+ type ParagraphStyle = (typeof PARAGRAPH_STYLES)[number];
137
+
138
+ const HEADING_LEVELS = { "1": 1, "2": 2, "3": 3 } as const;
139
+
140
+ function isParagraphStyle(value: string): value is ParagraphStyle {
141
+ return isOneOf(PARAGRAPH_STYLES, value);
142
+ }
143
+
144
+ function emptyParagraph(): JSONContent {
145
+ return { type: "paragraph" };
146
+ }
147
+
148
+ function repeatTableContent(listName: string): JSONContent {
149
+ const headerRow: JSONContent = {
150
+ type: "tableRow",
151
+ content: [
152
+ { type: "tableHeader", content: [emptyParagraph()] },
153
+ { type: "tableHeader", content: [emptyParagraph()] },
154
+ ],
155
+ };
156
+ const repeatRow: JSONContent = {
157
+ type: "tableRow",
158
+ attrs: { each: listName },
159
+ content: [
160
+ { type: "tableCell", content: [emptyParagraph()] },
161
+ { type: "tableCell", content: [emptyParagraph()] },
162
+ ],
163
+ };
164
+ return { type: "table", content: [headerRow, repeatRow] };
165
+ }
166
+
167
+ function Separator({ compact = false }: { readonly compact?: boolean }): ReactNode {
168
+ return (
169
+ <span aria-hidden="true" className={cn("h-5 w-px bg-border", compact ? "mx-0.5" : "mx-2")} />
170
+ );
171
+ }
172
+
173
+ function PanelActions({
174
+ onInsert,
175
+ onRemoveLink,
176
+ onCancel,
177
+ }: {
178
+ readonly onInsert?: () => void;
179
+ readonly onRemoveLink?: () => void;
180
+ readonly onCancel: () => void;
181
+ }): ReactNode {
182
+ const { Button } = usePrimitives();
183
+ const t = useTranslation();
184
+ return (
185
+ <div className="flex gap-2">
186
+ {onInsert && (
187
+ <Button type="button" size="sm" onClick={onInsert}>
188
+ {t("template-resolver.documentEditor.insert")}
189
+ </Button>
190
+ )}
191
+ {onRemoveLink && (
192
+ <Button type="button" size="sm" variant="danger-ghost" onClick={onRemoveLink}>
193
+ {t("template-resolver.documentEditor.removeLink")}
194
+ </Button>
195
+ )}
196
+ <Button type="button" size="sm" variant="secondary" onClick={onCancel}>
197
+ {t("template-resolver.documentEditor.cancel")}
198
+ </Button>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ type OptionSelectProps = {
204
+ readonly id: string;
205
+ readonly label: string;
206
+ readonly names: readonly string[];
207
+ readonly value: string;
208
+ readonly onChange: (name: string) => void;
209
+ };
210
+
211
+ function NameSelect({ id, label, names, value, onChange }: OptionSelectProps): ReactNode {
212
+ const { Field, Input } = usePrimitives();
213
+ return (
214
+ <Field id={id} label={label}>
215
+ <Input
216
+ kind="select"
217
+ id={id}
218
+ name={id}
219
+ value={value}
220
+ onChange={onChange}
221
+ display="dropdown"
222
+ options={names.map((name) => ({ value: name, label: name }))}
223
+ />
224
+ </Field>
225
+ );
226
+ }
227
+
228
+ type PanelProps = {
229
+ readonly editor: Editor;
230
+ readonly editorId: string;
231
+ readonly kind: PanelKind;
232
+ readonly variables: readonly string[];
233
+ readonly onClose: () => void;
234
+ };
235
+
236
+ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps): ReactNode {
237
+ const { Button, Field, Input } = usePrimitives();
238
+ const t = useTranslation();
239
+ const [href, setHref] = useState(() => {
240
+ const current = editor.getAttributes("link")["href"];
241
+ return typeof current === "string" ? current : "";
242
+ });
243
+ const [resource, setResource] = useState("");
244
+ const [alt, setAlt] = useState("");
245
+ const [buttonText, setButtonText] = useState(() =>
246
+ t("template-resolver.documentEditor.buttonDefaultText"),
247
+ );
248
+ const [chosenName, setChosenName] = useState("");
249
+ const [error, setError] = useState<string | null>(null);
250
+
251
+ const referenceNames = variables.filter(isDocumentReferenceName);
252
+ const buttonTargets = referenceNames.filter((name) => isDocumentPlaceholderHref(`{{${name}}}`));
253
+ const selectable = kind === "button" ? buttonTargets : referenceNames;
254
+ const selectedName = selectable.includes(chosenName) ? chosenName : (selectable[0] ?? "");
255
+
256
+ const insertAndClose = (content: JSONContent): void => {
257
+ editor.chain().focus().insertContent(content).run();
258
+ onClose();
259
+ };
260
+
261
+ const submitLink = (): void => {
262
+ if (!isAllowedDocumentLinkHref(href)) {
263
+ setError(t("template-resolver.documentEditor.invalidLink"));
264
+ return;
265
+ }
266
+ const chain = editor.chain().focus();
267
+ if (!editor.state.selection.empty) chain.setLink({ href }).run();
268
+ else if (editor.isActive("link")) chain.extendMarkRange("link").setLink({ href }).run();
269
+ else
270
+ chain
271
+ .insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] })
272
+ .run();
273
+ onClose();
274
+ };
275
+
276
+ const submitImage = (): void => {
277
+ if (!isDocumentResourceKey(resource)) {
278
+ setError(t("template-resolver.documentEditor.invalidResourceKey"));
279
+ return;
280
+ }
281
+ if (/[<>]/.test(alt)) {
282
+ setError(t("template-resolver.documentEditor.invalidImageAlt"));
283
+ return;
284
+ }
285
+ insertAndClose({ type: "resource", attrs: { resource, alt: alt || null } });
286
+ };
287
+
288
+ const needsVariables = kind === "button" || kind === "repeat" || kind === "placeholder";
289
+ const noVariables = needsVariables && selectable.length === 0;
290
+ const idPrefix = `${editorId}-panel`;
291
+
292
+ return (
293
+ <div className="flex flex-col gap-2 border-b border-input bg-muted/40 p-2">
294
+ {noVariables && (
295
+ <p className="text-sm text-muted-foreground">
296
+ {t("template-resolver.documentEditor.noVariables")}
297
+ </p>
298
+ )}
299
+ {kind === "link" && (
300
+ <Field id={`${idPrefix}-href`} label={t("template-resolver.documentEditor.linkHref")}>
301
+ <Input
302
+ kind="text"
303
+ id={`${idPrefix}-href`}
304
+ name="href"
305
+ value={href}
306
+ onChange={setHref}
307
+ placeholder={t("template-resolver.documentEditor.linkHrefPlaceholder")}
308
+ />
309
+ </Field>
310
+ )}
311
+ {kind === "placeholder" && !noVariables && (
312
+ <div className="flex flex-wrap gap-1">
313
+ <VariableChips
314
+ variables={selectable}
315
+ onInsert={(name) => insertAndClose({ type: "variable", attrs: { name } })}
316
+ />
317
+ </div>
318
+ )}
319
+ {kind === "align" && (
320
+ <div className="flex flex-wrap gap-1">
321
+ {DOCUMENT_TEXT_ALIGNMENTS.map((alignment) => (
322
+ <Button
323
+ key={alignment}
324
+ type="button"
325
+ variant="secondary"
326
+ size="sm"
327
+ onClick={() => {
328
+ editor
329
+ .chain()
330
+ .focus()
331
+ .updateAttributes("paragraph", { textAlign: alignment })
332
+ .updateAttributes("heading", { textAlign: alignment })
333
+ .run();
334
+ onClose();
335
+ }}
336
+ >
337
+ {ALIGNMENT_ICONS[alignment]}
338
+ {t(ALIGNMENT_LABEL_KEYS[alignment])}
339
+ </Button>
340
+ ))}
341
+ </div>
342
+ )}
343
+ {kind === "image" && (
344
+ <>
345
+ <Field
346
+ id={`${idPrefix}-resource`}
347
+ label={t("template-resolver.documentEditor.resourceKey")}
348
+ >
349
+ <Input
350
+ kind="text"
351
+ id={`${idPrefix}-resource`}
352
+ name="resource"
353
+ value={resource}
354
+ onChange={setResource}
355
+ />
356
+ </Field>
357
+ <Field id={`${idPrefix}-alt`} label={t("template-resolver.documentEditor.imageAlt")}>
358
+ <Input kind="text" id={`${idPrefix}-alt`} name="alt" value={alt} onChange={setAlt} />
359
+ </Field>
360
+ </>
361
+ )}
362
+ {kind === "button" && !noVariables && (
363
+ <>
364
+ <NameSelect
365
+ id={`${idPrefix}-target`}
366
+ label={t("template-resolver.documentEditor.buttonTarget")}
367
+ names={selectable}
368
+ value={selectedName}
369
+ onChange={setChosenName}
370
+ />
371
+ <Field id={`${idPrefix}-text`} label={t("template-resolver.documentEditor.buttonText")}>
372
+ <Input
373
+ kind="text"
374
+ id={`${idPrefix}-text`}
375
+ name="text"
376
+ value={buttonText}
377
+ onChange={setButtonText}
378
+ />
379
+ </Field>
380
+ </>
381
+ )}
382
+ {kind === "repeat" && !noVariables && (
383
+ <NameSelect
384
+ id={`${idPrefix}-list`}
385
+ label={t("template-resolver.documentEditor.repeatList")}
386
+ names={selectable}
387
+ value={selectedName}
388
+ onChange={setChosenName}
389
+ />
390
+ )}
391
+ {error !== null && (
392
+ <p role="alert" className="text-sm text-destructive">
393
+ {error}
394
+ </p>
395
+ )}
396
+ <div className="flex flex-wrap gap-2">
397
+ <PanelActions
398
+ onCancel={onClose}
399
+ {...(kind === "link" && { onInsert: submitLink })}
400
+ {...(kind === "link" &&
401
+ editor.isActive("link") && {
402
+ onRemoveLink: () => {
403
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
404
+ onClose();
405
+ },
406
+ })}
407
+ {...(kind === "image" && { onInsert: submitImage })}
408
+ {...(kind === "button" &&
409
+ !noVariables && {
410
+ onInsert: () =>
411
+ insertAndClose({
412
+ type: "button",
413
+ attrs: { href: `{{${selectedName}}}` },
414
+ content: buttonText ? [{ type: "text", text: buttonText }] : [],
415
+ }),
416
+ })}
417
+ {...(kind === "repeat" &&
418
+ !noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) })}
419
+ />
420
+ </div>
421
+ </div>
422
+ );
423
+ }
424
+
425
+ export default function DocumentTiptapEditor({
426
+ id,
427
+ value,
428
+ onChange,
429
+ variables,
430
+ readOnly,
431
+ blocks = MAIL_DOCUMENT_BLOCKS,
432
+ variableLabels = NO_LABELS,
433
+ systemVariables = NO_NAMES,
434
+ resourcePreviews = NO_PREVIEWS,
435
+ chrome = "default",
436
+ toolbar = "full",
437
+ onEditorReady,
438
+ }: DocumentEditorProps): ReactNode {
439
+ const t = useTranslation();
440
+ const { Button, Field, Input } = usePrimitives();
441
+ const [openPanel, setOpenPanel] = useState<PanelKind | null>(null);
442
+ const [showHtml, setShowHtml] = useState(false);
443
+ const canvas = chrome === "canvas";
444
+ const compact = toolbar === "compact" && !canvas;
445
+
446
+ const placeholderLabel = t("template-resolver.documentEditor.placeholder");
447
+ const imageLabel = t("template-resolver.documentEditor.image");
448
+ const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
449
+ const buttonLabel = t("template-resolver.documentEditor.button");
450
+ const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
451
+ const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
452
+ const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
453
+ const extensions = useMemo(
454
+ () =>
455
+ createDocumentEditorExtensions({
456
+ placeholder: placeholderLabel,
457
+ image: imageLabel,
458
+ pageBreak: pageBreakLabel,
459
+ button: buttonLabel,
460
+ variableLabels: stableLabels,
461
+ systemVariables: stableSystem,
462
+ resourcePreviews: stablePreviews,
463
+ blockLabels: canvas,
464
+ }),
465
+ [
466
+ placeholderLabel,
467
+ imageLabel,
468
+ pageBreakLabel,
469
+ buttonLabel,
470
+ stableLabels,
471
+ stableSystem,
472
+ stablePreviews,
473
+ canvas,
474
+ ],
475
+ );
476
+
477
+ // The node views are built from labels and previews, so a changed set
478
+ // rebuilds the editor; its content is re-read from `value`.
479
+ const editor = useEditor(
480
+ {
481
+ extensions,
482
+ content: value,
483
+ editable: !readOnly,
484
+ // Suspense (DocumentContentEditor's lazy boundary) speculatively mounts
485
+ // this component before committing it; immediate render would create an
486
+ // editor instance during that throwaway pass.
487
+ immediatelyRender: false,
488
+ editorProps: { attributes: { id } },
489
+ onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
490
+ onUpdate: ({ editor: e }) => onChange(e.getHTML()),
491
+ },
492
+ [extensions],
493
+ );
494
+
495
+ // @tiptap/react v3 does not re-render on selection-only transactions, so
496
+ // active states must come from a subscription.
497
+ const activeState = useEditorState({
498
+ editor,
499
+ selector: ({ editor: e }) =>
500
+ e === null
501
+ ? null
502
+ : {
503
+ blockStyle: e.isActive("heading", { level: 1 })
504
+ ? "1"
505
+ : e.isActive("heading", { level: 2 })
506
+ ? "2"
507
+ : e.isActive("heading", { level: 3 })
508
+ ? "3"
509
+ : "paragraph",
510
+ bold: e.isActive("bold"),
511
+ italic: e.isActive("italic"),
512
+ underline: e.isActive("underline"),
513
+ selectedBlock: e.isActive("resource")
514
+ ? {
515
+ type: "resource" as const,
516
+ detail: String(e.getAttributes("resource")["resource"]),
517
+ }
518
+ : e.isActive("button")
519
+ ? { type: "button" as const, detail: String(e.getAttributes("button")["href"]) }
520
+ : null,
521
+ html: showHtml ? e.getHTML() : "",
522
+ bulletList: e.isActive("bulletList"),
523
+ orderedList: e.isActive("orderedList"),
524
+ link: e.isActive("link"),
525
+ repeatTable: findRepeatTable(e),
526
+ },
527
+ });
528
+
529
+ // The entry loads asynchronously, so `value` arrives after useEditor read
530
+ // its initial content; the guard keeps in-flight typing from being clobbered.
531
+ // kumiko-lint-ignore no-raw-hooks DOM integration: syncs the controlled value into the imperative tiptap editor
532
+ useEffect(() => {
533
+ if (editor && value !== editor.getHTML()) {
534
+ editor.commands.setContent(value, { emitUpdate: false });
535
+ }
536
+ }, [value, editor]);
537
+
538
+ // kumiko-lint-ignore no-raw-hooks DOM integration: tiptap exposes editability only imperatively via setEditable
539
+ useEffect(() => {
540
+ editor?.setEditable(!readOnly);
541
+ }, [readOnly, editor]);
542
+
543
+ if (!editor)
544
+ return (
545
+ <TextareaContentEditor
546
+ id={id}
547
+ value={value}
548
+ onChange={onChange}
549
+ variables={variables}
550
+ readOnly={readOnly}
551
+ />
552
+ );
553
+
554
+ const togglePanel = (kind: PanelKind): void =>
555
+ setOpenPanel((current) => (current === kind ? null : kind));
556
+
557
+ const setBlockStyle = (style: string): void => {
558
+ if (!isParagraphStyle(style)) return;
559
+ const chain = editor.chain().focus();
560
+ if (style === "paragraph") chain.setParagraph().run();
561
+ else chain.setHeading({ level: HEADING_LEVELS[style] }).run();
562
+ };
563
+
564
+ const runBlock = (block: DocumentToolbarBlock): void => {
565
+ const panel = BLOCK_PANEL[block];
566
+ if (panel) {
567
+ togglePanel(panel);
568
+ return;
569
+ }
570
+ const chain = editor.chain().focus();
571
+ if (block === "table") chain.insertTable({ rows: 3, cols: 2, withHeaderRow: true }).run();
572
+ else if (block === "divider") chain.insertContent({ type: "divider" }).run();
573
+ else if (block === "pageBreak") chain.insertContent({ type: "pageBreak" }).run();
574
+ };
575
+
576
+ const blockButtons: Record<DocumentToolbarBlock, { label: string; icon: ReactNode }> = {
577
+ image: { label: imageLabel, icon: <ImageIcon size={16} /> },
578
+ table: { label: t("template-resolver.documentEditor.table"), icon: <TableIcon size={16} /> },
579
+ button: {
580
+ label: t("template-resolver.documentEditor.button"),
581
+ icon: <MousePointerClick size={16} />,
582
+ },
583
+ divider: { label: t("template-resolver.documentEditor.divider"), icon: <Minus size={16} /> },
584
+ repeat: { label: t("template-resolver.documentEditor.repeat"), icon: <Repeat size={16} /> },
585
+ pageBreak: { label: pageBreakLabel, icon: <Scissors size={16} /> },
586
+ align: { label: t("template-resolver.documentEditor.align"), icon: <AlignLeft size={16} /> },
587
+ };
588
+
589
+ const repeatTable = activeState?.repeatTable ?? null;
590
+ const repeatListLabel =
591
+ repeatTable !== null && repeatTable.lists.length === 1
592
+ ? (variableLabels[repeatTable.lists[0] ?? ""] ?? repeatTable.lists[0])
593
+ : undefined;
594
+
595
+ const paragraphStyleLabel = t("template-resolver.documentEditor.paragraphStyle");
596
+ const styleOptions = [
597
+ { value: "paragraph", label: t("template-resolver.documentEditor.paragraph") },
598
+ { value: "1", label: t("kumiko.contentEditor.heading1") },
599
+ { value: "2", label: t("kumiko.contentEditor.heading2") },
600
+ { value: "3", label: t("template-resolver.documentEditor.heading3") },
601
+ ];
602
+
603
+ // Canvas chrome shows plain text buttons instead of icons.
604
+ const iconButton = (
605
+ label: string,
606
+ icon: ReactNode,
607
+ isActive: boolean,
608
+ onClick: () => void,
609
+ ): ReactNode =>
610
+ canvas ? (
611
+ <Button
612
+ type="button"
613
+ variant={isActive ? "secondary" : "ghost"}
614
+ size="sm"
615
+ pressed={isActive}
616
+ disabled={readOnly}
617
+ onClick={onClick}
618
+ ariaLabel={label}
619
+ title={label}
620
+ >
621
+ {icon}
622
+ </Button>
623
+ ) : (
624
+ <Button
625
+ type="button"
626
+ variant={isActive ? "primary" : "secondary"}
627
+ size="icon"
628
+ pressed={isActive}
629
+ disabled={readOnly}
630
+ onClick={onClick}
631
+ ariaLabel={label}
632
+ title={label}
633
+ >
634
+ {icon}
635
+ </Button>
636
+ );
637
+
638
+ const markContent = (letter: string, icon: ReactNode, className: string): ReactNode =>
639
+ canvas ? <span className={className}>{letter}</span> : icon;
640
+ const selectedBlock = activeState?.selectedBlock ?? null;
641
+
642
+ const flatVariant = canvas ? "ghost" : "secondary";
643
+
644
+ const labelledButton = (spec: {
645
+ readonly key?: string;
646
+ readonly label: string;
647
+ readonly icon: ReactNode;
648
+ readonly showIcon: boolean;
649
+ readonly expanded?: boolean;
650
+ readonly onClick: () => void;
651
+ }): ReactNode => {
652
+ const shared = {
653
+ type: "button",
654
+ variant: flatVariant,
655
+ disabled: readOnly,
656
+ onClick: spec.onClick,
657
+ ...(spec.expanded !== undefined && { expanded: spec.expanded }),
658
+ } as const;
659
+ return compact ? (
660
+ <Button key={spec.key} {...shared} size="icon" ariaLabel={spec.label} title={spec.label}>
661
+ {spec.icon}
662
+ </Button>
663
+ ) : (
664
+ <Button key={spec.key} {...shared} size="sm">
665
+ {spec.showIcon && spec.icon}
666
+ {spec.label}
667
+ </Button>
668
+ );
669
+ };
670
+
671
+ return (
672
+ <div
673
+ className={cn("rounded-md border border-input", canvas ? "bg-background" : "bg-transparent")}
674
+ >
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(),
708
+ )}
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) =>
738
+ labelledButton({
739
+ key: block,
740
+ label: blockButtons[block].label,
741
+ icon: blockButtons[block].icon,
742
+ showIcon: !canvas,
743
+ 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}
770
+ </span>
771
+ <code className="text-xs text-muted-foreground">{selectedBlock.detail}</code>
772
+ <span className="flex-1" />
773
+ <Button
774
+ type="button"
775
+ size="sm"
776
+ variant="danger-ghost"
777
+ onClick={() => {
778
+ editor.chain().focus().deleteNode(selectedBlock.type).run();
779
+ }}
780
+ >
781
+ {t("template-resolver.documentEditor.removeBlock")}
782
+ </Button>
783
+ </div>
784
+ )}
785
+ {canvas && showHtml && (
786
+ <section aria-label={t("template-resolver.documentEditor.htmlSource")}>
787
+ <pre className="h-40 overflow-auto whitespace-pre-wrap break-all border-b border-input bg-muted p-2 font-mono text-xs">
788
+ {activeState?.html ?? ""}
789
+ </pre>
790
+ </section>
791
+ )}
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 && (
803
+ <div
804
+ data-testid="document-repeat-bar"
805
+ className="flex flex-wrap items-center gap-3 border-b border-input bg-muted/40 px-2 py-1 text-xs"
806
+ >
807
+ <span className="font-medium">
808
+ {repeatListLabel === undefined
809
+ ? t("template-resolver.documentEditor.repeat")
810
+ : t("template-resolver.documentEditor.repeatBarList", { list: repeatListLabel })}
811
+ </span>
812
+ <Button
813
+ type="button"
814
+ variant="secondary"
815
+ size="sm"
816
+ pressed={repeatTable.headerRepeats}
817
+ onClick={() => toggleRepeatHeader(editor)}
818
+ >
819
+ {t("template-resolver.documentEditor.repeatHeader")}
820
+ </Button>
821
+ <span className="text-muted-foreground">
822
+ {t("template-resolver.documentEditor.keepRowsTogether")}
823
+ </span>
824
+ </div>
825
+ )}
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
+ />
847
+ </div>
848
+ </div>
849
+ );
850
+ }