@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.
- package/LICENSE +110 -0
- package/README.md +82 -0
- package/dist/attribution.d.ts +28 -0
- package/dist/attribution.js +55 -0
- package/dist/code-block.d.ts +2 -0
- package/dist/code-block.js +89 -0
- package/dist/column-selection.d.ts +9 -0
- package/dist/column-selection.js +52 -0
- package/dist/controller.d.ts +64 -0
- package/dist/controller.js +772 -0
- package/dist/editor.css +813 -0
- package/dist/engine.d.ts +2 -0
- package/dist/engine.js +604 -0
- package/dist/fake.d.ts +190 -0
- package/dist/fake.js +658 -0
- package/dist/find.d.ts +30 -0
- package/dist/find.js +108 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +9 -0
- package/dist/input.js +57 -0
- package/dist/keymap-table.d.ts +102 -0
- package/dist/keymap-table.js +130 -0
- package/dist/keymap.d.ts +36 -0
- package/dist/keymap.js +163 -0
- package/dist/layout.d.ts +167 -0
- package/dist/layout.js +583 -0
- package/dist/lens.d.ts +3 -0
- package/dist/lens.js +29 -0
- package/dist/listeners.d.ts +3 -0
- package/dist/listeners.js +12 -0
- package/dist/metrics.d.ts +8 -0
- package/dist/metrics.js +54 -0
- package/dist/modifier-gesture.d.ts +24 -0
- package/dist/modifier-gesture.js +98 -0
- package/dist/native-input.d.ts +26 -0
- package/dist/native-input.js +147 -0
- package/dist/offsets.d.ts +2 -0
- package/dist/offsets.js +14 -0
- package/dist/outline.d.ts +55 -0
- package/dist/outline.js +181 -0
- package/dist/overview.d.ts +17 -0
- package/dist/overview.js +55 -0
- package/dist/paint.d.ts +86 -0
- package/dist/paint.js +481 -0
- package/dist/php-html.d.ts +9 -0
- package/dist/php-html.js +36 -0
- package/dist/pointer.d.ts +49 -0
- package/dist/pointer.js +243 -0
- package/dist/scroll-animation.d.ts +3 -0
- package/dist/scroll-animation.js +45 -0
- package/dist/scroll.d.ts +28 -0
- package/dist/scroll.js +76 -0
- package/dist/semantic.d.ts +8 -0
- package/dist/semantic.js +47 -0
- package/dist/shiki.d.ts +7 -0
- package/dist/shiki.js +106 -0
- package/dist/smart-keys.d.ts +3 -0
- package/dist/smart-keys.js +14 -0
- package/dist/testing.d.ts +34 -0
- package/dist/testing.js +117 -0
- package/dist/theme-scopes.d.ts +9 -0
- package/dist/theme-scopes.js +61 -0
- package/dist/tokens.d.ts +28 -0
- package/dist/tokens.js +152 -0
- package/dist/tracked-range.d.ts +5 -0
- package/dist/tracked-range.js +20 -0
- package/dist/types.d.ts +342 -0
- package/dist/types.js +1 -0
- package/dist/view.d.ts +382 -0
- package/dist/view.js +2134 -0
- package/package.json +63 -3
- package/src/attribution.ts +85 -0
- package/src/code-block.ts +100 -0
- package/src/column-selection.ts +63 -0
- package/src/controller.ts +838 -0
- package/src/editor.css +813 -0
- package/src/engine.ts +757 -0
- package/src/fake.ts +831 -0
- package/src/find.ts +128 -0
- package/src/index.ts +69 -0
- package/src/input.ts +63 -0
- package/src/keymap-table.ts +163 -0
- package/src/keymap.ts +204 -0
- package/src/layout.ts +754 -0
- package/src/lens.ts +32 -0
- package/src/listeners.ts +15 -0
- package/src/metrics.ts +65 -0
- package/src/modifier-gesture.ts +115 -0
- package/src/native-input.ts +179 -0
- package/src/offsets.ts +16 -0
- package/src/outline.ts +248 -0
- package/src/overview.ts +80 -0
- package/src/paint.ts +608 -0
- package/src/php-html.ts +43 -0
- package/src/pointer.ts +326 -0
- package/src/scroll-animation.ts +48 -0
- package/src/scroll.ts +102 -0
- package/src/semantic.ts +54 -0
- package/src/shiki.ts +118 -0
- package/src/smart-keys.ts +17 -0
- package/src/testing.ts +157 -0
- package/src/theme-scopes.ts +75 -0
- package/src/tokens.ts +175 -0
- package/src/tracked-range.ts +22 -0
- package/src/types.ts +656 -0
- package/src/view.ts +2436 -0
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const FALLBACK = { family: 'monospace', size: 13, lineHeight: 20 };
|
|
2
|
+
function pixels(value) {
|
|
3
|
+
const parsed = Number.parseFloat(value);
|
|
4
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
5
|
+
}
|
|
6
|
+
/* The code face the page gives the editor's element, in whole pixels so a line is as tall as the viewer's. */
|
|
7
|
+
export function readEditorFont(element) {
|
|
8
|
+
const view = element.ownerDocument.defaultView;
|
|
9
|
+
if (!view?.getComputedStyle) {
|
|
10
|
+
return FALLBACK;
|
|
11
|
+
}
|
|
12
|
+
const style = view.getComputedStyle(element);
|
|
13
|
+
const size = pixels(style.fontSize) ?? FALLBACK.size;
|
|
14
|
+
const lineHeight = pixels(style.lineHeight) ?? Math.round(size * 1.5);
|
|
15
|
+
return { family: style.fontFamily || FALLBACK.family, size, lineHeight: Math.round(lineHeight) };
|
|
16
|
+
}
|
|
17
|
+
/* Character widths from a canvas, which sets the text in the same face the page draws it in. Without a canvas a character is 0.6 of the size. */
|
|
18
|
+
export function createMetrics(font, tabSize, document) {
|
|
19
|
+
let context = null;
|
|
20
|
+
try {
|
|
21
|
+
context = document.createElement('canvas').getContext('2d');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
context = null;
|
|
25
|
+
}
|
|
26
|
+
const codeFont = `${font.size}px ${font.family}`;
|
|
27
|
+
const inlayFont = `${Math.max(12, font.size - 1)}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
|
|
28
|
+
const cache = new Map();
|
|
29
|
+
const measure = (text, face, fallback) => {
|
|
30
|
+
if (!context) {
|
|
31
|
+
return Array.from(text).length * fallback;
|
|
32
|
+
}
|
|
33
|
+
context.font = face;
|
|
34
|
+
context.fontKerning = 'none';
|
|
35
|
+
return context.measureText(text).width;
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
lineHeight: font.lineHeight,
|
|
39
|
+
charWidth: measure('M', codeFont, font.size * 0.6),
|
|
40
|
+
tabSize,
|
|
41
|
+
measureText(text) {
|
|
42
|
+
let width = cache.get(text);
|
|
43
|
+
if (width === undefined) {
|
|
44
|
+
width = measure(text, codeFont, font.size * 0.6);
|
|
45
|
+
if (cache.size > 2000) {
|
|
46
|
+
cache.clear();
|
|
47
|
+
}
|
|
48
|
+
cache.set(text, width);
|
|
49
|
+
}
|
|
50
|
+
return width;
|
|
51
|
+
},
|
|
52
|
+
measureInlay: (text) => measure(text, inlayFont, font.size * 0.5)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface GestureEvent {
|
|
2
|
+
key: string;
|
|
3
|
+
timeStamp: number;
|
|
4
|
+
repeat?: boolean;
|
|
5
|
+
altKey: boolean;
|
|
6
|
+
ctrlKey: boolean;
|
|
7
|
+
metaKey: boolean;
|
|
8
|
+
shiftKey: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type GestureAction = 'above' | 'below';
|
|
11
|
+
export declare class DoubleModifierGesture {
|
|
12
|
+
private firstPressed;
|
|
13
|
+
private firstReleased;
|
|
14
|
+
private secondPressed;
|
|
15
|
+
private last;
|
|
16
|
+
private readonly modifier;
|
|
17
|
+
constructor(modifier: 'Alt' | 'Control');
|
|
18
|
+
reset(): void;
|
|
19
|
+
get armed(): boolean;
|
|
20
|
+
private spoiled;
|
|
21
|
+
keydown(event: GestureEvent): GestureAction | null;
|
|
22
|
+
keyup(event: GestureEvent): void;
|
|
23
|
+
private step;
|
|
24
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The gesture that adds a caret above or below: tap a modifier twice, hold it down the second time and
|
|
3
|
+
* press an arrow, which Option does on macOS and Ctrl elsewhere. The taps have to follow each other
|
|
4
|
+
* within a third of a second; the arrow can come as long as the modifier stays down.
|
|
5
|
+
*/
|
|
6
|
+
/* How long the taps may be apart. */
|
|
7
|
+
const TAP_MS = 300;
|
|
8
|
+
/* A first tap this old is forgotten even by a press that comes after the slower rule. */
|
|
9
|
+
const FORGET_MS = 500;
|
|
10
|
+
export class DoubleModifierGesture {
|
|
11
|
+
firstPressed = false;
|
|
12
|
+
firstReleased = false;
|
|
13
|
+
secondPressed = false;
|
|
14
|
+
last = 0;
|
|
15
|
+
modifier;
|
|
16
|
+
constructor(modifier) {
|
|
17
|
+
this.modifier = modifier;
|
|
18
|
+
}
|
|
19
|
+
reset() {
|
|
20
|
+
this.firstPressed = false;
|
|
21
|
+
this.firstReleased = false;
|
|
22
|
+
this.secondPressed = false;
|
|
23
|
+
}
|
|
24
|
+
/* Whether the modifier is down for the second time, which is when an arrow means something. */
|
|
25
|
+
get armed() {
|
|
26
|
+
return this.firstPressed && this.firstReleased && this.secondPressed;
|
|
27
|
+
}
|
|
28
|
+
/* Another modifier held with the one that counts spoils the gesture. */
|
|
29
|
+
spoiled(event) {
|
|
30
|
+
const others = this.modifier === 'Alt' ? event.ctrlKey || event.metaKey || event.shiftKey : event.altKey || event.metaKey || event.shiftKey;
|
|
31
|
+
return others;
|
|
32
|
+
}
|
|
33
|
+
keydown(event) {
|
|
34
|
+
if (event.key === this.modifier) {
|
|
35
|
+
if (event.repeat === true) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (this.spoiled(event)) {
|
|
39
|
+
this.reset();
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (this.firstPressed && event.timeStamp - this.last > FORGET_MS) {
|
|
43
|
+
this.reset();
|
|
44
|
+
}
|
|
45
|
+
this.step(event, 'press');
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (this.armed) {
|
|
49
|
+
const held = this.modifier === 'Alt' ? event.altKey : event.ctrlKey;
|
|
50
|
+
if (held && !this.spoiled(event) && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
51
|
+
return event.key === 'ArrowUp' ? 'above' : 'below';
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
this.reset();
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
keyup(event) {
|
|
59
|
+
if (event.key === this.modifier) {
|
|
60
|
+
if (this.spoiled(event)) {
|
|
61
|
+
this.reset();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.step(event, 'release');
|
|
65
|
+
}
|
|
66
|
+
else if (!this.armed) {
|
|
67
|
+
this.reset();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
step(event, kind) {
|
|
71
|
+
if (this.firstPressed && event.timeStamp - this.last > TAP_MS) {
|
|
72
|
+
this.reset();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (kind === 'press') {
|
|
76
|
+
if (!this.firstPressed) {
|
|
77
|
+
this.reset();
|
|
78
|
+
this.firstPressed = true;
|
|
79
|
+
this.last = event.timeStamp;
|
|
80
|
+
}
|
|
81
|
+
else if (this.firstReleased) {
|
|
82
|
+
this.secondPressed = true;
|
|
83
|
+
this.last = event.timeStamp;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
this.reset();
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.firstPressed && !this.firstReleased) {
|
|
91
|
+
this.firstReleased = true;
|
|
92
|
+
this.last = event.timeStamp;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.reset();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DocumentModel, Selection, TextEdit } from '@adecore/editor-core';
|
|
2
|
+
export interface NativeInputHost {
|
|
3
|
+
readOnly(): boolean;
|
|
4
|
+
replace(text: string): void;
|
|
5
|
+
type(character: string): void;
|
|
6
|
+
apply(edit: TextEdit, selection: Selection): void;
|
|
7
|
+
preview(text: string | undefined): void;
|
|
8
|
+
selectionChanged(selection: Selection): void;
|
|
9
|
+
}
|
|
10
|
+
export declare class NativeInput {
|
|
11
|
+
composing: boolean;
|
|
12
|
+
private context;
|
|
13
|
+
private compositionContext;
|
|
14
|
+
private writing;
|
|
15
|
+
private readonly element;
|
|
16
|
+
private readonly model;
|
|
17
|
+
private readonly host;
|
|
18
|
+
constructor(element: HTMLTextAreaElement, model: DocumentModel, host: NativeInputHost);
|
|
19
|
+
sync(): void;
|
|
20
|
+
readSelection(): void;
|
|
21
|
+
onInput(): void;
|
|
22
|
+
startComposition(): void;
|
|
23
|
+
endComposition(data: string): void;
|
|
24
|
+
normalizeNewlines(text: string): string;
|
|
25
|
+
private composedText;
|
|
26
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { findChange, modelOffset, textareaOffset, textareaText } from './input.js';
|
|
2
|
+
/* The most of the document the textarea holds around the caret; a screen reader gets its context, a megabyte file does not get copied on every keystroke. */
|
|
3
|
+
const CONTEXT = 8192;
|
|
4
|
+
/* A dead key and the key after it commit as one composition of at most two characters, which is typing and not an input method's text. */
|
|
5
|
+
const DEAD_KEY_RESULT = /^[\x20-\x7e]{1,2}$/;
|
|
6
|
+
/*
|
|
7
|
+
* The hidden textarea. It is the sink for what the platform types, composes and pastes, and carries
|
|
8
|
+
* the text around the caret for assistive technology. It never draws and never decides: the model
|
|
9
|
+
* owns the text and the selection, and the textarea is written from it.
|
|
10
|
+
*/
|
|
11
|
+
export class NativeInput {
|
|
12
|
+
composing = false;
|
|
13
|
+
context = { from: 0, source: '', value: '', start: 0, end: 0 };
|
|
14
|
+
compositionContext;
|
|
15
|
+
writing = false;
|
|
16
|
+
element;
|
|
17
|
+
model;
|
|
18
|
+
host;
|
|
19
|
+
constructor(element, model, host) {
|
|
20
|
+
this.element = element;
|
|
21
|
+
this.model = model;
|
|
22
|
+
this.host = host;
|
|
23
|
+
}
|
|
24
|
+
sync() {
|
|
25
|
+
if (this.composing) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const selection = this.model.getPrimary();
|
|
29
|
+
const caretCount = this.model.getSelectionCount();
|
|
30
|
+
const length = this.model.getLength();
|
|
31
|
+
const start = Math.min(selection.anchor, selection.head);
|
|
32
|
+
const end = Math.max(selection.anchor, selection.head);
|
|
33
|
+
let from = 0;
|
|
34
|
+
let to = length;
|
|
35
|
+
if (length > CONTEXT) {
|
|
36
|
+
from = Math.max(0, Math.min(start, selection.head - 2048));
|
|
37
|
+
to = Math.min(length, end + 2048);
|
|
38
|
+
if (to - from > CONTEXT) {
|
|
39
|
+
from = Math.max(0, selection.head - CONTEXT / 2);
|
|
40
|
+
to = Math.min(length, from + CONTEXT);
|
|
41
|
+
}
|
|
42
|
+
// A boundary must not split a surrogate pair or a CRLF.
|
|
43
|
+
if (from > 0 && /[\uDC00-\uDFFF\n]/.test(this.model.slice(from, from + 1))) {
|
|
44
|
+
from--;
|
|
45
|
+
}
|
|
46
|
+
if (to < length && /[\uDC00-\uDFFF\n]/.test(this.model.slice(to, to + 1))) {
|
|
47
|
+
to++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const source = this.model.slice(from, to);
|
|
51
|
+
const value = textareaText(source);
|
|
52
|
+
const nativeStart = textareaOffset(source, Math.max(0, start - from));
|
|
53
|
+
const nativeEnd = textareaOffset(source, Math.max(0, end - from));
|
|
54
|
+
this.context = { from, source, value, start: nativeStart, end: nativeEnd };
|
|
55
|
+
this.writing = true;
|
|
56
|
+
if (this.element.value !== value) {
|
|
57
|
+
this.element.value = value;
|
|
58
|
+
}
|
|
59
|
+
this.element.setSelectionRange(nativeStart, nativeEnd, selection.head < selection.anchor ? 'backward' : 'forward');
|
|
60
|
+
this.writing = false;
|
|
61
|
+
const position = this.model.positionAt(selection.head);
|
|
62
|
+
this.element.setAttribute('aria-description', `Line ${position.line + 1}, column ${position.column + 1}. ${caretCount} caret${caretCount === 1 ? '' : 's'}.`);
|
|
63
|
+
}
|
|
64
|
+
/* A selection the platform made inside the textarea, such as a long press or an accessibility action. */
|
|
65
|
+
readSelection() {
|
|
66
|
+
if (this.writing || this.composing || this.element.value !== this.context.value) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const start = this.element.selectionStart;
|
|
70
|
+
const end = this.element.selectionEnd;
|
|
71
|
+
if (start === this.context.start && end === this.context.end) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const from = this.context.from + modelOffset(this.context.source, start);
|
|
75
|
+
const to = this.context.from + modelOffset(this.context.source, end);
|
|
76
|
+
this.host.selectionChanged(this.element.selectionDirection === 'backward' ? { anchor: to, head: from } : { anchor: from, head: to });
|
|
77
|
+
}
|
|
78
|
+
onInput() {
|
|
79
|
+
if (this.composing) {
|
|
80
|
+
this.host.preview(this.composedText());
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (this.host.readOnly()) {
|
|
84
|
+
this.sync();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const context = this.context;
|
|
88
|
+
const change = findChange(context.value, this.element.value);
|
|
89
|
+
if (change.from === change.to && !change.text) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (this.model.getSelections().length > 1) {
|
|
93
|
+
const suffix = context.value.length - context.end;
|
|
94
|
+
this.host.replace(this.element.value.slice(context.start, Math.max(context.start, this.element.value.length - suffix)));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const edit = {
|
|
98
|
+
from: context.from + modelOffset(context.source, change.from),
|
|
99
|
+
to: context.from + modelOffset(context.source, change.to),
|
|
100
|
+
text: this.normalizeNewlines(change.text)
|
|
101
|
+
};
|
|
102
|
+
const next = context.source.slice(0, edit.from - context.from) + edit.text + context.source.slice(edit.to - context.from);
|
|
103
|
+
const start = context.from + modelOffset(next, this.element.selectionStart);
|
|
104
|
+
const end = context.from + modelOffset(next, this.element.selectionEnd);
|
|
105
|
+
this.host.apply(edit, this.element.selectionDirection === 'backward' ? { anchor: end, head: start } : { anchor: start, head: end });
|
|
106
|
+
}
|
|
107
|
+
startComposition() {
|
|
108
|
+
if (this.host.readOnly()) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.compositionContext = { ...this.context };
|
|
112
|
+
this.composing = true;
|
|
113
|
+
}
|
|
114
|
+
endComposition(data) {
|
|
115
|
+
if (!this.composing) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const text = this.composedText();
|
|
119
|
+
const context = this.compositionContext;
|
|
120
|
+
const unchanged = this.element.value === context.value;
|
|
121
|
+
this.composing = false;
|
|
122
|
+
this.compositionContext = undefined;
|
|
123
|
+
this.host.preview(undefined);
|
|
124
|
+
if (data && !unchanged && !this.host.readOnly()) {
|
|
125
|
+
if (DEAD_KEY_RESULT.test(text)) {
|
|
126
|
+
for (const character of text) {
|
|
127
|
+
this.host.type(character);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.host.replace(text);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.sync();
|
|
135
|
+
}
|
|
136
|
+
/* The document's own line break, not the textarea's. */
|
|
137
|
+
normalizeNewlines(text) {
|
|
138
|
+
const first = this.model.getLine(0);
|
|
139
|
+
const newline = this.model.slice(first.end, first.next) === '\r\n' ? '\r\n' : '\n';
|
|
140
|
+
return textareaText(text).replace(/\n/g, newline);
|
|
141
|
+
}
|
|
142
|
+
composedText() {
|
|
143
|
+
const context = this.compositionContext;
|
|
144
|
+
const suffix = context.value.length - context.end;
|
|
145
|
+
return this.element.value.slice(context.start, Math.max(context.start, this.element.value.length - suffix));
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/offsets.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* Where an offset is after a batch of changes. One inside a replaced range lands after the new text. */
|
|
2
|
+
export function mapOffset(offset, changes) {
|
|
3
|
+
let delta = 0;
|
|
4
|
+
for (const change of changes) {
|
|
5
|
+
if (offset < change.from) {
|
|
6
|
+
break;
|
|
7
|
+
}
|
|
8
|
+
if (offset < change.to || (offset === change.from && change.from === change.to)) {
|
|
9
|
+
return change.from + delta + change.insertedLength;
|
|
10
|
+
}
|
|
11
|
+
delta += change.insertedLength - (change.to - change.from);
|
|
12
|
+
}
|
|
13
|
+
return offset + delta;
|
|
14
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DocumentChange, FoldingRange } from '@adecore/editor-core';
|
|
2
|
+
import type { EditorBlock } from './types.ts';
|
|
3
|
+
export interface OutlineBlock {
|
|
4
|
+
startLine: number;
|
|
5
|
+
endLine: number;
|
|
6
|
+
name?: string;
|
|
7
|
+
kind?: string;
|
|
8
|
+
}
|
|
9
|
+
interface OutlineEntry {
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
name?: string;
|
|
13
|
+
kind?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface OutlineDocument {
|
|
16
|
+
getLine(line: number): {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
text: string;
|
|
20
|
+
};
|
|
21
|
+
positionAt(offset: number): {
|
|
22
|
+
line: number;
|
|
23
|
+
};
|
|
24
|
+
getLineCount(): number;
|
|
25
|
+
}
|
|
26
|
+
export declare function describeHeader(text: string): {
|
|
27
|
+
name?: string;
|
|
28
|
+
kind?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function headerLabel(text: string): string;
|
|
31
|
+
export declare function structuralEntries(ranges: readonly FoldingRange[], document: OutlineDocument, slice: (from: number, to: number) => string, mate: (offset: number) => number | undefined): OutlineEntry[];
|
|
32
|
+
export declare class Outline {
|
|
33
|
+
private structure;
|
|
34
|
+
private provided;
|
|
35
|
+
private version;
|
|
36
|
+
private cache;
|
|
37
|
+
setStructure(entries: OutlineEntry[]): void;
|
|
38
|
+
setProvided(blocks: readonly EditorBlock[] | null, document: OutlineDocument): void;
|
|
39
|
+
edited(batches: readonly (readonly DocumentChange[])[]): void;
|
|
40
|
+
blocks(document: OutlineDocument): readonly OutlineBlock[];
|
|
41
|
+
}
|
|
42
|
+
export interface StickyRows {
|
|
43
|
+
top(line: number): number;
|
|
44
|
+
bottom(line: number): number;
|
|
45
|
+
startsRow(line: number): boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface StickyPlacement {
|
|
48
|
+
block: OutlineBlock;
|
|
49
|
+
depth: number;
|
|
50
|
+
offset: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function stickyPlacements(blocks: readonly OutlineBlock[], scrollTop: number, lineHeight: number, max: number, rows: StickyRows): StickyPlacement[];
|
|
53
|
+
export declare function stickyCover(placements: readonly StickyPlacement[], lineHeight: number): number;
|
|
54
|
+
export declare function scopeChain(blocks: readonly OutlineBlock[], line: number): OutlineBlock[];
|
|
55
|
+
export {};
|
package/dist/outline.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { mapOffset } from './offsets.js';
|
|
2
|
+
const MODIFIERS = '(?:export\\s+|default\\s+|declare\\s+|abstract\\s+|async\\s+|pub(?:\\([^)]*\\))?\\s+|public\\s+|private\\s+|protected\\s+|static\\s+|final\\s+|readonly\\s+|override\\s+|unsafe\\s+|extern\\s+)*';
|
|
3
|
+
const DECLARATION = new RegExp(`^${MODIFIERS}(function\\*?|class|interface|enum|type|namespace|module|trait|impl|struct|fn|def|func|object|record)\\s+([A-Za-z_$][\\w$]*)`);
|
|
4
|
+
const VARIABLE = new RegExp(`^${MODIFIERS}(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)`);
|
|
5
|
+
const KEYWORDS = new Set([
|
|
6
|
+
'if',
|
|
7
|
+
'for',
|
|
8
|
+
'while',
|
|
9
|
+
'switch',
|
|
10
|
+
'catch',
|
|
11
|
+
'function',
|
|
12
|
+
'return',
|
|
13
|
+
'else',
|
|
14
|
+
'with',
|
|
15
|
+
'do',
|
|
16
|
+
'try',
|
|
17
|
+
'new',
|
|
18
|
+
'await',
|
|
19
|
+
'typeof',
|
|
20
|
+
'super',
|
|
21
|
+
'finally'
|
|
22
|
+
]);
|
|
23
|
+
const METHOD = /^(?:(?:public|private|protected|static|async|get|set|override|readonly|abstract)\s+)*\*?\s*([A-Za-z_$#][\w$]*)\s*[(<]/;
|
|
24
|
+
const PROPERTY = /^["']?([\w$-]+)["']?\s*:\s*[{[(]?$/;
|
|
25
|
+
const KINDS = {
|
|
26
|
+
'function*': 'function',
|
|
27
|
+
fn: 'function',
|
|
28
|
+
def: 'function',
|
|
29
|
+
func: 'function',
|
|
30
|
+
object: 'class',
|
|
31
|
+
record: 'class',
|
|
32
|
+
struct: 'class',
|
|
33
|
+
trait: 'interface',
|
|
34
|
+
impl: 'class'
|
|
35
|
+
};
|
|
36
|
+
const LABEL_LIMIT = 48;
|
|
37
|
+
/* What a header line declares, when it declares something a person would look for by name. */
|
|
38
|
+
export function describeHeader(text) {
|
|
39
|
+
const line = text.trim();
|
|
40
|
+
const declaration = DECLARATION.exec(line);
|
|
41
|
+
if (declaration) {
|
|
42
|
+
return { name: declaration[2], kind: KINDS[declaration[1]] ?? declaration[1] };
|
|
43
|
+
}
|
|
44
|
+
const variable = VARIABLE.exec(line);
|
|
45
|
+
if (variable) {
|
|
46
|
+
return { name: variable[1], kind: 'variable' };
|
|
47
|
+
}
|
|
48
|
+
const method = METHOD.exec(line);
|
|
49
|
+
if (method && !KEYWORDS.has(method[1])) {
|
|
50
|
+
return { name: method[1], kind: 'method' };
|
|
51
|
+
}
|
|
52
|
+
const property = PROPERTY.exec(line);
|
|
53
|
+
return property ? { name: property[1], kind: 'property' } : {};
|
|
54
|
+
}
|
|
55
|
+
/* The header line as a label: what a breadcrumb shows for a block that declares nothing. */
|
|
56
|
+
export function headerLabel(text) {
|
|
57
|
+
const label = text.trim().replace(/[\s{(:]+$/, '');
|
|
58
|
+
return label.length > LABEL_LIMIT ? `${label.slice(0, LABEL_LIMIT - 1)}…` : label;
|
|
59
|
+
}
|
|
60
|
+
/*
|
|
61
|
+
* The blocks a language-less editor can tell from brackets and indentation: braces and indented
|
|
62
|
+
* stretches, each headed by the line it opens on. A header that only closes a parameter list is
|
|
63
|
+
* headed by the line the list opens on, so the signature of a function is the header and not its tail.
|
|
64
|
+
*/
|
|
65
|
+
export function structuralEntries(ranges, document, slice, mate) {
|
|
66
|
+
const entries = [];
|
|
67
|
+
for (const range of ranges) {
|
|
68
|
+
if (range.kind === 'comment' || (range.kind === 'bracket' && slice(range.from, range.from + 1) !== '{')) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
let headerLine = range.startLine;
|
|
72
|
+
const header = document.getLine(headerLine);
|
|
73
|
+
const lead = header.text.length - header.text.trimStart().length;
|
|
74
|
+
if (/^[)\]]/.test(header.text.slice(lead))) {
|
|
75
|
+
const opener = mate(header.start + lead);
|
|
76
|
+
if (opener !== undefined && opener < header.start) {
|
|
77
|
+
headerLine = document.positionAt(opener).line;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const start = document.getLine(headerLine);
|
|
81
|
+
entries.push({ from: start.start, to: document.getLine(range.endLine).end, ...describeHeader(start.text) });
|
|
82
|
+
}
|
|
83
|
+
return entries;
|
|
84
|
+
}
|
|
85
|
+
function toEditorLines(entry, document) {
|
|
86
|
+
const startLine = document.positionAt(entry.from).line;
|
|
87
|
+
return {
|
|
88
|
+
startLine,
|
|
89
|
+
endLine: Math.max(startLine, document.positionAt(entry.to).line),
|
|
90
|
+
...(entry.name === undefined ? {} : { name: entry.name }),
|
|
91
|
+
...(entry.kind === undefined ? {} : { kind: entry.kind })
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/*
|
|
95
|
+
* The blocks the editor knows the document by: its own reading of the structure, or the host's
|
|
96
|
+
* where it has better, such as a language server's symbols. Ends are offsets, so an edit keeps
|
|
97
|
+
* them on their lines until the next reading.
|
|
98
|
+
*/
|
|
99
|
+
export class Outline {
|
|
100
|
+
structure = [];
|
|
101
|
+
provided = null;
|
|
102
|
+
version = 0;
|
|
103
|
+
cache;
|
|
104
|
+
setStructure(entries) {
|
|
105
|
+
this.structure = entries;
|
|
106
|
+
this.version++;
|
|
107
|
+
}
|
|
108
|
+
/* Blocks the host drew from its own source, in one-based lines; null goes back to the structure. */
|
|
109
|
+
setProvided(blocks, document) {
|
|
110
|
+
this.version++;
|
|
111
|
+
if (blocks === null) {
|
|
112
|
+
this.provided = null;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const last = document.getLineCount() - 1;
|
|
116
|
+
const clamp = (line) => Math.min(last, Math.max(0, Math.trunc(line) - 1));
|
|
117
|
+
this.provided = blocks.map((block) => {
|
|
118
|
+
const start = document.getLine(clamp(block.startLine));
|
|
119
|
+
const header = block.name === undefined ? headerLabel(start.text) : block.name;
|
|
120
|
+
return {
|
|
121
|
+
from: start.start,
|
|
122
|
+
to: document.getLine(Math.max(clamp(block.startLine), clamp(block.endLine))).end,
|
|
123
|
+
name: header,
|
|
124
|
+
...(block.kind === undefined ? {} : { kind: block.kind })
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
edited(batches) {
|
|
129
|
+
const move = (entries) => entries.map((entry) => {
|
|
130
|
+
let from = entry.from;
|
|
131
|
+
let to = entry.to;
|
|
132
|
+
for (const changes of batches) {
|
|
133
|
+
from = mapOffset(from, changes);
|
|
134
|
+
to = mapOffset(to, changes);
|
|
135
|
+
}
|
|
136
|
+
return { ...entry, from, to };
|
|
137
|
+
});
|
|
138
|
+
this.structure = move(this.structure);
|
|
139
|
+
this.provided = this.provided === null ? null : move(this.provided);
|
|
140
|
+
this.version++;
|
|
141
|
+
}
|
|
142
|
+
/* Outermost first. Of two blocks that start on one line, only the larger one. */
|
|
143
|
+
blocks(document) {
|
|
144
|
+
if (this.cache?.version !== this.version) {
|
|
145
|
+
const sorted = (this.provided ?? this.structure)
|
|
146
|
+
.map((entry) => toEditorLines(entry, document))
|
|
147
|
+
.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
|
|
148
|
+
this.cache = { version: this.version, blocks: sorted.filter((block, index) => index === 0 || block.startLine !== sorted[index - 1].startLine) };
|
|
149
|
+
}
|
|
150
|
+
return this.cache.blocks;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/*
|
|
154
|
+
* The headers pinned at the top, outermost first. A header is pinned from the moment its own row
|
|
155
|
+
* reaches the place it would be pinned at, which is the one a row of its own is at when it comes
|
|
156
|
+
* there, so it never jumps. At the end of its block the next row pushes it up and out.
|
|
157
|
+
*/
|
|
158
|
+
export function stickyPlacements(blocks, scrollTop, lineHeight, max, rows) {
|
|
159
|
+
const placements = [];
|
|
160
|
+
for (let depth = 0; depth < max; depth++) {
|
|
161
|
+
const pinnedAt = scrollTop + depth * lineHeight;
|
|
162
|
+
const parent = placements.at(-1)?.block;
|
|
163
|
+
const next = blocks.find((block) => (parent === undefined || (block.startLine > parent.startLine && block.endLine <= parent.endLine)) &&
|
|
164
|
+
rows.startsRow(block.startLine) &&
|
|
165
|
+
rows.top(block.startLine) < pinnedAt &&
|
|
166
|
+
rows.bottom(block.endLine) > pinnedAt);
|
|
167
|
+
if (next === undefined) {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
placements.push({ block: next, depth, offset: Math.min(0, rows.bottom(next.endLine) - (pinnedAt + lineHeight)) });
|
|
171
|
+
}
|
|
172
|
+
return placements;
|
|
173
|
+
}
|
|
174
|
+
/* How far down from the top the pinned headers reach. */
|
|
175
|
+
export function stickyCover(placements, lineHeight) {
|
|
176
|
+
return Math.max(0, ...placements.map((placement) => (placement.depth + 1) * lineHeight + placement.offset));
|
|
177
|
+
}
|
|
178
|
+
/* The named blocks around a line, outermost first. */
|
|
179
|
+
export function scopeChain(blocks, line) {
|
|
180
|
+
return blocks.filter((block) => block.name !== undefined && block.startLine <= line && block.endLine >= line);
|
|
181
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type OverviewKind = 'added' | 'modified' | 'deleted' | 'find' | 'find-current' | 'occurrence' | 'info' | 'warning' | 'error';
|
|
2
|
+
export interface OverviewSpan {
|
|
3
|
+
kind: OverviewKind;
|
|
4
|
+
top: number;
|
|
5
|
+
bottom: number;
|
|
6
|
+
offset?: number;
|
|
7
|
+
title?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface OverviewTick {
|
|
10
|
+
kind: OverviewKind;
|
|
11
|
+
y: number;
|
|
12
|
+
height: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
title?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function overviewTicks(spans: readonly OverviewSpan[], contentHeight: number, trackHeight: number): OverviewTick[];
|
|
17
|
+
export declare function paintOverview(container: HTMLElement, ticks: readonly OverviewTick[]): void;
|
package/dist/overview.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const MIN_TICK_HEIGHT = 3;
|
|
2
|
+
/*
|
|
3
|
+
* The spans of the content as ticks in a track as tall as the view. A short document is not
|
|
4
|
+
* stretched over the track, so a tick is where its line is. Ticks of one kind that touch are one.
|
|
5
|
+
*/
|
|
6
|
+
export function overviewTicks(spans, contentHeight, trackHeight) {
|
|
7
|
+
if (trackHeight <= 0) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const scale = trackHeight / Math.max(contentHeight, trackHeight);
|
|
11
|
+
const ticks = spans
|
|
12
|
+
.map((span) => ({
|
|
13
|
+
kind: span.kind,
|
|
14
|
+
y: Math.round(span.top * scale),
|
|
15
|
+
height: Math.max(MIN_TICK_HEIGHT, Math.round((span.bottom - span.top) * scale)),
|
|
16
|
+
...(span.offset === undefined ? {} : { offset: span.offset }),
|
|
17
|
+
...(span.title === undefined ? {} : { title: span.title })
|
|
18
|
+
}))
|
|
19
|
+
.sort((left, right) => left.y - right.y);
|
|
20
|
+
const merged = [];
|
|
21
|
+
const last = new Map();
|
|
22
|
+
for (const tick of ticks) {
|
|
23
|
+
const before = last.get(tick.kind);
|
|
24
|
+
if (before && tick.y <= before.y + before.height) {
|
|
25
|
+
before.height = Math.max(before.height, tick.y + tick.height - before.y);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const next = { ...tick };
|
|
29
|
+
last.set(tick.kind, next);
|
|
30
|
+
merged.push(next);
|
|
31
|
+
}
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
|
34
|
+
/* What is painted later is on top: the current find match, and the worse a problem is the later it goes. */
|
|
35
|
+
const PAINT_ORDER = { warning: 1, error: 2, 'find-current': 3 };
|
|
36
|
+
/* Draws the ticks. */
|
|
37
|
+
export function paintOverview(container, ticks) {
|
|
38
|
+
const document = container.ownerDocument;
|
|
39
|
+
const order = (tick) => PAINT_ORDER[tick.kind] ?? 0;
|
|
40
|
+
container.replaceChildren(...[...ticks]
|
|
41
|
+
.sort((left, right) => order(left) - order(right))
|
|
42
|
+
.map((tick) => {
|
|
43
|
+
const element = document.createElement('span');
|
|
44
|
+
element.className = `se-tick se-tick-${tick.kind}`;
|
|
45
|
+
element.style.top = `${tick.y}px`;
|
|
46
|
+
element.style.height = `${tick.height}px`;
|
|
47
|
+
if (tick.offset !== undefined) {
|
|
48
|
+
element.setAttribute('data-offset', String(tick.offset));
|
|
49
|
+
}
|
|
50
|
+
if (tick.title !== undefined) {
|
|
51
|
+
element.setAttribute('data-title', tick.title);
|
|
52
|
+
}
|
|
53
|
+
return element;
|
|
54
|
+
}));
|
|
55
|
+
}
|