@adecore/editor 0.0.1 → 0.17.0-beta.1

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 (107) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +82 -0
  3. package/dist/attribution.d.ts +28 -0
  4. package/dist/attribution.js +55 -0
  5. package/dist/code-block.d.ts +2 -0
  6. package/dist/code-block.js +89 -0
  7. package/dist/column-selection.d.ts +9 -0
  8. package/dist/column-selection.js +52 -0
  9. package/dist/controller.d.ts +64 -0
  10. package/dist/controller.js +772 -0
  11. package/dist/editor.css +813 -0
  12. package/dist/engine.d.ts +2 -0
  13. package/dist/engine.js +604 -0
  14. package/dist/fake.d.ts +190 -0
  15. package/dist/fake.js +658 -0
  16. package/dist/find.d.ts +30 -0
  17. package/dist/find.js +108 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +4 -0
  20. package/dist/input.d.ts +9 -0
  21. package/dist/input.js +57 -0
  22. package/dist/keymap-table.d.ts +102 -0
  23. package/dist/keymap-table.js +130 -0
  24. package/dist/keymap.d.ts +36 -0
  25. package/dist/keymap.js +163 -0
  26. package/dist/layout.d.ts +167 -0
  27. package/dist/layout.js +583 -0
  28. package/dist/lens.d.ts +3 -0
  29. package/dist/lens.js +29 -0
  30. package/dist/listeners.d.ts +3 -0
  31. package/dist/listeners.js +12 -0
  32. package/dist/metrics.d.ts +8 -0
  33. package/dist/metrics.js +54 -0
  34. package/dist/modifier-gesture.d.ts +24 -0
  35. package/dist/modifier-gesture.js +98 -0
  36. package/dist/native-input.d.ts +26 -0
  37. package/dist/native-input.js +147 -0
  38. package/dist/offsets.d.ts +2 -0
  39. package/dist/offsets.js +14 -0
  40. package/dist/outline.d.ts +55 -0
  41. package/dist/outline.js +181 -0
  42. package/dist/overview.d.ts +17 -0
  43. package/dist/overview.js +55 -0
  44. package/dist/paint.d.ts +86 -0
  45. package/dist/paint.js +481 -0
  46. package/dist/php-html.d.ts +9 -0
  47. package/dist/php-html.js +36 -0
  48. package/dist/pointer.d.ts +49 -0
  49. package/dist/pointer.js +243 -0
  50. package/dist/scroll-animation.d.ts +3 -0
  51. package/dist/scroll-animation.js +45 -0
  52. package/dist/scroll.d.ts +28 -0
  53. package/dist/scroll.js +76 -0
  54. package/dist/semantic.d.ts +8 -0
  55. package/dist/semantic.js +47 -0
  56. package/dist/shiki.d.ts +7 -0
  57. package/dist/shiki.js +106 -0
  58. package/dist/smart-keys.d.ts +3 -0
  59. package/dist/smart-keys.js +14 -0
  60. package/dist/testing.d.ts +34 -0
  61. package/dist/testing.js +117 -0
  62. package/dist/theme-scopes.d.ts +9 -0
  63. package/dist/theme-scopes.js +61 -0
  64. package/dist/tokens.d.ts +28 -0
  65. package/dist/tokens.js +152 -0
  66. package/dist/tracked-range.d.ts +5 -0
  67. package/dist/tracked-range.js +20 -0
  68. package/dist/types.d.ts +342 -0
  69. package/dist/types.js +1 -0
  70. package/dist/view.d.ts +382 -0
  71. package/dist/view.js +2134 -0
  72. package/package.json +63 -3
  73. package/src/attribution.ts +85 -0
  74. package/src/code-block.ts +100 -0
  75. package/src/column-selection.ts +63 -0
  76. package/src/controller.ts +838 -0
  77. package/src/editor.css +813 -0
  78. package/src/engine.ts +757 -0
  79. package/src/fake.ts +831 -0
  80. package/src/find.ts +128 -0
  81. package/src/index.ts +69 -0
  82. package/src/input.ts +63 -0
  83. package/src/keymap-table.ts +163 -0
  84. package/src/keymap.ts +204 -0
  85. package/src/layout.ts +754 -0
  86. package/src/lens.ts +32 -0
  87. package/src/listeners.ts +15 -0
  88. package/src/metrics.ts +65 -0
  89. package/src/modifier-gesture.ts +115 -0
  90. package/src/native-input.ts +179 -0
  91. package/src/offsets.ts +16 -0
  92. package/src/outline.ts +248 -0
  93. package/src/overview.ts +80 -0
  94. package/src/paint.ts +608 -0
  95. package/src/php-html.ts +43 -0
  96. package/src/pointer.ts +326 -0
  97. package/src/scroll-animation.ts +48 -0
  98. package/src/scroll.ts +102 -0
  99. package/src/semantic.ts +54 -0
  100. package/src/shiki.ts +118 -0
  101. package/src/smart-keys.ts +17 -0
  102. package/src/testing.ts +157 -0
  103. package/src/theme-scopes.ts +75 -0
  104. package/src/tokens.ts +175 -0
  105. package/src/tracked-range.ts +22 -0
  106. package/src/types.ts +656 -0
  107. package/src/view.ts +2436 -0
package/src/shiki.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { GrammarState, Highlighter } from 'shiki';
2
+ import { documentLanguageOf, loadDocumentLanguage } from './php-html.ts';
3
+ import { scopeColorsOf, type ThemeRule } from './theme-scopes.ts';
4
+ import type { LineToken, LineTokenizer, ScopeColorSource, TokenizerSource } from './types.ts';
5
+
6
+ type LanguageId = Parameters<Highlighter['loadLanguage']>[0];
7
+ type ThemeId = Parameters<Highlighter['loadTheme']>[0];
8
+ type TokenOptions = Parameters<Highlighter['codeToTokensBase']>[1];
9
+
10
+ export { documentLanguageOf };
11
+
12
+ const PLAIN_TEXT = new Set(['', 'text', 'txt', 'plain', 'plaintext']);
13
+
14
+ /* Colors one line at a time and hands the grammar's state from one line to the next. A grammar Shiki rebuilt does not know the states of the old one. */
15
+ function lineTokenizer(highlighter: Highlighter, language: string, theme: string): LineTokenizer {
16
+ let grammar = highlighter.getLanguage(language);
17
+ return {
18
+ stale() {
19
+ const current = highlighter.getLanguage(language);
20
+ const changed = current !== grammar;
21
+ grammar = current;
22
+ return changed;
23
+ },
24
+ tokenizeLine(text, state) {
25
+ const options = { lang: language, theme, grammarState: (state ?? undefined) as GrammarState | undefined } as TokenOptions;
26
+ const lines = highlighter.codeToTokensBase(text, options);
27
+ const tokens: LineToken[] = (lines[0] ?? []).map((token) => ({
28
+ length: token.content.length,
29
+ color: token.color ?? '',
30
+ fontStyle: token.fontStyle ?? 0
31
+ }));
32
+ return { tokens, state: highlighter.getLastGrammarState(lines) };
33
+ },
34
+ sameState(a, b) {
35
+ if (a === b) {
36
+ return true;
37
+ }
38
+ const left = (a as GrammarState | null)?.getInternalStack(theme);
39
+ const right = (b as GrammarState | null)?.getInternalStack(theme);
40
+ return left !== undefined && right !== undefined && left.equals(right);
41
+ }
42
+ };
43
+ }
44
+
45
+ /* The words of a document that name a language, an alias or the id itself, and not part of a longer word. */
46
+ function mentions(text: string, names: readonly string[]): boolean {
47
+ return names.some((name) => new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'i').test(text));
48
+ }
49
+
50
+ /*
51
+ * A grammar embeds some languages only on demand (Vue's `lang="scss"` styles, the fences of Markdown),
52
+ * and Shiki builds the host grammar anew each time one of them loads later, which drops the states
53
+ * of every line colored before. So the ones a document names are loaded together with the host.
54
+ */
55
+ async function loadEmbedded(highlighter: Highlighter, grammar: string, text: string): Promise<void> {
56
+ const { bundledLanguages, bundledLanguagesInfo } = await import('shiki');
57
+ const bundled = bundledLanguages[grammar as keyof typeof bundledLanguages];
58
+ if (bundled === undefined) {
59
+ return;
60
+ }
61
+ const registrations = (await bundled()).default as readonly { readonly name: string; readonly embeddedLangsLazy?: readonly string[] }[];
62
+ const lazy = registrations.find((registration) => registration.name === grammar)?.embeddedLangsLazy ?? [];
63
+ const named = lazy.filter((id) => {
64
+ const aliases = bundledLanguagesInfo.find((info) => info.id === id)?.aliases ?? [];
65
+ return !highlighter.getLoadedLanguages().includes(id) && mentions(text, [id, ...aliases]);
66
+ });
67
+ await Promise.allSettled(named.map((id) => highlighter.loadLanguage(id as LanguageId)));
68
+ }
69
+
70
+ /*
71
+ * Loads the grammar of a language with what a document in it embeds, and says whether Shiki has it.
72
+ * `text` is the document, which decides the embedded languages that load only when named.
73
+ */
74
+ export async function loadGrammar(highlighter: Highlighter, grammar: string, text = ''): Promise<boolean> {
75
+ await loadDocumentLanguage(highlighter, grammar);
76
+ if (!highlighter.getLoadedLanguages().includes(grammar)) {
77
+ await highlighter.loadLanguage(grammar as LanguageId);
78
+ }
79
+ await loadEmbedded(highlighter, grammar, text);
80
+ return highlighter.getLoadedLanguages().includes(grammar);
81
+ }
82
+
83
+ /*
84
+ * Colors with the viewer's highlighter, so a grammar loads once for both. A language Shiki has no
85
+ * grammar for, or a theme it cannot load, is plain text.
86
+ */
87
+ export function shikiTokenizers(highlighter: () => Promise<Highlighter>): TokenizerSource {
88
+ return async (language, theme, text) => {
89
+ if (language === undefined || PLAIN_TEXT.has(language)) {
90
+ return null;
91
+ }
92
+ const grammar = documentLanguageOf(language);
93
+ const shiki = await highlighter();
94
+ try {
95
+ if (!shiki.getLoadedThemes().includes(theme)) {
96
+ await shiki.loadTheme(theme as ThemeId);
97
+ }
98
+ return (await loadGrammar(shiki, grammar, text)) ? lineTokenizer(shiki, grammar, theme) : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ };
103
+ }
104
+
105
+ /* The colors of a theme's own rules by scope, so what a language server classifies is drawn in the theme's colors. */
106
+ export function shikiScopeColors(highlighter: () => Promise<Highlighter>): ScopeColorSource {
107
+ return async (theme) => {
108
+ const shiki = await highlighter();
109
+ try {
110
+ if (!shiki.getLoadedThemes().includes(theme)) {
111
+ await shiki.loadTheme(theme as ThemeId);
112
+ }
113
+ return scopeColorsOf(shiki.getTheme(theme).settings as readonly ThemeRule[]);
114
+ } catch {
115
+ return null;
116
+ }
117
+ };
118
+ }
@@ -0,0 +1,17 @@
1
+ import type { EditorSmartKeys } from './types.ts';
2
+
3
+ export const DEFAULT_SMART_KEYS: EditorSmartKeys = {
4
+ autoPairBrackets: true,
5
+ autoPairQuotes: true,
6
+ surroundSelection: true,
7
+ tabOutOfClosers: true,
8
+ smartIndentOnEnter: true,
9
+ indentOnPaste: true,
10
+ smartSemicolon: true,
11
+ camelHumps: false
12
+ };
13
+
14
+ /* The keys with what a host left out filled in from the defaults. */
15
+ export function resolveSmartKeys(keys: Partial<EditorSmartKeys> | undefined): EditorSmartKeys {
16
+ return { ...DEFAULT_SMART_KEYS, ...keys };
17
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { parseHTML } from 'linkedom';
2
+ import { createSmartEditorEngine } from './engine.ts';
3
+ import type { Editor, EditorOptions, SmartEditorEngineOptions } from './types.ts';
4
+
5
+ /*
6
+ * A page for tests: linkedom has no layout, so every box is the editor's fallback size, and its
7
+ * textarea has no selection of its own, which the editor's input sink reads and writes.
8
+ */
9
+ export function createPage(): { document: Document; window: Window & typeof globalThis; host: HTMLElement } {
10
+ const { document, window } = parseHTML('<html><body><div id="host"></div></body></html>');
11
+ const prototype = (window as unknown as { HTMLTextAreaElement: { prototype: Record<string, unknown> } }).HTMLTextAreaElement.prototype;
12
+ const selections = new WeakMap<object, { start: number; end: number; direction: string }>();
13
+ const read = (element: object): { start: number; end: number; direction: string } => selections.get(element) ?? { start: 0, end: 0, direction: 'none' };
14
+ Object.defineProperty(prototype, 'selectionStart', {
15
+ get: function (this: object) {
16
+ return read(this).start;
17
+ },
18
+ configurable: true
19
+ });
20
+ Object.defineProperty(prototype, 'selectionEnd', {
21
+ get: function (this: object) {
22
+ return read(this).end;
23
+ },
24
+ configurable: true
25
+ });
26
+ Object.defineProperty(prototype, 'selectionDirection', {
27
+ get: function (this: object) {
28
+ return read(this).direction;
29
+ },
30
+ configurable: true
31
+ });
32
+ prototype.setSelectionRange = function (this: object, start: number, end: number, direction = 'none'): void {
33
+ selections.set(this, { start, end, direction });
34
+ };
35
+ const element = (window as unknown as { HTMLElement: { prototype: object } }).HTMLElement.prototype;
36
+ for (const name of ['scrollTop', 'scrollLeft']) {
37
+ Object.defineProperty(element, name, { value: 0, writable: true, configurable: true });
38
+ }
39
+ return {
40
+ document: document as unknown as Document,
41
+ window: window as unknown as Window & typeof globalThis,
42
+ host: document.getElementById('host') as unknown as HTMLElement
43
+ };
44
+ }
45
+
46
+ export interface KeyOptions {
47
+ ctrlKey?: boolean;
48
+ metaKey?: boolean;
49
+ altKey?: boolean;
50
+ shiftKey?: boolean;
51
+ }
52
+
53
+ /* Fires a keydown at the element and says whether the editor took the key. */
54
+ export function press(window: Window, target: Element, key: string, options: KeyOptions = {}): boolean {
55
+ const event = new (window as unknown as { Event: typeof Event }).Event('keydown', { bubbles: true, cancelable: true });
56
+ Object.assign(event, { key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, isComposing: false, ...options });
57
+ target.dispatchEvent(event);
58
+ return event.defaultPrevented;
59
+ }
60
+
61
+ /* Fires a keyup at the element, the other half of `press`. */
62
+ export function release(window: Window, target: Element, key: string, options: KeyOptions = {}): void {
63
+ const event = new (window as unknown as { Event: typeof Event }).Event('keyup', { bubbles: true, cancelable: true });
64
+ Object.assign(event, { key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, isComposing: false, ...options });
65
+ target.dispatchEvent(event);
66
+ }
67
+
68
+ /* What a browser sends before it puts text in a textarea. */
69
+ export function typeInto(window: Window, target: Element, text: string): void {
70
+ const event = new (window as unknown as { Event: typeof Event }).Event('beforeinput', { bubbles: true, cancelable: true });
71
+ Object.assign(event, { inputType: 'insertText', data: text, isComposing: false });
72
+ target.dispatchEvent(event);
73
+ }
74
+
75
+ /* A clipboard event with the data it carries; what a copy or cut sets is read back through `data`. */
76
+ export function clipboard(
77
+ window: Window,
78
+ target: Element,
79
+ type: 'copy' | 'cut' | 'paste',
80
+ data: { text: string } = { text: '' }
81
+ ): { prevented: boolean; text: string } {
82
+ const event = new (window as unknown as { Event: typeof Event }).Event(type, { bubbles: true, cancelable: true });
83
+ Object.assign(event, {
84
+ clipboardData: {
85
+ getData: () => data.text,
86
+ setData: (_format: string, value: string) => {
87
+ data.text = value;
88
+ }
89
+ }
90
+ });
91
+ target.dispatchEvent(event);
92
+ return { prevented: event.defaultPrevented, text: data.text };
93
+ }
94
+
95
+ /* A pointer event at a point of the screen; the page has no scroll or scale, so it is also the point of the content. */
96
+ export function pointer(
97
+ window: Window,
98
+ target: Element,
99
+ type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
100
+ x: number,
101
+ y: number,
102
+ options: KeyOptions = {}
103
+ ): void {
104
+ const event = new (window as unknown as { Event: typeof Event }).Event(type, { bubbles: true, cancelable: true });
105
+ Object.assign(event, {
106
+ clientX: x,
107
+ clientY: y,
108
+ button: 0,
109
+ pointerId: 1,
110
+ detail: 0,
111
+ shiftKey: false,
112
+ altKey: false,
113
+ ctrlKey: false,
114
+ metaKey: false,
115
+ ...options
116
+ });
117
+ target.dispatchEvent(event);
118
+ }
119
+
120
+ export interface MountedEditor {
121
+ editor: Editor;
122
+ page: ReturnType<typeof createPage>;
123
+ input: HTMLTextAreaElement;
124
+ viewport: HTMLElement;
125
+ press(key: string, modifiers?: KeyOptions): boolean;
126
+ release(key: string, modifiers?: KeyOptions): void;
127
+ type(text: string): void;
128
+ /* Copies, cuts or pastes through the clipboard; the text a copy or cut left comes back. */
129
+ clip(type: 'copy' | 'cut' | 'paste', text?: string): string;
130
+ click(x: number, y: number, options?: KeyOptions): void;
131
+ }
132
+
133
+ /* An editor on a page of its own, with the helpers that drive it the way a person would. */
134
+ export function mountEditor(options: Partial<EditorOptions> = {}, engineOptions: Partial<SmartEditorEngineOptions> = {}): MountedEditor {
135
+ const page = createPage();
136
+ const editor = createSmartEditorEngine({ tokenizer: async () => null, ...engineOptions }).mount(page.host, {
137
+ text: 'one\ntwo\nthree',
138
+ theme: 'dark',
139
+ ...options
140
+ });
141
+ const input = page.host.querySelector('textarea')!;
142
+ const viewport = page.host.querySelector('.se-viewport') as HTMLElement;
143
+ return {
144
+ editor,
145
+ page,
146
+ input,
147
+ viewport,
148
+ press: (key, modifiers = {}) => press(page.window, input, key, modifiers),
149
+ release: (key, modifiers = {}) => release(page.window, input, key, modifiers),
150
+ type: (text) => typeInto(page.window, input, text),
151
+ clip: (type, text = '') => clipboard(page.window, input, type, { text }).text,
152
+ click: (x, y, modifiers = {}) => {
153
+ pointer(page.window, viewport, 'pointerdown', x, y, modifiers);
154
+ pointer(page.window, viewport, 'pointerup', x, y);
155
+ }
156
+ };
157
+ }
@@ -0,0 +1,75 @@
1
+ import type { ScopeColors, ScopeStyle } from './types.ts';
2
+
3
+ /* The part of a TextMate theme's token colors this reads. */
4
+ export interface ThemeRule {
5
+ readonly scope?: string | readonly string[];
6
+ readonly settings: { readonly foreground?: string; readonly fontStyle?: string };
7
+ }
8
+
9
+ const FONT_STYLES: Record<string, number> = { italic: 1, bold: 2, underline: 4, strikethrough: 8 };
10
+
11
+ function fontStyleOf(text: string | undefined): number {
12
+ return (text ?? '').split(/\s+/).reduce((flags, word) => flags | (FONT_STYLES[word] ?? 0), 0);
13
+ }
14
+
15
+ function nameMatches(selector: string, scope: string): boolean {
16
+ return scope === selector || scope.startsWith(`${selector}.`);
17
+ }
18
+
19
+ /*
20
+ * How well a selector fits a stack of scopes, outermost first, or null when it does not. As in a
21
+ * TextMate theme the last word has to match the innermost scope and the words before it some outer
22
+ * ones in order; the longer the name it matched, the better, and a selector with more words beats one
23
+ * with fewer.
24
+ */
25
+ function selectorScore(selector: string, stack: readonly string[]): number | null {
26
+ const words = selector.trim().split(/\s+/);
27
+ const last = words[words.length - 1]!;
28
+ const innermost = stack[stack.length - 1];
29
+ if (innermost === undefined || !nameMatches(last, innermost)) {
30
+ return null;
31
+ }
32
+ let at = stack.length - 2;
33
+ for (let word = words.length - 2; word >= 0; word--) {
34
+ while (at >= 0 && !nameMatches(words[word]!, stack[at]!)) {
35
+ at--;
36
+ }
37
+ if (at < 0) {
38
+ return null;
39
+ }
40
+ at--;
41
+ }
42
+ return last.length * 100 + words.length;
43
+ }
44
+
45
+ /*
46
+ * What a theme draws a scope stack in, the way its own grammar's tokens are colored: the rule that
47
+ * fits best wins and a later rule beats an equal one. Undefined where no rule colors it, which is
48
+ * where the editor keeps what the grammar said.
49
+ */
50
+ export function scopeColorsOf(rules: readonly ThemeRule[]): ScopeColors {
51
+ const flat = rules.flatMap((rule, order) =>
52
+ (typeof rule.scope === 'string' ? rule.scope.split(',') : (rule.scope ?? [])).map((selector) => ({
53
+ selector: selector.trim(),
54
+ style: { color: rule.settings.foreground ?? '', fontStyle: fontStyleOf(rule.settings.fontStyle) },
55
+ order
56
+ }))
57
+ );
58
+ const cache = new Map<string, ScopeStyle | undefined>();
59
+ return (stack) => {
60
+ const key = stack.join(' ');
61
+ if (cache.has(key)) {
62
+ return cache.get(key);
63
+ }
64
+ let best: { score: number; order: number; style: ScopeStyle } | undefined;
65
+ for (const entry of flat) {
66
+ const score = entry.selector === '' ? null : selectorScore(entry.selector, stack);
67
+ if (score !== null && (best === undefined || score > best.score || (score === best.score && entry.order > best.order))) {
68
+ best = { score, order: entry.order, style: entry.style };
69
+ }
70
+ }
71
+ const style = best !== undefined && best.style.color !== '' ? best.style : undefined;
72
+ cache.set(key, style);
73
+ return style;
74
+ };
75
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,175 @@
1
+ import type { DocumentChange } from '@adecore/editor-core';
2
+ import type { LineToken, LineTokenizer } from './types.ts';
3
+
4
+ interface Entry {
5
+ tokens: readonly LineToken[];
6
+ state: unknown;
7
+ }
8
+
9
+ /* A line this long is left uncolored: a grammar can spend seconds on it, and nobody reads it in colors. */
10
+ const LONGEST_COLORED_LINE = 20000;
11
+
12
+ export interface TokenSource {
13
+ getLineCount(): number;
14
+ getLineText(line: number): string;
15
+ }
16
+
17
+ /*
18
+ * What a grammar made of each line, from the top of the document down. A grammar needs the state the
19
+ * line above it ended in, so the lines are colored in order from a frontier, and an edit moves the
20
+ * frontier up to the changed line. Lines below it keep their old colors, shifted to where they now
21
+ * are, until the recoloring reaches a line that ends in the state it ended in before, which is when
22
+ * everything under it is right again.
23
+ */
24
+ export class TokenCache {
25
+ private tokenizer: LineTokenizer | null = null;
26
+ private entries: (Entry | undefined)[] = [];
27
+ /* Every line above it is colored for the text as it is now. */
28
+ private frontier = 0;
29
+ /* The last line the edit touched; lines below it may be told right by their old state. */
30
+ private reusableFrom = Number.POSITIVE_INFINITY;
31
+ /* The state the last line of the edited stretch ended in before the edit; the same state now means the lines below are right. */
32
+ private pivotLine = -1;
33
+ private pivotState: unknown;
34
+ private lines: number;
35
+ private readonly source: TokenSource;
36
+
37
+ constructor(source: TokenSource) {
38
+ this.source = source;
39
+ this.lines = source.getLineCount();
40
+ }
41
+
42
+ get ready(): boolean {
43
+ return this.tokenizer !== null;
44
+ }
45
+
46
+ /* A new tokenizer, or none, colors everything anew. */
47
+ setTokenizer(tokenizer: LineTokenizer | null): void {
48
+ this.tokenizer = tokenizer;
49
+ this.lines = this.source.getLineCount();
50
+ this.entries = [];
51
+ this.frontier = 0;
52
+ this.reusableFrom = Number.POSITIVE_INFINITY;
53
+ this.pivotLine = -1;
54
+ }
55
+
56
+ /* How far down the document has colors that are right for the text. */
57
+ get colored(): number {
58
+ return this.frontier;
59
+ }
60
+
61
+ /* The colors of text that is not part of the document, a line at a time, or null without a tokenizer. */
62
+ tokenizeText(lines: readonly string[]): (readonly LineToken[])[] | null {
63
+ const tokenizer = this.tokenizer;
64
+ if (tokenizer === null) {
65
+ return null;
66
+ }
67
+ let state: unknown = null;
68
+ return lines.map((text) => {
69
+ if (text.length > LONGEST_COLORED_LINE) {
70
+ return [{ length: text.length, color: '', fontStyle: 0 }];
71
+ }
72
+ const result = tokenizer.tokenizeLine(text, state);
73
+ state = result.state;
74
+ return result.tokens;
75
+ });
76
+ }
77
+
78
+ /* The colors of a line, or null while it has none; the old colors of a line the recoloring has not reached yet. */
79
+ tokensOf(line: number): readonly LineToken[] | null {
80
+ return this.entries[line]?.tokens ?? null;
81
+ }
82
+
83
+ /* The text changed in these batches; `lineOf` answers for the document as it is now. */
84
+ edited(batches: readonly (readonly DocumentChange[])[], lineOf: (offset: number) => number): void {
85
+ const lineCount = this.source.getLineCount();
86
+ const delta = lineCount - this.lines;
87
+ this.lines = lineCount;
88
+ if (this.tokenizer === null) {
89
+ return;
90
+ }
91
+ const first = batches[0]?.[0];
92
+ if (first === undefined) {
93
+ this.entries = [];
94
+ this.frontier = 0;
95
+ this.reusableFrom = Number.POSITIVE_INFINITY;
96
+ this.pivotLine = -1;
97
+ return;
98
+ }
99
+ const firstLine = lineOf(first.from);
100
+ const changes = batches.length === 1 ? batches[0]! : [];
101
+ let shift = 0;
102
+ for (let i = 0; i < changes.length - 1; i++) {
103
+ shift += changes[i]!.insertedLength - (changes[i]!.to - changes[i]!.from);
104
+ }
105
+ const last = changes.at(-1);
106
+ const lastNew = last === undefined ? -1 : lineOf(last.from + shift + last.insertedLength);
107
+ const lastOld = lastNew - delta;
108
+ this.frontier = Math.min(this.frontier, firstLine);
109
+ if (last === undefined || lastOld < firstLine || this.entries.length <= firstLine) {
110
+ this.entries.length = Math.min(this.entries.length, firstLine);
111
+ this.reusableFrom = Number.POSITIVE_INFINITY;
112
+ this.pivotLine = -1;
113
+ return;
114
+ }
115
+ const old = this.entries[lastOld];
116
+ this.pivotLine = old ? lastNew : -1;
117
+ this.pivotState = old?.state;
118
+ this.entries.splice(firstLine, lastOld - firstLine + 1, ...new Array<undefined>(lastNew - firstLine + 1));
119
+ this.reusableFrom = lastNew + 1;
120
+ }
121
+
122
+ /*
123
+ * Colors lines from the frontier until `until` is reached, the colors are right for the rest of
124
+ * the document, or `budget` milliseconds are spent. Returns the first line that changed, so a
125
+ * view knows what to draw again, or null when nothing did.
126
+ */
127
+ advance(until: number, budget: number, now: () => number = () => performance.now()): { from: number; to: number } | null {
128
+ const tokenizer = this.tokenizer;
129
+ if (tokenizer === null) {
130
+ return null;
131
+ }
132
+ if (tokenizer.stale?.() === true) {
133
+ this.frontier = 0;
134
+ this.reusableFrom = Number.POSITIVE_INFINITY;
135
+ this.pivotLine = -1;
136
+ }
137
+ const count = this.source.getLineCount();
138
+ const limit = Math.min(until, count - 1);
139
+ const deadline = now() + budget;
140
+ const from = this.frontier;
141
+ while (this.frontier <= limit && this.frontier < count) {
142
+ const line = this.frontier;
143
+ const text = this.source.getLineText(line);
144
+ const state = line === 0 ? null : (this.entries[line - 1]?.state ?? null);
145
+ const previous = this.entries[line];
146
+ let next: Entry;
147
+ if (text.length > LONGEST_COLORED_LINE) {
148
+ next = { tokens: [{ length: text.length, color: '', fontStyle: 0 }], state };
149
+ } else {
150
+ const result = tokenizer.tokenizeLine(text, state);
151
+ next = { tokens: result.tokens, state: result.state };
152
+ }
153
+ this.entries[line] = next;
154
+ this.frontier = line + 1;
155
+ const settled =
156
+ (line === this.pivotLine && tokenizer.sameState(this.pivotState, next.state)) ||
157
+ (previous !== undefined && line >= this.reusableFrom && tokenizer.sameState(previous.state, next.state));
158
+ if (settled) {
159
+ this.frontier = this.entries.length;
160
+ this.reusableFrom = Number.POSITIVE_INFINITY;
161
+ this.pivotLine = -1;
162
+ break;
163
+ }
164
+ if (now() > deadline) {
165
+ break;
166
+ }
167
+ }
168
+ return this.frontier > from ? { from, to: this.frontier - 1 } : null;
169
+ }
170
+
171
+ /* Whether lines through `line` are colored for the text as it is now. */
172
+ covers(line: number): boolean {
173
+ return this.frontier > line || this.frontier >= this.source.getLineCount();
174
+ }
175
+ }
@@ -0,0 +1,22 @@
1
+ import type { DocumentChange } from '@adecore/editor-core';
2
+
3
+ /*
4
+ * Where a tracked range is after a batch of changes, or null once a change touched it. A change
5
+ * touches a range when it replaces any of the text inside it or inserts strictly between its ends.
6
+ * Text inserted exactly at an end leaves the range's own text alone: at the start the range moves
7
+ * behind it, at the end the range stays as it is.
8
+ */
9
+ export function mapTrackedRange(from: number, to: number, changes: readonly DocumentChange[]): { from: number; to: number } | null {
10
+ let delta = 0;
11
+ for (const change of changes) {
12
+ if (change.to <= from) {
13
+ delta += change.insertedLength - (change.to - change.from);
14
+ continue;
15
+ }
16
+ if (change.from >= to) {
17
+ break;
18
+ }
19
+ return null;
20
+ }
21
+ return { from: from + delta, to: to + delta };
22
+ }