@adecore/editor 0.0.1 → 0.17.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +82 -0
  3. package/dist/attribution.d.ts +28 -0
  4. package/dist/attribution.js +55 -0
  5. package/dist/code-block.d.ts +2 -0
  6. package/dist/code-block.js +89 -0
  7. package/dist/column-selection.d.ts +9 -0
  8. package/dist/column-selection.js +52 -0
  9. package/dist/controller.d.ts +64 -0
  10. package/dist/controller.js +772 -0
  11. package/dist/editor.css +813 -0
  12. package/dist/engine.d.ts +2 -0
  13. package/dist/engine.js +604 -0
  14. package/dist/fake.d.ts +190 -0
  15. package/dist/fake.js +658 -0
  16. package/dist/find.d.ts +30 -0
  17. package/dist/find.js +108 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +4 -0
  20. package/dist/input.d.ts +9 -0
  21. package/dist/input.js +57 -0
  22. package/dist/keymap-table.d.ts +102 -0
  23. package/dist/keymap-table.js +130 -0
  24. package/dist/keymap.d.ts +36 -0
  25. package/dist/keymap.js +163 -0
  26. package/dist/layout.d.ts +167 -0
  27. package/dist/layout.js +583 -0
  28. package/dist/lens.d.ts +3 -0
  29. package/dist/lens.js +29 -0
  30. package/dist/listeners.d.ts +3 -0
  31. package/dist/listeners.js +12 -0
  32. package/dist/metrics.d.ts +8 -0
  33. package/dist/metrics.js +54 -0
  34. package/dist/modifier-gesture.d.ts +24 -0
  35. package/dist/modifier-gesture.js +98 -0
  36. package/dist/native-input.d.ts +26 -0
  37. package/dist/native-input.js +147 -0
  38. package/dist/offsets.d.ts +2 -0
  39. package/dist/offsets.js +14 -0
  40. package/dist/outline.d.ts +55 -0
  41. package/dist/outline.js +181 -0
  42. package/dist/overview.d.ts +17 -0
  43. package/dist/overview.js +55 -0
  44. package/dist/paint.d.ts +86 -0
  45. package/dist/paint.js +481 -0
  46. package/dist/php-html.d.ts +9 -0
  47. package/dist/php-html.js +36 -0
  48. package/dist/pointer.d.ts +49 -0
  49. package/dist/pointer.js +243 -0
  50. package/dist/scroll-animation.d.ts +3 -0
  51. package/dist/scroll-animation.js +45 -0
  52. package/dist/scroll.d.ts +28 -0
  53. package/dist/scroll.js +76 -0
  54. package/dist/semantic.d.ts +8 -0
  55. package/dist/semantic.js +47 -0
  56. package/dist/shiki.d.ts +7 -0
  57. package/dist/shiki.js +106 -0
  58. package/dist/smart-keys.d.ts +3 -0
  59. package/dist/smart-keys.js +14 -0
  60. package/dist/testing.d.ts +34 -0
  61. package/dist/testing.js +117 -0
  62. package/dist/theme-scopes.d.ts +9 -0
  63. package/dist/theme-scopes.js +61 -0
  64. package/dist/tokens.d.ts +28 -0
  65. package/dist/tokens.js +152 -0
  66. package/dist/tracked-range.d.ts +5 -0
  67. package/dist/tracked-range.js +20 -0
  68. package/dist/types.d.ts +342 -0
  69. package/dist/types.js +1 -0
  70. package/dist/view.d.ts +382 -0
  71. package/dist/view.js +2134 -0
  72. package/package.json +63 -3
  73. package/src/attribution.ts +85 -0
  74. package/src/code-block.ts +100 -0
  75. package/src/column-selection.ts +63 -0
  76. package/src/controller.ts +838 -0
  77. package/src/editor.css +813 -0
  78. package/src/engine.ts +757 -0
  79. package/src/fake.ts +831 -0
  80. package/src/find.ts +128 -0
  81. package/src/index.ts +69 -0
  82. package/src/input.ts +63 -0
  83. package/src/keymap-table.ts +163 -0
  84. package/src/keymap.ts +204 -0
  85. package/src/layout.ts +754 -0
  86. package/src/lens.ts +32 -0
  87. package/src/listeners.ts +15 -0
  88. package/src/metrics.ts +65 -0
  89. package/src/modifier-gesture.ts +115 -0
  90. package/src/native-input.ts +179 -0
  91. package/src/offsets.ts +16 -0
  92. package/src/outline.ts +248 -0
  93. package/src/overview.ts +80 -0
  94. package/src/paint.ts +608 -0
  95. package/src/php-html.ts +43 -0
  96. package/src/pointer.ts +326 -0
  97. package/src/scroll-animation.ts +48 -0
  98. package/src/scroll.ts +102 -0
  99. package/src/semantic.ts +54 -0
  100. package/src/shiki.ts +118 -0
  101. package/src/smart-keys.ts +17 -0
  102. package/src/testing.ts +157 -0
  103. package/src/theme-scopes.ts +75 -0
  104. package/src/tokens.ts +175 -0
  105. package/src/tracked-range.ts +22 -0
  106. package/src/types.ts +656 -0
  107. package/src/view.ts +2436 -0
package/src/find.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { DocumentChange, DocumentModel } from '@adecore/editor-core';
2
+ import { mapOffset } from './offsets.ts';
3
+ import type { EditorFindQuery, EditorFindState } from './types.ts';
4
+
5
+ export interface FindMark {
6
+ from: number;
7
+ to: number;
8
+ }
9
+
10
+ /* Past this a count means nothing to a reader, and marking every match would stall the editor. */
11
+ export const FIND_LIMIT = 10000;
12
+
13
+ /*
14
+ * The find of the host's find bar: every match marked, one of them current. It searches the model on
15
+ * its own and never touches the selection until the find ends.
16
+ */
17
+ export class FindController {
18
+ private query: EditorFindQuery | null = null;
19
+ private marks: FindMark[] = [];
20
+ private index: number | null = null;
21
+ /* The text a search in the selection is held to, which follows the text through edits; null when nothing was selected. */
22
+ private bounds: { from: number; to: number } | null = null;
23
+ /* Where the next refresh looks for the current match from, once: right after what a replace wrote. */
24
+ pendingAnchor: number | undefined;
25
+
26
+ private readonly model: DocumentModel;
27
+
28
+ constructor(model: DocumentModel) {
29
+ this.model = model;
30
+ }
31
+
32
+ get activeQuery(): EditorFindQuery | null {
33
+ return this.query;
34
+ }
35
+
36
+ get active(): boolean {
37
+ return this.query !== null;
38
+ }
39
+
40
+ get matches(): readonly FindMark[] {
41
+ return this.marks;
42
+ }
43
+
44
+ get current(): number | null {
45
+ return this.index;
46
+ }
47
+
48
+ get state(): EditorFindState {
49
+ return { count: this.marks.length, current: this.index, ...(this.query?.inSelection === true && this.bounds === null ? { noSelection: true } : {}) };
50
+ }
51
+
52
+ /* The range a search in the selection is held to. */
53
+ get scope(): { from: number; to: number } | null {
54
+ return this.query?.inSelection === true ? this.bounds : null;
55
+ }
56
+
57
+ get currentMark(): FindMark | null {
58
+ return this.index === null ? null : (this.marks[this.index] ?? null);
59
+ }
60
+
61
+ /* A new query, or null to end the find. */
62
+ set(query: EditorFindQuery | null): void {
63
+ const wasInSelection = this.query?.inSelection === true;
64
+ this.query = query === null || query.text === '' ? null : query;
65
+ if (this.query?.inSelection !== true) {
66
+ this.bounds = null;
67
+ } else if (!wasInSelection) {
68
+ const selection = this.model.getPrimary();
69
+ this.bounds =
70
+ selection.anchor === selection.head
71
+ ? null
72
+ : { from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head) };
73
+ }
74
+ this.refresh();
75
+ }
76
+
77
+ /* The text of a search in the selection moved with an edit. */
78
+ mapBounds(changes: readonly DocumentChange[]): void {
79
+ if (this.bounds !== null) {
80
+ this.bounds = { from: mapOffset(this.bounds.from, changes), to: mapOffset(this.bounds.to, changes) };
81
+ }
82
+ }
83
+
84
+ /*
85
+ * The matches again, after a new query or an edit. The current one is the first from where the
86
+ * previous one was, or from the cursor, so each letter typed keeps the place instead of starting over.
87
+ */
88
+ refresh(): void {
89
+ const previous = this.currentMark;
90
+ const query = this.query;
91
+ if (query === null) {
92
+ this.marks = [];
93
+ this.index = null;
94
+ return;
95
+ }
96
+ let found: FindMark[] = [];
97
+ try {
98
+ const inSelection = query.inSelection === true;
99
+ if (!inSelection || this.bounds !== null) {
100
+ found = this.model
101
+ .find(query.text, {
102
+ caseSensitive: query.caseSensitive,
103
+ wholeWord: query.wholeWord,
104
+ regex: query.regex,
105
+ maxResults: FIND_LIMIT,
106
+ ...(inSelection ? this.bounds : {})
107
+ })
108
+ .map((match) => ({ from: match.from, to: match.to }));
109
+ }
110
+ } catch {
111
+ // A pattern that does not parse finds nothing; the find bar says it is invalid.
112
+ }
113
+ this.marks = found;
114
+ const selection = this.model.getPrimary();
115
+ const anchor = this.pendingAnchor ?? previous?.from ?? Math.min(selection.anchor, selection.head);
116
+ this.pendingAnchor = undefined;
117
+ const next = found.findIndex((mark) => mark.from >= anchor);
118
+ this.index = found.length === 0 ? null : Math.max(0, next);
119
+ }
120
+
121
+ step(direction: 1 | -1): void {
122
+ const count = this.marks.length;
123
+ if (count === 0) {
124
+ return;
125
+ }
126
+ this.index = this.index === null ? (direction === 1 ? 0 : count - 1) : (this.index + direction + count) % count;
127
+ }
128
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ export { AGENT_COLORS } from './attribution.ts';
2
+ export { createSmartEditorEngine } from './engine.ts';
3
+ export { DEFAULT_SMART_KEYS } from './smart-keys.ts';
4
+ export { shikiScopeColors, shikiTokenizers } from './shiki.ts';
5
+ export type {
6
+ Editor,
7
+ EditorAttributionHover,
8
+ EditorAttributionMark,
9
+ EditorMarkColor,
10
+ EditorBlock,
11
+ EditorChangeKind,
12
+ EditorChangeMark,
13
+ EditorCodeVision,
14
+ EditorCodeVisionEntry,
15
+ EditorClick,
16
+ EditorCodeBlockOptions,
17
+ EditorClickHandler,
18
+ EditorCommand,
19
+ EditorContextMenu,
20
+ EditorEngine,
21
+ EditorFindQuery,
22
+ EditorFoldHints,
23
+ EditorFoldOutline,
24
+ EditorFoldRange,
25
+ EditorFoldSymbol,
26
+ EditorFolds,
27
+ EditorFindState,
28
+ EditorReplaceOptions,
29
+ EditorGutterAction,
30
+ EditorGutterMarker,
31
+ EditorIndentation,
32
+ EditorContentChange,
33
+ EditorHighlight,
34
+ EditorHighlightKind,
35
+ EditorHover,
36
+ EditorGhostText,
37
+ EditorInlayHint,
38
+ EditorKeyHandler,
39
+ EditorLineAction,
40
+ EditorLineHighlight,
41
+ EditorMarker,
42
+ EditorMessages,
43
+ EditorMarkerSeverity,
44
+ EditorReveal,
45
+ EditorRect,
46
+ EditorSmartKeys,
47
+ EditorOptions,
48
+ EditorPosition,
49
+ EditorRemoteCursor,
50
+ EditorRange,
51
+ EditorTextChange,
52
+ EditorTrackedRange,
53
+ EditorRunCommand,
54
+ EditorSelectionRanges,
55
+ EditorTheme,
56
+ EditorViewCommand,
57
+ EditorWidget,
58
+ FoldRole,
59
+ KeyChord,
60
+ LineToken,
61
+ ScopeColorSource,
62
+ ScopeColors,
63
+ ScopeStyle,
64
+ EditorSemanticToken,
65
+ LineTokenizer,
66
+ SmartEditorEngineOptions,
67
+ TokenizedLine,
68
+ TokenizerSource
69
+ } from './types.ts';
package/src/input.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { Selection, TextEdit } from '@adecore/editor-core';
2
+
3
+ /* The one stretch two texts differ in, found from both ends and never inside a surrogate pair. */
4
+ export function findChange(before: string, after: string): TextEdit {
5
+ let from = 0;
6
+ while (from < before.length && from < after.length && before[from] === after[from]) {
7
+ from++;
8
+ }
9
+ if (from > 0 && /[\uDC00-\uDFFF]/.test(before[from] ?? '') && /[\uD800-\uDBFF]/.test(before[from - 1]!)) {
10
+ from--;
11
+ }
12
+ let endBefore = before.length;
13
+ let endAfter = after.length;
14
+ while (endBefore > from && endAfter > from && before[endBefore - 1] === after[endAfter - 1]) {
15
+ endBefore--;
16
+ endAfter--;
17
+ }
18
+ if (endBefore < before.length && /[\uDC00-\uDFFF]/.test(before[endBefore] ?? '') && /[\uD800-\uDBFF]/.test(before[endBefore - 1] ?? '')) {
19
+ endBefore++;
20
+ endAfter++;
21
+ }
22
+ return { from, to: endBefore, text: after.slice(from, endAfter) };
23
+ }
24
+
25
+ /* One replacement per selection, with the carets after what each inserted. */
26
+ export function replacementEdits(selections: readonly Selection[], text: string): { edits: TextEdit[]; selections: Selection[] } {
27
+ const sorted = selections
28
+ .map((selection, index) => ({ from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head), index }))
29
+ .sort((left, right) => left.from - right.from);
30
+ const edits: TextEdit[] = [];
31
+ const positions: Selection[] = [];
32
+ let delta = 0;
33
+ for (const range of sorted) {
34
+ edits.push({ from: range.from, to: range.to, text });
35
+ const head = range.from + delta + text.length;
36
+ positions[range.index] = { anchor: head, head };
37
+ delta += text.length - (range.to - range.from);
38
+ }
39
+ return { edits, selections: positions };
40
+ }
41
+
42
+ export function textareaText(text: string): string {
43
+ return text.replace(/\r\n?/g, '\n');
44
+ }
45
+
46
+ /* A textarea normalizes CRLF; the model keeps its own UTF-16 offsets. */
47
+ export function modelOffset(text: string, nativeOffset: number): number {
48
+ let native = 0;
49
+ for (let index = 0; index < text.length; index++) {
50
+ if (native === nativeOffset) {
51
+ return index;
52
+ }
53
+ if (text[index] === '\r' && text[index + 1] === '\n') {
54
+ continue;
55
+ }
56
+ native++;
57
+ }
58
+ return text.length;
59
+ }
60
+
61
+ export function textareaOffset(text: string, offset: number): number {
62
+ return textareaText(text.slice(0, offset)).length;
63
+ }
@@ -0,0 +1,163 @@
1
+ import type { KeyChord } from './types.ts';
2
+
3
+ export interface TakenKey {
4
+ /* The chord the platform binds on the platform this entry is for. */
5
+ readonly platform: string;
6
+ /* The host command that owns the chord. */
7
+ readonly by: string;
8
+ }
9
+
10
+ export interface KeyBinding {
11
+ readonly mac: string | null;
12
+ readonly other: string | null;
13
+ readonly takenMac?: TakenKey;
14
+ readonly takenOther?: TakenKey;
15
+ }
16
+
17
+ function bind(mac: string | null, other: string | null = mac, taken: { mac?: TakenKey; other?: TakenKey } = {}): KeyBinding {
18
+ return {
19
+ mac,
20
+ other,
21
+ ...(taken.mac === undefined ? {} : { takenMac: taken.mac }),
22
+ ...(taken.other === undefined ? {} : { takenOther: taken.other })
23
+ };
24
+ }
25
+
26
+ export const KEYMAP = {
27
+ undo: bind('Mod+Z'),
28
+ redo: bind('Mod+Shift+Z'),
29
+ selectAll: bind('Mod+A'),
30
+ save: bind('Mod+S'),
31
+
32
+ duplicateLine: bind('Mod+D'),
33
+ deleteLine: bind('Mod+Backspace', 'Mod+Y'),
34
+ moveLineUp: bind('Alt+Shift+ArrowUp'),
35
+ moveLineDown: bind('Alt+Shift+ArrowDown'),
36
+ toggleLineComment: bind('Mod+/'),
37
+ toggleBlockComment: bind('Mod+Alt+/', 'Mod+Shift+/'),
38
+ joinLines: bind('Ctrl+Shift+J'),
39
+ startNewLine: bind('Shift+Enter'),
40
+ startNewLineBefore: bind('Mod+Alt+Enter'),
41
+ splitLine: bind('Mod+Enter'),
42
+ toggleCase: bind('Mod+Shift+U'),
43
+ autoIndentLines: bind('Ctrl+Alt+I'),
44
+
45
+ expandSelection: bind('Alt+ArrowUp', 'Ctrl+W'),
46
+ shrinkSelection: bind('Alt+ArrowDown'),
47
+ matchBrace: bind('Ctrl+M', 'Ctrl+Shift+M'),
48
+
49
+ selectNextOccurrence: bind('Ctrl+G', 'Alt+J'),
50
+ unselectOccurrence: bind('Ctrl+Shift+G', 'Alt+Shift+J'),
51
+ selectAllOccurrences: bind('Meta+Ctrl+G', 'Ctrl+Alt+Shift+J'),
52
+ addCaretPerSelectedLine: bind('Alt+Shift+G'),
53
+ // Tap the modifier twice, hold it and press an arrow: Option on macOS, Ctrl elsewhere (`modifier-gesture.ts`). It has no chord to print.
54
+ addCaretAbove: bind(null),
55
+ addCaretBelow: bind(null),
56
+ toggleColumnMode: bind('Mod+Shift+8'),
57
+
58
+ wordLeft: bind('Alt+ArrowLeft', 'Ctrl+ArrowLeft'),
59
+ wordRight: bind('Alt+ArrowRight', 'Ctrl+ArrowRight'),
60
+ selectWordLeft: bind('Alt+Shift+ArrowLeft', 'Ctrl+Shift+ArrowLeft'),
61
+ selectWordRight: bind('Alt+Shift+ArrowRight', 'Ctrl+Shift+ArrowRight'),
62
+ deleteWordLeft: bind('Alt+Backspace', 'Ctrl+Backspace'),
63
+ deleteWordRight: bind('Alt+Delete', 'Ctrl+Delete'),
64
+ smartHome: bind('Meta+ArrowLeft', null),
65
+ selectSmartHome: bind('Meta+Shift+ArrowLeft', null),
66
+ smartEnd: bind('Meta+ArrowRight', null),
67
+ selectSmartEnd: bind('Meta+Shift+ArrowRight', null),
68
+ textStart: bind('Meta+ArrowUp', null),
69
+ selectTextStart: bind('Meta+Shift+ArrowUp', null),
70
+ textEnd: bind('Meta+ArrowDown', null),
71
+ selectTextEnd: bind('Meta+Shift+ArrowDown', null),
72
+
73
+ collapse: bind('Mod+-'),
74
+ expand: bind('Mod+='),
75
+ collapseAll: bind('Mod+Shift+-'),
76
+ expandAll: bind('Mod+Shift+='),
77
+ collapseRecursively: bind('Mod+Alt+-'),
78
+ expandRecursively: bind('Mod+Alt+='),
79
+ foldSelection: bind('Mod+.'),
80
+ // The platform binds the levels to a chord of two strokes, which this table cannot write, so they live in the menu and the palette.
81
+ collapseDocComments: bind(null),
82
+ expandDocComments: bind(null),
83
+ expandAllToLevel1: bind(null),
84
+ expandAllToLevel2: bind(null),
85
+ expandAllToLevel3: bind(null),
86
+ expandAllToLevel4: bind(null),
87
+ expandAllToLevel5: bind(null),
88
+
89
+ triggerCompletion: bind('Ctrl+Space'),
90
+ parameterInfo: bind('Mod+P'),
91
+ quickInfo: bind('Ctrl+J', 'Ctrl+Q'),
92
+ codeActions: bind('Alt+Enter'),
93
+ refactorThis: bind('Ctrl+T', 'Ctrl+Alt+Shift+T'),
94
+ renameSymbol: bind('Shift+F6'),
95
+ organizeImports: bind('Ctrl+Alt+O'),
96
+ formatDocument: bind('Mod+Alt+L'),
97
+ selectionToChat: bind(null),
98
+ inlineEdit: bind(null),
99
+ suggestInline: bind(null),
100
+ acceptGhostWord: bind(null),
101
+
102
+ goToSymbol: bind('Mod+F12'),
103
+ goToDefinition: bind('Mod+B'),
104
+ goToTypeDefinition: bind('Ctrl+Shift+B'),
105
+ goToImplementation: bind('Mod+Alt+B'),
106
+ peekDefinition: bind('Alt+Space', 'Ctrl+Shift+I'),
107
+ peekReferences: bind('Alt+F7'),
108
+ historyBack: bind('Mod+[', 'Ctrl+Alt+ArrowLeft'),
109
+ historyForward: bind('Mod+]', 'Ctrl+Alt+ArrowRight'),
110
+ recentLocations: bind('Mod+Shift+E'),
111
+ nextProblem: bind('F2'),
112
+ previousProblem: bind('Alt+Shift+F2'),
113
+ nextHighlight: bind('Ctrl+Alt+ArrowDown', 'Alt+F3'),
114
+ previousHighlight: bind('Ctrl+Alt+ArrowUp', 'Alt+Shift+F3'),
115
+
116
+ findNext: bind('Mod+G', 'Ctrl+L'),
117
+ findPrevious: bind('Mod+Shift+G', 'Ctrl+Shift+L'),
118
+ replace: bind('Mod+R')
119
+ } as const satisfies Record<string, KeyBinding>;
120
+
121
+ export type KeymapId = keyof typeof KEYMAP;
122
+
123
+ export type Keymap = Readonly<Record<KeymapId, KeyBinding>>;
124
+ export type KeymapOverrides = Partial<Record<KeymapId, Partial<KeyBinding>>>;
125
+
126
+ export const KEYMAP_IDS = Object.keys(KEYMAP) as KeymapId[];
127
+
128
+ export function resolveKeymap(overrides: KeymapOverrides = {}): Keymap {
129
+ return Object.fromEntries(KEYMAP_IDS.map((id) => [id, { ...KEYMAP[id], ...overrides[id] }])) as unknown as Keymap;
130
+ }
131
+
132
+ /* The chord of a command on a platform, or null when the platform has none. */
133
+ export function chordOf(id: KeymapId, apple: boolean, keymap: Keymap = KEYMAP): string | null {
134
+ return keymap[id][apple ? 'mac' : 'other'];
135
+ }
136
+
137
+ const MODIFIERS = new Map<string, 'mod' | 'ctrl' | 'meta' | 'alt' | 'shift'>([
138
+ ['Mod', 'mod'],
139
+ ['Ctrl', 'ctrl'],
140
+ ['Meta', 'meta'],
141
+ ['Alt', 'alt'],
142
+ ['Shift', 'shift']
143
+ ]);
144
+
145
+ /* Reads `Mod+Shift+K`. Throws on a text it cannot read, so a typo fails before a binding is used. */
146
+ export function parseChord(text: string): KeyChord {
147
+ const words = text.endsWith('++') ? [...text.slice(0, -2).split('+'), '+'] : text.split('+');
148
+ const chord = { mod: false, ctrl: false, meta: false, alt: false, shift: false, key: '' };
149
+ for (const word of words) {
150
+ const modifier = MODIFIERS.get(word);
151
+ if (modifier !== undefined) {
152
+ chord[modifier] = true;
153
+ } else if (word === '' || chord.key !== '') {
154
+ throw new Error(`Cannot read the chord "${text}"`);
155
+ } else {
156
+ chord.key = word.length === 1 ? word.toUpperCase() : word;
157
+ }
158
+ }
159
+ if (chord.key === '') {
160
+ throw new Error(`The chord "${text}" names no key`);
161
+ }
162
+ return chord;
163
+ }
package/src/keymap.ts ADDED
@@ -0,0 +1,204 @@
1
+ import type { EditorCommand } from '@adecore/editor-core';
2
+ import type { EditorViewCommand } from './types.ts';
3
+ import { chordOf, KEYMAP, KEYMAP_IDS, type Keymap, type KeymapId, parseChord } from './keymap-table.ts';
4
+ import type { KeyChord } from './types.ts';
5
+
6
+ export interface KeyLike {
7
+ key: string;
8
+ /* The physical key, for a chord whose character depends on the layout or on Option. */
9
+ code?: string;
10
+ ctrlKey: boolean;
11
+ metaKey: boolean;
12
+ altKey: boolean;
13
+ shiftKey: boolean;
14
+ }
15
+
16
+ export type MoveKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'PageUp' | 'PageDown';
17
+
18
+ export type KeyAction =
19
+ | { readonly type: 'command'; readonly command: EditorCommand }
20
+ | { readonly type: 'move'; readonly key: MoveKey; readonly extend: boolean }
21
+ | { readonly type: 'edge'; readonly end: boolean; readonly extend: boolean }
22
+ | { readonly type: 'bracket' }
23
+ | { readonly type: 'view'; readonly command: EditorViewCommand }
24
+ | { readonly type: 'save' }
25
+ | { readonly type: 'escape' };
26
+
27
+ const MOVES = new Set<string>(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown']);
28
+
29
+ function command(name: EditorCommand): KeyAction {
30
+ return { type: 'command', command: name };
31
+ }
32
+
33
+ /* What each key of the table does in the editor. A key of the table that is not here is a language command, which the host answers. */
34
+ const ACTIONS: Partial<Record<KeymapId, KeyAction>> = {
35
+ undo: command('undo'),
36
+ redo: command('redo'),
37
+ selectAll: command('selectAll'),
38
+ save: { type: 'save' },
39
+ duplicateLine: command('duplicateLine'),
40
+ deleteLine: command('deleteLine'),
41
+ moveLineUp: command('moveLineUp'),
42
+ moveLineDown: command('moveLineDown'),
43
+ toggleLineComment: command('toggleLineComment'),
44
+ toggleBlockComment: command('toggleBlockComment'),
45
+ joinLines: command('joinLines'),
46
+ startNewLine: command('startNewLine'),
47
+ startNewLineBefore: command('startNewLineBefore'),
48
+ splitLine: command('splitLine'),
49
+ toggleCase: command('toggleCase'),
50
+ autoIndentLines: command('autoIndentLines'),
51
+ expandSelection: command('expandSelection'),
52
+ shrinkSelection: command('shrinkSelection'),
53
+ matchBrace: { type: 'bracket' },
54
+ selectNextOccurrence: command('selectNextOccurrence'),
55
+ unselectOccurrence: command('unselectOccurrence'),
56
+ selectAllOccurrences: command('selectAllOccurrences'),
57
+ addCaretPerSelectedLine: command('addCaretPerSelectedLine'),
58
+ wordLeft: command('wordLeft'),
59
+ wordRight: command('wordRight'),
60
+ selectWordLeft: command('selectWordLeft'),
61
+ selectWordRight: command('selectWordRight'),
62
+ deleteWordLeft: command('deleteWordLeft'),
63
+ deleteWordRight: command('deleteWordRight'),
64
+ smartHome: command('smartHome'),
65
+ selectSmartHome: command('selectSmartHome'),
66
+ smartEnd: command('smartEnd'),
67
+ selectSmartEnd: command('selectSmartEnd'),
68
+ textStart: { type: 'edge', end: false, extend: false },
69
+ selectTextStart: { type: 'edge', end: false, extend: true },
70
+ textEnd: { type: 'edge', end: true, extend: false },
71
+ selectTextEnd: { type: 'edge', end: true, extend: true },
72
+ collapse: { type: 'view', command: 'collapseRegion' },
73
+ expand: { type: 'view', command: 'expandRegion' },
74
+ collapseAll: { type: 'view', command: 'collapseAllRegions' },
75
+ expandAll: { type: 'view', command: 'expandAllRegions' },
76
+ collapseRecursively: { type: 'view', command: 'collapseRegionRecursively' },
77
+ expandRecursively: { type: 'view', command: 'expandRegionRecursively' },
78
+ foldSelection: { type: 'view', command: 'foldSelection' },
79
+ collapseDocComments: { type: 'view', command: 'collapseDocComments' },
80
+ expandDocComments: { type: 'view', command: 'expandDocComments' },
81
+ expandAllToLevel1: { type: 'view', command: 'expandAllToLevel1' },
82
+ expandAllToLevel2: { type: 'view', command: 'expandAllToLevel2' },
83
+ expandAllToLevel3: { type: 'view', command: 'expandAllToLevel3' },
84
+ expandAllToLevel4: { type: 'view', command: 'expandAllToLevel4' },
85
+ expandAllToLevel5: { type: 'view', command: 'expandAllToLevel5' },
86
+ toggleColumnMode: { type: 'view', command: 'toggleColumnMode' }
87
+ };
88
+
89
+ const PUNCTUATION_CODES: Readonly<Record<string, string>> = {
90
+ ',': 'Comma',
91
+ '.': 'Period',
92
+ '/': 'Slash',
93
+ ';': 'Semicolon',
94
+ "'": 'Quote',
95
+ '`': 'Backquote',
96
+ '\\': 'Backslash',
97
+ '[': 'BracketLeft',
98
+ ']': 'BracketRight'
99
+ };
100
+
101
+ /* The fold keys sit on the minus and the equals key, which Shift and Option turn into other characters. */
102
+ const FOLD_CODES: Readonly<Record<string, readonly string[]>> = { '-': ['Minus', 'NumpadSubtract'], '=': ['Equal', 'NumpadAdd'] };
103
+
104
+ /* Letters and punctuation by the physical key when the event has one, since Option changes the character on macOS and Shift on every platform. */
105
+ function keyMatches(chordKey: string, event: KeyLike): boolean {
106
+ const code = event.code;
107
+ if (/^[A-Z]$/.test(chordKey)) {
108
+ return code ? code === `Key${chordKey}` : event.key.toUpperCase() === chordKey;
109
+ }
110
+ if (/^[0-9]$/.test(chordKey)) {
111
+ return code ? code === `Digit${chordKey}` || code === `Numpad${chordKey}` : event.key === chordKey;
112
+ }
113
+ const fold = FOLD_CODES[chordKey];
114
+ if (fold !== undefined) {
115
+ return (code !== undefined && fold.includes(code)) || event.key === chordKey || (chordKey === '=' && event.key === '+');
116
+ }
117
+ const punctuation = PUNCTUATION_CODES[chordKey];
118
+ if (punctuation !== undefined) {
119
+ return code ? code === punctuation : event.key === chordKey;
120
+ }
121
+ if (chordKey === 'Space') {
122
+ return event.key === ' ' || code === 'Space';
123
+ }
124
+ return event.key === chordKey;
125
+ }
126
+
127
+ /* Whether an event is a chord, with `mod` as Cmd on macOS and Ctrl elsewhere and every modifier the chord does not name up. */
128
+ export function chordMatches(chord: KeyChord, event: KeyLike, apple: boolean): boolean {
129
+ const wantsMeta = chord.meta || (chord.mod && apple);
130
+ const wantsCtrl = chord.ctrl || (chord.mod && !apple);
131
+ return (
132
+ wantsMeta === event.metaKey &&
133
+ wantsCtrl === event.ctrlKey &&
134
+ chord.alt === event.altKey &&
135
+ chord.shift === event.shiftKey &&
136
+ keyMatches(chord.key, event)
137
+ );
138
+ }
139
+
140
+ interface BoundAction {
141
+ readonly chord: KeyChord;
142
+ readonly action: KeyAction;
143
+ }
144
+
145
+ const bindings = new WeakMap<Keymap, Record<'mac' | 'other', readonly BoundAction[]>>();
146
+
147
+ function bound(apple: boolean, keymap: Keymap): BoundAction[] {
148
+ return KEYMAP_IDS.flatMap((id) => {
149
+ const text = chordOf(id, apple, keymap);
150
+ const action = ACTIONS[id];
151
+ return text === null || action === undefined ? [] : [{ chord: parseChord(text), action }];
152
+ });
153
+ }
154
+
155
+ /*
156
+ * What a key does in the editor, or null when it is not the editor's, which leaves it to the page and
157
+ * to the textarea. Movement of the caret is the view's, since it depends on how the lines are drawn.
158
+ */
159
+ export function keyAction(event: KeyLike, apple: boolean, keymap: Keymap = KEYMAP): KeyAction | null {
160
+ let resolved = bindings.get(keymap);
161
+ if (resolved === undefined) {
162
+ resolved = { mac: bound(true, keymap), other: bound(false, keymap) };
163
+ bindings.set(keymap, resolved);
164
+ }
165
+ for (const { chord, action } of resolved[apple ? 'mac' : 'other']) {
166
+ if (chordMatches(chord, event, apple)) {
167
+ return action;
168
+ }
169
+ }
170
+ const key = event.key.toLowerCase();
171
+ const mod = apple ? event.metaKey : event.ctrlKey;
172
+ const { altKey: alt, shiftKey: shift, ctrlKey: ctrl, metaKey: meta } = event;
173
+ if (mod && !alt && (key === 'home' || key === 'end')) {
174
+ return { type: 'edge', end: key === 'end', extend: shift };
175
+ }
176
+ if (mod || alt || ctrl || meta) {
177
+ return null;
178
+ }
179
+ if (key === 'home') {
180
+ return command(shift ? 'selectSmartHome' : 'smartHome');
181
+ }
182
+ if (key === 'end') {
183
+ return command(shift ? 'selectSmartEnd' : 'smartEnd');
184
+ }
185
+ if (key === 'tab') {
186
+ return command(shift ? 'outdent' : 'insertTab');
187
+ }
188
+ if (key === 'enter') {
189
+ return command('insertNewline');
190
+ }
191
+ if (key === 'backspace') {
192
+ return command('smartBackspace');
193
+ }
194
+ if (key === 'delete') {
195
+ return command('deleteForward');
196
+ }
197
+ if (MOVES.has(event.key)) {
198
+ return { type: 'move', key: event.key as MoveKey, extend: shift };
199
+ }
200
+ if (key === 'escape') {
201
+ return { type: 'escape' };
202
+ }
203
+ return null;
204
+ }