@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,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe DOM helper functions for PromptArea.
|
|
3
|
+
*
|
|
4
|
+
* These replace all `as` type assertions with proper type guards,
|
|
5
|
+
* following the codebase rule: "Never use `any` or `as` assertions."
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChipSegment } from './types.ts'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Type Guards
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Type guard: checks if a DOM node is an HTMLElement.
|
|
16
|
+
*/
|
|
17
|
+
export function isHTMLElement(node: Node): node is HTMLElement {
|
|
18
|
+
return node instanceof HTMLElement
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Type guard: checks if a DOM node is a chip element
|
|
23
|
+
* (an HTMLElement with data-chip-trigger attribute).
|
|
24
|
+
*/
|
|
25
|
+
export function isChipElement(node: Node): node is HTMLElement {
|
|
26
|
+
return node instanceof HTMLElement && node.dataset.chipTrigger !== undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Type guard: checks if a DOM node is a BR element.
|
|
31
|
+
*/
|
|
32
|
+
export function isBRElement(node: Node): node is HTMLBRElement {
|
|
33
|
+
return node instanceof HTMLBRElement
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Type guard: checks if a DOM node is a Text node.
|
|
38
|
+
*/
|
|
39
|
+
export function isTextNode(node: Node): node is Text {
|
|
40
|
+
return node instanceof Text
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Checks whether a chip element was auto-resolved (created by pressing space
|
|
45
|
+
* on resolveOnSpace triggers, rather than explicit dropdown selection).
|
|
46
|
+
*/
|
|
47
|
+
export function getChipAutoResolved(node: Node): boolean {
|
|
48
|
+
return isChipElement(node) && node.dataset.chipAutoResolved === 'true'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Type guard: checks if a DOM node is a URL link element
|
|
53
|
+
* (an HTMLAnchorElement with data-url attribute).
|
|
54
|
+
*/
|
|
55
|
+
export function isLinkElement(node: Node): node is HTMLAnchorElement {
|
|
56
|
+
return node instanceof HTMLAnchorElement && node.dataset.url === 'true'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Safe JSON
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Safely parses a JSON string, returning `unknown` instead of `any`.
|
|
65
|
+
* Returns `undefined` if parsing fails.
|
|
66
|
+
*/
|
|
67
|
+
export function safeJsonParse(json: string): unknown {
|
|
68
|
+
try {
|
|
69
|
+
// JSON.parse returns `any` by default. We narrow it to `unknown`
|
|
70
|
+
// which is the safest pattern — callers must validate before use.
|
|
71
|
+
const parsed: unknown = JSON.parse(json)
|
|
72
|
+
return parsed
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Safely serializes a value to JSON, returning undefined on failure.
|
|
80
|
+
*/
|
|
81
|
+
export function safeJsonStringify(value: unknown): string | undefined {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.stringify(value)
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// DOM reading helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads the chip trigger character from a chip element's dataset.
|
|
95
|
+
* Returns undefined if the node is not a chip element.
|
|
96
|
+
*/
|
|
97
|
+
export function getChipTrigger(node: Node): string | undefined {
|
|
98
|
+
if (!isChipElement(node)) return undefined
|
|
99
|
+
return node.dataset.chipTrigger
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reads the chip value from a chip element's dataset.
|
|
104
|
+
*/
|
|
105
|
+
export function getChipValue(node: Node): string | undefined {
|
|
106
|
+
if (!isChipElement(node)) return undefined
|
|
107
|
+
return node.dataset.chipValue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reads the chip display text from a chip element's dataset.
|
|
112
|
+
*/
|
|
113
|
+
export function getChipDisplay(node: Node): string | undefined {
|
|
114
|
+
if (!isChipElement(node)) return undefined
|
|
115
|
+
return node.dataset.chipDisplay ?? node.textContent ?? undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reads and safely parses the chip data from a chip element's dataset.
|
|
120
|
+
*/
|
|
121
|
+
export function getChipData(node: Node): unknown {
|
|
122
|
+
if (!isChipElement(node)) return undefined
|
|
123
|
+
const raw = node.dataset.chipData
|
|
124
|
+
if (!raw) return undefined
|
|
125
|
+
return safeJsonParse(raw)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Length of a chip's plain-text representation (`trigger + displayText`).
|
|
130
|
+
*
|
|
131
|
+
* This is the single definition used wherever DOM offsets are mapped to the
|
|
132
|
+
* plain-text model (cursor mapping, selection sizing). The fallbacks mirror
|
|
133
|
+
* how chips are rendered: `chipDisplay` is always set, but we degrade to
|
|
134
|
+
* `textContent` for resilience against externally-mutated nodes.
|
|
135
|
+
*/
|
|
136
|
+
export function chipNodeTextLength(node: HTMLElement): number {
|
|
137
|
+
const trigger = node.dataset.chipTrigger ?? ''
|
|
138
|
+
const display = node.dataset.chipDisplay ?? node.textContent ?? ''
|
|
139
|
+
return trigger.length + display.length
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reads a chip element into a `ChipSegment`, mirroring how chips are written
|
|
144
|
+
* in `renderSegmentsToDOM`. Returns null when the node is not a chip or is
|
|
145
|
+
* missing a required attribute (trigger, value, or display text).
|
|
146
|
+
*
|
|
147
|
+
* This is the single chip reader shared by the DOM->model sync, chip-click
|
|
148
|
+
* delegation, and clipboard serialization, so they cannot diverge on which
|
|
149
|
+
* fields are required or how optional `data` / `autoResolved` are attached.
|
|
150
|
+
*/
|
|
151
|
+
export function chipNodeToSegment(node: Node): ChipSegment | null {
|
|
152
|
+
if (!isChipElement(node)) return null
|
|
153
|
+
|
|
154
|
+
const trigger = getChipTrigger(node)
|
|
155
|
+
const value = getChipValue(node)
|
|
156
|
+
const displayText = getChipDisplay(node)
|
|
157
|
+
if (!trigger || value === undefined || !displayText) return null
|
|
158
|
+
|
|
159
|
+
const data = getChipData(node)
|
|
160
|
+
const autoResolved = getChipAutoResolved(node)
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
type: 'chip',
|
|
164
|
+
trigger,
|
|
165
|
+
value,
|
|
166
|
+
displayText,
|
|
167
|
+
...(data !== undefined ? { data } : {}),
|
|
168
|
+
...(autoResolved ? { autoResolved: true } : {}),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// DOM manipulation helpers
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Finds the index of a direct child node within a parent element.
|
|
178
|
+
* Returns -1 if not found.
|
|
179
|
+
*/
|
|
180
|
+
export function indexOfChildNode(parent: HTMLElement, child: Node): number {
|
|
181
|
+
const children = parent.childNodes
|
|
182
|
+
for (let i = 0; i < children.length; i++) {
|
|
183
|
+
if (children[i] === child) return i
|
|
184
|
+
}
|
|
185
|
+
return -1
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether a direct editor child node produces a segment when read by
|
|
190
|
+
* `readSegmentsFromDOM` in use-prompt-area.ts. This is the single predicate
|
|
191
|
+
* shared with `domChildIndexToSegmentIndex` below, so a DOM child index can
|
|
192
|
+
* never map to a different segment index than the one the reader actually
|
|
193
|
+
* produces — decoration elements (the URL `<a>` from `decorateURLsInEditor`,
|
|
194
|
+
* the markdown `<span data-md>` from `decorateMarkdownInEditor`) fall through
|
|
195
|
+
* to the reader's "unknown element" branch and DO produce a text segment, so
|
|
196
|
+
* they must count here too, not just chips/text/`<br>`. A chip element only
|
|
197
|
+
* counts if `chipNodeToSegment` would actually accept it — the reader skips a
|
|
198
|
+
* chip missing a required attribute (trigger/value/display), so this must too.
|
|
199
|
+
*/
|
|
200
|
+
export function childProducesSegment(child: Node): boolean {
|
|
201
|
+
if (child.nodeType === Node.TEXT_NODE) return (child.textContent ?? '') !== ''
|
|
202
|
+
if (isBRElement(child)) return !child.dataset.sentinel
|
|
203
|
+
if (isChipElement(child)) return chipNodeToSegment(child) !== null
|
|
204
|
+
if (isHTMLElement(child)) return (child.textContent ?? '') !== ''
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Maps the index of a direct child node within the editor to the index of the
|
|
210
|
+
* corresponding segment in the model array, by counting `childProducesSegment`
|
|
211
|
+
* matches up to (but not including) `childIndex`.
|
|
212
|
+
*
|
|
213
|
+
* Keeping this in one place ensures chip-removal, chip-revert, and chip
|
|
214
|
+
* in-place replacement all agree on the exact same mapping rules as the reader.
|
|
215
|
+
*/
|
|
216
|
+
export function domChildIndexToSegmentIndex(editor: HTMLElement, childIndex: number): number {
|
|
217
|
+
let segIdx = 0
|
|
218
|
+
for (let i = 0; i < childIndex; i++) {
|
|
219
|
+
if (childProducesSegment(editor.childNodes[i])) segIdx++
|
|
220
|
+
}
|
|
221
|
+
return segIdx
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Gets the direct child of `ancestor` that contains `descendant`.
|
|
226
|
+
* Walks up from descendant until we find a node whose parent is ancestor.
|
|
227
|
+
* Returns null if descendant is not inside ancestor.
|
|
228
|
+
*/
|
|
229
|
+
export function getDirectChildContaining(ancestor: HTMLElement, descendant: Node): Node | null {
|
|
230
|
+
let node: Node | null = descendant
|
|
231
|
+
while (node !== null) {
|
|
232
|
+
if (node.parentNode === ancestor) return node
|
|
233
|
+
node = node.parentNode
|
|
234
|
+
}
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Unwraps a block element (div, p) by replacing it with its child nodes
|
|
240
|
+
* plus a trailing BR. Used for browser DOM normalization.
|
|
241
|
+
*/
|
|
242
|
+
export function unwrapBlockElement(parent: HTMLElement, block: HTMLElement): void {
|
|
243
|
+
const fragment = document.createDocumentFragment()
|
|
244
|
+
|
|
245
|
+
// Move all children to fragment
|
|
246
|
+
while (block.firstChild) {
|
|
247
|
+
fragment.appendChild(block.firstChild)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Add a BR after the unwrapped content
|
|
251
|
+
fragment.appendChild(document.createElement('br'))
|
|
252
|
+
|
|
253
|
+
parent.replaceChild(fragment, block)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Normalizes the editor DOM after browser mutations.
|
|
258
|
+
*
|
|
259
|
+
* Browsers insert various wrapper elements on Enter/paste:
|
|
260
|
+
* - Chrome wraps new lines in <div>
|
|
261
|
+
* - Safari may use <div><br></div>
|
|
262
|
+
* - Some use <p> tags
|
|
263
|
+
*
|
|
264
|
+
* This function unwraps all non-chip block elements, leaving only:
|
|
265
|
+
* - Text nodes
|
|
266
|
+
* - <br> elements
|
|
267
|
+
* - Chip <span> elements (with data-chip-trigger)
|
|
268
|
+
*/
|
|
269
|
+
export function normalizeEditorDOM(editor: HTMLElement): boolean {
|
|
270
|
+
let changed = false
|
|
271
|
+
const blockTags = new Set(['DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE'])
|
|
272
|
+
|
|
273
|
+
// Iterate backwards since we're modifying the DOM
|
|
274
|
+
for (let i = editor.childNodes.length - 1; i >= 0; i--) {
|
|
275
|
+
const child = editor.childNodes[i]
|
|
276
|
+
|
|
277
|
+
// Skip non-element nodes, chip elements, and BR elements
|
|
278
|
+
if (!(child instanceof HTMLElement)) continue
|
|
279
|
+
if (child.dataset.chipTrigger !== undefined) continue
|
|
280
|
+
if (child instanceof HTMLBRElement) continue
|
|
281
|
+
|
|
282
|
+
const tag = child.tagName
|
|
283
|
+
if (blockTags.has(tag)) {
|
|
284
|
+
unwrapBlockElement(editor, child)
|
|
285
|
+
changed = true
|
|
286
|
+
} else if (
|
|
287
|
+
tag === 'FONT' ||
|
|
288
|
+
tag === 'B' ||
|
|
289
|
+
tag === 'I' ||
|
|
290
|
+
tag === 'U' ||
|
|
291
|
+
tag === 'STRONG' ||
|
|
292
|
+
tag === 'EM' ||
|
|
293
|
+
tag === 'A' ||
|
|
294
|
+
tag === 'SPAN'
|
|
295
|
+
) {
|
|
296
|
+
// Unwrap inline formatting/decoration elements (browser-inserted or markdown decorations)
|
|
297
|
+
const text = child.textContent ?? ''
|
|
298
|
+
if (text) {
|
|
299
|
+
editor.replaceChild(document.createTextNode(text), child)
|
|
300
|
+
} else {
|
|
301
|
+
editor.removeChild(child)
|
|
302
|
+
}
|
|
303
|
+
changed = true
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Merge adjacent text nodes
|
|
308
|
+
editor.normalize()
|
|
309
|
+
|
|
310
|
+
return changed
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// URL decoration
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/** URL pattern for detecting URLs in text content */
|
|
318
|
+
const URL_PATTERN = /https?:\/\/[^\s),]+/g
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Walks direct-child text nodes in the editor and wraps URL text in
|
|
322
|
+
* `<a>` elements for visual styling and clickability.
|
|
323
|
+
*
|
|
324
|
+
* This is a DOM-only decoration — it does NOT modify the segment model.
|
|
325
|
+
* The `<a>` elements are stripped by `normalizeEditorDOM` on every input cycle,
|
|
326
|
+
* so they are re-applied fresh each time.
|
|
327
|
+
*
|
|
328
|
+
* @param editor - The contentEditable root element
|
|
329
|
+
* @returns Whether any decorations were applied
|
|
330
|
+
*/
|
|
331
|
+
export function decorateURLsInEditor(editor: HTMLElement): boolean {
|
|
332
|
+
let decorated = false
|
|
333
|
+
|
|
334
|
+
// Collect text nodes first (avoid modifying while iterating)
|
|
335
|
+
const textNodes: Text[] = []
|
|
336
|
+
for (let i = 0; i < editor.childNodes.length; i++) {
|
|
337
|
+
const node = editor.childNodes[i]
|
|
338
|
+
if (isTextNode(node) && node.textContent) {
|
|
339
|
+
textNodes.push(node)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const textNode of textNodes) {
|
|
344
|
+
const text = textNode.textContent ?? ''
|
|
345
|
+
URL_PATTERN.lastIndex = 0
|
|
346
|
+
const matches: Array<{ url: string; index: number }> = []
|
|
347
|
+
let match: RegExpExecArray | null
|
|
348
|
+
|
|
349
|
+
while ((match = URL_PATTERN.exec(text)) !== null) {
|
|
350
|
+
// Trim trailing punctuation that's likely not part of the URL
|
|
351
|
+
let url = match[0]
|
|
352
|
+
while (url.length > 0 && /[.;:!?]$/.test(url)) {
|
|
353
|
+
url = url.slice(0, -1)
|
|
354
|
+
}
|
|
355
|
+
if (url.length > 0) {
|
|
356
|
+
matches.push({ url, index: match.index })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (matches.length === 0) continue
|
|
361
|
+
|
|
362
|
+
const parent = textNode.parentNode
|
|
363
|
+
if (!parent) continue
|
|
364
|
+
|
|
365
|
+
// Validate URLs upfront – only keep those with safe protocols (CWE-79)
|
|
366
|
+
const safeMatches: Array<{ url: string; href: string; index: number }> = []
|
|
367
|
+
for (const { url, index } of matches) {
|
|
368
|
+
try {
|
|
369
|
+
const parsed = new URL(url)
|
|
370
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
371
|
+
safeMatches.push({ url, href: parsed.href, index })
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
// skip malformed URLs
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (safeMatches.length === 0) continue
|
|
379
|
+
|
|
380
|
+
decorated = true
|
|
381
|
+
const fragment = document.createDocumentFragment()
|
|
382
|
+
let lastIndex = 0
|
|
383
|
+
|
|
384
|
+
for (const { url, href, index } of safeMatches) {
|
|
385
|
+
// Text before this URL
|
|
386
|
+
if (index > lastIndex) {
|
|
387
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Create the link element
|
|
391
|
+
const anchor = document.createElement('a')
|
|
392
|
+
anchor.href = href
|
|
393
|
+
anchor.target = '_blank'
|
|
394
|
+
anchor.rel = 'noopener noreferrer'
|
|
395
|
+
anchor.dataset.url = 'true'
|
|
396
|
+
anchor.className = 'text-accent hover:text-accent-hover underline cursor-pointer'
|
|
397
|
+
anchor.textContent = url
|
|
398
|
+
fragment.appendChild(anchor)
|
|
399
|
+
|
|
400
|
+
lastIndex = index + url.length
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Text after the last URL
|
|
404
|
+
if (lastIndex < text.length) {
|
|
405
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
parent.replaceChild(fragment, textNode)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return decorated
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Markdown inline decoration
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
/** Pattern to find ***bold-italic***, **bold**, and *italic* markdown spans */
|
|
419
|
+
const MARKDOWN_INLINE_PATTERN = /(\*{3})(.+?)\*{3}|(\*{2})(.+?)\*{2}|(\*)(.+?)\*/g
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Walks direct-child text nodes in the editor and wraps markdown-formatted
|
|
423
|
+
* text (`**bold**`, `*italic*`, `***bold-italic***`) in styled `<span>` elements.
|
|
424
|
+
*
|
|
425
|
+
* This is a DOM-only decoration — it does NOT modify the segment model.
|
|
426
|
+
* The `<span>` elements are stripped by `normalizeEditorDOM` on every input cycle,
|
|
427
|
+
* so they are re-applied fresh each time.
|
|
428
|
+
*
|
|
429
|
+
* The `*` markers stay visible in the text; only the CSS styling changes.
|
|
430
|
+
*
|
|
431
|
+
* @param editor - The contentEditable root element
|
|
432
|
+
* @returns Whether any decorations were applied
|
|
433
|
+
*/
|
|
434
|
+
export function decorateMarkdownInEditor(editor: HTMLElement): boolean {
|
|
435
|
+
let decorated = false
|
|
436
|
+
|
|
437
|
+
// Collect text nodes first (avoid modifying while iterating)
|
|
438
|
+
const textNodes: Text[] = []
|
|
439
|
+
for (let i = 0; i < editor.childNodes.length; i++) {
|
|
440
|
+
const node = editor.childNodes[i]
|
|
441
|
+
if (isTextNode(node) && node.textContent) {
|
|
442
|
+
textNodes.push(node)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const textNode of textNodes) {
|
|
447
|
+
const text = textNode.textContent ?? ''
|
|
448
|
+
MARKDOWN_INLINE_PATTERN.lastIndex = 0
|
|
449
|
+
const matches: Array<{
|
|
450
|
+
fullMatch: string
|
|
451
|
+
marker: string
|
|
452
|
+
content: string
|
|
453
|
+
index: number
|
|
454
|
+
className: string
|
|
455
|
+
}> = []
|
|
456
|
+
let match: RegExpExecArray | null
|
|
457
|
+
|
|
458
|
+
while ((match = MARKDOWN_INLINE_PATTERN.exec(text)) !== null) {
|
|
459
|
+
if (match[1] && match[2]) {
|
|
460
|
+
// ***bold-italic***
|
|
461
|
+
matches.push({
|
|
462
|
+
fullMatch: match[0],
|
|
463
|
+
marker: match[1],
|
|
464
|
+
content: match[2],
|
|
465
|
+
index: match.index,
|
|
466
|
+
className: 'font-bold italic',
|
|
467
|
+
})
|
|
468
|
+
} else if (match[3] && match[4]) {
|
|
469
|
+
// **bold**
|
|
470
|
+
matches.push({
|
|
471
|
+
fullMatch: match[0],
|
|
472
|
+
marker: match[3],
|
|
473
|
+
content: match[4],
|
|
474
|
+
index: match.index,
|
|
475
|
+
className: 'font-bold',
|
|
476
|
+
})
|
|
477
|
+
} else if (match[5] && match[6]) {
|
|
478
|
+
// *italic*
|
|
479
|
+
matches.push({
|
|
480
|
+
fullMatch: match[0],
|
|
481
|
+
marker: match[5],
|
|
482
|
+
content: match[6],
|
|
483
|
+
index: match.index,
|
|
484
|
+
className: 'italic',
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (matches.length === 0) continue
|
|
490
|
+
|
|
491
|
+
decorated = true
|
|
492
|
+
const parent = textNode.parentNode
|
|
493
|
+
if (!parent) continue
|
|
494
|
+
|
|
495
|
+
const fragment = document.createDocumentFragment()
|
|
496
|
+
let lastIndex = 0
|
|
497
|
+
|
|
498
|
+
for (const { fullMatch, marker, content, index, className } of matches) {
|
|
499
|
+
// Text before this match
|
|
500
|
+
if (index > lastIndex) {
|
|
501
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Parent span — textContent still returns full match (e.g. "**world**")
|
|
505
|
+
const span = document.createElement('span')
|
|
506
|
+
span.dataset.md = 'true'
|
|
507
|
+
|
|
508
|
+
// Opening marker (visually hidden)
|
|
509
|
+
const openMarker = document.createElement('span')
|
|
510
|
+
openMarker.className = 'prompt-area-md-marker'
|
|
511
|
+
openMarker.textContent = marker
|
|
512
|
+
|
|
513
|
+
// Styled content
|
|
514
|
+
const styledContent = document.createElement('span')
|
|
515
|
+
styledContent.className = className
|
|
516
|
+
styledContent.textContent = content
|
|
517
|
+
|
|
518
|
+
// Closing marker (visually hidden)
|
|
519
|
+
const closeMarker = document.createElement('span')
|
|
520
|
+
closeMarker.className = 'prompt-area-md-marker'
|
|
521
|
+
closeMarker.textContent = marker
|
|
522
|
+
|
|
523
|
+
span.appendChild(openMarker)
|
|
524
|
+
span.appendChild(styledContent)
|
|
525
|
+
span.appendChild(closeMarker)
|
|
526
|
+
fragment.appendChild(span)
|
|
527
|
+
|
|
528
|
+
lastIndex = index + fullMatch.length
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Text after the last match
|
|
532
|
+
if (lastIndex < text.length) {
|
|
533
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
parent.replaceChild(fragment, textNode)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return decorated
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Matches a `•` bullet glyph at the start of a line (optionally indented). */
|
|
543
|
+
const LIST_BULLET_PATTERN = /(^|\n)([ \t]*)•/g
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Walks direct-child text nodes and wraps each line-leading `•` bullet glyph in
|
|
547
|
+
* a `<span class="prompt-area-list-bullet">` so CSS can size it up (the raw
|
|
548
|
+
* U+2022 glyph renders much smaller than the surrounding text).
|
|
549
|
+
*
|
|
550
|
+
* Like {@link decorateMarkdownInEditor}, this is a DOM-only decoration: the span
|
|
551
|
+
* is stripped by {@link normalizeEditorDOM} on every input cycle, so the `•`
|
|
552
|
+
* stays a plain character in the segment model and is re-decorated each render.
|
|
553
|
+
*
|
|
554
|
+
* @param editor - The contentEditable root element
|
|
555
|
+
* @returns Whether any decorations were applied
|
|
556
|
+
*/
|
|
557
|
+
export function decorateBulletsInEditor(editor: HTMLElement): boolean {
|
|
558
|
+
let decorated = false
|
|
559
|
+
|
|
560
|
+
const textNodes: Text[] = []
|
|
561
|
+
for (let i = 0; i < editor.childNodes.length; i++) {
|
|
562
|
+
const node = editor.childNodes[i]
|
|
563
|
+
if (isTextNode(node) && node.textContent?.includes('•')) {
|
|
564
|
+
textNodes.push(node)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for (const textNode of textNodes) {
|
|
569
|
+
const text = textNode.textContent ?? ''
|
|
570
|
+
LIST_BULLET_PATTERN.lastIndex = 0
|
|
571
|
+
const bulletIndices: number[] = []
|
|
572
|
+
let match: RegExpExecArray | null
|
|
573
|
+
while ((match = LIST_BULLET_PATTERN.exec(text)) !== null) {
|
|
574
|
+
bulletIndices.push(match.index + match[1].length + match[2].length)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (bulletIndices.length === 0) continue
|
|
578
|
+
|
|
579
|
+
decorated = true
|
|
580
|
+
const parent = textNode.parentNode
|
|
581
|
+
if (!parent) continue
|
|
582
|
+
|
|
583
|
+
const fragment = document.createDocumentFragment()
|
|
584
|
+
let lastIndex = 0
|
|
585
|
+
|
|
586
|
+
for (const index of bulletIndices) {
|
|
587
|
+
if (index > lastIndex) {
|
|
588
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
|
|
589
|
+
}
|
|
590
|
+
const span = document.createElement('span')
|
|
591
|
+
span.dataset.md = 'true'
|
|
592
|
+
span.className = 'prompt-area-list-bullet'
|
|
593
|
+
span.textContent = '•'
|
|
594
|
+
fragment.appendChild(span)
|
|
595
|
+
lastIndex = index + 1 // the bullet is a single character
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (lastIndex < text.length) {
|
|
599
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
parent.replaceChild(fragment, textNode)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return decorated
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Matches the line-leading whitespace run of an indented list line (bullet or
|
|
610
|
+
* numbered). The lookahead keeps the list prefix itself out of the capture, so
|
|
611
|
+
* only the indentation is wrapped.
|
|
612
|
+
*/
|
|
613
|
+
const LIST_INDENT_PATTERN = /(^|\n)([ \t]+)(?=(?:[•\-*] |\d+\. ))/g
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Wraps each list line's leading indentation in an inline-block
|
|
617
|
+
* `<span class="prompt-area-list-indent">` sized per nesting level, so nested
|
|
618
|
+
* items read with a wide, Notion-like indent instead of the raw 2-space gap.
|
|
619
|
+
*
|
|
620
|
+
* Like the other decorations this is display-only: the span keeps the original
|
|
621
|
+
* whitespace as its textContent (so plain-text length and caret offsets are
|
|
622
|
+
* unchanged) and is stripped by {@link normalizeEditorDOM} each input cycle.
|
|
623
|
+
* Must run BEFORE the node-splitting passes ({@link decorateURLsInEditor},
|
|
624
|
+
* {@link decorateMarkdownInEditor}, {@link decorateBulletsInEditor}) so every
|
|
625
|
+
* direct-child text node is still a whole line — otherwise a mid-line split
|
|
626
|
+
* fragment beginning with whitespace would let the `^` anchor false-match
|
|
627
|
+
* non-line-leading whitespace.
|
|
628
|
+
*
|
|
629
|
+
* @returns Whether any decorations were applied
|
|
630
|
+
*/
|
|
631
|
+
export function decorateListIndentInEditor(editor: HTMLElement): boolean {
|
|
632
|
+
let decorated = false
|
|
633
|
+
|
|
634
|
+
const textNodes: Text[] = []
|
|
635
|
+
for (let i = 0; i < editor.childNodes.length; i++) {
|
|
636
|
+
const node = editor.childNodes[i]
|
|
637
|
+
if (isTextNode(node)) textNodes.push(node)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const textNode of textNodes) {
|
|
641
|
+
const text = textNode.textContent ?? ''
|
|
642
|
+
LIST_INDENT_PATTERN.lastIndex = 0
|
|
643
|
+
const runs: { start: number; end: number }[] = []
|
|
644
|
+
let match: RegExpExecArray | null
|
|
645
|
+
while ((match = LIST_INDENT_PATTERN.exec(text)) !== null) {
|
|
646
|
+
const start = match.index + match[1].length
|
|
647
|
+
runs.push({ start, end: start + match[2].length })
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (runs.length === 0) continue
|
|
651
|
+
|
|
652
|
+
decorated = true
|
|
653
|
+
const parent = textNode.parentNode
|
|
654
|
+
if (!parent) continue
|
|
655
|
+
|
|
656
|
+
const fragment = document.createDocumentFragment()
|
|
657
|
+
let lastIndex = 0
|
|
658
|
+
|
|
659
|
+
for (const { start, end } of runs) {
|
|
660
|
+
if (start > lastIndex) {
|
|
661
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)))
|
|
662
|
+
}
|
|
663
|
+
const whitespace = text.slice(start, end)
|
|
664
|
+
const level = Math.floor(whitespace.length / 2)
|
|
665
|
+
const span = document.createElement('span')
|
|
666
|
+
span.dataset.md = 'true'
|
|
667
|
+
span.className = 'prompt-area-list-indent'
|
|
668
|
+
span.style.width = `calc(var(--prompt-area-indent-size, 1.5em) * ${level})`
|
|
669
|
+
span.textContent = whitespace
|
|
670
|
+
fragment.appendChild(span)
|
|
671
|
+
lastIndex = end
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (lastIndex < text.length) {
|
|
675
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
parent.replaceChild(fragment, textNode)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return decorated
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Applies every display-only decoration to the editor in one pass: URL links
|
|
686
|
+
* always, plus markdown emphasis, list indentation, and list bullets when
|
|
687
|
+
* markdown mode is on. Each decoration is stripped by {@link normalizeEditorDOM}
|
|
688
|
+
* on the next input cycle and re-applied here, so the segment model is never
|
|
689
|
+
* mutated.
|
|
690
|
+
*
|
|
691
|
+
* List indentation runs FIRST, while each direct-child text node is still a
|
|
692
|
+
* whole line: the URL and markdown passes split text nodes mid-line, and a tail
|
|
693
|
+
* fragment starting with whitespace would let the indent regex's `^` anchor
|
|
694
|
+
* false-match non-line-leading whitespace (e.g. `see http://x 1. y`).
|
|
695
|
+
*/
|
|
696
|
+
export function decorateEditor(editor: HTMLElement, markdownEnabled: boolean): void {
|
|
697
|
+
// Whole-line passes run FIRST, while each direct-child text node is still a
|
|
698
|
+
// full line. The URL and markdown passes split text nodes mid-line, so a tail
|
|
699
|
+
// fragment beginning with "•" would let the bullet regex's `^` anchor
|
|
700
|
+
// false-match a mid-line separator (e.g. `**bold** • middle`).
|
|
701
|
+
if (markdownEnabled) {
|
|
702
|
+
decorateListIndentInEditor(editor)
|
|
703
|
+
decorateBulletsInEditor(editor)
|
|
704
|
+
}
|
|
705
|
+
decorateURLsInEditor(editor)
|
|
706
|
+
if (markdownEnabled) decorateMarkdownInEditor(editor)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// Selection helpers
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Returns the first Range from the current window selection, or null if
|
|
715
|
+
* there is no selection or it has no ranges.
|
|
716
|
+
*/
|
|
717
|
+
export function getSelectionRange(): Range | null {
|
|
718
|
+
const sel = window.getSelection()
|
|
719
|
+
if (!sel || sel.rangeCount === 0) return null
|
|
720
|
+
return sel.getRangeAt(0)
|
|
721
|
+
}
|