@adecore/editor 0.0.1 → 0.17.0-beta.2

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 +57 -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 +610 -0
  14. package/dist/fake.d.ts +192 -0
  15. package/dist/fake.js +663 -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 +344 -0
  69. package/dist/types.js +1 -0
  70. package/dist/view.d.ts +383 -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 +764 -0
  79. package/src/fake.ts +838 -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 +659 -0
  107. package/src/view.ts +2437 -0
@@ -0,0 +1,117 @@
1
+ import { parseHTML } from 'linkedom';
2
+ import { createSmartEditorEngine } from './engine.js';
3
+ /*
4
+ * A page for tests: linkedom has no layout, so every box is the editor's fallback size, and its
5
+ * textarea has no selection of its own, which the editor's input sink reads and writes.
6
+ */
7
+ export function createPage() {
8
+ const { document, window } = parseHTML('<html><body><div id="host"></div></body></html>');
9
+ const prototype = window.HTMLTextAreaElement.prototype;
10
+ const selections = new WeakMap();
11
+ const read = (element) => selections.get(element) ?? { start: 0, end: 0, direction: 'none' };
12
+ Object.defineProperty(prototype, 'selectionStart', {
13
+ get: function () {
14
+ return read(this).start;
15
+ },
16
+ configurable: true
17
+ });
18
+ Object.defineProperty(prototype, 'selectionEnd', {
19
+ get: function () {
20
+ return read(this).end;
21
+ },
22
+ configurable: true
23
+ });
24
+ Object.defineProperty(prototype, 'selectionDirection', {
25
+ get: function () {
26
+ return read(this).direction;
27
+ },
28
+ configurable: true
29
+ });
30
+ prototype.setSelectionRange = function (start, end, direction = 'none') {
31
+ selections.set(this, { start, end, direction });
32
+ };
33
+ const element = window.HTMLElement.prototype;
34
+ for (const name of ['scrollTop', 'scrollLeft']) {
35
+ Object.defineProperty(element, name, { value: 0, writable: true, configurable: true });
36
+ }
37
+ return {
38
+ document: document,
39
+ window: window,
40
+ host: document.getElementById('host')
41
+ };
42
+ }
43
+ /* Fires a keydown at the element and says whether the editor took the key. */
44
+ export function press(window, target, key, options = {}) {
45
+ const event = new window.Event('keydown', { bubbles: true, cancelable: true });
46
+ Object.assign(event, { key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, isComposing: false, ...options });
47
+ target.dispatchEvent(event);
48
+ return event.defaultPrevented;
49
+ }
50
+ /* Fires a keyup at the element, the other half of `press`. */
51
+ export function release(window, target, key, options = {}) {
52
+ const event = new window.Event('keyup', { bubbles: true, cancelable: true });
53
+ Object.assign(event, { key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, isComposing: false, ...options });
54
+ target.dispatchEvent(event);
55
+ }
56
+ /* What a browser sends before it puts text in a textarea. */
57
+ export function typeInto(window, target, text) {
58
+ const event = new window.Event('beforeinput', { bubbles: true, cancelable: true });
59
+ Object.assign(event, { inputType: 'insertText', data: text, isComposing: false });
60
+ target.dispatchEvent(event);
61
+ }
62
+ /* A clipboard event with the data it carries; what a copy or cut sets is read back through `data`. */
63
+ export function clipboard(window, target, type, data = { text: '' }) {
64
+ const event = new window.Event(type, { bubbles: true, cancelable: true });
65
+ Object.assign(event, {
66
+ clipboardData: {
67
+ getData: () => data.text,
68
+ setData: (_format, value) => {
69
+ data.text = value;
70
+ }
71
+ }
72
+ });
73
+ target.dispatchEvent(event);
74
+ return { prevented: event.defaultPrevented, text: data.text };
75
+ }
76
+ /* 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. */
77
+ export function pointer(window, target, type, x, y, options = {}) {
78
+ const event = new window.Event(type, { bubbles: true, cancelable: true });
79
+ Object.assign(event, {
80
+ clientX: x,
81
+ clientY: y,
82
+ button: 0,
83
+ pointerId: 1,
84
+ detail: 0,
85
+ shiftKey: false,
86
+ altKey: false,
87
+ ctrlKey: false,
88
+ metaKey: false,
89
+ ...options
90
+ });
91
+ target.dispatchEvent(event);
92
+ }
93
+ /* An editor on a page of its own, with the helpers that drive it the way a person would. */
94
+ export function mountEditor(options = {}, engineOptions = {}) {
95
+ const page = createPage();
96
+ const editor = createSmartEditorEngine({ tokenizer: async () => null, ...engineOptions }).mount(page.host, {
97
+ text: 'one\ntwo\nthree',
98
+ theme: 'dark',
99
+ ...options
100
+ });
101
+ const input = page.host.querySelector('textarea');
102
+ const viewport = page.host.querySelector('.se-viewport');
103
+ return {
104
+ editor,
105
+ page,
106
+ input,
107
+ viewport,
108
+ press: (key, modifiers = {}) => press(page.window, input, key, modifiers),
109
+ release: (key, modifiers = {}) => release(page.window, input, key, modifiers),
110
+ type: (text) => typeInto(page.window, input, text),
111
+ clip: (type, text = '') => clipboard(page.window, input, type, { text }).text,
112
+ click: (x, y, modifiers = {}) => {
113
+ pointer(page.window, viewport, 'pointerdown', x, y, modifiers);
114
+ pointer(page.window, viewport, 'pointerup', x, y);
115
+ }
116
+ };
117
+ }
@@ -0,0 +1,9 @@
1
+ import type { ScopeColors } from './types.ts';
2
+ export interface ThemeRule {
3
+ readonly scope?: string | readonly string[];
4
+ readonly settings: {
5
+ readonly foreground?: string;
6
+ readonly fontStyle?: string;
7
+ };
8
+ }
9
+ export declare function scopeColorsOf(rules: readonly ThemeRule[]): ScopeColors;
@@ -0,0 +1,61 @@
1
+ const FONT_STYLES = { italic: 1, bold: 2, underline: 4, strikethrough: 8 };
2
+ function fontStyleOf(text) {
3
+ return (text ?? '').split(/\s+/).reduce((flags, word) => flags | (FONT_STYLES[word] ?? 0), 0);
4
+ }
5
+ function nameMatches(selector, scope) {
6
+ return scope === selector || scope.startsWith(`${selector}.`);
7
+ }
8
+ /*
9
+ * How well a selector fits a stack of scopes, outermost first, or null when it does not. As in a
10
+ * TextMate theme the last word has to match the innermost scope and the words before it some outer
11
+ * ones in order; the longer the name it matched, the better, and a selector with more words beats one
12
+ * with fewer.
13
+ */
14
+ function selectorScore(selector, stack) {
15
+ const words = selector.trim().split(/\s+/);
16
+ const last = words[words.length - 1];
17
+ const innermost = stack[stack.length - 1];
18
+ if (innermost === undefined || !nameMatches(last, innermost)) {
19
+ return null;
20
+ }
21
+ let at = stack.length - 2;
22
+ for (let word = words.length - 2; word >= 0; word--) {
23
+ while (at >= 0 && !nameMatches(words[word], stack[at])) {
24
+ at--;
25
+ }
26
+ if (at < 0) {
27
+ return null;
28
+ }
29
+ at--;
30
+ }
31
+ return last.length * 100 + words.length;
32
+ }
33
+ /*
34
+ * What a theme draws a scope stack in, the way its own grammar's tokens are colored: the rule that
35
+ * fits best wins and a later rule beats an equal one. Undefined where no rule colors it, which is
36
+ * where the editor keeps what the grammar said.
37
+ */
38
+ export function scopeColorsOf(rules) {
39
+ const flat = rules.flatMap((rule, order) => (typeof rule.scope === 'string' ? rule.scope.split(',') : (rule.scope ?? [])).map((selector) => ({
40
+ selector: selector.trim(),
41
+ style: { color: rule.settings.foreground ?? '', fontStyle: fontStyleOf(rule.settings.fontStyle) },
42
+ order
43
+ })));
44
+ const cache = new Map();
45
+ return (stack) => {
46
+ const key = stack.join(' ');
47
+ if (cache.has(key)) {
48
+ return cache.get(key);
49
+ }
50
+ let best;
51
+ for (const entry of flat) {
52
+ const score = entry.selector === '' ? null : selectorScore(entry.selector, stack);
53
+ if (score !== null && (best === undefined || score > best.score || (score === best.score && entry.order > best.order))) {
54
+ best = { score, order: entry.order, style: entry.style };
55
+ }
56
+ }
57
+ const style = best !== undefined && best.style.color !== '' ? best.style : undefined;
58
+ cache.set(key, style);
59
+ return style;
60
+ };
61
+ }
@@ -0,0 +1,28 @@
1
+ import type { DocumentChange } from '@adecore/editor-core';
2
+ import type { LineToken, LineTokenizer } from './types.ts';
3
+ export interface TokenSource {
4
+ getLineCount(): number;
5
+ getLineText(line: number): string;
6
+ }
7
+ export declare class TokenCache {
8
+ private tokenizer;
9
+ private entries;
10
+ private frontier;
11
+ private reusableFrom;
12
+ private pivotLine;
13
+ private pivotState;
14
+ private lines;
15
+ private readonly source;
16
+ constructor(source: TokenSource);
17
+ get ready(): boolean;
18
+ setTokenizer(tokenizer: LineTokenizer | null): void;
19
+ get colored(): number;
20
+ tokenizeText(lines: readonly string[]): (readonly LineToken[])[] | null;
21
+ tokensOf(line: number): readonly LineToken[] | null;
22
+ edited(batches: readonly (readonly DocumentChange[])[], lineOf: (offset: number) => number): void;
23
+ advance(until: number, budget: number, now?: () => number): {
24
+ from: number;
25
+ to: number;
26
+ } | null;
27
+ covers(line: number): boolean;
28
+ }
package/dist/tokens.js ADDED
@@ -0,0 +1,152 @@
1
+ /* A line this long is left uncolored: a grammar can spend seconds on it, and nobody reads it in colors. */
2
+ const LONGEST_COLORED_LINE = 20000;
3
+ /*
4
+ * What a grammar made of each line, from the top of the document down. A grammar needs the state the
5
+ * line above it ended in, so the lines are colored in order from a frontier, and an edit moves the
6
+ * frontier up to the changed line. Lines below it keep their old colors, shifted to where they now
7
+ * are, until the recoloring reaches a line that ends in the state it ended in before, which is when
8
+ * everything under it is right again.
9
+ */
10
+ export class TokenCache {
11
+ tokenizer = null;
12
+ entries = [];
13
+ /* Every line above it is colored for the text as it is now. */
14
+ frontier = 0;
15
+ /* The last line the edit touched; lines below it may be told right by their old state. */
16
+ reusableFrom = Number.POSITIVE_INFINITY;
17
+ /* The state the last line of the edited stretch ended in before the edit; the same state now means the lines below are right. */
18
+ pivotLine = -1;
19
+ pivotState;
20
+ lines;
21
+ source;
22
+ constructor(source) {
23
+ this.source = source;
24
+ this.lines = source.getLineCount();
25
+ }
26
+ get ready() {
27
+ return this.tokenizer !== null;
28
+ }
29
+ /* A new tokenizer, or none, colors everything anew. */
30
+ setTokenizer(tokenizer) {
31
+ this.tokenizer = tokenizer;
32
+ this.lines = this.source.getLineCount();
33
+ this.entries = [];
34
+ this.frontier = 0;
35
+ this.reusableFrom = Number.POSITIVE_INFINITY;
36
+ this.pivotLine = -1;
37
+ }
38
+ /* How far down the document has colors that are right for the text. */
39
+ get colored() {
40
+ return this.frontier;
41
+ }
42
+ /* The colors of text that is not part of the document, a line at a time, or null without a tokenizer. */
43
+ tokenizeText(lines) {
44
+ const tokenizer = this.tokenizer;
45
+ if (tokenizer === null) {
46
+ return null;
47
+ }
48
+ let state = null;
49
+ return lines.map((text) => {
50
+ if (text.length > LONGEST_COLORED_LINE) {
51
+ return [{ length: text.length, color: '', fontStyle: 0 }];
52
+ }
53
+ const result = tokenizer.tokenizeLine(text, state);
54
+ state = result.state;
55
+ return result.tokens;
56
+ });
57
+ }
58
+ /* The colors of a line, or null while it has none; the old colors of a line the recoloring has not reached yet. */
59
+ tokensOf(line) {
60
+ return this.entries[line]?.tokens ?? null;
61
+ }
62
+ /* The text changed in these batches; `lineOf` answers for the document as it is now. */
63
+ edited(batches, lineOf) {
64
+ const lineCount = this.source.getLineCount();
65
+ const delta = lineCount - this.lines;
66
+ this.lines = lineCount;
67
+ if (this.tokenizer === null) {
68
+ return;
69
+ }
70
+ const first = batches[0]?.[0];
71
+ if (first === undefined) {
72
+ this.entries = [];
73
+ this.frontier = 0;
74
+ this.reusableFrom = Number.POSITIVE_INFINITY;
75
+ this.pivotLine = -1;
76
+ return;
77
+ }
78
+ const firstLine = lineOf(first.from);
79
+ const changes = batches.length === 1 ? batches[0] : [];
80
+ let shift = 0;
81
+ for (let i = 0; i < changes.length - 1; i++) {
82
+ shift += changes[i].insertedLength - (changes[i].to - changes[i].from);
83
+ }
84
+ const last = changes.at(-1);
85
+ const lastNew = last === undefined ? -1 : lineOf(last.from + shift + last.insertedLength);
86
+ const lastOld = lastNew - delta;
87
+ this.frontier = Math.min(this.frontier, firstLine);
88
+ if (last === undefined || lastOld < firstLine || this.entries.length <= firstLine) {
89
+ this.entries.length = Math.min(this.entries.length, firstLine);
90
+ this.reusableFrom = Number.POSITIVE_INFINITY;
91
+ this.pivotLine = -1;
92
+ return;
93
+ }
94
+ const old = this.entries[lastOld];
95
+ this.pivotLine = old ? lastNew : -1;
96
+ this.pivotState = old?.state;
97
+ this.entries.splice(firstLine, lastOld - firstLine + 1, ...new Array(lastNew - firstLine + 1));
98
+ this.reusableFrom = lastNew + 1;
99
+ }
100
+ /*
101
+ * Colors lines from the frontier until `until` is reached, the colors are right for the rest of
102
+ * the document, or `budget` milliseconds are spent. Returns the first line that changed, so a
103
+ * view knows what to draw again, or null when nothing did.
104
+ */
105
+ advance(until, budget, now = () => performance.now()) {
106
+ const tokenizer = this.tokenizer;
107
+ if (tokenizer === null) {
108
+ return null;
109
+ }
110
+ if (tokenizer.stale?.() === true) {
111
+ this.frontier = 0;
112
+ this.reusableFrom = Number.POSITIVE_INFINITY;
113
+ this.pivotLine = -1;
114
+ }
115
+ const count = this.source.getLineCount();
116
+ const limit = Math.min(until, count - 1);
117
+ const deadline = now() + budget;
118
+ const from = this.frontier;
119
+ while (this.frontier <= limit && this.frontier < count) {
120
+ const line = this.frontier;
121
+ const text = this.source.getLineText(line);
122
+ const state = line === 0 ? null : (this.entries[line - 1]?.state ?? null);
123
+ const previous = this.entries[line];
124
+ let next;
125
+ if (text.length > LONGEST_COLORED_LINE) {
126
+ next = { tokens: [{ length: text.length, color: '', fontStyle: 0 }], state };
127
+ }
128
+ else {
129
+ const result = tokenizer.tokenizeLine(text, state);
130
+ next = { tokens: result.tokens, state: result.state };
131
+ }
132
+ this.entries[line] = next;
133
+ this.frontier = line + 1;
134
+ const settled = (line === this.pivotLine && tokenizer.sameState(this.pivotState, next.state)) ||
135
+ (previous !== undefined && line >= this.reusableFrom && tokenizer.sameState(previous.state, next.state));
136
+ if (settled) {
137
+ this.frontier = this.entries.length;
138
+ this.reusableFrom = Number.POSITIVE_INFINITY;
139
+ this.pivotLine = -1;
140
+ break;
141
+ }
142
+ if (now() > deadline) {
143
+ break;
144
+ }
145
+ }
146
+ return this.frontier > from ? { from, to: this.frontier - 1 } : null;
147
+ }
148
+ /* Whether lines through `line` are colored for the text as it is now. */
149
+ covers(line) {
150
+ return this.frontier > line || this.frontier >= this.source.getLineCount();
151
+ }
152
+ }
@@ -0,0 +1,5 @@
1
+ import type { DocumentChange } from '@adecore/editor-core';
2
+ export declare function mapTrackedRange(from: number, to: number, changes: readonly DocumentChange[]): {
3
+ from: number;
4
+ to: number;
5
+ } | null;
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Where a tracked range is after a batch of changes, or null once a change touched it. A change
3
+ * touches a range when it replaces any of the text inside it or inserts strictly between its ends.
4
+ * Text inserted exactly at an end leaves the range's own text alone: at the start the range moves
5
+ * behind it, at the end the range stays as it is.
6
+ */
7
+ export function mapTrackedRange(from, to, changes) {
8
+ let delta = 0;
9
+ for (const change of changes) {
10
+ if (change.to <= from) {
11
+ delta += change.insertedLength - (change.to - change.from);
12
+ continue;
13
+ }
14
+ if (change.from >= to) {
15
+ break;
16
+ }
17
+ return null;
18
+ }
19
+ return { from: from + delta, to: to + delta };
20
+ }