@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/src/lens.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EditorCodeVisionEntry } from './types.ts';
|
|
2
|
+
|
|
3
|
+
/* What decides whether a row has to be drawn again: the words and the icons, never the handlers, which a press looks up at the time. */
|
|
4
|
+
export function lensSignature(entries: readonly EditorCodeVisionEntry[]): string {
|
|
5
|
+
return entries.map((entry) => `${entry.id}\0${entry.text}\0${entry.icon ?? ''}`).join('\n');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The entries of a row as buttons. A press looks its entry up when it happens, so an entry the host set
|
|
10
|
+
* after the row was drawn is the one that answers.
|
|
11
|
+
*/
|
|
12
|
+
export function fillLens(container: HTMLElement, entries: readonly EditorCodeVisionEntry[], lookup: (id: string) => EditorCodeVisionEntry | undefined): void {
|
|
13
|
+
const document = container.ownerDocument;
|
|
14
|
+
container.classList.add('se-lens');
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const button = document.createElement('button');
|
|
17
|
+
button.type = 'button';
|
|
18
|
+
button.className = 'se-lens-entry';
|
|
19
|
+
button.tabIndex = -1;
|
|
20
|
+
if (entry.icon !== undefined) {
|
|
21
|
+
const icon = document.createElement('span');
|
|
22
|
+
icon.className = `se-lens-icon se-lens-icon-${entry.icon}`;
|
|
23
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
24
|
+
button.append(icon);
|
|
25
|
+
}
|
|
26
|
+
button.append(entry.text);
|
|
27
|
+
// The editor keeps the focus, so the caret stays where it was and a popover can hand the keyboard back.
|
|
28
|
+
button.addEventListener('pointerdown', (event) => event.preventDefault());
|
|
29
|
+
button.addEventListener('click', () => lookup(entry.id)?.activate(button.getBoundingClientRect()));
|
|
30
|
+
container.append(button);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/listeners.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type Listener = () => void;
|
|
2
|
+
|
|
3
|
+
export function subscribe(listeners: Set<Listener>, listener: Listener): () => void {
|
|
4
|
+
listeners.add(listener);
|
|
5
|
+
return () => {
|
|
6
|
+
listeners.delete(listener);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Over a copy, so a listener that unsubscribes itself does not skip the next one. */
|
|
11
|
+
export function emit(listeners: Set<Listener>): void {
|
|
12
|
+
for (const listener of [...listeners]) {
|
|
13
|
+
listener();
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { LayoutMetrics } from './layout.ts';
|
|
2
|
+
|
|
3
|
+
export interface EditorFont {
|
|
4
|
+
family: string;
|
|
5
|
+
/* In pixels. */
|
|
6
|
+
size: number;
|
|
7
|
+
lineHeight: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const FALLBACK: EditorFont = { family: 'monospace', size: 13, lineHeight: 20 };
|
|
11
|
+
|
|
12
|
+
function pixels(value: string): number | null {
|
|
13
|
+
const parsed = Number.parseFloat(value);
|
|
14
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* The code face the page gives the editor's element, in whole pixels so a line is as tall as the viewer's. */
|
|
18
|
+
export function readEditorFont(element: HTMLElement): EditorFont {
|
|
19
|
+
const view = element.ownerDocument.defaultView;
|
|
20
|
+
if (!view?.getComputedStyle) {
|
|
21
|
+
return FALLBACK;
|
|
22
|
+
}
|
|
23
|
+
const style = view.getComputedStyle(element);
|
|
24
|
+
const size = pixels(style.fontSize) ?? FALLBACK.size;
|
|
25
|
+
const lineHeight = pixels(style.lineHeight) ?? Math.round(size * 1.5);
|
|
26
|
+
return { family: style.fontFamily || FALLBACK.family, size, lineHeight: Math.round(lineHeight) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* 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. */
|
|
30
|
+
export function createMetrics(font: EditorFont, tabSize: number, document: Document): LayoutMetrics {
|
|
31
|
+
let context: CanvasRenderingContext2D | null = null;
|
|
32
|
+
try {
|
|
33
|
+
context = document.createElement('canvas').getContext('2d');
|
|
34
|
+
} catch {
|
|
35
|
+
context = null;
|
|
36
|
+
}
|
|
37
|
+
const codeFont = `${font.size}px ${font.family}`;
|
|
38
|
+
const inlayFont = `${Math.max(12, font.size - 1)}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
|
|
39
|
+
const cache = new Map<string, number>();
|
|
40
|
+
const measure = (text: string, face: string, fallback: number): number => {
|
|
41
|
+
if (!context) {
|
|
42
|
+
return Array.from(text).length * fallback;
|
|
43
|
+
}
|
|
44
|
+
context.font = face;
|
|
45
|
+
context.fontKerning = 'none';
|
|
46
|
+
return context.measureText(text).width;
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
lineHeight: font.lineHeight,
|
|
50
|
+
charWidth: measure('M', codeFont, font.size * 0.6),
|
|
51
|
+
tabSize,
|
|
52
|
+
measureText(text) {
|
|
53
|
+
let width = cache.get(text);
|
|
54
|
+
if (width === undefined) {
|
|
55
|
+
width = measure(text, codeFont, font.size * 0.6);
|
|
56
|
+
if (cache.size > 2000) {
|
|
57
|
+
cache.clear();
|
|
58
|
+
}
|
|
59
|
+
cache.set(text, width);
|
|
60
|
+
}
|
|
61
|
+
return width;
|
|
62
|
+
},
|
|
63
|
+
measureInlay: (text) => measure(text, inlayFont, font.size * 0.5)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
|
|
7
|
+
export interface GestureEvent {
|
|
8
|
+
key: string;
|
|
9
|
+
timeStamp: number;
|
|
10
|
+
repeat?: boolean;
|
|
11
|
+
altKey: boolean;
|
|
12
|
+
ctrlKey: boolean;
|
|
13
|
+
metaKey: boolean;
|
|
14
|
+
shiftKey: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GestureAction = 'above' | 'below';
|
|
18
|
+
|
|
19
|
+
/* How long the taps may be apart. */
|
|
20
|
+
const TAP_MS = 300;
|
|
21
|
+
/* A first tap this old is forgotten even by a press that comes after the slower rule. */
|
|
22
|
+
const FORGET_MS = 500;
|
|
23
|
+
|
|
24
|
+
export class DoubleModifierGesture {
|
|
25
|
+
private firstPressed = false;
|
|
26
|
+
private firstReleased = false;
|
|
27
|
+
private secondPressed = false;
|
|
28
|
+
private last = 0;
|
|
29
|
+
private readonly modifier: 'Alt' | 'Control';
|
|
30
|
+
|
|
31
|
+
constructor(modifier: 'Alt' | 'Control') {
|
|
32
|
+
this.modifier = modifier;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
reset(): void {
|
|
36
|
+
this.firstPressed = false;
|
|
37
|
+
this.firstReleased = false;
|
|
38
|
+
this.secondPressed = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Whether the modifier is down for the second time, which is when an arrow means something. */
|
|
42
|
+
get armed(): boolean {
|
|
43
|
+
return this.firstPressed && this.firstReleased && this.secondPressed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Another modifier held with the one that counts spoils the gesture. */
|
|
47
|
+
private spoiled(event: GestureEvent): boolean {
|
|
48
|
+
const others = this.modifier === 'Alt' ? event.ctrlKey || event.metaKey || event.shiftKey : event.altKey || event.metaKey || event.shiftKey;
|
|
49
|
+
return others;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
keydown(event: GestureEvent): GestureAction | null {
|
|
53
|
+
if (event.key === this.modifier) {
|
|
54
|
+
if (event.repeat === true) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (this.spoiled(event)) {
|
|
58
|
+
this.reset();
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (this.firstPressed && event.timeStamp - this.last > FORGET_MS) {
|
|
62
|
+
this.reset();
|
|
63
|
+
}
|
|
64
|
+
this.step(event, 'press');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (this.armed) {
|
|
68
|
+
const held = this.modifier === 'Alt' ? event.altKey : event.ctrlKey;
|
|
69
|
+
if (held && !this.spoiled(event) && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
70
|
+
return event.key === 'ArrowUp' ? 'above' : 'below';
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
this.reset();
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
keyup(event: GestureEvent): void {
|
|
79
|
+
if (event.key === this.modifier) {
|
|
80
|
+
if (this.spoiled(event)) {
|
|
81
|
+
this.reset();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.step(event, 'release');
|
|
85
|
+
} else if (!this.armed) {
|
|
86
|
+
this.reset();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private step(event: GestureEvent, kind: 'press' | 'release'): void {
|
|
91
|
+
if (this.firstPressed && event.timeStamp - this.last > TAP_MS) {
|
|
92
|
+
this.reset();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (kind === 'press') {
|
|
96
|
+
if (!this.firstPressed) {
|
|
97
|
+
this.reset();
|
|
98
|
+
this.firstPressed = true;
|
|
99
|
+
this.last = event.timeStamp;
|
|
100
|
+
} else if (this.firstReleased) {
|
|
101
|
+
this.secondPressed = true;
|
|
102
|
+
this.last = event.timeStamp;
|
|
103
|
+
} else {
|
|
104
|
+
this.reset();
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.firstPressed && !this.firstReleased) {
|
|
109
|
+
this.firstReleased = true;
|
|
110
|
+
this.last = event.timeStamp;
|
|
111
|
+
} else {
|
|
112
|
+
this.reset();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { DocumentModel, Selection, TextEdit } from '@adecore/editor-core';
|
|
2
|
+
import { findChange, modelOffset, textareaOffset, textareaText } from './input.ts';
|
|
3
|
+
|
|
4
|
+
interface InputContext {
|
|
5
|
+
from: number;
|
|
6
|
+
source: string;
|
|
7
|
+
value: string;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NativeInputHost {
|
|
13
|
+
readOnly(): boolean;
|
|
14
|
+
replace(text: string): void;
|
|
15
|
+
/* One typed character, with the pairing and the overtyping a keystroke gets. */
|
|
16
|
+
type(character: string): void;
|
|
17
|
+
apply(edit: TextEdit, selection: Selection): void;
|
|
18
|
+
preview(text: string | undefined): void;
|
|
19
|
+
selectionChanged(selection: Selection): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* 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. */
|
|
23
|
+
const CONTEXT = 8192;
|
|
24
|
+
|
|
25
|
+
/* 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. */
|
|
26
|
+
const DEAD_KEY_RESULT = /^[\x20-\x7e]{1,2}$/;
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* The hidden textarea. It is the sink for what the platform types, composes and pastes, and carries
|
|
30
|
+
* the text around the caret for assistive technology. It never draws and never decides: the model
|
|
31
|
+
* owns the text and the selection, and the textarea is written from it.
|
|
32
|
+
*/
|
|
33
|
+
export class NativeInput {
|
|
34
|
+
composing = false;
|
|
35
|
+
private context: InputContext = { from: 0, source: '', value: '', start: 0, end: 0 };
|
|
36
|
+
private compositionContext: InputContext | undefined;
|
|
37
|
+
private writing = false;
|
|
38
|
+
private readonly element: HTMLTextAreaElement;
|
|
39
|
+
private readonly model: DocumentModel;
|
|
40
|
+
private readonly host: NativeInputHost;
|
|
41
|
+
|
|
42
|
+
constructor(element: HTMLTextAreaElement, model: DocumentModel, host: NativeInputHost) {
|
|
43
|
+
this.element = element;
|
|
44
|
+
this.model = model;
|
|
45
|
+
this.host = host;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
sync(): void {
|
|
49
|
+
if (this.composing) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const selection = this.model.getPrimary();
|
|
53
|
+
const caretCount = this.model.getSelectionCount();
|
|
54
|
+
const length = this.model.getLength();
|
|
55
|
+
const start = Math.min(selection.anchor, selection.head);
|
|
56
|
+
const end = Math.max(selection.anchor, selection.head);
|
|
57
|
+
let from = 0;
|
|
58
|
+
let to = length;
|
|
59
|
+
if (length > CONTEXT) {
|
|
60
|
+
from = Math.max(0, Math.min(start, selection.head - 2048));
|
|
61
|
+
to = Math.min(length, end + 2048);
|
|
62
|
+
if (to - from > CONTEXT) {
|
|
63
|
+
from = Math.max(0, selection.head - CONTEXT / 2);
|
|
64
|
+
to = Math.min(length, from + CONTEXT);
|
|
65
|
+
}
|
|
66
|
+
// A boundary must not split a surrogate pair or a CRLF.
|
|
67
|
+
if (from > 0 && /[\uDC00-\uDFFF\n]/.test(this.model.slice(from, from + 1))) {
|
|
68
|
+
from--;
|
|
69
|
+
}
|
|
70
|
+
if (to < length && /[\uDC00-\uDFFF\n]/.test(this.model.slice(to, to + 1))) {
|
|
71
|
+
to++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const source = this.model.slice(from, to);
|
|
75
|
+
const value = textareaText(source);
|
|
76
|
+
const nativeStart = textareaOffset(source, Math.max(0, start - from));
|
|
77
|
+
const nativeEnd = textareaOffset(source, Math.max(0, end - from));
|
|
78
|
+
this.context = { from, source, value, start: nativeStart, end: nativeEnd };
|
|
79
|
+
this.writing = true;
|
|
80
|
+
if (this.element.value !== value) {
|
|
81
|
+
this.element.value = value;
|
|
82
|
+
}
|
|
83
|
+
this.element.setSelectionRange(nativeStart, nativeEnd, selection.head < selection.anchor ? 'backward' : 'forward');
|
|
84
|
+
this.writing = false;
|
|
85
|
+
const position = this.model.positionAt(selection.head);
|
|
86
|
+
this.element.setAttribute(
|
|
87
|
+
'aria-description',
|
|
88
|
+
`Line ${position.line + 1}, column ${position.column + 1}. ${caretCount} caret${caretCount === 1 ? '' : 's'}.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* A selection the platform made inside the textarea, such as a long press or an accessibility action. */
|
|
93
|
+
readSelection(): void {
|
|
94
|
+
if (this.writing || this.composing || this.element.value !== this.context.value) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const start = this.element.selectionStart;
|
|
98
|
+
const end = this.element.selectionEnd;
|
|
99
|
+
if (start === this.context.start && end === this.context.end) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const from = this.context.from + modelOffset(this.context.source, start);
|
|
103
|
+
const to = this.context.from + modelOffset(this.context.source, end);
|
|
104
|
+
this.host.selectionChanged(this.element.selectionDirection === 'backward' ? { anchor: to, head: from } : { anchor: from, head: to });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
onInput(): void {
|
|
108
|
+
if (this.composing) {
|
|
109
|
+
this.host.preview(this.composedText());
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (this.host.readOnly()) {
|
|
113
|
+
this.sync();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const context = this.context;
|
|
117
|
+
const change = findChange(context.value, this.element.value);
|
|
118
|
+
if (change.from === change.to && !change.text) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (this.model.getSelections().length > 1) {
|
|
122
|
+
const suffix = context.value.length - context.end;
|
|
123
|
+
this.host.replace(this.element.value.slice(context.start, Math.max(context.start, this.element.value.length - suffix)));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const edit = {
|
|
127
|
+
from: context.from + modelOffset(context.source, change.from),
|
|
128
|
+
to: context.from + modelOffset(context.source, change.to),
|
|
129
|
+
text: this.normalizeNewlines(change.text)
|
|
130
|
+
};
|
|
131
|
+
const next = context.source.slice(0, edit.from - context.from) + edit.text + context.source.slice(edit.to - context.from);
|
|
132
|
+
const start = context.from + modelOffset(next, this.element.selectionStart);
|
|
133
|
+
const end = context.from + modelOffset(next, this.element.selectionEnd);
|
|
134
|
+
this.host.apply(edit, this.element.selectionDirection === 'backward' ? { anchor: end, head: start } : { anchor: start, head: end });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
startComposition(): void {
|
|
138
|
+
if (this.host.readOnly()) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.compositionContext = { ...this.context };
|
|
142
|
+
this.composing = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
endComposition(data: string): void {
|
|
146
|
+
if (!this.composing) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const text = this.composedText();
|
|
150
|
+
const context = this.compositionContext!;
|
|
151
|
+
const unchanged = this.element.value === context.value;
|
|
152
|
+
this.composing = false;
|
|
153
|
+
this.compositionContext = undefined;
|
|
154
|
+
this.host.preview(undefined);
|
|
155
|
+
if (data && !unchanged && !this.host.readOnly()) {
|
|
156
|
+
if (DEAD_KEY_RESULT.test(text)) {
|
|
157
|
+
for (const character of text) {
|
|
158
|
+
this.host.type(character);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
this.host.replace(text);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.sync();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* The document's own line break, not the textarea's. */
|
|
168
|
+
normalizeNewlines(text: string): string {
|
|
169
|
+
const first = this.model.getLine(0);
|
|
170
|
+
const newline = this.model.slice(first.end, first.next) === '\r\n' ? '\r\n' : '\n';
|
|
171
|
+
return textareaText(text).replace(/\n/g, newline);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private composedText(): string {
|
|
175
|
+
const context = this.compositionContext!;
|
|
176
|
+
const suffix = context.value.length - context.end;
|
|
177
|
+
return this.element.value.slice(context.start, Math.max(context.start, this.element.value.length - suffix));
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/offsets.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DocumentChange } from '@adecore/editor-core';
|
|
2
|
+
|
|
3
|
+
/* Where an offset is after a batch of changes. One inside a replaced range lands after the new text. */
|
|
4
|
+
export function mapOffset(offset: number, changes: readonly DocumentChange[]): number {
|
|
5
|
+
let delta = 0;
|
|
6
|
+
for (const change of changes) {
|
|
7
|
+
if (offset < change.from) {
|
|
8
|
+
break;
|
|
9
|
+
}
|
|
10
|
+
if (offset < change.to || (offset === change.from && change.from === change.to)) {
|
|
11
|
+
return change.from + delta + change.insertedLength;
|
|
12
|
+
}
|
|
13
|
+
delta += change.insertedLength - (change.to - change.from);
|
|
14
|
+
}
|
|
15
|
+
return offset + delta;
|
|
16
|
+
}
|
package/src/outline.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { DocumentChange, FoldingRange } from '@adecore/editor-core';
|
|
2
|
+
import { mapOffset } from './offsets.ts';
|
|
3
|
+
import type { EditorBlock } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/* A block with a header line, in zero-based lines and both ends inclusive. */
|
|
6
|
+
export interface OutlineBlock {
|
|
7
|
+
startLine: number;
|
|
8
|
+
endLine: number;
|
|
9
|
+
/* What a breadcrumb calls it. Blocks of the editor's own structure only have one when their header declares something. */
|
|
10
|
+
name?: string;
|
|
11
|
+
kind?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface OutlineEntry {
|
|
15
|
+
/* The start of the header line and the end of the last line, so an edit inside moves them along. */
|
|
16
|
+
from: number;
|
|
17
|
+
to: number;
|
|
18
|
+
name?: string;
|
|
19
|
+
kind?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OutlineDocument {
|
|
23
|
+
getLine(line: number): { start: number; end: number; text: string };
|
|
24
|
+
positionAt(offset: number): { line: number };
|
|
25
|
+
getLineCount(): number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MODIFIERS =
|
|
29
|
+
'(?: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+)*';
|
|
30
|
+
const DECLARATION = new RegExp(
|
|
31
|
+
`^${MODIFIERS}(function\\*?|class|interface|enum|type|namespace|module|trait|impl|struct|fn|def|func|object|record)\\s+([A-Za-z_$][\\w$]*)`
|
|
32
|
+
);
|
|
33
|
+
const VARIABLE = new RegExp(`^${MODIFIERS}(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)`);
|
|
34
|
+
const KEYWORDS = new Set([
|
|
35
|
+
'if',
|
|
36
|
+
'for',
|
|
37
|
+
'while',
|
|
38
|
+
'switch',
|
|
39
|
+
'catch',
|
|
40
|
+
'function',
|
|
41
|
+
'return',
|
|
42
|
+
'else',
|
|
43
|
+
'with',
|
|
44
|
+
'do',
|
|
45
|
+
'try',
|
|
46
|
+
'new',
|
|
47
|
+
'await',
|
|
48
|
+
'typeof',
|
|
49
|
+
'super',
|
|
50
|
+
'finally'
|
|
51
|
+
]);
|
|
52
|
+
const METHOD = /^(?:(?:public|private|protected|static|async|get|set|override|readonly|abstract)\s+)*\*?\s*([A-Za-z_$#][\w$]*)\s*[(<]/;
|
|
53
|
+
const PROPERTY = /^["']?([\w$-]+)["']?\s*:\s*[{[(]?$/;
|
|
54
|
+
const KINDS: Record<string, string> = {
|
|
55
|
+
'function*': 'function',
|
|
56
|
+
fn: 'function',
|
|
57
|
+
def: 'function',
|
|
58
|
+
func: 'function',
|
|
59
|
+
object: 'class',
|
|
60
|
+
record: 'class',
|
|
61
|
+
struct: 'class',
|
|
62
|
+
trait: 'interface',
|
|
63
|
+
impl: 'class'
|
|
64
|
+
};
|
|
65
|
+
const LABEL_LIMIT = 48;
|
|
66
|
+
|
|
67
|
+
/* What a header line declares, when it declares something a person would look for by name. */
|
|
68
|
+
export function describeHeader(text: string): { name?: string; kind?: string } {
|
|
69
|
+
const line = text.trim();
|
|
70
|
+
const declaration = DECLARATION.exec(line);
|
|
71
|
+
if (declaration) {
|
|
72
|
+
return { name: declaration[2]!, kind: KINDS[declaration[1]!] ?? declaration[1]! };
|
|
73
|
+
}
|
|
74
|
+
const variable = VARIABLE.exec(line);
|
|
75
|
+
if (variable) {
|
|
76
|
+
return { name: variable[1]!, kind: 'variable' };
|
|
77
|
+
}
|
|
78
|
+
const method = METHOD.exec(line);
|
|
79
|
+
if (method && !KEYWORDS.has(method[1]!)) {
|
|
80
|
+
return { name: method[1]!, kind: 'method' };
|
|
81
|
+
}
|
|
82
|
+
const property = PROPERTY.exec(line);
|
|
83
|
+
return property ? { name: property[1]!, kind: 'property' } : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* The header line as a label: what a breadcrumb shows for a block that declares nothing. */
|
|
87
|
+
export function headerLabel(text: string): string {
|
|
88
|
+
const label = text.trim().replace(/[\s{(:]+$/, '');
|
|
89
|
+
return label.length > LABEL_LIMIT ? `${label.slice(0, LABEL_LIMIT - 1)}…` : label;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* The blocks a language-less editor can tell from brackets and indentation: braces and indented
|
|
94
|
+
* stretches, each headed by the line it opens on. A header that only closes a parameter list is
|
|
95
|
+
* headed by the line the list opens on, so the signature of a function is the header and not its tail.
|
|
96
|
+
*/
|
|
97
|
+
export function structuralEntries(
|
|
98
|
+
ranges: readonly FoldingRange[],
|
|
99
|
+
document: OutlineDocument,
|
|
100
|
+
slice: (from: number, to: number) => string,
|
|
101
|
+
mate: (offset: number) => number | undefined
|
|
102
|
+
): OutlineEntry[] {
|
|
103
|
+
const entries: OutlineEntry[] = [];
|
|
104
|
+
for (const range of ranges) {
|
|
105
|
+
if (range.kind === 'comment' || (range.kind === 'bracket' && slice(range.from, range.from + 1) !== '{')) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let headerLine = range.startLine;
|
|
109
|
+
const header = document.getLine(headerLine);
|
|
110
|
+
const lead = header.text.length - header.text.trimStart().length;
|
|
111
|
+
if (/^[)\]]/.test(header.text.slice(lead))) {
|
|
112
|
+
const opener = mate(header.start + lead);
|
|
113
|
+
if (opener !== undefined && opener < header.start) {
|
|
114
|
+
headerLine = document.positionAt(opener).line;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const start = document.getLine(headerLine);
|
|
118
|
+
entries.push({ from: start.start, to: document.getLine(range.endLine).end, ...describeHeader(start.text) });
|
|
119
|
+
}
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toEditorLines(entry: OutlineEntry, document: OutlineDocument): OutlineBlock {
|
|
124
|
+
const startLine = document.positionAt(entry.from).line;
|
|
125
|
+
return {
|
|
126
|
+
startLine,
|
|
127
|
+
endLine: Math.max(startLine, document.positionAt(entry.to).line),
|
|
128
|
+
...(entry.name === undefined ? {} : { name: entry.name }),
|
|
129
|
+
...(entry.kind === undefined ? {} : { kind: entry.kind })
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* The blocks the editor knows the document by: its own reading of the structure, or the host's
|
|
135
|
+
* where it has better, such as a language server's symbols. Ends are offsets, so an edit keeps
|
|
136
|
+
* them on their lines until the next reading.
|
|
137
|
+
*/
|
|
138
|
+
export class Outline {
|
|
139
|
+
private structure: OutlineEntry[] = [];
|
|
140
|
+
private provided: OutlineEntry[] | null = null;
|
|
141
|
+
private version = 0;
|
|
142
|
+
private cache: { version: number; blocks: OutlineBlock[] } | undefined;
|
|
143
|
+
|
|
144
|
+
setStructure(entries: OutlineEntry[]): void {
|
|
145
|
+
this.structure = entries;
|
|
146
|
+
this.version++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Blocks the host drew from its own source, in one-based lines; null goes back to the structure. */
|
|
150
|
+
setProvided(blocks: readonly EditorBlock[] | null, document: OutlineDocument): void {
|
|
151
|
+
this.version++;
|
|
152
|
+
if (blocks === null) {
|
|
153
|
+
this.provided = null;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const last = document.getLineCount() - 1;
|
|
157
|
+
const clamp = (line: number): number => Math.min(last, Math.max(0, Math.trunc(line) - 1));
|
|
158
|
+
this.provided = blocks.map((block) => {
|
|
159
|
+
const start = document.getLine(clamp(block.startLine));
|
|
160
|
+
const header = block.name === undefined ? headerLabel(start.text) : block.name;
|
|
161
|
+
return {
|
|
162
|
+
from: start.start,
|
|
163
|
+
to: document.getLine(Math.max(clamp(block.startLine), clamp(block.endLine))).end,
|
|
164
|
+
name: header,
|
|
165
|
+
...(block.kind === undefined ? {} : { kind: block.kind })
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
edited(batches: readonly (readonly DocumentChange[])[]): void {
|
|
171
|
+
const move = (entries: OutlineEntry[]): OutlineEntry[] =>
|
|
172
|
+
entries.map((entry) => {
|
|
173
|
+
let from = entry.from;
|
|
174
|
+
let to = entry.to;
|
|
175
|
+
for (const changes of batches) {
|
|
176
|
+
from = mapOffset(from, changes);
|
|
177
|
+
to = mapOffset(to, changes);
|
|
178
|
+
}
|
|
179
|
+
return { ...entry, from, to };
|
|
180
|
+
});
|
|
181
|
+
this.structure = move(this.structure);
|
|
182
|
+
this.provided = this.provided === null ? null : move(this.provided);
|
|
183
|
+
this.version++;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Outermost first. Of two blocks that start on one line, only the larger one. */
|
|
187
|
+
blocks(document: OutlineDocument): readonly OutlineBlock[] {
|
|
188
|
+
if (this.cache?.version !== this.version) {
|
|
189
|
+
const sorted = (this.provided ?? this.structure)
|
|
190
|
+
.map((entry) => toEditorLines(entry, document))
|
|
191
|
+
.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
|
|
192
|
+
this.cache = { version: this.version, blocks: sorted.filter((block, index) => index === 0 || block.startLine !== sorted[index - 1]!.startLine) };
|
|
193
|
+
}
|
|
194
|
+
return this.cache.blocks;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Where the rows of the document are, which the pinned headers are placed by. */
|
|
199
|
+
export interface StickyRows {
|
|
200
|
+
/* The top and the bottom of the row a line is drawn in. */
|
|
201
|
+
top(line: number): number;
|
|
202
|
+
bottom(line: number): number;
|
|
203
|
+
/* Whether the line starts a row of its own, which a line a fold hides does not. */
|
|
204
|
+
startsRow(line: number): boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface StickyPlacement {
|
|
208
|
+
block: OutlineBlock;
|
|
209
|
+
/* How many headers are pinned above this one. */
|
|
210
|
+
depth: number;
|
|
211
|
+
/* Zero while the block goes on, then up to a row's height of pixels it is pushed out of the top by the end of its block. */
|
|
212
|
+
offset: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/*
|
|
216
|
+
* The headers pinned at the top, outermost first. A header is pinned from the moment its own row
|
|
217
|
+
* reaches the place it would be pinned at, which is the one a row of its own is at when it comes
|
|
218
|
+
* there, so it never jumps. At the end of its block the next row pushes it up and out.
|
|
219
|
+
*/
|
|
220
|
+
export function stickyPlacements(blocks: readonly OutlineBlock[], scrollTop: number, lineHeight: number, max: number, rows: StickyRows): StickyPlacement[] {
|
|
221
|
+
const placements: StickyPlacement[] = [];
|
|
222
|
+
for (let depth = 0; depth < max; depth++) {
|
|
223
|
+
const pinnedAt = scrollTop + depth * lineHeight;
|
|
224
|
+
const parent = placements.at(-1)?.block;
|
|
225
|
+
const next = blocks.find(
|
|
226
|
+
(block) =>
|
|
227
|
+
(parent === undefined || (block.startLine > parent.startLine && block.endLine <= parent.endLine)) &&
|
|
228
|
+
rows.startsRow(block.startLine) &&
|
|
229
|
+
rows.top(block.startLine) < pinnedAt &&
|
|
230
|
+
rows.bottom(block.endLine) > pinnedAt
|
|
231
|
+
);
|
|
232
|
+
if (next === undefined) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
placements.push({ block: next, depth, offset: Math.min(0, rows.bottom(next.endLine) - (pinnedAt + lineHeight)) });
|
|
236
|
+
}
|
|
237
|
+
return placements;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* How far down from the top the pinned headers reach. */
|
|
241
|
+
export function stickyCover(placements: readonly StickyPlacement[], lineHeight: number): number {
|
|
242
|
+
return Math.max(0, ...placements.map((placement) => (placement.depth + 1) * lineHeight + placement.offset));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* The named blocks around a line, outermost first. */
|
|
246
|
+
export function scopeChain(blocks: readonly OutlineBlock[], line: number): OutlineBlock[] {
|
|
247
|
+
return blocks.filter((block) => block.name !== undefined && block.startLine <= line && block.endLine >= line);
|
|
248
|
+
}
|