@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,470 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react'
|
|
4
|
+
import type { Segment, ChipSegment, TriggerConfig } from './types.ts'
|
|
5
|
+
import { resolveTriggersInSegments } from './prompt-area-engine.ts'
|
|
6
|
+
import { normalizeEditorDOM, safeJsonStringify, getSelectionRange } from './dom-helpers.ts'
|
|
7
|
+
import {
|
|
8
|
+
serializeFragmentToPlainText,
|
|
9
|
+
serializeFragmentToSegments,
|
|
10
|
+
parseSegmentsFromClipboard,
|
|
11
|
+
insertSegmentsAtCursor,
|
|
12
|
+
} from './clipboard-helpers.ts'
|
|
13
|
+
import { htmlToMarkdown } from './html-to-markdown.ts'
|
|
14
|
+
import {
|
|
15
|
+
normalizeListPrefixText,
|
|
16
|
+
renumberOrderedListLines,
|
|
17
|
+
hasOrderedListRun,
|
|
18
|
+
} from './prompt-area-list-ops.ts'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
type EventHandlerDeps = {
|
|
25
|
+
editorRef: React.RefObject<HTMLDivElement | null>
|
|
26
|
+
readSegmentsFromDOM: () => Segment[]
|
|
27
|
+
onChange: (segments: Segment[]) => void
|
|
28
|
+
renderSegmentsToDOM: (segments: Segment[]) => void
|
|
29
|
+
runTriggerDetection: () => void
|
|
30
|
+
dismissTrigger: () => void
|
|
31
|
+
triggers: TriggerConfig[]
|
|
32
|
+
/** When true, rich `text/html` on the clipboard is converted to markdown. */
|
|
33
|
+
markdownEnabled: boolean
|
|
34
|
+
/** When true, pasted list markers ("- ") are normalized to the "•" glyph. */
|
|
35
|
+
normalizeBullets: boolean
|
|
36
|
+
onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void
|
|
37
|
+
onUndo?: (segments: Segment[]) => void
|
|
38
|
+
onRedo?: (segments: Segment[]) => void
|
|
39
|
+
onChipAdd?: (chip: ChipSegment) => void
|
|
40
|
+
onImagePaste?: (file: File) => void
|
|
41
|
+
onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type PromptAreaEventHandlers = {
|
|
45
|
+
handlePaste: (e: React.ClipboardEvent<HTMLDivElement>) => void
|
|
46
|
+
handleCopy: (e: React.ClipboardEvent<HTMLDivElement>) => void
|
|
47
|
+
handleCut: (e: React.ClipboardEvent<HTMLDivElement>) => void
|
|
48
|
+
handleDrop: (e: React.DragEvent<HTMLDivElement>) => void
|
|
49
|
+
handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void
|
|
50
|
+
handleCompositionStart: () => void
|
|
51
|
+
handleCompositionEnd: () => void
|
|
52
|
+
handleBlur: () => void
|
|
53
|
+
handleKeyDownForUndoRedo: (e: React.KeyboardEvent<HTMLDivElement>) => boolean
|
|
54
|
+
pushUndo: (segments: Segment[]) => void
|
|
55
|
+
resetUndoHistory: () => void
|
|
56
|
+
isComposing: React.RefObject<boolean>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Undo/Redo Stack
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const MAX_UNDO_HISTORY = 100
|
|
64
|
+
|
|
65
|
+
/** Delay before dismissing trigger on blur, so popover clicks register first */
|
|
66
|
+
export const BLUR_DELAY_MS = 150
|
|
67
|
+
|
|
68
|
+
type UndoState = {
|
|
69
|
+
undoStack: Segment[][]
|
|
70
|
+
redoStack: Segment[][]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Hook
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Encapsulates all edge-case event handlers for the prompt area component:
|
|
79
|
+
* paste, copy, cut, drag/drop, IME composition, blur, and undo/redo.
|
|
80
|
+
*/
|
|
81
|
+
export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHandlers {
|
|
82
|
+
const {
|
|
83
|
+
editorRef,
|
|
84
|
+
readSegmentsFromDOM,
|
|
85
|
+
onChange,
|
|
86
|
+
renderSegmentsToDOM,
|
|
87
|
+
runTriggerDetection,
|
|
88
|
+
dismissTrigger,
|
|
89
|
+
triggers,
|
|
90
|
+
markdownEnabled,
|
|
91
|
+
normalizeBullets,
|
|
92
|
+
onPaste: onPasteCallback,
|
|
93
|
+
onUndo,
|
|
94
|
+
onRedo,
|
|
95
|
+
onChipAdd,
|
|
96
|
+
onImagePaste,
|
|
97
|
+
onRawPaste,
|
|
98
|
+
} = deps
|
|
99
|
+
|
|
100
|
+
const isComposing = useRef(false)
|
|
101
|
+
|
|
102
|
+
// -----------------------------------------------------------------------
|
|
103
|
+
// Undo/redo stack (MAX_UNDO_HISTORY entries; clears redo on new push).
|
|
104
|
+
// Invariants: stacks live in refs (not useState) so pushUndo /
|
|
105
|
+
// resetUndoHistory / handleKeyDownForUndoRedo keep stable identity.
|
|
106
|
+
// Destabilizing this would re-create handleInput / handleKeyDown /
|
|
107
|
+
// imperative handle on every render and silently regress IME + debounced
|
|
108
|
+
// undo in the parent hook.
|
|
109
|
+
// -----------------------------------------------------------------------
|
|
110
|
+
const undoState = useRef<UndoState>({ undoStack: [], redoStack: [] })
|
|
111
|
+
|
|
112
|
+
const pushUndo = useCallback((segments: Segment[]) => {
|
|
113
|
+
const state = undoState.current
|
|
114
|
+
state.undoStack.push(segments)
|
|
115
|
+
if (state.undoStack.length > MAX_UNDO_HISTORY) {
|
|
116
|
+
state.undoStack.shift()
|
|
117
|
+
}
|
|
118
|
+
// Clear redo stack on new change
|
|
119
|
+
state.redoStack = []
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
const resetUndoHistory = useCallback(() => {
|
|
123
|
+
undoState.current = { undoStack: [], redoStack: [] }
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
// -----------------------------------------------------------------------
|
|
127
|
+
// Paste: strip HTML, insert plain text only
|
|
128
|
+
// -----------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const handlePaste = useCallback(
|
|
131
|
+
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
132
|
+
// Let a consumer take over the paste entirely (e.g. divert large text or
|
|
133
|
+
// arbitrary files to an upload pipeline) by calling preventDefault().
|
|
134
|
+
onRawPaste?.(e)
|
|
135
|
+
if (e.defaultPrevented) return
|
|
136
|
+
e.preventDefault()
|
|
137
|
+
|
|
138
|
+
const editor = editorRef.current
|
|
139
|
+
if (!editor) return
|
|
140
|
+
|
|
141
|
+
// Check for image files in clipboard before processing text
|
|
142
|
+
// Some browsers/OSes provide pasted images via `items` instead of `files` (e.g. screenshots)
|
|
143
|
+
const imageFile =
|
|
144
|
+
Array.from(e.clipboardData.files).find((f) => f.type.startsWith('image/')) ??
|
|
145
|
+
(() => {
|
|
146
|
+
const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'))
|
|
147
|
+
return item?.getAsFile() ?? null
|
|
148
|
+
})()
|
|
149
|
+
if (imageFile) {
|
|
150
|
+
onImagePaste?.(imageFile)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Record undo snapshot
|
|
155
|
+
const currentSegments = readSegmentsFromDOM()
|
|
156
|
+
pushUndo(currentSegments)
|
|
157
|
+
|
|
158
|
+
// Check for internal segment data (copy/paste within the editor)
|
|
159
|
+
const segmentJson = e.clipboardData.getData('text/prompt-area-segments')
|
|
160
|
+
if (segmentJson) {
|
|
161
|
+
const parsed = parseSegmentsFromClipboard(segmentJson)
|
|
162
|
+
if (parsed && parsed.length > 0) {
|
|
163
|
+
// Insert the copied segments at cursor position
|
|
164
|
+
const range = getSelectionRange()
|
|
165
|
+
if (!range) return
|
|
166
|
+
|
|
167
|
+
range.deleteContents()
|
|
168
|
+
|
|
169
|
+
// Merge pasted segments into current segments at cursor position
|
|
170
|
+
const beforePaste = readSegmentsFromDOM()
|
|
171
|
+
const merged = insertSegmentsAtCursor(beforePaste, parsed, editor)
|
|
172
|
+
onChange(merged)
|
|
173
|
+
renderSegmentsToDOM(merged)
|
|
174
|
+
|
|
175
|
+
// Notify: internal paste with chip data preserved
|
|
176
|
+
onPasteCallback?.({ segments: merged, source: 'internal' })
|
|
177
|
+
for (const seg of parsed) {
|
|
178
|
+
if (seg.type === 'chip') {
|
|
179
|
+
onChipAdd?.(seg)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
runTriggerDetection()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// When markdown mode is on, prefer the richest clipboard flavor:
|
|
189
|
+
// 1. text/markdown — some apps (e.g. Slack) hand out markdown directly,
|
|
190
|
+
// preserving nested lists that their text/plain flattens.
|
|
191
|
+
// 2. text/html — convert web/Notion/Docs/GitHub HTML to markdown.
|
|
192
|
+
// Otherwise (markdown off, or neither present) fall back to plain text.
|
|
193
|
+
let text = ''
|
|
194
|
+
if (markdownEnabled) {
|
|
195
|
+
text = e.clipboardData.getData('text/markdown')
|
|
196
|
+
if (text) {
|
|
197
|
+
// Slack over-escapes inert punctuation (e.g. `\(` `\)`); unescape
|
|
198
|
+
// parentheses so the source reads cleanly. They carry no markdown
|
|
199
|
+
// meaning, unlike `\*` / `\.` / `\-` which are left intact.
|
|
200
|
+
text = text.replace(/\\([()])/g, '$1')
|
|
201
|
+
} else {
|
|
202
|
+
const html = e.clipboardData.getData('text/html')
|
|
203
|
+
// A converter failure (e.g. stack overflow on pathologically deep
|
|
204
|
+
// nesting) must not drop the paste — leave text empty so the
|
|
205
|
+
// text/plain fallback below still runs.
|
|
206
|
+
if (html) {
|
|
207
|
+
try {
|
|
208
|
+
text = htmlToMarkdown(html)
|
|
209
|
+
} catch {
|
|
210
|
+
text = ''
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!text) text = e.clipboardData.getData('text/plain')
|
|
216
|
+
if (!text) return
|
|
217
|
+
|
|
218
|
+
// Normalize pasted list markers ("- " → "•") so pasted bullets match
|
|
219
|
+
// typed input. Applies to both the HTML→markdown and plain-text paths.
|
|
220
|
+
if (markdownEnabled && normalizeBullets) {
|
|
221
|
+
text = normalizeListPrefixText(text, true)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Rebuild ordered-list numbering in the pasted block so a copied list with
|
|
225
|
+
// stale numbers lands sequential. Gated on `hasOrderedListRun` so incidental
|
|
226
|
+
// numeric-leading prose (e.g. `1985. Born / 2020. Died`) is left untouched.
|
|
227
|
+
// Done at the raw-string level (the caret collapses to end-of-content after
|
|
228
|
+
// insertion, so no offset remap needed).
|
|
229
|
+
if (markdownEnabled && hasOrderedListRun(text)) {
|
|
230
|
+
text = renumberOrderedListLines(text).text
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Insert plain text at cursor position using Selection API
|
|
234
|
+
const range = getSelectionRange()
|
|
235
|
+
if (!range) return
|
|
236
|
+
|
|
237
|
+
range.deleteContents()
|
|
238
|
+
|
|
239
|
+
// Handle multi-line paste: split into lines with BR elements
|
|
240
|
+
const lines = text.split('\n')
|
|
241
|
+
const fragment = document.createDocumentFragment()
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < lines.length; i++) {
|
|
244
|
+
if (lines[i]) {
|
|
245
|
+
fragment.appendChild(document.createTextNode(lines[i]))
|
|
246
|
+
}
|
|
247
|
+
if (i < lines.length - 1) {
|
|
248
|
+
fragment.appendChild(document.createElement('br'))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
range.insertNode(fragment)
|
|
253
|
+
|
|
254
|
+
// Move cursor to end of pasted content
|
|
255
|
+
range.collapse(false)
|
|
256
|
+
const sel = window.getSelection()
|
|
257
|
+
sel?.removeAllRanges()
|
|
258
|
+
sel?.addRange(range)
|
|
259
|
+
|
|
260
|
+
// Normalize DOM, sync model, detect triggers
|
|
261
|
+
normalizeEditorDOM(editor)
|
|
262
|
+
const newSegments = readSegmentsFromDOM()
|
|
263
|
+
|
|
264
|
+
// Auto-resolve trigger patterns in pasted text (e.g., #readme -> chip)
|
|
265
|
+
const resolvedSegments = resolveTriggersInSegments(newSegments, triggers)
|
|
266
|
+
|
|
267
|
+
if (resolvedSegments !== newSegments) {
|
|
268
|
+
onChange(resolvedSegments)
|
|
269
|
+
renderSegmentsToDOM(resolvedSegments)
|
|
270
|
+
|
|
271
|
+
// Notify about auto-resolved chips from pasted text
|
|
272
|
+
for (const seg of resolvedSegments) {
|
|
273
|
+
if (
|
|
274
|
+
seg.type === 'chip' &&
|
|
275
|
+
!newSegments.some(
|
|
276
|
+
(s) =>
|
|
277
|
+
s.type === 'chip' &&
|
|
278
|
+
s.trigger === seg.trigger &&
|
|
279
|
+
s.value === seg.value &&
|
|
280
|
+
s.displayText === seg.displayText,
|
|
281
|
+
)
|
|
282
|
+
) {
|
|
283
|
+
onChipAdd?.(seg)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
onChange(newSegments)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
onPasteCallback?.({ segments: resolvedSegments, source: 'external' })
|
|
291
|
+
runTriggerDetection()
|
|
292
|
+
},
|
|
293
|
+
[
|
|
294
|
+
editorRef,
|
|
295
|
+
readSegmentsFromDOM,
|
|
296
|
+
onChange,
|
|
297
|
+
pushUndo,
|
|
298
|
+
runTriggerDetection,
|
|
299
|
+
renderSegmentsToDOM,
|
|
300
|
+
triggers,
|
|
301
|
+
markdownEnabled,
|
|
302
|
+
normalizeBullets,
|
|
303
|
+
onPasteCallback,
|
|
304
|
+
onChipAdd,
|
|
305
|
+
onImagePaste,
|
|
306
|
+
onRawPaste,
|
|
307
|
+
],
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
// -----------------------------------------------------------------------
|
|
311
|
+
// Copy: serialize chips into plain text
|
|
312
|
+
// -----------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
const handleCopy = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
315
|
+
e.preventDefault()
|
|
316
|
+
|
|
317
|
+
const range = getSelectionRange()
|
|
318
|
+
if (!range) return
|
|
319
|
+
|
|
320
|
+
const fragment = range.cloneContents()
|
|
321
|
+
|
|
322
|
+
// Walk fragment and serialize, converting chips to their text representation
|
|
323
|
+
const plainText = serializeFragmentToPlainText(fragment)
|
|
324
|
+
e.clipboardData.setData('text/plain', plainText)
|
|
325
|
+
|
|
326
|
+
// Also serialize chip segments as JSON for internal paste
|
|
327
|
+
const fragmentSegments = serializeFragmentToSegments(fragment)
|
|
328
|
+
const hasChips = fragmentSegments.some((s) => s.type === 'chip')
|
|
329
|
+
if (hasChips) {
|
|
330
|
+
const json = safeJsonStringify(fragmentSegments)
|
|
331
|
+
if (json) {
|
|
332
|
+
e.clipboardData.setData('text/prompt-area-segments', json)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}, [])
|
|
336
|
+
|
|
337
|
+
// -----------------------------------------------------------------------
|
|
338
|
+
// Cut: copy + delete
|
|
339
|
+
// -----------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
const handleCut = useCallback(
|
|
342
|
+
(e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
343
|
+
// First, do the copy
|
|
344
|
+
handleCopy(e)
|
|
345
|
+
|
|
346
|
+
// Then delete the selection
|
|
347
|
+
const range = getSelectionRange()
|
|
348
|
+
if (!range) return
|
|
349
|
+
|
|
350
|
+
const currentSegments = readSegmentsFromDOM()
|
|
351
|
+
pushUndo(currentSegments)
|
|
352
|
+
|
|
353
|
+
range.deleteContents()
|
|
354
|
+
|
|
355
|
+
const editor = editorRef.current
|
|
356
|
+
if (editor) {
|
|
357
|
+
normalizeEditorDOM(editor)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const newSegments = readSegmentsFromDOM()
|
|
361
|
+
onChange(newSegments)
|
|
362
|
+
runTriggerDetection()
|
|
363
|
+
},
|
|
364
|
+
[handleCopy, editorRef, readSegmentsFromDOM, onChange, pushUndo, runTriggerDetection],
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
// -----------------------------------------------------------------------
|
|
368
|
+
// Drag & Drop: prevent to avoid unpredictable DOM mutations
|
|
369
|
+
// -----------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
372
|
+
e.preventDefault()
|
|
373
|
+
}, [])
|
|
374
|
+
|
|
375
|
+
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
376
|
+
e.preventDefault()
|
|
377
|
+
}, [])
|
|
378
|
+
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
// IME Composition: track state, defer trigger detection
|
|
381
|
+
// -----------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
const handleCompositionStart = useCallback(() => {
|
|
384
|
+
isComposing.current = true
|
|
385
|
+
}, [])
|
|
386
|
+
|
|
387
|
+
const handleCompositionEnd = useCallback(() => {
|
|
388
|
+
isComposing.current = false
|
|
389
|
+
// Run trigger detection after composition ends
|
|
390
|
+
runTriggerDetection()
|
|
391
|
+
}, [runTriggerDetection])
|
|
392
|
+
|
|
393
|
+
// -----------------------------------------------------------------------
|
|
394
|
+
// Blur: dismiss trigger dropdown with delay (so popover clicks work)
|
|
395
|
+
// -----------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
const handleBlur = useCallback(() => {
|
|
398
|
+
setTimeout(() => {
|
|
399
|
+
const editor = editorRef.current
|
|
400
|
+
if (!editor) return
|
|
401
|
+
|
|
402
|
+
// Only dismiss if focus didn't move to an element within the editor container
|
|
403
|
+
const activeEl = document.activeElement
|
|
404
|
+
if (activeEl && editor.parentElement?.contains(activeEl)) return
|
|
405
|
+
|
|
406
|
+
dismissTrigger()
|
|
407
|
+
}, BLUR_DELAY_MS)
|
|
408
|
+
}, [editorRef, dismissTrigger])
|
|
409
|
+
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
// Undo/Redo: intercept Ctrl+Z / Ctrl+Shift+Z
|
|
412
|
+
// -----------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
const handleKeyDownForUndoRedo = useCallback(
|
|
415
|
+
(e: React.KeyboardEvent<HTMLDivElement>): boolean => {
|
|
416
|
+
const isMeta = e.metaKey || e.ctrlKey
|
|
417
|
+
|
|
418
|
+
if (!isMeta || e.key !== 'z') return false
|
|
419
|
+
|
|
420
|
+
e.preventDefault()
|
|
421
|
+
const state = undoState.current
|
|
422
|
+
|
|
423
|
+
if (e.shiftKey) {
|
|
424
|
+
// Redo: Ctrl+Shift+Z
|
|
425
|
+
if (state.redoStack.length === 0) return true
|
|
426
|
+
|
|
427
|
+
const segments = state.redoStack.pop()
|
|
428
|
+
if (!segments) return true
|
|
429
|
+
|
|
430
|
+
const current = readSegmentsFromDOM()
|
|
431
|
+
state.undoStack.push(current)
|
|
432
|
+
|
|
433
|
+
onChange(segments)
|
|
434
|
+
renderSegmentsToDOM(segments)
|
|
435
|
+
onRedo?.(segments)
|
|
436
|
+
} else {
|
|
437
|
+
// Undo: Ctrl+Z
|
|
438
|
+
if (state.undoStack.length === 0) return true
|
|
439
|
+
|
|
440
|
+
const segments = state.undoStack.pop()
|
|
441
|
+
if (!segments) return true
|
|
442
|
+
|
|
443
|
+
const current = readSegmentsFromDOM()
|
|
444
|
+
state.redoStack.push(current)
|
|
445
|
+
|
|
446
|
+
onChange(segments)
|
|
447
|
+
renderSegmentsToDOM(segments)
|
|
448
|
+
onUndo?.(segments)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return true
|
|
452
|
+
},
|
|
453
|
+
[readSegmentsFromDOM, onChange, renderSegmentsToDOM, onUndo, onRedo],
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
handlePaste,
|
|
458
|
+
handleCopy,
|
|
459
|
+
handleCut,
|
|
460
|
+
handleDrop,
|
|
461
|
+
handleDragOver,
|
|
462
|
+
handleCompositionStart,
|
|
463
|
+
handleCompositionEnd,
|
|
464
|
+
handleBlur,
|
|
465
|
+
handleKeyDownForUndoRedo,
|
|
466
|
+
pushUndo,
|
|
467
|
+
resetUndoHistory,
|
|
468
|
+
isComposing,
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience hook that wires up all the boilerplate state for a PromptArea.
|
|
3
|
+
*
|
|
4
|
+
* Instead of manually managing `useState<Segment[]>`, `useRef<PromptAreaHandle>`,
|
|
5
|
+
* and computing derived values, call `usePromptAreaState()` once and spread
|
|
6
|
+
* `bind` into your `<PromptArea>`.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function ChatInput() {
|
|
11
|
+
* const { bind, plainText, isEmpty, chips, clear, focus } = usePromptAreaState()
|
|
12
|
+
*
|
|
13
|
+
* return (
|
|
14
|
+
* <PromptArea
|
|
15
|
+
* {...bind}
|
|
16
|
+
* onSubmit={() => {
|
|
17
|
+
* sendMessage(plainText)
|
|
18
|
+
* clear()
|
|
19
|
+
* }}
|
|
20
|
+
* />
|
|
21
|
+
* )
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use client'
|
|
27
|
+
|
|
28
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
29
|
+
import type { Segment, ChipSegment, PromptAreaHandle } from './types.ts'
|
|
30
|
+
import { segmentsToPlainText } from './prompt-area-engine.ts'
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Options
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export type UsePromptAreaStateOptions = {
|
|
37
|
+
/** Initial segment value. Defaults to `[]`. */
|
|
38
|
+
initialValue?: Segment[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Return type
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export type PromptAreaBind = {
|
|
46
|
+
/** Ref to attach to PromptArea — gives access to imperative methods. */
|
|
47
|
+
ref: React.RefObject<PromptAreaHandle | null>
|
|
48
|
+
/** Current segment array — pass as `value` prop. */
|
|
49
|
+
value: Segment[]
|
|
50
|
+
/** Setter — pass as `onChange` prop. */
|
|
51
|
+
onChange: (segments: Segment[]) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type PromptAreaState = {
|
|
55
|
+
/** Props to spread directly onto `<PromptArea {...bind} />`. Contains ref, value, and onChange. */
|
|
56
|
+
bind: PromptAreaBind
|
|
57
|
+
/** Derived plain text representation of the current value. */
|
|
58
|
+
plainText: string
|
|
59
|
+
/** `true` when the value is empty or whitespace-only. */
|
|
60
|
+
isEmpty: boolean
|
|
61
|
+
/** `true` when the value contains at least one chip. */
|
|
62
|
+
hasChips: boolean
|
|
63
|
+
/** All chip segments in the current value. */
|
|
64
|
+
chips: ChipSegment[]
|
|
65
|
+
/** Clear all content (both state and the editor DOM). */
|
|
66
|
+
clear: () => void
|
|
67
|
+
/** Focus the editor. */
|
|
68
|
+
focus: () => void
|
|
69
|
+
/** Blur the editor. */
|
|
70
|
+
blur: () => void
|
|
71
|
+
/** Insert a chip at the current cursor position. */
|
|
72
|
+
insertChip: (chip: Omit<ChipSegment, 'type'>) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Hook
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): PromptAreaState {
|
|
80
|
+
const { initialValue = [] } = options
|
|
81
|
+
|
|
82
|
+
const [value, setValue] = useState<Segment[]>(initialValue)
|
|
83
|
+
const ref = useRef<PromptAreaHandle>(null)
|
|
84
|
+
|
|
85
|
+
// Derived
|
|
86
|
+
const plainText = useMemo(() => segmentsToPlainText(value), [value])
|
|
87
|
+
|
|
88
|
+
const isEmpty = useMemo(() => {
|
|
89
|
+
if (value.length === 0) return true
|
|
90
|
+
return value.every((seg) => seg.type === 'text' && seg.text.trim() === '')
|
|
91
|
+
}, [value])
|
|
92
|
+
|
|
93
|
+
const hasChips = useMemo(() => value.some((seg) => seg.type === 'chip'), [value])
|
|
94
|
+
|
|
95
|
+
const chips = useMemo(
|
|
96
|
+
() => value.filter((seg): seg is ChipSegment => seg.type === 'chip'),
|
|
97
|
+
[value],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Bind object — safe to spread onto <PromptArea>
|
|
101
|
+
const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])
|
|
102
|
+
|
|
103
|
+
// Actions that proxy to the imperative handle
|
|
104
|
+
const clear = useCallback(() => {
|
|
105
|
+
if (ref.current) {
|
|
106
|
+
ref.current.clear()
|
|
107
|
+
} else {
|
|
108
|
+
setValue([])
|
|
109
|
+
}
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
const focus = useCallback(() => ref.current?.focus(), [])
|
|
113
|
+
const blur = useCallback(() => ref.current?.blur(), [])
|
|
114
|
+
|
|
115
|
+
const insertChip = useCallback(
|
|
116
|
+
(chip: Omit<ChipSegment, 'type'>) => ref.current?.insertChip(chip),
|
|
117
|
+
[],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
bind,
|
|
122
|
+
plainText,
|
|
123
|
+
isEmpty,
|
|
124
|
+
hasChips,
|
|
125
|
+
chips,
|
|
126
|
+
clear,
|
|
127
|
+
focus,
|
|
128
|
+
blur,
|
|
129
|
+
insertChip,
|
|
130
|
+
}
|
|
131
|
+
}
|