svedit 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -64
- package/dist/Command.svelte.d.ts +21 -5
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +86 -30
- package/dist/KeyMapper.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.js +18 -6
- package/dist/Node.svelte +29 -1
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +119 -12
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +48 -6
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +31 -14
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +155 -77
- package/dist/Svedit.svelte +110 -89
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
- package/dist/TextProperty.svelte.d.ts +7 -0
- package/dist/TextProperty.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +78 -40
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +412 -234
- package/dist/doc_utils.d.ts +96 -21
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +440 -168
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node_visibility.svelte.d.ts +30 -4
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +327 -363
- package/dist/paste_utils.d.ts +3 -2
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +16 -13
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +5 -6
- package/dist/types.d.ts +112 -42
- package/dist/utils.d.ts +89 -13
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +320 -85
- package/package.json +4 -1
- package/src/lib/Command.svelte.js +86 -30
- package/src/lib/KeyMapper.svelte.js +18 -6
- package/src/lib/Node.svelte +29 -1
- package/src/lib/NodeArrayProperty.svelte +119 -12
- package/src/lib/NodeGap.svelte +48 -6
- package/src/lib/Session.svelte.js +155 -77
- package/src/lib/Svedit.svelte +110 -89
- package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
- package/src/lib/Transaction.svelte.js +412 -234
- package/src/lib/doc_utils.js +440 -168
- package/src/lib/index.js +1 -1
- package/src/lib/node_visibility.svelte.js +327 -363
- package/src/lib/paste_utils.js +16 -13
- package/src/lib/transforms.svelte.js +5 -6
- package/src/lib/types.d.ts +112 -42
- package/src/lib/utils.js +320 -85
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
package/src/lib/doc_utils.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* These functions operate on the core document state (schema, doc, selection, config)
|
|
5
5
|
* without any history management or transaction tracking.
|
|
6
6
|
*
|
|
7
|
-
* @import { NodeId, DocumentPath, PrimitiveType, NodeProperty, NodeArrayProperty, NodeSchema, DocumentSchema, Selection, Annotation, Document,
|
|
7
|
+
* @import { NodeId, DocumentPath, PrimitiveType, NodeProperty, NodeArrayProperty, NodeSchema, DocumentSchema, Selection, Attachment, Mark, Annotation, Document, TextProperty, AnnotatedText, ValidateDocumentSchema } from './types'
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
is_path_string_segment_valid,
|
|
13
13
|
get_selection_range,
|
|
14
14
|
get_char_length,
|
|
15
|
-
serialize_path
|
|
15
|
+
serialize_path,
|
|
16
|
+
are_ranges_exclusive
|
|
16
17
|
} from './utils.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -39,7 +40,7 @@ export function is_primitive_type(type) {
|
|
|
39
40
|
'boolean',
|
|
40
41
|
'integer',
|
|
41
42
|
'datetime',
|
|
42
|
-
'
|
|
43
|
+
'text',
|
|
43
44
|
'string_array',
|
|
44
45
|
'number_array',
|
|
45
46
|
'boolean_array',
|
|
@@ -74,8 +75,8 @@ export function get_property_default(property_definition) {
|
|
|
74
75
|
if (property_definition.type === 'integer') return 0;
|
|
75
76
|
if (property_definition.type === 'number') return 0;
|
|
76
77
|
if (property_definition.type === 'boolean') return false;
|
|
77
|
-
if (property_definition.type === '
|
|
78
|
-
if (property_definition.type === 'node_array') return [];
|
|
78
|
+
if (property_definition.type === 'text') return { content: '', marks: [], annotations: [] };
|
|
79
|
+
if (property_definition.type === 'node_array') return { nodes: [], marks: [], annotations: [] };
|
|
79
80
|
if (
|
|
80
81
|
property_definition.type === 'string_array' ||
|
|
81
82
|
property_definition.type === 'number_array' ||
|
|
@@ -161,6 +162,42 @@ export function validate_document_schema(document_schema) {
|
|
|
161
162
|
);
|
|
162
163
|
}
|
|
163
164
|
}
|
|
165
|
+
if (prop_def.type === 'text' || prop_def.type === 'node_array') {
|
|
166
|
+
// mark_types must reference kind 'mark', annotation_types kind 'annotation'
|
|
167
|
+
for (const [key, expected_kind] of [
|
|
168
|
+
['mark_types', 'mark'],
|
|
169
|
+
['annotation_types', 'annotation']
|
|
170
|
+
]) {
|
|
171
|
+
const invalid_types = (prop_def[key] ?? []).filter(
|
|
172
|
+
(ref_type) => document_schema[ref_type]?.kind !== expected_kind
|
|
173
|
+
);
|
|
174
|
+
if (invalid_types.length > 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Node type "${node_type}" property "${prop_name}" ${key} must reference node types of kind '${expected_kind}', got: ${invalid_types.join(', ')}.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validate that no annotation node type has a registered component.
|
|
187
|
+
*
|
|
188
|
+
* Marks render in-place via components; annotations are data-only overlay
|
|
189
|
+
* ranges that must be interpreted by the app (via CSS classes or props).
|
|
190
|
+
*
|
|
191
|
+
* @param {DocumentSchema} schema - The document schema
|
|
192
|
+
* @param {any} config - The Svedit config
|
|
193
|
+
* @throws {Error} Throws if a kind 'annotation' type has a registered component
|
|
194
|
+
*/
|
|
195
|
+
export function validate_config_components(schema, config) {
|
|
196
|
+
for (const [node_type, node_schema] of Object.entries(schema)) {
|
|
197
|
+
if (node_schema.kind === 'annotation' && config?.node_components?.[node_type]) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Annotation type "${node_type}" must not have a registered component. Annotations are data-only; use kind 'mark' for in-place rendered ranges.`
|
|
200
|
+
);
|
|
164
201
|
}
|
|
165
202
|
}
|
|
166
203
|
}
|
|
@@ -183,11 +220,12 @@ function validate_primitive_value(type, value) {
|
|
|
183
220
|
return Number.isInteger(value);
|
|
184
221
|
case 'datetime':
|
|
185
222
|
return typeof value === 'string' && !isNaN(Date.parse(value));
|
|
186
|
-
case '
|
|
223
|
+
case 'text':
|
|
187
224
|
return (
|
|
188
225
|
typeof value === 'object' &&
|
|
189
226
|
value !== null &&
|
|
190
|
-
typeof value.
|
|
227
|
+
typeof value.content === 'string' &&
|
|
228
|
+
Array.isArray(value.marks) &&
|
|
191
229
|
Array.isArray(value.annotations)
|
|
192
230
|
);
|
|
193
231
|
case 'string_array':
|
|
@@ -204,79 +242,153 @@ function validate_primitive_value(type, value) {
|
|
|
204
242
|
}
|
|
205
243
|
|
|
206
244
|
/**
|
|
207
|
-
* Validate
|
|
245
|
+
* Validate ranges (marks or annotations) for bounds, references, and allowed types.
|
|
208
246
|
*
|
|
209
247
|
* @param {string} node_id - Owner node id, used for error messages
|
|
210
248
|
* @param {string} prop_name - Owner property name, used for error messages
|
|
211
|
-
* @param {
|
|
212
|
-
* @param {
|
|
249
|
+
* @param {'mark' | 'annotation'} label - Range label, used for error messages
|
|
250
|
+
* @param {Array<Attachment>} ranges - Ranges to validate
|
|
251
|
+
* @param {number} container_length - Length of the annotated text or node array
|
|
252
|
+
* @param {Array<string> | null | undefined} allowed_types - Allowed node types
|
|
213
253
|
* @param {Record<string, any>} all_nodes - All document nodes
|
|
214
254
|
* @param {boolean} require_references - Whether referenced nodes must already exist
|
|
215
|
-
* @throws {Error} Throws if
|
|
255
|
+
* @throws {Error} Throws if ranges are invalid
|
|
216
256
|
*/
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
257
|
+
function validate_range_array(
|
|
258
|
+
node_id,
|
|
259
|
+
prop_name,
|
|
260
|
+
label,
|
|
261
|
+
ranges,
|
|
262
|
+
container_length,
|
|
263
|
+
allowed_types,
|
|
264
|
+
all_nodes,
|
|
265
|
+
require_references
|
|
266
|
+
) {
|
|
267
|
+
for (const [index, range] of ranges.entries()) {
|
|
222
268
|
if (
|
|
223
|
-
typeof
|
|
224
|
-
|
|
225
|
-
!Number.isInteger(
|
|
226
|
-
!Number.isInteger(
|
|
227
|
-
!is_id_valid(
|
|
269
|
+
typeof range !== 'object' ||
|
|
270
|
+
range === null ||
|
|
271
|
+
!Number.isInteger(range.start_offset) ||
|
|
272
|
+
!Number.isInteger(range.end_offset) ||
|
|
273
|
+
!is_id_valid(range.node_id)
|
|
228
274
|
) {
|
|
229
275
|
throw new Error(
|
|
230
|
-
`Node ${node_id} property ${prop_name} has an invalid
|
|
276
|
+
`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.`
|
|
231
277
|
);
|
|
232
278
|
}
|
|
233
279
|
|
|
234
|
-
if (
|
|
280
|
+
if (range.start_offset < 0 || range.end_offset > container_length) {
|
|
235
281
|
throw new Error(
|
|
236
|
-
`Node ${node_id} property ${prop_name}
|
|
282
|
+
`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}.`
|
|
237
283
|
);
|
|
238
284
|
}
|
|
239
285
|
|
|
240
|
-
if (
|
|
286
|
+
if (range.start_offset >= range.end_offset) {
|
|
241
287
|
throw new Error(
|
|
242
|
-
`Node ${node_id} property ${prop_name}
|
|
288
|
+
`Node ${node_id} property ${prop_name} ${label} ${range.node_id} must not be empty or reversed: ${range.start_offset}-${range.end_offset}.`
|
|
243
289
|
);
|
|
244
290
|
}
|
|
245
291
|
|
|
246
|
-
const referenced_node = all_nodes[
|
|
292
|
+
const referenced_node = all_nodes[range.node_id];
|
|
247
293
|
if (!referenced_node) {
|
|
248
294
|
if (require_references) {
|
|
249
295
|
throw new Error(
|
|
250
|
-
`Node ${node_id} property ${prop_name}
|
|
296
|
+
`Node ${node_id} property ${prop_name} ${label} references missing node ${range.node_id}.`
|
|
251
297
|
);
|
|
252
298
|
}
|
|
253
299
|
continue;
|
|
254
300
|
}
|
|
255
301
|
|
|
256
|
-
if (
|
|
302
|
+
if (allowed_types?.length && !allowed_types.includes(referenced_node.type)) {
|
|
257
303
|
throw new Error(
|
|
258
|
-
`Node ${node_id} property ${prop_name}
|
|
304
|
+
`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.`
|
|
259
305
|
);
|
|
260
306
|
}
|
|
261
307
|
}
|
|
308
|
+
}
|
|
262
309
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Validate marks and annotations of an annotated container (text or node array).
|
|
312
|
+
*
|
|
313
|
+
* Marks must be mutually exclusive; annotations may overlap.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} node_id - Owner node id, used for error messages
|
|
316
|
+
* @param {string} prop_name - Owner property name, used for error messages
|
|
317
|
+
* @param {{ marks: Array<Mark>, annotations: Array<Annotation> }} value - Annotated value to validate
|
|
318
|
+
* @param {{ mark_types?: string[], annotation_types?: string[] }} prop_def - Property definition
|
|
319
|
+
* @param {number} container_length - Length of the annotated text or node array
|
|
320
|
+
* @param {Record<string, any>} all_nodes - All document nodes
|
|
321
|
+
* @param {boolean} require_references - Whether referenced nodes must already exist
|
|
322
|
+
* @throws {Error} Throws if marks or annotations are invalid
|
|
323
|
+
*/
|
|
324
|
+
function validate_marks_and_annotations(
|
|
325
|
+
node_id,
|
|
326
|
+
prop_name,
|
|
327
|
+
value,
|
|
328
|
+
prop_def,
|
|
329
|
+
container_length,
|
|
330
|
+
all_nodes,
|
|
331
|
+
require_references
|
|
332
|
+
) {
|
|
333
|
+
validate_range_array(
|
|
334
|
+
node_id,
|
|
335
|
+
prop_name,
|
|
336
|
+
'mark',
|
|
337
|
+
value.marks,
|
|
338
|
+
container_length,
|
|
339
|
+
prop_def.mark_types,
|
|
340
|
+
all_nodes,
|
|
341
|
+
require_references
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
if (!are_ranges_exclusive(value.marks, container_length)) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Node ${node_id} property ${prop_name} has overlapping marks. Marks must be mutually exclusive.`
|
|
268
347
|
);
|
|
348
|
+
}
|
|
269
349
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
350
|
+
validate_range_array(
|
|
351
|
+
node_id,
|
|
352
|
+
prop_name,
|
|
353
|
+
'annotation',
|
|
354
|
+
value.annotations,
|
|
355
|
+
container_length,
|
|
356
|
+
prop_def.annotation_types,
|
|
357
|
+
all_nodes,
|
|
358
|
+
require_references
|
|
359
|
+
);
|
|
360
|
+
}
|
|
273
361
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Validate text property marks and annotations for bounds, references, allowed
|
|
364
|
+
* types, and mark exclusivity.
|
|
365
|
+
*
|
|
366
|
+
* @param {string} node_id - Owner node id, used for error messages
|
|
367
|
+
* @param {string} prop_name - Owner property name, used for error messages
|
|
368
|
+
* @param {AnnotatedText} value - Annotated text value to validate
|
|
369
|
+
* @param {TextProperty} prop_def - Text property definition
|
|
370
|
+
* @param {Record<string, any>} all_nodes - All document nodes
|
|
371
|
+
* @param {boolean} require_references - Whether referenced nodes must already exist
|
|
372
|
+
* @throws {Error} Throws if marks or annotations are invalid
|
|
373
|
+
*/
|
|
374
|
+
function validate_text_property(
|
|
375
|
+
node_id,
|
|
376
|
+
prop_name,
|
|
377
|
+
value,
|
|
378
|
+
prop_def,
|
|
379
|
+
all_nodes,
|
|
380
|
+
require_references
|
|
381
|
+
) {
|
|
382
|
+
const char_length = get_char_length(value.content);
|
|
383
|
+
validate_marks_and_annotations(
|
|
384
|
+
node_id,
|
|
385
|
+
prop_name,
|
|
386
|
+
value,
|
|
387
|
+
prop_def,
|
|
388
|
+
char_length,
|
|
389
|
+
all_nodes,
|
|
390
|
+
require_references
|
|
391
|
+
);
|
|
280
392
|
}
|
|
281
393
|
|
|
282
394
|
/**
|
|
@@ -318,12 +430,12 @@ export function validate_node(node, schema, all_nodes = {}, options = {}) {
|
|
|
318
430
|
);
|
|
319
431
|
}
|
|
320
432
|
}
|
|
321
|
-
if (prop_def.type === '
|
|
322
|
-
|
|
433
|
+
if (prop_def.type === 'text') {
|
|
434
|
+
validate_text_property(
|
|
323
435
|
node.id,
|
|
324
436
|
prop_name,
|
|
325
437
|
value,
|
|
326
|
-
/** @type {
|
|
438
|
+
/** @type {TextProperty} */ (prop_def),
|
|
327
439
|
all_nodes,
|
|
328
440
|
require_references
|
|
329
441
|
);
|
|
@@ -354,15 +466,27 @@ export function validate_node(node, schema, all_nodes = {}, options = {}) {
|
|
|
354
466
|
// Check node arrays
|
|
355
467
|
else if (prop_def.type === 'node_array') {
|
|
356
468
|
if (
|
|
357
|
-
!
|
|
358
|
-
|
|
469
|
+
!value ||
|
|
470
|
+
typeof value !== 'object' ||
|
|
471
|
+
!Array.isArray(value.nodes) ||
|
|
472
|
+
!Array.isArray(value.marks) ||
|
|
473
|
+
!Array.isArray(value.annotations)
|
|
359
474
|
) {
|
|
360
475
|
throw new Error(
|
|
361
|
-
`Node ${node.id} has an invalid property: ${prop_name} must be an
|
|
476
|
+
`Node ${node.id} has an invalid property: ${prop_name} must be an object with nodes, marks and annotations.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const node_array_nodes = value.nodes;
|
|
481
|
+
|
|
482
|
+
if (!node_array_nodes.every((id) => typeof id === 'string' && is_id_valid(id))) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`Node ${node.id} has an invalid property: ${prop_name} must contain valid node ids.`
|
|
362
485
|
);
|
|
363
486
|
}
|
|
487
|
+
|
|
364
488
|
// Check if all referenced nodes exist and are of allowed types
|
|
365
|
-
for (const ref_id of
|
|
489
|
+
for (const ref_id of node_array_nodes) {
|
|
366
490
|
const referenced_node = all_nodes[ref_id];
|
|
367
491
|
if (!referenced_node) {
|
|
368
492
|
if (require_references) {
|
|
@@ -378,6 +502,15 @@ export function validate_node(node, schema, all_nodes = {}, options = {}) {
|
|
|
378
502
|
);
|
|
379
503
|
}
|
|
380
504
|
}
|
|
505
|
+
validate_marks_and_annotations(
|
|
506
|
+
node.id,
|
|
507
|
+
prop_name,
|
|
508
|
+
value,
|
|
509
|
+
prop_def,
|
|
510
|
+
node_array_nodes.length,
|
|
511
|
+
all_nodes,
|
|
512
|
+
require_references
|
|
513
|
+
);
|
|
381
514
|
}
|
|
382
515
|
}
|
|
383
516
|
}
|
|
@@ -393,7 +526,13 @@ export function validate_document(doc, schema) {
|
|
|
393
526
|
if (!is_id_valid(doc.document_id)) {
|
|
394
527
|
throw new Error(`Document ${doc.document_id} has an invalid id.`);
|
|
395
528
|
}
|
|
396
|
-
for (const node of Object.
|
|
529
|
+
for (const [node_id, node] of Object.entries(doc.nodes)) {
|
|
530
|
+
if (!is_id_valid(node_id)) {
|
|
531
|
+
throw new Error(`Document node map contains an invalid id: ${node_id}.`);
|
|
532
|
+
}
|
|
533
|
+
if (node.id !== node_id) {
|
|
534
|
+
throw new Error(`Document node map key ${node_id} does not match node id ${node.id}.`);
|
|
535
|
+
}
|
|
397
536
|
validate_node(node, schema, doc.nodes);
|
|
398
537
|
}
|
|
399
538
|
}
|
|
@@ -424,9 +563,9 @@ export function get(schema, doc, path) {
|
|
|
424
563
|
if (property_type(schema, val.type, path_segment_str) === 'node_array') {
|
|
425
564
|
val = val[path_segment];
|
|
426
565
|
val_type = 'node_array';
|
|
427
|
-
} else if (property_type(schema, val.type, path_segment_str) === '
|
|
566
|
+
} else if (property_type(schema, val.type, path_segment_str) === 'text') {
|
|
428
567
|
val = val[path_segment];
|
|
429
|
-
val_type = '
|
|
568
|
+
val_type = 'text';
|
|
430
569
|
} else if (property_type(schema, val.type, path_segment_str) === 'node') {
|
|
431
570
|
val = doc.nodes[val[path_segment]];
|
|
432
571
|
val_type = 'node';
|
|
@@ -442,27 +581,48 @@ export function get(schema, doc, path) {
|
|
|
442
581
|
val_type = 'value';
|
|
443
582
|
}
|
|
444
583
|
} else if (val_type === 'node_array') {
|
|
584
|
+
if (path_segment === 'nodes') {
|
|
585
|
+
val = val.nodes;
|
|
586
|
+
val_type = 'node_id_array';
|
|
587
|
+
} else if (path_segment === 'marks') {
|
|
588
|
+
val = val.marks;
|
|
589
|
+
val_type = 'range_array';
|
|
590
|
+
} else if (path_segment === 'annotations') {
|
|
591
|
+
val = val.annotations;
|
|
592
|
+
val_type = 'range_array';
|
|
593
|
+
} else if (typeof path_segment === 'number' || /^\d+$/.test(String(path_segment))) {
|
|
594
|
+
val = doc.nodes[val.nodes[path_segment]];
|
|
595
|
+
val_type = 'node';
|
|
596
|
+
} else {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`Invalid path segment "${path_segment}" for node_array. Use "nodes", "marks" or "annotations".`
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
} else if (val_type === 'node_id_array') {
|
|
445
602
|
val = doc.nodes[val[path_segment]];
|
|
446
603
|
val_type = 'node';
|
|
447
604
|
} else if (val_type === 'value_array') {
|
|
448
605
|
val = val[path_segment];
|
|
449
606
|
val_type = 'value';
|
|
450
|
-
} else if (val_type === '
|
|
451
|
-
if (path_segment === '
|
|
452
|
-
val = val.
|
|
607
|
+
} else if (val_type === 'text') {
|
|
608
|
+
if (path_segment === 'content') {
|
|
609
|
+
val = val.content;
|
|
453
610
|
val_type = 'value';
|
|
611
|
+
} else if (path_segment === 'marks') {
|
|
612
|
+
val = val.marks;
|
|
613
|
+
val_type = 'range_array';
|
|
454
614
|
} else if (path_segment === 'annotations') {
|
|
455
615
|
val = val.annotations;
|
|
456
|
-
val_type = '
|
|
616
|
+
val_type = 'range_array';
|
|
457
617
|
} else {
|
|
458
618
|
throw new Error(
|
|
459
|
-
`Invalid path segment "${path_segment}" for
|
|
619
|
+
`Invalid path segment "${path_segment}" for text. Use "content", "marks" or "annotations".`
|
|
460
620
|
);
|
|
461
621
|
}
|
|
462
|
-
} else if (val_type === '
|
|
622
|
+
} else if (val_type === 'range_array') {
|
|
463
623
|
val = val[path_segment];
|
|
464
|
-
val_type = '
|
|
465
|
-
} else if (val_type === '
|
|
624
|
+
val_type = 'range';
|
|
625
|
+
} else if (val_type === 'range') {
|
|
466
626
|
if (path_segment === 'node_id') {
|
|
467
627
|
val = doc.nodes[val.node_id];
|
|
468
628
|
val_type = 'node';
|
|
@@ -474,7 +634,7 @@ export function get(schema, doc, path) {
|
|
|
474
634
|
val_type = 'value';
|
|
475
635
|
} else {
|
|
476
636
|
throw new Error(
|
|
477
|
-
`Invalid path segment "${path_segment}" for
|
|
637
|
+
`Invalid path segment "${path_segment}" for range. Use "start_offset", "end_offset", or "node_id".`
|
|
478
638
|
);
|
|
479
639
|
}
|
|
480
640
|
}
|
|
@@ -505,41 +665,16 @@ export function property_type(schema, type, property) {
|
|
|
505
665
|
}
|
|
506
666
|
|
|
507
667
|
/**
|
|
508
|
-
* Determines the kind of a node ('block', 'text', or 'annotation').
|
|
668
|
+
* Determines the kind of a node ('block', 'text', 'mark', or 'annotation').
|
|
509
669
|
*
|
|
510
670
|
* @param {object} schema - The document schema
|
|
511
671
|
* @param {any} node - The node to check
|
|
512
|
-
* @returns {'block'|'text'|'annotation'} The node kind
|
|
672
|
+
* @returns {'block'|'text'|'mark'|'annotation'} The node kind
|
|
513
673
|
*/
|
|
514
674
|
export function kind(schema, node) {
|
|
515
675
|
return schema[node.type].kind;
|
|
516
676
|
}
|
|
517
677
|
|
|
518
|
-
/**
|
|
519
|
-
* Checks whether an annotation node can be switched to another annotation type
|
|
520
|
-
* without losing data.
|
|
521
|
-
*
|
|
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
|
-
|
|
543
678
|
/**
|
|
544
679
|
* Inspects a path to get metadata about the value at that location.
|
|
545
680
|
*
|
|
@@ -569,45 +704,62 @@ export function inspect(schema, doc, path) {
|
|
|
569
704
|
}
|
|
570
705
|
|
|
571
706
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
707
|
+
* Creates a mutable draft of a document: a new document object with a new
|
|
708
|
+
* nodes map, sharing the (immutable) node objects with the original.
|
|
574
709
|
*
|
|
575
|
-
*
|
|
710
|
+
* apply_op_to_draft mutates the draft's nodes map but copies node objects
|
|
711
|
+
* before changing them, so node identity is preserved for unchanged nodes
|
|
712
|
+
* (fine-grained rendering relies on this) and the original document is
|
|
713
|
+
* never touched.
|
|
714
|
+
*
|
|
715
|
+
* @param {Document} doc - The document to create a draft of
|
|
716
|
+
* @returns {Document} A draft document safe to mutate via apply_op_to_draft
|
|
717
|
+
*/
|
|
718
|
+
export function create_document_draft(doc) {
|
|
719
|
+
return {
|
|
720
|
+
...doc,
|
|
721
|
+
nodes: { ...doc.nodes }
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Applies an operation to a document draft in place.
|
|
727
|
+
*
|
|
728
|
+
* The draft's nodes map is mutated, but node objects are copied on write —
|
|
729
|
+
* one nodes-map copy per draft (see create_document_draft) replaces the
|
|
730
|
+
* previous one-copy-per-op behavior, which made transactions with many ops
|
|
731
|
+
* (paste, cascade delete) O(N·ops).
|
|
732
|
+
*
|
|
733
|
+
* @param {Document} draft - A draft created via create_document_draft
|
|
576
734
|
* @param {Array} op - The operation to apply [type, ...args]
|
|
577
|
-
* @returns {Document} The
|
|
735
|
+
* @returns {Document} The same draft, for convenience
|
|
578
736
|
*/
|
|
579
|
-
export function
|
|
737
|
+
export function apply_op_to_draft(draft, op) {
|
|
580
738
|
const [type, ...args] = op;
|
|
581
739
|
if (type === 'set') {
|
|
582
740
|
const [node_id, property] = args[0];
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
nodes: {
|
|
587
|
-
...doc.nodes,
|
|
588
|
-
[node_id]: {
|
|
589
|
-
...doc.nodes[node_id],
|
|
590
|
-
[property]: value
|
|
591
|
-
}
|
|
592
|
-
}
|
|
741
|
+
draft.nodes[node_id] = {
|
|
742
|
+
...draft.nodes[node_id],
|
|
743
|
+
[property]: structuredClone(args[1])
|
|
593
744
|
};
|
|
594
745
|
} else if (type === 'create') {
|
|
595
|
-
|
|
596
|
-
...doc,
|
|
597
|
-
nodes: {
|
|
598
|
-
...doc.nodes,
|
|
599
|
-
[args[0].id]: structuredClone(args[0])
|
|
600
|
-
}
|
|
601
|
-
};
|
|
746
|
+
draft.nodes[args[0].id] = structuredClone(args[0]);
|
|
602
747
|
} else if (type === 'delete') {
|
|
603
|
-
|
|
604
|
-
const { [args[0]]: _removed, ...remaining_nodes } = doc.nodes;
|
|
605
|
-
return {
|
|
606
|
-
...doc,
|
|
607
|
-
nodes: remaining_nodes
|
|
608
|
-
};
|
|
748
|
+
delete draft.nodes[args[0]];
|
|
609
749
|
}
|
|
610
|
-
return
|
|
750
|
+
return draft;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Applies an operation to a document and returns the new document.
|
|
755
|
+
* Uses copy-on-write semantics.
|
|
756
|
+
*
|
|
757
|
+
* @param {Document} doc - The document to apply the operation to
|
|
758
|
+
* @param {Array} op - The operation to apply [type, ...args]
|
|
759
|
+
* @returns {Document} The new document with the operation applied
|
|
760
|
+
*/
|
|
761
|
+
export function apply_op(doc, op) {
|
|
762
|
+
return apply_op_to_draft(create_document_draft(doc), op);
|
|
611
763
|
}
|
|
612
764
|
|
|
613
765
|
/**
|
|
@@ -627,11 +779,17 @@ export function count_references(schema, doc, node_id) {
|
|
|
627
779
|
|
|
628
780
|
const prop_type = property_type(schema, node.type, property);
|
|
629
781
|
|
|
630
|
-
if (prop_type === 'node_array'
|
|
631
|
-
count += value.filter((id) => id === node_id).length;
|
|
782
|
+
if (prop_type === 'node_array') {
|
|
783
|
+
count += value.nodes.filter((id) => id === node_id).length;
|
|
632
784
|
} else if (prop_type === 'node' && value === node_id) {
|
|
633
785
|
count += 1;
|
|
634
786
|
}
|
|
787
|
+
|
|
788
|
+
if ((prop_type === 'text' || prop_type === 'node_array') && value) {
|
|
789
|
+
count += [...value.marks, ...value.annotations].filter(
|
|
790
|
+
(range) => range.node_id === node_id
|
|
791
|
+
).length;
|
|
792
|
+
}
|
|
635
793
|
}
|
|
636
794
|
}
|
|
637
795
|
|
|
@@ -639,41 +797,93 @@ export function count_references(schema, doc, node_id) {
|
|
|
639
797
|
}
|
|
640
798
|
|
|
641
799
|
/**
|
|
642
|
-
*
|
|
800
|
+
* @param {DocumentSchema} schema - The document schema
|
|
801
|
+
* @param {string} from_type - Current mark type
|
|
802
|
+
* @param {string} to_type - Target mark type
|
|
803
|
+
* @returns {boolean} Whether a mark can be switched between these types
|
|
804
|
+
*/
|
|
805
|
+
export function can_switch_mark_type(schema, from_type, to_type) {
|
|
806
|
+
const from_schema = schema[from_type];
|
|
807
|
+
const to_schema = schema[to_type];
|
|
808
|
+
return (
|
|
809
|
+
from_schema?.kind === 'mark' &&
|
|
810
|
+
to_schema?.kind === 'mark' &&
|
|
811
|
+
Object.keys(from_schema.properties ?? {}).length === 0 &&
|
|
812
|
+
Object.keys(to_schema.properties ?? {}).length === 0
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Gets ranges of the given key ('marks' or 'annotations') touched by the
|
|
818
|
+
* current selection.
|
|
643
819
|
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
820
|
+
* Non-collapsed selections use strict half-open range intersection, so merely
|
|
821
|
+
* adjacent boundaries do not count as touching. Collapsed text carets are only
|
|
822
|
+
* inside a range when they are strictly between start and end; a caret at
|
|
823
|
+
* either boundary is not active.
|
|
648
824
|
*
|
|
649
825
|
* @param {DocumentSchema} schema - The document schema
|
|
650
826
|
* @param {Document} doc - The document containing nodes
|
|
651
|
-
* @param {Selection}
|
|
652
|
-
* @param {
|
|
653
|
-
* @returns {
|
|
827
|
+
* @param {Selection | null | undefined} selection - The current selection
|
|
828
|
+
* @param {'marks' | 'annotations'} key - Which range array to inspect
|
|
829
|
+
* @returns {(Attachment & { index: number, node: any })[]} Selected attachment records
|
|
654
830
|
*/
|
|
655
|
-
|
|
656
|
-
if (selection?.type !== 'text') return
|
|
831
|
+
function get_selected_ranges(schema, doc, selection, key) {
|
|
832
|
+
if (selection?.type !== 'text' && selection?.type !== 'node') return [];
|
|
657
833
|
const range = get_selection_range(selection);
|
|
658
|
-
if (!range) return
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
834
|
+
if (!range) return [];
|
|
835
|
+
|
|
836
|
+
const annotated_prop = get(schema, doc, selection.path);
|
|
837
|
+
const ranges = annotated_prop?.[key] ?? [];
|
|
838
|
+
const is_collapsed = range.start_offset === range.end_offset;
|
|
839
|
+
|
|
840
|
+
return ranges
|
|
841
|
+
.map((/** @type {Attachment} */ attachment, index) => {
|
|
842
|
+
const node = doc.nodes[attachment.node_id];
|
|
843
|
+
return {
|
|
844
|
+
...attachment,
|
|
845
|
+
index,
|
|
846
|
+
node
|
|
847
|
+
};
|
|
848
|
+
})
|
|
849
|
+
.filter(({ start_offset, end_offset }) => {
|
|
850
|
+
if (is_collapsed) {
|
|
851
|
+
return start_offset < range.start_offset && end_offset > range.start_offset;
|
|
852
|
+
}
|
|
853
|
+
return start_offset < range.end_offset && end_offset > range.start_offset;
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Gets marks touched by the current selection.
|
|
859
|
+
*
|
|
860
|
+
* @param {DocumentSchema} schema - The document schema
|
|
861
|
+
* @param {Document} doc - The document containing nodes
|
|
862
|
+
* @param {Selection} [selection] - The current selection
|
|
863
|
+
* @returns {(Mark & { index: number, node: any })[]} Selected mark records
|
|
864
|
+
*/
|
|
865
|
+
export function get_selected_marks(schema, doc, selection) {
|
|
866
|
+
return get_selected_ranges(schema, doc, selection, 'marks');
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Gets annotations touched by the current selection.
|
|
871
|
+
*
|
|
872
|
+
* @param {DocumentSchema} schema - The document schema
|
|
873
|
+
* @param {Document} doc - The document containing nodes
|
|
874
|
+
* @param {Selection} [selection] - The current selection
|
|
875
|
+
* @returns {(Annotation & { index: number, node: any })[]} Selected annotation records
|
|
876
|
+
*/
|
|
877
|
+
export function get_selected_annotations(schema, doc, selection) {
|
|
878
|
+
return get_selected_ranges(schema, doc, selection, 'annotations');
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* @param {(Attachment & { index: number, node: any })[]} selected_ranges
|
|
883
|
+
* @returns {Set<string>} Node types represented in the selected ranges
|
|
884
|
+
*/
|
|
885
|
+
export function get_selected_range_types(selected_ranges) {
|
|
886
|
+
return new Set(selected_ranges.map(({ node }) => node?.type).filter(Boolean));
|
|
677
887
|
}
|
|
678
888
|
|
|
679
889
|
/**
|
|
@@ -696,13 +906,15 @@ export function count_references_excluding_deleted(schema, doc, target_node_id,
|
|
|
696
906
|
|
|
697
907
|
const prop_type = property_type(schema, node.type, property);
|
|
698
908
|
|
|
699
|
-
if (prop_type === 'node_array'
|
|
700
|
-
count += value.filter((id) => id === target_node_id).length;
|
|
909
|
+
if (prop_type === 'node_array') {
|
|
910
|
+
count += value.nodes.filter((id) => id === target_node_id).length;
|
|
701
911
|
} else if (prop_type === 'node' && value === target_node_id) {
|
|
702
912
|
count += 1;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if ((prop_type === 'text' || prop_type === 'node_array') && value) {
|
|
916
|
+
count += [...value.marks, ...value.annotations].filter(
|
|
917
|
+
(range) => range.node_id === target_node_id
|
|
706
918
|
).length;
|
|
707
919
|
}
|
|
708
920
|
}
|
|
@@ -711,6 +923,60 @@ export function count_references_excluding_deleted(schema, doc, target_node_id,
|
|
|
711
923
|
return count;
|
|
712
924
|
}
|
|
713
925
|
|
|
926
|
+
/**
|
|
927
|
+
* Iterates all node references going out of a node, invoking the visitor
|
|
928
|
+
* once per occurrence (multiplicity matters for reference counting).
|
|
929
|
+
* Covers the same reference kinds as count_references_excluding_deleted:
|
|
930
|
+
* a `node` property, `node_array` .nodes ids, and the mark/annotation
|
|
931
|
+
* node_ids carried on `text` and `node_array` properties.
|
|
932
|
+
*
|
|
933
|
+
* @param {DocumentSchema} schema - The document schema
|
|
934
|
+
* @param {any} node - The node whose outgoing references to visit
|
|
935
|
+
* @param {(referenced_id: NodeId) => void} visit - Called per reference occurrence
|
|
936
|
+
*/
|
|
937
|
+
export function visit_node_references(schema, node, visit) {
|
|
938
|
+
const node_schema = schema[node.type];
|
|
939
|
+
if (!node_schema) return;
|
|
940
|
+
|
|
941
|
+
for (const [property, prop_def] of Object.entries(node_schema.properties)) {
|
|
942
|
+
const value = node[property];
|
|
943
|
+
if (value === undefined || value === null) continue;
|
|
944
|
+
|
|
945
|
+
const prop_type = prop_def.type;
|
|
946
|
+
|
|
947
|
+
if (prop_type === 'node_array') {
|
|
948
|
+
for (const id of value.nodes) visit(id);
|
|
949
|
+
} else if (prop_type === 'node' && typeof value === 'string') {
|
|
950
|
+
visit(value);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if ((prop_type === 'text' || prop_type === 'node_array') && value) {
|
|
954
|
+
for (const range of value.marks) visit(range.node_id);
|
|
955
|
+
for (const range of value.annotations) visit(range.node_id);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Builds reference counts for every referenced node in one full-document
|
|
962
|
+
* scan. Used by cascade deletion so the fixpoint can decrement counts
|
|
963
|
+
* incrementally instead of re-scanning the document per candidate node.
|
|
964
|
+
*
|
|
965
|
+
* @param {DocumentSchema} schema - The document schema
|
|
966
|
+
* @param {Document} doc - The document containing nodes
|
|
967
|
+
* @returns {Map<NodeId, number>} Reference count per referenced node id
|
|
968
|
+
*/
|
|
969
|
+
export function build_reference_counts(schema, doc) {
|
|
970
|
+
/** @type {Map<NodeId, number>} */
|
|
971
|
+
const counts = new Map();
|
|
972
|
+
for (const node of Object.values(doc.nodes)) {
|
|
973
|
+
visit_node_references(schema, node, (id) => {
|
|
974
|
+
counts.set(id, (counts.get(id) || 0) + 1);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
return counts;
|
|
978
|
+
}
|
|
979
|
+
|
|
714
980
|
/**
|
|
715
981
|
* Gets ids of nodes that reference any of the target nodes.
|
|
716
982
|
*
|
|
@@ -730,16 +996,22 @@ export function get_referencing_node_ids(schema, doc, target_node_ids) {
|
|
|
730
996
|
|
|
731
997
|
const prop_type = property_type(schema, node.type, property);
|
|
732
998
|
|
|
733
|
-
if (prop_type === 'node_array'
|
|
734
|
-
if (value.some((id) => target_ids.has(id))) {
|
|
999
|
+
if (prop_type === 'node_array') {
|
|
1000
|
+
if (value.nodes.some((id) => target_ids.has(id))) {
|
|
735
1001
|
referencing_node_ids.add(node.id);
|
|
736
1002
|
}
|
|
737
1003
|
} else if (prop_type === 'node' && target_ids.has(value)) {
|
|
738
1004
|
referencing_node_ids.add(node.id);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (
|
|
1008
|
+
(prop_type === 'text' || prop_type === 'node_array') &&
|
|
1009
|
+
value &&
|
|
1010
|
+
[...value.marks, ...value.annotations].some((range) =>
|
|
1011
|
+
target_ids.has(range.node_id)
|
|
1012
|
+
)
|
|
1013
|
+
) {
|
|
1014
|
+
referencing_node_ids.add(node.id);
|
|
743
1015
|
}
|
|
744
1016
|
}
|
|
745
1017
|
}
|
|
@@ -764,13 +1036,13 @@ export function validate_selection(selection, session_or_transaction) {
|
|
|
764
1036
|
}
|
|
765
1037
|
|
|
766
1038
|
if (selection_type === 'node') {
|
|
767
|
-
const
|
|
1039
|
+
const node_array_prop = session_or_transaction.get(selection.path);
|
|
768
1040
|
|
|
769
|
-
if (!Array.isArray(
|
|
1041
|
+
if (!node_array_prop || !Array.isArray(node_array_prop.nodes)) {
|
|
770
1042
|
throw new Error('Node selection path must point to a node_array');
|
|
771
1043
|
}
|
|
772
1044
|
|
|
773
|
-
const max_offset =
|
|
1045
|
+
const max_offset = node_array_prop.nodes.length;
|
|
774
1046
|
if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
|
|
775
1047
|
throw new Error(
|
|
776
1048
|
`Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
|
|
@@ -782,13 +1054,13 @@ export function validate_selection(selection, session_or_transaction) {
|
|
|
782
1054
|
);
|
|
783
1055
|
}
|
|
784
1056
|
} else if (selection_type === 'text') {
|
|
785
|
-
const
|
|
1057
|
+
const text = session_or_transaction.get(selection.path);
|
|
786
1058
|
|
|
787
|
-
if (!
|
|
788
|
-
throw new Error('Text selection path must point to
|
|
1059
|
+
if (!text || typeof text.content !== 'string') {
|
|
1060
|
+
throw new Error('Text selection path must point to text');
|
|
789
1061
|
}
|
|
790
1062
|
|
|
791
|
-
const char_length = get_char_length(
|
|
1063
|
+
const char_length = get_char_length(text.content);
|
|
792
1064
|
if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
|
|
793
1065
|
throw new Error(
|
|
794
1066
|
`Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`
|