@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.
Files changed (49) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-oWXfRfnM.mjs → blok-DbRn9adY.mjs} +2681 -2238
  3. package/dist/chunks/{constants-BQ1-lyZI.mjs → constants-C9lsSOXl.mjs} +4 -3
  4. package/dist/chunks/{core-C942GvJO.mjs → core-B7mxBIHA.mjs} +1 -1
  5. package/dist/chunks/{engine-javascript-Dd6ViPCH.mjs → engine-javascript-Bmmg8uL9.mjs} +1 -1
  6. package/dist/chunks/{i18next-loader-CIXsptng.mjs → i18next-loader-453gJdot.mjs} +1 -1
  7. package/dist/chunks/{tools-MuBQQyZ-.mjs → tools-D0W3_dlA.mjs} +504 -499
  8. package/dist/full.mjs +3 -3
  9. package/dist/react.mjs +3 -3
  10. package/dist/tools.mjs +2 -2
  11. package/package.json +3 -6
  12. package/src/components/block/index.ts +36 -0
  13. package/src/components/blocks.ts +191 -5
  14. package/src/components/modules/api/blocks.ts +20 -5
  15. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
  16. package/src/components/modules/blockManager/blockManager.ts +364 -23
  17. package/src/components/modules/blockManager/hierarchy.ts +164 -8
  18. package/src/components/modules/blockManager/operations.ts +223 -26
  19. package/src/components/modules/blockManager/types.ts +13 -1
  20. package/src/components/modules/blockManager/yjs-sync.ts +48 -3
  21. package/src/components/modules/drag/DragController.ts +209 -8
  22. package/src/components/modules/drag/operations/DragOperations.ts +153 -20
  23. package/src/components/modules/paste/handlers/base.ts +48 -20
  24. package/src/components/modules/paste/handlers/blok-data-handler.ts +184 -44
  25. package/src/components/modules/paste/index.ts +20 -0
  26. package/src/components/modules/renderer.ts +9 -1
  27. package/src/components/modules/saver.ts +75 -5
  28. package/src/components/modules/toolbar/index.ts +41 -60
  29. package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
  30. package/src/components/modules/yjs/block-observer.ts +87 -23
  31. package/src/components/modules/yjs/document-store.ts +37 -11
  32. package/src/components/modules/yjs/index.ts +83 -7
  33. package/src/components/modules/yjs/types.ts +35 -2
  34. package/src/components/modules/yjs/undo-history.ts +116 -5
  35. package/src/components/utils/data-model-transform.ts +247 -35
  36. package/src/components/utils/hierarchy-invariant.ts +137 -0
  37. package/src/markdown/markdown-handler.ts +9 -2
  38. package/src/styles/main.css +5 -0
  39. package/src/tools/callout/constants.ts +0 -1
  40. package/src/tools/callout/dom-builder.ts +1 -11
  41. package/src/tools/callout/index.ts +0 -6
  42. package/src/tools/header/index.ts +14 -1
  43. package/src/tools/table/table-operations.ts +9 -4
  44. package/src/tools/toggle/constants.ts +2 -1
  45. package/src/tools/toggle/dom-builder.ts +7 -0
  46. package/src/tools/toggle/index.ts +14 -1
  47. package/src/tools/toggle/toggle-lifecycle.ts +24 -0
  48. /package/dist/chunks/{lightweight-i18n-DTYoSr_o.mjs → lightweight-i18n-DSjG0iTr.mjs} +0 -0
  49. /package/dist/chunks/{objectWithoutProperties-D0XxKB4n.mjs → objectWithoutProperties-Dci1-l7D.mjs} +0 -0
@@ -124,28 +124,56 @@ export abstract class BasePasteHandler implements PasteHandler {
124
124
  : (currentBlock?.parentId ?? null);
125
125
  const insertedByIndex: Array<Awaited<ReturnType<BlokModules['BlockManager']['paste']>>> = [];
126
126
 
127
- for (const [index, pasteData] of data.entries()) {
128
- /**
129
- * Force each pasted block into its own Yjs undo entry so that
130
- * Ctrl+Z removes them one at a time.
131
- *
132
- * paste() wraps insert() in withAtomicOperation() which suppresses
133
- * the normal stopCapturing() from currentBlockIndexValue changes.
134
- * Without this, consecutive addBlock() calls within the 500ms
135
- * captureTimeout get merged into a single undo entry.
136
- */
137
- this.Blok.YjsManager.stopCapturing();
138
- const shouldReplace = index === 0 && canReplaceCurrentBlock && BlockManager.currentBlock?.isEmpty === true;
139
- const block = shouldReplace
140
- ? await BlockManager.paste(pasteData.tool, pasteData.event, true)
141
- : await BlockManager.paste(pasteData.tool, pasteData.event);
142
-
143
- Caret.setToBlock(block, Caret.positions.END);
144
- insertedByIndex.push(block);
145
-
146
- this.applyPastedBlockParent(block, pasteData, insertedByIndex, BlockManager, contextParentId);
127
+ /**
128
+ * Group every pasted block's Yjs write into a single undo entry so
129
+ * that one Cmd+Z removes the whole paste. transactForTool sets
130
+ * operations.suppressStopCapturing = true before invoking the fn and
131
+ * restores it in a trailing microtask, which keeps the synchronous
132
+ * prefix of BlockManager.paste (where the currentBlockIndex setter
133
+ * would otherwise fire stopCapturing) inside the group.
134
+ *
135
+ * The fn is synchronous transactForTool does not await — so the
136
+ * async work is kicked off inside the fn and its promise is awaited
137
+ * below. Between iterations we re-suppress directly on operations to
138
+ * keep the entire loop body inside the same undo group even after
139
+ * the initial close-boundary microtask has fired.
140
+ */
141
+ const operationsBridge = (BlockManager as unknown as { operations?: { suppressStopCapturing: boolean } }).operations;
142
+ const pasteChainRef: { current: Promise<void> } = { current: Promise.resolve() };
143
+
144
+ const runPasteLoop = (): void => {
145
+ pasteChainRef.current = (async (): Promise<void> => {
146
+ for (const [index, pasteData] of data.entries()) {
147
+ // Re-assert suppression on every iteration — transactForTool's
148
+ // close-boundary microtask may have flipped suppressStopCapturing
149
+ // back to false before the next paste() runs its sync prefix.
150
+ if (operationsBridge !== undefined) {
151
+ operationsBridge.suppressStopCapturing = true;
152
+ }
153
+
154
+ const shouldReplace = index === 0 && canReplaceCurrentBlock && BlockManager.currentBlock?.isEmpty === true;
155
+ const block = shouldReplace
156
+ ? await BlockManager.paste(pasteData.tool, pasteData.event, true)
157
+ : await BlockManager.paste(pasteData.tool, pasteData.event);
158
+
159
+ Caret.setToBlock(block, Caret.positions.END);
160
+ insertedByIndex.push(block);
161
+
162
+ this.applyPastedBlockParent(block, pasteData, insertedByIndex, BlockManager, contextParentId);
163
+ }
164
+ })();
165
+ };
166
+
167
+ // Older test mocks may not expose transactForTool; fall through
168
+ // gracefully in that case so unrelated suites keep working.
169
+ if (typeof BlockManager.transactForTool === 'function') {
170
+ BlockManager.transactForTool(runPasteLoop);
171
+ } else {
172
+ runPasteLoop();
147
173
  }
148
174
 
175
+ await pasteChainRef.current;
176
+
149
177
  BlockManager.currentBlock && Caret.setToBlock(BlockManager.currentBlock, Caret.positions.END);
150
178
 
151
179
  return;
@@ -142,10 +142,17 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
142
142
  * accumulated old→new ID mapping.
143
143
  */
144
144
  private insertBlokBlocks(
145
- blocks: BlokClipboardBlock[],
145
+ rawBlocks: BlokClipboardBlock[],
146
146
  canReplace: boolean
147
147
  ): void {
148
148
  const { BlockManager, Caret, Tools } = this.Blok;
149
+
150
+ // Some article shapes (e.g. flat-array exports) reference table children
151
+ // ONLY via `data.content[r][c].blocks = [<id>]` and never set parentId
152
+ // on the children themselves. Backfill parentId before classification so
153
+ // those children get adopted by the table during the two-pass insert
154
+ // instead of becoming detached top-level paragraphs.
155
+ const blocks = backfillTableChildParents(rawBlocks);
149
156
  const sanitizedBlocks = sanitizeBlocks(
150
157
  blocks,
151
158
  (name) => Tools.blockTools.get(name)?.sanitizeConfig ?? {},
@@ -158,6 +165,28 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
158
165
  Boolean(BlockManager.currentBlock?.tool.isDefault) &&
159
166
  Boolean(BlockManager.currentBlock?.isEmpty);
160
167
 
168
+ /**
169
+ * Capture the container membership of the current block BEFORE any
170
+ * inserts so pasted "root" blocks inherit it. Without this, pasting
171
+ * into a child of a callout / toggle / table cell would drop the
172
+ * parent link on the first pasted block and the Saver's
173
+ * derive-from-live-parentId fallback would emit it as a root sibling
174
+ * of the container — the "callout paste ejection" regression family.
175
+ *
176
+ * Mirrors the `contextParentId` logic in `BasePasteHandler.insertPasteData`
177
+ * for multi-item HTML/plain paste. `canReplaceCurrentBlock` is
178
+ * explicitly gated off inside table cells by paste/index.ts, so this
179
+ * capture is the only place where a table-cell paste picks up its
180
+ * parent id.
181
+ */
182
+ const currentBlock = BlockManager.currentBlock;
183
+ const childContainer = currentBlock?.holder?.querySelector('[data-blok-toggle-children]') ?? null;
184
+ const isInContainerTitle = childContainer !== null &&
185
+ !childContainer.contains(currentBlock?.currentInput ?? null);
186
+ const contextParentId = isInContainerTitle
187
+ ? (currentBlock?.id ?? null)
188
+ : (currentBlock?.parentId ?? null);
189
+
161
190
  // Set of old IDs present in this paste, used to identify parent-child pairs.
162
191
  const pastedOldIds = new Set(blocks.map(b => b.id));
163
192
 
@@ -186,61 +215,172 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
186
215
  */
187
216
  const oldIdToEntry = new Map<string, { newBlock: Block; original: BlokClipboardBlock }>();
188
217
 
189
- // Pass 1: insert children first so they exist with new IDs before the parent.
190
- children.forEach(({ sanitized, original }) => {
191
- const block = BlockManager.insert({ tool: sanitized.tool, data: sanitized.data });
192
-
193
- oldIdToEntry.set(original.id, { newBlock: block, original });
194
- Caret.setToBlock(block, Caret.positions.END);
195
- });
218
+ // Group every insert + setBlockParent call into a single Yjs undo entry
219
+ // so that one Cmd+Z removes the whole pasted set. Without this wrapper
220
+ // each BlockManager.insert and setBlockParent lands on its own undo
221
+ // stack item, and the user has to press undo N times to clear a paste.
222
+ const runInsertPasses = (): void => {
223
+ // Pass 1: insert children first so they exist with new IDs before the parent.
224
+ children.forEach(({ sanitized, original }) => {
225
+ const block = BlockManager.insert({ tool: sanitized.tool, data: sanitized.data });
226
+
227
+ oldIdToEntry.set(original.id, { newBlock: block, original });
228
+ Caret.setToBlock(block, Caret.positions.END);
229
+ });
196
230
 
197
- // Build old→new string map for remapping ID references inside parent data.
198
- const oldIdToNewId = new Map<string, string>();
231
+ // Build old→new string map for remapping ID references inside parent data.
232
+ const oldIdToNewId = new Map<string, string>();
199
233
 
200
- for (const [oldId, { newBlock }] of oldIdToEntry) {
201
- oldIdToNewId.set(oldId, newBlock.id);
202
- }
234
+ for (const [oldId, { newBlock }] of oldIdToEntry) {
235
+ oldIdToNewId.set(oldId, newBlock.id);
236
+ }
203
237
 
204
- // Pass 2: insert root blocks with child IDs remapped in their data.
205
- // Skip replace when children were pre-inserted to avoid replacing a
206
- // just-inserted child paragraph rather than the original empty block.
207
- roots.forEach(({ sanitized, original }, idx) => {
208
- const remappedData = oldIdToNewId.size > 0
209
- ? remapIds(sanitized.data, oldIdToNewId) as typeof sanitized.data
210
- : sanitized.data;
211
-
212
- const block = BlockManager.insert({
213
- tool: sanitized.tool,
214
- data: remappedData,
215
- replace: idx === 0 && shouldReplaceFirst && children.length === 0,
238
+ // Pass 2: insert root blocks with child IDs remapped in their data.
239
+ // Skip replace when children were pre-inserted to avoid replacing a
240
+ // just-inserted child paragraph rather than the original empty block.
241
+ roots.forEach(({ sanitized, original }, idx) => {
242
+ const remappedData = oldIdToNewId.size > 0
243
+ ? remapIds(sanitized.data, oldIdToNewId) as typeof sanitized.data
244
+ : sanitized.data;
245
+
246
+ const block = BlockManager.insert({
247
+ tool: sanitized.tool,
248
+ data: remappedData,
249
+ replace: idx === 0 && shouldReplaceFirst && children.length === 0,
250
+ });
251
+
252
+ /**
253
+ * Wire the root block into the surrounding container membership.
254
+ * Only applies when the clipboard payload itself did not declare
255
+ * a parentId for this block (explicit clipboard hierarchy wins,
256
+ * since it is restored by the pass below).
257
+ */
258
+ if (contextParentId !== null && (original.parentId === undefined || original.parentId === null)) {
259
+ BlockManager.setBlockParent(block, contextParentId);
260
+ }
261
+
262
+ oldIdToEntry.set(original.id, { newBlock: block, original });
263
+ Caret.setToBlock(block, Caret.positions.END);
216
264
  });
217
265
 
218
- oldIdToEntry.set(original.id, { newBlock: block, original });
219
- Caret.setToBlock(block, Caret.positions.END);
220
- });
221
-
222
- /**
223
- * Restore parent-child hierarchy using the old-to-new ID mapping.
224
- * Only restores relationships where both parent and child are in the pasted set.
225
- */
226
- for (const [, { newBlock, original }] of oldIdToEntry) {
227
- if (original.parentId === undefined || original.parentId === null) {
228
- continue;
266
+ /**
267
+ * Restore parent-child hierarchy using the old-to-new ID mapping.
268
+ * Only restores relationships where both parent and child are in the pasted set.
269
+ */
270
+ for (const [, { newBlock, original }] of oldIdToEntry) {
271
+ if (original.parentId === undefined || original.parentId === null) {
272
+ continue;
273
+ }
274
+
275
+ const parentEntry = oldIdToEntry.get(original.parentId);
276
+
277
+ if (parentEntry === undefined) {
278
+ continue;
279
+ }
280
+
281
+ // Route through the canonical reparent API — it handles old-parent
282
+ // splice, new-parent push, DOM reparent into the container's children
283
+ // wrapper, collapsed-hidden state sync, and the Yjs parentId +
284
+ // contentIds companion writes. Direct parentId/contentIds mutations
285
+ // bypass every one of those and reintroduce the callout paste
286
+ // ejection bug family.
287
+ BlockManager.setBlockParent(newBlock, parentEntry.newBlock.id);
229
288
  }
289
+ };
230
290
 
231
- const parentEntry = oldIdToEntry.get(original.parentId);
291
+ // Older test mocks may not expose transactForTool; fall through
292
+ // gracefully in that case so unrelated suites keep working.
293
+ if (typeof BlockManager.transactForTool === 'function') {
294
+ BlockManager.transactForTool(runInsertPasses);
295
+ } else {
296
+ runInsertPasses();
297
+ }
298
+ }
299
+ }
232
300
 
233
- if (parentEntry === undefined) {
234
- continue;
235
- }
301
+ /**
302
+ * Records each cell-referenced child id under its owning table.
303
+ */
304
+ function collectCellChildIds(
305
+ cell: unknown,
306
+ tableId: string,
307
+ childToTable: Map<string, string>
308
+ ): void {
309
+ if (
310
+ typeof cell !== 'object' ||
311
+ cell === null ||
312
+ !Array.isArray((cell as { blocks?: unknown }).blocks)
313
+ ) {
314
+ return;
315
+ }
316
+ const ids = (cell as { blocks: unknown[] }).blocks;
236
317
 
237
- newBlock.parentId = parentEntry.newBlock.id;
318
+ ids.forEach(childId => {
319
+ if (typeof childId === 'string' && !childToTable.has(childId)) {
320
+ childToTable.set(childId, tableId);
321
+ }
322
+ });
323
+ }
238
324
 
239
- if (!parentEntry.newBlock.contentIds.includes(newBlock.id)) {
240
- parentEntry.newBlock.contentIds = [...parentEntry.newBlock.contentIds, newBlock.id];
241
- }
325
+ /**
326
+ * Walks a clipboard table block's `data.content[r][c]` cells and records
327
+ * every child id referenced by `cell.blocks` under its owning table.
328
+ */
329
+ function collectTableCellRefs(
330
+ block: BlokClipboardBlock,
331
+ childToTable: Map<string, string>
332
+ ): void {
333
+ if (block.tool !== 'table' || block.id === undefined) {
334
+ return;
335
+ }
336
+ const data = block.data as { content?: unknown } | undefined;
337
+ const content = data?.content;
338
+
339
+ if (!Array.isArray(content)) {
340
+ return;
341
+ }
342
+
343
+ const tableId = block.id;
344
+
345
+ content.forEach(row => {
346
+ if (!Array.isArray(row)) {
347
+ return;
242
348
  }
349
+ row.forEach(cell => collectCellChildIds(cell, tableId, childToTable));
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Backfills `parentId` on clipboard blocks that are referenced by a sibling
355
+ * table's `data.content[r][c].blocks` array but never declare a parent of
356
+ * their own. Idempotent — never overwrites an explicit parentId.
357
+ */
358
+ function backfillTableChildParents(
359
+ blocks: BlokClipboardBlock[]
360
+ ): BlokClipboardBlock[] {
361
+ const childToTable = new Map<string, string>();
362
+
363
+ blocks.forEach(block => collectTableCellRefs(block, childToTable));
364
+
365
+ if (childToTable.size === 0) {
366
+ return blocks;
243
367
  }
368
+
369
+ return blocks.map(block => {
370
+ if (block.id === undefined) {
371
+ return block;
372
+ }
373
+ const tableId = childToTable.get(block.id);
374
+
375
+ if (tableId === undefined) {
376
+ return block;
377
+ }
378
+ if (block.parentId !== undefined && block.parentId !== null) {
379
+ return block;
380
+ }
381
+
382
+ return { ...block, parentId: tableId };
383
+ });
244
384
  }
245
385
 
246
386
  /**
@@ -297,6 +297,26 @@ export class Paste extends Module {
297
297
  return;
298
298
  }
299
299
 
300
+ /**
301
+ * Layer 20: paste-during-drag bail (regression: wrong-block-dropped family).
302
+ *
303
+ * DragController captures live Block references when the drag starts and
304
+ * commits them in `handleDrop` on mouseup. A paste firing mid-drag would
305
+ * call `BlockManager.paste` / `insert` / `convertToTool`, any of which
306
+ * reshuffles the flat blocks array under DragController's feet — the
307
+ * resulting indices are stale and a later move() silently drops an
308
+ * unrelated block. Mirrors the Cmd+Z-during-drag guard in
309
+ * uiControllers/controllers/keyboard.ts handleZ.
310
+ *
311
+ * Swallow the paste so the drag completes cleanly; the user can paste
312
+ * after releasing the mouse.
313
+ */
314
+ if (this.Blok.DragManager?.isDragging) {
315
+ event.preventDefault();
316
+
317
+ return;
318
+ }
319
+
300
320
  const { BlockManager, Toolbar } = this.Blok;
301
321
 
302
322
  const currentBlock = BlockManager.setCurrentBlockByChildNode(event.target as HTMLElement);
@@ -7,6 +7,7 @@ import { generateBlockId, log, logLabeled } from '../utils';
7
7
  import {
8
8
  analyzeDataFormat,
9
9
  expandToHierarchical,
10
+ normalizeTableChildParents,
10
11
  shouldExpandToHierarchical,
11
12
  type DataFormatAnalysis,
12
13
  } from '../utils/data-model-transform';
@@ -88,10 +89,17 @@ export class Renderer extends Module {
88
89
  this.detectedInputFormat = analysis.format;
89
90
 
90
91
  // Transform to hierarchical if config requires it
91
- const processedBlocks = shouldExpandToHierarchical(dataModelConfig, analysis.format)
92
+ const expandedBlocks = shouldExpandToHierarchical(dataModelConfig, analysis.format)
92
93
  ? expandToHierarchical(blocksData)
93
94
  : blocksData;
94
95
 
96
+ // Tables persist child references via `data.content[r][c].blocks = [<id>]`
97
+ // rather than an explicit `parent` field on each child. Pre-normalize
98
+ // those parent references so downstream code that gates on parentId
99
+ // (read-only cell mounter, saver filter, hierarchy queries) correctly
100
+ // recognizes the children as belonging to their table.
101
+ const processedBlocks = normalizeTableChildParents(expandedBlocks);
102
+
95
103
  // Note: Yjs data layer is loaded via BlockManager.insertMany() with the correct block IDs
96
104
 
97
105
  /**
@@ -11,6 +11,7 @@ import { Module } from '../__module';
11
11
  import type { Block } from '../block';
12
12
  import { getBlokVersion, isEmpty, isObject, log, logLabeled } from '../utils';
13
13
  import { collapseToLegacy, shouldCollapseToLegacy } from '../utils/data-model-transform';
14
+ import { validateHierarchy } from '../utils/hierarchy-invariant';
14
15
  import { sanitizeBlocks } from '../utils/sanitizer';
15
16
  import { normalizeInlineImages } from './normalizeInlineImages';
16
17
 
@@ -112,8 +113,54 @@ export class Saver extends Module {
112
113
  };
113
114
  }
114
115
 
116
+ /**
117
+ * Dangling parentId repair pass (belt-and-braces).
118
+ *
119
+ * Before deriving content[] or running hierarchy validation, walk every
120
+ * block and, if a block's parentId points at an id that does NOT exist in
121
+ * the live blocks array, clear it in-memory. This promotes the orphan to
122
+ * root-level — strictly better than emitting a dangling `parent` reference
123
+ * downstream, which produces corrupted JSON for users. This is the final
124
+ * exit ramp for the "container paste ejection" bug family: even if a
125
+ * mutation path somewhere else regresses, the saver is physically incapable
126
+ * of shipping output with a parent pointing to a non-existent block.
127
+ */
128
+ const blockIds = new Set(blocks.map(b => b.id));
129
+
130
+ for (const block of blocks) {
131
+ if (block.parentId !== null && !blockIds.has(block.parentId)) {
132
+ logLabeled(`Saver: cleared dangling parentId ${block.parentId} on block ${block.id}`, 'warn');
133
+ block.parentId = null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Derive each parent's content[] from the live blocks array.
139
+ *
140
+ * `block.contentIds` is a mutable array kept in sync by hierarchy.setBlockParent,
141
+ * but it can drift out of sync with `block.parentId` — e.g. when hierarchical data
142
+ * is loaded with `parent` fields on children but no `content` on the parent,
143
+ * insertMany does not reconcile the two. Downstream consumers
144
+ * (notably collapseToLegacy's processRootCalloutItem) read `content[]` as the
145
+ * source of truth for nesting, and any child missing from that array gets ejected
146
+ * from its parent. Deriving content[] at save time from `parentId` makes the
147
+ * invariant `child.parentId ⇒ parent.content.includes(child)` always hold.
148
+ */
149
+ const childrenByParent = new Map<string, string[]>();
150
+ for (const block of blocks) {
151
+ if (block.parentId === null) {
152
+ continue;
153
+ }
154
+ const siblings = childrenByParent.get(block.parentId);
155
+ if (siblings === undefined) {
156
+ childrenByParent.set(block.parentId, [block.id]);
157
+ } else {
158
+ siblings.push(block.id);
159
+ }
160
+ }
161
+
115
162
  const chainData: Array<Promise<SaverValidatedData>> = blocks.map((block: Block) => {
116
- return this.getSavedData(block);
163
+ return this.getSavedData(block, childrenByParent.get(block.id) ?? []);
117
164
  });
118
165
 
119
166
  this.lastSaveError = undefined;
@@ -147,10 +194,11 @@ export class Saver extends Module {
147
194
 
148
195
  /**
149
196
  * Saves and validates
150
- * @param {Block} block - Blok's Tool
151
- * @returns {ValidatedData} - Tool's validated data
197
+ * @param block - block to save
198
+ * @param derivedContentIds - content ids computed from live children's parentId
199
+ * (source of truth, see doSave for rationale)
152
200
  */
153
- private async getSavedData(block: Block): Promise<SaverValidatedData> {
201
+ private async getSavedData(block: Block, derivedContentIds: string[]): Promise<SaverValidatedData> {
154
202
  const blockData = await block.save();
155
203
  const toolName = block.name;
156
204
  const normalizedData = blockData?.data !== undefined
@@ -170,7 +218,7 @@ export class Saver extends Module {
170
218
  ...normalizedData,
171
219
  isValid,
172
220
  parentId: block.parentId,
173
- contentIds: block.contentIds,
221
+ contentIds: derivedContentIds,
174
222
  lastEditedAt: block.lastEditedAt,
175
223
  lastEditedBy: block.lastEditedBy,
176
224
  };
@@ -249,6 +297,28 @@ export class Saver extends Module {
249
297
  ? collapseToLegacy(extractedBlocks)
250
298
  : extractedBlocks;
251
299
 
300
+ // Defense-in-depth: assert the parent/content invariant on the final output
301
+ // in test/dev builds. Any drift here means a mutation path elsewhere is
302
+ // leaking inconsistent state through every reconciliation layer — that is
303
+ // the exact failure mode behind the callout paste ejection bug family.
304
+ // Throwing in test flushes the regression out of any future refactor; in
305
+ // production we only log, so an edge-case drift never breaks user saves.
306
+ const violations = validateHierarchy(finalBlocks);
307
+
308
+ if (violations.length > 0) {
309
+ const summary = violations.map(v => v.message).join('; ');
310
+ const message = `Saver produced output with hierarchy drift: ${summary}`;
311
+ const nodeEnv = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined;
312
+
313
+ // Throw in test AND development so manual dev-time testing (yarn serve)
314
+ // flushes drift out immediately. Only production silently logs so an
315
+ // edge-case drift never breaks end-user saves.
316
+ if (nodeEnv === 'test' || nodeEnv === 'development') {
317
+ throw new Error(message);
318
+ }
319
+ logLabeled(message, 'error');
320
+ }
321
+
252
322
  return {
253
323
  time: +new Date(),
254
324
  blocks: finalBlocks,