@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
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
231
|
+
// Build old→new string map for remapping ID references inside parent data.
|
|
232
|
+
const oldIdToNewId = new Map<string, string>();
|
|
199
233
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
234
|
+
for (const [oldId, { newBlock }] of oldIdToEntry) {
|
|
235
|
+
oldIdToNewId.set(oldId, newBlock.id);
|
|
236
|
+
}
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
318
|
+
ids.forEach(childId => {
|
|
319
|
+
if (typeof childId === 'string' && !childToTable.has(childId)) {
|
|
320
|
+
childToTable.set(childId, tableId);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
238
324
|
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|
151
|
-
* @
|
|
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:
|
|
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,
|