edytor 5.0.0 → 6.0.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 +2 -2
- package/dist/cloudflare/room/context.d.ts +62 -74
- package/dist/crdt/doc/history.js +2 -3
- package/dist/crdt/doc/ops.d.ts +81 -0
- package/dist/crdt/doc/ops.js +1898 -0
- package/dist/crdt/edytor-doc.d.ts +156 -156
- package/dist/crdt/edytor-doc.js +14 -42
- package/dist/crdt/flow.d.ts +4 -46
- package/dist/crdt/flow.js +2 -262
- package/dist/crdt/index.d.ts +62 -74
- package/dist/crdt/operations.d.ts +1 -1
- package/dist/crdt/placement/resolve.d.ts +1 -1
- package/dist/crdt/protocols/awareness.js +46 -81
- package/dist/crdt/providers/index.d.ts +10 -22
- package/dist/crdt/providers/indexeddb.d.ts +6 -10
- package/dist/crdt/providers/indexeddb.js +10 -16
- package/dist/crdt/providers/room.d.ts +11 -0
- package/dist/crdt/providers/room.js +26 -12
- package/dist/crdt/providers/websocket.d.ts +6 -12
- package/dist/crdt/providers/websocket.js +8 -15
- package/dist/crdt/rangeDelete.d.ts +2 -47
- package/dist/crdt/rangeDelete.js +1 -169
- package/dist/crdt/text/index/build.d.ts +125 -0
- package/dist/crdt/text/index/build.js +2895 -0
- package/dist/crdt/text/index/shared.d.ts +2 -6
- package/dist/crdt/text/index/shared.js +2 -5
- package/dist/crdt/text/model.js +2 -3
- package/dist/crdt/text/runs.d.ts +9 -12
- package/dist/crdt/text/runs.js +2 -165
- package/dist/events/domTextOffset.d.ts +2 -0
- package/dist/events/domTextOffset.js +8 -0
- package/dist/export/forms.d.ts +50 -50
- package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
- package/dist/plugins/richtext/richTextOperations.js +26 -32
- package/dist/selection/selection.utils.js +5 -8
- package/dist/session/handles.js +3 -7
- package/dist/surface/observer.svelte.js +15 -30
- package/dist/surface/projector.svelte.js +2 -2
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +7 -0
- package/package.json +1 -1
- package/dist/crdt/doc/capability.d.ts +0 -56
- package/dist/crdt/doc/capability.js +0 -207
- package/dist/crdt/doc/content.d.ts +0 -16
- package/dist/crdt/doc/content.js +0 -77
- package/dist/crdt/doc/delete.d.ts +0 -15
- package/dist/crdt/doc/delete.js +0 -84
- package/dist/crdt/doc/layout.d.ts +0 -9
- package/dist/crdt/doc/layout.js +0 -178
- package/dist/crdt/doc/meta.d.ts +0 -19
- package/dist/crdt/doc/meta.js +0 -198
- package/dist/crdt/doc/moves.d.ts +0 -21
- package/dist/crdt/doc/moves.js +0 -274
- package/dist/crdt/doc/split-merge.d.ts +0 -10
- package/dist/crdt/doc/split-merge.js +0 -179
- package/dist/crdt/doc/steps.d.ts +0 -65
- package/dist/crdt/doc/steps.js +0 -311
- package/dist/crdt/text/index/cache.d.ts +0 -16
- package/dist/crdt/text/index/cache.js +0 -175
- package/dist/crdt/text/index/claims.d.ts +0 -18
- package/dist/crdt/text/index/claims.js +0 -203
- package/dist/crdt/text/index/fold.d.ts +0 -21
- package/dist/crdt/text/index/fold.js +0 -656
- package/dist/crdt/text/index/layout.d.ts +0 -22
- package/dist/crdt/text/index/layout.js +0 -258
- package/dist/crdt/text/index/placement.d.ts +0 -47
- package/dist/crdt/text/index/placement.js +0 -738
- package/dist/crdt/text/index/records.d.ts +0 -13
- package/dist/crdt/text/index/records.js +0 -139
- package/dist/crdt/text/index/report.d.ts +0 -18
- package/dist/crdt/text/index/report.js +0 -337
- package/dist/crdt/text/index/state.d.ts +0 -197
- package/dist/crdt/text/index/state.js +0 -116
- package/dist/crdt/text/index/streams.d.ts +0 -25
- package/dist/crdt/text/index/streams.js +0 -248
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
<script module lang="ts">
|
|
2
2
|
import './richtext.css';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Plugin,
|
|
5
|
+
BlockDefinition,
|
|
6
|
+
BlockSnippetPayload,
|
|
7
|
+
PlaceholderView
|
|
8
|
+
} from '../../plugins.js';
|
|
4
9
|
import { branded } from '../brand.js';
|
|
5
10
|
import type { Edytor } from '../../edytor.svelte.js';
|
|
6
11
|
import { presetId } from '../../kinds.js';
|
|
@@ -191,11 +196,15 @@
|
|
|
191
196
|
let retyping: string | null = null;
|
|
192
197
|
/** The disclosures this view created, opened once drawn (`onBlockAttached`). */
|
|
193
198
|
const opening = new Set<string>();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
+
/** Each kind's presets, their keywords the view's (`labels`) where it names some. */
|
|
200
|
+
const named = (blocks: Record<string, BlockDefinition>) => {
|
|
201
|
+
for (const [type, kind] of Object.entries(blocks))
|
|
202
|
+
kind.presets = kind.presets?.map((row, index, rows) => {
|
|
203
|
+
const own = keywordsOf(edytor, presetId(type, index, rows.length), row.keywords);
|
|
204
|
+
return own ? { ...row, keywords: own } : row;
|
|
205
|
+
});
|
|
206
|
+
return blocks;
|
|
207
|
+
};
|
|
199
208
|
const operations = richTextOperations(edytor);
|
|
200
209
|
/** The space an autolink types itself: its own insertion is not looked at again. */
|
|
201
210
|
let autolinking = false;
|
|
@@ -395,62 +404,57 @@
|
|
|
395
404
|
])
|
|
396
405
|
)
|
|
397
406
|
},
|
|
398
|
-
blocks: {
|
|
407
|
+
blocks: named({
|
|
399
408
|
paragraph: {
|
|
400
409
|
snippet: paragraph,
|
|
401
|
-
presets:
|
|
402
|
-
{ label: kinds.paragraph, icon: 'T', keywords: ['paragraph', 'plain'] }
|
|
403
|
-
])
|
|
410
|
+
presets: [{ label: kinds.paragraph, icon: 'T', keywords: ['paragraph', 'plain'] }]
|
|
404
411
|
},
|
|
405
412
|
heading: {
|
|
406
413
|
...headingRead,
|
|
407
414
|
...headingWrite,
|
|
408
415
|
snippet: textThenChildren,
|
|
409
|
-
presets:
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
markdown: [`${'#'.repeat(index + 1)} `]
|
|
417
|
-
}))
|
|
418
|
-
)
|
|
416
|
+
presets: HEADING_LEVELS.map((level, index) => ({
|
|
417
|
+
label: [kinds.heading1, kinds.heading2, kinds.heading3][index]!,
|
|
418
|
+
icon: `H${['₁', '₂', '₃'][index]}`,
|
|
419
|
+
keywords: [level, ...[['title'], ['subtitle'], []][index]!],
|
|
420
|
+
data: { level },
|
|
421
|
+
markdown: [`${'#'.repeat(index + 1)} `]
|
|
422
|
+
}))
|
|
419
423
|
},
|
|
420
424
|
'bulleted-list-item': {
|
|
421
425
|
...itemRead,
|
|
422
426
|
...itemWrite,
|
|
423
427
|
continues: true,
|
|
424
428
|
snippet: listItem,
|
|
425
|
-
presets:
|
|
429
|
+
presets: [
|
|
426
430
|
{
|
|
427
431
|
label: kinds.bulletedList,
|
|
428
432
|
icon: '•',
|
|
429
433
|
keywords: ['bullet', 'ul'],
|
|
430
434
|
markdown: ['- ', '* ', '+ ']
|
|
431
435
|
}
|
|
432
|
-
]
|
|
436
|
+
]
|
|
433
437
|
},
|
|
434
438
|
'numbered-list-item': {
|
|
435
439
|
...numberedRead,
|
|
436
440
|
...itemWrite,
|
|
437
441
|
continues: true,
|
|
438
442
|
snippet: listItem,
|
|
439
|
-
presets:
|
|
443
|
+
presets: [
|
|
440
444
|
{
|
|
441
445
|
label: kinds.numberedList,
|
|
442
446
|
icon: '1.',
|
|
443
447
|
keywords: ['number', 'ol'],
|
|
444
448
|
markdown: ['1. ', 'a. ', 'i. ']
|
|
445
449
|
}
|
|
446
|
-
]
|
|
450
|
+
]
|
|
447
451
|
},
|
|
448
452
|
'todo-item': {
|
|
449
453
|
...todoWrite,
|
|
450
454
|
continues: true,
|
|
451
455
|
// The app's control markup, if any, through its own snippet: the default costs no branch.
|
|
452
456
|
snippet: checkbox ? todoItemOwn : todoItem,
|
|
453
|
-
presets:
|
|
457
|
+
presets: [
|
|
454
458
|
{
|
|
455
459
|
label: kinds.todoList,
|
|
456
460
|
icon: '☐',
|
|
@@ -458,7 +462,7 @@
|
|
|
458
462
|
data: { checked: false },
|
|
459
463
|
markdown: ['[ ] ', '[] ']
|
|
460
464
|
}
|
|
461
|
-
]
|
|
465
|
+
]
|
|
462
466
|
},
|
|
463
467
|
toggle: {
|
|
464
468
|
...toggleRead,
|
|
@@ -466,14 +470,14 @@
|
|
|
466
470
|
container: true,
|
|
467
471
|
snippet: emptyBody ? detailsOwn : details,
|
|
468
472
|
...disclosure,
|
|
469
|
-
presets:
|
|
473
|
+
presets: [
|
|
470
474
|
{
|
|
471
475
|
label: kinds.toggleList,
|
|
472
476
|
icon: '▸',
|
|
473
477
|
keywords: ['details', 'expand'],
|
|
474
478
|
markdown: ['> ']
|
|
475
479
|
}
|
|
476
|
-
]
|
|
480
|
+
]
|
|
477
481
|
},
|
|
478
482
|
// Notion's toggle headings: a heading's text as a toggle's header over its
|
|
479
483
|
// children. From a heading, `>` + space at its start makes it one (its level
|
|
@@ -485,23 +489,20 @@
|
|
|
485
489
|
container: true,
|
|
486
490
|
snippet: emptyBody ? detailsOwn : details,
|
|
487
491
|
...disclosure,
|
|
488
|
-
presets:
|
|
489
|
-
'
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
};
|
|
503
|
-
})
|
|
504
|
-
)
|
|
492
|
+
presets: HEADING_LEVELS.map((level, index) => {
|
|
493
|
+
const hashes = '#'.repeat(index + 1);
|
|
494
|
+
return {
|
|
495
|
+
label: [kinds.toggleHeading1, kinds.toggleHeading2, kinds.toggleHeading3][index]!,
|
|
496
|
+
icon: `▸H${['₁', '₂', '₃'][index]}`,
|
|
497
|
+
keywords: ['toggle', 'heading', 'collapse'],
|
|
498
|
+
data: { level },
|
|
499
|
+
markdownFrom: {
|
|
500
|
+
heading: ['> '],
|
|
501
|
+
toggle: [`${hashes} `],
|
|
502
|
+
'toggle-heading': [`${hashes} `]
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
})
|
|
505
506
|
},
|
|
506
507
|
// Notion's callout: an icon (`data.icon`, the picker's), a title (its own
|
|
507
508
|
// text) and a content (its children, any blocks), which shows even empty
|
|
@@ -511,9 +512,7 @@
|
|
|
511
512
|
container: true,
|
|
512
513
|
body: true,
|
|
513
514
|
snippet: calloutIcon || emptyBody ? calloutOwn : callout,
|
|
514
|
-
presets:
|
|
515
|
-
{ label: kinds.callout, icon: '✦', keywords: ['note', 'tip'], data: { icon } }
|
|
516
|
-
]),
|
|
515
|
+
presets: [{ label: kinds.callout, icon: '✦', keywords: ['note', 'tip'], data: { icon } }],
|
|
517
516
|
// A callout without its own icon exports the view's.
|
|
518
517
|
...calloutWrite((data) => iconOf(data, edytor)),
|
|
519
518
|
// The block menu's Change icon, while the view lists a picker: the keyboard's way to it.
|
|
@@ -537,15 +536,15 @@
|
|
|
537
536
|
container: true,
|
|
538
537
|
snippet: textThenChildren,
|
|
539
538
|
// Notion: `"` + space is a quote; `>` + space is a toggle.
|
|
540
|
-
presets:
|
|
539
|
+
presets: [{ label: kinds.quote, icon: '❝', markdown: ['" '] }]
|
|
541
540
|
},
|
|
542
541
|
divider: {
|
|
543
542
|
...richTextKinds.divider,
|
|
544
543
|
...dividerRead,
|
|
545
544
|
...dividerWrite,
|
|
546
|
-
presets:
|
|
545
|
+
presets: [
|
|
547
546
|
{ label: kinds.divider, icon: '—', keywords: ['hr', 'separator'], markdown: ['---'] }
|
|
548
|
-
]
|
|
547
|
+
],
|
|
549
548
|
empty: { content: [], children: [] }
|
|
550
549
|
},
|
|
551
550
|
'ordered-list': {
|
|
@@ -561,7 +560,7 @@
|
|
|
561
560
|
snippet: list
|
|
562
561
|
},
|
|
563
562
|
'list-item': { ...itemRead, ...itemWrite, snippet: listItem }
|
|
564
|
-
}
|
|
563
|
+
})
|
|
565
564
|
};
|
|
566
565
|
};
|
|
567
566
|
</script>
|
|
@@ -575,10 +574,13 @@
|
|
|
575
574
|
nested snippet or a text node between tags is one more node per block for
|
|
576
575
|
the browser to walk (`render.markers`).
|
|
577
576
|
-->
|
|
577
|
+
<!-- prettier-ignore -->
|
|
578
|
+
{#snippet nested(children: Snippet)}<div data-edytor-children>{@render children()}</div>{/snippet}
|
|
579
|
+
|
|
578
580
|
<!-- prettier-ignore -->
|
|
579
581
|
{#snippet paragraph({ content, children }: BlockSnippetPayload)}<!--
|
|
580
582
|
--><p>{@render content()}</p><!--
|
|
581
|
-
-->{#if children}
|
|
583
|
+
-->{#if children}{@render nested(children)}{/if}<!--
|
|
582
584
|
-->{/snippet}
|
|
583
585
|
|
|
584
586
|
<!--
|
|
@@ -588,7 +590,7 @@
|
|
|
588
590
|
<!-- prettier-ignore -->
|
|
589
591
|
{#snippet details({ block, content, children }: BlockSnippetPayload)}<!--
|
|
590
592
|
--><summary>{@render content()}</summary><!--
|
|
591
|
-
-->{#if children}
|
|
593
|
+
-->{#if children}{@render nested(children)}<!--
|
|
592
594
|
-->{:else}<EmptyBody {block} kind="toggle" />{/if}<!--
|
|
593
595
|
-->{/snippet}
|
|
594
596
|
|
|
@@ -600,7 +602,7 @@
|
|
|
600
602
|
<!-- prettier-ignore -->
|
|
601
603
|
{#snippet textThenChildren({ content, children }: BlockSnippetPayload)}<!--
|
|
602
604
|
-->{@render content()}<!--
|
|
603
|
-
-->{#if children}
|
|
605
|
+
-->{#if children}{@render nested(children)}{/if}<!--
|
|
604
606
|
-->{/snippet}
|
|
605
607
|
|
|
606
608
|
<!--
|
|
@@ -611,7 +613,7 @@
|
|
|
611
613
|
{#snippet callout({ block, content, children }: BlockSnippetPayload<{ icon?: string }>)}<!--
|
|
612
614
|
--><CalloutIcon {block} /><!--
|
|
613
615
|
--><div data-edytor-callout-title>{@render content()}</div><!--
|
|
614
|
-
-->{#if children}
|
|
616
|
+
-->{#if children}{@render nested(children)}<!--
|
|
615
617
|
-->{:else}<EmptyBody {block} kind="callout" />{/if}<!--
|
|
616
618
|
-->{/snippet}
|
|
617
619
|
|
|
@@ -619,7 +621,7 @@
|
|
|
619
621
|
{#snippet todoItem({ block, content, children }: BlockSnippetPayload<{ checked?: boolean }>)}<!--
|
|
620
622
|
--><TodoCheckbox {block} /><!--
|
|
621
623
|
--><div>{@render content()}</div><!--
|
|
622
|
-
-->{#if children}
|
|
624
|
+
-->{#if children}{@render nested(children)}{/if}<!--
|
|
623
625
|
-->{/snippet}
|
|
624
626
|
|
|
625
627
|
<!--
|
|
@@ -630,13 +632,13 @@
|
|
|
630
632
|
{#snippet todoItemOwn({ block, content, children }: BlockSnippetPayload<{ checked?: boolean }>)}<!--
|
|
631
633
|
--><RichTextControl {block} control="checkbox" /><!--
|
|
632
634
|
--><div>{@render content()}</div><!--
|
|
633
|
-
-->{#if children}
|
|
635
|
+
-->{#if children}{@render nested(children)}{/if}<!--
|
|
634
636
|
-->{/snippet}
|
|
635
637
|
|
|
636
638
|
<!-- prettier-ignore -->
|
|
637
639
|
{#snippet detailsOwn({ block, content, children }: BlockSnippetPayload)}<!--
|
|
638
640
|
--><summary>{@render content()}</summary><!--
|
|
639
|
-
-->{#if children}
|
|
641
|
+
-->{#if children}{@render nested(children)}<!--
|
|
640
642
|
-->{:else}<RichTextControl {block} control="toggle" />{/if}<!--
|
|
641
643
|
-->{/snippet}
|
|
642
644
|
|
|
@@ -644,14 +646,14 @@
|
|
|
644
646
|
{#snippet calloutOwn({ block, content, children }: BlockSnippetPayload<{ icon?: string }>)}<!--
|
|
645
647
|
--><RichTextControl {block} control="calloutIcon" /><!--
|
|
646
648
|
--><div data-edytor-callout-title>{@render content()}</div><!--
|
|
647
|
-
-->{#if children}
|
|
649
|
+
-->{#if children}{@render nested(children)}<!--
|
|
648
650
|
-->{:else}<RichTextControl {block} control="callout" />{/if}<!--
|
|
649
651
|
-->{/snippet}
|
|
650
652
|
|
|
651
653
|
<!-- prettier-ignore -->
|
|
652
654
|
{#snippet listItem({ content, children }: BlockSnippetPayload)}<!--
|
|
653
655
|
--><div>{@render content()}</div><!--
|
|
654
|
-
-->{#if children}
|
|
656
|
+
-->{#if children}{@render nested(children)}{/if}<!--
|
|
655
657
|
-->{/snippet}
|
|
656
658
|
|
|
657
659
|
<!--
|
|
@@ -74,9 +74,11 @@ export const openLink = (href, view = globalThis.window ?? null) => {
|
|
|
74
74
|
if (safe)
|
|
75
75
|
view?.open(safe, '_blank', 'noopener,noreferrer');
|
|
76
76
|
};
|
|
77
|
+
/** A mark's value as written: `true` when none is given. */
|
|
78
|
+
const markValue = (value) => value === undefined ? true : value;
|
|
77
79
|
/** Set `mark` to `value` (`null`: remove it) over `start`–`end` of `text`: the view's `formatRange`. */
|
|
78
80
|
const markText = (edytor, text, start, end, mark, value) => edytor.formatRange(text.parent.id, text.segStart + start, end - start, {
|
|
79
|
-
[mark]: value
|
|
81
|
+
[mark]: markValue(value)
|
|
80
82
|
});
|
|
81
83
|
const formatSelectedTextRange = (edytor, mark, value, toggle) => {
|
|
82
84
|
const { yStart, yEnd, startText, endText } = edytor.selection.textRange();
|
|
@@ -87,7 +89,7 @@ const formatSelectedTextRange = (edytor, mark, value, toggle) => {
|
|
|
87
89
|
// One decision for the whole range (Notion): a toggle removes the mark only
|
|
88
90
|
// when every selected character has it, else it marks all of them.
|
|
89
91
|
const on = spans.every(({ text, start, end }) => text.getMarksAtRange(start, end).every(({ marks }) => marks && mark in marks));
|
|
90
|
-
const next = toggle && on ? null : value
|
|
92
|
+
const next = toggle && on ? null : markValue(value);
|
|
91
93
|
// One command per span: a vetoed one keeps its marks (`dispatcher.each`).
|
|
92
94
|
edytor.dispatcher.each('format', spans, ({ text, start, end }) => edytor.formatRange(text.parent.id, text.segStart + start, end - start, { [mark]: next }));
|
|
93
95
|
// Selected blocks stay selected (Notion; a divider's phantom text is no range).
|
|
@@ -99,6 +101,23 @@ const formatSelectedTextRange = (edytor, mark, value, toggle) => {
|
|
|
99
101
|
* mark at (a lone selected divider's text is a phantom).
|
|
100
102
|
*/
|
|
101
103
|
const selectsBlocks = (edytor) => edytor.selection.value.kind === 'blocks';
|
|
104
|
+
/**
|
|
105
|
+
* At a caret (no block selection: a lone divider's text is a phantom),
|
|
106
|
+
* stage what `next` makes of the full set the next insertion carries
|
|
107
|
+
* (values kept), as a `format` command, and keep the caret; `false` over a
|
|
108
|
+
* range.
|
|
109
|
+
*/
|
|
110
|
+
const stageAtCaret = (edytor, next) => {
|
|
111
|
+
const { startText, yStart } = edytor.selection.textRange();
|
|
112
|
+
const { selection } = edytor;
|
|
113
|
+
if (!selection.projection.isCollapsed)
|
|
114
|
+
return false;
|
|
115
|
+
if (startText && !selectsBlocks(edytor)) {
|
|
116
|
+
edytor.dispatcher.run('format', () => selection.stage(next(marksForInsertion(startText, yStart, { pending: selection.pending }))));
|
|
117
|
+
selection.selectText(startText, yStart);
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
102
121
|
export const richTextOperations = (edytor) => ({
|
|
103
122
|
/**
|
|
104
123
|
* Insert a `divider` void block at the caret — the native
|
|
@@ -210,20 +229,8 @@ export const richTextOperations = (edytor) => ({
|
|
|
210
229
|
const safeValue = mark === 'color' || mark === 'highlight' ? sanitizeCssColorValue(value) : value;
|
|
211
230
|
if (safeValue === null)
|
|
212
231
|
return;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (isCollapsed) {
|
|
216
|
-
if (startText && !selectsBlocks(edytor)) {
|
|
217
|
-
// Stage the full set the next insertion carries, values kept.
|
|
218
|
-
edytor.dispatcher.run('format', () => edytor.selection.stage({
|
|
219
|
-
...marksForInsertion(startText, yStart, { pending: edytor.selection.pending }),
|
|
220
|
-
[mark]: safeValue
|
|
221
|
-
}));
|
|
222
|
-
edytor.selection.selectText(startText, yStart);
|
|
223
|
-
}
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
formatSelectedTextRange(edytor, mark, safeValue, false);
|
|
232
|
+
if (!stageAtCaret(edytor, (marks) => ({ ...marks, [mark]: safeValue })))
|
|
233
|
+
formatSelectedTextRange(edytor, mark, safeValue, false);
|
|
227
234
|
},
|
|
228
235
|
/**
|
|
229
236
|
* Toggle `mark` (any mark the editor defines: the rich text ones are
|
|
@@ -231,21 +238,8 @@ export const richTextOperations = (edytor) => ({
|
|
|
231
238
|
* stage it for the next character typed.
|
|
232
239
|
*/
|
|
233
240
|
setMarkAtRange: (mark, value) => {
|
|
234
|
-
const {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (startText && !selectsBlocks(edytor)) {
|
|
238
|
-
// A caret stages the full set the next insertion carries (values kept).
|
|
239
|
-
edytor.dispatcher.run('format', () => {
|
|
240
|
-
const { [mark]: current = null, ...rest } = marksForInsertion(startText, yStart, {
|
|
241
|
-
pending: selection.pending
|
|
242
|
-
});
|
|
243
|
-
selection.stage(current !== null ? rest : { ...rest, [mark]: value === undefined ? true : value });
|
|
244
|
-
});
|
|
245
|
-
selection.selectText(startText, yStart, endText, yEnd);
|
|
246
|
-
}
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
formatSelectedTextRange(edytor, mark, value, true);
|
|
241
|
+
const toggled = ({ [mark]: current = null, ...rest }) => current !== null ? rest : { ...rest, [mark]: markValue(value) };
|
|
242
|
+
if (!stageAtCaret(edytor, toggled))
|
|
243
|
+
formatSelectedTextRange(edytor, mark, value, true);
|
|
250
244
|
}
|
|
251
245
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTextContentOffsetAtPoint } from '../events/domTextOffset.js';
|
|
1
|
+
import { getTextContentOffsetAtPoint, textLeaves } from '../events/domTextOffset.js';
|
|
2
2
|
const TRAILING_NEWLINE_SELECTOR = '[data-edytor-trailing-newline]';
|
|
3
3
|
export const SYNTHETIC_TEXT_OVERLAY_SELECTOR = '[data-edytor-text-suggestion]';
|
|
4
4
|
/** A block suggestion's preview group: view-only, never a selection endpoint (`session/suggestions`). */
|
|
@@ -180,13 +180,10 @@ export const getInlineBlockInSelectedRange = (edytor, range) => {
|
|
|
180
180
|
* point, outside when one does not; `undefined` when that character has none.
|
|
181
181
|
*/
|
|
182
182
|
export const getMarkEdgeSide = (text, node, offset) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (offset <= start)
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
183
|
+
let start = 0;
|
|
184
|
+
const leaf = node && offset > 0 && text.node
|
|
185
|
+
? textLeaves(text.node).find((l) => offset <= (start += l.length))
|
|
186
|
+
: undefined;
|
|
190
187
|
let side;
|
|
191
188
|
for (let element = leaf?.parentElement; element && element !== text.node;) {
|
|
192
189
|
if (element.hasAttribute('data-edytor-mark'))
|
package/dist/session/handles.js
CHANGED
|
@@ -16,6 +16,7 @@ import { Block } from '../block/block.svelte.js';
|
|
|
16
16
|
import { InlineBlock } from '../block/inlineBlock.svelte.js';
|
|
17
17
|
import { Text } from '../text/text.svelte.js';
|
|
18
18
|
import { partsOf } from '../crdt/parts.js';
|
|
19
|
+
import { getOr } from '../utils.js';
|
|
19
20
|
/**
|
|
20
21
|
* A text segment's items as `JSONText` runs (`text.value`): empty items
|
|
21
22
|
* dropped, adjacent runs with the same marks joined, each run's marks a
|
|
@@ -61,10 +62,7 @@ export class Handles {
|
|
|
61
62
|
block = (id) => {
|
|
62
63
|
if (id === 'root')
|
|
63
64
|
return this.root;
|
|
64
|
-
|
|
65
|
-
if (!block)
|
|
66
|
-
this.#blocks.set(id, (block = new Block(this.edytor, id)));
|
|
67
|
-
return block;
|
|
65
|
+
return getOr(this.#blocks, id, () => new Block(this.edytor, id));
|
|
68
66
|
};
|
|
69
67
|
/** The handle of live block `id`; none for a dead or unknown id. */
|
|
70
68
|
get = (id) => id === 'root' || (id && this.edytor.facade.isVisibleBlock(id)) ? this.block(id) : undefined;
|
|
@@ -84,9 +82,7 @@ export class Handles {
|
|
|
84
82
|
};
|
|
85
83
|
/** The handle of atom `atomId`, shown in block `blockId`. */
|
|
86
84
|
atom = (blockId, atomId) => {
|
|
87
|
-
|
|
88
|
-
if (!atom)
|
|
89
|
-
this.#atoms.set(atomId, (atom = new InlineBlock(this.edytor, blockId, atomId)));
|
|
85
|
+
const atom = getOr(this.#atoms, atomId, () => new InlineBlock(this.edytor, blockId, atomId));
|
|
90
86
|
atom.blockId = blockId;
|
|
91
87
|
return atom;
|
|
92
88
|
};
|
|
@@ -45,7 +45,7 @@ import { runOccurrence } from '../events/onBeforeInput.js';
|
|
|
45
45
|
import { getDomSelectionSnapshot } from '../selection/domSelection.js';
|
|
46
46
|
import { domOffsetIn } from '../selection/selection.utils.js';
|
|
47
47
|
import { diverges, heal, ownedOf } from './attributes.js';
|
|
48
|
-
import { getTextContentOffsetAtPoint } from '../events/domTextOffset.js';
|
|
48
|
+
import { getTextContentOffsetAtPoint, textLeaves } from '../events/domTextOffset.js';
|
|
49
49
|
import { rendersMark } from './cells.js';
|
|
50
50
|
const ZWSP = /\u200B/g;
|
|
51
51
|
const ATOM = '\uFFFC';
|
|
@@ -73,13 +73,7 @@ const textOf = (cell) => cell.runs
|
|
|
73
73
|
/** Svelte's anchors: comments and empty text nodes are never input and never removed. */
|
|
74
74
|
const isAnchor = (node) => node.nodeType === Node.COMMENT_NODE || (node.nodeType === Node.TEXT_NODE && !node.nodeValue);
|
|
75
75
|
/** The `nth` non-empty text node under `node`. */
|
|
76
|
-
const textLeaf = (node, nth = 0) =>
|
|
77
|
-
const walker = (node.ownerDocument ?? document).createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
|
78
|
-
while (walker.nextNode())
|
|
79
|
-
if (walker.currentNode.data && nth-- === 0)
|
|
80
|
-
return walker.currentNode;
|
|
81
|
-
return null;
|
|
82
|
-
};
|
|
76
|
+
const textLeaf = (node, nth = 0) => textLeaves(node).filter((leaf) => leaf.data)[nth] ?? null;
|
|
83
77
|
const ZERO_WIDTH_SPACE = '\u200B';
|
|
84
78
|
/**
|
|
85
79
|
* What the browser shows for `text`: its element's text nodes in order (WebKit's
|
|
@@ -89,15 +83,10 @@ const ZERO_WIDTH_SPACE = '\u200B';
|
|
|
89
83
|
export const readDomText = (text) => {
|
|
90
84
|
let value = '';
|
|
91
85
|
const node = text.node;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
value += leaf.parentElement?.closest('span.Apple-converted-space')
|
|
97
|
-
? leaf.data.replaceAll('\u00A0', ' ')
|
|
98
|
-
: leaf.data;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
86
|
+
for (const leaf of node ? textLeaves(node) : [])
|
|
87
|
+
value += leaf.parentElement?.closest('span.Apple-converted-space')
|
|
88
|
+
? leaf.data.replaceAll('\u00A0', ' ')
|
|
89
|
+
: leaf.data;
|
|
101
90
|
// An empty text's filler shares its node with what the browser typed, on either side.
|
|
102
91
|
if (text.isEmpty)
|
|
103
92
|
return value.replaceAll(ZERO_WIDTH_SPACE, '');
|
|
@@ -968,21 +957,17 @@ const shownMarks = (text, at) => {
|
|
|
968
957
|
const element = text.node;
|
|
969
958
|
if (!element)
|
|
970
959
|
return null;
|
|
971
|
-
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
972
960
|
let offset = 0;
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
names.push(name);
|
|
982
|
-
}
|
|
983
|
-
return names.sort().join();
|
|
961
|
+
const node = textLeaves(element).find((leaf) => (offset += leaf.data.replace(ZWSP, '').length) > at);
|
|
962
|
+
if (!node)
|
|
963
|
+
return null;
|
|
964
|
+
const names = [];
|
|
965
|
+
for (let el = node.parentElement; el && el !== element; el = el.parentElement) {
|
|
966
|
+
const name = el.getAttribute('data-edytor-mark');
|
|
967
|
+
if (name)
|
|
968
|
+
names.push(name);
|
|
984
969
|
}
|
|
985
|
-
return
|
|
970
|
+
return names.sort().join();
|
|
986
971
|
};
|
|
987
972
|
/** The rendered marks the model gives the character at `at` of `text`. */
|
|
988
973
|
const modelMarks = (edytor, text, at) => Object.keys(activeMarks(text.getMarksAtRange(at, at + 1)[0]?.marks))
|
|
@@ -44,6 +44,7 @@ import { getMarkEdgeSide, getYIndex, isTextBoundSelectionPoint } from '../select
|
|
|
44
44
|
import { project, sameEnds } from '../session/selection.js';
|
|
45
45
|
import { isAndroidChromeBrowser } from '../events/events.utils.js';
|
|
46
46
|
import { isKindControl, isOwnPoint } from '../events/nativeInteractiveControl.js';
|
|
47
|
+
import { textLeaves } from '../events/domTextOffset.js';
|
|
47
48
|
/**
|
|
48
49
|
* Named rule: Android Chrome mutates the DOM after a
|
|
49
50
|
* canceled `deleteContentBackward` and reports the caret one position right
|
|
@@ -54,9 +55,8 @@ const ANDROID_SNAP_BACK_MS = 250;
|
|
|
54
55
|
const IME_POST_COMMIT_JUMP_MS = 100;
|
|
55
56
|
/** The DOM point of a text-segment offset: its text leaf, else the element itself. */
|
|
56
57
|
export const domPointOf = (element, offset) => {
|
|
57
|
-
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
58
58
|
let at = 0;
|
|
59
|
-
for (
|
|
59
|
+
for (const leaf of textLeaves(element)) {
|
|
60
60
|
const end = at + leaf.length;
|
|
61
61
|
if (offset <= end)
|
|
62
62
|
return [leaf, offset - at];
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export declare const id: (prefix: "t" | "b" | "i" | "v" | "s" | "c") => string;
|
|
2
|
+
/** `key`'s value in `map`, made (`make`) and stored when it holds none. */
|
|
3
|
+
export declare const getOr: <K, V>(map: {
|
|
4
|
+
get(key: K): V | undefined;
|
|
5
|
+
set(key: K, value: V): unknown;
|
|
6
|
+
}, key: K, make: () => V) => V;
|
|
2
7
|
/**
|
|
3
8
|
* A hook's veto as the dispatcher carries it: what `prevent(cb?)` recorded,
|
|
4
9
|
* and the extension that recorded it. Hooks never see one thrown at them:
|
package/dist/utils.js
CHANGED
|
@@ -6,6 +6,13 @@ export const id = (prefix) => {
|
|
|
6
6
|
t += alphabet[61 & r[n]];
|
|
7
7
|
return `${prefix}_${t}`;
|
|
8
8
|
};
|
|
9
|
+
/** `key`'s value in `map`, made (`make`) and stored when it holds none. */
|
|
10
|
+
export const getOr = (map, key, make) => {
|
|
11
|
+
let value = map.get(key);
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
map.set(key, (value = make()));
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
9
16
|
/**
|
|
10
17
|
* A hook's veto as the dispatcher carries it: what `prevent(cb?)` recorded,
|
|
11
18
|
* and the extension that recorded it. Hooks never see one thrown at them:
|
package/package.json
CHANGED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Structural capability: one answer in advance and at execution.
|
|
3
|
-
*
|
|
4
|
-
* ── Island/void enforcement (operation-layer, not stored) ──────────────
|
|
5
|
-
*
|
|
6
|
-
* Roles are resolved operation-time via `config.roleOf(type)`, through one
|
|
7
|
-
* role table (`DisplayRoles`) that the index's display and every guard
|
|
8
|
-
* below ask of a block's shown kind — the editor derives them from plugin
|
|
9
|
-
* block definitions, exactly like the baseline's
|
|
10
|
-
* `block.definition.island/void`. They are deliberately NOT replicated block
|
|
11
|
-
* data: the schema stays policy-free, and a replica with different plugins
|
|
12
|
-
* reads the same document. Enforced rules (baseline semantics, see
|
|
13
|
-
* `plugins.ts` + `block.utils.ts`):
|
|
14
|
-
*
|
|
15
|
-
* - `void`: cannot accept children (insert/move/nest into void rejected),
|
|
16
|
-
* cannot merge either direction, cannot be split. Content edits ARE
|
|
17
|
-
* allowed — void rendered content (captions) stays editable per the
|
|
18
|
-
* baseline contract. Deletion is allowed (delete is not editing).
|
|
19
|
-
* - `island`: editable content, but its subtree is structurally sealed —
|
|
20
|
-
* a block inside an island (`insideIsland`) cannot be moved, nested,
|
|
21
|
-
* unnested, or merged across the island boundary. Merges INSIDE one
|
|
22
|
-
* island are allowed; merging an island child into the island itself is
|
|
23
|
-
* allowed when the island renders its content. Moving or merging
|
|
24
|
-
* INTO an island subtree is rejected. An island declared `lines` (code)
|
|
25
|
-
* holds only lines of its `defaultChild` kind, and a line holds no
|
|
26
|
-
* children (the display enforces it against undo,
|
|
27
|
-
* `insertBlocks` refuses a line parent); any other island keeps its
|
|
28
|
-
* structure. `canPlace` and `canMerge` are the one answer, asked in
|
|
29
|
-
* advance or by the ops.
|
|
30
|
-
* - Island merge: when an island block itself is merged (backward or
|
|
31
|
-
* forward), its children are unnested to the vacated sibling slot and
|
|
32
|
-
* reset to the default child of that slot's parent (`defaultChild`).
|
|
33
|
-
* - Baseline merges NEVER adopt the merged block's children — they unnest
|
|
34
|
-
* to the vacated slot. The facade exposes both: `mergeBlocks` is the
|
|
35
|
-
* engine primitive (children adopt into the target
|
|
36
|
-
* by staying under the source, which the target's claim displays),
|
|
37
|
-
* `mergeBackward`/`mergeForward` reproduce the baseline command shape.
|
|
38
|
-
*
|
|
39
|
-
*/
|
|
40
|
-
import { type BlockId } from '../placement/model.js';
|
|
41
|
-
import type { DocBase, DocReads } from './reads.js';
|
|
42
|
-
/** The capability of the facade over its reads. */
|
|
43
|
-
export declare const docCapability: (c: DocBase & DocReads) => {
|
|
44
|
-
canPlace: (ids: readonly BlockId[], parent?: BlockId | null, kindOf?: (id: BlockId) => string | undefined) => boolean;
|
|
45
|
-
isContainer: (id: BlockId) => boolean;
|
|
46
|
-
fits: (parent: BlockId | null, kind: string | undefined) => boolean;
|
|
47
|
-
fitsIn: (parentType: string, kind: string | undefined) => boolean;
|
|
48
|
-
fitted: (parent: BlockId | null, kind: string | undefined) => string | undefined;
|
|
49
|
-
emptiable: (id: BlockId) => boolean;
|
|
50
|
-
nestParent: (ids: readonly BlockId[], parent: BlockId) => BlockId;
|
|
51
|
-
canMerge: (fromId: BlockId, intoId: BlockId) => boolean;
|
|
52
|
-
rendersContent: (id: BlockId) => boolean;
|
|
53
|
-
defaultChild: (parent: BlockId | null) => string;
|
|
54
|
-
kindToCopy: (id: BlockId) => string;
|
|
55
|
-
};
|
|
56
|
-
export type DocCapability = ReturnType<typeof docCapability>;
|