@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.
- package/LICENSE +110 -0
- package/README.md +57 -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 +610 -0
- package/dist/fake.d.ts +192 -0
- package/dist/fake.js +663 -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 +344 -0
- package/dist/types.js +1 -0
- package/dist/view.d.ts +383 -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 +764 -0
- package/src/fake.ts +838 -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 +659 -0
- package/src/view.ts +2437 -0
package/src/php-html.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Highlighter } from 'shiki';
|
|
2
|
+
|
|
3
|
+
type LanguageRegistration = Extract<Parameters<Highlighter['loadLanguage']>[0], { readonly scopeName: string }>;
|
|
4
|
+
|
|
5
|
+
/* Shiki's `php` is only the code inside the tags, which makes a plain `<?php` an operator and a constant. */
|
|
6
|
+
export const PHP_DOCUMENT_ID = 'php-html';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* A PHP file as a document: HTML with PHP between its tags, the way `text.html.php` of the PHP
|
|
10
|
+
* grammar family does it. Shiki bundles no such grammar, so it is written here on top of `html` and
|
|
11
|
+
* `php`, which have to be loaded first. The tags carry the scope a theme draws a tag's brackets in.
|
|
12
|
+
*/
|
|
13
|
+
export const PHP_DOCUMENT: LanguageRegistration = {
|
|
14
|
+
name: PHP_DOCUMENT_ID,
|
|
15
|
+
scopeName: 'text.html.php',
|
|
16
|
+
embeddedLangs: ['html', 'php'],
|
|
17
|
+
patterns: [
|
|
18
|
+
{
|
|
19
|
+
begin: '(?i)<\\?(?:php|=)?',
|
|
20
|
+
beginCaptures: { '0': { name: 'punctuation.definition.tag.begin.php' } },
|
|
21
|
+
end: '\\?>',
|
|
22
|
+
endCaptures: { '0': { name: 'punctuation.definition.tag.end.php' } },
|
|
23
|
+
name: 'meta.embedded.block.php',
|
|
24
|
+
contentName: 'source.php',
|
|
25
|
+
patterns: [{ include: 'source.php' }]
|
|
26
|
+
},
|
|
27
|
+
{ include: 'text.html.basic' }
|
|
28
|
+
]
|
|
29
|
+
} as LanguageRegistration;
|
|
30
|
+
|
|
31
|
+
/* The grammar to color a whole file in: a PHP file is a document, and every other language is its own. */
|
|
32
|
+
export function documentLanguageOf(language: string): string {
|
|
33
|
+
return language === 'php' ? PHP_DOCUMENT_ID : language;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Loads what a language needs besides its own grammar; a grammar that is already in is left alone. */
|
|
37
|
+
export async function loadDocumentLanguage(highlighter: Highlighter, language: string): Promise<void> {
|
|
38
|
+
if (language !== PHP_DOCUMENT_ID || highlighter.getLoadedLanguages().includes(PHP_DOCUMENT_ID)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await highlighter.loadLanguage('php');
|
|
42
|
+
await highlighter.loadLanguage(PHP_DOCUMENT);
|
|
43
|
+
}
|
package/src/pointer.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { DocumentModel, Selection } from '@adecore/editor-core';
|
|
2
|
+
import type { ContentPoint } from './column-selection.ts';
|
|
3
|
+
|
|
4
|
+
/* A visual line as the layout knows it: a wrapped line is as many as it has rows and a collapsed fold is one. */
|
|
5
|
+
export interface VisualLine {
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
next: number;
|
|
9
|
+
y: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PointerHost {
|
|
13
|
+
readonly model: DocumentModel;
|
|
14
|
+
readonly viewport: HTMLElement;
|
|
15
|
+
offsetAt(clientX: number, clientY: number): number;
|
|
16
|
+
contentPoint(clientX: number, clientY: number): ContentPoint;
|
|
17
|
+
/* The box between two points, or null while they are in the same cell, so a press with alt is still a click. */
|
|
18
|
+
columnSelections(from: ContentPoint, to: ContentPoint): Selection[] | null;
|
|
19
|
+
visualLine(offset: number): VisualLine;
|
|
20
|
+
/* The lines a triple click or a press on a line number selects, whole: wrapped rows and a collapsed fold are one line. */
|
|
21
|
+
lineAt(offset: number): { from: number; to: number };
|
|
22
|
+
/* Whether a double click selects a camel hump and not the whole name. */
|
|
23
|
+
camelHumps(): boolean;
|
|
24
|
+
/* Whether a drag selects a box of columns without alt held. */
|
|
25
|
+
columnMode(): boolean;
|
|
26
|
+
/* The selection a point of the screen is on, for a press that may begin dragging it; null on anything else or when the text cannot be edited. */
|
|
27
|
+
selectionAt(clientX: number, clientY: number): Selection | null;
|
|
28
|
+
/* The place a drop would land, drawn as a caret; null takes it away. */
|
|
29
|
+
setDropCaret(offset: number | null): void;
|
|
30
|
+
/* Moves `[from, to)` to `offset`, or copies it there. */
|
|
31
|
+
drop(from: number, to: number, offset: number, copy: boolean): void;
|
|
32
|
+
/* Whether the key that turns a move into a copy is down. */
|
|
33
|
+
copyHeld(event: PointerEvent): boolean;
|
|
34
|
+
focus(): void;
|
|
35
|
+
/* The view scrolled by itself while the pointer was held at an edge. */
|
|
36
|
+
scrolled(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* What a drag selects by: characters, or the words or lines the press started on. `drop` is no selection at all: the press was on a selection, and a drag carries its text. */
|
|
40
|
+
type DragUnit = 'character' | 'word' | 'line' | 'drop';
|
|
41
|
+
|
|
42
|
+
/* A press on a selection: it waits for the pointer to move this far before it carries the text, and without that it is a click. */
|
|
43
|
+
interface Carry {
|
|
44
|
+
from: number;
|
|
45
|
+
to: number;
|
|
46
|
+
pressOffset: number;
|
|
47
|
+
startX: number;
|
|
48
|
+
startY: number;
|
|
49
|
+
active: boolean;
|
|
50
|
+
/* Where it would land, or null over the text itself. */
|
|
51
|
+
target: number | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Drag {
|
|
55
|
+
id: number;
|
|
56
|
+
anchor: number;
|
|
57
|
+
selections: readonly Selection[];
|
|
58
|
+
add: boolean;
|
|
59
|
+
unit: DragUnit;
|
|
60
|
+
/* The word or the lines the press selected, which a drag never gives back. */
|
|
61
|
+
saved: { from: number; to: number };
|
|
62
|
+
carry?: Carry;
|
|
63
|
+
/* With shift and alt a dragged box is added to the carets that are there, not put in their place. */
|
|
64
|
+
keep: boolean;
|
|
65
|
+
/* Whether a drag so far makes a box of columns. */
|
|
66
|
+
box: boolean;
|
|
67
|
+
/* Where the press landed in the content, which a scroll leaves in place. */
|
|
68
|
+
anchorPoint: ContentPoint;
|
|
69
|
+
x: number;
|
|
70
|
+
y: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SCROLL_STEP = 40;
|
|
74
|
+
/* A press this soon after the last one, this close to it, is the next click of a double or triple click. */
|
|
75
|
+
const MULTI_CLICK_MS = 500;
|
|
76
|
+
const MULTI_CLICK_DISTANCE = 4;
|
|
77
|
+
/* How far the pointer goes, in pixels, before a press on a selection starts carrying it. */
|
|
78
|
+
const CARRY_THRESHOLD = 5;
|
|
79
|
+
|
|
80
|
+
/* The selection a mouse makes: a click, a drag, shift to extend, alt to add a caret or, dragged, a column, a double click for a word and a triple for a line, which a drag then grows by words and lines. */
|
|
81
|
+
export class PointerSelection {
|
|
82
|
+
private drag: Drag | undefined;
|
|
83
|
+
private frame: number | undefined;
|
|
84
|
+
private lastPress: { time: number; x: number; y: number; count: number } | undefined;
|
|
85
|
+
private readonly host: PointerHost;
|
|
86
|
+
|
|
87
|
+
constructor(host: PointerHost) {
|
|
88
|
+
this.host = host;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get dragging(): boolean {
|
|
92
|
+
return this.drag !== undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* A press in the text, or on a line number, which selects the line and drags by lines. */
|
|
96
|
+
start(event: PointerEvent, area: 'text' | 'gutter' = 'text'): void {
|
|
97
|
+
const { model, viewport } = this.host;
|
|
98
|
+
const count = area === 'gutter' ? 1 : this.countClick(event);
|
|
99
|
+
const head = this.host.offsetAt(event.clientX, event.clientY);
|
|
100
|
+
const selections = model.getSelections();
|
|
101
|
+
const base = event.altKey && area === 'text' ? selections : [];
|
|
102
|
+
this.drag = {
|
|
103
|
+
id: event.pointerId,
|
|
104
|
+
anchor: event.shiftKey && !event.altKey ? model.getPrimary().anchor : head,
|
|
105
|
+
selections,
|
|
106
|
+
add: event.altKey && area === 'text',
|
|
107
|
+
unit: 'character',
|
|
108
|
+
keep: event.altKey && event.shiftKey,
|
|
109
|
+
box: area === 'text' && (event.altKey || this.host.columnMode()),
|
|
110
|
+
saved: { from: head, to: head },
|
|
111
|
+
anchorPoint: this.host.contentPoint(event.clientX, event.clientY),
|
|
112
|
+
x: event.clientX,
|
|
113
|
+
y: event.clientY
|
|
114
|
+
};
|
|
115
|
+
this.host.focus();
|
|
116
|
+
viewport.setPointerCapture?.(event.pointerId);
|
|
117
|
+
// Alt on a caret that is there takes it away; there is no selection to drag after that.
|
|
118
|
+
if (area === 'text' && event.altKey && count === 1 && selections.length > 1 && selections.some((selection) => selection.head === head)) {
|
|
119
|
+
model.setSelections(selections.filter((selection) => selection.head !== head));
|
|
120
|
+
this.drag = undefined;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const selected = count === 1 && area === 'text' && !event.shiftKey && !event.altKey ? this.host.selectionAt(event.clientX, event.clientY) : null;
|
|
124
|
+
if (selected !== null) {
|
|
125
|
+
this.drag.unit = 'drop';
|
|
126
|
+
this.drag.carry = {
|
|
127
|
+
from: Math.min(selected.anchor, selected.head),
|
|
128
|
+
to: Math.max(selected.anchor, selected.head),
|
|
129
|
+
pressOffset: head,
|
|
130
|
+
startX: event.clientX,
|
|
131
|
+
startY: event.clientY,
|
|
132
|
+
active: false,
|
|
133
|
+
target: null
|
|
134
|
+
};
|
|
135
|
+
} else if (area === 'gutter') {
|
|
136
|
+
this.pressLineNumber(event, head);
|
|
137
|
+
} else if (count >= 3) {
|
|
138
|
+
this.selectUnit('line', this.host.lineAt(head), base);
|
|
139
|
+
} else if (count === 2) {
|
|
140
|
+
const word = model.wordSelectionAt(head, this.host.camelHumps());
|
|
141
|
+
if (word === null) {
|
|
142
|
+
this.update();
|
|
143
|
+
} else {
|
|
144
|
+
this.selectUnit('word', word, base);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
this.update();
|
|
148
|
+
}
|
|
149
|
+
if (this.drag && this.frame === undefined) {
|
|
150
|
+
this.frame = viewport.ownerDocument.defaultView?.requestAnimationFrame?.(() => this.autoScroll());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* A line number selects its line, and with shift grows or shrinks the selection by lines. */
|
|
155
|
+
private pressLineNumber(event: PointerEvent, offset: number): void {
|
|
156
|
+
const { model } = this.host;
|
|
157
|
+
const primary = model.getPrimary();
|
|
158
|
+
if (event.shiftKey && primary.anchor !== primary.head) {
|
|
159
|
+
model.setSelections([this.tweakedByLines(primary, offset)]);
|
|
160
|
+
this.drag = undefined;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.selectUnit('line', this.host.lineAt(offset), []);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private selectUnit(unit: DragUnit, saved: { from: number; to: number }, base: readonly Selection[]): void {
|
|
167
|
+
if (this.drag === undefined) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.drag.unit = unit;
|
|
171
|
+
this.drag.saved = saved;
|
|
172
|
+
this.drag.selections = base;
|
|
173
|
+
this.host.model.setSelections([...base, { anchor: saved.from, head: saved.to }]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* The selection after a shift press on a line number: the clicked line comes in, or goes out when it is inside. */
|
|
177
|
+
private tweakedByLines(selection: Selection, offset: number): Selection {
|
|
178
|
+
const { host } = this;
|
|
179
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
180
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
181
|
+
const first = host.visualLine(from);
|
|
182
|
+
const last = host.visualLine(to - 1);
|
|
183
|
+
const clicked = host.visualLine(offset);
|
|
184
|
+
if (clicked.y < first.y) {
|
|
185
|
+
return { anchor: to, head: clicked.start };
|
|
186
|
+
}
|
|
187
|
+
if (clicked.y > last.y) {
|
|
188
|
+
return { anchor: from, head: clicked.end };
|
|
189
|
+
}
|
|
190
|
+
if (first.y === last.y) {
|
|
191
|
+
return { anchor: selection.head, head: selection.head };
|
|
192
|
+
}
|
|
193
|
+
if (selection.anchor === to) {
|
|
194
|
+
const line = clicked.y === first.y ? host.visualLine(first.next) : clicked;
|
|
195
|
+
return { anchor: to, head: line.start };
|
|
196
|
+
}
|
|
197
|
+
const line = clicked.y === last.y ? host.visualLine(last.start - 1) : clicked;
|
|
198
|
+
return { anchor: from, head: line.end };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Pointer events carry no click count, so the presses are counted here. */
|
|
202
|
+
private countClick(event: PointerEvent): number {
|
|
203
|
+
const last = this.lastPress;
|
|
204
|
+
const repeated =
|
|
205
|
+
last !== undefined &&
|
|
206
|
+
event.timeStamp - last.time <= MULTI_CLICK_MS &&
|
|
207
|
+
Math.abs(event.clientX - last.x) <= MULTI_CLICK_DISTANCE &&
|
|
208
|
+
Math.abs(event.clientY - last.y) <= MULTI_CLICK_DISTANCE;
|
|
209
|
+
const count = repeated ? Math.min(3, last.count + 1) : 1;
|
|
210
|
+
this.lastPress = { time: event.timeStamp, x: event.clientX, y: event.clientY, count };
|
|
211
|
+
return count;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
move(event: PointerEvent): void {
|
|
215
|
+
if (!this.drag || event.pointerId !== this.drag.id) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.drag.x = event.clientX;
|
|
219
|
+
this.drag.y = event.clientY;
|
|
220
|
+
this.update();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* The pointer went up, or was taken away: a selection that was carried lands, and one that was only pressed gives way to a caret. */
|
|
224
|
+
end(event?: PointerEvent): void {
|
|
225
|
+
const { viewport } = this.host;
|
|
226
|
+
const carry = this.drag?.carry;
|
|
227
|
+
if (this.drag) {
|
|
228
|
+
viewport.releasePointerCapture?.(this.drag.id);
|
|
229
|
+
}
|
|
230
|
+
this.drag = undefined;
|
|
231
|
+
this.cancelFrame();
|
|
232
|
+
if (carry === undefined) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.host.setDropCaret(null);
|
|
236
|
+
if (event?.type === 'pointercancel') {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!carry.active) {
|
|
240
|
+
this.host.model.setSelections([{ anchor: carry.pressOffset, head: carry.pressOffset }]);
|
|
241
|
+
} else if (carry.target !== null && event !== undefined) {
|
|
242
|
+
this.host.drop(carry.from, carry.to, carry.target, this.host.copyHeld(event));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* Where the carried text would land at the pointer, once the pointer has moved far enough to carry it. */
|
|
247
|
+
private updateCarry(drag: Drag, carry: Carry): void {
|
|
248
|
+
if (!carry.active) {
|
|
249
|
+
if (Math.abs(drag.x - carry.startX) < CARRY_THRESHOLD && Math.abs(drag.y - carry.startY) < CARRY_THRESHOLD) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
carry.active = true;
|
|
253
|
+
}
|
|
254
|
+
const offset = this.host.offsetAt(drag.x, drag.y);
|
|
255
|
+
carry.target = offset >= carry.from && offset <= carry.to ? null : offset;
|
|
256
|
+
this.host.setDropCaret(carry.target);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
dispose(): void {
|
|
260
|
+
this.drag = undefined;
|
|
261
|
+
this.cancelFrame();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private cancelFrame(): void {
|
|
265
|
+
if (this.frame !== undefined) {
|
|
266
|
+
this.host.viewport.ownerDocument.defaultView?.cancelAnimationFrame?.(this.frame);
|
|
267
|
+
}
|
|
268
|
+
this.frame = undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private update(): void {
|
|
272
|
+
if (!this.drag) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (this.drag.carry !== undefined) {
|
|
276
|
+
this.updateCarry(this.drag, this.drag.carry);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (this.drag.box && this.drag.unit === 'character') {
|
|
280
|
+
const box = this.host.columnSelections(this.drag.anchorPoint, this.host.contentPoint(this.drag.x, this.drag.y));
|
|
281
|
+
if (box) {
|
|
282
|
+
this.host.model.setSelections(this.drag.keep ? [...this.drag.selections, ...box] : box);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const head = this.host.offsetAt(this.drag.x, this.drag.y);
|
|
287
|
+
const { anchor, target } = this.reach(this.drag, head);
|
|
288
|
+
this.host.model.setSelections([...(this.drag.add ? this.drag.selections : []), { anchor, head: target }]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Where a drag that has got to `head` selects from and to, by the unit the press began with. */
|
|
292
|
+
private reach(drag: Drag, head: number): { anchor: number; target: number } {
|
|
293
|
+
const { model } = this.host;
|
|
294
|
+
const backwards = head < drag.saved.from;
|
|
295
|
+
if (drag.unit === 'word') {
|
|
296
|
+
const camel = this.host.camelHumps();
|
|
297
|
+
return backwards
|
|
298
|
+
? { anchor: drag.saved.to, target: model.wordStartBefore(head, camel) }
|
|
299
|
+
: { anchor: drag.saved.from, target: model.wordEndAfter(head, camel) };
|
|
300
|
+
}
|
|
301
|
+
if (drag.unit === 'line') {
|
|
302
|
+
const line = this.host.visualLine(head);
|
|
303
|
+
return backwards ? { anchor: drag.saved.to, target: line.start } : { anchor: drag.saved.from, target: line.next };
|
|
304
|
+
}
|
|
305
|
+
return { anchor: drag.anchor, target: head };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* Holding the pointer past an edge of the view keeps scrolling that way. */
|
|
309
|
+
private autoScroll(): void {
|
|
310
|
+
const { viewport } = this.host;
|
|
311
|
+
if (!this.drag) {
|
|
312
|
+
this.frame = undefined;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const rect = viewport.getBoundingClientRect();
|
|
316
|
+
const dy = this.drag.y < rect.top ? this.drag.y - rect.top : this.drag.y > rect.bottom ? this.drag.y - rect.bottom : 0;
|
|
317
|
+
const dx = this.drag.x < rect.left ? this.drag.x - rect.left : this.drag.x > rect.right ? this.drag.x - rect.right : 0;
|
|
318
|
+
if (dy || dx) {
|
|
319
|
+
viewport.scrollTop += Math.max(-SCROLL_STEP, Math.min(SCROLL_STEP, dy));
|
|
320
|
+
viewport.scrollLeft += Math.max(-SCROLL_STEP, Math.min(SCROLL_STEP, dx));
|
|
321
|
+
this.host.scrolled();
|
|
322
|
+
this.update();
|
|
323
|
+
}
|
|
324
|
+
this.frame = viewport.ownerDocument.defaultView?.requestAnimationFrame?.(() => this.autoScroll());
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* How a scroll that moves the view a distance goes there: not at all for a line or less, and for more
|
|
3
|
+
* over a moment that grows with the distance up to a tenth of a second, starting fast and easing out.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* The longest the view takes to get anywhere. */
|
|
7
|
+
export const SCROLL_DURATION_MS = 100;
|
|
8
|
+
|
|
9
|
+
/* How many lines away the view has to go before it takes any time, and how many before it takes all of it. */
|
|
10
|
+
const FREE_LINES = 1;
|
|
11
|
+
const FULL_LINES = 11;
|
|
12
|
+
|
|
13
|
+
export function scrollDuration(distance: number, lineHeight: number): number {
|
|
14
|
+
const lines = distance / lineHeight;
|
|
15
|
+
const part = Math.min(1, Math.max(0, (lines - FREE_LINES) / (FULL_LINES - FREE_LINES)));
|
|
16
|
+
return Math.round(part * SCROLL_DURATION_MS);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* The curve `cubic-bezier(0, 0, 0.58, 1)`: how much of the way has been gone after a fraction of the time. */
|
|
20
|
+
export function easeOut(fraction: number): number {
|
|
21
|
+
if (fraction <= 0) {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (fraction >= 1) {
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
const x1 = 0;
|
|
28
|
+
const y1 = 0;
|
|
29
|
+
const x2 = 0.58;
|
|
30
|
+
const y2 = 1;
|
|
31
|
+
const bezier = (t: number, a: number, b: number): number => 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t;
|
|
32
|
+
let low = 0;
|
|
33
|
+
let high = 1;
|
|
34
|
+
let t = fraction;
|
|
35
|
+
for (let step = 0; step < 24; step++) {
|
|
36
|
+
const x = bezier(t, x1, x2);
|
|
37
|
+
if (Math.abs(x - fraction) < 1e-5) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (x < fraction) {
|
|
41
|
+
low = t;
|
|
42
|
+
} else {
|
|
43
|
+
high = t;
|
|
44
|
+
}
|
|
45
|
+
t = (low + high) / 2;
|
|
46
|
+
}
|
|
47
|
+
return bezier(t, y1, y2);
|
|
48
|
+
}
|
package/src/scroll.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where the view scrolls to when a target has to be seen. The rules are the platform's scrolling model
|
|
3
|
+
* with its defaults: a line of margin above and below the target, three characters at its sides, and a
|
|
4
|
+
* target out of reach lands a third of the way down the view.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* `relative` keeps a caret in view with a margin, the way typing and moving do. `makeVisible` leaves
|
|
9
|
+
* what is in view alone and centers what is not. `center` always puts the target a third from the top,
|
|
10
|
+
* and `centerDown` and `centerUp` do so only when it is out of view or on the wrong side of that mark,
|
|
11
|
+
* so stepping through results in one direction keeps them where the eye expects them.
|
|
12
|
+
*/
|
|
13
|
+
export type ScrollKind = 'relative' | 'makeVisible' | 'center' | 'centerDown' | 'centerUp';
|
|
14
|
+
|
|
15
|
+
export interface ScrollRequest {
|
|
16
|
+
/* The top left of the target in the content, as the caret is. */
|
|
17
|
+
readonly target: { readonly x: number; readonly y: number };
|
|
18
|
+
/* What is in view now: the text area, without the gutter. */
|
|
19
|
+
readonly view: { readonly x: number; readonly y: number; readonly width: number; readonly height: number };
|
|
20
|
+
/* How much there is to scroll over. */
|
|
21
|
+
readonly content: { readonly width: number; readonly height: number };
|
|
22
|
+
readonly lineHeight: number;
|
|
23
|
+
readonly charWidth: number;
|
|
24
|
+
/* Where the line above the target's starts, and where the line below it ends; the margin that has to stay in view. */
|
|
25
|
+
readonly topBound: number;
|
|
26
|
+
readonly bottomBound: number;
|
|
27
|
+
/* A jump to a target already in view is handled as `relative`, which leaves it where it is. */
|
|
28
|
+
readonly refrain: boolean;
|
|
29
|
+
/* A wrapped view has nothing to scroll sideways. */
|
|
30
|
+
readonly horizontal: boolean;
|
|
31
|
+
/* How much of the top of the view something else covers, such as pinned headers; `view` is what is left below it. */
|
|
32
|
+
readonly inset?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* The characters kept in view to the left and right of the target. */
|
|
36
|
+
const SIDE_MARGIN = 3;
|
|
37
|
+
|
|
38
|
+
function clamp(value: number, maximum: number): number {
|
|
39
|
+
return Math.min(Math.max(0, value), Math.max(0, maximum));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* The one third mark when the margins fit above it, the middle of the view when they do not. */
|
|
43
|
+
function centerOf(request: ScrollRequest): number {
|
|
44
|
+
const { target, view, lineHeight, topBound } = request;
|
|
45
|
+
const third = target.y - Math.trunc(view.height / 3);
|
|
46
|
+
return third < topBound ? third : target.y - Math.trunc(Math.max(0, view.height - lineHeight) / 2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function verticalPosition(request: ScrollRequest, kind: ScrollKind): number {
|
|
50
|
+
const { target, view, content, topBound, bottomBound } = request;
|
|
51
|
+
const height = view.height;
|
|
52
|
+
const center = centerOf(request);
|
|
53
|
+
const above = view.y > topBound;
|
|
54
|
+
const below = view.y + height < bottomBound;
|
|
55
|
+
let position = view.y;
|
|
56
|
+
if (kind === 'center') {
|
|
57
|
+
position = center;
|
|
58
|
+
} else if (kind === 'centerUp') {
|
|
59
|
+
position = above || below || view.y > center ? center : position;
|
|
60
|
+
} else if (kind === 'centerDown') {
|
|
61
|
+
position = above || below || view.y < center ? center : position;
|
|
62
|
+
} else if (kind === 'makeVisible') {
|
|
63
|
+
position = above || below ? center : position;
|
|
64
|
+
} else if (bottomBound - topBound > height) {
|
|
65
|
+
position = center;
|
|
66
|
+
} else if (below) {
|
|
67
|
+
const lowest = target.y + height > content.height ? content.height : target.y + height - (target.y - topBound);
|
|
68
|
+
position = Math.max(bottomBound - height, Math.min(lowest, view.y + height) - height);
|
|
69
|
+
} else if (above) {
|
|
70
|
+
const highest = target.y - height < 0 ? 0 : bottomBound - height;
|
|
71
|
+
position = Math.min(topBound, Math.max(highest, view.y));
|
|
72
|
+
}
|
|
73
|
+
const inset = request.inset ?? 0;
|
|
74
|
+
return Math.min(Math.max(inset, position), Math.max(inset, content.height - height));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function horizontalPosition(request: ScrollRequest, kind: ScrollKind): number {
|
|
78
|
+
const { target, view, content, charWidth } = request;
|
|
79
|
+
const width = view.width;
|
|
80
|
+
const left = target.x - SIDE_MARGIN * charWidth;
|
|
81
|
+
const right = target.x + SIDE_MARGIN * charWidth;
|
|
82
|
+
let position = view.x;
|
|
83
|
+
if (right - left > width) {
|
|
84
|
+
position = target.x - Math.trunc(width / 2);
|
|
85
|
+
} else if (left < view.x) {
|
|
86
|
+
position = kind === 'makeVisible' && right < width ? 0 : left;
|
|
87
|
+
} else if (right > view.x + width) {
|
|
88
|
+
position = right - width;
|
|
89
|
+
}
|
|
90
|
+
return clamp(position, content.width - width);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* The scroll position that shows the target the way `kind` asks. */
|
|
94
|
+
export function scrollPosition(request: ScrollRequest, kind: ScrollKind): { x: number; y: number } {
|
|
95
|
+
const { target, view } = request;
|
|
96
|
+
const inView = target.x >= view.x && target.x < view.x + view.width && target.y >= view.y && target.y < view.y + view.height;
|
|
97
|
+
const effective: ScrollKind = request.refrain && inView && (kind === 'center' || kind === 'centerDown' || kind === 'centerUp') ? 'relative' : kind;
|
|
98
|
+
return {
|
|
99
|
+
x: request.horizontal ? horizontalPosition(request, effective) : view.x,
|
|
100
|
+
y: verticalPosition(request, effective) - (request.inset ?? 0)
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/semantic.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LineToken } from './types.ts';
|
|
2
|
+
|
|
3
|
+
/* A stretch of one line, in characters from its start, that a language server colors. */
|
|
4
|
+
export interface SemanticSpan {
|
|
5
|
+
readonly from: number;
|
|
6
|
+
readonly to: number;
|
|
7
|
+
readonly color: string;
|
|
8
|
+
readonly fontStyle: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The colors of a line with the spans laid over them: inside a span its color and font style replace
|
|
13
|
+
* the grammar's, and everywhere else the grammar's stay. The tokens of the result add up to the line
|
|
14
|
+
* as before. Spans are in order and do not overlap.
|
|
15
|
+
*/
|
|
16
|
+
export function overlayTokens(base: readonly LineToken[], spans: readonly SemanticSpan[]): LineToken[] {
|
|
17
|
+
const result: LineToken[] = [];
|
|
18
|
+
const push = (length: number, color: string, fontStyle: number): void => {
|
|
19
|
+
if (length <= 0) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const last = result[result.length - 1];
|
|
23
|
+
if (last !== undefined && last.color === color && last.fontStyle === fontStyle) {
|
|
24
|
+
result[result.length - 1] = { length: last.length + length, color, fontStyle };
|
|
25
|
+
} else {
|
|
26
|
+
result.push({ length, color, fontStyle });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
let span = 0;
|
|
30
|
+
let start = 0;
|
|
31
|
+
for (const token of base) {
|
|
32
|
+
const end = start + token.length;
|
|
33
|
+
let at = start;
|
|
34
|
+
while (at < end) {
|
|
35
|
+
while (span < spans.length && spans[span]!.to <= at) {
|
|
36
|
+
span++;
|
|
37
|
+
}
|
|
38
|
+
const current = spans[span];
|
|
39
|
+
if (current === undefined || current.from >= end) {
|
|
40
|
+
push(end - at, token.color, token.fontStyle);
|
|
41
|
+
at = end;
|
|
42
|
+
} else if (current.from > at) {
|
|
43
|
+
push(current.from - at, token.color, token.fontStyle);
|
|
44
|
+
at = current.from;
|
|
45
|
+
} else {
|
|
46
|
+
const stop = Math.min(end, current.to);
|
|
47
|
+
push(stop - at, current.color, current.fontStyle);
|
|
48
|
+
at = stop;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
start = end;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|