@workerdeck/ui 0.6.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,1507 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import type {
6
+ Segment,
7
+ TriggerConfig,
8
+ ActiveTrigger,
9
+ TriggerSuggestion,
10
+ ChipSegment,
11
+ PromptAreaHandle,
12
+ } from './types.ts'
13
+ import {
14
+ detectActiveTrigger,
15
+ isValidTriggerPosition,
16
+ segmentsToPlainText,
17
+ plainTextToSegments,
18
+ segmentsEqual,
19
+ resolveChip,
20
+ removeChipAtIndex,
21
+ revertChipAtIndex,
22
+ replaceTextRange,
23
+ toggleMarkdownWrap,
24
+ truncateSegmentsToLength,
25
+ } from './prompt-area-engine.ts'
26
+ import {
27
+ getListContext,
28
+ autoFormatListPrefix,
29
+ insertListContinuation,
30
+ indentListItem,
31
+ outdentListItem,
32
+ removeListPrefix,
33
+ normalizeListPrefixes,
34
+ renumberOrderedListSegments,
35
+ remapOffset,
36
+ hasOrderedListRun,
37
+ } from './prompt-area-list-ops.ts'
38
+ import {
39
+ isHTMLElement,
40
+ isChipElement,
41
+ isLinkElement,
42
+ isBRElement,
43
+ chipNodeToSegment,
44
+ getChipAutoResolved,
45
+ getDirectChildContaining,
46
+ indexOfChildNode,
47
+ domChildIndexToSegmentIndex,
48
+ normalizeEditorDOM,
49
+ decorateEditor,
50
+ safeJsonStringify,
51
+ getSelectionRange,
52
+ } from './dom-helpers.ts'
53
+ import {
54
+ saveCursorPosition,
55
+ restoreCursorPosition,
56
+ getCursorOffset,
57
+ setCursorAtOffset,
58
+ createRangeAtOffset,
59
+ getSelectionOffsets,
60
+ setSelectionAtOffsets,
61
+ } from './cursor-helpers.ts'
62
+ import { usePromptAreaEvents } from './use-prompt-area-events.ts'
63
+ import { useTriggerSearch } from './use-trigger-search.ts'
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Types
67
+ // ---------------------------------------------------------------------------
68
+
69
+ type UsePromptAreaOptions = {
70
+ value: Segment[]
71
+ onChange: (segments: Segment[]) => void
72
+ triggers?: TriggerConfig[]
73
+ disabled?: boolean
74
+ onSubmit?: (segments: Segment[]) => void
75
+ onEscape?: () => void
76
+ onChipClick?: (chip: ChipSegment) => void
77
+ onChipAdd?: (chip: ChipSegment) => void
78
+ onChipDelete?: (chip: ChipSegment) => void
79
+ onLinkClick?: (url: string) => void
80
+ onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void
81
+ onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void
82
+ onUndo?: (segments: Segment[]) => void
83
+ onRedo?: (segments: Segment[]) => void
84
+ onImagePaste?: (file: File) => void
85
+ markdown?: boolean
86
+ normalizeBullets?: boolean
87
+ submitOnEnter?: boolean
88
+ maxLength?: number
89
+ }
90
+
91
+ type UsePromptAreaReturn = {
92
+ editorRef: React.RefObject<HTMLDivElement | null>
93
+ activeTrigger: ActiveTrigger | null
94
+ suggestions: TriggerSuggestion[]
95
+ suggestionsLoading: boolean
96
+ suggestionsError: string | null
97
+ selectedSuggestionIndex: number
98
+ handleInput: () => void
99
+ handleKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void
100
+ handleClick: (e: React.MouseEvent<HTMLDivElement>) => void
101
+ handleMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void
102
+ selectSuggestion: (suggestion: TriggerSuggestion) => void
103
+ dismissTrigger: () => void
104
+ handle: PromptAreaHandle
105
+ triggerRect: DOMRect | null
106
+ eventHandlers: {
107
+ onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => void
108
+ onCopy: (e: React.ClipboardEvent<HTMLDivElement>) => void
109
+ onCut: (e: React.ClipboardEvent<HTMLDivElement>) => void
110
+ onDrop: (e: React.DragEvent<HTMLDivElement>) => void
111
+ onDragOver: (e: React.DragEvent<HTMLDivElement>) => void
112
+ onCompositionStart: () => void
113
+ onCompositionEnd: () => void
114
+ onBlur: () => void
115
+ }
116
+ }
117
+
118
+ /** Debounce interval for grouping typed characters into a single undo snapshot */
119
+ const UNDO_DEBOUNCE_MS = 300
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Hook
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export function usePromptArea({
126
+ value,
127
+ onChange,
128
+ triggers = [],
129
+ disabled = false,
130
+ onSubmit,
131
+ onEscape,
132
+ onChipClick,
133
+ onChipAdd,
134
+ onChipDelete,
135
+ onLinkClick,
136
+ onPaste,
137
+ onRawPaste,
138
+ onUndo,
139
+ onRedo,
140
+ onImagePaste,
141
+ markdown: markdownEnabled = true,
142
+ normalizeBullets = true,
143
+ submitOnEnter = true,
144
+ maxLength,
145
+ }: UsePromptAreaOptions): UsePromptAreaReturn {
146
+ const editorRef = useRef<HTMLDivElement | null>(null)
147
+ const [activeTrigger, setActiveTrigger] = useState<ActiveTrigger | null>(null)
148
+ const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0)
149
+ const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null)
150
+
151
+ // Chip whose dropdown was reopened via `reopenOnChipClick`. While set, the
152
+ // active dropdown edits this chip in place instead of resolving typed text.
153
+ // Cleared on dismiss and by any fresh trigger detection (typing).
154
+ //
155
+ // `segIndex` is the segment index at CLICK time (computed from the live DOM,
156
+ // so it's always accurate then) — deliberately not the DOM node itself,
157
+ // which can detach if `renderSegmentsToDOM` re-renders while the dropdown is
158
+ // open (external value update, undo/redo). At selection time we re-verify
159
+ // this index still holds the same chip and fall back to a trigger+value
160
+ // search if the model shifted underneath, instead of silently no-op'ing.
161
+ const editingChip = useRef<{ chip: ChipSegment; segIndex: number } | null>(null)
162
+
163
+ // The DOM node of the chip currently being edited via `reopenOnChipClick`,
164
+ // kept in lockstep with `editingChip`/`activeTrigger` (set when opened,
165
+ // cleared by `dismissTrigger`). Used only to answer "is THIS exact chip
166
+ // element the one whose dropdown is open right now" by reference identity —
167
+ // never by trigger+value, which can't distinguish two chips that happen to
168
+ // share the same value.
169
+ const openChipNode = useRef<HTMLElement | null>(null)
170
+
171
+ // Set by `handleMouseDown` to the chip node a mousedown landed on, but only
172
+ // when that node === `openChipNode.current` at that instant; read and
173
+ // cleared by the following `handleClick` to distinguish "reopen" from
174
+ // "toggle closed" for that one click. A real `onMouseDown` (bubble-phase,
175
+ // attached to the editor root) is used instead of piggybacking on
176
+ // `dismissTrigger` because DOM bubbling reaches the editor root before it
177
+ // reaches `document` (where TriggerPopover's outside-click dismiss listens),
178
+ // so this always observes `openChipNode` before that dismiss clears it —
179
+ // and unlike a `dismissTrigger`-driven flag, it is scoped to mousedowns on
180
+ // this exact node, so Escape/blur/an unrelated dismiss can never poison a
181
+ // later, unrelated click on the same chip.
182
+ const suppressReopenChip = useRef<HTMLElement | null>(null)
183
+
184
+ const {
185
+ suggestions,
186
+ suggestionsLoading,
187
+ suggestionsError,
188
+ search: runSearch,
189
+ reset: resetSearch,
190
+ } = useTriggerSearch()
191
+
192
+ // Guard against circular DOM <-> model syncs
193
+ const isSyncing = useRef(false)
194
+ const lastRenderedValue = useRef<Segment[]>([])
195
+
196
+ // Debounced undo: groups consecutive keystrokes into a single undo snapshot
197
+ const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
198
+ const undoBaseState = useRef<Segment[] | null>(null)
199
+
200
+ // -----------------------------------------------------------------------
201
+ // DOM -> Model: read segments from the contentEditable DOM
202
+ // -----------------------------------------------------------------------
203
+
204
+ const readSegmentsFromDOM = useCallback((): Segment[] => {
205
+ const editor = editorRef.current
206
+ if (!editor) return []
207
+
208
+ const segments: Segment[] = []
209
+ // Track whether the editor holds any real content (text/chip) or a sentinel
210
+ // <br> that renderSegmentsToDOM added. When it holds neither, any <br> nodes
211
+ // present are the browser's filler <br> (see the empty-editor check below).
212
+ let hasRealContent = false
213
+ let hasSentinel = false
214
+
215
+ for (let i = 0; i < editor.childNodes.length; i++) {
216
+ const node = editor.childNodes[i]
217
+
218
+ if (node.nodeType === Node.TEXT_NODE) {
219
+ const text = node.textContent ?? ''
220
+ if (text) {
221
+ segments.push({ type: 'text', text })
222
+ hasRealContent = true
223
+ }
224
+ } else if (isChipElement(node)) {
225
+ const chip = chipNodeToSegment(node)
226
+ if (chip) {
227
+ segments.push(chip)
228
+ hasRealContent = true
229
+ }
230
+ } else if (isBRElement(node)) {
231
+ if (node.dataset.sentinel) {
232
+ hasSentinel = true
233
+ continue // skip sentinel <br>
234
+ }
235
+ segments.push({ type: 'text', text: '\n' })
236
+ } else if (isHTMLElement(node)) {
237
+ // Unknown element — extract text content
238
+ const text = node.textContent ?? ''
239
+ if (text) {
240
+ segments.push({ type: 'text', text })
241
+ hasRealContent = true
242
+ }
243
+ }
244
+ }
245
+
246
+ // When the user empties the editor (types something, then deletes it all),
247
+ // the browser leaves a lone filler <br> so the contentEditable block stays
248
+ // visible and focusable. Reading that <br> as a "\n" text segment would make
249
+ // `value` permanently non-empty and keep the placeholder hidden forever.
250
+ // A newline we actually rendered always carries surrounding text/chip
251
+ // content or a trailing sentinel <br>, so when neither is present the only
252
+ // <br> nodes are filler and the editor is genuinely empty.
253
+ if (!hasRealContent && !hasSentinel) return []
254
+
255
+ return segments
256
+ }, [])
257
+
258
+ // -----------------------------------------------------------------------
259
+ // Model -> DOM: render segments into the contentEditable div
260
+ // -----------------------------------------------------------------------
261
+
262
+ const renderSegmentsToDOM = useCallback(
263
+ (segments: Segment[]) => {
264
+ const editor = editorRef.current
265
+ if (!editor) return
266
+
267
+ isSyncing.current = true
268
+
269
+ const savedCursor = saveCursorPosition(editor)
270
+
271
+ // Clear DOM safely (no innerHTML assignment)
272
+ while (editor.firstChild) {
273
+ editor.removeChild(editor.firstChild)
274
+ }
275
+
276
+ for (const seg of segments) {
277
+ if (seg.type === 'text') {
278
+ const lines = seg.text.split('\n')
279
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
280
+ if (lines[lineIdx]) {
281
+ editor.appendChild(document.createTextNode(lines[lineIdx]))
282
+ }
283
+ if (lineIdx < lines.length - 1) {
284
+ editor.appendChild(document.createElement('br'))
285
+ }
286
+ }
287
+ } else {
288
+ // Render chip as non-editable span
289
+ const chip = document.createElement('span')
290
+ chip.contentEditable = 'false'
291
+ chip.dataset.chipTrigger = seg.trigger
292
+ chip.dataset.chipValue = seg.value
293
+ chip.dataset.chipDisplay = seg.displayText
294
+ if (seg.data !== undefined) {
295
+ const json = safeJsonStringify(seg.data)
296
+ if (json) {
297
+ chip.dataset.chipData = json
298
+ }
299
+ }
300
+ if (seg.autoResolved) {
301
+ chip.dataset.chipAutoResolved = 'true'
302
+ }
303
+ const triggerConfig = triggers.find((t) => t.char === seg.trigger)
304
+ const chipStyle = triggerConfig?.chipStyle ?? 'pill'
305
+ chip.dataset.chipStyle = chipStyle
306
+ chip.className = cn(
307
+ 'prompt-area-chip',
308
+ chipStyle === 'inline' && 'prompt-area-chip--inline',
309
+ triggerConfig?.chipClassName,
310
+ )
311
+ chip.textContent = `${seg.trigger}${seg.displayText}`
312
+ chip.setAttribute('role', 'button')
313
+ chip.setAttribute('tabindex', '-1')
314
+ editor.appendChild(chip)
315
+ }
316
+ }
317
+
318
+ // Append sentinel <br> so trailing newlines are visible in contentEditable
319
+ if (editor.lastChild && isBRElement(editor.lastChild)) {
320
+ const sentinel = document.createElement('br')
321
+ sentinel.dataset.sentinel = 'true'
322
+ editor.appendChild(sentinel)
323
+ }
324
+
325
+ // Decorate URLs, markdown formatting, and list bullets in text nodes
326
+ decorateEditor(editor, markdownEnabled)
327
+
328
+ if (savedCursor) {
329
+ restoreCursorPosition(editor, savedCursor)
330
+ }
331
+
332
+ lastRenderedValue.current = segments
333
+ isSyncing.current = false
334
+ },
335
+ [triggers, markdownEnabled],
336
+ )
337
+
338
+ // -----------------------------------------------------------------------
339
+ // Trigger detection (extracted so events module can call it)
340
+ // -----------------------------------------------------------------------
341
+
342
+ // Builds the insertChip handed to callback/launch activations: replaces the
343
+ // trigger's range with a chip and notifies onChipAdd.
344
+ const buildInsertChip = useCallback(
345
+ (segments: Segment[], trigger: ActiveTrigger) => (chip: Omit<ChipSegment, 'type'>) => {
346
+ const chipResult = resolveChip(segments, trigger, {
347
+ value: chip.value,
348
+ displayText: chip.displayText,
349
+ data: chip.data,
350
+ })
351
+ onChange(chipResult.segments)
352
+ renderSegmentsToDOM(chipResult.segments)
353
+ onChipAdd?.({
354
+ type: 'chip',
355
+ trigger: trigger.config.char,
356
+ value: chip.value,
357
+ displayText: chip.displayText,
358
+ ...(chip.data !== undefined ? { data: chip.data } : {}),
359
+ })
360
+ const editor = editorRef.current
361
+ if (editor) setCursorAtOffset(editor, chipResult.cursorOffset)
362
+ },
363
+ [onChange, renderSegmentsToDOM, onChipAdd],
364
+ )
365
+
366
+ const runTriggerDetection = useCallback(() => {
367
+ const editor = editorRef.current
368
+ if (!editor) return
369
+
370
+ const segments = readSegmentsFromDOM()
371
+ const plainText = segmentsToPlainText(segments)
372
+ const cursorPos = getCursorOffset(editor)
373
+
374
+ if (cursorPos === null) return
375
+
376
+ const detected = detectActiveTrigger(plainText, cursorPos, triggers)
377
+
378
+ // Typing supersedes a chip-click dropdown: whichever branch we take next,
379
+ // the popover no longer edits the clicked chip.
380
+ editingChip.current = null
381
+ openChipNode.current = null
382
+
383
+ if (detected) {
384
+ setActiveTrigger(detected)
385
+ setSelectedSuggestionIndex(0)
386
+
387
+ // Position the popover at the trigger character, not the cursor.
388
+ // Build a range at detected.startOffset so the dropdown anchors to
389
+ // the trigger char even when the cursor has moved past it.
390
+ const triggerRange = createRangeAtOffset(editor, detected.startOffset)
391
+ if (triggerRange) {
392
+ const rect = triggerRange.getBoundingClientRect()
393
+ // A zero rect means the range couldn't be mapped (e.g. after DOM
394
+ // re-render). Skip updating triggerRect so we keep the last valid one.
395
+ if (rect.height > 0 || rect.left > 0 || rect.top > 0) {
396
+ setTriggerRect(rect)
397
+ }
398
+ }
399
+
400
+ // Fetch suggestions for dropdown mode
401
+ if (detected.config.mode === 'dropdown' && detected.config.onSearch) {
402
+ runSearch(detected.query, detected.config)
403
+ }
404
+
405
+ // Fire callback for callback mode
406
+ if (detected.config.mode === 'callback' && detected.config.onActivate) {
407
+ detected.config.onActivate({
408
+ text: plainText,
409
+ cursorPosition: cursorPos,
410
+ insertChip: buildInsertChip(segments, detected),
411
+ })
412
+ }
413
+ } else {
414
+ setActiveTrigger(null)
415
+ resetSearch()
416
+ }
417
+ }, [triggers, readSegmentsFromDOM, buildInsertChip, resetSearch, runSearch])
418
+
419
+ // -----------------------------------------------------------------------
420
+ // Dismiss trigger
421
+ // -----------------------------------------------------------------------
422
+
423
+ const dismissTrigger = useCallback(() => {
424
+ editingChip.current = null
425
+ openChipNode.current = null
426
+ setActiveTrigger(null)
427
+ setSelectedSuggestionIndex(0)
428
+ resetSearch()
429
+ }, [resetSearch])
430
+
431
+ // -----------------------------------------------------------------------
432
+ // Wire up edge-case event handlers
433
+ // -----------------------------------------------------------------------
434
+
435
+ const events = usePromptAreaEvents({
436
+ editorRef,
437
+ readSegmentsFromDOM,
438
+ onChange,
439
+ renderSegmentsToDOM,
440
+ runTriggerDetection,
441
+ dismissTrigger,
442
+ triggers,
443
+ markdownEnabled,
444
+ normalizeBullets,
445
+ onPaste,
446
+ onRawPaste,
447
+ onUndo,
448
+ onRedo,
449
+ onChipAdd,
450
+ onImagePaste,
451
+ })
452
+
453
+ // -----------------------------------------------------------------------
454
+ // Sync value prop -> DOM on external changes
455
+ // -----------------------------------------------------------------------
456
+
457
+ useEffect(() => {
458
+ if (isSyncing.current) return
459
+ if (segmentsEqual(value, lastRenderedValue.current)) return
460
+
461
+ // Normalize list prefixes (e.g., "- " → "• " when markdown is on)
462
+ // so externally-provided segments render bullet characters correctly.
463
+ if (markdownEnabled && normalizeBullets) {
464
+ const normalized = normalizeListPrefixes(value, true)
465
+ if (normalized !== value) {
466
+ onChange(normalized)
467
+ return // onChange will trigger a re-render with the normalized value
468
+ }
469
+ }
470
+
471
+ renderSegmentsToDOM(value)
472
+ }, [value, renderSegmentsToDOM, markdownEnabled, normalizeBullets, onChange])
473
+
474
+ // Re-render when markdown mode changes to apply/strip decorations
475
+ // Also convert bullet characters: • ↔ - in text segments
476
+ const prevMarkdown = useRef(markdownEnabled)
477
+ useEffect(() => {
478
+ if (prevMarkdown.current === markdownEnabled) return
479
+ prevMarkdown.current = markdownEnabled
480
+
481
+ const converted = normalizeBullets ? normalizeListPrefixes(value, markdownEnabled) : value
482
+ if (converted !== value) {
483
+ onChange(converted)
484
+ } else {
485
+ renderSegmentsToDOM(value)
486
+ }
487
+ }, [markdownEnabled, normalizeBullets, renderSegmentsToDOM, value, onChange])
488
+
489
+ // Clean up undo debounce timer on unmount
490
+ useEffect(() => {
491
+ return () => {
492
+ if (undoTimer.current) clearTimeout(undoTimer.current)
493
+ }
494
+ }, [])
495
+
496
+ // -----------------------------------------------------------------------
497
+ // Handle input events
498
+ // -----------------------------------------------------------------------
499
+
500
+ const handleInput = useCallback(() => {
501
+ if (isSyncing.current) return
502
+
503
+ // During IME composition, sync model but skip trigger detection
504
+ if (events.isComposing.current) {
505
+ const segments = readSegmentsFromDOM()
506
+ lastRenderedValue.current = segments
507
+ onChange(segments)
508
+ return
509
+ }
510
+
511
+ const editor = editorRef.current
512
+
513
+ // Capture cursor offset BEFORE normalizeEditorDOM strips <a> elements,
514
+ // otherwise the anchor node becomes detached and we lose the position.
515
+ const savedCursorOffset = editor ? getCursorOffset(editor) : null
516
+
517
+ if (editor) {
518
+ // Normalize browser-inserted block elements (div, p, font, a, etc.)
519
+ normalizeEditorDOM(editor)
520
+ }
521
+
522
+ const segments = readSegmentsFromDOM()
523
+
524
+ // Enforce maxLength: if the edit pushed the editor past the cap, truncate
525
+ // back to maxLength characters and keep the caret where the user was
526
+ // editing (clamped to the cap) rather than forcing it to the end.
527
+ if (maxLength != null && editor && segmentsToPlainText(segments).length > maxLength) {
528
+ const caret = getCursorOffset(editor)
529
+ const truncated = truncateSegmentsToLength(segments, maxLength)
530
+ lastRenderedValue.current = truncated
531
+ onChange(truncated)
532
+ renderSegmentsToDOM(truncated)
533
+ setCursorAtOffset(editor, caret != null ? Math.min(caret, maxLength) : maxLength)
534
+ runTriggerDetection()
535
+ return
536
+ }
537
+
538
+ // Check for list auto-formatting (e.g., "- " -> "bullet ")
539
+ if (markdownEnabled && normalizeBullets && editor && savedCursorOffset !== null) {
540
+ const formatted = autoFormatListPrefix(segments, savedCursorOffset)
541
+ if (formatted) {
542
+ lastRenderedValue.current = formatted.segments
543
+ onChange(formatted.segments)
544
+ renderSegmentsToDOM(formatted.segments)
545
+ setCursorAtOffset(editor, formatted.cursorOffset)
546
+ runTriggerDetection()
547
+ return
548
+ }
549
+ }
550
+
551
+ // Native structural edits (e.g. a Backspace that deleted or merged a list
552
+ // row) bypass applyEditResult, so rebuild ordered-list numbering here too.
553
+ // handleInput fires on every keystroke, so gate on a genuine ordered-list
554
+ // run — this renumbers a real list (1,2,4 → 1,2,3) but leaves incidental
555
+ // numeric prose ("1985. Born / 2020. Died") untouched.
556
+ let nextSegments = segments
557
+ let renumberedCursor: number | null = null
558
+ if (
559
+ markdownEnabled &&
560
+ savedCursorOffset !== null &&
561
+ hasOrderedListRun(segmentsToPlainText(segments))
562
+ ) {
563
+ const renumbered = renumberOrderedListSegments(segments)
564
+ if (renumbered.edits.length > 0) {
565
+ nextSegments = renumbered.segments
566
+ renumberedCursor = remapOffset(savedCursorOffset, renumbered.edits)
567
+ }
568
+ }
569
+
570
+ // Debounced undo: capture the pre-edit state at the start of a typing
571
+ // session and push it to the undo stack after UNDO_DEBOUNCE_MS of idle.
572
+ if (!undoBaseState.current) {
573
+ undoBaseState.current = lastRenderedValue.current
574
+ }
575
+
576
+ lastRenderedValue.current = nextSegments
577
+ onChange(nextSegments)
578
+ if (undoTimer.current) clearTimeout(undoTimer.current)
579
+ undoTimer.current = setTimeout(() => {
580
+ if (undoBaseState.current) {
581
+ events.pushUndo(undoBaseState.current)
582
+ undoBaseState.current = null
583
+ }
584
+ undoTimer.current = null
585
+ }, UNDO_DEBOUNCE_MS)
586
+
587
+ // Apply the recomputed model to the DOM. A renumber rewrites text nodes, so
588
+ // it needs a full re-render (which also re-decorates); otherwise just
589
+ // re-decorate the existing DOM in place.
590
+ if (editor) {
591
+ if (renumberedCursor !== null) {
592
+ renderSegmentsToDOM(nextSegments)
593
+ setCursorAtOffset(editor, renumberedCursor)
594
+ } else {
595
+ decorateEditor(editor, markdownEnabled)
596
+ if (savedCursorOffset !== null) {
597
+ setCursorAtOffset(editor, savedCursorOffset)
598
+ }
599
+ }
600
+ }
601
+
602
+ runTriggerDetection()
603
+ }, [
604
+ onChange,
605
+ readSegmentsFromDOM,
606
+ runTriggerDetection,
607
+ renderSegmentsToDOM,
608
+ markdownEnabled,
609
+ normalizeBullets,
610
+ maxLength,
611
+ events,
612
+ ])
613
+
614
+ // -----------------------------------------------------------------------
615
+ // Chip click delegation
616
+ // -----------------------------------------------------------------------
617
+
618
+ const handleClick = useCallback(
619
+ (e: React.MouseEvent<HTMLDivElement>) => {
620
+ const target = e.target
621
+ if (!(target instanceof Node)) return
622
+
623
+ const editor = editorRef.current
624
+ if (!editor) return
625
+
626
+ // Walk from the click target up to find a link or chip element
627
+ let node: Node | null = target
628
+ while (node && node !== editor) {
629
+ // Check for URL link click — only navigate on Cmd/Ctrl+Click;
630
+ // plain click just positions the cursor for editing.
631
+ if (isLinkElement(node)) {
632
+ if (e.metaKey || e.ctrlKey) {
633
+ e.preventDefault()
634
+ onLinkClick?.(node.href)
635
+ window.open(node.href, '_blank', 'noopener,noreferrer')
636
+ return
637
+ }
638
+ // Plain click: let the browser place the cursor inside the link text
639
+ break
640
+ }
641
+
642
+ if (isChipElement(node)) {
643
+ // Spawn ripple effect. `isChipElement` has already narrowed `node`
644
+ // to HTMLElement, so no cast is needed.
645
+ const rect = node.getBoundingClientRect()
646
+ const ripple = document.createElement('span')
647
+ ripple.className = 'prompt-area-chip-ripple'
648
+ const size = Math.max(rect.width, rect.height)
649
+ ripple.style.width = `${size}px`
650
+ ripple.style.height = `${size}px`
651
+ ripple.style.left = `${e.clientX - rect.left - size / 2}px`
652
+ ripple.style.top = `${e.clientY - rect.top - size / 2}px`
653
+ node.appendChild(ripple)
654
+ ripple.addEventListener('animationend', () => ripple.remove())
655
+
656
+ const chip = chipNodeToSegment(node)
657
+ if (chip) {
658
+ // Native chip-click dropdown: reopen this trigger's suggestions
659
+ // anchored to the chip so the selection can replace it in place.
660
+ // Gated on `!disabled` — a disabled composer must not accept edits
661
+ // through any path, including this one.
662
+ const config = triggers.find((t) => t.char === chip.trigger)
663
+ // A click on THIS exact chip element while its own dropdown was
664
+ // open just closed it (see `suppressReopenChip` and
665
+ // `handleMouseDown`) — treat that as a toggle-close, not a reopen.
666
+ const wasOpenForThisChip = suppressReopenChip.current === node
667
+ suppressReopenChip.current = null
668
+ if (
669
+ !disabled &&
670
+ !wasOpenForThisChip &&
671
+ config?.reopenOnChipClick &&
672
+ config.mode === 'dropdown' &&
673
+ config.onSearch
674
+ ) {
675
+ const childIdx = indexOfChildNode(editor, node)
676
+ editingChip.current = {
677
+ chip,
678
+ segIndex: domChildIndexToSegmentIndex(editor, childIdx),
679
+ }
680
+ openChipNode.current = node
681
+ setActiveTrigger({ config, startOffset: 0, query: '' })
682
+ setSelectedSuggestionIndex(0)
683
+ setTriggerRect(rect)
684
+ runSearch('', config)
685
+ }
686
+ onChipClick?.(chip)
687
+ }
688
+ return
689
+ }
690
+ node = node.parentNode
691
+ }
692
+ },
693
+ [onChipClick, onLinkClick, triggers, runSearch, disabled],
694
+ )
695
+
696
+ // -----------------------------------------------------------------------
697
+ // Chip mousedown delegation — feeds `suppressReopenChip` for handleClick's
698
+ // toggle-close detection. See `openChipNode`/`suppressReopenChip` above for
699
+ // why this needs to be a real mousedown listener rather than piggybacking
700
+ // on `dismissTrigger`.
701
+ // -----------------------------------------------------------------------
702
+
703
+ const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
704
+ const target = e.target
705
+ const editor = editorRef.current
706
+ if (!editor || !(target instanceof Node)) {
707
+ suppressReopenChip.current = null
708
+ return
709
+ }
710
+
711
+ let node: Node | null = target
712
+ while (node && node !== editor) {
713
+ if (isChipElement(node)) {
714
+ suppressReopenChip.current = openChipNode.current === node ? node : null
715
+ return
716
+ }
717
+ node = node.parentNode
718
+ }
719
+ suppressReopenChip.current = null
720
+ }, [])
721
+
722
+ // -----------------------------------------------------------------------
723
+ // Remove a chip node from DOM and sync model
724
+ // -----------------------------------------------------------------------
725
+
726
+ const removeChipNodeFromDOM = useCallback(
727
+ (editor: HTMLElement, chipNode: HTMLElement): boolean => {
728
+ const segments = readSegmentsFromDOM()
729
+ const chipIdx = indexOfChildNode(editor, chipNode)
730
+ if (chipIdx === -1) return false
731
+
732
+ const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)
733
+ const deletedChip = segments[segIdx]
734
+ const newSegments = removeChipAtIndex(segments, segIdx)
735
+ onChange(newSegments)
736
+ renderSegmentsToDOM(newSegments)
737
+
738
+ if (deletedChip?.type === 'chip') {
739
+ onChipDelete?.(deletedChip)
740
+ }
741
+
742
+ return true
743
+ },
744
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],
745
+ )
746
+
747
+ // -----------------------------------------------------------------------
748
+ // Revert an auto-resolved chip back to plain text
749
+ // -----------------------------------------------------------------------
750
+
751
+ const revertChipNodeToText = useCallback(
752
+ (editor: HTMLElement, chipNode: HTMLElement): boolean => {
753
+ const segments = readSegmentsFromDOM()
754
+ const chipIdx = indexOfChildNode(editor, chipNode)
755
+ if (chipIdx === -1) return false
756
+
757
+ const segIdx = domChildIndexToSegmentIndex(editor, chipIdx)
758
+ const revertedChip = segments[segIdx]
759
+ const result = revertChipAtIndex(segments, segIdx)
760
+ if (!result) return false
761
+
762
+ // Compute cursor target: plain text offset at end of reverted text
763
+ let targetOffset = 0
764
+ for (let i = 0; i < segIdx; i++) {
765
+ const s = segments[i]
766
+ if (s.type === 'text') {
767
+ targetOffset += s.text.length
768
+ } else {
769
+ targetOffset += s.trigger.length + s.displayText.length
770
+ }
771
+ }
772
+ targetOffset += result.revertedText.length
773
+
774
+ onChange(result.segments)
775
+ renderSegmentsToDOM(result.segments)
776
+ setCursorAtOffset(editor, targetOffset)
777
+
778
+ if (revertedChip?.type === 'chip') {
779
+ onChipDelete?.(revertedChip)
780
+ }
781
+
782
+ return true
783
+ },
784
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipDelete],
785
+ )
786
+
787
+ // -----------------------------------------------------------------------
788
+ // Chip backspace (delete chip behind cursor as whole unit)
789
+ // -----------------------------------------------------------------------
790
+
791
+ const handleChipBackspace = useCallback((): boolean => {
792
+ const editor = editorRef.current
793
+ if (!editor) return false
794
+
795
+ const range = getSelectionRange()
796
+ if (!range || !range.collapsed) return false
797
+
798
+ const node = range.startContainer
799
+ const offset = range.startOffset
800
+
801
+ // Case 1: cursor is at the editor level (between child nodes)
802
+ if (node === editor && offset > 0) {
803
+ const prevChild = editor.childNodes[offset - 1]
804
+ if (prevChild && isChipElement(prevChild)) {
805
+ if (getChipAutoResolved(prevChild)) {
806
+ return revertChipNodeToText(editor, prevChild)
807
+ }
808
+ return removeChipNodeFromDOM(editor, prevChild)
809
+ }
810
+ }
811
+
812
+ // Case 2: cursor is at start of a text node, check previous sibling
813
+ if (node.nodeType === Node.TEXT_NODE && offset === 0) {
814
+ const directChild = getDirectChildContaining(editor, node)
815
+ if (!directChild) return false
816
+
817
+ let prevSibling = directChild.previousSibling
818
+ while (
819
+ prevSibling &&
820
+ prevSibling.nodeType === Node.TEXT_NODE &&
821
+ prevSibling.textContent === ''
822
+ ) {
823
+ prevSibling = prevSibling.previousSibling
824
+ }
825
+ if (prevSibling && isChipElement(prevSibling)) {
826
+ if (getChipAutoResolved(prevSibling)) {
827
+ return revertChipNodeToText(editor, prevSibling)
828
+ }
829
+ return removeChipNodeFromDOM(editor, prevSibling)
830
+ }
831
+ }
832
+
833
+ return false
834
+ }, [removeChipNodeFromDOM, revertChipNodeToText])
835
+
836
+ // -----------------------------------------------------------------------
837
+ // Chip forward delete (delete chip in front of cursor)
838
+ // -----------------------------------------------------------------------
839
+
840
+ const handleChipForwardDelete = useCallback((): boolean => {
841
+ const editor = editorRef.current
842
+ if (!editor) return false
843
+
844
+ const range = getSelectionRange()
845
+ if (!range || !range.collapsed) return false
846
+
847
+ const node = range.startContainer
848
+ const offset = range.startOffset
849
+
850
+ // Case 1: cursor at the editor level
851
+ if (node === editor && offset < editor.childNodes.length) {
852
+ const nextChild = editor.childNodes[offset]
853
+ if (nextChild && isChipElement(nextChild)) {
854
+ return removeChipNodeFromDOM(editor, nextChild)
855
+ }
856
+ }
857
+
858
+ // Case 2: cursor at end of a text node, check next sibling
859
+ if (node.nodeType === Node.TEXT_NODE && offset === (node.textContent ?? '').length) {
860
+ const directChild = getDirectChildContaining(editor, node)
861
+ if (!directChild) return false
862
+
863
+ let nextSibling = directChild.nextSibling
864
+ while (
865
+ nextSibling &&
866
+ nextSibling.nodeType === Node.TEXT_NODE &&
867
+ nextSibling.textContent === ''
868
+ ) {
869
+ nextSibling = nextSibling.nextSibling
870
+ }
871
+ if (nextSibling && isChipElement(nextSibling)) {
872
+ return removeChipNodeFromDOM(editor, nextSibling)
873
+ }
874
+ }
875
+
876
+ return false
877
+ }, [removeChipNodeFromDOM])
878
+
879
+ // -----------------------------------------------------------------------
880
+ // Auto-resolve active trigger on space
881
+ // -----------------------------------------------------------------------
882
+
883
+ const autoResolveActiveTrigger = useCallback(
884
+ (trigger: ActiveTrigger) => {
885
+ const segments = readSegmentsFromDOM()
886
+ const query = trigger.query
887
+
888
+ // Create a synthetic suggestion so onSelect can customize display text
889
+ const syntheticSuggestion: TriggerSuggestion = {
890
+ value: query,
891
+ label: query,
892
+ }
893
+
894
+ const displayText = trigger.config.onSelect?.(syntheticSuggestion) ?? query
895
+
896
+ const chipData = {
897
+ value: query,
898
+ displayText: displayText || query,
899
+ autoResolved: true,
900
+ }
901
+ const result = resolveChip(segments, trigger, chipData)
902
+
903
+ onChange(result.segments)
904
+ renderSegmentsToDOM(result.segments)
905
+
906
+ onChipAdd?.({
907
+ type: 'chip',
908
+ trigger: trigger.config.char,
909
+ ...chipData,
910
+ })
911
+
912
+ // Position cursor after the auto-resolved chip + trailing space
913
+ const editor = editorRef.current
914
+ if (editor) {
915
+ setCursorAtOffset(editor, result.cursorOffset)
916
+ }
917
+
918
+ dismissTrigger()
919
+ },
920
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, dismissTrigger, onChipAdd],
921
+ )
922
+
923
+ // -----------------------------------------------------------------------
924
+ // Select a suggestion from the dropdown
925
+ // -----------------------------------------------------------------------
926
+
927
+ const selectSuggestionInternal = useCallback(
928
+ (suggestion: TriggerSuggestion) => {
929
+ if (!activeTrigger) return
930
+
931
+ const segments = readSegmentsFromDOM()
932
+ const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label
933
+
934
+ const chipData = {
935
+ value: suggestion.value,
936
+ displayText: displayText || suggestion.label,
937
+ data: suggestion.data,
938
+ }
939
+
940
+ // Chip-click dropdown (`reopenOnChipClick`): replace the clicked chip in
941
+ // place instead of resolving typed trigger text at the caret. Disabled
942
+ // is re-checked here (not just at open time) in case the composer
943
+ // became disabled while the popover was still open.
944
+ const editing = editingChip.current
945
+ if (editing) {
946
+ const editor = editorRef.current
947
+ if (editor && !disabled) {
948
+ // Re-verify the click-time index still holds the same chip — the
949
+ // model may have shifted (external value update, undo/redo) while
950
+ // the dropdown was open. If it moved, recover ONLY when exactly one
951
+ // chip in the document now matches trigger+value: with duplicates,
952
+ // guessing risks silently editing the wrong instance, which is
953
+ // worse than the no-op this falls back to.
954
+ const atIndex = segments[editing.segIndex]
955
+ const stillThere =
956
+ atIndex?.type === 'chip' &&
957
+ atIndex.trigger === editing.chip.trigger &&
958
+ atIndex.value === editing.chip.value
959
+ const segIdx = stillThere
960
+ ? editing.segIndex
961
+ : (() => {
962
+ const matches: number[] = []
963
+ segments.forEach((seg, i) => {
964
+ if (
965
+ seg.type === 'chip' &&
966
+ seg.trigger === editing.chip.trigger &&
967
+ seg.value === editing.chip.value
968
+ ) {
969
+ matches.push(i)
970
+ }
971
+ })
972
+ return matches.length === 1 ? matches[0] : -1
973
+ })()
974
+ const oldChip = segIdx !== -1 ? segments[segIdx] : undefined
975
+
976
+ if (oldChip?.type === 'chip') {
977
+ const newChip: ChipSegment = {
978
+ type: 'chip',
979
+ trigger: activeTrigger.config.char,
980
+ ...chipData,
981
+ }
982
+ let newSegments = segments.map((seg, i) => (i === segIdx ? newChip : seg))
983
+
984
+ // Guarantee a real landing spot after the replaced chip, mirroring
985
+ // resolveChip's trailing-space convention (prompt-area-engine.ts):
986
+ // if the new chip is now the last segment (or directly followed by
987
+ // another chip), the caret would land at a bare element boundary
988
+ // with no text node, which some engines fail to render/snap a
989
+ // visible caret at.
990
+ const nextSeg = newSegments[segIdx + 1]
991
+ const insertedSpace = !nextSeg || nextSeg.type !== 'text' || nextSeg.text.length === 0
992
+ if (insertedSpace) {
993
+ newSegments = [
994
+ ...newSegments.slice(0, segIdx + 1),
995
+ { type: 'text', text: ' ' },
996
+ ...newSegments.slice(segIdx + 1),
997
+ ]
998
+ }
999
+
1000
+ events.pushUndo(segments)
1001
+ onChange(newSegments)
1002
+ renderSegmentsToDOM(newSegments)
1003
+
1004
+ // Same value + display text + data: treat as a no-op confirmation
1005
+ // rather than a destructive delete+add — onChipDelete is
1006
+ // documented as firing on backspace/forward-delete, not on
1007
+ // re-confirming the already-selected suggestion.
1008
+ const unchanged =
1009
+ oldChip.value === newChip.value &&
1010
+ oldChip.displayText === newChip.displayText &&
1011
+ safeJsonStringify(oldChip.data) === safeJsonStringify(newChip.data)
1012
+ if (!unchanged) {
1013
+ onChipDelete?.(oldChip)
1014
+ onChipAdd?.(newChip)
1015
+ }
1016
+
1017
+ // +1 when a space was inserted, matching resolveChip's own
1018
+ // "+1 accounts for the trailing space after the chip" placement —
1019
+ // landing exactly at the chip's end would put the caret at the
1020
+ // same bare element boundary the inserted space exists to avoid.
1021
+ const caretOffset =
1022
+ segmentsToPlainText(newSegments.slice(0, segIdx + 1)).length + (insertedSpace ? 1 : 0)
1023
+ setCursorAtOffset(editor, caretOffset)
1024
+ }
1025
+ }
1026
+
1027
+ dismissTrigger()
1028
+ setTimeout(() => {
1029
+ editorRef.current?.focus()
1030
+ }, 0)
1031
+ return
1032
+ }
1033
+
1034
+ const result = resolveChip(segments, activeTrigger, chipData)
1035
+
1036
+ onChange(result.segments)
1037
+ renderSegmentsToDOM(result.segments)
1038
+
1039
+ onChipAdd?.({
1040
+ type: 'chip',
1041
+ trigger: activeTrigger.config.char,
1042
+ ...chipData,
1043
+ })
1044
+
1045
+ // Position cursor after the chip + trailing space
1046
+ const editor = editorRef.current
1047
+ if (editor) {
1048
+ setCursorAtOffset(editor, result.cursorOffset)
1049
+ }
1050
+
1051
+ dismissTrigger()
1052
+
1053
+ // Refocus editor after popover interaction
1054
+ setTimeout(() => {
1055
+ editorRef.current?.focus()
1056
+ }, 0)
1057
+ },
1058
+ [
1059
+ activeTrigger,
1060
+ readSegmentsFromDOM,
1061
+ onChange,
1062
+ renderSegmentsToDOM,
1063
+ dismissTrigger,
1064
+ onChipAdd,
1065
+ onChipDelete,
1066
+ events,
1067
+ disabled,
1068
+ ],
1069
+ )
1070
+
1071
+ const selectSuggestion = selectSuggestionInternal
1072
+
1073
+ // Chip-click dropdown: once the empty-query suggestions arrive, preselect
1074
+ // the chip's current value so the list opens "on" the existing choice.
1075
+ useEffect(() => {
1076
+ const editing = editingChip.current
1077
+ if (!editing || !activeTrigger?.config.reopenOnChipClick) return
1078
+ const idx = suggestions.findIndex((s) => s.value === editing.chip.value)
1079
+ if (idx > 0) setSelectedSuggestionIndex(idx)
1080
+ }, [suggestions, activeTrigger])
1081
+
1082
+ // -----------------------------------------------------------------------
1083
+ // Handle key events
1084
+ // -----------------------------------------------------------------------
1085
+
1086
+ const handleKeyDown = useCallback(
1087
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
1088
+ const applyEditResult = (
1089
+ editor: HTMLDivElement,
1090
+ result: { segments: Segment[]; cursorOffset: number },
1091
+ ) => {
1092
+ // Ordered-list numbers are a projection of position: rebuild them on
1093
+ // every structural edit and remap the caret across any digit-run width
1094
+ // changes. No-op (same reference) when there are no ordered lists.
1095
+ let { segments, cursorOffset } = result
1096
+ if (markdownEnabled) {
1097
+ const renumbered = renumberOrderedListSegments(segments)
1098
+ segments = renumbered.segments
1099
+ cursorOffset = remapOffset(cursorOffset, renumbered.edits)
1100
+ }
1101
+ lastRenderedValue.current = segments
1102
+ onChange(segments)
1103
+ renderSegmentsToDOM(segments)
1104
+ setCursorAtOffset(editor, cursorOffset)
1105
+ }
1106
+
1107
+ const tryListContinuation = (editor: HTMLDivElement): boolean => {
1108
+ if (!markdownEnabled) return false
1109
+ const segments = readSegmentsFromDOM()
1110
+ const cursorPos = getCursorOffset(editor)
1111
+ if (cursorPos === null) return false
1112
+ const plainText = segmentsToPlainText(segments)
1113
+ if (!getListContext(plainText, cursorPos)) return false
1114
+ const result = insertListContinuation(segments, cursorPos)
1115
+ if (result) applyEditResult(editor, result)
1116
+ return true
1117
+ }
1118
+
1119
+ // 1. Flush pending undo debounce so Cmd+Z has the latest checkpoint
1120
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && undoBaseState.current) {
1121
+ if (undoTimer.current) {
1122
+ clearTimeout(undoTimer.current)
1123
+ undoTimer.current = null
1124
+ }
1125
+ events.pushUndo(undoBaseState.current)
1126
+ undoBaseState.current = null
1127
+ }
1128
+
1129
+ // 1a. Undo/redo intercept
1130
+ if (events.handleKeyDownForUndoRedo(e)) return
1131
+
1132
+ // 1.5 Markdown formatting shortcuts (Cmd+B bold, Cmd+I italic)
1133
+ if (
1134
+ markdownEnabled &&
1135
+ (e.metaKey || e.ctrlKey) &&
1136
+ !e.shiftKey &&
1137
+ (e.key === 'b' || e.key === 'i')
1138
+ ) {
1139
+ e.preventDefault()
1140
+ const editor = editorRef.current
1141
+ if (!editor) return
1142
+
1143
+ const offsets = getSelectionOffsets(editor)
1144
+ if (!offsets || offsets.start === offsets.end) return
1145
+
1146
+ const marker = e.key === 'b' ? '**' : '*'
1147
+ const currentSegments = readSegmentsFromDOM()
1148
+ events.pushUndo(currentSegments)
1149
+
1150
+ const result = toggleMarkdownWrap(currentSegments, offsets.start, offsets.end, marker)
1151
+ if (!result) return
1152
+
1153
+ lastRenderedValue.current = result.segments
1154
+ onChange(result.segments)
1155
+ renderSegmentsToDOM(result.segments)
1156
+ setSelectionAtOffsets(editor, result.selectionStart, result.selectionEnd)
1157
+ return
1158
+ }
1159
+
1160
+ // 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on
1161
+ // keydown and suppresses the char so it never enters the editor — for
1162
+ // opening an external surface (dialog, palette). The DOM read is gated on
1163
+ // the typed key actually matching a launch char, so it stays off the hot
1164
+ // path. insertChip still inserts a chip at the cursor if the consumer
1165
+ // wants one after the external selection.
1166
+ if (
1167
+ !e.metaKey &&
1168
+ !e.ctrlKey &&
1169
+ !e.altKey &&
1170
+ !e.nativeEvent.isComposing &&
1171
+ e.key.length === 1
1172
+ ) {
1173
+ const launcher = triggers.find((t) => t.mode === 'launch' && t.char === e.key)
1174
+ const editor = editorRef.current
1175
+ if (launcher?.onActivate && editor) {
1176
+ const cursorPos = getCursorOffset(editor)
1177
+ if (cursorPos !== null) {
1178
+ const segments = readSegmentsFromDOM()
1179
+ const plainText = segmentsToPlainText(segments)
1180
+ if (isValidTriggerPosition(plainText, cursorPos, launcher.position)) {
1181
+ e.preventDefault()
1182
+ launcher.onActivate({
1183
+ text: plainText,
1184
+ cursorPosition: cursorPos,
1185
+ insertChip: buildInsertChip(
1186
+ replaceTextRange(segments, cursorPos, cursorPos, launcher.char),
1187
+ { config: launcher, startOffset: cursorPos, query: '' },
1188
+ ),
1189
+ })
1190
+ return
1191
+ }
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ // 2. Trigger dropdown navigation. Gated on the dropdown actually being
1197
+ // ON SCREEN, which matches TriggerPopover's own render condition
1198
+ // (non-empty suggestions, OR loading/error/emptyMessage) rather than
1199
+ // just `suggestions.length > 0` — otherwise a popover left open in a
1200
+ // loading/empty state (e.g. right after a chip-click reopen, before its
1201
+ // empty-query search resolves) lets Enter fall through to onSubmit and
1202
+ // Escape fall through to onEscape while still visibly on screen.
1203
+ const dropdownVisible =
1204
+ activeTrigger &&
1205
+ activeTrigger.config.mode === 'dropdown' &&
1206
+ (suggestions.length > 0 ||
1207
+ suggestionsLoading ||
1208
+ suggestionsError !== null ||
1209
+ !!activeTrigger.config.emptyMessage)
1210
+ if (dropdownVisible) {
1211
+ if (e.key === 'ArrowDown') {
1212
+ e.preventDefault()
1213
+ if (suggestions.length > 0) {
1214
+ setSelectedSuggestionIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
1215
+ }
1216
+ return
1217
+ }
1218
+ if (e.key === 'ArrowUp') {
1219
+ e.preventDefault()
1220
+ if (suggestions.length > 0) {
1221
+ setSelectedSuggestionIndex((prev) => Math.max(prev - 1, 0))
1222
+ }
1223
+ return
1224
+ }
1225
+ if (e.key === 'Enter' || e.key === 'Tab') {
1226
+ e.preventDefault()
1227
+ const selected = suggestions[selectedSuggestionIndex]
1228
+ if (selected) {
1229
+ selectSuggestionInternal(selected)
1230
+ }
1231
+ return
1232
+ }
1233
+ if (e.key === 'Escape') {
1234
+ e.preventDefault()
1235
+ dismissTrigger()
1236
+ return
1237
+ }
1238
+ }
1239
+
1240
+ // 2.5. Auto-resolve on Space when trigger has resolveOnSpace
1241
+ if (e.key === ' ' && activeTrigger && activeTrigger.config.resolveOnSpace) {
1242
+ const query = activeTrigger.query.trim()
1243
+ if (query.length > 0) {
1244
+ e.preventDefault()
1245
+ autoResolveActiveTrigger(activeTrigger)
1246
+ return
1247
+ }
1248
+ }
1249
+
1250
+ // 2.6. Tab/Shift+Tab for list indentation (only when trigger dropdown is NOT open)
1251
+ if (markdownEnabled && e.key === 'Tab' && !activeTrigger) {
1252
+ const editor = editorRef.current
1253
+ if (editor) {
1254
+ const segments = readSegmentsFromDOM()
1255
+ const plainText = segmentsToPlainText(segments)
1256
+ const cursorPos = getCursorOffset(editor)
1257
+ if (cursorPos !== null) {
1258
+ const ctx = getListContext(plainText, cursorPos)
1259
+ if (ctx) {
1260
+ e.preventDefault()
1261
+ const result = e.shiftKey
1262
+ ? outdentListItem(segments, cursorPos)
1263
+ : indentListItem(segments, cursorPos)
1264
+ if (result) applyEditResult(editor, result)
1265
+ return
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ // Insert a newline at the model level (avoids the browser's broken
1272
+ // contentEditable behaviour near <a> elements).
1273
+ const insertPlainNewline = (editor: HTMLDivElement): void => {
1274
+ const offsets = getSelectionOffsets(editor)
1275
+ if (!offsets) return
1276
+ const currentSegments = readSegmentsFromDOM()
1277
+ events.pushUndo(currentSegments)
1278
+ const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '\n')
1279
+ applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start + 1 })
1280
+ }
1281
+
1282
+ // 2.8 Shift+Enter always inserts a newline (after a list-continuation check).
1283
+ if (e.key === 'Enter' && e.shiftKey && !e.nativeEvent.isComposing) {
1284
+ e.preventDefault()
1285
+ const editor = editorRef.current
1286
+ if (editor && !tryListContinuation(editor)) insertPlainNewline(editor)
1287
+ return
1288
+ }
1289
+
1290
+ // 3. Enter without Shift (skipping IME): continue a list, else submit when
1291
+ // `submitOnEnter` is set, else insert a newline.
1292
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
1293
+ const editor = editorRef.current
1294
+ if (editor && tryListContinuation(editor)) {
1295
+ e.preventDefault()
1296
+ return
1297
+ }
1298
+ if (submitOnEnter) {
1299
+ e.preventDefault()
1300
+ onSubmit?.(readSegmentsFromDOM())
1301
+ return
1302
+ }
1303
+ e.preventDefault()
1304
+ if (editor) insertPlainNewline(editor)
1305
+ return
1306
+ }
1307
+
1308
+ // 4. Escape
1309
+ if (e.key === 'Escape' && onEscape) {
1310
+ onEscape()
1311
+ return
1312
+ }
1313
+
1314
+ // 4.5 Non-collapsed selection delete (Backspace/Delete across <a> boundaries)
1315
+ if ((e.key === 'Backspace' || e.key === 'Delete') && !e.nativeEvent.isComposing) {
1316
+ const editor = editorRef.current
1317
+ if (editor) {
1318
+ const offsets = getSelectionOffsets(editor)
1319
+ if (offsets && offsets.start !== offsets.end) {
1320
+ e.preventDefault()
1321
+ const currentSegments = readSegmentsFromDOM()
1322
+ events.pushUndo(currentSegments)
1323
+ const newSegments = replaceTextRange(currentSegments, offsets.start, offsets.end, '')
1324
+ applyEditResult(editor, { segments: newSegments, cursorOffset: offsets.start })
1325
+ runTriggerDetection()
1326
+ return
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ // 5. Backspace: check list prefix removal, then chip deletion
1332
+ if (e.key === 'Backspace') {
1333
+ const editor = editorRef.current
1334
+ if (editor) {
1335
+ const segments = readSegmentsFromDOM()
1336
+ const cursorPos = getCursorOffset(editor)
1337
+ if (markdownEnabled && cursorPos !== null) {
1338
+ const result = removeListPrefix(segments, cursorPos)
1339
+ if (result) {
1340
+ e.preventDefault()
1341
+ applyEditResult(editor, result)
1342
+ runTriggerDetection()
1343
+ return
1344
+ }
1345
+ }
1346
+ }
1347
+ if (handleChipBackspace()) {
1348
+ e.preventDefault()
1349
+ runTriggerDetection()
1350
+ return
1351
+ }
1352
+ }
1353
+
1354
+ // 6. Delete (forward): delete chip as whole unit
1355
+ if (e.key === 'Delete' && handleChipForwardDelete()) {
1356
+ e.preventDefault()
1357
+ runTriggerDetection()
1358
+ return
1359
+ }
1360
+ },
1361
+ [
1362
+ activeTrigger,
1363
+ suggestions,
1364
+ suggestionsLoading,
1365
+ suggestionsError,
1366
+ selectedSuggestionIndex,
1367
+ onSubmit,
1368
+ submitOnEnter,
1369
+ onEscape,
1370
+ readSegmentsFromDOM,
1371
+ onChange,
1372
+ renderSegmentsToDOM,
1373
+ markdownEnabled,
1374
+ dismissTrigger,
1375
+ handleChipBackspace,
1376
+ handleChipForwardDelete,
1377
+ autoResolveActiveTrigger,
1378
+ runTriggerDetection,
1379
+ selectSuggestionInternal,
1380
+ events,
1381
+ triggers,
1382
+ buildInsertChip,
1383
+ ],
1384
+ )
1385
+
1386
+ // -----------------------------------------------------------------------
1387
+ // Imperative handle (memoized to avoid identity changes)
1388
+ // -----------------------------------------------------------------------
1389
+
1390
+ const handle: PromptAreaHandle = useMemo(
1391
+ () => ({
1392
+ focus: () => editorRef.current?.focus(),
1393
+ blur: () => editorRef.current?.blur(),
1394
+ insertChip: (chip) => {
1395
+ const segments = readSegmentsFromDOM()
1396
+ const newChip: ChipSegment = { type: 'chip', ...chip }
1397
+ const newSegments: Segment[] = [...segments, newChip, { type: 'text', text: ' ' }]
1398
+ onChange(newSegments)
1399
+ renderSegmentsToDOM(newSegments)
1400
+ onChipAdd?.(newChip)
1401
+ },
1402
+ getPlainText: () => segmentsToPlainText(readSegmentsFromDOM()),
1403
+ clear: () => {
1404
+ onChange([])
1405
+ const editor = editorRef.current
1406
+ if (editor) {
1407
+ while (editor.firstChild) editor.removeChild(editor.firstChild)
1408
+ }
1409
+ events.resetUndoHistory()
1410
+ if (undoTimer.current) {
1411
+ clearTimeout(undoTimer.current)
1412
+ undoTimer.current = null
1413
+ }
1414
+ undoBaseState.current = null
1415
+ },
1416
+ setText: (text) => {
1417
+ events.pushUndo(readSegmentsFromDOM())
1418
+ const segments = plainTextToSegments(text)
1419
+ onChange(segments)
1420
+ renderSegmentsToDOM(segments)
1421
+ const editor = editorRef.current
1422
+ if (editor) setCursorAtOffset(editor, text.length)
1423
+ },
1424
+ appendText: (text) => {
1425
+ const segments = readSegmentsFromDOM()
1426
+ events.pushUndo(segments)
1427
+ // Merge into the trailing text segment so the onChange value doesn't
1428
+ // carry two adjacent un-merged text segments.
1429
+ const last = segments[segments.length - 1]
1430
+ const next: Segment[] =
1431
+ last?.type === 'text'
1432
+ ? [...segments.slice(0, -1), { type: 'text', text: last.text + text }]
1433
+ : [...segments, { type: 'text', text }]
1434
+ onChange(next)
1435
+ renderSegmentsToDOM(next)
1436
+ const editor = editorRef.current
1437
+ if (editor) setCursorAtOffset(editor, segmentsToPlainText(next).length)
1438
+ },
1439
+ getCursorPosition: () => {
1440
+ const editor = editorRef.current
1441
+ return editor ? getCursorOffset(editor) : null
1442
+ },
1443
+ setCursorPosition: (offset) => {
1444
+ const editor = editorRef.current
1445
+ if (editor) setCursorAtOffset(editor, offset)
1446
+ },
1447
+ setCursorToEnd: () => {
1448
+ const editor = editorRef.current
1449
+ if (editor) setCursorAtOffset(editor, segmentsToPlainText(readSegmentsFromDOM()).length)
1450
+ },
1451
+ getSelection: () => {
1452
+ const editor = editorRef.current
1453
+ return editor ? getSelectionOffsets(editor) : null
1454
+ },
1455
+ setSelection: (start, end) => {
1456
+ const editor = editorRef.current
1457
+ if (editor) setSelectionAtOffsets(editor, start, end)
1458
+ },
1459
+ }),
1460
+ [readSegmentsFromDOM, onChange, renderSegmentsToDOM, onChipAdd, events],
1461
+ )
1462
+
1463
+ // -----------------------------------------------------------------------
1464
+ // Compose event handlers
1465
+ // -----------------------------------------------------------------------
1466
+
1467
+ const eventHandlers = useMemo(
1468
+ () => ({
1469
+ onPaste: events.handlePaste,
1470
+ onCopy: events.handleCopy,
1471
+ onCut: events.handleCut,
1472
+ onDrop: events.handleDrop,
1473
+ onDragOver: events.handleDragOver,
1474
+ onCompositionStart: events.handleCompositionStart,
1475
+ onCompositionEnd: events.handleCompositionEnd,
1476
+ onBlur: events.handleBlur,
1477
+ }),
1478
+ [
1479
+ events.handlePaste,
1480
+ events.handleCopy,
1481
+ events.handleCut,
1482
+ events.handleDrop,
1483
+ events.handleDragOver,
1484
+ events.handleCompositionStart,
1485
+ events.handleCompositionEnd,
1486
+ events.handleBlur,
1487
+ ],
1488
+ )
1489
+
1490
+ return {
1491
+ editorRef,
1492
+ activeTrigger,
1493
+ suggestions,
1494
+ suggestionsLoading,
1495
+ suggestionsError,
1496
+ selectedSuggestionIndex,
1497
+ handleInput,
1498
+ handleKeyDown,
1499
+ handleClick,
1500
+ handleMouseDown,
1501
+ selectSuggestion,
1502
+ dismissTrigger,
1503
+ handle,
1504
+ triggerRect,
1505
+ eventHandlers,
1506
+ }
1507
+ }