@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,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
|
+
}
|