@chatpanel/events 0.2.0 → 0.3.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/index.js +2 -0
- package/markdown-authoring.js +288 -0
- package/package.json +7 -3
- package/text-search.js +155 -0
package/index.js
CHANGED
|
@@ -42,3 +42,5 @@ export { explainMcpError, packageFromArgs } from './mcp-errors.js';
|
|
|
42
42
|
export { createManifest, ManifestError, SOURCES } from './manifest.js';
|
|
43
43
|
export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
|
|
44
44
|
export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
|
|
45
|
+
export { compileQuery, findMatches, matchIndexFor, expandReplacement, replaceMatch, replaceAll, replaceAllInRange, MAX_MATCHES } from './text-search.js';
|
|
46
|
+
export { outlineOf, parseListItem, continueList, indentSelection, toggleWrap, toggleLinePrefix, toggleTask, toggleLink, docStats, selectionStats } from './markdown-authoring.js';
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// The editing gestures that make writing markdown feel like writing, not like typing syntax.
|
|
2
|
+
//
|
|
3
|
+
// Outline, list continuation, format toggles and document stats are all the same shape of
|
|
4
|
+
// problem: given the document text and where the caret is, what should the text and the
|
|
5
|
+
// caret become. None of them needs a DOM — which is precisely why they do not belong in a
|
|
6
|
+
// `<textarea>` keydown handler, where Notes would have had to write them twice (once for
|
|
7
|
+
// the classic surface, once for CodeMirror) and a mobile client a third time.
|
|
8
|
+
//
|
|
9
|
+
// Every function here is pure and returns an EDIT ({ text, selStart, selEnd }) rather than
|
|
10
|
+
// mutating a surface, so the caller only has to apply a range and set a selection.
|
|
11
|
+
|
|
12
|
+
/** ``` or ~~~ opening/closing a fenced block. Headings inside a fence are code, not structure. */
|
|
13
|
+
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The heading structure of a markdown document.
|
|
17
|
+
*
|
|
18
|
+
* Fenced code is skipped: a `# comment` inside a shell example is not a section, and an
|
|
19
|
+
* outline that jumps into code is worse than no outline. Setext headings (`===` / `---`
|
|
20
|
+
* underlines) are recognised too, because notes pasted from other tools use them.
|
|
21
|
+
*/
|
|
22
|
+
export function outlineOf(markdown) {
|
|
23
|
+
const doc = String(markdown ?? '');
|
|
24
|
+
const lines = doc.split('\n');
|
|
25
|
+
const out = [];
|
|
26
|
+
let offset = 0;
|
|
27
|
+
let fence = '';
|
|
28
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
const fenceHit = line.match(FENCE_RE);
|
|
31
|
+
if (fenceHit) {
|
|
32
|
+
if (!fence) fence = fenceHit[1][0];
|
|
33
|
+
else if (fenceHit[1][0] === fence) fence = '';
|
|
34
|
+
offset += line.length + 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!fence) {
|
|
38
|
+
const atx = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
39
|
+
const next = lines[i + 1];
|
|
40
|
+
const setext = !atx && line.trim() && next && next.match(/^\s{0,3}(=+|-+)\s*$/);
|
|
41
|
+
if (atx) {
|
|
42
|
+
out.push({ level: atx[1].length, text: atx[2].trim(), line: i, start: offset, end: offset + line.length });
|
|
43
|
+
} else if (setext) {
|
|
44
|
+
out.push({ level: setext[1][0] === '=' ? 1 : 2, text: line.trim(), line: i, start: offset, end: offset + line.length });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
offset += line.length + 1;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ` - [ ] item` → its indent, marker, checkbox and content. Null when the line is not a list item. */
|
|
53
|
+
export function parseListItem(line) {
|
|
54
|
+
const m = String(line ?? '').match(/^(\s*)([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?(.*)$/);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
return {
|
|
57
|
+
indent: m[1],
|
|
58
|
+
marker: m[2],
|
|
59
|
+
ordered: /\d/.test(m[2]),
|
|
60
|
+
checkbox: m[3] ? m[3].trimEnd() : '',
|
|
61
|
+
content: m[4],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function lineBoundsAt(doc, pos) {
|
|
66
|
+
const start = doc.lastIndexOf('\n', Math.max(0, pos - 1)) + 1;
|
|
67
|
+
const nl = doc.indexOf('\n', pos);
|
|
68
|
+
return { start, end: nl === -1 ? doc.length : nl };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What Enter should do inside a list.
|
|
73
|
+
*
|
|
74
|
+
* Continues the list with the same indent and marker (incrementing an ordered one), and
|
|
75
|
+
* ENDS it when the item is empty — pressing Enter twice is how everyone expects to leave a
|
|
76
|
+
* list, and without that rule the user is left deleting a bullet they did not ask for.
|
|
77
|
+
*
|
|
78
|
+
* Returns null when the caret is not in a list item, so the caller can let the key fall
|
|
79
|
+
* through to the surface's own newline handling.
|
|
80
|
+
*/
|
|
81
|
+
export function continueList(text, cursor) {
|
|
82
|
+
const doc = String(text ?? '');
|
|
83
|
+
const pos = Math.max(0, Math.min(cursor, doc.length));
|
|
84
|
+
const { start, end } = lineBoundsAt(doc, pos);
|
|
85
|
+
const item = parseListItem(doc.slice(start, end));
|
|
86
|
+
if (!item) return null;
|
|
87
|
+
|
|
88
|
+
// Empty item → the user is done with the list. Clear the marker instead of adding another.
|
|
89
|
+
if (!item.content.trim()) {
|
|
90
|
+
return { text: `${doc.slice(0, start)}\n${doc.slice(end)}`, selStart: start + 1, selEnd: start + 1 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const marker = item.ordered
|
|
94
|
+
? `${(parseInt(item.marker, 10) || 0) + 1}${item.marker.slice(-1)}`
|
|
95
|
+
: item.marker;
|
|
96
|
+
// A checked box never continues as checked — the next item is new work, not done work.
|
|
97
|
+
const box = item.checkbox ? '[ ] ' : '';
|
|
98
|
+
const insert = `\n${item.indent}${marker} ${box}`;
|
|
99
|
+
const caret = pos + insert.length;
|
|
100
|
+
return { text: doc.slice(0, pos) + insert + doc.slice(pos), selStart: caret, selEnd: caret };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Indent or outdent every list item the selection touches.
|
|
105
|
+
*
|
|
106
|
+
* Whole lines, not the caret's line alone, so Tab on a multi-line selection does the
|
|
107
|
+
* obvious thing. Outdent removes at most one level and never eats non-space characters.
|
|
108
|
+
*/
|
|
109
|
+
export function indentSelection(text, selStart, selEnd, dir = 1, unit = ' ') {
|
|
110
|
+
const doc = String(text ?? '');
|
|
111
|
+
const from = lineBoundsAt(doc, Math.min(selStart, selEnd)).start;
|
|
112
|
+
const to = lineBoundsAt(doc, Math.max(selStart, selEnd)).end;
|
|
113
|
+
const lines = doc.slice(from, to).split('\n');
|
|
114
|
+
let firstDelta = 0;
|
|
115
|
+
let total = 0;
|
|
116
|
+
const shifted = lines.map((line, i) => {
|
|
117
|
+
let next = line;
|
|
118
|
+
if (dir >= 0) {
|
|
119
|
+
if (line.trim()) next = unit + line;
|
|
120
|
+
} else {
|
|
121
|
+
const m = line.match(new RegExp(`^(${unit}|\\t|\\s{1,${unit.length}})`));
|
|
122
|
+
if (m) next = line.slice(m[0].length);
|
|
123
|
+
}
|
|
124
|
+
const delta = next.length - line.length;
|
|
125
|
+
if (i === 0) firstDelta = delta;
|
|
126
|
+
total += delta;
|
|
127
|
+
return next;
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
text: doc.slice(0, from) + shifted.join('\n') + doc.slice(to),
|
|
131
|
+
selStart: Math.max(from, Math.min(selStart, selEnd) + firstDelta),
|
|
132
|
+
selEnd: Math.max(from, Math.max(selStart, selEnd) + total),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Inline formats, as the pair of markers that wrap the selection. */
|
|
137
|
+
const WRAPS = {
|
|
138
|
+
bold: '**',
|
|
139
|
+
italic: '*',
|
|
140
|
+
code: '`',
|
|
141
|
+
strike: '~~',
|
|
142
|
+
highlight: '==',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Toggle an inline format around the selection.
|
|
147
|
+
*
|
|
148
|
+
* Toggle, not apply: selecting already-bold text and pressing ⌘B must UNbold it, both when
|
|
149
|
+
* the markers are inside the selection and when they sit just outside it (which is what a
|
|
150
|
+
* double-click on the word gives you). Getting only the first case right is the usual bug,
|
|
151
|
+
* and it leaves users with `****text****`.
|
|
152
|
+
*/
|
|
153
|
+
export function toggleWrap(text, selStart, selEnd, kind) {
|
|
154
|
+
const doc = String(text ?? '');
|
|
155
|
+
const mark = WRAPS[kind];
|
|
156
|
+
if (!mark) return null;
|
|
157
|
+
let a = Math.min(selStart, selEnd);
|
|
158
|
+
let b = Math.max(selStart, selEnd);
|
|
159
|
+
|
|
160
|
+
// Nothing selected: grow to the word under the caret so ⌘B on a word just works.
|
|
161
|
+
if (a === b) {
|
|
162
|
+
const { start, end } = lineBoundsAt(doc, a);
|
|
163
|
+
const line = doc.slice(start, end);
|
|
164
|
+
let ws = a - start;
|
|
165
|
+
let we = a - start;
|
|
166
|
+
while (ws > 0 && /\w/.test(line[ws - 1])) ws -= 1;
|
|
167
|
+
while (we < line.length && /\w/.test(line[we])) we += 1;
|
|
168
|
+
if (we > ws) { a = start + ws; b = start + we; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const inner = doc.slice(a, b);
|
|
172
|
+
// Markers inside the selection.
|
|
173
|
+
if (inner.length >= mark.length * 2 && inner.startsWith(mark) && inner.endsWith(mark)) {
|
|
174
|
+
const stripped = inner.slice(mark.length, -mark.length);
|
|
175
|
+
return { text: doc.slice(0, a) + stripped + doc.slice(b), selStart: a, selEnd: a + stripped.length };
|
|
176
|
+
}
|
|
177
|
+
// Markers hugging the selection.
|
|
178
|
+
if (doc.slice(a - mark.length, a) === mark && doc.slice(b, b + mark.length) === mark) {
|
|
179
|
+
return {
|
|
180
|
+
text: doc.slice(0, a - mark.length) + inner + doc.slice(b + mark.length),
|
|
181
|
+
selStart: a - mark.length,
|
|
182
|
+
selEnd: b - mark.length,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
text: doc.slice(0, a) + mark + inner + mark + doc.slice(b),
|
|
187
|
+
selStart: a + mark.length,
|
|
188
|
+
selEnd: b + mark.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Toggle a line-level prefix (quote, bullet, number, checkbox) over the selected lines.
|
|
194
|
+
*
|
|
195
|
+
* Uniformly: if EVERY touched line already has the prefix the gesture removes it, otherwise
|
|
196
|
+
* it adds it to all of them. A per-line toggle on a mixed selection scrambles the block.
|
|
197
|
+
*/
|
|
198
|
+
export function toggleLinePrefix(text, selStart, selEnd, kind) {
|
|
199
|
+
const doc = String(text ?? '');
|
|
200
|
+
const from = lineBoundsAt(doc, Math.min(selStart, selEnd)).start;
|
|
201
|
+
const to = lineBoundsAt(doc, Math.max(selStart, selEnd)).end;
|
|
202
|
+
const lines = doc.slice(from, to).split('\n');
|
|
203
|
+
const RE = {
|
|
204
|
+
quote: /^(\s*)>\s?/,
|
|
205
|
+
bullet: /^(\s*)[-*+]\s+/,
|
|
206
|
+
number: /^(\s*)\d{1,9}[.)]\s+/,
|
|
207
|
+
task: /^(\s*)[-*+]\s+\[[ xX]\]\s+/,
|
|
208
|
+
}[kind];
|
|
209
|
+
if (!RE) return null;
|
|
210
|
+
const on = lines.every((l) => !l.trim() || RE.test(l));
|
|
211
|
+
let n = 0;
|
|
212
|
+
const next = lines.map((line) => {
|
|
213
|
+
if (!line.trim()) return line;
|
|
214
|
+
if (on) return line.replace(RE, '$1');
|
|
215
|
+
const indent = line.match(/^\s*/)[0];
|
|
216
|
+
const body = line.slice(indent.length).replace(/^(>\s?|[-*+]\s+(\[[ xX]\]\s+)?|\d{1,9}[.)]\s+)/, '');
|
|
217
|
+
n += 1;
|
|
218
|
+
const prefix = { quote: '> ', bullet: '- ', number: `${n}. `, task: '- [ ] ' }[kind];
|
|
219
|
+
return indent + prefix + body;
|
|
220
|
+
});
|
|
221
|
+
const body = next.join('\n');
|
|
222
|
+
return { text: doc.slice(0, from) + body + doc.slice(to), selStart: from, selEnd: from + body.length };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Toggle `- [ ]` ⇄ `- [x]` on the line at `cursor`. Null when that line has no checkbox. */
|
|
226
|
+
export function toggleTask(text, cursor) {
|
|
227
|
+
const doc = String(text ?? '');
|
|
228
|
+
const { start, end } = lineBoundsAt(doc, Math.max(0, Math.min(cursor, doc.length)));
|
|
229
|
+
const line = doc.slice(start, end);
|
|
230
|
+
const m = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s*)/);
|
|
231
|
+
if (!m) return null;
|
|
232
|
+
const flipped = `${m[1]}${m[2] === ' ' ? 'x' : ' '}${m[3]}${line.slice(m[0].length)}`;
|
|
233
|
+
return { text: doc.slice(0, start) + flipped + doc.slice(end), selStart: cursor, selEnd: cursor };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Wrap the selection as a markdown link.
|
|
238
|
+
*
|
|
239
|
+
* When the selection already looks like a URL it becomes the TARGET with an empty label,
|
|
240
|
+
* otherwise it becomes the label — which is what the user meant in each case, and saves
|
|
241
|
+
* retyping the half they already have. The caret lands on the empty half.
|
|
242
|
+
*/
|
|
243
|
+
export function toggleLink(text, selStart, selEnd, url = '') {
|
|
244
|
+
const doc = String(text ?? '');
|
|
245
|
+
const a = Math.min(selStart, selEnd);
|
|
246
|
+
const b = Math.max(selStart, selEnd);
|
|
247
|
+
const inner = doc.slice(a, b);
|
|
248
|
+
const looksUrl = /^(https?:\/\/|mailto:)\S+$/i.test(inner.trim());
|
|
249
|
+
const label = looksUrl ? '' : inner;
|
|
250
|
+
const target = url || (looksUrl ? inner.trim() : '');
|
|
251
|
+
const out = `[${label}](${target})`;
|
|
252
|
+
const caret = looksUrl ? a + 1 : a + out.length - 1;
|
|
253
|
+
return { text: doc.slice(0, a) + out + doc.slice(b), selStart: caret, selEnd: caret + (looksUrl ? 0 : 0) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Words in a string, counting a run of any non-whitespace as one word. */
|
|
257
|
+
function countWords(s) {
|
|
258
|
+
const t = String(s ?? '').trim();
|
|
259
|
+
return t ? t.split(/\s+/).length : 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Word / character counts and a reading estimate.
|
|
264
|
+
*
|
|
265
|
+
* 200 wpm is the usual prose figure. Reading time is reported in whole minutes with a floor
|
|
266
|
+
* of one, because "0 min read" is noise and a sub-minute note does not need a number at all.
|
|
267
|
+
*/
|
|
268
|
+
export function docStats(text, { wpm = 200 } = {}) {
|
|
269
|
+
const doc = String(text ?? '');
|
|
270
|
+
const words = countWords(doc);
|
|
271
|
+
return {
|
|
272
|
+
words,
|
|
273
|
+
chars: doc.length,
|
|
274
|
+
charsNoSpaces: doc.replace(/\s/g, '').length,
|
|
275
|
+
lines: doc ? doc.split('\n').length : 0,
|
|
276
|
+
readingMinutes: words ? Math.max(1, Math.round(words / wpm)) : 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Stats for the selection when there is one, otherwise for the whole document. */
|
|
281
|
+
export function selectionStats(text, selStart, selEnd, opts) {
|
|
282
|
+
const doc = String(text ?? '');
|
|
283
|
+
const a = Math.min(selStart, selEnd);
|
|
284
|
+
const b = Math.max(selStart, selEnd);
|
|
285
|
+
return a === b
|
|
286
|
+
? { ...docStats(doc, opts), selection: false }
|
|
287
|
+
: { ...docStats(doc.slice(a, b), opts), selection: true };
|
|
288
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The canonical ChatPanel event-log and capability contracts
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./kernel.js": "./kernel.js",
|
|
16
16
|
"./loop.js": "./loop.js",
|
|
17
17
|
"./manifest.js": "./manifest.js",
|
|
18
|
+
"./markdown-authoring.js": "./markdown-authoring.js",
|
|
18
19
|
"./mcp-errors.js": "./mcp-errors.js",
|
|
19
20
|
"./meeting-analyzers.js": "./meeting-analyzers.js",
|
|
20
21
|
"./order.js": "./order.js",
|
|
@@ -27,22 +28,24 @@
|
|
|
27
28
|
"./sources-retrieval.js": "./sources-retrieval.js",
|
|
28
29
|
"./sources.js": "./sources.js",
|
|
29
30
|
"./store.js": "./store.js",
|
|
31
|
+
"./text-search.js": "./text-search.js",
|
|
30
32
|
"./tool-groups.js": "./tool-groups.js",
|
|
31
33
|
"./tool-need.js": "./tool-need.js",
|
|
32
34
|
"./trajectory.js": "./trajectory.js",
|
|
33
35
|
"./upcast.js": "./upcast.js"
|
|
34
36
|
},
|
|
35
37
|
"files": [
|
|
36
|
-
"index.js",
|
|
37
38
|
"adapters.js",
|
|
38
39
|
"capability.js",
|
|
39
40
|
"citations.js",
|
|
40
41
|
"event.js",
|
|
41
42
|
"harness.js",
|
|
43
|
+
"index.js",
|
|
42
44
|
"invariants.js",
|
|
43
45
|
"kernel.js",
|
|
44
46
|
"loop.js",
|
|
45
47
|
"manifest.js",
|
|
48
|
+
"markdown-authoring.js",
|
|
46
49
|
"mcp-errors.js",
|
|
47
50
|
"meeting-analyzers.js",
|
|
48
51
|
"order.js",
|
|
@@ -55,6 +58,7 @@
|
|
|
55
58
|
"sources-retrieval.js",
|
|
56
59
|
"sources.js",
|
|
57
60
|
"store.js",
|
|
61
|
+
"text-search.js",
|
|
58
62
|
"tool-groups.js",
|
|
59
63
|
"tool-need.js",
|
|
60
64
|
"trajectory.js",
|
package/text-search.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Find, find-all and replace over a plain-text document.
|
|
2
|
+
//
|
|
3
|
+
// Every editing surface ChatPanel will ever ship needs this same answer: given a document,
|
|
4
|
+
// a query and a few toggles, WHERE are the matches and what does the document look like
|
|
5
|
+
// after a replace. The Notes editor asks it twice already — once for the `<textarea>` and
|
|
6
|
+
// once for the CodeMirror surface — and a mobile notes client would ask it a third time.
|
|
7
|
+
// Three implementations of "what counts as a whole word" become three different answers,
|
|
8
|
+
// so the rule lives here once and the surfaces only decide how to PAINT the ranges.
|
|
9
|
+
//
|
|
10
|
+
// Pure over strings: no DOM, no editor, no document object. That is what makes it testable
|
|
11
|
+
// without a browser and reusable from a `<textarea>`, a CM6 EditorState, a SwiftUI
|
|
12
|
+
// TextEditor, or the gateway rewriting a note server-side.
|
|
13
|
+
|
|
14
|
+
/** Escape a literal string so it can be embedded in a RegExp verbatim. */
|
|
15
|
+
function escapeLiteral(s) {
|
|
16
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the RegExp for a query, or explain why it can't be built.
|
|
21
|
+
*
|
|
22
|
+
* Returns a RESULT rather than throwing because an invalid regex is a normal thing for a
|
|
23
|
+
* user to type mid-keystroke ("(" on the way to "(a|b)") — the find bar wants to show a
|
|
24
|
+
* quiet hint, not catch an exception on every input event.
|
|
25
|
+
*
|
|
26
|
+
* Whole-word uses lookarounds instead of `\b` because `\b` is defined against word
|
|
27
|
+
* characters: `\bfoo(\b` never matches, since there is no word boundary after `(`. The
|
|
28
|
+
* lookarounds ask the question that was actually meant — "not glued to a word character".
|
|
29
|
+
*/
|
|
30
|
+
export function compileQuery(query, { caseSensitive = false, wholeWord = false, regex = false } = {}) {
|
|
31
|
+
const q = String(query ?? '');
|
|
32
|
+
if (!q) return { ok: false, error: 'empty' };
|
|
33
|
+
let source = regex ? q : escapeLiteral(q);
|
|
34
|
+
if (wholeWord) source = `(?<!\\w)(?:${source})(?!\\w)`;
|
|
35
|
+
try {
|
|
36
|
+
return { ok: true, re: new RegExp(source, caseSensitive ? 'gu' : 'giu') };
|
|
37
|
+
} catch {
|
|
38
|
+
// `u` mode rejects patterns older engines tolerate (e.g. a bare `\d` inside a class is
|
|
39
|
+
// fine, but `\-` is not). Retry without it so a user's plain regex still works.
|
|
40
|
+
try {
|
|
41
|
+
return { ok: true, re: new RegExp(source, caseSensitive ? 'g' : 'gi') };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { ok: false, error: e?.message || 'invalid regular expression' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every match of `query` in `text`, in document order.
|
|
50
|
+
*
|
|
51
|
+
* Zero-length matches (a user types `a*`, or `^` in multiline) would spin the loop forever
|
|
52
|
+
* on a global regex, because `lastIndex` never advances on its own. They are skipped and
|
|
53
|
+
* the cursor is nudged, so an empty-matching pattern degrades to "no matches" instead of
|
|
54
|
+
* hanging the editor — a find bar runs this on every keystroke.
|
|
55
|
+
*/
|
|
56
|
+
export function findMatches(text, query, opts = {}) {
|
|
57
|
+
const compiled = compileQuery(query, opts);
|
|
58
|
+
if (!compiled.ok) return [];
|
|
59
|
+
const doc = String(text ?? '');
|
|
60
|
+
const { re } = compiled;
|
|
61
|
+
const out = [];
|
|
62
|
+
re.lastIndex = 0;
|
|
63
|
+
for (let m = re.exec(doc); m; m = re.exec(doc)) {
|
|
64
|
+
if (m[0].length === 0) { re.lastIndex += 1; continue; }
|
|
65
|
+
out.push({ start: m.index, end: m.index + m[0].length, text: m[0], groups: m.slice(1) });
|
|
66
|
+
if (out.length > MAX_MATCHES) break;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A find bar that highlights 100k ranges janks the editor; past this we stop counting. */
|
|
72
|
+
export const MAX_MATCHES = 10000;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Which match should "Find next / previous" land on, given where the caret is.
|
|
76
|
+
*
|
|
77
|
+
* Next takes the first match STARTING at or after the caret; previous takes the last one
|
|
78
|
+
* ENDING at or before it. The asymmetry is deliberate: with the caret sitting inside the
|
|
79
|
+
* current match — which is exactly where "find next" just left it — a `start < cursor`
|
|
80
|
+
* rule for previous selects that same match again and the button appears dead.
|
|
81
|
+
*
|
|
82
|
+
* Both wrap, because a find bar that dead-ends at the last match makes the user scroll back
|
|
83
|
+
* to the top by hand. Returns -1 only when there is nothing to land on at all.
|
|
84
|
+
*/
|
|
85
|
+
export function matchIndexFor(matches, cursor = 0, dir = 1) {
|
|
86
|
+
if (!matches?.length) return -1;
|
|
87
|
+
if (dir >= 0) {
|
|
88
|
+
const i = matches.findIndex((m) => m.start >= cursor);
|
|
89
|
+
return i === -1 ? 0 : i;
|
|
90
|
+
}
|
|
91
|
+
for (let i = matches.length - 1; i >= 0; i -= 1) if (matches[i].end <= cursor) return i;
|
|
92
|
+
return matches.length - 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Expand `$1` / `$&` / `$$` in a replacement against one match.
|
|
97
|
+
*
|
|
98
|
+
* Only in regex mode. In literal mode a `$` the user typed is a dollar sign they want in
|
|
99
|
+
* the document — silently eating it as a group reference would corrupt the text and there
|
|
100
|
+
* would be no way to type a literal `$` at all.
|
|
101
|
+
*/
|
|
102
|
+
export function expandReplacement(replacement, match, regex = false) {
|
|
103
|
+
const r = String(replacement ?? '');
|
|
104
|
+
if (!regex) return r;
|
|
105
|
+
return r.replace(/\$(\$|&|\d{1,2})/g, (whole, token) => {
|
|
106
|
+
if (token === '$') return '$';
|
|
107
|
+
if (token === '&') return match.text;
|
|
108
|
+
const g = match.groups?.[Number(token) - 1];
|
|
109
|
+
return g === undefined ? whole : g; // an unmatched group stays literal, like String.replace
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Replace one already-located match. Returns the new text and the caret to leave behind. */
|
|
114
|
+
export function replaceMatch(text, match, replacement, { regex = false } = {}) {
|
|
115
|
+
const doc = String(text ?? '');
|
|
116
|
+
if (!match) return { text: doc, cursor: 0, changed: false };
|
|
117
|
+
const insert = expandReplacement(replacement, match, regex);
|
|
118
|
+
return {
|
|
119
|
+
text: doc.slice(0, match.start) + insert + doc.slice(match.end),
|
|
120
|
+
cursor: match.start + insert.length,
|
|
121
|
+
changed: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Replace every match in one pass.
|
|
127
|
+
*
|
|
128
|
+
* Applied right-to-left so each splice leaves the offsets of the matches still to come
|
|
129
|
+
* untouched — replacing left-to-right would shift every subsequent range by the length
|
|
130
|
+
* delta and quietly corrupt the document whenever the replacement is a different length
|
|
131
|
+
* from the match.
|
|
132
|
+
*/
|
|
133
|
+
export function replaceAll(text, query, replacement, opts = {}) {
|
|
134
|
+
const matches = findMatches(text, query, opts);
|
|
135
|
+
let doc = String(text ?? '');
|
|
136
|
+
for (let i = matches.length - 1; i >= 0; i -= 1) {
|
|
137
|
+
const m = matches[i];
|
|
138
|
+
doc = doc.slice(0, m.start) + expandReplacement(replacement, m, opts.regex) + doc.slice(m.end);
|
|
139
|
+
}
|
|
140
|
+
return { text: doc, count: matches.length };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Replace only inside a selected range — "Replace All in selection".
|
|
145
|
+
*
|
|
146
|
+
* The range is searched as a substring and the offsets are rebased, so the same matching
|
|
147
|
+
* rules apply; a caller must not have to reason about anchoring the pattern.
|
|
148
|
+
*/
|
|
149
|
+
export function replaceAllInRange(text, query, replacement, from, to, opts = {}) {
|
|
150
|
+
const doc = String(text ?? '');
|
|
151
|
+
const a = Math.max(0, Math.min(from, to, doc.length));
|
|
152
|
+
const b = Math.min(doc.length, Math.max(from, to, 0));
|
|
153
|
+
const { text: replaced, count } = replaceAll(doc.slice(a, b), query, replacement, opts);
|
|
154
|
+
return { text: doc.slice(0, a) + replaced + doc.slice(b), count };
|
|
155
|
+
}
|