@jackuait/blok 0.10.8 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
  3. package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
  4. package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
  5. package/dist/full.mjs +3 -3
  6. package/dist/react.mjs +2 -2
  7. package/dist/tools.mjs +2 -2
  8. package/package.json +3 -6
  9. package/src/components/block/index.ts +36 -0
  10. package/src/components/blocks.ts +191 -5
  11. package/src/components/modules/api/blocks.ts +6 -4
  12. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
  13. package/src/components/modules/blockManager/blockManager.ts +364 -23
  14. package/src/components/modules/blockManager/hierarchy.ts +164 -8
  15. package/src/components/modules/blockManager/operations.ts +223 -26
  16. package/src/components/modules/blockManager/types.ts +13 -1
  17. package/src/components/modules/blockManager/yjs-sync.ts +48 -3
  18. package/src/components/modules/drag/DragController.ts +209 -8
  19. package/src/components/modules/drag/operations/DragOperations.ts +153 -20
  20. package/src/components/modules/paste/handlers/base.ts +48 -20
  21. package/src/components/modules/paste/handlers/blok-data-handler.ts +93 -45
  22. package/src/components/modules/paste/index.ts +20 -0
  23. package/src/components/modules/saver.ts +75 -5
  24. package/src/components/modules/toolbar/index.ts +41 -60
  25. package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
  26. package/src/components/modules/yjs/block-observer.ts +87 -23
  27. package/src/components/modules/yjs/document-store.ts +37 -11
  28. package/src/components/modules/yjs/index.ts +83 -7
  29. package/src/components/modules/yjs/types.ts +35 -2
  30. package/src/components/modules/yjs/undo-history.ts +116 -5
  31. package/src/components/utils/data-model-transform.ts +81 -7
  32. package/src/components/utils/hierarchy-invariant.ts +137 -0
  33. package/src/styles/main.css +5 -0
  34. package/src/tools/callout/constants.ts +0 -1
  35. package/src/tools/callout/dom-builder.ts +1 -11
  36. package/src/tools/callout/index.ts +0 -6
  37. package/src/tools/header/index.ts +14 -1
  38. package/src/tools/toggle/constants.ts +2 -1
  39. package/src/tools/toggle/dom-builder.ts +7 -0
  40. package/src/tools/toggle/index.ts +14 -1
  41. package/src/tools/toggle/toggle-lifecycle.ts +24 -0
@@ -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;
@@ -165,6 +165,28 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
165
165
  Boolean(BlockManager.currentBlock?.tool.isDefault) &&
166
166
  Boolean(BlockManager.currentBlock?.isEmpty);
167
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
+
168
190
  // Set of old IDs present in this paste, used to identify parent-child pairs.
169
191
  const pastedOldIds = new Set(blocks.map(b => b.id));
170
192
 
@@ -193,59 +215,85 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
193
215
  */
194
216
  const oldIdToEntry = new Map<string, { newBlock: Block; original: BlokClipboardBlock }>();
195
217
 
196
- // Pass 1: insert children first so they exist with new IDs before the parent.
197
- children.forEach(({ sanitized, original }) => {
198
- const block = BlockManager.insert({ tool: sanitized.tool, data: sanitized.data });
199
-
200
- oldIdToEntry.set(original.id, { newBlock: block, original });
201
- Caret.setToBlock(block, Caret.positions.END);
202
- });
203
-
204
- // Build old→new string map for remapping ID references inside parent data.
205
- const oldIdToNewId = new Map<string, string>();
206
-
207
- for (const [oldId, { newBlock }] of oldIdToEntry) {
208
- oldIdToNewId.set(oldId, newBlock.id);
209
- }
210
-
211
- // Pass 2: insert root blocks with child IDs remapped in their data.
212
- // Skip replace when children were pre-inserted to avoid replacing a
213
- // just-inserted child paragraph rather than the original empty block.
214
- roots.forEach(({ sanitized, original }, idx) => {
215
- const remappedData = oldIdToNewId.size > 0
216
- ? remapIds(sanitized.data, oldIdToNewId) as typeof sanitized.data
217
- : sanitized.data;
218
-
219
- const block = BlockManager.insert({
220
- tool: sanitized.tool,
221
- data: remappedData,
222
- replace: idx === 0 && shouldReplaceFirst && children.length === 0,
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);
223
229
  });
224
230
 
225
- oldIdToEntry.set(original.id, { newBlock: block, original });
226
- Caret.setToBlock(block, Caret.positions.END);
227
- });
231
+ // Build old→new string map for remapping ID references inside parent data.
232
+ const oldIdToNewId = new Map<string, string>();
228
233
 
229
- /**
230
- * Restore parent-child hierarchy using the old-to-new ID mapping.
231
- * Only restores relationships where both parent and child are in the pasted set.
232
- */
233
- for (const [, { newBlock, original }] of oldIdToEntry) {
234
- if (original.parentId === undefined || original.parentId === null) {
235
- continue;
234
+ for (const [oldId, { newBlock }] of oldIdToEntry) {
235
+ oldIdToNewId.set(oldId, newBlock.id);
236
236
  }
237
237
 
238
- const parentEntry = oldIdToEntry.get(original.parentId);
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);
264
+ });
239
265
 
240
- if (parentEntry === undefined) {
241
- 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);
242
288
  }
289
+ };
243
290
 
244
- newBlock.parentId = parentEntry.newBlock.id;
245
-
246
- if (!parentEntry.newBlock.contentIds.includes(newBlock.id)) {
247
- parentEntry.newBlock.contentIds = [...parentEntry.newBlock.contentIds, newBlock.id];
248
- }
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();
249
297
  }
250
298
  }
251
299
  }
@@ -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);
@@ -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,
@@ -441,20 +441,24 @@ export class Toolbar extends Module<ToolbarNodes> {
441
441
 
442
442
  /**
443
443
  * Adjust toolbar button visibility based on context:
444
- * - Table cell focus: settings toggler hidden (drag/settings don't apply to cells)
445
444
  * - Callout first child: both plus button and settings toggler hidden
446
445
  * to prevent overlap with the callout's emoji icon
446
+ *
447
+ * The callout block itself still shows BOTH buttons — the actions container
448
+ * sits outside the block (positioned via right:100% on the left gutter) and
449
+ * does not overlap the emoji which is inside the block at pl-8.
450
+ *
451
+ * Note: when the toolbar resolves to a parent table block from a focused
452
+ * cell, the settings toggler must STAY visible — it is wired via
453
+ * setupDraggable() to drag the parent table, so hiding it would leave the
454
+ * whole table undraggable while the user edits cell text.
447
455
  */
448
- const focusIsInsideCell = this.isFocusInsideTableCell();
449
456
  const isCalloutFirstChild = this.isFirstChildOfCallout(targetBlock);
450
- const isCalloutBlock = targetBlock.name === 'callout';
451
457
 
452
- // Hide plus button for callout blocks and their first children to avoid
453
- // overlap with the callout emoji icon in the left padding area.
454
- plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
458
+ plusButton.style.display = isCalloutFirstChild ? 'none' : '';
455
459
 
456
460
  if (settingsToggler) {
457
- settingsToggler.style.display = (focusIsInsideCell || isCalloutFirstChild) ? 'none' : '';
461
+ settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
458
462
  }
459
463
 
460
464
  /**
@@ -466,7 +470,8 @@ export class Toolbar extends Module<ToolbarNodes> {
466
470
  * Skip when the target is the callout itself or its first child — their toolbar
467
471
  * buttons render outside the callout's visual background area.
468
472
  */
469
- const calloutBg = isCalloutFirstChild || targetBlock.name === 'callout'
473
+ const isCalloutBlock = targetBlock.name === 'callout';
474
+ const calloutBg = isCalloutFirstChild || isCalloutBlock
470
475
  ? null
471
476
  : this.getCalloutBackgroundColor(targetBlock);
472
477
 
@@ -875,70 +880,48 @@ export class Toolbar extends Module<ToolbarNodes> {
875
880
  return parent;
876
881
  }
877
882
 
878
- private isFocusInsideTableCell(): boolean {
879
- const active = document.activeElement;
880
-
881
- if (!active) {
882
- return false;
883
- }
884
-
885
- return active.closest('[data-blok-table-cell-blocks]') !== null;
886
- }
887
-
888
883
  /**
889
- * Updates toolbar button visibility based on whether a table cell has focus.
890
- * The plus button always stays visible; the settings toggler is hidden when
891
- * focus is inside a cell and restored when focus moves to a regular block.
884
+ * Updates toolbar button visibility based on the current hovered block.
885
+ * Called from the focusin listener so callout first-child state is
886
+ * refreshed immediately when focus moves, without waiting for the next
887
+ * hover/moveAndOpen cycle.
892
888
  *
893
- * Called from the focusin listener so button state is updated immediately
894
- * on click, without waiting for the next hover/moveAndOpen cycle.
889
+ * INVARIANT: the ONLY reason this method may hide the settings toggler
890
+ * (drag handle) is `isCalloutFirstChild` a structural property of the
891
+ * block, NOT where `document.activeElement` currently is. Hiding the drag
892
+ * handle based on focus position inside nested content (table cells,
893
+ * code editors, database titles, etc.) will break dragging of the
894
+ * containing block while the user edits its content. Do NOT add focus-
895
+ * based or DOM-attribute-based hide branches here. See the arch guard
896
+ * test in test/unit/components/modules/toolbar/index.test.ts.
895
897
  */
896
- private updateToolbarButtonsForTableCellFocus(): void {
898
+ private updateToolbarButtonsForCalloutFirstChild(): void {
897
899
  const { plusButton, settingsToggler } = this.nodes;
898
900
 
899
901
  if (!plusButton) {
900
902
  return;
901
903
  }
902
904
 
903
- const focusIsInsideCell = this.isFocusInsideTableCell();
904
905
  const isCalloutFirstChild = this.hoveredBlock !== null && this.isFirstChildOfCallout(this.hoveredBlock);
905
- const isCalloutBlock = this.hoveredBlock?.name === 'callout';
906
906
 
907
- plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
907
+ plusButton.style.display = isCalloutFirstChild ? 'none' : '';
908
908
 
909
909
  if (settingsToggler) {
910
- settingsToggler.style.display = (focusIsInsideCell || isCalloutFirstChild) ? 'none' : '';
910
+ settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
911
911
  }
912
912
  }
913
913
 
914
914
  /**
915
- * Re-enables pointer-events on the settings toggler (and callout drag zone) after
916
- * the actions container has been set to pointer-events: none for left-edge blocks.
915
+ * Re-enables pointer-events on the settings toggler after the actions
916
+ * container has been set to pointer-events: none for left-edge blocks.
917
917
  *
918
- * For callout blocks: also wires the dedicated drag zone as the drag handle and
919
- * re-enables the settings toggler so the settings menu remains accessible.
920
- * The emoji button (at x=32px) no longer overlaps the actions zone (x=[0,29px])
921
- * because the callout uses pl-8 (32px) left padding.
922
- *
923
- * For all other left-edge blocks (toggle, header with arrow): simply re-enables the
924
- * settings toggler so it continues to function as the drag handle.
918
+ * Left-edge blocks (callout, toggle, header-with-arrow) disable
919
+ * pointer-events on the actions container so clicks pass through to the
920
+ * block's own left-edge interactive element (emoji / toggle arrow). The
921
+ * settings toggler must remain clickable on top so it can still function
922
+ * as the drag handle and open the block tunes menu.
925
923
  */
926
- private restoreSettingsTogglerForLeftEdgeBlock(targetBlock: Block): void {
927
- if (targetBlock.name === 'callout') {
928
- if (this.nodes.settingsToggler) {
929
- this.nodes.settingsToggler.style.pointerEvents = 'auto';
930
- }
931
-
932
- const calloutDragZone = targetBlock.holder.querySelector<HTMLElement>('[data-callout-drag-zone]');
933
-
934
- if (calloutDragZone) {
935
- calloutDragZone.style.pointerEvents = 'auto';
936
- targetBlock.setupDraggable(calloutDragZone, this.Blok.DragManager);
937
- }
938
-
939
- return;
940
- }
941
-
924
+ private restoreSettingsTogglerForLeftEdgeBlock(_targetBlock: Block): void {
942
925
  if (this.nodes.settingsToggler) {
943
926
  this.nodes.settingsToggler.style.pointerEvents = 'auto';
944
927
  }
@@ -1264,15 +1247,13 @@ export class Toolbar extends Module<ToolbarNodes> {
1264
1247
  }
1265
1248
 
1266
1249
  /**
1267
- * Listen for focus changes inside the editor.
1268
- * When the user clicks/tabs into a table cell, hide the plus button and
1269
- * settings toggler. When focus moves to a regular block, restore them.
1270
- *
1271
- * This runs on every focusin event (not throttled) to ensure buttons
1272
- * update immediately on click — no 300ms delay.
1250
+ * Listen for focus changes inside the editor so callout first-child
1251
+ * button state refreshes immediately on click/tab no 300ms delay.
1252
+ * Drag handle visibility must NOT depend on focus position inside
1253
+ * nested content (see updateToolbarButtonsForCalloutFirstChild doc).
1273
1254
  */
1274
1255
  this.readOnlyMutableListeners.on(this.Blok.UI.nodes.wrapper, 'focusin', () => {
1275
- this.updateToolbarButtonsForTableCellFocus();
1256
+ this.updateToolbarButtonsForCalloutFirstChild();
1276
1257
  });
1277
1258
 
1278
1259
  /**
@@ -492,6 +492,26 @@ export class KeyboardController extends Controller {
492
492
  return;
493
493
  }
494
494
 
495
+ /**
496
+ * Layer 18: block undo/redo while a drag is in progress.
497
+ *
498
+ * Regression: "wrong block dropped" family. `moveUndoStack` entries capture
499
+ * `fromIndex`/`toIndex` at record time. Replaying them synchronously while
500
+ * DragController still holds a live source/target reference mutates the
501
+ * flat blocks array under the drag's feet — subsequent `handleDrop` then
502
+ * operates on stale indices and silently drops an unrelated block.
503
+ *
504
+ * The Escape handler already guards on `DragManager.isDragging` (see
505
+ * handleEscape above). Mirror that here: swallow the keystroke so the
506
+ * drag completes cleanly, then the user can undo.
507
+ */
508
+ if (this.Blok.DragManager.isDragging) {
509
+ event.preventDefault();
510
+ event.stopPropagation();
511
+
512
+ return;
513
+ }
514
+
495
515
  // Prevent double-firing within 50ms
496
516
  const now = Date.now();
497
517