@adecore/editor 0.22.0 → 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/src/fake.ts CHANGED
@@ -5,7 +5,9 @@ import type {
5
5
  EditorBlock,
6
6
  EditorCodeBlockOptions,
7
7
  EditorLineAction,
8
+ EditorReplacement,
8
9
  EditorLineHighlight,
10
+ LineToken,
9
11
  EditorChangeMark,
10
12
  EditorCodeVision,
11
13
  EditorClick,
@@ -22,9 +24,14 @@ import type {
22
24
  EditorHover,
23
25
  EditorIndentation,
24
26
  EditorGhostText,
27
+ EditorInlayClick,
28
+ EditorInlayClickHandler,
25
29
  EditorInlayHint,
30
+ EditorInlayTarget,
26
31
  EditorKeyHandler,
32
+ EditorInputPopup,
27
33
  EditorMarker,
34
+ EditorMessages,
28
35
  EditorOptions,
29
36
  EditorPosition,
30
37
  EditorRemoteCursor,
@@ -38,6 +45,7 @@ import type {
38
45
  EditorFolds,
39
46
  EditorSemanticToken,
40
47
  EditorSmartKeys,
48
+ EditorApplyEditsOptions,
41
49
  EditorTextChange,
42
50
  EditorTrackedRange,
43
51
  EditorTheme,
@@ -59,7 +67,7 @@ export class FakeEditor implements Editor {
59
67
  readonly language: string | undefined;
60
68
  readonly path: string | undefined;
61
69
  readonly column: number | null;
62
- readonly scrollTop: number | null;
70
+ scrollTop: number | null;
63
71
  wrap: boolean;
64
72
  theme: EditorTheme;
65
73
  readOnly: boolean;
@@ -130,7 +138,7 @@ export class FakeEditor implements Editor {
130
138
  }
131
139
 
132
140
  /* The text becomes `text`, and listeners of the text hear the one stretch that differs, as the real editor reports it. */
133
- private replaceText(text: string, source: EditorTextChange['source'] = 'external'): void {
141
+ private replaceText(text: string, source: EditorTextChange['source'] = 'external', selections?: readonly EditorRange[]): void {
134
142
  const span = changedSpan(this.text, text);
135
143
  const before = this.text;
136
144
  this.text = text;
@@ -139,7 +147,11 @@ export class FakeEditor implements Editor {
139
147
  }
140
148
  // As in the real editor the caret lands behind what was typed, and listeners of the caret hear it before those of the text.
141
149
  if (source !== 'external') {
142
- this.moveCaret(positionIn(text, span.start + span.text.length));
150
+ if (selections === undefined) {
151
+ this.moveCaret(positionIn(text, span.start + span.text.length));
152
+ } else {
153
+ this.setSelections(selections);
154
+ }
143
155
  }
144
156
  this.followTracked(span.start, span.end, span.text.length);
145
157
  this.ghost = null;
@@ -250,6 +262,10 @@ export class FakeEditor implements Editor {
250
262
  return this.readOnly ? 0 : this.findState.count;
251
263
  }
252
264
 
265
+ async replaceAllAsync(replacement: string, options: EditorReplaceOptions = {}): Promise<number> {
266
+ return this.replaceAll(replacement, options);
267
+ }
268
+
253
269
  setReplacePreview(replacement: string | null): void {
254
270
  this.replacePreview = replacement;
255
271
  }
@@ -288,11 +304,19 @@ export class FakeEditor implements Editor {
288
304
  blocks: readonly EditorBlock[] | null = null;
289
305
  private readonly scopes = new Set<(scope: readonly EditorBlock[]) => void>();
290
306
 
291
- /* What the host marked as changed last. */
292
- changeMarks: readonly EditorChangeMark[] = [];
307
+ /* The marks of every layer, in the order the layers last became non-empty. */
308
+ get changeMarks(): readonly EditorChangeMark[] {
309
+ return [...this.changeLayers.values()].flat();
310
+ }
311
+
312
+ private readonly changeLayers = new Map<string, readonly EditorChangeMark[]>();
293
313
 
294
- setChangeMarks(marks: readonly EditorChangeMark[]): void {
295
- this.changeMarks = marks;
314
+ setChangeMarks(marks: readonly EditorChangeMark[], layer = ''): void {
315
+ if (marks.length === 0) {
316
+ this.changeLayers.delete(layer);
317
+ } else {
318
+ this.changeLayers.set(layer, marks);
319
+ }
296
320
  }
297
321
 
298
322
  /* The tinted lines the host set last. */
@@ -305,6 +329,43 @@ export class FakeEditor implements Editor {
305
329
  /* What the host drew as code outside the document: the text and how, and into which element. */
306
330
  codeBlocks: { container: HTMLElement; text: string; options: EditorCodeBlockOptions }[] = [];
307
331
 
332
+ lineNumbers: ((line: number) => string | null) | null = null;
333
+ lineTokens: ((line: number) => readonly LineToken[] | null | undefined) | null = null;
334
+
335
+ setLineNumbers(labels: ((line: number) => string | null) | null): void {
336
+ this.lineNumbers = labels;
337
+ }
338
+
339
+ lineLabels: ((line: number) => string | null) | null = null;
340
+
341
+ setLineLabels(labels: ((line: number) => string | null) | null): void {
342
+ this.lineLabels = labels;
343
+ }
344
+
345
+ /* What the input points at, as the last call left it. */
346
+ inputPopup: EditorInputPopup | null = null;
347
+
348
+ setInputPopup(popup: EditorInputPopup | null): void {
349
+ this.inputPopup = popup;
350
+ }
351
+
352
+ /* Every sentence said, oldest first. */
353
+ announcements: string[] = [];
354
+
355
+ announce(message: string): void {
356
+ this.announcements.push(message);
357
+ }
358
+
359
+ messages: Partial<EditorMessages> = {};
360
+
361
+ setMessages(messages: Partial<EditorMessages>): void {
362
+ this.messages = { ...this.messages, ...messages };
363
+ }
364
+
365
+ setLineTokens(tokens: ((line: number) => readonly LineToken[] | null | undefined) | null): void {
366
+ this.lineTokens = tokens;
367
+ }
368
+
308
369
  renderCode(container: HTMLElement, text: string, options: EditorCodeBlockOptions = {}): void {
309
370
  this.codeBlocks.push({ container, text, options });
310
371
  }
@@ -359,12 +420,22 @@ export class FakeEditor implements Editor {
359
420
  const before = this.drawnWidgets.get(owner) ?? new Set<string>();
360
421
  for (const widget of widgets) {
361
422
  if (!before.has(widget.id)) {
362
- widget.render({} as HTMLElement);
423
+ widget.render(this.element.ownerDocument?.createElement('div') ?? ({} as HTMLElement));
363
424
  }
364
425
  }
365
426
  this.drawnWidgets.set(owner, new Set(widgets.map((widget) => widget.id)));
366
427
  }
367
428
 
429
+ readonly replacementsByOwner = new Map<string, readonly EditorReplacement[]>();
430
+
431
+ setReplacements(replacements: readonly EditorReplacement[], owner = 'default'): void {
432
+ if (replacements.length === 0) {
433
+ this.replacementsByOwner.delete(owner);
434
+ } else {
435
+ this.replacementsByOwner.set(owner, replacements);
436
+ }
437
+ }
438
+
368
439
  readonly lineActionsByOwner = new Map<string, readonly EditorLineAction[]>();
369
440
 
370
441
  setLineActions(actions: readonly EditorLineAction[], owner = 'default'): void {
@@ -487,6 +558,7 @@ export class FakeEditor implements Editor {
487
558
  charSize = { width: 8, height: 20 };
488
559
  private readonly carets = new Set<(position: EditorPosition) => void>();
489
560
  private readonly hovers = new Set<(hover: EditorHover | null) => void>();
561
+ private readonly lineHeights = new Set<Listener>();
490
562
  private readonly views = new Set<Listener>();
491
563
  private readonly keys = new Set<EditorKeyHandler>();
492
564
 
@@ -501,6 +573,35 @@ export class FakeEditor implements Editor {
501
573
  this.inlayHints = hints;
502
574
  }
503
575
 
576
+ private readonly inlayHovers = new Set<(target: EditorInlayTarget | null) => void>();
577
+ private readonly inlayHandlers = new Set<EditorInlayClickHandler>();
578
+
579
+ /* The hint, or part of one, the last `setInlayLink` drew as a link. */
580
+ inlayLink: { readonly id: string; readonly part: number | null } | null = null;
581
+
582
+ onInlayHover(listener: (target: EditorInlayTarget | null) => void): () => void {
583
+ return subscribe(this.inlayHovers, listener);
584
+ }
585
+
586
+ /* The pointer resting on a hint or a part of one, or leaving them. */
587
+ hoverInlay(id: string | null, part: number | null = null): void {
588
+ emit(this.inlayHovers, id === null ? null : { id, part, rect: { left: 0, top: 0, right: 40, bottom: 20 } });
589
+ }
590
+
591
+ onInlayClick(handler: EditorInlayClickHandler): () => void {
592
+ return subscribe(this.inlayHandlers, handler);
593
+ }
594
+
595
+ /* A press on a hint as the editor sees it first; true when a handler took it. */
596
+ clickInlay(click: Partial<EditorInlayClick> & Pick<EditorInlayClick, 'id'>): boolean {
597
+ const full: EditorInlayClick = { part: null, mod: false, alt: false, shift: false, clicks: 1, touch: false, ...click };
598
+ return [...this.inlayHandlers].some((handler) => handler(full));
599
+ }
600
+
601
+ setInlayLink(target: { readonly id: string; readonly part: number | null } | null): void {
602
+ this.inlayLink = target;
603
+ }
604
+
504
605
  /* The suggestion after the caret now, which any change of the text takes away as the real editor does. */
505
606
  ghost: EditorGhostText | null = null;
506
607
 
@@ -544,6 +645,32 @@ export class FakeEditor implements Editor {
544
645
  /* The selection a person made; without one the caret. */
545
646
  selection: EditorRange | null = null;
546
647
 
648
+ /* A test supplies the measured height; this fake has no layout. */
649
+ contentHeight = 0;
650
+
651
+ setScrollTop(top: number): void {
652
+ if (Number.isFinite(top) && this.getScrollTop() !== Math.max(0, top)) {
653
+ this.scrollTop = Math.max(0, top);
654
+ emit(this.views);
655
+ }
656
+ }
657
+
658
+ getLineHeight(line: number): number {
659
+ return Number.isInteger(line) && line >= 0 && line < this.text.split('\n').length ? this.charSize.height : 0;
660
+ }
661
+
662
+ getBaseLineHeight(): number {
663
+ return this.charSize.height;
664
+ }
665
+
666
+ onLineHeights(listener: Listener): () => void {
667
+ return subscribe(this.lineHeights, listener);
668
+ }
669
+
670
+ getContentHeight(): number {
671
+ return this.contentHeight;
672
+ }
673
+
547
674
  getSelection(): EditorRange {
548
675
  return this.selection ?? { start: this.caret, end: this.caret };
549
676
  }
@@ -675,7 +802,8 @@ export class FakeEditor implements Editor {
675
802
  return [...this.keys].some((handler) => handler(full));
676
803
  }
677
804
 
678
- applyEdits(edits: readonly EditorContentChange[]): boolean {
805
+ /* The fake keeps no undo history, so `mergeUndo` changes nothing here. It keeps no selection direction either, so a test of `mapSelections` keeping a backward selection needs the real editor. */
806
+ applyEdits(edits: readonly EditorContentChange[], options: EditorApplyEditsOptions = {}): boolean {
679
807
  if (this.readOnly || edits.length === 0) {
680
808
  return false;
681
809
  }
@@ -702,11 +830,14 @@ export class FakeEditor implements Editor {
702
830
  return offset + delta;
703
831
  };
704
832
  const moved = others.map((range) => ({ anchor: follow(range.anchor), head: follow(range.head) }));
705
- this.replaceText(text, 'command');
706
- this.otherSelections = moved.map((range) => ({
707
- start: positionIn(text, Math.min(range.anchor, range.head)),
708
- end: positionIn(text, Math.max(range.anchor, range.head))
709
- }));
833
+ const mapped = options.mapSelections && this.getSelections().map((range) => options.mapSelections!(range));
834
+ this.replaceText(text, 'command', mapped);
835
+ if (mapped === undefined) {
836
+ this.otherSelections = moved.map((range) => ({
837
+ start: positionIn(text, Math.min(range.anchor, range.head)),
838
+ end: positionIn(text, Math.max(range.anchor, range.head))
839
+ }));
840
+ }
710
841
  emit(this.changes);
711
842
  return true;
712
843
  }
@@ -724,6 +855,10 @@ export class FakeEditor implements Editor {
724
855
  this.readOnlyReason = reason;
725
856
  }
726
857
 
858
+ isReadOnly(): boolean {
859
+ return this.readOnly;
860
+ }
861
+
727
862
  /* The commands run, in order; the fake has no commands of its own to carry out. */
728
863
  readonly commands: EditorRunCommand[] = [];
729
864
 
@@ -742,8 +877,11 @@ export class FakeEditor implements Editor {
742
877
  this.textChanges.clear();
743
878
  this.carets.clear();
744
879
  this.hovers.clear();
880
+ this.inlayHovers.clear();
881
+ this.inlayHandlers.clear();
745
882
  this.attributionHovers.clear();
746
883
  this.views.clear();
884
+ this.lineHeights.clear();
747
885
  this.keys.clear();
748
886
  this.saves.clear();
749
887
  this.blurs.clear();
@@ -0,0 +1,165 @@
1
+ import { EditorSearchError, type EditorSearchClient } from './search-client.ts';
2
+
3
+ export interface FindSearchOptions {
4
+ readonly createClient: () => EditorSearchClient | null;
5
+ readonly schedule?: (callback: () => void, delay: number) => () => void;
6
+ readonly now?: () => number;
7
+ }
8
+
9
+ type SearchKind = 'live' | 'preview' | 'command';
10
+
11
+ interface SearchJob {
12
+ readonly kind: SearchKind;
13
+ readonly valid?: () => boolean;
14
+ readonly run: (client: EditorSearchClient, isCurrent: () => boolean) => Promise<unknown>;
15
+ readonly resolve: (value: unknown) => void;
16
+ readonly reject: (error: unknown) => void;
17
+ cancelled: boolean;
18
+ }
19
+
20
+ export function scheduleFind(callback: () => void, delay: number): () => void {
21
+ const timer = setTimeout(callback, delay);
22
+ return () => clearTimeout(timer);
23
+ }
24
+
25
+ function superseded(): EditorSearchError {
26
+ return new EditorSearchError('superseded', 'The find operation is no longer current.');
27
+ }
28
+
29
+ /* Commands finish atomically; short background requests reuse the worker before newer work starts. */
30
+ export class FindSearchQueue {
31
+ private client: EditorSearchClient | null | undefined;
32
+ private active: SearchJob | undefined;
33
+ private pending: SearchJob[] = [];
34
+ private cancelTimer: (() => void) | undefined;
35
+ private started = 0;
36
+ private disposed = false;
37
+ private epoch = 0;
38
+ private readonly options: FindSearchOptions;
39
+
40
+ constructor(options: FindSearchOptions) {
41
+ this.options = options;
42
+ }
43
+
44
+ get busy(): boolean {
45
+ return this.active !== undefined || this.pending.length > 0;
46
+ }
47
+
48
+ request<Result>(
49
+ run: (client: EditorSearchClient, isCurrent: () => boolean) => Promise<Result>,
50
+ kind: SearchKind = 'live',
51
+ valid?: () => boolean
52
+ ): Promise<Result> {
53
+ if (this.disposed) {
54
+ return Promise.reject(new EditorSearchError('disposed', 'The find session was disposed.'));
55
+ }
56
+ return new Promise((resolve, reject) => {
57
+ this.pending = this.pending.filter((job) => {
58
+ if (job.kind !== 'command' && (job.kind === kind || (kind !== 'preview' && job.kind === 'preview'))) {
59
+ job.cancelled = true;
60
+ job.reject(superseded());
61
+ return false;
62
+ }
63
+ return true;
64
+ });
65
+ this.pending.push({ kind, valid, run, resolve: (value) => resolve(value as Result), reject, cancelled: false });
66
+ this.pending.sort((left, right) => priority(left.kind) - priority(right.kind));
67
+ if (this.active === undefined) {
68
+ this.pump();
69
+ } else if (
70
+ (kind !== 'preview' || this.active.valid?.() === false) &&
71
+ (this.active.kind !== 'command' || this.active.valid?.() === false) &&
72
+ this.cancelTimer === undefined
73
+ ) {
74
+ this.cancelTimer = (this.options.schedule ?? scheduleFind)(
75
+ () => {
76
+ this.cancelTimer = undefined;
77
+ if (this.pending.length > 0 && this.active !== undefined && (this.active.kind !== 'command' || this.active.valid?.() === false)) {
78
+ this.active.cancelled = true;
79
+ this.active.reject(superseded());
80
+ this.client?.cancel();
81
+ }
82
+ },
83
+ Math.max(0, 200 - (this.now() - this.started))
84
+ );
85
+ }
86
+ });
87
+ }
88
+
89
+ private now(): number {
90
+ return this.options.now?.() ?? performance.now();
91
+ }
92
+
93
+ private pump(): void {
94
+ if (this.disposed || this.active !== undefined) {
95
+ return;
96
+ }
97
+ const job = this.pending.shift();
98
+ if (job === undefined) {
99
+ return;
100
+ }
101
+ this.active = job;
102
+ this.started = this.now();
103
+ const epoch = this.epoch;
104
+ const alive = (): boolean => !this.disposed && epoch === this.epoch && !job.cancelled;
105
+ const isCurrent = (): boolean => alive() && job.valid?.() !== false;
106
+ void Promise.resolve()
107
+ .then(() => {
108
+ if (!isCurrent()) {
109
+ throw superseded();
110
+ }
111
+ this.client ??= this.options.createClient();
112
+ if (this.client === null) {
113
+ throw new EditorSearchError('unavailable', 'Regex search needs a search worker.');
114
+ }
115
+ const client = this.client;
116
+ const guarded: EditorSearchClient = {
117
+ search: (...args) => (isCurrent() ? client.search(...args) : Promise.reject(superseded())),
118
+ match: (...args) => (isCurrent() ? client.match(...args) : Promise.reject(superseded())),
119
+ next: (...args) => (isCurrent() ? client.next(...args) : Promise.reject(superseded())),
120
+ replacements: (...args) => (isCurrent() ? client.replacements(...args) : Promise.reject(superseded())),
121
+ cancel: () => client.cancel(),
122
+ release: () => client.release(),
123
+ dispose: () => client.dispose()
124
+ };
125
+ return job.run(guarded, isCurrent);
126
+ })
127
+ .then((value) => {
128
+ if (alive()) {
129
+ job.resolve(value);
130
+ } else {
131
+ job.reject(superseded());
132
+ }
133
+ }, job.reject)
134
+ .finally(() => {
135
+ this.active = undefined;
136
+ this.cancelTimer?.();
137
+ this.cancelTimer = undefined;
138
+ this.pump();
139
+ });
140
+ }
141
+
142
+ release(): void {
143
+ this.epoch++;
144
+ this.active?.reject(superseded());
145
+ for (const job of this.pending) {
146
+ job.cancelled = true;
147
+ job.reject(superseded());
148
+ }
149
+ this.pending = [];
150
+ this.cancelTimer?.();
151
+ this.cancelTimer = undefined;
152
+ this.client?.release();
153
+ }
154
+
155
+ dispose(): void {
156
+ this.disposed = true;
157
+ this.release();
158
+ this.client?.dispose();
159
+ this.client = undefined;
160
+ }
161
+ }
162
+
163
+ function priority(kind: SearchKind): number {
164
+ return kind === 'command' ? 0 : kind === 'live' ? 1 : 2;
165
+ }