@jackuait/blok 0.10.2 → 0.10.3

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/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { n as e, t } from "./chunks/blok-D-T1XZ92.mjs";
2
- import { nr as n } from "./chunks/constants-CaB-mlB5.mjs";
1
+ import { n as e, t } from "./chunks/blok-3wc3aInM.mjs";
2
+ import { nr as n } from "./chunks/constants-Bp622jic.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-CWwMYL_U.mjs";
4
- import { _ as i, a, c as o, g as s, i as c, l, m as u, n as d, o as f, s as p, t as m, v as h } from "./chunks/tools-BFK2MvVI.mjs";
4
+ import { _ as i, a, c as o, g as s, i as c, l, m as u, n as d, o as f, s as p, t as m, v as h } from "./chunks/tools-BC1jRfoS.mjs";
5
5
  //#region src/full.ts
6
6
  var g = {
7
7
  paragraph: {
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { t as e } from "./chunks/blok-D-T1XZ92.mjs";
2
- import "./chunks/constants-CaB-mlB5.mjs";
1
+ import { t as e } from "./chunks/blok-3wc3aInM.mjs";
2
+ import "./chunks/constants-Bp622jic.mjs";
3
3
  import { t } from "./chunks/objectSpread2-CWwMYL_U.mjs";
4
4
  import { t as n } from "./chunks/objectWithoutProperties-D0XxKB4n.mjs";
5
5
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-CaB-mlB5.mjs";
2
- import { _ as t, a as n, c as r, d as i, f as a, g as o, h as s, i as c, l, m as u, n as d, o as f, p, r as m, s as h, t as g, u as _, v } from "./chunks/tools-BFK2MvVI.mjs";
1
+ import { m as e } from "./chunks/constants-Bp622jic.mjs";
2
+ import { _ as t, a as n, c as r, d as i, f as a, g as o, h as s, i as c, l, m as u, n as d, o as f, p, r as m, s as h, t as g, u as _, v } from "./chunks/tools-BC1jRfoS.mjs";
3
3
  export { l as Bold, p as Callout, _ as Code, e as Convert, a as Divider, t as Header, m as InlineCode, r as Italic, h as Link, o as List, f as Marker, v as Paragraph, i as Quote, c as Strikethrough, s as Table, u as Toggle, n as Underline, g as defaultBlockTools, d as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
5
5
  "module": "dist/blok.mjs",
6
6
  "types": "./types/index.d.ts",
@@ -118,6 +118,22 @@ export class Toolbar extends Module<ToolbarNodes> {
118
118
  */
119
119
  private settingsTogglerHandler: SettingsTogglerHandler;
120
120
 
121
+ /**
122
+ * The block that had focus immediately before the plus button opened the toolbox.
123
+ * Captured via the onFocusBlockCaptured callback in PlusButtonHandler.handleClick(),
124
+ * before any block manipulation occurs.
125
+ * Used to restore focus if the user dismisses the toolbox without selecting a tool.
126
+ * Cleared when a tool is selected (ToolboxEvent.BlockAdded) or when focus is restored.
127
+ */
128
+ private preToolboxBlock: Block | null = null;
129
+
130
+ /**
131
+ * A newly-inserted empty block created by the plus button click (not a reused block).
132
+ * If the user dismisses the toolbox without selecting a tool, this block is removed.
133
+ * Cleared when a tool is selected or when the block is removed on cancel.
134
+ */
135
+ private plusInsertedBlock: Block | null = null;
136
+
121
137
  /**
122
138
  * @class
123
139
  * @param moduleConfiguration - Module Configuration
@@ -143,6 +159,10 @@ export class Toolbar extends Module<ToolbarNodes> {
143
159
  openToolboxWithoutSlash: () => this.toolbox.openWithoutSlash(),
144
160
  closeToolbox: () => this.toolbox.close(),
145
161
  moveAndOpenToolbar: (block, target) => this.moveAndOpen(block, target),
162
+ onFocusBlockCaptured: (block, insertedBlock) => {
163
+ this.preToolboxBlock = block;
164
+ this.plusInsertedBlock = insertedBlock;
165
+ },
146
166
  }
147
167
  );
148
168
 
@@ -537,14 +557,22 @@ export class Toolbar extends Module<ToolbarNodes> {
537
557
  * Uses Math.max to guarantee the actions container (positioned via right:100%)
538
558
  * never extends beyond the left edge of the viewport, which would make the
539
559
  * drag handle unreachable by pointer events.
560
+ *
561
+ * For nested blocks (e.g. children inside a callout), the holder is already
562
+ * offset from the viewport left by the parent's indentation. In that case we
563
+ * only need to ensure the actions don't extend beyond the viewport left edge
564
+ * (holderLeft px are available to the left), so the minimum margin is
565
+ * max(0, actionsWidth - holderLeft) rather than a flat actionsWidth clamp.
540
566
  */
541
567
  if (blockContentElement && this.nodes.content) {
542
568
  const holderRect = this.nodes.wrapper?.getBoundingClientRect();
543
569
  const contentRect = blockContentElement.getBoundingClientRect();
544
570
  const visualOffset = holderRect ? Math.max(0, contentRect.left - holderRect.left) : 0;
545
571
  const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
572
+ const holderLeft = holderRect ? Math.max(0, holderRect.left) : 0;
573
+ const minMarginLeft = Math.max(0, actionsWidth - holderLeft);
546
574
 
547
- this.nodes.content.style.marginLeft = `${Math.max(visualOffset, actionsWidth)}px`;
575
+ this.nodes.content.style.marginLeft = `${Math.max(visualOffset, minMarginLeft)}px`;
548
576
  this.nodes.content.style.maxWidth = `${contentRect.width}px`;
549
577
  }
550
578
  }
@@ -668,15 +696,19 @@ export class Toolbar extends Module<ToolbarNodes> {
668
696
  /**
669
697
  * Sync toolbar content wrapper's position and width with the block content element.
670
698
  * Uses getBoundingClientRect so wide-mode content (max-width: none) is handled correctly.
671
- * Clamp to actionsWidth so actions never extend beyond the left viewport edge.
699
+ * Clamp to max(0, actionsWidth - holderLeft) so actions never extend beyond the left
700
+ * viewport edge. For nested blocks already offset from the left, a smaller clamp is
701
+ * used so buttons are not pushed into the text content.
672
702
  */
673
703
  if (blockContentElement && this.nodes.content) {
674
704
  const holderRect = this.nodes.wrapper?.getBoundingClientRect();
675
705
  const contentRect = blockContentElement.getBoundingClientRect();
676
706
  const visualOffset = holderRect ? Math.max(0, contentRect.left - holderRect.left) : 0;
677
707
  const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
708
+ const holderLeft = holderRect ? Math.max(0, holderRect.left) : 0;
709
+ const minMarginLeft = Math.max(0, actionsWidth - holderLeft);
678
710
 
679
- this.nodes.content.style.marginLeft = `${Math.max(visualOffset, actionsWidth)}px`;
711
+ this.nodes.content.style.marginLeft = `${Math.max(visualOffset, minMarginLeft)}px`;
680
712
  this.nodes.content.style.maxWidth = `${contentRect.width}px`;
681
713
  }
682
714
  }
@@ -1089,9 +1121,63 @@ export class Toolbar extends Module<ToolbarNodes> {
1089
1121
  // eslint-disable-next-line @typescript-eslint/no-deprecated
1090
1122
  this.Blok.UI.nodes.wrapper.classList.remove(this.CSS.openedToolboxHolderModifier);
1091
1123
  this.Blok.UI.nodes.wrapper.removeAttribute(DATA_ATTR.toolboxOpened);
1124
+
1125
+ /**
1126
+ * If the toolbox was opened via the plus button and the user dismissed
1127
+ * it without selecting a tool (Escape / click outside), restore focus to
1128
+ * the block that was focused BEFORE the plus button was clicked and
1129
+ * remove the orphan empty block that was inserted.
1130
+ *
1131
+ * When a tool IS selected, ToolboxEvent.BlockAdded fires first and clears
1132
+ * preToolboxBlock, so this branch is skipped for that case.
1133
+ */
1134
+ if (this.preToolboxBlock !== null) {
1135
+ const blockToRestore = this.preToolboxBlock;
1136
+
1137
+ this.preToolboxBlock = null;
1138
+
1139
+ // Remove the orphan block that was inserted by the plus button click,
1140
+ // then restore focus. removeBlock() is Promise-based but resolves
1141
+ // synchronously; chaining ensures setToBlock runs after removal.
1142
+ if (this.plusInsertedBlock !== null) {
1143
+ const orphan = this.plusInsertedBlock;
1144
+
1145
+ this.plusInsertedBlock = null;
1146
+ void this.Blok.BlockManager.removeBlock(orphan, false).then(() => {
1147
+ if (blockToRestore.inputs.length > 0) {
1148
+ this.Blok.Caret.setToBlock(blockToRestore, this.Blok.Caret.positions.END);
1149
+ }
1150
+ });
1151
+ } else if (blockToRestore.inputs.length > 0) {
1152
+ // Reused an existing block (emptyBlockToReuse path) — just restore focus
1153
+ this.Blok.Caret.setToBlock(blockToRestore, this.Blok.Caret.positions.END);
1154
+ }
1155
+
1156
+ return;
1157
+ }
1158
+
1159
+ /**
1160
+ * Restore focus to the current block when the toolbox closes via any
1161
+ * non-plus-button path (e.g. slash-search dismissed via Escape).
1162
+ * Without this, focus falls to document.body after non-keyboard close
1163
+ * paths, causing subsequent keystrokes to be lost.
1164
+ */
1165
+ const currentBlock = this.Blok.BlockManager.currentBlock;
1166
+
1167
+ if (currentBlock && currentBlock.inputs.length > 0) {
1168
+ this.Blok.Caret.setToBlock(currentBlock, this.Blok.Caret.positions.END);
1169
+ }
1092
1170
  });
1093
1171
 
1094
1172
  this.toolboxInstance.on(ToolboxEvent.BlockAdded, ({ block }) => {
1173
+ /**
1174
+ * A tool was selected and a block was added — clear the cancel context so
1175
+ * ToolboxEvent.Closed (which fires after this) does not try to undo the
1176
+ * insertion and restore focus to the pre-plus block.
1177
+ */
1178
+ this.preToolboxBlock = null;
1179
+ this.plusInsertedBlock = null;
1180
+
1095
1181
  const { BlockManager, Caret } = this.Blok;
1096
1182
  const newBlock = BlockManager.getBlockById(block.id);
1097
1183
 
@@ -1142,6 +1228,12 @@ export class Toolbar extends Module<ToolbarNodes> {
1142
1228
 
1143
1229
  if (plusButton) {
1144
1230
  this.readOnlyMutableListeners.on(plusButton, 'mousedown', (e) => {
1231
+ /**
1232
+ * Prevent focus from moving away from the currently-active contenteditable block.
1233
+ * Without this, clicking the plus button steals DOM focus, causing subsequent
1234
+ * keystrokes to land in the wrong block (text-jumping bug).
1235
+ */
1236
+ (e as MouseEvent).preventDefault();
1145
1237
  hide();
1146
1238
 
1147
1239
  this.clickDragHandler.setup(
@@ -46,6 +46,13 @@ export class PlusButtonHandler {
46
46
  */
47
47
  private moveAndOpenToolbar: (block?: Block | null, target?: Element | null) => void;
48
48
 
49
+ /**
50
+ * Optional callback invoked at the very start of handleClick(), before any
51
+ * block manipulation, with the block that currently has focus.
52
+ * Used by Toolbar to capture the pre-toolbox block for focus restoration on cancel.
53
+ */
54
+ private onFocusBlockCaptured: ((block: Block | null, insertedBlock: Block | null) => void) | undefined;
55
+
49
56
  /**
50
57
  * @param getBlok - Function to get Blok modules reference
51
58
  * @param callbacks - Object containing callback functions
@@ -58,6 +65,7 @@ export class PlusButtonHandler {
58
65
  openToolboxWithoutSlash: () => void;
59
66
  closeToolbox: () => void;
60
67
  moveAndOpenToolbar: (block?: Block | null, target?: Element | null) => void;
68
+ onFocusBlockCaptured?: (block: Block | null, insertedBlock: Block | null) => void;
61
69
  }
62
70
  ) {
63
71
  this.getBlok = getBlok;
@@ -66,6 +74,7 @@ export class PlusButtonHandler {
66
74
  this.openToolboxWithoutSlash = callbacks.openToolboxWithoutSlash;
67
75
  this.closeToolbox = callbacks.closeToolbox;
68
76
  this.moveAndOpenToolbar = callbacks.moveAndOpenToolbar;
77
+ this.onFocusBlockCaptured = callbacks.onFocusBlockCaptured;
69
78
  }
70
79
 
71
80
  /**
@@ -175,6 +184,23 @@ export class PlusButtonHandler {
175
184
  // If hoveredBlock is not empty (e.g. a table), check if the focused block
176
185
  // is empty and nested inside it (e.g. an empty paragraph in a table cell).
177
186
  const currentBlock = BlockManager.currentBlock ?? null;
187
+
188
+ /**
189
+ * Capture the block that CURRENTLY HAS DOM FOCUS before any manipulation,
190
+ * so that focus can be restored to it if the user cancels (Escape) without
191
+ * selecting a tool.
192
+ *
193
+ * We cannot rely on BlockManager.currentBlock here: the mousedown event on
194
+ * the plus button (which lives inside the hovered block's DOM) triggers the
195
+ * redactorTouchHandler in capture phase, which calls setCurrentBlockByChildNode
196
+ * and overwrites currentBlock to the hovered block BEFORE our preventDefault
197
+ * or handleClick() runs. Instead we look at the actual DOM-focused element
198
+ * and find which block owns it.
199
+ */
200
+ const activeEl = document.activeElement;
201
+ const focusedBlockBeforeOpen = activeEl !== null && activeEl !== document.body
202
+ ? (BlockManager.getBlockByChildNode(activeEl) ?? null)
203
+ : null;
178
204
  const hoveredIsEmpty = hoveredBlock !== null && hoveredBlock.isEmpty;
179
205
  const nestedCurrentBlockIsEmpty = !hoveredIsEmpty && currentBlock !== null
180
206
  && currentBlock !== hoveredBlock && currentBlock.isEmpty
@@ -215,6 +241,17 @@ export class PlusButtonHandler {
215
241
  hoveredBlock?.holder.after(targetBlock.holder);
216
242
  }
217
243
 
244
+ /**
245
+ * Notify Toolbar of the pre-open focus context.
246
+ * insertedBlock is non-null only when we created a brand-new empty block
247
+ * (not when we're reusing an existing empty block or operating in slash mode).
248
+ * On cancel (Escape), Toolbar will remove the inserted block and restore focus
249
+ * to focusedBlockBeforeOpen.
250
+ */
251
+ const insertedBlock = (!startsWithSlash && emptyBlockToReuse === null) ? targetBlock : null;
252
+
253
+ this.onFocusBlockCaptured?.(focusedBlockBeforeOpen, insertedBlock);
254
+
218
255
  // Position caret and open toolbox
219
256
  if (startsWithSlash) {
220
257
  // Block already has "/" - keep slash-search mode, position after the slash
@@ -179,6 +179,12 @@ export class SettingsTogglerHandler {
179
179
  */
180
180
  public createMousedownHandler(): (e: Event) => void {
181
181
  return (e: Event) => {
182
+ /**
183
+ * Prevent focus from moving away from the currently-active contenteditable block.
184
+ * Without this, clicking the settings toggler steals DOM focus, causing subsequent
185
+ * keystrokes to land in the wrong block (text-jumping bug).
186
+ */
187
+ (e as MouseEvent).preventDefault();
182
188
  hide();
183
189
 
184
190
  this.clickDragHandler.setup(
@@ -29,6 +29,33 @@ export class KeyboardController extends Controller {
29
29
  */
30
30
  private redactorElement: HTMLElement | null = null;
31
31
 
32
+ /**
33
+ * Stable handler references for deduplication via Listeners.findOne.
34
+ * Storing as class properties ensures the same function reference is passed
35
+ * to addEventListener on every enable() call, so the Listeners utility can
36
+ * detect and skip duplicate registrations (e.g. when toggleReadOnly calls
37
+ * enable() more than once via requestIdleCallback).
38
+ */
39
+ private readonly documentKeydownHandler = (event: Event): void => {
40
+ if (event instanceof KeyboardEvent) {
41
+ this.handleKeydown(event);
42
+ }
43
+ };
44
+
45
+ private readonly redactorBeforeinputHandler = (): void => {
46
+ this.Blok.YjsManager.markCaretBeforeChange();
47
+ };
48
+
49
+ private readonly redactorKeydownHandler = (event: Event): void => {
50
+ if (!(event instanceof KeyboardEvent)) {
51
+ return;
52
+ }
53
+
54
+ if (KEYS_REQUIRING_CARET_CAPTURE.has(event.key)) {
55
+ this.Blok.YjsManager.markCaretBeforeChange();
56
+ }
57
+ };
58
+
32
59
  constructor(options: {
33
60
  config: Controller['config'];
34
61
  eventsDispatcher: Controller['eventsDispatcher'];
@@ -54,34 +81,20 @@ export class KeyboardController extends Controller {
54
81
  }
55
82
 
56
83
  // Document-level keydown handler
57
- this.readOnlyMutableListeners.on(document, 'keydown', (event: Event) => {
58
- if (event instanceof KeyboardEvent) {
59
- this.handleKeydown(event);
60
- }
61
- }, true);
84
+ this.readOnlyMutableListeners.on(document, 'keydown', this.documentKeydownHandler, true);
62
85
 
63
86
  /**
64
87
  * Capture caret position before any input changes the DOM.
65
88
  * This ensures undo/redo restores the caret to the correct position.
66
89
  */
67
- this.readOnlyMutableListeners.on(this.redactorElement, 'beforeinput', () => {
68
- this.Blok.YjsManager.markCaretBeforeChange();
69
- }, true);
90
+ this.readOnlyMutableListeners.on(this.redactorElement, 'beforeinput', this.redactorBeforeinputHandler, true);
70
91
 
71
92
  /**
72
93
  * Capture caret position on keydown for keys that tools commonly intercept.
73
94
  * Uses capture phase to run before tool handlers.
74
95
  * markCaretBeforeChange() is idempotent - if beforeinput also fires, the second call is ignored.
75
96
  */
76
- this.readOnlyMutableListeners.on(this.redactorElement, 'keydown', (event: Event) => {
77
- if (!(event instanceof KeyboardEvent)) {
78
- return;
79
- }
80
-
81
- if (KEYS_REQUIRING_CARET_CAPTURE.has(event.key)) {
82
- this.Blok.YjsManager.markCaretBeforeChange();
83
- }
84
- }, true);
97
+ this.readOnlyMutableListeners.on(this.redactorElement, 'keydown', this.redactorKeydownHandler, true);
85
98
  }
86
99
 
87
100
  /**
@@ -260,12 +273,18 @@ export class KeyboardController extends Controller {
260
273
 
261
274
  /**
262
275
  * Toolbox needs specific Escape handling for caret restoration,
263
- * so check it before the registry
276
+ * so check it before the registry.
277
+ *
278
+ * stopPropagation() is required here: this handler runs in the capture phase
279
+ * on document, BEFORE the block-level keydown handler. If we let the event
280
+ * continue bubbling after closing the toolbox, the block's keydown handler
281
+ * (navigationMode.handleEscape) will see `toolbox.opened === false` and
282
+ * incorrectly enable navigation mode, which calls `activeElement.blur()`
283
+ * and drops focus to body.
264
284
  */
265
285
  if (this.Blok.Toolbar.toolbox.opened) {
286
+ event.stopPropagation();
266
287
  this.Blok.Toolbar.toolbox.close();
267
- this.Blok.BlockManager.currentBlock &&
268
- this.Blok.Caret.setToBlock(this.Blok.BlockManager.currentBlock, this.Blok.Caret.positions.END);
269
288
 
270
289
  return;
271
290
  }
@@ -313,15 +332,30 @@ export class KeyboardController extends Controller {
313
332
 
314
333
  /**
315
334
  * If focus is inside editor content and no toolbars are open,
316
- * enable navigation mode for keyboard-based block navigation
335
+ * enable navigation mode for keyboard-based block navigation.
336
+ *
337
+ * Skip navigation mode when a drag operation is in progress:
338
+ * the drag's own keydown handler (DragController.onKeyDown) must receive
339
+ * this Escape event to announce the cancellation and clean up drag state.
340
+ * Enabling navigation mode here would call blur() on the active element,
341
+ * then the block holder's bubbling keydown handler would see navigation
342
+ * mode enabled and call event.stopPropagation(), preventing DragController
343
+ * from ever receiving the event.
317
344
  */
318
345
  const target = event.target;
319
346
  const isTargetElement = target instanceof HTMLElement;
320
347
  const isInsideRedactor = this.redactorElement && isTargetElement && this.redactorElement.contains(target);
321
348
  const hasCurrentBlock = this.Blok.BlockManager.currentBlock !== undefined;
322
349
 
323
- if (isInsideRedactor && hasCurrentBlock) {
350
+ if (isInsideRedactor && hasCurrentBlock && !this.Blok.DragManager.isDragging) {
324
351
  event.preventDefault();
352
+ /**
353
+ * Stop propagation so the block holder's bubble keydown handler (blockEvents.keydown)
354
+ * does not see this same Escape event. Without this, the block-level NavigationMode
355
+ * composer's handleKey() would receive the event AFTER navigation mode is enabled,
356
+ * see navigationModeEnabled=true + key='Escape', and immediately disable it.
357
+ */
358
+ event.stopPropagation();
325
359
  this.Blok.Toolbar.close();
326
360
  this.Blok.BlockSelection.enableNavigationMode();
327
361
 
@@ -46,8 +46,18 @@ export class SelectionCursor {
46
46
  // Focus contenteditable elements explicitly after setting the selection range.
47
47
  // Placed after addRange() so the selection is preserved when focus transfers —
48
48
  // calling focus() before addRange() can reset the caret during arrow navigation.
49
- if ($.isContentEditable(element) && document.activeElement !== element) {
50
- element.focus();
49
+ //
50
+ // When `element` is a text node or a non-focusable inline element (e.g. <b>, <span>),
51
+ // `isContentEditable` will be false. In that case we walk up to the nearest
52
+ // contenteditable ancestor so that DOM focus is transferred there. Without this,
53
+ // focus stays on whatever had it before (e.g. the toolbox search input), causing
54
+ // subsequent keystrokes to land in the wrong place.
55
+ const focusTarget = $.isContentEditable(element)
56
+ ? element
57
+ : (element.parentElement?.closest('[contenteditable="true"]') as HTMLElement | null) ?? null;
58
+
59
+ if (focusTarget !== null && document.activeElement !== focusTarget) {
60
+ focusTarget.focus();
51
61
  }
52
62
 
53
63
  return range.getBoundingClientRect();
@@ -341,13 +341,17 @@ export class Toolbox extends EventsDispatcher<ToolboxEventMap> {
341
341
  this.popover?.show();
342
342
 
343
343
  /**
344
- * When opening toolbox inside a table cell, position it at the caret
345
- * instead of at the trigger element (which is outside the table).
344
+ * When opening toolbox inside a table cell or a nested block (toggle, callout),
345
+ * position it at the caret instead of at the trigger element (which is outside
346
+ * the nested container).
346
347
  * Must be called after show() so the popover is in the DOM.
347
348
  */
348
- const triggerHidden = this.triggerElement?.getBoundingClientRect().height === 0;
349
+ const triggerRect = this.triggerElement?.getBoundingClientRect();
350
+ const triggerHidden = triggerRect?.height === 0;
351
+ const triggerOffScreen = triggerRect !== undefined && triggerRect.bottom < 0;
352
+ const isInsideNestedBlock = currentBlock !== undefined && currentBlock.parentId !== null;
349
353
 
350
- if ((this.isInsideTableCell || triggerHidden) && this.popover instanceof PopoverDesktop) {
354
+ if ((this.isInsideTableCell || triggerHidden || triggerOffScreen || isInsideNestedBlock) && this.popover instanceof PopoverDesktop) {
351
355
  const caretRect = SelectionUtils.rect;
352
356
 
353
357
  this.popover.updatePosition(caretRect);
@@ -381,6 +385,18 @@ export class Toolbox extends EventsDispatcher<ToolboxEventMap> {
381
385
 
382
386
  this.stopListeningToBlockInput();
383
387
  this.popover?.hide();
388
+
389
+ /**
390
+ * Only emit Closed event when the toolbox was actually open.
391
+ * This prevents spurious Closed events (and their side-effects such as
392
+ * caret restoration) when close() is called as routine cleanup (e.g.
393
+ * during cross-block selection, block deletion, or toolbar dismissal)
394
+ * even though the toolbox was never shown.
395
+ */
396
+ if (!this.opened) {
397
+ return;
398
+ }
399
+
384
400
  this.opened = false;
385
401
  this.emit(ToolboxEvent.Closed);
386
402
  }
@@ -447,6 +463,17 @@ export class Toolbox extends EventsDispatcher<ToolboxEventMap> {
447
463
  * Handles popover close event
448
464
  */
449
465
  private onPopoverClose = (): void => {
466
+ /**
467
+ * Only handle the Closed event when the toolbox was actually open.
468
+ * The popover can fire Closed during routine cleanup (e.g. when Toolbar.close()
469
+ * is called unconditionally as part of CBS, block deletion, etc.), even though
470
+ * the toolbox was never shown. Emitting ToolboxEvent.Closed in those cases
471
+ * triggers side-effects (like caret restoration) that break cross-block selection.
472
+ */
473
+ if (!this.opened) {
474
+ return;
475
+ }
476
+
450
477
  if (this.isInsideTableCell) {
451
478
  this.toggleRestrictedToolsHidden(false);
452
479
  this.isInsideTableCell = false;
@@ -75,9 +75,14 @@ export function resolvePosition(input: PositionInput): ResolvedPosition {
75
75
  ? anchor.top - offset - popoverSize.height + scrollOffset.y
76
76
  : anchor.bottom + offset + scrollOffset.y;
77
77
 
78
- // Clamp: ensure popover doesn't overflow above top boundary
79
- const top = rawTop < boundaryTop + scrollOffset.y
80
- ? boundaryTop + scrollOffset.y
78
+ // Clamp: ensure popover doesn't overflow above top boundary.
79
+ // Use the scope's top in document coords (scopeBounds.top + scrollOffset.y) rather
80
+ // than the viewport-clamped boundaryTop, so the clamp is correct when the page is
81
+ // scrolled (boundaryTop is viewport-relative; adding scrollOffset converts it to
82
+ // document coords but discards any negative scope top that was clamped to 0).
83
+ const scopeTopInDocCoords = scopeBounds.top + scrollOffset.y;
84
+ const top = rawTop < scopeTopInDocCoords
85
+ ? scopeTopInDocCoords
81
86
  : rawTop;
82
87
 
83
88
  // --- Horizontal ---
@@ -1575,18 +1575,9 @@ export class Table implements BlockTool {
1575
1575
  return [];
1576
1576
  }
1577
1577
 
1578
- const allRows = Array.from(gridEl.querySelectorAll(`[${ROW_ATTR}]`));
1579
-
1580
1578
  return cells.map(cell => {
1581
- const row = cell.closest<HTMLElement>(`[${ROW_ATTR}]`);
1582
-
1583
- if (!row) {
1584
- return null;
1585
- }
1586
-
1587
- const rowIndex = allRows.indexOf(row);
1588
- const cellsInRow = Array.from(row.querySelectorAll(`[${CELL_ATTR}]`));
1589
- const colIndex = cellsInRow.indexOf(cell);
1579
+ const rowIndex = parseInt(cell.getAttribute(CELL_ROW_ATTR) ?? '0', 10);
1580
+ const colIndex = parseInt(cell.getAttribute(CELL_COL_ATTR) ?? '0', 10);
1590
1581
 
1591
1582
  const container = cell.querySelector(`[${CELL_BLOCKS_ATTR}]`);
1592
1583
  const blocks: ClipboardBlockData[] = [];
@@ -1643,7 +1634,7 @@ export class Table implements BlockTool {
1643
1634
  ...(color !== undefined ? { color } : {}),
1644
1635
  ...(textColor !== undefined ? { textColor } : {}),
1645
1636
  };
1646
- }).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
1637
+ });
1647
1638
  }
1648
1639
 
1649
1640
  private initCellSelection(gridEl: HTMLElement): void {
@@ -1741,6 +1732,9 @@ export class Table implements BlockTool {
1741
1732
  this.rebuildTableBody();
1742
1733
  });
1743
1734
  },
1735
+ getCellSpan: (row, col) => {
1736
+ return this.model.getCellSpan(row, col);
1737
+ },
1744
1738
  });
1745
1739
  }
1746
1740
 
@@ -1804,9 +1798,7 @@ export class Table implements BlockTool {
1804
1798
  return;
1805
1799
  }
1806
1800
 
1807
- const targetRow = targetCell.closest<HTMLElement>(`[${ROW_ATTR}]`);
1808
-
1809
- if (!targetRow) {
1801
+ if (!targetCell.closest(`[${ROW_ATTR}]`)) {
1810
1802
  return;
1811
1803
  }
1812
1804
 
@@ -1827,10 +1819,9 @@ export class Table implements BlockTool {
1827
1819
  e.preventDefault();
1828
1820
  e.stopPropagation();
1829
1821
 
1830
- const rows = Array.from(gridEl.querySelectorAll(`[${ROW_ATTR}]`));
1831
- const targetRowIndex = rows.indexOf(targetRow);
1832
- const cellsInRow = Array.from(targetRow.querySelectorAll(`[${CELL_ATTR}]`));
1833
- const targetColIndex = cellsInRow.indexOf(targetCell);
1822
+ // Read true model coordinates from stamped data attributes
1823
+ const targetRowIndex = parseInt(targetCell.getAttribute(CELL_ROW_ATTR) ?? '0', 10);
1824
+ const targetColIndex = parseInt(targetCell.getAttribute(CELL_COL_ATTR) ?? '0', 10);
1834
1825
 
1835
1826
  this.pastePayloadIntoCells(gridEl, payload, targetRowIndex, targetColIndex);
1836
1827
  }