testomatio-editor-blocks 0.4.55 → 0.4.57

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.
@@ -22,7 +22,7 @@ const headingPrefixes = {
22
22
  5: "#####",
23
23
  6: "######",
24
24
  };
25
- const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
25
+ const SPECIAL_CHAR_REGEX = /([*_`~()<\\])/g;
26
26
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
27
27
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
28
28
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
@@ -43,6 +43,9 @@ function escapeMarkdown(text) {
43
43
  result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
44
44
  return result;
45
45
  }
46
+ function escapeStepContent(text) {
47
+ return text.replace(/\./g, "\\.");
48
+ }
46
49
  function stripHtmlWrappers(text) {
47
50
  return text
48
51
  .replace(HTML_SPAN_REGEX, "")
@@ -57,7 +60,7 @@ function stripExpectedPrefix(text) {
57
60
  let remainder = text.slice(label.length).trimStart();
58
61
  const cleanupLeading = (value) => {
59
62
  let result = value.trimStart();
60
- result = result.replace(/^\\+(?=[*_`~:[\]])/, "");
63
+ result = result.replace(/^\\+(?=[*_`~:])/, "");
61
64
  result = result.replace(/^(?:[*_`~]+)(?=\s|$)/, "");
62
65
  return result.trimStart();
63
66
  };
@@ -88,7 +91,7 @@ function stripLeadingFormatting(text) {
88
91
  return result;
89
92
  }
90
93
  function unescapeMarkdown(text) {
91
- return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>\\])/g, "$1").replace(/\\>/g, ">");
94
+ return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>.\\])/g, "$1").replace(/\\>/g, ">");
92
95
  }
93
96
  function applyTextStyles(text, styles) {
94
97
  var _a, _b, _c, _d;
@@ -348,14 +351,14 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
348
351
  if (normalizedTitle.length > 0 || hasContent) {
349
352
  const listStyle = (_m = block.props.listStyle) !== null && _m !== void 0 ? _m : "bullet";
350
353
  const prefix = listStyle === "ordered" ? `${(stepIndex !== null && stepIndex !== void 0 ? stepIndex : 0) + 1}.` : "*";
351
- lines.push(normalizedTitle.length > 0 ? `${prefix} ${normalizedTitle}` : `${prefix} `);
354
+ lines.push(normalizedTitle.length > 0 ? `${prefix} ${escapeStepContent(normalizedTitle)}` : `${prefix} `);
352
355
  }
353
356
  if (stepData.length > 0) {
354
357
  const dataLines = stepData.split(/\r?\n/);
355
358
  dataLines.forEach((dataLine) => {
356
359
  const trimmedLine = dataLine.trim();
357
360
  if (trimmedLine.length > 0) {
358
- lines.push(` ${trimmedLine}`);
361
+ lines.push(` ${escapeStepContent(trimmedLine)}`);
359
362
  }
360
363
  else {
361
364
  lines.push(" ");
@@ -372,10 +375,10 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
372
375
  return;
373
376
  }
374
377
  if (index === 0) {
375
- lines.push(` ${label}: ${trimmedLine}`);
378
+ lines.push(` ${label}: ${escapeStepContent(trimmedLine)}`);
376
379
  }
377
380
  else {
378
- lines.push(` ${trimmedLine}`);
381
+ lines.push(` ${escapeStepContent(trimmedLine)}`);
379
382
  }
380
383
  });
381
384
  }
@@ -841,6 +844,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
841
844
  let next = index + 1;
842
845
  let inExpectedResult = false;
843
846
  let blankLineSeenOutsideCodeBlock = false;
847
+ let blankLineSeenInExpectedResult = false;
844
848
  const stepIndent = current.length - current.trimStart().length;
845
849
  while (next < lines.length) {
846
850
  const line = lines[next];
@@ -848,14 +852,15 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
848
852
  const hasIndent = lineIndent > stepIndent;
849
853
  const rawTrimmed = line.trim();
850
854
  if (!rawTrimmed) {
851
- if (stepDataLines.length > 0 || inExpectedResult) {
852
- if (inExpectedResult) {
853
- expectedResult += "\n";
854
- }
855
- else {
855
+ if (inExpectedResult) {
856
+ expectedResult += "\n";
857
+ blankLineSeenInExpectedResult = true;
858
+ }
859
+ else {
860
+ if (stepDataLines.length > 0) {
856
861
  stepDataLines.push("");
857
- blankLineSeenOutsideCodeBlock = true;
858
862
  }
863
+ blankLineSeenOutsideCodeBlock = true;
859
864
  }
860
865
  next += 1;
861
866
  continue;
@@ -947,7 +952,13 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
947
952
  continue;
948
953
  }
949
954
  if (inExpectedResult) {
950
- // After finding the first expected result, indented lines are part of it
955
+ // After a blank line inside the expected result, a non-indented line
956
+ // belongs to the outer document (e.g. a trailing file block after the
957
+ // step list), so stop here and let the root parser handle it.
958
+ if (blankLineSeenInExpectedResult && !hasIndent) {
959
+ break;
960
+ }
961
+ // Otherwise, indented lines are part of the expected result
951
962
  if (hasIndent) {
952
963
  const expectedContent = unescapeMarkdown(rawTrimmed);
953
964
  if (expectedResult.length > 0) {
@@ -951,6 +951,11 @@ html.dark .bn-step-image-preview__content {
951
951
  overflow-x: clip;
952
952
  }
953
953
 
954
+ .bn-container .bn-suggestion-menu {
955
+ max-height: none !important;
956
+ overflow: visible !important;
957
+ }
958
+
954
959
  [data-tooltip] {
955
960
  position: relative;
956
961
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.55",
3
+ "version": "0.4.57",
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,7 +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
+ import { flip, offset, shift, size } from "@floating-ui/react";
15
15
  import {
16
16
  blocksToMarkdown,
17
17
  markdownToBlocks,
@@ -362,17 +362,38 @@ function CustomSlashMenu() {
362
362
  floatingOptions={{
363
363
  middleware: [
364
364
  offset(10),
365
- autoPlacement({
366
- allowedPlacements: ["bottom-start", "top-start"],
367
- }),
368
- shift(),
369
365
  size({
370
- apply({ availableHeight, elements }) {
366
+ apply({ elements }) {
367
+ Object.assign(elements.floating.style, { maxHeight: "", overflowY: "" });
368
+ },
369
+ }),
370
+ flip({
371
+ padding: 10,
372
+ fallbackPlacements: ["top-start"],
373
+ }),
374
+ shift({ padding: 10 }),
375
+ {
376
+ name: "fitToViewport",
377
+ fn({ y, rects, elements }) {
378
+ const padding = 10;
379
+ if (y < padding) {
380
+ const bottomEdge = rects.reference.y - 10;
381
+ const maxHeight = Math.max(bottomEdge - padding, 100);
382
+ Object.assign(elements.floating.style, {
383
+ maxHeight: `${maxHeight}px`,
384
+ overflowY: "auto",
385
+ });
386
+ return { y: padding };
387
+ }
388
+ const viewportHeight = window.innerHeight;
389
+ const available = viewportHeight - y - padding;
371
390
  Object.assign(elements.floating.style, {
372
- maxHeight: `${Math.max(availableHeight - 10, 0)}px`,
391
+ maxHeight: `${Math.max(available, 100)}px`,
392
+ overflowY: "auto",
373
393
  });
394
+ return {};
374
395
  },
375
- }),
396
+ },
376
397
  ],
377
398
  }}
378
399
  />
@@ -196,10 +196,10 @@ describe("blocksToMarkdown", () => {
196
196
 
197
197
  expect(blocksToMarkdown(blocks)).toBe(
198
198
  [
199
- "* Open the Login page.",
200
- " *Expected result*: The Login page loads successfully.",
201
- "* Enter a valid username.",
202
- " *Expected result*: The username is accepted.",
199
+ "* Open the Login page\\.",
200
+ " *Expected result*: The Login page loads successfully\\.",
201
+ "* Enter a valid username\\.",
202
+ " *Expected result*: The username is accepted\\.",
203
203
  ].join("\n"),
204
204
  );
205
205
  });
@@ -417,7 +417,7 @@ describe("blocksToMarkdown", () => {
417
417
 
418
418
  expect(blocksToMarkdown(blocks)).toBe(
419
419
  [
420
- "* Update an order status.",
420
+ "* Update an order status\\.",
421
421
  " ```",
422
422
  " SQL CREATE bnbmnbm mnbmb mm",
423
423
  " mn,nm nm, m,nm,n,nn,m,",
@@ -428,8 +428,8 @@ describe("blocksToMarkdown", () => {
428
428
  " ",
429
429
  " asdsadas",
430
430
  " ```",
431
- " ![](/attachments/HMhkVtlDrO.png)",
432
- " *Expected result*: The user receives a real-time notification for the order update.",
431
+ " ![](/attachments/HMhkVtlDrO\\.png)",
432
+ " *Expected result*: The user receives a real-time notification for the order update\\.",
433
433
  ].join("\n"),
434
434
  );
435
435
  });
@@ -1061,7 +1061,7 @@ describe("markdownToBlocks", () => {
1061
1061
 
1062
1062
  expect(markdownRoundTrip).toBe(
1063
1063
  [
1064
- "* Step 2: Update an order status.",
1064
+ "* Step 2: Update an order status\\.",
1065
1065
  " ```",
1066
1066
  " SQL CREATE bnbmnbm mnbmb mm",
1067
1067
  " mn,nm nm, m,nm,n,nn,m,",
@@ -1072,8 +1072,8 @@ describe("markdownToBlocks", () => {
1072
1072
  " ",
1073
1073
  " asdsadas",
1074
1074
  " ```",
1075
- " ![](/attachments/HMhkVtlDrO.png)",
1076
- " *Expected result*: The user receives a real-time notification for the order update.",
1075
+ " ![](/attachments/HMhkVtlDrO\\.png)",
1076
+ " *Expected result*: The user receives a real-time notification for the order update\\.",
1077
1077
  ].join("\n"),
1078
1078
  );
1079
1079
  });
@@ -1098,6 +1098,85 @@ describe("markdownToBlocks", () => {
1098
1098
  });
1099
1099
  });
1100
1100
 
1101
+ it("does not include a file block after a blank line in step with expected result", () => {
1102
+ const markdown = [
1103
+ "### Steps",
1104
+ "",
1105
+ "* Open the Login page.",
1106
+ " *Expected*: The Login page loads successfully.",
1107
+ "",
1108
+ "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)",
1109
+ ].join("\n");
1110
+
1111
+ const blocks = markdownToBlocks(markdown);
1112
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1113
+ const fileBlocks = blocks.filter((b) => b.type === "file");
1114
+
1115
+ expect(stepBlocks).toHaveLength(1);
1116
+ expect(stepBlocks[0].props).toMatchObject({
1117
+ stepTitle: "Open the Login page.",
1118
+ stepData: "",
1119
+ });
1120
+ expect(((stepBlocks[0].props as any).expectedResult ?? "").trim()).toBe(
1121
+ "The Login page loads successfully.",
1122
+ );
1123
+
1124
+ expect(fileBlocks).toHaveLength(1);
1125
+ expect((fileBlocks[0].props as any).url).toBe("https://example.com/file.pdf");
1126
+ expect((fileBlocks[0].props as any).name).toBe("report.pdf");
1127
+ });
1128
+
1129
+ it("does not include a file block after a blank line in step with step data", () => {
1130
+ const markdown = [
1131
+ "### Steps",
1132
+ "",
1133
+ "* Open the Login page.",
1134
+ " Enter credentials",
1135
+ "",
1136
+ "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)",
1137
+ ].join("\n");
1138
+
1139
+ const blocks = markdownToBlocks(markdown);
1140
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1141
+ const fileBlocks = blocks.filter((b) => b.type === "file");
1142
+
1143
+ expect(stepBlocks).toHaveLength(1);
1144
+ expect(stepBlocks[0].props).toMatchObject({
1145
+ stepTitle: "Open the Login page.",
1146
+ stepData: "Enter credentials",
1147
+ expectedResult: "",
1148
+ });
1149
+
1150
+ expect(fileBlocks).toHaveLength(1);
1151
+ expect((fileBlocks[0].props as any).url).toBe("https://example.com/file.pdf");
1152
+ expect((fileBlocks[0].props as any).name).toBe("report.pdf");
1153
+ });
1154
+
1155
+ it("does not include a file block after a blank line in step with title only", () => {
1156
+ const markdown = [
1157
+ "### Steps",
1158
+ "",
1159
+ "* Open the Login page.",
1160
+ "",
1161
+ "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)",
1162
+ ].join("\n");
1163
+
1164
+ const blocks = markdownToBlocks(markdown);
1165
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1166
+ const fileBlocks = blocks.filter((b) => b.type === "file");
1167
+
1168
+ expect(stepBlocks).toHaveLength(1);
1169
+ expect(stepBlocks[0].props).toMatchObject({
1170
+ stepTitle: "Open the Login page.",
1171
+ stepData: "",
1172
+ expectedResult: "",
1173
+ });
1174
+
1175
+ expect(fileBlocks).toHaveLength(1);
1176
+ expect((fileBlocks[0].props as any).url).toBe("https://example.com/file.pdf");
1177
+ expect((fileBlocks[0].props as any).name).toBe("report.pdf");
1178
+ });
1179
+
1101
1180
  it("parses bullet lists written with asterisk markers", () => {
1102
1181
  const markdown = [
1103
1182
  "### Preconditions",
@@ -1306,8 +1385,8 @@ describe("markdownToBlocks", () => {
1306
1385
 
1307
1386
  expect(markdownRoundTrip).toBe(
1308
1387
  [
1309
- "* Display the generated report.",
1310
- " *Expected result*: ![](/attachments/report.png)",
1388
+ "* Display the generated report\\.",
1389
+ " *Expected result*: ![](/attachments/report\\.png)",
1311
1390
  ].join("\n"),
1312
1391
  );
1313
1392
  });
@@ -1355,7 +1434,7 @@ describe("markdownToBlocks", () => {
1355
1434
  [
1356
1435
  "* Should open login screen",
1357
1436
  " *Expected result*: Login should look like this",
1358
- " ![](/login.png)",
1437
+ " ![](/login\\.png)",
1359
1438
  ].join("\n"),
1360
1439
  );
1361
1440
  });
@@ -2057,10 +2136,10 @@ describe("markdownToBlocks", () => {
2057
2136
 
2058
2137
  // Test round-trip conversion — numbered steps preserve their ordered style
2059
2138
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
2060
- expect(roundTripMarkdown).toContain("1. Navigate to the product listing page.");
2061
- expect(roundTripMarkdown).toContain("2. Select a product and click the \"Add to Cart\" button.");
2062
- expect(roundTripMarkdown).toContain("3. Open the shopping cart page.");
2063
- expect(roundTripMarkdown).toContain("4. Verify that the added item is displayed with the correct name, price, and quantity.");
2139
+ expect(roundTripMarkdown).toContain("1. Navigate to the product listing page\\.");
2140
+ expect(roundTripMarkdown).toContain("2. Select a product and click the \"Add to Cart\" button\\.");
2141
+ expect(roundTripMarkdown).toContain("3. Open the shopping cart page\\.");
2142
+ expect(roundTripMarkdown).toContain("4. Verify that the added item is displayed with the correct name, price, and quantity\\.");
2064
2143
  // Check that step data is preserved
2065
2144
  expect(roundTripMarkdown).toContain(" Expected open");
2066
2145
  expect(roundTripMarkdown).toContain(" Expected result close");
@@ -60,7 +60,7 @@ const headingPrefixes: Record<number, string> = {
60
60
  6: "######",
61
61
  };
62
62
 
63
- const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
63
+ const SPECIAL_CHAR_REGEX = /([*_`~()<\\])/g;
64
64
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
65
65
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
66
66
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
@@ -84,6 +84,10 @@ function escapeMarkdown(text: string): string {
84
84
  return result;
85
85
  }
86
86
 
87
+ function escapeStepContent(text: string): string {
88
+ return text.replace(/\./g, "\\.");
89
+ }
90
+
87
91
  function stripHtmlWrappers(text: string): string {
88
92
  return text
89
93
  .replace(HTML_SPAN_REGEX, "")
@@ -100,7 +104,7 @@ function stripExpectedPrefix(text: string): string {
100
104
 
101
105
  const cleanupLeading = (value: string) => {
102
106
  let result = value.trimStart();
103
- result = result.replace(/^\\+(?=[*_`~:[\]])/, "");
107
+ result = result.replace(/^\\+(?=[*_`~:])/, "");
104
108
  result = result.replace(/^(?:[*_`~]+)(?=\s|$)/, "");
105
109
  return result.trimStart();
106
110
  };
@@ -136,7 +140,7 @@ function stripLeadingFormatting(text: string): string {
136
140
  }
137
141
 
138
142
  function unescapeMarkdown(text: string): string {
139
- return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>\\])/g, "$1").replace(/\\>/g, ">");
143
+ return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>.\\])/g, "$1").replace(/\\>/g, ">");
140
144
  }
141
145
 
142
146
  function applyTextStyles(text: string, styles: EditorStyles | undefined): string {
@@ -428,7 +432,7 @@ function serializeBlock(
428
432
  if (normalizedTitle.length > 0 || hasContent) {
429
433
  const listStyle = (block.props as any).listStyle ?? "bullet";
430
434
  const prefix = listStyle === "ordered" ? `${(stepIndex ?? 0) + 1}.` : "*";
431
- lines.push(normalizedTitle.length > 0 ? `${prefix} ${normalizedTitle}` : `${prefix} `);
435
+ lines.push(normalizedTitle.length > 0 ? `${prefix} ${escapeStepContent(normalizedTitle)}` : `${prefix} `);
432
436
  }
433
437
 
434
438
  if (stepData.length > 0) {
@@ -436,7 +440,7 @@ function serializeBlock(
436
440
  dataLines.forEach((dataLine: string) => {
437
441
  const trimmedLine = dataLine.trim();
438
442
  if (trimmedLine.length > 0) {
439
- lines.push(` ${trimmedLine}`);
443
+ lines.push(` ${escapeStepContent(trimmedLine)}`);
440
444
  } else {
441
445
  lines.push(" ");
442
446
  }
@@ -454,9 +458,9 @@ function serializeBlock(
454
458
  }
455
459
 
456
460
  if (index === 0) {
457
- lines.push(` ${label}: ${trimmedLine}`);
461
+ lines.push(` ${label}: ${escapeStepContent(trimmedLine)}`);
458
462
  } else {
459
- lines.push(` ${trimmedLine}`);
463
+ lines.push(` ${escapeStepContent(trimmedLine)}`);
460
464
  }
461
465
  });
462
466
  }
@@ -1017,6 +1021,7 @@ function parseTestStep(
1017
1021
  let next = index + 1;
1018
1022
  let inExpectedResult = false;
1019
1023
  let blankLineSeenOutsideCodeBlock = false;
1024
+ let blankLineSeenInExpectedResult = false;
1020
1025
  const stepIndent = current.length - current.trimStart().length;
1021
1026
 
1022
1027
  while (next < lines.length) {
@@ -1026,13 +1031,14 @@ function parseTestStep(
1026
1031
  const rawTrimmed = line.trim();
1027
1032
 
1028
1033
  if (!rawTrimmed) {
1029
- if (stepDataLines.length > 0 || inExpectedResult) {
1030
- if (inExpectedResult) {
1031
- expectedResult += "\n";
1032
- } else {
1034
+ if (inExpectedResult) {
1035
+ expectedResult += "\n";
1036
+ blankLineSeenInExpectedResult = true;
1037
+ } else {
1038
+ if (stepDataLines.length > 0) {
1033
1039
  stepDataLines.push("");
1034
- blankLineSeenOutsideCodeBlock = true;
1035
1040
  }
1041
+ blankLineSeenOutsideCodeBlock = true;
1036
1042
  }
1037
1043
  next += 1;
1038
1044
  continue;
@@ -1130,7 +1136,13 @@ function parseTestStep(
1130
1136
  }
1131
1137
 
1132
1138
  if (inExpectedResult) {
1133
- // After finding the first expected result, indented lines are part of it
1139
+ // After a blank line inside the expected result, a non-indented line
1140
+ // belongs to the outer document (e.g. a trailing file block after the
1141
+ // step list), so stop here and let the root parser handle it.
1142
+ if (blankLineSeenInExpectedResult && !hasIndent) {
1143
+ break;
1144
+ }
1145
+ // Otherwise, indented lines are part of the expected result
1134
1146
  if (hasIndent) {
1135
1147
  const expectedContent = unescapeMarkdown(rawTrimmed);
1136
1148
  if (expectedResult.length > 0) {
@@ -951,6 +951,11 @@ html.dark .bn-step-image-preview__content {
951
951
  overflow-x: clip;
952
952
  }
953
953
 
954
+ .bn-container .bn-suggestion-menu {
955
+ max-height: none !important;
956
+ overflow: visible !important;
957
+ }
958
+
954
959
  [data-tooltip] {
955
960
  position: relative;
956
961
  }