@adecore/editor 0.22.1 → 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.
Files changed (94) hide show
  1. package/dist/attribution.d.ts +9 -8
  2. package/dist/attribution.js +44 -9
  3. package/dist/bidi.d.ts +44 -0
  4. package/dist/bidi.js +26 -0
  5. package/dist/column-selection.js +2 -1
  6. package/dist/controller.d.ts +6 -1
  7. package/dist/controller.js +93 -3
  8. package/dist/editor.css +81 -1
  9. package/dist/engine.js +184 -41
  10. package/dist/fake.d.ts +43 -5
  11. package/dist/fake.js +117 -13
  12. package/dist/find-search.d.ts +26 -0
  13. package/dist/find-search.js +130 -0
  14. package/dist/find.d.ts +50 -2
  15. package/dist/find.js +575 -25
  16. package/dist/geometry-stops.d.ts +6 -0
  17. package/dist/geometry-stops.js +47 -0
  18. package/dist/grammar-preparation.d.ts +29 -0
  19. package/dist/grammar-preparation.js +147 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +2 -0
  22. package/dist/keymap-table.d.ts +10 -1
  23. package/dist/keymap-table.js +37 -1
  24. package/dist/keymap.d.ts +1 -0
  25. package/dist/keymap.js +4 -1
  26. package/dist/layout-changes.d.ts +6 -0
  27. package/dist/layout-changes.js +27 -0
  28. package/dist/layout.d.ts +45 -2
  29. package/dist/layout.js +202 -58
  30. package/dist/offsets.d.ts +1 -0
  31. package/dist/offsets.js +23 -0
  32. package/dist/outline.js +5 -4
  33. package/dist/paint.d.ts +11 -3
  34. package/dist/paint.js +180 -34
  35. package/dist/search-client.d.ts +27 -0
  36. package/dist/search-client.js +210 -0
  37. package/dist/search-protocol.d.ts +103 -0
  38. package/dist/search-protocol.js +3 -0
  39. package/dist/search-worker.d.ts +11 -0
  40. package/dist/search-worker.js +137 -0
  41. package/dist/semantic.d.ts +2 -1
  42. package/dist/semantic.js +6 -1
  43. package/dist/shiki-document.d.ts +8 -0
  44. package/dist/shiki-document.js +93 -0
  45. package/dist/shiki-worker.d.ts +65 -0
  46. package/dist/shiki-worker.js +265 -0
  47. package/dist/shiki.d.ts +1 -0
  48. package/dist/shiki.js +96 -33
  49. package/dist/testing.d.ts +1 -0
  50. package/dist/tokens.d.ts +2 -0
  51. package/dist/tokens.js +37 -7
  52. package/dist/touch.d.ts +27 -0
  53. package/dist/touch.js +100 -0
  54. package/dist/types.d.ts +84 -2
  55. package/dist/uniform-geometry.d.ts +7 -0
  56. package/dist/uniform-geometry.js +49 -0
  57. package/dist/view.d.ts +68 -17
  58. package/dist/view.js +424 -97
  59. package/dist/worker-client.d.ts +37 -0
  60. package/dist/worker-client.js +195 -0
  61. package/package.json +17 -2
  62. package/src/attribution.ts +50 -14
  63. package/src/bidi.ts +32 -0
  64. package/src/column-selection.ts +2 -1
  65. package/src/controller.ts +97 -3
  66. package/src/editor.css +81 -1
  67. package/src/engine.ts +216 -42
  68. package/src/fake.ts +152 -14
  69. package/src/find-search.ts +165 -0
  70. package/src/find.ts +622 -21
  71. package/src/geometry-stops.ts +51 -0
  72. package/src/grammar-preparation.ts +158 -0
  73. package/src/index.ts +10 -0
  74. package/src/keymap-table.ts +44 -2
  75. package/src/keymap.ts +5 -1
  76. package/src/layout-changes.ts +33 -0
  77. package/src/layout.ts +262 -63
  78. package/src/offsets.ts +24 -0
  79. package/src/outline.ts +5 -4
  80. package/src/paint.ts +202 -36
  81. package/src/search-client.ts +306 -0
  82. package/src/search-protocol.ts +100 -0
  83. package/src/search-worker.ts +171 -0
  84. package/src/semantic.ts +8 -2
  85. package/src/shiki-document.ts +143 -0
  86. package/src/shiki-worker.ts +344 -0
  87. package/src/shiki.ts +103 -33
  88. package/src/testing.ts +2 -0
  89. package/src/tokens.ts +36 -7
  90. package/src/touch.ts +134 -0
  91. package/src/types.ts +174 -8
  92. package/src/uniform-geometry.ts +57 -0
  93. package/src/view.ts +500 -102
  94. 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.22.1",
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.22.1",
57
+ "@adecore/editor-core": "0.23.0",
43
58
  "linkedom": "^0.18.13",
44
59
  "shiki": "^4.4.3"
45
60
  },
@@ -1,5 +1,6 @@
1
1
  import type { DocumentChange } from '@adecore/editor-core';
2
- import { mapOffset } from './offsets.ts';
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 costs one mapping of two offsets per run, and a read works out the lines of only the runs that
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
- return { id: mark.id, color: mark.color, sign: mark.sign, fill: mark.fill, from: lineBounds(start).start, to: lineBounds(end).end };
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 = mapOffset(run.from, changes);
66
- run.to = mapOffset(run.to, changes);
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
- lines.set(line, value);
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
+ }
@@ -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.before[geometry.before.length - 1]!;
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', () => this.view.setHover(null));
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 => this.pointer.move(event as PointerEvent);
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
  }