svedit 0.11.1 → 0.13.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 (93) hide show
  1. package/README.md +260 -82
  2. package/dist/Command.svelte.d.ts +54 -29
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +286 -283
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +40 -9
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +142 -18
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +60 -12
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +85 -116
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +543 -571
  32. package/dist/Svedit.svelte +243 -216
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  36. package/dist/TextProperty.svelte.d.ts +5 -0
  37. package/dist/TextProperty.svelte.d.ts.map +1 -0
  38. package/dist/Transaction.svelte.d.ts +101 -126
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +843 -827
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +109 -122
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +726 -698
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -18
  50. package/dist/node_visibility.svelte.d.ts +132 -5
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +535 -642
  53. package/dist/paste_utils.d.ts +11 -43
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -149
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +126 -152
  59. package/dist/types.d.ts +329 -291
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +79 -95
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +392 -305
  65. package/package.json +69 -67
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +40 -9
  70. package/src/lib/NodeArrayProperty.svelte +142 -18
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +60 -12
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/Session.svelte.ts +656 -0
  76. package/src/lib/Svedit.svelte +243 -216
  77. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  78. package/src/lib/Transaction.svelte.ts +1020 -0
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/doc_utils.ts +1120 -0
  81. package/src/lib/{index.js → index.ts} +48 -1
  82. package/src/lib/node_visibility.svelte.ts +658 -0
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
  85. package/src/lib/{types.d.ts → types.ts} +250 -103
  86. package/src/lib/utils.ts +660 -0
  87. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  88. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
  89. package/src/lib/Session.svelte.js +0 -575
  90. package/src/lib/Transaction.svelte.js +0 -846
  91. package/src/lib/doc_utils.js +0 -807
  92. package/src/lib/node_visibility.svelte.js +0 -718
  93. package/src/lib/utils.js +0 -465
package/package.json CHANGED
@@ -1,68 +1,70 @@
1
1
  {
2
- "name": "svedit",
3
- "version": "0.11.1",
4
- "license": "MIT",
5
- "scripts": {
6
- "dev": "vite dev",
7
- "build": "vite build",
8
- "preview": "vite preview",
9
- "prepare": "svelte-kit sync || echo ''",
10
- "prepack": "svelte-kit sync && svelte-package && publint",
11
- "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
12
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
13
- "format": "prettier --write .",
14
- "lint": "prettier --check . && eslint .",
15
- "test:unit": "vitest",
16
- "test": "npm run test:unit -- --run"
17
- },
18
- "files": [
19
- "dist",
20
- "!dist/**/*.test.*",
21
- "!dist/**/*.spec.*",
22
- "src/lib",
23
- "!src/lib/**/*.test.*",
24
- "!src/lib/**/*.spec.*"
25
- ],
26
- "sideEffects": [
27
- "**/*.css"
28
- ],
29
- "svelte": "./dist/index.js",
30
- "types": "./dist/index.d.ts",
31
- "type": "module",
32
- "exports": {
33
- ".": {
34
- "types": "./dist/index.d.ts",
35
- "svelte": "./dist/index.js"
36
- }
37
- },
38
- "peerDependencies": {
39
- "svelte": "^5.0.0"
40
- },
41
- "devDependencies": {
42
- "@eslint/compat": "^2.0.3",
43
- "@eslint/js": "^10.0.1",
44
- "@sveltejs/adapter-auto": "^7.0.0",
45
- "@sveltejs/kit": "^2.55.0",
46
- "@sveltejs/package": "^2.5.7",
47
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
48
- "@types/node": "^25.6.0",
49
- "@typescript-eslint/eslint-plugin": "^8.57.0",
50
- "@typescript-eslint/parser": "^8.57.0",
51
- "@vitest/browser": "^4.1.0",
52
- "@vitest/browser-preview": "^4.1.0",
53
- "eslint": "^10.0.3",
54
- "eslint-config-prettier": "^10.1.8",
55
- "eslint-plugin-svelte": "^3.15.1",
56
- "globals": "^17.4.0",
57
- "nanoid": "^5.1.6",
58
- "prettier": "^3.8.1",
59
- "prettier-plugin-svelte": "^3.5.1",
60
- "publint": "^0.3.18",
61
- "svelte": "^5.53.12",
62
- "svelte-check": "^4.4.5",
63
- "typescript": "^5.9.3",
64
- "vite": "^8.0.0",
65
- "vitest": "^4.1.0",
66
- "vitest-browser-svelte": "^2.1.0"
67
- }
68
- }
2
+ "name": "svedit",
3
+ "version": "0.13.0",
4
+ "license": "MIT",
5
+ "files": [
6
+ "dist",
7
+ "!dist/**/*.test.*",
8
+ "!dist/**/*.spec.*",
9
+ "src/lib",
10
+ "!src/lib/**/*.test.*",
11
+ "!src/lib/**/*.spec.*"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.css"
15
+ ],
16
+ "svelte": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "svelte": "./dist/index.js"
23
+ }
24
+ },
25
+ "peerDependencies": {
26
+ "svelte": "^5.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/compat": "^2.0.3",
30
+ "@eslint/js": "^10.0.1",
31
+ "@sveltejs/adapter-auto": "^7.0.0",
32
+ "@sveltejs/kit": "^2.55.0",
33
+ "@sveltejs/package": "^2.5.7",
34
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
35
+ "@types/node": "^25.6.0",
36
+ "@typescript-eslint/eslint-plugin": "^8.57.0",
37
+ "@typescript-eslint/parser": "^8.57.0",
38
+ "@vitest/browser": "^4.1.0",
39
+ "@vitest/browser-preview": "^4.1.0",
40
+ "eslint": "^10.0.3",
41
+ "eslint-config-prettier": "^10.1.8",
42
+ "eslint-plugin-svelte": "^3.15.1",
43
+ "globals": "^17.4.0",
44
+ "nanoid": "^5.1.6",
45
+ "prettier": "^3.8.1",
46
+ "prettier-plugin-svelte": "^3.5.1",
47
+ "publint": "^0.3.18",
48
+ "svelte": "^5.53.12",
49
+ "svelte-check": "^4.4.5",
50
+ "typescript": "^5.9.3",
51
+ "vite": "^8.0.0",
52
+ "vitest": "^4.1.0",
53
+ "vitest-browser-svelte": "^2.1.0"
54
+ },
55
+ "allowScripts": {
56
+ "fsevents@2.3.3": true
57
+ },
58
+ "scripts": {
59
+ "dev": "vite dev",
60
+ "build": "vite build",
61
+ "preview": "vite preview",
62
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
63
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
64
+ "check:strict": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.strict.json",
65
+ "format": "prettier --write .",
66
+ "lint": "prettier --check . && eslint .",
67
+ "test:unit": "vitest",
68
+ "test": "pnpm test:unit --run"
69
+ }
70
+ }
@@ -1,6 +1,16 @@
1
1
  import { insert_default_node, break_text_node } from './transforms.svelte.js';
2
- import { can_switch_annotation_type } from './doc_utils.js';
2
+ import { can_switch_mark_type, get_selected_range_types } from './doc_utils.js';
3
3
  import { is_selection_collapsed, get_char_length, char_slice } from './utils.js';
4
+ import type Session from './Session.svelte.js';
5
+
6
+ // Commands operate on sessions with arbitrary application schemas.
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ type CommandSession = Session<any>;
9
+
10
+ export type CommandContext = {
11
+ session: CommandSession;
12
+ editable: boolean;
13
+ };
4
14
 
5
15
  /**
6
16
  * Base class for commands that can be executed in response to user actions
@@ -25,6 +35,8 @@ import { is_selection_collapsed, get_char_length, char_slice } from './utils.js'
25
35
  * ```
26
36
  */
27
37
  export default class Command {
38
+ context: CommandContext;
39
+
28
40
  /**
29
41
  * Derived state that indicates if the command is disabled.
30
42
  * Automatically computed from is_enabled().
@@ -34,19 +46,17 @@ export default class Command {
34
46
  /**
35
47
  * Creates a new Command instance.
36
48
  *
37
- * @param {any} context - The context object providing access to application state
49
+ * @param context - The context object providing access to application state
38
50
  */
39
- constructor(context) {
51
+ constructor(context: CommandContext) {
40
52
  this.context = context;
41
53
  }
42
54
 
43
55
  /**
44
56
  * Determines if the command can currently be executed.
45
57
  * Override this method to implement command-specific logic.
46
- *
47
- * @returns {boolean} true if the command can be executed, false otherwise
48
58
  */
49
- is_enabled() {
59
+ is_enabled(): boolean {
50
60
  return true;
51
61
  }
52
62
 
@@ -54,10 +64,8 @@ export default class Command {
54
64
  * Executes the command.
55
65
  * Override this method to implement the command's behavior.
56
66
  * Can be async for commands that need to perform asynchronous operations.
57
- *
58
- * @returns {void | Promise<void>}
59
67
  */
60
- execute() {
68
+ execute(): void | Promise<void> {
61
69
  throw new Error('Not implemented');
62
70
  }
63
71
  }
@@ -94,7 +102,7 @@ export class RedoCommand extends Command {
94
102
  */
95
103
  export class SelectParentCommand extends Command {
96
104
  is_enabled() {
97
- return (
105
+ return Boolean(
98
106
  this.context.editable &&
99
107
  this.context.session.selection &&
100
108
  this.context.session.selection.path.length > 3
@@ -107,11 +115,16 @@ export class SelectParentCommand extends Command {
107
115
  }
108
116
 
109
117
  /**
110
- * Generic command that toggles an annotation on the current text selection.
111
- * Used for simple annotations like bold, italic, highlight, etc.
118
+ * Generic command that toggles a mark on the current text or node selection.
119
+ *
120
+ * Marks are mutually exclusive, so all touched marks compete: same-type marks
121
+ * are removed, a single touched property-less mark may switch type, and mixed
122
+ * touched types disable the command. Annotations never affect mark toggling.
112
123
  */
113
- export class ToggleAnnotationCommand extends Command {
114
- constructor(node_type, context) {
124
+ export class ToggleMarkCommand extends Command {
125
+ node_type: string;
126
+
127
+ constructor(node_type: string, context: CommandContext) {
115
128
  super(context);
116
129
  this.node_type = node_type;
117
130
  }
@@ -119,34 +132,90 @@ export class ToggleAnnotationCommand extends Command {
119
132
  active = $derived(this.is_active());
120
133
 
121
134
  is_active() {
122
- return this.context.session.active_annotation(this.node_type);
135
+ const selected_marks = this.context.session.selected_marks;
136
+ return (
137
+ selected_marks.length > 0 && selected_marks.every(({ node }) => node?.type === this.node_type)
138
+ );
123
139
  }
124
140
 
125
141
  is_enabled() {
126
142
  const { session, editable } = this.context;
127
- const active_annotation = session.active_annotation();
128
- const has_annotation = session.active_annotation(this.node_type);
129
- const can_switch_annotation =
130
- active_annotation &&
131
- !has_annotation &&
132
- session.available_annotation_types.includes(this.node_type) &&
133
- can_switch_annotation_type(
134
- session.schema,
135
- session.get(active_annotation.node_id),
136
- this.node_type
137
- );
138
- const no_annotation_and_caret_not_collapsed =
139
- !active_annotation && !is_selection_collapsed(session.selection);
143
+ const selection = session.selection;
144
+ const is_valid_selection =
145
+ selection?.type === 'text' ||
146
+ (selection?.type === 'node' && !is_selection_collapsed(selection));
147
+ const mark_type_is_allowed = session.available_mark_types.includes(this.node_type);
148
+ const selected_marks = session.selected_marks;
149
+ const selected_mark_types = get_selected_range_types(selected_marks);
150
+
151
+ if (!editable || !is_valid_selection || !mark_type_is_allowed) return false;
152
+ if (selected_mark_types.size > 1) return false;
153
+
154
+ if (selected_marks.length === 0) {
155
+ return Boolean(selection && !is_selection_collapsed(selection));
156
+ }
140
157
 
141
- return (
142
- editable &&
143
- session.selection?.type === 'text' &&
144
- (has_annotation || can_switch_annotation || no_annotation_and_caret_not_collapsed)
158
+ const first_selected_mark = selected_marks[0];
159
+ const selected_mark_type = first_selected_mark.node.type;
160
+ if (selected_mark_type === this.node_type) return true;
161
+ if (selected_marks.length !== 1) return false;
162
+
163
+ return can_switch_mark_type(session.schema, selected_mark_type, this.node_type);
164
+ }
165
+
166
+ execute() {
167
+ this.context.session.apply(this.context.session.tr.toggle_mark(this.node_type));
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Generic command that toggles an annotation on the current text or node selection.
173
+ *
174
+ * Annotations only compete with touched annotations of the same type: touched
175
+ * same-type annotations are removed, otherwise a new annotation is created.
176
+ * Marks and other annotation types never block the toggle.
177
+ */
178
+ export class ToggleAnnotationCommand extends Command {
179
+ node_type: string;
180
+
181
+ constructor(node_type: string, context: CommandContext) {
182
+ super(context);
183
+ this.node_type = node_type;
184
+ }
185
+
186
+ active = $derived(this.is_active());
187
+
188
+ // Mirrors the filter in tr.toggle_annotation, so active/enabled match what
189
+ // execute() does.
190
+ relevant_annotations() {
191
+ return this.context.session.selected_annotations.filter(
192
+ ({ node }) => node?.type === this.node_type
145
193
  );
146
194
  }
147
195
 
196
+ is_active() {
197
+ return this.relevant_annotations().length > 0;
198
+ }
199
+
200
+ is_enabled() {
201
+ const { session, editable } = this.context;
202
+ const selection = session.selection;
203
+ const is_valid_selection =
204
+ selection?.type === 'text' ||
205
+ (selection?.type === 'node' && !is_selection_collapsed(selection));
206
+ const annotation_type_is_allowed = session.available_annotation_types.includes(this.node_type);
207
+
208
+ if (!editable || !is_valid_selection || !annotation_type_is_allowed) return false;
209
+
210
+ // Removing touched same-type annotations also works from a collapsed
211
+ // caret inside the annotation; creating a new one requires an expanded
212
+ // selection.
213
+ if (this.relevant_annotations().length > 0) return true;
214
+ return Boolean(selection && !is_selection_collapsed(selection));
215
+ }
216
+
148
217
  execute() {
149
- this.context.session.apply(this.context.session.tr.annotate_text(this.node_type));
218
+ this.context.session.apply(this.context.session.tr.toggle_annotation(this.node_type));
150
219
  }
151
220
  }
152
221
 
@@ -176,10 +245,11 @@ export class AddNewLineCommand extends Command {
176
245
  if (selection.anchor_offset !== selection.focus_offset) {
177
246
  tr.delete_selection();
178
247
  }
248
+ if (tr.selection.type !== 'text') return;
179
249
 
180
250
  const collapsed_offset = tr.selection.anchor_offset;
181
251
  const content = tr.get(tr.selection.path);
182
- const text_before_caret = char_slice(content.text, 0, collapsed_offset);
252
+ const text_before_caret = char_slice(content.content, 0, collapsed_offset);
183
253
  const line_start_index = text_before_caret.lastIndexOf('\n') + 1;
184
254
  const current_line_prefix = text_before_caret.slice(line_start_index);
185
255
  const indentation_match = current_line_prefix.match(/^[\t ]*/);
@@ -222,7 +292,7 @@ export class BreakTextNodeCommand extends Command {
222
292
  */
223
293
  export class SelectAllCommand extends Command {
224
294
  is_enabled() {
225
- return this.context.editable && this.context.session.selection;
295
+ return Boolean(this.context.editable && this.context.session.selection);
226
296
  }
227
297
 
228
298
  execute() {
@@ -235,7 +305,7 @@ export class SelectAllCommand extends Command {
235
305
 
236
306
  if (selection.type === 'text') {
237
307
  const text_content = session.get(selection.path);
238
- const text_length = get_char_length(text_content.text);
308
+ const text_length = get_char_length(text_content.content);
239
309
 
240
310
  // Check if all text is already selected
241
311
  const is_all_text_selected =
@@ -260,7 +330,7 @@ export class SelectAllCommand extends Command {
260
330
  session.inspect(node_path.slice(0, -1))?.type === 'node_array';
261
331
 
262
332
  if (is_inside_node_array) {
263
- const node_index = parseInt(node_path.at(-1));
333
+ const node_index = node_path.at(-1) as number;
264
334
  session.selection = {
265
335
  type: 'node',
266
336
  path: node_path.slice(0, -1),
@@ -278,7 +348,7 @@ export class SelectAllCommand extends Command {
278
348
  // Check if the entire node_array is already selected
279
349
  const is_entire_node_array_selected =
280
350
  Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
281
- Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
351
+ Math.max(selection.anchor_offset, selection.focus_offset) === node_array.nodes.length;
282
352
 
283
353
  if (!is_entire_node_array_selected) {
284
354
  // Select the entire node_array
@@ -286,7 +356,7 @@ export class SelectAllCommand extends Command {
286
356
  type: 'node',
287
357
  path: node_array_path,
288
358
  anchor_offset: 0,
289
- focus_offset: node_array.length
359
+ focus_offset: node_array.nodes.length
290
360
  };
291
361
  } else {
292
362
  // Entire node_array is selected, try to move up to parent node_array
@@ -298,7 +368,7 @@ export class SelectAllCommand extends Command {
298
368
  session.inspect(parent_path.slice(0, -1))?.type === 'node_array';
299
369
 
300
370
  if (is_parent_node_array) {
301
- const parent_node_index = parseInt(parent_path.at(-1));
371
+ const parent_node_index = parent_path.at(-1) as number;
302
372
  session.selection = {
303
373
  type: 'node',
304
374
  path: parent_path.slice(0, -1),
@@ -318,7 +388,7 @@ export class SelectAllCommand extends Command {
318
388
  const is_inside_node_array = session.inspect(node_path.slice(0, -1))?.type === 'node_array';
319
389
 
320
390
  if (is_inside_node_array) {
321
- const node_index = parseInt(node_path.at(-1));
391
+ const node_index = node_path.at(-1) as number;
322
392
  session.selection = {
323
393
  type: 'node',
324
394
  path: node_path.slice(0, -1),
@@ -1,10 +1,8 @@
1
- <script>
1
+ <script lang="ts">
2
2
  import { serialize_path } from './utils.js';
3
+ import type { CustomPropertyProps } from './types.js';
3
4
 
4
- /** @import { CustomPropertyProps } from './types.d.ts'; */
5
-
6
- /** @type {CustomPropertyProps} */
7
- let { path, tag = 'div', class: css_class, children, style, ...rest } = $props();
5
+ let { path, tag = 'div', class: css_class, children, style, ...rest }: CustomPropertyProps = $props();
8
6
  let path_str = $derived(serialize_path(path));
9
7
 
10
8
  </script>
@@ -1,20 +1,30 @@
1
1
  import { is_virtual_keyboard_active } from './utils.js';
2
2
 
3
- const MODIFIER_KEYS = ['meta', 'ctrl', 'alt', 'shift'];
4
- const MODIFIER_EVENT_KEYS = {
3
+ const MODIFIER_KEYS: readonly string[] = ['meta', 'ctrl', 'alt', 'shift'];
4
+ const MODIFIER_EVENT_KEYS: Record<string, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'> = {
5
5
  meta: 'metaKey',
6
6
  ctrl: 'ctrlKey',
7
7
  alt: 'altKey',
8
8
  shift: 'shiftKey'
9
9
  };
10
10
 
11
+ /**
12
+ * A command as required by the key mapper: anything with is_enabled/execute.
13
+ */
14
+ type KeymapCommand = { is_enabled: () => boolean; execute: () => unknown };
15
+
16
+ /**
17
+ * A keymap maps key combo strings to arrays of commands.
18
+ */
19
+ export type Keymap = Record<string, KeymapCommand[]>;
20
+
11
21
  /**
12
22
  * Validates and defines a keymap.
13
23
  * Throws an error if any key combo is invalid.
14
24
  * Valid formats: 'meta+e,ctrl+e' or 'meta+shift+a'
15
25
  * Invalid: 'meta+e+a' (only one non-modifier key allowed)
16
26
  */
17
- export function define_keymap(keymap) {
27
+ export function define_keymap<K extends Keymap>(keymap: K): K {
18
28
  for (const [key_combo] of Object.entries(keymap)) {
19
29
  const alternatives = key_combo.split(',');
20
30
 
@@ -36,7 +46,11 @@ export function define_keymap(keymap) {
36
46
  * Matches a keyboard event against a key combo string.
37
47
  * Example: 'meta+e,ctrl+e' matches either (metaKey && key==='e') OR (ctrlKey && key==='e')
38
48
  */
39
- function matches_key_combo(key_combo, event, virtual_keyboard_active = false) {
49
+ function matches_key_combo(
50
+ key_combo: string,
51
+ event: KeyboardEvent,
52
+ virtual_keyboard_active = false
53
+ ): boolean {
40
54
  const alternatives = key_combo.split(',');
41
55
 
42
56
  return alternatives.some((alternative) => {
@@ -57,9 +71,9 @@ function matches_key_combo(key_combo, event, virtual_keyboard_active = false) {
57
71
  const no_extra_modifiers = virtual_keyboard_active
58
72
  ? true
59
73
  : MODIFIER_KEYS.every((mod) => {
60
- if (modifiers.includes(mod)) return true; // This modifier is expected
61
- return !event[MODIFIER_EVENT_KEYS[mod]]; // This modifier should NOT be pressed
62
- });
74
+ if (modifiers.includes(mod)) return true; // This modifier is expected
75
+ return !event[MODIFIER_EVENT_KEYS[mod]]; // This modifier should NOT be pressed
76
+ });
63
77
 
64
78
  // Check if the key matches
65
79
  const key_matches = event.key.toLowerCase() === non_modifier;
@@ -74,7 +88,7 @@ function matches_key_combo(key_combo, event, virtual_keyboard_active = false) {
74
88
  * Supports both sync and async commands. For async commands, errors are logged
75
89
  * but don't crash the application.
76
90
  */
77
- function handle_key_map(key_map, event) {
91
+ function handle_key_map(key_map: Keymap, event: KeyboardEvent): boolean {
78
92
  const virtual_keyboard_active = is_virtual_keyboard_active();
79
93
 
80
94
  for (const [key_combo, commands] of Object.entries(key_map)) {
@@ -114,6 +128,9 @@ function handle_key_map(key_map, event) {
114
128
  * mapper.pop_scope(); // When editor loses focus
115
129
  */
116
130
  export class KeyMapper {
131
+ scope_stack: Keymap[];
132
+ skip_onkeydown: boolean;
133
+
117
134
  constructor() {
118
135
  this.scope_stack = [];
119
136
  this.skip_onkeydown = false;
@@ -122,7 +139,7 @@ export class KeyMapper {
122
139
  /**
123
140
  * Push a new scope onto the stack (becomes highest priority)
124
141
  */
125
- push_scope(keymap) {
142
+ push_scope(keymap: Keymap): void {
126
143
  // console.log('pushed keymap', keymap);
127
144
  this.scope_stack.push(keymap);
128
145
  }
@@ -130,7 +147,7 @@ export class KeyMapper {
130
147
  /**
131
148
  * Pop the most recent scope from the stack
132
149
  */
133
- pop_scope() {
150
+ pop_scope(): Keymap | undefined {
134
151
  const keymap = this.scope_stack.pop();
135
152
  // console.log('popped keymap', keymap);
136
153
  return keymap;
@@ -139,7 +156,7 @@ export class KeyMapper {
139
156
  /**
140
157
  * Handle keyboard event by trying scopes from top to bottom
141
158
  */
142
- handle_keydown(event) {
159
+ handle_keydown(event: KeyboardEvent): void {
143
160
  // Key handling temporarily disabled (e.g. while character composition takes place)
144
161
  if (this.skip_onkeydown) return;
145
162
  // console.log('KeyMapper.handle_keydown', event);
@@ -1,32 +1,63 @@
1
- <script>
1
+ <script lang="ts">
2
2
  import { getContext } from 'svelte';
3
3
  import { serialize_path } from './utils.js';
4
+ import type {
5
+ NodeArrayAttachmentContext,
6
+ NodeArrayRenderContext,
7
+ NodeProps,
8
+ SveditRenderContext
9
+ } from './types.js';
4
10
 
5
- /** @import { NodeProps } from './types.d.ts'; */
11
+ const svedit = getContext<SveditRenderContext>('svedit');
6
12
 
7
- const svedit = getContext('svedit');
8
-
9
- /** @type {NodeProps} */
10
- let { path, children, tag = 'div', class: css_class, style = '', ...rest } = $props();
13
+ let { path, children, tag = 'div', class: css_class, style = '', ...rest }: NodeProps = $props();
11
14
 
12
15
  let node = $derived(svedit.session.get(path));
13
16
  let path_str = $derived(serialize_path(path));
14
17
 
15
- const node_array_meta = getContext('node_array_meta');
16
- let child_index = $derived(node_array_meta ? parseInt(String(path.at(-1)), 10) : -1);
18
+ const node_array_meta = getContext<NodeArrayRenderContext | undefined>('node_array_meta');
19
+ let child_index = $derived(node_array_meta ? (path.at(-1) as number) : -1);
17
20
  let is_first = $derived(node_array_meta && child_index === 0);
18
21
  let is_last = $derived(node_array_meta && child_index === node_array_meta.length - 1);
22
+
23
+ // Marks and annotations covering this node become classes automatically:
24
+ // `mark-<type>`/`anno-<type>`, plus `-start`/`-end` variants on the run's
25
+ // first/last covered node. This allows styling ranges (including
26
+ // annotations, which never render wrapper components) with pure CSS. For
27
+ // richer rendering, node components still receive the `mark` and
28
+ // `annotations` props.
29
+ let range_classes = $derived.by(() => {
30
+ if (child_index < 0 || !node_array_meta?.annotations_for) return '';
31
+ const classes: string[] = [];
32
+
33
+ const add_classes = (range: NodeArrayAttachmentContext | null | undefined, prefix: string) => {
34
+ const range_type = range?.node?.type;
35
+ if (!range_type) return;
36
+ classes.push(`${prefix}-${range_type}`);
37
+ if (range.is_start) classes.push(`${prefix}-${range_type}-start`);
38
+ if (range.is_end) classes.push(`${prefix}-${range_type}-end`);
39
+ };
40
+
41
+ add_classes(node_array_meta.mark_for?.(child_index), 'mark');
42
+ for (const annotation of node_array_meta.annotations_for(child_index)) {
43
+ add_classes(annotation, 'anno');
44
+ }
45
+ return classes.join(' ');
46
+ });
19
47
  </script>
20
48
 
21
49
  <svelte:element
22
50
  this={tag}
23
51
  id={node.id}
24
- class="node-{node.type} {css_class}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
52
+ class="node-{node.type} {css_class}{range_classes ? ` ${range_classes}` : ''}{is_first
53
+ ? ' first'
54
+ : ''}{is_last ? ' last' : ''}"
25
55
  data-node-id={node.id}
26
56
  data-path={path_str}
27
57
  data-type="node"
28
58
  style="anchor-name: --{path_str};{style}"
29
59
  {...rest}
60
+ {@attach svedit.visibility_registry.track_node(path_str)}
30
61
  >
31
62
  {@render children()}
32
63
  </svelte:element>