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.
Files changed (75) hide show
  1. package/README.md +2 -2
  2. package/dist/cloudflare/room/context.d.ts +62 -74
  3. package/dist/crdt/doc/history.js +2 -3
  4. package/dist/crdt/doc/ops.d.ts +81 -0
  5. package/dist/crdt/doc/ops.js +1898 -0
  6. package/dist/crdt/edytor-doc.d.ts +156 -156
  7. package/dist/crdt/edytor-doc.js +14 -42
  8. package/dist/crdt/flow.d.ts +4 -46
  9. package/dist/crdt/flow.js +2 -262
  10. package/dist/crdt/index.d.ts +62 -74
  11. package/dist/crdt/operations.d.ts +1 -1
  12. package/dist/crdt/placement/resolve.d.ts +1 -1
  13. package/dist/crdt/protocols/awareness.js +46 -81
  14. package/dist/crdt/providers/index.d.ts +10 -22
  15. package/dist/crdt/providers/indexeddb.d.ts +6 -10
  16. package/dist/crdt/providers/indexeddb.js +10 -16
  17. package/dist/crdt/providers/room.d.ts +11 -0
  18. package/dist/crdt/providers/room.js +26 -12
  19. package/dist/crdt/providers/websocket.d.ts +6 -12
  20. package/dist/crdt/providers/websocket.js +8 -15
  21. package/dist/crdt/rangeDelete.d.ts +2 -47
  22. package/dist/crdt/rangeDelete.js +1 -169
  23. package/dist/crdt/text/index/build.d.ts +125 -0
  24. package/dist/crdt/text/index/build.js +2895 -0
  25. package/dist/crdt/text/index/shared.d.ts +2 -6
  26. package/dist/crdt/text/index/shared.js +2 -5
  27. package/dist/crdt/text/model.js +2 -3
  28. package/dist/crdt/text/runs.d.ts +9 -12
  29. package/dist/crdt/text/runs.js +2 -165
  30. package/dist/events/domTextOffset.d.ts +2 -0
  31. package/dist/events/domTextOffset.js +8 -0
  32. package/dist/export/forms.d.ts +50 -50
  33. package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
  34. package/dist/plugins/richtext/richTextOperations.js +26 -32
  35. package/dist/selection/selection.utils.js +5 -8
  36. package/dist/session/handles.js +3 -7
  37. package/dist/surface/observer.svelte.js +15 -30
  38. package/dist/surface/projector.svelte.js +2 -2
  39. package/dist/utils.d.ts +5 -0
  40. package/dist/utils.js +7 -0
  41. package/package.json +1 -1
  42. package/dist/crdt/doc/capability.d.ts +0 -56
  43. package/dist/crdt/doc/capability.js +0 -207
  44. package/dist/crdt/doc/content.d.ts +0 -16
  45. package/dist/crdt/doc/content.js +0 -77
  46. package/dist/crdt/doc/delete.d.ts +0 -15
  47. package/dist/crdt/doc/delete.js +0 -84
  48. package/dist/crdt/doc/layout.d.ts +0 -9
  49. package/dist/crdt/doc/layout.js +0 -178
  50. package/dist/crdt/doc/meta.d.ts +0 -19
  51. package/dist/crdt/doc/meta.js +0 -198
  52. package/dist/crdt/doc/moves.d.ts +0 -21
  53. package/dist/crdt/doc/moves.js +0 -274
  54. package/dist/crdt/doc/split-merge.d.ts +0 -10
  55. package/dist/crdt/doc/split-merge.js +0 -179
  56. package/dist/crdt/doc/steps.d.ts +0 -65
  57. package/dist/crdt/doc/steps.js +0 -311
  58. package/dist/crdt/text/index/cache.d.ts +0 -16
  59. package/dist/crdt/text/index/cache.js +0 -175
  60. package/dist/crdt/text/index/claims.d.ts +0 -18
  61. package/dist/crdt/text/index/claims.js +0 -203
  62. package/dist/crdt/text/index/fold.d.ts +0 -21
  63. package/dist/crdt/text/index/fold.js +0 -656
  64. package/dist/crdt/text/index/layout.d.ts +0 -22
  65. package/dist/crdt/text/index/layout.js +0 -258
  66. package/dist/crdt/text/index/placement.d.ts +0 -47
  67. package/dist/crdt/text/index/placement.js +0 -738
  68. package/dist/crdt/text/index/records.d.ts +0 -13
  69. package/dist/crdt/text/index/records.js +0 -139
  70. package/dist/crdt/text/index/report.d.ts +0 -18
  71. package/dist/crdt/text/index/report.js +0 -337
  72. package/dist/crdt/text/index/state.d.ts +0 -197
  73. package/dist/crdt/text/index/state.js +0 -116
  74. package/dist/crdt/text/index/streams.d.ts +0 -25
  75. 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 { Plugin, BlockSnippetPayload, KindPreset, PlaceholderView } from '../../plugins.js';
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
- const presets = (type: string, rows: KindPreset[]) =>
195
- rows.map((row, index) => {
196
- const own = keywordsOf(edytor, presetId(type, index, rows.length), row.keywords);
197
- return own ? { ...row, keywords: own } : row;
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: presets('paragraph', [
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: presets(
410
- 'heading',
411
- HEADING_LEVELS.map((level, index) => ({
412
- label: [kinds.heading1, kinds.heading2, kinds.heading3][index]!,
413
- icon: `H${['₁', '₂', '₃'][index]}`,
414
- keywords: [level, ...[['title'], ['subtitle'], []][index]!],
415
- data: { level },
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: presets('bulleted-list-item', [
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: presets('numbered-list-item', [
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: presets('todo-item', [
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: presets('toggle', [
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: presets(
489
- 'toggle-heading',
490
- HEADING_LEVELS.map((level, index) => {
491
- const hashes = '#'.repeat(index + 1);
492
- return {
493
- label: [kinds.toggleHeading1, kinds.toggleHeading2, kinds.toggleHeading3][index]!,
494
- icon: `▸H${['₁', '₂', '₃'][index]}`,
495
- keywords: ['toggle', 'heading', 'collapse'],
496
- data: { level },
497
- markdownFrom: {
498
- heading: ['> '],
499
- toggle: [`${hashes} `],
500
- 'toggle-heading': [`${hashes} `]
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: presets('callout', [
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: presets('quote', [{ label: kinds.quote, icon: '❝', markdown: ['" '] }])
539
+ presets: [{ label: kinds.quote, icon: '❝', markdown: ['" '] }]
541
540
  },
542
541
  divider: {
543
542
  ...richTextKinds.divider,
544
543
  ...dividerRead,
545
544
  ...dividerWrite,
546
- presets: presets('divider', [
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}<div data-edytor-children>{@render children()}</div>{/if}<!--
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}<div data-edytor-children>{@render children()}</div><!--
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}<div data-edytor-children>{@render children()}</div>{/if}<!--
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}<div data-edytor-children>{@render children()}</div><!--
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}<div data-edytor-children>{@render children()}</div>{/if}<!--
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}<div data-edytor-children>{@render children()}</div>{/if}<!--
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}<div data-edytor-children>{@render children()}</div><!--
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}<div data-edytor-children>{@render children()}</div><!--
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}<div data-edytor-children>{@render children()}</div>{/if}<!--
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 === undefined ? true : 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 === undefined ? true : 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
- const { startText, yStart } = edytor.selection.textRange();
214
- const { isCollapsed } = edytor.selection.projection;
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 { yStart, yEnd, startText, endText } = edytor.selection.textRange();
235
- const { selection } = edytor;
236
- if (selection.projection.isCollapsed) {
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
- const walker = text.node?.ownerDocument.createTreeWalker(text.node, NodeFilter.SHOW_TEXT);
184
- let leaf = null;
185
- for (let start = 0; node && offset > 0 && (leaf = walker?.nextNode());) {
186
- start += leaf.textContent?.length ?? 0;
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'))
@@ -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
- let block = this.#blocks.get(id);
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
- let atom = this.#atoms.get(atomId);
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
- if (node) {
93
- const walker = (node.ownerDocument ?? document).createTreeWalker(node, NodeFilter.SHOW_TEXT);
94
- while (walker.nextNode()) {
95
- const leaf = walker.currentNode;
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
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
974
- offset += node.data.replace(ZWSP, '').length;
975
- if (offset <= at)
976
- continue;
977
- const names = [];
978
- for (let el = node.parentElement; el && el !== element; el = el.parentElement) {
979
- const name = el.getAttribute('data-edytor-mark');
980
- if (name)
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 null;
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 (let leaf = walker.nextNode(); leaf; leaf = walker.nextNode()) {
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,7 +1,7 @@
1
1
  {
2
2
  "name": "edytor",
3
3
  "license": "MIT",
4
- "version": "5.0.0",
4
+ "version": "6.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/beynar/edytor.git"
@@ -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>;