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/README.md CHANGED
@@ -17,11 +17,11 @@
17
17
 
18
18
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
19
19
 
20
- > **1.0.** Edytor `1.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
+ > **2.0.** Edytor `2.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
21
21
 
22
22
  ## Features
23
23
 
24
- - **Notion-style editing out of the box.** `<Edytor />` alone is a rich text editor with images and block handles: headings, toggle headings, lists, to-dos, toggles, callouts, quotes, dividers, ten marks and Notion's hotkeys. Add the Notion theme, a block menu (block colours; drag one block, or every block the selection covers), a slash menu, a selection toolbar, markdown shortcuts (`createMarkdownShortcutsPlugin()`) and code blocks.
24
+ - **Notion-style editing out of the box.** `<Edytor />` alone is a rich text editor with block handles: headings, toggle headings, lists, to-dos, toggles, callouts, quotes, dividers, ten marks and Notion's hotkeys. Add the Notion theme, a block menu (block colours; drag one block, or every block the selection covers), a slash menu, a selection toolbar, markdown shortcuts (`createMarkdownShortcutsPlugin()`) code blocks and images (`createImagePlugin({ upload })`).
25
25
  - **Pages and a table of contents, opt-in.** `createPagePlugin` links a block to another document (a page per room, opened and named by your app), and `createTocPlugin()` lists the headings live ([Page](https://edytor.dev/docs/plugins/page), [Table of contents](https://edytor.dev/docs/plugins/table-of-contents)).
26
26
  - **Columns, opt-in.** List `createColumnsPlugin()` for Notion's multi-column layouts: drag a block to the edge of another to put them side by side, or pick "2 columns" to "5 columns"; drag the gap to resize. Layouts converge across collaborators and the room ([Columns](https://edytor.dev/docs/plugins/columns)).
27
27
  - **Tables, opt-in.** List `createTablePlugin()` for Notion's simple table: cells of text, <kbd>Tab</kbd> between them, rows and columns added, moved and deleted from the table's edges and grips, resizable columns, header row and column, HTML tables pasted and copied. Concurrent row and column edits converge on every replica ([Table](https://edytor.dev/docs/plugins/table)).
@@ -29,7 +29,7 @@ Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable
29
29
  - **Comments, opt-in.** `createCommentsPlugin` adds Notion's comment threads: select text, press Comment, and the thread shows in a sidebar beside it with replies, Resolve and Re-open. The room keeps the threads beside the document and sends each change live; the anchor follows the text through edits and is not copied ([Comments](https://edytor.dev/docs/plugins/comments)).
30
30
  - **Your markup.** Blocks, marks and inline atoms render through your Svelte snippets; the slash menu, toolbar, block menu and handles take a snippet and keep their behavior.
31
31
  - **Your language.** Every word the package shows or says, slash keywords included, comes from one labels dictionary the view takes (`<Edytor labels>`); your chrome's words are your markup's; English by default ([Localization](https://edytor.dev/docs/customization/localization)).
32
- - **Synced properties.** `block.data`, `atom.data` and `edytor.data` read like plain objects, and `edytor.patchData(id, [{ path, value }])` writes them; each property and each array item syncs on its own, so concurrent edits of different properties, or of different items of one array, merge.
32
+ - **Synced properties.** `block.data`, `atom.data` and `edytor.data` are plain Svelte state at any depth (`block.data.meta.author = 'Ada'`, `block.data.tags.push('x')`, `delete block.data.draft`), each write an undoable operation; each property and each array item syncs on its own, so concurrent edits of different properties, or of different items of one array, merge.
33
33
  - **AI suggestions.** `edytor.suggestions.add(position, content)` proposes text, paragraphs, lists, to-dos or images after, before or inside a block, at the end of its text, or in place of the selection; it streams in, shows only on your screen, and becomes one undo step when accepted ([Suggestions](https://edytor.dev/docs/editor/suggestions)).
34
34
  - **Plugins that can veto anything.** Every command is prepared before it writes; plugins see the command and each planned step and can refuse or replace it.
35
35
  - **Real-time collaboration.** One `EdytorDocument` shared by any number of views, or none (headless). Presence cursors, identity-preserving moves, splits and merges, and undo that only takes back your own edits.
@@ -49,83 +49,25 @@ pnpm add edytor
49
49
 
50
50
  ## Quick start
51
51
 
52
- The package draws no menu, toolbar or block handle: each is a snippet you pass its plugin. Without them the editor works but shows none. The demo's ready-made, Notion-style ones are one copy away: copy [`src/routes/chrome`](https://github.com/beynar/edytor/tree/master/src/routes/chrome) into your app, make its imports name the package, and pass its plugins ([quick start](https://edytor.dev/docs/getting-started/quick-start#the-ready-made-menus-and-handles)):
53
-
54
- ```bash
55
- npx degit beynar/edytor/src/routes/chrome src/lib/chrome
56
- sed -i.bak 's#\$lib/index\.js#edytor#' src/lib/chrome/*.ts src/lib/chrome/*.svelte && rm src/lib/chrome/*.bak
57
- ```
58
-
59
- Take the folder of the version you install, by its tag (`beynar/edytor/src/routes/chrome#v1.0.0`): without one you get `master`'s, which follows the next release.
60
-
61
52
  ```svelte
62
53
  <script lang="ts">
63
- import { Edytor } from 'edytor';
64
- import 'edytor/themes/notion.css';
65
- import {
66
- slashMenuPlugin,
67
- toolbarPlugin,
68
- blockHandlesPlugin,
69
- blockMenuPlugin,
70
- codePlugin
71
- } from '$lib/chrome/plugins';
72
- </script>
73
-
74
- <div class="edytor-notion">
75
- <Edytor
76
- plugins={[slashMenuPlugin, toolbarPlugin, blockHandlesPlugin, blockMenuPlugin, codePlugin]}
77
- />
78
- </div>
79
- ```
80
-
81
- Or draw a menu yourself: your snippet draws it, the plugin gives it its keys and ARIA.
54
+ import { Edytor, createCodePlugin, type JSONDoc } from 'edytor';
82
55
 
83
- ```svelte
84
- <script lang="ts">
85
- import {
86
- Edytor,
87
- createCodePlugin,
88
- createSlashMenuPlugin,
89
- createMarkdownShortcutsPlugin,
90
- richTextPlaceholder,
91
- type JSONDoc,
92
- type SlashMenuController
93
- } from 'edytor';
94
- import 'edytor/themes/notion.css';
95
-
96
- const value: JSONDoc = {
56
+ let value = $state<JSONDoc>({
97
57
  children: [{ type: 'paragraph', content: [{ text: 'Hello, World!' }] }]
98
- };
99
- const plugins = [
100
- // Notion's hints in empty blocks.
101
- () => ({ placeholder: richTextPlaceholder }),
102
- createCodePlugin(),
103
- createMarkdownShortcutsPlugin(),
104
- createSlashMenuPlugin({ menu })
105
- ];
58
+ });
106
59
  </script>
107
60
 
108
- <!-- Your snippet draws the menu; the plugin's attachments give it its keys and ARIA. -->
109
- {#snippet menu(slash: SlashMenuController)}
110
- <ul {@attach slash.popup}>
111
- {#each slash.commands as command, index (command.id)}
112
- <li
113
- data-selected={slash.index === index}
114
- {@attach slash.option(index)}
115
- onclick={() => void slash.run(command)}
116
- >
117
- {command.label}
118
- </li>
119
- {/each}
120
- </ul>
121
- {/snippet}
122
-
123
- <div class="edytor-notion">
124
- <Edytor {value} {plugins} onChange={(root) => console.log(root)} />
125
- </div>
61
+ <Edytor {value} plugins={[createCodePlugin()]} onChange={(next) => (value = next)} />
62
+
63
+ <p>{value.children?.length ?? 0} blocks</p>
126
64
  ```
127
65
 
128
- An editable editor is client-only: in SvelteKit, mount it once the page has hydrated ([quick start](https://edytor.dev/docs/getting-started/quick-start#mount-it-in-a-page)). `<Edytor>` adds the rich text, image, arrow-move and suggestions plugins after yours. Every menu, toolbar, handle and panel is drawn by a snippet, yours or the demo's ([menus and handles](https://edytor.dev/docs/customization/menus)). To collaborate, name a room and a server running the [`edytor/cloudflare` room](https://edytor.dev/docs/server/quick-start):
66
+ `<Edytor>` renders on the server and hydrates, so it works in any route. It includes rich text; each other block kind, block moves from the keyboard (`createArrowMovePlugin()`), and file drops (a media plugin given an `upload`) is a plugin you add, here code blocks; nesting blocks (<kbd>Tab</kbd>, drops inside a block) is the `allowNesting` option, off by default. `onChange` hands you the document after every change ([quick start](https://edytor.dev/docs/getting-started/quick-start)).
67
+
68
+ The package draws no menu, toolbar or block handle: each is a snippet you pass its plugin ([menus and handles](https://edytor.dev/docs/customization/menus)). The demo's Notion-style ones are in [`src/routes/chrome`](https://github.com/beynar/edytor/tree/master/src/routes/chrome), ready to copy into your app ([ready-made menus](https://edytor.dev/docs/customization/ready-made-menus)).
69
+
70
+ To collaborate, name a room and a server running the [`edytor/cloudflare` room](https://edytor.dev/docs/server/quick-start):
129
71
 
130
72
  ```svelte
131
73
  <Edytor
@@ -31,11 +31,13 @@ export declare class Block {
31
31
  get content(): (Text | InlineBlock)[];
32
32
  get type(): string;
33
33
  /**
34
- * The block's `data`, read-your-writes; a reactive reader depends on the
35
- * block's cell. The root's is the document's data (`edytor.data`). Write
36
- * it with `edytor.patchData(block.id, patches)`.
34
+ * The block's `data` as plain state: reactive at any depth (through the
35
+ * block's cell), and writable (`block.data.color = 'red'`,
36
+ * `block.data.tags.push('x')`, `delete block.data.color`). Each write is
37
+ * the `patchData` operation, dispatched (`session/props`). The root's is
38
+ * the document's data (`edytor.data`).
37
39
  */
38
- get data(): Readonly<Record<string, any>>;
40
+ get data(): Record<string, any>;
39
41
  get definition(): BlockDefinition;
40
42
  get selected(): boolean;
41
43
  get focused(): boolean;
@@ -76,14 +76,20 @@ export class Block {
76
76
  return this.edytor.facade.blockTypeOf(this.id) ?? '';
77
77
  }
78
78
  #data;
79
+ #props;
79
80
  /**
80
- * The block's `data`, read-your-writes; a reactive reader depends on the
81
- * block's cell. The root's is the document's data (`edytor.data`). Write
82
- * it with `edytor.patchData(block.id, patches)`.
81
+ * The block's `data` as plain state: reactive at any depth (through the
82
+ * block's cell), and writable (`block.data.color = 'red'`,
83
+ * `block.data.tags.push('x')`, `delete block.data.color`). Each write is
84
+ * the `patchData` operation, dispatched (`session/props`). The root's is
85
+ * the document's data (`edytor.data`).
83
86
  */
84
87
  get data() {
85
88
  if (this.isRoot)
86
- return this.edytor.docData();
89
+ return this.edytor.data;
90
+ return (this.#props ??= this.edytor.dataWrites.proxy(this.id, () => this.#current()));
91
+ }
92
+ #current() {
87
93
  const { facade, cells } = this.edytor;
88
94
  cells?.get(this.id);
89
95
  const version = facade.version;
@@ -22,8 +22,12 @@ export declare class InlineBlock {
22
22
  get parent(): Block;
23
23
  get selected(): boolean;
24
24
  get type(): string;
25
- /** The atom's `data`; a reactive reader depends on its block's cell. */
26
- get data(): Readonly<Record<string, any>>;
25
+ /**
26
+ * The atom's `data` as plain state: reactive at any depth (through its
27
+ * block's cell), and writable; each write is its `patchData` operation
28
+ * (`session/props`).
29
+ */
30
+ get data(): Record<string, any>;
27
31
  get definition(): InlineBlockDefinition;
28
32
  /** Position among the block's content parts. */
29
33
  get index(): number;
@@ -37,9 +37,14 @@ export class InlineBlock {
37
37
  get type() {
38
38
  return this.#item?.type ?? '';
39
39
  }
40
- /** The atom's `data`; a reactive reader depends on its block's cell. */
40
+ #props;
41
+ /**
42
+ * The atom's `data` as plain state: reactive at any depth (through its
43
+ * block's cell), and writable; each write is its `patchData` operation
44
+ * (`session/props`).
45
+ */
41
46
  get data() {
42
- return this.#item?.data ?? {};
47
+ return (this.#props ??= this.edytor.dataWrites.proxy({ block: this.blockId, atom: this.id }, () => this.#item?.data ?? {}));
43
48
  }
44
49
  get definition() {
45
50
  return this.edytor.inlineBlocks.get(this.type) ?? {};
@@ -233,6 +233,12 @@ export declare const crdt: {
233
233
  blockDataOf: (id: string) => Record<string, unknown> | undefined;
234
234
  docData: () => import("../../crdt/doc/types.js").JsonObj;
235
235
  dataItemIds: (target: import("../../index.js").DataTarget, path: readonly string[]) => string[];
236
+ dataPreview: (target: import("../../index.js").DataTarget, pending: readonly import("../../index.js").DataPatch[]) => {
237
+ data: {
238
+ [x: string]: unknown;
239
+ };
240
+ ids: (path: readonly string[]) => string[];
241
+ } | null;
236
242
  blockAttribution: (id: string) => import("../../index.js").BlockAttribution | undefined;
237
243
  crdtId: (id: string) => string | null;
238
244
  resolveBlock: (id: string) => E.EngineNode | null;
@@ -56,6 +56,20 @@ export type AwarenessLike = {
56
56
  getLocalState: () => Record<string, unknown> | null;
57
57
  setLocalState: (state: Record<string, unknown> | null) => void;
58
58
  };
59
+ /**
60
+ * The awareness a view's presence writer reads and writes: its own state,
61
+ * every client's state, and the `change` event (the real `Awareness`).
62
+ */
63
+ export type PresenceSource = AwarenessLike & {
64
+ clientID: number;
65
+ getStates: () => Map<number, Record<string, unknown>>;
66
+ on: (name: 'change', listener: () => void) => void;
67
+ off: (name: 'change', listener: () => void) => void;
68
+ };
69
+ /** Another person online: their client id and the fields of their `user` state (`name`, `color`, …), the document's actor name and colour when `user` has none. */
70
+ export type PresencePeer = EdytorAwarenessUser & Record<string, unknown> & {
71
+ clientId: number;
72
+ };
59
73
  /** A client-local presence key, minted once by the view that owns the entry. */
60
74
  export declare const mintPresenceKey: () => string;
61
75
  /**
@@ -90,9 +104,16 @@ export declare class PresenceWriter {
90
104
  private readonly awareness;
91
105
  private readonly key;
92
106
  throttle: number;
93
- constructor(awareness: AwarenessLike, key: string, { share, throttle }?: PresenceOptions);
107
+ constructor(awareness: PresenceSource, key: string, { share, throttle }?: PresenceOptions);
94
108
  get share(): PresenceShare;
95
109
  set share(share: PresenceShare);
110
+ /**
111
+ * The other people online: every client but this one, by client id,
112
+ * each `{ clientId, name?, color?, …user }`. Reactive: read in markup or
113
+ * an effect, it updates when someone joins, leaves or changes their
114
+ * state; the awareness listener exists only while something reads it.
115
+ */
116
+ get peers(): PresencePeer[];
96
117
  /** Publish `payload` (the full selection; `focus`: the block holding its focus) as this view shares it, within the throttle. */
97
118
  write(payload: PresenceSelection | null, focus?: string | null): void;
98
119
  /** Remove this view's entry and drop a held write (the view's teardown). */
@@ -1,4 +1,19 @@
1
1
  import { isRecord, jsonEquals } from '../utils/json.js';
2
+ import { createSubscriber } from 'svelte/reactivity';
3
+ /** Every client but this one, by client id. */
4
+ const readPeers = (awareness) => [...awareness.getStates()]
5
+ .filter(([clientId]) => clientId !== awareness.clientID)
6
+ .sort(([a], [b]) => a - b)
7
+ .map(([clientId, state]) => {
8
+ const actor = isRecord(state.actor) ? state.actor : {};
9
+ const user = isRecord(state.user) ? state.user : {};
10
+ const peer = { ...user, clientId };
11
+ if (peer.name === undefined && typeof actor.name === 'string')
12
+ peer.name = actor.name;
13
+ if (peer.color === undefined && typeof actor.color === 'string')
14
+ peer.color = actor.color;
15
+ return peer;
16
+ });
2
17
  let presenceKeys = 0;
3
18
  /** A client-local presence key, minted once by the view that owns the entry. */
4
19
  export const mintPresenceKey = () => `view-${++presenceKeys}`;
@@ -50,6 +65,21 @@ export class PresenceWriter {
50
65
  #held;
51
66
  /** The last payload and focused block handed in, kept so a share change republishes at once. */
52
67
  #latest = [null, null];
68
+ /** The peers while someone reads them reactively (`undefined`: recompute). */
69
+ #peers;
70
+ #watched = false;
71
+ #subscribe = createSubscriber((update) => {
72
+ const changed = () => {
73
+ this.#peers = undefined;
74
+ update();
75
+ };
76
+ this.#watched = true;
77
+ this.awareness.on('change', changed);
78
+ return () => {
79
+ this.awareness.off('change', changed);
80
+ [this.#watched, this.#peers] = [false, undefined];
81
+ };
82
+ });
53
83
  constructor(awareness, key, { share = 'caret', throttle = DEFAULT_PRESENCE_THROTTLE } = {}) {
54
84
  this.awareness = awareness;
55
85
  this.key = key;
@@ -65,6 +95,18 @@ export class PresenceWriter {
65
95
  this.#share = share;
66
96
  this.write(...this.#latest);
67
97
  }
98
+ /**
99
+ * The other people online: every client but this one, by client id,
100
+ * each `{ clientId, name?, color?, …user }`. Reactive: read in markup or
101
+ * an effect, it updates when someone joins, leaves or changes their
102
+ * state; the awareness listener exists only while something reads it.
103
+ */
104
+ get peers() {
105
+ this.#subscribe();
106
+ if (!this.#watched)
107
+ return readPeers(this.awareness);
108
+ return (this.#peers ??= readPeers(this.awareness));
109
+ }
68
110
  /** Publish `payload` (the full selection; `focus`: the block holding its focus) as this view shares it, within the throttle. */
69
111
  write(payload, focus = null) {
70
112
  this.#latest = [payload, focus];
@@ -35,7 +35,9 @@
35
35
  const previewViewOf = (cell: PreviewCell): BlockView => ({
36
36
  id: cell.id,
37
37
  type: cell.type,
38
- data: cell.data ?? {},
38
+ get data() {
39
+ return structuredClone(cell.data ?? {});
40
+ },
39
41
  selected: false,
40
42
  focused: false,
41
43
  handle: undefined,
@@ -23,21 +23,15 @@
23
23
  import { brandOf } from '../plugins/brand.js';
24
24
  import type { PartialLabels } from '../labels.js';
25
25
  import { createRichTextPlugin } from '../plugins/richtext/RichTextPlugin.svelte';
26
- import { createArrowMovePlugin } from '../plugins/arrowMove/arrowMove.js';
27
- import { createImagePlugin } from '../plugins/image/ImagePlugin.svelte';
28
26
  import { createSuggestionsPlugin } from '../plugins/suggestions/suggestionsPlugin.js';
29
27
 
30
28
  /**
31
29
  * The plugins every view has unless it lists one of their kind (or sets
32
30
  * `defaultPlugins={false}`), after the app's so its kinds and keys win:
33
- * block moves, the suggestion UI, the image kind, then rich text.
31
+ * the suggestion UI, then rich text (an app lists `createArrowMovePlugin()`
32
+ * for block moves and `createImagePlugin({ upload })` for images itself).
34
33
  */
35
- const DEFAULTS = [
36
- createArrowMovePlugin(),
37
- createSuggestionsPlugin(),
38
- createImagePlugin(),
39
- createRichTextPlugin()
40
- ];
34
+ const DEFAULTS = [createSuggestionsPlugin(), createRichTextPlugin()];
41
35
  const withDefaults = (plugins: Plugin[] = []): Plugin[] => {
42
36
  const listed = new Set(plugins.map(brandOf));
43
37
  return [...plugins, ...DEFAULTS.filter((plugin) => !listed.has(brandOf(plugin)))];
@@ -67,7 +61,7 @@
67
61
 
68
62
  export type EdytorProps = {
69
63
  plugins?: Plugin[];
70
- /** Add rich text, arrow moves, the suggestion UI and images unless `plugins` lists them (default `true`). */
64
+ /** Add rich text and the suggestion UI unless `plugins` lists them (default `true`). */
71
65
  defaultPlugins?: boolean;
72
66
  class?: string;
73
67
  /** The root textbox's accessible name (or name it with `aria-labelledby`). */
@@ -96,6 +90,13 @@
96
90
  * the connection.
97
91
  */
98
92
  readonly?: boolean;
93
+ /**
94
+ * Let users nest blocks (default `false`): Tab and Shift+Tab nest and
95
+ * unnest, drops and handle keys may place a block inside another. Off,
96
+ * Tab is left to the browser and no gesture nests; containers keep their
97
+ * bodies. Read once.
98
+ */
99
+ allowNesting?: boolean;
99
100
  /** Chords (`mod+s`, `shift+alt+enter`) the view binds before plugins and built-ins. Read once. */
100
101
  hotkeys?: Partial<Record<HotKeyCombination, HotKey>>;
101
102
  /** After every commit that changed the visible document: a `JSONDoc`, the type `value` takes. */
@@ -188,6 +189,7 @@
188
189
  document: edytorDocument,
189
190
  doc,
190
191
  readonly = false,
192
+ allowNesting = false,
191
193
  value = defaultValue,
192
194
  hotkeys,
193
195
  sync,
@@ -216,6 +218,7 @@
216
218
 
217
219
  const initialEdytorOptions = untrack(() => ({
218
220
  readonly,
221
+ allowNesting,
219
222
  plugins: defaultPlugins ? withDefaults(userPlugins) : userPlugins,
220
223
  document: edytorDocument,
221
224
  doc,
@@ -408,6 +411,29 @@
408
411
  {/if}
409
412
 
410
413
  <style>
414
+ /* Where a drag lands, a block handle's or files' from outside (`DropIndicator`):
415
+ * the bar (4px) and the nest backdrop, placed in the overlay and shown
416
+ * (`data-shown`) by the drag; their look is a theme's (`edytor/themes/notion.css`). */
417
+ :global([data-edytor-drop-indicator]),
418
+ :global([data-edytor-drop-backdrop]) {
419
+ position: absolute;
420
+ pointer-events: none;
421
+ }
422
+
423
+ :global([data-edytor-drop-indicator]) {
424
+ z-index: 100;
425
+ box-sizing: border-box;
426
+ height: 4px;
427
+ }
428
+
429
+ :global([data-edytor-drop-backdrop]) {
430
+ z-index: 99;
431
+ }
432
+
433
+ :global([data-edytor-drop-backdrop]:not([data-shown='true'])) {
434
+ visibility: hidden;
435
+ }
436
+
411
437
  /*
412
438
  * Room below the last block, where a press puts the caret in a trailing
413
439
  * paragraph (`nav.trailing.press`): `--edytor-trailing-space`. No
@@ -7,7 +7,7 @@ import type { Plugin } from '../plugins.js';
7
7
  import type { PartialLabels } from '../labels.js';
8
8
  export type EdytorProps = {
9
9
  plugins?: Plugin[];
10
- /** Add rich text, arrow moves, the suggestion UI and images unless `plugins` lists them (default `true`). */
10
+ /** Add rich text and the suggestion UI unless `plugins` lists them (default `true`). */
11
11
  defaultPlugins?: boolean;
12
12
  class?: string;
13
13
  /** The root textbox's accessible name (or name it with `aria-labelledby`). */
@@ -36,6 +36,13 @@ export type EdytorProps = {
36
36
  * the connection.
37
37
  */
38
38
  readonly?: boolean;
39
+ /**
40
+ * Let users nest blocks (default `false`): Tab and Shift+Tab nest and
41
+ * unnest, drops and handle keys may place a block inside another. Off,
42
+ * Tab is left to the browser and no gesture nests; containers keep their
43
+ * bodies. Read once.
44
+ */
45
+ allowNesting?: boolean;
39
46
  /** Chords (`mod+s`, `shift+alt+enter`) the view binds before plugins and built-ins. Read once. */
40
47
  hotkeys?: Partial<Record<HotKeyCombination, HotKey>>;
41
48
  /** After every commit that changed the visible document: a `JSONDoc`, the type `value` takes. */
@@ -28,7 +28,7 @@
28
28
  return part.type;
29
29
  },
30
30
  get data() {
31
- return block ? block.data : (part.data ?? {});
31
+ return block ? block.data : structuredClone(part.data ?? {});
32
32
  },
33
33
  get selected() {
34
34
  return block?.selected ?? false;
@@ -73,6 +73,16 @@ export declare const readData: (node: EngineNode) => JsonObj | undefined;
73
73
  * in `node`'s data, in order; `[]` where no array is.
74
74
  */
75
75
  export declare const itemIds: (node: EngineNode, path: readonly string[]) => string[];
76
+ /**
77
+ * `node`'s data as it reads once `patches` are applied (nothing written),
78
+ * with the ids of the array items at a path (`itemIds`): an item a patch
79
+ * makes has no id yet (`''`), so a later patch names it by index.
80
+ * `null` when a patch fits no value.
81
+ */
82
+ export declare const previewData: (node: EngineNode | undefined, patches: readonly DataPatch[]) => {
83
+ data: JsonObj;
84
+ ids: (path: readonly string[]) => string[];
85
+ } | null;
76
86
  /** `data` with `patches` applied in order, as a fresh value (a root patch replaces it); `null` when one fits no value. */
77
87
  export declare const applyPatch: (data: JsonObj, patches: readonly DataPatch[]) => JsonObj | null;
78
88
  /**
package/dist/crdt/data.js CHANGED
@@ -354,8 +354,9 @@ export const readData = (node) => valueOf(treeOf(leavesOf(node)));
354
354
  * The ids of the items of the array at `path` (keys, item indexes or ids)
355
355
  * in `node`'s data, in order; `[]` where no array is.
356
356
  */
357
- export const itemIds = (node, path) => {
358
- let t = treeOf(leavesOf(node));
357
+ export const itemIds = (node, path) => idsAt(treeOf(leavesOf(node)), path);
358
+ const idsAt = (root, path) => {
359
+ let t = root;
359
360
  for (const k of path)
360
361
  if (t && isArr(open(t))) {
361
362
  const item = itemAt(itemsOf(t), k);
@@ -365,6 +366,23 @@ export const itemIds = (node, path) => {
365
366
  t = itemish(k) ? undefined : t?.k.get(enc(segmentKey(k)));
366
367
  return t ? itemsOf(open(t)).map(({ id }) => id) : [];
367
368
  };
369
+ /**
370
+ * `node`'s data as it reads once `patches` are applied (nothing written),
371
+ * with the ids of the array items at a path (`itemIds`): an item a patch
372
+ * makes has no id yet (`''`), so a later patch names it by index.
373
+ * `null` when a patch fits no value.
374
+ */
375
+ export const previewData = (node, patches) => {
376
+ const base = node ? leavesOf(node) : new Map();
377
+ const root = treeOf(base);
378
+ if (!patches.every((p) => edits().apply(root, p)))
379
+ return null;
380
+ const known = new Set([...base.keys()].flatMap((key) => key.split('/')));
381
+ return {
382
+ data: valueOf(root) ?? {},
383
+ ids: (path) => idsAt(root, path).map((id) => (known.has(`${id}#`) ? id : ''))
384
+ };
385
+ };
368
386
  /** `data` with `patches` applied in order, as a fresh value (a root patch replaces it); `null` when one fits no value. */
369
387
  export const applyPatch = (data, patches) => {
370
388
  const root = treeOf(dataLeaves(data));
@@ -1,6 +1,7 @@
1
1
  import type { EngineApi, EngineDoc, EngineNode } from '../engine-api.js';
2
2
  import { type BlockId, type ContentItem, type Destination, type ModelView } from '../placement/model.js';
3
3
  import type { DisplayRoles, RunView } from '../text/runs.js';
4
+ import { type DataPatch } from '../data.js';
4
5
  import { type JSONBlock } from '../../utils/json.js';
5
6
  import type { BlockRole, DataTarget, JsonObj, OrderPolicy } from './types.js';
6
7
  /** What every part of a document facade is built over: one doc, its engine layers, its index and its roles. */
@@ -25,6 +26,12 @@ export declare const docReads: (c: DocBase) => {
25
26
  blockDataOf: (id: BlockId) => Record<string, unknown> | undefined;
26
27
  docData: () => JsonObj;
27
28
  dataItemIds: (target: DataTarget, path: readonly string[]) => string[];
29
+ dataPreview: (target: DataTarget, pending: readonly DataPatch[]) => {
30
+ data: {
31
+ [x: string]: unknown;
32
+ };
33
+ ids: (path: readonly string[]) => string[];
34
+ } | null;
28
35
  childrenIds: (parent: BlockId | null) => BlockId[];
29
36
  positionOf: (id: BlockId) => Destination | null;
30
37
  pathOf: (id: BlockId) => number[] | null;
@@ -9,7 +9,7 @@ import { DOC_DATA_ROOT, TYPE } from '../schema.js';
9
9
  import { blockNodeOf, displayParentOf, displaySlotOf, isLive, isLiveIn, positionInView, positionOf as modelPositionOf } from '../placement/model.js';
10
10
  import { DEAD, findAtom, ownedLength } from '../text/model.js';
11
11
  import { holdsPending } from '../structs.js';
12
- import { itemIds, readData } from '../data.js';
12
+ import { itemIds, previewData, readData } from '../data.js';
13
13
  import { columnIds, listedColumns, unlistedOrder } from '../tables.js';
14
14
  import { cloneJsonSafe } from '../../utils/json.js';
15
15
  import { ref } from './plan.js';
@@ -69,6 +69,15 @@ export const docReads = (c) => {
69
69
  const node = dataNode(id && ref(id), atom && ref(atom));
70
70
  return node && Array.isArray(path) ? itemIds(node, path) : [];
71
71
  };
72
+ /**
73
+ * A block's, an atom's or the document's data once `pending` patches are
74
+ * applied, without writing them, and its array items' ids (`previewData`):
75
+ * what a view reads of its data writes not yet dispatched.
76
+ */
77
+ const dataPreview = (target, pending) => {
78
+ const [id, atom] = typeof target === 'object' && target ? [target.block, target.atom] : [target ?? undefined];
79
+ return previewData(dataNode(id && ref(id), atom && ref(atom)), pending);
80
+ };
72
81
  /** Ordered visible children of `parent` (`null` = root) — canonical read. */
73
82
  const childrenIds = (parent) => (view().kids.get(parent) ?? []).map((k) => k.id);
74
83
  const positionOf = (id) => modelPositionOf(doc, id);
@@ -302,6 +311,7 @@ export const docReads = (c) => {
302
311
  blockDataOf,
303
312
  docData,
304
313
  dataItemIds,
314
+ dataPreview,
305
315
  childrenIds,
306
316
  positionOf,
307
317
  pathOf,
@@ -353,6 +353,14 @@ export declare class EdytorDocument {
353
353
  private _destroyed;
354
354
  /** {@link DocumentOptions.requireHydration}: an empty document waits for a provider's `synced`. */
355
355
  readonly requireHydration: boolean;
356
+ /**
357
+ * The document's properties as a live object (`crdt/props`): a read
358
+ * follows the document, and a write at any depth (`document.data.title
359
+ * = 'Notes'`, `document.data.tags.push('x')`, `delete …`) is the raw
360
+ * `patchData` operation at once, as `document.patchData(null, …)`. Not
361
+ * reactive by itself: a view's `edytor.data` is, and dispatches.
362
+ */
363
+ get data(): Record<string, any>;
356
364
  /** The replica id (`doc.clientID`) — identifies the replica, not the actor. */
357
365
  get clientID(): number;
358
366
  /**
@@ -2,6 +2,7 @@ import { DEV } from 'esm-env';
2
2
  import { Y } from './engine.js';
3
3
  import { asEngineDoc } from './structs.js';
4
4
  import { DOCUMENT_OPERATIONS } from './operations.js';
5
+ import { propsProxy } from './props.js';
5
6
  import { bindAttribution } from './attribution/index.js';
6
7
  import { profileOf } from './attribution/attribution.js';
7
8
  import { docFacade, DEFAULT_HISTORY_LIMIT, isInitialized } from './edytor-doc.js';
@@ -237,6 +238,17 @@ export class EdytorDocument {
237
238
  else if (DEV)
238
239
  advertise(this.awareness, this.#capability.config, this.#defaultType);
239
240
  }
241
+ #data;
242
+ /**
243
+ * The document's properties as a live object (`crdt/props`): a read
244
+ * follows the document, and a write at any depth (`document.data.title
245
+ * = 'Notes'`, `document.data.tags.push('x')`, `delete …`) is the raw
246
+ * `patchData` operation at once, as `document.patchData(null, …)`. Not
247
+ * reactive by itself: a view's `edytor.data` is, and dispatches.
248
+ */
249
+ get data() {
250
+ return (this.#data ??= propsProxy(() => this.facade.docData(), (patches) => void this.facade.apply(this.facade.prepare.patchData(null, patches)), (path) => this.facade.dataItemIds(null, path)));
251
+ }
240
252
  /** The replica id (`doc.clientID`) — identifies the replica, not the actor. */
241
253
  get clientID() {
242
254
  return this.doc.clientID;
@@ -168,6 +168,12 @@ export declare const docFacade: <L extends Layers>(Y: EngineApi, layers: L) => (
168
168
  blockDataOf: (id: string) => Record<string, unknown> | undefined;
169
169
  docData: () => JsonObj;
170
170
  dataItemIds: (target: DataTarget, path: readonly string[]) => string[];
171
+ dataPreview: (target: DataTarget, pending: readonly DataPatch[]) => {
172
+ data: {
173
+ [x: string]: unknown;
174
+ };
175
+ ids: (path: readonly string[]) => string[];
176
+ } | null;
171
177
  /**
172
178
  * Compact per-block attribution (`{createdBy, contributors,
173
179
  * lastChangedBy}`), or `undefined` for unauthored/system blocks.
@@ -482,6 +488,12 @@ export declare const bindDocModel: (Y: EngineApi) => {
482
488
  blockDataOf: (id: string) => Record<string, unknown> | undefined;
483
489
  docData: () => JsonObj;
484
490
  dataItemIds: (target: DataTarget, path: readonly string[]) => string[];
491
+ dataPreview: (target: DataTarget, pending: readonly DataPatch[]) => {
492
+ data: {
493
+ [x: string]: unknown;
494
+ };
495
+ ids: (path: readonly string[]) => string[];
496
+ } | null;
485
497
  /**
486
498
  * Compact per-block attribution (`{createdBy, contributors,
487
499
  * lastChangedBy}`), or `undefined` for unauthored/system blocks.
@@ -882,6 +894,12 @@ export declare const bindEdytorDoc: (Y: EngineApi) => {
882
894
  blockDataOf: (id: string) => Record<string, unknown> | undefined;
883
895
  docData: () => JsonObj;
884
896
  dataItemIds: (target: DataTarget, path: readonly string[]) => string[];
897
+ dataPreview: (target: DataTarget, pending: readonly DataPatch[]) => {
898
+ data: {
899
+ [x: string]: unknown;
900
+ };
901
+ ids: (path: readonly string[]) => string[];
902
+ } | null;
885
903
  /**
886
904
  * Compact per-block attribution (`{createdBy, contributors,
887
905
  * lastChangedBy}`), or `undefined` for unauthored/system blocks.