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/dist/utils.js
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
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' });
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Detect if the
|
|
8
|
+
* Detect if the virtual keyboard is likely visible.
|
|
9
|
+
*
|
|
10
|
+
* This is a heuristic based on the visual viewport becoming smaller than the
|
|
11
|
+
* layout viewport.
|
|
12
|
+
*
|
|
13
|
+
* @returns {boolean} true if the virtual keyboard is likely active, false otherwise
|
|
14
|
+
*/
|
|
15
|
+
export function is_virtual_keyboard_active() {
|
|
16
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const visual_viewport = window.visualViewport;
|
|
21
|
+
if (!visual_viewport) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return visual_viewport.height < document.documentElement.clientHeight;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect if the current browser is likely on a mobile device.
|
|
30
|
+
*
|
|
31
|
+
* This uses the user agent only so touch-capable laptops are not treated as
|
|
32
|
+
* mobile browsers.
|
|
33
|
+
*
|
|
9
34
|
* @returns {boolean} true if mobile browser, false otherwise
|
|
10
35
|
*/
|
|
11
36
|
export function is_mobile_browser() {
|
|
12
|
-
if (typeof
|
|
37
|
+
if (typeof navigator === 'undefined') {
|
|
13
38
|
return false;
|
|
14
39
|
}
|
|
15
40
|
|
|
16
41
|
const user_agent = navigator.userAgent;
|
|
17
|
-
return (
|
|
18
|
-
/iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
|
|
19
|
-
'ontouchstart' in window ||
|
|
20
|
-
navigator.maxTouchPoints > 0
|
|
21
|
-
);
|
|
42
|
+
return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent);
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
// ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
|
|
@@ -155,110 +176,109 @@ export function char_to_utf16_offset(str, char_offset) {
|
|
|
155
176
|
/**
|
|
156
177
|
* Splits an annotated text at the specified character position.
|
|
157
178
|
*
|
|
158
|
-
*
|
|
159
|
-
* 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.
|
|
160
181
|
*
|
|
161
|
-
* @param {AnnotatedText}
|
|
182
|
+
* @param {AnnotatedText} text_value - Annotated text object
|
|
162
183
|
* @param {number} at_position - Character position where to split (0-based)
|
|
163
184
|
* @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
|
|
164
185
|
*
|
|
165
186
|
* @example
|
|
166
|
-
*
|
|
187
|
+
* split_text({content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}, 8)
|
|
167
188
|
* // Returns:
|
|
168
189
|
* // [
|
|
169
|
-
* // {
|
|
170
|
-
* // {
|
|
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: []}
|
|
171
192
|
* // ]
|
|
172
193
|
*/
|
|
173
|
-
export function
|
|
174
|
-
const {
|
|
194
|
+
export function split_text(text_value, at_position) {
|
|
195
|
+
const { content } = text_value;
|
|
175
196
|
|
|
176
197
|
// Split the text using character-aware slicing
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
node_id
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// Annotation spans the split point - split it
|
|
199
|
-
left_annotations.push({ start_offset, end_offset: at_position, node_id });
|
|
200
|
-
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
|
+
}
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
|
|
204
|
-
return [
|
|
205
|
-
{ text: left_text, annotations: left_annotations },
|
|
206
|
-
{ text: right_text, annotations: right_annotations }
|
|
207
|
-
];
|
|
222
|
+
return [left, right];
|
|
208
223
|
}
|
|
209
224
|
|
|
210
225
|
/**
|
|
211
226
|
* Joins two annotated texts into a single annotated text.
|
|
212
227
|
*
|
|
213
|
-
*
|
|
214
|
-
* 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.
|
|
215
231
|
*
|
|
216
232
|
* @param {AnnotatedText} first_text - First annotated text object
|
|
217
233
|
* @param {AnnotatedText} second_text - Second annotated text object
|
|
218
234
|
* @returns {AnnotatedText} Combined annotated text object
|
|
219
235
|
*
|
|
220
236
|
* @example
|
|
221
|
-
*
|
|
222
|
-
* // 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: []}
|
|
223
239
|
*/
|
|
224
|
-
export function
|
|
225
|
-
const { text: first_text_content, annotations: first_annotations } = first_text;
|
|
226
|
-
const { text: second_text_content, annotations: second_annotations } = second_text;
|
|
227
|
-
|
|
240
|
+
export function join_text(first_text, second_text) {
|
|
228
241
|
// Join the text content
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Add annotations from the second text, shifting their offsets
|
|
236
|
-
const offset = get_char_length(first_text_content);
|
|
237
|
-
for (const { start_offset, end_offset, node_id } of second_annotations) {
|
|
238
|
-
/** @type {Annotation} */
|
|
239
|
-
const shifted_annotation = {
|
|
240
|
-
start_offset: start_offset + offset,
|
|
241
|
-
end_offset: end_offset + offset,
|
|
242
|
-
node_id
|
|
243
|
-
};
|
|
242
|
+
const joined = {
|
|
243
|
+
content: first_text.content + second_text.content,
|
|
244
|
+
marks: [],
|
|
245
|
+
annotations: []
|
|
246
|
+
};
|
|
244
247
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}
|
|
258
276
|
}
|
|
277
|
+
|
|
278
|
+
joined[key] = joined_ranges;
|
|
259
279
|
}
|
|
260
280
|
|
|
261
|
-
return
|
|
281
|
+
return joined;
|
|
262
282
|
}
|
|
263
283
|
|
|
264
284
|
/**
|
|
@@ -384,6 +404,51 @@ export function paths_equal(a, b) {
|
|
|
384
404
|
return a.every((segment, index) => segment === b[index]);
|
|
385
405
|
}
|
|
386
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
|
+
|
|
387
452
|
export function traverse(node_id, schema, nodes) {
|
|
388
453
|
const json = [];
|
|
389
454
|
const visited = {};
|
|
@@ -396,16 +461,22 @@ export function traverse(node_id, schema, nodes) {
|
|
|
396
461
|
const property_definition = schema[node.type].properties[property_name];
|
|
397
462
|
|
|
398
463
|
if (property_definition?.type === 'node_array') {
|
|
399
|
-
|
|
464
|
+
const node_ids = value?.nodes || [];
|
|
465
|
+
|
|
466
|
+
for (const v of node_ids) {
|
|
400
467
|
if (typeof v === 'string') {
|
|
401
468
|
visit(nodes[v]);
|
|
402
469
|
}
|
|
403
470
|
}
|
|
471
|
+
|
|
472
|
+
for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
|
|
473
|
+
visit(nodes[range.node_id]);
|
|
474
|
+
}
|
|
404
475
|
} else if (property_definition?.type === 'node') {
|
|
405
476
|
visit(nodes[value]);
|
|
406
|
-
} else if (property_definition?.type === '
|
|
407
|
-
for (const
|
|
408
|
-
visit(nodes[
|
|
477
|
+
} else if (property_definition?.type === 'text') {
|
|
478
|
+
for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
|
|
479
|
+
visit(nodes[range.node_id]);
|
|
409
480
|
}
|
|
410
481
|
}
|
|
411
482
|
}
|
|
@@ -442,3 +513,167 @@ export function is_selection_collapsed(selection) {
|
|
|
442
513
|
return false;
|
|
443
514
|
}
|
|
444
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,6 +1,6 @@
|
|
|
1
1
|
import { insert_default_node, break_text_node } from './transforms.svelte.js';
|
|
2
|
-
import {
|
|
3
|
-
import { is_selection_collapsed,
|
|
2
|
+
import { can_switch_mark_type, get_selected_range_types } from './doc_utils.js';
|
|
3
|
+
import { is_selection_collapsed, get_char_length, char_slice } from './utils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Base class for commands that can be executed in response to user actions
|
|
@@ -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,41 +122,95 @@ 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
|
|
|
153
211
|
/**
|
|
154
212
|
* Command that adds a new line character at the current caret position.
|
|
155
213
|
* Only works in text selections where newlines are allowed.
|
|
156
|
-
* Disabled on mobile browsers where Shift+Enter has different behavior.
|
|
157
214
|
*/
|
|
158
215
|
export class AddNewLineCommand extends Command {
|
|
159
216
|
is_enabled() {
|
|
@@ -162,7 +219,6 @@ export class AddNewLineCommand extends Command {
|
|
|
162
219
|
|
|
163
220
|
return (
|
|
164
221
|
this.context.editable &&
|
|
165
|
-
!is_mobile_browser() &&
|
|
166
222
|
selection?.type === 'text' &&
|
|
167
223
|
session.inspect(selection.path).allow_newlines
|
|
168
224
|
);
|
|
@@ -181,7 +237,7 @@ export class AddNewLineCommand extends Command {
|
|
|
181
237
|
|
|
182
238
|
const collapsed_offset = tr.selection.anchor_offset;
|
|
183
239
|
const content = tr.get(tr.selection.path);
|
|
184
|
-
const text_before_caret = char_slice(content.
|
|
240
|
+
const text_before_caret = char_slice(content.content, 0, collapsed_offset);
|
|
185
241
|
const line_start_index = text_before_caret.lastIndexOf('\n') + 1;
|
|
186
242
|
const current_line_prefix = text_before_caret.slice(line_start_index);
|
|
187
243
|
const indentation_match = current_line_prefix.match(/^[\t ]*/);
|
|
@@ -237,7 +293,7 @@ export class SelectAllCommand extends Command {
|
|
|
237
293
|
|
|
238
294
|
if (selection.type === 'text') {
|
|
239
295
|
const text_content = session.get(selection.path);
|
|
240
|
-
const text_length = get_char_length(text_content.
|
|
296
|
+
const text_length = get_char_length(text_content.content);
|
|
241
297
|
|
|
242
298
|
// Check if all text is already selected
|
|
243
299
|
const is_all_text_selected =
|
|
@@ -280,7 +336,7 @@ export class SelectAllCommand extends Command {
|
|
|
280
336
|
// Check if the entire node_array is already selected
|
|
281
337
|
const is_entire_node_array_selected =
|
|
282
338
|
Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
|
|
283
|
-
Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
|
|
339
|
+
Math.max(selection.anchor_offset, selection.focus_offset) === node_array.nodes.length;
|
|
284
340
|
|
|
285
341
|
if (!is_entire_node_array_selected) {
|
|
286
342
|
// Select the entire node_array
|
|
@@ -288,7 +344,7 @@ export class SelectAllCommand extends Command {
|
|
|
288
344
|
type: 'node',
|
|
289
345
|
path: node_array_path,
|
|
290
346
|
anchor_offset: 0,
|
|
291
|
-
focus_offset: node_array.length
|
|
347
|
+
focus_offset: node_array.nodes.length
|
|
292
348
|
};
|
|
293
349
|
} else {
|
|
294
350
|
// Entire node_array is selected, try to move up to parent node_array
|