edytor 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/block/block.svelte.js +6 -21
- package/dist/block/inlineBlock.svelte.js +4 -6
- package/dist/clipboard/moveText.js +6 -8
- package/dist/cloudflare/DocumentRoom.d.ts +10 -3
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/room/access.d.ts +8 -3
- package/dist/cloudflare/room/access.js +44 -20
- package/dist/cloudflare/room/admission.js +28 -5
- package/dist/cloudflare/room/comments.js +28 -1
- package/dist/cloudflare/room/context.d.ts +106 -109
- package/dist/cloudflare/room/context.js +10 -3
- package/dist/cloudflare/room/forged.d.ts +14 -0
- package/dist/cloudflare/room/forged.js +30 -2
- package/dist/cloudflare/room/frames.d.ts +4 -0
- package/dist/cloudflare/room/frames.js +5 -1
- package/dist/cloudflare/room/purge.js +2 -1
- package/dist/cloudflare/room/replicas.d.ts +4 -2
- package/dist/cloudflare/room/replicas.js +8 -3
- package/dist/cloudflare/room/suggest.d.ts +7 -0
- package/dist/cloudflare/room/suggest.js +143 -0
- package/dist/cloudflare/routeDocumentSocket.d.ts +10 -1
- package/dist/cloudflare/routeDocumentSocket.js +5 -3
- package/dist/components/Content.svelte +9 -1
- package/dist/crdt/anchors.js +3 -2
- package/dist/crdt/attribution/attribution.d.ts +3 -0
- package/dist/crdt/attribution/attribution.js +3 -3
- package/dist/crdt/doc/funnel.d.ts +1 -1
- package/dist/crdt/doc/funnel.js +5 -2
- package/dist/crdt/doc/gate.d.ts +1 -1
- package/dist/crdt/doc/history.d.ts +6 -0
- package/dist/crdt/doc/history.js +79 -59
- package/dist/crdt/doc/ops.d.ts +81 -0
- package/dist/crdt/doc/ops.js +1898 -0
- package/dist/crdt/doc/reads.d.ts +2 -2
- package/dist/crdt/doc/reads.js +18 -28
- package/dist/crdt/doc/suggest.d.ts +167 -0
- package/dist/crdt/doc/suggest.js +579 -0
- package/dist/crdt/document.d.ts +10 -1
- package/dist/crdt/document.js +19 -11
- package/dist/crdt/edytor-doc.d.ts +294 -272
- package/dist/crdt/edytor-doc.js +123 -108
- package/dist/crdt/engine-api.d.ts +8 -2
- package/dist/crdt/engine.d.ts +2 -1
- package/dist/crdt/engine.js +3 -1
- package/dist/crdt/flow.d.ts +5 -47
- package/dist/crdt/flow.js +3 -269
- package/dist/crdt/index.d.ts +102 -108
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/operations.d.ts +13 -4
- package/dist/crdt/parts.d.ts +43 -0
- package/dist/crdt/parts.js +44 -0
- package/dist/crdt/placement/display.d.ts +1 -1
- package/dist/crdt/placement/display.js +3 -3
- package/dist/crdt/placement/keys.d.ts +5 -0
- package/dist/crdt/placement/keys.js +5 -0
- package/dist/crdt/placement/resolve.d.ts +1 -1
- package/dist/crdt/placement/resolve.js +2 -1
- package/dist/crdt/protocol.d.ts +1 -1
- package/dist/crdt/protocol.js +1 -1
- package/dist/crdt/protocols/auth.d.ts +11 -1
- package/dist/crdt/protocols/auth.js +17 -1
- package/dist/crdt/protocols/awareness.js +46 -81
- package/dist/crdt/protocols/comments.d.ts +16 -1
- package/dist/crdt/protocols/comments.js +9 -1
- package/dist/crdt/providers/index.d.ts +12 -22
- package/dist/crdt/providers/index.js +3 -1
- package/dist/crdt/providers/indexeddb.d.ts +6 -10
- package/dist/crdt/providers/indexeddb.js +10 -16
- package/dist/crdt/providers/room.d.ts +11 -0
- package/dist/crdt/providers/room.js +26 -12
- package/dist/crdt/providers/websocket.d.ts +8 -12
- package/dist/crdt/providers/websocket.js +17 -19
- package/dist/crdt/purge.d.ts +7 -1
- package/dist/crdt/purge.js +32 -2
- package/dist/crdt/rangeDelete.d.ts +2 -47
- package/dist/crdt/rangeDelete.js +1 -169
- package/dist/crdt/restore.d.ts +2 -1
- package/dist/crdt/restore.js +9 -0
- package/dist/crdt/schema.d.ts +9 -1
- package/dist/crdt/schema.js +13 -3
- package/dist/crdt/structs.d.ts +2 -0
- package/dist/crdt/structs.js +2 -0
- package/dist/crdt/text/index/build.d.ts +125 -0
- package/dist/crdt/text/index/build.js +2895 -0
- package/dist/crdt/text/index/shared.d.ts +2 -6
- package/dist/crdt/text/index/shared.js +19 -19
- package/dist/crdt/text/model.d.ts +10 -0
- package/dist/crdt/text/model.js +10 -4
- package/dist/crdt/text/runs.d.ts +43 -42
- package/dist/crdt/text/runs.js +2 -165
- package/dist/crdt/vendor/yjs/UPSTREAM.md +83 -0
- package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/structs/Item.d.ts +6 -3
- package/dist/crdt/vendor/yjs/dts/ynode.d.ts +13 -2
- package/dist/crdt/vendor/yjs/src/index.js +1 -1
- package/dist/crdt/vendor/yjs/src/structs/Item.js +44 -5
- package/dist/crdt/vendor/yjs/src/ynode.js +29 -7
- package/dist/edytor.svelte.d.ts +1 -1
- package/dist/edytor.svelte.js +100 -75
- package/dist/events/beforeInputCommands.d.ts +2 -2
- package/dist/events/domTextOffset.d.ts +2 -0
- package/dist/events/domTextOffset.js +8 -0
- package/dist/events/nativeInteractiveControl.d.ts +11 -1
- package/dist/events/nativeInteractiveControl.js +41 -1
- package/dist/events/onBeforeInput.js +3 -3
- package/dist/events/onFocus.js +9 -11
- package/dist/export/forms.d.ts +75 -75
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/labels.d.ts +8 -0
- package/dist/labels.js +6 -1
- package/dist/plugins/comments/CommentsController.svelte.d.ts +5 -1
- package/dist/plugins/comments/CommentsController.svelte.js +14 -4
- package/dist/plugins/find/search.js +2 -2
- package/dist/plugins/index.d.ts +1 -0
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
- package/dist/plugins/richtext/richTextOperations.js +26 -32
- package/dist/plugins/suggestions/SuggestionBars.svelte +40 -23
- package/dist/plugins/suggestions/SuggestionBars.svelte.d.ts +3 -1
- package/dist/plugins/suggestions/SuggestionRuns.svelte +77 -0
- package/dist/plugins/suggestions/SuggestionRuns.svelte.d.ts +9 -0
- package/dist/plugins/suggestions/shared.svelte.d.ts +58 -0
- package/dist/plugins/suggestions/shared.svelte.js +421 -0
- package/dist/plugins/suggestions/suggestionsPlugin.d.ts +15 -4
- package/dist/selection/domSelection.d.ts +2 -0
- package/dist/selection/domSelection.js +6 -2
- package/dist/selection/pointer.svelte.js +75 -48
- package/dist/selection/replaceSelection.js +2 -6
- package/dist/selection/selection.svelte.d.ts +3 -3
- package/dist/selection/selection.svelte.js +39 -32
- package/dist/selection/selection.utils.d.ts +14 -0
- package/dist/selection/selection.utils.js +33 -11
- package/dist/session/commands.js +21 -11
- package/dist/session/composition.svelte.d.ts +5 -3
- package/dist/session/composition.svelte.js +21 -17
- package/dist/session/handles.d.ts +4 -17
- package/dist/session/handles.js +14 -44
- package/dist/session/navigation.js +6 -9
- package/dist/session/selection.d.ts +11 -5
- package/dist/session/selection.js +14 -48
- package/dist/session/suggestions.svelte.d.ts +38 -9
- package/dist/session/suggestions.svelte.js +37 -18
- package/dist/session/virtual.d.ts +1 -1
- package/dist/surface/attributes.d.ts +5 -0
- package/dist/surface/attributes.js +2 -2
- package/dist/surface/cells.d.ts +2 -26
- package/dist/surface/cells.js +2 -33
- package/dist/surface/observer.svelte.d.ts +6 -1
- package/dist/surface/observer.svelte.js +143 -111
- package/dist/surface/pin.svelte.d.ts +9 -9
- package/dist/surface/pin.svelte.js +15 -22
- package/dist/surface/projector.svelte.js +10 -16
- package/dist/text/text.svelte.js +14 -19
- package/dist/themes/notion.css +29 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +7 -0
- package/package.json +3 -1
- package/dist/crdt/doc/capability.d.ts +0 -56
- package/dist/crdt/doc/capability.js +0 -211
- package/dist/crdt/doc/content.d.ts +0 -16
- package/dist/crdt/doc/content.js +0 -77
- package/dist/crdt/doc/delete.d.ts +0 -15
- package/dist/crdt/doc/delete.js +0 -84
- package/dist/crdt/doc/layout.d.ts +0 -9
- package/dist/crdt/doc/layout.js +0 -178
- package/dist/crdt/doc/meta.d.ts +0 -19
- package/dist/crdt/doc/meta.js +0 -199
- package/dist/crdt/doc/moves.d.ts +0 -21
- package/dist/crdt/doc/moves.js +0 -274
- package/dist/crdt/doc/split-merge.d.ts +0 -10
- package/dist/crdt/doc/split-merge.js +0 -179
- package/dist/crdt/doc/steps.d.ts +0 -65
- package/dist/crdt/doc/steps.js +0 -311
- package/dist/crdt/text/index/cache.d.ts +0 -16
- package/dist/crdt/text/index/cache.js +0 -175
- package/dist/crdt/text/index/claims.d.ts +0 -18
- package/dist/crdt/text/index/claims.js +0 -225
- package/dist/crdt/text/index/fold.d.ts +0 -21
- package/dist/crdt/text/index/fold.js +0 -656
- package/dist/crdt/text/index/layout.d.ts +0 -24
- package/dist/crdt/text/index/layout.js +0 -271
- package/dist/crdt/text/index/placement.d.ts +0 -47
- package/dist/crdt/text/index/placement.js +0 -741
- package/dist/crdt/text/index/records.d.ts +0 -13
- package/dist/crdt/text/index/records.js +0 -139
- package/dist/crdt/text/index/report.d.ts +0 -18
- package/dist/crdt/text/index/report.js +0 -337
- package/dist/crdt/text/index/state.d.ts +0 -197
- package/dist/crdt/text/index/state.js +0 -116
- package/dist/crdt/text/index/streams.d.ts +0 -25
- package/dist/crdt/text/index/streams.js +0 -248
|
@@ -0,0 +1,1898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prepared ops of one facade, in one closure over its reads: the
|
|
3
|
+
* structural capability (`canPlace`, `fits`, `canMerge`: one answer in
|
|
4
|
+
* advance and at execution), the step writers every op composes, and the
|
|
5
|
+
* op families — placement (insert, move, outdent, lift), layout, split and
|
|
6
|
+
* merge, block deletion, meta (type and data) and content (text, atoms,
|
|
7
|
+
* marks) — then range deletion (`del.range.*`) and flow placement
|
|
8
|
+
* (`flow.*`), which compose them; the types they read are in
|
|
9
|
+
* `../rangeDelete.ts` and `../flow.ts`.
|
|
10
|
+
*
|
|
11
|
+
* ── Island/void enforcement (operation-layer, not stored) ──────────────
|
|
12
|
+
*
|
|
13
|
+
* Roles are resolved operation-time via `config.roleOf(type)`, through one
|
|
14
|
+
* role table (`DisplayRoles`) that the index's display and every guard
|
|
15
|
+
* below ask of a block's shown kind — the editor derives them from plugin
|
|
16
|
+
* block definitions, exactly like the baseline's
|
|
17
|
+
* `block.definition.island/void`. They are deliberately NOT replicated block
|
|
18
|
+
* data: the schema stays policy-free, and a replica with different plugins
|
|
19
|
+
* reads the same document. Enforced rules (baseline semantics, see
|
|
20
|
+
* `plugins.ts` + `block.utils.ts`):
|
|
21
|
+
*
|
|
22
|
+
* - `void`: cannot accept children (insert/move/nest into void rejected),
|
|
23
|
+
* cannot merge either direction, cannot be split. Content edits ARE
|
|
24
|
+
* allowed — void rendered content (captions) stays editable per the
|
|
25
|
+
* baseline contract. Deletion is allowed (delete is not editing).
|
|
26
|
+
* - `island`: editable content, but its subtree is structurally sealed —
|
|
27
|
+
* a block inside an island (`insideIsland`) cannot be moved, nested,
|
|
28
|
+
* unnested, or merged across the island boundary. Merges INSIDE one
|
|
29
|
+
* island are allowed; merging an island child into the island itself is
|
|
30
|
+
* allowed when the island renders its content. Moving or merging
|
|
31
|
+
* INTO an island subtree is rejected. An island declared `lines` (code)
|
|
32
|
+
* holds only lines of its `defaultChild` kind, and a line holds no
|
|
33
|
+
* children (the display enforces it against undo,
|
|
34
|
+
* `insertBlocks` refuses a line parent); any other island keeps its
|
|
35
|
+
* structure. `canPlace` and `canMerge` are the one answer, asked in
|
|
36
|
+
* advance or by the ops.
|
|
37
|
+
* - Island merge: when an island block itself is merged (backward or
|
|
38
|
+
* forward), its children are unnested to the vacated sibling slot and
|
|
39
|
+
* reset to the default child of that slot's parent (`defaultChild`).
|
|
40
|
+
* - Baseline merges NEVER adopt the merged block's children — they unnest
|
|
41
|
+
* to the vacated slot. The facade exposes both: `mergeBlocks` is the
|
|
42
|
+
* engine primitive (children adopt into the target
|
|
43
|
+
* by staying under the source, which the target's claim displays),
|
|
44
|
+
* `mergeBackward`/`mergeForward` reproduce the baseline command shape.
|
|
45
|
+
*/
|
|
46
|
+
import { id as newId } from '../../utils.js';
|
|
47
|
+
import { asBlockSpec, asContentItem, jsonEquals, sanitizeSpec, sanitizeWireJson } from '../../utils/json.js';
|
|
48
|
+
import { isIncarnationId } from '../incarnations.js';
|
|
49
|
+
import { randOf } from '../rand.js';
|
|
50
|
+
import { TYPE } from '../schema.js';
|
|
51
|
+
import { isObject, leafKey, patchWrites, readData } from '../data.js';
|
|
52
|
+
import { blockNodeOf, collides, isLiveIn, isSelfOrDescendant } from '../placement/model.js';
|
|
53
|
+
import { REFUSED, effectOf, ref, refused, sanitizeInline, sanitizeItem } from './plan.js';
|
|
54
|
+
import { placedEnd, lengthOf } from '../flow.js';
|
|
55
|
+
/**
|
|
56
|
+
* A record's order among the records standing at one block, for the
|
|
57
|
+
* engine's sort of two records at one origin (fork patch YP22): what a split
|
|
58
|
+
* of a list around a block writes there — a new head list holding the items
|
|
59
|
+
* before it (`head`), new blocks placed after the items before it, which
|
|
60
|
+
* stay (`kept`), a head holding those items and the block (`with`), the
|
|
61
|
+
* block itself leaving (`self`), new blocks after it (`after`) — in the
|
|
62
|
+
* order every serial run of the gestures gives (`del.merge.container`).
|
|
63
|
+
*/
|
|
64
|
+
export const PART = { head: 0, kept: 1, with: 2, self: 3, after: 4 };
|
|
65
|
+
/** The prepared ops of one facade over its reads. */
|
|
66
|
+
export const docOps = (c) => {
|
|
67
|
+
const { doc, runsView, roles, roleOf, dataNode, view, blockTypeOf, blockDataOf, childrenIds, positionOf, ancestorsOf, isVoid, isIsland, isLines, isLine, islandOf, insideIsland, itemKindOf, isLayout, isLayoutItem, holdsLayout, insideItem, isTable, isTableRow, isRowKind, isCellKind, isTableCell, tableOf, next, previous, displayLength, contentItems, live, contentTarget } = c;
|
|
68
|
+
// ── structural capability: one answer in advance and at execution ──
|
|
69
|
+
/**
|
|
70
|
+
* May `ids` be placed under `parent` (`null` = the root)? Every id is
|
|
71
|
+
* live, distinct and outside any island interior (island subtrees are
|
|
72
|
+
* sealed); the destination is live, neither void nor an island nor
|
|
73
|
+
* inside one, and not inside any moved block's own subtree; and every
|
|
74
|
+
* block fits it as the kind `kindOf` gives (`fits`; a move keeps its
|
|
75
|
+
* kind) or already sits in it (a reorder changes nothing a list holds:
|
|
76
|
+
* an image shed into a list still moves among its items); and
|
|
77
|
+
* no layout, nor a block holding one, lands inside a layout item
|
|
78
|
+
* (`layout.nest`). A table's cell never moves, and its row only
|
|
79
|
+
* within its table (`table.fits`).
|
|
80
|
+
* Without a `parent`: may these blocks move at all (the drag
|
|
81
|
+
* affordance). The move ops refuse exactly when this answers `false`.
|
|
82
|
+
* (`insertBlock` is looser — island interiors are built by inserting
|
|
83
|
+
* into them.)
|
|
84
|
+
*/
|
|
85
|
+
const canPlace = (ids, parent, kindOf = blockTypeOf) => {
|
|
86
|
+
const v = view();
|
|
87
|
+
if (ids.length === 0 || new Set(ids).size !== ids.length)
|
|
88
|
+
return false;
|
|
89
|
+
if (ids.some((id) => !isLiveIn(v, id) || insideIsland(id, v)))
|
|
90
|
+
return false;
|
|
91
|
+
// A cell moves only with its column, never as a block (`table.fits`).
|
|
92
|
+
if (ids.some((id) => isCellKind(blockTypeOf(id))))
|
|
93
|
+
return false;
|
|
94
|
+
// A row moves only within its table: a table's rows are reordered, never re-homed.
|
|
95
|
+
const rows = ids.filter((id) => isRowKind(blockTypeOf(id)));
|
|
96
|
+
if (rows.length > 0 && parent !== undefined)
|
|
97
|
+
if (parent === null || rows.some((id) => positionOf(id)?.parent !== parent))
|
|
98
|
+
return false;
|
|
99
|
+
if (parent === undefined || parent === null)
|
|
100
|
+
return true;
|
|
101
|
+
if (!isLiveIn(v, parent) || isVoid(parent))
|
|
102
|
+
return false;
|
|
103
|
+
const stays = (id) => positionOf(id)?.parent === parent;
|
|
104
|
+
if (!ids.every((id) => stays(id) || fits(parent, kindOf(id))))
|
|
105
|
+
return false;
|
|
106
|
+
if (insideItem(parent, v) && ids.some(holdsLayout))
|
|
107
|
+
return false;
|
|
108
|
+
return ![parent, ...ancestorsOf(parent, v)].some((a) => isIsland(a) || ids.includes(a));
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* A container: a block that shows only its children — no content of
|
|
112
|
+
* its own, neither void nor an island (a list, a table row, a column).
|
|
113
|
+
*/
|
|
114
|
+
const isContainer = (id) => roles.of(blockTypeOf(id) ?? '').container;
|
|
115
|
+
/**
|
|
116
|
+
* THE container rule: may a block of `kind` sit directly
|
|
117
|
+
* under `parent` (`null` = the root)? A container whose default child is
|
|
118
|
+
* a kind of its own — its item (a list's `list-item`, a columns
|
|
119
|
+
* layout's `column`) — holds only its items, and containers of them
|
|
120
|
+
* when the item renders content (a list directly in a list, from JSON
|
|
121
|
+
* or the API; a layout holds no layout, `layout.fits`); one whose
|
|
122
|
+
* default child is the document's (a column) holds any block. Every
|
|
123
|
+
* structural placement asks it: a move is refused where its blocks do
|
|
124
|
+
* not fit (`canPlace`), Tab nests under a container's last item
|
|
125
|
+
* (`nestParent`), an outdent or a lift is refused where the block would
|
|
126
|
+
* not fit, and a block a merge, delete or range sheds into a container
|
|
127
|
+
* takes its item kind when it is a plain block (`fitted`). Explicit kind
|
|
128
|
+
* writes (`insertBlocks`, a retype) place what they are told (the
|
|
129
|
+
* view's Turn into places the kind where it fits first, `liftOut`); a
|
|
130
|
+
* plain block stored directly in a list still shows as its item (the
|
|
131
|
+
* index's `typeOf`), whatever write or race put it there.
|
|
132
|
+
*/
|
|
133
|
+
const fits = (parent, kind) => parent === null || fitsIn(blockTypeOf(parent) ?? '', kind);
|
|
134
|
+
/** `fits`, by the parent's kind (a block not written yet: `placeBeside`'s new item). */
|
|
135
|
+
const fitsIn = (parentType, kind) => {
|
|
136
|
+
const { container, child: item } = roles.of(parentType);
|
|
137
|
+
if (!container || item === defaultChild(null) || kind === item)
|
|
138
|
+
return true;
|
|
139
|
+
const k = kind === undefined ? undefined : roles.of(kind);
|
|
140
|
+
return k?.container === true && k.child === item && roles.of(item).rendersContent;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* `kind`, or — where a plain block (the document's default kind, or
|
|
144
|
+
* none: a pasted run) does not fit `parent` — `parent`'s item, when
|
|
145
|
+
* that renders content (a paragraph landing in a list is its item; one
|
|
146
|
+
* in a columns layout keeps its kind: its text never vanishes). Any
|
|
147
|
+
* other kind keeps its kind and data wherever a merge, a delete or a
|
|
148
|
+
* paste sheds it — an image under a bullet stays an image, a heading a
|
|
149
|
+
* heading, a to-do keeps its check — as a peer's
|
|
150
|
+
* concurrent promotion shows it (`typeOf` resets only the default
|
|
151
|
+
* kind); an outdent or a move that would place one directly in a list
|
|
152
|
+
* is refused (`fits`).
|
|
153
|
+
*/
|
|
154
|
+
const fitted = (parent, kind) => {
|
|
155
|
+
if (fits(parent, kind) || (kind !== undefined && kind !== defaultChild(null)))
|
|
156
|
+
return kind;
|
|
157
|
+
const item = defaultChild(parent);
|
|
158
|
+
return roles.of(item).rendersContent ? item : kind;
|
|
159
|
+
};
|
|
160
|
+
/** A container that goes once it loses every child: it holds no text of its own (a peer's retype can leave some, hidden: it renders none). */
|
|
161
|
+
const emptiable = (id) => isContainer(id) && displayLength(id) === 0;
|
|
162
|
+
/**
|
|
163
|
+
* Where `ids` nest when nested into `parent` (Tab, a drop inside it):
|
|
164
|
+
* `parent`, or — a container they are no items of — its last child,
|
|
165
|
+
* and so on down. Tab after a list nests under its last item (Notion);
|
|
166
|
+
* a last child that holds no children (an image, a code block) is
|
|
167
|
+
* answered as it is, and `canPlace` refuses it, as Tab right under
|
|
168
|
+
* that block is refused.
|
|
169
|
+
*/
|
|
170
|
+
const nestParent = (ids, parent) => {
|
|
171
|
+
let at = parent;
|
|
172
|
+
while (!ids.every((id) => fits(at, blockTypeOf(id)))) {
|
|
173
|
+
const last = childrenIds(at).at(-1);
|
|
174
|
+
if (last === undefined)
|
|
175
|
+
break;
|
|
176
|
+
at = last;
|
|
177
|
+
}
|
|
178
|
+
return at;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* May `fromId`'s content merge into `intoId`? Both live and distinct,
|
|
182
|
+
* neither void, `intoId` renders its content (a list, a table row or a
|
|
183
|
+
* code block shows none, so a first item, cell or line never merges
|
|
184
|
+
* into it), `fromId` renders its own unless it is
|
|
185
|
+
* an island (a list or a row never merges as a whole: its items would
|
|
186
|
+
* leave it, `del.merge.container`), and the merge stays on one side of an island
|
|
187
|
+
* boundary (a block may merge into its own island root — that stays
|
|
188
|
+
* inside — but nothing from outside merges into an island).
|
|
189
|
+
*/
|
|
190
|
+
const canMerge = (fromId, intoId) => {
|
|
191
|
+
const v = view();
|
|
192
|
+
if (fromId === intoId || !isLiveIn(v, fromId) || !isLiveIn(v, intoId))
|
|
193
|
+
return false;
|
|
194
|
+
if (isVoid(fromId) || isVoid(intoId))
|
|
195
|
+
return false;
|
|
196
|
+
// Nothing merges into or out of a table's cell (`table.merge`).
|
|
197
|
+
if (isCellKind(blockTypeOf(fromId)) || isCellKind(blockTypeOf(intoId)))
|
|
198
|
+
return false;
|
|
199
|
+
if (!rendersContent(intoId) || !(rendersContent(fromId) || isIsland(fromId)))
|
|
200
|
+
return false;
|
|
201
|
+
const islandFrom = islandOf(fromId, v);
|
|
202
|
+
if (intoId === islandFrom)
|
|
203
|
+
return true;
|
|
204
|
+
return islandFrom === islandOf(intoId, v) && !isIsland(intoId);
|
|
205
|
+
};
|
|
206
|
+
/** `id`'s shown kind renders its content. */
|
|
207
|
+
const rendersContent = (id) => roles.of(blockTypeOf(id) ?? '').rendersContent;
|
|
208
|
+
/** The adopted default child type under `parent` (`null` = the root). */
|
|
209
|
+
const defaultChild = (parent) => roles.of(parent === null ? null : (blockTypeOf(parent) ?? null)).child;
|
|
210
|
+
/**
|
|
211
|
+
* The kind a new block copies from `id` (a split tail, a flow's tail,
|
|
212
|
+
* a duplicate and each of its descendants). A type a peer's retype is
|
|
213
|
+
* replacing can be missing while its new value is pending: the copy
|
|
214
|
+
* then takes its parent's default child, never a missing type
|
|
215
|
+
* (it showed as `unknown` everywhere, for good).
|
|
216
|
+
*/
|
|
217
|
+
const kindToCopy = (id) => blockTypeOf(id) ?? defaultChild(positionOf(id)?.parent ?? null);
|
|
218
|
+
// ── the step writers every op composes ──────────────────────────────
|
|
219
|
+
// The plan itself, moves (a move's place and side), type steps and the
|
|
220
|
+
// kind a block shows where it lands (`settle`), the containers and layouts
|
|
221
|
+
// a write leaves empty (`emptying`, `dissolving`), delete and merge steps,
|
|
222
|
+
// and display spans of a block's content.
|
|
223
|
+
// ── prepared ops ─────────────────────────────────────────────
|
|
224
|
+
// Every op is `prepare` (pure: `refused`, or a plan of named steps plus
|
|
225
|
+
// its effect summary, against the current version) then `apply(plan)`;
|
|
226
|
+
// composites compose their steps into one plan, so a hook sees the
|
|
227
|
+
// whole command before any write and a refusal refuses before one.
|
|
228
|
+
// Each prepare normalizes its inputs once, here at ingress: ids
|
|
229
|
+
// and strings as the wire would deliver them, payloads cloned.
|
|
230
|
+
/** The plan of `writes` touching `ids`; `at`, where it leaves the caret when it decides one. */
|
|
231
|
+
const plan = (ids, writes, at) => ({
|
|
232
|
+
ids,
|
|
233
|
+
writes,
|
|
234
|
+
effect: effectOf(writes),
|
|
235
|
+
version: runsView.version(),
|
|
236
|
+
...(at && { at })
|
|
237
|
+
});
|
|
238
|
+
/** The step deleting `length` display units of `id` from `offset` (none for nothing). */
|
|
239
|
+
const cutText = (id, offset, length) => length > 0 ? [{ op: 'deleteText', id, offset, length }] : [];
|
|
240
|
+
/** The step inserting `specs` at `index` under `parent` (none for no spec). */
|
|
241
|
+
const insertAt = (parent, index, specs, split) => specs.length
|
|
242
|
+
? [{ op: 'insertBlocks', parent, index, specs, ...(split !== undefined && { split }) }]
|
|
243
|
+
: [];
|
|
244
|
+
/**
|
|
245
|
+
* Move `ids` to display index `index` among `parent`'s children (the
|
|
246
|
+
* slot hooks see). `side`: the move record's class among the items of
|
|
247
|
+
* its origin (`placement/slots.ts`) — `0`, the blocks stand after what
|
|
248
|
+
* precedes them (a move; blocks leaving a block for the gap after it:
|
|
249
|
+
* its children, a list's last items); `1`, before what follows them
|
|
250
|
+
* (blocks leaving a block for the gap before it: a list's first item
|
|
251
|
+
* lifted out). In one gap every serial order of the gestures puts a
|
|
252
|
+
* split's pieces first, then the after-records, then the before-records
|
|
253
|
+
* (`del.merge.container`), which the engine's sort gives whatever the
|
|
254
|
+
* client ids.
|
|
255
|
+
* Every planned move carries the type steps that keep what the moved
|
|
256
|
+
* blocks show: one displayed out of an island as another kind than its
|
|
257
|
+
* stored one (`displayType`) gets that kind written, so leaving the
|
|
258
|
+
* island's slot never brings the island's child kind back.
|
|
259
|
+
* `at`, `part`: the block the record stands at and its order among the
|
|
260
|
+
* records standing there, for the engine's sort of two records at one
|
|
261
|
+
* origin (`PART`; by default the first moved block, as itself).
|
|
262
|
+
*/
|
|
263
|
+
const moveTo = (ids, parent, index, side = 0, at, part) => ids.length === 0
|
|
264
|
+
? []
|
|
265
|
+
: [
|
|
266
|
+
{
|
|
267
|
+
op: 'moveBlocks',
|
|
268
|
+
ids,
|
|
269
|
+
parent,
|
|
270
|
+
index,
|
|
271
|
+
...(side === 1 && { side }),
|
|
272
|
+
...(at !== undefined && { at }),
|
|
273
|
+
...(part !== undefined && { part })
|
|
274
|
+
},
|
|
275
|
+
...ids.flatMap((id) => {
|
|
276
|
+
const shown = runsView.displayType(id);
|
|
277
|
+
return shown === undefined ? [] : attr(id, TYPE, shown);
|
|
278
|
+
})
|
|
279
|
+
];
|
|
280
|
+
/** Move `ids` to `index` among `parent`'s children. */
|
|
281
|
+
const move = (ids, parent, index) => moveTo(ids, parent, index);
|
|
282
|
+
/** The side of blocks leaving a block for the gap right after it (`after`) or right before it. */
|
|
283
|
+
const leaving = (after) => (after ? 0 : 1);
|
|
284
|
+
/**
|
|
285
|
+
* A type step, planned only when the kind differs (the one same-value
|
|
286
|
+
* guard). A kind counts as the same only when the block is stored and
|
|
287
|
+
* shown as it: a move in the same plan pins the kind a block shows
|
|
288
|
+
* (`moveTo`), so a paragraph shown as its list's item (`itemOf`) that
|
|
289
|
+
* an outdent settles back to a paragraph is written back.
|
|
290
|
+
*/
|
|
291
|
+
const attr = (id, key, value) => {
|
|
292
|
+
const same = blockNodeOf(doc, id).getAttr(key) === value;
|
|
293
|
+
if (same && (runsView.displayType(id) ?? value) === value)
|
|
294
|
+
return [];
|
|
295
|
+
return [{ op: 'setBlockType', id, type: value }];
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* The kind `kid` shows once it leaves `from` for a slot under `parent`
|
|
299
|
+
* — the island and container rules, one answer:
|
|
300
|
+
* - an island's child takes `parent`'s default child (it leaves the
|
|
301
|
+
* island's kinds) — unless that renders no content while the child
|
|
302
|
+
* does: a line then takes the document's default kind (a code line
|
|
303
|
+
* shed into a columns layout is a paragraph), any other child
|
|
304
|
+
* keeps its kind, as `typeOf` shows one a peer adds meanwhile;
|
|
305
|
+
* - a container's item (its default child) takes `parent`'s default
|
|
306
|
+
* child — an item never shows outside its list — unless it
|
|
307
|
+
* stays inside an outer container of that kind (a nested list),
|
|
308
|
+
* or that kind renders no content (a column in a
|
|
309
|
+
* columns layout: its text would vanish);
|
|
310
|
+
* - then a plain block that does not fit `parent` (`fits`: a paragraph
|
|
311
|
+
* shed into a list) becomes its item, when that item renders content
|
|
312
|
+
* (`fitted`). A block that cannot (a paragraph in a columns
|
|
313
|
+
* layout) keeps its kind: its text never vanishes; any other kind (an
|
|
314
|
+
* image, a code block, a heading) keeps its kind.
|
|
315
|
+
*/
|
|
316
|
+
/**
|
|
317
|
+
* The kind a child of `island` (now `kind`) takes where the default
|
|
318
|
+
* child is `to`: `to` — unless `to` renders no content while the child
|
|
319
|
+
* does (its text would vanish): a line then takes the document's
|
|
320
|
+
* default kind, any other child keeps its kind (as `typeOf`).
|
|
321
|
+
*/
|
|
322
|
+
const leavingIsland = (island, kind, to) => {
|
|
323
|
+
if (roles.of(to).rendersContent || (kind !== undefined && !roles.of(kind).rendersContent))
|
|
324
|
+
return to;
|
|
325
|
+
return isLines(island) ? defaultChild(null) : kind;
|
|
326
|
+
};
|
|
327
|
+
const settledKind = (from, kid, parent) => {
|
|
328
|
+
let kind = blockTypeOf(kid);
|
|
329
|
+
if (from !== null && isIsland(from))
|
|
330
|
+
kind = leavingIsland(from, kind, defaultChild(parent));
|
|
331
|
+
else if (from !== null && isContainer(from) && kind === defaultChild(from)) {
|
|
332
|
+
const within = parent === null ? [] : [parent, ...ancestorsOf(parent)];
|
|
333
|
+
const to = defaultChild(parent);
|
|
334
|
+
// An outer container of its kind keeps it an item (a nested list); a
|
|
335
|
+
// column's default child is the document's: it holds no items of its own.
|
|
336
|
+
const outer = kind !== defaultChild(null) &&
|
|
337
|
+
within.some((a) => isContainer(a) && defaultChild(a) === kind);
|
|
338
|
+
if (!outer && roles.of(to).rendersContent)
|
|
339
|
+
kind = to;
|
|
340
|
+
}
|
|
341
|
+
return fitted(parent, kind);
|
|
342
|
+
};
|
|
343
|
+
/** The kind steps for the `kids` of `from` (`null`: the root) landing under `parent` (`settledKind`). */
|
|
344
|
+
const settle = (from, kids, parent) => kids.flatMap((kid) => {
|
|
345
|
+
const kind = settledKind(from, kid, parent);
|
|
346
|
+
return kind === undefined || kind === blockTypeOf(kid) ? [] : attr(kid, TYPE, kind);
|
|
347
|
+
});
|
|
348
|
+
/** `parent` and its display ancestors: where blocks landing under `parent` keep alive. */
|
|
349
|
+
const landing = (parent) => new Set(parent === null ? [] : [parent, ...ancestorsOf(parent)]);
|
|
350
|
+
/**
|
|
351
|
+
* `writes`, then every container one of `from` (the blocks' former
|
|
352
|
+
* parents) is left with no child but `leaving` removed
|
|
353
|
+
* (`del.range.empty-container`), and so on upward — never one of
|
|
354
|
+
* `kept`, and only an `emptiable` one (a container holding text of its
|
|
355
|
+
* own stays). The containers are found together, deepest first: a
|
|
356
|
+
* list that loses its last item along with a list nested in it goes
|
|
357
|
+
* too. Then every layout the plan leaves with one
|
|
358
|
+
* item or none dissolves (`dissolving`); `removed`: blocks the plan
|
|
359
|
+
* deletes besides (a block delete's members), which only that counts.
|
|
360
|
+
*/
|
|
361
|
+
const emptyingAll = (from, leaving, writes, kept = new Set(), removed = []) => {
|
|
362
|
+
const gone = new Set(leaving);
|
|
363
|
+
const tops = [];
|
|
364
|
+
const deepest = [...new Set(from)]
|
|
365
|
+
.filter((c) => c !== null)
|
|
366
|
+
.sort((a, b) => ancestorsOf(b).length - ancestorsOf(a).length);
|
|
367
|
+
for (const start of deepest) {
|
|
368
|
+
let top = null;
|
|
369
|
+
for (let c = start; c !== null && !gone.has(c) && !kept.has(c) && emptiable(c); c = positionOf(c).parent) {
|
|
370
|
+
if (!childrenIds(c).every((kid) => gone.has(kid)))
|
|
371
|
+
break;
|
|
372
|
+
gone.add((top = c));
|
|
373
|
+
}
|
|
374
|
+
if (top !== null)
|
|
375
|
+
tops.push(top);
|
|
376
|
+
}
|
|
377
|
+
const roots = tops.filter((t) => !ancestorsOf(t).some((a) => tops.includes(a)));
|
|
378
|
+
const steps = [...writes, ...roots.map((t) => remove(t, leaving))];
|
|
379
|
+
for (const id of removed)
|
|
380
|
+
gone.add(id);
|
|
381
|
+
return [...steps, ...dissolving(gone, leaving, steps)];
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* `layout.dissolving`: the steps that delete each layout a plan leaves
|
|
385
|
+
* with one item or none — `gone` (blocks the plan removes) and
|
|
386
|
+
* `leaving` (blocks it moves) no longer count, an item `writes` move or
|
|
387
|
+
* insert into it does. With one left, the layout and that item are
|
|
388
|
+
* deleted and the item's children moved right after the layout, where
|
|
389
|
+
* `layout.single` shows them, as the kind they show there (`settle`);
|
|
390
|
+
* with none, the layout is deleted. One plan with the write that caused
|
|
391
|
+
* it.
|
|
392
|
+
*/
|
|
393
|
+
const dissolving = (gone, leaving, writes) => {
|
|
394
|
+
const away = new Set([...gone, ...leaving]);
|
|
395
|
+
const layouts = new Set();
|
|
396
|
+
for (const id of away) {
|
|
397
|
+
const parent = positionOf(id)?.parent;
|
|
398
|
+
if (parent != null && isLayout(parent))
|
|
399
|
+
layouts.add(parent);
|
|
400
|
+
}
|
|
401
|
+
const { kids } = view();
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const layout of layouts) {
|
|
404
|
+
if ([layout, ...ancestorsOf(layout)].some((a) => gone.has(a)))
|
|
405
|
+
continue;
|
|
406
|
+
const item = itemKindOf(layout);
|
|
407
|
+
const arriving = writes.reduce((n, w) => w.op === 'moveBlocks' && w.parent === layout
|
|
408
|
+
? n + w.ids.filter((id) => blockTypeOf(id) === item).length
|
|
409
|
+
: w.op === 'insertBlocks' && w.parent === layout
|
|
410
|
+
? n + w.specs.filter((spec) => spec.type === item).length
|
|
411
|
+
: n, 0);
|
|
412
|
+
const slots = kids.get(layout) ?? [];
|
|
413
|
+
const items = slots.filter((k) => !away.has(k.id) && blockTypeOf(k.id) === item);
|
|
414
|
+
if (items.length + arriving > 1)
|
|
415
|
+
continue;
|
|
416
|
+
const { parent } = positionOf(layout);
|
|
417
|
+
const last = items[0];
|
|
418
|
+
if (last === undefined) {
|
|
419
|
+
out.push(deleting(layout, [layout]));
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// Its blocks keep their home slots: the read shows them in the layout's
|
|
423
|
+
// slot (`layout.single`), settled for its parent.
|
|
424
|
+
const moved = (kids.get(last.id) ?? []).filter((k) => !away.has(k.id));
|
|
425
|
+
const ids = moved.map((k) => k.id);
|
|
426
|
+
out.push(...settle(last.id, ids, parent), deleting(layout, [layout, last.id]));
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
};
|
|
430
|
+
/** `writes`, then `container` removed when they leave it no child but `leaving` (`emptyingAll`). */
|
|
431
|
+
const emptying = (container, leaving, writes, kept) => emptyingAll([container], leaving, writes, kept);
|
|
432
|
+
/**
|
|
433
|
+
* Delete: `removes` leave. Every one is marked with what it
|
|
434
|
+
* displays — an unmarked one would be promoted into the deleted slot
|
|
435
|
+
* (`displaySlotOf`).
|
|
436
|
+
*/
|
|
437
|
+
const deleting = (id, removes) => {
|
|
438
|
+
const marks = [...new Set(removes.flatMap((b) => view().displays(b)))];
|
|
439
|
+
return { op: 'deleteBlock', id, marks, removes };
|
|
440
|
+
};
|
|
441
|
+
/** Delete `id` and its subtree, `kept` children aside. */
|
|
442
|
+
const remove = (id, kept = []) => {
|
|
443
|
+
const removes = [];
|
|
444
|
+
const skip = new Set(kept);
|
|
445
|
+
const walk = (b) => {
|
|
446
|
+
removes.push(b);
|
|
447
|
+
for (const kid of childrenIds(b))
|
|
448
|
+
if (!skip.has(kid))
|
|
449
|
+
walk(kid);
|
|
450
|
+
};
|
|
451
|
+
walk(id);
|
|
452
|
+
return deleting(id, removes);
|
|
453
|
+
};
|
|
454
|
+
const merge = (from, into) => ({
|
|
455
|
+
op: 'mergeBlocks',
|
|
456
|
+
from,
|
|
457
|
+
into,
|
|
458
|
+
at: displayLength(into),
|
|
459
|
+
length: displayLength(from)
|
|
460
|
+
});
|
|
461
|
+
/** `[at, end)` — `[offset, offset + length)` clamped to `id`'s display. */
|
|
462
|
+
const clamp = (id, offset, length) => {
|
|
463
|
+
const total = displayLength(id);
|
|
464
|
+
const at = Math.max(0, Math.min(offset, total));
|
|
465
|
+
return [at, Math.min(total, at + Math.max(0, length))];
|
|
466
|
+
};
|
|
467
|
+
/** `id`'s display items, each with its `[at, end)` display offsets. */
|
|
468
|
+
const spans = (id) => {
|
|
469
|
+
let end = 0;
|
|
470
|
+
return contentItems(id).map((item) => {
|
|
471
|
+
const at = end;
|
|
472
|
+
end += item.kind === 'text' ? item.text.length : 1;
|
|
473
|
+
return { item, at, end };
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
/** The live inline atom `atom` in `id`'s display, or undefined. */
|
|
477
|
+
const atomOf = (id, atom) => live(id) ? spans(id).find(({ item }) => item.kind === 'inline' && item.id === atom) : undefined;
|
|
478
|
+
/** Text items overlapping `[at, end)` of `id` (inline atoms carry no marks). */
|
|
479
|
+
const textIn = (id, at, end) => spans(id).flatMap((s) => (s.item.kind === 'text' && s.at < end && s.end > at ? [s.item] : []));
|
|
480
|
+
/** Push the steps writing `items` into `id` from `at` (text runs and atoms in order); the end offset. */
|
|
481
|
+
const writeContent = (writes, id, at, items = []) => {
|
|
482
|
+
for (const item of items) {
|
|
483
|
+
if (item.kind === 'inline')
|
|
484
|
+
writes.push({ op: 'insertInline', id, offset: at++, atom: item });
|
|
485
|
+
else if (item.text !== '') {
|
|
486
|
+
writes.push({ op: 'insertText', id, offset: at, text: item.text, marks: item.marks });
|
|
487
|
+
at += item.text.length;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return at;
|
|
491
|
+
};
|
|
492
|
+
/** `writes`, then every container the `leaving` blocks leave with no child removed. */
|
|
493
|
+
const emptied = (leaving, writes, kept) => emptyingAll(leaving.map((id) => positionOf(id).parent), leaving, writes, kept);
|
|
494
|
+
// ── placement: insert (`insertBlocks`, `duplicateBlock`), move
|
|
495
|
+
// (`moveBlocks`), outdent (`unNestBlocks`) and the lift out of the
|
|
496
|
+
// containers a kind does not fit (`landingOf`, `splitOut`, `liftOut`) ──
|
|
497
|
+
/**
|
|
498
|
+
* Insert blocks (specs may carry children/content/data; the batch is
|
|
499
|
+
* all-or-nothing). A spec is a `BlockSpec` or the `JSONBlock` that
|
|
500
|
+
* `toJSON` and the view's `value` speak (`{ type, content: [{ text }] }`):
|
|
501
|
+
* its ids are kept, minted where missing (`asBlockSpec`). Ids must be fresh.
|
|
502
|
+
* Refused when the parent is not live, is `void` or is an island's
|
|
503
|
+
* line (it holds no children), or any id collides.
|
|
504
|
+
* Inserting INSIDE an island is allowed — island interiors are built
|
|
505
|
+
* this way. `ids`: the inserted roots.
|
|
506
|
+
*/
|
|
507
|
+
const insertBlocks = (dest, specs) => {
|
|
508
|
+
const parent = ref(dest.parent);
|
|
509
|
+
const clean = specs.map((spec) => sanitizeSpec(asBlockSpec(spec)));
|
|
510
|
+
if (parent !== null && (isVoid(parent) || isLine(parent)))
|
|
511
|
+
return REFUSED;
|
|
512
|
+
if (clean.length === 0)
|
|
513
|
+
return plan([], []);
|
|
514
|
+
if ((parent !== null && !live(parent)) || collides(doc, clean))
|
|
515
|
+
return REFUSED;
|
|
516
|
+
return plan(clean.map((s) => s.id), insertAt(parent, dest.index, clean));
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* Grouped move — one step, per-member conflict resolution; refused
|
|
520
|
+
* exactly when `canPlace` refuses the group. `ids`: the moved blocks,
|
|
521
|
+
* in request order.
|
|
522
|
+
*/
|
|
523
|
+
const moveBlocks = (ids, dest) => {
|
|
524
|
+
const moved = ids.map(ref);
|
|
525
|
+
const parent = ref(dest.parent);
|
|
526
|
+
if (moved.length === 0)
|
|
527
|
+
return plan([], []);
|
|
528
|
+
if (!canPlace(moved, parent))
|
|
529
|
+
return REFUSED;
|
|
530
|
+
// A list the move leaves with no item goes (not one it moves or lands in).
|
|
531
|
+
const into = new Set([...moved, ...landing(parent)]);
|
|
532
|
+
return plan(moved, emptied(moved, move(moved, parent, dest.index), into));
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* Outdent: move sibling blocks `ids` (document order) right
|
|
536
|
+
* after their parent, and hand the siblings that followed the last of
|
|
537
|
+
* them to it as its last children — every outliner's Shift+Tab. A last
|
|
538
|
+
* block that cannot adopt them (void, island, a container they do not
|
|
539
|
+
* fit) leaves them with the parent. The blocks take the kind they show
|
|
540
|
+
* in their new slot (`settledKind`: a list's item becomes a paragraph,
|
|
541
|
+
* a paragraph outdented into a list its item); refused where one would
|
|
542
|
+
* not fit (a paragraph out of a column into its columns layout).
|
|
543
|
+
* Items leaving a container never take the items after them:
|
|
544
|
+
* the list splits around them, Notion's way — outdented first items go
|
|
545
|
+
* before it, last ones after it; from the middle, the list keeps the
|
|
546
|
+
* items after them and a new list of its kind takes the ones before
|
|
547
|
+
* them, so an item a peer appends meanwhile stays with the items it
|
|
548
|
+
* follows (`splitOut`, the split Turn into's `liftOut` makes). A
|
|
549
|
+
* container left with no child goes. One plan; refused as the
|
|
550
|
+
* move is. `ids`: the moved blocks.
|
|
551
|
+
*/
|
|
552
|
+
const unNestBlocks = (ids) => {
|
|
553
|
+
const moved = ids.map(ref);
|
|
554
|
+
const last = moved.at(-1);
|
|
555
|
+
const pos = last === undefined ? null : positionOf(last);
|
|
556
|
+
const ppos = pos?.parent != null ? positionOf(pos.parent) : null;
|
|
557
|
+
if (!ppos || moved.some((id) => positionOf(id)?.parent !== pos.parent))
|
|
558
|
+
return REFUSED;
|
|
559
|
+
const from = pos.parent;
|
|
560
|
+
if (!canPlace(moved, ppos.parent, (id) => settledKind(from, id, ppos.parent)))
|
|
561
|
+
return REFUSED;
|
|
562
|
+
const after = childrenIds(from).slice(pos.index + 1);
|
|
563
|
+
const retype = settle(from, moved, ppos.parent);
|
|
564
|
+
if (isContainer(from))
|
|
565
|
+
return splitOut(moved, [from], [], false, retype);
|
|
566
|
+
const writes = [...moveTo(moved, ppos.parent, ppos.index + 1, leaving(true)), ...retype];
|
|
567
|
+
const adopts = after.length > 0 &&
|
|
568
|
+
!isVoid(last) &&
|
|
569
|
+
!isIsland(last) &&
|
|
570
|
+
after.every((id) => fits(last, blockTypeOf(id)));
|
|
571
|
+
if (adopts)
|
|
572
|
+
writes.push(...move(after, last, Infinity));
|
|
573
|
+
return plan(moved, writes);
|
|
574
|
+
};
|
|
575
|
+
/** Outdent one block (`unNestBlocks`). */
|
|
576
|
+
const unNestBlock = (id) => unNestBlocks([id]);
|
|
577
|
+
/**
|
|
578
|
+
* Where a block of `kind` at `id`'s place lands: `id`'s parent, or the
|
|
579
|
+
* first one up that `kind` fits (`fits`: a list holds only its items),
|
|
580
|
+
* and the containers it leaves on the way, innermost first (`levels`).
|
|
581
|
+
* Its own kind keeps a block where it is, as a reorder
|
|
582
|
+
* does (`canPlace`); a block inserted after it (`after`) has no place
|
|
583
|
+
* yet.
|
|
584
|
+
*/
|
|
585
|
+
const landingOf = (id, kind, after = false) => {
|
|
586
|
+
const levels = [];
|
|
587
|
+
let parent = positionOf(id)?.parent ?? null;
|
|
588
|
+
const own = !after && blockTypeOf(id) === kind;
|
|
589
|
+
for (; !own && parent !== null && !fits(parent, kind); parent = positionOf(parent).parent)
|
|
590
|
+
levels.push(parent);
|
|
591
|
+
return { parent, levels };
|
|
592
|
+
};
|
|
593
|
+
/**
|
|
594
|
+
* Lift sibling blocks `ids` (document order) out of `levels`, the
|
|
595
|
+
* containers around them, innermost first: each level
|
|
596
|
+
* splits around them as an outdent splits one list — the blocks
|
|
597
|
+
* before them go to a new container of its kind, which the level above
|
|
598
|
+
* holds the same way, and the level keeps the ones after them, so an
|
|
599
|
+
* item a peer appends meanwhile stays with the items it follows; one
|
|
600
|
+
* left with no child goes. `specs` (new blocks) land right after them.
|
|
601
|
+
* With `keep`, `ids` stay before the split and only `specs` go out.
|
|
602
|
+
* `retype` (the caller's kind steps) joins the plan. The one split
|
|
603
|
+
* `unNestBlocks` and `liftOut` share. `ids`: the placed blocks.
|
|
604
|
+
*/
|
|
605
|
+
const splitOut = (ids, levels, specs, keep, retype = []) => {
|
|
606
|
+
const moved = keep ? [] : [...ids];
|
|
607
|
+
const placed = [...moved, ...specs.map((s) => s.id)];
|
|
608
|
+
const last = ids.at(-1);
|
|
609
|
+
const { parent, index } = positionOf(levels.at(-1) ?? last);
|
|
610
|
+
if (levels.length === 0)
|
|
611
|
+
return plan(placed, [...insertAt(parent, index + 1, specs), ...retype]);
|
|
612
|
+
// Bottom up: what stays before the split at each level (the level
|
|
613
|
+
// itself, or a new head holding it), and whether anything follows it.
|
|
614
|
+
const heads = [];
|
|
615
|
+
let before = null;
|
|
616
|
+
let follows = false;
|
|
617
|
+
// The block right after `last` (what the split ends at when `keep`).
|
|
618
|
+
let next;
|
|
619
|
+
let child = last;
|
|
620
|
+
for (const level of levels) {
|
|
621
|
+
const kids = childrenIds(level);
|
|
622
|
+
const at = kids.indexOf(child);
|
|
623
|
+
const stays = (keep && child === last) || (before !== null && !before.head);
|
|
624
|
+
const lead = kids.slice(0, at + (stays ? 1 : 0)).filter((k) => !moved.includes(k));
|
|
625
|
+
if (!follows && at + 1 < kids.length)
|
|
626
|
+
[follows, next] = [true, kids[at + 1]];
|
|
627
|
+
if ((lead.length > 0 || before?.head) && follows) {
|
|
628
|
+
const spec = sanitizeSpec({
|
|
629
|
+
id: newId('b'),
|
|
630
|
+
type: kindToCopy(level),
|
|
631
|
+
data: blockDataOf(level) ?? {}
|
|
632
|
+
});
|
|
633
|
+
heads.push({ spec, kids: lead, inner: before?.head });
|
|
634
|
+
before = { id: spec.id, head: spec };
|
|
635
|
+
}
|
|
636
|
+
else
|
|
637
|
+
before = lead.length > 0 ? { id: level } : null;
|
|
638
|
+
child = level;
|
|
639
|
+
}
|
|
640
|
+
// Written in one gap, each right after the block before it, as an
|
|
641
|
+
// after-record out of the level's slot or a before-record into the gap
|
|
642
|
+
// before it (`leaving`): the engine's sort decides their order against
|
|
643
|
+
// a peer's records there (fork patch YP22, `sourceOrder`: the block a
|
|
644
|
+
// record stands at, then its part), so a peer's heads, pieces, lifts
|
|
645
|
+
// and new blocks in the same gap keep the serial order whatever the
|
|
646
|
+
// client ids (`del.merge.container`). The head stands at the first block
|
|
647
|
+
// (with `keep`, at it: `with`), the blocks at themselves, the new
|
|
648
|
+
// blocks after the last (`after`) — or, kept before the block that
|
|
649
|
+
// follows, at that block (`kept`). The records are written last first,
|
|
650
|
+
// so each stands before the one written before it, in the order the
|
|
651
|
+
// sort gives them; the head and the new blocks are created in the
|
|
652
|
+
// innermost level right after the block they stand at (their home,
|
|
653
|
+
// hidden while their records show them) and moved out by them.
|
|
654
|
+
const outside = before !== null && !before.head;
|
|
655
|
+
const side = leaving(outside);
|
|
656
|
+
const gap = index + (outside ? 1 : 0);
|
|
657
|
+
const at = moved[0] ?? last;
|
|
658
|
+
const kids0 = childrenIds(levels[0]);
|
|
659
|
+
const home = kids0.indexOf(last) + 1;
|
|
660
|
+
const specIds = specs.map((sp) => sp.id);
|
|
661
|
+
const [stands, part] = keep && !outside ? [next, PART.kept] : [last, PART.after];
|
|
662
|
+
// One record per run of adjacent blocks, each standing at its first: a
|
|
663
|
+
// block a peer's move takes out of a run leaves the others at their own place.
|
|
664
|
+
const runs = [];
|
|
665
|
+
for (const id of moved) {
|
|
666
|
+
const run = runs.at(-1);
|
|
667
|
+
if (run !== undefined && kids0.indexOf(run.at(-1)) + 1 === kids0.indexOf(id))
|
|
668
|
+
run.push(id);
|
|
669
|
+
else
|
|
670
|
+
runs.push([id]);
|
|
671
|
+
}
|
|
672
|
+
const writes = [
|
|
673
|
+
...insertAt(levels[0], home, specs),
|
|
674
|
+
...(before?.head ? insertAt(levels[0], home, [before.head]) : []),
|
|
675
|
+
...moveTo(specIds, parent, gap, side, stands, part),
|
|
676
|
+
...runs.reverse().flatMap((run) => moveTo(run, parent, gap, side, run[0], PART.self)),
|
|
677
|
+
...(before?.head
|
|
678
|
+
? moveTo([before.head.id], parent, gap, side, at, keep ? PART.with : PART.head)
|
|
679
|
+
: [])
|
|
680
|
+
];
|
|
681
|
+
for (const { spec, kids, inner } of heads.reverse()) {
|
|
682
|
+
writes.push(...moveTo(kids, spec.id, 0), ...(inner ? insertAt(spec.id, kids.length, [inner]) : []));
|
|
683
|
+
}
|
|
684
|
+
writes.push(...retype);
|
|
685
|
+
void next;
|
|
686
|
+
return plan(placed, emptying(levels[0], moved, writes, landing(parent)));
|
|
687
|
+
};
|
|
688
|
+
/**
|
|
689
|
+
* Place `id` where a block of `kind` fits, in one plan:
|
|
690
|
+
* out of every container around it that `kind` does not fit
|
|
691
|
+
* (`landingOf`: a list, and a list holding that list directly, for a
|
|
692
|
+
* heading or a divider), each split around it (`splitOut`). `after`
|
|
693
|
+
* (new blocks) lands right after it. With `keep`, `id` stays and only
|
|
694
|
+
* `after` goes out, the split right after `id` (a divider inserted
|
|
695
|
+
* after an item). Its own kind never moves it. The kinds are the
|
|
696
|
+
* caller's: it composes the retype (`setBlock`). Refused where `id`
|
|
697
|
+
* may not move there (`canPlace`). `ids`: the placed blocks.
|
|
698
|
+
*/
|
|
699
|
+
const liftOut = (id, kind, { keep = false, after = [] } = {}) => {
|
|
700
|
+
id = ref(id);
|
|
701
|
+
kind = ref(kind);
|
|
702
|
+
const specs = after.map(sanitizeSpec);
|
|
703
|
+
const { parent, levels } = landingOf(id, kind, keep);
|
|
704
|
+
if (!positionOf(id) || collides(doc, specs) || !canPlace([id], parent, () => kind))
|
|
705
|
+
return REFUSED;
|
|
706
|
+
return splitOut([id], levels, specs, keep);
|
|
707
|
+
};
|
|
708
|
+
/**
|
|
709
|
+
* Explicit fresh-identity copy of a subtree (paste / drag-clone):
|
|
710
|
+
* serializes `id`, remaps every block and inline atom id through
|
|
711
|
+
* `freshId`, inserts the copy right after `id`. Text atoms get new
|
|
712
|
+
* identity too — duplication is a creation op, not a relocation. An
|
|
713
|
+
* inline id `freshId` does not answer (a block-only callback) is
|
|
714
|
+
* minted; a block id it does not answer refuses the copy. `ids`: the
|
|
715
|
+
* copy's root.
|
|
716
|
+
*/
|
|
717
|
+
const duplicateBlock = (id, freshId = (_, kind) => newId(kind === 'block' ? 'b' : 'i')) => {
|
|
718
|
+
id = ref(id);
|
|
719
|
+
const pos = positionOf(id);
|
|
720
|
+
if (pos === null)
|
|
721
|
+
return REFUSED;
|
|
722
|
+
const spec = (b) => {
|
|
723
|
+
const fresh = freshId(b, 'block');
|
|
724
|
+
if (typeof fresh !== 'string')
|
|
725
|
+
return null;
|
|
726
|
+
const content = contentItems(b).map((item) => {
|
|
727
|
+
if (item.kind !== 'inline')
|
|
728
|
+
return item;
|
|
729
|
+
const atom = freshId(item.id, 'inline');
|
|
730
|
+
return { ...item, id: typeof atom === 'string' ? atom : newId('i') };
|
|
731
|
+
});
|
|
732
|
+
const children = childrenIds(b).map(spec);
|
|
733
|
+
if (children.includes(null))
|
|
734
|
+
return null;
|
|
735
|
+
const data = blockDataOf(b);
|
|
736
|
+
return {
|
|
737
|
+
id: fresh,
|
|
738
|
+
type: kindToCopy(b),
|
|
739
|
+
...(data !== undefined && { data }),
|
|
740
|
+
content,
|
|
741
|
+
children: children
|
|
742
|
+
};
|
|
743
|
+
};
|
|
744
|
+
const copy = spec(id);
|
|
745
|
+
return copy ? insertBlocks({ parent: pos.parent, index: pos.index + 1 }, [copy]) : REFUSED;
|
|
746
|
+
};
|
|
747
|
+
// ── layout (`layout.*`): the layout kind a new layout takes, where a
|
|
748
|
+
// beside placement lands (`besideAt`), `placeBeside` and `wrapInLayout` ──
|
|
749
|
+
/** The layout kind a new layout takes: `kind` when it is one, else the only one the roles declare. */
|
|
750
|
+
const layoutKind = (kind) => {
|
|
751
|
+
if (kind !== undefined)
|
|
752
|
+
return roles.of(kind).layout === undefined ? undefined : kind;
|
|
753
|
+
const kinds = roles.declared().layouts;
|
|
754
|
+
return kinds.length === 1 ? kinds[0] : undefined;
|
|
755
|
+
};
|
|
756
|
+
/** A fresh empty block of `type`. */
|
|
757
|
+
const spec = (type) => sanitizeSpec({ id: newId('b'), type, data: {} });
|
|
758
|
+
/**
|
|
759
|
+
* The block a beside placement at `target` stands beside
|
|
760
|
+
* (`layout.place-beside`): a layout or an item for itself; a block whose
|
|
761
|
+
* parent shows only its children (a list item → its list, at any depth)
|
|
762
|
+
* or is an island of lines (a code line → its code block) for that
|
|
763
|
+
* parent; then the block itself when it sits directly in an item or at
|
|
764
|
+
* the root, or where a new layout of `kind` (else the only layout kind)
|
|
765
|
+
* fits beside it (a toggle's, a callout's or a nested block's child,
|
|
766
|
+
* outside any item and island); else its outermost block below the
|
|
767
|
+
* root or an item.
|
|
768
|
+
*/
|
|
769
|
+
const besideAt = (target, kind) => {
|
|
770
|
+
let at = ref(target);
|
|
771
|
+
if (isLayout(at) || isLayoutItem(at))
|
|
772
|
+
return at;
|
|
773
|
+
for (let parent = positionOf(at)?.parent ?? null; parent !== null && !isLayoutItem(parent) && (isContainer(parent) || isLines(parent)); parent = positionOf(parent)?.parent ?? null)
|
|
774
|
+
at = parent;
|
|
775
|
+
const parent = positionOf(at)?.parent ?? null;
|
|
776
|
+
if (parent === null || isLayoutItem(parent))
|
|
777
|
+
return at;
|
|
778
|
+
const wrap = layoutKind(kind);
|
|
779
|
+
if (wrap !== undefined &&
|
|
780
|
+
fits(parent, wrap) &&
|
|
781
|
+
!insideItem(parent) &&
|
|
782
|
+
!isIsland(parent) &&
|
|
783
|
+
!insideIsland(parent))
|
|
784
|
+
return at;
|
|
785
|
+
for (let up = parent; up !== null && !isLayoutItem(up); up = positionOf(up).parent)
|
|
786
|
+
at = up;
|
|
787
|
+
return at;
|
|
788
|
+
};
|
|
789
|
+
/**
|
|
790
|
+
* Place `ids` beside `target`, to its `side` (`layout.place-beside`: a
|
|
791
|
+
* block dragged to another block's left or right edge). The target
|
|
792
|
+
* resolves to its outermost block below the root or below a layout
|
|
793
|
+
* item (a list item → its list, a code line → its code block); an item
|
|
794
|
+
* stands for itself, a layout for its first or last slot. Beside a
|
|
795
|
+
* block in an item, a new item holding `ids` goes beside that item;
|
|
796
|
+
* otherwise the block and a new item holding `ids` are wrapped in a new
|
|
797
|
+
* layout of the layout `kind` (else the only one the roles declare) at
|
|
798
|
+
* its place. Refused when `ids` holds the target or an ancestor of it,
|
|
799
|
+
* an item, a layout or a block holding one, a block that does not
|
|
800
|
+
* fit an item, a block or a layout inside an island, and, wrapping,
|
|
801
|
+
* when there is no layout kind. The sources are cleaned in the same
|
|
802
|
+
* plan (`emptying`, `dissolving`); a plain move. `ids`: the moved
|
|
803
|
+
* blocks.
|
|
804
|
+
*/
|
|
805
|
+
const placeBeside = (ids, target, side, kind) => {
|
|
806
|
+
const moved = ids.map(ref);
|
|
807
|
+
target = ref(target);
|
|
808
|
+
const v = view();
|
|
809
|
+
if ((side !== 'left' && side !== 'right') || !canPlace(moved) || !live(target))
|
|
810
|
+
return REFUSED;
|
|
811
|
+
if ([target, ...ancestorsOf(target, v)].some((a) => moved.includes(a)))
|
|
812
|
+
return REFUSED;
|
|
813
|
+
if (moved.some((id) => isLayoutItem(id) || holdsLayout(id)))
|
|
814
|
+
return REFUSED;
|
|
815
|
+
const right = side === 'right';
|
|
816
|
+
// Where the new item goes: beside an item of a layout, or a new layout wrapping `at`.
|
|
817
|
+
const at = besideAt(target, kind && ref(kind));
|
|
818
|
+
let layout = null;
|
|
819
|
+
let index = 0;
|
|
820
|
+
if (isLayout(at))
|
|
821
|
+
[layout, index] = [at, right ? childrenIds(at).length : 0];
|
|
822
|
+
else {
|
|
823
|
+
const item = isLayoutItem(at) ? at : positionOf(at).parent;
|
|
824
|
+
if (item !== null) {
|
|
825
|
+
const pos = positionOf(item);
|
|
826
|
+
[layout, index] = [pos.parent, pos.index + (right ? 1 : 0)];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const wrap = layout === null ? layoutKind(kind && ref(kind)) : blockTypeOf(layout);
|
|
830
|
+
const item = wrap === undefined ? undefined : roles.of(wrap).layout;
|
|
831
|
+
if (wrap === undefined || item === undefined)
|
|
832
|
+
return REFUSED;
|
|
833
|
+
if (moved.some((id) => !fitsIn(item, blockTypeOf(id))))
|
|
834
|
+
return REFUSED;
|
|
835
|
+
if (layout !== null && (isIsland(layout) || insideIsland(layout, v)))
|
|
836
|
+
return REFUSED;
|
|
837
|
+
const fresh = spec(item);
|
|
838
|
+
const into = (parent) => moveTo(moved, parent, 0);
|
|
839
|
+
let writes;
|
|
840
|
+
let kept;
|
|
841
|
+
if (layout !== null) {
|
|
842
|
+
writes = [...insertAt(layout, index, [fresh]), ...into(fresh.id)];
|
|
843
|
+
kept = new Set([fresh.id, ...landing(layout)]);
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
const pos = positionOf(at);
|
|
847
|
+
const host = spec(item);
|
|
848
|
+
const wrapper = { ...spec(wrap), children: right ? [host, fresh] : [fresh, host] };
|
|
849
|
+
writes = [
|
|
850
|
+
...insertAt(pos.parent, pos.index, [wrapper]),
|
|
851
|
+
...moveTo([at], host.id, 0),
|
|
852
|
+
...into(fresh.id)
|
|
853
|
+
];
|
|
854
|
+
kept = new Set([wrapper.id, host.id, fresh.id, ...landing(pos.parent)]);
|
|
855
|
+
}
|
|
856
|
+
return plan(moved, emptied(moved, writes, kept));
|
|
857
|
+
};
|
|
858
|
+
/**
|
|
859
|
+
* Wrap sibling blocks in a new layout of `columns` items (default: one
|
|
860
|
+
* per block), at the first one's place (`layout.wrap`: Turn into N
|
|
861
|
+
* columns, as Notion): the blocks fill the first items, one each, in
|
|
862
|
+
* document order; each item after them holds one empty block of the
|
|
863
|
+
* item's default child (one block turned into 3 columns: it, then two
|
|
864
|
+
* empty columns). The layout is of the layout `kind` (else the only one
|
|
865
|
+
* the roles declare). Refused for no block, fewer than two items or
|
|
866
|
+
* fewer items than blocks, blocks of different parents, an item, a
|
|
867
|
+
* layout or a block holding one, where the layout does not fit
|
|
868
|
+
* (`fits`: in a list, a code block) or lands inside an item or an
|
|
869
|
+
* island, and for a block that does not fit an item. A plain move.
|
|
870
|
+
* `ids`: the new layout.
|
|
871
|
+
*/
|
|
872
|
+
const wrapInLayout = (ids, kind, columns = ids.length) => {
|
|
873
|
+
const blocks = ids.map(ref);
|
|
874
|
+
const v = view();
|
|
875
|
+
if (!Number.isInteger(columns) || columns < 2 || blocks.length < 1)
|
|
876
|
+
return REFUSED;
|
|
877
|
+
if (columns < blocks.length || !canPlace(blocks))
|
|
878
|
+
return REFUSED;
|
|
879
|
+
const parent = positionOf(blocks[0]).parent;
|
|
880
|
+
if (blocks.some((id) => positionOf(id).parent !== parent))
|
|
881
|
+
return REFUSED;
|
|
882
|
+
if (blocks.some((id) => isLayoutItem(id) || holdsLayout(id)))
|
|
883
|
+
return REFUSED;
|
|
884
|
+
const wrap = layoutKind(kind && ref(kind));
|
|
885
|
+
const item = wrap === undefined ? undefined : roles.of(wrap).layout;
|
|
886
|
+
if (wrap === undefined || item === undefined || !fits(parent, wrap))
|
|
887
|
+
return REFUSED;
|
|
888
|
+
if (parent !== null && (insideItem(parent, v) || isIsland(parent) || insideIsland(parent, v)))
|
|
889
|
+
return REFUSED;
|
|
890
|
+
if (blocks.some((id) => !fitsIn(item, blockTypeOf(id))))
|
|
891
|
+
return REFUSED;
|
|
892
|
+
const fill = roles.of(item).child;
|
|
893
|
+
if (columns > blocks.length && !fitsIn(item, fill))
|
|
894
|
+
return REFUSED;
|
|
895
|
+
const ordered = blocks.toSorted((a, b) => positionOf(a).index - positionOf(b).index);
|
|
896
|
+
const items = Array.from({ length: columns }, (_, i) => i < ordered.length ? spec(item) : { ...spec(item), children: [spec(fill)] });
|
|
897
|
+
const wrapper = { ...spec(wrap), children: items };
|
|
898
|
+
const at = positionOf(ordered[0]).index;
|
|
899
|
+
const writes = [
|
|
900
|
+
...insertAt(parent, at, [wrapper]),
|
|
901
|
+
...ordered.flatMap((id, i) => moveTo([id], items[i].id, 0))
|
|
902
|
+
];
|
|
903
|
+
const kept = new Set([
|
|
904
|
+
wrapper.id,
|
|
905
|
+
...items.map((it) => it.id),
|
|
906
|
+
...landing(parent)
|
|
907
|
+
]);
|
|
908
|
+
return plan([wrapper.id], emptied(ordered, writes, kept));
|
|
909
|
+
};
|
|
910
|
+
// ── split and merge: `splitBlock` (one boundary item, no text copied),
|
|
911
|
+
// the engine merge primitive `mergeBlocks`, and the baseline-shaped
|
|
912
|
+
// `mergeBackward`/`mergeForward` (a first item lifts out of its container,
|
|
913
|
+
// a layout item's first block merges across items) ──
|
|
914
|
+
/**
|
|
915
|
+
* Split `id` at content `offset` into a new sibling `newId` (one boundary
|
|
916
|
+
* item and the claims that follow it, no text copied; children follow).
|
|
917
|
+
* `tail` decides the sibling's type/data once (default: the source's —
|
|
918
|
+
* the kind it displays; an empty `tail.type` is the default too).
|
|
919
|
+
* Refused on `void` blocks and on blocks that render no content (a
|
|
920
|
+
* list, a code block: nothing to split). `ids`: the new
|
|
921
|
+
* block.
|
|
922
|
+
*/
|
|
923
|
+
const splitBlock = (id, offset, newId, tail) => {
|
|
924
|
+
id = ref(id);
|
|
925
|
+
const born = ref(newId);
|
|
926
|
+
const pos = positionOf(id);
|
|
927
|
+
const rec = view().blocks.get(id);
|
|
928
|
+
if (pos === null || isVoid(id) || !rendersContent(id) || rec === undefined)
|
|
929
|
+
return REFUSED;
|
|
930
|
+
// A table's cell holds its lines as line breaks: it never splits (`table.split`).
|
|
931
|
+
if (isCellKind(blockTypeOf(id)))
|
|
932
|
+
return REFUSED;
|
|
933
|
+
if (isIncarnationId(born) || blockNodeOf(doc, born) !== null)
|
|
934
|
+
return REFUSED;
|
|
935
|
+
const [at] = clamp(id, offset, 0);
|
|
936
|
+
// An empty tail type is the one a view reads mid-retype: copy the kind.
|
|
937
|
+
const type = tail?.type ? ref(tail.type) : kindToCopy(id);
|
|
938
|
+
const t = tail
|
|
939
|
+
? { type, data: tail.data && sanitizeWireJson(tail.data) }
|
|
940
|
+
: { type, data: readData(rec.node) };
|
|
941
|
+
const length = displayLength(id) - at;
|
|
942
|
+
const split = {
|
|
943
|
+
op: 'splitBlock',
|
|
944
|
+
id,
|
|
945
|
+
offset: at,
|
|
946
|
+
length,
|
|
947
|
+
newId: born,
|
|
948
|
+
tail: t,
|
|
949
|
+
parent: pos.parent,
|
|
950
|
+
index: pos.index + 1
|
|
951
|
+
};
|
|
952
|
+
return plan([born], [split, ...move(childrenIds(id), born, 0)]);
|
|
953
|
+
};
|
|
954
|
+
/**
|
|
955
|
+
* Engine merge primitive: `from`'s content is claimed by `into`, its
|
|
956
|
+
* children ADOPTED as `into`'s last children — an island's take
|
|
957
|
+
* `into`'s default child, like a baseline merge's — and `from` is
|
|
958
|
+
* hidden via the claim (undo restores it). The children stay under
|
|
959
|
+
* `from` and display under `into` through the claim, after `into`'s
|
|
960
|
+
* own children (the display's claim order): a concurrent delete of
|
|
961
|
+
* `into` voids the claim and `from` comes back with its children,
|
|
962
|
+
* never below them. Role rules are `canMerge`'s; a merge that would
|
|
963
|
+
* close a display cycle is refused. `ids`: `into`.
|
|
964
|
+
*/
|
|
965
|
+
const mergeBlocks = (fromId, intoId) => {
|
|
966
|
+
const from = ref(fromId);
|
|
967
|
+
const into = ref(intoId);
|
|
968
|
+
const v = view();
|
|
969
|
+
if (!canMerge(from, into) || !contentTarget(into))
|
|
970
|
+
return REFUSED;
|
|
971
|
+
if (isSelfOrDescendant(v.placements, v.own, into, from))
|
|
972
|
+
return REFUSED;
|
|
973
|
+
const kids = childrenIds(from);
|
|
974
|
+
const retype = settle(from, kids, into);
|
|
975
|
+
// A list its only item leaves goes, as with every sibling op.
|
|
976
|
+
return plan([into], emptied([from], [merge(from, into), ...retype], landing(into)));
|
|
977
|
+
};
|
|
978
|
+
/**
|
|
979
|
+
* Baseline-shaped merge (both `mergeBackward` and `mergeForward`):
|
|
980
|
+
* `from`'s children unnest right after `from`'s vacated sibling slot —
|
|
981
|
+
* NOT adopted into `into` — and take the kind they show there
|
|
982
|
+
* (`settledKind`: an island's children its parent's default child, a
|
|
983
|
+
* paragraph in a list its item); then `from`'s content claims
|
|
984
|
+
* into `into`. Placed after `from`, not at it: a concurrent delete of
|
|
985
|
+
* `into` revives `from` above its former children.
|
|
986
|
+
*/
|
|
987
|
+
const mergeUnnesting = (from, into) => {
|
|
988
|
+
const pos = canMerge(from, into) ? positionOf(from) : null;
|
|
989
|
+
if (pos === null || !contentTarget(into))
|
|
990
|
+
return REFUSED;
|
|
991
|
+
const kids = childrenIds(from);
|
|
992
|
+
const retype = settle(from, kids, pos.parent);
|
|
993
|
+
return plan([into], [...moveTo(kids, pos.parent, pos.index + 1, leaving(true)), ...retype, merge(from, into)]);
|
|
994
|
+
};
|
|
995
|
+
/** Remove `container`, left empty, for the key at `id` (refused if it holds text). */
|
|
996
|
+
const gone = (id, container) => {
|
|
997
|
+
const writes = emptying(container, [], []);
|
|
998
|
+
return writes.length > 0 ? plan([id], writes) : REFUSED;
|
|
999
|
+
};
|
|
1000
|
+
/**
|
|
1001
|
+
* Baseline `mergeBlockBackward`: merge `id` into the previous block in
|
|
1002
|
+
* document order. No previous block → an empty block merges forward,
|
|
1003
|
+
* else refused. The first item of a container (`isContainer`) outside
|
|
1004
|
+
* any island lifts out of it instead (Notion): it takes the
|
|
1005
|
+
* container's slot, as its new parent's default child, with its
|
|
1006
|
+
* children (a first cell stays: nothing leaves an island; a block that
|
|
1007
|
+
* would land directly in a container it is no item of stays too).
|
|
1008
|
+
* The first block of a layout item merges across items
|
|
1009
|
+
* instead, in reading order (`layout.merge`, Notion): into the
|
|
1010
|
+
* previous item's last line, or, in the first item, the line before
|
|
1011
|
+
* the layout; an item it empties goes and the layout dissolves.
|
|
1012
|
+
* `ids`: the surviving block.
|
|
1013
|
+
*/
|
|
1014
|
+
const mergeBackward = (id) => {
|
|
1015
|
+
id = ref(id);
|
|
1016
|
+
const prev = previous(id);
|
|
1017
|
+
if (prev === null && childrenIds(id).length === 0 && displayLength(id) === 0) {
|
|
1018
|
+
return mergeForward(id);
|
|
1019
|
+
}
|
|
1020
|
+
// A container a concurrent edit left empty shows nothing: the key removes it.
|
|
1021
|
+
if (prev !== null && isContainer(prev) && childrenIds(prev).length === 0)
|
|
1022
|
+
return gone(id, prev);
|
|
1023
|
+
if (prev !== null && prev === positionOf(id)?.parent && isLayoutItem(prev)) {
|
|
1024
|
+
const layout = positionOf(prev).parent;
|
|
1025
|
+
let into = previous(prev);
|
|
1026
|
+
if (into === layout)
|
|
1027
|
+
into = previous(layout);
|
|
1028
|
+
const merged = into === null ? REFUSED : mergeUnnesting(id, into);
|
|
1029
|
+
if (!('writes' in merged) || childrenIds(id).length > 0)
|
|
1030
|
+
return merged;
|
|
1031
|
+
return plan(merged.ids, emptying(prev, [id], merged.writes));
|
|
1032
|
+
}
|
|
1033
|
+
if (prev !== null &&
|
|
1034
|
+
prev === positionOf(id)?.parent &&
|
|
1035
|
+
isContainer(prev) &&
|
|
1036
|
+
!insideIsland(prev)) {
|
|
1037
|
+
// It lands only where it fits: never directly in a container it is no item of.
|
|
1038
|
+
const slot = positionOf(prev);
|
|
1039
|
+
if (!fits(slot.parent, settledKind(prev, id, slot.parent)))
|
|
1040
|
+
return REFUSED;
|
|
1041
|
+
const lift = [
|
|
1042
|
+
...moveTo([id], slot.parent, slot.index, leaving(false)),
|
|
1043
|
+
...settle(prev, [id], slot.parent)
|
|
1044
|
+
];
|
|
1045
|
+
return plan([id], emptying(prev, [id], lift, landing(slot.parent)));
|
|
1046
|
+
}
|
|
1047
|
+
return prev ? mergeUnnesting(id, prev) : REFUSED;
|
|
1048
|
+
};
|
|
1049
|
+
/**
|
|
1050
|
+
* Baseline `mergeBlockForward`: pull the next block in document order
|
|
1051
|
+
* into `id`. A container passes the merge to its first item (`del.merge.container`:
|
|
1052
|
+
* Delete above a list pulls the item's text up and the list keeps the
|
|
1053
|
+
* rest, or goes when that was its only item). A container left
|
|
1054
|
+
* empty (by concurrent edits) is removed instead, by either key.
|
|
1055
|
+
*/
|
|
1056
|
+
const mergeForward = (id) => {
|
|
1057
|
+
id = ref(id);
|
|
1058
|
+
let after = next(id);
|
|
1059
|
+
while (after !== null && isContainer(after)) {
|
|
1060
|
+
const first = childrenIds(after)[0];
|
|
1061
|
+
// A container a concurrent edit left empty shows nothing: the key removes it.
|
|
1062
|
+
if (first === undefined)
|
|
1063
|
+
return gone(id, after);
|
|
1064
|
+
after = first;
|
|
1065
|
+
}
|
|
1066
|
+
const out = after ? mergeUnnesting(after, id) : REFUSED;
|
|
1067
|
+
const parent = after && positionOf(after)?.parent;
|
|
1068
|
+
if (!('writes' in out) || !parent || childrenIds(after).length > 0)
|
|
1069
|
+
return out;
|
|
1070
|
+
return plan(out.ids, emptying(parent, [after], out.writes));
|
|
1071
|
+
};
|
|
1072
|
+
// ── block deletion (`del.blocks.promote`): only the members leave, the
|
|
1073
|
+
// unselected children of a deleted block take its slot ──
|
|
1074
|
+
/**
|
|
1075
|
+
* Delete a set of blocks (`del.blocks.promote`): this writer's mark on
|
|
1076
|
+
* every member and on what it displays through merge claims (wins over
|
|
1077
|
+
* concurrent moves). Only the members leave: the unselected children of a
|
|
1078
|
+
* deleted block take its slot, in order, with their subtrees, as the
|
|
1079
|
+
* kind they show there (`settledKind`: a deleted island's children take
|
|
1080
|
+
* the slot parent's default child, a deleted list's items leave its
|
|
1081
|
+
* kind, a block promoted into a list is its item). A container the delete leaves with no child goes too
|
|
1082
|
+
* (`del.range.empty-container`). `subtree` is the explicit whole-subtree
|
|
1083
|
+
* delete. `filled`: a parent the same plan fills again (a flow placed in
|
|
1084
|
+
* the deleted blocks' slot, `flow.slot`), which is never emptied, so its
|
|
1085
|
+
* layout never dissolves (`layout.flow-slot`).
|
|
1086
|
+
*/
|
|
1087
|
+
const deleteBlocks = (ids, subtree = false, filled) => {
|
|
1088
|
+
const set = new Set(ids.map(ref));
|
|
1089
|
+
if ([...set].some((id) => !live(id)))
|
|
1090
|
+
return REFUSED;
|
|
1091
|
+
// A layout goes with its items: their blocks take its slot (`layout.dissolving`).
|
|
1092
|
+
for (const id of [...set])
|
|
1093
|
+
if (isLayout(id))
|
|
1094
|
+
for (const kid of childrenIds(id))
|
|
1095
|
+
if (isLayoutItem(kid))
|
|
1096
|
+
set.add(kid);
|
|
1097
|
+
// A table goes with its rows and cells, a row with its cells: none shows outside them (`table.delete-row`).
|
|
1098
|
+
const whole = (id) => {
|
|
1099
|
+
for (const kid of childrenIds(id)) {
|
|
1100
|
+
set.add(kid);
|
|
1101
|
+
whole(kid);
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
for (const id of [...set])
|
|
1105
|
+
if (isTable(id) || isTableRow(id))
|
|
1106
|
+
whole(id);
|
|
1107
|
+
if (subtree) {
|
|
1108
|
+
const roots = [...set].filter((id) => !ancestorsOf(id).some((a) => set.has(a)));
|
|
1109
|
+
return plan(roots, emptied(roots, roots.map((id) => remove(id))));
|
|
1110
|
+
}
|
|
1111
|
+
const roots = [...set].filter((id) => !set.has(positionOf(id).parent));
|
|
1112
|
+
const { kids } = view();
|
|
1113
|
+
const promoting = new Set();
|
|
1114
|
+
const members = [];
|
|
1115
|
+
const writes = roots.flatMap((root) => {
|
|
1116
|
+
const pos = positionOf(root);
|
|
1117
|
+
const chunk = [];
|
|
1118
|
+
const kept = [];
|
|
1119
|
+
// The kept children keep their home slots: the read shows them in the
|
|
1120
|
+
// root's slot, in its order (a block a peer puts under a member
|
|
1121
|
+
// meanwhile takes the slot in that order too); only their kinds are
|
|
1122
|
+
// written, as the slot's parent settles them.
|
|
1123
|
+
const walk = (b) => {
|
|
1124
|
+
chunk.push(b);
|
|
1125
|
+
for (const kid of kids.get(b) ?? []) {
|
|
1126
|
+
if (set.has(kid.id))
|
|
1127
|
+
walk(kid.id);
|
|
1128
|
+
else
|
|
1129
|
+
kept.push({ id: kid.id, from: b });
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
walk(root);
|
|
1133
|
+
members.push(...chunk);
|
|
1134
|
+
if (kept.length > 0)
|
|
1135
|
+
promoting.add(root);
|
|
1136
|
+
// A layout shows only its items: a deleted item's blocks show right
|
|
1137
|
+
// after the layout (`layout.only-items`), settled for its parent.
|
|
1138
|
+
const item = pos.parent === null ? undefined : itemKindOf(pos.parent);
|
|
1139
|
+
const out = (k) => item !== undefined && blockTypeOf(k.id) !== item;
|
|
1140
|
+
return [
|
|
1141
|
+
...kept.flatMap((k) => settle(k.from, [k.id], out(k) ? positionOf(pos.parent).parent : pos.parent)),
|
|
1142
|
+
deleting(root, chunk)
|
|
1143
|
+
];
|
|
1144
|
+
});
|
|
1145
|
+
return plan(roots, emptyingAll(roots.filter((r) => !promoting.has(r)).map((id) => positionOf(id).parent), roots.filter((r) => !promoting.has(r)), writes, filled === undefined ? undefined : new Set([filled]), members));
|
|
1146
|
+
};
|
|
1147
|
+
/** Delete `id` — its children take its slot (`keepChildren: false`: the whole subtree). */
|
|
1148
|
+
const deleteBlock = (id, opts = {}) => deleteBlocks([id], opts.keepChildren === false);
|
|
1149
|
+
// ── meta: a block's type (`setBlockType`, the steps a retype implies),
|
|
1150
|
+
// its data, a block's, an atom's or the document's (`patchData`, the
|
|
1151
|
+
// data steps of a patch), and the baseline `setBlock` ──
|
|
1152
|
+
/**
|
|
1153
|
+
* The data step of `patches` (sanitized) on a block, one of its atoms or
|
|
1154
|
+
* the document (`crdt/data.ts`): none when it changes nothing, `null`
|
|
1155
|
+
* when a patch is refused.
|
|
1156
|
+
*/
|
|
1157
|
+
const dataSteps = (target, patches) => {
|
|
1158
|
+
const [id, inlineId] = typeof target === 'object' && target ? [target.block, target.atom] : [target ?? undefined];
|
|
1159
|
+
const node = dataNode(id, inlineId);
|
|
1160
|
+
// A block kind's atomic paths are written as one leaf each (`data.atomic`).
|
|
1161
|
+
const type = inlineId === undefined && id !== undefined ? blockTypeOf(id) : undefined;
|
|
1162
|
+
const atomic = (type === undefined ? undefined : roleOf(type)?.atomic) ?? [];
|
|
1163
|
+
const writes = node
|
|
1164
|
+
? patchWrites(node, patches, doc.clientID, randOf(doc), atomic.map((p) => leafKey(typeof p === 'string' ? [p] : p)))
|
|
1165
|
+
: [];
|
|
1166
|
+
if (writes === null)
|
|
1167
|
+
return null; // a patch fits no value there
|
|
1168
|
+
if (writes.length === 0)
|
|
1169
|
+
return [];
|
|
1170
|
+
const offset = inlineId === undefined ? undefined : atomOf(id, inlineId)?.at;
|
|
1171
|
+
return [
|
|
1172
|
+
{
|
|
1173
|
+
op: 'patchData',
|
|
1174
|
+
...(id !== undefined && { id }),
|
|
1175
|
+
...(inlineId !== undefined && { inlineId, offset }),
|
|
1176
|
+
ops: patches,
|
|
1177
|
+
writes
|
|
1178
|
+
}
|
|
1179
|
+
];
|
|
1180
|
+
};
|
|
1181
|
+
/** A whole-data replace: a patch of the root. */
|
|
1182
|
+
const replaceData = (data) => [{ path: [], value: sanitizeWireJson(data) }];
|
|
1183
|
+
/**
|
|
1184
|
+
* `data`'s leaves as sets over `current` (`data.retype.keep`): a
|
|
1185
|
+
* plain object's keys, recursively where `current` holds an object
|
|
1186
|
+
* there too; anything else (an array, a primitive, an empty object, an
|
|
1187
|
+
* object where `current` holds none) as one value at its path. Nothing
|
|
1188
|
+
* else under the root is touched.
|
|
1189
|
+
*/
|
|
1190
|
+
const leafSets = (data, current) => {
|
|
1191
|
+
const out = [];
|
|
1192
|
+
const walk = (v, at, path) => {
|
|
1193
|
+
if (isObject(v) && Object.keys(v).length > 0 && (path.length === 0 || isObject(at)))
|
|
1194
|
+
for (const [k, x] of Object.entries(v))
|
|
1195
|
+
walk(x, isObject(at) ? at[k] : undefined, [...path, k]);
|
|
1196
|
+
else if (path.length > 0)
|
|
1197
|
+
out.push({ path, value: v });
|
|
1198
|
+
};
|
|
1199
|
+
walk(sanitizeWireJson(data), current, []);
|
|
1200
|
+
return out;
|
|
1201
|
+
};
|
|
1202
|
+
/**
|
|
1203
|
+
* The target role decides: nothing renders a void's children,
|
|
1204
|
+
* so a block retyped to a void kind hands them to the slot right after
|
|
1205
|
+
* it, as the kind they show there (`settledKind`), in their order (where
|
|
1206
|
+
* the read-time shedding shows a child a peer adds meanwhile). An island
|
|
1207
|
+
* declared `lines` retyped to an ordinary kind keeps its lines, each
|
|
1208
|
+
* retyped to the new kind's default child (the document's where that
|
|
1209
|
+
* renders no content): no line kind outside its island. Any other
|
|
1210
|
+
* island's children keep their kinds (a table's rows stay rows), as
|
|
1211
|
+
* `typeOf` shows a child a peer adds meanwhile.
|
|
1212
|
+
*/
|
|
1213
|
+
const retypeSteps = (id, type) => {
|
|
1214
|
+
const { kids } = view();
|
|
1215
|
+
const pos = positionOf(id);
|
|
1216
|
+
const steps = attr(id, TYPE, type);
|
|
1217
|
+
const moved = kids.get(id) ?? [];
|
|
1218
|
+
// A code block retyped to an ordinary kind keeps its lines as that kind's children
|
|
1219
|
+
// (`leavingIsland`: never a kind that hides their text).
|
|
1220
|
+
const to = roles.of(type);
|
|
1221
|
+
if (isLines(id) && !to.island && !to.childless) {
|
|
1222
|
+
return [
|
|
1223
|
+
...steps,
|
|
1224
|
+
...moved.flatMap((k) => {
|
|
1225
|
+
const kind = leavingIsland(id, blockTypeOf(k.id), to.child);
|
|
1226
|
+
return kind === undefined ? [] : attr(k.id, TYPE, kind);
|
|
1227
|
+
})
|
|
1228
|
+
];
|
|
1229
|
+
}
|
|
1230
|
+
if (!to.childless || moved.length === 0 || pos === null)
|
|
1231
|
+
return steps;
|
|
1232
|
+
// The children keep their home slots: the read shows them in the void's
|
|
1233
|
+
// slot (`conc.void-children`), settled for its parent.
|
|
1234
|
+
return [
|
|
1235
|
+
...steps,
|
|
1236
|
+
...settle(id, moved.map((k) => k.id), pos.parent)
|
|
1237
|
+
];
|
|
1238
|
+
};
|
|
1239
|
+
/** Set the block type (attr write — the block keeps its identity; see `retypeSteps`). */
|
|
1240
|
+
const setBlockType = (id, type) => {
|
|
1241
|
+
id = ref(id);
|
|
1242
|
+
return live(id) ? plan([id], retypeSteps(id, ref(type))) : REFUSED;
|
|
1243
|
+
};
|
|
1244
|
+
/**
|
|
1245
|
+
* Patch the data of a block, one of its inline atoms or the document
|
|
1246
|
+
* (`null`), in order (`crdt/data.ts`): each patch replaces the value at
|
|
1247
|
+
* its path (`value` absent deletes it), `splice` and `order` edit the
|
|
1248
|
+
* array there; a path addresses array items by index, resolved here
|
|
1249
|
+
* to the items' ids. Only the leaves it changes are written, so a
|
|
1250
|
+
* peer's edit of another key or item is kept. Refused for an absent
|
|
1251
|
+
* target, a path that is no array of strings, a root value that is no
|
|
1252
|
+
* object, or an op that fits no value at its path.
|
|
1253
|
+
*/
|
|
1254
|
+
const patchData = (target, patches) => {
|
|
1255
|
+
const valid = (p) => Array.isArray(p?.path) &&
|
|
1256
|
+
p.path.every((k) => typeof k === 'string') &&
|
|
1257
|
+
(p.path.length > 0 || p.value === undefined || isObject(p.value));
|
|
1258
|
+
if (!Array.isArray(patches) || !patches.every(valid))
|
|
1259
|
+
return REFUSED;
|
|
1260
|
+
// Only the fields a patch has: an absent one is no non-JSON value to report.
|
|
1261
|
+
const clean = patches.map(({ path, value, splice, order }) => sanitizeWireJson({
|
|
1262
|
+
path,
|
|
1263
|
+
...(value !== undefined && { value }),
|
|
1264
|
+
...(splice !== undefined && { splice }),
|
|
1265
|
+
...(order !== undefined && { order })
|
|
1266
|
+
}));
|
|
1267
|
+
const t = target === null
|
|
1268
|
+
? null
|
|
1269
|
+
: typeof target === 'string'
|
|
1270
|
+
? ref(target)
|
|
1271
|
+
: { block: ref(target.block), atom: ref(target.atom) };
|
|
1272
|
+
if (typeof t === 'string' ? !live(t) : t !== null && atomOf(t.block, t.atom) === undefined)
|
|
1273
|
+
return REFUSED;
|
|
1274
|
+
const steps = dataSteps(t, clean);
|
|
1275
|
+
return steps ? plan(t === null ? [] : [typeof t === 'string' ? t : t.block], steps) : REFUSED;
|
|
1276
|
+
};
|
|
1277
|
+
/**
|
|
1278
|
+
* Baseline `setBlock`: `type` updates the block in place and `data`
|
|
1279
|
+
* sets the leaves it names, removing none (`data.retype.keep`: a
|
|
1280
|
+
* retype keeps the block's properties, as Notion's Turn into does;
|
|
1281
|
+
* `setBlockData` replaces them); `content`/`children` REPLACE
|
|
1282
|
+
* wholesale — explicit replacement is a
|
|
1283
|
+
* new-identity operation; a retype to a void kind without `children`
|
|
1284
|
+
* unnests the current ones (`retypeSteps`). All-or-nothing: children
|
|
1285
|
+
* for a block that is `void` after the write, or a replacement id that
|
|
1286
|
+
* is already taken (a live or deleted
|
|
1287
|
+
* block, the replaced children included, or a duplicate inside the
|
|
1288
|
+
* replacement), refuses before any write — `id-collision` for the
|
|
1289
|
+
* latter. A caller wanting a child back re-creates it with a fresh id.
|
|
1290
|
+
*/
|
|
1291
|
+
const setBlock = (id, value) => {
|
|
1292
|
+
id = ref(id);
|
|
1293
|
+
const content = value.content?.map((item) => sanitizeItem(asContentItem(item)));
|
|
1294
|
+
const children = value.children?.map((child) => sanitizeSpec(asBlockSpec(child)));
|
|
1295
|
+
if (!live(id) || (content !== undefined && !contentTarget(id)))
|
|
1296
|
+
return REFUSED;
|
|
1297
|
+
const type = value.type === undefined ? undefined : ref(value.type);
|
|
1298
|
+
const toVoid = type === undefined ? isVoid(id) : roles.of(type).childless;
|
|
1299
|
+
if (children?.length && toVoid)
|
|
1300
|
+
return REFUSED;
|
|
1301
|
+
if (children !== undefined && collides(doc, children))
|
|
1302
|
+
return refused('id-collision');
|
|
1303
|
+
const data = value.data === undefined ? [] : dataSteps(id, leafSets(value.data, blockDataOf(id)));
|
|
1304
|
+
if (data === null)
|
|
1305
|
+
return REFUSED;
|
|
1306
|
+
const writes = [
|
|
1307
|
+
...(type === undefined
|
|
1308
|
+
? []
|
|
1309
|
+
: children === undefined
|
|
1310
|
+
? retypeSteps(id, type)
|
|
1311
|
+
: attr(id, TYPE, type)),
|
|
1312
|
+
...data
|
|
1313
|
+
];
|
|
1314
|
+
if (content !== undefined) {
|
|
1315
|
+
writes.push(...cutText(id, 0, displayLength(id)));
|
|
1316
|
+
writeContent(writes, id, 0, content);
|
|
1317
|
+
}
|
|
1318
|
+
if (children !== undefined) {
|
|
1319
|
+
for (const kid of childrenIds(id))
|
|
1320
|
+
writes.push(remove(kid));
|
|
1321
|
+
writes.push(...insertAt(id, 0, children));
|
|
1322
|
+
}
|
|
1323
|
+
return plan([id], writes);
|
|
1324
|
+
};
|
|
1325
|
+
// ── content: text and inline atoms in a block's display (allowed inside
|
|
1326
|
+
// voids — the caption contract), and marks over a range ──
|
|
1327
|
+
// content ops (allowed inside voids — caption contract)
|
|
1328
|
+
const insertText = (id, offset, text, marks) => {
|
|
1329
|
+
id = ref(id);
|
|
1330
|
+
const clean = ref(text);
|
|
1331
|
+
if (!contentTarget(id))
|
|
1332
|
+
return REFUSED;
|
|
1333
|
+
const [at] = clamp(id, offset, 0);
|
|
1334
|
+
const m = marks && sanitizeWireJson(marks);
|
|
1335
|
+
return plan([id], clean === '' ? [] : [{ op: 'insertText', id, offset: at, text: clean, marks: m }]);
|
|
1336
|
+
};
|
|
1337
|
+
const deleteText = (id, offset, length) => {
|
|
1338
|
+
id = ref(id);
|
|
1339
|
+
if (!contentTarget(id))
|
|
1340
|
+
return REFUSED;
|
|
1341
|
+
const [at, end] = clamp(id, offset, length);
|
|
1342
|
+
return plan([id], cutText(id, at, end - at));
|
|
1343
|
+
};
|
|
1344
|
+
/**
|
|
1345
|
+
* Multi-mark format over a range (values may be null = unset); planned
|
|
1346
|
+
* only when some text atom in the range carries a different value.
|
|
1347
|
+
*/
|
|
1348
|
+
const formatRange = (id, offset, length, marks) => {
|
|
1349
|
+
id = ref(id);
|
|
1350
|
+
const clean = sanitizeWireJson(marks);
|
|
1351
|
+
if (!contentTarget(id))
|
|
1352
|
+
return REFUSED;
|
|
1353
|
+
const [at, end] = clamp(id, offset, length);
|
|
1354
|
+
// Planned only when some text atom carries another value (the same-value guard).
|
|
1355
|
+
const differs = textIn(id, at, end).some((item) => Object.keys(clean).some((k) => !jsonEquals(item.marks?.[k] ?? null, clean[k] ?? null)));
|
|
1356
|
+
return plan([id], differs ? [{ op: 'formatRange', id, offset: at, length: end - at, marks: clean }] : []);
|
|
1357
|
+
};
|
|
1358
|
+
/**
|
|
1359
|
+
* Baseline `removeMarksFromText`: every mark present anywhere in the
|
|
1360
|
+
* range is unset over the range. Names are discovered from the content.
|
|
1361
|
+
*/
|
|
1362
|
+
const clearMarks = (id, offset, length) => {
|
|
1363
|
+
const clears = {};
|
|
1364
|
+
for (const item of textIn(ref(id), offset, offset + length)) {
|
|
1365
|
+
for (const k of Object.keys(item.marks ?? {}))
|
|
1366
|
+
clears[k] = null;
|
|
1367
|
+
}
|
|
1368
|
+
return formatRange(id, offset, length, clears);
|
|
1369
|
+
};
|
|
1370
|
+
/** An atom without an id gets a fresh one. */
|
|
1371
|
+
const insertInline = (id, offset, atom) => {
|
|
1372
|
+
id = ref(id);
|
|
1373
|
+
const clean = sanitizeInline({ ...atom, id: atom.id ?? newId('i') });
|
|
1374
|
+
if (!contentTarget(id))
|
|
1375
|
+
return REFUSED;
|
|
1376
|
+
return plan([id], [{ op: 'insertInline', id, offset: clamp(id, offset, 0)[0], atom: clean }]);
|
|
1377
|
+
};
|
|
1378
|
+
const removeInline = (id, inlineId) => {
|
|
1379
|
+
id = ref(id);
|
|
1380
|
+
const atom = atomOf(id, ref(inlineId));
|
|
1381
|
+
if (atom === undefined)
|
|
1382
|
+
return REFUSED;
|
|
1383
|
+
return plan([id], [{ op: 'removeInline', id, offset: atom.at, inlineId: ref(inlineId) }]);
|
|
1384
|
+
};
|
|
1385
|
+
// ── range deletion (`deleteRange`, `replaceRange`) ─────────────────
|
|
1386
|
+
// Here `view` is what the view knows (`RangeView`, `FlowView`); the
|
|
1387
|
+
// document's own view is `c.view()`.
|
|
1388
|
+
/**
|
|
1389
|
+
* Range deletion between two positions — one prepared document operation
|
|
1390
|
+
* whose merge is the document's `canMerge`. The rules are the
|
|
1391
|
+
* `del.range.*` rows of `docs/editor-delete-contract.md`:
|
|
1392
|
+
*
|
|
1393
|
+
* - the head keeps `[0, start)`, the tail keeps `[end, len)`, and every
|
|
1394
|
+
* block strictly between them in document order dies;
|
|
1395
|
+
* - the head dies iff its prefix is empty (never for a replacement,
|
|
1396
|
+
* `del.range.replace`, nor when nothing else would be left to hold the
|
|
1397
|
+
* caret, `del.range.whole-doc`: the head is kept, emptied, with its id,
|
|
1398
|
+
* type and data, so peers deleting the same range keep the same block)
|
|
1399
|
+
* and the tail then keeps its id; the tail dies iff
|
|
1400
|
+
* its suffix is empty; otherwise the tail's suffix merges into the head
|
|
1401
|
+
* when `canMerge(tail, head)` allows it (`del.range.island-seal`) — an end
|
|
1402
|
+
* at the tail's start included: the seam between them is deleted;
|
|
1403
|
+
* - what follows the range end survives: the dying tail's children and the
|
|
1404
|
+
* later siblings inside every container the range dies through take the
|
|
1405
|
+
* topmost such container's slot, unless that would cross an island seal —
|
|
1406
|
+
* then those containers stay (`del.range.outside-survives`);
|
|
1407
|
+
* - a container (a list: the document's `isContainer`) never dies because
|
|
1408
|
+
* the range starts before it: it keeps what follows the range end, like
|
|
1409
|
+
* the keys (`del.merge.container`); it dies only when it loses every
|
|
1410
|
+
* child and holds no text of its own — the document's `emptiable`, the
|
|
1411
|
+
* keys' rule (`del.range.empty-container`). An island that renders
|
|
1412
|
+
* no content dies when it loses every child too, except a `lines` island
|
|
1413
|
+
* (a code block) the range lies in: its head is then kept, emptied
|
|
1414
|
+
* (`del.range.island-kept`);
|
|
1415
|
+
* - what a range rescues takes the kind it shows in its new slot (the
|
|
1416
|
+
* document's `settle`: a paragraph rescued into a list is its item);
|
|
1417
|
+
* - blocks the view hides (a closed toggle's body) are not in the range:
|
|
1418
|
+
* they go only with a block that goes, so a surviving head keeps its
|
|
1419
|
+
* hidden children, unless it is kept only to hold the caret; a dying
|
|
1420
|
+
* closed tail's body takes its place, shown (`del.range.hidden-body`);
|
|
1421
|
+
* - a layout the range leaves with one column dissolves, in the same plan
|
|
1422
|
+
* (`layout.dissolving`);
|
|
1423
|
+
* - a range with an end in a table's cell keeps every cell and row: each
|
|
1424
|
+
* cell it crosses loses the text it covers, nothing merges across a
|
|
1425
|
+
* cell's edge, the blocks outside the table between the ends go, a
|
|
1426
|
+
* table wholly between them goes whole, and the caret lands at the start
|
|
1427
|
+
* (`table.range`);
|
|
1428
|
+
* - the plan's `at` is where the caret lands (`del.range.caret`).
|
|
1429
|
+
*
|
|
1430
|
+
* `headSpent`: a whole-doc retry keeps the head, but what it hid goes as if it went.
|
|
1431
|
+
*/
|
|
1432
|
+
const rangeDelete = (keepHead, headSpent = false) => (from, to, view = {}) => {
|
|
1433
|
+
// A position names shown text: a live block that renders its own content.
|
|
1434
|
+
const holds = (id) => contentTarget(id) && rendersContent(id);
|
|
1435
|
+
const pos = (p) => {
|
|
1436
|
+
const block = ref(p.block);
|
|
1437
|
+
const len = holds(block) ? displayLength(block) : -1;
|
|
1438
|
+
return { block, offset: Math.max(0, Math.min(p.offset, len)) };
|
|
1439
|
+
};
|
|
1440
|
+
let [s, e] = [pos(from), pos(to)];
|
|
1441
|
+
if (!holds(s.block) || !holds(e.block))
|
|
1442
|
+
return REFUSED;
|
|
1443
|
+
const { ids, at } = c.view().order;
|
|
1444
|
+
if (at.get(s.block) > at.get(e.block) || (s.block === e.block && s.offset > e.offset))
|
|
1445
|
+
[s, e] = [e, s];
|
|
1446
|
+
const S = s.block;
|
|
1447
|
+
const E = e.block;
|
|
1448
|
+
if (S === E) {
|
|
1449
|
+
return plan([S], cutText(S, s.offset, e.offset - s.offset), s);
|
|
1450
|
+
}
|
|
1451
|
+
const parent = (id) => ancestorsOf(id)[0] ?? null;
|
|
1452
|
+
const chain = ancestorsOf(E);
|
|
1453
|
+
if (isTableCell(S) || isTableCell(E))
|
|
1454
|
+
return inTables(s, e, view);
|
|
1455
|
+
// What the view hides once `removed` go (nothing: as it is now).
|
|
1456
|
+
const hiddenOnce = (removed) => {
|
|
1457
|
+
const set = new Set(removed);
|
|
1458
|
+
return (id) => !!view.hidden?.(id, set);
|
|
1459
|
+
};
|
|
1460
|
+
const headDies = !keepHead && s.offset === 0;
|
|
1461
|
+
// What the view hides is not in the range: it goes only with a block that goes,
|
|
1462
|
+
// a dying head's body included.
|
|
1463
|
+
const hidden = hiddenOnce(headDies || headSpent ? [S] : []);
|
|
1464
|
+
const between = ids.slice(at.get(S) + 1, at.get(E)).filter((id) => !hidden(id));
|
|
1465
|
+
const lenE = displayLength(E);
|
|
1466
|
+
const tailDies = e.offset === lenE;
|
|
1467
|
+
const merges = !headDies && !tailDies && canMerge(E, S);
|
|
1468
|
+
const tailGone = tailDies || merges;
|
|
1469
|
+
// E's ancestors the range starts before die unless the rescue would cross an island.
|
|
1470
|
+
// A container (a list) is not one: it dies only when the range empties it, and
|
|
1471
|
+
// keeps its later items like the keys do (`del.merge.container`).
|
|
1472
|
+
const partial = chain.filter((a) => (a === (headDies ? S : null) || between.includes(a)) && !isContainer(a));
|
|
1473
|
+
const top = partial.at(-1);
|
|
1474
|
+
const sealed = top !== undefined && chain.slice(0, chain.indexOf(top) + 1).some(isIsland);
|
|
1475
|
+
const spared = new Set(sealed ? chain : []);
|
|
1476
|
+
const doomed = new Set(between.filter((id) => !spared.has(id) && !chain.includes(id)));
|
|
1477
|
+
if (!sealed)
|
|
1478
|
+
partial.forEach((a) => doomed.add(a));
|
|
1479
|
+
if (headDies && !spared.has(S))
|
|
1480
|
+
doomed.add(S);
|
|
1481
|
+
if (tailDies)
|
|
1482
|
+
doomed.add(E);
|
|
1483
|
+
// Rescue what follows the range end right after the topmost dying container's slot
|
|
1484
|
+
// (after, not at it: a concurrent delete of S revives a merged E above them).
|
|
1485
|
+
const home = sealed || top === undefined ? (tailGone ? E : undefined) : top;
|
|
1486
|
+
const rescued = home === undefined ? [] : tailGone ? childrenIds(E) : [E];
|
|
1487
|
+
for (let cur = E; home !== undefined && cur !== home; cur = parent(cur)) {
|
|
1488
|
+
const sibs = childrenIds(parent(cur));
|
|
1489
|
+
rescued.push(...sibs.slice(sibs.indexOf(cur) + 1));
|
|
1490
|
+
}
|
|
1491
|
+
const dest = home === undefined || rescued.length === 0 ? null : positionOf(home);
|
|
1492
|
+
// A container (or an island that renders nothing) losing every child dies too.
|
|
1493
|
+
const gone = (id) => doomed.has(id) || (merges && id === E);
|
|
1494
|
+
const dies = (id) => emptiable(id) || (isIsland(id) && !rendersContent(id));
|
|
1495
|
+
const emptied = (id) => {
|
|
1496
|
+
if (id === null || gone(id) || !dies(id) || id === dest?.parent)
|
|
1497
|
+
return;
|
|
1498
|
+
if (!childrenIds(id).every(gone))
|
|
1499
|
+
return;
|
|
1500
|
+
doomed.add(id);
|
|
1501
|
+
emptied(parent(id));
|
|
1502
|
+
};
|
|
1503
|
+
[...doomed, ...(merges ? [E] : [])].forEach((id) => emptied(parent(id)));
|
|
1504
|
+
// A range inside one `lines` island never removes it: the head stays, emptied
|
|
1505
|
+
// (a code block's Mod+A, `del.range.island-kept`).
|
|
1506
|
+
const island = ancestorsOf(S).find(isIsland);
|
|
1507
|
+
const linesIsland = island !== undefined && isLines(island);
|
|
1508
|
+
if (!keepHead && linesIsland && doomed.has(island) && chain.includes(island))
|
|
1509
|
+
return rangeDelete(true, headDies)(from, to, view);
|
|
1510
|
+
const lenS = displayLength(S);
|
|
1511
|
+
const writes = [];
|
|
1512
|
+
if (!doomed.has(S))
|
|
1513
|
+
writes.push(...cutText(S, s.offset, lenS - s.offset));
|
|
1514
|
+
if (!gone(E))
|
|
1515
|
+
writes.push(...cutText(E, 0, e.offset));
|
|
1516
|
+
if (dest !== null) {
|
|
1517
|
+
writes.push(...move(rescued, dest.parent, dest.index + 1));
|
|
1518
|
+
// Each shows the kind of its new slot (an island's line, a list's item leave theirs).
|
|
1519
|
+
for (const id of rescued)
|
|
1520
|
+
writes.push(...settle(parent(id), [id], dest.parent));
|
|
1521
|
+
}
|
|
1522
|
+
if (merges) {
|
|
1523
|
+
// The whole tail merges, then its cut prefix goes: E's own text is never written.
|
|
1524
|
+
writes.push({ op: 'mergeBlocks', from: E, into: S, at: s.offset, length: lenE });
|
|
1525
|
+
writes.push(...cutText(S, s.offset, e.offset));
|
|
1526
|
+
}
|
|
1527
|
+
const kept = [...rescued, ...(merges ? [E] : [])];
|
|
1528
|
+
for (const id of doomed)
|
|
1529
|
+
if (!doomed.has(parent(id)))
|
|
1530
|
+
writes.push(remove(id, kept));
|
|
1531
|
+
// A layout the range leaves with one column dissolves (`layout.dissolving`).
|
|
1532
|
+
writes.push(...dissolving(new Set([...doomed, ...(merges ? [E] : [])]), rescued, writes));
|
|
1533
|
+
const caret = (() => {
|
|
1534
|
+
if (!doomed.has(S))
|
|
1535
|
+
return s;
|
|
1536
|
+
if (!gone(E))
|
|
1537
|
+
return { block: E, offset: 0 };
|
|
1538
|
+
const survives = (id) => id === null || rescued.includes(id) || (!gone(id) && survives(parent(id)));
|
|
1539
|
+
// Shown once the plan runs: a dying closed tail's rescued body is.
|
|
1540
|
+
const hiddenAfter = hiddenOnce([...doomed, ...(merges ? [E] : [])]);
|
|
1541
|
+
const shown = (id) => survives(id) && holds(id) && !hiddenAfter(id);
|
|
1542
|
+
// Before the tail: blocks a sealed rescue spared between S and E count too.
|
|
1543
|
+
const prev = ids.slice(0, at.get(E)).findLast(shown);
|
|
1544
|
+
if (prev !== undefined)
|
|
1545
|
+
return { block: prev, offset: displayLength(prev) };
|
|
1546
|
+
const next = ids.slice(at.get(E) + 1).find(shown);
|
|
1547
|
+
return next === undefined ? null : { block: next, offset: 0 };
|
|
1548
|
+
})();
|
|
1549
|
+
// Nothing else left to hold the caret: the head stays, emptied (whole-doc).
|
|
1550
|
+
if (caret === null)
|
|
1551
|
+
return rangeDelete(true, headDies)(from, to, view);
|
|
1552
|
+
return plan([caret.block], writes, caret);
|
|
1553
|
+
};
|
|
1554
|
+
/**
|
|
1555
|
+
* `table.range`: a range with an end in a cell. Every cell it crosses
|
|
1556
|
+
* loses the text it covers and stays, with its row; the ends keep what
|
|
1557
|
+
* lies outside the range, merging nothing; any other block between the
|
|
1558
|
+
* ends goes (a table wholly between them whole), but the ancestors of
|
|
1559
|
+
* the end, which stay and lose their text (the range covers it: they
|
|
1560
|
+
* come after the start in reading order); the caret lands at the start.
|
|
1561
|
+
*/
|
|
1562
|
+
const inTables = (s, e, view) => {
|
|
1563
|
+
const { ids, at } = c.view().order;
|
|
1564
|
+
const [S, E] = [s.block, e.block];
|
|
1565
|
+
const kept = new Set(ancestorsOf(E));
|
|
1566
|
+
const between = ids.slice(at.get(S) + 1, at.get(E)).filter((id) => !view.hidden?.(id));
|
|
1567
|
+
const writes = [];
|
|
1568
|
+
const lenS = displayLength(S);
|
|
1569
|
+
writes.push(...cutText(S, s.offset, lenS - s.offset));
|
|
1570
|
+
const [tableS, tableE] = [tableOf(S), tableOf(E)];
|
|
1571
|
+
const doomed = new Set();
|
|
1572
|
+
for (const id of between) {
|
|
1573
|
+
const table = tableOf(id);
|
|
1574
|
+
if (kept.has(id) || (table !== null && (table === tableS || table === tableE))) {
|
|
1575
|
+
const len = isTableCell(id) || kept.has(id) ? displayLength(id) : 0;
|
|
1576
|
+
writes.push(...cutText(id, 0, len));
|
|
1577
|
+
}
|
|
1578
|
+
else
|
|
1579
|
+
doomed.add(id);
|
|
1580
|
+
}
|
|
1581
|
+
writes.push(...cutText(E, 0, e.offset));
|
|
1582
|
+
const parentOf = (id) => ancestorsOf(id)[0] ?? null;
|
|
1583
|
+
const roots = [...doomed].filter((id) => !doomed.has(parentOf(id)));
|
|
1584
|
+
for (const id of roots)
|
|
1585
|
+
writes.push(remove(id, []));
|
|
1586
|
+
writes.push(...dissolving(new Set(roots), [], writes));
|
|
1587
|
+
return plan([S], writes, s);
|
|
1588
|
+
};
|
|
1589
|
+
// ── flow placement (`insertFlow`) ────────────────────────────────
|
|
1590
|
+
/** What a kind is in a table: the table, its row or its cell. */
|
|
1591
|
+
const tableKind = (kind) => roles.of(kind).table !== undefined
|
|
1592
|
+
? 'table'
|
|
1593
|
+
: isRowKind(kind)
|
|
1594
|
+
? 'row'
|
|
1595
|
+
: isCellKind(kind)
|
|
1596
|
+
? 'cell'
|
|
1597
|
+
: undefined;
|
|
1598
|
+
/**
|
|
1599
|
+
* Placement of an admitted flow — one prepared document operation for
|
|
1600
|
+
* paste, drop and fragment insertion. The rules are the `flow.*` rows of
|
|
1601
|
+
* `docs/editor-delete-contract.md`:
|
|
1602
|
+
*
|
|
1603
|
+
* - a flow is lines with fresh ids: a kinded block, or an inline run (no
|
|
1604
|
+
* `type`); an empty flow changes nothing (`flow.shape`);
|
|
1605
|
+
* - one line joins the text at the position; an empty block takes a kinded
|
|
1606
|
+
* line's kind (`flow.inline`);
|
|
1607
|
+
* - several lines split the block: the first joins the head, the last is the
|
|
1608
|
+
* tail's block and takes the text after the position and the block's
|
|
1609
|
+
* children, the rest go between (`flow.split`) — children the view
|
|
1610
|
+
* hides (a closed toggle's body) stay with the head, as Enter keeps them;
|
|
1611
|
+
* - a line that joins no text (a list, a code block, a divider: it renders
|
|
1612
|
+
* none of its own, or is a void or an island) is placed as a block, never
|
|
1613
|
+
* joined; the text after the position stays in a shown line of the
|
|
1614
|
+
* block's kind (`flow.apart`), and the caret ends the last pasted
|
|
1615
|
+
* shown line;
|
|
1616
|
+
* - in a code line (a `lines` island) the lines, nested ones included, are
|
|
1617
|
+
* placed as plain lines (`flow.lines`);
|
|
1618
|
+
* - a `whole` flow (a block-selection copy) goes after the block, replacing
|
|
1619
|
+
* it when empty (`flow.whole`); over selected blocks the lines take their
|
|
1620
|
+
* slot (`flow.slot`), and at a slot they are placed with nothing replaced
|
|
1621
|
+
* (`flow.place`: an accepted suggestion), as plain inserts (`insertBlocks`: their order
|
|
1622
|
+
* against a peer's split beside them is not claimed); a void
|
|
1623
|
+
* takes one run (`flow.void`);
|
|
1624
|
+
* - a table's cell is never split: it takes the lines a flow shows, nested
|
|
1625
|
+
* ones and a pasted table's cells included, as one text joined by line
|
|
1626
|
+
* breaks; a cell outside a row (cells copied out of a table) is a line of
|
|
1627
|
+
* text, and a row outside a table its cells (`table.paste`);
|
|
1628
|
+
* - a plain line that lands directly in a container takes its item kind
|
|
1629
|
+
* (the document's `fitted`: a pasted paragraph in a list is its item),
|
|
1630
|
+
* and so does a line of the list's flat item kind (the view's
|
|
1631
|
+
* `itemKind`: a pasted numbered item in an `ordered-list`); any
|
|
1632
|
+
* other kind keeps its kind and data (a pasted image stays an image, a
|
|
1633
|
+
* bulleted item in an `ordered-list` a bulleted item).
|
|
1634
|
+
*/
|
|
1635
|
+
const insertFlow = (target, flow, view = {}) => {
|
|
1636
|
+
// Ingress: a run carries a placeholder kind through the spec sanitizer.
|
|
1637
|
+
let lines = flow.lines
|
|
1638
|
+
.map((l) => sanitizeSpec({ ...l, type: l.type ?? '' }))
|
|
1639
|
+
.map((s) => ({ ...s, type: s.type || undefined }));
|
|
1640
|
+
if (lines.length === 0)
|
|
1641
|
+
return plan([], []);
|
|
1642
|
+
// A cell outside a row is its text, a row outside a table its cells (`table.paste`).
|
|
1643
|
+
const unbare = (l, holder) => {
|
|
1644
|
+
const kind = l.type ? tableKind(l.type) : undefined;
|
|
1645
|
+
const kids = (next) => (l.children ?? []).flatMap((kid) => unbare(kid, next));
|
|
1646
|
+
if (kind === 'cell' && holder !== 'row')
|
|
1647
|
+
return [{ id: l.id, content: l.content }, ...kids(null)];
|
|
1648
|
+
if (kind === 'row' && holder !== 'table')
|
|
1649
|
+
return kids(null);
|
|
1650
|
+
if (!l.children)
|
|
1651
|
+
return [l];
|
|
1652
|
+
return [{ ...l, children: kids(kind === 'cell' ? null : (kind ?? null)) }];
|
|
1653
|
+
};
|
|
1654
|
+
lines = lines.flatMap((l) => unbare(l, null));
|
|
1655
|
+
/** A line's kind under `parent`: the list's item for its flat item kind, then `fitted`. */
|
|
1656
|
+
const fit = (parent, kind) => fitted(parent, parent !== null && kind !== undefined && kind === view.itemKind?.(parent)
|
|
1657
|
+
? defaultChild(parent)
|
|
1658
|
+
: kind);
|
|
1659
|
+
const specs = (parent, of = lines) => of.map((l) => ({ ...l, type: fit(parent, l.type) ?? defaultChild(parent) }));
|
|
1660
|
+
const last = () => lines.at(-1);
|
|
1661
|
+
/** `l` and its nested lines as runs, each line that shows its own content one run. */
|
|
1662
|
+
const plain = (l) => [
|
|
1663
|
+
...(!l.type || roles.of(l.type).rendersContent ? [{ id: l.id, content: l.content }] : []),
|
|
1664
|
+
...(l.children ?? []).flatMap(plain)
|
|
1665
|
+
];
|
|
1666
|
+
const endOf = placedEnd(roles.of);
|
|
1667
|
+
/**
|
|
1668
|
+
* A layout line, here or nested, as its items' lines in reading order, and
|
|
1669
|
+
* any other child it holds (`flow.layout`): no layout lands in an item.
|
|
1670
|
+
*/
|
|
1671
|
+
const unwrap = (l) => {
|
|
1672
|
+
const item = l.type ? roles.of(l.type).layout : undefined;
|
|
1673
|
+
if (item === undefined)
|
|
1674
|
+
return [l.children ? { ...l, children: l.children.flatMap(unwrap) } : l];
|
|
1675
|
+
return (l.children ?? []).flatMap((kid) => kid.type === item ? (kid.children ?? []).flatMap(unwrap) : unwrap(kid));
|
|
1676
|
+
};
|
|
1677
|
+
const intoItem = (parent) => {
|
|
1678
|
+
if (parent !== null && insideItem(parent))
|
|
1679
|
+
lines = lines.flatMap(unwrap);
|
|
1680
|
+
};
|
|
1681
|
+
/** Whole blocks at a slot, after `pre`; the caret ends the last one's shown content. */
|
|
1682
|
+
const atSlot = (dest, pre) => {
|
|
1683
|
+
const placed = specs(dest.parent);
|
|
1684
|
+
const p = insertBlocks(dest, placed);
|
|
1685
|
+
const at = endOf(placed.at(-1)) ?? { block: last().id, offset: lengthOf(last()) };
|
|
1686
|
+
// The lines are placed while the replaced blocks still show (each step reads the
|
|
1687
|
+
// display as it stands), then those go.
|
|
1688
|
+
return 'writes' in p ? plan(p.ids, [...p.writes, ...pre], at) : p;
|
|
1689
|
+
};
|
|
1690
|
+
if ('replace' in target || 'slot' in target) {
|
|
1691
|
+
let slot;
|
|
1692
|
+
let pre = [];
|
|
1693
|
+
if ('slot' in target)
|
|
1694
|
+
slot = target.slot;
|
|
1695
|
+
else {
|
|
1696
|
+
// The lines fill the first block's slot: its parent is never emptied
|
|
1697
|
+
// by the delete, so a column keeps its layout (`layout.flow-slot`).
|
|
1698
|
+
const at = c.view().order.at;
|
|
1699
|
+
const first = target.replace
|
|
1700
|
+
.filter((id) => at.has(id))
|
|
1701
|
+
.sort((a, b) => at.get(a) - at.get(b))[0];
|
|
1702
|
+
const del = deleteBlocks(target.replace, false, first === undefined ? undefined : positionOf(first)?.parent);
|
|
1703
|
+
if (!('writes' in del))
|
|
1704
|
+
return del;
|
|
1705
|
+
if (first === undefined)
|
|
1706
|
+
return REFUSED;
|
|
1707
|
+
slot = positionOf(first);
|
|
1708
|
+
pre = [...del.writes];
|
|
1709
|
+
}
|
|
1710
|
+
intoItem(slot.parent);
|
|
1711
|
+
if (lines.length === 0)
|
|
1712
|
+
return plan([], []);
|
|
1713
|
+
// In a code block (selected lines, a slot), the lines are plain lines too (`flow.lines`).
|
|
1714
|
+
if (slot.parent !== null && isLines(slot.parent)) {
|
|
1715
|
+
const shown = lines.flatMap(plain);
|
|
1716
|
+
lines = shown.length ? shown : [{ id: lines[0].id, content: [] }];
|
|
1717
|
+
}
|
|
1718
|
+
return atSlot(slot, pre);
|
|
1719
|
+
}
|
|
1720
|
+
const B = ref(target.block);
|
|
1721
|
+
if (!contentTarget(B) || !rendersContent(B))
|
|
1722
|
+
return REFUSED;
|
|
1723
|
+
const len = displayLength(B);
|
|
1724
|
+
const o = Math.max(0, Math.min(target.offset, len));
|
|
1725
|
+
const { parent, index } = positionOf(B);
|
|
1726
|
+
intoItem(B);
|
|
1727
|
+
const empty = len === 0 && childrenIds(B).length === 0;
|
|
1728
|
+
// A line of a lines island (a code line) takes plain lines: every line the flow
|
|
1729
|
+
// shows, nested ones included, in order; no block lands in the island (`flow.lines`).
|
|
1730
|
+
const inLines = parent !== null && isLines(parent);
|
|
1731
|
+
if (inLines)
|
|
1732
|
+
lines = lines.flatMap(plain);
|
|
1733
|
+
// A table's cell is never split: the lines it shows join its text (`table.paste`).
|
|
1734
|
+
const cell = isTableCell(B);
|
|
1735
|
+
if (cell)
|
|
1736
|
+
lines = lines.flatMap(plain);
|
|
1737
|
+
// A line that joins no text under `under` (`flow.apart`): its kind renders
|
|
1738
|
+
// none of its own (a list, a code block), or is a void or an island.
|
|
1739
|
+
const apart = (l, under) => {
|
|
1740
|
+
const kind = l.type && fit(under, l.type);
|
|
1741
|
+
const role = kind ? roles.of(kind) : undefined;
|
|
1742
|
+
return !!role && (!role.rendersContent || role.childless || role.island);
|
|
1743
|
+
};
|
|
1744
|
+
// At the end of a container's header whose body shows, what follows the caret
|
|
1745
|
+
// leads its children and the body stays, as Enter opens a first child
|
|
1746
|
+
// (`flow.header`), unless the first line stands apart and replaces an empty
|
|
1747
|
+
// one (`flow.apart`); elsewhere it follows `B` among its siblings.
|
|
1748
|
+
const header = view.header?.(B);
|
|
1749
|
+
// A closed header shows no children: a line it takes brings none into its hidden
|
|
1750
|
+
// body; they follow it, shown (`flow.header`).
|
|
1751
|
+
const shut = header === 'closed';
|
|
1752
|
+
const inside = o === len && !inLines && !flow.whole && header === true && !(empty && apart(lines[0], B));
|
|
1753
|
+
const home = inside ? B : parent;
|
|
1754
|
+
if (lines.length === 0)
|
|
1755
|
+
return plan([B], [], { block: B, offset: o });
|
|
1756
|
+
if (flow.whole && !inLines && !cell)
|
|
1757
|
+
return atSlot({ parent, index: index + 1 }, empty ? [remove(B, [])] : []);
|
|
1758
|
+
// A void is never split: its caption takes the lines as one run; nor is a cell.
|
|
1759
|
+
const br = { kind: 'text', text: '\n' };
|
|
1760
|
+
if (isVoid(B) || cell) {
|
|
1761
|
+
const content = lines.flatMap((l, i) => [...(i ? [br] : []), ...(l.content ?? [])]);
|
|
1762
|
+
lines = [{ id: lines[0].id, content }];
|
|
1763
|
+
}
|
|
1764
|
+
// So a first line it joins leaves its nested lines as lines of the flow after it.
|
|
1765
|
+
if (shut && lines[0].children?.length && !apart(lines[0], home))
|
|
1766
|
+
lines = [{ ...lines[0], children: undefined }, ...lines[0].children, ...lines.slice(1)];
|
|
1767
|
+
const first = lines[0];
|
|
1768
|
+
const [joinsHead, joinsTail] = [!apart(first, home), lines.length > 1 && !apart(last(), home)];
|
|
1769
|
+
const all = specs(home);
|
|
1770
|
+
const placed = all.slice(joinsHead ? 1 : 0, joinsTail ? -1 : undefined);
|
|
1771
|
+
const ids = placed.map((s) => s.id);
|
|
1772
|
+
if (collides(doc, [...(joinsHead ? (first.children ?? []) : []), ...all.slice(joinsHead ? 1 : 0)]))
|
|
1773
|
+
return REFUSED;
|
|
1774
|
+
const writes = [];
|
|
1775
|
+
/** A joined line's children lead the block it joins; `moved` follow them. */
|
|
1776
|
+
const kids = (to, l, moved = []) => {
|
|
1777
|
+
const lead = l?.children ?? [];
|
|
1778
|
+
writes.push(...insertAt(to, 0, lead));
|
|
1779
|
+
if (moved.length)
|
|
1780
|
+
writes.push({ op: 'moveBlocks', ids: moved, parent: to, index: lead.length });
|
|
1781
|
+
};
|
|
1782
|
+
/**
|
|
1783
|
+
* `l` joins `B` at `at`: its content, its kind when `B` shows no text (a header
|
|
1784
|
+
* keeps its own, as Enter does), its children first.
|
|
1785
|
+
*/
|
|
1786
|
+
const join = (l, at) => {
|
|
1787
|
+
const end = writeContent(writes, B, at, l.content);
|
|
1788
|
+
const kind = l.type && fit(parent, l.type);
|
|
1789
|
+
if (len === 0 && !header && kind && kind === l.type)
|
|
1790
|
+
writes.push(...attr(B, TYPE, kind), ...(dataSteps(B, replaceData(l.data ?? {})) ?? []));
|
|
1791
|
+
if (!shut)
|
|
1792
|
+
kids(B, l);
|
|
1793
|
+
return end;
|
|
1794
|
+
};
|
|
1795
|
+
/** The placed lines at `at` under `home` (`split`: the tail of the split they come with). */
|
|
1796
|
+
const place = (at, split) => writes.push(...insertAt(home, at, placed, split));
|
|
1797
|
+
// Under a header, the first line's children, the placed lines and the rest, in order.
|
|
1798
|
+
const k = joinsHead ? (first.children?.length ?? 0) : 0;
|
|
1799
|
+
const slot = inside ? k : index + 1;
|
|
1800
|
+
// Where the placed lines end the caret: after their last shown line (`flow.apart`).
|
|
1801
|
+
const end = placed.length ? endOf(placed.at(-1)) : undefined;
|
|
1802
|
+
// Nothing before the position and the first line stands apart: the lines go before `B`,
|
|
1803
|
+
// which keeps its text and takes a joining last line, or goes when empty and not needed.
|
|
1804
|
+
if (!joinsHead && o === 0 && !inside) {
|
|
1805
|
+
place(index);
|
|
1806
|
+
if (joinsTail) {
|
|
1807
|
+
const at = join(last(), 0);
|
|
1808
|
+
// A closed header's taken line's nested lines follow it, the caret ending them.
|
|
1809
|
+
const after = shut ? specs(parent, last().children ?? []) : [];
|
|
1810
|
+
writes.push(...insertAt(parent, index + placed.length + 1, after));
|
|
1811
|
+
const caret = after.length ? endOf(after.at(-1)) : undefined;
|
|
1812
|
+
const touched = [...ids, B, ...after.map((s) => s.id)];
|
|
1813
|
+
return plan(touched, writes, caret ?? { block: B, offset: at });
|
|
1814
|
+
}
|
|
1815
|
+
if (empty && end)
|
|
1816
|
+
return plan(ids, [...writes, remove(B, [])], end);
|
|
1817
|
+
return plan([...ids, B], writes, end ?? { block: B, offset: 0 });
|
|
1818
|
+
}
|
|
1819
|
+
const head = joinsHead ? join(first, o) : o;
|
|
1820
|
+
if (lines.length === 1 && joinsHead)
|
|
1821
|
+
return plan([B], writes, { block: B, offset: head });
|
|
1822
|
+
const moved = inside ? [] : childrenIds(B).filter((id) => !view.hidden?.(id));
|
|
1823
|
+
// Nothing after the position to keep, and the caret has a line: no split.
|
|
1824
|
+
if (!joinsTail && o === len && moved.length === 0 && end) {
|
|
1825
|
+
place(slot);
|
|
1826
|
+
return plan([B, ...ids], writes, end);
|
|
1827
|
+
}
|
|
1828
|
+
// The rest of `B` stays in a shown line: the last line's block when it joins, else
|
|
1829
|
+
// a line of `B`'s kind, as Enter makes; one made only for the caret (after a
|
|
1830
|
+
// divider at the end) or under a header is a fresh line of its parent's default kind.
|
|
1831
|
+
const T = joinsTail ? last().id : newId('b');
|
|
1832
|
+
const lastType = joinsTail && last().type && fit(home, last().type);
|
|
1833
|
+
const bare = !joinsTail && o === len && moved.length === 0;
|
|
1834
|
+
const tail = lastType
|
|
1835
|
+
? { type: lastType, data: last().data }
|
|
1836
|
+
: bare || inside
|
|
1837
|
+
? { type: defaultChild(home), data: {} }
|
|
1838
|
+
: { type: kindToCopy(B), data: blockDataOf(B) };
|
|
1839
|
+
// After `B` (under a header: after the first line's children); the placed lines go before it.
|
|
1840
|
+
const length = len - o;
|
|
1841
|
+
writes.push({
|
|
1842
|
+
op: 'splitBlock',
|
|
1843
|
+
id: B,
|
|
1844
|
+
offset: head,
|
|
1845
|
+
length,
|
|
1846
|
+
newId: T,
|
|
1847
|
+
tail,
|
|
1848
|
+
parent: home,
|
|
1849
|
+
index: slot
|
|
1850
|
+
});
|
|
1851
|
+
kids(T, joinsTail ? last() : undefined, moved);
|
|
1852
|
+
place(slot, T);
|
|
1853
|
+
const at = joinsTail
|
|
1854
|
+
? { block: T, offset: writeContent(writes, T, 0, last().content) }
|
|
1855
|
+
: (end ?? { block: T, offset: 0 });
|
|
1856
|
+
return plan([B, ...ids, T], writes, at);
|
|
1857
|
+
};
|
|
1858
|
+
return {
|
|
1859
|
+
// what the facade composes
|
|
1860
|
+
plan,
|
|
1861
|
+
canPlace,
|
|
1862
|
+
fits,
|
|
1863
|
+
nestParent,
|
|
1864
|
+
canMerge,
|
|
1865
|
+
defaultChild,
|
|
1866
|
+
landingOf,
|
|
1867
|
+
besideAt,
|
|
1868
|
+
/** Every op of the families, prepared. */
|
|
1869
|
+
prepare: {
|
|
1870
|
+
insertBlocks,
|
|
1871
|
+
moveBlocks,
|
|
1872
|
+
unNestBlocks,
|
|
1873
|
+
unNestBlock,
|
|
1874
|
+
liftOut,
|
|
1875
|
+
duplicateBlock,
|
|
1876
|
+
placeBeside,
|
|
1877
|
+
wrapInLayout,
|
|
1878
|
+
splitBlock,
|
|
1879
|
+
mergeBlocks,
|
|
1880
|
+
mergeBackward,
|
|
1881
|
+
mergeForward,
|
|
1882
|
+
deleteBlocks,
|
|
1883
|
+
deleteBlock,
|
|
1884
|
+
setBlockType,
|
|
1885
|
+
patchData,
|
|
1886
|
+
setBlock,
|
|
1887
|
+
insertText,
|
|
1888
|
+
deleteText,
|
|
1889
|
+
formatRange,
|
|
1890
|
+
clearMarks,
|
|
1891
|
+
insertInline,
|
|
1892
|
+
removeInline,
|
|
1893
|
+
insertFlow,
|
|
1894
|
+
deleteRange: rangeDelete(false),
|
|
1895
|
+
replaceRange: rangeDelete(true)
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
};
|