@cosmicdrift/kumiko-samples 0.361.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 (265) 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/aggregate-id.ts +10 -0
  75. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  76. package/packages/bundled-features/src/document-ingest-foundation/events.ts +3 -1
  77. package/packages/bundled-features/src/document-ingest-foundation/feature.ts +5 -3
  78. package/packages/bundled-features/src/document-ingest-foundation/index.ts +1 -0
  79. package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
  80. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
  81. package/packages/bundled-features/src/file-foundation/index.ts +7 -0
  82. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +86 -0
  83. package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
  84. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
  85. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
  86. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
  87. package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  89. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
  90. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  91. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  92. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
  93. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
  95. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
  96. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
  97. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +40 -25
  98. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
  99. package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
  100. package/packages/bundled-features/src/ledger/recurring.ts +1 -3
  101. package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
  102. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  103. package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
  104. package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
  105. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
  106. package/packages/bundled-features/src/page-render/branding.ts +3 -0
  107. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  108. package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
  109. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
  110. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
  111. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
  112. package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
  113. package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
  114. package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
  115. package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
  116. package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
  117. package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
  118. package/packages/bundled-features/src/renderer-foundation/types.ts +132 -35
  119. package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
  120. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
  121. package/packages/bundled-features/src/sessions/feature.ts +2 -4
  122. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
  123. package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
  124. package/packages/bundled-features/src/shared/index.ts +6 -0
  125. package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
  126. package/packages/bundled-features/src/shared/mail-template-body.ts +25 -0
  127. package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
  128. package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
  129. package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
  130. package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
  131. package/packages/bundled-features/src/template-resolver/README.md +63 -7
  132. package/packages/bundled-features/src/template-resolver/api.ts +83 -8
  133. package/packages/bundled-features/src/template-resolver/changes.json +118 -0
  134. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +214 -0
  135. package/packages/bundled-features/src/template-resolver/constants.ts +39 -11
  136. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
  137. package/packages/bundled-features/src/template-resolver/document-schema.ts +323 -0
  138. package/packages/bundled-features/src/template-resolver/feature.ts +56 -6
  139. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
  140. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
  141. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +142 -0
  142. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +72 -0
  143. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +83 -0
  144. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -3
  145. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
  146. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
  147. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +108 -0
  148. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +5 -7
  149. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +250 -0
  150. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
  151. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
  153. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
  154. package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
  155. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
  156. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +106 -0
  157. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
  158. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
  159. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +184 -0
  160. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +183 -0
  161. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +55 -0
  162. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +57 -10
  163. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
  164. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +15 -76
  165. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +17 -0
  166. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
  167. package/packages/bundled-features/src/template-resolver/index.ts +100 -0
  168. package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
  169. package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
  170. package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
  171. package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
  172. package/packages/bundled-features/src/template-resolver/qualified-names.ts +30 -0
  173. package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
  174. package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
  175. package/packages/bundled-features/src/template-resolver/seeding.ts +147 -17
  176. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
  177. package/packages/bundled-features/src/template-resolver/table.ts +14 -0
  178. package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
  179. package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
  180. package/packages/bundled-features/src/template-resolver/template-meta.ts +129 -0
  181. package/packages/bundled-features/src/template-resolver/template-preview.ts +573 -0
  182. package/packages/bundled-features/src/template-resolver/template-resources.ts +74 -0
  183. package/packages/bundled-features/src/template-resolver/template-usage.ts +48 -0
  184. package/packages/bundled-features/src/template-resolver/template-validation.ts +229 -0
  185. package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
  186. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +9 -6
  187. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +162 -0
  188. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +28 -0
  189. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +850 -0
  190. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +61 -0
  191. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
  192. package/packages/bundled-features/src/template-resolver/web/i18n.ts +100 -0
  193. package/packages/bundled-features/src/template-resolver/web/index.ts +19 -0
  194. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
  195. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +169 -0
  196. package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
  197. package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
  198. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
  199. package/packages/bundled-features/src/tenant/seeding.ts +41 -7
  200. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
  201. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
  202. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
  203. package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
  204. package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
  205. package/packages/bundled-features/src/user-data-rights/changes.json +14 -0
  206. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
  207. package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
  208. package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
  209. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
  210. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +5 -1
  211. package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts +13 -1
  212. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
  213. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  214. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
  215. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  216. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
  217. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  218. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +6 -12
  219. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  220. package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +10 -1
  221. package/packages/bundled-features/src/user-data-rights-defaults/hooks/download-attempt.userdata-hook.ts +58 -0
  222. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
  223. package/packages/bundled-features/src/waitlist/changes.json +7 -0
  224. package/packages/bundled-features/src/waitlist/feature.ts +17 -0
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
  226. package/packages/bundled-features/src/waitlist/mail.ts +69 -18
  227. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +1 -2
  228. package/samples/apps/ui-walkthrough/package.json +2 -0
  229. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
  230. package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
  231. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +154 -0
  232. package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
  233. package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
  234. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +354 -0
  235. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +68 -0
  236. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
  237. package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
  238. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
  239. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +1 -1
  240. package/samples/apps/ui-walkthrough/src/run-config.ts +37 -1
  241. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
  242. package/samples/apps/use-all-bundled/feature-manifest.json +123 -22
  243. package/samples/apps/use-all-bundled/kumiko/migrations/0044_download-attempt-ip-bidx.rebuild.json +4 -0
  244. package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
  245. package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
  246. package/samples/apps/use-all-bundled/package.json +2 -0
  247. package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
  248. package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
  249. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  250. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
  251. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
  252. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
  253. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
  254. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
  255. package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
  256. package/samples/recipes/delivery-notifications/README.md +26 -25
  257. package/samples/recipes/delivery-notifications/package.json +2 -1
  258. package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
  259. package/samples/recipes/seed-migration/README.md +1 -1
  260. package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
  261. package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
  262. package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
  263. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
  264. package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
  265. 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
+ }