@adecore/editor 0.39.0 → 0.40.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 (53) hide show
  1. package/dist/controller.d.ts +1 -0
  2. package/dist/controller.js +17 -28
  3. package/dist/engine.js +2 -9
  4. package/dist/find.js +12 -13
  5. package/dist/layout.js +7 -2
  6. package/dist/paint.d.ts +2 -0
  7. package/dist/paint.js +23 -20
  8. package/dist/view.d.ts +3 -2
  9. package/dist/view.js +46 -59
  10. package/package.json +6 -6
  11. package/src/attribution.ts +14 -0
  12. package/src/code-block.ts +25 -0
  13. package/src/column-selection.ts +10 -0
  14. package/src/controller.ts +189 -28
  15. package/src/engine.ts +87 -9
  16. package/src/fake.ts +79 -0
  17. package/src/find-search.ts +20 -0
  18. package/src/find.ts +135 -14
  19. package/src/geometry-stops.ts +10 -0
  20. package/src/grammar-preparation.ts +23 -0
  21. package/src/index.ts +1 -0
  22. package/src/input.ts +13 -0
  23. package/src/keymap-table.ts +9 -0
  24. package/src/keymap.ts +25 -0
  25. package/src/layout-changes.ts +7 -0
  26. package/src/layout.ts +219 -3
  27. package/src/lens.ts +3 -0
  28. package/src/listeners.ts +1 -0
  29. package/src/metrics.ts +14 -0
  30. package/src/modifier-gesture.ts +17 -0
  31. package/src/native-input.ts +27 -0
  32. package/src/offsets.ts +10 -0
  33. package/src/outline.ts +38 -0
  34. package/src/overview.ts +13 -0
  35. package/src/paint.ts +140 -20
  36. package/src/php-html.ts +1 -0
  37. package/src/pointer.ts +50 -0
  38. package/src/scroll-animation.ts +10 -0
  39. package/src/scroll.ts +7 -0
  40. package/src/search-client.ts +32 -0
  41. package/src/search-worker.ts +38 -0
  42. package/src/semantic.ts +12 -0
  43. package/src/shiki-document.ts +19 -0
  44. package/src/shiki-worker.ts +68 -0
  45. package/src/shiki.ts +37 -0
  46. package/src/testing.ts +17 -0
  47. package/src/theme-scopes.ts +14 -0
  48. package/src/tokens.ts +31 -0
  49. package/src/touch.ts +16 -0
  50. package/src/tracked-range.ts +5 -0
  51. package/src/uniform-geometry.ts +10 -0
  52. package/src/view.ts +481 -75
  53. package/src/worker-client.ts +54 -0
package/src/testing.ts CHANGED
@@ -10,32 +10,40 @@ export function createPage(): { document: Document; window: Window & typeof glob
10
10
  const { document, window } = parseHTML('<html><body><div id="host"></div></body></html>');
11
11
  const prototype = (window as unknown as { HTMLTextAreaElement: { prototype: Record<string, unknown> } }).HTMLTextAreaElement.prototype;
12
12
  const selections = new WeakMap<object, { start: number; end: number; direction: string }>();
13
+
13
14
  const read = (element: object): { start: number; end: number; direction: string } => selections.get(element) ?? { start: 0, end: 0, direction: 'none' };
15
+
14
16
  Object.defineProperty(prototype, 'selectionStart', {
15
17
  get: function (this: object) {
16
18
  return read(this).start;
17
19
  },
18
20
  configurable: true
19
21
  });
22
+
20
23
  Object.defineProperty(prototype, 'selectionEnd', {
21
24
  get: function (this: object) {
22
25
  return read(this).end;
23
26
  },
24
27
  configurable: true
25
28
  });
29
+
26
30
  Object.defineProperty(prototype, 'selectionDirection', {
27
31
  get: function (this: object) {
28
32
  return read(this).direction;
29
33
  },
30
34
  configurable: true
31
35
  });
36
+
32
37
  prototype.setSelectionRange = function (this: object, start: number, end: number, direction = 'none'): void {
33
38
  selections.set(this, { start, end, direction });
34
39
  };
40
+
35
41
  const element = (window as unknown as { HTMLElement: { prototype: object } }).HTMLElement.prototype;
42
+
36
43
  for (const name of ['scrollTop', 'scrollLeft']) {
37
44
  Object.defineProperty(element, name, { value: 0, writable: true, configurable: true });
38
45
  }
46
+
39
47
  return {
40
48
  document: document as unknown as Document,
41
49
  window: window as unknown as Window & typeof globalThis,
@@ -61,6 +69,7 @@ export function press(window: Window, target: Element, key: string, options: Key
61
69
  const event = createEvent(window, 'keydown');
62
70
  Object.assign(event, { key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, isComposing: false, ...options });
63
71
  target.dispatchEvent(event);
72
+
64
73
  return event.defaultPrevented;
65
74
  }
66
75
 
@@ -86,6 +95,7 @@ export function clipboard(
86
95
  data: { text: string } = { text: '' }
87
96
  ): { prevented: boolean; text: string } {
88
97
  const event = createEvent(window, type);
98
+
89
99
  Object.assign(event, {
90
100
  clipboardData: {
91
101
  getData: () => data.text,
@@ -94,7 +104,9 @@ export function clipboard(
94
104
  }
95
105
  }
96
106
  });
107
+
97
108
  target.dispatchEvent(event);
109
+
98
110
  return { prevented: event.defaultPrevented, text: data.text };
99
111
  }
100
112
 
@@ -108,6 +120,7 @@ export function pointer(
108
120
  options: KeyOptions = {}
109
121
  ): void {
110
122
  const event = createEvent(window, type);
123
+
111
124
  Object.assign(event, {
112
125
  clientX: x,
113
126
  clientY: y,
@@ -120,6 +133,7 @@ export function pointer(
120
133
  metaKey: false,
121
134
  ...options
122
135
  });
136
+
123
137
  target.dispatchEvent(event);
124
138
  }
125
139
 
@@ -139,13 +153,16 @@ export interface MountedEditor {
139
153
  /* An editor on a page of its own, with the helpers that drive it the way a person would. */
140
154
  export function mountEditor(options: Partial<EditorOptions> = {}, engineOptions: Partial<SmartEditorEngineOptions> = {}): MountedEditor {
141
155
  const page = createPage();
156
+
142
157
  const editor = createSmartEditorEngine({ tokenizer: async () => null, ...engineOptions }).mount(page.host, {
143
158
  text: 'one\ntwo\nthree',
144
159
  theme: 'dark',
145
160
  ...options
146
161
  });
162
+
147
163
  const input = page.host.querySelector('textarea')!;
148
164
  const viewport = page.host.querySelector('.se-viewport') as HTMLElement;
165
+
149
166
  return {
150
167
  editor,
151
168
  page,
@@ -26,19 +26,25 @@ function selectorScore(selector: string, stack: readonly string[]): number | nul
26
26
  const words = selector.trim().split(/\s+/);
27
27
  const last = words[words.length - 1]!;
28
28
  const innermost = stack[stack.length - 1];
29
+
29
30
  if (innermost === undefined || !nameMatches(last, innermost)) {
30
31
  return null;
31
32
  }
33
+
32
34
  let at = stack.length - 2;
35
+
33
36
  for (let word = words.length - 2; word >= 0; word--) {
34
37
  while (at >= 0 && !nameMatches(words[word]!, stack[at]!)) {
35
38
  at--;
36
39
  }
40
+
37
41
  if (at < 0) {
38
42
  return null;
39
43
  }
44
+
40
45
  at--;
41
46
  }
47
+
42
48
  return last.length * 100 + words.length;
43
49
  }
44
50
 
@@ -55,21 +61,29 @@ export function scopeColorsOf(rules: readonly ThemeRule[]): ScopeColors {
55
61
  order
56
62
  }))
57
63
  );
64
+
58
65
  const cache = new Map<string, ScopeStyle | undefined>();
66
+
59
67
  return (stack) => {
60
68
  const key = stack.join(' ');
69
+
61
70
  if (cache.has(key)) {
62
71
  return cache.get(key);
63
72
  }
73
+
64
74
  let best: { score: number; order: number; style: ScopeStyle } | undefined;
75
+
65
76
  for (const entry of flat) {
66
77
  const score = entry.selector === '' ? null : selectorScore(entry.selector, stack);
78
+
67
79
  if (score !== null && (best === undefined || score > best.score || (score === best.score && entry.order > best.order))) {
68
80
  best = { score, order: entry.order, style: entry.style };
69
81
  }
70
82
  }
83
+
71
84
  const style = best !== undefined && best.style.color !== '' ? best.style : undefined;
72
85
  cache.set(key, style);
86
+
73
87
  return style;
74
88
  };
75
89
  }
package/src/tokens.ts CHANGED
@@ -63,16 +63,21 @@ export class TokenCache {
63
63
  /* The colors of text that is not part of the document, a line at a time, or null without a tokenizer. */
64
64
  tokenizeText(lines: readonly string[]): (readonly LineToken[])[] | null {
65
65
  const tokenizer = this.tokenizer;
66
+
66
67
  if (tokenizer === null) {
67
68
  return null;
68
69
  }
70
+
69
71
  let state: unknown = null;
72
+
70
73
  return lines.map((text) => {
71
74
  if (text.length > LONGEST_COLORED_LINE) {
72
75
  return [{ length: text.length, color: '', fontStyle: 0 }];
73
76
  }
77
+
74
78
  const result = tokenizer.tokenizeLine(text, state);
75
79
  state = result.state;
80
+
76
81
  return result.tokens;
77
82
  });
78
83
  }
@@ -87,27 +92,35 @@ export class TokenCache {
87
92
  const lineCount = this.source.getLineCount();
88
93
  const delta = lineCount - this.lines;
89
94
  this.lines = lineCount;
95
+
90
96
  if (this.tokenizer === null) {
91
97
  return;
92
98
  }
99
+
93
100
  const first = batches[0]?.[0];
101
+
94
102
  if (first === undefined) {
95
103
  this.entries = [];
96
104
  this.staleFrom = Number.POSITIVE_INFINITY;
97
105
  this.frontier = 0;
98
106
  this.forgetPivot();
107
+
99
108
  return;
100
109
  }
110
+
101
111
  const firstLine = lineOf(first.from);
102
112
  const changes = batches.length === 1 ? batches[0]! : [];
103
113
  let shift = 0;
114
+
104
115
  for (let i = 0; i < changes.length - 1; i++) {
105
116
  shift += changes[i]!.insertedLength - (changes[i]!.to - changes[i]!.from);
106
117
  }
118
+
107
119
  const last = changes.at(-1);
108
120
  const lastNew = last === undefined ? -1 : lineOf(last.from + shift + last.insertedLength);
109
121
  const lastOld = lastNew - delta;
110
122
  this.frontier = Math.min(this.frontier, firstLine);
123
+
111
124
  if (last === undefined && Number.isFinite(this.staleFrom)) {
112
125
  // A history batch has no single mapping for the old grammar boundary.
113
126
  this.staleFrom = 0;
@@ -117,14 +130,19 @@ export class TokenCache {
117
130
  } else if (this.staleFrom > firstLine) {
118
131
  this.staleFrom = firstLine;
119
132
  }
133
+
120
134
  if (last === undefined || lastOld < firstLine || this.entries.length <= firstLine) {
121
135
  this.entries.length = Math.min(this.entries.length, firstLine);
136
+
122
137
  if (this.staleFrom >= this.entries.length) {
123
138
  this.staleFrom = Number.POSITIVE_INFINITY;
124
139
  }
140
+
125
141
  this.forgetPivot();
142
+
126
143
  return;
127
144
  }
145
+
128
146
  const old = this.entries[lastOld];
129
147
  this.pivotLine = old ? lastNew : -1;
130
148
  this.pivotState = old?.state;
@@ -138,9 +156,11 @@ export class TokenCache {
138
156
  if (this.tokenizer?.stale?.() !== true) {
139
157
  return false;
140
158
  }
159
+
141
160
  this.frontier = 0;
142
161
  this.staleFrom = 0;
143
162
  this.forgetPivot();
163
+
144
164
  return true;
145
165
  }
146
166
 
@@ -151,45 +171,56 @@ export class TokenCache {
151
171
  */
152
172
  advance(until: number, budget: number, now: () => number = () => performance.now()): { from: number; to: number } | null {
153
173
  const tokenizer = this.tokenizer;
174
+
154
175
  if (tokenizer === null) {
155
176
  return null;
156
177
  }
178
+
157
179
  this.revalidate();
158
180
  const count = this.source.getLineCount();
159
181
  const limit = Math.min(until, count - 1);
160
182
  const deadline = now() + budget;
161
183
  const from = this.frontier;
184
+
162
185
  while (this.frontier <= limit && this.frontier < count) {
163
186
  const line = this.frontier;
164
187
  const text = this.source.getLineText(line);
165
188
  const state = line === 0 ? null : (this.entries[line - 1]?.state ?? null);
166
189
  const previous = this.entries[line];
167
190
  let next: Entry;
191
+
168
192
  if (text.length > LONGEST_COLORED_LINE) {
169
193
  next = { tokens: [{ length: text.length, color: '', fontStyle: 0 }], state };
170
194
  } else {
171
195
  const result = tokenizer.tokenizeLine(text, state);
172
196
  next = { tokens: result.tokens, state: result.state };
173
197
  }
198
+
174
199
  this.entries[line] = next;
175
200
  this.frontier = line + 1;
176
201
  const fresh = line < this.staleFrom;
202
+
177
203
  if (line === this.staleFrom) {
178
204
  this.staleFrom = line + 1 >= this.entries.length ? Number.POSITIVE_INFINITY : line + 1;
179
205
  }
206
+
180
207
  const settled =
181
208
  fresh &&
182
209
  ((line === this.pivotLine && tokenizer.sameState(this.pivotState, next.state)) ||
183
210
  (previous !== undefined && line >= this.reusableFrom && tokenizer.sameState(previous.state, next.state)));
211
+
184
212
  if (settled) {
185
213
  this.frontier = Math.min(this.entries.length, this.staleFrom);
186
214
  this.forgetPivot();
215
+
187
216
  break;
188
217
  }
218
+
189
219
  if (now() > deadline) {
190
220
  break;
191
221
  }
192
222
  }
223
+
193
224
  return this.frontier > from ? { from, to: this.frontier - 1 } : null;
194
225
  }
195
226
 
package/src/touch.ts CHANGED
@@ -50,6 +50,7 @@ export class TouchSelection {
50
50
  start(event: PointerEvent, area: 'text' | 'gutter' = 'text', taken?: () => boolean): void {
51
51
  this.cancel();
52
52
  this.contact = { id: event.pointerId, x: event.clientX, y: event.clientY, area, moved: false, pressed: false, taken };
53
+
53
54
  if (area === 'text') {
54
55
  this.timer = setTimeout(() => this.press(), LONG_PRESS_MS);
55
56
  }
@@ -57,9 +58,11 @@ export class TouchSelection {
57
58
 
58
59
  move(event: PointerEvent): void {
59
60
  const contact = this.contact;
61
+
60
62
  if (contact === undefined || event.pointerId !== contact.id || contact.moved) {
61
63
  return;
62
64
  }
65
+
63
66
  if (Math.abs(event.clientX - contact.x) > TAP_SLOP || Math.abs(event.clientY - contact.y) > TAP_SLOP) {
64
67
  contact.moved = true;
65
68
  this.clearTimer();
@@ -69,13 +72,17 @@ export class TouchSelection {
69
72
  /* The finger lifted: a tap that did not scroll and was not a long press lands here. */
70
73
  end(event: PointerEvent): void {
71
74
  const contact = this.contact;
75
+
72
76
  if (contact === undefined || event.pointerId !== contact.id) {
73
77
  return;
74
78
  }
79
+
75
80
  this.cancel();
81
+
76
82
  if (event.type === 'pointercancel' || contact.moved || contact.pressed) {
77
83
  return;
78
84
  }
85
+
79
86
  this.tap(contact, event);
80
87
  }
81
88
 
@@ -87,20 +94,26 @@ export class TouchSelection {
87
94
  const { model } = this.host;
88
95
  const offset = this.host.offsetAt(contact.x, contact.y);
89
96
  this.host.focus();
97
+
90
98
  if (contact.area === 'gutter') {
91
99
  const line = this.host.lineAt(offset);
92
100
  model.setSelections([{ anchor: line.from, head: line.to }]);
101
+
93
102
  return;
94
103
  }
104
+
95
105
  if (contact.taken?.()) {
96
106
  return;
97
107
  }
108
+
98
109
  const last = this.lastTap;
110
+
99
111
  const double =
100
112
  last !== undefined &&
101
113
  event.timeStamp - last.time <= DOUBLE_TAP_MS &&
102
114
  Math.abs(contact.x - last.x) <= DOUBLE_TAP_DISTANCE &&
103
115
  Math.abs(contact.y - last.y) <= DOUBLE_TAP_DISTANCE;
116
+
104
117
  this.lastTap = double ? undefined : { time: event.timeStamp, x: contact.x, y: contact.y };
105
118
  const word = double ? model.wordSelectionAt(offset, this.host.camelHumps()) : null;
106
119
  model.setSelections([word === null ? { anchor: offset, head: offset } : { anchor: word.from, head: word.to }]);
@@ -109,9 +122,11 @@ export class TouchSelection {
109
122
  private press(): void {
110
123
  const contact = this.contact;
111
124
  this.timer = undefined;
125
+
112
126
  if (contact === undefined || contact.moved) {
113
127
  return;
114
128
  }
129
+
115
130
  contact.pressed = true;
116
131
  this.lastTap = undefined;
117
132
  const offset = this.host.offsetAt(contact.x, contact.y);
@@ -124,6 +139,7 @@ export class TouchSelection {
124
139
  if (this.timer !== undefined) {
125
140
  clearTimeout(this.timer);
126
141
  }
142
+
127
143
  this.timer = undefined;
128
144
  }
129
145
 
@@ -8,15 +8,20 @@ import type { DocumentChange } from '@adecore/editor-core';
8
8
  */
9
9
  export function mapTrackedRange(from: number, to: number, changes: readonly DocumentChange[]): { from: number; to: number } | null {
10
10
  let delta = 0;
11
+
11
12
  for (const change of changes) {
12
13
  if (change.to <= from) {
13
14
  delta += change.insertedLength - (change.to - change.from);
15
+
14
16
  continue;
15
17
  }
18
+
16
19
  if (change.from >= to) {
17
20
  break;
18
21
  }
22
+
19
23
  return null;
20
24
  }
25
+
21
26
  return { from: from + delta, to: to + delta };
22
27
  }
@@ -10,21 +10,28 @@ export function uniformGeometry(
10
10
  const rowStarts = [0, ...breaks];
11
11
  const firstLength = rowStarts[1] ?? line.text.length;
12
12
  let continuationLength = 0;
13
+
13
14
  for (let row = 1; row < rowStarts.length; row++) {
14
15
  continuationLength = Math.max(continuationLength, (rowStarts[row + 1] ?? line.text.length) - rowStarts[row]!);
15
16
  }
17
+
16
18
  // A shared prefix preserves repeated floating-point addition exactly, even for fallback font metrics.
17
19
  if (Math.max(firstLength, continuationLength) > 4096) {
18
20
  return null;
19
21
  }
22
+
20
23
  const positions = (start: number, length: number): number[] => {
21
24
  const result = [start];
25
+
22
26
  for (let index = 0; index < length; index++) {
23
27
  result.push(result.at(-1)! + metrics.charWidth);
24
28
  }
29
+
25
30
  return result;
26
31
  };
32
+
27
33
  const uniform = { first: positions(0, firstLength), continuation: positions(indent, continuationLength) };
34
+
28
35
  const geometry: LineGeometry = {
29
36
  start: line.start,
30
37
  end: line.end,
@@ -43,15 +50,18 @@ export function uniformGeometry(
43
50
  width: 0,
44
51
  uniform
45
52
  };
53
+
46
54
  for (let row = 0; row < rowStarts.length; row++) {
47
55
  const from = rowStarts[row]!;
48
56
  const to = rowStarts[row + 1] ?? line.text.length;
49
57
  const end = uniform[row === 0 ? 'first' : 'continuation'][to - from]!;
50
58
  geometry.rowEnds.push(end);
51
59
  geometry.width = Math.max(geometry.width, end);
60
+
52
61
  if (from < to) {
53
62
  geometry.runs.push({ from, to, x: row === 0 ? 0 : indent, subRow: row, text: line.text.slice(from, to) });
54
63
  }
55
64
  }
65
+
56
66
  return geometry;
57
67
  }