@jackuait/blok 0.10.7 → 0.10.9
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/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-oWXfRfnM.mjs → blok-DbRn9adY.mjs} +2681 -2238
- package/dist/chunks/{constants-BQ1-lyZI.mjs → constants-C9lsSOXl.mjs} +4 -3
- package/dist/chunks/{core-C942GvJO.mjs → core-B7mxBIHA.mjs} +1 -1
- package/dist/chunks/{engine-javascript-Dd6ViPCH.mjs → engine-javascript-Bmmg8uL9.mjs} +1 -1
- package/dist/chunks/{i18next-loader-CIXsptng.mjs → i18next-loader-453gJdot.mjs} +1 -1
- package/dist/chunks/{tools-MuBQQyZ-.mjs → tools-D0W3_dlA.mjs} +504 -499
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +3 -3
- package/dist/tools.mjs +2 -2
- package/package.json +3 -6
- package/src/components/block/index.ts +36 -0
- package/src/components/blocks.ts +191 -5
- package/src/components/modules/api/blocks.ts +20 -5
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
- package/src/components/modules/blockManager/blockManager.ts +364 -23
- package/src/components/modules/blockManager/hierarchy.ts +164 -8
- package/src/components/modules/blockManager/operations.ts +223 -26
- package/src/components/modules/blockManager/types.ts +13 -1
- package/src/components/modules/blockManager/yjs-sync.ts +48 -3
- package/src/components/modules/drag/DragController.ts +209 -8
- package/src/components/modules/drag/operations/DragOperations.ts +153 -20
- package/src/components/modules/paste/handlers/base.ts +48 -20
- package/src/components/modules/paste/handlers/blok-data-handler.ts +184 -44
- package/src/components/modules/paste/index.ts +20 -0
- package/src/components/modules/renderer.ts +9 -1
- package/src/components/modules/saver.ts +75 -5
- package/src/components/modules/toolbar/index.ts +41 -60
- package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
- package/src/components/modules/yjs/block-observer.ts +87 -23
- package/src/components/modules/yjs/document-store.ts +37 -11
- package/src/components/modules/yjs/index.ts +83 -7
- package/src/components/modules/yjs/types.ts +35 -2
- package/src/components/modules/yjs/undo-history.ts +116 -5
- package/src/components/utils/data-model-transform.ts +247 -35
- package/src/components/utils/hierarchy-invariant.ts +137 -0
- package/src/markdown/markdown-handler.ts +9 -2
- package/src/styles/main.css +5 -0
- package/src/tools/callout/constants.ts +0 -1
- package/src/tools/callout/dom-builder.ts +1 -11
- package/src/tools/callout/index.ts +0 -6
- package/src/tools/header/index.ts +14 -1
- package/src/tools/table/table-operations.ts +9 -4
- package/src/tools/toggle/constants.ts +2 -1
- package/src/tools/toggle/dom-builder.ts +7 -0
- package/src/tools/toggle/index.ts +14 -1
- package/src/tools/toggle/toggle-lifecycle.ts +24 -0
- /package/dist/chunks/{lightweight-i18n-DTYoSr_o.mjs → lightweight-i18n-DSjG0iTr.mjs} +0 -0
- /package/dist/chunks/{objectWithoutProperties-D0XxKB4n.mjs → objectWithoutProperties-Dci1-l7D.mjs} +0 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Block } from '../../block';
|
|
7
7
|
import { DATA_ATTR } from '../../constants/data-attributes';
|
|
8
|
+
import { logLabeled } from '../../utils';
|
|
8
9
|
|
|
9
10
|
import type { BlockRepository } from './repository';
|
|
10
11
|
|
|
@@ -14,28 +15,56 @@ import type { BlockRepository } from './repository';
|
|
|
14
15
|
export class BlockHierarchy {
|
|
15
16
|
private readonly repository: BlockRepository;
|
|
16
17
|
private readonly onParentChanged?: (parentId: string) => void;
|
|
18
|
+
private readonly getIsSyncingFromYjs?: () => boolean;
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* @param repository - BlockRepository for looking up blocks by id
|
|
20
22
|
* @param onParentChanged - optional callback invoked after a block is assigned a non-null parent
|
|
23
|
+
* @param getIsSyncingFromYjs - optional getter that reports whether the editor is
|
|
24
|
+
* currently applying a remote Yjs update. When true, the Layer 7 dangling
|
|
25
|
+
* parent id guard skips the throw and always coerces + logs — remote
|
|
26
|
+
* peers can legitimately deliver a transiently-dangling parent id during
|
|
27
|
+
* conflict resolution, batched undo replay, or initial sync ordering.
|
|
21
28
|
*/
|
|
22
|
-
constructor(
|
|
29
|
+
constructor(
|
|
30
|
+
repository: BlockRepository,
|
|
31
|
+
onParentChanged?: (parentId: string) => void,
|
|
32
|
+
getIsSyncingFromYjs?: () => boolean
|
|
33
|
+
) {
|
|
23
34
|
this.repository = repository;
|
|
24
35
|
this.onParentChanged = onParentChanged;
|
|
36
|
+
this.getIsSyncingFromYjs = getIsSyncingFromYjs;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
/**
|
|
28
40
|
* Returns the depth (nesting level) of a block in the hierarchy.
|
|
29
41
|
* Root-level blocks have depth 0.
|
|
42
|
+
*
|
|
43
|
+
* Fix 4: a `visited` set guards against malformed parent chains that form a
|
|
44
|
+
* cycle (e.g. remote peers that concurrently reparent A→B and B→A converge
|
|
45
|
+
* into A↔B). Without the guard, the recursion blows the stack and takes
|
|
46
|
+
* down the tab.
|
|
30
47
|
* @param block - the block to get depth for
|
|
31
48
|
* @returns {number} - depth level (0 for root, 1 for first level children, etc.)
|
|
32
49
|
*/
|
|
33
50
|
public getBlockDepth(block: Block): number {
|
|
51
|
+
const visited = new Set<string>();
|
|
52
|
+
|
|
53
|
+
if (block.id !== undefined) {
|
|
54
|
+
visited.add(block.id);
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
const calculateDepth = (parentId: string | null, currentDepth: number): number => {
|
|
35
58
|
if (parentId === null) {
|
|
36
59
|
return currentDepth;
|
|
37
60
|
}
|
|
38
61
|
|
|
62
|
+
if (visited.has(parentId)) {
|
|
63
|
+
// Cycle detected — bail to the current depth so we don't blow the stack.
|
|
64
|
+
return currentDepth;
|
|
65
|
+
}
|
|
66
|
+
visited.add(parentId);
|
|
67
|
+
|
|
39
68
|
const parentBlock = this.repository.getBlockById(parentId);
|
|
40
69
|
|
|
41
70
|
if (parentBlock === undefined) {
|
|
@@ -48,12 +77,129 @@ export class BlockHierarchy {
|
|
|
48
77
|
return calculateDepth(block.parentId, 0);
|
|
49
78
|
}
|
|
50
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Walks the target parent chain and returns true if `childId` already
|
|
82
|
+
* appears in it — meaning assigning `child` as a descendant of the target
|
|
83
|
+
* parent would form a cycle.
|
|
84
|
+
*
|
|
85
|
+
* Fix 4 companion guard for {@link setBlockParent}.
|
|
86
|
+
* @param childId - block id being reparented
|
|
87
|
+
* @param targetParentId - prospective new parent id
|
|
88
|
+
* @returns true if the assignment would form a cycle
|
|
89
|
+
*/
|
|
90
|
+
private wouldFormCycle(childId: string, targetParentId: string): boolean {
|
|
91
|
+
const walk = (cursor: string | null, visited: Set<string>): boolean => {
|
|
92
|
+
if (cursor === null) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (cursor === childId) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (visited.has(cursor)) {
|
|
99
|
+
// Pre-existing cycle — still disqualifies the reparent.
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
visited.add(cursor);
|
|
103
|
+
|
|
104
|
+
const parent = this.repository.getBlockById(cursor);
|
|
105
|
+
|
|
106
|
+
if (parent === undefined) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return walk(parent.parentId, visited);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return walk(targetParentId, new Set<string>());
|
|
114
|
+
}
|
|
115
|
+
|
|
51
116
|
/**
|
|
52
117
|
* Sets the parent of a block, updating both the block's parentId and the parent's contentIds.
|
|
53
118
|
* @param block - the block to reparent
|
|
54
119
|
* @param newParentId - the new parent block id, or null for root level
|
|
55
120
|
*/
|
|
56
121
|
public setBlockParent(block: Block, newParentId: string | null): void {
|
|
122
|
+
/**
|
|
123
|
+
* Layer 19: stale-block guard (regression: wrong-block-dropped family).
|
|
124
|
+
*
|
|
125
|
+
* If `block` has been destroyed and is no longer in the repository,
|
|
126
|
+
* `repository.blocks.indexOf(block)` below returns -1. The toggle-DOM
|
|
127
|
+
* anchor logic then runs `allBlocks.slice(0, -1)` — the whole array
|
|
128
|
+
* minus its last element — and silently anchors the stale block's
|
|
129
|
+
* holder at a completely unrelated DOM position. The new-parent
|
|
130
|
+
* branch repeats the same failure with `slice(0)` returning every
|
|
131
|
+
* block. That's the DOM-manipulation analogue of the `splice(-1, …)`
|
|
132
|
+
* root cause behind the original "wrong block dropped" bug.
|
|
133
|
+
*
|
|
134
|
+
* Additionally, without this guard `block.parentId` would be mutated
|
|
135
|
+
* on a destroyed reference and `onParentChanged` would fire with a
|
|
136
|
+
* ghost id, polluting Yjs with writes against a dead block.
|
|
137
|
+
*
|
|
138
|
+
* Bail out cleanly at entry so callers — DragController.handleDrop in
|
|
139
|
+
* particular — get a no-op instead of silent DOM/data corruption.
|
|
140
|
+
*/
|
|
141
|
+
if (this.repository.getBlockIndex(block) === -1) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fix 4: cycle guard.
|
|
147
|
+
*
|
|
148
|
+
* Reject reparents that would form a cycle (e.g. make A a descendant of
|
|
149
|
+
* one of its own descendants). Without this guard, a corrupted remote
|
|
150
|
+
* update can land the editor in a state where getBlockDepth recurses
|
|
151
|
+
* forever, plus any hierarchical save would produce a tree that can
|
|
152
|
+
* never round-trip.
|
|
153
|
+
*/
|
|
154
|
+
if (newParentId !== null && this.wouldFormCycle(block.id, newParentId)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`BlockHierarchy.setBlockParent: refusing to form cycle — assigning ${block.id} to parent ${newParentId} would create a parent/child cycle.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Layer 7: universal chokepoint guard against dangling parentId.
|
|
162
|
+
*
|
|
163
|
+
* Every reparent in the editor — paste, drag, split, duplicate, slash
|
|
164
|
+
* menu, Cmd+D, markdown shortcut, public api — flows through this
|
|
165
|
+
* method. Previously, if the caller passed a parent id that was no
|
|
166
|
+
* longer in the repository, the write silently mutated block.parentId
|
|
167
|
+
* to garbage: getBlockById returned undefined, the new-parent DOM and
|
|
168
|
+
* contentIds branches no-opped, but `block.parentId = newParentId`
|
|
169
|
+
* still ran. The ghost id then survived until Saver's dangling-parent
|
|
170
|
+
* repair (layer 5), by which point the block has already been
|
|
171
|
+
* ejected from any container it was supposed to belong to.
|
|
172
|
+
*
|
|
173
|
+
* Guarding at this chokepoint catches the regression at the point of
|
|
174
|
+
* introduction instead of one save cycle later:
|
|
175
|
+
* - test/dev: throw loudly so the offending caller is fixed before
|
|
176
|
+
* the build ships.
|
|
177
|
+
* - prod: coerce to null + log `error`, matching the saver's graceful
|
|
178
|
+
* repair semantics so end users never see a wedged editor.
|
|
179
|
+
*
|
|
180
|
+
* This is the upstream-most defense in the callout paste ejection
|
|
181
|
+
* bug family (operations.paste title-vs-child, insert transfer, blok
|
|
182
|
+
* data handler contextParent, saver repair, validateHierarchy gate).
|
|
183
|
+
*/
|
|
184
|
+
const parentExists =
|
|
185
|
+
newParentId === null || this.repository.getBlockById(newParentId) !== undefined;
|
|
186
|
+
|
|
187
|
+
if (!parentExists) {
|
|
188
|
+
const env = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined;
|
|
189
|
+
const isSyncingFromYjs = this.getIsSyncingFromYjs?.() === true;
|
|
190
|
+
const message =
|
|
191
|
+
`BlockHierarchy.setBlockParent: dangling parent id "${newParentId}" ` +
|
|
192
|
+
`for block "${block.id}" — parent block is not in the repository.`;
|
|
193
|
+
|
|
194
|
+
if (!isSyncingFromYjs && (env === 'test' || env === 'development')) {
|
|
195
|
+
throw new Error(message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logLabeled(message, 'error');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sanitizedParentId = parentExists ? newParentId : null;
|
|
202
|
+
|
|
57
203
|
const oldParentId = block.parentId;
|
|
58
204
|
|
|
59
205
|
// Remove from old parent's contentIds
|
|
@@ -85,7 +231,7 @@ export class BlockHierarchy {
|
|
|
85
231
|
}
|
|
86
232
|
|
|
87
233
|
// Add to new parent's contentIds
|
|
88
|
-
const newParent =
|
|
234
|
+
const newParent = sanitizedParentId !== null ? this.repository.getBlockById(sanitizedParentId) : undefined;
|
|
89
235
|
const shouldAddToNewParent = newParent !== undefined && !newParent.contentIds.includes(block.id);
|
|
90
236
|
|
|
91
237
|
if (shouldAddToNewParent) {
|
|
@@ -94,19 +240,29 @@ export class BlockHierarchy {
|
|
|
94
240
|
|
|
95
241
|
// Update block's parentId - parentId is a public mutable property on Block
|
|
96
242
|
// eslint-disable-next-line no-param-reassign
|
|
97
|
-
block.parentId =
|
|
243
|
+
block.parentId = sanitizedParentId;
|
|
98
244
|
|
|
99
245
|
// If the new parent's existing children are hidden (toggle is collapsed),
|
|
100
246
|
// hide this newly added child too so Tab navigation skips it.
|
|
101
|
-
|
|
247
|
+
//
|
|
248
|
+
// Fix 5: a previously-empty collapsed container has no existing hidden
|
|
249
|
+
// children to infer state from. Fall back to reading the toggle/header
|
|
250
|
+
// tool's persistent open-state attribute (`data-blok-toggle-open="false"`)
|
|
251
|
+
// on any descendant of the parent holder.
|
|
252
|
+
if (sanitizedParentId !== null && newParent !== undefined) {
|
|
102
253
|
const existingChildren = newParent.contentIds
|
|
103
254
|
.filter(id => id !== block.id)
|
|
104
255
|
.map(id => this.repository.getBlockById(id))
|
|
105
256
|
.filter((b): b is NonNullable<typeof b> => b !== undefined);
|
|
106
257
|
|
|
107
|
-
const
|
|
258
|
+
const parentIsCollapsedFromChildren = existingChildren.length > 0 &&
|
|
108
259
|
existingChildren.every(b => b.holder.classList.contains('hidden'));
|
|
109
260
|
|
|
261
|
+
const parentIsCollapsedFromAttr =
|
|
262
|
+
newParent.holder.querySelector('[data-blok-toggle-open="false"]') !== null;
|
|
263
|
+
|
|
264
|
+
const parentIsCollapsed = parentIsCollapsedFromChildren || parentIsCollapsedFromAttr;
|
|
265
|
+
|
|
110
266
|
if (parentIsCollapsed) {
|
|
111
267
|
block.holder.classList.add('hidden');
|
|
112
268
|
}
|
|
@@ -116,7 +272,7 @@ export class BlockHierarchy {
|
|
|
116
272
|
// honouring the flat-array order so the DOM order matches the logical order.
|
|
117
273
|
// Skip if the holder is already claimed by another nested-blocks container
|
|
118
274
|
// (e.g. a table cell) — moving it would steal it from that container.
|
|
119
|
-
if (
|
|
275
|
+
if (sanitizedParentId !== null && newParent !== undefined && !block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
|
|
120
276
|
const newContainer = newParent.holder.querySelector('[data-blok-toggle-children]');
|
|
121
277
|
if (newContainer) {
|
|
122
278
|
const allBlocks = this.repository.blocks;
|
|
@@ -134,8 +290,8 @@ export class BlockHierarchy {
|
|
|
134
290
|
this.updateBlockIndentation(block);
|
|
135
291
|
|
|
136
292
|
// Notify listener so parent data can be synced (e.g. to Yjs)
|
|
137
|
-
if (
|
|
138
|
-
this.onParentChanged(
|
|
293
|
+
if (sanitizedParentId !== null && this.onParentChanged !== undefined) {
|
|
294
|
+
this.onParentChanged(sanitizedParentId);
|
|
139
295
|
}
|
|
140
296
|
}
|
|
141
297
|
|
|
@@ -221,6 +221,7 @@ export class BlockOperations {
|
|
|
221
221
|
tunes,
|
|
222
222
|
skipYjsSync = false,
|
|
223
223
|
appendToWorkingArea = false,
|
|
224
|
+
forceTopLevel = false,
|
|
224
225
|
} = options;
|
|
225
226
|
|
|
226
227
|
const targetIndex = index ?? this.currentBlockIndex + (replace ? 0 : 1);
|
|
@@ -277,13 +278,36 @@ export class BlockOperations {
|
|
|
277
278
|
throw new Error(`Could not replace Block at index ${targetIndex}. Block not found.`);
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Capture the replaced block's parent link BEFORE it leaves the
|
|
283
|
+
* repository so the new block inherits the same container membership.
|
|
284
|
+
* Without this, a replace-insert inside a callout/toggle/table-cell
|
|
285
|
+
* child drops parentId and the Saver's derive-from-live-parentId
|
|
286
|
+
* fallback then emits the new block as a root sibling — the "callout
|
|
287
|
+
* paste ejection" regression family. Defense-in-depth counterpart to
|
|
288
|
+
* the paste() method's inheritance handling.
|
|
289
|
+
*/
|
|
290
|
+
const replacedParentId = blockToReplace?.parentId ?? null;
|
|
291
|
+
const replacedBlockId = blockToReplace?.id;
|
|
292
|
+
|
|
280
293
|
if (replace && blockToReplace !== undefined) {
|
|
281
294
|
this.blockDidMutated(BlockRemovedMutationType, blockToReplace, {
|
|
282
295
|
index: targetIndex,
|
|
283
296
|
});
|
|
284
297
|
}
|
|
285
298
|
|
|
286
|
-
blocksStore.insert(targetIndex, block, replace, appendToWorkingArea);
|
|
299
|
+
blocksStore.insert(targetIndex, block, replace, appendToWorkingArea, forceTopLevel);
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Transfer the parent link to the new block BEFORE Yjs sync so
|
|
303
|
+
* `addBlock` writes the final parentId in a single shot. Routing
|
|
304
|
+
* through `transferParentLinkToNewBlock` swaps the old id for the new
|
|
305
|
+
* one in the parent's contentIds while preserving its original
|
|
306
|
+
* position, matching the semantics used by `replace()`.
|
|
307
|
+
*/
|
|
308
|
+
if (replace && replacedParentId !== null && replacedBlockId !== undefined) {
|
|
309
|
+
this.transferParentLinkToNewBlock(replacedBlockId, block, replacedParentId);
|
|
310
|
+
}
|
|
287
311
|
|
|
288
312
|
/**
|
|
289
313
|
* Update the raw currentBlockIndex BEFORE firing the mutation event so
|
|
@@ -342,9 +366,18 @@ export class BlockOperations {
|
|
|
342
366
|
* @param needToFocus - If true, updates current Block index
|
|
343
367
|
* @param skipYjsSync - If true, skip syncing to Yjs
|
|
344
368
|
* @param blocksStore - The blocks store to modify
|
|
369
|
+
* @param forceTopLevel - If true, place new block at workingArea root level regardless of
|
|
370
|
+
* whether the predecessor in the flat array is nested. Used by Enter-at-start and
|
|
371
|
+
* Enter-at-end handlers when the current block is top-level.
|
|
345
372
|
* @returns Inserted Block
|
|
346
373
|
*/
|
|
347
|
-
public insertDefaultBlockAtIndex(
|
|
374
|
+
public insertDefaultBlockAtIndex(
|
|
375
|
+
index: number,
|
|
376
|
+
needToFocus = false,
|
|
377
|
+
skipYjsSync = false,
|
|
378
|
+
blocksStore: BlocksStore,
|
|
379
|
+
forceTopLevel = false
|
|
380
|
+
): Block {
|
|
348
381
|
const defaultTool = this.dependencies.config.defaultBlock;
|
|
349
382
|
|
|
350
383
|
if (defaultTool === undefined) {
|
|
@@ -356,6 +389,7 @@ export class BlockOperations {
|
|
|
356
389
|
index,
|
|
357
390
|
needToFocus,
|
|
358
391
|
skipYjsSync,
|
|
392
|
+
forceTopLevel,
|
|
359
393
|
}, blocksStore);
|
|
360
394
|
}
|
|
361
395
|
|
|
@@ -475,6 +509,26 @@ export class BlockOperations {
|
|
|
475
509
|
|
|
476
510
|
const existingData = await block.data;
|
|
477
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Layer 16: stale-source guard (regression: wrong-block-dropped family).
|
|
514
|
+
*
|
|
515
|
+
* `await block.data` is async — during that await, `block` can be removed
|
|
516
|
+
* by a Yjs remote delete, undo/redo, or tool conversion. When that happens
|
|
517
|
+
* `getBlockIndex(block)` returns -1 and `blocksStore.replace(-1, newBlock)`
|
|
518
|
+
* throws `Incorrect index`, aborting the surrounding batch mid-flight and
|
|
519
|
+
* leaving the flat blocks array inconsistent with the DOM — exactly the
|
|
520
|
+
* stale-state condition that lets drag drop an unrelated block.
|
|
521
|
+
*
|
|
522
|
+
* Abort cleanly: return the original block with no mutation or Yjs side
|
|
523
|
+
* effects. Revalidate AFTER the await, not before, so the guard covers
|
|
524
|
+
* the full async gap.
|
|
525
|
+
*/
|
|
526
|
+
const blockIndex = this.repository.getBlockIndex(block);
|
|
527
|
+
|
|
528
|
+
if (blockIndex === -1) {
|
|
529
|
+
return block;
|
|
530
|
+
}
|
|
531
|
+
|
|
478
532
|
const newBlock = this.factory.composeBlock({
|
|
479
533
|
id: block.id,
|
|
480
534
|
tool: block.name,
|
|
@@ -485,8 +539,6 @@ export class BlockOperations {
|
|
|
485
539
|
bindEventsImmediately: true,
|
|
486
540
|
});
|
|
487
541
|
|
|
488
|
-
const blockIndex = this.repository.getBlockIndex(block);
|
|
489
|
-
|
|
490
542
|
blocksStore.replace(blockIndex, newBlock);
|
|
491
543
|
|
|
492
544
|
this.blockDidMutated(BlockChangedMutationType, newBlock, {
|
|
@@ -511,7 +563,50 @@ export class BlockOperations {
|
|
|
511
563
|
}
|
|
512
564
|
|
|
513
565
|
/**
|
|
514
|
-
*
|
|
566
|
+
* Attach `newBlock` to the old parent in place of the old block id,
|
|
567
|
+
* routing through `setBlockParent` so the DOM reparent/hide side effects
|
|
568
|
+
* run, then restoring the original position in the parent's contentIds[].
|
|
569
|
+
*
|
|
570
|
+
* Extracted from `replace()` to keep nesting depth under the lint cap.
|
|
571
|
+
* @param oldBlockId - The id of the block being replaced
|
|
572
|
+
* @param newBlock - The newly composed replacement block
|
|
573
|
+
* @param oldParentId - The parent id to transfer onto `newBlock`
|
|
574
|
+
*/
|
|
575
|
+
private transferParentLinkToNewBlock(oldBlockId: string, newBlock: Block, oldParentId: string): void {
|
|
576
|
+
const parentBlock = this.repository.getBlockById(oldParentId);
|
|
577
|
+
|
|
578
|
+
if (parentBlock === undefined) {
|
|
579
|
+
// Parent already gone from the repository (e.g. race) — fall back to
|
|
580
|
+
// the bare parentId assignment so the new block at least carries the
|
|
581
|
+
// parent link for save/serialization.
|
|
582
|
+
// eslint-disable-next-line no-param-reassign
|
|
583
|
+
newBlock.parentId = oldParentId;
|
|
584
|
+
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const oldPositionInParent = parentBlock.contentIds.indexOf(oldBlockId);
|
|
589
|
+
|
|
590
|
+
this.hierarchy.setBlockParent(newBlock, oldParentId);
|
|
591
|
+
|
|
592
|
+
if (oldPositionInParent < 0) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const withoutStale = parentBlock.contentIds.filter(id => id !== oldBlockId && id !== newBlock.id);
|
|
597
|
+
|
|
598
|
+
withoutStale.splice(oldPositionInParent, 0, newBlock.id);
|
|
599
|
+
parentBlock.contentIds = withoutStale;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Route each child through `BlockHierarchy.setBlockParent` so that DOM
|
|
604
|
+
* reparenting (into the new parent's toggle-children container) and
|
|
605
|
+
* collapsed-state propagation run as a single atomic side effect per child.
|
|
606
|
+
*
|
|
607
|
+
* Direct `childBlock.parentId = ...` mutation is the same architectural bug
|
|
608
|
+
* as the callout paste-ejection family: the parent/content invariant is
|
|
609
|
+
* maintained but the DOM drifts from the logical tree until the next render.
|
|
515
610
|
* @param childIds - Array of child block IDs to reparent
|
|
516
611
|
* @param newParentId - New parent block ID
|
|
517
612
|
*/
|
|
@@ -520,7 +615,7 @@ export class BlockOperations {
|
|
|
520
615
|
const childBlock = this.repository.getBlockById(childId);
|
|
521
616
|
|
|
522
617
|
if (childBlock !== undefined) {
|
|
523
|
-
childBlock
|
|
618
|
+
this.hierarchy.setBlockParent(childBlock, newParentId);
|
|
524
619
|
}
|
|
525
620
|
}
|
|
526
621
|
}
|
|
@@ -534,6 +629,24 @@ export class BlockOperations {
|
|
|
534
629
|
*/
|
|
535
630
|
public replace(block: Block, newTool: string, data: BlockToolData, blocksStore: BlocksStore): Block {
|
|
536
631
|
const blockIndex = this.repository.getBlockIndex(block);
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Layer 16: stale-source guard (regression: wrong-block-dropped family).
|
|
635
|
+
*
|
|
636
|
+
* `convert()` calls this after `await block.save()` — during that await
|
|
637
|
+
* the block can be removed by a Yjs remote delete or undo. A stale source
|
|
638
|
+
* here would drive `YjsManager.addBlock({...}, -1)` and
|
|
639
|
+
* `insert({ index: -1, replace: true })` — both feed negative indices
|
|
640
|
+
* into downstream splice paths that silently corrupt the flat array.
|
|
641
|
+
*
|
|
642
|
+
* Abort cleanly: return the original block with no Yjs or DOM side
|
|
643
|
+
* effects. The caller (conversion dropdown, paste) already tolerates a
|
|
644
|
+
* no-op outcome for a destroyed source.
|
|
645
|
+
*/
|
|
646
|
+
if (blockIndex === -1) {
|
|
647
|
+
return block;
|
|
648
|
+
}
|
|
649
|
+
|
|
537
650
|
const newBlockId = generateBlockId();
|
|
538
651
|
|
|
539
652
|
// Capture hierarchy before replacement
|
|
@@ -560,15 +673,23 @@ export class BlockOperations {
|
|
|
560
673
|
skipYjsSync: true,
|
|
561
674
|
}, blocksStore);
|
|
562
675
|
|
|
563
|
-
// Transfer hierarchy to new block
|
|
676
|
+
// Transfer hierarchy to new block.
|
|
677
|
+
//
|
|
678
|
+
// Route through `BlockHierarchy.setBlockParent` rather than mutating
|
|
679
|
+
// `newBlock.parentId` / `parentBlock.contentIds` directly, so that DOM
|
|
680
|
+
// reparenting (into the parent's toggle-children container) and
|
|
681
|
+
// collapsed-state propagation happen atomically. Direct mutation was the
|
|
682
|
+
// last remaining path that could leave a replaced child inside a
|
|
683
|
+
// callout/toggle rendered at the wrong DOM position until the next full
|
|
684
|
+
// render pass — same architectural shape as the callout paste-ejection
|
|
685
|
+
// bug family.
|
|
686
|
+
//
|
|
687
|
+
// Ordering concern: `setBlockParent` appends the new id to the parent's
|
|
688
|
+
// contentIds[], but `replace()` must preserve the OLD block's position.
|
|
689
|
+
// Capture the old index first, then run setBlockParent, then move the new
|
|
690
|
+
// id back into the captured slot and drop the (now-stale) old id.
|
|
564
691
|
if (oldParentId !== null) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const parentBlock = this.repository.getBlockById(oldParentId);
|
|
568
|
-
|
|
569
|
-
if (parentBlock !== undefined) {
|
|
570
|
-
parentBlock.contentIds = parentBlock.contentIds.map(id => id === block.id ? newBlock.id : id);
|
|
571
|
-
}
|
|
692
|
+
this.transferParentLinkToNewBlock(block.id, newBlock, oldParentId);
|
|
572
693
|
}
|
|
573
694
|
|
|
574
695
|
/**
|
|
@@ -584,7 +705,11 @@ export class BlockOperations {
|
|
|
584
705
|
(newTool === 'header' && (data as { isToggleable?: boolean }).isToggleable === true);
|
|
585
706
|
|
|
586
707
|
if (oldContentIds.length > 0 && !newToolCanHostChildren) {
|
|
587
|
-
// Promote each child to root level, inserting after the new block
|
|
708
|
+
// Promote each child to root level, inserting after the new block.
|
|
709
|
+
// Route through setBlockParent so the child holder is also moved out
|
|
710
|
+
// of the old (now-removed) parent's toggle-children container and any
|
|
711
|
+
// hidden/indentation state is recomputed — same reasoning as
|
|
712
|
+
// `reparentChildren` above.
|
|
588
713
|
const insertAfterIndex = this.repository.getBlockIndex(newBlock);
|
|
589
714
|
|
|
590
715
|
oldContentIds.forEach((childId, offset) => {
|
|
@@ -594,14 +719,17 @@ export class BlockOperations {
|
|
|
594
719
|
return;
|
|
595
720
|
}
|
|
596
721
|
|
|
597
|
-
childBlock
|
|
722
|
+
this.hierarchy.setBlockParent(childBlock, null);
|
|
598
723
|
blocksStore.insert(insertAfterIndex + 1 + offset, childBlock, false, false);
|
|
599
724
|
});
|
|
600
725
|
|
|
601
726
|
newBlock.contentIds = [];
|
|
602
727
|
} else {
|
|
728
|
+
// `reparentChildren` uses setBlockParent, which pushes each child id
|
|
729
|
+
// onto `newBlock.contentIds`. Reset it first so we don't end up with a
|
|
730
|
+
// pre-existing array plus appended ids.
|
|
731
|
+
newBlock.contentIds = [];
|
|
603
732
|
this.reparentChildren(oldContentIds, newBlock.id);
|
|
604
|
-
newBlock.contentIds = oldContentIds;
|
|
605
733
|
}
|
|
606
734
|
|
|
607
735
|
return newBlock;
|
|
@@ -684,15 +812,61 @@ export class BlockOperations {
|
|
|
684
812
|
* @param blocksStore - The blocks store to modify
|
|
685
813
|
*/
|
|
686
814
|
public async mergeBlocks(targetBlock: Block, blockToMerge: Block, blocksStore: BlocksStore): Promise<void> {
|
|
815
|
+
/**
|
|
816
|
+
* Layer 17: stale-source guard (regression: wrong-block-dropped family).
|
|
817
|
+
*
|
|
818
|
+
* `mergeBlocks` awaits `blockToMerge.data` (and `blockToMerge.exportDataAsString`
|
|
819
|
+
* in the conversion path), then re-awaits `targetBlock.data` inside
|
|
820
|
+
* `completeMerge`. During those awaits, either block can be removed by a
|
|
821
|
+
* Yjs remote delete, undo/redo, or a tool-conversion callback. The original
|
|
822
|
+
* code held closure references and used them unguarded after the awaits,
|
|
823
|
+
* which drove:
|
|
824
|
+
* - `YjsManager.transact` + `updateBlockData(targetBlock.id, …)` against a
|
|
825
|
+
* dead target id (silent no-op but still a mutation attempt)
|
|
826
|
+
* - `targetBlock.mergeWith(mergeData).then(…)` on a destroyed Block where
|
|
827
|
+
* `mergeWith` returns undefined → `.then` crash
|
|
828
|
+
* - `removeBlock(blockToMerge, …)` → `Can't find a Block to remove` thrown
|
|
829
|
+
* inside a `void ... .then(...)` chain → unhandled rejection
|
|
830
|
+
* - `currentBlockIndexValue = getBlockIndex(targetBlock)` → -1, corrupting
|
|
831
|
+
* caret state downstream
|
|
832
|
+
*
|
|
833
|
+
* Verify both blocks are still in the store before starting and also before
|
|
834
|
+
* each mutation step so a remote delete during any of the awaits aborts
|
|
835
|
+
* cleanly rather than propagating the stale reference into Yjs and the DOM.
|
|
836
|
+
*/
|
|
837
|
+
if (
|
|
838
|
+
this.repository.getBlockIndex(targetBlock) === -1 ||
|
|
839
|
+
this.repository.getBlockIndex(blockToMerge) === -1
|
|
840
|
+
) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
687
844
|
/**
|
|
688
845
|
* Complete the merge operation with the prepared data
|
|
689
846
|
* Syncs to Yjs atomically, then updates DOM without re-syncing
|
|
690
847
|
*/
|
|
691
848
|
const completeMerge = async (mergeData: BlockToolData): Promise<void> => {
|
|
849
|
+
// Layer 17 re-check: post-await staleness window. Both blocks must still
|
|
850
|
+
// be in the store, otherwise abort before any Yjs/DOM mutation.
|
|
851
|
+
if (
|
|
852
|
+
this.repository.getBlockIndex(targetBlock) === -1 ||
|
|
853
|
+
this.repository.getBlockIndex(blockToMerge) === -1
|
|
854
|
+
) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
692
858
|
// Get current target data to compute merged result for Yjs
|
|
693
859
|
const targetData = await targetBlock.data;
|
|
694
860
|
const mergedData = { ...targetData, ...mergeData };
|
|
695
861
|
|
|
862
|
+
// Layer 17 re-check after the second await.
|
|
863
|
+
if (
|
|
864
|
+
this.repository.getBlockIndex(targetBlock) === -1 ||
|
|
865
|
+
this.repository.getBlockIndex(blockToMerge) === -1
|
|
866
|
+
) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
696
870
|
// Sync to Yjs atomically: update target + remove source as single undo entry
|
|
697
871
|
this.dependencies.YjsManager.transact(() => {
|
|
698
872
|
for (const [key, value] of Object.entries(mergedData)) {
|
|
@@ -957,7 +1131,7 @@ export class BlockOperations {
|
|
|
957
1131
|
this.hierarchy.setBlockParent(newBlock, parentId);
|
|
958
1132
|
|
|
959
1133
|
return newBlock;
|
|
960
|
-
});
|
|
1134
|
+
}, { extendThroughRAF: true });
|
|
961
1135
|
}
|
|
962
1136
|
|
|
963
1137
|
/**
|
|
@@ -1033,11 +1207,27 @@ export class BlockOperations {
|
|
|
1033
1207
|
replace = false,
|
|
1034
1208
|
blocksStore: BlocksStore
|
|
1035
1209
|
): Promise<Block> {
|
|
1036
|
-
// Capture predecessor's parentId BEFORE insert
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1210
|
+
// Capture predecessor's parentId and id BEFORE insert. The predecessor is
|
|
1211
|
+
// the current block — whether we're replacing it in place or inserting
|
|
1212
|
+
// after it, the new block belongs to the same parent. Without this, pasting
|
|
1213
|
+
// into a nested empty block (e.g. a paragraph inside a callout) via the
|
|
1214
|
+
// replace=true path strands the new block as a root sibling once Saver
|
|
1215
|
+
// re-derives content[] from live parentIds.
|
|
1216
|
+
//
|
|
1217
|
+
// Title-vs-child defense: when the caret is in the CONTAINER's own title
|
|
1218
|
+
// input (the header of a toggle/callout) rather than inside one of its
|
|
1219
|
+
// children, the new block should become a CHILD of the container — its
|
|
1220
|
+
// parent must be the container's id, NOT the container's parentId.
|
|
1221
|
+
// Mirrors the `contextParentId` logic in BasePasteHandler.insertPasteData
|
|
1222
|
+
// and BlokDataHandler so all paste entry points agree.
|
|
1223
|
+
const currentBlock = this.currentBlock;
|
|
1224
|
+
const childContainer = currentBlock?.holder?.querySelector('[data-blok-toggle-children]') ?? null;
|
|
1225
|
+
const isInContainerTitle = childContainer !== null &&
|
|
1226
|
+
!childContainer.contains(currentBlock?.currentInput ?? null);
|
|
1227
|
+
const predecessorParentId = isInContainerTitle
|
|
1228
|
+
? (currentBlock?.id ?? null)
|
|
1229
|
+
: (currentBlock?.parentId ?? null);
|
|
1230
|
+
const oldBlockId = replace ? currentBlock?.id : undefined;
|
|
1041
1231
|
|
|
1042
1232
|
// Insert block without syncing to Yjs yet.
|
|
1043
1233
|
// Wrap in atomic operation so that child blocks created during rendered()
|
|
@@ -1066,9 +1256,16 @@ export class BlockOperations {
|
|
|
1066
1256
|
block.refreshToolRootElement();
|
|
1067
1257
|
});
|
|
1068
1258
|
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1071
|
-
|
|
1259
|
+
// Wire the new block into the predecessor's parent BEFORE the Yjs addBlock
|
|
1260
|
+
// call below so Yjs sees the final parentId in one shot. For replace we
|
|
1261
|
+
// route through `transferParentLinkToNewBlock` which swaps the old id for
|
|
1262
|
+
// the new id inside the parent's contentIds while preserving position.
|
|
1263
|
+
if (predecessorParentId !== null) {
|
|
1264
|
+
if (replace && oldBlockId !== undefined) {
|
|
1265
|
+
this.transferParentLinkToNewBlock(oldBlockId, block, predecessorParentId);
|
|
1266
|
+
} else {
|
|
1267
|
+
this.hierarchy.setBlockParent(block, predecessorParentId);
|
|
1268
|
+
}
|
|
1072
1269
|
}
|
|
1073
1270
|
|
|
1074
1271
|
// Sync final state to Yjs as single operation
|
|
@@ -54,6 +54,12 @@ export interface InsertBlockOptions {
|
|
|
54
54
|
skipYjsSync?: boolean;
|
|
55
55
|
/** When true, append block to workingArea instead of positioning relative to adjacent blocks */
|
|
56
56
|
appendToWorkingArea?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* When true, force DOM placement at workingArea root level even if the previous block
|
|
59
|
+
* in the flat array is nested. Prevents the Enter-after-callout regression family where
|
|
60
|
+
* a top-level insertion would otherwise land inside a nested container.
|
|
61
|
+
*/
|
|
62
|
+
forceTopLevel?: boolean;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/**
|
|
@@ -111,7 +117,13 @@ export interface ConvertBlockOptions {
|
|
|
111
117
|
*/
|
|
112
118
|
export type BlocksStore = Blocks & {
|
|
113
119
|
[index: number]: Block | undefined;
|
|
114
|
-
insert(
|
|
120
|
+
insert(
|
|
121
|
+
index: number,
|
|
122
|
+
block: Block,
|
|
123
|
+
replace?: boolean,
|
|
124
|
+
appendToWorkingArea?: boolean,
|
|
125
|
+
forceTopLevel?: boolean
|
|
126
|
+
): void;
|
|
115
127
|
remove(index: number): void;
|
|
116
128
|
move(toIndex: number, fromIndex: number, skipDOM?: boolean, skipMovedHook?: boolean): void;
|
|
117
129
|
};
|