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/dist/doc_utils.js CHANGED
@@ -3,91 +3,67 @@
3
3
  *
4
4
  * These functions operate on the core document state (schema, doc, selection, config)
5
5
  * without any history management or transaction tracking.
6
- *
7
- * @import { NodeId, DocumentPath, PrimitiveType, NodeProperty, NodeArrayProperty, NodeSchema, DocumentSchema, Selection, Annotation, Document, AnnotatedTextProperty, AnnotatedText, ValidateDocumentSchema } from './types'
8
- */
9
-
10
- import {
11
- assert_path_string_segment,
12
- is_path_string_segment_valid,
13
- get_selection_range,
14
- get_char_length,
15
- serialize_path
16
- } from './utils.js';
17
-
6
+ */
7
+ import { assert_path_string_segment, is_path_string_segment_valid, get_selection_range, get_char_length, serialize_path, are_ranges_exclusive } from './utils.js';
18
8
  /**
19
9
  * Identity function — keeps schema at runtime & makes IDE infer types.
20
10
  * Similar to your define_schema pattern but for document schemas.
21
- *
22
- * @template {Record<string, NodeSchema>} S
23
- * @param {S & ValidateDocumentSchema<S>} schema - The document schema to validate
24
- * @returns {S} The same schema, but with type information preserved
25
11
  */
26
12
  export function define_document_schema(schema) {
27
- return schema;
13
+ return schema;
28
14
  }
29
-
30
15
  /**
31
16
  * Check if a string represents a valid primitive type.
32
- * @param {string} type - The type string to check
33
- * @returns {type is PrimitiveType} True if it's a valid primitive type
34
17
  */
35
18
  export function is_primitive_type(type) {
36
- return [
37
- 'string',
38
- 'number',
39
- 'boolean',
40
- 'integer',
41
- 'datetime',
42
- 'annotated_text',
43
- 'string_array',
44
- 'number_array',
45
- 'boolean_array',
46
- 'integer_array'
47
- ].includes(type);
48
- }
49
-
19
+ return [
20
+ 'string',
21
+ 'number',
22
+ 'boolean',
23
+ 'integer',
24
+ 'datetime',
25
+ 'text',
26
+ 'string_array',
27
+ 'number_array',
28
+ 'boolean_array',
29
+ 'integer_array'
30
+ ].includes(type);
31
+ }
50
32
  /**
51
33
  * Get the default node type for a property that references nodes.
52
- * @param {NodeProperty | NodeArrayProperty} property_definition - The property definition
53
- * @returns {string | null} The default node type, or null if none specified
34
+ * Returns null if none specified.
54
35
  */
55
36
  export function get_default_node_type(property_definition) {
56
- if (!property_definition || !property_definition.node_types) {
57
- return null;
58
- }
59
-
60
- return (
61
- property_definition.default_node_type ||
62
- (property_definition.node_types.length === 1 ? property_definition.node_types[0] : null)
63
- );
37
+ if (!property_definition || !property_definition.node_types) {
38
+ return null;
39
+ }
40
+ return (property_definition.default_node_type ||
41
+ (property_definition.node_types.length === 1 ? property_definition.node_types[0] : null));
64
42
  }
65
-
66
- /**
67
- * @param {import('./types').PropertyDefinition} property_definition
68
- * @returns {any}
69
- */
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
44
  export function get_property_default(property_definition) {
71
- if ('default' in property_definition) return structuredClone(property_definition.default);
72
-
73
- if (property_definition.type === 'string') return '';
74
- if (property_definition.type === 'integer') return 0;
75
- if (property_definition.type === 'number') return 0;
76
- if (property_definition.type === 'boolean') return false;
77
- if (property_definition.type === 'annotated_text') return { text: '', annotations: [] };
78
- if (property_definition.type === 'node_array') return [];
79
- if (
80
- property_definition.type === 'string_array' ||
81
- property_definition.type === 'number_array' ||
82
- property_definition.type === 'boolean_array' ||
83
- property_definition.type === 'integer_array'
84
- ) {
85
- return [];
86
- }
87
-
88
- return undefined;
89
- }
90
-
45
+ if ('default' in property_definition)
46
+ return structuredClone(property_definition.default);
47
+ if (property_definition.type === 'string')
48
+ return '';
49
+ if (property_definition.type === 'integer')
50
+ return 0;
51
+ if (property_definition.type === 'number')
52
+ return 0;
53
+ if (property_definition.type === 'boolean')
54
+ return false;
55
+ if (property_definition.type === 'text')
56
+ return { content: '', marks: [], annotations: [] };
57
+ if (property_definition.type === 'node_array')
58
+ return { nodes: [], marks: [], annotations: [] };
59
+ if (property_definition.type === 'string_array' ||
60
+ property_definition.type === 'number_array' ||
61
+ property_definition.type === 'boolean_array' ||
62
+ property_definition.type === 'integer_array') {
63
+ return [];
64
+ }
65
+ return undefined;
66
+ }
91
67
  /**
92
68
  * Fill omitted properties with schema defaults and Svedit's built-in type defaults.
93
69
  *
@@ -96,26 +72,22 @@ export function get_property_default(property_definition) {
96
72
  * schema changes cannot be represented by defaults, such as property renames or
97
73
  * data transformations.
98
74
  *
99
- * @param {any} node - The node to fill defaults for
100
- * @param {DocumentSchema} schema - The document schema
101
- * @returns {any} A shallow copy of the node with cloned default values filled in
75
+ * @returns A shallow copy of the node with cloned default values filled in
102
76
  */
103
77
  export function fill_node_defaults(node, schema) {
104
- const node_schema = schema[node.type];
105
- if (!node_schema) return { ...node };
106
-
107
- const node_with_defaults = { ...node };
108
-
109
- for (const [property_name, property_definition] of Object.entries(node_schema.properties)) {
110
- if (node_with_defaults[property_name] === undefined) {
111
- const property_default = get_property_default(property_definition);
112
- if (property_default !== undefined) node_with_defaults[property_name] = property_default;
113
- }
114
- }
115
-
116
- return node_with_defaults;
117
- }
118
-
78
+ const node_schema = schema[node.type];
79
+ if (!node_schema)
80
+ return { ...node };
81
+ const node_with_defaults = { ...node };
82
+ for (const [property_name, property_definition] of Object.entries(node_schema.properties)) {
83
+ if (node_with_defaults[property_name] === undefined) {
84
+ const property_default = get_property_default(property_definition);
85
+ if (property_default !== undefined)
86
+ node_with_defaults[property_name] = property_default;
87
+ }
88
+ }
89
+ return node_with_defaults;
90
+ }
119
91
  /**
120
92
  * Fill omitted properties with schema defaults and Svedit's built-in type defaults across a document.
121
93
  *
@@ -123,685 +95,741 @@ export function fill_node_defaults(node, schema) {
123
95
  * by itself. Call this as one step in an explicit document migration when it is
124
96
  * appropriate.
125
97
  *
126
- * @param {Document} doc - The document to fill defaults for
127
- * @param {DocumentSchema} schema - The document schema
128
- * @returns {Document} A document copy with cloned default values filled in
98
+ * @returns A document copy with cloned default values filled in
129
99
  */
130
100
  export function fill_document_defaults(doc, schema) {
131
- /** @type {Record<string, any>} */
132
- const nodes = {};
133
-
134
- for (const [node_id, node] of Object.entries(doc.nodes)) {
135
- nodes[node_id] = fill_node_defaults(node, schema);
136
- }
137
-
138
- return {
139
- ...doc,
140
- nodes
141
- };
142
- }
143
-
101
+ const nodes = {};
102
+ for (const [node_id, node] of Object.entries(doc.nodes)) {
103
+ nodes[node_id] = fill_node_defaults(node, schema);
104
+ }
105
+ return {
106
+ ...doc,
107
+ nodes
108
+ };
109
+ }
144
110
  /**
145
111
  * Validate a document schema to ensure all referenced node types exist.
146
- * @param {DocumentSchema} document_schema - The document schema to validate
147
112
  * @throws {Error} Throws if the document schema is invalid
148
113
  */
149
114
  export function validate_document_schema(document_schema) {
150
- // Check that all referenced node types exist
151
- for (const [node_type, node_schema] of Object.entries(document_schema)) {
152
- for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
153
- assert_path_string_segment(prop_name, `Property name "${prop_name}"`);
154
- if (prop_def.type === 'node' || prop_def.type === 'node_array') {
155
- const missing_types = prop_def.node_types.filter(
156
- (ref_type) => !(ref_type in document_schema)
157
- );
158
- if (missing_types.length > 0) {
159
- throw new Error(
160
- `Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}`
161
- );
162
- }
163
- }
164
- }
165
- }
166
- }
167
-
115
+ // Check that all referenced node types exist
116
+ for (const [node_type, node_schema] of Object.entries(document_schema)) {
117
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
118
+ assert_path_string_segment(prop_name, `Property name "${prop_name}"`);
119
+ if (prop_def.type === 'string' && prop_def.values !== undefined) {
120
+ if (!Array.isArray(prop_def.values) ||
121
+ prop_def.values.some((value) => typeof value !== 'string')) {
122
+ throw new Error(`Node type "${node_type}" property "${prop_name}" values must be an array of strings.`);
123
+ }
124
+ if (prop_def.values.length === 0) {
125
+ throw new Error(`Node type "${node_type}" property "${prop_name}" values must not be empty.`);
126
+ }
127
+ if (new Set(prop_def.values).size !== prop_def.values.length) {
128
+ throw new Error(`Node type "${node_type}" property "${prop_name}" values must be unique.`);
129
+ }
130
+ if (prop_def.default !== undefined && !prop_def.values.includes(prop_def.default)) {
131
+ throw new Error(`Node type "${node_type}" property "${prop_name}" default must be one of its allowed values.`);
132
+ }
133
+ }
134
+ if (prop_def.type === 'integer') {
135
+ if (prop_def.default !== undefined && !Number.isInteger(prop_def.default)) {
136
+ throw new Error(`Node type "${node_type}" property "${prop_name}" default must be an integer.`);
137
+ }
138
+ if (prop_def.min !== undefined && !Number.isInteger(prop_def.min)) {
139
+ throw new Error(`Node type "${node_type}" property "${prop_name}" min must be an integer.`);
140
+ }
141
+ if (prop_def.max !== undefined && !Number.isInteger(prop_def.max)) {
142
+ throw new Error(`Node type "${node_type}" property "${prop_name}" max must be an integer.`);
143
+ }
144
+ if (prop_def.min !== undefined &&
145
+ prop_def.max !== undefined &&
146
+ prop_def.min > prop_def.max) {
147
+ throw new Error(`Node type "${node_type}" property "${prop_name}" min must not be greater than max.`);
148
+ }
149
+ if (prop_def.default !== undefined &&
150
+ ((prop_def.min !== undefined && prop_def.default < prop_def.min) ||
151
+ (prop_def.max !== undefined && prop_def.default > prop_def.max))) {
152
+ throw new Error(`Node type "${node_type}" property "${prop_name}" default must be within its min/max range.`);
153
+ }
154
+ }
155
+ if (prop_def.type === 'node' || prop_def.type === 'node_array') {
156
+ const missing_types = prop_def.node_types.filter((ref_type) => !(ref_type in document_schema));
157
+ if (missing_types.length > 0) {
158
+ throw new Error(`Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}`);
159
+ }
160
+ }
161
+ if (prop_def.type === 'text' || prop_def.type === 'node_array') {
162
+ // mark_types must reference kind 'mark', annotation_types kind 'annotation'
163
+ for (const [key, expected_kind] of [
164
+ ['mark_types', 'mark'],
165
+ ['annotation_types', 'annotation']
166
+ ]) {
167
+ const invalid_types = (prop_def[key] ?? []).filter((ref_type) => document_schema[ref_type]?.kind !== expected_kind);
168
+ if (invalid_types.length > 0) {
169
+ throw new Error(`Node type "${node_type}" property "${prop_name}" ${key} must reference node types of kind '${expected_kind}', got: ${invalid_types.join(', ')}.`);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+ /**
177
+ * Validate that no annotation node type has a registered component.
178
+ *
179
+ * Marks render in-place via components; annotations are data-only overlay
180
+ * ranges that must be interpreted by the app (via CSS classes or props).
181
+ *
182
+ * @throws {Error} Throws if a kind 'annotation' type has a registered component
183
+ */
184
+ export function validate_config_components(schema, config) {
185
+ for (const [node_type, node_schema] of Object.entries(schema)) {
186
+ if (node_schema.kind === 'annotation' && config?.node_components?.[node_type]) {
187
+ throw new Error(`Annotation type "${node_type}" must not have a registered component. Annotations are data-only; use kind 'mark' for in-place rendered ranges.`);
188
+ }
189
+ }
190
+ }
168
191
  /**
169
192
  * Validate a primitive value against its schema type.
170
- * @param {PrimitiveType} type - The expected type
171
- * @param {any} value - The value to validate
172
- * @returns {boolean} True if the value matches the type
173
193
  */
194
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
195
  function validate_primitive_value(type, value) {
175
- switch (type) {
176
- case 'string':
177
- return typeof value === 'string';
178
- case 'number':
179
- return typeof value === 'number' && !isNaN(value);
180
- case 'boolean':
181
- return typeof value === 'boolean';
182
- case 'integer':
183
- return Number.isInteger(value);
184
- case 'datetime':
185
- return typeof value === 'string' && !isNaN(Date.parse(value));
186
- case 'annotated_text':
187
- return (
188
- typeof value === 'object' &&
189
- value !== null &&
190
- typeof value.text === 'string' &&
191
- Array.isArray(value.annotations)
192
- );
193
- case 'string_array':
194
- return Array.isArray(value) && value.every((v) => typeof v === 'string');
195
- case 'number_array':
196
- return Array.isArray(value) && value.every((v) => typeof v === 'number' && !isNaN(v));
197
- case 'boolean_array':
198
- return Array.isArray(value) && value.every((v) => typeof v === 'boolean');
199
- case 'integer_array':
200
- return Array.isArray(value) && value.every((v) => Number.isInteger(v));
201
- default:
202
- return false;
203
- }
204
- }
205
-
206
- /**
207
- * Validate annotated text annotations for bounds, references, allowed types, and exclusivity.
196
+ switch (type) {
197
+ case 'string':
198
+ return typeof value === 'string';
199
+ case 'number':
200
+ return typeof value === 'number' && !isNaN(value);
201
+ case 'boolean':
202
+ return typeof value === 'boolean';
203
+ case 'integer':
204
+ return Number.isInteger(value);
205
+ case 'datetime':
206
+ return typeof value === 'string' && !isNaN(Date.parse(value));
207
+ case 'text':
208
+ return (typeof value === 'object' &&
209
+ value !== null &&
210
+ typeof value.content === 'string' &&
211
+ Array.isArray(value.marks) &&
212
+ Array.isArray(value.annotations));
213
+ case 'string_array':
214
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
215
+ case 'number_array':
216
+ return Array.isArray(value) && value.every((v) => typeof v === 'number' && !isNaN(v));
217
+ case 'boolean_array':
218
+ return Array.isArray(value) && value.every((v) => typeof v === 'boolean');
219
+ case 'integer_array':
220
+ return Array.isArray(value) && value.every((v) => Number.isInteger(v));
221
+ default:
222
+ return false;
223
+ }
224
+ }
225
+ /**
226
+ * Validate ranges (marks or annotations) for bounds, references, and allowed types.
227
+ *
228
+ * @throws {Error} Throws if ranges are invalid
229
+ */
230
+ function validate_range_array(node_id, prop_name, label, ranges, container_length, allowed_types, all_nodes, require_references) {
231
+ for (const [index, range] of ranges.entries()) {
232
+ if (typeof range !== 'object' ||
233
+ range === null ||
234
+ !Number.isInteger(range.start_offset) ||
235
+ !Number.isInteger(range.end_offset) ||
236
+ !is_id_valid(range.node_id)) {
237
+ throw new Error(`Node ${node_id} property ${prop_name} has an invalid ${label} at index ${index}. Ranges must have integer start_offset/end_offset and a valid node_id.`);
238
+ }
239
+ if (range.start_offset < 0 || range.end_offset > container_length) {
240
+ throw new Error(`Node ${node_id} property ${prop_name} ${label} ${range.node_id} is out of bounds: ${range.start_offset}-${range.end_offset}, container length is ${container_length}.`);
241
+ }
242
+ if (range.start_offset >= range.end_offset) {
243
+ throw new Error(`Node ${node_id} property ${prop_name} ${label} ${range.node_id} must not be empty or reversed: ${range.start_offset}-${range.end_offset}.`);
244
+ }
245
+ const referenced_node = all_nodes[range.node_id];
246
+ if (!referenced_node) {
247
+ if (require_references) {
248
+ throw new Error(`Node ${node_id} property ${prop_name} ${label} references missing node ${range.node_id}.`);
249
+ }
250
+ continue;
251
+ }
252
+ if (allowed_types?.length && !allowed_types.includes(referenced_node.type)) {
253
+ throw new Error(`Node ${node_id} property ${prop_name} ${label} references node ${range.node_id} of type ${referenced_node.type}, but only types [${allowed_types.join(', ')}] are allowed.`);
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * Validate marks and annotations of an annotated container (text or node array).
259
+ *
260
+ * Marks must be mutually exclusive; annotations may overlap.
261
+ *
262
+ * @throws {Error} Throws if marks or annotations are invalid
263
+ */
264
+ function validate_marks_and_annotations(node_id, prop_name, value, prop_def, container_length, all_nodes, require_references) {
265
+ validate_range_array(node_id, prop_name, 'mark', value.marks, container_length, prop_def.mark_types, all_nodes, require_references);
266
+ if (!are_ranges_exclusive(value.marks, container_length)) {
267
+ throw new Error(`Node ${node_id} property ${prop_name} has overlapping marks. Marks must be mutually exclusive.`);
268
+ }
269
+ validate_range_array(node_id, prop_name, 'annotation', value.annotations, container_length, prop_def.annotation_types, all_nodes, require_references);
270
+ }
271
+ /**
272
+ * Validate text property marks and annotations for bounds, references, allowed
273
+ * types, and mark exclusivity.
208
274
  *
209
- * @param {string} node_id - Owner node id, used for error messages
210
- * @param {string} prop_name - Owner property name, used for error messages
211
- * @param {AnnotatedText} value - Annotated text value to validate
212
- * @param {AnnotatedTextProperty} prop_def - Annotated text property definition
213
- * @param {Record<string, any>} all_nodes - All document nodes
214
- * @param {boolean} require_references - Whether referenced nodes must already exist
215
- * @throws {Error} Throws if annotations are invalid
216
- */
217
- function validate_annotated_text_property(node_id, prop_name, value, prop_def, all_nodes, require_references) {
218
- const char_length = get_char_length(value.text);
219
- const annotations = value.annotations;
220
-
221
- for (const [index, annotation] of annotations.entries()) {
222
- if (
223
- typeof annotation !== 'object' ||
224
- annotation === null ||
225
- !Number.isInteger(annotation.start_offset) ||
226
- !Number.isInteger(annotation.end_offset) ||
227
- !is_id_valid(annotation.node_id)
228
- ) {
229
- throw new Error(
230
- `Node ${node_id} property ${prop_name} has an invalid annotation at index ${index}. Annotations must have integer start_offset/end_offset and a valid node_id.`
231
- );
232
- }
233
-
234
- if (annotation.start_offset < 0 || annotation.end_offset > char_length) {
235
- throw new Error(
236
- `Node ${node_id} property ${prop_name} annotation ${annotation.node_id} is out of bounds: ${annotation.start_offset}-${annotation.end_offset}, text length is ${char_length}.`
237
- );
238
- }
239
-
240
- if (annotation.start_offset >= annotation.end_offset) {
241
- throw new Error(
242
- `Node ${node_id} property ${prop_name} annotation ${annotation.node_id} must not be empty or reversed: ${annotation.start_offset}-${annotation.end_offset}.`
243
- );
244
- }
245
-
246
- const referenced_node = all_nodes[annotation.node_id];
247
- if (!referenced_node) {
248
- if (require_references) {
249
- throw new Error(
250
- `Node ${node_id} property ${prop_name} annotation references missing node ${annotation.node_id}.`
251
- );
252
- }
253
- continue;
254
- }
255
-
256
- if (prop_def.node_types?.length && !prop_def.node_types.includes(referenced_node.type)) {
257
- throw new Error(
258
- `Node ${node_id} property ${prop_name} annotation references node ${annotation.node_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`
259
- );
260
- }
261
- }
262
-
263
- const sorted_annotations = annotations
264
- .map((annotation, index) => ({ annotation, index }))
265
- .sort((a, b) =>
266
- a.annotation.start_offset - b.annotation.start_offset ||
267
- a.annotation.end_offset - b.annotation.end_offset
268
- );
269
-
270
- for (let index = 1; index < sorted_annotations.length; index++) {
271
- const previous = sorted_annotations[index - 1];
272
- const current = sorted_annotations[index];
273
-
274
- if (current.annotation.start_offset < previous.annotation.end_offset) {
275
- throw new Error(
276
- `Node ${node_id} property ${prop_name} has overlapping annotations: annotation index ${previous.index} (${previous.annotation.node_id}, ${previous.annotation.start_offset}-${previous.annotation.end_offset}) overlaps annotation index ${current.index} (${current.annotation.node_id}, ${current.annotation.start_offset}-${current.annotation.end_offset}). Annotations must be exclusive.`
277
- );
278
- }
279
- }
280
- }
281
-
282
- /**
283
- * @param {string} id
284
- * @returns {boolean}
275
+ * @throws {Error} Throws if marks or annotations are invalid
285
276
  */
277
+ function validate_text_property(node_id, prop_name, value, prop_def, all_nodes, require_references) {
278
+ const char_length = get_char_length(value.content);
279
+ validate_marks_and_annotations(node_id, prop_name, value, prop_def, char_length, all_nodes, require_references);
280
+ }
286
281
  export function is_id_valid(id) {
287
- return typeof id === 'string' && id.length > 0 && is_path_string_segment_valid(id);
282
+ return typeof id === 'string' && id.length > 0 && is_path_string_segment_valid(id);
288
283
  }
289
-
290
284
  /**
291
285
  * Validate a node against its schema.
292
- * @param {any} node - The node to validate
293
- * @param {DocumentSchema} schema - The document schema
294
- * @param {Record<string, any>} all_nodes - All nodes in the document to check references
295
- * @param {{ require_references?: boolean }} [options] - Validation options
286
+ *
287
+ * @param node - The node to validate
288
+ * @param schema - The document schema
289
+ * @param all_nodes - All nodes in the document to check references
290
+ * @param options - Validation options
296
291
  * @throws {Error} Throws if the node is invalid
297
292
  */
298
293
  export function validate_node(node, schema, all_nodes = {}, options = {}) {
299
- const require_references = options.require_references ?? true;
300
- if (!is_id_valid(node.id)) {
301
- throw new Error(`Node ${node.id} has an invalid id.`);
302
- }
303
-
304
- if (!node.type || !schema[node.type]) {
305
- throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
306
- }
307
-
308
- const node_schema = schema[node.type];
309
-
310
- for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
311
- const value = node[prop_name];
312
-
313
- // Check primitive types
314
- if (is_primitive_type(prop_def.type)) {
315
- if (!validate_primitive_value(prop_def.type, value)) {
316
- throw new Error(
317
- `Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`
318
- );
319
- }
320
- }
321
- if (prop_def.type === 'annotated_text') {
322
- validate_annotated_text_property(
323
- node.id,
324
- prop_name,
325
- value,
326
- /** @type {AnnotatedTextProperty} */ (prop_def),
327
- all_nodes,
328
- require_references
329
- );
330
- }
331
- // Check node references
332
- if (prop_def.type === 'node') {
333
- if (!is_id_valid(value)) {
334
- throw new Error(
335
- `Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`
336
- );
337
- }
338
- // Check if referenced node exists and is of allowed type
339
- const referenced_node = all_nodes[value];
340
- if (!referenced_node) {
341
- if (require_references) {
342
- throw new Error(
343
- `Node ${node.id} property ${prop_name} references missing node ${value}.`
344
- );
345
- }
346
- continue;
347
- }
348
- if (!prop_def.node_types.includes(referenced_node.type)) {
349
- throw new Error(
350
- `Node ${node.id} property ${prop_name} references node ${value} of type ${referenced_node.type}, but only types [${/** @type {NodeProperty} */ (prop_def).node_types.join(', ')}] are allowed.`
351
- );
352
- }
353
- }
354
- // Check node arrays
355
- else if (prop_def.type === 'node_array') {
356
- if (
357
- !Array.isArray(value) ||
358
- !value.every((id) => typeof id === 'string' && is_id_valid(id))
359
- ) {
360
- throw new Error(
361
- `Node ${node.id} has an invalid property: ${prop_name} must be an array of node ids.`
362
- );
363
- }
364
- // Check if all referenced nodes exist and are of allowed types
365
- for (const ref_id of value) {
366
- const referenced_node = all_nodes[ref_id];
367
- if (!referenced_node) {
368
- if (require_references) {
369
- throw new Error(
370
- `Node ${node.id} property ${prop_name} references missing node ${ref_id}.`
371
- );
372
- }
373
- continue;
374
- }
375
- if (!prop_def.node_types.includes(referenced_node.type)) {
376
- throw new Error(
377
- `Node ${node.id} property ${prop_name} references node ${ref_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`
378
- );
379
- }
380
- }
381
- }
382
- }
383
- }
384
-
294
+ const require_references = options.require_references ?? true;
295
+ if (!is_id_valid(node.id)) {
296
+ throw new Error(`Node ${node.id} has an invalid id.`);
297
+ }
298
+ if (!node.type || !schema[node.type]) {
299
+ throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
300
+ }
301
+ const node_schema = schema[node.type];
302
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
303
+ const value = node[prop_name];
304
+ // Check primitive types
305
+ if (is_primitive_type(prop_def.type)) {
306
+ if (!validate_primitive_value(prop_def.type, value)) {
307
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`);
308
+ }
309
+ if (prop_def.type === 'string' && prop_def.values && !prop_def.values.includes(value)) {
310
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be one of [${prop_def.values.join(', ')}].`);
311
+ }
312
+ if (prop_def.type === 'integer' &&
313
+ ((prop_def.min !== undefined && value < prop_def.min) ||
314
+ (prop_def.max !== undefined && value > prop_def.max))) {
315
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be between ${prop_def.min ?? '-Infinity'} and ${prop_def.max ?? 'Infinity'}.`);
316
+ }
317
+ }
318
+ if (prop_def.type === 'text') {
319
+ validate_text_property(node.id, prop_name, value, prop_def, all_nodes, require_references);
320
+ }
321
+ // Check node references
322
+ if (prop_def.type === 'node') {
323
+ if (!is_id_valid(value)) {
324
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`);
325
+ }
326
+ // Check if referenced node exists and is of allowed type
327
+ const referenced_node = all_nodes[value];
328
+ if (!referenced_node) {
329
+ if (require_references) {
330
+ throw new Error(`Node ${node.id} property ${prop_name} references missing node ${value}.`);
331
+ }
332
+ continue;
333
+ }
334
+ if (!prop_def.node_types.includes(referenced_node.type)) {
335
+ throw new Error(`Node ${node.id} property ${prop_name} references node ${value} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`);
336
+ }
337
+ }
338
+ // Check node arrays
339
+ else if (prop_def.type === 'node_array') {
340
+ if (!value ||
341
+ typeof value !== 'object' ||
342
+ !Array.isArray(value.nodes) ||
343
+ !Array.isArray(value.marks) ||
344
+ !Array.isArray(value.annotations)) {
345
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be an object with nodes, marks and annotations.`);
346
+ }
347
+ const node_array_nodes = value.nodes;
348
+ if (!node_array_nodes.every((id) => typeof id === 'string' && is_id_valid(id))) {
349
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must contain valid node ids.`);
350
+ }
351
+ // Check if all referenced nodes exist and are of allowed types
352
+ for (const ref_id of node_array_nodes) {
353
+ const referenced_node = all_nodes[ref_id];
354
+ if (!referenced_node) {
355
+ if (require_references) {
356
+ throw new Error(`Node ${node.id} property ${prop_name} references missing node ${ref_id}.`);
357
+ }
358
+ continue;
359
+ }
360
+ if (!prop_def.node_types.includes(referenced_node.type)) {
361
+ throw new Error(`Node ${node.id} property ${prop_name} references node ${ref_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`);
362
+ }
363
+ }
364
+ validate_marks_and_annotations(node.id, prop_name, value, prop_def, node_array_nodes.length, all_nodes, require_references);
365
+ }
366
+ }
367
+ }
385
368
  /**
386
369
  * Validate a document against its schema.
387
370
  *
388
- * @param {Document} doc - The document to validate
389
- * @param {DocumentSchema} schema - The document schema
390
371
  * @throws {Error} Throws if the document is invalid
391
372
  */
392
373
  export function validate_document(doc, schema) {
393
- if (!is_id_valid(doc.document_id)) {
394
- throw new Error(`Document ${doc.document_id} has an invalid id.`);
395
- }
396
- for (const node of Object.values(doc.nodes)) {
397
- validate_node(node, schema, doc.nodes);
398
- }
374
+ if (!is_id_valid(doc.document_id)) {
375
+ throw new Error(`Document ${doc.document_id} has an invalid id.`);
376
+ }
377
+ for (const [node_id, node] of Object.entries(doc.nodes)) {
378
+ if (!is_id_valid(node_id)) {
379
+ throw new Error(`Document node map contains an invalid id: ${node_id}.`);
380
+ }
381
+ if (node.id !== node_id) {
382
+ throw new Error(`Document node map key ${node_id} does not match node id ${node.id}.`);
383
+ }
384
+ validate_node(node, schema, doc.nodes);
385
+ }
399
386
  }
400
-
401
387
  /**
402
388
  * Gets a value from the document at the specified path.
403
389
  *
404
- * @param {DocumentSchema} schema - The document schema
405
- * @param {Document} doc - The document containing nodes
406
- * @param {DocumentPath|string} path - Array path to the value, or a string node ID
407
- * @returns {any} The value at the specified path
390
+ * @param schema - The document schema
391
+ * @param doc - The document containing nodes
392
+ * @param path - Array path to the value, or a string node ID
393
+ * @returns The value at the specified path
408
394
  */
395
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
409
396
  export function get(schema, doc, path) {
410
- if (typeof path === 'string') {
411
- path = [path];
412
- }
413
- if (!(Array.isArray(path) && path.length >= 1)) {
414
- throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
415
- }
416
-
417
- let val = doc.nodes[path[0]];
418
- let val_type = 'node';
419
-
420
- for (let i = 1; i < path.length; i++) {
421
- const path_segment = path[i];
422
- const path_segment_str = String(path_segment);
423
- if (val_type === 'node') {
424
- if (property_type(schema, val.type, path_segment_str) === 'node_array') {
425
- val = val[path_segment];
426
- val_type = 'node_array';
427
- } else if (property_type(schema, val.type, path_segment_str) === 'annotated_text') {
428
- val = val[path_segment];
429
- val_type = 'annotated_text';
430
- } else if (property_type(schema, val.type, path_segment_str) === 'node') {
431
- val = doc.nodes[val[path_segment]];
432
- val_type = 'node';
433
- } else if (
434
- ['string_array', 'integer_array'].includes(
435
- property_type(schema, val.type, path_segment_str)
436
- )
437
- ) {
438
- val = val[path_segment];
439
- val_type = 'value_array';
440
- } else {
441
- val = val[path_segment];
442
- val_type = 'value';
443
- }
444
- } else if (val_type === 'node_array') {
445
- val = doc.nodes[val[path_segment]];
446
- val_type = 'node';
447
- } else if (val_type === 'value_array') {
448
- val = val[path_segment];
449
- val_type = 'value';
450
- } else if (val_type === 'annotated_text') {
451
- if (path_segment === 'text') {
452
- val = val.text;
453
- val_type = 'value';
454
- } else if (path_segment === 'annotations') {
455
- val = val.annotations;
456
- val_type = 'annotation_array';
457
- } else {
458
- throw new Error(
459
- `Invalid path segment "${path_segment}" for annotated_text. Use "text" or "annotations".`
460
- );
461
- }
462
- } else if (val_type === 'annotation_array') {
463
- val = val[path_segment];
464
- val_type = 'annotation';
465
- } else if (val_type === 'annotation') {
466
- if (path_segment === 'node_id') {
467
- val = doc.nodes[val.node_id];
468
- val_type = 'node';
469
- } else if (path_segment === 'start_offset') {
470
- val = val.start_offset;
471
- val_type = 'value';
472
- } else if (path_segment === 'end_offset') {
473
- val = val.end_offset;
474
- val_type = 'value';
475
- } else {
476
- throw new Error(
477
- `Invalid path segment "${path_segment}" for annotation. Use "start_offset", "end_offset", or "node_id".`
478
- );
479
- }
480
- }
481
- }
482
- return val;
483
- }
484
-
397
+ if (typeof path === 'string') {
398
+ path = [path];
399
+ }
400
+ if (!(Array.isArray(path) && path.length >= 1)) {
401
+ throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
402
+ }
403
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
404
+ let val = doc.nodes[path[0]];
405
+ let val_type = 'node';
406
+ for (let i = 1; i < path.length; i++) {
407
+ const path_segment = path[i];
408
+ const path_segment_str = String(path_segment);
409
+ if (val_type === 'node') {
410
+ if (property_type(schema, val.type, path_segment_str) === 'node_array') {
411
+ val = val[path_segment];
412
+ val_type = 'node_array';
413
+ }
414
+ else if (property_type(schema, val.type, path_segment_str) === 'text') {
415
+ val = val[path_segment];
416
+ val_type = 'text';
417
+ }
418
+ else if (property_type(schema, val.type, path_segment_str) === 'node') {
419
+ val = doc.nodes[val[path_segment]];
420
+ val_type = 'node';
421
+ }
422
+ else if (['string_array', 'integer_array'].includes(property_type(schema, val.type, path_segment_str))) {
423
+ val = val[path_segment];
424
+ val_type = 'value_array';
425
+ }
426
+ else {
427
+ val = val[path_segment];
428
+ val_type = 'value';
429
+ }
430
+ }
431
+ else if (val_type === 'node_array') {
432
+ if (path_segment === 'nodes') {
433
+ val = val.nodes;
434
+ val_type = 'node_id_array';
435
+ }
436
+ else if (path_segment === 'marks') {
437
+ val = val.marks;
438
+ val_type = 'range_array';
439
+ }
440
+ else if (path_segment === 'annotations') {
441
+ val = val.annotations;
442
+ val_type = 'range_array';
443
+ }
444
+ else if (typeof path_segment === 'number' || /^\d+$/.test(String(path_segment))) {
445
+ val = doc.nodes[val.nodes[path_segment]];
446
+ val_type = 'node';
447
+ }
448
+ else {
449
+ throw new Error(`Invalid path segment "${path_segment}" for node_array. Use "nodes", "marks" or "annotations".`);
450
+ }
451
+ }
452
+ else if (val_type === 'node_id_array') {
453
+ val = doc.nodes[val[path_segment]];
454
+ val_type = 'node';
455
+ }
456
+ else if (val_type === 'value_array') {
457
+ val = val[path_segment];
458
+ val_type = 'value';
459
+ }
460
+ else if (val_type === 'text') {
461
+ if (path_segment === 'content') {
462
+ val = val.content;
463
+ val_type = 'value';
464
+ }
465
+ else if (path_segment === 'marks') {
466
+ val = val.marks;
467
+ val_type = 'range_array';
468
+ }
469
+ else if (path_segment === 'annotations') {
470
+ val = val.annotations;
471
+ val_type = 'range_array';
472
+ }
473
+ else {
474
+ throw new Error(`Invalid path segment "${path_segment}" for text. Use "content", "marks" or "annotations".`);
475
+ }
476
+ }
477
+ else if (val_type === 'range_array') {
478
+ val = val[path_segment];
479
+ val_type = 'range';
480
+ }
481
+ else if (val_type === 'range') {
482
+ if (path_segment === 'node_id') {
483
+ val = doc.nodes[val.node_id];
484
+ val_type = 'node';
485
+ }
486
+ else if (path_segment === 'start_offset') {
487
+ val = val.start_offset;
488
+ val_type = 'value';
489
+ }
490
+ else if (path_segment === 'end_offset') {
491
+ val = val.end_offset;
492
+ val_type = 'value';
493
+ }
494
+ else {
495
+ throw new Error(`Invalid path segment "${path_segment}" for range. Use "start_offset", "end_offset", or "node_id".`);
496
+ }
497
+ }
498
+ }
499
+ return val;
500
+ }
485
501
  /**
486
502
  * Gets the type of a property from the schema.
487
- *
488
- * @param {object} schema - The document schema
489
- * @param {string} type - The node type
490
- * @param {string} property - The property name
491
- * @returns {string} The property type
492
503
  */
493
504
  export function property_type(schema, type, property) {
494
- if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
495
- if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
496
-
497
- if (property === 'type') return 'string';
498
- if (property === 'id') return 'string';
499
-
500
- if (!schema[type]) throw new Error(`Type ${type} not found in schema`);
501
- if (!schema[type].properties[property])
502
- throw new Error(`Property ${property} not found in type ${type}`);
503
-
504
- return schema[type].properties[property].type;
505
- }
506
-
507
- /**
508
- * Determines the kind of a node ('block', 'text', or 'annotation').
509
- *
510
- * @param {object} schema - The document schema
511
- * @param {any} node - The node to check
512
- * @returns {'block'|'text'|'annotation'} The node kind
505
+ if (typeof type !== 'string')
506
+ throw new Error(`Invalid type ${type} provided`);
507
+ if (typeof property !== 'string')
508
+ throw new Error(`Invalid property ${property} provided`);
509
+ if (property === 'type')
510
+ return 'string';
511
+ if (property === 'id')
512
+ return 'string';
513
+ if (!schema[type])
514
+ throw new Error(`Type ${type} not found in schema`);
515
+ if (!schema[type].properties[property])
516
+ throw new Error(`Property ${property} not found in type ${type}`);
517
+ return schema[type].properties[property].type;
518
+ }
519
+ /**
520
+ * Determines the kind of a node ('document', 'block', 'text', 'mark', or 'annotation').
513
521
  */
514
522
  export function kind(schema, node) {
515
- return schema[node.type].kind;
523
+ return schema[node.type].kind;
516
524
  }
517
-
518
525
  /**
519
- * Checks whether an annotation node can be switched to another annotation type
520
- * without losing data.
526
+ * Inspects a path to get metadata about the value at that location.
527
+ */
528
+ export function inspect(schema, doc, path) {
529
+ const parent = path.length > 1 ? get(schema, doc, path.slice(0, -1)) : undefined;
530
+ if (parent?.type) {
531
+ const property_name = path.at(-1);
532
+ return {
533
+ kind: 'property',
534
+ name: property_name,
535
+ ...schema[parent.type].properties[property_name]
536
+ };
537
+ }
538
+ else {
539
+ const node = get(schema, doc, path);
540
+ return {
541
+ kind: 'node',
542
+ id: node.id,
543
+ type: node.type,
544
+ properties: schema[node.type]
545
+ };
546
+ }
547
+ }
548
+ /**
549
+ * Creates a mutable draft of a document: a new document object with a new
550
+ * nodes map, sharing the (immutable) node objects with the original.
551
+ *
552
+ * apply_op_to_draft mutates the draft's nodes map but copies node objects
553
+ * before changing them, so node identity is preserved for unchanged nodes
554
+ * (fine-grained rendering relies on this) and the original document is
555
+ * never touched.
521
556
  *
522
- * @param {DocumentSchema} schema - The document schema
523
- * @param {any} annotation_node - The annotation node to switch
524
- * @param {string} target_annotation_type - The target annotation type
525
- * @returns {boolean} True if the annotation can be switched safely
526
- */
527
- export function can_switch_annotation_type(schema, annotation_node, target_annotation_type) {
528
- if (!annotation_node || annotation_node.type === target_annotation_type) return false;
529
-
530
- const source_schema = schema[annotation_node.type];
531
- const target_schema = schema[target_annotation_type];
532
- if (source_schema?.kind !== 'annotation' || target_schema?.kind !== 'annotation') return false;
533
-
534
- const source_has_properties = Object.keys(source_schema.properties ?? {}).length > 0;
535
- const target_has_properties = Object.keys(target_schema.properties ?? {}).length > 0;
536
- if (source_has_properties || target_has_properties) return false;
537
-
538
- // eslint-disable-next-line no-unused-vars
539
- const { id, type, ...extra_properties } = annotation_node;
540
- return Object.keys(extra_properties).length === 0;
541
- }
542
-
557
+ * @returns A draft document safe to mutate via apply_op_to_draft
558
+ */
559
+ export function create_document_draft(doc) {
560
+ return {
561
+ ...doc,
562
+ nodes: { ...doc.nodes }
563
+ };
564
+ }
543
565
  /**
544
- * Inspects a path to get metadata about the value at that location.
566
+ * Applies an operation to a document draft in place.
545
567
  *
546
- * @param {DocumentSchema} schema - The document schema
547
- * @param {Document} doc - The document containing nodes
548
- * @param {DocumentPath} path - The path to inspect
549
- * @returns {{kind: 'property'|'node', [key: string]: any}} Metadata about the path
568
+ * The draft's nodes map is mutated, but node objects are copied on write —
569
+ * one nodes-map copy per draft (see create_document_draft) replaces the
570
+ * previous one-copy-per-op behavior, which made transactions with many ops
571
+ * (paste, cascade delete) O(N·ops).
572
+ *
573
+ * @param draft - A draft created via create_document_draft
574
+ * @param op - The operation to apply [type, ...args]
575
+ * @returns The same draft, for convenience
550
576
  */
551
- export function inspect(schema, doc, path) {
552
- const parent = path.length > 1 ? get(schema, doc, path.slice(0, -1)) : undefined;
553
- if (parent?.type) {
554
- const property_name = path.at(-1);
555
- return {
556
- kind: 'property',
557
- name: property_name,
558
- ...schema[parent.type].properties[property_name]
559
- };
560
- } else {
561
- const node = get(schema, doc, path);
562
- return {
563
- kind: 'node',
564
- id: node.id,
565
- type: node.type,
566
- properties: schema[node.type]
567
- };
568
- }
569
- }
570
-
577
+ export function apply_op_to_draft(draft, op) {
578
+ const [type, ...args] = op;
579
+ if (type === 'set') {
580
+ const [node_id, property] = args[0];
581
+ draft.nodes[node_id] = {
582
+ ...draft.nodes[node_id],
583
+ [property]: structuredClone(args[1])
584
+ };
585
+ }
586
+ else if (type === 'create') {
587
+ draft.nodes[args[0].id] = structuredClone(args[0]);
588
+ }
589
+ else if (type === 'delete') {
590
+ delete draft.nodes[args[0]];
591
+ }
592
+ return draft;
593
+ }
571
594
  /**
572
595
  * Applies an operation to a document and returns the new document.
573
596
  * Uses copy-on-write semantics.
574
597
  *
575
- * @param {Document} doc - The document to apply the operation to
576
- * @param {Array} op - The operation to apply [type, ...args]
577
- * @returns {Document} The new document with the operation applied
598
+ * @param doc - The document to apply the operation to
599
+ * @param op - The operation to apply [type, ...args]
600
+ * @returns The new document with the operation applied
578
601
  */
579
602
  export function apply_op(doc, op) {
580
- const [type, ...args] = op;
581
- if (type === 'set') {
582
- const [node_id, property] = args[0];
583
- const value = structuredClone(args[1]);
584
- return {
585
- ...doc,
586
- nodes: {
587
- ...doc.nodes,
588
- [node_id]: {
589
- ...doc.nodes[node_id],
590
- [property]: value
591
- }
592
- }
593
- };
594
- } else if (type === 'create') {
595
- return {
596
- ...doc,
597
- nodes: {
598
- ...doc.nodes,
599
- [args[0].id]: structuredClone(args[0])
600
- }
601
- };
602
- } else if (type === 'delete') {
603
- // eslint-disable-next-line
604
- const { [args[0]]: _removed, ...remaining_nodes } = doc.nodes;
605
- return {
606
- ...doc,
607
- nodes: remaining_nodes
608
- };
609
- }
610
- return doc;
611
- }
612
-
603
+ return apply_op_to_draft(create_document_draft(doc), op);
604
+ }
613
605
  /**
614
606
  * Counts how many times a node is referenced in the document.
615
- *
616
- * @param {DocumentSchema} schema - The document schema
617
- * @param {Document} doc - The document containing nodes
618
- * @param {NodeId} node_id - The node ID to count references for
619
- * @returns {number} The number of references
620
607
  */
621
608
  export function count_references(schema, doc, node_id) {
622
- let count = 0;
623
-
624
- for (const node of Object.values(doc.nodes)) {
625
- for (const [property, value] of Object.entries(node)) {
626
- if (property === 'id' || property === 'type') continue;
627
-
628
- const prop_type = property_type(schema, node.type, property);
629
-
630
- if (prop_type === 'node_array' && Array.isArray(value)) {
631
- count += value.filter((id) => id === node_id).length;
632
- } else if (prop_type === 'node' && value === node_id) {
633
- count += 1;
634
- }
635
- }
636
- }
637
-
638
- return count;
639
- }
640
-
641
- /**
642
- * Gets the currently active annotation at the selection, optionally filtered by type.
643
- *
644
- * An annotation is considered "active" if it overlaps with the current selection in any way:
645
- * - Annotation contains the selection start
646
- * - Annotation contains the selection end
647
- * - Selection fully contains the annotation
609
+ let count = 0;
610
+ for (const node of Object.values(doc.nodes)) {
611
+ for (const [property, value] of Object.entries(node)) {
612
+ if (property === 'id' || property === 'type')
613
+ continue;
614
+ const prop_type = property_type(schema, node.type, property);
615
+ if (prop_type === 'node_array') {
616
+ count += value.nodes.filter((id) => id === node_id).length;
617
+ }
618
+ else if (prop_type === 'node' && value === node_id) {
619
+ count += 1;
620
+ }
621
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
622
+ count += [...value.marks, ...value.annotations].filter((range) => range.node_id === node_id).length;
623
+ }
624
+ }
625
+ }
626
+ return count;
627
+ }
628
+ /**
629
+ * Whether a mark can be switched between these types.
630
+ */
631
+ export function can_switch_mark_type(schema, from_type, to_type) {
632
+ const from_schema = schema[from_type];
633
+ const to_schema = schema[to_type];
634
+ return (from_schema?.kind === 'mark' &&
635
+ to_schema?.kind === 'mark' &&
636
+ Object.keys(from_schema.properties ?? {}).length === 0 &&
637
+ Object.keys(to_schema.properties ?? {}).length === 0);
638
+ }
639
+ /**
640
+ * Gets ranges of the given key ('marks' or 'annotations') touched by the
641
+ * current selection.
648
642
  *
649
- * @param {DocumentSchema} schema - The document schema
650
- * @param {Document} doc - The document containing nodes
651
- * @param {Selection} [selection] - The current selection
652
- * @param {string} [annotation_type] - Optional annotation type to filter by
653
- * @returns {Annotation | null} The active annotation, or null if none found
654
- */
655
- export function get_active_annotation(schema, doc, selection, annotation_type) {
656
- if (selection?.type !== 'text') return null;
657
- const range = get_selection_range(selection);
658
- if (!range) return null;
659
-
660
- const annotated_text = get(schema, doc, selection.path);
661
- const annotations = annotated_text.annotations;
662
-
663
- const active_annotation =
664
- annotations.find(
665
- (/** @type {Annotation} */ { start_offset, end_offset }) =>
666
- (start_offset <= range.start_offset && end_offset > range.start_offset) ||
667
- (start_offset < range.end_offset && end_offset >= range.end_offset) ||
668
- (start_offset >= range.start_offset && end_offset <= range.end_offset)
669
- ) || null;
670
-
671
- if (annotation_type && active_annotation) {
672
- const annotation_node = get(schema, doc, [active_annotation.node_id]);
673
- return annotation_node?.type === annotation_type ? active_annotation : null;
674
- } else {
675
- return active_annotation;
676
- }
677
- }
678
-
643
+ * Non-collapsed selections use strict half-open range intersection, so merely
644
+ * adjacent boundaries do not count as touching. Collapsed text carets are only
645
+ * inside a range when they are strictly between start and end; a caret at
646
+ * either boundary is not active.
647
+ */
648
+ function get_selected_ranges(schema, doc, selection, key) {
649
+ if (selection?.type !== 'text' && selection?.type !== 'node')
650
+ return [];
651
+ const range = get_selection_range(selection);
652
+ if (!range)
653
+ return [];
654
+ const annotated_prop = get(schema, doc, selection.path);
655
+ const ranges = annotated_prop?.[key] ?? [];
656
+ const is_collapsed = range.start_offset === range.end_offset;
657
+ return ranges
658
+ .map((attachment, index) => {
659
+ const node = doc.nodes[attachment.node_id];
660
+ return {
661
+ ...attachment,
662
+ index,
663
+ node
664
+ };
665
+ })
666
+ .filter(({ start_offset, end_offset }) => {
667
+ if (is_collapsed) {
668
+ return start_offset < range.start_offset && end_offset > range.start_offset;
669
+ }
670
+ return start_offset < range.end_offset && end_offset > range.start_offset;
671
+ });
672
+ }
673
+ /**
674
+ * Gets marks touched by the current selection.
675
+ */
676
+ export function get_selected_marks(schema, doc, selection) {
677
+ return get_selected_ranges(schema, doc, selection, 'marks');
678
+ }
679
+ /**
680
+ * Gets annotations touched by the current selection.
681
+ */
682
+ export function get_selected_annotations(schema, doc, selection) {
683
+ return get_selected_ranges(schema, doc, selection, 'annotations');
684
+ }
685
+ /**
686
+ * Node types represented in the selected ranges.
687
+ */
688
+ export function get_selected_range_types(selected_ranges) {
689
+ return new Set(selected_ranges.map(({ node }) => node?.type).filter((type) => Boolean(type)));
690
+ }
679
691
  /**
680
692
  * Counts references to a node, excluding nodes marked for deletion.
681
- *
682
- * @param {DocumentSchema} schema - The document schema
683
- * @param {Document} doc - The document containing nodes
684
- * @param {NodeId} target_node_id - The node ID to count references for
685
- * @param {Record<NodeId, boolean>} nodes_to_delete - Nodes marked for deletion
686
- * @returns {number} The number of references excluding deleted nodes
687
693
  */
688
694
  export function count_references_excluding_deleted(schema, doc, target_node_id, nodes_to_delete) {
689
- let count = 0;
690
-
691
- for (const node of Object.values(doc.nodes)) {
692
- if (nodes_to_delete[node.id]) continue;
693
-
694
- for (const [property, value] of Object.entries(node)) {
695
- if (property === 'id' || property === 'type') continue;
696
-
697
- const prop_type = property_type(schema, node.type, property);
698
-
699
- if (prop_type === 'node_array' && Array.isArray(value)) {
700
- count += value.filter((id) => id === target_node_id).length;
701
- } else if (prop_type === 'node' && value === target_node_id) {
702
- count += 1;
703
- } else if (prop_type === 'annotated_text' && value && value.annotations) {
704
- count += value.annotations.filter(
705
- (annotation) => annotation.node_id === target_node_id
706
- ).length;
707
- }
708
- }
709
- }
710
-
711
- return count;
712
- }
713
-
695
+ let count = 0;
696
+ for (const node of Object.values(doc.nodes)) {
697
+ if (nodes_to_delete[node.id])
698
+ continue;
699
+ for (const [property, value] of Object.entries(node)) {
700
+ if (property === 'id' || property === 'type')
701
+ continue;
702
+ const prop_type = property_type(schema, node.type, property);
703
+ if (prop_type === 'node_array') {
704
+ count += value.nodes.filter((id) => id === target_node_id).length;
705
+ }
706
+ else if (prop_type === 'node' && value === target_node_id) {
707
+ count += 1;
708
+ }
709
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
710
+ count += [...value.marks, ...value.annotations].filter((range) => range.node_id === target_node_id).length;
711
+ }
712
+ }
713
+ }
714
+ return count;
715
+ }
716
+ /**
717
+ * Iterates all node references going out of a node, invoking the visitor
718
+ * once per occurrence (multiplicity matters for reference counting).
719
+ * Covers the same reference kinds as count_references_excluding_deleted:
720
+ * a `node` property, `node_array` .nodes ids, and the mark/annotation
721
+ * node_ids carried on `text` and `node_array` properties.
722
+ */
723
+ export function visit_node_references(schema, node, visit) {
724
+ const node_schema = schema[node.type];
725
+ if (!node_schema)
726
+ return;
727
+ for (const [property, prop_def] of Object.entries(node_schema.properties)) {
728
+ const value = node[property];
729
+ if (value === undefined || value === null)
730
+ continue;
731
+ const prop_type = prop_def.type;
732
+ if (prop_type === 'node_array') {
733
+ for (const id of value.nodes)
734
+ visit(id);
735
+ }
736
+ else if (prop_type === 'node' && typeof value === 'string') {
737
+ visit(value);
738
+ }
739
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
740
+ for (const range of value.marks)
741
+ visit(range.node_id);
742
+ for (const range of value.annotations)
743
+ visit(range.node_id);
744
+ }
745
+ }
746
+ }
747
+ /**
748
+ * Builds reference counts for every referenced node in one full-document
749
+ * scan. Used by cascade deletion so the fixpoint can decrement counts
750
+ * incrementally instead of re-scanning the document per candidate node.
751
+ */
752
+ export function build_reference_counts(schema, doc) {
753
+ const counts = new Map();
754
+ for (const node of Object.values(doc.nodes)) {
755
+ visit_node_references(schema, node, (id) => {
756
+ counts.set(id, (counts.get(id) || 0) + 1);
757
+ });
758
+ }
759
+ return counts;
760
+ }
714
761
  /**
715
762
  * Gets ids of nodes that reference any of the target nodes.
716
- *
717
- * @param {DocumentSchema} schema - The document schema
718
- * @param {Document} doc - The document containing nodes
719
- * @param {Iterable<NodeId>} target_node_ids - Node IDs to find referrers for
720
- * @returns {NodeId[]} IDs of nodes with references to any target node
721
763
  */
722
764
  export function get_referencing_node_ids(schema, doc, target_node_ids) {
723
- const target_ids = new Set(target_node_ids);
724
- const referencing_node_ids = new Set();
725
- if (target_ids.size === 0) return [];
726
-
727
- for (const node of Object.values(doc.nodes)) {
728
- for (const [property, value] of Object.entries(node)) {
729
- if (property === 'id' || property === 'type') continue;
730
-
731
- const prop_type = property_type(schema, node.type, property);
732
-
733
- if (prop_type === 'node_array' && Array.isArray(value)) {
734
- if (value.some((id) => target_ids.has(id))) {
735
- referencing_node_ids.add(node.id);
736
- }
737
- } else if (prop_type === 'node' && target_ids.has(value)) {
738
- referencing_node_ids.add(node.id);
739
- } else if (prop_type === 'annotated_text' && value?.annotations) {
740
- if (value.annotations.some((annotation) => target_ids.has(annotation.node_id))) {
741
- referencing_node_ids.add(node.id);
742
- }
743
- }
744
- }
745
- }
746
-
747
- return [...referencing_node_ids];
748
- }
749
-
765
+ const target_ids = new Set(target_node_ids);
766
+ const referencing_node_ids = new Set();
767
+ if (target_ids.size === 0)
768
+ return [];
769
+ for (const node of Object.values(doc.nodes)) {
770
+ for (const [property, value] of Object.entries(node)) {
771
+ if (property === 'id' || property === 'type')
772
+ continue;
773
+ const prop_type = property_type(schema, node.type, property);
774
+ if (prop_type === 'node_array') {
775
+ if (value.nodes.some((id) => target_ids.has(id))) {
776
+ referencing_node_ids.add(node.id);
777
+ }
778
+ }
779
+ else if (prop_type === 'node' && target_ids.has(value)) {
780
+ referencing_node_ids.add(node.id);
781
+ }
782
+ if ((prop_type === 'text' || prop_type === 'node_array') &&
783
+ value &&
784
+ [...value.marks, ...value.annotations].some((range) => target_ids.has(range.node_id))) {
785
+ referencing_node_ids.add(node.id);
786
+ }
787
+ }
788
+ }
789
+ return [...referencing_node_ids];
790
+ }
750
791
  /**
751
792
  * Validates a selection against the current document state.
752
793
  * Works with any object that implements get() and inspect() (Session or Transaction).
753
794
  *
754
- * @param {Selection} selection - The selection to validate
755
- * @param {{ get: Function, inspect: Function }} session_or_transaction - A Session or Transaction instance
756
795
  * @throws {Error} Throws if the selection is invalid
757
796
  */
758
797
  export function validate_selection(selection, session_or_transaction) {
759
- if (!selection) return;
760
-
761
- const selection_type = selection.type;
762
- if (!['node', 'text', 'property'].includes(selection_type)) {
763
- throw new Error(`Invalid selection type: ${selection_type}`);
764
- }
765
-
766
- if (selection_type === 'node') {
767
- const node_array = session_or_transaction.get(selection.path);
768
-
769
- if (!Array.isArray(node_array)) {
770
- throw new Error('Node selection path must point to a node_array');
771
- }
772
-
773
- const max_offset = node_array.length;
774
- if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
775
- throw new Error(
776
- `Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
777
- );
778
- }
779
- if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
780
- throw new Error(
781
- `Node selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${max_offset}.`
782
- );
783
- }
784
- } else if (selection_type === 'text') {
785
- const annotated_text = session_or_transaction.get(selection.path);
786
-
787
- if (!annotated_text || typeof annotated_text.text !== 'string') {
788
- throw new Error('Text selection path must point to annotated_text');
789
- }
790
-
791
- const char_length = get_char_length(annotated_text.text);
792
- if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
793
- throw new Error(
794
- `Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`
795
- );
796
- }
797
- if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
798
- throw new Error(
799
- `Text selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${char_length}.`
800
- );
801
- }
802
- } else if (selection_type === 'property') {
803
- if (!session_or_transaction.inspect(selection.path)) {
804
- throw new Error(`Property selection path not found: ${serialize_path(selection.path)}`);
805
- }
806
- }
798
+ if (!selection)
799
+ return;
800
+ const selection_type = selection.type;
801
+ if (!['node', 'text', 'property'].includes(selection_type)) {
802
+ throw new Error(`Invalid selection type: ${selection_type}`);
803
+ }
804
+ if (selection_type === 'node') {
805
+ const node_array_prop = session_or_transaction.get(selection.path);
806
+ if (!node_array_prop || !Array.isArray(node_array_prop.nodes)) {
807
+ throw new Error('Node selection path must point to a node_array');
808
+ }
809
+ const max_offset = node_array_prop.nodes.length;
810
+ if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
811
+ throw new Error(`Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`);
812
+ }
813
+ if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
814
+ throw new Error(`Node selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${max_offset}.`);
815
+ }
816
+ }
817
+ else if (selection_type === 'text') {
818
+ const text = session_or_transaction.get(selection.path);
819
+ if (!text || typeof text.content !== 'string') {
820
+ throw new Error('Text selection path must point to text');
821
+ }
822
+ const char_length = get_char_length(text.content);
823
+ if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
824
+ throw new Error(`Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`);
825
+ }
826
+ if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
827
+ throw new Error(`Text selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${char_length}.`);
828
+ }
829
+ }
830
+ else if (selection_type === 'property') {
831
+ if (!session_or_transaction.inspect(selection.path)) {
832
+ throw new Error(`Property selection path not found: ${serialize_path(selection.path)}`);
833
+ }
834
+ }
807
835
  }