@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,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic engine for the PromptArea component.
|
|
3
|
+
* No DOM dependencies - fully testable in Node.
|
|
4
|
+
*/
|
|
5
|
+
import type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigger } from './types.ts'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Serialization
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts an array of segments to a plain text string.
|
|
13
|
+
* Chips are represented as `{trigger}{displayText}` (e.g., "@Alice").
|
|
14
|
+
*/
|
|
15
|
+
export function segmentsToPlainText(segments: Segment[]): string {
|
|
16
|
+
return segments
|
|
17
|
+
.map((seg) => {
|
|
18
|
+
if (seg.type === 'text') return seg.text
|
|
19
|
+
return `${seg.trigger}${seg.displayText}`
|
|
20
|
+
})
|
|
21
|
+
.join('')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Converts plain text into a single text segment.
|
|
26
|
+
* Used for initial value conversion from plain strings.
|
|
27
|
+
*/
|
|
28
|
+
export function plainTextToSegments(text: string): Segment[] {
|
|
29
|
+
if (!text) return []
|
|
30
|
+
return [{ type: 'text', text }]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Truncates segments so their combined plain-text length is at most `maxLength`.
|
|
35
|
+
* Whole segments are kept while they fit; a text segment that crosses the limit
|
|
36
|
+
* is sliced to fit, and a chip that would cross the limit is dropped (a chip
|
|
37
|
+
* can't be partially represented).
|
|
38
|
+
*/
|
|
39
|
+
export function truncateSegmentsToLength(segments: Segment[], maxLength: number): Segment[] {
|
|
40
|
+
if (maxLength <= 0) return []
|
|
41
|
+
const result: Segment[] = []
|
|
42
|
+
let length = 0
|
|
43
|
+
for (const seg of segments) {
|
|
44
|
+
const segLength =
|
|
45
|
+
seg.type === 'text' ? seg.text.length : seg.trigger.length + seg.displayText.length
|
|
46
|
+
if (length + segLength <= maxLength) {
|
|
47
|
+
result.push(seg)
|
|
48
|
+
length += segLength
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (seg.type === 'text') {
|
|
52
|
+
let remaining = maxLength - length
|
|
53
|
+
if (remaining > 0) {
|
|
54
|
+
// Don't split a surrogate pair: if the cut lands right after a high
|
|
55
|
+
// surrogate, drop the incomplete code point instead of a lone surrogate.
|
|
56
|
+
const code = seg.text.charCodeAt(remaining - 1)
|
|
57
|
+
if (code >= 0xd800 && code <= 0xdbff) remaining -= 1
|
|
58
|
+
if (remaining > 0) result.push({ type: 'text', text: seg.text.slice(0, remaining) })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Whitespace / word boundaries
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Single source of truth for what counts as an inline-whitespace word boundary
|
|
72
|
+
* in the editor model: a space, newline, or tab.
|
|
73
|
+
*
|
|
74
|
+
* Trigger detection, paste auto-resolution, and position validation all rely
|
|
75
|
+
* on the *same* notion of a boundary — keeping it here prevents the three
|
|
76
|
+
* call sites from silently drifting apart (e.g. one handling tabs and the
|
|
77
|
+
* others not).
|
|
78
|
+
*/
|
|
79
|
+
export function isInlineWhitespace(char: string | undefined): boolean {
|
|
80
|
+
return char === ' ' || char === '\n' || char === '\t'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Builds a lookup from trigger character to its config. When two triggers
|
|
85
|
+
* share a character the first one wins, preserving the previous `Array.find`
|
|
86
|
+
* semantics while turning the per-character scan into an O(1) map read.
|
|
87
|
+
*/
|
|
88
|
+
function buildTriggerCharMap(triggers: TriggerConfig[]): Map<string, TriggerConfig> {
|
|
89
|
+
const map = new Map<string, TriggerConfig>()
|
|
90
|
+
for (const trigger of triggers) {
|
|
91
|
+
if (!map.has(trigger.char)) map.set(trigger.char, trigger)
|
|
92
|
+
}
|
|
93
|
+
return map
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Trigger position validation
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Checks whether a trigger character at the given position in text
|
|
102
|
+
* is valid according to the position rule.
|
|
103
|
+
*
|
|
104
|
+
* @param text - The full text content
|
|
105
|
+
* @param charIndex - The index of the trigger character in the text
|
|
106
|
+
* @param position - The position rule to validate against
|
|
107
|
+
*/
|
|
108
|
+
export function isValidTriggerPosition(
|
|
109
|
+
text: string,
|
|
110
|
+
charIndex: number,
|
|
111
|
+
position: TriggerPosition,
|
|
112
|
+
): boolean {
|
|
113
|
+
if (charIndex === 0) return true
|
|
114
|
+
|
|
115
|
+
const prevChar = text[charIndex - 1]
|
|
116
|
+
|
|
117
|
+
if (position === 'start') {
|
|
118
|
+
return prevChar === '\n'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// position === 'any': valid after any whitespace
|
|
122
|
+
return isInlineWhitespace(prevChar)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Trigger detection
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scans backwards from the cursor position to detect if the user is
|
|
131
|
+
* currently typing a trigger word.
|
|
132
|
+
*
|
|
133
|
+
* Returns the active trigger info, or null if no trigger is active.
|
|
134
|
+
*
|
|
135
|
+
* @param text - The full plain text content
|
|
136
|
+
* @param cursorPos - The cursor position (character offset from start)
|
|
137
|
+
* @param triggers - Available trigger configurations
|
|
138
|
+
*/
|
|
139
|
+
export function detectActiveTrigger(
|
|
140
|
+
text: string,
|
|
141
|
+
cursorPos: number,
|
|
142
|
+
triggers: TriggerConfig[],
|
|
143
|
+
): ActiveTrigger | null {
|
|
144
|
+
if (!text || cursorPos === 0 || triggers.length === 0) return null
|
|
145
|
+
|
|
146
|
+
const triggerByChar = buildTriggerCharMap(triggers)
|
|
147
|
+
|
|
148
|
+
// Scan backwards from cursor to find the nearest trigger character.
|
|
149
|
+
// Stop at whitespace (trigger word has ended) or start of text.
|
|
150
|
+
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
151
|
+
const char = text[i]
|
|
152
|
+
|
|
153
|
+
// If we hit whitespace before finding a trigger, check if this whitespace
|
|
154
|
+
// is immediately followed by a trigger character
|
|
155
|
+
if (isInlineWhitespace(char)) {
|
|
156
|
+
// The character after this whitespace could be a trigger
|
|
157
|
+
if (i + 1 < cursorPos) {
|
|
158
|
+
const nextChar = text[i + 1]
|
|
159
|
+
const matchingTrigger = triggerByChar.get(nextChar)
|
|
160
|
+
if (matchingTrigger && isValidTriggerPosition(text, i + 1, matchingTrigger.position)) {
|
|
161
|
+
return {
|
|
162
|
+
config: matchingTrigger,
|
|
163
|
+
startOffset: i + 1,
|
|
164
|
+
query: text.slice(i + 2, cursorPos),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// No trigger found after this whitespace, stop searching
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check if this character is a trigger character
|
|
173
|
+
const matchingTrigger = triggerByChar.get(char)
|
|
174
|
+
if (matchingTrigger && isValidTriggerPosition(text, i, matchingTrigger.position)) {
|
|
175
|
+
return {
|
|
176
|
+
config: matchingTrigger,
|
|
177
|
+
startOffset: i,
|
|
178
|
+
query: text.slice(i + 1, cursorPos),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Chip resolution
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolves an active trigger into a chip within the segments array.
|
|
192
|
+
* Replaces the trigger text (trigger char + query) with a chip segment.
|
|
193
|
+
*
|
|
194
|
+
* @param segments - Current document segments
|
|
195
|
+
* @param activeTrigger - The active trigger to resolve
|
|
196
|
+
* @param chip - The chip data (value, displayText, optional data)
|
|
197
|
+
* @returns New segments array with the chip inserted, and the new cursor position
|
|
198
|
+
*/
|
|
199
|
+
export function resolveChip(
|
|
200
|
+
segments: Segment[],
|
|
201
|
+
activeTrigger: ActiveTrigger,
|
|
202
|
+
chip: { value: string; displayText: string; data?: unknown; autoResolved?: boolean },
|
|
203
|
+
): { segments: Segment[]; cursorOffset: number } {
|
|
204
|
+
const triggerStart = activeTrigger.startOffset
|
|
205
|
+
const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
|
|
206
|
+
|
|
207
|
+
// Build the new segments by mapping plain text positions back to segment boundaries
|
|
208
|
+
const newSegments: Segment[] = []
|
|
209
|
+
let offset = 0
|
|
210
|
+
|
|
211
|
+
for (const seg of segments) {
|
|
212
|
+
if (seg.type === 'chip') {
|
|
213
|
+
const chipText = `${seg.trigger}${seg.displayText}`
|
|
214
|
+
const chipStart = offset
|
|
215
|
+
const chipEnd = offset + chipText.length
|
|
216
|
+
|
|
217
|
+
// If the trigger range overlaps with this chip, something is wrong.
|
|
218
|
+
// Chips should not be partially replaced.
|
|
219
|
+
if (chipEnd <= triggerStart || chipStart >= triggerEnd) {
|
|
220
|
+
newSegments.push(seg)
|
|
221
|
+
}
|
|
222
|
+
offset = chipEnd
|
|
223
|
+
} else {
|
|
224
|
+
const textStart = offset
|
|
225
|
+
const textEnd = offset + seg.text.length
|
|
226
|
+
|
|
227
|
+
if (textEnd <= triggerStart) {
|
|
228
|
+
// Entirely before the trigger - keep as-is
|
|
229
|
+
newSegments.push(seg)
|
|
230
|
+
} else if (textStart >= triggerEnd) {
|
|
231
|
+
// Entirely after the trigger - keep as-is
|
|
232
|
+
newSegments.push(seg)
|
|
233
|
+
} else {
|
|
234
|
+
// This text segment contains (part of) the trigger range
|
|
235
|
+
const beforeText = seg.text.slice(0, Math.max(0, triggerStart - textStart))
|
|
236
|
+
const afterText = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
|
|
237
|
+
|
|
238
|
+
if (beforeText) {
|
|
239
|
+
newSegments.push({ type: 'text', text: beforeText })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const newChip: ChipSegment = {
|
|
243
|
+
type: 'chip',
|
|
244
|
+
trigger: activeTrigger.config.char,
|
|
245
|
+
value: chip.value,
|
|
246
|
+
displayText: chip.displayText,
|
|
247
|
+
...(chip.data !== undefined ? { data: chip.data } : {}),
|
|
248
|
+
...(chip.autoResolved ? { autoResolved: true } : {}),
|
|
249
|
+
}
|
|
250
|
+
newSegments.push(newChip)
|
|
251
|
+
|
|
252
|
+
// Add trailing space after chip, then any remaining text
|
|
253
|
+
if (afterText) {
|
|
254
|
+
newSegments.push({ type: 'text', text: ' ' + afterText.replace(/^\s/, '') })
|
|
255
|
+
} else {
|
|
256
|
+
newSegments.push({ type: 'text', text: ' ' })
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
offset = textEnd
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Merge adjacent text segments
|
|
265
|
+
const merged = mergeAdjacentTextSegments(newSegments)
|
|
266
|
+
|
|
267
|
+
// Cursor should be placed after the chip + trailing space.
|
|
268
|
+
// Find the *last* matching chip so duplicates resolve correctly.
|
|
269
|
+
let lastChipEndOffset = -1
|
|
270
|
+
let runningOffset = 0
|
|
271
|
+
for (const seg of merged) {
|
|
272
|
+
if (seg.type === 'text') {
|
|
273
|
+
runningOffset += seg.text.length
|
|
274
|
+
} else {
|
|
275
|
+
runningOffset += seg.trigger.length + seg.displayText.length
|
|
276
|
+
if (
|
|
277
|
+
seg.value === chip.value &&
|
|
278
|
+
seg.displayText === chip.displayText &&
|
|
279
|
+
seg.trigger === activeTrigger.config.char
|
|
280
|
+
) {
|
|
281
|
+
lastChipEndOffset = runningOffset
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// +1 accounts for the trailing space after the chip
|
|
286
|
+
const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + 1
|
|
287
|
+
|
|
288
|
+
return { segments: merged, cursorOffset }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Chip removal
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Removes a chip at the given segment index and merges adjacent text segments.
|
|
297
|
+
*
|
|
298
|
+
* @param segments - Current document segments
|
|
299
|
+
* @param index - The segment index to remove
|
|
300
|
+
* @returns New segments array with the chip removed
|
|
301
|
+
*/
|
|
302
|
+
export function removeChipAtIndex(segments: Segment[], index: number): Segment[] {
|
|
303
|
+
if (index < 0 || index >= segments.length) return segments
|
|
304
|
+
if (segments[index].type !== 'chip') return segments
|
|
305
|
+
|
|
306
|
+
const result = [...segments.slice(0, index), ...segments.slice(index + 1)]
|
|
307
|
+
return mergeAdjacentTextSegments(result)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Reverts an auto-resolved chip at the given segment index back to plain text.
|
|
312
|
+
* The text includes the trigger character + display text (e.g., "#readme").
|
|
313
|
+
*
|
|
314
|
+
* @param segments - Current document segments
|
|
315
|
+
* @param index - The segment index to revert
|
|
316
|
+
* @returns New segments with the chip replaced by text, or null if not applicable
|
|
317
|
+
*/
|
|
318
|
+
export function revertChipAtIndex(
|
|
319
|
+
segments: Segment[],
|
|
320
|
+
index: number,
|
|
321
|
+
): { segments: Segment[]; revertedText: string } | null {
|
|
322
|
+
if (index < 0 || index >= segments.length) return null
|
|
323
|
+
const seg = segments[index]
|
|
324
|
+
if (seg.type !== 'chip' || !seg.autoResolved) return null
|
|
325
|
+
|
|
326
|
+
const revertedText = `${seg.trigger}${seg.displayText}`
|
|
327
|
+
const result = [
|
|
328
|
+
...segments.slice(0, index),
|
|
329
|
+
{ type: 'text' as const, text: revertedText },
|
|
330
|
+
...segments.slice(index + 1),
|
|
331
|
+
]
|
|
332
|
+
return { segments: mergeAdjacentTextSegments(result), revertedText }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Paste: resolve trigger patterns in segments
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Scans text segments for trigger patterns and auto-resolves them into chips.
|
|
341
|
+
* Only resolves triggers that have `resolveOnSpace: true`.
|
|
342
|
+
*
|
|
343
|
+
* Trigger patterns must appear at word boundaries: start of text, after
|
|
344
|
+
* whitespace, or after a newline. This avoids false positives like email
|
|
345
|
+
* addresses (user@example.com).
|
|
346
|
+
*/
|
|
347
|
+
export function resolveTriggersInSegments(
|
|
348
|
+
segments: Segment[],
|
|
349
|
+
triggers: TriggerConfig[],
|
|
350
|
+
): Segment[] {
|
|
351
|
+
const autoResolveTriggers = triggers.filter((t) => t.resolveOnSpace)
|
|
352
|
+
if (autoResolveTriggers.length === 0) return segments
|
|
353
|
+
|
|
354
|
+
const triggerByChar = buildTriggerCharMap(autoResolveTriggers)
|
|
355
|
+
const result: Segment[] = []
|
|
356
|
+
|
|
357
|
+
for (const seg of segments) {
|
|
358
|
+
if (seg.type === 'chip') {
|
|
359
|
+
result.push(seg)
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const parts = splitTextByTriggerPatterns(seg.text, triggerByChar)
|
|
364
|
+
result.push(...parts)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return mergeAdjacentTextSegments(result)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Splits a text string into text and chip segments based on trigger patterns.
|
|
372
|
+
* A trigger pattern is: (start-of-string | whitespace) + trigger_char + word_chars
|
|
373
|
+
* followed by whitespace or end-of-string.
|
|
374
|
+
*/
|
|
375
|
+
function splitTextByTriggerPatterns(
|
|
376
|
+
text: string,
|
|
377
|
+
triggerByChar: Map<string, TriggerConfig>,
|
|
378
|
+
): Segment[] {
|
|
379
|
+
if (!text) return []
|
|
380
|
+
|
|
381
|
+
const segments: Segment[] = []
|
|
382
|
+
let i = 0
|
|
383
|
+
|
|
384
|
+
while (i < text.length) {
|
|
385
|
+
const char = text[i]
|
|
386
|
+
|
|
387
|
+
if (triggerByChar.has(char)) {
|
|
388
|
+
const isAtBoundary = i === 0 || isInlineWhitespace(text[i - 1])
|
|
389
|
+
|
|
390
|
+
if (isAtBoundary) {
|
|
391
|
+
const trigger = triggerByChar.get(char)
|
|
392
|
+
if (trigger && isValidTriggerPosition(text, i, trigger.position)) {
|
|
393
|
+
let end = i + 1
|
|
394
|
+
while (end < text.length && !isInlineWhitespace(text[end])) {
|
|
395
|
+
end++
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const query = text.slice(i + 1, end)
|
|
399
|
+
if (query.length > 0) {
|
|
400
|
+
// Treat both undefined and '' from onSelect as "no custom label"
|
|
401
|
+
// and fall back to the query — an empty displayText would render
|
|
402
|
+
// a blank chip.
|
|
403
|
+
const displayText = trigger.onSelect?.({ value: query, label: query }) || query
|
|
404
|
+
segments.push({
|
|
405
|
+
type: 'chip',
|
|
406
|
+
trigger: char,
|
|
407
|
+
value: query,
|
|
408
|
+
displayText,
|
|
409
|
+
autoResolved: true,
|
|
410
|
+
})
|
|
411
|
+
i = end
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const start = i
|
|
419
|
+
i++
|
|
420
|
+
while (i < text.length && !(triggerByChar.has(text[i]) && isInlineWhitespace(text[i - 1]))) {
|
|
421
|
+
i++
|
|
422
|
+
}
|
|
423
|
+
segments.push({ type: 'text', text: text.slice(start, i) })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return segments
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Text range replacement
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Replaces a range of plain text within the segments array.
|
|
435
|
+
* Handles segment boundaries correctly, preserving chip segments.
|
|
436
|
+
*
|
|
437
|
+
* @param segments - Current document segments
|
|
438
|
+
* @param start - Start offset in plain text
|
|
439
|
+
* @param end - End offset in plain text
|
|
440
|
+
* @param replacement - The replacement text
|
|
441
|
+
* @returns New segments array with the replacement applied
|
|
442
|
+
*/
|
|
443
|
+
export function replaceTextRange(
|
|
444
|
+
segments: Segment[],
|
|
445
|
+
start: number,
|
|
446
|
+
end: number,
|
|
447
|
+
replacement: string,
|
|
448
|
+
): Segment[] {
|
|
449
|
+
const newSegments: Segment[] = []
|
|
450
|
+
let offset = 0
|
|
451
|
+
let inserted = false
|
|
452
|
+
|
|
453
|
+
for (const seg of segments) {
|
|
454
|
+
if (seg.type === 'chip') {
|
|
455
|
+
const chipText = `${seg.trigger}${seg.displayText}`
|
|
456
|
+
const chipStart = offset
|
|
457
|
+
const chipEnd = offset + chipText.length
|
|
458
|
+
|
|
459
|
+
// For insertion (start === end), insert before this chip if position matches
|
|
460
|
+
if (!inserted && start === end && chipStart === start) {
|
|
461
|
+
newSegments.push({ type: 'text', text: replacement })
|
|
462
|
+
inserted = true
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (chipEnd <= start || chipStart >= end) {
|
|
466
|
+
newSegments.push(seg)
|
|
467
|
+
}
|
|
468
|
+
// Chips within the range are removed
|
|
469
|
+
offset = chipEnd
|
|
470
|
+
} else {
|
|
471
|
+
const textStart = offset
|
|
472
|
+
const textEnd = offset + seg.text.length
|
|
473
|
+
|
|
474
|
+
// Check if this segment contains the insertion/replacement point
|
|
475
|
+
const isBefore = start === end ? textEnd < start : textEnd <= start
|
|
476
|
+
const isAfter = start === end ? textStart > end : textStart >= end
|
|
477
|
+
|
|
478
|
+
if (isBefore) {
|
|
479
|
+
// Entirely before the range
|
|
480
|
+
newSegments.push(seg)
|
|
481
|
+
} else if (isAfter) {
|
|
482
|
+
// Entirely after the range
|
|
483
|
+
newSegments.push(seg)
|
|
484
|
+
} else {
|
|
485
|
+
// Overlaps with the range (or contains the insertion point)
|
|
486
|
+
const beforeText = seg.text.slice(0, Math.max(0, start - textStart))
|
|
487
|
+
const afterText = seg.text.slice(Math.min(seg.text.length, end - textStart))
|
|
488
|
+
|
|
489
|
+
if (beforeText) {
|
|
490
|
+
newSegments.push({ type: 'text', text: beforeText })
|
|
491
|
+
}
|
|
492
|
+
// Insert replacement only once (when we first enter the range)
|
|
493
|
+
if (!inserted && textStart <= start) {
|
|
494
|
+
newSegments.push({ type: 'text', text: replacement })
|
|
495
|
+
inserted = true
|
|
496
|
+
}
|
|
497
|
+
if (afterText) {
|
|
498
|
+
newSegments.push({ type: 'text', text: afterText })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
offset = textEnd
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Fallback: if replacement wasn't inserted (e.g., insertion at very end)
|
|
507
|
+
if (!inserted && replacement) {
|
|
508
|
+
newSegments.push({ type: 'text', text: replacement })
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return mergeAdjacentTextSegments(newSegments)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Markdown formatting shortcuts
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Toggles markdown wrap markers around a selected text range.
|
|
520
|
+
* If the selection is already wrapped with the marker, unwraps it.
|
|
521
|
+
* If not wrapped, wraps it.
|
|
522
|
+
*
|
|
523
|
+
* @param segments - Current document segments
|
|
524
|
+
* @param selectionStart - Start offset in plain text
|
|
525
|
+
* @param selectionEnd - End offset in plain text
|
|
526
|
+
* @param marker - The markdown marker (e.g., '**' for bold, '*' for italic)
|
|
527
|
+
* @returns New segments and selection offsets, or null if selection is collapsed
|
|
528
|
+
*/
|
|
529
|
+
export function toggleMarkdownWrap(
|
|
530
|
+
segments: Segment[],
|
|
531
|
+
selectionStart: number,
|
|
532
|
+
selectionEnd: number,
|
|
533
|
+
marker: string,
|
|
534
|
+
): { segments: Segment[]; selectionStart: number; selectionEnd: number } | null {
|
|
535
|
+
if (selectionStart === selectionEnd) return null
|
|
536
|
+
|
|
537
|
+
const plainText = segmentsToPlainText(segments)
|
|
538
|
+
const markerLen = marker.length
|
|
539
|
+
|
|
540
|
+
// Check if already wrapped
|
|
541
|
+
const hasOpeningMarker =
|
|
542
|
+
selectionStart >= markerLen &&
|
|
543
|
+
plainText.slice(selectionStart - markerLen, selectionStart) === marker
|
|
544
|
+
const hasClosingMarker =
|
|
545
|
+
selectionEnd + markerLen <= plainText.length &&
|
|
546
|
+
plainText.slice(selectionEnd, selectionEnd + markerLen) === marker
|
|
547
|
+
|
|
548
|
+
let isWrapped = hasOpeningMarker && hasClosingMarker
|
|
549
|
+
|
|
550
|
+
// For single-char markers (e.g., '*'), ensure we're not matching
|
|
551
|
+
// inside a multi-char marker (e.g., '**')
|
|
552
|
+
if (isWrapped && markerLen === 1) {
|
|
553
|
+
const charBeforeOpening =
|
|
554
|
+
selectionStart > markerLen ? plainText[selectionStart - markerLen - 1] : ''
|
|
555
|
+
const charAfterClosing =
|
|
556
|
+
selectionEnd + markerLen < plainText.length ? plainText[selectionEnd + markerLen] : ''
|
|
557
|
+
if (charBeforeOpening === marker || charAfterClosing === marker) {
|
|
558
|
+
isWrapped = false
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (isWrapped) {
|
|
563
|
+
// Unwrap: remove closing marker first (preserves start offsets), then opening
|
|
564
|
+
const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd + markerLen, '')
|
|
565
|
+
const afterOpening = replaceTextRange(
|
|
566
|
+
afterClosing,
|
|
567
|
+
selectionStart - markerLen,
|
|
568
|
+
selectionStart,
|
|
569
|
+
'',
|
|
570
|
+
)
|
|
571
|
+
return {
|
|
572
|
+
segments: afterOpening,
|
|
573
|
+
selectionStart: selectionStart - markerLen,
|
|
574
|
+
selectionEnd: selectionEnd - markerLen,
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Wrap: insert closing marker first (preserves start offsets), then opening
|
|
579
|
+
const afterClosing = replaceTextRange(segments, selectionEnd, selectionEnd, marker)
|
|
580
|
+
const afterOpening = replaceTextRange(afterClosing, selectionStart, selectionStart, marker)
|
|
581
|
+
return {
|
|
582
|
+
segments: afterOpening,
|
|
583
|
+
selectionStart: selectionStart + markerLen,
|
|
584
|
+
selectionEnd: selectionEnd + markerLen,
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// Inline markdown parsing
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
export type MarkdownToken =
|
|
593
|
+
| { type: 'plain'; text: string }
|
|
594
|
+
| { type: 'bold'; text: string }
|
|
595
|
+
| { type: 'italic'; text: string }
|
|
596
|
+
| { type: 'bold-italic'; text: string }
|
|
597
|
+
| { type: 'url'; text: string }
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Parses text for simple inline markdown: bold, italic, bold-italic, and URLs.
|
|
601
|
+
* Does NOT handle block-level markdown (lists, headings, etc.).
|
|
602
|
+
*/
|
|
603
|
+
export function parseInlineMarkdown(text: string): MarkdownToken[] {
|
|
604
|
+
if (!text) return []
|
|
605
|
+
|
|
606
|
+
const tokens: MarkdownToken[] = []
|
|
607
|
+
// Regex patterns for inline markdown elements:
|
|
608
|
+
// 1. ***text*** or ___text___ -> bold-italic
|
|
609
|
+
// 2. **text** or __text__ -> bold
|
|
610
|
+
// 3. *text* or _text_ -> italic
|
|
611
|
+
// 4. https://... or http://... -> URL
|
|
612
|
+
const pattern = /(\*{3}(.+?)\*{3})|(\*{2}(.+?)\*{2})|(\*(.+?)\*)|(https?:\/\/[^\s),]+)/g
|
|
613
|
+
|
|
614
|
+
let lastIndex = 0
|
|
615
|
+
let match: RegExpExecArray | null
|
|
616
|
+
|
|
617
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
618
|
+
// Add any plain text before this match
|
|
619
|
+
if (match.index > lastIndex) {
|
|
620
|
+
tokens.push({ type: 'plain', text: text.slice(lastIndex, match.index) })
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (match[1] && match[2]) {
|
|
624
|
+
// ***bold-italic***
|
|
625
|
+
tokens.push({ type: 'bold-italic', text: match[2] })
|
|
626
|
+
} else if (match[3] && match[4]) {
|
|
627
|
+
// **bold**
|
|
628
|
+
tokens.push({ type: 'bold', text: match[4] })
|
|
629
|
+
} else if (match[5] && match[6]) {
|
|
630
|
+
// *italic*
|
|
631
|
+
tokens.push({ type: 'italic', text: match[6] })
|
|
632
|
+
} else if (match[7]) {
|
|
633
|
+
// URL
|
|
634
|
+
tokens.push({ type: 'url', text: match[7] })
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
lastIndex = match.index + match[0].length
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Add any remaining plain text
|
|
641
|
+
if (lastIndex < text.length) {
|
|
642
|
+
tokens.push({ type: 'plain', text: text.slice(lastIndex) })
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return tokens
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// Segment comparison
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Shallow equality check for two segment arrays.
|
|
654
|
+
* Compares type, text, trigger, value, displayText, and autoResolved fields.
|
|
655
|
+
* Avoids JSON.stringify overhead for the common case.
|
|
656
|
+
*/
|
|
657
|
+
export function segmentsEqual(a: Segment[], b: Segment[]): boolean {
|
|
658
|
+
if (a === b) return true
|
|
659
|
+
if (a.length !== b.length) return false
|
|
660
|
+
|
|
661
|
+
for (let i = 0; i < a.length; i++) {
|
|
662
|
+
const sa = a[i]
|
|
663
|
+
const sb = b[i]
|
|
664
|
+
if (sa.type !== sb.type) return false
|
|
665
|
+
if (sa.type === 'text') {
|
|
666
|
+
if (sb.type !== 'text' || sa.text !== sb.text) return false
|
|
667
|
+
} else {
|
|
668
|
+
if (
|
|
669
|
+
sb.type !== 'chip' ||
|
|
670
|
+
sa.trigger !== sb.trigger ||
|
|
671
|
+
sa.value !== sb.value ||
|
|
672
|
+
sa.displayText !== sb.displayText ||
|
|
673
|
+
sa.autoResolved !== sb.autoResolved
|
|
674
|
+
)
|
|
675
|
+
return false
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return true
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Helpers
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Merges adjacent text segments into single text segments.
|
|
687
|
+
* Also removes empty text segments.
|
|
688
|
+
*/
|
|
689
|
+
export function mergeAdjacentTextSegments(segments: Segment[]): Segment[] {
|
|
690
|
+
const result: Segment[] = []
|
|
691
|
+
|
|
692
|
+
for (const seg of segments) {
|
|
693
|
+
if (seg.type === 'text' && seg.text === '') continue
|
|
694
|
+
|
|
695
|
+
const last = result[result.length - 1]
|
|
696
|
+
if (seg.type === 'text' && last?.type === 'text') {
|
|
697
|
+
// Merge with previous text segment
|
|
698
|
+
result[result.length - 1] = { type: 'text', text: last.text + seg.text }
|
|
699
|
+
} else {
|
|
700
|
+
result.push(seg)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return result
|
|
705
|
+
}
|