@jackuait/blok 0.10.0-beta.13 → 0.10.0-beta.15

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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Preprocess old knowledgebase HTML before block conversion.
3
+ *
4
+ * Fixes issues from the old KB's Summernote editor:
5
+ * 1. `<div style="background: rgb(...)">` callout-like blocks → `<aside>`
6
+ * 2. White/transparent `background-color` on inline elements → stripped
7
+ * 3. Multiple `<p>` inside table cells → `<br>`-separated content
8
+ * 4. `<p>&nbsp;</p>` visual spacers → removed
9
+ * 5. `<del>`/`<strike>` → `<s>` (Blok only recognises `<s>`)
10
+ * 6. `<p>• text</p>` pseudo-lists → `<ul><li>text</li></ul>`
11
+ *
12
+ * Adapted from the knowledgebase frontend's preprocessKnowledgebaseHtml
13
+ * for use with jsdom's DOM API in a Node.js CLI tool.
14
+ */
15
+ export function preprocess(wrapper: HTMLElement): void {
16
+ convertBackgroundDivsToCallouts(wrapper);
17
+ stripSpuriousBackgroundColors(wrapper);
18
+ convertTableCellParagraphs(wrapper);
19
+ stripNbspOnlyParagraphs(wrapper);
20
+ convertStrikethroughTags(wrapper);
21
+ convertBulletParagraphsToLists(wrapper);
22
+ }
23
+
24
+ /**
25
+ * Convert block-level `<div>` elements with background colors to `<aside>`.
26
+ *
27
+ * Skips divs inside tables and those with white/transparent backgrounds.
28
+ * Unwraps non-semantic inner `<div>` wrappers and strips trailing `<br>`
29
+ * inside paragraphs.
30
+ */
31
+ function convertBackgroundDivsToCallouts(wrapper: HTMLElement): void {
32
+ const doc = wrapper.ownerDocument;
33
+
34
+ for (const div of Array.from(wrapper.querySelectorAll<HTMLElement>('div[style]'))) {
35
+ if (div.closest('table')) {
36
+ continue;
37
+ }
38
+
39
+ const bgColor = getBackgroundColor(div);
40
+
41
+ if (!bgColor || isSpuriousBackgroundColor(bgColor)) {
42
+ continue;
43
+ }
44
+
45
+ const aside = doc.createElement('aside');
46
+
47
+ aside.style.backgroundColor = bgColor;
48
+ aside.append(...Array.from(div.childNodes));
49
+
50
+ // Unwrap non-semantic <div> wrappers so the aside's direct children are
51
+ // the content elements (<p>, <a>, etc.), not intermediate <div> shells.
52
+ let bareDivs: HTMLElement[];
53
+
54
+ while ((bareDivs = Array.from(aside.querySelectorAll<HTMLElement>(':scope > div'))
55
+ .filter((d) => !d.getAttribute('style') && !d.getAttribute('class'))).length > 0) {
56
+ for (const child of bareDivs) {
57
+ child.replaceWith(...Array.from(child.childNodes));
58
+ }
59
+ }
60
+
61
+ // Strip trailing <br> inside paragraphs — the paste handler splits
62
+ // content at <br> boundaries, so a trailing one creates an empty block.
63
+ for (const p of Array.from(aside.querySelectorAll('p'))) {
64
+ const lastChild = p.lastElementChild;
65
+
66
+ if (lastChild?.tagName === 'BR') {
67
+ lastChild.remove();
68
+ }
69
+ }
70
+
71
+ div.replaceWith(aside);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Minimum channel brightness for a colour to be considered "near-white".
77
+ *
78
+ * Any `rgb(r, g, b)` where all three channels are >= this value is stripped.
79
+ */
80
+ const NEAR_WHITE_MIN_CHANNEL = 250;
81
+
82
+ /**
83
+ * Remove white/transparent `background-color` from inline elements.
84
+ *
85
+ * After stripping the property, empty wrapper elements (no remaining styles
86
+ * and no text) are unwrapped.
87
+ */
88
+ function stripSpuriousBackgroundColors(wrapper: HTMLElement): void {
89
+ const candidates = wrapper.querySelectorAll<HTMLElement>('[style*="background-color"]');
90
+
91
+ for (const el of Array.from(candidates)) {
92
+ if (isSpuriousBackgroundColor(el.style.backgroundColor)) {
93
+ el.style.removeProperty('background-color');
94
+
95
+ if (el.getAttribute('style')?.trim() === '') {
96
+ el.removeAttribute('style');
97
+ }
98
+
99
+ if (isEmptyWrapper(el)) {
100
+ el.replaceWith(...Array.from(el.childNodes));
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Check whether a computed `background-color` value is visually invisible
108
+ * (white, near-white, or transparent).
109
+ */
110
+ function isSpuriousBackgroundColor(value: string): boolean {
111
+ if (!value) {
112
+ return false;
113
+ }
114
+
115
+ const normalised = value.replace(/\s/g, '').toLowerCase();
116
+
117
+ if (normalised === 'transparent') {
118
+ return true;
119
+ }
120
+
121
+ const rgbaMatch = normalised.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([^)]+))?\)$/);
122
+
123
+ if (!rgbaMatch) {
124
+ return false;
125
+ }
126
+
127
+ const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
128
+
129
+ if (alpha === 0) {
130
+ return true;
131
+ }
132
+
133
+ const r = parseInt(rgbaMatch[1], 10);
134
+ const g = parseInt(rgbaMatch[2], 10);
135
+ const b = parseInt(rgbaMatch[3], 10);
136
+
137
+ return r >= NEAR_WHITE_MIN_CHANNEL && g >= NEAR_WHITE_MIN_CHANNEL && b >= NEAR_WHITE_MIN_CHANNEL;
138
+ }
139
+
140
+ /**
141
+ * Check whether an element is a pure wrapper with no semantic value
142
+ * (no attributes and no text content, or just whitespace).
143
+ */
144
+ function isEmptyWrapper(el: HTMLElement): boolean {
145
+ if (el.attributes.length > 0) {
146
+ return false;
147
+ }
148
+
149
+ const text = (el.textContent ?? '').replace(/[\s\u00A0]/g, '');
150
+
151
+ return text.length === 0;
152
+ }
153
+
154
+ /**
155
+ * Extract `background-color` from an element's style attribute.
156
+ *
157
+ * Handles both `background-color:` and `background:` (shorthand) properties.
158
+ * Uses the element's computed style property first, falling back to manual
159
+ * parsing of the style attribute for shorthand notation.
160
+ */
161
+ function getBackgroundColor(el: HTMLElement): string {
162
+ // Try the direct property first
163
+ if (el.style.backgroundColor) {
164
+ return el.style.backgroundColor;
165
+ }
166
+
167
+ // Fall back to parsing the style attribute for shorthand `background:`
168
+ const styleAttr = el.getAttribute('style') ?? '';
169
+ const match = styleAttr.match(/background:\s*([^;]+)/i);
170
+
171
+ if (match) {
172
+ const value = match[1].trim();
173
+ // Only return color values, not url() or other background sub-properties
174
+ const colorMatch = value.match(/^(rgb[a]?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-z]+)$/i);
175
+
176
+ if (colorMatch) {
177
+ return colorMatch[1];
178
+ }
179
+ }
180
+
181
+ return '';
182
+ }
183
+
184
+ /**
185
+ * Convert `<p>` boundaries to `<br>` line breaks inside table cells.
186
+ *
187
+ * Only targets `<td>` and `<th>` — top-level `<p>` tags are left intact.
188
+ */
189
+ function convertTableCellParagraphs(wrapper: HTMLElement): void {
190
+ const doc = wrapper.ownerDocument;
191
+
192
+ for (const cell of Array.from(wrapper.querySelectorAll('td, th'))) {
193
+ const paragraphs = cell.querySelectorAll('p');
194
+
195
+ if (paragraphs.length === 0) {
196
+ continue;
197
+ }
198
+
199
+ for (const p of Array.from(paragraphs)) {
200
+ if (p.innerHTML.trim() === '' || p.innerHTML.trim() === '&nbsp;') {
201
+ p.remove();
202
+ continue;
203
+ }
204
+
205
+ const fragment = doc.createDocumentFragment();
206
+
207
+ fragment.append(...Array.from(p.childNodes));
208
+ fragment.append(doc.createElement('br'));
209
+ p.replaceWith(fragment);
210
+ }
211
+
212
+ stripTrailingBreaks(cell);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Remove paragraphs whose only content is non-breaking spaces or whitespace.
218
+ */
219
+ function stripNbspOnlyParagraphs(wrapper: HTMLElement): void {
220
+ for (const p of Array.from(wrapper.querySelectorAll('p'))) {
221
+ // Skip paragraphs inside table cells — those are handled by convertTableCellParagraphs
222
+ if (p.closest('td') || p.closest('th')) {
223
+ continue;
224
+ }
225
+
226
+ const textContent = p.textContent ?? '';
227
+ const stripped = textContent.replace(/[\s\u00A0]/g, '');
228
+
229
+ if (stripped.length === 0) {
230
+ p.remove();
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Convert `<del>` and `<strike>` elements to `<s>`.
237
+ */
238
+ function convertStrikethroughTags(wrapper: HTMLElement): void {
239
+ const doc = wrapper.ownerDocument;
240
+
241
+ for (const el of Array.from(wrapper.querySelectorAll('del, strike'))) {
242
+ const replacement = doc.createElement('s');
243
+
244
+ replacement.append(...Array.from(el.childNodes));
245
+ el.replaceWith(replacement);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Bullet characters that indicate a pseudo-list paragraph.
251
+ *
252
+ * Matches: `\u2022` (bullet), `\u00B7` (middle dot), or `- ` (hyphen + space).
253
+ */
254
+ const BULLET_PREFIX = /^[\u2022\u00B7][\s\u00A0]*|^-\s/;
255
+
256
+ /**
257
+ * Convert `<p>• text</p>` pseudo-lists into proper `<ul><li>` markup.
258
+ *
259
+ * Groups consecutive bullet paragraphs into a single `<ul>` and strips
260
+ * the bullet prefix. Only processes direct children of the wrapper.
261
+ */
262
+ function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
263
+ const doc = wrapper.ownerDocument;
264
+ const children = Array.from(wrapper.childNodes);
265
+ let currentList: HTMLUListElement | null = null;
266
+
267
+ for (const child of children) {
268
+ if (child.nodeType !== Node.ELEMENT_NODE) {
269
+ continue;
270
+ }
271
+
272
+ const el = child as HTMLElement;
273
+
274
+ if (el.tagName !== 'P') {
275
+ currentList = null;
276
+ continue;
277
+ }
278
+
279
+ const textContent = el.textContent ?? '';
280
+
281
+ if (!BULLET_PREFIX.test(textContent)) {
282
+ currentList = null;
283
+ continue;
284
+ }
285
+
286
+ if (!currentList) {
287
+ currentList = doc.createElement('ul');
288
+ el.before(currentList);
289
+ }
290
+
291
+ const li = doc.createElement('li');
292
+
293
+ // Strip the bullet character and any leading nbsp/whitespace from
294
+ // the first text node, preserving any inline HTML that follows.
295
+ const firstTextNode = findFirstTextNode(el);
296
+
297
+ if (firstTextNode) {
298
+ firstTextNode.textContent = (firstTextNode.textContent ?? '').replace(BULLET_PREFIX, '');
299
+ }
300
+
301
+ li.append(...Array.from(el.childNodes));
302
+ currentList.appendChild(li);
303
+ el.remove();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Remove trailing `<br>` elements and whitespace-only text nodes from the end
309
+ * of an element.
310
+ */
311
+ function stripTrailingBreaks(element: Element): void {
312
+ let node = element.lastChild;
313
+
314
+ while (node) {
315
+ if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR') {
316
+ const prev = node.previousSibling;
317
+
318
+ node.remove();
319
+ node = prev;
320
+ } else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
321
+ const prev = node.previousSibling;
322
+
323
+ node.remove();
324
+ node = prev;
325
+ } else {
326
+ break;
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Walk the DOM tree depth-first to find the first Text node.
333
+ */
334
+ function findFirstTextNode(node: Node): Text | null {
335
+ if (node.nodeType === Node.TEXT_NODE) {
336
+ return node as Text;
337
+ }
338
+
339
+ for (const child of Array.from(node.childNodes)) {
340
+ const found = findFirstTextNode(child);
341
+
342
+ if (found) {
343
+ return found;
344
+ }
345
+ }
346
+
347
+ return null;
348
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Whitelist of allowed tags and their allowed attributes.
3
+ * Tags not in this list are unwrapped (children preserved, tag removed).
4
+ */
5
+ const ALLOWED: Record<string, Set<string> | true> = {
6
+ // Inline
7
+ B: true,
8
+ STRONG: true,
9
+ I: true,
10
+ EM: true,
11
+ A: new Set(['href']),
12
+ S: true,
13
+ U: true,
14
+ CODE: true,
15
+ MARK: new Set(['style']),
16
+ BR: true,
17
+ // Block
18
+ P: true,
19
+ H1: true, H2: true, H3: true, H4: true, H5: true, H6: true,
20
+ UL: true,
21
+ OL: true,
22
+ LI: new Set(['aria-level']),
23
+ TABLE: true, THEAD: true, TBODY: true, TR: true,
24
+ TD: new Set(['style']),
25
+ TH: new Set(['style']),
26
+ BLOCKQUOTE: true,
27
+ PRE: true,
28
+ HR: true,
29
+ ASIDE: new Set(['style']),
30
+ DETAILS: true,
31
+ SUMMARY: true,
32
+ IMG: new Set(['src', 'style']),
33
+ };
34
+
35
+ /**
36
+ * Sanitize DOM tree in place. Removes disallowed tags (unwrapping children)
37
+ * and strips disallowed attributes from allowed tags.
38
+ */
39
+ export function sanitize(wrapper: HTMLElement): void {
40
+ sanitizeNode(wrapper);
41
+ }
42
+
43
+ function sanitizeNode(node: Node): void {
44
+ // Use a live-like approach: collect children, then process each.
45
+ // When a child is unwrapped its grandchildren are inserted in place and
46
+ // must themselves be processed as children of the same parent.
47
+ const queue = Array.from(node.childNodes);
48
+
49
+ for (const child of queue) {
50
+ if (child.nodeType !== child.ELEMENT_NODE) {
51
+ continue;
52
+ }
53
+
54
+ const el = child as HTMLElement;
55
+ const tag = el.tagName;
56
+ const allowedAttrs = ALLOWED[tag];
57
+
58
+ if (allowedAttrs === undefined) {
59
+ // Unwrap: move children to the parent, then remove this element.
60
+ const grandchildren = Array.from(el.childNodes);
61
+
62
+ for (const gc of grandchildren) {
63
+ el.before(gc);
64
+ }
65
+
66
+ el.remove();
67
+
68
+ // Push the moved grandchildren onto the queue so they are evaluated
69
+ // for unwrapping / attribute-stripping in the same parent context.
70
+ for (const gc of grandchildren) {
71
+ if (gc.nodeType === gc.ELEMENT_NODE) {
72
+ queue.push(gc);
73
+ }
74
+ }
75
+ } else {
76
+ // Strip disallowed attributes.
77
+ if (allowedAttrs !== true) {
78
+ for (const attr of Array.from(el.attributes)) {
79
+ if (!allowedAttrs.has(attr.name)) {
80
+ el.removeAttribute(attr.name);
81
+ }
82
+ }
83
+ } else {
84
+ // true means no attributes allowed — strip all.
85
+ for (const attr of Array.from(el.attributes)) {
86
+ el.removeAttribute(attr.name);
87
+ }
88
+ }
89
+
90
+ // Recurse into children.
91
+ sanitizeNode(el);
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,20 @@
1
+ import * as fs from 'node:fs';
2
+ import { JSDOM } from 'jsdom';
3
+
4
+ // Provide DOM globals for convertHtml and block-builder
5
+ const dom = new JSDOM('');
6
+ globalThis.DOMParser = dom.window.DOMParser;
7
+ globalThis.Node = dom.window.Node;
8
+
9
+ async function main(): Promise<void> {
10
+ const { convertHtml } = await import('./index');
11
+ const html = fs.readFileSync('/dev/stdin', 'utf-8');
12
+ const json = convertHtml(html);
13
+
14
+ process.stdout.write(json);
15
+ }
16
+
17
+ main().catch((err) => {
18
+ process.stderr.write(`Error: ${(err as Error).message}\n`);
19
+ process.exitCode = 1;
20
+ });
@@ -0,0 +1,15 @@
1
+ export interface OutputBlockData {
2
+ id: string;
3
+ type: string;
4
+ data: Record<string, unknown>;
5
+ parent?: string;
6
+ content?: string[];
7
+ stretched?: number | null;
8
+ key?: string | null;
9
+ width?: number | null;
10
+ }
11
+
12
+ export interface OutputData {
13
+ version: string;
14
+ blocks: OutputBlockData[];
15
+ }
package/src/cli/index.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  import { getMigrationDoc } from './commands/migration';
2
2
  import { writeOutput } from './utils/output';
3
3
 
4
- const HELP_TEXT = `Usage: blok [options]
4
+ const HELP_TEXT = `Usage: blok-cli [options]
5
5
 
6
6
  Options:
7
+ --convert-html Convert legacy HTML from stdin to Blok JSON (stdout)
7
8
  --migration Output the EditorJS to Blok migration guide (LLM-friendly)
8
9
  --output <file> Write output to a file instead of stdout
9
10
  --help Show this help message
10
11
 
11
12
  Examples:
12
- npx @jackuait/blok --migration
13
- npx @jackuait/blok --migration | pbcopy
14
- npx @jackuait/blok --migration --output migration-guide.md
13
+ npx @jackuait/blok-cli --convert-html < article.html
14
+ npx @jackuait/blok-cli --convert-html < article.html --output article.json
15
+ npx @jackuait/blok-cli --migration
16
+ npx @jackuait/blok-cli --migration | pbcopy
17
+ npx @jackuait/blok-cli --migration --output migration-guide.md
15
18
  `;
16
19
 
17
20
  const parseArgs = (argv: string[]): { command: string | null; output?: string } => {
@@ -19,6 +22,13 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
19
22
  return { command: 'help' };
20
23
  }
21
24
 
25
+ if (argv.includes('--convert-html')) {
26
+ const outputIndex = argv.indexOf('--output');
27
+ const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
28
+
29
+ return { command: 'convert-html', output };
30
+ }
31
+
22
32
  if (argv.includes('--migration')) {
23
33
  const outputIndex = argv.indexOf('--output');
24
34
  const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
@@ -29,10 +39,25 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
29
39
  return { command: null };
30
40
  };
31
41
 
32
- export const run = (argv: string[], version: string): void => {
42
+ export const run = async (argv: string[], version: string): Promise<void> => {
33
43
  const { command, output } = parseArgs(argv);
34
44
 
35
45
  switch (command) {
46
+ case 'convert-html': {
47
+ const jsdom = await import('jsdom') as { JSDOM: new (html: string) => { window: typeof globalThis } };
48
+ const dom = new jsdom.JSDOM('');
49
+
50
+ globalThis.DOMParser = dom.window.DOMParser;
51
+ globalThis.Node = dom.window.Node;
52
+
53
+ const { convertHtml } = await import('./commands/convert-html/index');
54
+ const fs = await import('node:fs');
55
+ const html = fs.readFileSync('/dev/stdin', 'utf-8');
56
+ const json = convertHtml(html);
57
+
58
+ writeOutput(json, output);
59
+ break;
60
+ }
36
61
  case 'migration': {
37
62
  const content = getMigrationDoc(version);
38
63
 
@@ -505,6 +505,22 @@ export class Toolbar extends Module<ToolbarNodes> {
505
505
 
506
506
  this.open();
507
507
 
508
+ /**
509
+ * For blocks with interactive elements at the left edge (toggle arrows,
510
+ * callout emoji buttons), disable pointer-events on the actions
511
+ * container so clicks pass through to the block content.
512
+ * Must run after open() which sets pointer-events: auto on actions.
513
+ */
514
+ const isToggleHeader = targetBlock.name === 'header'
515
+ && targetBlock.holder.querySelector('[data-blok-toggle-arrow]') !== null;
516
+ const hasLeftEdgeInteraction = targetBlock.name === 'callout'
517
+ || targetBlock.name === 'toggle'
518
+ || isToggleHeader;
519
+
520
+ if (hasLeftEdgeInteraction && this.nodes.actions) {
521
+ this.nodes.actions.style.pointerEvents = 'none';
522
+ }
523
+
508
524
  /**
509
525
  * Sync toolbar content wrapper's margin with the block content element
510
526
  * so toolbar buttons align with the block content edge, even when
@@ -514,8 +530,9 @@ export class Toolbar extends Module<ToolbarNodes> {
514
530
  */
515
531
  if (blockContentElement && this.nodes.content) {
516
532
  const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
533
+ const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
517
534
 
518
- this.nodes.content.style.marginLeft = `${blockMarginLeft}px`;
535
+ this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
519
536
  }
520
537
  }
521
538
 
@@ -641,8 +658,9 @@ export class Toolbar extends Module<ToolbarNodes> {
641
658
  */
642
659
  if (blockContentElement && this.nodes.content) {
643
660
  const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
661
+ const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
644
662
 
645
- this.nodes.content.style.marginLeft = `${blockMarginLeft}px`;
663
+ this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
646
664
  }
647
665
  }
648
666
 
@@ -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.