svedit 0.11.1 → 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 -4
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +85 -27
- 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 +75 -12
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +292 -78
- package/package.json +4 -1
- package/src/lib/Command.svelte.js +85 -27
- 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 +292 -78
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
package/dist/utils.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
|
|
2
|
+
* @import { Attachment, Mark, Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
@@ -176,110 +176,109 @@ export function char_to_utf16_offset(str, char_offset) {
|
|
|
176
176
|
/**
|
|
177
177
|
* Splits an annotated text at the specified character position.
|
|
178
178
|
*
|
|
179
|
-
*
|
|
180
|
-
* with offsets adjusted for each resulting part.
|
|
179
|
+
* Marks and annotations that span the split point will be divided
|
|
180
|
+
* appropriately, with offsets adjusted for each resulting part.
|
|
181
181
|
*
|
|
182
|
-
* @param {AnnotatedText}
|
|
182
|
+
* @param {AnnotatedText} text_value - Annotated text object
|
|
183
183
|
* @param {number} at_position - Character position where to split (0-based)
|
|
184
184
|
* @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
|
|
185
185
|
*
|
|
186
186
|
* @example
|
|
187
|
-
*
|
|
187
|
+
* split_text({content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}, 8)
|
|
188
188
|
* // Returns:
|
|
189
189
|
* // [
|
|
190
|
-
* // {
|
|
191
|
-
* // {
|
|
190
|
+
* // {content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []},
|
|
191
|
+
* // {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []}
|
|
192
192
|
* // ]
|
|
193
193
|
*/
|
|
194
|
-
export function
|
|
195
|
-
const {
|
|
194
|
+
export function split_text(text_value, at_position) {
|
|
195
|
+
const { content } = text_value;
|
|
196
196
|
|
|
197
197
|
// Split the text using character-aware slicing
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
node_id
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// Annotation spans the split point - split it
|
|
220
|
-
left_annotations.push({ start_offset, end_offset: at_position, node_id });
|
|
221
|
-
right_annotations.push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
|
|
198
|
+
const left = { content: char_slice(content, 0, at_position), marks: [], annotations: [] };
|
|
199
|
+
const right = { content: char_slice(content, at_position), marks: [], annotations: [] };
|
|
200
|
+
|
|
201
|
+
// Process marks and annotations with the same range logic
|
|
202
|
+
for (const key of ['marks', 'annotations']) {
|
|
203
|
+
for (const { start_offset, end_offset, node_id } of text_value[key] ?? []) {
|
|
204
|
+
if (end_offset <= at_position) {
|
|
205
|
+
// Range is entirely in the left part
|
|
206
|
+
left[key].push({ start_offset, end_offset, node_id });
|
|
207
|
+
} else if (start_offset >= at_position) {
|
|
208
|
+
// Range is entirely in the right part - shift offsets
|
|
209
|
+
right[key].push({
|
|
210
|
+
start_offset: start_offset - at_position,
|
|
211
|
+
end_offset: end_offset - at_position,
|
|
212
|
+
node_id
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
// Range spans the split point - split it
|
|
216
|
+
left[key].push({ start_offset, end_offset: at_position, node_id });
|
|
217
|
+
right[key].push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
|
|
218
|
+
}
|
|
222
219
|
}
|
|
223
220
|
}
|
|
224
221
|
|
|
225
|
-
return [
|
|
226
|
-
{ text: left_text, annotations: left_annotations },
|
|
227
|
-
{ text: right_text, annotations: right_annotations }
|
|
228
|
-
];
|
|
222
|
+
return [left, right];
|
|
229
223
|
}
|
|
230
224
|
|
|
231
225
|
/**
|
|
232
226
|
* Joins two annotated texts into a single annotated text.
|
|
233
227
|
*
|
|
234
|
-
*
|
|
235
|
-
* of the first text. Adjacent
|
|
228
|
+
* Marks and annotations from the second text will have their offsets shifted
|
|
229
|
+
* by the length of the first text. Adjacent ranges referencing the same node
|
|
230
|
+
* will be merged.
|
|
236
231
|
*
|
|
237
232
|
* @param {AnnotatedText} first_text - First annotated text object
|
|
238
233
|
* @param {AnnotatedText} second_text - Second annotated text object
|
|
239
234
|
* @returns {AnnotatedText} Combined annotated text object
|
|
240
235
|
*
|
|
241
236
|
* @example
|
|
242
|
-
*
|
|
243
|
-
* // Returns: {
|
|
237
|
+
* join_text({content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []}, {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []})
|
|
238
|
+
* // Returns: {content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}
|
|
244
239
|
*/
|
|
245
|
-
export function
|
|
246
|
-
const { text: first_text_content, annotations: first_annotations } = first_text;
|
|
247
|
-
const { text: second_text_content, annotations: second_annotations } = second_text;
|
|
248
|
-
|
|
240
|
+
export function join_text(first_text, second_text) {
|
|
249
241
|
// Join the text content
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// Add annotations from the second text, shifting their offsets
|
|
257
|
-
const offset = get_char_length(first_text_content);
|
|
258
|
-
for (const { start_offset, end_offset, node_id } of second_annotations) {
|
|
259
|
-
/** @type {Annotation} */
|
|
260
|
-
const shifted_annotation = {
|
|
261
|
-
start_offset: start_offset + offset,
|
|
262
|
-
end_offset: end_offset + offset,
|
|
263
|
-
node_id
|
|
264
|
-
};
|
|
242
|
+
const joined = {
|
|
243
|
+
content: first_text.content + second_text.content,
|
|
244
|
+
marks: [],
|
|
245
|
+
annotations: []
|
|
246
|
+
};
|
|
265
247
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
248
|
+
const offset = get_char_length(first_text.content);
|
|
249
|
+
|
|
250
|
+
for (const key of ['marks', 'annotations']) {
|
|
251
|
+
// Start with all ranges from the first text (unchanged)
|
|
252
|
+
const joined_ranges = (first_text[key] ?? []).map((range) => ({ ...range }));
|
|
253
|
+
|
|
254
|
+
// Add ranges from the second text, shifting their offsets
|
|
255
|
+
for (const { start_offset, end_offset, node_id } of second_text[key] ?? []) {
|
|
256
|
+
const shifted_range = {
|
|
257
|
+
start_offset: start_offset + offset,
|
|
258
|
+
end_offset: end_offset + offset,
|
|
259
|
+
node_id
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Check if this range can be merged with the last range from the first text
|
|
263
|
+
const last_range = joined_ranges[joined_ranges.length - 1];
|
|
264
|
+
if (
|
|
265
|
+
last_range &&
|
|
266
|
+
last_range.end_offset === shifted_range.start_offset && // ranges are adjacent
|
|
267
|
+
last_range.node_id === shifted_range.node_id
|
|
268
|
+
) {
|
|
269
|
+
// same node_id
|
|
270
|
+
// Merge by extending the end position of the last range
|
|
271
|
+
last_range.end_offset = shifted_range.end_offset;
|
|
272
|
+
} else {
|
|
273
|
+
// Add as separate range
|
|
274
|
+
joined_ranges.push(shifted_range);
|
|
275
|
+
}
|
|
279
276
|
}
|
|
277
|
+
|
|
278
|
+
joined[key] = joined_ranges;
|
|
280
279
|
}
|
|
281
280
|
|
|
282
|
-
return
|
|
281
|
+
return joined;
|
|
283
282
|
}
|
|
284
283
|
|
|
285
284
|
/**
|
|
@@ -405,6 +404,51 @@ export function paths_equal(a, b) {
|
|
|
405
404
|
return a.every((segment, index) => segment === b[index]);
|
|
406
405
|
}
|
|
407
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Like traverse, but returns only node ids in depth-first order and never
|
|
409
|
+
* clones nodes. Use this when only the reachable id set is needed (e.g.
|
|
410
|
+
* reference bookkeeping) — traverse() deep-clones every visited node,
|
|
411
|
+
* which is wasteful for id-only consumers.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} node_id - The ID of the node to start traversing from
|
|
414
|
+
* @param {object} schema - The document schema
|
|
415
|
+
* @param {Record<string, any>} nodes - All document nodes
|
|
416
|
+
* @returns {string[]} Node ids in depth-first order (entry point last)
|
|
417
|
+
*/
|
|
418
|
+
export function traverse_ids(node_id, schema, nodes) {
|
|
419
|
+
const ids = [];
|
|
420
|
+
const visited = {};
|
|
421
|
+
const visit = (node) => {
|
|
422
|
+
if (!node || visited[node.id]) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
visited[node.id] = true;
|
|
426
|
+
for (const [property_name, value] of Object.entries(node)) {
|
|
427
|
+
const property_definition = schema[node.type].properties[property_name];
|
|
428
|
+
|
|
429
|
+
if (property_definition?.type === 'node_array') {
|
|
430
|
+
for (const v of value?.nodes || []) {
|
|
431
|
+
if (typeof v === 'string') {
|
|
432
|
+
visit(nodes[v]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
|
|
436
|
+
visit(nodes[range.node_id]);
|
|
437
|
+
}
|
|
438
|
+
} else if (property_definition?.type === 'node') {
|
|
439
|
+
visit(nodes[value]);
|
|
440
|
+
} else if (property_definition?.type === 'text') {
|
|
441
|
+
for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
|
|
442
|
+
visit(nodes[range.node_id]);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
ids.push(node.id);
|
|
447
|
+
};
|
|
448
|
+
visit(nodes[node_id]);
|
|
449
|
+
return ids;
|
|
450
|
+
}
|
|
451
|
+
|
|
408
452
|
export function traverse(node_id, schema, nodes) {
|
|
409
453
|
const json = [];
|
|
410
454
|
const visited = {};
|
|
@@ -417,16 +461,22 @@ export function traverse(node_id, schema, nodes) {
|
|
|
417
461
|
const property_definition = schema[node.type].properties[property_name];
|
|
418
462
|
|
|
419
463
|
if (property_definition?.type === 'node_array') {
|
|
420
|
-
|
|
464
|
+
const node_ids = value?.nodes || [];
|
|
465
|
+
|
|
466
|
+
for (const v of node_ids) {
|
|
421
467
|
if (typeof v === 'string') {
|
|
422
468
|
visit(nodes[v]);
|
|
423
469
|
}
|
|
424
470
|
}
|
|
471
|
+
|
|
472
|
+
for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
|
|
473
|
+
visit(nodes[range.node_id]);
|
|
474
|
+
}
|
|
425
475
|
} else if (property_definition?.type === 'node') {
|
|
426
476
|
visit(nodes[value]);
|
|
427
|
-
} else if (property_definition?.type === '
|
|
428
|
-
for (const
|
|
429
|
-
visit(nodes[
|
|
477
|
+
} else if (property_definition?.type === 'text') {
|
|
478
|
+
for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
|
|
479
|
+
visit(nodes[range.node_id]);
|
|
430
480
|
}
|
|
431
481
|
}
|
|
432
482
|
}
|
|
@@ -463,3 +513,167 @@ export function is_selection_collapsed(selection) {
|
|
|
463
513
|
return false;
|
|
464
514
|
}
|
|
465
515
|
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Adjust ranges (marks or annotations) after deleting a sequence range.
|
|
519
|
+
*
|
|
520
|
+
* Works for both annotated text (character offsets) and annotated node arrays
|
|
521
|
+
* (node offsets).
|
|
522
|
+
*
|
|
523
|
+
* @param {Array<import('./types.d.ts').Attachment>} ranges
|
|
524
|
+
* @param {number} start
|
|
525
|
+
* @param {number} end
|
|
526
|
+
* @returns {{ranges: Array<import('./types.d.ts').Attachment>, removed_node_ids: string[]}}
|
|
527
|
+
*/
|
|
528
|
+
export function adjust_ranges_for_deletion(ranges, start, end) {
|
|
529
|
+
const removed_node_ids = [];
|
|
530
|
+
const deletion_length = end - start;
|
|
531
|
+
|
|
532
|
+
const next_ranges = ranges
|
|
533
|
+
.map((range) => {
|
|
534
|
+
if (range.end_offset <= start) return range;
|
|
535
|
+
|
|
536
|
+
let start_offset = range.start_offset;
|
|
537
|
+
if (range.start_offset >= end) {
|
|
538
|
+
start_offset -= deletion_length;
|
|
539
|
+
} else if (range.start_offset > start) {
|
|
540
|
+
start_offset = start;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let end_offset = range.end_offset;
|
|
544
|
+
if (range.end_offset >= end) {
|
|
545
|
+
end_offset -= deletion_length;
|
|
546
|
+
} else if (range.end_offset > start) {
|
|
547
|
+
end_offset = start;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (start_offset >= end_offset) {
|
|
551
|
+
removed_node_ids.push(range.node_id);
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { start_offset, end_offset, node_id: range.node_id };
|
|
556
|
+
})
|
|
557
|
+
.filter(Boolean);
|
|
558
|
+
|
|
559
|
+
return { ranges: next_ranges, removed_node_ids };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Adjust ranges (marks or annotations) after inserting into a sequence.
|
|
564
|
+
*
|
|
565
|
+
* Insertion inside a range extends it. Insertion exactly at either edge
|
|
566
|
+
* stays outside the range.
|
|
567
|
+
*
|
|
568
|
+
* @param {Array<import('./types.d.ts').Attachment>} ranges
|
|
569
|
+
* @param {number} offset
|
|
570
|
+
* @param {number} length
|
|
571
|
+
* @returns {Array<import('./types.d.ts').Attachment>}
|
|
572
|
+
*/
|
|
573
|
+
export function adjust_ranges_for_insertion(ranges, offset, length) {
|
|
574
|
+
if (length === 0) return ranges;
|
|
575
|
+
|
|
576
|
+
return ranges.map((range) => {
|
|
577
|
+
if (range.end_offset <= offset) return range;
|
|
578
|
+
|
|
579
|
+
if (range.start_offset < offset) {
|
|
580
|
+
return {
|
|
581
|
+
start_offset: range.start_offset,
|
|
582
|
+
end_offset: range.end_offset + length,
|
|
583
|
+
node_id: range.node_id
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
start_offset: range.start_offset + length,
|
|
589
|
+
end_offset: range.end_offset + length,
|
|
590
|
+
node_id: range.node_id
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Check whether ranges are non-empty and mutually exclusive.
|
|
597
|
+
*
|
|
598
|
+
* @param {Array<import('./types.d.ts').Attachment>} ranges
|
|
599
|
+
* @param {number} [length]
|
|
600
|
+
* @returns {boolean}
|
|
601
|
+
*/
|
|
602
|
+
export function are_ranges_exclusive(ranges, length = Infinity) {
|
|
603
|
+
const sorted = [...ranges].sort(
|
|
604
|
+
(a, b) => a.start_offset - b.start_offset || a.end_offset - b.end_offset
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
return sorted.every(
|
|
608
|
+
(range, index) =>
|
|
609
|
+
Number.isInteger(range.start_offset) &&
|
|
610
|
+
Number.isInteger(range.end_offset) &&
|
|
611
|
+
range.start_offset >= 0 &&
|
|
612
|
+
range.start_offset < range.end_offset &&
|
|
613
|
+
range.end_offset <= length &&
|
|
614
|
+
(index === 0 || range.start_offset >= sorted[index - 1].end_offset)
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Calculates abstract fragment ranges from a length and marks.
|
|
620
|
+
*
|
|
621
|
+
* @param {number} length - Length of the sequence (e.g. text length or array length)
|
|
622
|
+
* @param {Array<import('./types.d.ts').Mark>} marks - Array of marks
|
|
623
|
+
* @param {import('./types.d.ts').SelectionRange} [selection_highlight_range] - Optional selection highlight range
|
|
624
|
+
* @returns {Array<{type: 'content' | 'mark' | 'selection_highlight', start_offset: number, end_offset: number, mark_index?: number, node_id?: string}>}
|
|
625
|
+
*/
|
|
626
|
+
export function calculate_fragment_ranges(length, marks, selection_highlight_range) {
|
|
627
|
+
/** @type {Array<{type: 'content' | 'mark' | 'selection_highlight', start_offset: number, end_offset: number, mark_index?: number, node_id?: string}>} */
|
|
628
|
+
const fragments = [];
|
|
629
|
+
let last_index = 0;
|
|
630
|
+
|
|
631
|
+
// Merge marks with selection highlight and sort by start offset
|
|
632
|
+
const ranges = [...marks, ...(selection_highlight_range ? [selection_highlight_range] : [])].sort(
|
|
633
|
+
(a, b) => a.start_offset - b.start_offset
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
for (const range of ranges) {
|
|
637
|
+
// Add content before this range
|
|
638
|
+
if (range.start_offset > last_index) {
|
|
639
|
+
fragments.push({
|
|
640
|
+
type: 'content',
|
|
641
|
+
start_offset: last_index,
|
|
642
|
+
end_offset: range.start_offset
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if ('node_id' in range) {
|
|
647
|
+
const mark = /** @type {import('./types.d.ts').Mark} */ (range);
|
|
648
|
+
const mark_index =
|
|
649
|
+
/** @type {import('./types.d.ts').Mark & { mark_index?: number }} */ (mark).mark_index ??
|
|
650
|
+
marks.indexOf(mark);
|
|
651
|
+
fragments.push({
|
|
652
|
+
type: 'mark',
|
|
653
|
+
start_offset: mark.start_offset,
|
|
654
|
+
end_offset: mark.end_offset,
|
|
655
|
+
node_id: mark.node_id,
|
|
656
|
+
mark_index
|
|
657
|
+
});
|
|
658
|
+
} else {
|
|
659
|
+
fragments.push({
|
|
660
|
+
type: 'selection_highlight',
|
|
661
|
+
start_offset: range.start_offset,
|
|
662
|
+
end_offset: range.end_offset
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
last_index = range.end_offset;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Add any remaining content
|
|
670
|
+
if (last_index < length) {
|
|
671
|
+
fragments.push({
|
|
672
|
+
type: 'content',
|
|
673
|
+
start_offset: last_index,
|
|
674
|
+
end_offset: length
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return fragments;
|
|
679
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svedit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite dev",
|
|
@@ -64,5 +64,8 @@
|
|
|
64
64
|
"vite": "^8.0.0",
|
|
65
65
|
"vitest": "^4.1.0",
|
|
66
66
|
"vitest-browser-svelte": "^2.1.0"
|
|
67
|
+
},
|
|
68
|
+
"allowScripts": {
|
|
69
|
+
"fsevents@2.3.3": true
|
|
67
70
|
}
|
|
68
71
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { insert_default_node, break_text_node } from './transforms.svelte.js';
|
|
2
|
-
import {
|
|
2
|
+
import { can_switch_mark_type, get_selected_range_types } from './doc_utils.js';
|
|
3
3
|
import { is_selection_collapsed, get_char_length, char_slice } from './utils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -107,10 +107,13 @@ export class SelectParentCommand extends Command {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* Generic command that toggles
|
|
111
|
-
*
|
|
110
|
+
* Generic command that toggles a mark on the current text or node selection.
|
|
111
|
+
*
|
|
112
|
+
* Marks are mutually exclusive, so all touched marks compete: same-type marks
|
|
113
|
+
* are removed, a single touched property-less mark may switch type, and mixed
|
|
114
|
+
* touched types disable the command. Annotations never affect mark toggling.
|
|
112
115
|
*/
|
|
113
|
-
export class
|
|
116
|
+
export class ToggleMarkCommand extends Command {
|
|
114
117
|
constructor(node_type, context) {
|
|
115
118
|
super(context);
|
|
116
119
|
this.node_type = node_type;
|
|
@@ -119,34 +122,89 @@ export class ToggleAnnotationCommand extends Command {
|
|
|
119
122
|
active = $derived(this.is_active());
|
|
120
123
|
|
|
121
124
|
is_active() {
|
|
122
|
-
|
|
125
|
+
const selected_marks = this.context.session.selected_marks;
|
|
126
|
+
return (
|
|
127
|
+
selected_marks.length > 0 &&
|
|
128
|
+
selected_marks.every(({ node }) => node?.type === this.node_type)
|
|
129
|
+
);
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
is_enabled() {
|
|
126
133
|
const { session, editable } = this.context;
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
const selection = session.selection;
|
|
135
|
+
const is_valid_selection =
|
|
136
|
+
selection?.type === 'text' ||
|
|
137
|
+
(selection?.type === 'node' && !is_selection_collapsed(selection));
|
|
138
|
+
const mark_type_is_allowed = session.available_mark_types.includes(this.node_type);
|
|
139
|
+
const selected_marks = session.selected_marks;
|
|
140
|
+
const selected_mark_types = get_selected_range_types(selected_marks);
|
|
141
|
+
|
|
142
|
+
if (!editable || !is_valid_selection || !mark_type_is_allowed) return false;
|
|
143
|
+
if (selected_mark_types.size > 1) return false;
|
|
144
|
+
|
|
145
|
+
if (selected_marks.length === 0) {
|
|
146
|
+
return Boolean(selection && !is_selection_collapsed(selection));
|
|
147
|
+
}
|
|
140
148
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
const first_selected_mark = selected_marks[0];
|
|
150
|
+
const selected_mark_type = first_selected_mark.node.type;
|
|
151
|
+
if (selected_mark_type === this.node_type) return true;
|
|
152
|
+
if (selected_marks.length !== 1) return false;
|
|
153
|
+
|
|
154
|
+
return can_switch_mark_type(session.schema, selected_mark_type, this.node_type);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
execute() {
|
|
158
|
+
this.context.session.apply(this.context.session.tr.toggle_mark(this.node_type));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Generic command that toggles an annotation on the current text or node selection.
|
|
164
|
+
*
|
|
165
|
+
* Annotations only compete with touched annotations of the same type: touched
|
|
166
|
+
* same-type annotations are removed, otherwise a new annotation is created.
|
|
167
|
+
* Marks and other annotation types never block the toggle.
|
|
168
|
+
*/
|
|
169
|
+
export class ToggleAnnotationCommand extends Command {
|
|
170
|
+
constructor(node_type, context) {
|
|
171
|
+
super(context);
|
|
172
|
+
this.node_type = node_type;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
active = $derived(this.is_active());
|
|
176
|
+
|
|
177
|
+
// Mirrors the filter in tr.toggle_annotation, so active/enabled match what
|
|
178
|
+
// execute() does.
|
|
179
|
+
relevant_annotations() {
|
|
180
|
+
return this.context.session.selected_annotations.filter(
|
|
181
|
+
({ node }) => node?.type === this.node_type
|
|
145
182
|
);
|
|
146
183
|
}
|
|
147
184
|
|
|
185
|
+
is_active() {
|
|
186
|
+
return this.relevant_annotations().length > 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
is_enabled() {
|
|
190
|
+
const { session, editable } = this.context;
|
|
191
|
+
const selection = session.selection;
|
|
192
|
+
const is_valid_selection =
|
|
193
|
+
selection?.type === 'text' ||
|
|
194
|
+
(selection?.type === 'node' && !is_selection_collapsed(selection));
|
|
195
|
+
const annotation_type_is_allowed = session.available_annotation_types.includes(this.node_type);
|
|
196
|
+
|
|
197
|
+
if (!editable || !is_valid_selection || !annotation_type_is_allowed) return false;
|
|
198
|
+
|
|
199
|
+
// Removing touched same-type annotations also works from a collapsed
|
|
200
|
+
// caret inside the annotation; creating a new one requires an expanded
|
|
201
|
+
// selection.
|
|
202
|
+
if (this.relevant_annotations().length > 0) return true;
|
|
203
|
+
return Boolean(selection && !is_selection_collapsed(selection));
|
|
204
|
+
}
|
|
205
|
+
|
|
148
206
|
execute() {
|
|
149
|
-
this.context.session.apply(this.context.session.tr.
|
|
207
|
+
this.context.session.apply(this.context.session.tr.toggle_annotation(this.node_type));
|
|
150
208
|
}
|
|
151
209
|
}
|
|
152
210
|
|
|
@@ -179,7 +237,7 @@ export class AddNewLineCommand extends Command {
|
|
|
179
237
|
|
|
180
238
|
const collapsed_offset = tr.selection.anchor_offset;
|
|
181
239
|
const content = tr.get(tr.selection.path);
|
|
182
|
-
const text_before_caret = char_slice(content.
|
|
240
|
+
const text_before_caret = char_slice(content.content, 0, collapsed_offset);
|
|
183
241
|
const line_start_index = text_before_caret.lastIndexOf('\n') + 1;
|
|
184
242
|
const current_line_prefix = text_before_caret.slice(line_start_index);
|
|
185
243
|
const indentation_match = current_line_prefix.match(/^[\t ]*/);
|
|
@@ -235,7 +293,7 @@ export class SelectAllCommand extends Command {
|
|
|
235
293
|
|
|
236
294
|
if (selection.type === 'text') {
|
|
237
295
|
const text_content = session.get(selection.path);
|
|
238
|
-
const text_length = get_char_length(text_content.
|
|
296
|
+
const text_length = get_char_length(text_content.content);
|
|
239
297
|
|
|
240
298
|
// Check if all text is already selected
|
|
241
299
|
const is_all_text_selected =
|
|
@@ -278,7 +336,7 @@ export class SelectAllCommand extends Command {
|
|
|
278
336
|
// Check if the entire node_array is already selected
|
|
279
337
|
const is_entire_node_array_selected =
|
|
280
338
|
Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
|
|
281
|
-
Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
|
|
339
|
+
Math.max(selection.anchor_offset, selection.focus_offset) === node_array.nodes.length;
|
|
282
340
|
|
|
283
341
|
if (!is_entire_node_array_selected) {
|
|
284
342
|
// Select the entire node_array
|
|
@@ -286,7 +344,7 @@ export class SelectAllCommand extends Command {
|
|
|
286
344
|
type: 'node',
|
|
287
345
|
path: node_array_path,
|
|
288
346
|
anchor_offset: 0,
|
|
289
|
-
focus_offset: node_array.length
|
|
347
|
+
focus_offset: node_array.nodes.length
|
|
290
348
|
};
|
|
291
349
|
} else {
|
|
292
350
|
// Entire node_array is selected, try to move up to parent node_array
|
package/src/lib/Node.svelte
CHANGED
|
@@ -16,17 +16,45 @@
|
|
|
16
16
|
let child_index = $derived(node_array_meta ? parseInt(String(path.at(-1)), 10) : -1);
|
|
17
17
|
let is_first = $derived(node_array_meta && child_index === 0);
|
|
18
18
|
let is_last = $derived(node_array_meta && child_index === node_array_meta.length - 1);
|
|
19
|
+
|
|
20
|
+
// Marks and annotations covering this node become classes automatically:
|
|
21
|
+
// `mark-<type>`/`anno-<type>`, plus `-start`/`-end` variants on the run's
|
|
22
|
+
// first/last covered node. This allows styling ranges (including
|
|
23
|
+
// annotations, which never render wrapper components) with pure CSS. For
|
|
24
|
+
// richer rendering, node components still receive the `mark` and
|
|
25
|
+
// `annotations` props.
|
|
26
|
+
let range_classes = $derived.by(() => {
|
|
27
|
+
if (child_index < 0 || !node_array_meta?.annotations_for) return '';
|
|
28
|
+
const classes = [];
|
|
29
|
+
|
|
30
|
+
const add_classes = (range, prefix) => {
|
|
31
|
+
const range_type = range?.node?.type;
|
|
32
|
+
if (!range_type) return;
|
|
33
|
+
classes.push(`${prefix}-${range_type}`);
|
|
34
|
+
if (range.is_start) classes.push(`${prefix}-${range_type}-start`);
|
|
35
|
+
if (range.is_end) classes.push(`${prefix}-${range_type}-end`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
add_classes(node_array_meta.mark_for?.(child_index), 'mark');
|
|
39
|
+
for (const annotation of node_array_meta.annotations_for(child_index)) {
|
|
40
|
+
add_classes(annotation, 'anno');
|
|
41
|
+
}
|
|
42
|
+
return classes.join(' ');
|
|
43
|
+
});
|
|
19
44
|
</script>
|
|
20
45
|
|
|
21
46
|
<svelte:element
|
|
22
47
|
this={tag}
|
|
23
48
|
id={node.id}
|
|
24
|
-
class="node-{node.type} {css_class}{
|
|
49
|
+
class="node-{node.type} {css_class}{range_classes
|
|
50
|
+
? ` ${range_classes}`
|
|
51
|
+
: ''}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
|
|
25
52
|
data-node-id={node.id}
|
|
26
53
|
data-path={path_str}
|
|
27
54
|
data-type="node"
|
|
28
55
|
style="anchor-name: --{path_str};{style}"
|
|
29
56
|
{...rest}
|
|
57
|
+
{@attach svedit.visibility_registry.track_node(path_str)}
|
|
30
58
|
>
|
|
31
59
|
{@render children()}
|
|
32
60
|
</svelte:element>
|