@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.
- package/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
- package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
- package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- 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 +6 -4
- 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 +93 -45
- package/src/components/modules/paste/index.ts +20 -0
- 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 +81 -7
- package/src/components/utils/hierarchy-invariant.ts +137 -0
- 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/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
|
@@ -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;
|
|
@@ -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
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
890
|
-
*
|
|
891
|
-
*
|
|
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
|
-
*
|
|
894
|
-
*
|
|
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
|
|
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 =
|
|
907
|
+
plusButton.style.display = isCalloutFirstChild ? 'none' : '';
|
|
908
908
|
|
|
909
909
|
if (settingsToggler) {
|
|
910
|
-
settingsToggler.style.display =
|
|
910
|
+
settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
|
|
911
911
|
}
|
|
912
912
|
}
|
|
913
913
|
|
|
914
914
|
/**
|
|
915
|
-
* Re-enables pointer-events on the settings toggler
|
|
916
|
-
*
|
|
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
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
1269
|
-
*
|
|
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.
|
|
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
|
|