testomatio-editor-blocks 0.4.35 → 0.4.37

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.
@@ -193,31 +193,6 @@ export const stepBlock = createReactBlockSpec({
193
193
  }
194
194
  return count;
195
195
  }, [block.id, documentVersion, editor.document]);
196
- // Check if there is a preceding "Steps" heading
197
- const hasStepsHeading = useMemo(() => {
198
- const allBlocks = editor.document;
199
- const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
200
- if (blockIndex < 0)
201
- return false;
202
- for (let i = blockIndex - 1; i >= 0; i--) {
203
- const b = allBlocks[i];
204
- if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
205
- continue;
206
- }
207
- if (b.type === "heading") {
208
- const text = (Array.isArray(b.content) ? b.content : [])
209
- .filter((n) => n.type === "text")
210
- .map((n) => { var _a; return (_a = n.text) !== null && _a !== void 0 ? _a : ""; })
211
- .join("")
212
- .trim()
213
- .toLowerCase()
214
- .replace(/[:\-–—]$/, "");
215
- return isStepsHeading(text);
216
- }
217
- return false;
218
- }
219
- return false;
220
- }, [block.id, documentVersion, editor.document]);
221
196
  useEditorChange(() => {
222
197
  setDocumentVersion((version) => version + 1);
223
198
  }, editor);
@@ -362,10 +337,6 @@ export const stepBlock = createReactBlockSpec({
362
337
  }, [expectedHasContent, isExpectedVisible]);
363
338
  const canToggleData = !dataHasContent;
364
339
  const canToggleExpected = !expectedHasContent;
365
- // Render as plain text when not under a "Steps" heading
366
- if (!hasStepsHeading) {
367
- return (_jsxs("div", { className: "bn-teststep-plain", "data-block-id": block.id, children: [_jsx("span", { children: stepTitle || "(empty step)" }), stepData ? _jsx("span", { className: "bn-teststep-plain__data", children: stepData }) : null, expectedResult ? _jsx("span", { className: "bn-teststep-plain__expected", children: expectedResult }) : null] }));
368
- }
369
340
  if (viewMode === "horizontal") {
370
341
  return (_jsx(StepHorizontalView, { blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: _jsx("button", { type: "button", className: "bn-teststep__view-toggle bn-teststep__view-toggle--horizontal", "data-tooltip": "Switch step view", "aria-label": "Switch step view", onClick: handleToggleView, 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" }) })] }) }) }));
371
342
  }
@@ -80,7 +80,6 @@ function unescapeMarkdown(text) {
80
80
  return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>\\])/g, "$1");
81
81
  }
82
82
  function applyTextStyles(text, styles) {
83
- var _a;
84
83
  if (!styles) {
85
84
  return text;
86
85
  }
@@ -116,11 +115,22 @@ function applyTextStyles(text, styles) {
116
115
  suffix: "</span>",
117
116
  });
118
117
  }
119
- for (const wrapper of wrappers) {
120
- const suffix = (_a = wrapper.suffix) !== null && _a !== void 0 ? _a : wrapper.prefix;
121
- result = `${wrapper.prefix}${result}${suffix}`;
122
- }
123
- return result;
118
+ // Split on newlines so that style markers wrap each line individually.
119
+ // This prevents <br/> (inserted by table cell formatting) from being
120
+ // trapped inside markers like **bold<br/>text**.
121
+ const segments = result.split("\n");
122
+ const wrapped = segments.map((segment) => {
123
+ var _a;
124
+ if (!segment)
125
+ return segment;
126
+ let s = segment;
127
+ for (const wrapper of wrappers) {
128
+ const suffix = (_a = wrapper.suffix) !== null && _a !== void 0 ? _a : wrapper.prefix;
129
+ s = `${wrapper.prefix}${s}${suffix}`;
130
+ }
131
+ return s;
132
+ });
133
+ return wrapped.join("\n");
124
134
  }
125
135
  function inlineToMarkdown(content) {
126
136
  if (!content || !Array.isArray(content)) {
@@ -309,17 +319,17 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
309
319
  }
310
320
  return flattenWithBlankLine(lines, true);
311
321
  }
312
- if (stepTitle.length > 0) {
313
- const normalizedTitle = stepTitle
314
- .split(/\r?\n/)
315
- .map((segment) => segment.trim())
316
- .filter((segment) => segment.length > 0)
317
- .join(" ");
318
- if (normalizedTitle.length > 0) {
319
- const listStyle = (_m = block.props.listStyle) !== null && _m !== void 0 ? _m : "bullet";
320
- const prefix = listStyle === "ordered" ? `${(stepIndex !== null && stepIndex !== void 0 ? stepIndex : 0) + 1}.` : "*";
321
- lines.push(`${prefix} ${normalizedTitle}`);
322
- }
322
+ const normalizedTitle = stepTitle
323
+ .split(/\r?\n/)
324
+ .map((segment) => segment.trim())
325
+ .filter((segment) => segment.length > 0)
326
+ .join(" ");
327
+ const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
328
+ const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
329
+ if (normalizedTitle.length > 0 || hasContent) {
330
+ const listStyle = (_m = block.props.listStyle) !== null && _m !== void 0 ? _m : "bullet";
331
+ const prefix = listStyle === "ordered" ? `${(stepIndex !== null && stepIndex !== void 0 ? stepIndex : 0) + 1}.` : "*";
332
+ lines.push(normalizedTitle.length > 0 ? `${prefix} ${normalizedTitle}` : `${prefix} `);
323
333
  }
324
334
  if (stepData.length > 0) {
325
335
  const dataLines = stepData.split(/\r?\n/);
@@ -416,7 +426,16 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
416
426
  const formatCell = (value) => {
417
427
  if (!value.length)
418
428
  return " ";
419
- return value.replace(/\n/g, "<br/>");
429
+ // Handle newlines inside backtick code spans:
430
+ // close code before <br>, reopen after
431
+ let result = value.replace(/`([^`]*)`/g, (match) => {
432
+ if (!match.includes("\n"))
433
+ return match;
434
+ const inner = match.slice(1, -1);
435
+ const parts = inner.split("\n");
436
+ return parts.map((p) => (p ? `\`${p}\`` : "")).join("<br>");
437
+ });
438
+ return result.replace(/\n/g, "<br>");
420
439
  };
421
440
  const toAlignmentToken = (alignment) => {
422
441
  switch (alignment) {
@@ -606,7 +625,7 @@ function detectListType(trimmed) {
606
625
  if (/^\d+[.)]\s+/.test(trimmed)) {
607
626
  return "numbered";
608
627
  }
609
- if (/^[-*+]\s+/.test(trimmed)) {
628
+ if (/^[-*+](\s+|$)/.test(trimmed)) {
610
629
  return "bullet";
611
630
  }
612
631
  return null;
@@ -695,7 +714,7 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
695
714
  }
696
715
  else {
697
716
  const bulletMatch = trimmed.match(/^[-*+]\s+(.*)$/);
698
- const text = (_d = bulletMatch === null || bulletMatch === void 0 ? void 0 : bulletMatch[1]) !== null && _d !== void 0 ? _d : trimmed.slice(2);
717
+ const text = (_d = bulletMatch === null || bulletMatch === void 0 ? void 0 : bulletMatch[1]) !== null && _d !== void 0 ? _d : (trimmed.length <= 1 ? "" : trimmed.slice(2));
699
718
  items.push({
700
719
  type: "bulletListItem",
701
720
  props: cloneBaseProps(),
@@ -721,7 +740,7 @@ function isLikelyStep(lines, index) {
721
740
  if (hasIndent)
722
741
  return true;
723
742
  // Stop at new list items, headings, or other block-level elements (only if not indented)
724
- if (/^[-*+]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed))
743
+ if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed))
725
744
  break;
726
745
  if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::"))
727
746
  break;
@@ -736,7 +755,7 @@ function isLikelyStep(lines, index) {
736
755
  function parseTestStep(lines, index, allowEmpty = false, snippetId) {
737
756
  const current = lines[index];
738
757
  const trimmed = current.trim();
739
- const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ");
758
+ const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ") || trimmed === "*" || trimmed === "-";
740
759
  const isNumbered = /^\d+[.)]\s+/.test(trimmed);
741
760
  if (!isBullet && !isNumbered) {
742
761
  return null;
@@ -749,7 +768,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
749
768
  }
750
769
  let rawTitle;
751
770
  if (isBullet) {
752
- rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
771
+ rawTitle = unescapeMarkdown((trimmed.startsWith("* ") || trimmed.startsWith("- ")) ? trimmed.slice(2) : trimmed.slice(1)).trim();
753
772
  }
754
773
  else {
755
774
  // For numbered lists, remove the number and delimiter
@@ -793,7 +812,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
793
812
  continue;
794
813
  }
795
814
  const isNumberedStep = NUMBERED_STEP_REGEX.test(rawTrimmed);
796
- const isNewStep = (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- "))) ||
815
+ const isNewStep = (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- ") || rawTrimmed === "*" || rawTrimmed === "-")) ||
797
816
  (!hasIndent && isNumberedStep);
798
817
  if (isNewStep) {
799
818
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
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
@@ -22,7 +22,6 @@ import { customSchema, type CustomEditor } from "./editor/customSchema";
22
22
  import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
23
23
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
24
24
  import { setImageUploadHandler } from "./editor/stepImageUpload";
25
- import { canInsertStepOrSnippet } from "./editor/blocks/step";
26
25
  import "./App.css";
27
26
 
28
27
  const focusStepField = (
@@ -335,11 +334,7 @@ function CustomSlashMenu() {
335
334
  },
336
335
  };
337
336
 
338
- const cursorBlock = editor.getTextCursorPosition()?.block;
339
- const canInsert = cursorBlock ? canInsertStepOrSnippet(editor, cursorBlock.id) : false;
340
- const customItems = canInsert ? [stepItem, snippetItem] : [];
341
-
342
- return filterSuggestionItems([...defaultItems, ...customItems], query);
337
+ return filterSuggestionItems([...defaultItems, stepItem, snippetItem], query);
343
338
  };
344
339
 
345
340
  return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
@@ -489,7 +484,7 @@ function App() {
489
484
  const fallbackBlock = documentBlocks[documentBlocks.length - 1];
490
485
  const referenceId = selectedBlock?.id ?? fallbackBlock?.id;
491
486
 
492
- if (!referenceId || !canInsertStepOrSnippet(editor, referenceId)) {
487
+ if (!referenceId) {
493
488
  return;
494
489
  }
495
490
 
@@ -212,32 +212,6 @@ export const stepBlock = createReactBlockSpec(
212
212
  return count;
213
213
  }, [block.id, documentVersion, editor.document]);
214
214
 
215
- // Check if there is a preceding "Steps" heading
216
- const hasStepsHeading = useMemo(() => {
217
- const allBlocks = editor.document;
218
- const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
219
- if (blockIndex < 0) return false;
220
-
221
- for (let i = blockIndex - 1; i >= 0; i--) {
222
- const b = allBlocks[i];
223
- if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
224
- continue;
225
- }
226
- if (b.type === "heading") {
227
- const text = (Array.isArray(b.content) ? b.content : [])
228
- .filter((n: any) => n.type === "text")
229
- .map((n: any) => n.text ?? "")
230
- .join("")
231
- .trim()
232
- .toLowerCase()
233
- .replace(/[:\-–—]$/, "");
234
- return isStepsHeading(text);
235
- }
236
- return false;
237
- }
238
- return false;
239
- }, [block.id, documentVersion, editor.document]);
240
-
241
215
  useEditorChange(() => {
242
216
  setDocumentVersion((version) => version + 1);
243
217
  }, editor);
@@ -417,17 +391,6 @@ export const stepBlock = createReactBlockSpec(
417
391
  const canToggleData = !dataHasContent;
418
392
  const canToggleExpected = !expectedHasContent;
419
393
 
420
- // Render as plain text when not under a "Steps" heading
421
- if (!hasStepsHeading) {
422
- return (
423
- <div className="bn-teststep-plain" data-block-id={block.id}>
424
- <span>{stepTitle || "(empty step)"}</span>
425
- {stepData ? <span className="bn-teststep-plain__data">{stepData}</span> : null}
426
- {expectedResult ? <span className="bn-teststep-plain__expected">{expectedResult}</span> : null}
427
- </div>
428
- );
429
- }
430
-
431
394
  if (viewMode === "horizontal") {
432
395
  return (
433
396
  <StepHorizontalView
@@ -105,6 +105,67 @@ describe("blocksToMarkdown", () => {
105
105
  );
106
106
  });
107
107
 
108
+ it("serializes a step with empty title but with stepData", () => {
109
+ const blocks: CustomEditorBlock[] = [
110
+ {
111
+ id: "s1",
112
+ type: "testStep",
113
+ props: {
114
+ stepTitle: "",
115
+ stepData: "Navigate to the page",
116
+ expectedResult: "",
117
+ listStyle: "bullet",
118
+ },
119
+ content: undefined,
120
+ children: [],
121
+ },
122
+ ];
123
+
124
+ expect(blocksToMarkdown(blocks)).toBe(
125
+ ["* ", " Navigate to the page"].join("\n"),
126
+ );
127
+ });
128
+
129
+ it("serializes a step with empty title but with expectedResult", () => {
130
+ const blocks: CustomEditorBlock[] = [
131
+ {
132
+ id: "s1",
133
+ type: "testStep",
134
+ props: {
135
+ stepTitle: "",
136
+ stepData: "",
137
+ expectedResult: "Login form visible",
138
+ listStyle: "bullet",
139
+ },
140
+ content: undefined,
141
+ children: [],
142
+ },
143
+ ];
144
+
145
+ expect(blocksToMarkdown(blocks)).toBe(
146
+ ["* ", " *Expected*: Login form visible"].join("\n"),
147
+ );
148
+ });
149
+
150
+ it("drops completely empty step (no title, no data, no expected)", () => {
151
+ const blocks: CustomEditorBlock[] = [
152
+ {
153
+ id: "s1",
154
+ type: "testStep",
155
+ props: {
156
+ stepTitle: "",
157
+ stepData: "",
158
+ expectedResult: "",
159
+ listStyle: "bullet",
160
+ },
161
+ content: undefined,
162
+ children: [],
163
+ },
164
+ ];
165
+
166
+ expect(blocksToMarkdown(blocks)).toBe("");
167
+ });
168
+
108
169
  it("serializes a snippet block with prefixed title", () => {
109
170
  const blocks: CustomEditorBlock[] = [
110
171
  {
@@ -377,7 +438,120 @@ describe("blocksToMarkdown", () => {
377
438
  [
378
439
  "| Steps | Expected Results |",
379
440
  "| --- | --- |",
380
- "| line1<br/>line2 | ok |",
441
+ "| line1<br>line2 | ok |",
442
+ ].join("\n"),
443
+ );
444
+ });
445
+
446
+ it("serializes table cell with code style and newline placing br outside backticks", () => {
447
+ const blocks: CustomEditorBlock[] = [
448
+ {
449
+ id: "tbl-code",
450
+ type: "table",
451
+ props: { textColor: "default" },
452
+ content: {
453
+ type: "tableContent",
454
+ columnWidths: [undefined, undefined],
455
+ headerRows: 1,
456
+ rows: [
457
+ {
458
+ cells: [
459
+ {
460
+ type: "tableCell",
461
+ props: cellProps,
462
+ content: [{ type: "text", text: "A", styles: {} }],
463
+ },
464
+ {
465
+ type: "tableCell",
466
+ props: cellProps,
467
+ content: [{ type: "text", text: "B", styles: {} }],
468
+ },
469
+ ],
470
+ },
471
+ {
472
+ cells: [
473
+ {
474
+ type: "tableCell",
475
+ props: cellProps,
476
+ content: [
477
+ { type: "text", text: "code", styles: { code: true } },
478
+ { type: "text", text: "\nnext line", styles: {} },
479
+ ],
480
+ },
481
+ {
482
+ type: "tableCell",
483
+ props: cellProps,
484
+ content: [{ type: "text", text: "ok", styles: {} }],
485
+ },
486
+ ],
487
+ },
488
+ ],
489
+ },
490
+ children: [],
491
+ },
492
+ ];
493
+
494
+ expect(blocksToMarkdown(blocks)).toBe(
495
+ [
496
+ "| A | B |",
497
+ "| --- | --- |",
498
+ "| `code`<br>next line | ok |",
499
+ ].join("\n"),
500
+ );
501
+ });
502
+
503
+ it("serializes table cells with styled text and newlines without trapping <br/> inside markers", () => {
504
+ const blocks: CustomEditorBlock[] = [
505
+ {
506
+ id: "tbl3",
507
+ type: "table",
508
+ props: { textColor: "default" },
509
+ content: {
510
+ type: "tableContent",
511
+ columnWidths: [undefined, undefined],
512
+ headerRows: 1,
513
+ rows: [
514
+ {
515
+ cells: [
516
+ {
517
+ type: "tableCell",
518
+ props: cellProps,
519
+ content: [{ type: "text", text: "Col A", styles: {} }],
520
+ },
521
+ {
522
+ type: "tableCell",
523
+ props: cellProps,
524
+ content: [{ type: "text", text: "Col B", styles: {} }],
525
+ },
526
+ ],
527
+ },
528
+ {
529
+ cells: [
530
+ {
531
+ type: "tableCell",
532
+ props: cellProps,
533
+ content: [{ type: "text", text: "ok", styles: {} }],
534
+ },
535
+ {
536
+ type: "tableCell",
537
+ props: cellProps,
538
+ content: [
539
+ { type: "text", text: "opened\nnewline", styles: { bold: true } },
540
+ ],
541
+ },
542
+ ],
543
+ },
544
+ ],
545
+ },
546
+ children: [],
547
+ },
548
+ ];
549
+
550
+ expect(blocksToMarkdown(blocks)).toBe(
551
+ [
552
+ "| Col A | Col B |",
553
+ "| --- | --- |",
554
+ "| ok | **opened**<br>**newline** |",
381
555
  ].join("\n"),
382
556
  );
383
557
  });
@@ -419,6 +593,55 @@ describe("markdownToBlocks", () => {
419
593
  ]);
420
594
  });
421
595
 
596
+ it("parses a step with empty title but with step data", () => {
597
+ const markdown = ["* ", " Navigate to the page"].join("\n");
598
+
599
+ expect(markdownToBlocks(markdown)).toEqual([
600
+ {
601
+ type: "testStep",
602
+ props: {
603
+ stepTitle: "",
604
+ stepData: "Navigate to the page",
605
+ expectedResult: "",
606
+ listStyle: "bullet",
607
+ },
608
+ children: [],
609
+ },
610
+ ]);
611
+ });
612
+
613
+ it("round-trips a title-less step with data", () => {
614
+ const blocks: CustomEditorBlock[] = [
615
+ {
616
+ id: "s1",
617
+ type: "testStep",
618
+ props: {
619
+ stepTitle: "",
620
+ stepData: "Open the browser",
621
+ expectedResult: "Page loads",
622
+ listStyle: "bullet",
623
+ },
624
+ content: undefined,
625
+ children: [],
626
+ },
627
+ ];
628
+
629
+ const md = blocksToMarkdown(blocks);
630
+ const parsed = markdownToBlocks(md);
631
+ expect(parsed).toEqual([
632
+ {
633
+ type: "testStep",
634
+ props: {
635
+ stepTitle: "",
636
+ stepData: "Open the browser",
637
+ expectedResult: "Page loads",
638
+ listStyle: "bullet",
639
+ },
640
+ children: [],
641
+ },
642
+ ]);
643
+ });
644
+
422
645
  it("parses snippet markdown into snippet blocks", () => {
423
646
  const markdown = [
424
647
  "<!-- begin snippet #501 -->",
@@ -1270,7 +1493,7 @@ describe("markdownToBlocks", () => {
1270
1493
  const markdown = [
1271
1494
  "| A | B |",
1272
1495
  "| --- | --- |",
1273
- "| line1<br/>line2 | ok |",
1496
+ "| line1<br>line2 | ok |",
1274
1497
  ].join("\n");
1275
1498
 
1276
1499
  const blocks = markdownToBlocks(markdown);
@@ -1364,7 +1587,7 @@ describe("markdownToBlocks", () => {
1364
1587
  ];
1365
1588
 
1366
1589
  const markdown = blocksToMarkdown(blocks);
1367
- expect(markdown).toContain("first<br/>second<br/>third");
1590
+ expect(markdown).toContain("first<br>second<br>third");
1368
1591
 
1369
1592
  const parsed = markdownToBlocks(markdown);
1370
1593
  const row = (parsed[0] as any).content.rows[1];
@@ -174,12 +174,21 @@ function applyTextStyles(text: string, styles: EditorStyles | undefined): string
174
174
  });
175
175
  }
176
176
 
177
- for (const wrapper of wrappers) {
178
- const suffix = wrapper.suffix ?? wrapper.prefix;
179
- result = `${wrapper.prefix}${result}${suffix}`;
180
- }
177
+ // Split on newlines so that style markers wrap each line individually.
178
+ // This prevents <br/> (inserted by table cell formatting) from being
179
+ // trapped inside markers like **bold<br/>text**.
180
+ const segments = result.split("\n");
181
+ const wrapped = segments.map((segment) => {
182
+ if (!segment) return segment;
183
+ let s = segment;
184
+ for (const wrapper of wrappers) {
185
+ const suffix = wrapper.suffix ?? wrapper.prefix;
186
+ s = `${wrapper.prefix}${s}${suffix}`;
187
+ }
188
+ return s;
189
+ });
181
190
 
182
- return result;
191
+ return wrapped.join("\n");
183
192
  }
184
193
 
185
194
  function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
@@ -392,18 +401,19 @@ function serializeBlock(
392
401
  return flattenWithBlankLine(lines, true);
393
402
  }
394
403
 
395
- if (stepTitle.length > 0) {
396
- const normalizedTitle = stepTitle
397
- .split(/\r?\n/)
398
- .map((segment: string) => segment.trim())
399
- .filter((segment: string) => segment.length > 0)
400
- .join(" ");
401
-
402
- if (normalizedTitle.length > 0) {
403
- const listStyle = (block.props as any).listStyle ?? "bullet";
404
- const prefix = listStyle === "ordered" ? `${(stepIndex ?? 0) + 1}.` : "*";
405
- lines.push(`${prefix} ${normalizedTitle}`);
406
- }
404
+ const normalizedTitle = stepTitle
405
+ .split(/\r?\n/)
406
+ .map((segment: string) => segment.trim())
407
+ .filter((segment: string) => segment.length > 0)
408
+ .join(" ");
409
+
410
+ const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
411
+ const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
412
+
413
+ if (normalizedTitle.length > 0 || hasContent) {
414
+ const listStyle = (block.props as any).listStyle ?? "bullet";
415
+ const prefix = listStyle === "ordered" ? `${(stepIndex ?? 0) + 1}.` : "*";
416
+ lines.push(normalizedTitle.length > 0 ? `${prefix} ${normalizedTitle}` : `${prefix} `);
407
417
  }
408
418
 
409
419
  if (stepData.length > 0) {
@@ -515,7 +525,15 @@ function serializeBlock(
515
525
  const formattedRows = rows.map(normalizeRow);
516
526
  const formatCell = (value: string) => {
517
527
  if (!value.length) return " ";
518
- return value.replace(/\n/g, "<br/>");
528
+ // Handle newlines inside backtick code spans:
529
+ // close code before <br>, reopen after
530
+ let result = value.replace(/`([^`]*)`/g, (match) => {
531
+ if (!match.includes("\n")) return match;
532
+ const inner = match.slice(1, -1);
533
+ const parts = inner.split("\n");
534
+ return parts.map((p) => (p ? `\`${p}\`` : "")).join("<br>");
535
+ });
536
+ return result.replace(/\n/g, "<br>");
519
537
  };
520
538
  const toAlignmentToken = (alignment: string) => {
521
539
  switch (alignment) {
@@ -734,7 +752,7 @@ function detectListType(trimmed: string): "bullet" | "numbered" | "check" | null
734
752
  if (/^\d+[.)]\s+/.test(trimmed)) {
735
753
  return "numbered";
736
754
  }
737
- if (/^[-*+]\s+/.test(trimmed)) {
755
+ if (/^[-*+](\s+|$)/.test(trimmed)) {
738
756
  return "bullet";
739
757
  }
740
758
  return null;
@@ -850,7 +868,7 @@ function parseList(
850
868
  });
851
869
  } else {
852
870
  const bulletMatch = trimmed.match(/^[-*+]\s+(.*)$/);
853
- const text = bulletMatch?.[1] ?? trimmed.slice(2);
871
+ const text = bulletMatch?.[1] ?? (trimmed.length <= 1 ? "" : trimmed.slice(2));
854
872
  items.push({
855
873
  type: "bulletListItem",
856
874
  props: cloneBaseProps(),
@@ -880,7 +898,7 @@ function isLikelyStep(lines: string[], index: number): boolean {
880
898
  if (hasIndent) return true;
881
899
 
882
900
  // Stop at new list items, headings, or other block-level elements (only if not indented)
883
- if (/^[-*+]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) break;
901
+ if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) break;
884
902
  if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::")) break;
885
903
 
886
904
  // Check for expected result markers
@@ -899,7 +917,7 @@ function parseTestStep(
899
917
  ): { block: CustomPartialBlock; nextIndex: number } | null {
900
918
  const current = lines[index];
901
919
  const trimmed = current.trim();
902
- const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ");
920
+ const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ") || trimmed === "*" || trimmed === "-";
903
921
  const isNumbered = /^\d+[.)]\s+/.test(trimmed);
904
922
 
905
923
  if (!isBullet && !isNumbered) {
@@ -915,7 +933,9 @@ function parseTestStep(
915
933
 
916
934
  let rawTitle: string;
917
935
  if (isBullet) {
918
- rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
936
+ rawTitle = unescapeMarkdown(
937
+ (trimmed.startsWith("* ") || trimmed.startsWith("- ")) ? trimmed.slice(2) : trimmed.slice(1)
938
+ ).trim();
919
939
  } else {
920
940
  // For numbered lists, remove the number and delimiter
921
941
  rawTitle = unescapeMarkdown(trimmed.replace(/^\d+[.)]\s+/, "")).trim();
@@ -963,7 +983,7 @@ function parseTestStep(
963
983
  }
964
984
  const isNumberedStep = NUMBERED_STEP_REGEX.test(rawTrimmed);
965
985
  const isNewStep =
966
- (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- "))) ||
986
+ (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- ") || rawTrimmed === "*" || rawTrimmed === "-")) ||
967
987
  (!hasIndent && isNumberedStep);
968
988
 
969
989
  if (isNewStep) {