@jackuait/blok 0.7.1 → 0.7.2

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 (27) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-D-7DpjTs.mjs → blok-3RuPZd3G.mjs} +1205 -1172
  3. package/dist/chunks/{constants-DXYRzX7f.mjs → constants-BkelccB1.mjs} +185 -164
  4. package/dist/chunks/{tools-Chd7Auwx.mjs → tools-rsbC2UUN.mjs} +31 -12
  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 +1 -1
  9. package/src/components/inline-tools/inline-tool-marker.ts +11 -0
  10. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +1 -9
  11. package/src/components/modules/caret.ts +13 -1
  12. package/src/components/modules/paste/google-docs-preprocessor.ts +96 -38
  13. package/src/components/modules/toolbar/blockSettings.ts +1 -1
  14. package/src/components/modules/toolbar/index.ts +10 -1
  15. package/src/components/modules/toolbar/inline/index.ts +24 -2
  16. package/src/components/selection/cursor.ts +7 -0
  17. package/src/components/ui/toolbox.ts +14 -0
  18. package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +1 -1
  19. package/src/components/utils/popover/components/search-input/search-input.const.ts +1 -0
  20. package/src/components/utils/popover/components/search-input/search-input.ts +32 -1
  21. package/src/components/utils/popover/popover-desktop.ts +9 -1
  22. package/src/components/utils/popover/popover-inline.ts +8 -0
  23. package/src/styles/main.css +39 -11
  24. package/src/tools/paragraph/index.ts +3 -5
  25. package/src/tools/table/index.ts +70 -0
  26. package/src/tools/table/table-cell-blocks.ts +15 -3
  27. package/src/tools/table/table-cell-clipboard.ts +32 -5
@@ -1,4 +1,4 @@
1
- import { $ as e, A as t, At as n, B as r, D as i, Dn as a, Dt as o, En as s, F as c, Ft as l, G as u, H as d, I as f, J as p, K as m, L as h, M as g, N as ee, P as te, Pt as ne, Q as re, R as ie, Tt as ae, U as oe, V as se, W as ce, X as le, Xt as ue, _t as de, a as fe, bn as pe, bt as me, c as he, ct as ge, d as _e, dt as ve, et as ye, f as be, ft as xe, g as Se, gt as Ce, h as we, ht as Te, i as Ee, it as De, j as Oe, jt as _, k as ke, l as Ae, lt as je, mt as Me, n as Ne, nt as Pe, o as Fe, p as Ie, pt as Le, q as Re, r as ze, rt as Be, s as Ve, t as He, tt as Ue, u as v, ut as We, v as Ge, wt as Ke, z as qe } from "./constants-DXYRzX7f.mjs";
1
+ import { $ as e, A as t, At as n, B as r, D as i, Dn as a, Dt as o, En as s, F as c, Ft as l, G as u, H as d, I as f, J as p, K as m, L as h, M as g, N as ee, P as te, Pt as ne, Q as re, R as ie, Tt as ae, U as oe, V as se, W as ce, X as le, Xt as ue, _t as de, a as fe, bn as pe, bt as me, c as he, ct as ge, d as _e, dt as ve, et as ye, f as be, ft as xe, g as Se, gt as Ce, h as we, ht as Te, i as Ee, it as De, j as Oe, jt as _, k as ke, l as Ae, lt as je, mt as Me, n as Ne, nt as Pe, o as Fe, p as Ie, pt as Le, q as Re, r as ze, rt as Be, s as Ve, t as He, tt as Ue, u as v, ut as We, v as Ge, wt as Ke, z as qe } from "./constants-BkelccB1.mjs";
2
2
  import { t as y } from "./objectSpread2-CyPxu8-u.mjs";
3
3
  import { n as b } from "./tw-DmW6-pCY.mjs";
4
4
  //#region src/components/utils/html.ts
@@ -106,9 +106,7 @@ var Je = (e) => {
106
106
  }
107
107
  onPaste(e) {
108
108
  let t = e.detail;
109
- "data" in t && (this._data = { text: t.data.innerHTML }, queueMicrotask(() => {
110
- this._element && (this._element.innerHTML = this._data.text || "");
111
- }));
109
+ "data" in t && (this._data = { text: t.data.innerHTML }, this._element && (this._element.innerHTML = this._data.text || ""));
112
110
  }
113
111
  static get conversionConfig() {
114
112
  return {
@@ -1956,7 +1954,7 @@ var vt = "outline-hidden py-[3px] mt-[2px] mb-px", yt = "outline-hidden pl-0.5 l
1956
1954
  e.preventDefault(), this.handleShiftTabNavigation(t);
1957
1955
  return;
1958
1956
  }
1959
- e.key === "ArrowDown" && t.row === this.getRowCount() - 1 && (e.preventDefault(), this.exitTableForward());
1957
+ e.key === "ArrowDown" && !e.defaultPrevented && t.row === this.getRowCount() - 1 && (e.preventDefault(), this.exitTableForward());
1960
1958
  }
1961
1959
  handleTabNavigation(e) {
1962
1960
  let t = e.col + 1;
@@ -2090,12 +2088,15 @@ var vt = "outline-hidden py-[3px] mt-[2px] mb-px", yt = "outline-hidden pl-0.5 l
2090
2088
  return n;
2091
2089
  }
2092
2090
  claimBlockForCell(e, t) {
2093
- let n = e.querySelector(`[${k}]`);
2094
- if (!n) return;
2095
- let r = this.api.blocks.getBlockIndex(t);
2096
- if (r === void 0) return;
2097
- let i = this.api.blocks.getBlockByIndex(r);
2098
- i && (i.holder.contains(n) || (n.appendChild(i.holder), this.api.blocks.setBlockParent(t, this.tableBlockId), this.stripPlaceholders(n)));
2091
+ var n, r;
2092
+ let i = e.querySelector(`[${k}]`);
2093
+ if (!i) return;
2094
+ let a = this.api.blocks.getBlockIndex(t);
2095
+ if (a === void 0) return;
2096
+ let o = this.api.blocks.getBlockByIndex(a);
2097
+ if (!o || o.holder.contains(i)) return;
2098
+ let s = this.api.blocks.getBlocksCount(), c = (n = (r = Array.from({ length: s - a - 1 }, (e, t) => this.api.blocks.getBlockByIndex(a + 1 + t)).find((e) => (e == null ? void 0 : e.holder.parentElement) === i)) == null ? void 0 : r.holder) == null ? null : n;
2099
+ i.insertBefore(o.holder, c), this.api.blocks.setBlockParent(t, this.tableBlockId), this.stripPlaceholders(i);
2099
2100
  }
2100
2101
  findCellForNewBlock(e) {
2101
2102
  return this.findCellForAdjacentBlock(e - 1) || this.findCellForAdjacentBlock(e + 1);
@@ -4489,10 +4490,28 @@ var er = (e) => ({ element: $n({
4489
4490
  if (!s || !t.contains(s)) return;
4490
4491
  let c = s.closest(`[${T}]`);
4491
4492
  if (!c) return;
4493
+ if (a.rows === 1 && a.cols === 1) {
4494
+ e.preventDefault(), e.stopPropagation(), this.insertSingleCellPayloadInline(a.cells[0][0]);
4495
+ return;
4496
+ }
4492
4497
  e.preventDefault(), e.stopPropagation();
4493
4498
  let l = Array.from(t.querySelectorAll(`[${T}]`)).indexOf(c), u = Array.from(c.querySelectorAll(`[${E}]`)).indexOf(s);
4494
4499
  this.pastePayloadIntoCells(t, a, l, u);
4495
4500
  }
4501
+ insertSingleCellPayloadInline(e) {
4502
+ let t = e.blocks.map((e) => typeof e.data.text == "string" ? e.data.text : "").filter(Boolean).join("<br>");
4503
+ if (!t) return;
4504
+ let n = window.getSelection();
4505
+ if (!n || n.rangeCount === 0) return;
4506
+ let r = n.getRangeAt(0);
4507
+ r.deleteContents();
4508
+ let i = document.createDocumentFragment(), a = document.createElement("div");
4509
+ a.innerHTML = t, Array.from(a.childNodes).forEach((e) => i.appendChild(e)), i.childNodes.length === 0 && i.appendChild(new Text());
4510
+ let o = i.lastChild;
4511
+ r.insertNode(i);
4512
+ let s = document.createRange(), c = o.nodeType === Node.TEXT_NODE ? o : o.firstChild;
4513
+ c !== null && c.textContent !== null && s.setStart(c, c.textContent.length), n.removeAllRanges(), n.addRange(s);
4514
+ }
4496
4515
  pastePayloadIntoCells(e, t, n, r) {
4497
4516
  this.runTransactedStructuralOp(() => {
4498
4517
  var i, a;
@@ -5767,7 +5786,7 @@ var Q = (e) => e.tagName === "MARK", Ji = (e) => q(e, Q), $, Yi = {
5767
5786
  return { mark: (t) => {
5768
5787
  let n = t.style, r = Array.from({ length: n.length }, (e, t) => n.item(t));
5769
5788
  for (let t of r) e.ALLOWED_STYLE_PROPS.has(t) || n.removeProperty(t);
5770
- return n.length > 0 ? { style: !0 } : {};
5789
+ return n.getPropertyValue("color") && !n.getPropertyValue("background-color") && n.setProperty("background-color", "transparent"), n.length > 0 ? { style: !0 } : {};
5771
5790
  } };
5772
5791
  }
5773
5792
  constructor({ api: e }) {
package/dist/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { En as e } from "./chunks/constants-DXYRzX7f.mjs";
2
- import { n as t, t as n } from "./chunks/blok-D-7DpjTs.mjs";
1
+ import { En as e } from "./chunks/constants-BkelccB1.mjs";
2
+ import { n as t, t as n } from "./chunks/blok-3RuPZd3G.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-CyPxu8-u.mjs";
4
- import { a as i, d as a, i as o, l as s, n as c, o as l, r as u, s as d, t as f, u as p } from "./chunks/tools-Chd7Auwx.mjs";
4
+ import { a as i, d as a, i as o, l as s, n as c, o as l, r as u, s as d, t as f, u as p } from "./chunks/tools-rsbC2UUN.mjs";
5
5
  //#region src/full.ts
6
6
  var m = {
7
7
  paragraph: {
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import "./chunks/constants-DXYRzX7f.mjs";
2
- import { i as e, t } from "./chunks/blok-D-7DpjTs.mjs";
1
+ import "./chunks/constants-BkelccB1.mjs";
2
+ import { i as e, t } from "./chunks/blok-3RuPZd3G.mjs";
3
3
  import { t as n } from "./chunks/objectSpread2-CyPxu8-u.mjs";
4
4
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
5
5
  import { jsx as c } from "react/jsx-runtime";
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-DXYRzX7f.mjs";
2
- import { a as t, c as n, d as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "./chunks/tools-Chd7Auwx.mjs";
1
+ import { m as e } from "./chunks/constants-BkelccB1.mjs";
2
+ import { a as t, c as n, d as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "./chunks/tools-rsbC2UUN.mjs";
3
3
  export { s as Bold, e as Convert, d as Header, t as Italic, i as Link, a as List, c as Marker, r as Paragraph, n as Table, l as Toggle, u as defaultBlockTools, o as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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",
@@ -86,6 +86,17 @@ export class MarkerInlineTool implements InlineTool {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * When text color is set without an explicit background-color,
91
+ * add transparent background to override the browser's default
92
+ * <mark> background (yellow/Mark system color). This handles
93
+ * pasted content where the browser may have dropped the
94
+ * transparent value during clipboard serialization.
95
+ */
96
+ if (style.getPropertyValue('color') && !style.getPropertyValue('background-color')) {
97
+ style.setProperty('background-color', 'transparent');
98
+ }
99
+
89
100
  return style.length > 0 ? { style: true } : {};
90
101
  },
91
102
  } as SanitizerConfig;
@@ -249,7 +249,7 @@ export class KeyboardNavigation extends BlockEventComposer {
249
249
 
250
250
  if (toggleWrapper?.getAttribute('data-blok-toggle-open') === 'true') {
251
251
  this.Blok.BlockManager.setBlockParent(newBlock, currentBlock.id);
252
- } else if (currentBlock.parentId !== null) {
252
+ } else if (currentBlock.parentId !== null && newBlock.parentId !== currentBlock.parentId) {
253
253
  this.Blok.BlockManager.setBlockParent(newBlock, currentBlock.parentId);
254
254
  }
255
255
 
@@ -600,14 +600,6 @@ export class KeyboardNavigation extends BlockEventComposer {
600
600
  const isDownKey = keyCode === keyCodes.DOWN;
601
601
  const isRightKey = keyCode === keyCodes.RIGHT && !this.isRtl;
602
602
 
603
- /**
604
- * Skip ArrowDown handling for blocks inside table cells — the table's own
605
- * grid-level handler manages row-based navigation and table exit.
606
- */
607
- if (isDownKey && this.isCurrentBlockInsideTableCell) {
608
- return;
609
- }
610
-
611
603
  const isNavigated = (() => {
612
604
  if (isDownKey) {
613
605
  /**
@@ -681,10 +681,22 @@ export class Caret extends Module {
681
681
  return true;
682
682
  }
683
683
 
684
+ /**
685
+ * If both blocks share the same DOM container (e.g., same table cell),
686
+ * navigate directly to the next block instead of exiting the table.
687
+ */
688
+ if (nextBlock !== null && currentBlock.parentId !== null &&
689
+ currentBlock.holder.parentElement !== null &&
690
+ currentBlock.holder.parentElement === nextBlock.holder.parentElement) {
691
+ this.setToBlockAtXPosition(nextBlock, caretX, true);
692
+
693
+ return true;
694
+ }
695
+
684
696
  /**
685
697
  * If current block is inside a table cell (has parentId), check if we should
686
698
  * exit the table. This handles two cases:
687
- * 1. nextBlock is still in the same table → skip all cell paragraphs
699
+ * 1. nextBlock is in a different cell of the same table → skip to block after table
688
700
  * 2. nextBlock is null (last block in flat list) → table is at the end
689
701
  */
690
702
  const shouldExitParent = currentBlock.parentId !== null && (
@@ -17,9 +17,13 @@ export function preprocessGoogleDocsHtml(html: string): string {
17
17
 
18
18
  wrapper.innerHTML = html;
19
19
 
20
- unwrapGoogleDocsContent(wrapper);
21
- convertGoogleDocsStyles(wrapper);
22
- convertTableCellParagraphs(wrapper);
20
+ const isGoogleDocs = unwrapGoogleDocsContent(wrapper);
21
+
22
+ convertGoogleDocsStyles(wrapper, isGoogleDocs);
23
+
24
+ if (isGoogleDocs) {
25
+ convertTableCellParagraphs(wrapper);
26
+ }
23
27
 
24
28
  return wrapper.innerHTML;
25
29
  }
@@ -29,12 +33,14 @@ export function preprocessGoogleDocsHtml(html: string): string {
29
33
  * Google Docs wraps clipboard HTML in `<b id="docs-internal-guid-...">`.
30
34
  * Content may be split across multiple child `<div>` elements (e.g. one
31
35
  * per table), so all children are moved out of the wrapper.
36
+ *
37
+ * @returns true if Google Docs content was detected
32
38
  */
33
- function unwrapGoogleDocsContent(wrapper: HTMLElement): void {
39
+ function unwrapGoogleDocsContent(wrapper: HTMLElement): boolean {
34
40
  const googleDocsWrapper = wrapper.querySelector<HTMLElement>('b[id^="docs-internal-guid-"]');
35
41
 
36
42
  if (!googleDocsWrapper) {
37
- return;
43
+ return false;
38
44
  }
39
45
 
40
46
  const fragment = document.createDocumentFragment();
@@ -44,6 +50,8 @@ function unwrapGoogleDocsContent(wrapper: HTMLElement): void {
44
50
  }
45
51
 
46
52
  googleDocsWrapper.replaceWith(fragment);
53
+
54
+ return true;
47
55
  }
48
56
 
49
57
  /**
@@ -76,6 +84,82 @@ function isDefaultBlack(color: string): boolean {
76
84
  return normalized === 'rgb(0,0,0)' || normalized === '#000000';
77
85
  }
78
86
 
87
+ /**
88
+ * Check whether a CSS background-color value is the default white page background.
89
+ * When the browser natively copies from a contenteditable, it adds computed styles
90
+ * including `background-color: rgb(255, 255, 255)` — the resolved page background.
91
+ * These should not be treated as intentional marker formatting.
92
+ */
93
+ function isDefaultWhiteBackground(bgColor: string): boolean {
94
+ const normalized = bgColor.replace(/\s/g, '').toLowerCase();
95
+
96
+ return normalized === 'rgb(255,255,255)' || normalized === '#ffffff' || normalized === 'white';
97
+ }
98
+
99
+ /**
100
+ * Optionally wrap innerHTML in a `<mark>` with mapped color styles.
101
+ * Returns the original content unchanged when no color formatting is needed.
102
+ */
103
+ function buildMarkWrapper(
104
+ innerHTML: string,
105
+ hasColor: boolean,
106
+ hasBgColor: boolean,
107
+ color: string | undefined,
108
+ bgColor: string | undefined
109
+ ): string {
110
+ if (!hasColor && !hasBgColor) {
111
+ return innerHTML;
112
+ }
113
+
114
+ const mappedColor = hasColor && color !== undefined ? mapToNearestPresetColor(color, 'text') : '';
115
+ const mappedBg = hasBgColor && bgColor !== undefined ? mapToNearestPresetColor(bgColor, 'bg') : '';
116
+
117
+ const colorStyles = [
118
+ hasColor ? `color: ${mappedColor}` : '',
119
+ resolveBackgroundStyle(hasBgColor, hasColor, mappedBg),
120
+ ].filter(Boolean).join('; ');
121
+
122
+ return colorStyles
123
+ ? `<mark style="${colorStyles};">${innerHTML}</mark>`
124
+ : innerHTML;
125
+ }
126
+
127
+ /**
128
+ * Convert a single style `<span>` to semantic HTML.
129
+ *
130
+ * For Google Docs content, all non-transparent backgrounds are treated as
131
+ * intentional formatting. For browser-native clipboard content, default
132
+ * page values (black text, white background) are filtered out so computed
133
+ * styles on plain text don't produce spurious `<mark>` elements.
134
+ *
135
+ * @returns replacement HTML string, or `null` if the span should be left as-is
136
+ */
137
+ function convertSpanToSemanticHtml(span: Element, isGoogleDocs: boolean): string | null {
138
+ const style = span.getAttribute('style') ?? '';
139
+ const isBold = /font-weight\s*:\s*(700|bold)/i.test(style);
140
+ const isItalic = /font-style\s*:\s*italic/i.test(style);
141
+
142
+ const colorMatch = /(?<![a-z-])color\s*:\s*([^;]+)/i.exec(style);
143
+ const bgMatch = /background-color\s*:\s*([^;]+)/i.exec(style);
144
+
145
+ const color = colorMatch?.[1]?.trim();
146
+ const bgColor = bgMatch?.[1]?.trim();
147
+
148
+ const hasColor = color !== undefined && !isDefaultBlack(color);
149
+ const hasBgColor = isGoogleDocs
150
+ ? bgColor !== undefined && bgColor !== 'transparent'
151
+ : bgColor !== undefined && bgColor !== 'transparent' && !isDefaultWhiteBackground(bgColor);
152
+
153
+ if (!isBold && !isItalic && !hasColor && !hasBgColor) {
154
+ return null;
155
+ }
156
+
157
+ const inner = buildMarkWrapper(span.innerHTML, hasColor, hasBgColor, color, bgColor);
158
+ const italic = isItalic ? `<i>${inner}</i>` : inner;
159
+
160
+ return isBold ? `<b>${italic}</b>` : italic;
161
+ }
162
+
79
163
  /**
80
164
  * Convert Google Docs style-based `<span>` elements to semantic HTML tags.
81
165
  *
@@ -86,44 +170,18 @@ function isDefaultBlack(color: string): boolean {
86
170
  *
87
171
  * Color and bold/italic can combine: a bold red span becomes `<b><mark style="color: red;">text</mark></b>`.
88
172
  */
89
- function convertGoogleDocsStyles(wrapper: HTMLElement): void {
173
+ function convertGoogleDocsStyles(wrapper: HTMLElement, isGoogleDocs: boolean): void {
90
174
  for (const span of Array.from(wrapper.querySelectorAll('span[style]'))) {
91
- const style = span.getAttribute('style') ?? '';
92
- const isBold = /font-weight\s*:\s*(700|bold)/i.test(style);
93
- const isItalic = /font-style\s*:\s*italic/i.test(style);
175
+ const replacement = convertSpanToSemanticHtml(span, isGoogleDocs);
94
176
 
95
- const colorMatch = /(?<![a-z-])color\s*:\s*([^;]+)/i.exec(style);
96
- const bgMatch = /background-color\s*:\s*([^;]+)/i.exec(style);
97
-
98
- const color = colorMatch?.[1]?.trim();
99
- const bgColor = bgMatch?.[1]?.trim();
100
-
101
- const hasColor = color !== undefined && !isDefaultBlack(color);
102
- const hasBgColor = bgColor !== undefined && bgColor !== 'transparent';
103
-
104
- if (!isBold && !isItalic && !hasColor && !hasBgColor) {
105
- continue;
177
+ if (replacement !== null) {
178
+ span.replaceWith(document.createRange().createContextualFragment(replacement));
106
179
  }
107
-
108
- const mappedColor = hasColor ? mapToNearestPresetColor(color, 'text') : '';
109
- const mappedBg = hasBgColor ? mapToNearestPresetColor(bgColor, 'bg') : '';
110
-
111
- const colorStyles = [
112
- hasColor ? `color: ${mappedColor}` : '',
113
- resolveBackgroundStyle(hasBgColor, hasColor, mappedBg),
114
- ].filter(Boolean).join('; ');
115
-
116
- const inner = colorStyles
117
- ? `<mark style="${colorStyles};">${span.innerHTML}</mark>`
118
- : span.innerHTML;
119
-
120
- const italic = isItalic ? `<i>${inner}</i>` : inner;
121
- const wrapped = isBold ? `<b>${italic}</b>` : italic;
122
-
123
- span.replaceWith(document.createRange().createContextualFragment(wrapped));
124
180
  }
125
181
 
126
- convertAnchorColorStyles(wrapper);
182
+ if (isGoogleDocs) {
183
+ convertAnchorColorStyles(wrapper);
184
+ }
127
185
  }
128
186
 
129
187
  /**
@@ -188,7 +188,7 @@ export class BlockSettings extends Module<BlockSettingsNodes> {
188
188
 
189
189
  const PopoverClass = isMobileScreen() ? PopoverMobile : PopoverDesktop;
190
190
  const popoverParams: PopoverParams & { flipper?: Flipper } = {
191
- searchable: false,
191
+ searchable: true,
192
192
  trigger: trigger || this.nodes.wrapper,
193
193
  items: await this.getTunesItems(block, commonTunes, toolTunes),
194
194
  scopeElement: this.Blok.API.methods.ui.nodes.redactor,
@@ -428,6 +428,16 @@ export class Toolbar extends Module<ToolbarNodes> {
428
428
  targetBlock.setupDraggable(settingsToggler, this.Blok.DragManager);
429
429
  }
430
430
 
431
+ /**
432
+ * Update toolbox left alignment to the block's content element so the popover
433
+ * aligns with the actual visible content, not the toolbar's internal wrapper.
434
+ */
435
+ const blockContentElement = targetBlockHolder.querySelector<HTMLElement>(`[${DATA_ATTR.elementContent}]`);
436
+
437
+ if (blockContentElement) {
438
+ this.toolboxInstance.updateLeftAlignElement(blockContentElement);
439
+ }
440
+
431
441
  /**
432
442
  * Apply content offset for nested elements (e.g., nested list items)
433
443
  */
@@ -836,7 +846,6 @@ export class Toolbar extends Module<ToolbarNodes> {
836
846
  },
837
847
  i18n: this.Blok.I18n,
838
848
  triggerElement: this.nodes.plusButton,
839
- leftAlignElement: this.nodes.content,
840
849
  });
841
850
 
842
851
  this.toolboxInstance.on(ToolboxEvent.Opened, () => {
@@ -81,6 +81,11 @@ export class InlineToolbar extends Module<InlineToolbarNodes> {
81
81
  private keyboardHandler: InlineKeyboardHandler;
82
82
  private lifecycleManager: InlineLifecycleManager;
83
83
 
84
+ /**
85
+ * SelectionUtils instance for fake background cleanup
86
+ */
87
+ private selection: SelectionUtils = new SelectionUtils();
88
+
84
89
  /**
85
90
  * Currently visible tools instances
86
91
  */
@@ -185,8 +190,22 @@ export class InlineToolbar extends Module<InlineToolbarNodes> {
185
190
  }
186
191
 
187
192
  this.openingPromise = this.open();
188
- await this.openingPromise;
189
- this.openingPromise = null;
193
+
194
+ try {
195
+ await this.openingPromise;
196
+ } catch {
197
+ this.opened = false;
198
+
199
+ if (this.popover) {
200
+ this.popover.hide?.();
201
+ this.popover.destroy?.();
202
+ this.popover = null;
203
+ }
204
+
205
+ return;
206
+ } finally {
207
+ this.openingPromise = null;
208
+ }
190
209
 
191
210
  this.Blok.Toolbar.hideBlockActions();
192
211
  }
@@ -203,6 +222,9 @@ export class InlineToolbar extends Module<InlineToolbarNodes> {
203
222
  this.opened = false;
204
223
  this.openingPromise = null;
205
224
 
225
+ // Clean up any orphaned fake background elements left by inline tools (Link, Marker, Convert)
226
+ this.selection.clearFakeBackground();
227
+
206
228
  // Hide and destroy popover
207
229
  if (this.popover) {
208
230
  this.popover.hide?.();
@@ -43,6 +43,13 @@ export class SelectionCursor {
43
43
  selection.removeAllRanges();
44
44
  selection.addRange(range);
45
45
 
46
+ // Focus contenteditable elements explicitly after setting the selection range.
47
+ // Placed after addRange() so the selection is preserved when focus transfers —
48
+ // calling focus() before addRange() can reset the caret during arrow navigation.
49
+ if ($.isContentEditable(element) && document.activeElement !== element) {
50
+ element.focus();
51
+ }
52
+
46
53
  return range.getBoundingClientRect();
47
54
  }
48
55
 
@@ -205,6 +205,20 @@ export class Toolbox extends EventsDispatcher<ToolboxEventMap> {
205
205
  this.api.events.on(BlokMobileLayoutToggled, this.handleMobileLayoutToggle);
206
206
  }
207
207
 
208
+ /**
209
+ * Updates the element used for horizontal popover alignment.
210
+ * Called when the toolbar moves to a new block so the popover
211
+ * aligns with the block's content element rather than the toolbar's own wrapper.
212
+ * @param element - block content element to align against
213
+ */
214
+ public updateLeftAlignElement(element: HTMLElement | undefined): void {
215
+ this.leftAlignElement = element;
216
+
217
+ if (this.popover !== null && 'setLeftAlignElement' in this.popover) {
218
+ (this.popover as { setLeftAlignElement: (el: HTMLElement | undefined) => void }).setLeftAlignElement(element);
219
+ }
220
+ }
221
+
208
222
  /**
209
223
  * Returns root block settings element
210
224
  */
@@ -339,7 +339,7 @@ export class PopoverItemDefault extends PopoverItem {
339
339
  private getIconClass(iconWithGap: boolean, isInline: boolean, isNestedInline: boolean, isWobbling: boolean): string {
340
340
  return twMerge(
341
341
  css.icon,
342
- isInline && 'w-auto h-auto [&_svg]:w-icon [&_svg]:h-icon mobile:[&_svg]:w-icon-mobile mobile:[&_svg]:h-icon-mobile',
342
+ isInline && 'w-auto h-auto bg-transparent [&_svg]:w-icon [&_svg]:h-icon mobile:[&_svg]:w-icon-mobile mobile:[&_svg]:h-icon-mobile',
343
343
  isNestedInline && 'w-toolbox-btn h-toolbox-btn',
344
344
  iconWithGap && 'mr-3',
345
345
  iconWithGap && isInline && 'shadow-none mr-0!',
@@ -5,4 +5,5 @@ export const css = {
5
5
  wrapper: 'bg-search-input-bg border border-search-input-border rounded-lg p-1 grid grid-cols-[auto_auto_1fr] grid-rows-[auto] transition-all duration-200 focus-within:bg-popover-bg focus-within:border-search-input-focus-border focus-within:shadow-[0_0_0_2px_rgba(35,131,226,0.08)]',
6
6
  icon: 'w-toolbox-btn h-toolbox-btn flex items-center justify-center mr-2 [&_svg]:w-icon [&_svg]:h-icon [&_svg]:text-gray-text',
7
7
  input: "text-sm outline-hidden font-medium font-inherit border-0 bg-transparent m-0 p-0 leading-[22px] min-w-[calc(100%-(--spacing(6))-10px)] placeholder:text-gray-text/60 placeholder:font-normal",
8
+ clearButton: 'flex items-center justify-center w-toolbox-btn h-toolbox-btn cursor-pointer border-0 bg-transparent rounded p-0 opacity-0 pointer-events-none transition-opacity duration-150 [&_svg]:w-3 [&_svg]:h-3 [&_svg]:text-gray-text can-hover:hover:[&_svg]:text-dark',
8
9
  };
@@ -1,5 +1,5 @@
1
1
  import { Dom } from '../../../../dom';
2
- import { IconSearch } from '../../../../icons';
2
+ import { IconCross, IconSearch } from '../../../../icons';
3
3
  import { EventsDispatcher } from '../../../events';
4
4
  import { Listeners } from '../../../listeners';
5
5
 
@@ -23,6 +23,11 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
23
23
  */
24
24
  private input: HTMLInputElement;
25
25
 
26
+ /**
27
+ * Clear button element
28
+ */
29
+ private clearButton: HTMLButtonElement;
30
+
26
31
  /**
27
32
  * The instance of the Listeners util
28
33
  */
@@ -73,8 +78,15 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
73
78
  this.input.setAttribute('data-blok-flipper-navigation-target', 'true');
74
79
  this.input.setAttribute('data-blok-testid', 'popover-search-input');
75
80
 
81
+ this.clearButton = Dom.make('button', css.clearButton, {
82
+ type: 'button',
83
+ innerHTML: IconCross,
84
+ }) as HTMLButtonElement;
85
+ this.clearButton.setAttribute('aria-label', 'Clear search');
86
+
76
87
  this.wrapper.appendChild(iconWrapper);
77
88
  this.wrapper.appendChild(this.input);
89
+ this.wrapper.appendChild(this.clearButton);
78
90
 
79
91
  this.overrideValueProperty();
80
92
 
@@ -83,6 +95,11 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
83
95
  eventsToHandle.forEach((eventName) => {
84
96
  this.listeners.on(this.input, eventName, this.handleValueChange);
85
97
  });
98
+
99
+ this.listeners.on(this.clearButton, 'click', () => {
100
+ this.clear();
101
+ this.input.focus();
102
+ });
86
103
  }
87
104
 
88
105
  /**
@@ -104,12 +121,14 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
104
121
  */
105
122
  public clear(): void {
106
123
  this.input.value = '';
124
+ this.updateClearButtonVisibility();
107
125
  }
108
126
 
109
127
  /**
110
128
  * Handles value changes for the input element
111
129
  */
112
130
  private handleValueChange = (): void => {
131
+ this.updateClearButtonVisibility();
113
132
  this.applySearch(this.input.value);
114
133
  };
115
134
 
@@ -159,6 +178,18 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
159
178
  });
160
179
  }
161
180
 
181
+ /**
182
+ * Shows or hides the clear button based on whether the input has a value
183
+ */
184
+ private updateClearButtonVisibility(): void {
185
+ const visible = this.input.value.length > 0;
186
+
187
+ this.clearButton.classList.toggle('opacity-0', !visible);
188
+ this.clearButton.classList.toggle('pointer-events-none', !visible);
189
+ this.clearButton.classList.toggle('opacity-100', visible);
190
+ this.clearButton.classList.toggle('pointer-events-auto', visible);
191
+ }
192
+
162
193
  /**
163
194
  * Clears memory
164
195
  */
@@ -67,6 +67,14 @@ export class PopoverDesktop extends PopoverAbstract {
67
67
  */
68
68
  private leftAlignElement: HTMLElement | undefined;
69
69
 
70
+ /**
71
+ * Updates the element whose left edge is used for horizontal positioning.
72
+ * @param element - new element to align against, or undefined to fall back to trigger
73
+ */
74
+ public setLeftAlignElement(element: HTMLElement | undefined): void {
75
+ this.leftAlignElement = element;
76
+ }
77
+
70
78
  /**
71
79
  * Popover size cache
72
80
  */
@@ -405,7 +413,7 @@ export class PopoverDesktop extends PopoverAbstract {
405
413
  * Destroys nested popover unless the mouse moved into it.
406
414
  * @param event - mouseleave event
407
415
  */
408
- private handleMouseLeave(event: Event): void {
416
+ protected handleMouseLeave(event: Event): void {
409
417
  const mouseEvent = event as MouseEvent;
410
418
  const relatedTarget = mouseEvent.relatedTarget;
411
419
 
@@ -240,6 +240,14 @@ export class PopoverInline extends PopoverDesktop {
240
240
  return;
241
241
  }
242
242
 
243
+ /**
244
+ * Disable mouse-leave event handling.
245
+ * Inline toolbar uses click-to-toggle for nested popovers, not hover.
246
+ */
247
+ protected override handleMouseLeave(): void {
248
+ return;
249
+ }
250
+
243
251
  /**
244
252
  * Sets CSS variable with position of item near which nested popover should be displayed.
245
253
  * Is used to position nested popover right below clicked item