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
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import Transaction from './Transaction.svelte.js';
|
|
2
|
-
import { char_slice, traverse } from './utils.js';
|
|
2
|
+
import { char_slice, traverse, traverse_ids } from './utils.js';
|
|
3
3
|
import {
|
|
4
4
|
get as doc_get,
|
|
5
5
|
property_type as doc_property_type,
|
|
6
6
|
kind as doc_kind,
|
|
7
7
|
inspect as doc_inspect,
|
|
8
|
-
|
|
8
|
+
create_document_draft,
|
|
9
|
+
apply_op_to_draft,
|
|
9
10
|
count_references as doc_count_references,
|
|
10
11
|
validate_document_schema,
|
|
11
12
|
validate_document,
|
|
13
|
+
validate_config_components,
|
|
12
14
|
validate_node,
|
|
15
|
+
is_id_valid,
|
|
13
16
|
get_referencing_node_ids,
|
|
14
|
-
|
|
17
|
+
get_selected_marks,
|
|
18
|
+
get_selected_annotations,
|
|
15
19
|
validate_selection
|
|
16
20
|
} from './doc_utils.js';
|
|
17
21
|
|
|
@@ -20,6 +24,8 @@ import {
|
|
|
20
24
|
* NodeId,
|
|
21
25
|
* DocumentPath,
|
|
22
26
|
* Selection,
|
|
27
|
+
* Attachment,
|
|
28
|
+
* Mark,
|
|
23
29
|
* Annotation,
|
|
24
30
|
* NodeKind,
|
|
25
31
|
* DocumentSchema,
|
|
@@ -63,8 +69,14 @@ export default class Session {
|
|
|
63
69
|
|
|
64
70
|
// Reactive variable for selected node
|
|
65
71
|
selected_node = $derived(this.get_selected_node());
|
|
72
|
+
available_mark_types = $derived(this.get_available_mark_types());
|
|
66
73
|
available_annotation_types = $derived(this.get_available_annotation_types());
|
|
67
|
-
|
|
74
|
+
selected_marks = $derived(get_selected_marks(this.schema, this.doc, this.selection));
|
|
75
|
+
active_mark = $derived(this.selected_marks.length === 1 ? this.selected_marks[0] : null);
|
|
76
|
+
selected_annotations = $derived(get_selected_annotations(this.schema, this.doc, this.selection));
|
|
77
|
+
active_annotation = $derived(
|
|
78
|
+
this.selected_annotations.length === 1 ? this.selected_annotations[0] : null
|
|
79
|
+
);
|
|
68
80
|
|
|
69
81
|
/**
|
|
70
82
|
* @param {DocumentSchema} schema - The document schema
|
|
@@ -81,6 +93,7 @@ export default class Session {
|
|
|
81
93
|
this.doc = doc;
|
|
82
94
|
this.config = config;
|
|
83
95
|
validate_document(this.doc, this.schema);
|
|
96
|
+
validate_config_components(this.schema, this.config);
|
|
84
97
|
|
|
85
98
|
// Set selection after doc is initialized so validation can work properly
|
|
86
99
|
this.selection = options.selection ?? null;
|
|
@@ -136,23 +149,33 @@ export default class Session {
|
|
|
136
149
|
*/
|
|
137
150
|
validate_transaction_result(transaction) {
|
|
138
151
|
const doc = transaction.doc;
|
|
139
|
-
/** @type {NodeId
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
/** @type {Set<NodeId>} */
|
|
153
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
|
|
154
|
+
const affected_node_ids = new Set([
|
|
155
|
+
...transaction.created_node_ids,
|
|
156
|
+
...transaction.modified_node_ids
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
// A full-document referrer scan is only needed when a reference held
|
|
160
|
+
// by an UNTOUCHED node can have become invalid:
|
|
161
|
+
// - deletions can leave dangling references behind
|
|
162
|
+
// - a 'type' change can violate a referrer's node/mark/annotation type
|
|
163
|
+
// constraint
|
|
164
|
+
// References to created nodes can only live in nodes that were
|
|
165
|
+
// themselves created or modified in this transaction (the reference
|
|
166
|
+
// did not exist before), and modifying a node's other properties
|
|
167
|
+
// cannot invalidate its referrers — so those cases are already
|
|
168
|
+
// covered by the affected set and need no scan.
|
|
169
|
+
if (transaction.deleted_node_ids.length > 0 || transaction.changed_node_types) {
|
|
170
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
|
|
171
|
+
const scan_targets = new Set([
|
|
172
|
+
...transaction.created_node_ids,
|
|
173
|
+
...transaction.modified_node_ids,
|
|
174
|
+
...transaction.deleted_node_ids
|
|
175
|
+
]);
|
|
176
|
+
for (const node_id of get_referencing_node_ids(this.schema, doc, scan_targets)) {
|
|
177
|
+
affected_node_ids.add(node_id);
|
|
178
|
+
}
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
for (const node_id of affected_node_ids) {
|
|
@@ -162,11 +185,13 @@ export default class Session {
|
|
|
162
185
|
}
|
|
163
186
|
|
|
164
187
|
generate_id() {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
|
|
189
|
+
if (!is_id_valid(id)) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Generated node id ${JSON.stringify(id)} is invalid. Node ids must be non-empty strings that start with a letter or underscore, contain only letters, numbers, underscores, or dashes, and must not contain "__".`
|
|
192
|
+
);
|
|
169
193
|
}
|
|
194
|
+
return id;
|
|
170
195
|
}
|
|
171
196
|
|
|
172
197
|
/**
|
|
@@ -187,11 +212,16 @@ export default class Session {
|
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
214
|
|
|
215
|
+
get_available_mark_types() {
|
|
216
|
+
if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
|
|
217
|
+
const property_definition = this.inspect(this.selection.path);
|
|
218
|
+
return property_definition.mark_types || [];
|
|
219
|
+
}
|
|
220
|
+
|
|
190
221
|
get_available_annotation_types() {
|
|
191
|
-
if (this.selection?.type !== 'text') return [];
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
return property_definition.node_types || [];
|
|
222
|
+
if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
|
|
223
|
+
const property_definition = this.inspect(this.selection.path);
|
|
224
|
+
return property_definition.annotation_types || [];
|
|
195
225
|
}
|
|
196
226
|
|
|
197
227
|
// Helper function to get the currently selected node
|
|
@@ -204,7 +234,7 @@ export default class Session {
|
|
|
204
234
|
// Only consider selection of a single node
|
|
205
235
|
if (end - start !== 1) return null;
|
|
206
236
|
const node_array = this.get(this.selection.path);
|
|
207
|
-
const node_id = node_array[start];
|
|
237
|
+
const node_id = node_array.nodes[start];
|
|
208
238
|
return node_id ? this.get(node_id) : null;
|
|
209
239
|
} else {
|
|
210
240
|
// we are assuming we are either in a text or property (=custom) selection
|
|
@@ -235,7 +265,13 @@ export default class Session {
|
|
|
235
265
|
*/
|
|
236
266
|
apply(transaction, { batch = false } = {}) {
|
|
237
267
|
this.validate_transaction_result(transaction);
|
|
238
|
-
|
|
268
|
+
// Only swap the doc when something changed. The transaction draft is
|
|
269
|
+
// always a fresh object, and assigning it for an ops-less transaction
|
|
270
|
+
// (e.g. selection-only) would needlessly invalidate every doc-dependent
|
|
271
|
+
// derived in the component tree.
|
|
272
|
+
if (transaction.ops.length > 0) {
|
|
273
|
+
this.doc = transaction.doc;
|
|
274
|
+
}
|
|
239
275
|
// Make sure selection gets a new reference (is rerendered)
|
|
240
276
|
this.selection = structuredClone(transaction.selection);
|
|
241
277
|
if (this.history_index < this.history.length - 1) {
|
|
@@ -284,12 +320,14 @@ export default class Session {
|
|
|
284
320
|
return;
|
|
285
321
|
}
|
|
286
322
|
const change = this.history[this.history_index];
|
|
287
|
-
|
|
323
|
+
// One draft for the whole change set — ops mutate the draft with
|
|
324
|
+
// node-level copy-on-write instead of copying the nodes map per op.
|
|
325
|
+
const doc = create_document_draft(this.doc);
|
|
288
326
|
change.inverse_ops
|
|
289
327
|
.slice()
|
|
290
328
|
.reverse()
|
|
291
329
|
.forEach((op) => {
|
|
292
|
-
|
|
330
|
+
apply_op_to_draft(doc, op);
|
|
293
331
|
});
|
|
294
332
|
this.doc = doc;
|
|
295
333
|
this.selection = change.selection_before;
|
|
@@ -303,9 +341,9 @@ export default class Session {
|
|
|
303
341
|
}
|
|
304
342
|
this.history_index = this.history_index + 1;
|
|
305
343
|
const change = this.history[this.history_index];
|
|
306
|
-
|
|
344
|
+
const doc = create_document_draft(this.doc);
|
|
307
345
|
change.ops.forEach((op) => {
|
|
308
|
-
|
|
346
|
+
apply_op_to_draft(doc, op);
|
|
309
347
|
});
|
|
310
348
|
this.doc = doc;
|
|
311
349
|
this.selection = change.selection_after;
|
|
@@ -329,8 +367,8 @@ export default class Session {
|
|
|
329
367
|
* session.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
|
|
330
368
|
*
|
|
331
369
|
* @example
|
|
332
|
-
* // Get
|
|
333
|
-
* session.get(['page_1', 'cover', 'title']) // => {
|
|
370
|
+
* // Get a text property
|
|
371
|
+
* session.get(['page_1', 'cover', 'title']) // => {content: 'Hello world', marks: [], annotations: []}
|
|
334
372
|
*/
|
|
335
373
|
get(path) {
|
|
336
374
|
return doc_get(this.schema, this.doc, path);
|
|
@@ -368,7 +406,7 @@ export default class Session {
|
|
|
368
406
|
|
|
369
407
|
/**
|
|
370
408
|
* Determines the kind of a node ('block' for structured blocks, 'text' for pure
|
|
371
|
-
* text nodes or 'annotation' for annotation nodes.
|
|
409
|
+
* text nodes, 'mark' for mark nodes or 'annotation' for annotation nodes.
|
|
372
410
|
* @param {any} node
|
|
373
411
|
* @returns {NodeKind}
|
|
374
412
|
*/
|
|
@@ -417,46 +455,82 @@ export default class Session {
|
|
|
417
455
|
};
|
|
418
456
|
}
|
|
419
457
|
|
|
420
|
-
|
|
421
|
-
* Returns the annotation object that is currently "under the cursor".
|
|
422
|
-
* NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
|
|
423
|
-
*
|
|
424
|
-
* @param {string} [annotation_type] Optional annotation type to filter by
|
|
425
|
-
* @returns {Annotation|null}
|
|
426
|
-
*/
|
|
427
|
-
active_annotation(annotation_type) {
|
|
428
|
-
return get_active_annotation(this.schema, this.doc, this.selection, annotation_type);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
get_selected_annotated_text() {
|
|
458
|
+
get_selected_text() {
|
|
432
459
|
if (this.selection?.type !== 'text') return null;
|
|
433
460
|
|
|
434
461
|
const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
435
462
|
const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
436
|
-
const
|
|
437
|
-
const
|
|
463
|
+
const text_value = this.get(this.selection.path);
|
|
464
|
+
const selected_text = char_slice(text_value.content, selection_start, selection_end);
|
|
438
465
|
const nodes = {};
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
466
|
+
|
|
467
|
+
const clip_ranges = (ranges) =>
|
|
468
|
+
ranges
|
|
469
|
+
.map((range) => {
|
|
470
|
+
if (selection_start < range.end_offset && selection_end > range.start_offset) {
|
|
471
|
+
const sub_graph = this.traverse(range.node_id);
|
|
472
|
+
for (const node of sub_graph) {
|
|
473
|
+
if (!nodes[node.id]) {
|
|
474
|
+
nodes[node.id] = node;
|
|
475
|
+
}
|
|
446
476
|
}
|
|
477
|
+
return {
|
|
478
|
+
start_offset: Math.max(range.start_offset - selection_start, 0),
|
|
479
|
+
end_offset: Math.min(
|
|
480
|
+
range.end_offset - selection_start,
|
|
481
|
+
selection_end - selection_start
|
|
482
|
+
),
|
|
483
|
+
node_id: range.node_id
|
|
484
|
+
};
|
|
485
|
+
} else {
|
|
486
|
+
return null;
|
|
447
487
|
}
|
|
488
|
+
})
|
|
489
|
+
.filter(Boolean);
|
|
490
|
+
|
|
491
|
+
const marks = clip_ranges(text_value.marks);
|
|
492
|
+
const annotations = clip_ranges(text_value.annotations);
|
|
493
|
+
|
|
494
|
+
return { content: selected_text, marks, annotations, nodes };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
get_selected_annotated_nodes() {
|
|
498
|
+
if (this.selection?.type !== 'node') return null;
|
|
499
|
+
|
|
500
|
+
const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
501
|
+
const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
502
|
+
const node_array = this.get(this.selection.path);
|
|
503
|
+
const main_nodes = node_array.nodes.slice(selection_start, selection_end);
|
|
504
|
+
const nodes = {};
|
|
505
|
+
|
|
506
|
+
const add_subgraph = (node_id) => {
|
|
507
|
+
for (const node of this.traverse(node_id)) {
|
|
508
|
+
if (!nodes[node.id]) nodes[node.id] = node;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
for (const node_id of main_nodes) add_subgraph(node_id);
|
|
513
|
+
|
|
514
|
+
const clip_ranges = (ranges) =>
|
|
515
|
+
ranges
|
|
516
|
+
.map((range) => {
|
|
517
|
+
if (selection_start >= range.end_offset || selection_end <= range.start_offset) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
add_subgraph(range.node_id);
|
|
448
522
|
return {
|
|
449
|
-
start_offset: Math.max(
|
|
450
|
-
end_offset: Math.min(
|
|
451
|
-
node_id:
|
|
523
|
+
start_offset: Math.max(range.start_offset - selection_start, 0),
|
|
524
|
+
end_offset: Math.min(range.end_offset - selection_start, selection_end - selection_start),
|
|
525
|
+
node_id: range.node_id
|
|
452
526
|
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
})
|
|
457
|
-
.filter(Boolean);
|
|
527
|
+
})
|
|
528
|
+
.filter(Boolean);
|
|
458
529
|
|
|
459
|
-
|
|
530
|
+
const marks = clip_ranges(node_array.marks);
|
|
531
|
+
const annotations = clip_ranges(node_array.annotations);
|
|
532
|
+
|
|
533
|
+
return { nodes, main_nodes, marks, annotations };
|
|
460
534
|
}
|
|
461
535
|
|
|
462
536
|
// TODO: think about ways how we can also turn a node selection into plain text.
|
|
@@ -465,8 +539,8 @@ export default class Session {
|
|
|
465
539
|
|
|
466
540
|
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
467
541
|
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
468
|
-
const
|
|
469
|
-
return char_slice(
|
|
542
|
+
const text = this.get(this.selection.path);
|
|
543
|
+
return char_slice(text.content, start, end);
|
|
470
544
|
}
|
|
471
545
|
|
|
472
546
|
get_selected_nodes() {
|
|
@@ -475,7 +549,9 @@ export default class Session {
|
|
|
475
549
|
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
476
550
|
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
477
551
|
const node_array = this.get(this.selection.path);
|
|
478
|
-
|
|
552
|
+
// node_array.nodes is a plain array of id strings (doc is $state.raw),
|
|
553
|
+
// so a slice is already a safe fresh copy.
|
|
554
|
+
return node_array.nodes.slice(start, end);
|
|
479
555
|
}
|
|
480
556
|
|
|
481
557
|
select_parent() {
|
|
@@ -528,7 +604,11 @@ export default class Session {
|
|
|
528
604
|
* @note Nodes that are not reachable from the entry point node will not be included
|
|
529
605
|
*/
|
|
530
606
|
traverse(node_id) {
|
|
531
|
-
|
|
607
|
+
// doc is $state.raw, so doc.nodes is a plain (non-proxied) object —
|
|
608
|
+
// traverse() reads it directly and deep-clones each visited node.
|
|
609
|
+
// The previous $state.snapshot(this.doc.nodes) here deep-cloned the
|
|
610
|
+
// ENTIRE document per call, which made copying M nodes O(M·N).
|
|
611
|
+
return traverse(node_id, this.schema, this.doc.nodes);
|
|
532
612
|
}
|
|
533
613
|
|
|
534
614
|
/**
|
|
@@ -551,7 +631,7 @@ export default class Session {
|
|
|
551
631
|
}
|
|
552
632
|
|
|
553
633
|
// property_type('page', 'body') => 'node_array'
|
|
554
|
-
// property_type('paragraph', 'content') => '
|
|
634
|
+
// property_type('paragraph', 'content') => 'text'
|
|
555
635
|
property_type(type, property) {
|
|
556
636
|
return doc_property_type(this.schema, type, property);
|
|
557
637
|
}
|
|
@@ -567,9 +647,7 @@ export default class Session {
|
|
|
567
647
|
* @returns {NodeId[]}
|
|
568
648
|
*/
|
|
569
649
|
get_referenced_nodes(node_id) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
// Extract IDs and exclude the last element (root node)
|
|
573
|
-
return traversed_nodes.slice(0, -1).map((node) => node.id);
|
|
650
|
+
// Id-only traversal — no need to deep-clone the subgraph for ids.
|
|
651
|
+
return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
|
|
574
652
|
}
|
|
575
653
|
}
|