@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,1601 @@
1
+ /**
2
+ * RichThreadComposerImpl — the TipTap surface behind the rich composer.
3
+ *
4
+ * ADR-148. This module is reached ONLY through the `lazy()` boundary in
5
+ * rich-composer.tsx (D5): every TipTap import here is static and ordinary, so
6
+ * Vite splits the whole editor into one chunk and the D6 fallback wraps one
7
+ * promise. Nothing in this file is re-exported from the package barrel.
8
+ *
9
+ * Two components. The default export is the GATE (D3 rule 2): it runs
10
+ * `isRichEditable` on the draft before any editor exists and, if the body
11
+ * holds syntax the subset cannot carry, renders the plain composer with the
12
+ * source untouched and a one-line notice. `RichSurface` below it owns the
13
+ * editor. The gate lives here rather than in the shell because the detector
14
+ * needs the subset's schema, and the shell must stay free of TipTap.
15
+ *
16
+ * The seam stays `ThreadComposerProps`: markdown in through `value`, markdown
17
+ * out through `onChange`, and verified attachment ticket ids out through the
18
+ * optional `onSubmit` argument (ADR-151 D1). Hooks and host capabilities sit
19
+ * on top: `onEditor`, `mentionItems`, upload/GIF callbacks, limits, and the
20
+ * shell's polite `announce` region. Missing capabilities render no inert UI.
21
+ *
22
+ * DRAFT TIMING (D12). `onChange` receives serialized markdown on a 300 ms
23
+ * trailing debounce, not on every transaction, and is flushed on blur, send
24
+ * and unmount. Because the parent is therefore up to 300 ms behind the editor,
25
+ * SEND does not call `onSubmit` until the parent has caught up: `requestSubmit`
26
+ * flushes, and an effect fires `onSubmit` once `value` equals what was flushed
27
+ * and every attachment has a verified ticket — so `useThread.submit`, which
28
+ * reads the draft it was rendered with, always sends the latest keystrokes.
29
+ * `canSend` reads the editor's own emptiness for the same reason.
30
+ *
31
+ * KEYBOARD (D9). Every Escape level lives in ONE `handleKeyDown`: bubble menu,
32
+ * then Suggestion popup, then `onCancel`. `editorProps` handlers run before
33
+ * any extension keymap, which is what makes the order a fact rather than a
34
+ * hope. Tab is answered from `handleDOMEvents.keydown` returning true — that
35
+ * skips ProseMirror's keymaps (StarterKit binds Tab to indent a list item)
36
+ * WITHOUT preventing the browser default, so focus leaves the editor. While a
37
+ * suggestion popup is open Tab is NOT waved through: ADR-149 D5 gives it to
38
+ * the popup, so the DOM-event handler stands down and `handleKeyDown` inserts.
39
+ *
40
+ * SUGGESTIONS (ADR-149). Two triggers, ONE popup. `markdown-spec.ts` owns the
41
+ * data half (what `@` and `:` offer, and what choosing inserts); this file
42
+ * owns the UI half: the `render()` lifecycle each trigger gets, the single
43
+ * `<SuggestionList>` both of them draw into, and the keys. The popup NEVER
44
+ * takes focus — it is mounted in a portal outside the editor and pointed at by
45
+ * `aria-controls` / `aria-activedescendant` on the contenteditable itself, so
46
+ * `document.activeElement` is the editor through open, arrow, insert and
47
+ * escape (D5, AC#6).
48
+ *
49
+ * IME (D11). The bubble predicate refuses while `view.composing` or our own
50
+ * composition flag is set; `compositionstart` closes an open menu outright.
51
+ *
52
+ * What jsdom cannot verify here, and the tests say so: the bubble menu's
53
+ * pixel placement (floating-ui needs layout) and real composition sessions.
54
+ * Both are on the owner's eyeball + IME pass (AC#13).
55
+ */
56
+
57
+ import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from 'react'
58
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
59
+ import { createPortal } from 'react-dom'
60
+ import {
61
+ Bold,
62
+ Check,
63
+ Code,
64
+ CornerDownRight,
65
+ Heading1,
66
+ Heading2,
67
+ Heading3,
68
+ Italic,
69
+ Images,
70
+ Link as LinkIcon,
71
+ List,
72
+ ListOrdered,
73
+ Minus,
74
+ SquareCode,
75
+ Strikethrough,
76
+ TextQuote,
77
+ Underline as UnderlineIcon,
78
+ Unlink,
79
+ X,
80
+ } from 'lucide-react'
81
+ import { isTextSelection, type Editor } from '@tiptap/core'
82
+ import { Placeholder } from '@tiptap/extension-placeholder'
83
+ import { Fragment, Node as ProseMirrorNode, Slice } from '@tiptap/pm/model'
84
+ import type { PluginKey } from '@tiptap/pm/state'
85
+ import { PluginKey as ProseMirrorPluginKey } from '@tiptap/pm/state'
86
+ import type { EditorView } from '@tiptap/pm/view'
87
+ import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
88
+ import { BubbleMenu } from '@tiptap/react/menus'
89
+ import { Button } from '../button'
90
+ import { Kbd } from '../kbd'
91
+ import { cn } from '../lib/utils'
92
+ import { Popover } from '../popover'
93
+ import {
94
+ ATTACHMENT_ACCEPT,
95
+ AttachmentButton,
96
+ AttachmentDropZone,
97
+ AttachmentTray,
98
+ GifPicker,
99
+ } from './attachments'
100
+ import type { MentionTargetType } from './comment-body'
101
+ import type { MentionSuggestionItem } from './types'
102
+ import { ThreadComposer, type ThreadComposerProps } from './composer'
103
+ import { ComposerFooter } from './composer-footer'
104
+ import { caretScrollDelta } from './scroll-caret'
105
+ import { searchEmoji } from './emoji'
106
+ import {
107
+ EMOJI_SUGGESTION_MIN_QUERY,
108
+ MENTION_SUGGESTION_MIN_QUERY,
109
+ THREAD_EMOJI_SUGGESTION_KEY,
110
+ THREAD_MENTION_SUGGESTION_KEY,
111
+ isRichEditable,
112
+ parseRichMarkdown,
113
+ richSubsetExtensions,
114
+ serializeRichMarkdown,
115
+ type EmojiSuggestionAttrs,
116
+ } from './markdown-spec'
117
+ import { ComposerNotice, type RichComposerAttachmentProps } from './rich-composer'
118
+ import { SuggestionList, wrapIndex, type SuggestionOption } from './suggestion-list'
119
+ import { useAttachments } from './use-attachments'
120
+
121
+ /** D12: the draft leaves the editor on this trailing debounce. */
122
+ export const DRAFT_DEBOUNCE_MS = 300
123
+
124
+ /** D3 rule 2, in the user's words. Rendered above the plain composer and spoken once. */
125
+ export const FAIL_CLOSED_NOTICE =
126
+ 'Opened in the plain editor: this comment uses formatting the rich editor cannot hold. Nothing was changed.'
127
+
128
+ export type { MentionSuggestionItem } from './types'
129
+
130
+ /** Hooks on top of the seam. None of these is part of `ThreadComposerProps`. */
131
+ export interface RichComposerHooks extends RichComposerAttachmentProps {
132
+ /** Receives the live editor once created, and null when it goes away. */
133
+ readonly onEditor?: ((editor: Editor | null) => void) | undefined
134
+ /** ADR-149's seam: items for the `@` picker. Absent, the popup never opens. */
135
+ readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
136
+ /** The shell's polite live region. */
137
+ readonly announce?: ((message: string) => void) | undefined
138
+ }
139
+
140
+ export type RichThreadComposerImplProps = ThreadComposerProps & RichComposerHooks
141
+
142
+ /* ── Platform spelling, read once (same reasoning as composer.tsx) ─────────── */
143
+
144
+ const IS_APPLE: boolean =
145
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent)
146
+ const SEND_KEYCAP = IS_APPLE ? '⌘↵' : 'Ctrl ↵'
147
+ const MOD_NAME = IS_APPLE ? 'Command' : 'Control'
148
+
149
+ /** React accepts CSS custom properties in `style`; the DOM typings do not declare them. */
150
+ type StyleWithVars = CSSProperties & Record<`--${string}`, string>
151
+
152
+ const BUBBLE_MENU_KEY = new ProseMirrorPluginKey('threadBubbleMenu')
153
+
154
+ /* ── The gate (D3 rule 2) ─────────────────────────────────────────────────── */
155
+
156
+ export default function RichThreadComposerImpl(props: RichThreadComposerImplProps) {
157
+ const { value, onChange, announce } = props
158
+
159
+ // What the parent currently holds BECAUSE we emitted it. A `value` that
160
+ // differs from it arrived from outside — a resumed draft, a comment opened
161
+ // for editing, a reset after send — and is the only kind that can change the
162
+ // gate's answer. Our own emissions never re-run the detector: the editor
163
+ // cannot produce syntax the editor cannot hold.
164
+ const lastEmitted = useRef<string | null>(null)
165
+ const [gate, setGate] = useState(() => ({ value, rich: isRichEditable(value) }))
166
+ if (value !== gate.value) {
167
+ setGate({ value, rich: value === lastEmitted.current ? gate.rich : isRichEditable(value) })
168
+ }
169
+
170
+ const emit = useCallback(
171
+ (next: string) => {
172
+ lastEmitted.current = next
173
+ onChange(next)
174
+ },
175
+ [onChange],
176
+ )
177
+
178
+ useEffect(() => {
179
+ if (!gate.rich) announce?.(FAIL_CLOSED_NOTICE)
180
+ }, [announce, gate.rich])
181
+
182
+ if (!gate.rich) {
183
+ const {
184
+ onEditor: _onEditor,
185
+ mentionItems: _items,
186
+ announce: _announce,
187
+ uploadAttachment: _uploadAttachment,
188
+ attachGif: _attachGif,
189
+ searchGifs: _searchGifs,
190
+ attachmentAccept: _attachmentAccept,
191
+ attachmentMaxPerComment: _attachmentMaxPerComment,
192
+ attachmentMaxFileBytes: _attachmentMaxFileBytes,
193
+ attachmentMaxTotalBytes: _attachmentMaxTotalBytes,
194
+ ...plain
195
+ } = props
196
+ return (
197
+ <div className="flex flex-col gap-[var(--space-1)]">
198
+ <ComposerNotice>{FAIL_CLOSED_NOTICE}</ComposerNotice>
199
+ <ThreadComposer {...plain} onChange={emit} />
200
+ </div>
201
+ )
202
+ }
203
+ return <RichSurface {...props} onChange={emit} />
204
+ }
205
+
206
+ /* ── The suggestion popup (ADR-149) ───────────────────────────────────────── */
207
+
208
+ /** Which trigger a popup session belongs to. Two configs, one popup (D1). */
209
+ type SuggestionKind = 'mention' | 'emoji'
210
+
211
+ /** What choosing an option hands back. Both shapes carry `id` + `label`. */
212
+ type SuggestionAttrs = MentionSuggestionItem | EmojiSuggestionAttrs
213
+
214
+ /**
215
+ * The slice of `@tiptap/suggestion`'s render props this file actually uses,
216
+ * declared locally and precisely.
217
+ *
218
+ * `@tiptap/suggestion` types `SuggestionProps` with `I = any`, so reading
219
+ * `props.items` or `props.command` through its own types would leak `any` into
220
+ * this file (CLAUDE.md §3). A narrower parameter type is still assignable — a
221
+ * handler that accepts less than it is given always is — so this costs
222
+ * nothing and keeps the file free of `any`.
223
+ */
224
+ interface SuggestionRenderProps {
225
+ readonly query: string
226
+ readonly command: (attrs: SuggestionAttrs) => void
227
+ readonly mount: (
228
+ element: HTMLElement,
229
+ options?: {
230
+ onPosition?: (data: { x: number; y: number; strategy: 'absolute' | 'fixed' }) => void
231
+ },
232
+ ) => () => void
233
+ }
234
+
235
+ /** The rows a session is showing: what to draw, and what to insert. */
236
+ interface SuggestionRows {
237
+ readonly options: readonly SuggestionOption[]
238
+ readonly attrs: readonly SuggestionAttrs[]
239
+ }
240
+
241
+ const EMPTY_ROWS: SuggestionRows = { options: [], attrs: [] }
242
+
243
+ const SUGGESTION_KEYS: Readonly<Record<SuggestionKind, PluginKey>> = {
244
+ mention: THREAD_MENTION_SUGGESTION_KEY,
245
+ emoji: THREAD_EMOJI_SUGGESTION_KEY,
246
+ }
247
+
248
+ const SUGGESTION_LABELS: Readonly<Record<SuggestionKind, string>> = {
249
+ mention: 'Mention someone',
250
+ emoji: 'Emoji',
251
+ }
252
+
253
+ /** The same two, as the noun the polite announcement counts. */
254
+ const SUGGESTION_NOUNS: Readonly<Record<SuggestionKind, string>> = {
255
+ mention: 'mention',
256
+ emoji: 'emoji',
257
+ }
258
+
259
+ /**
260
+ * D4's thresholds, enforced HERE and not only on the plugin.
261
+ *
262
+ * `minQueryLength` in the Suggestion config stops the plugin doing needless
263
+ * `items()` work, but all it does to the renderer is hand it an empty item
264
+ * list — and this file computes its rows from the QUERY rather than from
265
+ * `props.items` (see `SuggestionRenderProps`). So the threshold has to be a
266
+ * rule the popup itself applies, or a host whose `mentionItems` ignores its
267
+ * query would open the popup on a bare `@`. Same constants, one behaviour.
268
+ */
269
+ const SUGGESTION_MIN_QUERY: Readonly<Record<SuggestionKind, number>> = {
270
+ mention: MENTION_SUGGESTION_MIN_QUERY,
271
+ emoji: EMOJI_SUGGESTION_MIN_QUERY,
272
+ }
273
+
274
+ /** User-facing names for the three mention schemes (ADR-147 D11). */
275
+ const MENTION_KIND_LABELS: Readonly<Record<MentionTargetType, string>> = {
276
+ user: 'Person',
277
+ task_item: 'Task',
278
+ task_board: 'Board',
279
+ }
280
+
281
+ /**
282
+ * Close a Suggestion plugin without touching the document.
283
+ *
284
+ * This is the exact transaction `@tiptap/suggestion`'s own `exitSuggestion()`
285
+ * dispatches. It is re-spelled here rather than imported because that package
286
+ * is transitive — ADR-148 D2 deliberately did not install it, and AC#1 forbids
287
+ * adding it — so under pnpm's strict layout `@lovett/ui` cannot resolve it.
288
+ * The meta also marks the range dismissed, which is what keeps the popup shut
289
+ * after Escape instead of reopening on the next keystroke.
290
+ */
291
+ function exitSuggestionPlugin(view: EditorView, kind: SuggestionKind): void {
292
+ view.dispatch(view.state.tr.setMeta(SUGGESTION_KEYS[kind], { exit: true }))
293
+ }
294
+
295
+ /** The `:` picker's rows: the glyph, its canonical `:shortcode:`. */
296
+ function emojiRows(query: string): SuggestionRows {
297
+ const entries = searchEmoji(query)
298
+ return {
299
+ options: entries.map((entry) => ({
300
+ key: entry.name,
301
+ label: `:${entry.name}:`,
302
+ glyph: entry.glyph,
303
+ })),
304
+ attrs: entries.map((entry) => ({ id: entry.name, label: entry.glyph })),
305
+ }
306
+ }
307
+
308
+ /** The first character of a label, for the leading slot. */
309
+ function leadingInitial(label: string): string {
310
+ return ([...label][0] ?? '').toUpperCase()
311
+ }
312
+
313
+ /** The `@` picker's rows, from whatever list the host handed us (D6). */
314
+ function mentionRows(items: readonly MentionSuggestionItem[]): SuggestionRows {
315
+ return {
316
+ options: items.map((item) => ({
317
+ key: `${item.type}:${item.id}`,
318
+ label: item.label,
319
+ hint: MENTION_KIND_LABELS[item.type],
320
+ glyph: leadingInitial(item.label),
321
+ // The initial is the label's own first letter — decoration, not
322
+ // information. The emoji rows above deliberately do NOT set this.
323
+ glyphDecorative: true,
324
+ })),
325
+ attrs: [...items],
326
+ }
327
+ }
328
+
329
+ /* ── Helpers ──────────────────────────────────────────────────────────────── */
330
+
331
+ function isPlainTab(event: KeyboardEvent): boolean {
332
+ return event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey
333
+ }
334
+
335
+ const BARE_URL = /^(?:https?:\/\/|www\.)\S+$/i
336
+
337
+ /**
338
+ * What the link field accepts: an http(s) / mailto / tel URL, or a bare domain
339
+ * that gets `https://` in front. Anything with another scheme is refused —
340
+ * `javascript:` above all — before it can reach `setLink`, which refuses it
341
+ * again by its own allowlist. Two locks, same door.
342
+ */
343
+ function normalizeHref(input: string): string | null {
344
+ const trimmed = input.trim()
345
+ if (trimmed === '') return null
346
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
347
+ return /^(?:https?:|mailto:|tel:)/i.test(trimmed) ? trimmed : null
348
+ }
349
+ if (/^(?:www\.)?[\w-]+(?:\.[\w-]+)+(?:[/?#]\S*)?$/i.test(trimmed)) return `https://${trimmed}`
350
+ return null
351
+ }
352
+
353
+ /** A `--space-*` token as a number, for floating-ui's offset. Falls back to the token's documented value. */
354
+ function readSpaceToken(element: HTMLElement | null, token: string, fallback: number): number {
355
+ if (element === null || typeof getComputedStyle !== 'function') return fallback
356
+ const raw = parseFloat(getComputedStyle(element).getPropertyValue(token))
357
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback
358
+ }
359
+
360
+ function focusableButtons(root: HTMLElement | null): HTMLButtonElement[] {
361
+ if (root === null) return []
362
+ return Array.from(root.querySelectorAll<HTMLButtonElement>('button:not([disabled])'))
363
+ }
364
+
365
+ /* ── The editor ───────────────────────────────────────────────────────────── */
366
+
367
+ interface LatestProps {
368
+ readonly value: string
369
+ readonly onChange: (value: string) => void
370
+ readonly onSubmit: (attachmentIds?: readonly string[]) => void
371
+ readonly onCancel: (() => void) | undefined
372
+ readonly mentionItems: ((query: string) => readonly MentionSuggestionItem[]) | undefined
373
+ readonly announce: ((message: string) => void) | undefined
374
+ }
375
+
376
+ function RichSurface({
377
+ value,
378
+ onChange,
379
+ onSubmit,
380
+ onCancel,
381
+ placeholder = 'Add a comment…',
382
+ submitLabel = 'Comment',
383
+ cancelLabel = 'Cancel',
384
+ sending = false,
385
+ disabled = false,
386
+ replyingTo,
387
+ avatar,
388
+ rows = 3,
389
+ mode,
390
+ onModeChange,
391
+ autoFocus = false,
392
+ toolbar = true,
393
+ maxLength,
394
+ label,
395
+ className,
396
+ onEditor,
397
+ mentionItems,
398
+ announce,
399
+ uploadAttachment,
400
+ attachGif,
401
+ searchGifs,
402
+ attachmentAccept,
403
+ attachmentMaxPerComment,
404
+ attachmentMaxFileBytes,
405
+ attachmentMaxTotalBytes,
406
+ }: RichThreadComposerImplProps) {
407
+ const labelId = useId()
408
+ const hintId = useId()
409
+ const linkErrorId = useId()
410
+ const listboxId = useId()
411
+ const shellRef = useRef<HTMLDivElement>(null)
412
+ const bubbleRef = useRef<HTMLDivElement>(null)
413
+ const linkInputRef = useRef<HTMLInputElement>(null)
414
+ const [gifPickerOpen, setGifPickerOpen] = useState(false)
415
+ const attachments = useAttachments({
416
+ ...(uploadAttachment === undefined ? {} : { upload: uploadAttachment }),
417
+ ...(attachGif === undefined ? {} : { attachGif }),
418
+ ...(announce === undefined ? {} : { onError: announce }),
419
+ ...(attachmentMaxPerComment === undefined
420
+ ? {}
421
+ : { maxPerComment: attachmentMaxPerComment }),
422
+ ...(attachmentMaxFileBytes === undefined ? {} : { maxFileBytes: attachmentMaxFileBytes }),
423
+ ...(attachmentMaxTotalBytes === undefined
424
+ ? {}
425
+ : { maxTotalBytes: attachmentMaxTotalBytes }),
426
+ })
427
+
428
+ // The editor is created once; its callbacks read the latest props through
429
+ // this ref rather than the closures they were created with.
430
+ const latest = useRef<LatestProps>({ value, onChange, onSubmit, onCancel, mentionItems, announce })
431
+ useLayoutEffect(() => {
432
+ latest.current = { value, onChange, onSubmit, onCancel, mentionItems, announce }
433
+ })
434
+ const editorRef = useRef<Editor | null>(null)
435
+
436
+ // D12 — draft emission.
437
+ const lastEmitted = useRef<string>(value)
438
+ const emitTimer = useRef<number | null>(null)
439
+ const [pendingSubmit, setPendingSubmit] = useState<string | null>(null)
440
+
441
+ // D7 / D9 / D11 — menu state. Refs are the truth the ProseMirror handlers
442
+ // read synchronously; the React state mirrors them for rendering.
443
+ const composing = useRef(false)
444
+ const bubbleOpenRef = useRef(false)
445
+
446
+ // ADR-149 — the suggestion popup. The ref is what `handleKeyDown` reads
447
+ // (a keystroke cannot wait for a render); the state is what draws.
448
+ const suggestionRef = useRef<{
449
+ readonly kind: SuggestionKind
450
+ readonly attrs: readonly SuggestionAttrs[]
451
+ readonly command: (attrs: SuggestionAttrs) => void
452
+ } | null>(null)
453
+ const activeIndexRef = useRef(0)
454
+ const [suggestion, setSuggestion] = useState<{
455
+ readonly kind: SuggestionKind
456
+ readonly container: HTMLElement
457
+ readonly options: readonly SuggestionOption[]
458
+ } | null>(null)
459
+ const [activeIndex, setActiveIndex] = useState(0)
460
+
461
+ const setActiveOption = useCallback((index: number) => {
462
+ activeIndexRef.current = index
463
+ setActiveIndex(index)
464
+ }, [])
465
+
466
+ const optionDomId = useCallback(
467
+ (index: number) => `${listboxId}-option-${String(index)}`,
468
+ [listboxId],
469
+ )
470
+
471
+ /** Insert the row at `index`; the plugin's own command owns the document. */
472
+ const commitSuggestion = useCallback((index: number) => {
473
+ const active = suggestionRef.current
474
+ const attrs = active?.attrs[index]
475
+ if (active === undefined || active === null || attrs === undefined) return
476
+ active.command(attrs)
477
+ }, [])
478
+ const dismissedSelection = useRef<{ from: number; to: number } | null>(null)
479
+ const [bubbleOpen, setBubbleOpen] = useState(false)
480
+ const [focusRequest, setFocusRequest] = useState<'toolbar' | 'link' | null>(null)
481
+ const [linkMode, setLinkMode] = useState(false)
482
+ const [linkDraft, setLinkDraft] = useState('')
483
+ const [linkError, setLinkError] = useState(false)
484
+ const [rovingIndex, setRovingIndex] = useState(0)
485
+
486
+ const setBubble = useCallback((open: boolean) => {
487
+ bubbleOpenRef.current = open
488
+ setBubbleOpen((prev) => (prev === open ? prev : open))
489
+ if (!open) {
490
+ setLinkMode(false)
491
+ setLinkError(false)
492
+ setFocusRequest(null)
493
+ }
494
+ }, [])
495
+
496
+ /** D7: the one predicate both the plugin and the React state answer to. */
497
+ const shouldShowBubble = useCallback((editor: Editor): boolean => {
498
+ if (!editor.isEditable) return false
499
+ const { selection, doc } = editor.state
500
+ if (selection.empty || !isTextSelection(selection)) return false
501
+ if (doc.textBetween(selection.from, selection.to, ' ').trim() === '') return false
502
+ if (editor.isActive('codeBlock')) return false
503
+ if (editor.view.composing || composing.current) return false
504
+ if (suggestionRef.current !== null) return false
505
+ const dismissed = dismissedSelection.current
506
+ if (dismissed !== null) {
507
+ if (dismissed.from === selection.from && dismissed.to === selection.to) return false
508
+ dismissedSelection.current = null
509
+ }
510
+ return true
511
+ }, [])
512
+
513
+ const syncBubble = useCallback(
514
+ (editor: Editor) => {
515
+ setBubble(shouldShowBubble(editor))
516
+ },
517
+ [setBubble, shouldShowBubble],
518
+ )
519
+
520
+ const dismissBubble = useCallback(
521
+ (editor: Editor) => {
522
+ const { from, to } = editor.state.selection
523
+ dismissedSelection.current = { from, to }
524
+ setBubble(false)
525
+ editor.view.dispatch(editor.state.tr.setMeta(BUBBLE_MENU_KEY, 'hide'))
526
+ },
527
+ [setBubble],
528
+ )
529
+
530
+ const emitNow = useCallback((editor: Editor): string => {
531
+ const markdown = serializeRichMarkdown(editor.getJSON())
532
+ if (markdown !== lastEmitted.current) {
533
+ lastEmitted.current = markdown
534
+ latest.current.onChange(markdown)
535
+ }
536
+ return markdown
537
+ }, [])
538
+
539
+ const clearEmitTimer = useCallback(() => {
540
+ if (emitTimer.current !== null) {
541
+ window.clearTimeout(emitTimer.current)
542
+ emitTimer.current = null
543
+ }
544
+ }, [])
545
+
546
+ const scheduleEmit = useCallback(
547
+ (editor: Editor) => {
548
+ clearEmitTimer()
549
+ emitTimer.current = window.setTimeout(() => {
550
+ emitTimer.current = null
551
+ if (!editor.isDestroyed) emitNow(editor)
552
+ }, DRAFT_DEBOUNCE_MS)
553
+ },
554
+ [clearEmitTimer, emitNow],
555
+ )
556
+
557
+ const flushEmit = useCallback(
558
+ (editor: Editor): string => {
559
+ clearEmitTimer()
560
+ return emitNow(editor)
561
+ },
562
+ [clearEmitTimer, emitNow],
563
+ )
564
+
565
+ const requestSubmit = useCallback(
566
+ (editor: Editor) => {
567
+ if (editor.isEmpty) return
568
+ const markdown = flushEmit(editor)
569
+ setPendingSubmit(markdown)
570
+ },
571
+ [flushEmit],
572
+ )
573
+
574
+ /**
575
+ * D8 — pasted text. A single URL becomes a link whose text is the URL; text
576
+ * that parses within the subset is parsed as markdown; anything else — a
577
+ * plain paste (Shift), or markdown holding a table, raw HTML, an h4 — lands
578
+ * as literal paragraphs so no character is dropped.
579
+ */
580
+ const parsePastedText = useCallback(
581
+ (text: string, plain: boolean, view: EditorView): Slice => {
582
+ const { schema } = view.state
583
+ const trimmed = text.trim()
584
+ const linkType = schema.marks['link']
585
+ const paragraphType = schema.nodes['paragraph']
586
+ if (linkType !== undefined && BARE_URL.test(trimmed)) {
587
+ const href = /^www\./i.test(trimmed) ? `https://${trimmed}` : trimmed
588
+ return new Slice(Fragment.from(schema.text(trimmed, [linkType.create({ href })])), 0, 0)
589
+ }
590
+ if (!plain && isRichEditable(text)) {
591
+ try {
592
+ const doc = ProseMirrorNode.fromJSON(schema, parseRichMarkdown(text))
593
+ doc.check()
594
+ return Slice.maxOpen(doc.content)
595
+ } catch {
596
+ // Fall through to literal text; nothing is dropped either way.
597
+ }
598
+ }
599
+ if (paragraphType === undefined) return Slice.empty
600
+ const paragraphs = text
601
+ .split(/\r\n?|\n/)
602
+ .map((line) => paragraphType.create(null, line === '' ? null : schema.text(line)))
603
+ return Slice.maxOpen(Fragment.from(paragraphs))
604
+ },
605
+ [],
606
+ )
607
+
608
+ /**
609
+ * One `render()` per trigger, both drawing the same `<SuggestionList>`
610
+ * (ADR-149 D1). The closure per plugin owns exactly two things: the portal
611
+ * element the popup lives in, and the `unmount` that floating-ui hands back.
612
+ *
613
+ * The rows are recomputed here from `props.query` rather than read off
614
+ * `props.items`, because `@tiptap/suggestion` types its items as `any` and
615
+ * this file does not take `any` (CLAUDE.md §3). The plugin still owns the
616
+ * threshold — below `minQueryLength` it reports an empty query's worth of
617
+ * items and we close, which is the same door "no matches" comes through.
618
+ */
619
+ const makeSuggestionRenderer = useCallback(
620
+ (kind: SuggestionKind) => {
621
+ let container: HTMLElement | null = null
622
+ let unmount: (() => void) | null = null
623
+ let lastQuery: string | null = null
624
+
625
+ const rowsFor = (query: string): SuggestionRows => {
626
+ // D4: a bare sigil is punctuation, not a trigger.
627
+ if (query.length < SUGGESTION_MIN_QUERY[kind]) return EMPTY_ROWS
628
+ if (kind === 'emoji') return emojiRows(query)
629
+ const items = latest.current.mentionItems?.(query)
630
+ return items === undefined ? EMPTY_ROWS : mentionRows(items)
631
+ }
632
+
633
+ const close = () => {
634
+ if (unmount !== null) {
635
+ unmount()
636
+ unmount = null
637
+ }
638
+ container = null
639
+ lastQuery = null
640
+ if (suggestionRef.current?.kind === kind) suggestionRef.current = null
641
+ setSuggestion((prev) => (prev !== null && prev.kind === kind ? null : prev))
642
+ }
643
+
644
+ const sync = (props: SuggestionRenderProps) => {
645
+ const rows = rowsFor(props.query)
646
+ // D4: nothing to show is one of the four things that mean "no".
647
+ if (rows.options.length === 0) {
648
+ close()
649
+ return
650
+ }
651
+ const opening = container === null
652
+ let element = container
653
+ if (element === null) {
654
+ element = document.createElement('div')
655
+ element.className = 'ds-thread-suggestions-anchor'
656
+ container = element
657
+ // The plugin owns anchoring — it re-reads the caret rect on scroll,
658
+ // resize and layout shift. `onPosition` is what keeps it from
659
+ // hiding the element until it has measured, which would make the
660
+ // popup invisible to a jsdom role query for no benefit.
661
+ const anchored = element
662
+ unmount = props.mount(element, {
663
+ onPosition: ({ x, y, strategy }) => {
664
+ anchored.style.position = strategy
665
+ anchored.style.left = `${String(x)}px`
666
+ anchored.style.top = `${String(y)}px`
667
+ },
668
+ })
669
+ }
670
+ // The plugin dispatches `started` and `updated` for one keystroke;
671
+ // redrawing the same query would also reset the active option under a
672
+ // user who had already arrowed down. The ref is refreshed on the SAME
673
+ // side of this gate as the drawn rows, deliberately: `attrs` is what
674
+ // an index resolves against, so letting it move while the options on
675
+ // screen stay put would let Enter insert a row nobody was looking at.
676
+ if (!opening && lastQuery === props.query) return
677
+ suggestionRef.current = { kind, attrs: rows.attrs, command: props.command }
678
+ lastQuery = props.query
679
+ setActiveOption(0)
680
+ setBubble(false)
681
+ setSuggestion({ kind, container: element, options: rows.options })
682
+ // A `role="textbox"` cannot carry `aria-expanded` (ARIA reserves it
683
+ // for combobox), so nothing in the markup tells a screen-reader user
684
+ // the popup appeared. One polite line at OPEN does — once per
685
+ // session, not once per keystroke, because a count that re-announces
686
+ // on every letter is worse than no count at all.
687
+ if (opening) {
688
+ latest.current.announce?.(
689
+ `${String(rows.options.length)} ${SUGGESTION_NOUNS[kind]} suggestions. Use the arrow keys to choose, Enter to insert, Escape to close.`,
690
+ )
691
+ }
692
+ }
693
+
694
+ return {
695
+ onStart: sync,
696
+ onUpdate: sync,
697
+ onExit: close,
698
+ // Escape, the arrows, Enter and Tab are all answered in
699
+ // `handleKeyDown` below — `editorProps` handlers run before any
700
+ // plugin's, which is what makes ADR-148 D9's Escape order a fact.
701
+ onKeyDown: () => false,
702
+ }
703
+ },
704
+ [setActiveOption, setBubble],
705
+ )
706
+
707
+ const editor = useEditor(
708
+ {
709
+ extensions: [
710
+ ...richSubsetExtensions({
711
+ mention: { render: () => makeSuggestionRenderer('mention') },
712
+ emoji: { render: () => makeSuggestionRenderer('emoji') },
713
+ }),
714
+ Placeholder.configure({ placeholder }),
715
+ ],
716
+ content: value,
717
+ contentType: 'markdown',
718
+ editable: !disabled,
719
+ autofocus: autoFocus ? 'end' : false,
720
+ // D8 is implemented once, in `clipboardTextParser` below. TipTap's
721
+ // regex paste rules (bold, italic, strike, code, …) would run a second,
722
+ // looser pass over the pasted text — and re-format a Shift-paste that
723
+ // was meant to stay literal. Input rules (typing `**x**`, `- `, `---`)
724
+ // are unaffected.
725
+ enablePasteRules: false,
726
+ editorProps: {
727
+ /**
728
+ * THE fix for "the entire viewport is scrolling down" on Enter.
729
+ *
730
+ * ProseMirror scrolls the selection into view after any transaction
731
+ * that asks for it — every Enter, every arrow key, most typing. Its
732
+ * `scrollRectIntoView` walks UP from the editor and scrolls *every*
733
+ * scrollable ancestor it finds until the caret is visible: the
734
+ * thread's own body, then `PageShell`'s content region, then whatever
735
+ * else. Capping the editor's height does not stop that; it only
736
+ * changes which ancestor moves. The ancestors have to be taken out of
737
+ * the walk entirely, and this hook is the only supported way to do it.
738
+ *
739
+ * Returning `true` means "handled — do not run the default". We then
740
+ * do the one scroll that is legitimate: bring the caret into view
741
+ * inside the EDITOR, and touch nothing else. `scrollTop` on a single
742
+ * element cannot chain.
743
+ */
744
+ handleScrollToSelection: (view) => {
745
+ const dom = view.dom as HTMLElement
746
+ const head = view.state.selection.head
747
+ let caret: { top: number; bottom: number }
748
+ try {
749
+ caret = view.coordsAtPos(head)
750
+ } catch {
751
+ // A position with no coords yet (mid-reconfigure). Nothing to
752
+ // scroll to, and the default would be no better.
753
+ return true
754
+ }
755
+ const box = dom.getBoundingClientRect()
756
+ // One line of slack so the caret is never flush against the edge.
757
+ const pad = parseFloat(getComputedStyle(dom).lineHeight) || 0
758
+ dom.scrollTop += caretScrollDelta(box, caret, pad)
759
+ return true
760
+ },
761
+ attributes: {
762
+ role: 'textbox',
763
+ 'aria-multiline': 'true',
764
+ 'aria-labelledby': labelId,
765
+ 'aria-describedby': hintId,
766
+ },
767
+ handleDOMEvents: {
768
+ // D9: Tab is never captured. Returning true here skips ProseMirror's
769
+ // own keydown handling (and every keymap) without preventDefault, so
770
+ // the browser moves focus. ADR-149 D5 is the one exception: while a
771
+ // suggestion popup is open Tab INSERTS, so this stands down and
772
+ // `handleKeyDown` answers it (and does preventDefault, so focus
773
+ // stays where it already is — on the editor).
774
+ keydown: (_view, event) => isPlainTab(event) && suggestionRef.current === null,
775
+ compositionstart: () => {
776
+ composing.current = true
777
+ setBubble(false)
778
+ return false
779
+ },
780
+ compositionend: () => {
781
+ composing.current = false
782
+ return false
783
+ },
784
+ },
785
+ handleKeyDown: (view, event) => {
786
+ const editorFromView = editorRef.current
787
+ if (editorFromView === null) return false
788
+ const mod = event.metaKey || event.ctrlKey
789
+ if (event.key === 'Escape') {
790
+ if (bubbleOpenRef.current) {
791
+ dismissBubble(editorFromView)
792
+ return true
793
+ }
794
+ const openSuggestion = suggestionRef.current
795
+ if (openSuggestion !== null) {
796
+ // D5 / AC#7: close WITHOUT inserting, and do not fall through to
797
+ // the reply composer's cancel — that is the third slot, not the
798
+ // second. The plugin also records the range as dismissed, so the
799
+ // popup does not spring back on the next keystroke.
800
+ exitSuggestionPlugin(view, openSuggestion.kind)
801
+ return true
802
+ }
803
+ const cancel = latest.current.onCancel
804
+ if (cancel !== undefined) {
805
+ cancel()
806
+ return true
807
+ }
808
+ return false
809
+ }
810
+ if (mod && event.key === 'Enter') {
811
+ requestSubmit(editorFromView)
812
+ return true
813
+ }
814
+ // ADR-149 D5 — the popup's keys, intercepted before the editor's own
815
+ // keymap. Every one of them is unmodified: Cmd+Enter above still
816
+ // sends, and Shift+Enter still makes a hard break.
817
+ const navigating = suggestionRef.current
818
+ if (navigating !== null && !mod && !event.altKey && !event.shiftKey) {
819
+ const count = navigating.attrs.length
820
+ if (event.key === 'ArrowDown') {
821
+ setActiveOption(wrapIndex(activeIndexRef.current, count, 1))
822
+ return true
823
+ }
824
+ if (event.key === 'ArrowUp') {
825
+ setActiveOption(wrapIndex(activeIndexRef.current, count, -1))
826
+ return true
827
+ }
828
+ if (event.key === 'Enter' || event.key === 'Tab') {
829
+ commitSuggestion(activeIndexRef.current)
830
+ return true
831
+ }
832
+ }
833
+ if (mod && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'x') {
834
+ editorFromView.chain().focus().toggleStrike().run()
835
+ return true
836
+ }
837
+ if (mod && event.key === '.') {
838
+ if (!shouldShowBubble(editorFromView)) return false
839
+ dismissedSelection.current = null
840
+ setBubble(true)
841
+ setLinkMode(false)
842
+ setRovingIndex(0)
843
+ setFocusRequest('toolbar')
844
+ return true
845
+ }
846
+ if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
847
+ if (editorFromView.isActive('link') && view.state.selection.empty) {
848
+ editorFromView.commands.extendMarkRange('link')
849
+ }
850
+ if (!shouldShowBubble(editorFromView)) return true
851
+ dismissedSelection.current = null
852
+ setBubble(true)
853
+ setLinkDraft(readHref(editorFromView))
854
+ setLinkError(false)
855
+ setLinkMode(true)
856
+ setFocusRequest('link')
857
+ return true
858
+ }
859
+ return false
860
+ },
861
+ handlePaste: (_view, event) => {
862
+ const dt = event.clipboardData
863
+ if (!dt || dt.files.length > 0 || dt.types.includes('text/html')) return false
864
+ return attachments.addPastedText(dt.getData('text/plain'))
865
+ },
866
+ clipboardTextParser: (text, _context, plain, view) => parsePastedText(text, plain, view),
867
+ },
868
+ onCreate: ({ editor: created }) => {
869
+ editorRef.current = created
870
+ },
871
+ onUpdate: ({ editor: updated }) => {
872
+ setPendingSubmit(null)
873
+ scheduleEmit(updated)
874
+ syncBubble(updated)
875
+ },
876
+ onSelectionUpdate: ({ editor: updated }) => {
877
+ syncBubble(updated)
878
+ },
879
+ onFocus: ({ editor: focused }) => {
880
+ syncBubble(focused)
881
+ },
882
+ onBlur: ({ editor: blurred, event }) => {
883
+ flushEmit(blurred)
884
+ const next = event.relatedTarget
885
+ const insideMenu = next instanceof Node && bubbleRef.current?.contains(next) === true
886
+ if (!insideMenu) setBubble(false)
887
+ // D4: blur is one of the four things that close the popup. Clicking an
888
+ // option cannot reach here — the option's `mousedown` is prevented, so
889
+ // the editor never loses focus (D5, AC#6).
890
+ const openSuggestion = suggestionRef.current
891
+ if (openSuggestion !== null) exitSuggestionPlugin(blurred.view, openSuggestion.kind)
892
+ },
893
+ },
894
+ [],
895
+ )
896
+
897
+ useLayoutEffect(() => {
898
+ editorRef.current = editor
899
+ }, [editor])
900
+
901
+ useEffect(() => {
902
+ onEditor?.(editor)
903
+ return () => {
904
+ onEditor?.(null)
905
+ }
906
+ }, [editor, onEditor])
907
+
908
+ useEffect(() => {
909
+ editor.setEditable(!disabled)
910
+ }, [disabled, editor])
911
+
912
+ const insertPastedText = useCallback(
913
+ (localId: string) => {
914
+ const text = attachments.insertAsText(localId)
915
+ if (text === null) return
916
+ const slice = parsePastedText(text, true, editor.view)
917
+ editor.view.dispatch(editor.state.tr.replaceSelection(slice).scrollIntoView())
918
+ editor.commands.focus()
919
+ },
920
+ [attachments, editor, parsePastedText],
921
+ )
922
+
923
+ // An external `value` — a resumed draft, a reset after send — replaces the
924
+ // document. Our own emissions never come back through here, and a pending
925
+ // emission is dropped rather than allowed to resurrect what was just replaced.
926
+ useEffect(() => {
927
+ if (value === lastEmitted.current) return
928
+ clearEmitTimer()
929
+ lastEmitted.current = value
930
+ editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false })
931
+ }, [clearEmitTimer, editor, value])
932
+
933
+ // Send fires once the parent holds what was flushed AND every upload has a
934
+ // verified ticket. Uploading never disables Send: pressing it records the
935
+ // intent here, then this effect finishes the post when the last ticket lands.
936
+ useEffect(() => {
937
+ if (pendingSubmit === null || value !== pendingSubmit || attachments.isUploading) return
938
+ if (attachments.attachments.some((attachment) => attachment.state !== 'ready')) return
939
+ setPendingSubmit(null)
940
+ onSubmit(attachments.ticketIds.length === 0 ? undefined : attachments.ticketIds)
941
+ attachments.clear()
942
+ }, [attachments, onSubmit, pendingSubmit, value])
943
+
944
+ // Unmount: keystrokes still inside the debounce window reach the draft —
945
+ // unless the parent moved on in the meantime, in which case they are stale
946
+ // and must not clobber what replaced them.
947
+ useEffect(
948
+ () => () => {
949
+ if (emitTimer.current === null) return
950
+ window.clearTimeout(emitTimer.current)
951
+ emitTimer.current = null
952
+ if (latest.current.value === lastEmitted.current && !editor.isDestroyed) emitNow(editor)
953
+ },
954
+ [editor, emitNow],
955
+ )
956
+
957
+ // D10 — the counter's polite announcement at 90 %, once per crossing.
958
+ const announced = useRef(false)
959
+ useEffect(() => {
960
+ if (maxLength === undefined) return
961
+ const over = value.length > maxLength * 0.9
962
+ if (over && !announced.current) {
963
+ announced.current = true
964
+ announce?.(
965
+ `Approaching the comment length limit: ${String(value.length)} of ${String(maxLength)} characters`,
966
+ )
967
+ } else if (!over) {
968
+ announced.current = false
969
+ }
970
+ }, [announce, maxLength, value.length])
971
+
972
+ /**
973
+ * D5 — the editor is the accessible OWNER of the popup. It keeps DOM focus,
974
+ * points at the listbox with `aria-controls`, and names the active option
975
+ * with `aria-activedescendant`; nothing inside the popup is ever focused.
976
+ *
977
+ * Written straight onto the contenteditable because `editorProps.attributes`
978
+ * are fixed when the editor is created. That is safe: ProseMirror's document
979
+ * decoration only removes attribute names IT set on a previous pass, so a
980
+ * name it has never seen survives every transaction.
981
+ */
982
+ useLayoutEffect(() => {
983
+ if (editor.isDestroyed) return
984
+ const dom = editor.view.dom
985
+ if (suggestion === null) {
986
+ dom.removeAttribute('aria-controls')
987
+ dom.removeAttribute('aria-activedescendant')
988
+ return
989
+ }
990
+ dom.setAttribute('aria-controls', listboxId)
991
+ dom.setAttribute('aria-activedescendant', optionDomId(activeIndex))
992
+ }, [activeIndex, editor, listboxId, optionDomId, suggestion])
993
+
994
+ // Keyboard-opened menu (D7): focus lands on the first button or the link field.
995
+ useEffect(() => {
996
+ if (focusRequest === null || !bubbleOpen) return
997
+ const target =
998
+ focusRequest === 'link' ? linkInputRef.current : focusableButtons(bubbleRef.current)[0]
999
+ if (target instanceof HTMLElement) {
1000
+ target.focus()
1001
+ setFocusRequest(null)
1002
+ }
1003
+ }, [bubbleOpen, focusRequest, linkMode])
1004
+
1005
+ const active = useEditorState({
1006
+ editor,
1007
+ selector: ({ editor: current }) => ({
1008
+ isEmpty: current.isEmpty,
1009
+ bold: current.isActive('bold'),
1010
+ italic: current.isActive('italic'),
1011
+ underline: current.isActive('underline'),
1012
+ strike: current.isActive('strike'),
1013
+ code: current.isActive('code'),
1014
+ link: current.isActive('link'),
1015
+ h1: current.isActive('heading', { level: 1 }),
1016
+ h2: current.isActive('heading', { level: 2 }),
1017
+ h3: current.isActive('heading', { level: 3 }),
1018
+ bulletList: current.isActive('bulletList'),
1019
+ orderedList: current.isActive('orderedList'),
1020
+ blockquote: current.isActive('blockquote'),
1021
+ codeBlock: current.isActive('codeBlock'),
1022
+ }),
1023
+ })
1024
+
1025
+ const overLimit = maxLength !== undefined && value.length > maxLength
1026
+ const canSend =
1027
+ !active.isEmpty &&
1028
+ !sending &&
1029
+ !disabled &&
1030
+ !overLimit &&
1031
+ pendingSubmit === null &&
1032
+ !attachments.attachments.some((attachment) => attachment.state === 'failed')
1033
+
1034
+ /* ── Bubble menu actions ───────────────────────────────────────────────── */
1035
+
1036
+ const openLinkField = useCallback(() => {
1037
+ setLinkDraft(readHref(editor))
1038
+ setLinkError(false)
1039
+ setLinkMode(true)
1040
+ setFocusRequest('link')
1041
+ }, [editor])
1042
+
1043
+ const applyLink = useCallback(() => {
1044
+ const href = normalizeHref(linkDraft)
1045
+ if (href === null) {
1046
+ setLinkError(true)
1047
+ return
1048
+ }
1049
+ const applied = editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
1050
+ if (!applied) {
1051
+ setLinkError(true)
1052
+ return
1053
+ }
1054
+ setLinkMode(false)
1055
+ setLinkError(false)
1056
+ }, [editor, linkDraft])
1057
+
1058
+ const removeLink = useCallback(() => {
1059
+ editor.chain().focus().extendMarkRange('link').unsetLink().run()
1060
+ setLinkMode(false)
1061
+ setLinkError(false)
1062
+ }, [editor])
1063
+
1064
+ const closeLinkField = useCallback(() => {
1065
+ setLinkMode(false)
1066
+ setLinkError(false)
1067
+ setRovingIndex(BUBBLE_ACTIONS.length - 1)
1068
+ setFocusRequest('toolbar')
1069
+ }, [])
1070
+
1071
+ /** Roving focus across the bubble toolbar; Escape returns to the editor. */
1072
+ const onToolbarKeyDown = useCallback(
1073
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
1074
+ const buttons = focusableButtons(event.currentTarget)
1075
+ const current = buttons.findIndex((button) => button === document.activeElement)
1076
+ const move = (index: number) => {
1077
+ const next = buttons[(index + buttons.length) % buttons.length]
1078
+ if (next === undefined) return
1079
+ setRovingIndex(buttons.indexOf(next))
1080
+ next.focus()
1081
+ }
1082
+ switch (event.key) {
1083
+ case 'ArrowRight':
1084
+ case 'ArrowDown':
1085
+ event.preventDefault()
1086
+ move(current + 1)
1087
+ return
1088
+ case 'ArrowLeft':
1089
+ case 'ArrowUp':
1090
+ event.preventDefault()
1091
+ move(current - 1)
1092
+ return
1093
+ case 'Home':
1094
+ event.preventDefault()
1095
+ move(0)
1096
+ return
1097
+ case 'End':
1098
+ event.preventDefault()
1099
+ move(buttons.length - 1)
1100
+ return
1101
+ case 'Escape':
1102
+ event.preventDefault()
1103
+ event.stopPropagation()
1104
+ dismissBubble(editor)
1105
+ editor.commands.focus()
1106
+ return
1107
+ default:
1108
+ return
1109
+ }
1110
+ },
1111
+ [dismissBubble, editor],
1112
+ )
1113
+
1114
+ const onLinkFieldKeyDown = useCallback(
1115
+ (event: ReactKeyboardEvent<HTMLInputElement>) => {
1116
+ if (event.key === 'Enter') {
1117
+ event.preventDefault()
1118
+ applyLink()
1119
+ } else if (event.key === 'Escape') {
1120
+ event.preventDefault()
1121
+ event.stopPropagation()
1122
+ closeLinkField()
1123
+ }
1124
+ },
1125
+ [applyLink, closeLinkField],
1126
+ )
1127
+
1128
+ const bubbleActive: Readonly<Record<BubbleAction, boolean>> = {
1129
+ bold: active.bold,
1130
+ italic: active.italic,
1131
+ underline: active.underline,
1132
+ strike: active.strike,
1133
+ code: active.code,
1134
+ link: active.link,
1135
+ }
1136
+
1137
+ const runBubbleAction = useCallback(
1138
+ (action: BubbleAction) => {
1139
+ switch (action) {
1140
+ case 'bold':
1141
+ editor.chain().focus().toggleBold().run()
1142
+ return
1143
+ case 'italic':
1144
+ editor.chain().focus().toggleItalic().run()
1145
+ return
1146
+ case 'underline':
1147
+ editor.chain().focus().toggleUnderline().run()
1148
+ return
1149
+ case 'strike':
1150
+ editor.chain().focus().toggleStrike().run()
1151
+ return
1152
+ case 'code':
1153
+ editor.chain().focus().toggleCode().run()
1154
+ return
1155
+ case 'link':
1156
+ openLinkField()
1157
+ return
1158
+ }
1159
+ },
1160
+ [editor, openLinkField],
1161
+ )
1162
+
1163
+ const runBlockAction = useCallback(
1164
+ (action: BlockAction) => {
1165
+ const chain = editor.chain().focus()
1166
+ switch (action) {
1167
+ case 'h1':
1168
+ chain.toggleHeading({ level: 1 }).run()
1169
+ return
1170
+ case 'h2':
1171
+ chain.toggleHeading({ level: 2 }).run()
1172
+ return
1173
+ case 'h3':
1174
+ chain.toggleHeading({ level: 3 }).run()
1175
+ return
1176
+ case 'bulletList':
1177
+ chain.toggleBulletList().run()
1178
+ return
1179
+ case 'orderedList':
1180
+ chain.toggleOrderedList().run()
1181
+ return
1182
+ case 'blockquote':
1183
+ chain.toggleBlockquote().run()
1184
+ return
1185
+ case 'codeBlock':
1186
+ chain.toggleCodeBlock().run()
1187
+ return
1188
+ case 'divider':
1189
+ chain.setHorizontalRule().run()
1190
+ return
1191
+ }
1192
+ },
1193
+ [editor],
1194
+ )
1195
+
1196
+ const blockActive: Readonly<Record<BlockAction, boolean>> = {
1197
+ h1: active.h1,
1198
+ h2: active.h2,
1199
+ h3: active.h3,
1200
+ bulletList: active.bulletList,
1201
+ orderedList: active.orderedList,
1202
+ blockquote: active.blockquote,
1203
+ codeBlock: active.codeBlock,
1204
+ divider: false,
1205
+ }
1206
+
1207
+ const shellStyle: StyleWithVars = {
1208
+ // Concentric: the host panel declares --thread-inner-radius as its own
1209
+ // radius minus its padding. Nothing here eyeballs a corner.
1210
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
1211
+ borderColor: 'rgb(var(--border))',
1212
+ background: 'rgb(var(--surface-card))',
1213
+ '--composer-rows': String(rows),
1214
+ }
1215
+
1216
+ const controlsDisabled = disabled || sending || pendingSubmit !== null
1217
+
1218
+ return (
1219
+ <div className={cn('flex flex-col gap-[var(--space-1)]', className)}>
1220
+ {replyingTo === undefined ? null : (
1221
+ <p
1222
+ className="flex items-center gap-[var(--space-1)] text-[12px]"
1223
+ style={{ color: 'rgb(var(--text-tertiary))' }}
1224
+ >
1225
+ <CornerDownRight size={10} strokeWidth={1.5} aria-hidden="true" />
1226
+ Replying to{' '}
1227
+ <span className="font-semibold" style={{ color: 'rgb(var(--text-secondary))' }}>
1228
+ {replyingTo}
1229
+ </span>
1230
+ </p>
1231
+ )}
1232
+ <div className="flex items-start gap-[var(--space-2)]">
1233
+ {avatar}
1234
+ <AttachmentDropZone
1235
+ onFiles={attachments.addFiles}
1236
+ disabled={controlsDisabled || uploadAttachment === undefined}
1237
+ className="min-w-0 flex-1"
1238
+ >
1239
+ <div
1240
+ ref={shellRef}
1241
+ className={cn(
1242
+ 'ds-thread-rich-composer relative min-w-0 w-full border',
1243
+ 'transition-[border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
1244
+ 'motion-reduce:transition-none',
1245
+ 'focus-within:border-[rgb(var(--accent))] focus-within:[box-shadow:var(--ring-focus)]',
1246
+ )}
1247
+ style={shellStyle}
1248
+ >
1249
+ {/* A contenteditable has no implicit name; these two are what the
1250
+ textbox points at (D10). */}
1251
+ <span id={labelId} className="sr-only">
1252
+ {label}
1253
+ </span>
1254
+ <span id={hintId} className="sr-only">
1255
+ {`${MOD_NAME}+Enter sends. ${MOD_NAME}+Period opens the formatting menu on a selection. Type @ to mention someone, or a colon and two letters for emoji, then use the arrow keys and Enter. Escape closes the menu${onCancel === undefined ? '' : ', then cancels the reply'}.`}
1256
+ </span>
1257
+
1258
+ <EditorContent editor={editor} />
1259
+
1260
+ {/* ADR-149 — ONE popup for both triggers. It is portalled into the
1261
+ element the Suggestion plugin mounts and anchors (floating-ui,
1262
+ already a dependency of that plugin), so it escapes the thread's
1263
+ own scroll clipping. It is still the editor's, through
1264
+ `aria-controls` above. */}
1265
+ {suggestion === null
1266
+ ? null
1267
+ : createPortal(
1268
+ <SuggestionList
1269
+ id={listboxId}
1270
+ label={SUGGESTION_LABELS[suggestion.kind]}
1271
+ options={suggestion.options}
1272
+ activeIndex={activeIndex}
1273
+ optionId={optionDomId}
1274
+ onSelect={commitSuggestion}
1275
+ onActivate={setActiveOption}
1276
+ />,
1277
+ suggestion.container,
1278
+ )}
1279
+
1280
+ <BubbleMenu
1281
+ ref={bubbleRef}
1282
+ editor={editor}
1283
+ pluginKey={BUBBLE_MENU_KEY}
1284
+ updateDelay={0}
1285
+ shouldShow={({ editor: current }) => shouldShowBubble(current)}
1286
+ appendTo={() => shellRef.current ?? document.body}
1287
+ options={{
1288
+ placement: 'top',
1289
+ offset: readSpaceToken(shellRef.current, '--space-2', 8),
1290
+ flip: true,
1291
+ shift: true,
1292
+ }}
1293
+ className="ds-thread-bubble-menu"
1294
+ data-open={bubbleOpen}
1295
+ >
1296
+ {!bubbleOpen ? null : linkMode ? (
1297
+ <div
1298
+ role="group"
1299
+ aria-label="Link"
1300
+ className="flex items-center gap-[var(--space-1)]"
1301
+ style={{
1302
+ padding: 'var(--space-1)',
1303
+ background: 'rgb(var(--surface-card))',
1304
+ border: '1px solid rgb(var(--border))',
1305
+ borderRadius: 'var(--radius-sm)',
1306
+ boxShadow: 'var(--shadow-lg)',
1307
+ }}
1308
+ >
1309
+ <input
1310
+ ref={linkInputRef}
1311
+ type="url"
1312
+ aria-label="Link URL"
1313
+ aria-invalid={linkError}
1314
+ {...(linkError ? { 'aria-describedby': linkErrorId } : {})}
1315
+ placeholder="https://"
1316
+ value={linkDraft}
1317
+ onChange={(event) => {
1318
+ setLinkDraft(event.target.value)
1319
+ setLinkError(false)
1320
+ }}
1321
+ onKeyDown={onLinkFieldKeyDown}
1322
+ className="min-w-0 bg-transparent text-[12.5px] outline-none"
1323
+ style={{
1324
+ inlineSize: '22ch',
1325
+ blockSize: 'var(--ctrl-sm)',
1326
+ paddingInline: 'var(--space-2)',
1327
+ color: 'rgb(var(--foreground))',
1328
+ borderRadius: 'var(--radius-xs)',
1329
+ border: linkError
1330
+ ? '1px solid rgb(var(--warning))'
1331
+ : '1px solid rgb(var(--border))',
1332
+ }}
1333
+ />
1334
+ <Button
1335
+ variant="icon"
1336
+ size="sm"
1337
+ aria-label="Apply link"
1338
+ title="Apply link"
1339
+ onMouseDown={(event) => {
1340
+ event.preventDefault()
1341
+ }}
1342
+ onClick={applyLink}
1343
+ >
1344
+ <Check size={12} strokeWidth={2} aria-hidden="true" />
1345
+ </Button>
1346
+ {active.link ? (
1347
+ <Button
1348
+ variant="icon"
1349
+ size="sm"
1350
+ aria-label="Remove link"
1351
+ title="Remove link"
1352
+ onMouseDown={(event) => {
1353
+ event.preventDefault()
1354
+ }}
1355
+ onClick={removeLink}
1356
+ >
1357
+ <Unlink size={12} strokeWidth={1.75} aria-hidden="true" />
1358
+ </Button>
1359
+ ) : null}
1360
+ <Button
1361
+ variant="icon"
1362
+ size="sm"
1363
+ aria-label="Close link field"
1364
+ title="Close"
1365
+ onMouseDown={(event) => {
1366
+ event.preventDefault()
1367
+ }}
1368
+ onClick={closeLinkField}
1369
+ >
1370
+ <X size={12} strokeWidth={1.75} aria-hidden="true" />
1371
+ </Button>
1372
+ {linkError ? (
1373
+ <span id={linkErrorId} role="alert" className="sr-only">
1374
+ Enter a web, email or phone link.
1375
+ </span>
1376
+ ) : null}
1377
+ </div>
1378
+ ) : (
1379
+ <div
1380
+ role="toolbar"
1381
+ aria-label="Format selection"
1382
+ aria-orientation="horizontal"
1383
+ onKeyDown={onToolbarKeyDown}
1384
+ >
1385
+ {BUBBLE_ACTIONS.map((action, index) => {
1386
+ const Icon = action.icon
1387
+ return (
1388
+ <Button
1389
+ key={action.id}
1390
+ variant="icon"
1391
+ size="sm"
1392
+ aria-label={action.label}
1393
+ aria-pressed={bubbleActive[action.id]}
1394
+ title={`${action.label} (${MOD_NAME}+${action.keycap})`}
1395
+ tabIndex={index === rovingIndex ? 0 : -1}
1396
+ onMouseDown={(event) => {
1397
+ // Keep the editor's selection: a click here must
1398
+ // format it, not collapse it.
1399
+ event.preventDefault()
1400
+ }}
1401
+ onClick={() => {
1402
+ runBubbleAction(action.id)
1403
+ }}
1404
+ >
1405
+ <Icon size={12} strokeWidth={1.75} aria-hidden="true" />
1406
+ </Button>
1407
+ )
1408
+ })}
1409
+ </div>
1410
+ )}
1411
+ </BubbleMenu>
1412
+
1413
+ <AttachmentTray
1414
+ attachments={attachments.attachments}
1415
+ onRemove={attachments.remove}
1416
+ onInsertAsText={insertPastedText}
1417
+ className="px-[var(--space-2)]"
1418
+ />
1419
+
1420
+ {/* Switching composer implementations would unmount this hook and
1421
+ discard its local upload tickets. Remove the chips first; never
1422
+ turn a mode switch into a silent attachment loss. */}
1423
+ <ComposerFooter
1424
+ {...(mode === undefined ? {} : { mode })}
1425
+ {...(onModeChange === undefined ? {} : { onModeChange })}
1426
+ modeDisabled={controlsDisabled || attachments.attachments.length > 0}
1427
+ toolbar={
1428
+ <>
1429
+ {uploadAttachment === undefined ? null : (
1430
+ <AttachmentButton
1431
+ onFiles={attachments.addFiles}
1432
+ accept={attachmentAccept ?? ATTACHMENT_ACCEPT}
1433
+ disabled={controlsDisabled}
1434
+ />
1435
+ )}
1436
+ {attachGif === undefined || searchGifs === undefined ? null : (
1437
+ <Popover open={gifPickerOpen} onOpenChange={setGifPickerOpen}>
1438
+ <Popover.Trigger asChild>
1439
+ <Button
1440
+ variant="icon"
1441
+ size="sm"
1442
+ aria-label="Choose a GIF"
1443
+ title="Choose a GIF"
1444
+ disabled={controlsDisabled}
1445
+ >
1446
+ <Images size={12} strokeWidth={1.75} aria-hidden="true" />
1447
+ </Button>
1448
+ </Popover.Trigger>
1449
+ <Popover.Content
1450
+ aria-label="Choose a GIF"
1451
+ align="start"
1452
+ className="p-[var(--space-3)]"
1453
+ style={{
1454
+ inlineSize: 'calc(var(--space-12) * 8)',
1455
+ maxInlineSize: 'calc(100vw - var(--space-6))',
1456
+ }}
1457
+ >
1458
+ <GifPicker
1459
+ search={searchGifs}
1460
+ onPick={(gif) => {
1461
+ attachments.addGif(gif)
1462
+ setGifPickerOpen(false)
1463
+ }}
1464
+ />
1465
+ </Popover.Content>
1466
+ </Popover>
1467
+ )}
1468
+ {toolbar
1469
+ ? BLOCK_ACTIONS.map((action) => {
1470
+ const Icon = action.icon
1471
+ return (
1472
+ <Button
1473
+ key={action.id}
1474
+ variant="icon"
1475
+ size="sm"
1476
+ aria-label={action.label}
1477
+ title={action.label}
1478
+ {...(action.id === 'divider' ? {} : { 'aria-pressed': blockActive[action.id] })}
1479
+ disabled={controlsDisabled}
1480
+ onMouseDown={(event) => {
1481
+ event.preventDefault()
1482
+ }}
1483
+ onClick={() => {
1484
+ runBlockAction(action.id)
1485
+ }}
1486
+ >
1487
+ <Icon size={12} strokeWidth={1.75} aria-hidden="true" />
1488
+ </Button>
1489
+ )
1490
+ })
1491
+ : null}
1492
+ </>
1493
+ }
1494
+ meta={
1495
+ <>
1496
+ {maxLength === undefined ? null : (
1497
+ <span
1498
+ className="tabular-nums text-right"
1499
+ // Reserved slot so the counter cannot push the buttons around.
1500
+ style={{
1501
+ minInlineSize: '7ch',
1502
+ fontSize: '11.5px',
1503
+ color:
1504
+ value.length > maxLength * 0.9
1505
+ ? 'rgb(var(--warning-ink))'
1506
+ : 'rgb(var(--text-tertiary))',
1507
+ }}
1508
+ >
1509
+ {value.length}/{maxLength}
1510
+ </span>
1511
+ )}
1512
+ </>
1513
+ }
1514
+ actions={
1515
+ <>
1516
+ {onCancel === undefined ? null : (
1517
+ <Button variant="secondary" size="sm" onClick={onCancel} disabled={controlsDisabled}>
1518
+ {cancelLabel}
1519
+ </Button>
1520
+ )}
1521
+ <Button
1522
+ variant="primary"
1523
+ size="sm"
1524
+ onClick={() => {
1525
+ requestSubmit(editor)
1526
+ }}
1527
+ disabled={!canSend}
1528
+ aria-label={sending ? 'Sending…' : submitLabel}
1529
+ trailingIcon={
1530
+ <span aria-hidden="true" className="ds-composer-keycap">
1531
+ <Kbd size="sm" tone="on-accent">
1532
+ {SEND_KEYCAP}
1533
+ </Kbd>
1534
+ </span>
1535
+ }
1536
+ >
1537
+ {sending ? 'Sending…' : submitLabel}
1538
+ </Button>
1539
+ </>
1540
+ }
1541
+ />
1542
+ </div>
1543
+ </AttachmentDropZone>
1544
+ </div>
1545
+ </div>
1546
+ )
1547
+ }
1548
+
1549
+ function readHref(editor: Editor): string {
1550
+ const href = editor.getAttributes('link')['href']
1551
+ return typeof href === 'string' ? href : ''
1552
+ }
1553
+
1554
+ /* ── The two action tables ────────────────────────────────────────────────── */
1555
+
1556
+ type BubbleAction = 'bold' | 'italic' | 'underline' | 'strike' | 'code' | 'link'
1557
+
1558
+ interface BubbleActionSpec {
1559
+ readonly id: BubbleAction
1560
+ readonly label: string
1561
+ readonly icon: typeof Bold
1562
+ readonly keycap: string
1563
+ }
1564
+
1565
+ /** D7: six actions on a real selection, nothing else. */
1566
+ const BUBBLE_ACTIONS: readonly BubbleActionSpec[] = [
1567
+ { id: 'bold', label: 'Bold', icon: Bold, keycap: 'B' },
1568
+ { id: 'italic', label: 'Italic', icon: Italic, keycap: 'I' },
1569
+ { id: 'underline', label: 'Underline', icon: UnderlineIcon, keycap: 'U' },
1570
+ { id: 'strike', label: 'Strikethrough', icon: Strikethrough, keycap: 'Shift+X' },
1571
+ { id: 'code', label: 'Code', icon: Code, keycap: 'E' },
1572
+ { id: 'link', label: 'Link', icon: LinkIcon, keycap: 'K' },
1573
+ ]
1574
+
1575
+ type BlockAction =
1576
+ | 'h1'
1577
+ | 'h2'
1578
+ | 'h3'
1579
+ | 'bulletList'
1580
+ | 'orderedList'
1581
+ | 'blockquote'
1582
+ | 'codeBlock'
1583
+ | 'divider'
1584
+
1585
+ interface BlockActionSpec {
1586
+ readonly id: BlockAction
1587
+ readonly label: string
1588
+ readonly icon: typeof Bold
1589
+ }
1590
+
1591
+ /** The persistent row: block-level actions act on the block, not the selection. */
1592
+ const BLOCK_ACTIONS: readonly BlockActionSpec[] = [
1593
+ { id: 'h1', label: 'Heading 1', icon: Heading1 },
1594
+ { id: 'h2', label: 'Heading 2', icon: Heading2 },
1595
+ { id: 'h3', label: 'Heading 3', icon: Heading3 },
1596
+ { id: 'bulletList', label: 'Bulleted list', icon: List },
1597
+ { id: 'orderedList', label: 'Numbered list', icon: ListOrdered },
1598
+ { id: 'blockquote', label: 'Quote', icon: TextQuote },
1599
+ { id: 'codeBlock', label: 'Code block', icon: SquareCode },
1600
+ { id: 'divider', label: 'Divider', icon: Minus },
1601
+ ]