@jackuait/blok 0.10.8 → 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.
Files changed (41) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
  3. package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
  4. package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
  5. package/dist/full.mjs +3 -3
  6. package/dist/react.mjs +2 -2
  7. package/dist/tools.mjs +2 -2
  8. package/package.json +3 -6
  9. package/src/components/block/index.ts +36 -0
  10. package/src/components/blocks.ts +191 -5
  11. package/src/components/modules/api/blocks.ts +6 -4
  12. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
  13. package/src/components/modules/blockManager/blockManager.ts +364 -23
  14. package/src/components/modules/blockManager/hierarchy.ts +164 -8
  15. package/src/components/modules/blockManager/operations.ts +223 -26
  16. package/src/components/modules/blockManager/types.ts +13 -1
  17. package/src/components/modules/blockManager/yjs-sync.ts +48 -3
  18. package/src/components/modules/drag/DragController.ts +209 -8
  19. package/src/components/modules/drag/operations/DragOperations.ts +153 -20
  20. package/src/components/modules/paste/handlers/base.ts +48 -20
  21. package/src/components/modules/paste/handlers/blok-data-handler.ts +93 -45
  22. package/src/components/modules/paste/index.ts +20 -0
  23. package/src/components/modules/saver.ts +75 -5
  24. package/src/components/modules/toolbar/index.ts +41 -60
  25. package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
  26. package/src/components/modules/yjs/block-observer.ts +87 -23
  27. package/src/components/modules/yjs/document-store.ts +37 -11
  28. package/src/components/modules/yjs/index.ts +83 -7
  29. package/src/components/modules/yjs/types.ts +35 -2
  30. package/src/components/modules/yjs/undo-history.ts +116 -5
  31. package/src/components/utils/data-model-transform.ts +81 -7
  32. package/src/components/utils/hierarchy-invariant.ts +137 -0
  33. package/src/styles/main.css +5 -0
  34. package/src/tools/callout/constants.ts +0 -1
  35. package/src/tools/callout/dom-builder.ts +1 -11
  36. package/src/tools/callout/index.ts +0 -6
  37. package/src/tools/header/index.ts +14 -1
  38. package/src/tools/toggle/constants.ts +2 -1
  39. package/src/tools/toggle/dom-builder.ts +7 -0
  40. package/src/tools/toggle/index.ts +14 -1
  41. package/src/tools/toggle/toggle-lifecycle.ts +24 -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
- this.hierarchy = new BlockHierarchy(this.repository, (parentId) => {
329
- if (!this.yjsSync.isSyncingFromYjs) {
330
- this.scheduleParentSync(parentId);
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(index: number, needToFocus = false, skipYjsSync = false): Block {
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
- yblock.set('parentId', newParentId);
873
- } else {
874
- yblock.delete('parentId');
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
- for (const [key, value] of Object.entries(savedData.data)) {
1159
- this.Blok.YjsManager.updateBlockData(block.id, key, value);
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
  }