@adecore/editor 0.22.0 → 0.23.0
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/dist/attribution.d.ts +9 -8
- package/dist/attribution.js +44 -9
- package/dist/bidi.d.ts +44 -0
- package/dist/bidi.js +26 -0
- package/dist/column-selection.js +2 -1
- package/dist/controller.d.ts +6 -1
- package/dist/controller.js +93 -3
- package/dist/editor.css +81 -1
- package/dist/engine.js +184 -41
- package/dist/fake.d.ts +43 -5
- package/dist/fake.js +117 -13
- package/dist/find-search.d.ts +26 -0
- package/dist/find-search.js +130 -0
- package/dist/find.d.ts +50 -2
- package/dist/find.js +575 -25
- package/dist/geometry-stops.d.ts +6 -0
- package/dist/geometry-stops.js +47 -0
- package/dist/grammar-preparation.d.ts +29 -0
- package/dist/grammar-preparation.js +147 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/keymap-table.d.ts +10 -1
- package/dist/keymap-table.js +37 -1
- package/dist/keymap.d.ts +1 -0
- package/dist/keymap.js +4 -1
- package/dist/layout-changes.d.ts +6 -0
- package/dist/layout-changes.js +27 -0
- package/dist/layout.d.ts +45 -2
- package/dist/layout.js +202 -58
- package/dist/offsets.d.ts +1 -0
- package/dist/offsets.js +23 -0
- package/dist/outline.js +5 -4
- package/dist/paint.d.ts +11 -3
- package/dist/paint.js +180 -34
- package/dist/search-client.d.ts +27 -0
- package/dist/search-client.js +210 -0
- package/dist/search-protocol.d.ts +103 -0
- package/dist/search-protocol.js +3 -0
- package/dist/search-worker.d.ts +11 -0
- package/dist/search-worker.js +137 -0
- package/dist/semantic.d.ts +2 -1
- package/dist/semantic.js +6 -1
- package/dist/shiki-document.d.ts +8 -0
- package/dist/shiki-document.js +93 -0
- package/dist/shiki-worker.d.ts +65 -0
- package/dist/shiki-worker.js +265 -0
- package/dist/shiki.d.ts +1 -0
- package/dist/shiki.js +96 -33
- package/dist/testing.d.ts +1 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +37 -7
- package/dist/touch.d.ts +27 -0
- package/dist/touch.js +100 -0
- package/dist/types.d.ts +84 -2
- package/dist/uniform-geometry.d.ts +7 -0
- package/dist/uniform-geometry.js +49 -0
- package/dist/view.d.ts +68 -17
- package/dist/view.js +424 -97
- package/dist/worker-client.d.ts +37 -0
- package/dist/worker-client.js +195 -0
- package/package.json +17 -2
- package/src/attribution.ts +50 -14
- package/src/bidi.ts +32 -0
- package/src/column-selection.ts +2 -1
- package/src/controller.ts +97 -3
- package/src/editor.css +81 -1
- package/src/engine.ts +216 -42
- package/src/fake.ts +152 -14
- package/src/find-search.ts +165 -0
- package/src/find.ts +622 -21
- package/src/geometry-stops.ts +51 -0
- package/src/grammar-preparation.ts +158 -0
- package/src/index.ts +10 -0
- package/src/keymap-table.ts +44 -2
- package/src/keymap.ts +5 -1
- package/src/layout-changes.ts +33 -0
- package/src/layout.ts +262 -63
- package/src/offsets.ts +24 -0
- package/src/outline.ts +5 -4
- package/src/paint.ts +202 -36
- package/src/search-client.ts +306 -0
- package/src/search-protocol.ts +100 -0
- package/src/search-worker.ts +171 -0
- package/src/semantic.ts +8 -2
- package/src/shiki-document.ts +143 -0
- package/src/shiki-worker.ts +344 -0
- package/src/shiki.ts +103 -33
- package/src/testing.ts +2 -0
- package/src/tokens.ts +36 -7
- package/src/touch.ts +134 -0
- package/src/types.ts +174 -8
- package/src/uniform-geometry.ts +57 -0
- package/src/view.ts +500 -102
- package/src/worker-client.ts +212 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type WorkerRequestPort = Pick<Worker, 'addEventListener' | 'removeEventListener' | 'postMessage' | 'terminate'>;
|
|
2
|
+
export type WorkerRequestErrorKind = 'timeout' | 'superseded' | 'unavailable' | 'error' | 'disposed';
|
|
3
|
+
export declare class WorkerRequestError extends Error {
|
|
4
|
+
readonly kind: WorkerRequestErrorKind;
|
|
5
|
+
constructor(kind: WorkerRequestErrorKind, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface WorkerRequestOptions<Input extends {
|
|
8
|
+
id: number;
|
|
9
|
+
}, Output extends {
|
|
10
|
+
id: number;
|
|
11
|
+
}> {
|
|
12
|
+
readonly workerFactory: () => WorkerRequestPort;
|
|
13
|
+
readonly deadline?: number;
|
|
14
|
+
readonly schedule?: (callback: () => void, delay: number) => () => void;
|
|
15
|
+
readonly prepare?: (input: Input) => Input;
|
|
16
|
+
readonly accepts?: (reply: Output, input: Input) => boolean;
|
|
17
|
+
readonly failure?: (reply: Output) => {
|
|
18
|
+
error: Error;
|
|
19
|
+
restart: boolean;
|
|
20
|
+
} | undefined;
|
|
21
|
+
readonly stopped?: () => void;
|
|
22
|
+
}
|
|
23
|
+
export interface WorkerRequestClient<Input extends {
|
|
24
|
+
id: number;
|
|
25
|
+
}, Output extends {
|
|
26
|
+
id: number;
|
|
27
|
+
}> {
|
|
28
|
+
request(input: (id: number) => Input, deadline?: number, validate?: () => void): Promise<Output>;
|
|
29
|
+
cancel(): void;
|
|
30
|
+
release(): void;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createWorkerRequestClient<Input extends {
|
|
34
|
+
id: number;
|
|
35
|
+
}, Output extends {
|
|
36
|
+
id: number;
|
|
37
|
+
}>(options: WorkerRequestOptions<Input, Output>): WorkerRequestClient<Input, Output>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export class WorkerRequestError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
constructor(kind, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.kind = kind;
|
|
6
|
+
this.name = 'WorkerRequestError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function scheduleTimer(callback, delay) {
|
|
10
|
+
const timer = setTimeout(callback, delay);
|
|
11
|
+
return () => clearTimeout(timer);
|
|
12
|
+
}
|
|
13
|
+
export function createWorkerRequestClient(options) {
|
|
14
|
+
const deadline = options.deadline ?? 1500;
|
|
15
|
+
if (!Number.isFinite(deadline) || deadline <= 0 || deadline > 2147483647) {
|
|
16
|
+
throw new RangeError('The worker deadline must fit a positive browser timer.');
|
|
17
|
+
}
|
|
18
|
+
const schedule = options.schedule ?? scheduleTimer;
|
|
19
|
+
let worker, detach, cancelTimer;
|
|
20
|
+
let active, pending;
|
|
21
|
+
let unavailable;
|
|
22
|
+
let disposed = false, nextId = 0;
|
|
23
|
+
const stopWorker = () => {
|
|
24
|
+
cancelTimer?.();
|
|
25
|
+
cancelTimer = undefined;
|
|
26
|
+
detach?.();
|
|
27
|
+
detach = undefined;
|
|
28
|
+
const previous = worker;
|
|
29
|
+
worker = undefined;
|
|
30
|
+
options.stopped?.();
|
|
31
|
+
previous?.terminate();
|
|
32
|
+
};
|
|
33
|
+
const fail = (request, error, restart = true) => {
|
|
34
|
+
if (active !== request) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
active = undefined;
|
|
38
|
+
if (restart) {
|
|
39
|
+
stopWorker();
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
cancelTimer?.();
|
|
43
|
+
cancelTimer = undefined;
|
|
44
|
+
}
|
|
45
|
+
request.reject(error);
|
|
46
|
+
};
|
|
47
|
+
const startWorker = () => {
|
|
48
|
+
if (worker !== undefined) {
|
|
49
|
+
return worker;
|
|
50
|
+
}
|
|
51
|
+
let created;
|
|
52
|
+
try {
|
|
53
|
+
created = options.workerFactory();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
unavailable = new WorkerRequestError('unavailable', error instanceof Error ? error.message : String(error));
|
|
57
|
+
throw unavailable;
|
|
58
|
+
}
|
|
59
|
+
worker = created;
|
|
60
|
+
let received = false;
|
|
61
|
+
const onMessage = (event) => {
|
|
62
|
+
if (worker !== created) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const reply = event.data;
|
|
66
|
+
if (reply === null || typeof reply !== 'object') {
|
|
67
|
+
if (active !== undefined) {
|
|
68
|
+
fail(active, new WorkerRequestError('error', 'The worker returned an invalid response.'));
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
received = true;
|
|
73
|
+
if ('type' in reply && reply.type === 'ready') {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const request = active;
|
|
77
|
+
if (request === undefined || !('id' in reply) || reply.id !== request.input.id) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
if (options.accepts?.(reply, request.input) === false) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const failure = options.failure?.(reply);
|
|
85
|
+
if (failure !== undefined) {
|
|
86
|
+
fail(request, failure.error, failure.restart);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
active = undefined;
|
|
90
|
+
cancelTimer?.();
|
|
91
|
+
cancelTimer = undefined;
|
|
92
|
+
request.resolve(reply);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
fail(request, new WorkerRequestError('error', error instanceof Error ? error.message : String(error)));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const onError = (event) => {
|
|
99
|
+
if (worker !== created) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const error = new WorkerRequestError(received ? 'error' : 'unavailable', event.message || 'The worker failed.');
|
|
103
|
+
if (!received) {
|
|
104
|
+
unavailable = error;
|
|
105
|
+
}
|
|
106
|
+
if (active !== undefined) {
|
|
107
|
+
fail(active, error);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
stopWorker();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const onMessageError = () => {
|
|
114
|
+
if (worker !== created) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (active !== undefined) {
|
|
118
|
+
fail(active, new WorkerRequestError('error', 'The worker response could not be decoded.'));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
stopWorker();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
created.addEventListener('message', onMessage);
|
|
125
|
+
created.addEventListener('error', onError);
|
|
126
|
+
created.addEventListener('messageerror', onMessageError);
|
|
127
|
+
detach = () => {
|
|
128
|
+
created.removeEventListener('message', onMessage);
|
|
129
|
+
created.removeEventListener('error', onError);
|
|
130
|
+
created.removeEventListener('messageerror', onMessageError);
|
|
131
|
+
};
|
|
132
|
+
return created;
|
|
133
|
+
};
|
|
134
|
+
const pump = () => {
|
|
135
|
+
const request = pending;
|
|
136
|
+
if (disposed || request === undefined) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
pending = undefined;
|
|
140
|
+
active = request;
|
|
141
|
+
try {
|
|
142
|
+
const target = startWorker();
|
|
143
|
+
cancelTimer = schedule(() => fail(request, new WorkerRequestError('timeout', 'The worker exceeded its time limit.')), request.deadline);
|
|
144
|
+
target.postMessage(options.prepare?.(request.input) ?? request.input);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
fail(request, error instanceof WorkerRequestError ? error : new WorkerRequestError('error', error instanceof Error ? error.message : String(error)));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const cancel = (kind) => {
|
|
151
|
+
const error = new WorkerRequestError(kind, kind === 'disposed' ? 'The worker client was disposed.' : 'A newer request superseded this request.');
|
|
152
|
+
pending?.reject(error);
|
|
153
|
+
pending = undefined;
|
|
154
|
+
if (active !== undefined) {
|
|
155
|
+
fail(active, error);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
request(input, duration = deadline, validate) {
|
|
160
|
+
if (disposed) {
|
|
161
|
+
return Promise.reject(new WorkerRequestError('disposed', 'The worker client was disposed.'));
|
|
162
|
+
}
|
|
163
|
+
if (unavailable !== undefined) {
|
|
164
|
+
return Promise.reject(unavailable);
|
|
165
|
+
}
|
|
166
|
+
cancel('superseded');
|
|
167
|
+
try {
|
|
168
|
+
validate?.();
|
|
169
|
+
if (!Number.isFinite(duration) || duration <= 0 || duration > 2147483647) {
|
|
170
|
+
throw new RangeError('The request deadline must fit a positive browser timer.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
return Promise.reject(error);
|
|
175
|
+
}
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
pending = { input: input(++nextId), deadline: duration, resolve, reject };
|
|
178
|
+
// A burst before the microtask posts only its newest request.
|
|
179
|
+
queueMicrotask(pump);
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
cancel() {
|
|
183
|
+
cancel('superseded');
|
|
184
|
+
},
|
|
185
|
+
release() {
|
|
186
|
+
cancel('superseded');
|
|
187
|
+
stopWorker();
|
|
188
|
+
},
|
|
189
|
+
dispose() {
|
|
190
|
+
disposed = true;
|
|
191
|
+
cancel('disposed');
|
|
192
|
+
stopWorker();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
"types": "./dist/shiki.d.ts",
|
|
23
23
|
"default": "./dist/shiki.js"
|
|
24
24
|
},
|
|
25
|
+
"./shiki-worker": {
|
|
26
|
+
"source": "./src/shiki-worker.ts",
|
|
27
|
+
"types": "./dist/shiki-worker.d.ts",
|
|
28
|
+
"default": "./dist/shiki-worker.js"
|
|
29
|
+
},
|
|
25
30
|
"./keymap": {
|
|
26
31
|
"source": "./src/keymap-table.ts",
|
|
27
32
|
"types": "./dist/keymap-table.d.ts",
|
|
@@ -31,6 +36,16 @@
|
|
|
31
36
|
"source": "./src/testing.ts",
|
|
32
37
|
"types": "./dist/testing.d.ts",
|
|
33
38
|
"default": "./dist/testing.js"
|
|
39
|
+
},
|
|
40
|
+
"./search-worker": {
|
|
41
|
+
"source": "./src/search-worker.ts",
|
|
42
|
+
"types": "./dist/search-worker.d.ts",
|
|
43
|
+
"default": "./dist/search-worker.js"
|
|
44
|
+
},
|
|
45
|
+
"./worker-client": {
|
|
46
|
+
"source": "./src/worker-client.ts",
|
|
47
|
+
"types": "./dist/worker-client.d.ts",
|
|
48
|
+
"default": "./dist/worker-client.js"
|
|
34
49
|
}
|
|
35
50
|
},
|
|
36
51
|
"scripts": {
|
|
@@ -39,7 +54,7 @@
|
|
|
39
54
|
"test": "bun test --conditions=source"
|
|
40
55
|
},
|
|
41
56
|
"dependencies": {
|
|
42
|
-
"@adecore/editor-core": "0.
|
|
57
|
+
"@adecore/editor-core": "0.23.0",
|
|
43
58
|
"linkedom": "^0.18.13",
|
|
44
59
|
"shiki": "^4.4.3"
|
|
45
60
|
},
|
package/src/attribution.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DocumentChange } from '@adecore/editor-core';
|
|
2
|
-
import {
|
|
2
|
+
import { offsetMapper } from './offsets.ts';
|
|
3
|
+
import type { EditorLineHighlight } from './types.ts';
|
|
3
4
|
|
|
4
5
|
/* The custom properties of the agent palette, defined in `editor.css` for both themes. */
|
|
5
6
|
export const AGENT_COLORS = ['--agent-1', '--agent-2', '--agent-3', '--agent-4', '--agent-5', '--agent-6'] as const;
|
|
@@ -9,6 +10,8 @@ export interface AttributedLines {
|
|
|
9
10
|
readonly color: string;
|
|
10
11
|
readonly sign?: string;
|
|
11
12
|
readonly fill?: string;
|
|
13
|
+
readonly bar?: 'solid' | 'hatched';
|
|
14
|
+
readonly emphasis?: readonly { from: number; to: number }[];
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
interface Run {
|
|
@@ -16,8 +19,10 @@ interface Run {
|
|
|
16
19
|
color: string;
|
|
17
20
|
sign: string | undefined;
|
|
18
21
|
fill: string | undefined;
|
|
22
|
+
bar: 'solid' | 'hatched' | undefined;
|
|
19
23
|
from: number;
|
|
20
24
|
to: number;
|
|
25
|
+
emphasis: { from: number; to: number }[];
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/* A color washed out to a background that text stays readable on in either theme. */
|
|
@@ -32,8 +37,8 @@ export function colorValue(color: string): string {
|
|
|
32
37
|
|
|
33
38
|
/*
|
|
34
39
|
* The runs of lines a host attributed, anchored by offsets so they follow their text through edits. An
|
|
35
|
-
* edit
|
|
36
|
-
* reach the lines asked for.
|
|
40
|
+
* edit maps each run and its emphasized ranges, and a read works out the lines of only the runs
|
|
41
|
+
* that reach the lines asked for.
|
|
37
42
|
*/
|
|
38
43
|
export class AttributionRuns {
|
|
39
44
|
private runs: Run[] = [];
|
|
@@ -43,16 +48,33 @@ export class AttributionRuns {
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/* Marks name lines one-based and inclusive; `lineBounds` answers for the document as it is now. */
|
|
46
|
-
set(
|
|
47
|
-
marks: readonly { id: string; startLine: number; endLine: number; color: string; sign?: string; fill?: string }[],
|
|
48
|
-
lineCount: number,
|
|
49
|
-
lineBounds: (line: number) => { start: number; end: number }
|
|
50
|
-
): void {
|
|
51
|
+
set(marks: readonly (EditorLineHighlight & { id: string })[], lineCount: number, lineBounds: (line: number) => { start: number; end: number }): void {
|
|
51
52
|
const clamp = (line: number): number => Math.min(lineCount, Math.max(1, Math.trunc(line) || 1)) - 1;
|
|
52
53
|
this.runs = marks.map((mark) => {
|
|
53
54
|
const start = clamp(mark.startLine);
|
|
54
55
|
const end = Math.max(start, clamp(mark.endLine));
|
|
55
|
-
|
|
56
|
+
const emphasis = (mark.emphasis ?? []).slice(0, end - start + 1).flatMap((ranges, index) => {
|
|
57
|
+
const bounds = lineBounds(start + index);
|
|
58
|
+
const length = bounds.end - bounds.start;
|
|
59
|
+
return ranges.flatMap(([from, to]) => {
|
|
60
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const first = Math.min(length, Math.max(0, Math.trunc(from)));
|
|
64
|
+
const last = Math.min(length, Math.max(0, Math.trunc(to)));
|
|
65
|
+
return last > first ? [{ from: bounds.start + first, to: bounds.start + last }] : [];
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
id: mark.id,
|
|
70
|
+
color: mark.color,
|
|
71
|
+
sign: mark.sign,
|
|
72
|
+
fill: mark.fill,
|
|
73
|
+
bar: mark.bar,
|
|
74
|
+
from: lineBounds(start).start,
|
|
75
|
+
to: lineBounds(end).end,
|
|
76
|
+
emphasis
|
|
77
|
+
};
|
|
56
78
|
});
|
|
57
79
|
}
|
|
58
80
|
|
|
@@ -60,10 +82,11 @@ export class AttributionRuns {
|
|
|
60
82
|
this.runs = [];
|
|
61
83
|
}
|
|
62
84
|
|
|
63
|
-
map(changes: readonly DocumentChange[]): void {
|
|
85
|
+
map(changes: readonly DocumentChange[], map = offsetMapper(changes)): void {
|
|
64
86
|
for (const run of this.runs) {
|
|
65
|
-
run.from =
|
|
66
|
-
run.to =
|
|
87
|
+
run.from = map(run.from);
|
|
88
|
+
run.to = map(run.to);
|
|
89
|
+
run.emphasis = run.emphasis.map((range) => ({ from: map(range.from), to: map(range.to) })).filter((range) => range.to > range.from);
|
|
67
90
|
}
|
|
68
91
|
}
|
|
69
92
|
|
|
@@ -74,10 +97,23 @@ export class AttributionRuns {
|
|
|
74
97
|
if (run.to < from || run.from > to) {
|
|
75
98
|
continue;
|
|
76
99
|
}
|
|
77
|
-
const value = { id: run.id, color: run.color, sign: run.sign, fill: run.fill };
|
|
100
|
+
const value = { id: run.id, color: run.color, sign: run.sign, fill: run.fill, bar: run.bar };
|
|
101
|
+
const emphasis = new Map<number, { from: number; to: number }[]>();
|
|
102
|
+
for (const range of run.emphasis) {
|
|
103
|
+
if (range.to <= from || range.from > to) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const endLine = Math.min(lineOf(range.to - 1), last);
|
|
107
|
+
for (let line = Math.max(lineOf(range.from), first); line <= endLine; line++) {
|
|
108
|
+
const ranges = emphasis.get(line) ?? [];
|
|
109
|
+
ranges.push(range);
|
|
110
|
+
emphasis.set(line, ranges);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
78
113
|
const end = Math.min(Math.max(lineOf(run.from), lineOf(run.to)), last);
|
|
79
114
|
for (let line = Math.max(lineOf(run.from), first); line <= end; line++) {
|
|
80
|
-
|
|
115
|
+
const ranges = emphasis.get(line);
|
|
116
|
+
lines.set(line, ranges === undefined ? value : { ...value, emphasis: ranges });
|
|
81
117
|
}
|
|
82
118
|
}
|
|
83
119
|
return lines;
|
package/src/bidi.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* The label drawn for each control character. The boxes are visual: assistive technology reads the raw characters from the input. */
|
|
2
|
+
export const BIDI_CONTROLS = {
|
|
3
|
+
'': { label: 'LRE' },
|
|
4
|
+
'': { label: 'RLE' },
|
|
5
|
+
'': { label: 'PDF' },
|
|
6
|
+
'': { label: 'LRO' },
|
|
7
|
+
'': { label: 'RLO' },
|
|
8
|
+
'': { label: 'LRI' },
|
|
9
|
+
'': { label: 'RLI' },
|
|
10
|
+
'': { label: 'FSI' },
|
|
11
|
+
'': { label: 'PDI' },
|
|
12
|
+
'': { label: 'LRM' },
|
|
13
|
+
'': { label: 'RLM' },
|
|
14
|
+
'': { label: 'ALM' }
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
type BidiControl = keyof typeof BIDI_CONTROLS;
|
|
18
|
+
|
|
19
|
+
/* Every label is this many columns wide, so the placeholder is a box of fixed size in the grid. */
|
|
20
|
+
export const BIDI_COLUMNS = 3;
|
|
21
|
+
|
|
22
|
+
export const BIDI_PATTERN = /[--]/g;
|
|
23
|
+
|
|
24
|
+
/* The placeholder of a bidirectional control character, or null for any other text. */
|
|
25
|
+
export function bidiControl(character: string): (typeof BIDI_CONTROLS)[BidiControl] | null {
|
|
26
|
+
return character.length === 1 ? ((BIDI_CONTROLS as Record<string, (typeof BIDI_CONTROLS)[BidiControl]>)[character] ?? null) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Text a person reads but the editor never edits, such as a hint or a suggestion: each control is spelled out in brackets so none reaches the DOM. */
|
|
30
|
+
export function visibleBidi(text: string): string {
|
|
31
|
+
return text.replace(BIDI_PATTERN, (character) => `[${BIDI_CONTROLS[character as BidiControl].label}]`);
|
|
32
|
+
}
|
package/src/column-selection.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stopCount, xOfStop } from './geometry-stops.ts';
|
|
1
2
|
import type { Selection } from '@adecore/editor-core';
|
|
2
3
|
import { type EditorLayout, TEXT_PADDING } from './layout.ts';
|
|
3
4
|
|
|
@@ -33,7 +34,7 @@ export function columnSelections(layout: EditorLayout, from: ContentPoint, to: C
|
|
|
33
34
|
continue;
|
|
34
35
|
}
|
|
35
36
|
const geometry = layout.geometry(row);
|
|
36
|
-
const endX = geometry.rowStarts.length > 1 ? geometry.rowEnds[0]! : geometry
|
|
37
|
+
const endX = geometry.rowStarts.length > 1 ? geometry.rowEnds[0]! : xOfStop(geometry, stopCount(geometry) - 1);
|
|
37
38
|
const offsetAt = (cell: number): number => layout.offsetAtX(geometry, 0, TEXT_PADDING + cell * charWidth).offset;
|
|
38
39
|
if (cellAt(layout, endX + TEXT_PADDING) < left) {
|
|
39
40
|
if (left === right) {
|
package/src/controller.ts
CHANGED
|
@@ -7,9 +7,12 @@ import { DoubleModifierGesture } from './modifier-gesture.ts';
|
|
|
7
7
|
import { NativeInput } from './native-input.ts';
|
|
8
8
|
import { columnSelections, sameCell } from './column-selection.ts';
|
|
9
9
|
import { PointerSelection } from './pointer.ts';
|
|
10
|
+
import { TouchSelection } from './touch.ts';
|
|
10
11
|
import type {
|
|
11
12
|
EditorClick,
|
|
12
13
|
EditorClickHandler,
|
|
14
|
+
EditorInlayClick,
|
|
15
|
+
EditorInlayClickHandler,
|
|
13
16
|
EditorContextMenu,
|
|
14
17
|
EditorKeyHandler,
|
|
15
18
|
EditorPosition,
|
|
@@ -80,6 +83,8 @@ function selectionSignature(selections: readonly Selection[]): string {
|
|
|
80
83
|
|
|
81
84
|
/* Typing in the same stretch is one undo step until a pause this long. */
|
|
82
85
|
const TYPING_PAUSE_MS = 750;
|
|
86
|
+
/* A second press on the same hint within this many milliseconds is a double click. */
|
|
87
|
+
const DOUBLE_PRESS_MS = 500;
|
|
83
88
|
|
|
84
89
|
/*
|
|
85
90
|
* What a person does to the editor: keys, typing, the clipboard, an input method, the mouse. It turns
|
|
@@ -88,10 +93,13 @@ const TYPING_PAUSE_MS = 750;
|
|
|
88
93
|
export class InputController {
|
|
89
94
|
private readonly native: NativeInput;
|
|
90
95
|
private readonly pointer: PointerSelection;
|
|
96
|
+
private readonly touch: TouchSelection;
|
|
91
97
|
private readonly cleanup: (() => void)[] = [];
|
|
92
98
|
private readonly keyHandlers = new Set<EditorKeyHandler>();
|
|
93
99
|
private readonly gesture: DoubleModifierGesture;
|
|
94
100
|
private readonly clickHandlers = new Set<EditorClickHandler>();
|
|
101
|
+
private readonly inlayHandlers = new Set<EditorInlayClickHandler>();
|
|
102
|
+
private lastInlayPress: { id: string; at: number } | null = null;
|
|
95
103
|
private readonly contextListeners = new Set<(menu: EditorContextMenu) => void>();
|
|
96
104
|
private readonly subscription: { dispose(): void };
|
|
97
105
|
private historyGroup = 0;
|
|
@@ -146,6 +154,16 @@ export class InputController {
|
|
|
146
154
|
focus: () => view.focus(),
|
|
147
155
|
scrolled: () => view.requestRender()
|
|
148
156
|
});
|
|
157
|
+
this.touch = new TouchSelection({
|
|
158
|
+
model,
|
|
159
|
+
offsetAt: (x, y) => view.offsetAtPoint(x, y),
|
|
160
|
+
camelHumps: () => view.settings.smartKeys.camelHumps,
|
|
161
|
+
lineAt: (offset) => {
|
|
162
|
+
const row = view.layout.rowForLine(model.positionAt(offset).line);
|
|
163
|
+
return row.kind === 'text' ? { from: model.getLine(row.line).start, to: model.getLine(row.lastLine).next } : { from: offset, to: offset };
|
|
164
|
+
},
|
|
165
|
+
focus: () => view.focus()
|
|
166
|
+
});
|
|
149
167
|
this.subscription = model.subscribe(() => {
|
|
150
168
|
if (!this.keepDesiredXs) {
|
|
151
169
|
this.desiredXs = [];
|
|
@@ -196,22 +214,45 @@ export class InputController {
|
|
|
196
214
|
this.listen(input, 'cut', (event) => this.copy(event, true));
|
|
197
215
|
this.listen(viewport, 'scroll', () => this.view.scrolled());
|
|
198
216
|
this.listen(viewport, 'wheel', () => this.view.cancelScroll());
|
|
199
|
-
this.listen(viewport, 'touchstart', () => this.view.cancelScroll());
|
|
200
217
|
this.listen(viewport, 'pointermove', (event) => this.view.hoverMoved(event));
|
|
201
|
-
this.listen(viewport, 'pointerleave', () =>
|
|
218
|
+
this.listen(viewport, 'pointerleave', () => {
|
|
219
|
+
this.view.setHover(null);
|
|
220
|
+
this.view.setInlayHover(null);
|
|
221
|
+
});
|
|
202
222
|
this.listen(viewport, 'pointerdown', (event) => this.pointerDown(event));
|
|
203
223
|
this.listen(viewport, 'contextmenu', (event) => this.contextMenu(event));
|
|
204
224
|
this.listen(this.view.gutterElement, 'pointerdown', (event) => this.gutterDown(event));
|
|
205
225
|
this.listen(this.view.stickyElement, 'pointerdown', (event) => this.stickyDown(event));
|
|
206
|
-
const move = (event: Event): void =>
|
|
226
|
+
const move = (event: Event): void => {
|
|
227
|
+
this.pointer.move(event as PointerEvent);
|
|
228
|
+
this.touch.move(event as PointerEvent);
|
|
229
|
+
};
|
|
207
230
|
const end = (event: Event): void => {
|
|
208
231
|
this.pointer.end(event as PointerEvent);
|
|
232
|
+
this.touch.end(event as PointerEvent);
|
|
209
233
|
this.historyGroup++;
|
|
210
234
|
};
|
|
211
235
|
document.addEventListener('pointermove', move);
|
|
212
236
|
document.addEventListener('pointerup', end);
|
|
213
237
|
document.addEventListener('pointercancel', end);
|
|
238
|
+
// The on-screen keyboard shrinks the visual viewport and not the layout one, which would leave the caret behind it.
|
|
239
|
+
const visualViewport = document.defaultView?.visualViewport;
|
|
240
|
+
let keyboardHeight = visualViewport?.height ?? 0;
|
|
241
|
+
let keyboardScale = visualViewport?.scale ?? 1;
|
|
242
|
+
const keyboard = (): void => {
|
|
243
|
+
const height = visualViewport?.height ?? 0;
|
|
244
|
+
const scale = visualViewport?.scale ?? 1;
|
|
245
|
+
// Zooming in shrinks the visual viewport too, and must not pull the view back to the caret.
|
|
246
|
+
const shrunk = height < keyboardHeight && scale === keyboardScale;
|
|
247
|
+
keyboardHeight = height;
|
|
248
|
+
keyboardScale = scale;
|
|
249
|
+
if (shrunk && this.view.focused) {
|
|
250
|
+
this.view.revealCaret();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
visualViewport?.addEventListener('resize', keyboard);
|
|
214
254
|
this.cleanup.push(() => {
|
|
255
|
+
visualViewport?.removeEventListener('resize', keyboard);
|
|
215
256
|
document.removeEventListener('pointermove', move);
|
|
216
257
|
document.removeEventListener('pointerup', end);
|
|
217
258
|
document.removeEventListener('pointercancel', end);
|
|
@@ -221,6 +262,7 @@ export class InputController {
|
|
|
221
262
|
dispose(): void {
|
|
222
263
|
this.subscription.dispose();
|
|
223
264
|
this.pointer.dispose();
|
|
265
|
+
this.touch.dispose();
|
|
224
266
|
for (const remove of this.cleanup) {
|
|
225
267
|
remove();
|
|
226
268
|
}
|
|
@@ -306,6 +348,9 @@ export class InputController {
|
|
|
306
348
|
/* Folding is the view's, since it knows where the ranges are. */
|
|
307
349
|
private viewCommand(name: EditorViewCommand): boolean {
|
|
308
350
|
const { view } = this;
|
|
351
|
+
if (view.settings.folding === false && name !== 'toggleColumnMode') {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
309
354
|
const run: Record<EditorViewCommand, () => void> = {
|
|
310
355
|
collapseRegion: () => view.collapseRegion(),
|
|
311
356
|
expandRegion: () => view.expandRegion(),
|
|
@@ -641,6 +686,10 @@ export class InputController {
|
|
|
641
686
|
return subscribe(this.clickHandlers, handler);
|
|
642
687
|
}
|
|
643
688
|
|
|
689
|
+
onInlayClick(handler: EditorInlayClickHandler): () => void {
|
|
690
|
+
return subscribe(this.inlayHandlers, handler);
|
|
691
|
+
}
|
|
692
|
+
|
|
644
693
|
onContextMenu(listener: (menu: EditorContextMenu) => void): () => void {
|
|
645
694
|
return subscribe(this.contextListeners, listener);
|
|
646
695
|
}
|
|
@@ -744,7 +793,15 @@ export class InputController {
|
|
|
744
793
|
if (event.button !== 0 || target.closest('button, a, input, textarea, [contenteditable], .se-block, .se-gutter, .se-sticky')) {
|
|
745
794
|
return;
|
|
746
795
|
}
|
|
796
|
+
// Cancels the compatibility mouse events, whose focus change would close the keyboard a tap opened; scrolling is not affected.
|
|
747
797
|
event.preventDefault();
|
|
798
|
+
if (this.takenByInlayHandler(event)) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (event.pointerType === 'touch') {
|
|
802
|
+
this.touch.start(event, 'text', () => this.takenByHost(event));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
748
805
|
if (this.takenByHost(event)) {
|
|
749
806
|
this.view.focus();
|
|
750
807
|
return;
|
|
@@ -752,6 +809,34 @@ export class InputController {
|
|
|
752
809
|
this.pointer.start(event);
|
|
753
810
|
}
|
|
754
811
|
|
|
812
|
+
/* Whether a handler of the host took a press on a hint, which then moves neither the caret nor the focus. */
|
|
813
|
+
private takenByInlayHandler(event: PointerEvent): boolean {
|
|
814
|
+
const inlay = this.inlayHandlers.size === 0 ? null : this.view.inlayAt(event.target);
|
|
815
|
+
if (inlay === null) {
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
const again = this.lastInlayPress?.id === inlay.id && now - this.lastInlayPress.at <= DOUBLE_PRESS_MS;
|
|
820
|
+
this.lastInlayPress = again ? null : { id: inlay.id, at: now };
|
|
821
|
+
const touch = event.pointerType === 'touch';
|
|
822
|
+
const click: EditorInlayClick = {
|
|
823
|
+
id: inlay.id,
|
|
824
|
+
part: inlay.part,
|
|
825
|
+
mod: this.options.apple ? event.metaKey : event.ctrlKey,
|
|
826
|
+
alt: event.altKey,
|
|
827
|
+
shift: event.shiftKey,
|
|
828
|
+
clicks: again ? 2 : 1,
|
|
829
|
+
touch
|
|
830
|
+
};
|
|
831
|
+
if (![...this.inlayHandlers].some((handler) => handler(click))) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
if (!touch) {
|
|
835
|
+
this.view.focus();
|
|
836
|
+
}
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
755
840
|
/* Whether a handler of the host took a press on a character, such as a Mod+click that follows a name. */
|
|
756
841
|
private takenByHost(event: PointerEvent): boolean {
|
|
757
842
|
if (this.clickHandlers.size === 0) {
|
|
@@ -776,6 +861,10 @@ export class InputController {
|
|
|
776
861
|
}
|
|
777
862
|
|
|
778
863
|
private contextMenu(event: MouseEvent): void {
|
|
864
|
+
if (this.contextListeners.size === 0 && this.touch.pressed) {
|
|
865
|
+
event.preventDefault();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
779
868
|
if (this.contextListeners.size === 0 || (event.target as HTMLElement | null)?.closest?.('.se-sticky')) {
|
|
780
869
|
return;
|
|
781
870
|
}
|
|
@@ -801,6 +890,7 @@ export class InputController {
|
|
|
801
890
|
|
|
802
891
|
/* A press on a line number selects the line. */
|
|
803
892
|
private gutterDown(event: PointerEvent): void {
|
|
893
|
+
this.view.cancelScroll();
|
|
804
894
|
if (event.button !== 0) {
|
|
805
895
|
return;
|
|
806
896
|
}
|
|
@@ -822,6 +912,10 @@ export class InputController {
|
|
|
822
912
|
this.view.focus();
|
|
823
913
|
return;
|
|
824
914
|
}
|
|
915
|
+
if (event.pointerType === 'touch') {
|
|
916
|
+
this.touch.start(event, 'gutter');
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
825
919
|
this.pointer.start(event, 'gutter');
|
|
826
920
|
this.view.focus();
|
|
827
921
|
}
|