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