edytor 1.0.0 → 2.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 (116) hide show
  1. package/README.md +14 -72
  2. package/dist/block/block.svelte.d.ts +6 -4
  3. package/dist/block/block.svelte.js +10 -4
  4. package/dist/block/inlineBlock.svelte.d.ts +6 -2
  5. package/dist/block/inlineBlock.svelte.js +7 -2
  6. package/dist/cloudflare/room/context.d.ts +6 -0
  7. package/dist/collaboration/awarenessSelection.d.ts +22 -1
  8. package/dist/collaboration/awarenessSelection.js +42 -0
  9. package/dist/components/Block.svelte +3 -1
  10. package/dist/components/Edytor.svelte +36 -10
  11. package/dist/components/Edytor.svelte.d.ts +8 -1
  12. package/dist/components/InlineBlock.svelte +1 -1
  13. package/dist/crdt/data.d.ts +10 -0
  14. package/dist/crdt/data.js +20 -2
  15. package/dist/crdt/doc/reads.d.ts +7 -0
  16. package/dist/crdt/doc/reads.js +11 -1
  17. package/dist/crdt/document.d.ts +8 -0
  18. package/dist/crdt/document.js +12 -0
  19. package/dist/crdt/edytor-doc.d.ts +18 -0
  20. package/dist/crdt/edytor-doc.js +2 -1
  21. package/dist/crdt/index.d.ts +6 -0
  22. package/dist/crdt/operations.d.ts +1 -1
  23. package/dist/crdt/operations.js +1 -0
  24. package/dist/crdt/props.d.ts +32 -0
  25. package/dist/crdt/props.js +172 -0
  26. package/dist/{plugins/blockHandles → dnd}/dropIndicator.d.ts +2 -2
  27. package/dist/{plugins/blockHandles → dnd}/geometry.d.ts +2 -2
  28. package/dist/dnd/placement.d.ts +200 -0
  29. package/dist/dnd/placement.js +426 -0
  30. package/dist/edytor.svelte.d.ts +17 -4
  31. package/dist/edytor.svelte.js +16 -7
  32. package/dist/events/beforeInputCommands.d.ts +2 -2
  33. package/dist/events/beforeInputCommands.js +8 -4
  34. package/dist/events/onDrop.d.ts +6 -0
  35. package/dist/events/onDrop.js +38 -10
  36. package/dist/events/onPaste.d.ts +2 -1
  37. package/dist/events/onPaste.js +8 -3
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.js +1 -1
  40. package/dist/labels.d.ts +2 -0
  41. package/dist/labels.js +2 -1
  42. package/dist/plugins/arrowMove/arrowMove.d.ts +1 -1
  43. package/dist/plugins/arrowMove/arrowMove.js +1 -1
  44. package/dist/plugins/blockHandles/BlockHandleController.svelte.d.ts +2 -122
  45. package/dist/plugins/blockHandles/BlockHandleController.svelte.js +29 -387
  46. package/dist/plugins/blockHandles/BlockHandles.svelte +14 -32
  47. package/dist/plugins/blockHandles/blockHandlesPlugin.js +2 -1
  48. package/dist/plugins/code/CodeHeader.svelte +5 -3
  49. package/dist/plugins/code/CodePlugin.svelte +3 -3
  50. package/dist/plugins/code/languages.d.ts +0 -3
  51. package/dist/plugins/code/languages.js +0 -2
  52. package/dist/plugins/columns/ColumnResize.svelte +3 -2
  53. package/dist/plugins/columns/ColumnsPlugin.svelte +1 -1
  54. package/dist/plugins/columns/gaps.d.ts +7 -5
  55. package/dist/plugins/columns/gaps.js +11 -6
  56. package/dist/plugins/columns/resize.svelte.d.ts +3 -3
  57. package/dist/plugins/columns/resize.svelte.js +11 -10
  58. package/dist/plugins/equation/EquationPlugin.svelte +1 -8
  59. package/dist/plugins/equation/EquationPlugin.svelte.d.ts +1 -0
  60. package/dist/plugins/equation/equation.css +7 -0
  61. package/dist/plugins/fileDrop.d.ts +28 -0
  62. package/dist/plugins/fileDrop.js +116 -0
  63. package/dist/plugins/image/ImagePlugin.svelte +42 -33
  64. package/dist/plugins/image/ImagePlugin.svelte.d.ts +13 -1
  65. package/dist/plugins/image/image.css +17 -0
  66. package/dist/plugins/index.d.ts +3 -4
  67. package/dist/plugins/index.js +0 -1
  68. package/dist/plugins/markdownShortcuts.js +5 -5
  69. package/dist/plugins/media/AudioPlugin.svelte +13 -1
  70. package/dist/plugins/media/AudioPlugin.svelte.d.ts +11 -1
  71. package/dist/plugins/media/FilePlugin.svelte +11 -1
  72. package/dist/plugins/media/FilePlugin.svelte.d.ts +9 -0
  73. package/dist/plugins/media/SourceBlock.svelte +48 -12
  74. package/dist/plugins/media/SourceBlock.svelte.d.ts +4 -3
  75. package/dist/plugins/media/UploadPreview.svelte +32 -0
  76. package/dist/plugins/media/UploadPreview.svelte.d.ts +11 -0
  77. package/dist/plugins/media/VideoPlugin.svelte +13 -1
  78. package/dist/plugins/media/VideoPlugin.svelte.d.ts +11 -1
  79. package/dist/plugins/media/empty.svelte.d.ts +13 -4
  80. package/dist/plugins/media/empty.svelte.js +30 -19
  81. package/dist/plugins/media/media.d.ts +7 -0
  82. package/dist/plugins/media/upload.css +23 -0
  83. package/dist/plugins/mention/MentionPlugin.svelte +72 -33
  84. package/dist/plugins/mention/MentionPlugin.svelte.d.ts +11 -2
  85. package/dist/plugins/mention/mention.css +6 -0
  86. package/dist/plugins/pageLink/PageLinkPlugin.svelte +122 -17
  87. package/dist/plugins/pageLink/PageLinkPlugin.svelte.d.ts +13 -1
  88. package/dist/plugins/pageLink/pageLink.css +6 -0
  89. package/dist/plugins/richtext/RichTextPlugin.svelte +14 -7
  90. package/dist/plugins/richtext/RichTextPlugin.svelte.d.ts +1 -0
  91. package/dist/plugins/richtext/richtext.css +6 -0
  92. package/dist/plugins/table/chrome.svelte.js +4 -3
  93. package/dist/plugins/uploads.svelte.d.ts +45 -32
  94. package/dist/plugins/uploads.svelte.js +105 -39
  95. package/dist/plugins.d.ts +40 -14
  96. package/dist/session/attempt.d.ts +16 -6
  97. package/dist/session/attempt.js +1 -0
  98. package/dist/session/bindings.js +34 -31
  99. package/dist/session/commands.js +2 -0
  100. package/dist/session/history.js +1 -0
  101. package/dist/session/inputRules.d.ts +6 -3
  102. package/dist/session/inputRules.js +9 -5
  103. package/dist/session/moves.d.ts +33 -0
  104. package/dist/session/moves.js +71 -1
  105. package/dist/session/navigation.d.ts +7 -0
  106. package/dist/session/navigation.js +19 -10
  107. package/dist/session/props.d.ts +28 -0
  108. package/dist/session/props.js +65 -0
  109. package/dist/session/uploads.d.ts +29 -0
  110. package/dist/session/uploads.js +10 -0
  111. package/dist/themes/notion.css +9 -0
  112. package/package.json +1 -1
  113. /package/dist/{plugins/blockHandles → dnd}/dropIndicator.js +0 -0
  114. /package/dist/{plugins/blockHandles → dnd}/geometry.js +0 -0
  115. /package/dist/{plugins/columns → dnd}/stacking.d.ts +0 -0
  116. /package/dist/{plugins/columns → dnd}/stacking.js +0 -0
package/dist/plugins.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Edytor } from './edytor.svelte.js';
3
3
  import type { Block } from './block/block.svelte.js';
4
+ import type { BlockMovePosition } from './session/moves.js';
4
5
  import type { JSONBlock, JSONDoc, JSONInlineBlock, JSONText } from './utils/json.js';
5
6
  import type { Text } from './text/text.svelte.js';
6
7
  import type { TextTransform } from './surface/cells.js';
@@ -42,7 +43,12 @@ export type { Prevent };
42
43
  export type BlockView<D = Record<string, any>> = {
43
44
  readonly id: string;
44
45
  readonly type: string;
45
- readonly data: Readonly<D>;
46
+ /**
47
+ * The block's data as plain state (`block.data`): reactive at any depth,
48
+ * and writable, each write a dispatched `patchData`. In a suggestion's
49
+ * preview it is a copy: a write there changes nothing.
50
+ */
51
+ readonly data: D;
46
52
  readonly selected: boolean;
47
53
  readonly focused: boolean;
48
54
  /**
@@ -241,7 +247,10 @@ export type TextRuleContext = {
241
247
  };
242
248
  /** What an input rule's `replace` receives besides the match. */
243
249
  export type InputRuleContext = TextRuleContext & {
244
- /** The typed text that completed the match: part of the match, not in the document. */
250
+ /**
251
+ * The typed text that completed the match: the end of the match, already
252
+ * in the document as its own undo step (`to` is after it).
253
+ */
245
254
  typed: string;
246
255
  /**
247
256
  * Remove the matched text (`from` to `to`) in the same plan as the first
@@ -267,13 +276,14 @@ export type InputRule = {
267
276
  */
268
277
  find: RegExp;
269
278
  /**
270
- * The replacement. Return a string to replace the whole match with it
271
- * (one command and one undo step with the matched text's removal; the
272
- * caret after it), or write it yourself and return `true`, usually
273
- * through `ctx.remove(() => …)`. Return `false` or `null`, writing
274
- * nothing, to decline: the next rule is asked, then the typed text is
275
- * inserted as usual, as it is when the replacement is refused (by the
276
- * document, readonly or a plugin's veto).
279
+ * The replacement, run once the typed text is in the document (its own
280
+ * undo step, so the first undo gives the typed text back). Return a
281
+ * string to replace the whole match with it (one command and one undo
282
+ * step with the matched text's removal; the caret after it), or write it
283
+ * yourself and return `true`, usually through `ctx.remove(() => …)`.
284
+ * Return `false` or `null`, writing nothing, to decline: the next rule is
285
+ * asked, and the typed text stays as typed, as it does when the
286
+ * replacement is refused (by the document, readonly or a plugin's veto).
277
287
  */
278
288
  replace: (match: RegExpExecArray, ctx: InputRuleContext) => string | boolean | null | void;
279
289
  };
@@ -292,6 +302,11 @@ export type TriggerContext = TextRuleContext & {
292
302
  export type Trigger<T = any> = {
293
303
  /** The text that opens the menu (one or two characters: `@`, `[[`). */
294
304
  char: string;
305
+ /**
306
+ * The menu's name, read by its markup as `controller.trigger.name` (the
307
+ * mention plugin's `kind`: one menu snippet can serve several triggers).
308
+ */
309
+ name?: string;
295
310
  /** The rows for `query`, at once or as a promise (a search); the newest query wins. */
296
311
  items: (query: string, ctx: TriggerContext) => readonly T[] | Promise<readonly T[]>;
297
312
  /**
@@ -397,10 +412,18 @@ export type PluginOperations = {
397
412
  prevent: Prevent;
398
413
  e: ClipboardEvent;
399
414
  }) => void;
400
- /** Called when a paste event is detected */
415
+ /**
416
+ * Called when a paste event is detected, and for a drop (its data as the
417
+ * clipboard's). `at`: where files dropped from outside land, the drop
418
+ * indicator's placement (beside, before, after or inside `block`).
419
+ */
401
420
  onPaste?: (payload: {
402
421
  prevent: Prevent;
403
422
  e: ClipboardEvent;
423
+ at?: {
424
+ block: string;
425
+ position: BlockMovePosition;
426
+ };
404
427
  }) => void;
405
428
  };
406
429
  /**
@@ -493,9 +516,11 @@ export type BlockDefinition = {
493
516
  */
494
517
  rendersContent?: boolean;
495
518
  /**
496
- * The files the kind takes (the image's `image/*`, the file block's any):
497
- * with the uploads plugin listed, an empty block's Upload sends one and a
498
- * pasted or dropped file becomes a block of this kind (`media.files`).
519
+ * The files the kind takes (the image's `image/*`, the file block's any)
520
+ * and who sends them (`send`: the media plugins set it from their
521
+ * `upload` option): an empty block's Upload sends one, and a pasted or
522
+ * dropped file becomes a block of this kind (`media.files`). A kind
523
+ * without it takes no files.
499
524
  */
500
525
  upload?: KindUpload;
501
526
  /**
@@ -609,7 +634,8 @@ export type BlockDefinition = {
609
634
  export type InlineBlockView<D = Record<string, any>> = {
610
635
  readonly id: string;
611
636
  readonly type: string;
612
- readonly data: Readonly<D>;
637
+ /** The atom's data as plain state (`atom.data`), writable; a suggested atom's is a copy. */
638
+ readonly data: D;
613
639
  readonly selected: boolean;
614
640
  /** The atom's id-only handle; a suggested atom has none. */
615
641
  readonly handle: InlineBlock | undefined;
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import type { Edytor } from '../edytor.svelte.js';
20
20
  import type { Text } from '../text/text.svelte.js';
21
+ import type { BlockMovePosition } from './moves.js';
21
22
  type Kind = 'text' | 'composition' | 'payload' | 'break' | 'delete' | 'history';
22
23
  export type IntentRow = {
23
24
  kind: Kind;
@@ -63,10 +64,10 @@ export declare const untargeted: (edytor: Edytor) => boolean;
63
64
  * void's own field.
64
65
  */
65
66
  declare const facts: (edytor: Edytor) => {
66
- projection: import("./selection").SelectionProjection;
67
+ projection: import("./selection.js").SelectionProjection;
67
68
  isCollapsed: boolean;
68
69
  /** The admitted mark-edge side of a caret. */
69
- edge: import("./editing/text").EdgeSide | undefined;
70
+ edge: import("./editing/text.js").EdgeSide | undefined;
70
71
  isVoidEditableElement: boolean;
71
72
  startText: Text | null;
72
73
  endText: Text | null;
@@ -74,7 +75,7 @@ declare const facts: (edytor: Edytor) => {
74
75
  yEnd: number;
75
76
  texts: Text[];
76
77
  /** The anchored target, fixed at admission. */
77
- target: import("./selection").SelectionValue;
78
+ target: import("./selection.js").SelectionValue;
78
79
  };
79
80
  /**
80
81
  * How DOM drift around a model-owned attempt is repaired: the observer
@@ -107,9 +108,16 @@ export type Occurrence = {
107
108
  dataTransfer?: DataTransfer | null;
108
109
  /** The range the browser declared (`getTargetRanges()`, a drop point). */
109
110
  declared?: StaticRange | null;
111
+ /** Where a file drop lands, the indicator's placement (`plugins/fileDrop.ts`). */
112
+ placement?: DropAt | null;
110
113
  cancelable: boolean;
111
114
  event?: InputEvent;
112
115
  };
116
+ /** A drop's placement in block terms: beside, before, after or inside `block`. */
117
+ export type DropAt = {
118
+ block: string;
119
+ position: BlockMovePosition;
120
+ };
113
121
  export type Attempt = ReturnType<typeof facts> & {
114
122
  id: number;
115
123
  /** The intent, fixed at admission. */
@@ -121,6 +129,7 @@ export type Attempt = ReturnType<typeof facts> & {
121
129
  dataTransfer: DataTransfer | null;
122
130
  hasDataTransferTextPayload: boolean;
123
131
  declared: StaticRange | null;
132
+ placement: DropAt | null;
124
133
  /** Admitted from a keydown (the key's intent wins; the browser may never announce it). */
125
134
  isStructuralKeyFallback: boolean;
126
135
  owner: 'model' | 'browser';
@@ -135,10 +144,10 @@ export declare const intentSnapshot: (edytor: Edytor, inputType: string) => Atte
135
144
  export declare const caretAt: (edytor: Edytor, text: Text | null | undefined, offset: number) => void;
136
145
  /** Re-project an attempt's anchored target when it runs later than its admission. */
137
146
  export declare const reproject: (edytor: Edytor, attempt: Attempt) => {
138
- projection: import("./selection").SelectionProjection;
147
+ projection: import("./selection.js").SelectionProjection;
139
148
  isCollapsed: boolean;
140
149
  /** The admitted mark-edge side of a caret. */
141
- edge: import("./editing/text").EdgeSide | undefined;
150
+ edge: import("./editing/text.js").EdgeSide | undefined;
142
151
  isVoidEditableElement: boolean;
143
152
  startText: Text | null;
144
153
  endText: Text | null;
@@ -146,7 +155,7 @@ export declare const reproject: (edytor: Edytor, attempt: Attempt) => {
146
155
  yEnd: number;
147
156
  texts: Text[];
148
157
  /** The anchored target, fixed at admission. */
149
- target: import("./selection").SelectionValue;
158
+ target: import("./selection.js").SelectionValue;
150
159
  } & {
151
160
  id: number;
152
161
  /** The intent, fixed at admission. */
@@ -158,6 +167,7 @@ export declare const reproject: (edytor: Edytor, attempt: Attempt) => {
158
167
  dataTransfer: DataTransfer | null;
159
168
  hasDataTransferTextPayload: boolean;
160
169
  declared: StaticRange | null;
170
+ placement: DropAt | null;
161
171
  /** Admitted from a keydown (the key's intent wins; the browser may never announce it). */
162
172
  isStructuralKeyFallback: boolean;
163
173
  owner: "model" | "browser";
@@ -119,6 +119,7 @@ export const attemptOf = (edytor, occurrence, keyIntent) => {
119
119
  dataTransfer: occurrence.dataTransfer ?? null,
120
120
  hasDataTransferTextPayload: payload !== null,
121
121
  declared: occurrence.declared ?? null,
122
+ placement: occurrence.placement ?? null,
122
123
  isStructuralKeyFallback: Boolean(keyIntent),
123
124
  owner: 'model',
124
125
  expect: null,
@@ -119,41 +119,44 @@ const moveRoots = (edytor, blocks, direction) => {
119
119
  .flatMap((moved) => moved ?? []);
120
120
  };
121
121
  /**
122
- * Tab / Shift+Tab: nest or unnest the selected block, or the caret's block.
122
+ * Tab / Shift+Tab, with `allowNesting`: nest or unnest the selected block, or the caret's block.
123
123
  * Selected blocks, or the blocks a text range spans, move one level per
124
124
  * group of siblings, a selected block's selected descendants with it, as
125
125
  * one command; the selection stays (its anchors moved with the blocks).
126
126
  */
127
- const nest = (operation) => ({ edytor, prevent }) => prevent(() => {
128
- const { selection } = edytor;
129
- const { value } = selection;
130
- const direction = operation === 'nestBlock' ? 'in' : 'out';
131
- const blocks = getSelectionBlocks(edytor);
132
- if (blocks.length > 1) {
133
- if (moveRoots(edytor, blocks, direction).length)
134
- selection.select(value);
135
- return;
136
- }
137
- const [selected] = selection.selectedBlocks;
138
- const at = selection.textAt(selection.projection.start);
139
- const block = selected ?? at?.text.parent;
140
- if (!block)
141
- return;
142
- // Into the block before it; the view's move reveals a closed toggle it lands in or that adopts.
143
- const into = block.previousBlock;
144
- if (operation === 'nestBlock' && !into)
145
- return edytor.dispatcher.record({ operation, status: 'refused' });
146
- const result = operation === 'unNestBlock'
147
- ? edytor.unNestBlock(block.id)
148
- : edytor.nestBlock(block.id, into.id);
149
- if (result.status !== 'applied')
150
- return;
151
- edytor.announcer.moved({ blocks: [block], direction }, [block]);
152
- if (selected)
153
- selection.select(blockSelection([block.id]));
154
- else if (at)
155
- caretAt(edytor, block.content[at.text.index], at.offset);
156
- });
127
+ const nest = (operation) => ({ edytor, prevent }) =>
128
+ // Nesting off (`allowNesting`, the default): the key is not the editor's, the browser moves the focus on.
129
+ edytor.allowNesting &&
130
+ prevent(() => {
131
+ const { selection } = edytor;
132
+ const { value } = selection;
133
+ const direction = operation === 'nestBlock' ? 'in' : 'out';
134
+ const blocks = getSelectionBlocks(edytor);
135
+ if (blocks.length > 1) {
136
+ if (moveRoots(edytor, blocks, direction).length)
137
+ selection.select(value);
138
+ return;
139
+ }
140
+ const [selected] = selection.selectedBlocks;
141
+ const at = selection.textAt(selection.projection.start);
142
+ const block = selected ?? at?.text.parent;
143
+ if (!block)
144
+ return;
145
+ // Into the block before it; the view's move reveals a closed toggle it lands in or that adopts.
146
+ const into = block.previousBlock;
147
+ if (operation === 'nestBlock' && !into)
148
+ return edytor.dispatcher.record({ operation, status: 'refused' });
149
+ const result = operation === 'unNestBlock'
150
+ ? edytor.unNestBlock(block.id)
151
+ : edytor.nestBlock(block.id, into.id);
152
+ if (result.status !== 'applied')
153
+ return;
154
+ edytor.announcer.moved({ blocks: [block], direction }, [block]);
155
+ if (selected)
156
+ selection.select(blockSelection([block.id]));
157
+ else if (at)
158
+ caretAt(edytor, block.content[at.text.index], at.offset);
159
+ });
157
160
  /**
158
161
  * Backspace/Delete over an editor-owned selection: the selected atom, or
159
162
  * the selected blocks (the caret lands at the end of the block before them).
@@ -458,6 +458,8 @@ export class Dispatcher {
458
458
  */
459
459
  operate = (operation, args) => {
460
460
  const { edytor } = this;
461
+ // The property writes queued in this task go first (`session/props`).
462
+ edytor.dataWrites.flush();
461
463
  const own = edytor.facade.prepare[operation];
462
464
  const prepare = (a) => {
463
465
  if (operation === 'insertText' && a[3] === undefined)
@@ -114,6 +114,7 @@ export class History {
114
114
  replay = (command) => !this.edytor.dispatcher.permits() ? 'refused' : this.#run(command) ? 'applied' : 'noop';
115
115
  #run(command) {
116
116
  const { undoManager: um, transaction: key, selection, facade } = this.edytor;
117
+ this.edytor.dataWrites.flush(); // this task's property writes are a step first
117
118
  this.#open = null;
118
119
  // Other writers (remote-apply and repair restores) stand aside while the
119
120
  // replay commits: this view's recorded value decides.
@@ -3,9 +3,12 @@
3
3
  * (`## ` → a heading, `:smile:` → 😄). The one engine behind every
4
4
  * plugin's `inputRules`: it reads the typed text and the caret, finds the
5
5
  * first rule whose `find` ends at the typed text, and replaces the typed
6
- * `insertText` with the rule's replacement — a command of its own, the
7
- * matched text's removal leading its first operation (`dispatcher.lead`), so
8
- * a refusal of either writes neither and the typed text lands as typed.
6
+ * `insertText` with two steps: the typed text as typed (an insertion, which
7
+ * may continue the typing before it), then the rule's replacement, a command
8
+ * of its own whose first operation the matched text's removal leads
9
+ * (`dispatcher.lead`), so a refusal of either writes neither and the typed
10
+ * text stays as typed. The first undo gives the typed text back, the
11
+ * caret after it (as the autolink does).
9
12
  */
10
13
  import type { Block } from '../block/block.svelte.js';
11
14
  import type { Edytor } from '../edytor.svelte.js';
@@ -57,7 +57,8 @@ export const inputRulesHook = (edytor, rules) => {
57
57
  /** The match's start in the segment; the match in the block, `from` to the caret. */
58
58
  const start = match.index;
59
59
  const from = text.segStart + start;
60
- const to = text.segStart + yStart;
60
+ // The typed text is in the document: the match ends after it.
61
+ const to = text.segStart + yStart + value.length;
61
62
  let removed = null;
62
63
  /** Remove the match in the same plan as `then`'s first operation: `ctx.remove`. */
63
64
  const remove = (then) => {
@@ -104,12 +105,15 @@ export const inputRulesHook = (edytor, rules) => {
104
105
  prevent(() => {
105
106
  running = true;
106
107
  try {
108
+ // The typed text first, a step the replacement does not join.
109
+ const last = dispatcher.last;
110
+ typeAsIs();
111
+ if (dispatcher.last === last || dispatcher.last?.status !== 'applied')
112
+ return;
107
113
  for (const { rule, match } of matches) {
108
- const written = apply(rule, match);
109
- if (written !== null)
110
- return written || typeAsIs();
114
+ if (apply(rule, match) !== null)
115
+ return;
111
116
  }
112
- typeAsIs();
113
117
  }
114
118
  finally {
115
119
  running = false;
@@ -25,6 +25,17 @@ export type BlockMoveRequest = {
25
25
  } | {
26
26
  direction: BlockMoveDirection;
27
27
  });
28
+ /**
29
+ * The view's nesting rule (`allowNesting`, off by default): whether a user
30
+ * gesture may place `blocks` under `parent`. With nesting off, blocks land
31
+ * only at the root or in a block made to hold children — a container (a
32
+ * list, a column, a toggle's or a callout's body: `isContainer`, the kind's
33
+ * `container`), an island (a code block's lines) — or stay under the parent
34
+ * they already have (a document holding nested blocks still edits them).
35
+ * Every move (`destination`: keys, drops, menus, `edytor.move`) and every
36
+ * new block a drop places (`canPlaceNew`) reads it.
37
+ */
38
+ export declare const nestsInto: (edytor: Edytor, parent: Block, blocks?: readonly Block[]) => boolean;
28
39
  /** Whether the move is allowed: for runs (`parts`), whether one of them may move. */
29
40
  export declare const canMove: (edytor: Edytor, request: BlockMoveRequest) => boolean;
30
41
  /**
@@ -38,3 +49,25 @@ export declare const canMove: (edytor: Edytor, request: BlockMoveRequest) => boo
38
49
  * (`announcer.moved`).
39
50
  */
40
51
  export declare const move: (edytor: Edytor, request: BlockMoveRequest) => Block[];
52
+ /** A new block a drop places (`placeNew`): its kind, and its id when the caller names it. */
53
+ export type NewBlock = {
54
+ type: string;
55
+ id?: string;
56
+ };
57
+ /**
58
+ * Whether new blocks of `kinds` may land at `target`'s `position` (a file
59
+ * dropped there, `placeNew`): the move's places (`destination`) asked of
60
+ * blocks not yet in the document — the container rule (`fits`) of the
61
+ * parent they land in, never inside a void or an island; beside, a layout
62
+ * kind and a target outside any island (`placeBeside` takes any block a
63
+ * column holds).
64
+ */
65
+ export declare const canPlaceNew: (edytor: Edytor, kinds: readonly string[], target: Block, position: BlockMovePosition) => boolean;
66
+ /**
67
+ * Insert new blocks at `target`'s `position` (a file drop's placement):
68
+ * before or after it, inside it (its last children), or beside it — inserted
69
+ * after the block it stands beside (`besideAt`) and moved there by the one
70
+ * move path (`layout.place-beside`) — in one transaction. The caller's
71
+ * command makes it one undo step. Answers the ids placed (none: refused).
72
+ */
73
+ export declare const placeNew: (edytor: Edytor, blocks: readonly NewBlock[], target: Block, position: BlockMovePosition) => string[];
@@ -24,6 +24,22 @@ const placement = (edytor, request) => {
24
24
  return step?.[0] ? { blocks, target: step[0], position: step[1] } : null;
25
25
  };
26
26
  const POSITIONS = ['before', 'after', 'inside', ...BESIDE];
27
+ /**
28
+ * The view's nesting rule (`allowNesting`, off by default): whether a user
29
+ * gesture may place `blocks` under `parent`. With nesting off, blocks land
30
+ * only at the root or in a block made to hold children — a container (a
31
+ * list, a column, a toggle's or a callout's body: `isContainer`, the kind's
32
+ * `container`), an island (a code block's lines) — or stay under the parent
33
+ * they already have (a document holding nested blocks still edits them).
34
+ * Every move (`destination`: keys, drops, menus, `edytor.move`) and every
35
+ * new block a drop places (`canPlaceNew`) reads it.
36
+ */
37
+ export const nestsInto = (edytor, parent, blocks = []) => edytor.allowNesting ||
38
+ parent.isRoot ||
39
+ parent.isContainer ||
40
+ !!parent.definition.container ||
41
+ edytor.facade.isIsland(parent.id) ||
42
+ (blocks.length > 0 && blocks.every((block) => block.parent === parent));
27
43
  /**
28
44
  * The document write a move request is, or `null` when refused: a
29
45
  * well-formed request, then the document's one structural answer
@@ -59,7 +75,7 @@ const destination = (edytor, request) => {
59
75
  ? { blocks, write: () => edytor.unNestBlocks(ids) }
60
76
  : null;
61
77
  const to = parent?.isRoot ? null : parent?.id;
62
- if (!parent || !edytor.facade.canPlace(ids, to))
78
+ if (!parent || !nestsInto(edytor, parent, blocks) || !edytor.facade.canPlace(ids, to))
63
79
  return null;
64
80
  const at = position === 'inside' ? parent.children.length : target.index + (position === 'after' ? 1 : 0);
65
81
  // The document counts the destination's children without the moved blocks.
@@ -122,3 +138,57 @@ const place = (edytor, request) => {
122
138
  const result = move.write();
123
139
  return result.status === 'applied' ? move.blocks : [];
124
140
  };
141
+ /**
142
+ * Whether new blocks of `kinds` may land at `target`'s `position` (a file
143
+ * dropped there, `placeNew`): the move's places (`destination`) asked of
144
+ * blocks not yet in the document — the container rule (`fits`) of the
145
+ * parent they land in, never inside a void or an island; beside, a layout
146
+ * kind and a target outside any island (`placeBeside` takes any block a
147
+ * column holds).
148
+ */
149
+ export const canPlaceNew = (edytor, kinds, target, position) => {
150
+ const { facade } = edytor;
151
+ if (edytor.readonly || !kinds.length || !target.isInTree || target.isRoot)
152
+ return false;
153
+ if (!POSITIONS.includes(position) || facade.insideIsland(target.id))
154
+ return false;
155
+ if (isBeside(position))
156
+ return ([...edytor.blocks.values()].some((definition) => definition.layout) &&
157
+ !facade.isIsland(target.id));
158
+ const parent = position === 'inside' ? target : target.parent;
159
+ if (!parent || !nestsInto(edytor, parent))
160
+ return false;
161
+ const to = parent.isRoot ? null : parent.id;
162
+ if (to !== null && (facade.isVoid(to) || facade.isIsland(to)))
163
+ return false;
164
+ return kinds.every((kind) => facade.fits(to, kind));
165
+ };
166
+ /**
167
+ * Insert new blocks at `target`'s `position` (a file drop's placement):
168
+ * before or after it, inside it (its last children), or beside it — inserted
169
+ * after the block it stands beside (`besideAt`) and moved there by the one
170
+ * move path (`layout.place-beside`) — in one transaction. The caller's
171
+ * command makes it one undo step. Answers the ids placed (none: refused).
172
+ */
173
+ export const placeNew = (edytor, blocks, target, position) => {
174
+ const kinds = blocks.map((block) => block.type);
175
+ if (!canPlaceNew(edytor, kinds, target, position))
176
+ return [];
177
+ const beside = isBeside(position);
178
+ const at = beside ? (edytor.idToBlock.get(edytor.facade.besideAt(target.id)) ?? target) : target;
179
+ const parent = position === 'inside' ? target : at.parent;
180
+ const destination = position === 'inside'
181
+ ? { parent: target.id, index: target.children.length }
182
+ : {
183
+ parent: !parent || parent.isRoot ? null : parent.id,
184
+ index: at.index + (position === 'before' ? 0 : 1)
185
+ };
186
+ return edytor.transact(() => {
187
+ const { ids } = edytor.insertBlocks(destination, blocks.map((block) => ({ ...block })));
188
+ if (!beside || !ids.length)
189
+ return [...ids];
190
+ const placed = ids.flatMap((id) => edytor.idToBlock.get(id) ?? []);
191
+ move(edytor, { blocks: placed, target, position });
192
+ return [...ids];
193
+ });
194
+ };
@@ -23,6 +23,7 @@
23
23
  * lands it on no caret stop (`landed`).
24
24
  */
25
25
  import type { Edytor } from '../edytor.svelte.js';
26
+ import type { Block } from '../block/block.svelte.js';
26
27
  import type { HotKey } from './keymap.js';
27
28
  /**
28
29
  * With no target (`untargeted`: the value is `none`, no gesture placed the
@@ -48,6 +49,12 @@ export declare const move: (edytor: Edytor, unit: Unit, key: Dir | "left" | "rig
48
49
  * moves keep the goal column. Answers whether a text selection moved.
49
50
  */
50
51
  export declare const extendVertically: (edytor: Edytor, dir: Dir) => boolean;
52
+ /**
53
+ * The block a collapsed text caret sits on the last line of (a line box
54
+ * where the view lays one out, else a line break), if it does: where
55
+ * ArrowDown leaves the block (`nav.trailing.exit`, `body.down`).
56
+ */
57
+ export declare const onLastLine: (edytor: Edytor) => Block | undefined;
51
58
  /**
52
59
  * The trailing paragraph (`nav.trailing`, Notion): the caret goes to an
53
60
  * empty top-level block of the root's default kind after the document's
@@ -255,21 +255,30 @@ const stopBelow = (edytor, block) => {
255
255
  * cell, a code line, a heading). Never in a view that writes nothing.
256
256
  */
257
257
  const leavesLastStop = (edytor) => {
258
- const { selection, root } = edytor;
259
- const { value, projection } = selection;
260
- const at = projection.start;
261
- const block = at && edytor.idToBlock.get(at.block);
262
- if (value.kind !== 'text' || !projection.isCollapsed || !block || !root)
263
- return false;
264
- if (!edytor.dispatcher.permits())
258
+ const block = onLastLine(edytor);
259
+ const { root } = edytor;
260
+ if (!block || !root || !edytor.dispatcher.permits())
265
261
  return false;
266
262
  if (block.parent?.isRoot && block.type === edytor.defaultChild(root))
267
263
  return false;
264
+ return !stopBelow(edytor, block.id);
265
+ };
266
+ /**
267
+ * The block a collapsed text caret sits on the last line of (a line box
268
+ * where the view lays one out, else a line break), if it does: where
269
+ * ArrowDown leaves the block (`nav.trailing.exit`, `body.down`).
270
+ */
271
+ export const onLastLine = (edytor) => {
272
+ const { value, projection } = edytor.selection;
273
+ const at = projection.start;
274
+ const block = at && edytor.idToBlock.get(at.block);
275
+ if (value.kind !== 'text' || !projection.isCollapsed || !block)
276
+ return;
268
277
  const text = stringOf(edytor, at.block);
269
278
  if (text.includes('\n', at.offset))
270
- return false;
271
- return (edytor.ports.surface.sameLine(at, { block: at.block, offset: text.length }) !== false &&
272
- !stopBelow(edytor, at.block));
279
+ return;
280
+ const last = edytor.ports.surface.sameLine(at, { block: at.block, offset: text.length });
281
+ return last === false ? undefined : block;
273
282
  };
274
283
  /**
275
284
  * The trailing paragraph (`nav.trailing`, Notion): the caret goes to an
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A view's property writes (`block.data`, `atom.data`, `edytor.data`): the
3
+ * data proxy (`crdt/props.ts`) over the view's one write path.
4
+ *
5
+ * The writes of one synchronous task are one dispatch: they queue per
6
+ * target and leave on the next microtask, or before any other operation
7
+ * this view dispatches, as the `patchData` operation of each target (more
8
+ * than one target: one user command, so one undo step, in one
9
+ * transaction). Hooks see that operation; a veto drops the burst. Until
10
+ * then the proxy reads its target's data with the queued patches applied
11
+ * (`dataPreview`), so a write reads back at once. A view that refuses
12
+ * writes (readonly, a read-only document) refuses an assignment at once:
13
+ * nothing is queued, the dispatcher records `refused`, nothing throws.
14
+ */
15
+ import type { DataPatch } from '../crdt/data.js';
16
+ import type { DataTarget } from '../crdt/doc/types.js';
17
+ import type { Edytor } from '../edytor.svelte.js';
18
+ export declare class DataWrites {
19
+ #private;
20
+ private readonly edytor;
21
+ constructor(edytor: Edytor);
22
+ /** The live proxy over `target`'s data; `read` is its reactive, committed source. */
23
+ proxy<T extends object>(target: DataTarget, read: () => Record<string, unknown>): T;
24
+ /** Queue `patches` on `target` for this task's dispatch (refused at once when the view refuses writes). */
25
+ write(target: DataTarget, patches: DataPatch[]): undefined;
26
+ /** Dispatch the queued writes now: before any other operation of this view, and at the end of the task. */
27
+ flush(): undefined;
28
+ }
@@ -0,0 +1,65 @@
1
+ import { propsProxy } from '../crdt/props.js';
2
+ const keyOf = (target) => JSON.stringify(target);
3
+ export class DataWrites {
4
+ edytor;
5
+ #pending = new Map();
6
+ #scheduled = false;
7
+ constructor(edytor) {
8
+ this.edytor = edytor;
9
+ }
10
+ /** The live proxy over `target`'s data; `read` is its reactive, committed source. */
11
+ proxy(target, read) {
12
+ let preview;
13
+ const pending = () => this.#pending.get(keyOf(target));
14
+ const current = () => {
15
+ const base = read();
16
+ const queued = pending();
17
+ if (!queued)
18
+ return base;
19
+ const { version } = this.edytor.facade;
20
+ const count = queued.patches.length;
21
+ if (preview?.version !== version || preview.count !== count) {
22
+ const seen = this.edytor.facade.dataPreview(target, queued.patches);
23
+ preview = { version, count, data: seen?.data ?? null };
24
+ }
25
+ return preview.data ?? base;
26
+ };
27
+ return propsProxy(current, (patches) => this.write(target, patches), (path) => {
28
+ const queued = pending();
29
+ return queued
30
+ ? (this.edytor.facade.dataPreview(target, queued.patches)?.ids(path) ?? [])
31
+ : this.edytor.facade.dataItemIds(target, path);
32
+ });
33
+ }
34
+ /** Queue `patches` on `target` for this task's dispatch (refused at once when the view refuses writes). */
35
+ write(target, patches) {
36
+ const { edytor } = this;
37
+ if (!edytor.dispatcher.permits())
38
+ return void edytor.patchData(target, patches);
39
+ const key = keyOf(target);
40
+ const queued = this.#pending.get(key);
41
+ if (queued)
42
+ queued.patches.push(...patches);
43
+ else
44
+ this.#pending.set(key, { target, patches: [...patches] });
45
+ if (this.#scheduled)
46
+ return;
47
+ this.#scheduled = true;
48
+ queueMicrotask(() => {
49
+ this.#scheduled = false;
50
+ if (!edytor.destroyed)
51
+ this.flush();
52
+ });
53
+ }
54
+ /** Dispatch the queued writes now: before any other operation of this view, and at the end of the task. */
55
+ flush() {
56
+ if (this.#pending.size === 0)
57
+ return;
58
+ const all = [...this.#pending.values()];
59
+ this.#pending.clear();
60
+ const { edytor } = this;
61
+ if (all.length === 1)
62
+ return void edytor.patchData(all[0].target, all[0].patches);
63
+ edytor.dispatcher.run('patchData', () => edytor.transact(() => all.forEach(({ target, patches }) => edytor.patchData(target, patches))));
64
+ }
65
+ }