testomatio-editor-blocks 0.4.52 → 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.
@@ -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]);
@@ -365,7 +365,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
365
365
  const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
366
366
  if (normalizedExpected.length > 0) {
367
367
  const expectedLines = normalizedExpected.split(/\r?\n/);
368
- const label = "*Expected*";
368
+ const label = "*Expected result*";
369
369
  expectedLines.forEach((expectedLine, index) => {
370
370
  const trimmedLine = expectedLine.trim();
371
371
  if (trimmedLine.length === 0) {
@@ -656,7 +656,27 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
656
656
  const rawLine = lines[index];
657
657
  const trimmed = rawLine.trim();
658
658
  if (!trimmed) {
659
- 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;
660
680
  continue;
661
681
  }
662
682
  let indent = countIndent(rawLine);
@@ -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.52",
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",
@@ -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]);
@@ -197,9 +197,9 @@ describe("blocksToMarkdown", () => {
197
197
  expect(blocksToMarkdown(blocks)).toBe(
198
198
  [
199
199
  "* Open the Login page.",
200
- " *Expected*: The Login page loads successfully.",
200
+ " *Expected result*: The Login page loads successfully.",
201
201
  "* Enter a valid username.",
202
- " *Expected*: The username is accepted.",
202
+ " *Expected result*: The username is accepted.",
203
203
  ].join("\n"),
204
204
  );
205
205
  });
@@ -242,7 +242,7 @@ describe("blocksToMarkdown", () => {
242
242
  ];
243
243
 
244
244
  expect(blocksToMarkdown(blocks)).toBe(
245
- ["* ", " *Expected*: Login form visible"].join("\n"),
245
+ ["* ", " *Expected result*: Login form visible"].join("\n"),
246
246
  );
247
247
  });
248
248
 
@@ -355,7 +355,7 @@ describe("blocksToMarkdown", () => {
355
355
  expect(blocksToMarkdown(blocks)).toBe(
356
356
  [
357
357
  "* **Click** the _Login_ button",
358
- " *Expected*: **Success** is shown",
358
+ " *Expected result*: **Success** is shown",
359
359
  " Second line with <u>underline</u>",
360
360
  ].join("\n"),
361
361
  );
@@ -382,7 +382,7 @@ describe("blocksToMarkdown", () => {
382
382
  "* Navigate to login",
383
383
  " Open browser",
384
384
  " Go to login page",
385
- " *Expected*: Login form visible",
385
+ " *Expected result*: Login form visible",
386
386
  ].join("\n"),
387
387
  );
388
388
  });
@@ -429,7 +429,7 @@ describe("blocksToMarkdown", () => {
429
429
  " asdsadas",
430
430
  " ```",
431
431
  " ![](/attachments/HMhkVtlDrO.png)",
432
- " *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.",
433
433
  ].join("\n"),
434
434
  );
435
435
  });
@@ -1073,7 +1073,7 @@ describe("markdownToBlocks", () => {
1073
1073
  " asdsadas",
1074
1074
  " ```",
1075
1075
  " ![](/attachments/HMhkVtlDrO.png)",
1076
- " *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.",
1077
1077
  ].join("\n"),
1078
1078
  );
1079
1079
  });
@@ -1307,7 +1307,7 @@ describe("markdownToBlocks", () => {
1307
1307
  expect(markdownRoundTrip).toBe(
1308
1308
  [
1309
1309
  "* Display the generated report.",
1310
- " *Expected*: ![](/attachments/report.png)",
1310
+ " *Expected result*: ![](/attachments/report.png)",
1311
1311
  ].join("\n"),
1312
1312
  );
1313
1313
  });
@@ -1354,7 +1354,7 @@ describe("markdownToBlocks", () => {
1354
1354
  expect(roundTrip).toBe(
1355
1355
  [
1356
1356
  "* Should open login screen",
1357
- " *Expected*: Login should look like this",
1357
+ " *Expected result*: Login should look like this",
1358
1358
  " ![](/login.png)",
1359
1359
  ].join("\n"),
1360
1360
  );
@@ -1493,9 +1493,9 @@ describe("markdownToBlocks", () => {
1493
1493
  expect(roundTrip).toBe(
1494
1494
  [
1495
1495
  "* Existing email + invalid password",
1496
- " *Expected*: 'Oops, wrong email or password' is displayed",
1496
+ " *Expected result*: 'Oops, wrong email or password' is displayed",
1497
1497
  "* Not existing email + valid password",
1498
- " *Expected*: 'Oops, wrong email or password' is displayed",
1498
+ " *Expected result*: 'Oops, wrong email or password' is displayed",
1499
1499
  ].join("\n"),
1500
1500
  );
1501
1501
  });
@@ -2667,6 +2667,27 @@ describe("blank line <-> empty paragraph mapping", () => {
2667
2667
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2668
2668
  });
2669
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
+
2670
2691
  it("preserves blank lines across the user-reported screenshot example", () => {
2671
2692
  const markdown = [
2672
2693
  "### Requirements",
@@ -446,7 +446,7 @@ function serializeBlock(
446
446
  const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
447
447
  if (normalizedExpected.length > 0) {
448
448
  const expectedLines = normalizedExpected.split(/\r?\n/);
449
- const label = "*Expected*";
449
+ const label = "*Expected result*";
450
450
  expectedLines.forEach((expectedLine: string, index: number) => {
451
451
  const trimmedLine = expectedLine.trim();
452
452
  if (trimmedLine.length === 0) {
@@ -792,7 +792,27 @@ function parseList(
792
792
  const trimmed = rawLine.trim();
793
793
 
794
794
  if (!trimmed) {
795
- 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;
796
816
  continue;
797
817
  }
798
818
 
@@ -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;