testomatio-editor-blocks 0.4.50 → 0.4.52

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.
@@ -107,7 +107,7 @@ function fallbackHtmlToMarkdown(html) {
107
107
  .replace(/<br\s*\/?>/gi, "\n")
108
108
  .replace(/<\/?(div|p)>/gi, "\n")
109
109
  .replace(/<strong>(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
110
- .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `*${content}*`)
110
+ .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `_${content}_`)
111
111
  .replace(/<span[^>]*>/gi, "")
112
112
  .replace(/<\/span>/gi, "")
113
113
  .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
@@ -400,7 +400,7 @@ export const stepBlock = createReactBlockSpec({
400
400
  writeExpectedCollapsedPreference(true);
401
401
  editor.updateBlock(block.id, { props: { expectedResult: "" } });
402
402
  }, [editor, block.id]);
403
- const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
403
+ const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, tabIndex: -1, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
404
404
  if (!effectiveVertical) {
405
405
  return (_jsx(StepHorizontalView, { ref: containerRef, blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: viewToggleButton }));
406
406
  }
@@ -185,7 +185,7 @@ export function buildFullMarkdown(plainText, links, formatting) {
185
185
  closeMarker = isMultiline ? "\n```" : "`";
186
186
  }
187
187
  else {
188
- openMarker = fmt.type === "bold" ? "**" : "*";
188
+ openMarker = fmt.type === "bold" ? "**" : "_";
189
189
  closeMarker = openMarker;
190
190
  }
191
191
  // Opening: outer markers (bold) before inner (italic) → bold order=0, italic order=1
@@ -1,3 +1,18 @@
1
+ const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
2
+ function isInlineOnlyPaste(plainText, parsedBlocks) {
3
+ if (parsedBlocks.length !== 1)
4
+ return false;
5
+ const [block] = parsedBlocks;
6
+ if (block.type !== "paragraph")
7
+ return false;
8
+ if (block.children && block.children.length > 0)
9
+ return false;
10
+ if (/\r?\n/.test(plainText))
11
+ return false;
12
+ if (BLOCK_MARKDOWN_PREFIX.test(plainText))
13
+ return false;
14
+ return true;
15
+ }
1
16
  export function createMarkdownPasteHandler(converter) {
2
17
  return ({ event, editor, defaultPasteHandler }) => {
3
18
  var _a, _b, _c, _d, _e, _f, _g, _h;
@@ -21,6 +36,9 @@ export function createMarkdownPasteHandler(converter) {
21
36
  const parsedBlocks = converter(plainText);
22
37
  if (parsedBlocks.length === 0)
23
38
  return defaultPasteHandler();
39
+ if (isInlineOnlyPaste(plainText, parsedBlocks)) {
40
+ return defaultPasteHandler({ plainTextAsMarkdown: false });
41
+ }
24
42
  const selection = editor.getSelection();
25
43
  const selectedIds = (_h = (_g = selection === null || selection === void 0 ? void 0 : selection.blocks) === null || _g === void 0 ? void 0 : _g.map((block) => block.id).filter((id) => Boolean(id))) !== null && _h !== void 0 ? _h : [];
26
44
  if (selectedIds.length > 0) {
@@ -6,8 +6,7 @@ export type CustomPartialBlock = PartialBlock<Schema["blockSchema"], Schema["inl
6
6
  export declare function blocksToMarkdown(blocks: CustomEditorBlock[]): string;
7
7
  export declare function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPartialBlock[];
8
8
  export interface MarkdownToBlocksOptions {
9
- /** When true, every blank line produces an empty paragraph block. */
10
9
  preserveBlankLines?: boolean;
11
10
  }
12
- export declare function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOptions): CustomPartialBlock[];
11
+ export declare function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[];
13
12
  export {};
@@ -23,6 +23,7 @@ const headingPrefixes = {
23
23
  6: "######",
24
24
  };
25
25
  const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
26
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
26
27
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
27
28
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
28
29
  const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
@@ -30,7 +31,17 @@ const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]
30
31
  const STEP_DATA_LINE_REGEX = /^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i;
31
32
  const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
32
33
  function escapeMarkdown(text) {
33
- return text.replace(SPECIAL_CHAR_REGEX, "\\$1");
34
+ let result = "";
35
+ let lastIndex = 0;
36
+ HTML_COMMENT_REGEX.lastIndex = 0;
37
+ let match;
38
+ while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
39
+ result += text.slice(lastIndex, match.index).replace(SPECIAL_CHAR_REGEX, "\\$1");
40
+ result += match[0];
41
+ lastIndex = match.index + match[0].length;
42
+ }
43
+ result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
44
+ return result;
34
45
  }
35
46
  function stripHtmlWrappers(text) {
36
47
  return text
@@ -100,7 +111,7 @@ function applyTextStyles(text, styles) {
100
111
  wrappers.push({ prefix: "**", suffix: "**" });
101
112
  }
102
113
  if (styles.italic) {
103
- wrappers.push({ prefix: "*", suffix: "*" });
114
+ wrappers.push({ prefix: "_", suffix: "_" });
104
115
  }
105
116
  if (styles.strike) {
106
117
  wrappers.push({ prefix: "~~", suffix: "~~" });
@@ -206,12 +217,6 @@ function serializeChildren(block, ctx) {
206
217
  const childCtx = { ...ctx, listDepth: ctx.listDepth + 1 };
207
218
  return serializeBlocks(block.children, childCtx);
208
219
  }
209
- function flattenWithBlankLine(lines, appendBlank = false) {
210
- if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
211
- return [...lines, ""];
212
- }
213
- return lines;
214
- }
215
220
  function serializeBlock(block, ctx, orderedIndex, stepIndex) {
216
221
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
217
222
  const lines = [];
@@ -219,17 +224,21 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
219
224
  switch (block.type) {
220
225
  case "paragraph": {
221
226
  const text = inlineToMarkdown(block.content);
222
- if (text.length > 0) {
223
- lines.push(ctx.insideQuote ? `> ${text}` : text);
227
+ if (text.length === 0) {
228
+ // Empty paragraph = one blank line in the output. Under the 1:1
229
+ // block model, this is the only mechanism that produces blank lines
230
+ // between top-level blocks.
231
+ return [""];
224
232
  }
225
- return flattenWithBlankLine(lines, !ctx.insideQuote);
233
+ lines.push(ctx.insideQuote ? `> ${text}` : text);
234
+ return lines;
226
235
  }
227
236
  case "heading": {
228
237
  const level = (_a = block.props.level) !== null && _a !== void 0 ? _a : 1;
229
238
  const prefix = (_b = headingPrefixes[level]) !== null && _b !== void 0 ? _b : headingPrefixes[3];
230
239
  const text = inlineToMarkdown(block.content);
231
240
  lines.push(`${prefix} ${text}`.trimEnd());
232
- return flattenWithBlankLine(lines, true);
241
+ return lines;
233
242
  }
234
243
  case "quote": {
235
244
  const quoteContent = serializeBlocks((_c = block.children) !== null && _c !== void 0 ? _c : [], {
@@ -244,7 +253,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
244
253
  lines.push(...quoteText);
245
254
  }
246
255
  lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
247
- return flattenWithBlankLine(lines, true);
256
+ return lines;
248
257
  }
249
258
  case "codeBlock": {
250
259
  const language = block.props.language || "";
@@ -255,7 +264,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
255
264
  lines.push(body);
256
265
  }
257
266
  lines.push("```");
258
- return flattenWithBlankLine(lines, true);
267
+ return lines;
259
268
  }
260
269
  case "bulletListItem": {
261
270
  const text = inlineToMarkdown(block.content);
@@ -287,7 +296,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
287
296
  const size = width ? ` =${width}x*` : "";
288
297
  lines.push(`![${caption}](${url}${size})`);
289
298
  }
290
- return flattenWithBlankLine(lines, true);
299
+ return lines;
291
300
  }
292
301
  case "file":
293
302
  case "video":
@@ -299,7 +308,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
299
308
  const displayUrl = caption || resolveFileDisplayUrl(url);
300
309
  lines.push(`[![${name}](${displayUrl})](${url})`);
301
310
  }
302
- return flattenWithBlankLine(lines, true);
311
+ return lines;
303
312
  }
304
313
  case "testStep":
305
314
  case "snippet": {
@@ -327,7 +336,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
327
336
  if (snippetId) {
328
337
  lines.push(`<!-- end snippet #${snippetId} -->`);
329
338
  }
330
- return flattenWithBlankLine(lines, true);
339
+ return lines;
331
340
  }
332
341
  const normalizedTitle = stepTitle
333
342
  .split(/\r?\n/)
@@ -370,28 +379,25 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
370
379
  }
371
380
  });
372
381
  }
373
- if (lines.length === 0) {
374
- return lines;
375
- }
376
- return flattenWithBlankLine(lines, false);
382
+ return lines;
377
383
  }
378
384
  case "table": {
379
385
  const tableContent = block.content;
380
386
  if (!tableContent || tableContent.type !== "tableContent") {
381
- return flattenWithBlankLine(lines, true);
387
+ return lines;
382
388
  }
383
389
  const rows = Array.isArray(tableContent.rows)
384
390
  ? tableContent.rows
385
391
  : [];
386
392
  if (rows.length === 0) {
387
- return flattenWithBlankLine(lines, true);
393
+ return lines;
388
394
  }
389
395
  const columnCount = rows.reduce((max, row) => {
390
396
  const length = Array.isArray(row.cells) ? row.cells.length : 0;
391
397
  return Math.max(max, length);
392
398
  }, 0);
393
399
  if (columnCount === 0) {
394
- return flattenWithBlankLine(lines, true);
400
+ return lines;
395
401
  }
396
402
  const headerRowCount = rows.length
397
403
  ? Math.min(rows.length, Math.max((_o = tableContent.headerRows) !== null && _o !== void 0 ? _o : 1, 1))
@@ -468,7 +474,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
468
474
  formattedRows.slice(bodyStartIndex).forEach((row) => {
469
475
  lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
470
476
  });
471
- return flattenWithBlankLine(lines, true);
477
+ return lines;
472
478
  }
473
479
  }
474
480
  const fallbackBlock = block;
@@ -479,7 +485,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
479
485
  }
480
486
  }
481
487
  lines.push(...serializeChildren(fallbackBlock, ctx));
482
- return flattenWithBlankLine(lines, false);
488
+ return lines;
483
489
  }
484
490
  function serializeBlocks(blocks, ctx) {
485
491
  const lines = [];
@@ -511,10 +517,7 @@ function serializeBlocks(blocks, ctx) {
511
517
  }
512
518
  export function blocksToMarkdown(blocks) {
513
519
  const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
514
- const cleaned = lines
515
- .join("\n")
516
- .replace(/\n{3,}/g, "\n\n")
517
- .trimEnd();
520
+ const cleaned = lines.join("\n").trimEnd();
518
521
  return cleaned;
519
522
  }
520
523
  function parseInlineMarkdown(text) {
@@ -592,8 +595,9 @@ function parseInlineMarkdown(text) {
592
595
  continue;
593
596
  }
594
597
  }
595
- if (cleaned.startsWith("*", i)) {
596
- const end = cleaned.indexOf("*", i + 1);
598
+ if (cleaned[i] === "*" || cleaned[i] === "_") {
599
+ const marker = cleaned[i];
600
+ const end = cleaned.indexOf(marker, i + 1);
597
601
  if (end !== -1) {
598
602
  pushPlain();
599
603
  const inner = cleaned.slice(i + 1, end);
@@ -1174,8 +1178,8 @@ export function fixMalformedImageBlocks(blocks) {
1174
1178
  }
1175
1179
  return result;
1176
1180
  }
1177
- export function markdownToBlocks(markdown, options) {
1178
- var _a, _b, _c;
1181
+ export function markdownToBlocks(markdown, _options) {
1182
+ var _a, _b;
1179
1183
  const normalized = markdown.replace(/\r\n/g, "\n");
1180
1184
  const lines = normalized.split("\n");
1181
1185
  const blocks = [];
@@ -1184,22 +1188,12 @@ export function markdownToBlocks(markdown, options) {
1184
1188
  while (index < lines.length) {
1185
1189
  const line = lines[index];
1186
1190
  if (!line.trim()) {
1187
- if (options === null || options === void 0 ? void 0 : options.preserveBlankLines) {
1191
+ // Drop blank lines until we've emitted at least one block, so leading
1192
+ // blanks don't produce a ghost empty paragraph at the top of the doc.
1193
+ if (blocks.length > 0) {
1188
1194
  blocks.push({ type: "paragraph", content: [], children: [] });
1189
- index += 1;
1190
- continue;
1191
1195
  }
1192
1196
  index += 1;
1193
- // Count consecutive blank lines
1194
- let blankCount = 1;
1195
- while (index < lines.length && !lines[index].trim()) {
1196
- blankCount++;
1197
- index++;
1198
- }
1199
- // Create empty paragraph for each extra blank line beyond the first
1200
- for (let i = 1; i < blankCount; i++) {
1201
- blocks.push({ type: "paragraph", content: [], children: [] });
1202
- }
1203
1197
  continue;
1204
1198
  }
1205
1199
  const snippetWrapper = stepsHeadingLevel !== null
@@ -1295,15 +1289,15 @@ export function markdownToBlocks(markdown, options) {
1295
1289
  blocks.push(paragraph.block);
1296
1290
  index = paragraph.nextIndex;
1297
1291
  }
1298
- // Insert empty paragraphs between consecutive headings so users can type between them
1299
- const result = [];
1300
- for (let i = 0; i < blocks.length; i++) {
1301
- result.push(blocks[i]);
1302
- if (blocks[i].type === "heading" && ((_c = blocks[i + 1]) === null || _c === void 0 ? void 0 : _c.type) === "heading") {
1303
- result.push({ type: "paragraph", content: [], children: [] });
1304
- }
1292
+ // Drop trailing empty paragraphs so a trailing blank line in the source
1293
+ // doesn't leave a ghost empty block at the end of the document.
1294
+ while (blocks.length > 0 &&
1295
+ blocks[blocks.length - 1].type === "paragraph" &&
1296
+ (!blocks[blocks.length - 1].content ||
1297
+ blocks[blocks.length - 1].content.length === 0)) {
1298
+ blocks.pop();
1305
1299
  }
1306
- return fixMalformedImageBlocks(result);
1300
+ return fixMalformedImageBlocks(blocks);
1307
1301
  }
1308
1302
  function splitTableRow(line) {
1309
1303
  let value = line.trim();
@@ -947,6 +947,10 @@ html.dark .bn-step-image-preview__content {
947
947
  color: var(--step-input-border-focus);
948
948
  }
949
949
 
950
+ .bn-container {
951
+ overflow-x: clip;
952
+ }
953
+
950
954
  [data-tooltip] {
951
955
  position: relative;
952
956
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.50",
3
+ "version": "0.4.52",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
package/src/App.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  filterSuggestionItems,
12
12
  insertOrUpdateBlock,
13
13
  } from "@blocknote/core";
14
+ import { autoPlacement, offset, shift, size } from "@floating-ui/react";
14
15
  import {
15
16
  blocksToMarkdown,
16
17
  markdownToBlocks,
@@ -354,7 +355,28 @@ function CustomSlashMenu() {
354
355
  return filterSuggestionItems(items, query);
355
356
  };
356
357
 
357
- return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
358
+ return (
359
+ <SuggestionMenuController
360
+ triggerCharacter="/"
361
+ getItems={getItems}
362
+ floatingOptions={{
363
+ middleware: [
364
+ offset(10),
365
+ autoPlacement({
366
+ allowedPlacements: ["bottom-start", "top-start"],
367
+ }),
368
+ shift(),
369
+ size({
370
+ apply({ availableHeight, elements }) {
371
+ Object.assign(elements.floating.style, {
372
+ maxHeight: `${Math.max(availableHeight - 10, 0)}px`,
373
+ });
374
+ },
375
+ }),
376
+ ],
377
+ }}
378
+ />
379
+ );
358
380
  }
359
381
 
360
382
  function App() {
@@ -140,7 +140,7 @@ function fallbackHtmlToMarkdown(html: string): string {
140
140
  .replace(/<br\s*\/?>/gi, "\n")
141
141
  .replace(/<\/?(div|p)>/gi, "\n")
142
142
  .replace(/<strong>(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
143
- .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `*${content}*`)
143
+ .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `_${content}_`)
144
144
  .replace(/<span[^>]*>/gi, "")
145
145
  .replace(/<\/span>/gi, "")
146
146
  .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
@@ -463,6 +463,7 @@ export const stepBlock = createReactBlockSpec(
463
463
  aria-label={forceVertical ? "Not enough space for horizontal view" : "Switch step view"}
464
464
  onClick={forceVertical ? undefined : handleToggleView}
465
465
  aria-disabled={forceVertical}
466
+ tabIndex={-1}
466
467
  >
467
468
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
468
469
  <mask id="mask-toggle" style={{maskType: "alpha"}} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
@@ -286,7 +286,7 @@ export function buildFullMarkdown(plainText: string, links: LinkMeta[], formatti
286
286
  openMarker = isMultiline ? "```\n" : "`";
287
287
  closeMarker = isMultiline ? "\n```" : "`";
288
288
  } else {
289
- openMarker = fmt.type === "bold" ? "**" : "*";
289
+ openMarker = fmt.type === "bold" ? "**" : "_";
290
290
  closeMarker = openMarker;
291
291
  }
292
292
  // Opening: outer markers (bold) before inner (italic) → bold order=0, italic order=1
@@ -9,7 +9,7 @@ describe("buildFullMarkdown formatting combinations", () => {
9
9
  { start: 0, end: 5, type: "bold" },
10
10
  { start: 0, end: 5, type: "italic" },
11
11
  ];
12
- expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("***hello***");
12
+ expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("_**hello**_");
13
13
  });
14
14
 
15
15
  it("preserves word-level bold when sentence-level bold is applied", () => {
@@ -31,7 +31,7 @@ describe("buildFullMarkdown formatting combinations", () => {
31
31
  { start: 0, end: 5, type: "bold" },
32
32
  { start: 0, end: 11, type: "italic" },
33
33
  ];
34
- expect(buildFullMarkdown(text, noLinks, formatting)).toBe("***hello** world*");
34
+ expect(buildFullMarkdown(text, noLinks, formatting)).toBe("_**hello** world_");
35
35
  });
36
36
 
37
37
  it("code formatting removes bold and italic", () => {
@@ -10,6 +10,18 @@ type PasteHandlerContext = {
10
10
  }) => boolean | undefined;
11
11
  };
12
12
 
13
+ const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
14
+
15
+ function isInlineOnlyPaste(plainText: string, parsedBlocks: CustomPartialBlock[]): boolean {
16
+ if (parsedBlocks.length !== 1) return false;
17
+ const [block] = parsedBlocks;
18
+ if (block.type !== "paragraph") return false;
19
+ if (block.children && block.children.length > 0) return false;
20
+ if (/\r?\n/.test(plainText)) return false;
21
+ if (BLOCK_MARKDOWN_PREFIX.test(plainText)) return false;
22
+ return true;
23
+ }
24
+
13
25
  export function createMarkdownPasteHandler(
14
26
  converter: (markdown: string) => CustomPartialBlock[],
15
27
  ) {
@@ -34,6 +46,10 @@ export function createMarkdownPasteHandler(
34
46
  const parsedBlocks = converter(plainText);
35
47
  if (parsedBlocks.length === 0) return defaultPasteHandler();
36
48
 
49
+ if (isInlineOnlyPaste(plainText, parsedBlocks)) {
50
+ return defaultPasteHandler({ plainTextAsMarkdown: false });
51
+ }
52
+
37
53
  const selection = editor.getSelection();
38
54
  const selectedIds = selection?.blocks
39
55
  ?.map((block: any) => block.id)
@@ -43,7 +43,39 @@ describe("blocksToMarkdown", () => {
43
43
  },
44
44
  ];
45
45
 
46
- expect(blocksToMarkdown(blocks)).toBe("Hello **world***!*");
46
+ expect(blocksToMarkdown(blocks)).toBe("Hello **world**_!_");
47
+ });
48
+
49
+ it("preserves HTML comments without escaping", () => {
50
+ const blocks: CustomEditorBlock[] = [
51
+ {
52
+ id: "c1",
53
+ type: "paragraph",
54
+ props: baseProps,
55
+ content: [
56
+ { type: "text", text: "<!-- ai/agent generated description -->", styles: {} },
57
+ ],
58
+ children: [],
59
+ },
60
+ ];
61
+
62
+ expect(blocksToMarkdown(blocks)).toBe("<!-- ai/agent generated description -->");
63
+ });
64
+
65
+ it("preserves HTML comments inline among text and still escapes stray angle brackets", () => {
66
+ const blocks: CustomEditorBlock[] = [
67
+ {
68
+ id: "c2",
69
+ type: "paragraph",
70
+ props: baseProps,
71
+ content: [
72
+ { type: "text", text: "before <!-- note --> after <div>", styles: {} },
73
+ ],
74
+ children: [],
75
+ },
76
+ ];
77
+
78
+ expect(blocksToMarkdown(blocks)).toBe("before <!-- note --> after \\<div>");
47
79
  });
48
80
 
49
81
  it("places bold markers outside leading/trailing spaces", () => {
@@ -76,7 +108,7 @@ describe("blocksToMarkdown", () => {
76
108
  children: [],
77
109
  },
78
110
  ];
79
- expect(blocksToMarkdown(blocks)).toBe("*word* next");
111
+ expect(blocksToMarkdown(blocks)).toBe("_word_ next");
80
112
  });
81
113
 
82
114
  it("places code backticks outside leading/trailing spaces", () => {
@@ -301,7 +333,7 @@ describe("blocksToMarkdown", () => {
301
333
  ];
302
334
 
303
335
  const markdown = blocksToMarkdown(blocks);
304
- expect(markdown).toBe("***text***");
336
+ expect(markdown).toBe("_**text**_");
305
337
  });
306
338
 
307
339
  it("keeps inline formatting inside step fields", () => {
@@ -1082,6 +1114,11 @@ describe("markdownToBlocks", () => {
1082
1114
  content: [{ type: "text", text: "Preconditions", styles: {} }],
1083
1115
  children: [],
1084
1116
  },
1117
+ {
1118
+ type: "paragraph",
1119
+ content: [],
1120
+ children: [],
1121
+ },
1085
1122
  {
1086
1123
  type: "bulletListItem",
1087
1124
  props: baseProps,
@@ -2525,3 +2562,121 @@ describe("steps require Steps heading", () => {
2525
2562
  expect((stepBlocks[0].props as any).stepTitle).toBe("next 22");
2526
2563
  });
2527
2564
  });
2565
+
2566
+ describe("blank line <-> empty paragraph mapping", () => {
2567
+ const isEmptyParagraph = (block: CustomPartialBlock | CustomEditorBlock) =>
2568
+ block.type === "paragraph" &&
2569
+ (!block.content || (block.content as any[]).length === 0);
2570
+
2571
+ it("parses a single blank line between headings as one empty paragraph", () => {
2572
+ const blocks = markdownToBlocks("### A\n\n### B");
2573
+ expect(blocks).toHaveLength(3);
2574
+ expect(blocks[0].type).toBe("heading");
2575
+ expect(isEmptyParagraph(blocks[1])).toBe(true);
2576
+ expect(blocks[2].type).toBe("heading");
2577
+ });
2578
+
2579
+ it("parses two blank lines between headings as two empty paragraphs", () => {
2580
+ const blocks = markdownToBlocks("### A\n\n\n### B");
2581
+ expect(blocks).toHaveLength(4);
2582
+ expect(blocks[0].type).toBe("heading");
2583
+ expect(isEmptyParagraph(blocks[1])).toBe(true);
2584
+ expect(isEmptyParagraph(blocks[2])).toBe(true);
2585
+ expect(blocks[3].type).toBe("heading");
2586
+ });
2587
+
2588
+ it("drops leading and trailing blank lines", () => {
2589
+ const blocks = markdownToBlocks("\n\n### A\n\n");
2590
+ expect(blocks).toHaveLength(1);
2591
+ expect(blocks[0].type).toBe("heading");
2592
+ });
2593
+
2594
+ it("serializes an empty paragraph between two blocks as one blank line", () => {
2595
+ const blocks: CustomEditorBlock[] = [
2596
+ {
2597
+ id: "h1",
2598
+ type: "heading",
2599
+ props: { ...baseProps, level: 3 } as any,
2600
+ content: [{ type: "text", text: "A", styles: {} }],
2601
+ children: [],
2602
+ },
2603
+ {
2604
+ id: "p1",
2605
+ type: "paragraph",
2606
+ props: baseProps,
2607
+ content: [],
2608
+ children: [],
2609
+ },
2610
+ {
2611
+ id: "h2",
2612
+ type: "heading",
2613
+ props: { ...baseProps, level: 3 } as any,
2614
+ content: [{ type: "text", text: "B", styles: {} }],
2615
+ children: [],
2616
+ },
2617
+ ];
2618
+ expect(blocksToMarkdown(blocks)).toBe("### A\n\n### B");
2619
+ });
2620
+
2621
+ it("round-trips a single blank line between headings", () => {
2622
+ const markdown = "### A\n\n### B";
2623
+ const blocks = markdownToBlocks(markdown);
2624
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2625
+ });
2626
+
2627
+ it("round-trips two blank lines between headings", () => {
2628
+ const markdown = "### A\n\n\n### B";
2629
+ const blocks = markdownToBlocks(markdown);
2630
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2631
+ });
2632
+
2633
+ it("round-trips headings adjacent with no blank line", () => {
2634
+ const markdown = "### A\n### B";
2635
+ const blocks = markdownToBlocks(markdown);
2636
+ expect(blocks.some(isEmptyParagraph)).toBe(false);
2637
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2638
+ });
2639
+
2640
+ it("deleting an empty paragraph removes the blank line from the output", () => {
2641
+ const blocks = markdownToBlocks("### A\n\n### B") as CustomEditorBlock[];
2642
+ const withoutEmpty = blocks.filter((block) => !isEmptyParagraph(block));
2643
+ expect(withoutEmpty).toHaveLength(2);
2644
+ expect(blocksToMarkdown(withoutEmpty)).toBe("### A\n### B");
2645
+ });
2646
+
2647
+ it("inserting an empty paragraph adds a blank line to the output", () => {
2648
+ const blocks = markdownToBlocks("### A\n### B") as CustomEditorBlock[];
2649
+ const withEmpty: CustomEditorBlock[] = [
2650
+ blocks[0],
2651
+ {
2652
+ id: "inserted",
2653
+ type: "paragraph",
2654
+ props: baseProps,
2655
+ content: [],
2656
+ children: [],
2657
+ },
2658
+ blocks[1],
2659
+ ];
2660
+ expect(blocksToMarkdown(withEmpty)).toBe("### A\n\n### B");
2661
+ });
2662
+
2663
+ it("preserves blank lines between a heading and a bullet list", () => {
2664
+ const markdown = "### Steps\n\n* first\n* second";
2665
+ const blocks = markdownToBlocks(markdown);
2666
+ expect(isEmptyParagraph(blocks[1])).toBe(true);
2667
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2668
+ });
2669
+
2670
+ it("preserves blank lines across the user-reported screenshot example", () => {
2671
+ const markdown = [
2672
+ "### Requirements",
2673
+ "",
2674
+ "### Steps",
2675
+ "",
2676
+ "* *open* **webiste**",
2677
+ " step data with `image`",
2678
+ ].join("\n");
2679
+ const blocks = markdownToBlocks(markdown);
2680
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2681
+ });
2682
+ });
@@ -61,6 +61,7 @@ const headingPrefixes: Record<number, string> = {
61
61
  };
62
62
 
63
63
  const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
64
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
64
65
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
65
66
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
66
67
  const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
@@ -70,7 +71,17 @@ const STEP_DATA_LINE_REGEX =
70
71
  const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
71
72
 
72
73
  function escapeMarkdown(text: string): string {
73
- return text.replace(SPECIAL_CHAR_REGEX, "\\$1");
74
+ let result = "";
75
+ let lastIndex = 0;
76
+ HTML_COMMENT_REGEX.lastIndex = 0;
77
+ let match: RegExpExecArray | null;
78
+ while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
79
+ result += text.slice(lastIndex, match.index).replace(SPECIAL_CHAR_REGEX, "\\$1");
80
+ result += match[0];
81
+ lastIndex = match.index + match[0].length;
82
+ }
83
+ result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
84
+ return result;
74
85
  }
75
86
 
76
87
  function stripHtmlWrappers(text: string): string {
@@ -152,7 +163,7 @@ function applyTextStyles(text: string, styles: EditorStyles | undefined): string
152
163
  }
153
164
 
154
165
  if (styles.italic) {
155
- wrappers.push({ prefix: "*", suffix: "*" });
166
+ wrappers.push({ prefix: "_", suffix: "_" });
156
167
  }
157
168
 
158
169
  if (styles.strike) {
@@ -273,13 +284,6 @@ function serializeChildren(block: CustomEditorBlock, ctx: MarkdownContext): stri
273
284
  return serializeBlocks(block.children, childCtx);
274
285
  }
275
286
 
276
- function flattenWithBlankLine(lines: string[], appendBlank = false): string[] {
277
- if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
278
- return [...lines, ""];
279
- }
280
- return lines;
281
- }
282
-
283
287
  function serializeBlock(
284
288
  block: CustomEditorBlock,
285
289
  ctx: MarkdownContext,
@@ -292,17 +296,21 @@ function serializeBlock(
292
296
  switch (block.type) {
293
297
  case "paragraph": {
294
298
  const text = inlineToMarkdown(block.content);
295
- if (text.length > 0) {
296
- lines.push(ctx.insideQuote ? `> ${text}` : text);
299
+ if (text.length === 0) {
300
+ // Empty paragraph = one blank line in the output. Under the 1:1
301
+ // block model, this is the only mechanism that produces blank lines
302
+ // between top-level blocks.
303
+ return [""];
297
304
  }
298
- return flattenWithBlankLine(lines, !ctx.insideQuote);
305
+ lines.push(ctx.insideQuote ? `> ${text}` : text);
306
+ return lines;
299
307
  }
300
308
  case "heading": {
301
309
  const level = (block.props as any).level ?? 1;
302
310
  const prefix = headingPrefixes[level] ?? headingPrefixes[3];
303
311
  const text = inlineToMarkdown(block.content);
304
312
  lines.push(`${prefix} ${text}`.trimEnd());
305
- return flattenWithBlankLine(lines, true);
313
+ return lines;
306
314
  }
307
315
  case "quote": {
308
316
  const quoteContent = serializeBlocks(block.children ?? [], {
@@ -317,7 +325,7 @@ function serializeBlock(
317
325
  lines.push(...quoteText);
318
326
  }
319
327
  lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
320
- return flattenWithBlankLine(lines, true);
328
+ return lines;
321
329
  }
322
330
  case "codeBlock": {
323
331
  const language = (block.props as any).language || "";
@@ -328,7 +336,7 @@ function serializeBlock(
328
336
  lines.push(body);
329
337
  }
330
338
  lines.push("```");
331
- return flattenWithBlankLine(lines, true);
339
+ return lines;
332
340
  }
333
341
  case "bulletListItem": {
334
342
  const text = inlineToMarkdown(block.content);
@@ -361,7 +369,7 @@ function serializeBlock(
361
369
  const size = width ? ` =${width}x*` : "";
362
370
  lines.push(`![${caption}](${url}${size})`);
363
371
  }
364
- return flattenWithBlankLine(lines, true);
372
+ return lines;
365
373
  }
366
374
  case "file":
367
375
  case "video":
@@ -373,7 +381,7 @@ function serializeBlock(
373
381
  const displayUrl = caption || resolveFileDisplayUrl(url);
374
382
  lines.push(`[![${name}](${displayUrl})](${url})`);
375
383
  }
376
- return flattenWithBlankLine(lines, true);
384
+ return lines;
377
385
  }
378
386
  case "testStep":
379
387
  case "snippet": {
@@ -405,7 +413,7 @@ function serializeBlock(
405
413
  lines.push(`<!-- end snippet #${snippetId} -->`);
406
414
  }
407
415
 
408
- return flattenWithBlankLine(lines, true);
416
+ return lines;
409
417
  }
410
418
 
411
419
  const normalizedTitle = stepTitle
@@ -453,16 +461,12 @@ function serializeBlock(
453
461
  });
454
462
  }
455
463
 
456
- if (lines.length === 0) {
457
- return lines;
458
- }
459
-
460
- return flattenWithBlankLine(lines, false);
464
+ return lines;
461
465
  }
462
466
  case "table": {
463
467
  const tableContent = block.content as any;
464
468
  if (!tableContent || tableContent.type !== "tableContent") {
465
- return flattenWithBlankLine(lines, true);
469
+ return lines;
466
470
  }
467
471
 
468
472
  const rows: any[] = Array.isArray(tableContent.rows)
@@ -470,7 +474,7 @@ function serializeBlock(
470
474
  : [];
471
475
 
472
476
  if (rows.length === 0) {
473
- return flattenWithBlankLine(lines, true);
477
+ return lines;
474
478
  }
475
479
 
476
480
  const columnCount = rows.reduce((max, row) => {
@@ -479,7 +483,7 @@ function serializeBlock(
479
483
  }, 0);
480
484
 
481
485
  if (columnCount === 0) {
482
- return flattenWithBlankLine(lines, true);
486
+ return lines;
483
487
  }
484
488
 
485
489
  const headerRowCount = rows.length
@@ -570,7 +574,7 @@ function serializeBlock(
570
574
  lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
571
575
  });
572
576
 
573
- return flattenWithBlankLine(lines, true);
577
+ return lines;
574
578
  }
575
579
  }
576
580
 
@@ -582,7 +586,7 @@ function serializeBlock(
582
586
  }
583
587
  }
584
588
  lines.push(...serializeChildren(fallbackBlock, ctx));
585
- return flattenWithBlankLine(lines, false);
589
+ return lines;
586
590
  }
587
591
 
588
592
  function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): string[] {
@@ -620,10 +624,7 @@ function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): str
620
624
 
621
625
  export function blocksToMarkdown(blocks: CustomEditorBlock[]): string {
622
626
  const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
623
- const cleaned = lines
624
- .join("\n")
625
- .replace(/\n{3,}/g, "\n\n")
626
- .trimEnd();
627
+ const cleaned = lines.join("\n").trimEnd();
627
628
 
628
629
  return cleaned;
629
630
  }
@@ -709,8 +710,9 @@ function parseInlineMarkdown(text: string): EditorInline[] {
709
710
  }
710
711
  }
711
712
 
712
- if (cleaned.startsWith("*", i)) {
713
- const end = cleaned.indexOf("*", i + 1);
713
+ if (cleaned[i] === "*" || cleaned[i] === "_") {
714
+ const marker = cleaned[i];
715
+ const end = cleaned.indexOf(marker, i + 1);
714
716
  if (end !== -1) {
715
717
  pushPlain();
716
718
  const inner = cleaned.slice(i + 1, end);
@@ -1396,12 +1398,16 @@ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPar
1396
1398
  return result;
1397
1399
  }
1398
1400
 
1401
+ // The `preserveBlankLines` option is retained for backwards compatibility
1402
+ // but is now a no-op: blank lines in the source markdown always produce
1403
+ // empty paragraph blocks (except for leading/trailing blanks, which are
1404
+ // dropped). This gives a 1:1 mapping between blank lines and blocks so the
1405
+ // Rich editor can render and delete each blank line individually.
1399
1406
  export interface MarkdownToBlocksOptions {
1400
- /** When true, every blank line produces an empty paragraph block. */
1401
1407
  preserveBlankLines?: boolean;
1402
1408
  }
1403
1409
 
1404
- export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
1410
+ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
1405
1411
  const normalized = markdown.replace(/\r\n/g, "\n");
1406
1412
  const lines = normalized.split("\n");
1407
1413
  const blocks: CustomPartialBlock[] = [];
@@ -1411,22 +1417,12 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1411
1417
  while (index < lines.length) {
1412
1418
  const line = lines[index];
1413
1419
  if (!line.trim()) {
1414
- if (options?.preserveBlankLines) {
1420
+ // Drop blank lines until we've emitted at least one block, so leading
1421
+ // blanks don't produce a ghost empty paragraph at the top of the doc.
1422
+ if (blocks.length > 0) {
1415
1423
  blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1416
- index += 1;
1417
- continue;
1418
1424
  }
1419
1425
  index += 1;
1420
- // Count consecutive blank lines
1421
- let blankCount = 1;
1422
- while (index < lines.length && !lines[index].trim()) {
1423
- blankCount++;
1424
- index++;
1425
- }
1426
- // Create empty paragraph for each extra blank line beyond the first
1427
- for (let i = 1; i < blankCount; i++) {
1428
- blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1429
- }
1430
1426
  continue;
1431
1427
  }
1432
1428
 
@@ -1542,16 +1538,18 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1542
1538
  index = paragraph.nextIndex;
1543
1539
  }
1544
1540
 
1545
- // Insert empty paragraphs between consecutive headings so users can type between them
1546
- const result: CustomPartialBlock[] = [];
1547
- for (let i = 0; i < blocks.length; i++) {
1548
- result.push(blocks[i]);
1549
- if (blocks[i].type === "heading" && blocks[i + 1]?.type === "heading") {
1550
- result.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1551
- }
1541
+ // Drop trailing empty paragraphs so a trailing blank line in the source
1542
+ // doesn't leave a ghost empty block at the end of the document.
1543
+ while (
1544
+ blocks.length > 0 &&
1545
+ blocks[blocks.length - 1].type === "paragraph" &&
1546
+ (!blocks[blocks.length - 1].content ||
1547
+ (blocks[blocks.length - 1].content as any[]).length === 0)
1548
+ ) {
1549
+ blocks.pop();
1552
1550
  }
1553
1551
 
1554
- return fixMalformedImageBlocks(result);
1552
+ return fixMalformedImageBlocks(blocks);
1555
1553
  }
1556
1554
 
1557
1555
  function splitTableRow(line: string): string[] {
@@ -947,6 +947,10 @@ html.dark .bn-step-image-preview__content {
947
947
  color: var(--step-input-border-focus);
948
948
  }
949
949
 
950
+ .bn-container {
951
+ overflow-x: clip;
952
+ }
953
+
950
954
  [data-tooltip] {
951
955
  position: relative;
952
956
  }