@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
|
@@ -16,7 +16,9 @@ import { BlockAPI } from '../../block/api';
|
|
|
16
16
|
import { Blocks } from '../../blocks';
|
|
17
17
|
import { DATA_ATTR } from '../../constants';
|
|
18
18
|
import { BlockChanged } from '../../events';
|
|
19
|
-
import { generateBlockId } from '../../utils';
|
|
19
|
+
import { generateBlockId, logLabeled } from '../../utils';
|
|
20
|
+
import { assertHierarchy, validateHierarchy } from '../../utils/hierarchy-invariant';
|
|
21
|
+
import * as Y from 'yjs';
|
|
20
22
|
|
|
21
23
|
// Imported modules
|
|
22
24
|
import { BlockEventBinder } from './event-binder';
|
|
@@ -324,12 +326,20 @@ export class BlockManager extends Module {
|
|
|
324
326
|
this.bindBlockEvents.bind(this)
|
|
325
327
|
);
|
|
326
328
|
|
|
327
|
-
// Initialize hierarchy with callback to sync parent data to Yjs
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
// Initialize hierarchy with callback to sync parent data to Yjs.
|
|
330
|
+
// The third argument exposes `yjsSync.isSyncingFromYjs` lazily (yjsSync is
|
|
331
|
+
// constructed later in this ctor) so the Layer 7 dangling-parentId guard
|
|
332
|
+
// can exempt remote sync paths from throwing — a remote peer may legally
|
|
333
|
+
// deliver a transiently-dangling parent id during conflict resolution.
|
|
334
|
+
this.hierarchy = new BlockHierarchy(
|
|
335
|
+
this.repository,
|
|
336
|
+
(parentId) => {
|
|
337
|
+
if (!this.yjsSync.isSyncingFromYjs) {
|
|
338
|
+
this.scheduleParentSync(parentId);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
() => Boolean(this.yjsSync?.isSyncingFromYjs)
|
|
342
|
+
);
|
|
333
343
|
|
|
334
344
|
// Initialize operations first (before yjsSync) to allow circular dependency resolution
|
|
335
345
|
this.operations = new BlockOperations(
|
|
@@ -490,6 +500,16 @@ export class BlockManager extends Module {
|
|
|
490
500
|
* @param index - index where to insert
|
|
491
501
|
*/
|
|
492
502
|
public insertMany(blocks: Block[], index = 0): void {
|
|
503
|
+
const blockById = new Map<string, Block>();
|
|
504
|
+
|
|
505
|
+
for (const block of blocks) {
|
|
506
|
+
blockById.set(block.id, block);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
this.reconcileChildrenToParents(blocks, blockById);
|
|
510
|
+
this.reconcileParentsToChildren(blocks, blockById);
|
|
511
|
+
this.assertInsertManyHierarchy(blocks);
|
|
512
|
+
|
|
493
513
|
// Load blocks into Yjs BEFORE adding to the store.
|
|
494
514
|
// blocksStore.insertMany() triggers rendered() on each block, which may
|
|
495
515
|
// create nested blocks (e.g., table cell paragraphs) via api.blocks.insert().
|
|
@@ -570,9 +590,14 @@ export class BlockManager extends Module {
|
|
|
570
590
|
* @param {boolean} skipYjsSync - if true, skip syncing to Yjs (caller handles sync separately)
|
|
571
591
|
* @returns {Block} inserted Block
|
|
572
592
|
*/
|
|
573
|
-
public insertDefaultBlockAtIndex(
|
|
593
|
+
public insertDefaultBlockAtIndex(
|
|
594
|
+
index: number,
|
|
595
|
+
needToFocus = false,
|
|
596
|
+
skipYjsSync = false,
|
|
597
|
+
forceTopLevel = false
|
|
598
|
+
): Block {
|
|
574
599
|
this._currentBlockIndex = this.operations.currentBlockIndexValue;
|
|
575
|
-
const result = this.operations.insertDefaultBlockAtIndex(index, needToFocus, skipYjsSync, this.blocksStore);
|
|
600
|
+
const result = this.operations.insertDefaultBlockAtIndex(index, needToFocus, skipYjsSync, this.blocksStore, forceTopLevel);
|
|
576
601
|
this._currentBlockIndex = this.operations.currentBlockIndexValue;
|
|
577
602
|
return result;
|
|
578
603
|
}
|
|
@@ -847,10 +872,25 @@ export class BlockManager extends Module {
|
|
|
847
872
|
|
|
848
873
|
/**
|
|
849
874
|
* Sets the parent of a block, updating both the block's parentId and the parent's contentIds.
|
|
875
|
+
*
|
|
876
|
+
* Fix 1: the Yjs-side contentIds Y.Arrays on the old and new parents must be
|
|
877
|
+
* updated in the SAME transaction as the child's parentId write. Previously,
|
|
878
|
+
* only `yblock.set('parentId', …)` was synced to Yjs, so:
|
|
879
|
+
* - Two concurrent peers reparenting siblings would drift on both parents
|
|
880
|
+
* because neither ever learned the move through CRDT.
|
|
881
|
+
* - Undo snapshots captured the parentId change but left stale contentIds
|
|
882
|
+
* on the old/new parents, so redo could restore a child that no parent
|
|
883
|
+
* actually claimed.
|
|
884
|
+
* The companion writes keep the persistent Yjs store consistent with the
|
|
885
|
+
* in-memory hierarchy at every transaction boundary.
|
|
850
886
|
* @param block - the block to reparent
|
|
851
887
|
* @param newParentId - the new parent block id, or null for root level
|
|
852
888
|
*/
|
|
853
889
|
public setBlockParent(block: Block, newParentId: string | null): void {
|
|
890
|
+
// Capture the old parent id BEFORE hierarchy.setBlockParent mutates it —
|
|
891
|
+
// we need it to remove the child from the old parent's Yjs contentIds.
|
|
892
|
+
const oldParentId = block.parentId;
|
|
893
|
+
|
|
854
894
|
this.hierarchy.setBlockParent(block, newParentId);
|
|
855
895
|
|
|
856
896
|
// Sync the child block's parentId to Yjs so undo/redo can restore the relationship.
|
|
@@ -868,11 +908,142 @@ export class BlockManager extends Module {
|
|
|
868
908
|
return;
|
|
869
909
|
}
|
|
870
910
|
|
|
911
|
+
// Drag-reparent path: when a move group is open (DragController wraps
|
|
912
|
+
// its drop handler in `YjsManager.transactMoves`), route the Yjs write
|
|
913
|
+
// through `transactWithoutCapture` so Y.UndoManager does not record it
|
|
914
|
+
// as a separate stack item. Attach the parent change to the in-flight
|
|
915
|
+
// move entry instead — on undo/redo we rewind both atomically.
|
|
916
|
+
if (this.Blok.YjsManager.isInMoveGroup) {
|
|
917
|
+
this.Blok.YjsManager.transactWithoutCapture(() => {
|
|
918
|
+
if (newParentId !== null) {
|
|
919
|
+
yblock.set('parentId', newParentId);
|
|
920
|
+
} else {
|
|
921
|
+
yblock.delete('parentId');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
this.syncParentContentIdsToYjs(block.id, oldParentId, newParentId);
|
|
925
|
+
});
|
|
926
|
+
this.Blok.YjsManager.recordParentChangeForPendingMove(
|
|
927
|
+
block.id,
|
|
928
|
+
oldParentId,
|
|
929
|
+
newParentId
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Wrap parentId + parent contentIds updates in a single Yjs transaction so
|
|
936
|
+
// they land atomically on remote peers and in the undo stack.
|
|
937
|
+
this.Blok.YjsManager.transact(() => {
|
|
938
|
+
if (newParentId !== null) {
|
|
939
|
+
yblock.set('parentId', newParentId);
|
|
940
|
+
} else {
|
|
941
|
+
yblock.delete('parentId');
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
this.syncParentContentIdsToYjs(block.id, oldParentId, newParentId);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Reparent a block in response to UndoHistory replaying a drag move.
|
|
950
|
+
*
|
|
951
|
+
* The replay path has ALREADY written the new parentId to Yjs under
|
|
952
|
+
* `transactWithoutCapture`. This method exists so UndoHistory has a
|
|
953
|
+
* stable entry point for the in-memory reparent that:
|
|
954
|
+
* - routes through `BlockHierarchy.setBlockParent` so contentIds, DOM
|
|
955
|
+
* placement, and indentation all stay consistent
|
|
956
|
+
* - does NOT re-write Yjs (that would double-emit or re-enter capture)
|
|
957
|
+
* @param block - the block being reparented during move-undo/move-redo
|
|
958
|
+
* @param newParentId - the parent id to restore
|
|
959
|
+
*/
|
|
960
|
+
public reparentFromHistoryReplay(block: Block, newParentId: string | null): void {
|
|
961
|
+
// Run inside withAtomicOperation so `isSyncingFromYjs` is true for the
|
|
962
|
+
// duration of the hierarchy update. This suppresses:
|
|
963
|
+
// - `onParentChanged` → `scheduleParentSync` → a fresh Yjs write that
|
|
964
|
+
// would land on Y.UndoManager (polluting the undo stack)
|
|
965
|
+
// - any DOM mutation observer write-back into Yjs
|
|
966
|
+
this.yjsSync.withAtomicOperation(() => {
|
|
967
|
+
this.hierarchy.setBlockParent(block, newParentId);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Fix 1 helper: update the old and new parents' Yjs `contentIds` Y.Arrays
|
|
973
|
+
* so the persistent store mirrors the in-memory hierarchy after a reparent.
|
|
974
|
+
* Extracted from {@link setBlockParent} to keep block nesting shallow.
|
|
975
|
+
* @param childId - id of the block being reparented
|
|
976
|
+
* @param oldParentId - parent id before the reparent (may be null)
|
|
977
|
+
* @param newParentId - parent id after the reparent (may be null)
|
|
978
|
+
*/
|
|
979
|
+
private syncParentContentIdsToYjs(
|
|
980
|
+
childId: string,
|
|
981
|
+
oldParentId: string | null,
|
|
982
|
+
newParentId: string | null
|
|
983
|
+
): void {
|
|
984
|
+
if (oldParentId !== null && oldParentId !== newParentId) {
|
|
985
|
+
this.removeChildFromParentYContent(oldParentId, childId);
|
|
986
|
+
}
|
|
871
987
|
if (newParentId !== null) {
|
|
872
|
-
|
|
873
|
-
}
|
|
874
|
-
|
|
988
|
+
this.appendChildToParentYContent(newParentId, childId);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Fix 1 helper: remove a child id from a parent block's Yjs `contentIds`
|
|
994
|
+
* Y.Array if it is present. No-op when the parent or its contentIds are
|
|
995
|
+
* missing from Yjs.
|
|
996
|
+
* @param parentId - parent block id
|
|
997
|
+
* @param childId - child block id to remove
|
|
998
|
+
*/
|
|
999
|
+
private removeChildFromParentYContent(parentId: string, childId: string): void {
|
|
1000
|
+
const parentYBlock = this.Blok.YjsManager.getBlockById(parentId);
|
|
1001
|
+
|
|
1002
|
+
if (parentYBlock === undefined) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const content = parentYBlock.get('contentIds');
|
|
1006
|
+
|
|
1007
|
+
if (!(content instanceof Y.Array)) {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const idx = (content.toArray() as string[]).indexOf(childId);
|
|
1011
|
+
|
|
1012
|
+
if (idx !== -1) {
|
|
1013
|
+
content.delete(idx, 1);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Fix 1 helper: append a child id to a parent block's Yjs `contentIds`
|
|
1019
|
+
* Y.Array, creating the Y.Array on the parent yblock if missing. Inserts at
|
|
1020
|
+
* the index the child occupies in the in-memory parent so remote peers see
|
|
1021
|
+
* the same ordering as the local editor.
|
|
1022
|
+
* @param parentId - parent block id
|
|
1023
|
+
* @param childId - child block id to append
|
|
1024
|
+
*/
|
|
1025
|
+
private appendChildToParentYContent(parentId: string, childId: string): void {
|
|
1026
|
+
const parentYBlock = this.Blok.YjsManager.getBlockById(parentId);
|
|
1027
|
+
|
|
1028
|
+
if (parentYBlock === undefined) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const existing = parentYBlock.get('contentIds');
|
|
1032
|
+
const content: Y.Array<string> = existing instanceof Y.Array
|
|
1033
|
+
? (existing as Y.Array<string>)
|
|
1034
|
+
: new Y.Array<string>();
|
|
1035
|
+
|
|
1036
|
+
if (!(existing instanceof Y.Array)) {
|
|
1037
|
+
parentYBlock.set('contentIds', content);
|
|
875
1038
|
}
|
|
1039
|
+
if ((content.toArray()).includes(childId)) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const parentBlock = this.repository.getBlockById(parentId);
|
|
1043
|
+
const memoryIndex = parentBlock !== undefined ? parentBlock.contentIds.indexOf(childId) : -1;
|
|
1044
|
+
const insertAt = memoryIndex === -1 ? content.length : Math.min(memoryIndex, content.length);
|
|
1045
|
+
|
|
1046
|
+
content.insert(insertAt, [childId]);
|
|
876
1047
|
}
|
|
877
1048
|
|
|
878
1049
|
/**
|
|
@@ -1015,6 +1186,24 @@ export class BlockManager extends Module {
|
|
|
1015
1186
|
* Moves the current block up by one position
|
|
1016
1187
|
*/
|
|
1017
1188
|
public moveCurrentBlockUp(): void {
|
|
1189
|
+
/**
|
|
1190
|
+
* Layer 21: block move shortcuts while a drag is in progress.
|
|
1191
|
+
*
|
|
1192
|
+
* Regression: "wrong block dropped" family. Cmd/Ctrl+Shift+ArrowUp routes
|
|
1193
|
+
* through BlockShortcuts → this method → BlockOperations.moveCurrentBlockUp,
|
|
1194
|
+
* which mutates the flat blocks array. If DragController is mid-drag (it
|
|
1195
|
+
* holds live source/target Block references captured on dragstart), the
|
|
1196
|
+
* array reshuffle leaves its stored indices pointing at the wrong rows
|
|
1197
|
+
* and handleDrop silently drops an unrelated block.
|
|
1198
|
+
*
|
|
1199
|
+
* Mirrors the Cmd+Z-during-drag guard (layer 18) and the paste-during-drag
|
|
1200
|
+
* guard (layer 20): swallow the shortcut so the drag completes cleanly,
|
|
1201
|
+
* then the user can retry the move.
|
|
1202
|
+
*/
|
|
1203
|
+
if (this.Blok.DragManager?.isDragging) {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1018
1207
|
this._currentBlockIndex = this.operations.currentBlockIndexValue;
|
|
1019
1208
|
this.operations.moveCurrentBlockUp(this.blocksStore);
|
|
1020
1209
|
this._currentBlockIndex = this.operations.currentBlockIndexValue;
|
|
@@ -1024,6 +1213,11 @@ export class BlockManager extends Module {
|
|
|
1024
1213
|
* Moves the current block down by one position
|
|
1025
1214
|
*/
|
|
1026
1215
|
public moveCurrentBlockDown(): void {
|
|
1216
|
+
// Layer 21: see moveCurrentBlockUp above for rationale.
|
|
1217
|
+
if (this.Blok.DragManager?.isDragging) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1027
1221
|
this._currentBlockIndex = this.operations.currentBlockIndexValue;
|
|
1028
1222
|
this.operations.moveCurrentBlockDown(this.blocksStore);
|
|
1029
1223
|
this._currentBlockIndex = this.operations.currentBlockIndexValue;
|
|
@@ -1099,17 +1293,137 @@ export class BlockManager extends Module {
|
|
|
1099
1293
|
// Also skip if a pointer drag is active — the browser can mutate contenteditable DOM across
|
|
1100
1294
|
// cell boundaries during a drag, and we must not write that corrupted state to Yjs.
|
|
1101
1295
|
if (mutationType === BlockChangedMutationType && !this.yjsSync.isSyncingFromYjs && !this._isPointerDragActive) {
|
|
1102
|
-
// eslint-disable-next-line no-param-reassign
|
|
1103
|
-
block.lastEditedAt = Date.now();
|
|
1104
|
-
// eslint-disable-next-line no-param-reassign
|
|
1105
|
-
block.lastEditedBy = this.config.user?.id ?? null;
|
|
1106
|
-
|
|
1107
1296
|
void this.syncBlockDataToYjs(block);
|
|
1108
1297
|
}
|
|
1109
1298
|
|
|
1110
1299
|
return block;
|
|
1111
1300
|
}
|
|
1112
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* insertMany helper: fills parent.contentIds from child.parentId.
|
|
1304
|
+
*
|
|
1305
|
+
* Hierarchical input JSON may carry `parent` on children without a matching
|
|
1306
|
+
* `content` on the parent (valid hierarchical data, but leaves the parent's
|
|
1307
|
+
* contentIds empty after composeBlock). Downstream code treats
|
|
1308
|
+
* `parent.contentIds` as the authoritative child list, so reconciling here
|
|
1309
|
+
* makes the invariant `child.parentId ⇒ parent.contentIds.includes(child.id)`
|
|
1310
|
+
* hold from the moment blocks enter the editor.
|
|
1311
|
+
*
|
|
1312
|
+
* If a child's parentId points to a block id that is not in the input, the
|
|
1313
|
+
* parentId is cleared — matching the editor's pre-existing permissive
|
|
1314
|
+
* behaviour of dropping dangling cross-references so the subsequent Fix 3
|
|
1315
|
+
* `assertHierarchy` pass can run on a consistent snapshot.
|
|
1316
|
+
* @param blocks - blocks being inserted
|
|
1317
|
+
* @param blockById - id→block lookup built from `blocks`
|
|
1318
|
+
*/
|
|
1319
|
+
private reconcileChildrenToParents(blocks: Block[], blockById: Map<string, Block>): void {
|
|
1320
|
+
for (const block of blocks) {
|
|
1321
|
+
if (block.parentId === null) {
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
const parent = blockById.get(block.parentId);
|
|
1325
|
+
|
|
1326
|
+
if (parent === undefined) {
|
|
1327
|
+
// Dangling parentId: the referenced parent is missing from the input.
|
|
1328
|
+
// Clear the orphan reference so the block becomes root-level instead
|
|
1329
|
+
// of carrying a stale pointer into the editor state.
|
|
1330
|
+
block.parentId = null;
|
|
1331
|
+
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (!parent.contentIds.includes(block.id)) {
|
|
1335
|
+
parent.contentIds.push(block.id);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Fix 2: inverse reconcile — sanitise parent.contentIds against the children.
|
|
1342
|
+
*
|
|
1343
|
+
* The symmetric case: a parent with `content: ['c1']` whose child c1 has no
|
|
1344
|
+
* `parent` field (or points at a different parent). Child is the source of
|
|
1345
|
+
* truth, because the block physically carries the back-pointer downstream.
|
|
1346
|
+
* For every parent→child claim:
|
|
1347
|
+
* - child missing from the input: drop the dangling id from parent.contentIds
|
|
1348
|
+
* - child has no parentId: set child.parentId = parent.id (keep the claim)
|
|
1349
|
+
* - child has a different parentId: trust the child, sanitise the parent
|
|
1350
|
+
* @param blocks - blocks being inserted
|
|
1351
|
+
* @param blockById - id→block lookup built from `blocks`
|
|
1352
|
+
*/
|
|
1353
|
+
private reconcileParentsToChildren(blocks: Block[], blockById: Map<string, Block>): void {
|
|
1354
|
+
for (const block of blocks) {
|
|
1355
|
+
if (block.contentIds.length === 0) {
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
block.contentIds = block.contentIds.filter((childId) =>
|
|
1359
|
+
this.resolveChildForParent(block, childId, blockById)
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Fix 2 helper: decide whether a parent.contentIds entry should be kept.
|
|
1366
|
+
*
|
|
1367
|
+
* Side effect: when a child exists and has no parentId, its parentId is set
|
|
1368
|
+
* to the claiming parent id (keeping the entry in the parent's contentIds).
|
|
1369
|
+
* @param parent - the parent block whose contentIds we are sanitising
|
|
1370
|
+
* @param childId - candidate child id from parent.contentIds
|
|
1371
|
+
* @param blockById - id→block lookup built from the insertMany input
|
|
1372
|
+
* @returns true when the child id should remain in parent.contentIds
|
|
1373
|
+
*/
|
|
1374
|
+
private resolveChildForParent(
|
|
1375
|
+
parent: Block,
|
|
1376
|
+
childId: string,
|
|
1377
|
+
blockById: Map<string, Block>
|
|
1378
|
+
): boolean {
|
|
1379
|
+
const child = blockById.get(childId);
|
|
1380
|
+
|
|
1381
|
+
if (child === undefined) {
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
if (child.parentId === null) {
|
|
1385
|
+
child.parentId = parent.id;
|
|
1386
|
+
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return child.parentId === parent.id;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Fix 3: assert the hierarchy invariant before handing the blocks off to Yjs.
|
|
1395
|
+
*
|
|
1396
|
+
* Matches the saver pattern (`saver.ts:287-295`): in test and development
|
|
1397
|
+
* builds, any residual drift throws loudly so the regression is caught at
|
|
1398
|
+
* the point of introduction; in production we only log, so an edge-case
|
|
1399
|
+
* drift never breaks user loads.
|
|
1400
|
+
* @param blocks - the fully reconciled blocks about to be handed to Yjs
|
|
1401
|
+
*/
|
|
1402
|
+
private assertInsertManyHierarchy(blocks: Block[]): void {
|
|
1403
|
+
const snapshot: OutputBlockData<string, Record<string, unknown>>[] = blocks.map((block) => ({
|
|
1404
|
+
id: block.id,
|
|
1405
|
+
type: block.name,
|
|
1406
|
+
data: {},
|
|
1407
|
+
...(block.parentId !== null && { parent: block.parentId }),
|
|
1408
|
+
...(block.contentIds.length > 0 && { content: block.contentIds }),
|
|
1409
|
+
}));
|
|
1410
|
+
const env = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined;
|
|
1411
|
+
|
|
1412
|
+
if (env === 'test' || env === 'development') {
|
|
1413
|
+
assertHierarchy(snapshot, 'BlockManager.insertMany');
|
|
1414
|
+
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
const violations = validateHierarchy(snapshot);
|
|
1418
|
+
|
|
1419
|
+
if (violations.length === 0) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const summary = violations.map((v) => v.message).join('; ');
|
|
1423
|
+
|
|
1424
|
+
logLabeled(`BlockManager.insertMany produced output with hierarchy drift: ${summary}`, 'error');
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1113
1427
|
/**
|
|
1114
1428
|
* Schedule a deferred sync of a parent block's data to Yjs.
|
|
1115
1429
|
* Uses queueMicrotask to batch multiple parent changes (e.g. when initializing
|
|
@@ -1146,7 +1460,13 @@ export class BlockManager extends Module {
|
|
|
1146
1460
|
}
|
|
1147
1461
|
|
|
1148
1462
|
/**
|
|
1149
|
-
* Sync block data to Yjs after DOM mutation
|
|
1463
|
+
* Sync block data to Yjs after DOM mutation.
|
|
1464
|
+
*
|
|
1465
|
+
* Only writes metadata (lastEditedAt / lastEditedBy) if at least one data field
|
|
1466
|
+
* actually changed. This preserves the invariant "no data change → no Yjs write →
|
|
1467
|
+
* no undo entry." Without this guard, a spurious metadata-only transaction lands
|
|
1468
|
+
* on the Yjs undo stack after every user operation, causing a single CMD+Z to pop
|
|
1469
|
+
* only the metadata entry instead of the actual data change.
|
|
1150
1470
|
*/
|
|
1151
1471
|
private async syncBlockDataToYjs(block: Block): Promise<void> {
|
|
1152
1472
|
const savedData = await block.save();
|
|
@@ -1155,12 +1475,33 @@ export class BlockManager extends Module {
|
|
|
1155
1475
|
return;
|
|
1156
1476
|
}
|
|
1157
1477
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1478
|
+
// Wrap data + metadata writes into a single Yjs transaction. Without this,
|
|
1479
|
+
// each updateBlockData / updateBlockMetadata call opens its own transaction
|
|
1480
|
+
// and fires a stack-item-added event, which runs caret capture that may
|
|
1481
|
+
// trigger stopCapturing() as a side effect — splitting a single logical
|
|
1482
|
+
// save across multiple undo groups (so a single CMD+Z only reverts the
|
|
1483
|
+
// metadata bump instead of the data change).
|
|
1484
|
+
const dataChangedRef = { value: false };
|
|
1485
|
+
|
|
1486
|
+
this.Blok.YjsManager.transact(() => {
|
|
1487
|
+
for (const [key, value] of Object.entries(savedData.data)) {
|
|
1488
|
+
if (this.Blok.YjsManager.updateBlockData(block.id, key, value)) {
|
|
1489
|
+
dataChangedRef.value = true;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (!dataChangedRef.value) {
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Bump edit metadata only when data actually changed, so we don't add
|
|
1498
|
+
// a spurious metadata-only entry to the Yjs undo stack.
|
|
1499
|
+
// eslint-disable-next-line no-param-reassign
|
|
1500
|
+
block.lastEditedAt = Date.now();
|
|
1501
|
+
// eslint-disable-next-line no-param-reassign
|
|
1502
|
+
block.lastEditedBy = this.config.user?.id ?? null;
|
|
1161
1503
|
|
|
1162
|
-
if (block.lastEditedAt !== undefined) {
|
|
1163
1504
|
this.Blok.YjsManager.updateBlockMetadata(block.id, block.lastEditedAt, block.lastEditedBy);
|
|
1164
|
-
}
|
|
1505
|
+
});
|
|
1165
1506
|
}
|
|
1166
1507
|
}
|