@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
|
@@ -102,6 +102,15 @@ export class UndoHistory {
|
|
|
102
102
|
*/
|
|
103
103
|
private moveCallback: (blockId: string, toIndex: number, origin: 'local' | 'move-undo' | 'move-redo') => void;
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Callback to restore a block's parent during move-undo/move-redo.
|
|
107
|
+
*
|
|
108
|
+
* Must not record its own history entry — the call is part of replaying
|
|
109
|
+
* an existing `SingleMoveEntry`. Set by YjsManager to route through
|
|
110
|
+
* `transactWithoutCapture` + a direct in-memory reparent.
|
|
111
|
+
*/
|
|
112
|
+
private parentRestoreCallback: (blockId: string, parentId: string | null) => void;
|
|
113
|
+
|
|
105
114
|
constructor(
|
|
106
115
|
yblocks: Y.Array<Y.Map<unknown>>,
|
|
107
116
|
blok: BlokModules
|
|
@@ -120,6 +129,9 @@ export class UndoHistory {
|
|
|
120
129
|
this.moveCallback = () => {
|
|
121
130
|
// Placeholder, will be set by setMoveCallback
|
|
122
131
|
};
|
|
132
|
+
this.parentRestoreCallback = () => {
|
|
133
|
+
// Placeholder, will be set by setParentRestoreCallback
|
|
134
|
+
};
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
/**
|
|
@@ -131,6 +143,16 @@ export class UndoHistory {
|
|
|
131
143
|
this.moveCallback = callback;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Set the parent-restore callback used by move-undo/move-redo to rewind
|
|
148
|
+
* drag-reparent side effects. See `parentRestoreCallback`.
|
|
149
|
+
*/
|
|
150
|
+
public setParentRestoreCallback(
|
|
151
|
+
callback: (blockId: string, parentId: string | null) => void
|
|
152
|
+
): void {
|
|
153
|
+
this.parentRestoreCallback = callback;
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
/**
|
|
135
157
|
* Set the Blok modules. Called when Blok modules are initialized.
|
|
136
158
|
*/
|
|
@@ -216,10 +238,14 @@ export class UndoHistory {
|
|
|
216
238
|
// Push to redo stack for potential redo
|
|
217
239
|
this.moveRedoStack.push(lastMoveGroup);
|
|
218
240
|
|
|
219
|
-
// Reverse all moves in the group, in reverse order
|
|
220
|
-
// This is crucial for multi-block moves to restore correctly
|
|
241
|
+
// Reverse all moves in the group, in reverse order.
|
|
242
|
+
// This is crucial for multi-block moves to restore correctly.
|
|
243
|
+
//
|
|
244
|
+
// Drag-reparent entries may additionally carry `fromParentId`; restore
|
|
245
|
+
// the parent BEFORE the position so the block lands in the correct
|
|
246
|
+
// flat-array slot relative to its (soon-to-be-restored) parent siblings.
|
|
221
247
|
[...lastMoveGroup].reverse().forEach((move) => {
|
|
222
|
-
this.
|
|
248
|
+
this.replayMoveUndo(move);
|
|
223
249
|
});
|
|
224
250
|
|
|
225
251
|
// Pop caret entry only after move succeeds
|
|
@@ -257,9 +283,12 @@ export class UndoHistory {
|
|
|
257
283
|
// Push back to undo stack
|
|
258
284
|
this.moveUndoStack.push(lastMoveGroup);
|
|
259
285
|
|
|
260
|
-
// Redo all moves in the group, in original order
|
|
286
|
+
// Redo all moves in the group, in original order. Drag-reparent
|
|
287
|
+
// entries restore the destination parent AFTER the position so that
|
|
288
|
+
// the flat-array splice settles first and the parent's contentIds
|
|
289
|
+
// then re-attach cleanly.
|
|
261
290
|
for (const move of lastMoveGroup) {
|
|
262
|
-
this.
|
|
291
|
+
this.replayMoveRedo(move);
|
|
263
292
|
}
|
|
264
293
|
|
|
265
294
|
// Pop caret entry only after move succeeds
|
|
@@ -307,6 +336,36 @@ export class UndoHistory {
|
|
|
307
336
|
this.restoreCaretSnapshot(snapshot);
|
|
308
337
|
}
|
|
309
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Replay a single move entry in the undo direction.
|
|
341
|
+
* Parent restore runs BEFORE the position restore so the block lands in
|
|
342
|
+
* the correct slot relative to its (soon-to-be-restored) parent siblings.
|
|
343
|
+
*/
|
|
344
|
+
private replayMoveUndo(move: SingleMoveEntry): void {
|
|
345
|
+
if (move.fromParentId !== undefined) {
|
|
346
|
+
this.parentRestoreCallback(move.blockId, move.fromParentId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (move.fromIndex !== -1) {
|
|
350
|
+
this.moveCallback(move.blockId, move.fromIndex, 'move-undo');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Replay a single move entry in the redo direction.
|
|
356
|
+
* Position restore runs BEFORE the parent restore so the flat-array splice
|
|
357
|
+
* settles first and the destination parent's contentIds re-attach cleanly.
|
|
358
|
+
*/
|
|
359
|
+
private replayMoveRedo(move: SingleMoveEntry): void {
|
|
360
|
+
if (move.toIndex !== -1) {
|
|
361
|
+
this.moveCallback(move.blockId, move.toIndex, 'move-redo');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (move.toParentId !== undefined) {
|
|
365
|
+
this.parentRestoreCallback(move.blockId, move.toParentId);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
310
369
|
/**
|
|
311
370
|
* Execute a Yjs UndoManager operation with the isPerformingUndoRedo flag set.
|
|
312
371
|
* This prevents the stack-item-added listener from modifying caret stacks during
|
|
@@ -438,6 +497,58 @@ export class UndoHistory {
|
|
|
438
497
|
}
|
|
439
498
|
}
|
|
440
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Attach a parent change to the in-flight move entry (or create a
|
|
502
|
+
* parent-only entry if the block hasn't been moved inside the group yet).
|
|
503
|
+
*
|
|
504
|
+
* Used by drag-reparent so that `undo` restores the parent relationship
|
|
505
|
+
* atomically with the array move. The caller (`BlockManager.setBlockParent`
|
|
506
|
+
* when `YjsManager.isInMoveGroup` is true) is responsible for writing the
|
|
507
|
+
* parentId/contentIds to Yjs through `transactWithoutCapture` so the
|
|
508
|
+
* Y.UndoManager does not also record the change.
|
|
509
|
+
* @param blockId - id of the reparented block
|
|
510
|
+
* @param fromParentId - parent id before the reparent (null for root)
|
|
511
|
+
* @param toParentId - parent id after the reparent (null for root)
|
|
512
|
+
*/
|
|
513
|
+
public recordParentChangeForPendingMove(
|
|
514
|
+
blockId: string,
|
|
515
|
+
fromParentId: string | null,
|
|
516
|
+
toParentId: string | null
|
|
517
|
+
): void {
|
|
518
|
+
if (this.pendingMoveGroup === null) {
|
|
519
|
+
// Not inside a move group — nothing to attach to. Drop the hint.
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const existing = this.pendingMoveGroup.find(
|
|
524
|
+
entry => entry.blockId === blockId
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
if (existing !== undefined) {
|
|
528
|
+
// Preserve the earliest known `fromParentId` (first write wins — that's
|
|
529
|
+
// the parent BEFORE the drag started). Always update `toParentId` to
|
|
530
|
+
// the most recent write.
|
|
531
|
+
if (existing.fromParentId === undefined) {
|
|
532
|
+
existing.fromParentId = fromParentId;
|
|
533
|
+
}
|
|
534
|
+
existing.toParentId = toParentId;
|
|
535
|
+
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// No matching move entry yet (e.g. a same-index reparent within a toggle
|
|
540
|
+
// body, where DragController calls setBlockParent without a prior move).
|
|
541
|
+
// Push a parent-only entry with identical from/to indices so the undo
|
|
542
|
+
// walker still has something to unwind.
|
|
543
|
+
this.pendingMoveGroup.push({
|
|
544
|
+
blockId,
|
|
545
|
+
fromIndex: -1,
|
|
546
|
+
toIndex: -1,
|
|
547
|
+
fromParentId,
|
|
548
|
+
toParentId,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
441
552
|
/**
|
|
442
553
|
* Capture the current caret position as a snapshot.
|
|
443
554
|
* @returns CaretSnapshot or null if no block is focused
|
|
@@ -368,6 +368,45 @@ const expandListToHierarchical = (
|
|
|
368
368
|
return blocks;
|
|
369
369
|
};
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Recursively expand a list of legacy body blocks into hierarchical flat blocks.
|
|
373
|
+
* Each body block is routed through expandToHierarchical so that nested legacy
|
|
374
|
+
* toggleList/callout/list structures are fully flattened instead of passing
|
|
375
|
+
* through with their legacy type (which would hit Renderer's "unknown tool"
|
|
376
|
+
* fallback and render as a stub).
|
|
377
|
+
*
|
|
378
|
+
* Returns both the direct-child IDs (for the parent's `content` array) and
|
|
379
|
+
* the flattened descendant blocks in document order.
|
|
380
|
+
* @param bodyBlocks - legacy body blocks to expand
|
|
381
|
+
* @param parentId - id of the parent block (toggle/callout) that owns them
|
|
382
|
+
*/
|
|
383
|
+
const expandLegacyBodyBlocks = (
|
|
384
|
+
bodyBlocks: OutputBlockData[],
|
|
385
|
+
parentId: BlockId
|
|
386
|
+
): { childIds: BlockId[]; childBlocks: OutputBlockData[] } => {
|
|
387
|
+
const childIds: BlockId[] = [];
|
|
388
|
+
const childBlocks: OutputBlockData[] = [];
|
|
389
|
+
|
|
390
|
+
for (const childBlock of bodyBlocks) {
|
|
391
|
+
const expanded = expandToHierarchical([childBlock]);
|
|
392
|
+
|
|
393
|
+
if (expanded.length === 0) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The first emitted block corresponds to the original input block.
|
|
398
|
+
// Re-parent it to the legacy container; descendants already carry the
|
|
399
|
+
// correct parent refs assigned during their own recursive expansion.
|
|
400
|
+
const [first, ...rest] = expanded;
|
|
401
|
+
const childId = first.id ?? generateBlockId();
|
|
402
|
+
|
|
403
|
+
childIds.push(childId);
|
|
404
|
+
childBlocks.push({ ...first, id: childId, parent: parentId }, ...rest);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { childIds, childBlocks };
|
|
408
|
+
};
|
|
409
|
+
|
|
371
410
|
/**
|
|
372
411
|
* Expand a legacy toggleList block into flat toggle block + child blocks
|
|
373
412
|
*/
|
|
@@ -378,20 +417,7 @@ const expandToggleListToHierarchical = (
|
|
|
378
417
|
const toggleId = block.id ?? generateBlockId();
|
|
379
418
|
const bodyBlocks = block.data.body?.blocks ?? [];
|
|
380
419
|
|
|
381
|
-
|
|
382
|
-
const childIds: BlockId[] = [];
|
|
383
|
-
const childBlocks: OutputBlockData[] = [];
|
|
384
|
-
|
|
385
|
-
for (const childBlock of bodyBlocks) {
|
|
386
|
-
const childId = childBlock.id ?? generateBlockId();
|
|
387
|
-
|
|
388
|
-
childIds.push(childId);
|
|
389
|
-
childBlocks.push({
|
|
390
|
-
...childBlock,
|
|
391
|
-
id: childId,
|
|
392
|
-
parent: toggleId,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
420
|
+
const { childIds, childBlocks } = expandLegacyBodyBlocks(bodyBlocks, toggleId);
|
|
395
421
|
|
|
396
422
|
const sharedFields = {
|
|
397
423
|
id: toggleId,
|
|
@@ -439,20 +465,7 @@ const expandCalloutToHierarchical = (
|
|
|
439
465
|
const calloutId = block.id ?? generateBlockId();
|
|
440
466
|
const bodyBlocks = block.data.body?.blocks ?? [];
|
|
441
467
|
|
|
442
|
-
|
|
443
|
-
const childIds: BlockId[] = [];
|
|
444
|
-
const childBlocks: OutputBlockData[] = [];
|
|
445
|
-
|
|
446
|
-
for (const childBlock of bodyBlocks) {
|
|
447
|
-
const childId = childBlock.id ?? generateBlockId();
|
|
448
|
-
|
|
449
|
-
childIds.push(childId);
|
|
450
|
-
childBlocks.push({
|
|
451
|
-
...childBlock,
|
|
452
|
-
id: childId,
|
|
453
|
-
parent: calloutId,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
468
|
+
const { childIds, childBlocks } = expandLegacyBodyBlocks(bodyBlocks, calloutId);
|
|
456
469
|
|
|
457
470
|
// Map variant → backgroundColor preset
|
|
458
471
|
const variant = block.data.variant ?? 'general';
|
|
@@ -831,31 +844,105 @@ const processRootCalloutItem = (
|
|
|
831
844
|
* @param blocks - array of flat blocks with parent/content references
|
|
832
845
|
* @returns collapsed array with nested structures
|
|
833
846
|
*/
|
|
847
|
+
/**
|
|
848
|
+
* Groups one block under its parent in the derived-content map if it has a
|
|
849
|
+
* valid parent reference. Helper extracted for collapseToLegacy reconciliation.
|
|
850
|
+
*/
|
|
851
|
+
const appendChildToDerivedContent = (
|
|
852
|
+
block: OutputBlockData,
|
|
853
|
+
blockById: Map<BlockId, OutputBlockData>,
|
|
854
|
+
derivedContent: Map<BlockId, BlockId[]>
|
|
855
|
+
): void => {
|
|
856
|
+
if (!block.id || !block.parent || !blockById.has(block.parent)) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const siblings = derivedContent.get(block.parent);
|
|
860
|
+
|
|
861
|
+
if (siblings === undefined) {
|
|
862
|
+
derivedContent.set(block.parent, [block.id]);
|
|
863
|
+
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
siblings.push(block.id);
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Merges live (parent-derived) ids into the existing content[] preserving its
|
|
871
|
+
* order, dropping any dead ids that don't resolve to a block in the input.
|
|
872
|
+
*/
|
|
873
|
+
const mergeContentIds = (
|
|
874
|
+
existingContent: BlockId[] | undefined,
|
|
875
|
+
derivedIds: BlockId[],
|
|
876
|
+
blockById: Map<BlockId, OutputBlockData>
|
|
877
|
+
): BlockId[] => {
|
|
878
|
+
const existing = Array.isArray(existingContent) ? existingContent : [];
|
|
879
|
+
const merged = existing.filter((id) => blockById.has(id));
|
|
880
|
+
|
|
881
|
+
for (const id of derivedIds) {
|
|
882
|
+
if (!merged.includes(id)) {
|
|
883
|
+
merged.push(id);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return merged;
|
|
888
|
+
};
|
|
889
|
+
|
|
834
890
|
export const collapseToLegacy = (blocks: OutputBlockData[]): OutputBlockData[] => {
|
|
891
|
+
// Defense-in-depth: reconcile each parent's content[] from children's parent
|
|
892
|
+
// fields before processing. Saver is the primary source of truth for content[]
|
|
893
|
+
// (see src/components/modules/saver.ts#doSave), but this pass guarantees the
|
|
894
|
+
// invariant `child.parent === X ⇒ X.content.includes(child.id)` even when
|
|
895
|
+
// OutputBlockData originates from a path that bypassed the saver — migrations,
|
|
896
|
+
// external JSON, tests, 3rd-party consumers. Without this, stale content[]
|
|
897
|
+
// causes processRootCalloutItem to eject real children as root siblings.
|
|
898
|
+
const reconciledBlocks = blocks.map((block) => ({ ...block }));
|
|
899
|
+
const reconciledById = new Map<BlockId, OutputBlockData>();
|
|
900
|
+
|
|
901
|
+
for (const block of reconciledBlocks) {
|
|
902
|
+
if (block.id) {
|
|
903
|
+
reconciledById.set(block.id, block);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const derivedContent = new Map<BlockId, BlockId[]>();
|
|
908
|
+
|
|
909
|
+
for (const block of reconciledBlocks) {
|
|
910
|
+
appendChildToDerivedContent(block, reconciledById, derivedContent);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
for (const [parentId, derivedIds] of derivedContent) {
|
|
914
|
+
const parent = reconciledById.get(parentId);
|
|
915
|
+
|
|
916
|
+
if (parent === undefined) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
parent.content = mergeContentIds(parent.content, derivedIds, reconciledById);
|
|
920
|
+
}
|
|
921
|
+
|
|
835
922
|
// Build a map of blocks by ID for quick lookup
|
|
836
923
|
const blockMap = new Map<BlockId, OutputBlockData>();
|
|
837
924
|
|
|
838
|
-
for (const block of
|
|
925
|
+
for (const block of reconciledBlocks) {
|
|
839
926
|
if (block.id) {
|
|
840
927
|
blockMap.set(block.id, block);
|
|
841
928
|
}
|
|
842
929
|
}
|
|
843
930
|
|
|
844
931
|
// If no flat-model list, toggle, or callout blocks, just strip hierarchy fields and return
|
|
845
|
-
const hasFlatListBlocks =
|
|
846
|
-
const hasFlatToggleBlocks =
|
|
847
|
-
const hasFlatToggleableHeaders =
|
|
848
|
-
const hasFlatCalloutBlocks =
|
|
932
|
+
const hasFlatListBlocks = reconciledBlocks.some(isFlatModelListBlock);
|
|
933
|
+
const hasFlatToggleBlocks = reconciledBlocks.some(isFlatModelToggleBlock);
|
|
934
|
+
const hasFlatToggleableHeaders = reconciledBlocks.some(b => isToggleableHeaderBlock(b) && !b.parent);
|
|
935
|
+
const hasFlatCalloutBlocks = reconciledBlocks.some(isFlatModelCalloutBlock);
|
|
849
936
|
|
|
850
937
|
if (!hasFlatListBlocks && !hasFlatToggleBlocks && !hasFlatToggleableHeaders && !hasFlatCalloutBlocks) {
|
|
851
|
-
return
|
|
938
|
+
return reconciledBlocks.map(stripHierarchyFields);
|
|
852
939
|
}
|
|
853
940
|
|
|
854
941
|
// Process blocks, converting root flat-model list blocks to legacy List blocks
|
|
855
942
|
const result: OutputBlockData[] = [];
|
|
856
943
|
const processedIds = new Set<BlockId>();
|
|
857
944
|
|
|
858
|
-
for (const block of
|
|
945
|
+
for (const block of reconciledBlocks) {
|
|
859
946
|
const alreadyProcessed = block.id && processedIds.has(block.id);
|
|
860
947
|
|
|
861
948
|
if (alreadyProcessed) {
|
|
@@ -905,6 +992,131 @@ export const collapseToLegacy = (blocks: OutputBlockData[]): OutputBlockData[] =
|
|
|
905
992
|
return result;
|
|
906
993
|
};
|
|
907
994
|
|
|
995
|
+
/**
|
|
996
|
+
* A table cell that references its content blocks by id.
|
|
997
|
+
* Tables persist their child blocks via `data.content[row][col].blocks = [<id>, ...]`
|
|
998
|
+
* rather than nesting block payloads inline, so the parent/content relationship
|
|
999
|
+
* is implicit in the table data instead of explicit on each child block.
|
|
1000
|
+
*/
|
|
1001
|
+
interface CellWithBlockRefs {
|
|
1002
|
+
blocks: string[];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const isCellWithBlockRefs = (cell: unknown): cell is CellWithBlockRefs => {
|
|
1006
|
+
return (
|
|
1007
|
+
typeof cell === 'object' &&
|
|
1008
|
+
cell !== null &&
|
|
1009
|
+
Array.isArray((cell as { blocks?: unknown }).blocks)
|
|
1010
|
+
);
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
interface TableDataShape {
|
|
1014
|
+
content?: unknown;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const getTableContentRows = (data: unknown): unknown[][] | null => {
|
|
1018
|
+
if (typeof data !== 'object' || data === null) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
const content = (data as TableDataShape).content;
|
|
1022
|
+
|
|
1023
|
+
if (!Array.isArray(content)) {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
return content as unknown[][];
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* When a flat block array contains `table` blocks that reference child blocks
|
|
1031
|
+
* via `data.content[row][col].blocks = [<id>, ...]`, ensure each referenced
|
|
1032
|
+
* child carries `parent: <tableId>`. This makes the parent/content invariant
|
|
1033
|
+
* explicit even for externally-authored data shapes that omit the `parent`
|
|
1034
|
+
* field on children.
|
|
1035
|
+
*
|
|
1036
|
+
* Without this normalization, downstream readers that key on parentId
|
|
1037
|
+
* (`mountCellBlocksReadOnly`'s cross-table guard, the table saver's
|
|
1038
|
+
* own-child filter, hierarchy queries, drag-and-drop) skip those children
|
|
1039
|
+
* and leak them out of the table, rendering them at the bottom of the page
|
|
1040
|
+
* instead of inside the cells.
|
|
1041
|
+
*
|
|
1042
|
+
* The function is idempotent, never mutates the input array, and leaves
|
|
1043
|
+
* pre-existing `parent` fields unchanged. Children referenced by multiple
|
|
1044
|
+
* tables get assigned to the first table that lists them (first-writer-wins);
|
|
1045
|
+
* corrupted cross-table references are preserved as-is so defensive guards
|
|
1046
|
+
* downstream can still reject them.
|
|
1047
|
+
* @param blocks - flat block array potentially containing tables with cell refs
|
|
1048
|
+
*/
|
|
1049
|
+
const collectCellChildRefs = (
|
|
1050
|
+
cell: unknown,
|
|
1051
|
+
tableId: BlockId,
|
|
1052
|
+
childToTable: Map<BlockId, BlockId>
|
|
1053
|
+
): void => {
|
|
1054
|
+
if (!isCellWithBlockRefs(cell)) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
for (const childId of cell.blocks) {
|
|
1058
|
+
if (typeof childId !== 'string' || childToTable.has(childId)) {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
childToTable.set(childId, tableId);
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
const collectRowChildRefs = (
|
|
1066
|
+
row: unknown,
|
|
1067
|
+
tableId: BlockId,
|
|
1068
|
+
childToTable: Map<BlockId, BlockId>
|
|
1069
|
+
): void => {
|
|
1070
|
+
if (!Array.isArray(row)) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
row.forEach(cell => collectCellChildRefs(cell, tableId, childToTable));
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const collectTableChildRefs = (
|
|
1077
|
+
tableBlock: OutputBlockData,
|
|
1078
|
+
childToTable: Map<BlockId, BlockId>
|
|
1079
|
+
): void => {
|
|
1080
|
+
if (tableBlock.id === undefined || tableBlock.id === null) {
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const rows = getTableContentRows(tableBlock.data);
|
|
1084
|
+
|
|
1085
|
+
if (rows === null) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const tableId = tableBlock.id;
|
|
1089
|
+
|
|
1090
|
+
rows.forEach(row => collectRowChildRefs(row, tableId, childToTable));
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
export const normalizeTableChildParents = (blocks: OutputBlockData[]): OutputBlockData[] => {
|
|
1094
|
+
const childToTable = new Map<BlockId, BlockId>();
|
|
1095
|
+
|
|
1096
|
+
blocks
|
|
1097
|
+
.filter(block => block.type === 'table')
|
|
1098
|
+
.forEach(tableBlock => collectTableChildRefs(tableBlock, childToTable));
|
|
1099
|
+
|
|
1100
|
+
if (childToTable.size === 0) {
|
|
1101
|
+
return blocks;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return blocks.map(block => {
|
|
1105
|
+
if (block.id === undefined || block.id === null) {
|
|
1106
|
+
return block;
|
|
1107
|
+
}
|
|
1108
|
+
const tableId = childToTable.get(block.id);
|
|
1109
|
+
|
|
1110
|
+
if (tableId === undefined) {
|
|
1111
|
+
return block;
|
|
1112
|
+
}
|
|
1113
|
+
if (block.parent !== undefined && block.parent !== null) {
|
|
1114
|
+
return block;
|
|
1115
|
+
}
|
|
1116
|
+
return { ...block, parent: tableId };
|
|
1117
|
+
});
|
|
1118
|
+
};
|
|
1119
|
+
|
|
908
1120
|
/**
|
|
909
1121
|
* Check if transformation is needed based on config and detected format
|
|
910
1122
|
*/
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { OutputBlockData } from '@/types';
|
|
2
|
+
import type { BlockId } from '../../../types/data-formats/block-id';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hierarchy invariant validator.
|
|
6
|
+
*
|
|
7
|
+
* Every block with `parent: X` must appear in `X.content`, and every id in a
|
|
8
|
+
* block's `content[]` must resolve to a block whose `parent` points back. Any
|
|
9
|
+
* drift between the two representations is the signature of the callout paste
|
|
10
|
+
* ejection bug (and its siblings across toggle, toggleable header, list, and
|
|
11
|
+
* any future container block).
|
|
12
|
+
*
|
|
13
|
+
* This util exists so tests and saver-level assertions can detect drift at
|
|
14
|
+
* any point in the pipeline — load, save, collapse, or post-mutation — without
|
|
15
|
+
* hand-rolling the same loop. Treat it as the single source of truth for the
|
|
16
|
+
* parent/content invariant.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface HierarchyViolation {
|
|
20
|
+
kind:
|
|
21
|
+
| 'child-parent-missing'
|
|
22
|
+
| 'child-not-in-parent-content'
|
|
23
|
+
| 'content-id-dangling'
|
|
24
|
+
| 'content-parent-mismatch'
|
|
25
|
+
| 'content-duplicate';
|
|
26
|
+
blockId: BlockId | undefined;
|
|
27
|
+
parentId?: BlockId;
|
|
28
|
+
childId?: BlockId;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pushViolation = (violations: HierarchyViolation[], v: HierarchyViolation): void => {
|
|
33
|
+
violations.push(v);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const checkParentLinks = (
|
|
37
|
+
block: OutputBlockData,
|
|
38
|
+
blockById: Map<BlockId, OutputBlockData>,
|
|
39
|
+
violations: HierarchyViolation[]
|
|
40
|
+
): void => {
|
|
41
|
+
if (block.parent === undefined || block.parent === null) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const parent = blockById.get(block.parent);
|
|
45
|
+
|
|
46
|
+
if (parent === undefined) {
|
|
47
|
+
pushViolation(violations, {
|
|
48
|
+
kind: 'child-parent-missing',
|
|
49
|
+
blockId: block.id,
|
|
50
|
+
parentId: block.parent,
|
|
51
|
+
message: `Block ${String(block.id)} references missing parent ${String(block.parent)}`,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (block.id === undefined || !Array.isArray(parent.content) || !parent.content.includes(block.id)) {
|
|
57
|
+
pushViolation(violations, {
|
|
58
|
+
kind: 'child-not-in-parent-content',
|
|
59
|
+
blockId: block.id,
|
|
60
|
+
parentId: block.parent,
|
|
61
|
+
message: `Block ${String(block.id)} has parent=${String(block.parent)} but that parent's content[] does not include it`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const checkContentArray = (
|
|
67
|
+
block: OutputBlockData,
|
|
68
|
+
blockById: Map<BlockId, OutputBlockData>,
|
|
69
|
+
violations: HierarchyViolation[]
|
|
70
|
+
): void => {
|
|
71
|
+
if (!Array.isArray(block.content)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const seen = new Set<BlockId>();
|
|
75
|
+
|
|
76
|
+
for (const childId of block.content) {
|
|
77
|
+
if (seen.has(childId)) {
|
|
78
|
+
pushViolation(violations, {
|
|
79
|
+
kind: 'content-duplicate',
|
|
80
|
+
blockId: block.id,
|
|
81
|
+
childId,
|
|
82
|
+
message: `Block ${String(block.id)}.content[] contains duplicate id ${String(childId)}`,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
seen.add(childId);
|
|
87
|
+
|
|
88
|
+
const child = blockById.get(childId);
|
|
89
|
+
|
|
90
|
+
if (child === undefined) {
|
|
91
|
+
pushViolation(violations, {
|
|
92
|
+
kind: 'content-id-dangling',
|
|
93
|
+
blockId: block.id,
|
|
94
|
+
childId,
|
|
95
|
+
message: `Block ${String(block.id)}.content[] references missing child ${String(childId)}`,
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (child.parent !== block.id) {
|
|
100
|
+
pushViolation(violations, {
|
|
101
|
+
kind: 'content-parent-mismatch',
|
|
102
|
+
blockId: block.id,
|
|
103
|
+
childId,
|
|
104
|
+
message: `Block ${String(block.id)}.content[] includes ${String(childId)} but that child's parent is ${String(child.parent)}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const validateHierarchy = (blocks: OutputBlockData[]): HierarchyViolation[] => {
|
|
111
|
+
const violations: HierarchyViolation[] = [];
|
|
112
|
+
const blockById = new Map<BlockId, OutputBlockData>();
|
|
113
|
+
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
if (block.id !== undefined) {
|
|
116
|
+
blockById.set(block.id, block);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const block of blocks) {
|
|
121
|
+
checkParentLinks(block, blockById, violations);
|
|
122
|
+
checkContentArray(block, blockById, violations);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return violations;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const assertHierarchy = (blocks: OutputBlockData[], context: string): void => {
|
|
129
|
+
const violations = validateHierarchy(blocks);
|
|
130
|
+
|
|
131
|
+
if (violations.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const summary = violations.map(v => ` - ${v.message}`).join('\n');
|
|
135
|
+
|
|
136
|
+
throw new Error(`Hierarchy invariant violated at ${context}:\n${summary}`);
|
|
137
|
+
};
|
|
@@ -6,6 +6,7 @@ import type { HandlerContext } from '../components/modules/paste/types';
|
|
|
6
6
|
import type { PasteHandler } from '../components/modules/paste/handlers/base';
|
|
7
7
|
import { BasePasteHandler } from '../components/modules/paste/handlers/base';
|
|
8
8
|
import { Block } from '../components/block';
|
|
9
|
+
import { normalizeTableChildParents } from '../components/utils/data-model-transform';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Patterns that indicate text is likely Markdown rather than plain text.
|
|
@@ -66,12 +67,18 @@ export class MarkdownHandler extends BasePasteHandler implements PasteHandler {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const { markdownToBlocks } = await import('./index');
|
|
69
|
-
const
|
|
70
|
+
const rawOutputBlocks = await markdownToBlocks(data);
|
|
70
71
|
|
|
71
|
-
if (!
|
|
72
|
+
if (!rawOutputBlocks.length) {
|
|
72
73
|
return false;
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// Defense-in-depth: backfill `parent` on table cell children so that any
|
|
77
|
+
// future regression in mdast-to-blocks (or external converter) cannot
|
|
78
|
+
// produce the dodopizza shape (children referenced by table cells but
|
|
79
|
+
// lacking explicit parent), which would render them at page bottom.
|
|
80
|
+
const outputBlocks = normalizeTableChildParents(rawOutputBlocks);
|
|
81
|
+
|
|
75
82
|
const { BlockManager, Caret } = this.Blok;
|
|
76
83
|
|
|
77
84
|
// Replace empty default block if present
|
package/src/styles/main.css
CHANGED
|
@@ -1306,6 +1306,11 @@
|
|
|
1306
1306
|
@apply p-0 m-0 min-h-[1.6em];
|
|
1307
1307
|
}
|
|
1308
1308
|
|
|
1309
|
+
/* List items inside table cells use tight 2px top/bottom spacing */
|
|
1310
|
+
[data-blok-table-cell-blocks] [data-blok-tool="list"] {
|
|
1311
|
+
@apply py-0 mt-[2px] mb-[2px];
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1309
1314
|
/* ─── Cell content placement ──────────────────────────────────── */
|
|
1310
1315
|
|
|
1311
1316
|
[data-blok-cell-placement="top-center"] {
|
|
@@ -29,4 +29,3 @@ export const WRAPPER_STYLES = 'rounded-xl pl-8 pr-4 py-[5px] my-1 flex items-sta
|
|
|
29
29
|
// h-[38px] = py-[7px]×2 + 1.5rem×1 = 14+24; explicit height prevents platform-specific emoji font metrics from inflating the button
|
|
30
30
|
export const EMOJI_BUTTON_STYLES = 'text-[1.5rem] leading-[1] cursor-pointer bg-transparent border-0 px-0 py-[7px] h-[38px] flex-shrink-0 select-none';
|
|
31
31
|
export const CHILDREN_STYLES = 'flex-1 min-w-0';
|
|
32
|
-
export const DRAG_ZONE_STYLES = 'absolute left-0 top-0 h-full cursor-grab select-none';
|