@adecore/editor 0.22.1 → 0.23.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 (94) hide show
  1. package/dist/attribution.d.ts +9 -8
  2. package/dist/attribution.js +44 -9
  3. package/dist/bidi.d.ts +44 -0
  4. package/dist/bidi.js +26 -0
  5. package/dist/column-selection.js +2 -1
  6. package/dist/controller.d.ts +6 -1
  7. package/dist/controller.js +93 -3
  8. package/dist/editor.css +81 -1
  9. package/dist/engine.js +184 -41
  10. package/dist/fake.d.ts +43 -5
  11. package/dist/fake.js +117 -13
  12. package/dist/find-search.d.ts +26 -0
  13. package/dist/find-search.js +130 -0
  14. package/dist/find.d.ts +50 -2
  15. package/dist/find.js +575 -25
  16. package/dist/geometry-stops.d.ts +6 -0
  17. package/dist/geometry-stops.js +47 -0
  18. package/dist/grammar-preparation.d.ts +29 -0
  19. package/dist/grammar-preparation.js +147 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +2 -0
  22. package/dist/keymap-table.d.ts +10 -1
  23. package/dist/keymap-table.js +37 -1
  24. package/dist/keymap.d.ts +1 -0
  25. package/dist/keymap.js +4 -1
  26. package/dist/layout-changes.d.ts +6 -0
  27. package/dist/layout-changes.js +27 -0
  28. package/dist/layout.d.ts +45 -2
  29. package/dist/layout.js +202 -58
  30. package/dist/offsets.d.ts +1 -0
  31. package/dist/offsets.js +23 -0
  32. package/dist/outline.js +5 -4
  33. package/dist/paint.d.ts +11 -3
  34. package/dist/paint.js +180 -34
  35. package/dist/search-client.d.ts +27 -0
  36. package/dist/search-client.js +210 -0
  37. package/dist/search-protocol.d.ts +103 -0
  38. package/dist/search-protocol.js +3 -0
  39. package/dist/search-worker.d.ts +11 -0
  40. package/dist/search-worker.js +137 -0
  41. package/dist/semantic.d.ts +2 -1
  42. package/dist/semantic.js +6 -1
  43. package/dist/shiki-document.d.ts +8 -0
  44. package/dist/shiki-document.js +93 -0
  45. package/dist/shiki-worker.d.ts +65 -0
  46. package/dist/shiki-worker.js +265 -0
  47. package/dist/shiki.d.ts +1 -0
  48. package/dist/shiki.js +96 -33
  49. package/dist/testing.d.ts +1 -0
  50. package/dist/tokens.d.ts +2 -0
  51. package/dist/tokens.js +37 -7
  52. package/dist/touch.d.ts +27 -0
  53. package/dist/touch.js +100 -0
  54. package/dist/types.d.ts +84 -2
  55. package/dist/uniform-geometry.d.ts +7 -0
  56. package/dist/uniform-geometry.js +49 -0
  57. package/dist/view.d.ts +68 -17
  58. package/dist/view.js +424 -97
  59. package/dist/worker-client.d.ts +37 -0
  60. package/dist/worker-client.js +195 -0
  61. package/package.json +17 -2
  62. package/src/attribution.ts +50 -14
  63. package/src/bidi.ts +32 -0
  64. package/src/column-selection.ts +2 -1
  65. package/src/controller.ts +97 -3
  66. package/src/editor.css +81 -1
  67. package/src/engine.ts +216 -42
  68. package/src/fake.ts +152 -14
  69. package/src/find-search.ts +165 -0
  70. package/src/find.ts +622 -21
  71. package/src/geometry-stops.ts +51 -0
  72. package/src/grammar-preparation.ts +158 -0
  73. package/src/index.ts +10 -0
  74. package/src/keymap-table.ts +44 -2
  75. package/src/keymap.ts +5 -1
  76. package/src/layout-changes.ts +33 -0
  77. package/src/layout.ts +262 -63
  78. package/src/offsets.ts +24 -0
  79. package/src/outline.ts +5 -4
  80. package/src/paint.ts +202 -36
  81. package/src/search-client.ts +306 -0
  82. package/src/search-protocol.ts +100 -0
  83. package/src/search-worker.ts +171 -0
  84. package/src/semantic.ts +8 -2
  85. package/src/shiki-document.ts +143 -0
  86. package/src/shiki-worker.ts +344 -0
  87. package/src/shiki.ts +103 -33
  88. package/src/testing.ts +2 -0
  89. package/src/tokens.ts +36 -7
  90. package/src/touch.ts +134 -0
  91. package/src/types.ts +174 -8
  92. package/src/uniform-geometry.ts +57 -0
  93. package/src/view.ts +500 -102
  94. package/src/worker-client.ts +212 -0
package/dist/paint.js CHANGED
@@ -1,5 +1,7 @@
1
- import { colorValue } from './attribution.js';
2
- import { scanLine } from './layout.js';
1
+ import { offsetOfStop, rowOfStop, stopAtOffset, stopCount, xOfStop } from './geometry-stops.js';
2
+ import { colorValue, tintValue } from './attribution.js';
3
+ import { scanLine, subRowBand } from './layout.js';
4
+ import { BIDI_CONTROLS, BIDI_PATTERN, visibleBidi } from './bidi.js';
3
5
  const ITALIC = 1;
4
6
  const BOLD = 2;
5
7
  const UNDERLINE = 4;
@@ -64,36 +66,61 @@ function fillRun(run, geometry, from, to, tokens, whitespace, tokensFrom = geome
64
66
  }
65
67
  }
66
68
  }
67
- /* One line of text as absolutely placed runs and inlays. `firstRowOnly` leaves out what a wrapped line carries onto later visual lines. */
68
- export function lineElement(document, geometry, tokens, lineHeight, firstRowOnly, whitespace = false) {
69
+ export function lineElement(document, geometry, tokens, lineHeight, band, whitespace = false) {
69
70
  const element = document.createElement('div');
70
71
  element.className = 'se-line';
71
72
  element.style.width = `${geometry.width}px`;
72
- for (const run of geometry.runs) {
73
- if (firstRowOnly && run.subRow > 0) {
74
- continue;
73
+ let first = 0;
74
+ let last = geometry.runs.length;
75
+ while (first < last) {
76
+ const middle = (first + last) >>> 1;
77
+ if (geometry.runs[middle].subRow < band.first) {
78
+ first = middle + 1;
79
+ }
80
+ else {
81
+ last = middle;
82
+ }
83
+ }
84
+ for (let index = first; index < geometry.runs.length; index++) {
85
+ const run = geometry.runs[index];
86
+ if (run.subRow > band.last) {
87
+ break;
75
88
  }
76
89
  const span = document.createElement('span');
77
90
  span.className = 'se-run';
78
91
  span.style.left = `${run.x}px`;
79
92
  span.style.top = `${run.subRow * lineHeight}px`;
80
- fillRun(span, geometry, run.from, run.to, tokens, whitespace);
93
+ fillRun(span, geometry, geometry.start + run.from, geometry.start + run.to, tokens, whitespace);
81
94
  element.append(span);
82
95
  }
83
96
  if (whitespace) {
84
- appendTabSigns(element, geometry, lineHeight, firstRowOnly);
97
+ appendTabSigns(element, geometry, lineHeight, band);
85
98
  }
99
+ appendBidiSigns(element, geometry, lineHeight, band);
86
100
  for (const box of geometry.inlays) {
87
- if (firstRowOnly && box.subRow > 0) {
101
+ if (box.subRow < band.first || box.subRow > band.last) {
88
102
  continue;
89
103
  }
90
104
  const span = document.createElement('span');
91
105
  span.className = box.inlay.ghost ? 'se-ghost' : box.inlay.composition ? 'se-composition' : 'se-inlay';
92
106
  span.dataset.inlayId = box.inlay.id;
93
- span.textContent = box.inlay.text;
94
- span.style.left = `${box.x}px`;
107
+ const { parts } = box.inlay;
108
+ if (parts === undefined) {
109
+ span.textContent = visibleBidi(box.inlay.text);
110
+ }
111
+ else {
112
+ parts.forEach((part, index) => {
113
+ const element = document.createElement('span');
114
+ element.className = 'se-inlay-part';
115
+ element.dataset.part = String(index);
116
+ element.textContent = visibleBidi(part);
117
+ span.append(element);
118
+ });
119
+ }
120
+ const gapLeft = box.inlay.gapLeft ?? 0;
121
+ span.style.left = `${box.x + gapLeft}px`;
95
122
  span.style.top = `${box.subRow * lineHeight}px`;
96
- span.style.width = `${box.width}px`;
123
+ span.style.width = `${box.width - gapLeft - (box.inlay.gapRight ?? 0)}px`;
97
124
  element.append(span);
98
125
  }
99
126
  return element;
@@ -125,7 +152,10 @@ export class RowPainter {
125
152
  }
126
153
  let measured = false;
127
154
  for (const row of rows) {
128
- if (row.kind === 'text') {
155
+ if (row.kind === 'text' && row.replacement !== undefined) {
156
+ measured = this.paintReplacement(row, row.replacement, paint) || measured;
157
+ }
158
+ else if (row.kind === 'text') {
129
159
  this.paintText(row, paint);
130
160
  }
131
161
  else {
@@ -137,6 +167,10 @@ export class RowPainter {
137
167
  paintText(row, paint) {
138
168
  const geometry = this.layout.geometry(row);
139
169
  const tokens = paint.tokensOf(row.line);
170
+ // Block boundaries keep a scroll within the band from rebuilding its text DOM.
171
+ const band = geometry.subRows <= 64
172
+ ? { first: 0, last: geometry.subRows - 1 }
173
+ : subRowBand(row.top, geometry.subRows, this.layout.metrics.lineHeight, paint.window, 64);
140
174
  const folded = row.lastLine > row.line;
141
175
  const lastLine = folded && row.tail !== null ? this.layout.document.getLine(row.lastLine) : null;
142
176
  const tail = lastLine === null ? '' : lastLine.text.slice(row.tail - lastLine.start);
@@ -149,8 +183,10 @@ export class RowPainter {
149
183
  entry.whitespace !== paint.whitespace ||
150
184
  entry.folded !== folded ||
151
185
  entry.tail !== tail ||
152
- entry.tailTokens !== tailTokens) {
153
- const element = this.buildText(row, geometry, tokens, paint, tail, tailTokens);
186
+ entry.tailTokens !== tailTokens ||
187
+ entry.band?.first !== band.first ||
188
+ entry.band?.last !== band.last) {
189
+ const element = this.buildText(row, geometry, tokens, paint, tail, tailTokens, band);
154
190
  if (entry) {
155
191
  entry.element.replaceWith(element);
156
192
  }
@@ -166,7 +202,8 @@ export class RowPainter {
166
202
  folded,
167
203
  tail,
168
204
  tailTokens,
169
- revision: 0
205
+ revision: 0,
206
+ band
170
207
  };
171
208
  this.rows.set(row.key, entry);
172
209
  }
@@ -174,11 +211,11 @@ export class RowPainter {
174
211
  entry.element.style.height = `${row.height}px`;
175
212
  entry.element.dataset.line = String(row.line);
176
213
  }
177
- buildText(row, geometry, tokens, paint, tail, tailTokens) {
214
+ buildText(row, geometry, tokens, paint, tail, tailTokens, band) {
178
215
  const document = this.code.ownerDocument;
179
216
  const lineHeight = this.layout.metrics.lineHeight;
180
- const element = lineElement(document, geometry, tokens, lineHeight, false, paint.whitespace);
181
- if (row.lastLine > row.line) {
217
+ const element = lineElement(document, geometry, tokens, lineHeight, band, paint.whitespace);
218
+ if (row.lastLine > row.line && band.last === geometry.subRows - 1) {
182
219
  const chip = document.createElement('button');
183
220
  chip.type = 'button';
184
221
  chip.className = 'se-fold-chip';
@@ -208,9 +245,42 @@ export class RowPainter {
208
245
  span.className = 'se-run';
209
246
  span.style.left = `${left + run.x}px`;
210
247
  span.style.top = `${(header.subRows - 1) * this.layout.metrics.lineHeight}px`;
211
- fillRun(span, geometry, run.from, run.to, tokens, false, lineStart);
248
+ fillRun(span, geometry, geometry.start + run.from, geometry.start + run.to, tokens, false, lineStart);
212
249
  element.append(span);
213
250
  }
251
+ appendBidiSigns(element, geometry, this.layout.metrics.lineHeight, { first: 0, last: 0 }, left, (header.subRows - 1) * this.layout.metrics.lineHeight);
252
+ }
253
+ /* The lines the host draws itself: an element of its own, indented as the first line is and as tall as its content. */
254
+ paintReplacement(row, replacement, paint) {
255
+ const revision = replacement.revision ?? 0;
256
+ let entry = this.rows.get(row.key);
257
+ if (!entry) {
258
+ const element = this.code.ownerDocument.createElement('div');
259
+ element.className = 'se-block se-replacement';
260
+ element.dataset.rowKey = row.key;
261
+ element.dataset.replacementId = replacement.id;
262
+ element.setAttribute('role', 'group');
263
+ if (replacement.label !== undefined) {
264
+ element.setAttribute('aria-label', replacement.label);
265
+ }
266
+ replacement.render(element);
267
+ this.code.append(element);
268
+ entry = { element, text: '', tokens: null, layoutVersion: 0, whitespace: false, folded: false, tail: '', tailTokens: null, revision };
269
+ this.rows.set(row.key, entry);
270
+ }
271
+ else if (entry.revision !== revision) {
272
+ entry.element.replaceChildren();
273
+ replacement.render(entry.element);
274
+ entry.revision = revision;
275
+ }
276
+ const indent = this.layout.indentX(row.line);
277
+ entry.element.style.top = `${row.top}px`;
278
+ entry.element.style.left = `${indent}px`;
279
+ entry.element.style.width = `${Math.max(80, paint.viewportWidth - indent)}px`;
280
+ entry.element.style.height = '';
281
+ this.observer?.observe(entry.element);
282
+ const height = entry.element.getBoundingClientRect?.().height ?? 0;
283
+ return height > 0 && this.layout.setMeasuredHeight(row.key, height);
214
284
  }
215
285
  paintBlock(row, paint) {
216
286
  const { widget } = row;
@@ -223,7 +293,7 @@ export class RowPainter {
223
293
  element.dataset.widgetId = widget.id;
224
294
  this.fillBlock(element, widget);
225
295
  this.code.append(element);
226
- if (!widget.lens) {
296
+ if (!widget.lens && !widget.fixed) {
227
297
  this.observer?.observe(element);
228
298
  }
229
299
  entry = { element, text: '', tokens: null, layoutVersion: 0, whitespace: false, folded: false, tail: '', tailTokens: null, revision };
@@ -247,6 +317,13 @@ export class RowPainter {
247
317
  else {
248
318
  entry.element.style.width = `${Math.max(80, paint.viewportWidth)}px`;
249
319
  }
320
+ if (widget.fixed) {
321
+ this.observer?.unobserve(entry.element);
322
+ entry.element.style.height = `${row.height}px`;
323
+ return false;
324
+ }
325
+ entry.element.style.height = '';
326
+ this.observer?.observe(entry.element);
250
327
  const height = entry.element.getBoundingClientRect?.().height ?? 0;
251
328
  return height > 0 && this.layout.setMeasuredHeight(row.key, height);
252
329
  }
@@ -286,13 +363,33 @@ export function paintGutter(container, layout, rows, paint) {
286
363
  item.className = paint.activeLines.has(row.line) ? 'se-line-number se-active-number' : 'se-line-number';
287
364
  item.style.top = `${row.top}px`;
288
365
  item.style.height = `${layout.metrics.lineHeight}px`;
289
- const fill = paint.highlights.get(row.line)?.fill;
366
+ const highlight = paint.highlights.get(row.line);
367
+ const fill = highlight?.fill;
290
368
  if (fill !== undefined) {
291
369
  item.style.background = colorValue(fill);
292
370
  }
371
+ if (highlight?.bar !== undefined) {
372
+ item.classList.add('se-line-marked');
373
+ item.style.height = `${row.height}px`;
374
+ item.style.setProperty('--se-line', `${layout.metrics.lineHeight}px`);
375
+ item.style.setProperty('--se-mark', colorValue(highlight.color));
376
+ if (fill === undefined) {
377
+ item.style.setProperty('--se-tint', tintValue(highlight.color));
378
+ }
379
+ }
293
380
  const number = document.createElement('span');
294
- number.textContent = String(row.line + 1);
381
+ number.textContent = paint.lineNumber(row.line);
382
+ const label = paint.lineLabel(row.line);
383
+ if (label !== null) {
384
+ number.setAttribute('aria-hidden', 'true');
385
+ }
295
386
  item.append(number);
387
+ if (label !== null) {
388
+ const hidden = document.createElement('span');
389
+ hidden.className = 'se-sr-only';
390
+ hidden.textContent = label;
391
+ item.append(hidden);
392
+ }
296
393
  if (paint.action?.line === row.line) {
297
394
  const button = gutterButton(document, 'se-gutter-action', paint.action.label);
298
395
  button.dataset.gutterAction = String(row.line);
@@ -302,6 +399,12 @@ export function paintGutter(container, layout, rows, paint) {
302
399
  if (marker !== undefined) {
303
400
  const button = gutterButton(document, 'se-gutter-marker', marker.label);
304
401
  button.dataset.gutterMarker = marker.id;
402
+ if (marker.icon !== undefined) {
403
+ button.dataset.icon = marker.icon;
404
+ }
405
+ if (marker.pressed !== undefined) {
406
+ button.setAttribute('aria-pressed', String(marker.pressed));
407
+ }
305
408
  item.append(button);
306
409
  }
307
410
  const sign = paint.highlights.get(row.line)?.sign;
@@ -309,9 +412,18 @@ export function paintGutter(container, layout, rows, paint) {
309
412
  const mark = document.createElement('span');
310
413
  mark.className = 'se-line-sign';
311
414
  mark.textContent = sign;
415
+ if (label !== null) {
416
+ mark.setAttribute('aria-hidden', 'true');
417
+ }
312
418
  mark.style.color = colorValue(paint.highlights.get(row.line).color);
313
419
  item.append(mark);
314
420
  }
421
+ if (highlight?.bar !== undefined) {
422
+ const mark = document.createElement('span');
423
+ mark.className = `se-line-bar se-line-bar-${highlight.bar}`;
424
+ mark.setAttribute('aria-hidden', 'true');
425
+ item.append(mark);
426
+ }
315
427
  const bar = paint.attribution.get(row.line);
316
428
  if (bar) {
317
429
  const mark = document.createElement('span');
@@ -323,7 +435,7 @@ export function paintGutter(container, layout, rows, paint) {
323
435
  }
324
436
  const change = paint.changes.get(row.line);
325
437
  // The bar of an agent stands where the mark of a change would, and a removal is a triangle between lines that the bar never hides.
326
- if (change && (!bar || change === 'deleted')) {
438
+ if (change && (!bar || change === 'deleted') && highlight?.bar === undefined) {
327
439
  const mark = document.createElement('span');
328
440
  mark.className = `se-change se-change-${change}`;
329
441
  if (change !== 'deleted') {
@@ -333,7 +445,8 @@ export function paintGutter(container, layout, rows, paint) {
333
445
  }
334
446
  const collapsed = paint.foldable.get(row.line);
335
447
  if (collapsed !== undefined) {
336
- const button = gutterButton(document, collapsed ? 'se-fold-toggle se-folded' : 'se-fold-toggle', collapsed ? 'Expand' : 'Collapse');
448
+ const button = gutterButton(document, collapsed ? 'se-fold-toggle se-folded' : 'se-fold-toggle', paint.foldName(row.line, collapsed));
449
+ button.setAttribute('aria-expanded', String(!collapsed));
337
450
  button.dataset.foldLine = String(row.line);
338
451
  item.append(button);
339
452
  }
@@ -415,10 +528,10 @@ export function paintSticky(container, entries, lineHeight) {
415
528
  row.dataset.depth = String(depth);
416
529
  const number = document.createElement('span');
417
530
  number.className = 'se-sticky-number';
418
- number.textContent = String(entry.line + 1);
531
+ number.textContent = entry.label;
419
532
  const code = document.createElement('div');
420
533
  code.className = 'se-sticky-code';
421
- code.append(lineElement(document, entry.geometry, entry.tokens, lineHeight, true));
534
+ code.append(lineElement(document, entry.geometry, entry.tokens, lineHeight, { first: 0, last: 0 }));
422
535
  row.append(number, code);
423
536
  return row;
424
537
  }));
@@ -448,20 +561,53 @@ export function arrowSign(document, width, lineHeight, className) {
448
561
  return svg;
449
562
  }
450
563
  /* An arrow over each tab of a line. */
451
- function appendTabSigns(element, geometry, lineHeight, firstRowOnly) {
564
+ function appendTabSigns(element, geometry, lineHeight, band) {
565
+ if (geometry.uniform !== undefined) {
566
+ return;
567
+ }
452
568
  const document = element.ownerDocument;
453
- for (let stop = 0; stop < geometry.offsets.length - 1; stop++) {
454
- if (geometry.text[geometry.offsets[stop]] !== '\t' || (firstRowOnly && geometry.rowOf[stop] > 0)) {
569
+ const end = geometry.rowStarts[band.last + 1] ?? stopCount(geometry) - 1;
570
+ for (let stop = geometry.rowStarts[band.first]; stop < end; stop++) {
571
+ if (geometry.text[offsetOfStop(geometry, stop)] !== '\t') {
455
572
  continue;
456
573
  }
457
- const left = geometry.before[stop];
458
- const right = geometry.rowOf[stop + 1] === geometry.rowOf[stop] ? geometry.before[stop + 1] : geometry.rowEnds[geometry.rowOf[stop]];
574
+ const left = xOfStop(geometry, stop);
575
+ const right = rowOfStop(geometry, stop + 1) === rowOfStop(geometry, stop) ? xOfStop(geometry, stop + 1) : geometry.rowEnds[rowOfStop(geometry, stop)];
459
576
  if (right - left < 3) {
460
577
  continue;
461
578
  }
462
579
  const sign = arrowSign(document, right - left - 2, lineHeight, 'se-tab-sign');
463
580
  sign.style.left = `${left + 1}px`;
464
- sign.style.top = `${geometry.rowOf[stop] * lineHeight}px`;
581
+ sign.style.top = `${rowOfStop(geometry, stop) * lineHeight}px`;
582
+ element.append(sign);
583
+ }
584
+ }
585
+ /*
586
+ * A boxed label over each bidirectional control character, which no run carries, so the control never reaches the text nodes
587
+ * where it would reorder the line. `top` and `originX` place a line that is drawn as part of another one.
588
+ */
589
+ function appendBidiSigns(element, geometry, lineHeight, band, originX = 0, top) {
590
+ if (geometry.uniform !== undefined) {
591
+ return;
592
+ }
593
+ const document = element.ownerDocument;
594
+ const from = offsetOfStop(geometry, geometry.rowStarts[band.first]);
595
+ const to = geometry.rowStarts[band.last + 1] === undefined ? geometry.text.length : offsetOfStop(geometry, geometry.rowStarts[band.last + 1]);
596
+ const pattern = new RegExp(BIDI_PATTERN.source, 'g');
597
+ pattern.lastIndex = from;
598
+ for (let match = pattern.exec(geometry.text); match !== null && match.index < to; match = pattern.exec(geometry.text)) {
599
+ const stop = stopAtOffset(geometry, match.index);
600
+ const row = rowOfStop(geometry, stop);
601
+ const control = BIDI_CONTROLS[match[0]];
602
+ const left = xOfStop(geometry, stop);
603
+ const right = rowOfStop(geometry, stop + 1) === row ? xOfStop(geometry, stop + 1) : geometry.rowEnds[row];
604
+ const sign = document.createElement('span');
605
+ sign.className = 'se-bidi';
606
+ sign.textContent = control.label;
607
+ sign.style.left = `${originX + left}px`;
608
+ sign.style.top = `${top ?? row * lineHeight}px`;
609
+ sign.style.width = `${right - left}px`;
610
+ sign.style.height = `${lineHeight}px`;
465
611
  element.append(sign);
466
612
  }
467
613
  }
@@ -0,0 +1,27 @@
1
+ import type { FindMatch } from '@adecore/editor-core';
2
+ import type { EditorFindQuery } from './types.ts';
3
+ import { type EditorSearchRange, type EditorSearchNextOptions, type EditorSearchReplacementOptions, type EditorSearchReplacements, type EditorSearchOptions, type EditorSearchResult, type EditorSearchSnapshot } from './search-protocol.ts';
4
+ export type EditorSearchErrorKind = 'timeout' | 'superseded' | 'unavailable' | 'syntax' | 'range' | 'snapshot' | 'error' | 'disposed';
5
+ export declare class EditorSearchError extends Error {
6
+ readonly kind: EditorSearchErrorKind;
7
+ constructor(kind: EditorSearchErrorKind, message: string);
8
+ }
9
+ export interface EditorSearchClientOptions {
10
+ readonly workerFactory: () => Pick<Worker, 'addEventListener' | 'removeEventListener' | 'postMessage' | 'terminate'>;
11
+ readonly deadline?: number;
12
+ readonly schedule?: (callback: () => void, delay: number) => () => void;
13
+ }
14
+ export interface EditorSearchClient {
15
+ search(snapshot: EditorSearchSnapshot, query: EditorFindQuery, options?: EditorSearchOptions): Promise<EditorSearchResult>;
16
+ match(snapshot: EditorSearchSnapshot, query: EditorFindQuery, range: {
17
+ readonly from: number;
18
+ readonly to: number;
19
+ readonly deadline?: number;
20
+ }): Promise<FindMatch | null>;
21
+ next(snapshot: EditorSearchSnapshot, query: EditorFindQuery, offset: number, options?: EditorSearchNextOptions): Promise<EditorSearchRange | null>;
22
+ replacements(snapshot: EditorSearchSnapshot, query: EditorFindQuery, replacement: string, options?: EditorSearchReplacementOptions): Promise<EditorSearchReplacements>;
23
+ cancel(): void;
24
+ release(): void;
25
+ dispose(): void;
26
+ }
27
+ export declare function createSearchClient(options: EditorSearchClientOptions): EditorSearchClient;
@@ -0,0 +1,210 @@
1
+ import { createWorkerRequestClient, WorkerRequestError } from './worker-client.js';
2
+ import { SEARCH_LIMIT, MAX_REPLACEMENT_PAGE, MAX_REPLACEMENT_CHARACTERS } from './search-protocol.js';
3
+ export class EditorSearchError extends Error {
4
+ kind;
5
+ constructor(kind, message) {
6
+ super(message);
7
+ this.name = 'EditorSearchError';
8
+ this.kind = kind;
9
+ }
10
+ }
11
+ function validateSearch(snapshot, query, options) {
12
+ const from = options.from ?? 0;
13
+ const to = options.to ?? snapshot.text.length;
14
+ const maximum = options.maxResults ?? SEARCH_LIMIT;
15
+ if (!Number.isSafeInteger(snapshot.revision) ||
16
+ snapshot.revision < 0 ||
17
+ !Number.isInteger(from) ||
18
+ !Number.isInteger(to) ||
19
+ from < 0 ||
20
+ to < from ||
21
+ to > snapshot.text.length) {
22
+ throw new EditorSearchError('range', 'Search bounds and revision must identify the current document.');
23
+ }
24
+ if (!Number.isInteger(maximum) || maximum < 0 || maximum > SEARCH_LIMIT) {
25
+ throw new EditorSearchError('range', `Search results must be limited to ${SEARCH_LIMIT} matches or fewer.`);
26
+ }
27
+ if (options.deadline !== undefined && (!Number.isFinite(options.deadline) || options.deadline <= 0 || options.deadline > 2147483647)) {
28
+ throw new EditorSearchError('range', 'A request deadline must fit a positive browser timer.');
29
+ }
30
+ if (query.regex) {
31
+ try {
32
+ // Compilation reports syntax errors without executing the expression on the page.
33
+ new RegExp(query.text, `gmu${query.caseSensitive ? '' : 'i'}`);
34
+ }
35
+ catch (error) {
36
+ throw new EditorSearchError('syntax', error instanceof Error ? error.message : String(error));
37
+ }
38
+ }
39
+ }
40
+ function splitsSurrogate(text, offset) {
41
+ const previous = text.charCodeAt(offset - 1), current = text.charCodeAt(offset);
42
+ return previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff;
43
+ }
44
+ export function createSearchClient(options) {
45
+ let revision;
46
+ const transport = createWorkerRequestClient({
47
+ ...options,
48
+ accepts: (reply, input) => reply.revision === input.revision,
49
+ failure: (reply) => reply.type === 'error'
50
+ ? { error: new EditorSearchError(reply.kind, reply.message), restart: reply.kind !== 'syntax' && reply.kind !== 'range' }
51
+ : undefined,
52
+ prepare: (input) => {
53
+ const { text, ...rest } = input;
54
+ const message = { ...rest, ...(revision !== input.revision ? { text } : {}) };
55
+ revision = input.revision;
56
+ return message;
57
+ },
58
+ stopped: () => {
59
+ revision = undefined;
60
+ }
61
+ });
62
+ const run = (snapshot, query, bounds, input, read, validate) => {
63
+ return transport
64
+ .request(input, bounds.deadline, () => {
65
+ validateSearch(snapshot, query, bounds);
66
+ validate?.();
67
+ })
68
+ .then((reply) => {
69
+ if (reply.type === 'error') {
70
+ throw new EditorSearchError(reply.kind, reply.message);
71
+ }
72
+ return read(reply);
73
+ })
74
+ .catch((error) => {
75
+ throw error instanceof WorkerRequestError
76
+ ? new EditorSearchError(error.kind, error.message)
77
+ : error instanceof EditorSearchError
78
+ ? error
79
+ : new EditorSearchError('error', error instanceof Error ? error.message : String(error));
80
+ });
81
+ };
82
+ return {
83
+ search(snapshot, query, bounds = {}) {
84
+ return run(snapshot, query, bounds, (id) => ({
85
+ type: 'search',
86
+ id,
87
+ revision: snapshot.revision,
88
+ text: snapshot.text,
89
+ query: { ...query },
90
+ ...bounds,
91
+ maxResults: bounds.maxResults ?? SEARCH_LIMIT
92
+ }), (reply) => {
93
+ if (reply.type !== 'matches') {
94
+ throw new EditorSearchError('error', 'The worker returned an unexpected search response.');
95
+ }
96
+ return { revision: reply.revision, ranges: reply.ranges, limited: reply.limited };
97
+ });
98
+ },
99
+ match(snapshot, query, range) {
100
+ const from = range.from, to = range.to;
101
+ const text = snapshot.text;
102
+ return run(snapshot, query, { from, to, maxResults: 1, deadline: range.deadline }, (id) => ({ type: 'match', id, revision: snapshot.revision, text, query: { ...query }, from, to }), (reply) => {
103
+ if (reply.type !== 'match') {
104
+ throw new EditorSearchError('error', 'The worker returned an unexpected match response.');
105
+ }
106
+ if (reply.match === null) {
107
+ return null;
108
+ }
109
+ if (reply.match.from !== from || reply.match.to !== to) {
110
+ throw new EditorSearchError('error', 'The worker returned a different match range.');
111
+ }
112
+ return { ...reply.match, text: text.slice(from, to), revision: reply.revision };
113
+ });
114
+ },
115
+ next(snapshot, query, offset, bounds = {}) {
116
+ return run(snapshot, query, { from: bounds.from, to: bounds.to, maxResults: 1, deadline: bounds.deadline }, (id) => ({ type: 'next', id, revision: snapshot.revision, text: snapshot.text, query: { ...query }, offset, ...bounds }), (reply) => {
117
+ if (reply.type !== 'next') {
118
+ throw new EditorSearchError('error', 'The worker returned an unexpected cursor response.');
119
+ }
120
+ const range = reply.range;
121
+ if (range !== null &&
122
+ (!Number.isInteger(range.from) ||
123
+ !Number.isInteger(range.to) ||
124
+ range.from < (bounds.from ?? 0) ||
125
+ range.to < range.from ||
126
+ range.to > (bounds.to ?? snapshot.text.length) ||
127
+ splitsSurrogate(snapshot.text, range.from) ||
128
+ splitsSurrogate(snapshot.text, range.to))) {
129
+ throw new EditorSearchError('error', 'The worker returned invalid cursor bounds.');
130
+ }
131
+ return range;
132
+ });
133
+ },
134
+ replacements(snapshot, query, replacement, options = {}) {
135
+ const { from, to, preserveCase } = options;
136
+ const maxEdits = options.maxEdits ?? 1000;
137
+ const maxCharacters = options.maxCharacters ?? MAX_REPLACEMENT_CHARACTERS;
138
+ return run(snapshot, query, { from, to, maxResults: 1, deadline: options.deadline }, (id) => ({
139
+ type: 'replacements',
140
+ id,
141
+ revision: snapshot.revision,
142
+ text: snapshot.text,
143
+ query: { ...query },
144
+ from,
145
+ to,
146
+ preserveCase,
147
+ replacement,
148
+ maxEdits,
149
+ maxCharacters
150
+ }), (reply) => {
151
+ if (reply.type !== 'replacements') {
152
+ throw new EditorSearchError('error', 'The worker returned an unexpected replacement response.');
153
+ }
154
+ if (reply.ranges.length % 3 !== 0 || reply.ranges.length / 3 > maxEdits || reply.text.length > maxCharacters) {
155
+ throw new EditorSearchError('error', 'The worker returned an invalid replacement page.');
156
+ }
157
+ let characters = 0;
158
+ let previous = from ?? 0;
159
+ let previousEmpty = false;
160
+ for (let index = 0; index < reply.ranges.length; index += 3) {
161
+ const start = reply.ranges[index], end = reply.ranges[index + 1];
162
+ if (start < previous ||
163
+ (previousEmpty && start === previous) ||
164
+ end < start ||
165
+ end > (to ?? snapshot.text.length) ||
166
+ splitsSurrogate(snapshot.text, start) ||
167
+ splitsSurrogate(snapshot.text, end)) {
168
+ throw new EditorSearchError('error', 'The worker returned invalid replacement bounds.');
169
+ }
170
+ previous = end;
171
+ previousEmpty = start === end;
172
+ characters += reply.ranges[index + 2];
173
+ }
174
+ if (characters !== reply.text.length) {
175
+ throw new EditorSearchError('error', 'The worker returned inconsistent replacement lengths.');
176
+ }
177
+ if (reply.next !== null) {
178
+ const index = reply.ranges.length - 3;
179
+ if (index < 0) {
180
+ throw new EditorSearchError('error', 'The worker returned an empty replacement continuation.');
181
+ }
182
+ const start = reply.ranges[index], end = reply.ranges[index + 1];
183
+ const resume = end + (start === end ? ((snapshot.text.codePointAt(end) ?? 0) > 0xffff ? 2 : 1) : 0);
184
+ if (reply.next !== resume || reply.next > (to ?? snapshot.text.length)) {
185
+ throw new EditorSearchError('error', 'The worker returned an invalid replacement continuation.');
186
+ }
187
+ }
188
+ return { revision: reply.revision, ranges: reply.ranges, text: reply.text, next: reply.next };
189
+ }, () => {
190
+ if (!Number.isInteger(maxEdits) ||
191
+ maxEdits < 1 ||
192
+ maxEdits > MAX_REPLACEMENT_PAGE ||
193
+ !Number.isInteger(maxCharacters) ||
194
+ maxCharacters < 0 ||
195
+ maxCharacters > MAX_REPLACEMENT_CHARACTERS) {
196
+ throw new EditorSearchError('range', 'Replacement pages need a positive edit limit and a bounded character limit.');
197
+ }
198
+ });
199
+ },
200
+ cancel() {
201
+ transport.cancel();
202
+ },
203
+ release() {
204
+ transport.release();
205
+ },
206
+ dispose() {
207
+ transport.dispose();
208
+ }
209
+ };
210
+ }