@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- 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
|
+
}
|