@jackuait/blok 0.10.0-beta.9 → 0.10.0

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 (59) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-DDu252IK.mjs → blok-BfcBwAfE.mjs} +1211 -1159
  3. package/dist/chunks/{constants-DMW9a31I.mjs → constants-QNVyXALL.mjs} +49 -48
  4. package/dist/chunks/{tools-XmzH2rgQ.mjs → tools-DHtzbrxy.mjs} +1403 -1220
  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 -5
  9. package/src/cli/commands/convert-gdocs/index.ts +26 -0
  10. package/src/cli/commands/convert-html/block-builder.ts +392 -0
  11. package/src/cli/commands/convert-html/id-generator.ts +11 -0
  12. package/src/cli/commands/convert-html/index.ts +23 -0
  13. package/src/cli/commands/convert-html/preprocessor.ts +422 -0
  14. package/src/cli/commands/convert-html/sanitizer.ts +93 -0
  15. package/src/cli/commands/convert-html/types.ts +15 -0
  16. package/src/cli/index.ts +56 -5
  17. package/src/components/block/index.ts +44 -10
  18. package/src/components/constants/data-attributes.ts +10 -0
  19. package/src/components/icons/index.ts +16 -0
  20. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +18 -0
  21. package/src/components/modules/blockManager/hierarchy.ts +4 -1
  22. package/src/components/modules/readonly.ts +46 -0
  23. package/src/components/modules/rectangleSelection.ts +25 -5
  24. package/src/components/modules/toolbar/index.ts +96 -19
  25. package/src/components/modules/toolbar/styles.ts +0 -2
  26. package/src/components/modules/uiControllers/controllers/blockHover.ts +44 -1
  27. package/src/components/tools/block.ts +10 -0
  28. package/src/components/utils/placeholder.ts +9 -2
  29. package/src/styles/main.css +16 -0
  30. package/src/tools/callout/constants.ts +2 -1
  31. package/src/tools/callout/dom-builder.ts +13 -1
  32. package/src/tools/callout/index.ts +21 -7
  33. package/src/tools/code/constants.ts +9 -1
  34. package/src/tools/code/dom-builder.ts +90 -54
  35. package/src/tools/code/index.ts +73 -31
  36. package/src/tools/divider/index.ts +5 -0
  37. package/src/tools/header/index.ts +47 -1
  38. package/src/tools/list/dom-builder.ts +3 -1
  39. package/src/tools/list/index.ts +55 -3
  40. package/src/tools/list/list-helpers.ts +2 -2
  41. package/src/tools/nested-blocks.ts +25 -0
  42. package/src/tools/paragraph/index.ts +47 -6
  43. package/src/tools/quote/index.ts +43 -8
  44. package/src/tools/stub/index.ts +10 -0
  45. package/src/tools/table/index.ts +238 -6
  46. package/src/tools/table/table-add-controls.ts +37 -5
  47. package/src/tools/table/table-cell-blocks.ts +57 -18
  48. package/src/tools/table/table-core.ts +2 -0
  49. package/src/tools/table/table-corner-drag.ts +247 -0
  50. package/src/tools/table/table-operations.ts +41 -14
  51. package/src/tools/toggle/dom-builder.ts +1 -0
  52. package/src/tools/toggle/index.ts +25 -0
  53. package/src/tools/toggle/toggle-lifecycle.ts +5 -4
  54. package/src/types-internal/jsdom.d.ts +9 -0
  55. package/types/tools/adapters/block-tool-adapter.d.ts +6 -0
  56. package/types/tools/block-tool.d.ts +10 -0
  57. package/bin/blok.mjs +0 -10
  58. package/dist/cli.mjs +0 -37
  59. package/src/tools/code/language-picker.ts +0 -241
@@ -220,6 +220,16 @@ export const DATA_ATTR = {
220
220
  /** Active placeholder text */
221
221
  placeholderActive: 'data-blok-placeholder-active',
222
222
 
223
+ // ============================================
224
+ // Nested Blocks
225
+ // ============================================
226
+
227
+ /** Container that hosts nested block holders (table cells, toggle/callout/header children).
228
+ * Used as a universal guard: before moving a block holder via appendChild,
229
+ * check `holder.closest([nestedBlocks])` — if truthy, the holder is already
230
+ * claimed by another container and must not be stolen. */
231
+ nestedBlocks: 'data-blok-nested-blocks',
232
+
223
233
  // ============================================
224
234
  // Mutation Tracking
225
235
  // ============================================
@@ -95,6 +95,22 @@ export const IconPlus = `
95
95
  </svg>
96
96
  `;
97
97
 
98
+ // Chevron Down icon
99
+ export const IconChevronDown = `
100
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
101
+ <path d="m6 8 4 4 4-4" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
102
+ </svg>
103
+ `;
104
+
105
+ // Ellipsis (horizontal three dots) icon
106
+ export const IconEllipsis = `
107
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
108
+ <circle cx="5" cy="10" r="1.25" fill="currentColor"/>
109
+ <circle cx="10" cy="10" r="1.25" fill="currentColor"/>
110
+ <circle cx="15" cy="10" r="1.25" fill="currentColor"/>
111
+ </svg>
112
+ `;
113
+
98
114
  // Chevron Left icon
99
115
  export const IconChevronLeft = `
100
116
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -344,6 +344,15 @@ export class KeyboardNavigation extends BlockEventComposer {
344
344
  // Previous sibling exists in the same parent — fall through to merge/remove logic
345
345
  }
346
346
 
347
+ /**
348
+ * Don't merge across table cell boundaries.
349
+ * When the caret is at the start of the first input in a table cell, Backspace should be a no-op
350
+ * rather than merging the previous cell's last block into the current cell.
351
+ */
352
+ if (this.isCurrentBlockInsideTableCell) {
353
+ return;
354
+ }
355
+
347
356
  /**
348
357
  * Backspace at the start of the first Block should do nothing
349
358
  */
@@ -447,6 +456,15 @@ export class KeyboardNavigation extends BlockEventComposer {
447
456
  return;
448
457
  }
449
458
 
459
+ /**
460
+ * Don't merge across table cell boundaries.
461
+ * When the caret is at the end of the last input in a table cell, Delete should be a no-op
462
+ * rather than merging the next cell's first block into the current cell.
463
+ */
464
+ if (this.isCurrentBlockInsideTableCell) {
465
+ return;
466
+ }
467
+
450
468
  /**
451
469
  * If next Block is empty, it should be removed just like a character
452
470
  */
@@ -4,6 +4,7 @@
4
4
  * @module BlockHierarchy
5
5
  */
6
6
  import type { Block } from '../../block';
7
+ import { DATA_ATTR } from '../../constants/data-attributes';
7
8
 
8
9
  import type { BlockRepository } from './repository';
9
10
 
@@ -113,7 +114,9 @@ export class BlockHierarchy {
113
114
 
114
115
  // Move block holder into toggle child container if the new parent has one,
115
116
  // honouring the flat-array order so the DOM order matches the logical order.
116
- if (newParentId !== null && newParent !== undefined) {
117
+ // Skip if the holder is already claimed by another nested-blocks container
118
+ // (e.g. a table cell) — moving it would steal it from that container.
119
+ if (newParentId !== null && newParent !== undefined && !block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
117
120
  const newContainer = newParent.holder.querySelector('[data-blok-toggle-children]');
118
121
  if (newContainer) {
119
122
  const allBlocks = this.repository.blocks;
@@ -16,6 +16,11 @@ export class ReadOnly extends Module {
16
16
  */
17
17
  private toolsDontSupportReadOnly: string[] = [];
18
18
 
19
+ /**
20
+ * Array of tools name which don't support in-place read-only toggle via setReadOnly()
21
+ */
22
+ private toolsDontSupportInPlaceToggle: string[] = [];
23
+
19
24
  /**
20
25
  * Value to track read-only state
21
26
  * @type {boolean}
@@ -29,6 +34,19 @@ export class ReadOnly extends Module {
29
34
  return this.readOnlyEnabled;
30
35
  }
31
36
 
37
+ /**
38
+ * Whether tool support for in-place toggle has been checked during prepare()
39
+ */
40
+ private inPlaceToggleChecked = false;
41
+
42
+ /**
43
+ * Whether all registered block tools support in-place read-only toggle.
44
+ * Returns false until prepare() has run the check.
45
+ */
46
+ private get supportsInPlaceToggle(): boolean {
47
+ return this.inPlaceToggleChecked && this.toolsDontSupportInPlaceToggle.length === 0;
48
+ }
49
+
32
50
  /**
33
51
  * Set initial state
34
52
  */
@@ -43,9 +61,13 @@ export class ReadOnly extends Module {
43
61
  if (!tool.isReadOnlySupported) {
44
62
  toolsDontSupportReadOnly.push(name);
45
63
  }
64
+ if (!tool.supportsInPlaceReadOnly) {
65
+ this.toolsDontSupportInPlaceToggle.push(name);
66
+ }
46
67
  });
47
68
 
48
69
  this.toolsDontSupportReadOnly = toolsDontSupportReadOnly;
70
+ this.inPlaceToggleChecked = true;
49
71
 
50
72
  if (this.config.readOnly === true && toolsDontSupportReadOnly.length > 0) {
51
73
  this.throwCriticalError();
@@ -101,6 +123,24 @@ export class ReadOnly extends Module {
101
123
  return this.readOnlyEnabled;
102
124
  }
103
125
 
126
+ /**
127
+ * If all tools support in-place toggle, call setReadOnly on each block
128
+ * instead of the full save/clear/render cycle
129
+ */
130
+ if (this.supportsInPlaceToggle) {
131
+ this.Blok.ModificationsObserver.disable();
132
+
133
+ const blocks = (this.Blok.BlockManager as { blocks?: Array<{ setReadOnly: (s: boolean) => void }> }).blocks ?? [];
134
+
135
+ for (const block of blocks) {
136
+ block.setReadOnly(state);
137
+ }
138
+
139
+ this.Blok.ModificationsObserver.enable();
140
+
141
+ return this.readOnlyEnabled;
142
+ }
143
+
104
144
  /**
105
145
  * Mutex for modifications observer to prevent onChange call when read-only mode is enabled
106
146
  */
@@ -117,6 +157,8 @@ export class ReadOnly extends Module {
117
157
  return this.readOnlyEnabled;
118
158
  }
119
159
 
160
+ const savedScrollY = window.scrollY;
161
+
120
162
  this.Blok.Renderer.markRenderStart();
121
163
 
122
164
  try {
@@ -126,6 +168,10 @@ export class ReadOnly extends Module {
126
168
  this.Blok.Renderer.markRenderEnd();
127
169
  }
128
170
 
171
+ if (window.scrollY !== savedScrollY) {
172
+ window.scrollTo(0, savedScrollY);
173
+ }
174
+
129
175
  this.Blok.ModificationsObserver.enable();
130
176
 
131
177
  return this.readOnlyEnabled;
@@ -72,6 +72,14 @@ export class RectangleSelection extends Module {
72
72
  */
73
73
  private mouseDownWithinBoundsFromContentEditable = false;
74
74
 
75
+ /**
76
+ * Set when the user mousedowns in a position that should eventually close the toolbar
77
+ * if a drag begins, but NOT immediately (to avoid interfering with button clicks like
78
+ * the plus button). The toolbar is closed on the first mousemove after mousedown.
79
+ * Cleared on mouseup / endSelection.
80
+ */
81
+ private pendingToolbarClose = false;
82
+
75
83
  /**
76
84
  * Is scrolling now
77
85
  */
@@ -187,7 +195,7 @@ export class RectangleSelection extends Module {
187
195
 
188
196
  const selectorsToAvoid = [
189
197
  createSelector(DATA_ATTR.elementContent),
190
- createSelector(DATA_ATTR.toolbar),
198
+ createSelector(DATA_ATTR.settingsToggler),
191
199
  createSelector(DATA_ATTR.popover),
192
200
  INLINE_TOOLBAR_INTERFACE_SELECTOR,
193
201
  ];
@@ -202,12 +210,13 @@ export class RectangleSelection extends Module {
202
210
  }
203
211
 
204
212
  /**
205
- * Hide the toolbar immediately so it does not obstruct drag selection.
206
- * Only close the toolbar if the selection starts within the horizontal bounds of the editor.
207
- * This allows rectangle selection to start from outside (e.g., left margin) without closing the toolbar.
213
+ * Schedule toolbar close for the first mousemove (i.e. when the user actually drags).
214
+ * Deferring prevents a plain click (e.g. on the plus button) from accidentally closing
215
+ * the toolbar before its own click handler fires.
216
+ * Only close if starting within the horizontal bounds of the editor.
208
217
  */
209
218
  if (withinEditorHorizontally) {
210
- this.Blok.Toolbar.close();
219
+ this.pendingToolbarClose = true;
211
220
  }
212
221
 
213
222
  this.mousedown = true;
@@ -221,6 +230,7 @@ export class RectangleSelection extends Module {
221
230
  public endSelection(): void {
222
231
  this.mousedown = false;
223
232
  this.mouseDownWithinBoundsFromContentEditable = false;
233
+ this.pendingToolbarClose = false;
224
234
  this.startX = 0;
225
235
  this.startY = 0;
226
236
  this.anchorBlockIndex = null;
@@ -361,6 +371,16 @@ export class RectangleSelection extends Module {
361
371
  this.Blok.Toolbar.close();
362
372
  }
363
373
 
374
+ /**
375
+ * Close the toolbar on the first actual drag movement after a mousedown
376
+ * that was scheduled to close it. Using mousemove instead of mousedown ensures
377
+ * plain button clicks (e.g. the plus button) don't close the toolbar prematurely.
378
+ */
379
+ if (this.pendingToolbarClose) {
380
+ this.pendingToolbarClose = false;
381
+ this.Blok.Toolbar.close();
382
+ }
383
+
364
384
  this.changingRectangle(mouseEvent);
365
385
  this.scrollByZones(mouseEvent.clientY);
366
386
  }
@@ -289,11 +289,17 @@ export class Toolbar extends Module<ToolbarNodes> {
289
289
  // eslint-disable-next-line @typescript-eslint/no-deprecated
290
290
  this.nodes.actions?.classList.remove(this.CSS.actionsOpened);
291
291
  this.nodes.actions?.removeAttribute('data-blok-opened');
292
+ if (this.nodes.actions) {
293
+ this.nodes.actions.style.pointerEvents = 'none';
294
+ }
292
295
  },
293
296
  show: (): void => {
294
297
  // eslint-disable-next-line @typescript-eslint/no-deprecated
295
298
  this.nodes.actions?.classList.add(this.CSS.actionsOpened);
296
299
  this.nodes.actions?.setAttribute('data-blok-opened', 'true');
300
+ if (this.nodes.actions) {
301
+ this.nodes.actions.style.pointerEvents = 'auto';
302
+ }
297
303
  },
298
304
  };
299
305
  }
@@ -421,8 +427,11 @@ export class Toolbar extends Module<ToolbarNodes> {
421
427
  */
422
428
  const focusIsInsideCell = this.isFocusInsideTableCell();
423
429
  const isCalloutFirstChild = this.isFirstChildOfCallout(targetBlock);
430
+ const isCalloutBlock = targetBlock.name === 'callout';
424
431
 
425
- plusButton.style.display = isCalloutFirstChild ? 'none' : '';
432
+ // Hide plus button for callout blocks and their first children to avoid
433
+ // overlap with the callout emoji icon in the left padding area.
434
+ plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
426
435
 
427
436
  if (settingsToggler) {
428
437
  settingsToggler.style.display = (focusIsInsideCell || isCalloutFirstChild) ? 'none' : '';
@@ -478,15 +487,6 @@ export class Toolbar extends Module<ToolbarNodes> {
478
487
 
479
488
  if (blockContentElement) {
480
489
  this.toolboxInstance.updateLeftAlignElement(blockContentElement);
481
-
482
- /**
483
- * Sync toolbar content wrapper's margin with the block content element
484
- * so toolbar buttons align with the block content edge, even when
485
- * consumer CSS overrides the block content's margin.
486
- */
487
- if (this.nodes.content) {
488
- this.nodes.content.style.marginLeft = getComputedStyle(blockContentElement).marginLeft;
489
- }
490
490
  }
491
491
 
492
492
  /**
@@ -507,6 +507,39 @@ export class Toolbar extends Module<ToolbarNodes> {
507
507
  }
508
508
 
509
509
  this.open();
510
+
511
+ /**
512
+ * For blocks with interactive elements at the left edge (toggle arrows,
513
+ * callout emoji buttons), disable pointer-events on the actions
514
+ * container so clicks pass through to the block content.
515
+ * Must run after open() which sets pointer-events: auto on actions.
516
+ */
517
+ const isToggleHeader = targetBlock.name === 'header'
518
+ && targetBlock.holder.querySelector('[data-blok-toggle-arrow]') !== null;
519
+ const hasLeftEdgeInteraction = targetBlock.name === 'callout'
520
+ || targetBlock.name === 'toggle'
521
+ || isToggleHeader;
522
+
523
+ if (hasLeftEdgeInteraction && this.nodes.actions) {
524
+ this.nodes.actions.style.pointerEvents = 'none';
525
+ this.restoreSettingsTogglerForLeftEdgeBlock(targetBlock);
526
+ }
527
+
528
+ /**
529
+ * Sync toolbar content wrapper's margin with the block content element
530
+ * so toolbar buttons align with the block content edge, even when
531
+ * consumer CSS overrides the block content's margin.
532
+ *
533
+ * Uses Math.max to guarantee the actions container (positioned via right:100%)
534
+ * never extends beyond the left edge of the viewport, which would make the
535
+ * drag handle unreachable by pointer events.
536
+ */
537
+ if (blockContentElement && this.nodes.content) {
538
+ const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
539
+ const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
540
+
541
+ this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
542
+ }
510
543
  }
511
544
 
512
545
  /**
@@ -607,18 +640,10 @@ export class Toolbar extends Module<ToolbarNodes> {
607
640
  targetBlock.setupDraggable(settingsToggler, this.Blok.DragManager);
608
641
  }
609
642
 
610
- /**
611
- * Sync toolbar content wrapper's margin with the block content element
612
- * so toolbar buttons align with the block content edge.
613
- */
614
643
  const blockContentElement = targetBlockHolder.querySelector<HTMLElement>(`[${DATA_ATTR.elementContent}]`);
615
644
 
616
645
  if (blockContentElement) {
617
646
  this.toolboxInstance.updateLeftAlignElement(blockContentElement);
618
-
619
- if (this.nodes.content) {
620
- this.nodes.content.style.marginLeft = getComputedStyle(blockContentElement).marginLeft;
621
- }
622
647
  }
623
648
 
624
649
  /**
@@ -632,6 +657,17 @@ export class Toolbar extends Module<ToolbarNodes> {
632
657
  this.blockTunesToggler.show();
633
658
 
634
659
  this.open();
660
+
661
+ /**
662
+ * Sync toolbar content wrapper's margin with the block content element.
663
+ * Clamp to actionsWidth so actions never extend beyond the left viewport edge.
664
+ */
665
+ if (blockContentElement && this.nodes.content) {
666
+ const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
667
+ const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
668
+
669
+ this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
670
+ }
635
671
  }
636
672
 
637
673
  /**
@@ -822,14 +858,48 @@ export class Toolbar extends Module<ToolbarNodes> {
822
858
 
823
859
  const focusIsInsideCell = this.isFocusInsideTableCell();
824
860
  const isCalloutFirstChild = this.hoveredBlock !== null && this.isFirstChildOfCallout(this.hoveredBlock);
861
+ const isCalloutBlock = this.hoveredBlock?.name === 'callout';
825
862
 
826
- plusButton.style.display = isCalloutFirstChild ? 'none' : '';
863
+ plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
827
864
 
828
865
  if (settingsToggler) {
829
866
  settingsToggler.style.display = (focusIsInsideCell || isCalloutFirstChild) ? 'none' : '';
830
867
  }
831
868
  }
832
869
 
870
+ /**
871
+ * Re-enables pointer-events on the settings toggler (and callout drag zone) after
872
+ * the actions container has been set to pointer-events: none for left-edge blocks.
873
+ *
874
+ * For callout blocks: also wires the dedicated drag zone as the drag handle and
875
+ * re-enables the settings toggler so the settings menu remains accessible.
876
+ * The emoji button (at x=32px) no longer overlaps the actions zone (x=[0,29px])
877
+ * because the callout uses pl-8 (32px) left padding.
878
+ *
879
+ * For all other left-edge blocks (toggle, header with arrow): simply re-enables the
880
+ * settings toggler so it continues to function as the drag handle.
881
+ */
882
+ private restoreSettingsTogglerForLeftEdgeBlock(targetBlock: Block): void {
883
+ if (targetBlock.name === 'callout') {
884
+ if (this.nodes.settingsToggler) {
885
+ this.nodes.settingsToggler.style.pointerEvents = 'auto';
886
+ }
887
+
888
+ const calloutDragZone = targetBlock.holder.querySelector<HTMLElement>('[data-callout-drag-zone]');
889
+
890
+ if (calloutDragZone) {
891
+ calloutDragZone.style.pointerEvents = 'auto';
892
+ targetBlock.setupDraggable(calloutDragZone, this.Blok.DragManager);
893
+ }
894
+
895
+ return;
896
+ }
897
+
898
+ if (this.nodes.settingsToggler) {
899
+ this.nodes.settingsToggler.style.pointerEvents = 'auto';
900
+ }
901
+ }
902
+
833
903
  /**
834
904
  * If the block is inside a table cell, resolve to the parent table block.
835
905
  * This ensures the toolbar shows for the table when clicking/focusing inside cells.
@@ -915,6 +985,13 @@ export class Toolbar extends Module<ToolbarNodes> {
915
985
  this.CSS.actions,
916
986
  ]);
917
987
 
988
+ /**
989
+ * Start with pointer-events disabled so invisible (opacity-0) actions
990
+ * don't intercept clicks on elements underneath (e.g. toggle arrows).
991
+ * blockActions.show()/hide() toggles this inline style.
992
+ */
993
+ actions.style.pointerEvents = 'none';
994
+
918
995
  this.nodes.content = content;
919
996
 
920
997
  this.nodes.actions = actions;
@@ -19,8 +19,6 @@ export const getToolbarStyles = (): { [name: string]: string } => {
19
19
  actions: twJoin(
20
20
  'absolute flex opacity-0 pr-[5px]',
21
21
  'right-full',
22
- // Re-enable pointer events for interactive elements
23
- 'pointer-events-auto',
24
22
  // Mobile styles
25
23
  'mobile:right-auto',
26
24
  // RTL styles
@@ -34,6 +34,13 @@ export class BlockHoverController extends Controller {
34
34
  */
35
35
  private static readonly HOVER_COOLDOWN_MS = 50;
36
36
 
37
+ /**
38
+ * Maximum horizontal distance from content edges for extended hover zone.
39
+ * When cursor is within this distance of the content area, nearest-block
40
+ * detection activates. Beyond this distance, no hover event is emitted.
41
+ */
42
+ private static readonly HOVER_ZONE_SIZE = 100;
43
+
37
44
  constructor(options: {
38
45
  config: Controller['config'];
39
46
  eventsDispatcher: Controller['eventsDispatcher'];
@@ -104,9 +111,10 @@ export class BlockHoverController extends Controller {
104
111
 
105
112
  /**
106
113
  * If no block element found directly, find the nearest block by Y distance
114
+ * but only if the cursor is within the extended hover zone (100px from content edges).
107
115
  */
108
116
  if (!hoveredBlockElement) {
109
- this.emitNearestBlockHovered(event.clientY);
117
+ this.emitNearestBlockHoveredInZone(event.clientX, event.clientY);
110
118
 
111
119
  return;
112
120
  }
@@ -169,6 +177,41 @@ export class BlockHoverController extends Controller {
169
177
  });
170
178
  }
171
179
 
180
+ /**
181
+ * Emits a BlockHovered event for the nearest block, but only if the cursor
182
+ * is within the extended hover zone (HOVER_ZONE_SIZE px from content edges).
183
+ * @param clientX - Cursor X position
184
+ * @param clientY - Cursor Y position
185
+ */
186
+ private emitNearestBlockHoveredInZone(clientX: number, clientY: number): void {
187
+ const blocks = this.Blok.BlockManager.blocks;
188
+ const topLevelBlocks = blocks.filter(block =>
189
+ block.holder.closest('[data-blok-table-cell-blocks], [data-blok-toggle-children]') === null
190
+ );
191
+
192
+ if (topLevelBlocks.length === 0) {
193
+ return;
194
+ }
195
+
196
+ const contentEl = topLevelBlocks[0].holder.querySelector<HTMLElement>('[data-blok-element-content]');
197
+
198
+ if (!contentEl) {
199
+ this.emitNearestBlockHovered(clientY);
200
+
201
+ return;
202
+ }
203
+
204
+ const contentRect = contentEl.getBoundingClientRect();
205
+ const distLeft = Math.abs(clientX - contentRect.left);
206
+ const distRight = Math.abs(clientX - contentRect.right);
207
+ const withinZone = distLeft <= BlockHoverController.HOVER_ZONE_SIZE
208
+ || distRight <= BlockHoverController.HOVER_ZONE_SIZE;
209
+
210
+ if (withinZone) {
211
+ this.emitNearestBlockHovered(clientY);
212
+ }
213
+ }
214
+
172
215
  /**
173
216
  * Finds the nearest block by vertical distance to cursor position.
174
217
  * Returns the block whose vertical center is closest to the cursor Y position.
@@ -69,6 +69,16 @@ export class BlockToolAdapter extends BaseToolAdapter<ToolType.Block, IBlockTool
69
69
  return (this.constructable as BlockToolConstructable)[InternalBlockToolSettings.IsReadOnlySupported] === true;
70
70
  }
71
71
 
72
+ /**
73
+ * Returns true if the Tool's prototype has a setReadOnly method,
74
+ * enabling the in-place read-only toggle path (no save/clear/render cycle).
75
+ */
76
+ public get supportsInPlaceReadOnly(): boolean {
77
+ const prototype = (this.constructable as unknown as { prototype?: { setReadOnly?: unknown } })?.prototype;
78
+
79
+ return typeof prototype?.setReadOnly === 'function';
80
+ }
81
+
72
82
  /**
73
83
  * Returns true if Tool supports linebreaks
74
84
  */
@@ -132,11 +132,18 @@ export const setupPlaceholder = (
132
132
  element: HTMLElement,
133
133
  placeholder?: string,
134
134
  attributeName: 'data-placeholder' | 'data-blok-placeholder-active' = 'data-placeholder'
135
- ): void => {
135
+ ): (() => void) => {
136
136
  // Always set the attribute, even if empty (for consistency and testing)
137
137
  element.setAttribute(attributeName, placeholder ?? '');
138
138
 
139
- element.addEventListener('focus', () => handleEmptyElement(element));
139
+ const handler = (): void => handleEmptyElement(element);
140
+
141
+ element.addEventListener('focus', handler);
142
+
143
+ return () => {
144
+ element.removeEventListener('focus', handler);
145
+ element.removeAttribute(attributeName);
146
+ };
140
147
  };
141
148
 
142
149
  /**
@@ -644,6 +644,10 @@
644
644
  --color-swatch-ring-hover: var(--blok-swatch-ring-hover);
645
645
  --color-swatch-ring-active: var(--blok-swatch-ring-active);
646
646
 
647
+ /* Surface tokens (code blocks, secondary containers) */
648
+ --color-bg-secondary: var(--blok-bg-secondary);
649
+ --color-border-secondary: var(--blok-border-secondary);
650
+
647
651
  }
648
652
 
649
653
  @layer utilities {
@@ -725,6 +729,10 @@
725
729
  --blok-swatch-ring-hover: rgba(0, 0, 0, 0.10);
726
730
  --blok-swatch-ring-active: rgba(0, 0, 0, 0.30);
727
731
 
732
+ /* Surface tokens (code blocks, secondary containers) */
733
+ --blok-bg-secondary: #f7f8fa;
734
+ --blok-border-secondary: rgba(55, 53, 47, 0.09);
735
+
728
736
  /* Marker colors — light theme */
729
737
  --blok-color-gray-text: #787774;
730
738
  --blok-color-gray-bg: #f1f1ef;
@@ -818,6 +826,10 @@
818
826
  --blok-swatch-ring-hover: rgba(255, 255, 255, 0.15);
819
827
  --blok-swatch-ring-active: rgba(255, 255, 255, 0.35);
820
828
 
829
+ /* Surface tokens (code blocks, secondary containers) */
830
+ --blok-bg-secondary: rgba(255, 255, 255, 0.04);
831
+ --blok-border-secondary: rgba(255, 255, 255, 0.08);
832
+
821
833
  /* Marker colors — dark theme */
822
834
  --blok-color-gray-text: #9b9b9b;
823
835
  --blok-color-gray-bg: #2f2f2f;
@@ -910,6 +922,10 @@
910
922
  --blok-swatch-ring-hover: rgba(255, 255, 255, 0.15);
911
923
  --blok-swatch-ring-active: rgba(255, 255, 255, 0.35);
912
924
 
925
+ /* Surface tokens (code blocks, secondary containers) */
926
+ --blok-bg-secondary: rgba(255, 255, 255, 0.04);
927
+ --blok-border-secondary: rgba(255, 255, 255, 0.08);
928
+
913
929
  /* Marker colors — dark theme */
914
930
  --blok-color-gray-text: #9b9b9b;
915
931
  --blok-color-gray-bg: #2f2f2f;
@@ -25,7 +25,8 @@ export const EMOJI_CATEGORY_FLAGS_KEY = 'tools.callout.emojiCategoryFlags';
25
25
  export const DEFAULT_EMOJI = '💡';
26
26
 
27
27
  // CSS — Tailwind classes
28
- export const WRAPPER_STYLES = 'rounded-xl px-4 py-[5px] my-1 flex items-start gap-2';
28
+ export const WRAPPER_STYLES = 'rounded-xl pl-8 pr-4 py-[5px] my-1 flex items-start gap-2 relative';
29
29
  // h-[38px] = py-[7px]×2 + 1.5rem×1 = 14+24; explicit height prevents platform-specific emoji font metrics from inflating the button
30
30
  export const EMOJI_BUTTON_STYLES = 'text-[1.5rem] leading-[1] cursor-pointer bg-transparent border-0 px-0 py-[7px] h-[38px] flex-shrink-0 select-none';
31
31
  export const CHILDREN_STYLES = 'flex-1 min-w-0';
32
+ export const DRAG_ZONE_STYLES = 'absolute left-0 top-0 h-full cursor-grab select-none';
@@ -1,16 +1,19 @@
1
1
  // src/tools/callout/dom-builder.ts
2
2
 
3
+ import { DATA_ATTR } from '../../components/constants/data-attributes';
3
4
  import { TOGGLE_ATTR } from '../toggle/constants';
4
5
  import {
5
6
  WRAPPER_STYLES,
6
7
  EMOJI_BUTTON_STYLES,
7
8
  CHILDREN_STYLES,
9
+ DRAG_ZONE_STYLES,
8
10
  } from './constants';
9
11
 
10
12
  export interface CalloutDOMRefs {
11
13
  wrapper: HTMLElement;
12
14
  emojiButton: HTMLButtonElement;
13
15
  childContainer: HTMLElement;
16
+ dragZone: HTMLElement;
14
17
  }
15
18
 
16
19
  export interface BuildCalloutDOMOptions {
@@ -43,11 +46,20 @@ export function buildCalloutDOM(options: BuildCalloutDOMOptions): CalloutDOMRefs
43
46
  const childContainer = document.createElement('div');
44
47
  childContainer.className = CHILDREN_STYLES;
45
48
  childContainer.setAttribute(TOGGLE_ATTR.toggleChildren, '');
49
+ childContainer.setAttribute(DATA_ATTR.nestedBlocks, '');
46
50
  childContainer.setAttribute('data-blok-child-toolbar', '');
47
51
  childContainer.setAttribute('data-blok-mutation-free', 'true');
48
52
 
49
53
  wrapper.appendChild(emojiButton);
50
54
  wrapper.appendChild(childContainer);
51
55
 
52
- return { wrapper, emojiButton, childContainer };
56
+ // Drag zone — covers left padding area (x=[0,16px]) for drag handle,
57
+ // sits behind emoji button so emoji clicks pass through
58
+ const dragZone = document.createElement('span');
59
+ dragZone.className = DRAG_ZONE_STYLES;
60
+ dragZone.style.width = '32px'; // matches pl-8 left padding
61
+ dragZone.setAttribute('data-callout-drag-zone', '');
62
+ wrapper.prepend(dragZone);
63
+
64
+ return { wrapper, emojiButton, childContainer, dragZone };
53
65
  }