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.
- package/README.md +14 -72
- package/dist/block/block.svelte.d.ts +6 -4
- package/dist/block/block.svelte.js +10 -4
- package/dist/block/inlineBlock.svelte.d.ts +6 -2
- package/dist/block/inlineBlock.svelte.js +7 -2
- package/dist/cloudflare/room/context.d.ts +6 -0
- package/dist/collaboration/awarenessSelection.d.ts +22 -1
- package/dist/collaboration/awarenessSelection.js +42 -0
- package/dist/components/Block.svelte +3 -1
- package/dist/components/Edytor.svelte +36 -10
- package/dist/components/Edytor.svelte.d.ts +8 -1
- package/dist/components/InlineBlock.svelte +1 -1
- package/dist/crdt/data.d.ts +10 -0
- package/dist/crdt/data.js +20 -2
- package/dist/crdt/doc/reads.d.ts +7 -0
- package/dist/crdt/doc/reads.js +11 -1
- package/dist/crdt/document.d.ts +8 -0
- package/dist/crdt/document.js +12 -0
- package/dist/crdt/edytor-doc.d.ts +18 -0
- package/dist/crdt/edytor-doc.js +2 -1
- package/dist/crdt/index.d.ts +6 -0
- package/dist/crdt/operations.d.ts +1 -1
- package/dist/crdt/operations.js +1 -0
- package/dist/crdt/props.d.ts +32 -0
- package/dist/crdt/props.js +172 -0
- package/dist/{plugins/blockHandles → dnd}/dropIndicator.d.ts +2 -2
- package/dist/{plugins/blockHandles → dnd}/geometry.d.ts +2 -2
- package/dist/dnd/placement.d.ts +200 -0
- package/dist/dnd/placement.js +426 -0
- package/dist/edytor.svelte.d.ts +17 -4
- package/dist/edytor.svelte.js +16 -7
- package/dist/events/beforeInputCommands.d.ts +2 -2
- package/dist/events/beforeInputCommands.js +8 -4
- package/dist/events/onDrop.d.ts +6 -0
- package/dist/events/onDrop.js +38 -10
- package/dist/events/onPaste.d.ts +2 -1
- package/dist/events/onPaste.js +8 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/labels.d.ts +2 -0
- package/dist/labels.js +2 -1
- package/dist/plugins/arrowMove/arrowMove.d.ts +1 -1
- package/dist/plugins/arrowMove/arrowMove.js +1 -1
- package/dist/plugins/blockHandles/BlockHandleController.svelte.d.ts +2 -122
- package/dist/plugins/blockHandles/BlockHandleController.svelte.js +29 -387
- package/dist/plugins/blockHandles/BlockHandles.svelte +14 -32
- package/dist/plugins/blockHandles/blockHandlesPlugin.js +2 -1
- package/dist/plugins/code/CodeHeader.svelte +5 -3
- package/dist/plugins/code/CodePlugin.svelte +3 -3
- package/dist/plugins/code/languages.d.ts +0 -3
- package/dist/plugins/code/languages.js +0 -2
- package/dist/plugins/columns/ColumnResize.svelte +3 -2
- package/dist/plugins/columns/ColumnsPlugin.svelte +1 -1
- package/dist/plugins/columns/gaps.d.ts +7 -5
- package/dist/plugins/columns/gaps.js +11 -6
- package/dist/plugins/columns/resize.svelte.d.ts +3 -3
- package/dist/plugins/columns/resize.svelte.js +11 -10
- package/dist/plugins/equation/EquationPlugin.svelte +1 -8
- package/dist/plugins/equation/EquationPlugin.svelte.d.ts +1 -0
- package/dist/plugins/equation/equation.css +7 -0
- package/dist/plugins/fileDrop.d.ts +28 -0
- package/dist/plugins/fileDrop.js +116 -0
- package/dist/plugins/image/ImagePlugin.svelte +42 -33
- package/dist/plugins/image/ImagePlugin.svelte.d.ts +13 -1
- package/dist/plugins/image/image.css +17 -0
- package/dist/plugins/index.d.ts +3 -4
- package/dist/plugins/index.js +0 -1
- package/dist/plugins/markdownShortcuts.js +5 -5
- package/dist/plugins/media/AudioPlugin.svelte +13 -1
- package/dist/plugins/media/AudioPlugin.svelte.d.ts +11 -1
- package/dist/plugins/media/FilePlugin.svelte +11 -1
- package/dist/plugins/media/FilePlugin.svelte.d.ts +9 -0
- package/dist/plugins/media/SourceBlock.svelte +48 -12
- package/dist/plugins/media/SourceBlock.svelte.d.ts +4 -3
- package/dist/plugins/media/UploadPreview.svelte +32 -0
- package/dist/plugins/media/UploadPreview.svelte.d.ts +11 -0
- package/dist/plugins/media/VideoPlugin.svelte +13 -1
- package/dist/plugins/media/VideoPlugin.svelte.d.ts +11 -1
- package/dist/plugins/media/empty.svelte.d.ts +13 -4
- package/dist/plugins/media/empty.svelte.js +30 -19
- package/dist/plugins/media/media.d.ts +7 -0
- package/dist/plugins/media/upload.css +23 -0
- package/dist/plugins/mention/MentionPlugin.svelte +72 -33
- package/dist/plugins/mention/MentionPlugin.svelte.d.ts +11 -2
- package/dist/plugins/mention/mention.css +6 -0
- package/dist/plugins/pageLink/PageLinkPlugin.svelte +122 -17
- package/dist/plugins/pageLink/PageLinkPlugin.svelte.d.ts +13 -1
- package/dist/plugins/pageLink/pageLink.css +6 -0
- package/dist/plugins/richtext/RichTextPlugin.svelte +14 -7
- package/dist/plugins/richtext/RichTextPlugin.svelte.d.ts +1 -0
- package/dist/plugins/richtext/richtext.css +6 -0
- package/dist/plugins/table/chrome.svelte.js +4 -3
- package/dist/plugins/uploads.svelte.d.ts +45 -32
- package/dist/plugins/uploads.svelte.js +105 -39
- package/dist/plugins.d.ts +40 -14
- package/dist/session/attempt.d.ts +16 -6
- package/dist/session/attempt.js +1 -0
- package/dist/session/bindings.js +34 -31
- package/dist/session/commands.js +2 -0
- package/dist/session/history.js +1 -0
- package/dist/session/inputRules.d.ts +6 -3
- package/dist/session/inputRules.js +9 -5
- package/dist/session/moves.d.ts +33 -0
- package/dist/session/moves.js +71 -1
- package/dist/session/navigation.d.ts +7 -0
- package/dist/session/navigation.js +19 -10
- package/dist/session/props.d.ts +28 -0
- package/dist/session/props.js +65 -0
- package/dist/session/uploads.d.ts +29 -0
- package/dist/session/uploads.js +10 -0
- package/dist/themes/notion.css +9 -0
- package/package.json +1 -1
- /package/dist/{plugins/blockHandles → dnd}/dropIndicator.js +0 -0
- /package/dist/{plugins/blockHandles → dnd}/geometry.js +0 -0
- /package/dist/{plugins/columns → dnd}/stacking.d.ts +0 -0
- /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
|
-
> **
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
* block's cell
|
|
36
|
-
*
|
|
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():
|
|
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
|
|
81
|
-
* block's cell
|
|
82
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
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:
|
|
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];
|
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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. */
|
package/dist/crdt/data.d.ts
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/crdt/doc/reads.d.ts
CHANGED
|
@@ -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;
|
package/dist/crdt/doc/reads.js
CHANGED
|
@@ -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,
|
package/dist/crdt/document.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/crdt/document.js
CHANGED
|
@@ -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.
|