@jackuait/blok 0.10.11 → 0.10.12
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-CX_pZ_qq.mjs → blok-Cb7w54t6.mjs} +81 -38
- package/dist/chunks/{constants-lxerM-Xa.mjs → constants-C0aZXxoO.mjs} +1 -1
- package/dist/chunks/{tools-CMNxZqJC.mjs → tools-vS7102lG.mjs} +83 -32
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +1 -1
- package/src/components/modules/api/blocks.ts +9 -4
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +29 -6
- package/src/components/modules/blockManager/blockManager.ts +12 -2
- package/src/components/modules/blockManager/operations.ts +189 -9
- package/src/components/modules/caret.ts +57 -0
- package/src/components/modules/drag/operations/DragOperations.ts +10 -3
- package/src/styles/main.css +5 -0
- package/src/tools/callout/index.ts +39 -4
- package/src/tools/table/index.ts +59 -7
- package/src/tools/table/table-cell-blocks.ts +90 -3
- package/types/api/blocks.d.ts +2 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @classdesc Handles state-changing operations on blocks
|
|
4
4
|
* @module BlockOperations
|
|
5
5
|
*/
|
|
6
|
-
import type { BlockToolData, PasteEvent, SanitizerConfig
|
|
6
|
+
import type { BlockToolData, PasteEvent, SanitizerConfig, BlokConfig, OutputBlockData } from '../../../../types';
|
|
7
7
|
import type { BlockTuneData } from '../../../../types/block-tunes/block-tune-data';
|
|
8
8
|
import type { BlockMutationType } from '../../../../types/events/block';
|
|
9
9
|
import { BlockAddedMutationType } from '../../../../types/events/block/BlockAdded';
|
|
@@ -17,6 +17,7 @@ import type { BlokEventMap } from '../../events';
|
|
|
17
17
|
import { isEmpty, isObject, isString, log, generateBlockId } from '../../utils';
|
|
18
18
|
import { announce } from '../../utils/announcer';
|
|
19
19
|
import { convertStringToBlockData, isBlockConvertable } from '../../utils/blocks';
|
|
20
|
+
import { validateHierarchy } from '../../utils/hierarchy-invariant';
|
|
20
21
|
import type { EventsDispatcher } from '../../utils/events';
|
|
21
22
|
import { sanitizeBlocks, clean, composeSanitizerConfig } from '../../utils/sanitizer';
|
|
22
23
|
import { isInsideTableCell, isRestrictedInTableCell } from '../../../tools/table/table-restrictions';
|
|
@@ -357,6 +358,8 @@ export class BlockOperations {
|
|
|
357
358
|
this.dependencies.YjsManager?.stopCapturing();
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
this.assertHierarchyInvariantInDev('insert');
|
|
362
|
+
|
|
360
363
|
return block;
|
|
361
364
|
}
|
|
362
365
|
|
|
@@ -491,6 +494,8 @@ export class BlockOperations {
|
|
|
491
494
|
this.currentBlockIndexValue = 0;
|
|
492
495
|
}
|
|
493
496
|
|
|
497
|
+
this.assertHierarchyInvariantInDev('removeBlock');
|
|
498
|
+
|
|
494
499
|
resolve();
|
|
495
500
|
});
|
|
496
501
|
}
|
|
@@ -732,6 +737,8 @@ export class BlockOperations {
|
|
|
732
737
|
this.reparentChildren(oldContentIds, newBlock.id);
|
|
733
738
|
}
|
|
734
739
|
|
|
740
|
+
this.assertHierarchyInvariantInDev('replace');
|
|
741
|
+
|
|
735
742
|
return newBlock;
|
|
736
743
|
}
|
|
737
744
|
|
|
@@ -767,6 +774,23 @@ export class BlockOperations {
|
|
|
767
774
|
return;
|
|
768
775
|
}
|
|
769
776
|
|
|
777
|
+
/**
|
|
778
|
+
* Defense-in-depth: capture the destination's parentId BEFORE the flat
|
|
779
|
+
* reorder so we can auto-heal cross-container moves below.
|
|
780
|
+
*
|
|
781
|
+
* `move()` is only a flat-array reorder — it does NOT touch parentId or
|
|
782
|
+
* the source/destination container `contentIds`. Without an auto-heal,
|
|
783
|
+
* any caller that drags or keyboard-shuffles a block past a container
|
|
784
|
+
* boundary leaves `parentId` stale: the block lands visually inside the
|
|
785
|
+
* new container but still claims the old one. That's the exact drift
|
|
786
|
+
* the cross-parent merge guard already blocks at the merge layer; we
|
|
787
|
+
* mirror the defense here so the same bug family can never re-enter
|
|
788
|
+
* via the move pipeline. DragController already calls setBlockParent
|
|
789
|
+
* after move(); the auto-heal below makes that a no-op (idempotent),
|
|
790
|
+
* and rescues every other caller (keyboard moveUp/Down, public api).
|
|
791
|
+
*/
|
|
792
|
+
const destinationParentId = neighborBlock !== undefined ? neighborBlock.parentId : null;
|
|
793
|
+
|
|
770
794
|
// Suppress stopCapturing to keep DOM + Yjs move as single undo entry
|
|
771
795
|
this.suppressStopCapturing = true;
|
|
772
796
|
try {
|
|
@@ -790,6 +814,19 @@ export class BlockOperations {
|
|
|
790
814
|
throw new Error(`Could not move Block. Block at index ${toIndex} is not available.`);
|
|
791
815
|
}
|
|
792
816
|
|
|
817
|
+
/**
|
|
818
|
+
* Cross-container auto-heal — see the comment above destinationParentId.
|
|
819
|
+
*
|
|
820
|
+
* Routes through `setBlockParent`, which is the canonical chokepoint
|
|
821
|
+
* that updates BOTH the moved block's `parentId` AND the source/dest
|
|
822
|
+
* container `contentIds` arrays. Idempotent when the parent already
|
|
823
|
+
* matches, so DragController's existing post-move setBlockParent call
|
|
824
|
+
* remains a safe no-op.
|
|
825
|
+
*/
|
|
826
|
+
if (movedBlock.parentId !== destinationParentId) {
|
|
827
|
+
this.hierarchy.setBlockParent(movedBlock, destinationParentId);
|
|
828
|
+
}
|
|
829
|
+
|
|
793
830
|
/**
|
|
794
831
|
* Force call of didMutated event on Block movement
|
|
795
832
|
*/
|
|
@@ -800,6 +837,8 @@ export class BlockOperations {
|
|
|
800
837
|
|
|
801
838
|
// Sync to Yjs using the actual resolved index
|
|
802
839
|
this.dependencies.YjsManager.moveBlock(movedBlock.id, resolvedIndex);
|
|
840
|
+
|
|
841
|
+
this.assertHierarchyInvariantInDev('move');
|
|
803
842
|
} finally {
|
|
804
843
|
this.suppressStopCapturing = false;
|
|
805
844
|
}
|
|
@@ -841,6 +880,27 @@ export class BlockOperations {
|
|
|
841
880
|
return;
|
|
842
881
|
}
|
|
843
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Defense-in-depth: refuse to merge across container boundaries.
|
|
885
|
+
*
|
|
886
|
+
* Every block belongs to a logical container identified by `parentId`
|
|
887
|
+
* (null = root, or the id of a table/toggle/callout/header/database-row
|
|
888
|
+
* block). Merging across containers silently mangles the hierarchy —
|
|
889
|
+
* the source block's data is appended to a target in a DIFFERENT
|
|
890
|
+
* container, and the source is then deleted, losing content and
|
|
891
|
+
* breaking the invariant that a block lives under exactly one parent.
|
|
892
|
+
*
|
|
893
|
+
* keyboardNavigation already guards Backspace/Delete at cell/toggle
|
|
894
|
+
* boundaries, but a missed guard (or a future composer that forgets
|
|
895
|
+
* the check) must fail safe at this layer instead of corrupting data.
|
|
896
|
+
* This is the root-cause fix for the "Enter-then-Backspace-inside-a-
|
|
897
|
+
* table-cell" bug family — any similar bug in any nested-container
|
|
898
|
+
* tool is prevented here.
|
|
899
|
+
*/
|
|
900
|
+
if (targetBlock.parentId !== blockToMerge.parentId) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
844
904
|
/**
|
|
845
905
|
* Complete the merge operation with the prepared data
|
|
846
906
|
* Syncs to Yjs atomically, then updates DOM without re-syncing
|
|
@@ -1070,6 +1130,8 @@ export class BlockOperations {
|
|
|
1070
1130
|
this.hierarchy.setBlockParent(newBlock, currentBlock.parentId);
|
|
1071
1131
|
}
|
|
1072
1132
|
|
|
1133
|
+
this.assertHierarchyInvariantInDev('splitBlockWithData');
|
|
1134
|
+
|
|
1073
1135
|
return newBlock;
|
|
1074
1136
|
});
|
|
1075
1137
|
}
|
|
@@ -1090,7 +1152,7 @@ export class BlockOperations {
|
|
|
1090
1152
|
* @param blocksStore - The blocks store to modify
|
|
1091
1153
|
* @returns the newly created child block
|
|
1092
1154
|
*/
|
|
1093
|
-
public insertInsideParent(parentId: string, insertIndex: number, blocksStore: BlocksStore): Block {
|
|
1155
|
+
public insertInsideParent(parentId: string, insertIndex: number, blocksStore: BlocksStore, childData?: BlockToolData): Block {
|
|
1094
1156
|
const parentBlock = this.repository.getBlockById(parentId);
|
|
1095
1157
|
|
|
1096
1158
|
if (parentBlock === undefined) {
|
|
@@ -1098,14 +1160,16 @@ export class BlockOperations {
|
|
|
1098
1160
|
}
|
|
1099
1161
|
|
|
1100
1162
|
const newBlockId = generateBlockId();
|
|
1163
|
+
const defaultBlockTool = this.dependencies.config.defaultBlock ?? 'paragraph';
|
|
1164
|
+
const resolvedChildData = childData ?? { text: '' };
|
|
1101
1165
|
|
|
1102
1166
|
return this.yjsSync.withAtomicOperation(() => {
|
|
1103
1167
|
// Atomic Yjs transaction: add new block with parent (single undo entry)
|
|
1104
1168
|
this.dependencies.YjsManager.transact(() => {
|
|
1105
1169
|
this.dependencies.YjsManager.addBlock({
|
|
1106
1170
|
id: newBlockId,
|
|
1107
|
-
type:
|
|
1108
|
-
data:
|
|
1171
|
+
type: defaultBlockTool,
|
|
1172
|
+
data: resolvedChildData,
|
|
1109
1173
|
parent: parentId,
|
|
1110
1174
|
}, insertIndex);
|
|
1111
1175
|
});
|
|
@@ -1113,8 +1177,8 @@ export class BlockOperations {
|
|
|
1113
1177
|
// Insert DOM block (skip Yjs sync — already done above)
|
|
1114
1178
|
const newBlock = this.insert({
|
|
1115
1179
|
id: newBlockId,
|
|
1116
|
-
tool:
|
|
1117
|
-
data:
|
|
1180
|
+
tool: defaultBlockTool,
|
|
1181
|
+
data: resolvedChildData,
|
|
1118
1182
|
index: insertIndex,
|
|
1119
1183
|
needToFocus: false,
|
|
1120
1184
|
skipYjsSync: true,
|
|
@@ -1130,6 +1194,8 @@ export class BlockOperations {
|
|
|
1130
1194
|
// otherwise create a second Yjs undo entry for the toggle data update, splitting undo).
|
|
1131
1195
|
this.hierarchy.setBlockParent(newBlock, parentId);
|
|
1132
1196
|
|
|
1197
|
+
this.assertHierarchyInvariantInDev('insertInsideParent');
|
|
1198
|
+
|
|
1133
1199
|
return newBlock;
|
|
1134
1200
|
}, { extendThroughRAF: true });
|
|
1135
1201
|
}
|
|
@@ -1190,7 +1256,50 @@ export class BlockOperations {
|
|
|
1190
1256
|
? Object.assign(baseBlockData, blockDataOverrides)
|
|
1191
1257
|
: baseBlockData;
|
|
1192
1258
|
|
|
1193
|
-
|
|
1259
|
+
/**
|
|
1260
|
+
* Bracket the whole convert in a single undo group.
|
|
1261
|
+
*
|
|
1262
|
+
* Two things can split a convert across multiple Cmd+Z entries if left
|
|
1263
|
+
* unchecked:
|
|
1264
|
+
*
|
|
1265
|
+
* 1. Container tools (callout) seed a first child paragraph inside their
|
|
1266
|
+
* `rendered()` hook via `api.blocks.insertInsideParent`, which normally
|
|
1267
|
+
* forces a new undo boundary via `stopCapturing()`.
|
|
1268
|
+
*
|
|
1269
|
+
* 2. ANY tool can accept `{text}` on conversion but then populate extra
|
|
1270
|
+
* fields (e.g. toggle's `isOpen: true`) during its first `save()` pass.
|
|
1271
|
+
* That first save is triggered by the MutationObserver watching the
|
|
1272
|
+
* brand-new block's DOM, and its `syncBlockDataToYjs` would write the
|
|
1273
|
+
* extra fields as a *separate* Yjs transaction — creating a phantom
|
|
1274
|
+
* post-convert undo entry so Cmd+Z needs two presses.
|
|
1275
|
+
*
|
|
1276
|
+
* We solve (1) with `suppressStopCapturing` (no new undo boundary) and
|
|
1277
|
+
* (2) with `yjsSync.withAtomicOperation({ extendThroughRAF: true })` which
|
|
1278
|
+
* keeps `isSyncingFromYjs = true` through the next animation frame, so
|
|
1279
|
+
* mutation-triggered `syncBlockDataToYjs` calls are suppressed for
|
|
1280
|
+
* rendered()/first-save writes. The tool's real data persists because
|
|
1281
|
+
* `replace()` already wrote it into Yjs via its own transaction.
|
|
1282
|
+
*/
|
|
1283
|
+
this.dependencies.YjsManager.stopCapturing();
|
|
1284
|
+
const prevSuppress = this.suppressStopCapturing;
|
|
1285
|
+
|
|
1286
|
+
this.suppressStopCapturing = true;
|
|
1287
|
+
|
|
1288
|
+
try {
|
|
1289
|
+
return this.yjsSync.withAtomicOperation(
|
|
1290
|
+
() => this.replace(blockToConvert, replacingTool.name, newBlockData, blocksStore),
|
|
1291
|
+
{ extendThroughRAF: true }
|
|
1292
|
+
);
|
|
1293
|
+
} finally {
|
|
1294
|
+
// Close the undo group after the sync `replace()` and any synchronous
|
|
1295
|
+
// `rendered()` → `insertInsideParent` have landed, but wait one microtask
|
|
1296
|
+
// so DOM MutationObserver-triggered Yjs writes settle inside the same
|
|
1297
|
+
// entry.
|
|
1298
|
+
queueMicrotask(() => {
|
|
1299
|
+
this.suppressStopCapturing = prevSuppress;
|
|
1300
|
+
this.dependencies.YjsManager.stopCapturing();
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1194
1303
|
}
|
|
1195
1304
|
|
|
1196
1305
|
/**
|
|
@@ -1232,6 +1341,16 @@ export class BlockOperations {
|
|
|
1232
1341
|
// Insert block without syncing to Yjs yet.
|
|
1233
1342
|
// Wrap in atomic operation so that child blocks created during rendered()
|
|
1234
1343
|
// (e.g., table cell paragraph blocks) also skip Yjs sync.
|
|
1344
|
+
//
|
|
1345
|
+
// `extendThroughRAF: true` keeps `isSyncingFromYjs` elevated past the end
|
|
1346
|
+
// of this sync closure and through the next animation frame. Without it,
|
|
1347
|
+
// the cleanup runs immediately on return and the subsequent
|
|
1348
|
+
// `await block.ready` → `onPaste` → `addBlock` microtask chain would see
|
|
1349
|
+
// `isSyncingFromYjs === false`. Any MutationObserver-triggered first
|
|
1350
|
+
// `save()` on the freshly rendered block would then land as a separate
|
|
1351
|
+
// Yjs transaction *before* the authoritative `YjsManager.addBlock()` call
|
|
1352
|
+
// below, producing a phantom post-paste undo entry. Mirrors the guard in
|
|
1353
|
+
// `convert()` for the same bug class.
|
|
1235
1354
|
const block = this.yjsSync.withAtomicOperation(() => {
|
|
1236
1355
|
return this.insert({
|
|
1237
1356
|
tool: toolName,
|
|
@@ -1239,7 +1358,7 @@ export class BlockOperations {
|
|
|
1239
1358
|
needToFocus: false,
|
|
1240
1359
|
skipYjsSync: true,
|
|
1241
1360
|
}, blocksStore);
|
|
1242
|
-
});
|
|
1361
|
+
}, { extendThroughRAF: true });
|
|
1243
1362
|
|
|
1244
1363
|
// Update currentBlockIndex AFTER insert (and handleBlockMutation) completes.
|
|
1245
1364
|
this.currentBlockIndex = this.repository.getBlockIndex(block);
|
|
@@ -1251,10 +1370,19 @@ export class BlockOperations {
|
|
|
1251
1370
|
|
|
1252
1371
|
// Call onPaste within atomic operation so child blocks created
|
|
1253
1372
|
// during cell initialization also skip Yjs sync.
|
|
1373
|
+
//
|
|
1374
|
+
// `extendThroughRAF: true` is critical for tools whose `onPaste()`
|
|
1375
|
+
// performs async DOM mutation — e.g. database card drawer dynamic
|
|
1376
|
+
// `import('../../blok')`, code tool shiki/mermaid/katex imports.
|
|
1377
|
+
// Without it, the atomic-op cleanup fires synchronously on return
|
|
1378
|
+
// and the async work lands after `isSyncingFromYjs` flips back to
|
|
1379
|
+
// false, letting MutationObserver-triggered `syncBlockDataToYjs`
|
|
1380
|
+
// calls on the fresh block become a separate Yjs transaction — the
|
|
1381
|
+
// same phantom-undo bug class as the insert-time wrap above.
|
|
1254
1382
|
this.yjsSync.withAtomicOperation(() => {
|
|
1255
1383
|
block.call(BlockToolAPI.ON_PASTE, pasteEvent as unknown as Record<string, unknown>);
|
|
1256
1384
|
block.refreshToolRootElement();
|
|
1257
|
-
});
|
|
1385
|
+
}, { extendThroughRAF: true });
|
|
1258
1386
|
|
|
1259
1387
|
// Wire the new block into the predecessor's parent BEFORE the Yjs addBlock
|
|
1260
1388
|
// call below so Yjs sees the final parentId in one shot. For replace we
|
|
@@ -1358,4 +1486,56 @@ export class BlockOperations {
|
|
|
1358
1486
|
this.dependencies.Caret.setToBlock(block, this.dependencies.Caret.positions.END);
|
|
1359
1487
|
}
|
|
1360
1488
|
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Dev/test invariant gate.
|
|
1492
|
+
*
|
|
1493
|
+
* Validates the parent/contentIds bidirectional invariant against the live
|
|
1494
|
+
* repository. Gated behind NODE_ENV so prod stays free, but every test run
|
|
1495
|
+
* and dev session asserts it after every BlockOperations mutation. Catches
|
|
1496
|
+
* any future regression that would corrupt the hierarchy at the point of
|
|
1497
|
+
* introduction instead of one save cycle later — closing the last gap in
|
|
1498
|
+
* the "callout/table/toggle ejection" bug family by instrumenting the
|
|
1499
|
+
* mutation pipeline itself.
|
|
1500
|
+
*
|
|
1501
|
+
* The save-time gate (saver) and the setBlockParent dangling-id guard are
|
|
1502
|
+
* defenses at the boundaries; this is the defense at the core.
|
|
1503
|
+
*
|
|
1504
|
+
* Filters out the saver-repairable violation kinds (`child-parent-missing`,
|
|
1505
|
+
* `content-id-dangling`) because complex multi-step ops legitimately pass
|
|
1506
|
+
* through transient orphan states between sub-operations. The gate fires
|
|
1507
|
+
* only on the irreversible drift kinds: bidirectional divergence
|
|
1508
|
+
* (`child-not-in-parent-content`, `content-parent-mismatch`) and duplicate
|
|
1509
|
+
* content ids (`content-duplicate`) — the patterns the callout/table/toggle
|
|
1510
|
+
* ejection bug family exhibits.
|
|
1511
|
+
*/
|
|
1512
|
+
private assertHierarchyInvariantInDev(context: string): void {
|
|
1513
|
+
const env = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined;
|
|
1514
|
+
|
|
1515
|
+
if (env !== 'test' && env !== 'development') {
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const blocks: OutputBlockData[] = this.repository.blocks.map(b => ({
|
|
1520
|
+
id: b.id,
|
|
1521
|
+
type: b.name,
|
|
1522
|
+
data: {},
|
|
1523
|
+
...(b.parentId !== null && b.parentId !== undefined ? { parent: b.parentId } : {}),
|
|
1524
|
+
...(Array.isArray(b.contentIds) && b.contentIds.length > 0 ? { content: [...b.contentIds] } : {}),
|
|
1525
|
+
} as OutputBlockData));
|
|
1526
|
+
|
|
1527
|
+
const violations = validateHierarchy(blocks).filter(v =>
|
|
1528
|
+
v.kind === 'child-not-in-parent-content' ||
|
|
1529
|
+
v.kind === 'content-parent-mismatch' ||
|
|
1530
|
+
v.kind === 'content-duplicate'
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
if (violations.length === 0) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const summary = violations.map(v => ` - ${v.message}`).join('\n');
|
|
1538
|
+
|
|
1539
|
+
throw new Error(`Hierarchy invariant violated at BlockOperations.${context}:\n${summary}`);
|
|
1540
|
+
}
|
|
1361
1541
|
}
|
|
@@ -802,6 +802,47 @@ export class Caret extends Module {
|
|
|
802
802
|
return true;
|
|
803
803
|
}
|
|
804
804
|
|
|
805
|
+
/**
|
|
806
|
+
* If both blocks share the same DOM container (e.g., same table cell),
|
|
807
|
+
* navigate directly to the previous block instead of exiting the container.
|
|
808
|
+
* Symmetric to navigateVerticalNext — without this guard, ArrowUp from the
|
|
809
|
+
* first child of a callout/toggle/table cell would silently escape the
|
|
810
|
+
* container even though a sibling block sits right above it in the same
|
|
811
|
+
* DOM container.
|
|
812
|
+
*/
|
|
813
|
+
if (previousBlock !== null && currentBlock.parentId !== null &&
|
|
814
|
+
currentBlock.holder.parentElement !== null &&
|
|
815
|
+
currentBlock.holder.parentElement === previousBlock.holder.parentElement) {
|
|
816
|
+
this.setToBlockAtXPosition(previousBlock, caretX, false);
|
|
817
|
+
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* If current block is inside a container, the "previous block" in the flat
|
|
823
|
+
* array may belong to a DIFFERENT cell of the SAME parent (e.g., a sibling
|
|
824
|
+
* cell of the same table). Navigating to it would jump across cells, which
|
|
825
|
+
* Notion-style navigation should treat as exiting the container UP.
|
|
826
|
+
*/
|
|
827
|
+
const shouldExitParent = currentBlock.parentId !== null && (
|
|
828
|
+
previousBlock === null ||
|
|
829
|
+
previousBlock.parentId === currentBlock.parentId ||
|
|
830
|
+
previousBlock.id === currentBlock.parentId
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
if (shouldExitParent && currentBlock.parentId !== null) {
|
|
834
|
+
const blockBeforeContainer = this.findFirstBlockBeforeParent(currentBlock.parentId);
|
|
835
|
+
|
|
836
|
+
if (blockBeforeContainer !== null) {
|
|
837
|
+
this.setToBlockAtXPosition(blockBeforeContainer, caretX, false);
|
|
838
|
+
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// No block before container — we're at the top of the document.
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
|
|
805
846
|
/**
|
|
806
847
|
* Navigate to previous block, preserving horizontal position
|
|
807
848
|
*/
|
|
@@ -814,6 +855,22 @@ export class Caret extends Module {
|
|
|
814
855
|
return false;
|
|
815
856
|
}
|
|
816
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Find the first block before a parent block (e.g., a table) — the block
|
|
860
|
+
* sitting immediately above the parent in the flat block array.
|
|
861
|
+
*/
|
|
862
|
+
private findFirstBlockBeforeParent(parentBlockId: string): Block | null {
|
|
863
|
+
const { BlockManager } = this.Blok;
|
|
864
|
+
const blocks = BlockManager.blocks;
|
|
865
|
+
const parentIndex = blocks.findIndex(b => b.id === parentBlockId);
|
|
866
|
+
|
|
867
|
+
if (parentIndex <= 0) {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return blocks[parentIndex - 1];
|
|
872
|
+
}
|
|
873
|
+
|
|
817
874
|
/**
|
|
818
875
|
* Find the first block after a parent block (e.g., a table) by scanning the flat
|
|
819
876
|
* block array and skipping blocks whose parentId matches.
|
|
@@ -239,12 +239,19 @@ export class DragOperations {
|
|
|
239
239
|
return { duplicatedBlocks: [], targetIndex: prep.baseInsertIndex };
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
// Insert duplicated blocks
|
|
242
|
+
// Insert duplicated blocks.
|
|
243
|
+
//
|
|
244
|
+
// Deep-clone `saved.data` and `saved.tunes` so the duplicate does not share
|
|
245
|
+
// nested structures with the source. Tools like `table` return arrays from
|
|
246
|
+
// their internal state straight out of `save()`, so a shallow pass would
|
|
247
|
+
// leave the duplicate and the original mutating each other's `content`
|
|
248
|
+
// until the next save cycle — a silent data-corruption class of bug in the
|
|
249
|
+
// same family as the nested-container ejection regressions.
|
|
243
250
|
const duplicatedBlocks = prep.validResults.map(({ saved, toolName }, index) =>
|
|
244
251
|
this.blockManager.insert({
|
|
245
252
|
tool: toolName,
|
|
246
|
-
data: saved.data,
|
|
247
|
-
tunes: saved.tunes,
|
|
253
|
+
data: structuredClone(saved.data),
|
|
254
|
+
tunes: structuredClone(saved.tunes),
|
|
248
255
|
index: prep.baseInsertIndex + index,
|
|
249
256
|
needToFocus: false,
|
|
250
257
|
})
|
package/src/styles/main.css
CHANGED
|
@@ -1458,6 +1458,11 @@
|
|
|
1458
1458
|
@apply bg-search-input-bg rounded-[10px] transition-colors duration-150 w-fit max-w-[240px] px-2;
|
|
1459
1459
|
}
|
|
1460
1460
|
|
|
1461
|
+
[data-blok-table-cell] [data-blok-slash-search],
|
|
1462
|
+
[data-blok-table-cell] [data-blok-slash-search]:focus-visible {
|
|
1463
|
+
@apply rounded-[6px];
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1461
1466
|
[data-blok-slash-search]::after {
|
|
1462
1467
|
content: attr(data-blok-slash-search);
|
|
1463
1468
|
@apply text-gray-text font-medium text-base pointer-events-none;
|
|
@@ -67,10 +67,26 @@ export class CalloutTool implements BlockTool {
|
|
|
67
67
|
private _emojiPicker: EmojiPicker | null = null;
|
|
68
68
|
private _colorPicker: ColorPickerHandle | null = null;
|
|
69
69
|
private blockId?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Text captured from a source block during conversion (paragraph -> callout).
|
|
72
|
+
* Callout stores its rich content inside child blocks rather than in `data`,
|
|
73
|
+
* so the first-time `rendered()` hook seeds a child paragraph with this
|
|
74
|
+
* text — preserving the original content across the conversion.
|
|
75
|
+
*/
|
|
76
|
+
private _pendingChildText: string | null = null;
|
|
70
77
|
|
|
71
78
|
constructor({ data, api, readOnly, block }: BlockToolConstructorOptions<CalloutData, CalloutConfig>) {
|
|
72
79
|
this.api = api;
|
|
73
80
|
this.readOnly = readOnly;
|
|
81
|
+
|
|
82
|
+
const importedText = typeof (data as Record<string, unknown>).__importedText === 'string'
|
|
83
|
+
? (data as Record<string, unknown>).__importedText as string
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
if (importedText !== null && importedText.length > 0) {
|
|
87
|
+
this._pendingChildText = importedText;
|
|
88
|
+
}
|
|
89
|
+
|
|
74
90
|
this._data = this.normalizeData(data);
|
|
75
91
|
|
|
76
92
|
if (block) {
|
|
@@ -156,13 +172,23 @@ export class CalloutTool implements BlockTool {
|
|
|
156
172
|
const blockIndex = this.api.blocks.getBlockIndex(this.blockId);
|
|
157
173
|
|
|
158
174
|
if (blockIndex !== undefined) {
|
|
159
|
-
|
|
175
|
+
// If conversion handed us source text to preserve, seed the first
|
|
176
|
+
// child paragraph with it (single-shot — cleared immediately).
|
|
177
|
+
const seedText = this._pendingChildText;
|
|
178
|
+
|
|
179
|
+
this._pendingChildText = null;
|
|
180
|
+
|
|
181
|
+
const childData = seedText !== null && seedText.length > 0
|
|
182
|
+
? { text: seedText }
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
const newBlock = this.api.blocks.insertInsideParent(this.blockId, blockIndex + 1, childData);
|
|
160
186
|
|
|
161
187
|
// Manually append the new child's holder — insertInsideParent places it in the
|
|
162
188
|
// flat block list but doesn't know about our childContainer DOM.
|
|
163
189
|
this._dom.childContainer.appendChild(newBlock.holder);
|
|
164
190
|
|
|
165
|
-
this.api.caret.setToBlock(newBlock.id, 'start');
|
|
191
|
+
this.api.caret.setToBlock(newBlock.id, seedText !== null ? 'end' : 'start');
|
|
166
192
|
}
|
|
167
193
|
}
|
|
168
194
|
}
|
|
@@ -392,11 +418,20 @@ export class CalloutTool implements BlockTool {
|
|
|
392
418
|
|
|
393
419
|
public static get conversionConfig(): ConversionConfig<CalloutData> {
|
|
394
420
|
return {
|
|
395
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Callout stores its text inside child blocks, not in its own `data`.
|
|
423
|
+
* On import we capture the source block's text through a transient
|
|
424
|
+
* `__importedText` field that the callout constructor reads and the
|
|
425
|
+
* `rendered()` hook uses to seed the first child paragraph with the
|
|
426
|
+
* original content — preserving the text across paragraph -> callout
|
|
427
|
+
* conversion.
|
|
428
|
+
*/
|
|
429
|
+
import: (stringToImport: string): CalloutData => ({
|
|
396
430
|
emoji: DEFAULT_EMOJI,
|
|
397
431
|
textColor: null,
|
|
398
432
|
backgroundColor: null,
|
|
399
|
-
|
|
433
|
+
__importedText: stringToImport,
|
|
434
|
+
} as CalloutData),
|
|
400
435
|
};
|
|
401
436
|
}
|
|
402
437
|
|
package/src/tools/table/index.ts
CHANGED
|
@@ -675,9 +675,10 @@ export class Table implements BlockTool {
|
|
|
675
675
|
// (blocks deleted but matrix not updated); persisting them causes DOM
|
|
676
676
|
// node stealing and data loss on subsequent renders.
|
|
677
677
|
const tableId = this.blockId ?? '';
|
|
678
|
+
const gridEl = this.gridElement;
|
|
678
679
|
|
|
679
|
-
data.content = data.content.map(row =>
|
|
680
|
-
row.map(cell => {
|
|
680
|
+
data.content = data.content.map((row, rowIndex) =>
|
|
681
|
+
row.map((cell, colIndex) => {
|
|
681
682
|
if (!isCellWithBlocks(cell)) {
|
|
682
683
|
return cell;
|
|
683
684
|
}
|
|
@@ -688,6 +689,35 @@ export class Table implements BlockTool {
|
|
|
688
689
|
return block != null && (block.parentId ?? '') === tableId;
|
|
689
690
|
});
|
|
690
691
|
|
|
692
|
+
// Recover from a stale model snapshot: if the model says this cell is
|
|
693
|
+
// empty but the live DOM still has child blocks parented to the table
|
|
694
|
+
// here, harvest their ids from the DOM. Without this guard, a
|
|
695
|
+
// mid-render snapshot can persist empty cells to Yjs and become an
|
|
696
|
+
// attractor state that later undo presses revert to — see
|
|
697
|
+
// table-undo-redo-orphans regression.
|
|
698
|
+
if (filtered.length === 0 && gridEl) {
|
|
699
|
+
const cellEl = gridEl.querySelector<HTMLElement>(
|
|
700
|
+
`[${CELL_ROW_ATTR}="${rowIndex}"][${CELL_COL_ATTR}="${colIndex}"]`
|
|
701
|
+
);
|
|
702
|
+
const container = cellEl?.querySelector<HTMLElement>(`[${CELL_BLOCKS_ATTR}]`);
|
|
703
|
+
const harvested = container
|
|
704
|
+
? Array.from(container.querySelectorAll<HTMLElement>('[data-blok-id]'))
|
|
705
|
+
.map(el => el.getAttribute('data-blok-id') ?? '')
|
|
706
|
+
.filter(id => {
|
|
707
|
+
if (!id) {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
const block = this.api.blocks.getById?.(id);
|
|
711
|
+
|
|
712
|
+
return block != null && (block.parentId ?? '') === tableId;
|
|
713
|
+
})
|
|
714
|
+
: [];
|
|
715
|
+
|
|
716
|
+
if (harvested.length > 0) {
|
|
717
|
+
return { ...cell, blocks: harvested };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
691
721
|
return { ...cell, blocks: filtered };
|
|
692
722
|
})
|
|
693
723
|
);
|
|
@@ -772,6 +802,8 @@ export class Table implements BlockTool {
|
|
|
772
802
|
return;
|
|
773
803
|
}
|
|
774
804
|
|
|
805
|
+
const isSyncReplay = this.api.blocks.isSyncingFromYjs;
|
|
806
|
+
|
|
775
807
|
this.runStructuralOp(() => {
|
|
776
808
|
const setDataContent = this.cellBlocks?.initializeCells(this.initialContent ?? []) ?? this.initialContent ?? [];
|
|
777
809
|
|
|
@@ -782,9 +814,14 @@ export class Table implements BlockTool {
|
|
|
782
814
|
return;
|
|
783
815
|
}
|
|
784
816
|
|
|
785
|
-
// When
|
|
786
|
-
// but
|
|
787
|
-
// with empty
|
|
817
|
+
// When an undo replay reverts content to empty, the DOM grid has its
|
|
818
|
+
// default dimensions but the model has zero rows. Reflect the grid
|
|
819
|
+
// shape in the model with empty cells so subsequent operations have
|
|
820
|
+
// valid bounds. Do NOT call populateNewCells here — fabricating new
|
|
821
|
+
// paragraph blocks during a Yjs replay creates orphans that survive
|
|
822
|
+
// the next undo cycle (regression: table-undo-redo-orphans).
|
|
823
|
+
// If Yjs actually contains child blocks for those cells they will
|
|
824
|
+
// arrive via separate block-add events.
|
|
788
825
|
if (this.api.blocks.isSyncingFromYjs && setDataContent.length === 0 && gridEl) {
|
|
789
826
|
const emptyGridContent = Array.from(gridEl.querySelectorAll(`[${ROW_ATTR}]`), (row) => {
|
|
790
827
|
const cellCount = row.querySelectorAll(`[${CELL_ATTR}]`).length;
|
|
@@ -796,8 +833,6 @@ export class Table implements BlockTool {
|
|
|
796
833
|
...this.model.snapshot(),
|
|
797
834
|
content: emptyGridContent,
|
|
798
835
|
});
|
|
799
|
-
|
|
800
|
-
populateNewCells(gridEl, this.cellBlocks);
|
|
801
836
|
} else {
|
|
802
837
|
this.model.replaceAll({
|
|
803
838
|
...this.model.snapshot(),
|
|
@@ -825,6 +860,23 @@ export class Table implements BlockTool {
|
|
|
825
860
|
const snapSet = this.model.snapshot();
|
|
826
861
|
applyCellColors(gridEl, snapSet.content);
|
|
827
862
|
applyCellPlacements(gridEl, snapSet.content);
|
|
863
|
+
|
|
864
|
+
if (isSyncReplay) {
|
|
865
|
+
// Catch blocks already restored by sibling Yjs ops in this same replay
|
|
866
|
+
// batch — they may be sitting at the top level waiting to be reattached
|
|
867
|
+
// to their original cell. Without this, multi-cell undo restoration
|
|
868
|
+
// leaves cell content as orphan top-level blocks.
|
|
869
|
+
this.cellBlocks?.reclaimReferencedBlocks();
|
|
870
|
+
// Yjs sometimes restores sibling blocks AFTER this sync transaction
|
|
871
|
+
// commits. Schedule a second pass on the next microtask so blocks that
|
|
872
|
+
// arrive late are still attached to their cells.
|
|
873
|
+
void Promise.resolve().then(() => {
|
|
874
|
+
if (currentGeneration !== this.setDataGeneration) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
this.cellBlocks?.reclaimReferencedBlocks();
|
|
878
|
+
});
|
|
879
|
+
}
|
|
828
880
|
}
|
|
829
881
|
|
|
830
882
|
public onPaste(event: HTMLPasteEvent): void {
|