@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,499 @@
1
+ /**
2
+ * List auto-formatting logic for the PromptArea component.
3
+ * Pure — no DOM dependencies, fully testable in Node.
4
+ */
5
+ import type { Segment } from './types.ts'
6
+ import { replaceTextRange, segmentsToPlainText } from './prompt-area-engine.ts'
7
+
8
+ /**
9
+ * Information about a list line at a given cursor position.
10
+ */
11
+ export type ListContext = {
12
+ /** Offset in plain text where the line begins */
13
+ lineStart: number
14
+ /** The full prefix including indentation (e.g., " • ") */
15
+ prefix: string
16
+ /** Number of indentation levels (each = 2 spaces) */
17
+ indent: number
18
+ /** Type of list */
19
+ listType: 'bullet' | 'numbered'
20
+ /** For bullet lists, the marker char actually used (`•`, `-`, or `*`) */
21
+ marker?: string
22
+ /** For numbered lists, the number */
23
+ number?: number
24
+ /** Offset in plain text where content after the prefix starts */
25
+ contentStart: number
26
+ }
27
+
28
+ /**
29
+ * Parsed shape of a single list line — the SINGLE source of truth for what
30
+ * counts as a list line (both `getListContext` and the renumber engine derive
31
+ * from this, so the bullet/number regexes live in exactly one place).
32
+ *
33
+ * Offsets (`numberStart`/`numberEnd`) are relative to the START of the line.
34
+ */
35
+ type ParsedListLine =
36
+ | { kind: 'bullet'; indent: number; marker: string; prefixLen: number }
37
+ | {
38
+ kind: 'numbered'
39
+ indent: number
40
+ number: number
41
+ /** Offset of the first digit within the line. */
42
+ numberStart: number
43
+ /** Offset just past the last digit within the line (exclusive). */
44
+ numberEnd: number
45
+ prefixLen: number
46
+ }
47
+
48
+ /** Classifies a single line as a bullet/numbered list item, or null. */
49
+ function parseListLine(line: string): ParsedListLine | null {
50
+ const bulletMatch = line.match(/^(\s*)([•\-*]) /)
51
+ if (bulletMatch) {
52
+ return {
53
+ kind: 'bullet',
54
+ indent: Math.floor(bulletMatch[1].length / 2),
55
+ marker: bulletMatch[2],
56
+ prefixLen: bulletMatch[0].length,
57
+ }
58
+ }
59
+
60
+ const numberMatch = line.match(/^(\s*)(\d+)\. /)
61
+ if (numberMatch) {
62
+ const numberStart = numberMatch[1].length
63
+ return {
64
+ kind: 'numbered',
65
+ indent: Math.floor(numberMatch[1].length / 2),
66
+ number: parseInt(numberMatch[2], 10),
67
+ numberStart,
68
+ numberEnd: numberStart + numberMatch[2].length,
69
+ prefixLen: numberMatch[0].length,
70
+ }
71
+ }
72
+
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Detects if the cursor is in a list line and returns context about it.
78
+ *
79
+ * @param text - The full plain text content
80
+ * @param cursorPos - The cursor position (character offset from start)
81
+ * @returns List context if the cursor is in a list line, null otherwise
82
+ */
83
+ export function getListContext(text: string, cursorPos: number): ListContext | null {
84
+ const lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1
85
+ const lineEnd = text.indexOf('\n', cursorPos)
86
+ const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd)
87
+
88
+ const parsed = parseListLine(line)
89
+ if (!parsed) return null
90
+
91
+ return {
92
+ lineStart,
93
+ prefix: line.slice(0, parsed.prefixLen),
94
+ indent: parsed.indent,
95
+ listType: parsed.kind,
96
+ ...(parsed.kind === 'bullet' ? { marker: parsed.marker } : { number: parsed.number }),
97
+ contentStart: lineStart + parsed.prefixLen,
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Detects if the user just typed a list trigger pattern (e.g., "- " or "* ")
103
+ * and returns the segments with the replacement applied.
104
+ */
105
+ export function autoFormatListPrefix(
106
+ segments: Segment[],
107
+ cursorPos: number,
108
+ ): { segments: Segment[]; cursorOffset: number } | null {
109
+ const plainText = segmentsToPlainText(segments)
110
+ const lineStart = plainText.lastIndexOf('\n', cursorPos - 1) + 1
111
+ const lineText = plainText.slice(lineStart, cursorPos)
112
+
113
+ const match = lineText.match(/^(\s*)[-*] $/)
114
+ if (!match) return null
115
+
116
+ const indent = match[1]
117
+ const replacement = `${indent}• `
118
+ const rangeStart = lineStart
119
+ const rangeEnd = lineStart + lineText.length
120
+
121
+ const newSegments = replaceTextRange(segments, rangeStart, rangeEnd, replacement)
122
+ return {
123
+ segments: newSegments,
124
+ cursorOffset: lineStart + replacement.length,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Handles Enter key in a list line — continues the list or exits.
130
+ */
131
+ export function insertListContinuation(
132
+ segments: Segment[],
133
+ cursorPos: number,
134
+ ): { segments: Segment[]; cursorOffset: number } | null {
135
+ const plainText = segmentsToPlainText(segments)
136
+ const ctx = getListContext(plainText, cursorPos)
137
+ if (!ctx) return null
138
+
139
+ const lineEnd = plainText.indexOf('\n', cursorPos)
140
+ const lineContent = plainText.slice(ctx.contentStart, lineEnd === -1 ? plainText.length : lineEnd)
141
+
142
+ if (lineContent.trim() === '') {
143
+ // Enter on an empty item: outdent one level if nested (Notion/Docs style),
144
+ // otherwise remove the prefix and exit the list to plain text.
145
+ if (ctx.indent > 0) {
146
+ const newSegments = replaceTextRange(segments, ctx.lineStart, ctx.lineStart + 2, '')
147
+ return {
148
+ segments: newSegments,
149
+ cursorOffset: Math.max(ctx.lineStart, cursorPos - 2),
150
+ }
151
+ }
152
+ const newSegments = replaceTextRange(
153
+ segments,
154
+ ctx.lineStart,
155
+ ctx.lineStart + ctx.prefix.length,
156
+ '',
157
+ )
158
+ return {
159
+ segments: newSegments,
160
+ cursorOffset: ctx.lineStart,
161
+ }
162
+ }
163
+
164
+ const indent = ' '.repeat(ctx.indent)
165
+ let nextPrefix: string
166
+ if (ctx.listType === 'bullet') {
167
+ nextPrefix = `${indent}${ctx.marker ?? '•'} `
168
+ } else {
169
+ const nextNum = (ctx.number ?? 1) + 1
170
+ nextPrefix = `${indent}${nextNum}. `
171
+ }
172
+
173
+ const insertion = `\n${nextPrefix}`
174
+ const newSegments = replaceTextRange(segments, cursorPos, cursorPos, insertion)
175
+ return {
176
+ segments: newSegments,
177
+ cursorOffset: cursorPos + insertion.length,
178
+ }
179
+ }
180
+
181
+ /** Returns the indent level of the list line directly above `lineStart`, or null. */
182
+ function getPrevListLineLevel(text: string, lineStart: number): number | null {
183
+ if (lineStart === 0) return null
184
+ const prevLineStart = text.lastIndexOf('\n', lineStart - 2) + 1
185
+ const parsed = parseListLine(text.slice(prevLineStart, lineStart - 1))
186
+ return parsed ? parsed.indent : null
187
+ }
188
+
189
+ /**
190
+ * Indents a list item by one level (adds 2 spaces before the prefix), capped at
191
+ * one level deeper than the line above. An item can only nest under a preceding
192
+ * sibling, so the first item of a list — or an item already one level below its
193
+ * parent — cannot indent further (returns null). This keeps sub-items visually
194
+ * connected to a parent instead of drifting arbitrarily deep.
195
+ */
196
+ export function indentListItem(
197
+ segments: Segment[],
198
+ cursorPos: number,
199
+ ): { segments: Segment[]; cursorOffset: number } | null {
200
+ const plainText = segmentsToPlainText(segments)
201
+ const ctx = getListContext(plainText, cursorPos)
202
+ if (!ctx) return null
203
+
204
+ const prevLevel = getPrevListLineLevel(plainText, ctx.lineStart)
205
+ const maxLevel = prevLevel === null ? 0 : prevLevel + 1
206
+ if (ctx.indent >= maxLevel) return null
207
+
208
+ const newSegments = replaceTextRange(segments, ctx.lineStart, ctx.lineStart, ' ')
209
+ return {
210
+ segments: newSegments,
211
+ cursorOffset: cursorPos + 2,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Outdents a list item by one level (removes 2 spaces from before the prefix).
217
+ */
218
+ export function outdentListItem(
219
+ segments: Segment[],
220
+ cursorPos: number,
221
+ ): { segments: Segment[]; cursorOffset: number } | null {
222
+ const plainText = segmentsToPlainText(segments)
223
+ const ctx = getListContext(plainText, cursorPos)
224
+ if (!ctx || ctx.indent === 0) return null
225
+
226
+ const newSegments = replaceTextRange(segments, ctx.lineStart, ctx.lineStart + 2, '')
227
+ return {
228
+ segments: newSegments,
229
+ cursorOffset: Math.max(ctx.lineStart, cursorPos - 2),
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Removes the list prefix from the current line (e.g., on Backspace).
235
+ */
236
+ export function removeListPrefix(
237
+ segments: Segment[],
238
+ cursorPos: number,
239
+ ): { segments: Segment[]; cursorOffset: number } | null {
240
+ const plainText = segmentsToPlainText(segments)
241
+ const ctx = getListContext(plainText, cursorPos)
242
+ if (!ctx) return null
243
+
244
+ if (cursorPos > ctx.contentStart) return null
245
+
246
+ const newSegments = replaceTextRange(
247
+ segments,
248
+ ctx.lineStart,
249
+ ctx.contentStart,
250
+ ' '.repeat(ctx.indent),
251
+ )
252
+ return {
253
+ segments: newSegments,
254
+ cursorOffset: ctx.lineStart + ctx.indent * 2,
255
+ }
256
+ }
257
+
258
+ /** A line that opens or closes a fenced code block (```), optionally indented. */
259
+ const FENCE_LINE = /^\s*```/
260
+
261
+ /** Swaps the leading list marker on a single line ("- " ↔ "• "). */
262
+ function swapListPrefixLine(line: string, markdownEnabled: boolean): string {
263
+ return markdownEnabled ? line.replace(/^(\s*)- /, '$1• ') : line.replace(/^(\s*)• /, '$1- ')
264
+ }
265
+
266
+ /**
267
+ * Returns the set of line indices that sit inside a *balanced* fenced code
268
+ * block (a ```…``` pair), so their leading "- "/"• " markers are preserved
269
+ * verbatim. An unterminated (unpaired) fence marker is NOT protective — its
270
+ * following lines still normalize — so a stray "```" in prose does not silently
271
+ * suppress bullet normalization for the rest of the text.
272
+ */
273
+ function fenceProtectedLineIndices(lines: string[]): Set<number> {
274
+ const protectedLines = new Set<number>()
275
+ let openIndex = -1
276
+ lines.forEach((line, i) => {
277
+ if (!FENCE_LINE.test(line)) return
278
+ if (openIndex === -1) {
279
+ openIndex = i
280
+ } else {
281
+ for (let k = openIndex; k <= i; k++) protectedLines.add(k)
282
+ openIndex = -1
283
+ }
284
+ })
285
+ return protectedLines
286
+ }
287
+
288
+ /**
289
+ * Normalizes markdown list prefixes in a raw text string (single source of
290
+ * truth for the bullet-glyph swap, shared by segment normalization and paste):
291
+ * - When markdown is enabled, converts "- " at line starts to "• "
292
+ * - When markdown is disabled, converts "• " at line starts to "- "
293
+ *
294
+ * Lines inside a balanced ```…``` block are preserved verbatim.
295
+ */
296
+ export function normalizeListPrefixText(text: string, markdownEnabled: boolean): string {
297
+ const lines = text.split('\n')
298
+ const protectedLines = fenceProtectedLineIndices(lines)
299
+ return lines
300
+ .map((line, i) => (protectedLines.has(i) ? line : swapListPrefixLine(line, markdownEnabled)))
301
+ .join('\n')
302
+ }
303
+
304
+ /**
305
+ * Normalizes markdown list prefixes across text segments. See
306
+ * {@link normalizeListPrefixText} for the per-line rule. Fence detection spans
307
+ * the whole document (text segments flattened to a global line sequence), so a
308
+ * code block split into per-line text segments on paste still has its "- " lines
309
+ * preserved, while an unterminated fence does not suppress later bullets.
310
+ */
311
+ export function normalizeListPrefixes(segments: Segment[], markdownEnabled: boolean): Segment[] {
312
+ const globalLines: string[] = []
313
+ segments.forEach((seg) => {
314
+ if (seg.type === 'text') globalLines.push(...seg.text.split('\n'))
315
+ })
316
+ const protectedLines = fenceProtectedLineIndices(globalLines)
317
+
318
+ let globalIndex = 0
319
+ let changed = false
320
+ const result = segments.map((seg) => {
321
+ if (seg.type !== 'text') return seg
322
+ const newText = seg.text
323
+ .split('\n')
324
+ .map((line) => {
325
+ const out = protectedLines.has(globalIndex)
326
+ ? line
327
+ : swapListPrefixLine(line, markdownEnabled)
328
+ globalIndex++
329
+ return out
330
+ })
331
+ .join('\n')
332
+ if (newText === seg.text) return seg
333
+ changed = true
334
+ return { ...seg, text: newText }
335
+ })
336
+ return changed ? result : segments
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Ordered-list renumbering
341
+ //
342
+ // The visible number is a projection of position (like BlockNote/ProseMirror/
343
+ // Notion), recomputed on every structural edit rather than trusted as stored
344
+ // text. A per-indent-level counter STACK models nested lists: descending starts
345
+ // a fresh counter at 1, ascending continues the shallower level and clears
346
+ // deeper ones. Every run restarts at 1, so `1. 1. 1.` rebuilds to `1. 2. 3.`
347
+ // and Tab-indenting an item restarts its sublist at 1.
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /**
351
+ * A single rewritten number's digit run, in the INPUT text's coordinates.
352
+ * `[oldStart, oldEnd)` spans only the digits (never the indentation or `. `).
353
+ */
354
+ export type NumberEdit = { oldStart: number; oldEnd: number; newText: string }
355
+
356
+ /**
357
+ * Whether the text holds a genuine ordered-list run worth renumbering — a run
358
+ * of 2+ consecutive same-level numbered lines that either starts at 1 or is
359
+ * already a contiguous `n, n+1, …` sequence. Used to gate the paste path so a
360
+ * copied list fragment (`3. 4. 5.` → renumber, or a broken `1. 1. 1.`) is
361
+ * rebuilt, while incidental numeric-leading prose that `parseListLine` would
362
+ * otherwise treat as a list — `1985. Born / 2020. Died`, `5. / 10. / 15.` — is
363
+ * left untouched.
364
+ */
365
+ export function hasOrderedListRun(text: string): boolean {
366
+ let runLevel: number | null = null
367
+ let runStart = 0
368
+ let prevNumber = 0
369
+ let runLength = 0
370
+ let sequential = true
371
+
372
+ for (const line of text.split('\n')) {
373
+ const parsed = parseListLine(line)
374
+ if (parsed?.kind === 'numbered' && parsed.indent === runLevel) {
375
+ sequential = sequential && parsed.number === prevNumber + 1
376
+ prevNumber = parsed.number
377
+ runLength++
378
+ if (runLength >= 2 && (runStart === 1 || sequential)) return true
379
+ } else if (parsed?.kind === 'numbered') {
380
+ // Start a fresh run at this line's level.
381
+ runLevel = parsed.indent
382
+ runStart = parsed.number
383
+ prevNumber = parsed.number
384
+ runLength = 1
385
+ sequential = true
386
+ } else {
387
+ runLevel = null
388
+ runLength = 0
389
+ }
390
+ }
391
+
392
+ return false
393
+ }
394
+
395
+ /**
396
+ * Recomputes ordered-list numbering across the whole text. Returns the new text
397
+ * plus the list of changed digit runs (ascending by `oldStart`) for cursor
398
+ * remapping. When nothing changes, returns the SAME text reference and an empty
399
+ * `edits` array — the no-op guard that keeps this off the typing hot path.
400
+ */
401
+ export function renumberOrderedListLines(text: string): { text: string; edits: NumberEdit[] } {
402
+ // Cheap pre-gate: with no ordered-list line there is nothing to renumber, so
403
+ // skip the per-line scan and throwaway rebuild. This runs on every structural
404
+ // edit (Enter, Tab, bold/italic wrap) and each paste, most of which never
405
+ // touch a numbered list.
406
+ if (!/^[ \t]*\d+\. /m.test(text)) return { text, edits: [] }
407
+
408
+ const counters = new Map<number, number>()
409
+ const edits: NumberEdit[] = []
410
+ const lines = text.split('\n')
411
+ let out = ''
412
+ let lineStart = 0
413
+
414
+ const clearDeeperThan = (level: number, inclusive: boolean) => {
415
+ for (const key of counters.keys()) {
416
+ if (inclusive ? key >= level : key > level) counters.delete(key)
417
+ }
418
+ }
419
+
420
+ for (let i = 0; i < lines.length; i++) {
421
+ const line = lines[i]
422
+ const parsed = parseListLine(line)
423
+
424
+ if (!parsed) {
425
+ // A blank or plain (non-list) line breaks every open list run.
426
+ counters.clear()
427
+ out += line
428
+ } else if (parsed.kind === 'bullet') {
429
+ // A bullet interrupts numbered runs at its level and deeper; a shallower
430
+ // numbered list continues across it.
431
+ clearDeeperThan(parsed.indent, true)
432
+ out += line
433
+ } else {
434
+ const level = parsed.indent
435
+ clearDeeperThan(level, false)
436
+ const current = counters.get(level)
437
+ const n = current === undefined ? 1 : current + 1
438
+ counters.set(level, n)
439
+
440
+ const newDigits = String(n)
441
+ if (newDigits === String(parsed.number)) {
442
+ out += line
443
+ } else {
444
+ edits.push({
445
+ oldStart: lineStart + parsed.numberStart,
446
+ oldEnd: lineStart + parsed.numberEnd,
447
+ newText: newDigits,
448
+ })
449
+ out += line.slice(0, parsed.numberStart) + newDigits + line.slice(parsed.numberEnd)
450
+ }
451
+ }
452
+
453
+ if (i < lines.length - 1) out += '\n'
454
+ lineStart += line.length + 1 // + 1 for the consumed '\n'
455
+ }
456
+
457
+ return edits.length === 0 ? { text, edits } : { text: out, edits }
458
+ }
459
+
460
+ /**
461
+ * Remaps a caret/selection offset (in the renumber INPUT's coordinates) across
462
+ * the digit-run edits. A single scalar delta is wrong: many spans each change
463
+ * width, so the shift depends on how many changed spans lie strictly before the
464
+ * offset, with a clamp when the offset sits inside a resized number.
465
+ */
466
+ export function remapOffset(old: number, edits: NumberEdit[]): number {
467
+ let shift = 0
468
+ for (const e of edits) {
469
+ if (e.oldEnd <= old) {
470
+ shift += e.newText.length - (e.oldEnd - e.oldStart) // fully before
471
+ } else if (e.oldStart >= old) {
472
+ break // this and every later span is after the offset
473
+ } else {
474
+ return e.oldStart + shift + Math.min(old - e.oldStart, e.newText.length) // inside → clamp
475
+ }
476
+ }
477
+ return old + shift
478
+ }
479
+
480
+ /**
481
+ * Segment-level renumber used by the edit-commit path. Applies the digit-run
482
+ * edits to the segments (right-to-left so earlier offsets stay valid) and
483
+ * returns the changed spans for {@link remapOffset}. Digit runs are pure text at
484
+ * line starts, so chips are never touched.
485
+ */
486
+ export function renumberOrderedListSegments(segments: Segment[]): {
487
+ segments: Segment[]
488
+ edits: NumberEdit[]
489
+ } {
490
+ const { edits } = renumberOrderedListLines(segmentsToPlainText(segments))
491
+ if (edits.length === 0) return { segments, edits }
492
+
493
+ let result = segments
494
+ for (let i = edits.length - 1; i >= 0; i--) {
495
+ const e = edits[i]
496
+ result = replaceTextRange(result, e.oldStart, e.oldEnd, e.newText)
497
+ }
498
+ return { segments: result, edits }
499
+ }