@refraction-ui/astro 0.15.2 → 0.16.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.
@@ -0,0 +1,799 @@
1
+ /**
2
+ * `createComposer(config)` — the headless composer core.
3
+ *
4
+ * Owns the flat value + selection, committed tokens, staged attachments,
5
+ * trigger/suggestion lifecycle, drafts, and the optimistic submit. Pure of
6
+ * DOM/browser globals: `now`/`generateId` are injected (defaults live only at
7
+ * this boundary, matching the other cores in the repo), so the same instance
8
+ * is hydration-deterministic and portable to any framework adapter.
9
+ */
10
+
11
+ import { generateId as sharedGenerateId } from '../shared/index.ts'
12
+ import { clampGraphemes, graphemeLength } from './graphemes.js'
13
+ import { canSend as computeCanSend, computeCounter, shouldSubmitOnEnter, payloadFor } from './rules.js'
14
+ import {
15
+ detectActiveTrigger,
16
+ resolveTriggerConfig,
17
+ type DismissedOccurrence,
18
+ type ResolvedTrigger,
19
+ type TriggerMatch,
20
+ } from './trigger-engine.js'
21
+ import { createSuggestionController, CLOSED_SUGGESTION_STATE } from './suggestion.js'
22
+ import {
23
+ applyValueEdit,
24
+ commitTokenAt,
25
+ detectTypedEmoji,
26
+ expandRangeOverTokens,
27
+ serializeTokens,
28
+ snapSelectionToTokens,
29
+ } from './tokens.js'
30
+ import type {
31
+ ActiveTriggerState,
32
+ ComposerAPI,
33
+ ComposerAttachment,
34
+ ComposerConfig,
35
+ ComposerDraft,
36
+ ComposerOutput,
37
+ ComposerSelection,
38
+ ComposerState,
39
+ ComposerSubmission,
40
+ ComposerToken,
41
+ EnterResult,
42
+ PlacedToken,
43
+ ResolvedToken,
44
+ } from './types.js'
45
+
46
+ export const DEFAULT_DRAFT_DEBOUNCE_MS = 400
47
+ export const DEFAULT_TYPING_SIGNAL_INTERVAL_MS = 3000
48
+ export const DEFAULT_MIN_LINES = 1
49
+ export const DEFAULT_MAX_LINES = 6
50
+
51
+ interface ClipShadow {
52
+ text: string
53
+ /** Tokens rebased to the start of the cut range. */
54
+ tokens: PlacedToken[]
55
+ }
56
+
57
+ interface EditModeSnapshot {
58
+ value: string
59
+ tokens: PlacedToken[]
60
+ selection: ComposerSelection
61
+ attachments: ComposerAttachment[]
62
+ }
63
+
64
+ function validateDraft(raw: unknown): ComposerDraft | null {
65
+ if (typeof raw !== 'object' || raw === null) return null
66
+ const draft = raw as Partial<ComposerDraft>
67
+ if (typeof draft.value !== 'string') return null
68
+ if (!Array.isArray(draft.tokens)) return null
69
+ if (!Array.isArray(draft.attachmentIds)) return null
70
+ for (const token of draft.tokens) {
71
+ if (
72
+ typeof token !== 'object' ||
73
+ token === null ||
74
+ typeof token.start !== 'number' ||
75
+ typeof token.end !== 'number' ||
76
+ typeof token.display !== 'string' ||
77
+ // The one invariant that matters: ranges must project the display.
78
+ draft.value.substring(token.start, token.end) !== token.display
79
+ ) {
80
+ return null
81
+ }
82
+ }
83
+ return {
84
+ value: draft.value,
85
+ tokens: draft.tokens as PlacedToken[],
86
+ attachmentIds: draft.attachmentIds as string[],
87
+ updatedAt: typeof draft.updatedAt === 'number' ? draft.updatedAt : 0,
88
+ }
89
+ }
90
+
91
+ export function createComposer(config: ComposerConfig = {}): ComposerAPI {
92
+ // Injection boundary: defaults using ambient time/counters live ONLY here.
93
+ const now = config.now ?? (() => Date.now())
94
+ const generateId = config.generateId ?? sharedGenerateId
95
+ const onEvent = config.onEvent ?? (() => undefined)
96
+ const triggers: ResolvedTrigger[] = (config.triggers ?? []).map(resolveTriggerConfig)
97
+ const draftDebounceMs = config.draftDebounceMs ?? DEFAULT_DRAFT_DEBOUNCE_MS
98
+ const typingSignalIntervalMs = config.typingSignalIntervalMs ?? DEFAULT_TYPING_SIGNAL_INTERVAL_MS
99
+ const emojiTrigger = triggers.find((t) => t.symbol === ':') ?? null
100
+
101
+ // --- mutable internal state -------------------------------------------------
102
+ let value = config.initialValue ?? ''
103
+ let tokens: PlacedToken[] = [...(config.initialTokens ?? [])]
104
+ let selection: ComposerSelection = { start: value.length, end: value.length }
105
+ let isComposing = false
106
+ let isBusy = false
107
+ let disabled = false
108
+ let readOnly = false
109
+ let error: string | null = null
110
+ let attachments: ComposerAttachment[] = []
111
+ let mode: 'compose' | 'edit' = 'compose'
112
+ let editingMessageId: string | undefined
113
+ let preEditSnapshot: EditModeSnapshot | null = null
114
+ let activeMatch: TriggerMatch | null = null
115
+ let dismissed: DismissedOccurrence[] = []
116
+ let clipShadow: ClipShadow | null = null
117
+ let lastTypingAt = Number.NEGATIVE_INFINITY
118
+ let draftTimer: ReturnType<typeof setTimeout> | null = null
119
+ let destroyed = false
120
+
121
+ const listeners = new Set<(state: ComposerState) => void>()
122
+
123
+ // Draft restore on create (fail closed: a corrupt draft yields a clean state).
124
+ if (config.draftStore && config.draftKey) {
125
+ try {
126
+ const draft = validateDraft(config.draftStore.read(config.draftKey))
127
+ if (draft) {
128
+ value = draft.value
129
+ tokens = draft.tokens
130
+ selection = { start: value.length, end: value.length }
131
+ }
132
+ } catch {
133
+ // fail closed — start empty
134
+ }
135
+ }
136
+
137
+ const suggestions = createSuggestionController({ emit: () => emit() })
138
+
139
+ // --- state assembly ----------------------------------------------------------
140
+
141
+ function toActiveTriggerState(match: TriggerMatch | null): ActiveTriggerState | null {
142
+ if (!match) return null
143
+ return {
144
+ triggerId: match.trigger.id,
145
+ symbol: match.trigger.symbol,
146
+ symbolStart: match.symbolStart,
147
+ caret: match.caret,
148
+ query: match.query,
149
+ }
150
+ }
151
+
152
+ function buildState(): ComposerState {
153
+ const counter = computeCounter(value, config.maxLength)
154
+ const overBudget = counter.remaining !== null && counter.remaining < 0
155
+ return {
156
+ value,
157
+ selection,
158
+ isComposing,
159
+ isBusy,
160
+ disabled,
161
+ readOnly,
162
+ isEmpty: payloadFor(value).length === 0 && attachments.length === 0,
163
+ canSend:
164
+ computeCanSend({ text: value, attachments, disabled, readOnly, busy: isBusy }) &&
165
+ !overBudget,
166
+ error,
167
+ attachments: [...attachments],
168
+ tokens: [...tokens],
169
+ activeTrigger: toActiveTriggerState(activeMatch),
170
+ suggestion: destroyed ? CLOSED_SUGGESTION_STATE : suggestions.state,
171
+ counter,
172
+ mode,
173
+ editingMessageId,
174
+ }
175
+ }
176
+
177
+ function emit(): void {
178
+ if (destroyed) return
179
+ const snapshot = buildState()
180
+ for (const listener of listeners) listener(snapshot)
181
+ }
182
+
183
+ // --- cross-cutting helpers ----------------------------------------------------
184
+
185
+ function shiftDismissedForEdit(editStart: number, editOldEnd: number, delta: number): void {
186
+ dismissed = dismissed
187
+ .map((d) => (d.symbolStart >= editOldEnd ? { ...d, symbolStart: d.symbolStart + delta } : d))
188
+ // An occurrence only stays dismissed while its symbol still sits at that
189
+ // offset — deleting and retyping the symbol re-arms (C9).
190
+ .filter((d) => {
191
+ if (d.symbolStart >= editStart && d.symbolStart < editOldEnd) return false
192
+ const trigger = triggers.find((t) => t.id === d.triggerId)
193
+ return trigger !== undefined && value.startsWith(trigger.symbol, d.symbolStart)
194
+ })
195
+ }
196
+
197
+ function recomputeTriggers(): void {
198
+ const collapsed = selection.start === selection.end
199
+ activeMatch =
200
+ collapsed && !isComposing && !disabled && !readOnly
201
+ ? detectActiveTrigger({
202
+ text: value,
203
+ caret: selection.start,
204
+ triggers,
205
+ isComposing,
206
+ dismissed,
207
+ tokenRanges: tokens,
208
+ })
209
+ : null
210
+ suggestions.sync(activeMatch)
211
+ }
212
+
213
+ function fireTypingSignal(): void {
214
+ // Leading edge + max one per interval; trailing edge deliberately dropped.
215
+ const at = now()
216
+ if (at - lastTypingAt >= typingSignalIntervalMs) {
217
+ lastTypingAt = at
218
+ onEvent({ type: 'typing' })
219
+ }
220
+ }
221
+
222
+ function scheduleDraftWrite(): void {
223
+ if (!config.draftStore || !config.draftKey) return
224
+ if (draftTimer !== null) clearTimeout(draftTimer)
225
+ draftTimer = setTimeout(() => {
226
+ draftTimer = null
227
+ // Never persist mid-IME text (F7); the post-composition mutation reschedules.
228
+ if (isComposing || destroyed) return
229
+ config.draftStore?.write(config.draftKey as string, {
230
+ value,
231
+ tokens: [...tokens],
232
+ attachmentIds: attachments.map((a) => a.id),
233
+ updatedAt: now(),
234
+ })
235
+ }, draftDebounceMs)
236
+ }
237
+
238
+ function cancelDraftWrite(): void {
239
+ if (draftTimer !== null) {
240
+ clearTimeout(draftTimer)
241
+ draftTimer = null
242
+ }
243
+ }
244
+
245
+ function clearPersistedDraft(): void {
246
+ cancelDraftWrite()
247
+ if (config.draftStore && config.draftKey) config.draftStore.clear(config.draftKey)
248
+ }
249
+
250
+ function maybeCommitTypedEmoji(): void {
251
+ if (!emojiTrigger || selection.start !== selection.end) return
252
+ const caretBefore = selection.start
253
+ const hit = detectTypedEmoji(value, caretBefore)
254
+ if (!hit) return
255
+ // Never re-tokenize inside an existing token's range.
256
+ if (tokens.some((t) => t.start < caretBefore && hit.start < t.end)) return
257
+ const token: ComposerToken = {
258
+ triggerId: emojiTrigger.id,
259
+ symbol: emojiTrigger.symbol,
260
+ id: hit.shortcode,
261
+ label: hit.shortcode.slice(1, -1),
262
+ display: hit.unicode,
263
+ }
264
+ const committed = commitTokenAt({
265
+ value,
266
+ tokens,
267
+ start: hit.start,
268
+ end: caretBefore,
269
+ token,
270
+ })
271
+ const delta = committed.value.length - value.length
272
+ value = committed.value
273
+ tokens = committed.tokens
274
+ selection = committed.selection
275
+ shiftDismissedForEdit(hit.start, caretBefore, delta)
276
+ }
277
+
278
+ interface MutateOptions {
279
+ programmatic?: boolean
280
+ /** Emit 'paste-trimmed' (vs silent clamp) when the budget trims the insert. */
281
+ trimEventOnClamp?: boolean
282
+ }
283
+
284
+ /**
285
+ * The one write path for text: diff → token atomicity → clamp → snap →
286
+ * emoji commit → trigger detection → typing signal → draft write → emit.
287
+ * Returns the applied edit start (for clip-shadow restores) or null.
288
+ */
289
+ function mutateValue(
290
+ newText: string,
291
+ newSelection: ComposerSelection,
292
+ opts: MutateOptions = {},
293
+ ): { applied: boolean; editStart: number } {
294
+ const previousSelection = selection
295
+
296
+ if (isComposing) {
297
+ // IME owns mid-composition text: track it verbatim (token ranges still
298
+ // shift via the diff) but suspend clamping, triggers, drafts, signals.
299
+ const result = applyValueEdit({ oldValue: value, newValue: newText, tokens, newSelection })
300
+ value = result.value
301
+ tokens = result.tokens
302
+ selection = result.selection
303
+ emit()
304
+ return { applied: true, editStart: result.edit?.start ?? newSelection.start }
305
+ }
306
+
307
+ const result = applyValueEdit({
308
+ oldValue: value,
309
+ newValue: newText,
310
+ tokens,
311
+ newSelection,
312
+ maxLength: config.maxLength,
313
+ })
314
+
315
+ if (result.rejected) {
316
+ onEvent({ type: 'edit-rejected', reason: 'inside-token' })
317
+ selection = result.selection
318
+ recomputeTriggers()
319
+ emit()
320
+ return { applied: false, editStart: 0 }
321
+ }
322
+
323
+ if (result.trimmed && opts.trimEventOnClamp !== false) {
324
+ onEvent({ type: 'paste-trimmed' })
325
+ }
326
+
327
+ const editStart = result.edit?.start ?? newSelection.start
328
+ value = result.value
329
+ tokens = result.tokens
330
+ selection = snapSelectionToTokens(tokens, result.selection, previousSelection)
331
+
332
+ if (result.edit) {
333
+ shiftDismissedForEdit(
334
+ result.edit.start,
335
+ result.edit.oldEnd,
336
+ result.edit.newEnd - result.edit.oldEnd,
337
+ )
338
+ }
339
+
340
+ maybeCommitTypedEmoji()
341
+ recomputeTriggers()
342
+ if (!opts.programmatic) fireTypingSignal()
343
+ scheduleDraftWrite()
344
+ emit()
345
+ return { applied: true, editStart }
346
+ }
347
+
348
+ function buildSubmissionTokens(): { plainText: string; tokens: ResolvedToken[] } {
349
+ const plainText = payloadFor(value)
350
+ const leading = value.length - value.trimStart().length
351
+ const serialized = serializeTokens(value, tokens)
352
+ return {
353
+ plainText,
354
+ tokens: serialized.tokens.map((t) => ({ ...t, start: t.start - leading, end: t.end - leading })),
355
+ }
356
+ }
357
+
358
+ function commitSuggestion(index?: number): boolean {
359
+ const match = activeMatch
360
+ const menu = suggestions.state
361
+ if (!match || !menu.isOpen) return false
362
+ const item = menu.items[index ?? menu.activeIndex]
363
+ if (!item) return false
364
+
365
+ const display = match.trigger.toDisplay(item)
366
+ if (config.maxLength !== undefined) {
367
+ const replacedSpan = value.slice(match.symbolStart, match.caret)
368
+ const nextLength =
369
+ graphemeLength(value) - graphemeLength(replacedSpan) + graphemeLength(display)
370
+ // A token commits whole or not at all — never a clamped half-token (E16).
371
+ if (nextLength > config.maxLength) {
372
+ onEvent({ type: 'insert-rejected', reason: 'max-length' })
373
+ emit()
374
+ return false
375
+ }
376
+ }
377
+
378
+ const token: ComposerToken = {
379
+ triggerId: match.trigger.id,
380
+ symbol: match.trigger.symbol,
381
+ id: item.id,
382
+ label: item.display,
383
+ display,
384
+ metadata: item.metadata,
385
+ }
386
+ const committed = commitTokenAt({
387
+ value,
388
+ tokens,
389
+ start: match.symbolStart,
390
+ end: match.caret,
391
+ token,
392
+ })
393
+ const delta = committed.value.length - value.length
394
+ value = committed.value
395
+ tokens = committed.tokens
396
+ selection = committed.selection
397
+ shiftDismissedForEdit(match.symbolStart, match.caret, delta)
398
+ suggestions.cancelDeferredDismiss()
399
+ activeMatch = null
400
+ suggestions.sync(null)
401
+ scheduleDraftWrite()
402
+ emit()
403
+ return true
404
+ }
405
+
406
+ function performSubmit(): ComposerSubmission | null {
407
+ const counter = computeCounter(value, config.maxLength)
408
+ const overBudget = counter.remaining !== null && counter.remaining < 0
409
+ if (!computeCanSend({ text: value, attachments, disabled, readOnly, busy: isBusy }) || overBudget) {
410
+ return null
411
+ }
412
+
413
+ const { plainText, tokens: submissionTokens } = buildSubmissionTokens()
414
+ if (config.validator) {
415
+ const verdict = config.validator(plainText, submissionTokens)
416
+ if (!verdict.isValid) {
417
+ // Draft-preserving: surface the reason, leave the value untouched.
418
+ error = verdict.reason ?? 'Message failed validation'
419
+ emit()
420
+ return null
421
+ }
422
+ }
423
+
424
+ const submission: ComposerSubmission = {
425
+ plainText,
426
+ tokens: submissionTokens,
427
+ attachments: [...attachments],
428
+ replyToMessageId: config.replyToMessageId,
429
+ ...(mode === 'edit' ? { editingMessageId } : {}),
430
+ }
431
+
432
+ // Optimistic: snapshot then clear synchronously; transport is host-owned.
433
+ value = ''
434
+ tokens = []
435
+ attachments = []
436
+ selection = { start: 0, end: 0 }
437
+ error = null
438
+ activeMatch = null
439
+ dismissed = []
440
+ clipShadow = null
441
+ mode = 'compose'
442
+ editingMessageId = undefined
443
+ preEditSnapshot = null
444
+ suggestions.close()
445
+ clearPersistedDraft()
446
+ emit()
447
+ return submission
448
+ }
449
+
450
+ // --- public API ----------------------------------------------------------------
451
+
452
+ const api: ComposerAPI = {
453
+ getState(): ComposerState {
454
+ return buildState()
455
+ },
456
+
457
+ subscribe(listener: (state: ComposerState) => void): () => void {
458
+ listeners.add(listener)
459
+ return () => listeners.delete(listener)
460
+ },
461
+
462
+ setValue(text, sel, opts): void {
463
+ if (destroyed || disabled) return
464
+ if (readOnly) {
465
+ // Value is immutable via the user path; selection moves are allowed.
466
+ if (text === value && sel) api.setSelection(sel)
467
+ return
468
+ }
469
+ const nextSelection = sel ?? { start: text.length, end: text.length }
470
+ mutateValue(text, nextSelection, { programmatic: opts?.programmatic })
471
+ },
472
+
473
+ setSelection(sel): void {
474
+ if (destroyed || disabled) return
475
+ const previous = selection
476
+ selection = snapSelectionToTokens(tokens, {
477
+ start: Math.max(0, Math.min(sel.start, value.length)),
478
+ end: Math.max(0, Math.min(sel.end, value.length)),
479
+ }, previous)
480
+ // Any caret move re-evaluates the trigger (closes when leaving its range).
481
+ recomputeTriggers()
482
+ emit()
483
+ },
484
+
485
+ insertTextAtCursor(text): void {
486
+ if (destroyed) return
487
+ if (disabled) {
488
+ onEvent({ type: 'insert-rejected', reason: 'disabled' })
489
+ return
490
+ }
491
+ if (readOnly) {
492
+ onEvent({ type: 'insert-rejected', reason: 'read-only' })
493
+ return
494
+ }
495
+ if (isComposing) {
496
+ // Splicing into an active composition corrupts the candidate — no-op.
497
+ onEvent({ type: 'insert-rejected', reason: 'composing' })
498
+ return
499
+ }
500
+ if (config.maxLength !== undefined) {
501
+ const range = expandRangeOverTokens(tokens, selection)
502
+ const base =
503
+ graphemeLength(value.slice(0, range.start)) + graphemeLength(value.slice(range.end))
504
+ // All-or-nothing: a programmatic insert is rejected, never truncated.
505
+ if (base + graphemeLength(text) > config.maxLength) {
506
+ onEvent({ type: 'insert-rejected', reason: 'max-length' })
507
+ return
508
+ }
509
+ }
510
+ const nextText = value.slice(0, selection.start) + text + value.slice(selection.end)
511
+ const caret = selection.start + text.length
512
+ mutateValue(nextText, { start: caret, end: caret })
513
+ },
514
+
515
+ setComposing(composing): void {
516
+ if (destroyed || isComposing === composing) return
517
+ isComposing = composing
518
+ if (!composing) {
519
+ // Re-run everything suspended during composition (R5/C12): clamp the
520
+ // now-committed text, re-detect triggers, resume drafts.
521
+ if (config.maxLength !== undefined && graphemeLength(value) > config.maxLength) {
522
+ value = clampGraphemes(value, config.maxLength)
523
+ tokens = tokens.filter((t) => t.end <= value.length)
524
+ selection = {
525
+ start: Math.min(selection.start, value.length),
526
+ end: Math.min(selection.end, value.length),
527
+ }
528
+ }
529
+ maybeCommitTypedEmoji()
530
+ recomputeTriggers()
531
+ scheduleDraftWrite()
532
+ } else {
533
+ activeMatch = null
534
+ suggestions.sync(null)
535
+ }
536
+ emit()
537
+ },
538
+
539
+ applyEnter({ shiftPressed }): EnterResult {
540
+ if (destroyed || disabled || readOnly) return 'noop'
541
+ const menu = suggestions.state
542
+ if (menu.isOpen && !isComposing) {
543
+ // While the menu is open Enter always commits, never submits (D11).
544
+ if (menu.items.length > 0) {
545
+ return commitSuggestion() ? 'committed-suggestion' : 'noop'
546
+ }
547
+ return 'noop'
548
+ }
549
+ if (!shouldSubmitOnEnter({ shiftPressed, isComposing })) {
550
+ // 'newline' asks the adapter to let the native key insert; while
551
+ // composing the IME owns Enter entirely, so report 'noop'.
552
+ return isComposing ? 'noop' : 'newline'
553
+ }
554
+ return performSubmit() ? 'submitted' : 'noop'
555
+ },
556
+
557
+ moveSuggestionNext(): void {
558
+ suggestions.moveNext()
559
+ emit()
560
+ },
561
+
562
+ moveSuggestionPrevious(): void {
563
+ suggestions.movePrevious()
564
+ emit()
565
+ },
566
+
567
+ setSuggestionActiveIndex(index): void {
568
+ suggestions.setActiveIndex(index)
569
+ emit()
570
+ },
571
+
572
+ applySuggestion(index): void {
573
+ commitSuggestion(index)
574
+ },
575
+
576
+ dismissSuggestion(): void {
577
+ if (activeMatch) {
578
+ // Escape marks this occurrence dismissed — it will not re-arm until
579
+ // the symbol is deleted and retyped (C9).
580
+ dismissed.push({ triggerId: activeMatch.trigger.id, symbolStart: activeMatch.symbolStart })
581
+ activeMatch = null
582
+ }
583
+ suggestions.close()
584
+ emit()
585
+ },
586
+
587
+ dismissSuggestionDeferred(): void {
588
+ suggestions.dismissDeferred(() => {
589
+ // Blur-close: no dismissal marking — the next mutation may re-arm.
590
+ activeMatch = null
591
+ emit()
592
+ })
593
+ },
594
+
595
+ retrySuggestions(): void {
596
+ suggestions.retry()
597
+ emit()
598
+ },
599
+
600
+ addAttachment(draft): string | null {
601
+ if (destroyed) return null
602
+ if (config.maxAttachments !== undefined && attachments.length >= config.maxAttachments) {
603
+ onEvent({ type: 'attachment-rejected', reason: 'max-attachments', name: draft.name })
604
+ return null
605
+ }
606
+ if (
607
+ config.maxAttachmentSizeBytes !== undefined &&
608
+ draft.sizeBytes !== undefined &&
609
+ draft.sizeBytes > config.maxAttachmentSizeBytes
610
+ ) {
611
+ onEvent({ type: 'attachment-rejected', reason: 'max-size', name: draft.name })
612
+ return null
613
+ }
614
+ if (config.acceptAttachment) {
615
+ const verdict = config.acceptAttachment(draft)
616
+ if (verdict !== true) {
617
+ onEvent({
618
+ type: 'attachment-rejected',
619
+ reason: 'not-accepted',
620
+ name: draft.name,
621
+ ...(typeof verdict === 'string' ? { detail: verdict } : {}),
622
+ })
623
+ return null
624
+ }
625
+ }
626
+ const id = draft.id ?? generateId('rfr-attachment')
627
+ attachments = [...attachments, { ...draft, id, status: draft.status ?? 'pending' }]
628
+ scheduleDraftWrite()
629
+ emit()
630
+ return id
631
+ },
632
+
633
+ updateAttachment(id, patch): void {
634
+ if (destroyed) return
635
+ attachments = attachments.map((a) => (a.id === id ? { ...a, ...patch, id } : a))
636
+ emit()
637
+ },
638
+
639
+ removeAttachment(id): void {
640
+ if (destroyed) return
641
+ attachments = attachments.filter((a) => a.id !== id)
642
+ scheduleDraftWrite()
643
+ emit()
644
+ },
645
+
646
+ setBusy(busy): void {
647
+ if (destroyed || isBusy === busy) return
648
+ isBusy = busy
649
+ emit()
650
+ },
651
+
652
+ setError(nextError): void {
653
+ if (destroyed) return
654
+ error = nextError
655
+ emit()
656
+ },
657
+
658
+ setDisabled(nextDisabled): void {
659
+ if (destroyed || disabled === nextDisabled) return
660
+ disabled = nextDisabled
661
+ if (disabled) {
662
+ activeMatch = null
663
+ suggestions.close()
664
+ }
665
+ emit()
666
+ },
667
+
668
+ setReadOnly(nextReadOnly): void {
669
+ if (destroyed || readOnly === nextReadOnly) return
670
+ readOnly = nextReadOnly
671
+ if (readOnly) {
672
+ activeMatch = null
673
+ suggestions.close()
674
+ }
675
+ emit()
676
+ },
677
+
678
+ beginEdit({ value: editValue, tokens: editTokens, messageId }): void {
679
+ if (destroyed || disabled) return
680
+ preEditSnapshot = { value, tokens: [...tokens], selection, attachments: [...attachments] }
681
+ mode = 'edit'
682
+ editingMessageId = messageId
683
+ value = editValue
684
+ tokens = [...(editTokens ?? [])]
685
+ selection = { start: value.length, end: value.length }
686
+ activeMatch = null
687
+ dismissed = []
688
+ suggestions.close()
689
+ emit()
690
+ },
691
+
692
+ cancelEdit(): void {
693
+ if (destroyed || mode !== 'edit') return
694
+ const snapshot = preEditSnapshot
695
+ mode = 'compose'
696
+ editingMessageId = undefined
697
+ preEditSnapshot = null
698
+ if (snapshot) {
699
+ value = snapshot.value
700
+ tokens = snapshot.tokens
701
+ selection = snapshot.selection
702
+ attachments = snapshot.attachments
703
+ }
704
+ activeMatch = null
705
+ suggestions.close()
706
+ emit()
707
+ },
708
+
709
+ copySelection(): string {
710
+ // Tokens copy as their display text — the value already inlines it.
711
+ return value.slice(selection.start, selection.end)
712
+ },
713
+
714
+ cutSelection(): string {
715
+ if (destroyed || disabled || readOnly) return ''
716
+ if (selection.start === selection.end) return ''
717
+ const range = expandRangeOverTokens(tokens, selection)
718
+ const text = value.slice(range.start, range.end)
719
+ // Clip shadow: same-instance paste can restore live tokens (E12).
720
+ clipShadow = {
721
+ text,
722
+ tokens: tokens
723
+ .filter((t) => t.start >= range.start && t.end <= range.end)
724
+ .map((t) => ({ ...t, start: t.start - range.start, end: t.end - range.start })),
725
+ }
726
+ const nextText = value.slice(0, range.start) + value.slice(range.end)
727
+ mutateValue(nextText, { start: range.start, end: range.start })
728
+ return text
729
+ },
730
+
731
+ pasteText(text): void {
732
+ if (destroyed || disabled || readOnly) return
733
+ const shadow = clipShadow
734
+ const range = expandRangeOverTokens(tokens, selection)
735
+ const nextText = value.slice(0, range.start) + text + value.slice(range.end)
736
+ const caret = range.start + text.length
737
+ const before = value
738
+ mutateValue(nextText, { start: caret, end: caret }, { trimEventOnClamp: true })
739
+ // Restore cut tokens only for an exact, untrimmed same-instance paste;
740
+ // pasted look-alike text is never auto-tokenized (E11).
741
+ if (
742
+ shadow &&
743
+ text === shadow.text &&
744
+ value === before.slice(0, range.start) + text + before.slice(range.end)
745
+ ) {
746
+ const restored = shadow.tokens.map((t) => ({
747
+ ...t,
748
+ start: t.start + range.start,
749
+ end: t.end + range.start,
750
+ }))
751
+ tokens = [...tokens, ...restored].sort((a, b) => a.start - b.start)
752
+ emit()
753
+ }
754
+ },
755
+
756
+ submit(): ComposerSubmission | null {
757
+ if (destroyed) return null
758
+ return performSubmit()
759
+ },
760
+
761
+ reset(): void {
762
+ if (destroyed) return
763
+ cancelDraftWrite()
764
+ suggestions.close()
765
+ value = config.initialValue ?? ''
766
+ tokens = [...(config.initialTokens ?? [])]
767
+ selection = { start: value.length, end: value.length }
768
+ isComposing = false
769
+ isBusy = false
770
+ error = null
771
+ attachments = []
772
+ mode = 'compose'
773
+ editingMessageId = undefined
774
+ preEditSnapshot = null
775
+ activeMatch = null
776
+ dismissed = []
777
+ clipShadow = null
778
+ lastTypingAt = Number.NEGATIVE_INFINITY
779
+ recomputeTriggers()
780
+ emit()
781
+ },
782
+
783
+ serialize(): ComposerOutput {
784
+ return serializeTokens(value, tokens)
785
+ },
786
+
787
+ destroy(): void {
788
+ if (destroyed) return
789
+ cancelDraftWrite()
790
+ suggestions.destroy()
791
+ listeners.clear()
792
+ destroyed = true
793
+ },
794
+ }
795
+
796
+ // Hydration determinism (§6.7): suggestion.isOpen starts false — the menu
797
+ // only opens on the first user interaction, never at create time.
798
+ return api
799
+ }