@adecore/editor 0.23.0 → 0.24.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/dist/layout.js CHANGED
@@ -2,6 +2,7 @@ import { offsetOfStop, rowOfStop, stopAtOffset, stopCount, xOfStop } from './geo
2
2
  import { BIDI_COLUMNS, bidiControl, visibleBidi } from './bidi.js';
3
3
  import { uniformGeometry } from './uniform-geometry.js';
4
4
  import { lineChangeMapper } from './layout-changes.js';
5
+ import { offsetMapper } from './offsets.js';
5
6
  export function subRowBand(top, subRows, lineHeight, window, block = 1) {
6
7
  const first = Math.max(0, Math.min(subRows - 1, Math.floor((window.top - top) / lineHeight)));
7
8
  const last = Math.max(first, Math.min(subRows - 1, Math.ceil((window.bottom - top) / lineHeight) - 1));
@@ -21,7 +22,7 @@ const WRAP_INDENT_STEP = 2;
21
22
  const GEOMETRY_CACHE_SIZE = 400;
22
23
  const PLAIN = /^[\x20-\x7e\t]*$/;
23
24
  const GRAPHEME_LIMIT = 20000;
24
- const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
25
+ let segmenter;
25
26
  /* Where each character starts, with the length last. A very long line is cut by code unit, since splitting it by grapheme would cost more than it shows. */
26
27
  function characterOffsets(text) {
27
28
  const offsets = [];
@@ -31,7 +32,8 @@ function characterOffsets(text) {
31
32
  }
32
33
  }
33
34
  else {
34
- for (const part of SEGMENTER.segment(text)) {
35
+ segmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
36
+ for (const part of segmenter.segment(text)) {
35
37
  offsets.push(part.index);
36
38
  }
37
39
  }
@@ -190,6 +192,47 @@ export function scanLine(line, metrics, limit, lineInlays) {
190
192
  }
191
193
  return scanDenseLine(line, metrics, limit, lineInlays);
192
194
  }
195
+ /* A text row whose key follows its line, so an edit above it moves the row without a new key. */
196
+ class LineRow {
197
+ kind = 'text';
198
+ index;
199
+ line;
200
+ lastLine;
201
+ tail;
202
+ head;
203
+ subRows;
204
+ top = 0;
205
+ height;
206
+ constructor(index, line, lastLine, tail, head, subRows, height) {
207
+ this.index = index;
208
+ this.line = line;
209
+ this.lastLine = lastLine;
210
+ this.tail = tail;
211
+ this.head = head;
212
+ this.subRows = subRows;
213
+ this.height = height;
214
+ }
215
+ get key() {
216
+ return `line:${this.line}`;
217
+ }
218
+ }
219
+ /* Sorted, with the ranges that overlap or touch joined. */
220
+ function mergeRanges(ranges) {
221
+ ranges.sort((left, right) => left.first - right.first);
222
+ const merged = [];
223
+ for (const range of ranges) {
224
+ const previous = merged.at(-1);
225
+ if (previous !== undefined && range.first <= previous.last + 1) {
226
+ previous.last = Math.max(previous.last, range.last);
227
+ }
228
+ else {
229
+ merged.push({ ...range });
230
+ }
231
+ }
232
+ return merged;
233
+ }
234
+ /* Past this many lines to lay out again, building every row costs about as much, and it keeps the arguments of a splice few. */
235
+ const PATCH_LIMIT = 20000;
193
236
  /*
194
237
  * The rows of the document as they are drawn: one per line, taller for a wrapped line, one for a
195
238
  * widget, none for a line a fold hides. Only the rows that are on screen are ever measured; the
@@ -202,6 +245,9 @@ export class EditorLayout {
202
245
  /* The widest line measured since the text last changed, and the room past it; what a horizontal scroll bar needs. */
203
246
  width = 0;
204
247
  lineRows = [];
248
+ /* The rows of widgets and replacements by key, which their measured heights come in by. */
249
+ keyed = new Map();
250
+ special = [];
205
251
  geometryCache = new Map();
206
252
  measuredRows = new Map();
207
253
  measuredHeights = new Map();
@@ -246,6 +292,7 @@ export class EditorLayout {
246
292
  this.replacements = config.replacements;
247
293
  this.lineHeightsVersion++;
248
294
  }
295
+ let edit;
249
296
  if (measureAgain || (config.textChanged && !config.changes?.length)) {
250
297
  this.lineHeightsVersion++;
251
298
  this.geometryCache.clear();
@@ -254,6 +301,7 @@ export class EditorLayout {
254
301
  }
255
302
  else {
256
303
  if (config.changes?.length) {
304
+ edit = this.lineEdit(config.changes);
257
305
  this.retainGeometry(config.changes);
258
306
  this.lineHeightsVersion++;
259
307
  }
@@ -278,7 +326,31 @@ export class EditorLayout {
278
326
  }
279
327
  }
280
328
  }
281
- this.rebuild();
329
+ this.rebuild(edit);
330
+ }
331
+ /* The lines the transactions changed, in the coordinates of the text before and after them. */
332
+ lineEdit(batches) {
333
+ let from = Number.POSITIVE_INFINITY;
334
+ let to = Number.NEGATIVE_INFINITY;
335
+ for (const changes of batches) {
336
+ if (Number.isFinite(to)) {
337
+ to = offsetMapper(changes)(to);
338
+ }
339
+ let delta = 0;
340
+ for (const change of changes) {
341
+ from = Math.min(from, change.from);
342
+ to = Math.max(to, change.from + delta + change.insertedLength);
343
+ delta += change.insertedLength - (change.to - change.from);
344
+ }
345
+ }
346
+ if (!Number.isFinite(from)) {
347
+ return undefined;
348
+ }
349
+ // The line above too, since an edit at the start of a line can turn the `\r` that ends it into a `\r\n`.
350
+ const first = Math.max(0, this.document.positionAt(from).line - 1);
351
+ const newLast = this.document.positionAt(to).line;
352
+ const oldLast = newLast - (this.document.getLineCount() - this.lineRows.length);
353
+ return oldLast < first ? undefined : { first, oldLast, newLast };
282
354
  }
283
355
  retainGeometry(batches) {
284
356
  const map = lineChangeMapper(batches);
@@ -318,7 +390,18 @@ export class EditorLayout {
318
390
  }
319
391
  return Math.max(1, Math.ceil((length * this.metrics.charWidth) / Math.max(limit, this.metrics.charWidth)));
320
392
  }
321
- rebuild() {
393
+ rebuild(edit) {
394
+ const structure = this.structure();
395
+ if (edit === undefined || !this.patch(edit, structure)) {
396
+ this.keyed.clear();
397
+ const built = this.buildRows(structure, 0, this.document.getLineCount() - 1, 0);
398
+ this.rows = built.rows;
399
+ this.lineRows = built.lineRows;
400
+ this.reflow(0);
401
+ }
402
+ this.special = structure.special;
403
+ }
404
+ structure() {
322
405
  this.inlaysByLine.clear();
323
406
  for (const inlay of this.inlays) {
324
407
  const line = this.document.positionAt(inlay.at).line;
@@ -351,15 +434,115 @@ export class EditorLayout {
351
434
  replaced.set(startLine, { replacement, endLine });
352
435
  }
353
436
  }
437
+ const special = [];
438
+ for (const line of [...this.inlaysByLine.keys(), ...above.keys(), ...below.keys()]) {
439
+ special.push({ first: line, last: line });
440
+ }
441
+ for (const fold of folds.values()) {
442
+ special.push({ first: fold.startLine, last: Math.max(fold.startLine, fold.endLine) });
443
+ }
444
+ for (const [startLine, entry] of replaced) {
445
+ special.push({ first: startLine, last: entry.endLine });
446
+ }
447
+ return { above, below, folds, replaced, special: mergeRanges(special) };
448
+ }
449
+ /*
450
+ * Lays out again only the lines an edit changed and the rows that stand on more than their line, and moves the
451
+ * rest. False when that would cost no less than building every row.
452
+ */
453
+ patch(edit, structure) {
354
454
  const count = this.document.getLineCount();
355
- this.rows = [];
356
- this.lineRows = new Array(count);
455
+ const delta = edit.newLast - edit.oldLast;
456
+ if (this.rows.length === 0 || this.lineRows.length + delta !== count) {
457
+ return false;
458
+ }
459
+ const moved = (range) => {
460
+ if (range.last < edit.first) {
461
+ return range;
462
+ }
463
+ if (range.first > edit.oldLast) {
464
+ return { first: range.first + delta, last: range.last + delta };
465
+ }
466
+ return { first: Math.min(range.first, edit.first), last: range.last > edit.oldLast ? range.last + delta : edit.newLast };
467
+ };
468
+ const ranges = mergeRanges([{ first: edit.first, last: edit.newLast }, ...structure.special, ...this.special.map(moved)]).filter((range) => range.first < count);
469
+ let lines = 0;
470
+ for (const range of ranges) {
471
+ range.last = Math.min(range.last, count - 1);
472
+ lines += range.last - range.first + 1;
473
+ }
474
+ if (lines > Math.min(PATCH_LIMIT, count / 2)) {
475
+ return false;
476
+ }
477
+ const rows = this.rows;
478
+ const built = new Set();
479
+ let first = rows.length;
480
+ let resized = delta !== 0;
481
+ // From the bottom up, so the rows and lines of the ranges above keep their indices. Rows not built again keep their old lines until the end.
482
+ for (let index = ranges.length - 1; index >= 0; index--) {
483
+ const range = ranges[index];
484
+ const oldFirst = range.first > edit.newLast ? range.first - delta : range.first;
485
+ const oldLast = range.last >= edit.newLast ? range.last - delta : range.last;
486
+ let start = this.lineRows[oldFirst];
487
+ while (start > 0 && rows[start - 1].kind === 'block' && rows[start - 1].line >= oldFirst) {
488
+ start--;
489
+ }
490
+ let end = this.lineRows[oldLast];
491
+ while (end + 1 < rows.length && rows[end + 1].kind === 'block' && rows[end + 1].line <= oldLast) {
492
+ end++;
493
+ }
494
+ const fresh = this.buildRows(structure, range.first, range.last, start);
495
+ const removed = rows.splice(start, end - start + 1, ...fresh.rows);
496
+ this.lineRows.splice(oldFirst, oldLast - oldFirst + 1, ...fresh.lineRows);
497
+ let height = 0;
498
+ for (const row of removed) {
499
+ height -= row.height;
500
+ if (this.keyed.get(row.key) === row) {
501
+ this.keyed.delete(row.key);
502
+ }
503
+ }
504
+ let top = start > 0 ? rows[start - 1].top + rows[start - 1].height : 0;
505
+ for (const row of fresh.rows) {
506
+ built.add(row);
507
+ height += row.height;
508
+ row.top = top;
509
+ top += row.height;
510
+ }
511
+ resized ||= height !== 0 || removed.length !== fresh.rows.length;
512
+ first = start;
513
+ }
514
+ if (resized) {
515
+ for (let index = first; index < rows.length; index++) {
516
+ const row = rows[index];
517
+ row.index = index;
518
+ if (!built.has(row) && row.line > edit.oldLast) {
519
+ row.line += delta;
520
+ if (row.kind === 'text') {
521
+ row.lastLine += delta;
522
+ }
523
+ }
524
+ if (row.kind === 'text') {
525
+ for (let covered = row.line; covered <= row.lastLine; covered++) {
526
+ this.lineRows[covered] = index;
527
+ }
528
+ }
529
+ }
530
+ this.reflow(first);
531
+ }
532
+ return true;
533
+ }
534
+ /* The rows of lines `from` through `to`, the first of them at `index`, and the row each of the lines is in. */
535
+ buildRows(structure, from, to, index) {
536
+ const { above, below, folds, replaced } = structure;
537
+ const count = this.document.getLineCount();
538
+ const rows = [];
539
+ const lineRows = new Array(to - from + 1);
357
540
  const addBlock = (widget, line) => {
358
541
  const key = `block:${widget.id}`;
359
- this.rows.push({
542
+ const row = {
360
543
  key,
361
544
  kind: 'block',
362
- index: this.rows.length,
545
+ index: index + rows.length,
363
546
  line,
364
547
  widget,
365
548
  top: 0,
@@ -368,9 +551,11 @@ export class EditorLayout {
368
551
  : widget.fixed
369
552
  ? Math.max(1, widget.height ?? DEFAULT_BLOCK_HEIGHT)
370
553
  : (this.measuredHeights.get(key) ?? widget.height ?? DEFAULT_BLOCK_HEIGHT)
371
- });
554
+ };
555
+ rows.push(row);
556
+ this.keyed.set(key, row);
372
557
  };
373
- for (let line = 0; line < count; line++) {
558
+ for (let line = from; line <= to; line++) {
374
559
  for (const block of above.get(line) ?? []) {
375
560
  addBlock(block, line);
376
561
  }
@@ -378,11 +563,11 @@ export class EditorLayout {
378
563
  if (standIn !== undefined) {
379
564
  const last = Math.min(count - 1, standIn.endLine);
380
565
  const key = `replaced:${standIn.replacement.id}`;
381
- const index = this.rows.length;
382
- this.rows.push({
566
+ const rowIndex = index + rows.length;
567
+ const row = {
383
568
  key,
384
569
  kind: 'text',
385
- index,
570
+ index: rowIndex,
386
571
  line,
387
572
  lastLine: last,
388
573
  tail: null,
@@ -391,9 +576,11 @@ export class EditorLayout {
391
576
  top: 0,
392
577
  height: this.measuredHeights.get(key) ?? standIn.replacement.height ?? DEFAULT_BLOCK_HEIGHT,
393
578
  replacement: standIn.replacement
394
- });
579
+ };
580
+ rows.push(row);
581
+ this.keyed.set(key, row);
395
582
  for (let covered = line; covered <= last; covered++) {
396
- this.lineRows[covered] = index;
583
+ lineRows[covered - from] = rowIndex;
397
584
  }
398
585
  for (const block of below.get(last) ?? []) {
399
586
  addBlock(block, last);
@@ -405,59 +592,59 @@ export class EditorLayout {
405
592
  const last = Math.max(line, Math.min(count - 1, fold?.endLine ?? line));
406
593
  const tail = fold?.closer !== undefined && last > line ? this.clampToLine(fold.closer, last) : null;
407
594
  const head = fold?.head !== undefined && last > line ? this.clampToLine(fold.head, line) : null;
408
- const index = this.rows.length;
595
+ const rowIndex = index + rows.length;
409
596
  const bounds = this.wrapWidth === null ? null : this.document.getLine(line);
410
597
  const subRows = this.rowCountOf(line, bounds === null ? 0 : bounds.end - bounds.start);
411
- this.rows.push({
412
- key: `line:${line}`,
413
- kind: 'text',
414
- index,
415
- line,
416
- lastLine: last,
417
- tail,
418
- head,
419
- subRows,
420
- top: 0,
421
- height: subRows * this.metrics.lineHeight
422
- });
598
+ rows.push(new LineRow(rowIndex, line, last, tail, head, subRows, subRows * this.metrics.lineHeight));
423
599
  for (let covered = line; covered <= last; covered++) {
424
- this.lineRows[covered] = index;
600
+ lineRows[covered - from] = rowIndex;
425
601
  }
426
602
  for (const block of below.get(line) ?? []) {
427
603
  addBlock(block, line);
428
604
  }
429
605
  line = last;
430
606
  }
431
- this.reflow();
607
+ return { rows, lineRows };
432
608
  }
433
609
  /* An offset of a fold clamped to its line, since the fold may be a moment older than an edit. */
434
610
  clampToLine(offset, line) {
435
611
  const bounds = this.document.getLine(line);
436
612
  return Math.min(bounds.end, Math.max(bounds.start, offset));
437
613
  }
438
- reflow() {
439
- let top = 0;
440
- for (const row of this.rows) {
614
+ /* The tops of the rows from `from` on. */
615
+ reflow(from) {
616
+ const previous = this.rows[from - 1];
617
+ let top = previous === undefined ? 0 : previous.top + previous.height;
618
+ for (let index = from; index < this.rows.length; index++) {
619
+ const row = this.rows[index];
441
620
  row.top = top;
442
621
  top += row.height;
443
622
  }
444
623
  this.height = top + BOTTOM_PADDING;
445
624
  }
625
+ /* The row of a widget, a replacement or a line by its key. */
626
+ rowByKey(key) {
627
+ if (key.startsWith('line:')) {
628
+ const row = this.rowForLine(Number(key.slice(5)));
629
+ return row.key === key ? row : undefined;
630
+ }
631
+ return this.keyed.get(key);
632
+ }
446
633
  /* A widget's measured height. Returns whether it moved anything. */
447
634
  setMeasuredHeight(key, height) {
448
- const row = this.rows.find((candidate) => candidate.key === key);
635
+ const row = this.rowByKey(key);
449
636
  const next = Math.max(1, Math.round(height));
450
637
  if (!row || (row.kind === 'block' && row.widget.fixed) || !Number.isFinite(next) || row.height === next) {
451
638
  return false;
452
639
  }
453
640
  this.measuredHeights.set(key, next);
454
641
  row.height = next;
455
- this.reflow();
642
+ this.reflow(row.index);
456
643
  return true;
457
644
  }
458
645
  /* The visual lines a drawn row really has, which its estimate may have missed. Returns whether any row changed. */
459
646
  syncRows(rows) {
460
- let changed = false;
647
+ let first = this.rows.length;
461
648
  for (const row of rows) {
462
649
  if (row.kind !== 'text' || row.replacement !== undefined) {
463
650
  continue;
@@ -476,14 +663,15 @@ export class EditorLayout {
476
663
  if (subRows !== row.subRows) {
477
664
  row.subRows = subRows;
478
665
  row.height = subRows * this.metrics.lineHeight;
479
- changed = true;
666
+ first = Math.min(first, row.index);
480
667
  }
481
668
  }
482
- if (changed) {
669
+ if (first < this.rows.length) {
483
670
  this.lineHeightsVersion++;
484
- this.reflow();
671
+ this.reflow(first);
672
+ return true;
485
673
  }
486
- return changed;
674
+ return false;
487
675
  }
488
676
  rowAt(y) {
489
677
  let low = 0;
package/dist/tokens.js CHANGED
@@ -109,7 +109,8 @@ export class TokenCache {
109
109
  const old = this.entries[lastOld];
110
110
  this.pivotLine = old ? lastNew : -1;
111
111
  this.pivotState = old?.state;
112
- this.entries.splice(firstLine, lastOld - firstLine + 1, ...new Array(lastNew - firstLine + 1));
112
+ // Not a spread into splice: a paste of a few hundred thousand lines would overflow the call stack.
113
+ this.entries = this.entries.slice(0, firstLine).concat(new Array(lastNew - firstLine + 1).fill(undefined), this.entries.slice(lastOld + 1));
113
114
  this.reusableFrom = lastNew + 1;
114
115
  }
115
116
  /* Old colors stay visible until the rebuilt grammar reaches each line. */
package/dist/types.d.ts CHANGED
@@ -26,6 +26,7 @@ export interface EditorOptions {
26
26
  readonly indentation?: EditorIndentation;
27
27
  readonly smartKeys?: Partial<EditorSmartKeys>;
28
28
  readonly guides?: boolean;
29
+ readonly stickyScroll?: boolean;
29
30
  readonly whitespace?: boolean;
30
31
  readonly rightMargin?: number | null;
31
32
  readonly foldOutline?: EditorFoldOutline;
@@ -319,6 +320,7 @@ export interface Editor {
319
320
  setIndentation(indentation: EditorIndentation): void;
320
321
  setSmartKeys(keys: Partial<EditorSmartKeys>): void;
321
322
  setGuides(guides: boolean): void;
323
+ setStickyScroll?(sticky: boolean): void;
322
324
  setWhitespace(whitespace: boolean): void;
323
325
  setRightMargin(column: number | null): void;
324
326
  setFoldOutline(outline: EditorFoldOutline): void;
package/dist/view.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface ViewSettings {
21
21
  rightMargin: number | null;
22
22
  foldOutline: 'off' | 'hover' | 'always';
23
23
  folding?: boolean;
24
+ stickyScroll?: boolean;
24
25
  }
25
26
  export type ViewFold = Omit<FoldingRange, 'kind'> & {
26
27
  kind: FoldingRange['kind'] | 'custom';
@@ -156,11 +157,11 @@ export declare class EditorView {
156
157
  private defaultRoles;
157
158
  private settled;
158
159
  private foldTimer;
160
+ private bracketTimer;
159
161
  private colorTimer;
160
162
  private noticeTimer;
161
163
  private liveTimer;
162
164
  private lineLabelProvider;
163
- private bracketCache;
164
165
  private wrapWidth;
165
166
  private rowEndCaret;
166
167
  private topAnchor;
@@ -276,7 +277,6 @@ export declare class EditorView {
276
277
  private anchorScroll;
277
278
  private syncWrap;
278
279
  private bracketAt;
279
- private brackets;
280
280
  matchingBracket(selection: {
281
281
  anchor: number;
282
282
  head: number;
@@ -284,7 +284,7 @@ export declare class EditorView {
284
284
  at: number;
285
285
  mate: number | undefined;
286
286
  } | null;
287
- unmatchedBracket(at: number): boolean;
287
+ private scheduleBrackets;
288
288
  contentPoint(clientX: number, clientY: number): {
289
289
  x: number;
290
290
  y: number;
package/dist/view.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { offsetOfStop, xOfStop } from './geometry-stops.js';
2
2
  import { visibleBidi } from './bidi.js';
3
- import { indentationColumn, replacementText, scanBrackets } from '@adecore/editor-core';
3
+ import { indentationColumn, replacementText } from '@adecore/editor-core';
4
4
  import { createSearchClient } from './search-client.js';
5
5
  import { FindController } from './find.js';
6
6
  import { EditorLayout, RIGHT_PADDING, subRowBand } from './layout.js';
@@ -39,6 +39,8 @@ const MIN_OVERSCAN = 400;
39
39
  const COLOR_SLICE_MS = 8;
40
40
  const FIRST_PAINT_MS = 12;
41
41
  const FOLD_DELAY_MS = 300;
42
+ /* How much text one frame lexes to pair the brackets at the carets; a large document edited all over takes a few frames. */
43
+ const BRACKET_FRAME_LIMIT = 256 * 1024;
42
44
  /* The space between the last character of a line and what the host puts after it. */
43
45
  const LINE_ACTION_GAP = 28;
44
46
  const NOTICE_MS = 3000;
@@ -181,11 +183,11 @@ export class EditorView {
181
183
  /* The folds somebody already decided on, a person or the defaults, so a later answer of a server leaves them as they are. */
182
184
  settled = new Set();
183
185
  foldTimer;
186
+ bracketTimer;
184
187
  colorTimer;
185
188
  noticeTimer;
186
189
  liveTimer;
187
190
  lineLabelProvider = null;
188
- bracketCache;
189
191
  wrapWidth = null;
190
192
  /* The offset a click past the end of a wrapped visual line chose, which its caret is drawn at the end of that line for. */
191
193
  rowEndCaret = null;
@@ -766,7 +768,7 @@ export class EditorView {
766
768
  ...(this.foldHints === null ? {} : { hints: this.foldHints })
767
769
  });
768
770
  this.foldRanges = this.withCustomFolds(ranges);
769
- this.outline.setStructure(structuralEntries(ranges, this.model, (from, to) => this.model.slice(from, to), (offset) => this.brackets().pairs.get(offset)));
771
+ this.outline.setStructure(structuralEntries(ranges, this.model, (from, to) => this.model.slice(from, to), (offset) => this.model.matchBracket(offset, this.settings.language)?.mate));
770
772
  const anchors = new Set(this.foldRanges.map((range) => range.from));
771
773
  this.collapsed = new Set([...this.collapsed].filter((anchor) => anchors.has(anchor)));
772
774
  this.refoldLayout();
@@ -1079,7 +1081,7 @@ export class EditorView {
1079
1081
  const anchor = this.layout.rowAt(top);
1080
1082
  const offset = top - anchor.top;
1081
1083
  action();
1082
- const next = this.layout.rows.find((row) => row.key === anchor.key);
1084
+ const next = this.layout.rowByKey(anchor.key);
1083
1085
  if (next) {
1084
1086
  // Unwrapping can shrink the anchor below its old offset.
1085
1087
  this.viewport.scrollTop = Math.max(0, next.top + Math.min(offset, Math.max(0, next.height - 1)));
@@ -1100,23 +1102,15 @@ export class EditorView {
1100
1102
  const candidates = from !== to ? [from] : [head, head - 1];
1101
1103
  return candidates.find((at) => at >= 0 && /[()[\]{}]/.test(this.model.slice(at, at + 1)));
1102
1104
  }
1103
- brackets() {
1104
- if (this.bracketCache?.revision !== this.revision || this.bracketCache.language !== this.settings.language) {
1105
- this.bracketCache = {
1106
- revision: this.revision,
1107
- language: this.settings.language,
1108
- index: scanBrackets(this.model.getText(), this.settings.language)
1109
- };
1110
- }
1111
- return this.bracketCache.index;
1112
- }
1113
1105
  /* The offset a bracket at the caret pairs with, if it is one. */
1114
1106
  matchingBracket(selection) {
1115
1107
  const at = this.bracketAt(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), selection.head);
1116
- return at === undefined ? null : { at, mate: this.brackets().pairs.get(at) };
1108
+ return at === undefined ? null : { at, mate: this.model.matchBracket(at, this.settings.language)?.mate };
1117
1109
  }
1118
- unmatchedBracket(at) {
1119
- return this.brackets().unmatched.has(at);
1110
+ /* Draws the highlights of the brackets at the carets that the last frame ran out of lexing for. */
1111
+ scheduleBrackets() {
1112
+ clearTimeout(this.bracketTimer);
1113
+ this.bracketTimer = setTimeout(() => this.requestRender(), 0);
1120
1114
  }
1121
1115
  /* A point of the screen in the coordinates of the content, undoing the scale a canvas puts on a node. */
1122
1116
  contentPoint(clientX, clientY) {
@@ -1813,12 +1807,16 @@ export class EditorView {
1813
1807
  const firstLine = rows[0]?.line ?? 0;
1814
1808
  const lastLine = lastRow?.kind === 'text' ? lastRow.lastLine : (lastRow?.line ?? 0);
1815
1809
  const highlighted = this.highlightedLines(firstLine, lastLine);
1810
+ const shownFrom = this.model.offsetAt({ line: firstLine, column: 0 });
1811
+ const shownTo = lastLine + 1 < this.model.getLineCount() ? this.model.offsetAt({ line: lastLine + 1, column: 0 }) : this.model.getLength();
1816
1812
  paintGutter(this.gutterLines, this.layout, rows, {
1817
1813
  lineNumber: (line) => this.lineNumberLabel(line),
1818
1814
  changes: this.changedLines(rows),
1819
1815
  attribution: this.attributedLines(firstLine, lastLine),
1820
1816
  highlights: highlighted,
1821
- activeLines: new Set(selections.map((selection) => this.model.positionAt(selection.head).line)),
1817
+ activeLines: new Set(selections
1818
+ .filter((selection) => selection.head >= shownFrom && selection.head <= shownTo)
1819
+ .map((selection) => this.model.positionAt(selection.head).line)),
1822
1820
  foldable,
1823
1821
  lineLabel: (line) => this.lineLabelProvider?.(line) ?? null,
1824
1822
  foldName: (line, collapsed) => this.foldName(line, collapsed),
@@ -1890,18 +1888,28 @@ export class EditorView {
1890
1888
  }
1891
1889
  }
1892
1890
  const seen = new Set();
1893
- for (const selection of selections) {
1894
- const bracket = this.matchingBracket(selection);
1895
- if (!bracket) {
1891
+ for (const [index, selection] of selections.entries()) {
1892
+ // A caret off screen has no bracket to draw, except the primary one, whose mate may be on screen.
1893
+ if (index < selections.length - 1 && (selection.head < visibleFrom - 1 || selection.head > visibleTo + 1)) {
1896
1894
  continue;
1897
1895
  }
1898
- if (bracket.mate === undefined) {
1899
- if (this.unmatchedBracket(bracket.at)) {
1900
- marks.push({ className: 'se-bracket-unmatched', rects: this.paintRectangles(bracket.at, bracket.at + 1, rows) });
1901
- }
1896
+ const at = this.bracketAt(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), selection.head);
1897
+ if (at === undefined) {
1898
+ continue;
1899
+ }
1900
+ const match = this.model.matchBracket(at, this.settings.language, BRACKET_FRAME_LIMIT);
1901
+ if (match === null) {
1902
+ this.scheduleBrackets();
1903
+ break;
1904
+ }
1905
+ if (match === undefined) {
1906
+ continue;
1907
+ }
1908
+ if (match.mate === undefined) {
1909
+ marks.push({ className: 'se-bracket-unmatched', rects: this.paintRectangles(at, at + 1, rows) });
1902
1910
  continue;
1903
1911
  }
1904
- for (const offset of [bracket.at, bracket.mate]) {
1912
+ for (const offset of [at, match.mate]) {
1905
1913
  if (!seen.has(offset)) {
1906
1914
  seen.add(offset);
1907
1915
  marks.push({ className: 'se-bracket-match', rects: this.paintRectangles(offset, offset + 1, rows) });
@@ -2294,6 +2302,9 @@ export class EditorView {
2294
2302
  return Math.min(STICKY_MAX_LINES, Math.floor(this.viewportHeight / this.layout.metrics.lineHeight / STICKY_ROW_SHARE));
2295
2303
  }
2296
2304
  stickyAt(scrollTop) {
2305
+ if (this.settings.stickyScroll === false) {
2306
+ return [];
2307
+ }
2297
2308
  const { layout } = this;
2298
2309
  const bottomOf = (line) => {
2299
2310
  const row = layout.rowForLine(line);
@@ -2427,6 +2438,7 @@ export class EditorView {
2427
2438
  this.subscription.dispose();
2428
2439
  this.resizeObserver?.disconnect();
2429
2440
  this.blockObserver?.disconnect();
2441
+ clearTimeout(this.bracketTimer);
2430
2442
  clearTimeout(this.foldTimer);
2431
2443
  clearTimeout(this.colorTimer);
2432
2444
  clearTimeout(this.noticeTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -54,7 +54,7 @@
54
54
  "test": "bun test --conditions=source"
55
55
  },
56
56
  "dependencies": {
57
- "@adecore/editor-core": "0.23.0",
57
+ "@adecore/editor-core": "0.24.0",
58
58
  "linkedom": "^0.18.13",
59
59
  "shiki": "^4.4.3"
60
60
  },
package/src/controller.ts CHANGED
@@ -202,7 +202,9 @@ export class InputController {
202
202
  });
203
203
  this.listen(input, 'keyup', (event) => this.gesture.keyup(event));
204
204
  this.listen(root, 'focusout', (event) => {
205
- if (!(event.relatedTarget instanceof Node) || !root.contains(event.relatedTarget)) {
205
+ // A node by its type, as `instanceof Node` fails for one of another window, such as a window the opener mounted the editor in.
206
+ const related = event.relatedTarget as Node | null;
207
+ if (typeof related?.nodeType !== 'number' || !root.contains(related)) {
206
208
  this.options.blur();
207
209
  }
208
210
  });
@@ -790,7 +792,12 @@ export class InputController {
790
792
  private pointerDown(event: PointerEvent): void {
791
793
  this.view.cancelScroll();
792
794
  const target = event.target as HTMLElement;
793
- if (event.button !== 0 || target.closest('button, a, input, textarea, [contenteditable], .se-block, .se-gutter, .se-sticky')) {
795
+ // A press a part took already, such as a pinned header, which draws itself anew and so takes its target off the page before the press bubbles here.
796
+ if (
797
+ event.defaultPrevented ||
798
+ event.button !== 0 ||
799
+ target.closest('button, a, input, textarea, [contenteditable], .se-block, .se-gutter, .se-sticky')
800
+ ) {
794
801
  return;
795
802
  }
796
803
  // Cancels the compatibility mouse events, whose focus change would close the keyboard a tap opened; scrolling is not affected.