testomatio-editor-blocks 0.4.51 → 0.4.53

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.
@@ -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
  }
@@ -40,6 +40,18 @@ export type FormattingMeta = {
40
40
  end: number;
41
41
  type: "bold" | "italic" | "code";
42
42
  };
43
+ type FormatType = "bold" | "italic" | "code";
44
+ /**
45
+ * Remove formatting and link entries that conflict with applying `fmtType`
46
+ * over the half-open range [start, end). Exclusion rules:
47
+ * - code: exclusive with everything — drops any overlapping formatting and links
48
+ * - bold/italic: coexist with each other, but exclusive with code and links
49
+ * — drops overlapping same-type, overlapping code, and overlapping links
50
+ */
51
+ export declare function applyInlineExclusion(formatting: FormattingMeta[], links: LinkMeta[], start: number, end: number, fmtType: FormatType): {
52
+ formatting: FormattingMeta[];
53
+ links: LinkMeta[];
54
+ };
43
55
  export declare function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string;
44
56
  export declare function StepField({ label, showLabel, labelToggle, labelAction, placeholder, value, onChange, autoFocus, focusSignal, multiline, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, onFieldFocus, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
45
57
  export {};
@@ -18,6 +18,27 @@ const markdownParser = OverType.MarkdownParser;
18
18
  function ImageUploadIcon() {
19
19
  return (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M12.667 2C13.0335 2.00008 13.3474 2.13057 13.6084 2.3916C13.8694 2.65264 13.9999 2.96648 14 3.33301V12.667C13.9999 13.0335 13.8694 13.3474 13.6084 13.6084C13.3474 13.8694 13.0335 13.9999 12.667 14H3.33301C2.96648 13.9999 2.65264 13.8694 2.3916 13.6084C2.13057 13.3474 2.00008 13.0335 2 12.667V3.33301C2.00008 2.96648 2.13057 2.65264 2.3916 2.3916C2.65264 2.13057 2.96648 2.00008 3.33301 2H12.667ZM3.33301 12.667H12.667V3.33301H3.33301V12.667ZM12 11.333H4L6 8.66699L7.5 10.667L9.5 8L12 11.333ZM5.66699 4.66699C5.94455 4.66707 6.18066 4.76375 6.375 4.95801C6.56944 5.15245 6.66699 5.38921 6.66699 5.66699C6.66692 5.94463 6.56937 6.18063 6.375 6.375C6.18063 6.56937 5.94463 6.66692 5.66699 6.66699C5.38921 6.66699 5.15245 6.56944 4.95801 6.375C4.76375 6.18066 4.66707 5.94455 4.66699 5.66699C4.66699 5.38921 4.76356 5.15245 4.95801 4.95801C5.15245 4.76356 5.38921 4.66699 5.66699 4.66699Z", fill: "currentColor" }) }));
20
20
  }
21
+ /**
22
+ * Remove formatting and link entries that conflict with applying `fmtType`
23
+ * over the half-open range [start, end). Exclusion rules:
24
+ * - code: exclusive with everything — drops any overlapping formatting and links
25
+ * - bold/italic: coexist with each other, but exclusive with code and links
26
+ * — drops overlapping same-type, overlapping code, and overlapping links
27
+ */
28
+ export function applyInlineExclusion(formatting, links, start, end, fmtType) {
29
+ const overlaps = (a) => !(a.start >= end || a.end <= start);
30
+ const nextFormatting = formatting.filter((f) => {
31
+ if (!overlaps(f))
32
+ return true;
33
+ if (fmtType === "code")
34
+ return false;
35
+ if (f.type === "code")
36
+ return false;
37
+ return f.type !== fmtType;
38
+ });
39
+ const nextLinks = links.filter((l) => !overlaps(l));
40
+ return { formatting: nextFormatting, links: nextLinks };
41
+ }
21
42
  const UNDO_STACK_LIMIT = 100;
22
43
  function getActiveFormats(formatting, selStart, selEnd) {
23
44
  const active = new Set();
@@ -965,12 +986,9 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
965
986
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
966
987
  }
967
988
  else if (start !== end) {
968
- // Remove overlapping formatting:
969
- // - Code: remove ALL overlapping formatting (code replaces bold/italic)
970
- // - Bold/Italic: remove only overlapping formatting of the SAME type
971
- formattingRef.current = formattingRef.current.filter((f) => f.start >= end || f.end <= start || (fmtType !== "code" && f.type !== fmtType));
972
- // Add formatting for selection
973
- formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
989
+ const cleaned = applyInlineExclusion(formattingRef.current, linksRef.current, start, end, fmtType);
990
+ formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }];
991
+ linksRef.current = cleaned.links;
974
992
  }
975
993
  else {
976
994
  // No selection — nothing to format
@@ -1071,7 +1089,10 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
1071
1089
  const adjustedLinks = adjustLinksForEdit(linksRef.current.filter((l) => !(l.start < sel.end && l.end > sel.start)), sel.start, delta);
1072
1090
  const newLink = { start: sel.start, end: sel.start + linkText.length, url };
1073
1091
  linksRef.current = [...adjustedLinks, newLink];
1074
- formattingRef.current = adjustFormattingForEdit(formattingRef.current, sel.start, delta);
1092
+ // Links are exclusive with bold/italic/code: strip any formatting that
1093
+ // overlaps the original selection before shifting positions.
1094
+ const keptFormatting = formattingRef.current.filter((f) => f.start >= sel.end || f.end <= sel.start);
1095
+ formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta);
1075
1096
  prevTextRef.current = nextValue;
1076
1097
  isSyncingRef.current = true;
1077
1098
  instance.setValue(nextValue);
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
2
2
  export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRows = 12 }) {
3
3
  const frameRef = useRef(0);
4
4
  useEffect(() => {
5
+ var _a;
5
6
  if (!textarea || !multiline) {
6
7
  return;
7
8
  }
@@ -14,19 +15,50 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
14
15
  textarea.style.height = `${clampedHeight}px`;
15
16
  textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
16
17
  };
17
- const observer = new MutationObserver(resize);
18
- observer.observe(textarea, { childList: true, characterData: true, subtree: true });
18
+ const mutationObserver = new MutationObserver(resize);
19
+ mutationObserver.observe(textarea, { childList: true, characterData: true, subtree: true });
20
+ const resizeObserver = new ResizeObserver(resize);
21
+ resizeObserver.observe(textarea);
19
22
  const handleInput = () => {
20
23
  var _a;
21
24
  cancelAnimationFrame((_a = frameRef.current) !== null && _a !== void 0 ? _a : 0);
22
25
  frameRef.current = requestAnimationFrame(resize);
23
26
  };
24
27
  textarea.addEventListener("input", handleInput);
25
- resize();
28
+ let cancelled = false;
29
+ const initialFrame = requestAnimationFrame(() => {
30
+ frameRef.current = requestAnimationFrame(() => {
31
+ if (!cancelled)
32
+ resize();
33
+ });
34
+ });
35
+ // Re-run resize once the textarea is actually laid out. During drag-drop
36
+ // remounts the element can be briefly detached, so the initial RAF resize
37
+ // sees scrollHeight === 0 and clamps to minRows.
38
+ const intersectionObserver = new IntersectionObserver((entries) => {
39
+ for (const entry of entries) {
40
+ if (entry.isIntersecting && !cancelled) {
41
+ resize();
42
+ intersectionObserver.disconnect();
43
+ break;
44
+ }
45
+ }
46
+ });
47
+ intersectionObserver.observe(textarea);
48
+ if (typeof document !== "undefined" && ((_a = document.fonts) === null || _a === void 0 ? void 0 : _a.ready)) {
49
+ document.fonts.ready.then(() => {
50
+ if (!cancelled)
51
+ resize();
52
+ }).catch(() => { });
53
+ }
26
54
  return () => {
27
55
  var _a;
28
- observer.disconnect();
56
+ cancelled = true;
57
+ mutationObserver.disconnect();
58
+ resizeObserver.disconnect();
59
+ intersectionObserver.disconnect();
29
60
  textarea.removeEventListener("input", handleInput);
61
+ cancelAnimationFrame(initialFrame);
30
62
  cancelAnimationFrame((_a = frameRef.current) !== null && _a !== void 0 ? _a : 0);
31
63
  };
32
64
  }, [textarea, multiline, minRows, maxRows]);
@@ -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
@@ -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/)
@@ -356,7 +365,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
356
365
  const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
357
366
  if (normalizedExpected.length > 0) {
358
367
  const expectedLines = normalizedExpected.split(/\r?\n/);
359
- const label = "*Expected*";
368
+ const label = "*Expected result*";
360
369
  expectedLines.forEach((expectedLine, index) => {
361
370
  const trimmedLine = expectedLine.trim();
362
371
  if (trimmedLine.length === 0) {
@@ -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 = [];
@@ -650,7 +656,27 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
650
656
  const rawLine = lines[index];
651
657
  const trimmed = rawLine.trim();
652
658
  if (!trimmed) {
653
- index += 1;
659
+ // Peek at the next non-blank line. If it's another item of this list
660
+ // (same indent level and list type), treat the blank lines as loose-list
661
+ // separators and consume them. Otherwise leave the blank line for the
662
+ // outer loop so it can become an empty paragraph block.
663
+ let lookahead = index + 1;
664
+ while (lookahead < lines.length && !lines[lookahead].trim()) {
665
+ lookahead += 1;
666
+ }
667
+ if (lookahead >= lines.length) {
668
+ break;
669
+ }
670
+ const nextLine = lines[lookahead];
671
+ const nextIndent = countIndent(nextLine);
672
+ if (nextIndent < indentLevel * 2) {
673
+ break;
674
+ }
675
+ const nextType = detectListType(nextLine.trim());
676
+ if (nextType !== listType) {
677
+ break;
678
+ }
679
+ index = lookahead;
654
680
  continue;
655
681
  }
656
682
  let indent = countIndent(rawLine);
@@ -1172,8 +1198,8 @@ export function fixMalformedImageBlocks(blocks) {
1172
1198
  }
1173
1199
  return result;
1174
1200
  }
1175
- export function markdownToBlocks(markdown, options) {
1176
- var _a, _b, _c;
1201
+ export function markdownToBlocks(markdown, _options) {
1202
+ var _a, _b;
1177
1203
  const normalized = markdown.replace(/\r\n/g, "\n");
1178
1204
  const lines = normalized.split("\n");
1179
1205
  const blocks = [];
@@ -1182,22 +1208,12 @@ export function markdownToBlocks(markdown, options) {
1182
1208
  while (index < lines.length) {
1183
1209
  const line = lines[index];
1184
1210
  if (!line.trim()) {
1185
- if (options === null || options === void 0 ? void 0 : options.preserveBlankLines) {
1211
+ // Drop blank lines until we've emitted at least one block, so leading
1212
+ // blanks don't produce a ghost empty paragraph at the top of the doc.
1213
+ if (blocks.length > 0) {
1186
1214
  blocks.push({ type: "paragraph", content: [], children: [] });
1187
- index += 1;
1188
- continue;
1189
1215
  }
1190
1216
  index += 1;
1191
- // Count consecutive blank lines
1192
- let blankCount = 1;
1193
- while (index < lines.length && !lines[index].trim()) {
1194
- blankCount++;
1195
- index++;
1196
- }
1197
- // Create empty paragraph for each extra blank line beyond the first
1198
- for (let i = 1; i < blankCount; i++) {
1199
- blocks.push({ type: "paragraph", content: [], children: [] });
1200
- }
1201
1217
  continue;
1202
1218
  }
1203
1219
  const snippetWrapper = stepsHeadingLevel !== null
@@ -1293,15 +1309,15 @@ export function markdownToBlocks(markdown, options) {
1293
1309
  blocks.push(paragraph.block);
1294
1310
  index = paragraph.nextIndex;
1295
1311
  }
1296
- // Insert empty paragraphs between consecutive headings so users can type between them
1297
- const result = [];
1298
- for (let i = 0; i < blocks.length; i++) {
1299
- result.push(blocks[i]);
1300
- if (blocks[i].type === "heading" && ((_c = blocks[i + 1]) === null || _c === void 0 ? void 0 : _c.type) === "heading") {
1301
- result.push({ type: "paragraph", content: [], children: [] });
1302
- }
1312
+ // Drop trailing empty paragraphs so a trailing blank line in the source
1313
+ // doesn't leave a ghost empty block at the end of the document.
1314
+ while (blocks.length > 0 &&
1315
+ blocks[blocks.length - 1].type === "paragraph" &&
1316
+ (!blocks[blocks.length - 1].content ||
1317
+ blocks[blocks.length - 1].content.length === 0)) {
1318
+ blocks.pop();
1303
1319
  }
1304
- return fixMalformedImageBlocks(result);
1320
+ return fixMalformedImageBlocks(blocks);
1305
1321
  }
1306
1322
  function splitTableRow(line) {
1307
1323
  let value = line.trim();
@@ -1100,6 +1100,12 @@ html.dark .bn-step-image-preview__content {
1100
1100
  color: rgb(146, 64, 14) !important;
1101
1101
  }
1102
1102
 
1103
+ .bn-step-editor .overtype-wrapper .overtype-preview li.bullet-list .syntax-marker,
1104
+ .bn-step-editor .overtype-wrapper .overtype-preview li.ordered-list .syntax-marker {
1105
+ color: inherit !important;
1106
+ opacity: 1 !important;
1107
+ }
1108
+
1103
1109
  .bn-step-custom-caret {
1104
1110
  display: none;
1105
1111
  position: absolute;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.51",
3
+ "version": "0.4.53",
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() {
@@ -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">
@@ -83,6 +83,31 @@ export type LinkMeta = { start: number; end: number; url: string };
83
83
  export type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
84
84
  type FormatType = "bold" | "italic" | "code";
85
85
 
86
+ /**
87
+ * Remove formatting and link entries that conflict with applying `fmtType`
88
+ * over the half-open range [start, end). Exclusion rules:
89
+ * - code: exclusive with everything — drops any overlapping formatting and links
90
+ * - bold/italic: coexist with each other, but exclusive with code and links
91
+ * — drops overlapping same-type, overlapping code, and overlapping links
92
+ */
93
+ export function applyInlineExclusion(
94
+ formatting: FormattingMeta[],
95
+ links: LinkMeta[],
96
+ start: number,
97
+ end: number,
98
+ fmtType: FormatType,
99
+ ): { formatting: FormattingMeta[]; links: LinkMeta[] } {
100
+ const overlaps = (a: { start: number; end: number }) => !(a.start >= end || a.end <= start);
101
+ const nextFormatting = formatting.filter((f) => {
102
+ if (!overlaps(f)) return true;
103
+ if (fmtType === "code") return false;
104
+ if (f.type === "code") return false;
105
+ return f.type !== fmtType;
106
+ });
107
+ const nextLinks = links.filter((l) => !overlaps(l));
108
+ return { formatting: nextFormatting, links: nextLinks };
109
+ }
110
+
86
111
  type EditorSnapshot = {
87
112
  text: string;
88
113
  formatting: FormattingMeta[];
@@ -1188,14 +1213,15 @@ export function StepField({
1188
1213
  // Remove formatting
1189
1214
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
1190
1215
  } else if (start !== end) {
1191
- // Remove overlapping formatting:
1192
- // - Code: remove ALL overlapping formatting (code replaces bold/italic)
1193
- // - Bold/Italic: remove only overlapping formatting of the SAME type
1194
- formattingRef.current = formattingRef.current.filter(
1195
- (f) => f.start >= end || f.end <= start || (fmtType !== "code" && f.type !== fmtType),
1216
+ const cleaned = applyInlineExclusion(
1217
+ formattingRef.current,
1218
+ linksRef.current,
1219
+ start,
1220
+ end,
1221
+ fmtType,
1196
1222
  );
1197
- // Add formatting for selection
1198
- formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
1223
+ formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }];
1224
+ linksRef.current = cleaned.links;
1199
1225
  } else {
1200
1226
  // No selection — nothing to format
1201
1227
  return;
@@ -1314,7 +1340,12 @@ export function StepField({
1314
1340
  );
1315
1341
  const newLink: LinkMeta = { start: sel.start, end: sel.start + linkText.length, url };
1316
1342
  linksRef.current = [...adjustedLinks, newLink];
1317
- formattingRef.current = adjustFormattingForEdit(formattingRef.current, sel.start, delta);
1343
+ // Links are exclusive with bold/italic/code: strip any formatting that
1344
+ // overlaps the original selection before shifting positions.
1345
+ const keptFormatting = formattingRef.current.filter(
1346
+ (f) => f.start >= sel.end || f.end <= sel.start,
1347
+ );
1348
+ formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta);
1318
1349
  prevTextRef.current = nextValue;
1319
1350
 
1320
1351
  isSyncingRef.current = true;
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildFullMarkdown, type FormattingMeta, type LinkMeta } from "./stepField";
2
+ import {
3
+ applyInlineExclusion,
4
+ buildFullMarkdown,
5
+ type FormattingMeta,
6
+ type LinkMeta,
7
+ } from "./stepField";
3
8
 
4
9
  describe("buildFullMarkdown formatting combinations", () => {
5
10
  const noLinks: LinkMeta[] = [];
@@ -42,3 +47,59 @@ describe("buildFullMarkdown formatting combinations", () => {
42
47
  expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("`hello`");
43
48
  });
44
49
  });
50
+
51
+ describe("applyInlineExclusion mutual-exclusion rules", () => {
52
+ it("applying code strips overlapping bold/italic", () => {
53
+ const formatting: FormattingMeta[] = [
54
+ { start: 0, end: 5, type: "bold" },
55
+ { start: 0, end: 5, type: "italic" },
56
+ ];
57
+ const result = applyInlineExclusion(formatting, [], 0, 5, "code");
58
+ expect(result.formatting).toEqual([]);
59
+ expect(result.links).toEqual([]);
60
+ });
61
+
62
+ it("applying code strips overlapping links", () => {
63
+ const links: LinkMeta[] = [{ start: 0, end: 5, url: "https://a" }];
64
+ const result = applyInlineExclusion([], links, 0, 5, "code");
65
+ expect(result.links).toEqual([]);
66
+ });
67
+
68
+ it("applying bold over a code range strips the code", () => {
69
+ const formatting: FormattingMeta[] = [{ start: 0, end: 5, type: "code" }];
70
+ const result = applyInlineExclusion(formatting, [], 0, 5, "bold");
71
+ expect(result.formatting).toEqual([]);
72
+ });
73
+
74
+ it("applying italic over a linked range strips the link", () => {
75
+ const links: LinkMeta[] = [{ start: 0, end: 5, url: "https://a" }];
76
+ const result = applyInlineExclusion([], links, 0, 5, "italic");
77
+ expect(result.links).toEqual([]);
78
+ });
79
+
80
+ it("applying bold preserves non-overlapping italic and non-overlapping links", () => {
81
+ const formatting: FormattingMeta[] = [
82
+ { start: 10, end: 20, type: "italic" },
83
+ ];
84
+ const links: LinkMeta[] = [{ start: 30, end: 40, url: "https://a" }];
85
+ const result = applyInlineExclusion(formatting, links, 0, 5, "bold");
86
+ expect(result.formatting).toEqual(formatting);
87
+ expect(result.links).toEqual(links);
88
+ });
89
+
90
+ it("applying bold preserves overlapping italic (bold and italic coexist)", () => {
91
+ const formatting: FormattingMeta[] = [
92
+ { start: 0, end: 10, type: "italic" },
93
+ ];
94
+ const result = applyInlineExclusion(formatting, [], 0, 10, "bold");
95
+ expect(result.formatting).toEqual(formatting);
96
+ });
97
+
98
+ it("applying italic drops overlapping italic (same-type replacement)", () => {
99
+ const formatting: FormattingMeta[] = [
100
+ { start: 0, end: 10, type: "italic" },
101
+ ];
102
+ const result = applyInlineExclusion(formatting, [], 2, 8, "italic");
103
+ expect(result.formatting).toEqual([]);
104
+ });
105
+ });
@@ -26,8 +26,11 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
26
26
  textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
27
27
  };
28
28
 
29
- const observer = new MutationObserver(resize);
30
- observer.observe(textarea, { childList: true, characterData: true, subtree: true });
29
+ const mutationObserver = new MutationObserver(resize);
30
+ mutationObserver.observe(textarea, { childList: true, characterData: true, subtree: true });
31
+
32
+ const resizeObserver = new ResizeObserver(resize);
33
+ resizeObserver.observe(textarea);
31
34
 
32
35
  const handleInput = () => {
33
36
  cancelAnimationFrame(frameRef.current ?? 0);
@@ -35,11 +38,41 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
35
38
  };
36
39
 
37
40
  textarea.addEventListener("input", handleInput);
38
- resize();
41
+
42
+ let cancelled = false;
43
+ const initialFrame = requestAnimationFrame(() => {
44
+ frameRef.current = requestAnimationFrame(() => {
45
+ if (!cancelled) resize();
46
+ });
47
+ });
48
+
49
+ // Re-run resize once the textarea is actually laid out. During drag-drop
50
+ // remounts the element can be briefly detached, so the initial RAF resize
51
+ // sees scrollHeight === 0 and clamps to minRows.
52
+ const intersectionObserver = new IntersectionObserver((entries) => {
53
+ for (const entry of entries) {
54
+ if (entry.isIntersecting && !cancelled) {
55
+ resize();
56
+ intersectionObserver.disconnect();
57
+ break;
58
+ }
59
+ }
60
+ });
61
+ intersectionObserver.observe(textarea);
62
+
63
+ if (typeof document !== "undefined" && document.fonts?.ready) {
64
+ document.fonts.ready.then(() => {
65
+ if (!cancelled) resize();
66
+ }).catch(() => {});
67
+ }
39
68
 
40
69
  return () => {
41
- observer.disconnect();
70
+ cancelled = true;
71
+ mutationObserver.disconnect();
72
+ resizeObserver.disconnect();
73
+ intersectionObserver.disconnect();
42
74
  textarea.removeEventListener("input", handleInput);
75
+ cancelAnimationFrame(initialFrame);
43
76
  cancelAnimationFrame(frameRef.current ?? 0);
44
77
  };
45
78
  }, [textarea, multiline, minRows, maxRows]);
@@ -46,6 +46,38 @@ describe("blocksToMarkdown", () => {
46
46
  expect(blocksToMarkdown(blocks)).toBe("Hello **world**_!_");
47
47
  });
48
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>");
79
+ });
80
+
49
81
  it("places bold markers outside leading/trailing spaces", () => {
50
82
  const blocks: CustomEditorBlock[] = [
51
83
  {
@@ -165,9 +197,9 @@ describe("blocksToMarkdown", () => {
165
197
  expect(blocksToMarkdown(blocks)).toBe(
166
198
  [
167
199
  "* Open the Login page.",
168
- " *Expected*: The Login page loads successfully.",
200
+ " *Expected result*: The Login page loads successfully.",
169
201
  "* Enter a valid username.",
170
- " *Expected*: The username is accepted.",
202
+ " *Expected result*: The username is accepted.",
171
203
  ].join("\n"),
172
204
  );
173
205
  });
@@ -210,7 +242,7 @@ describe("blocksToMarkdown", () => {
210
242
  ];
211
243
 
212
244
  expect(blocksToMarkdown(blocks)).toBe(
213
- ["* ", " *Expected*: Login form visible"].join("\n"),
245
+ ["* ", " *Expected result*: Login form visible"].join("\n"),
214
246
  );
215
247
  });
216
248
 
@@ -323,7 +355,7 @@ describe("blocksToMarkdown", () => {
323
355
  expect(blocksToMarkdown(blocks)).toBe(
324
356
  [
325
357
  "* **Click** the _Login_ button",
326
- " *Expected*: **Success** is shown",
358
+ " *Expected result*: **Success** is shown",
327
359
  " Second line with <u>underline</u>",
328
360
  ].join("\n"),
329
361
  );
@@ -350,7 +382,7 @@ describe("blocksToMarkdown", () => {
350
382
  "* Navigate to login",
351
383
  " Open browser",
352
384
  " Go to login page",
353
- " *Expected*: Login form visible",
385
+ " *Expected result*: Login form visible",
354
386
  ].join("\n"),
355
387
  );
356
388
  });
@@ -397,7 +429,7 @@ describe("blocksToMarkdown", () => {
397
429
  " asdsadas",
398
430
  " ```",
399
431
  " ![](/attachments/HMhkVtlDrO.png)",
400
- " *Expected*: The user receives a real-time notification for the order update.",
432
+ " *Expected result*: The user receives a real-time notification for the order update.",
401
433
  ].join("\n"),
402
434
  );
403
435
  });
@@ -1041,7 +1073,7 @@ describe("markdownToBlocks", () => {
1041
1073
  " asdsadas",
1042
1074
  " ```",
1043
1075
  " ![](/attachments/HMhkVtlDrO.png)",
1044
- " *Expected*: The user receives a real-time notification for the order update.",
1076
+ " *Expected result*: The user receives a real-time notification for the order update.",
1045
1077
  ].join("\n"),
1046
1078
  );
1047
1079
  });
@@ -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,
@@ -1270,7 +1307,7 @@ describe("markdownToBlocks", () => {
1270
1307
  expect(markdownRoundTrip).toBe(
1271
1308
  [
1272
1309
  "* Display the generated report.",
1273
- " *Expected*: ![](/attachments/report.png)",
1310
+ " *Expected result*: ![](/attachments/report.png)",
1274
1311
  ].join("\n"),
1275
1312
  );
1276
1313
  });
@@ -1317,7 +1354,7 @@ describe("markdownToBlocks", () => {
1317
1354
  expect(roundTrip).toBe(
1318
1355
  [
1319
1356
  "* Should open login screen",
1320
- " *Expected*: Login should look like this",
1357
+ " *Expected result*: Login should look like this",
1321
1358
  " ![](/login.png)",
1322
1359
  ].join("\n"),
1323
1360
  );
@@ -1456,9 +1493,9 @@ describe("markdownToBlocks", () => {
1456
1493
  expect(roundTrip).toBe(
1457
1494
  [
1458
1495
  "* Existing email + invalid password",
1459
- " *Expected*: 'Oops, wrong email or password' is displayed",
1496
+ " *Expected result*: 'Oops, wrong email or password' is displayed",
1460
1497
  "* Not existing email + valid password",
1461
- " *Expected*: 'Oops, wrong email or password' is displayed",
1498
+ " *Expected result*: 'Oops, wrong email or password' is displayed",
1462
1499
  ].join("\n"),
1463
1500
  );
1464
1501
  });
@@ -2525,3 +2562,142 @@ 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 a blank line between a bullet list and the next heading", () => {
2671
+ const markdown = [
2672
+ "### Requirements",
2673
+ "* first requirement",
2674
+ "* second requirement",
2675
+ "",
2676
+ "### Steps",
2677
+ "",
2678
+ "* do the thing",
2679
+ " *Expected result*: it works",
2680
+ ].join("\n");
2681
+ const blocks = markdownToBlocks(markdown) as CustomEditorBlock[];
2682
+ const types = blocks.map((b) => b.type);
2683
+ // Blank line between the last bullet and the next heading must survive
2684
+ // parseList and become an empty paragraph.
2685
+ const lastBulletIdx = types.lastIndexOf("bulletListItem");
2686
+ expect(blocks[lastBulletIdx + 1].type).toBe("paragraph");
2687
+ expect((blocks[lastBulletIdx + 1].content as any[]).length).toBe(0);
2688
+ expect(blocksToMarkdown(blocks)).toBe(markdown);
2689
+ });
2690
+
2691
+ it("preserves blank lines across the user-reported screenshot example", () => {
2692
+ const markdown = [
2693
+ "### Requirements",
2694
+ "",
2695
+ "### Steps",
2696
+ "",
2697
+ "* *open* **webiste**",
2698
+ " step data with `image`",
2699
+ ].join("\n");
2700
+ const blocks = markdownToBlocks(markdown);
2701
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2702
+ });
2703
+ });
@@ -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 {
@@ -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
@@ -438,7 +446,7 @@ function serializeBlock(
438
446
  const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
439
447
  if (normalizedExpected.length > 0) {
440
448
  const expectedLines = normalizedExpected.split(/\r?\n/);
441
- const label = "*Expected*";
449
+ const label = "*Expected result*";
442
450
  expectedLines.forEach((expectedLine: string, index: number) => {
443
451
  const trimmedLine = expectedLine.trim();
444
452
  if (trimmedLine.length === 0) {
@@ -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[] {
@@ -788,7 +792,27 @@ function parseList(
788
792
  const trimmed = rawLine.trim();
789
793
 
790
794
  if (!trimmed) {
791
- index += 1;
795
+ // Peek at the next non-blank line. If it's another item of this list
796
+ // (same indent level and list type), treat the blank lines as loose-list
797
+ // separators and consume them. Otherwise leave the blank line for the
798
+ // outer loop so it can become an empty paragraph block.
799
+ let lookahead = index + 1;
800
+ while (lookahead < lines.length && !lines[lookahead].trim()) {
801
+ lookahead += 1;
802
+ }
803
+ if (lookahead >= lines.length) {
804
+ break;
805
+ }
806
+ const nextLine = lines[lookahead];
807
+ const nextIndent = countIndent(nextLine);
808
+ if (nextIndent < indentLevel * 2) {
809
+ break;
810
+ }
811
+ const nextType = detectListType(nextLine.trim());
812
+ if (nextType !== listType) {
813
+ break;
814
+ }
815
+ index = lookahead;
792
816
  continue;
793
817
  }
794
818
 
@@ -1394,12 +1418,16 @@ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPar
1394
1418
  return result;
1395
1419
  }
1396
1420
 
1421
+ // The `preserveBlankLines` option is retained for backwards compatibility
1422
+ // but is now a no-op: blank lines in the source markdown always produce
1423
+ // empty paragraph blocks (except for leading/trailing blanks, which are
1424
+ // dropped). This gives a 1:1 mapping between blank lines and blocks so the
1425
+ // Rich editor can render and delete each blank line individually.
1397
1426
  export interface MarkdownToBlocksOptions {
1398
- /** When true, every blank line produces an empty paragraph block. */
1399
1427
  preserveBlankLines?: boolean;
1400
1428
  }
1401
1429
 
1402
- export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
1430
+ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
1403
1431
  const normalized = markdown.replace(/\r\n/g, "\n");
1404
1432
  const lines = normalized.split("\n");
1405
1433
  const blocks: CustomPartialBlock[] = [];
@@ -1409,22 +1437,12 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1409
1437
  while (index < lines.length) {
1410
1438
  const line = lines[index];
1411
1439
  if (!line.trim()) {
1412
- if (options?.preserveBlankLines) {
1440
+ // Drop blank lines until we've emitted at least one block, so leading
1441
+ // blanks don't produce a ghost empty paragraph at the top of the doc.
1442
+ if (blocks.length > 0) {
1413
1443
  blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1414
- index += 1;
1415
- continue;
1416
1444
  }
1417
1445
  index += 1;
1418
- // Count consecutive blank lines
1419
- let blankCount = 1;
1420
- while (index < lines.length && !lines[index].trim()) {
1421
- blankCount++;
1422
- index++;
1423
- }
1424
- // Create empty paragraph for each extra blank line beyond the first
1425
- for (let i = 1; i < blankCount; i++) {
1426
- blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1427
- }
1428
1446
  continue;
1429
1447
  }
1430
1448
 
@@ -1540,16 +1558,18 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1540
1558
  index = paragraph.nextIndex;
1541
1559
  }
1542
1560
 
1543
- // Insert empty paragraphs between consecutive headings so users can type between them
1544
- const result: CustomPartialBlock[] = [];
1545
- for (let i = 0; i < blocks.length; i++) {
1546
- result.push(blocks[i]);
1547
- if (blocks[i].type === "heading" && blocks[i + 1]?.type === "heading") {
1548
- result.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1549
- }
1561
+ // Drop trailing empty paragraphs so a trailing blank line in the source
1562
+ // doesn't leave a ghost empty block at the end of the document.
1563
+ while (
1564
+ blocks.length > 0 &&
1565
+ blocks[blocks.length - 1].type === "paragraph" &&
1566
+ (!blocks[blocks.length - 1].content ||
1567
+ (blocks[blocks.length - 1].content as any[]).length === 0)
1568
+ ) {
1569
+ blocks.pop();
1550
1570
  }
1551
1571
 
1552
- return fixMalformedImageBlocks(result);
1572
+ return fixMalformedImageBlocks(blocks);
1553
1573
  }
1554
1574
 
1555
1575
  function splitTableRow(line: string): string[] {
@@ -1100,6 +1100,12 @@ html.dark .bn-step-image-preview__content {
1100
1100
  color: rgb(146, 64, 14) !important;
1101
1101
  }
1102
1102
 
1103
+ .bn-step-editor .overtype-wrapper .overtype-preview li.bullet-list .syntax-marker,
1104
+ .bn-step-editor .overtype-wrapper .overtype-preview li.ordered-list .syntax-marker {
1105
+ color: inherit !important;
1106
+ opacity: 1 !important;
1107
+ }
1108
+
1103
1109
  .bn-step-custom-caret {
1104
1110
  display: none;
1105
1111
  position: absolute;