@refraction-ui/astro 0.15.1 → 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.
- package/dist/astro-composer/Composer.astro +66 -0
- package/dist/astro-composer/index.ts +1 -0
- package/dist/astro-date-picker/DatePicker.astro +39 -355
- package/dist/composer/composer.ts +799 -0
- package/dist/composer/graphemes.ts +92 -0
- package/dist/composer/index.ts +8 -0
- package/dist/composer/rules.ts +63 -0
- package/dist/composer/styles.ts +102 -0
- package/dist/composer/suggestion.ts +261 -0
- package/dist/composer/tokens.ts +288 -0
- package/dist/composer/trigger-engine.ts +205 -0
- package/dist/composer/types.ts +337 -0
- package/dist/date-picker/date-picker.styles.ts +1 -1
- package/dist/date-picker/date-picker.ts +3 -1
- package/dist/index.ts +1 -0
- package/dist/rich-editor/index.ts +4 -0
- package/dist/rich-editor/mentions.ts +3 -17
- package/dist/rich-editor/slash-commands.ts +3 -17
- package/dist/rich-editor/trigger.ts +40 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|