testomatio-editor-blocks 0.4.24 → 0.4.26

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.
@@ -110,11 +110,11 @@ export const snippetBlock = createReactBlockSpec({
110
110
  if (!hasSnippets) {
111
111
  return (_jsx("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, children: _jsx("p", { className: "bn-snippet__empty", children: "No snippets in this project." }) }));
112
112
  }
113
- return (_jsxs("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, onFocus: handleFieldFocus, children: [_jsxs("div", { className: "bn-snippet__header", children: [_jsx("span", { className: "bn-snippet__label", children: "Snippet" }), _jsx(SnippetDropdown, { value: resolvedTitle, placeholder: "Select Snippet", suggestions: snippetSuggestions, selectedId: snippetId, onSelect: handleSnippetSelect })] }), isSnippetSelected && snippetData && (_jsx("div", { className: "bn-snippet__content", dangerouslySetInnerHTML: {
114
- __html: snippetData
115
- .replace(/&/g, "&")
116
- .replace(/</g, "&lt;")
117
- .replace(/>/g, "&gt;"),
118
- } }))] }));
113
+ return (_jsxs("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, onFocus: handleFieldFocus, children: [_jsxs("div", { className: "bn-snippet__header", children: [_jsx("span", { className: "bn-snippet__label", children: "Snippet" }), _jsx(SnippetDropdown, { value: resolvedTitle, placeholder: "Select Snippet", suggestions: snippetSuggestions, selectedId: snippetId, onSelect: handleSnippetSelect })] }), isSnippetSelected && (_jsx("div", { className: "bn-snippet__content", children: snippetData ? (_jsx("span", { dangerouslySetInnerHTML: {
114
+ __html: snippetData
115
+ .replace(/&/g, "&amp;")
116
+ .replace(/</g, "&lt;")
117
+ .replace(/>/g, "&gt;"),
118
+ } })) : (_jsx("span", { className: "bn-snippet__empty", children: "No content here. Please update the snippet." })) }))] }));
119
119
  },
120
120
  });
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Returns true when a normalised (lowercased, trailing-punctuation-stripped)
3
+ * heading text looks like a "Steps" heading.
4
+ * Accepted forms: steps, step, step(s).
5
+ */
6
+ export declare function isStepsHeading(text: string): boolean;
1
7
  export declare const isEmptyParagraph: (b: any) => boolean;
2
8
  /**
3
9
  * Check whether a step or snippet can be inserted at / after the given block.
@@ -43,6 +43,14 @@ const writeStepViewMode = (mode) => {
43
43
  //
44
44
  }
45
45
  };
46
+ /**
47
+ * Returns true when a normalised (lowercased, trailing-punctuation-stripped)
48
+ * heading text looks like a "Steps" heading.
49
+ * Accepted forms: steps, step, step(s).
50
+ */
51
+ export function isStepsHeading(text) {
52
+ return /^step(s|\(s\))?$/.test(text);
53
+ }
46
54
  export const isEmptyParagraph = (b) => b.type === "paragraph" &&
47
55
  (!Array.isArray(b.content) ||
48
56
  b.content.length === 0 ||
@@ -71,7 +79,7 @@ export function canInsertStepOrSnippet(editor, referenceBlockId) {
71
79
  .trim()
72
80
  .toLowerCase()
73
81
  .replace(/[:\-–—]$/, "");
74
- return text === "steps";
82
+ return isStepsHeading(text);
75
83
  }
76
84
  return false;
77
85
  }
@@ -148,7 +156,7 @@ export const stepBlock = createReactBlockSpec({
148
156
  .trim()
149
157
  .toLowerCase()
150
158
  .replace(/[:\-–—]$/, "");
151
- return text === "steps";
159
+ return isStepsHeading(text);
152
160
  }
153
161
  return false;
154
162
  }
@@ -1,4 +1,5 @@
1
1
  import { isLinkInlineContent, isStyledTextInlineContent, } from "@blocknote/core";
2
+ import { isStepsHeading } from "./blocks/step";
2
3
  const BASE_BLOCK_PROPS = {
3
4
  textAlignment: "left",
4
5
  textColor: "default",
@@ -257,6 +258,14 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
257
258
  lines.push(...serializeChildren(block, ctx));
258
259
  return lines;
259
260
  }
261
+ case "image": {
262
+ const url = block.props.url || "";
263
+ const caption = block.props.caption || "";
264
+ if (url) {
265
+ lines.push(`![${caption}](${url})`);
266
+ }
267
+ return flattenWithBlankLine(lines, true);
268
+ }
260
269
  case "testStep":
261
270
  case "snippet": {
262
271
  const isSnippet = block.type === "snippet";
@@ -455,9 +464,9 @@ function serializeBlocks(blocks, ctx) {
455
464
  export function blocksToMarkdown(blocks) {
456
465
  const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
457
466
  const cleaned = lines
458
- // Collapse more than two blank lines into just two for readability.
467
+ // Collapse excessive blank lines but preserve one extra for empty paragraphs.
459
468
  .join("\n")
460
- .replace(/\n{3,}/g, "\n\n")
469
+ .replace(/\n{4,}/g, "\n\n\n")
461
470
  .trimEnd();
462
471
  return cleaned;
463
472
  }
@@ -1086,12 +1095,6 @@ export function fixMalformedImageBlocks(blocks) {
1086
1095
  while (i < blocks.length) {
1087
1096
  const current = blocks[i];
1088
1097
  const next = blocks[i + 1];
1089
- // Skip empty paragraphs
1090
- if (current.type === "paragraph" &&
1091
- (!current.content || !Array.isArray(current.content) || current.content.length === 0)) {
1092
- i += 1;
1093
- continue;
1094
- }
1095
1098
  // Check if current is a paragraph with just "!" - this is definitely a malformed image
1096
1099
  if (current.type === "paragraph" &&
1097
1100
  current.content &&
@@ -1133,7 +1136,7 @@ export function fixMalformedImageBlocks(blocks) {
1133
1136
  return result;
1134
1137
  }
1135
1138
  export function markdownToBlocks(markdown) {
1136
- var _a, _b;
1139
+ var _a, _b, _c;
1137
1140
  const normalized = markdown.replace(/\r\n/g, "\n");
1138
1141
  const lines = normalized.split("\n");
1139
1142
  const blocks = [];
@@ -1143,6 +1146,16 @@ export function markdownToBlocks(markdown) {
1143
1146
  const line = lines[index];
1144
1147
  if (!line.trim()) {
1145
1148
  index += 1;
1149
+ // Count consecutive blank lines
1150
+ let blankCount = 1;
1151
+ while (index < lines.length && !lines[index].trim()) {
1152
+ blankCount++;
1153
+ index++;
1154
+ }
1155
+ // Create empty paragraph for each extra blank line beyond the first
1156
+ for (let i = 1; i < blankCount; i++) {
1157
+ blocks.push({ type: "paragraph", content: [], children: [] });
1158
+ }
1146
1159
  continue;
1147
1160
  }
1148
1161
  const snippetWrapper = parseSnippetWrapper(lines, index);
@@ -1171,7 +1184,7 @@ export function markdownToBlocks(markdown) {
1171
1184
  const headingLevel = (_b = (_a = headingBlock.props) === null || _a === void 0 ? void 0 : _a.level) !== null && _b !== void 0 ? _b : 3;
1172
1185
  const headingText = inlineContentToPlainText(headingBlock.content);
1173
1186
  const normalizedHeading = headingText.trim().toLowerCase();
1174
- if (normalizedHeading.replace(/[:\-–—]$/, "") === "steps") {
1187
+ if (isStepsHeading(normalizedHeading.replace(/[:\-–—]$/, ""))) {
1175
1188
  stepsHeadingLevel = headingLevel;
1176
1189
  }
1177
1190
  else if (stepsHeadingLevel !== null &&
@@ -1202,11 +1215,33 @@ export function markdownToBlocks(markdown) {
1202
1215
  index = nextIndex;
1203
1216
  continue;
1204
1217
  }
1218
+ const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
1219
+ if (imageMatch) {
1220
+ blocks.push({
1221
+ type: "image",
1222
+ props: {
1223
+ url: imageMatch[2],
1224
+ caption: imageMatch[1] || "",
1225
+ name: "",
1226
+ },
1227
+ children: [],
1228
+ });
1229
+ index += 1;
1230
+ continue;
1231
+ }
1205
1232
  const paragraph = parseParagraph(lines, index);
1206
1233
  blocks.push(paragraph.block);
1207
1234
  index = paragraph.nextIndex;
1208
1235
  }
1209
- return fixMalformedImageBlocks(blocks);
1236
+ // Insert empty paragraphs between consecutive headings so users can type between them
1237
+ const result = [];
1238
+ for (let i = 0; i < blocks.length; i++) {
1239
+ result.push(blocks[i]);
1240
+ if (blocks[i].type === "heading" && ((_c = blocks[i + 1]) === null || _c === void 0 ? void 0 : _c.type) === "heading") {
1241
+ result.push({ type: "paragraph", content: [], children: [] });
1242
+ }
1243
+ }
1244
+ return fixMalformedImageBlocks(result);
1210
1245
  }
1211
1246
  function splitTableRow(line) {
1212
1247
  let value = line.trim();
@@ -1,5 +1,5 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
- export { stepBlock } from "./editor/blocks/step";
2
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
5
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, } from "./editor/customMarkdownConverter";
package/package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
- export { stepBlock } from "./editor/blocks/step";
2
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
5
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
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",
@@ -200,16 +200,21 @@ export const snippetBlock = createReactBlockSpec(
200
200
  onSelect={handleSnippetSelect}
201
201
  />
202
202
  </div>
203
- {isSnippetSelected && snippetData && (
204
- <div
205
- className="bn-snippet__content"
206
- dangerouslySetInnerHTML={{
207
- __html: snippetData
208
- .replace(/&/g, "&amp;")
209
- .replace(/</g, "&lt;")
210
- .replace(/>/g, "&gt;"),
211
- }}
212
- />
203
+ {isSnippetSelected && (
204
+ <div className="bn-snippet__content">
205
+ {snippetData ? (
206
+ <span
207
+ dangerouslySetInnerHTML={{
208
+ __html: snippetData
209
+ .replace(/&/g, "&amp;")
210
+ .replace(/</g, "&lt;")
211
+ .replace(/>/g, "&gt;"),
212
+ }}
213
+ />
214
+ ) : (
215
+ <span className="bn-snippet__empty">No content here. Please update the snippet.</span>
216
+ )}
217
+ </div>
213
218
  )}
214
219
  </div>
215
220
  );
@@ -47,6 +47,15 @@ const writeStepViewMode = (mode: StepViewMode) => {
47
47
  }
48
48
  };
49
49
 
50
+ /**
51
+ * Returns true when a normalised (lowercased, trailing-punctuation-stripped)
52
+ * heading text looks like a "Steps" heading.
53
+ * Accepted forms: steps, step, step(s).
54
+ */
55
+ export function isStepsHeading(text: string): boolean {
56
+ return /^step(s|\(s\))?$/.test(text);
57
+ }
58
+
50
59
  export const isEmptyParagraph = (b: any): boolean =>
51
60
  b.type === "paragraph" &&
52
61
  (!Array.isArray(b.content) ||
@@ -80,7 +89,7 @@ export function canInsertStepOrSnippet(
80
89
  .trim()
81
90
  .toLowerCase()
82
91
  .replace(/[:\-–—]$/, "");
83
- return text === "steps";
92
+ return isStepsHeading(text);
84
93
  }
85
94
  return false;
86
95
  }
@@ -162,7 +171,7 @@ export const stepBlock = createReactBlockSpec(
162
171
  .trim()
163
172
  .toLowerCase()
164
173
  .replace(/[:\-–—]$/, "");
165
- return text === "steps";
174
+ return isStepsHeading(text);
166
175
  }
167
176
  return false;
168
177
  }
@@ -863,6 +863,40 @@ describe("markdownToBlocks", () => {
863
863
  ]);
864
864
  });
865
865
 
866
+ it("parses steps under a 'Step' heading (singular)", () => {
867
+ const markdown = ["## Step", "", "* Open the app", "* Click login"].join("\n");
868
+ const blocks = markdownToBlocks(markdown);
869
+ const stepBlocks = blocks.filter((block) => block.type === "testStep");
870
+ expect(stepBlocks).toHaveLength(2);
871
+ expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Open the app" });
872
+ expect(stepBlocks[1].props).toMatchObject({ stepTitle: "Click login" });
873
+ });
874
+
875
+ it("parses steps under a 'Step(s)' heading", () => {
876
+ const markdown = ["# Step(s)", "", "1. First step", "2. Second step"].join("\n");
877
+ const blocks = markdownToBlocks(markdown);
878
+ const stepBlocks = blocks.filter((block) => block.type === "testStep");
879
+ expect(stepBlocks).toHaveLength(2);
880
+ expect(stepBlocks[0].props).toMatchObject({ stepTitle: "First step", listStyle: "ordered" });
881
+ expect(stepBlocks[1].props).toMatchObject({ stepTitle: "Second step", listStyle: "ordered" });
882
+ });
883
+
884
+ it("parses steps under an h4 'step' heading (lowercase)", () => {
885
+ const markdown = ["#### step", "", "* Do something"].join("\n");
886
+ const blocks = markdownToBlocks(markdown);
887
+ const stepBlocks = blocks.filter((block) => block.type === "testStep");
888
+ expect(stepBlocks).toHaveLength(1);
889
+ expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Do something" });
890
+ });
891
+
892
+ it("parses steps under a 'Step:' heading with trailing colon", () => {
893
+ const markdown = ["### Step:", "", "* Verify output"].join("\n");
894
+ const blocks = markdownToBlocks(markdown);
895
+ const stepBlocks = blocks.filter((block) => block.type === "testStep");
896
+ expect(stepBlocks).toHaveLength(1);
897
+ expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Verify output" });
898
+ });
899
+
866
900
  it("handles multiple steps with expected results without extra asterisks", () => {
867
901
  const markdown = [
868
902
  "### Preconditions",
@@ -1173,36 +1207,17 @@ describe("markdownToBlocks", () => {
1173
1207
 
1174
1208
  const blocks = markdownToBlocks(markdown);
1175
1209
 
1176
- // Find the paragraph blocks that contain the images (links)
1177
- const imageBlocks = blocks.filter(block =>
1178
- block.type === "paragraph" &&
1179
- block.content &&
1180
- Array.isArray(block.content) &&
1181
- block.content.some((item: any) =>
1182
- (item.type === "text" && item.text === "!") ||
1183
- (item.type === "link" && item.href && item.href.includes("/attachments/"))
1184
- )
1185
- );
1210
+ // Find the image blocks
1211
+ const imageBlocks = blocks.filter(block => block.type === "image");
1186
1212
 
1187
- // Should have two paragraph blocks with images
1213
+ // Should have two image blocks
1188
1214
  expect(imageBlocks.length).toBe(2);
1189
1215
 
1190
- // Check that both image links are properly parsed
1191
- const imageLinks: any[] = [];
1192
- imageBlocks.forEach(block => {
1193
- if (block.content && Array.isArray(block.content)) {
1194
- const link = (block.content as any[]).find(item => item.type === "link");
1195
- if (link) {
1196
- imageLinks.push(link);
1197
- }
1198
- }
1199
- });
1200
-
1201
- expect(imageLinks).toHaveLength(2);
1202
- expect(imageLinks[0].href).toBe("/attachments/se2n8jaGon.png");
1203
- expect(imageLinks[0].content).toEqual([{ type: "text", text: "logs", styles: {} }]);
1204
- expect(imageLinks[1].href).toBe("/attachments/p5DgklVeMg.png");
1205
- expect(imageLinks[1].content).toEqual([{ type: "text", text: "", styles: {} }]);
1216
+ // Check image block props
1217
+ expect((imageBlocks[0].props as any).url).toBe("/attachments/se2n8jaGon.png");
1218
+ expect((imageBlocks[0].props as any).caption).toBe("logs");
1219
+ expect((imageBlocks[1].props as any).url).toBe("/attachments/p5DgklVeMg.png");
1220
+ expect((imageBlocks[1].props as any).caption).toBe("");
1206
1221
 
1207
1222
  // Test round-trip conversion
1208
1223
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
@@ -1311,47 +1326,19 @@ describe("markdownToBlocks", () => {
1311
1326
 
1312
1327
  const blocks = markdownToBlocks(markdown);
1313
1328
 
1314
- // Find image paragraphs
1315
- const imageParagraphs = blocks.filter(block =>
1316
- block.type === "paragraph" &&
1317
- block.content &&
1318
- Array.isArray(block.content) &&
1319
- block.content.some((item: any) => item.type === "link")
1320
- );
1329
+ // Find image blocks
1330
+ const imageBlocks = blocks.filter(block => block.type === "image");
1321
1331
 
1322
- // Should have exactly 2 image paragraphs
1323
- expect(imageParagraphs).toHaveLength(2);
1332
+ // Should have exactly 2 image blocks
1333
+ expect(imageBlocks).toHaveLength(2);
1324
1334
 
1325
1335
  // First image with alt text
1326
- expect(imageParagraphs[0].content).toContainEqual({
1327
- type: "text",
1328
- text: "!",
1329
- styles: {}
1330
- });
1331
- expect(imageParagraphs[0].content).toContainEqual({
1332
- type: "link",
1333
- href: "/attachments/se2n8jaGon.png",
1334
- content: [{ type: "text", text: "logs", styles: {} }]
1335
- });
1336
+ expect((imageBlocks[0].props as any).url).toBe("/attachments/se2n8jaGon.png");
1337
+ expect((imageBlocks[0].props as any).caption).toBe("logs");
1336
1338
 
1337
1339
  // Second image without alt text
1338
- expect(imageParagraphs[1].content).toContainEqual({
1339
- type: "text",
1340
- text: "!",
1341
- styles: {}
1342
- });
1343
- expect(imageParagraphs[1].content).toContainEqual({
1344
- type: "link",
1345
- href: "/attachments/p5DgklVeMg.png",
1346
- content: [{ type: "text", text: "", styles: {} }]
1347
- });
1348
-
1349
- // No extra empty paragraphs
1350
- const emptyParagraphs = blocks.filter(block =>
1351
- block.type === "paragraph" &&
1352
- (!block.content || block.content.length === 0)
1353
- );
1354
- expect(emptyParagraphs).toHaveLength(0);
1340
+ expect((imageBlocks[1].props as any).url).toBe("/attachments/p5DgklVeMg.png");
1341
+ expect((imageBlocks[1].props as any).caption).toBe("");
1355
1342
 
1356
1343
  // Test round-trip conversion
1357
1344
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
@@ -1374,18 +1361,9 @@ describe("markdownToBlocks", () => {
1374
1361
 
1375
1362
  const blocks = markdownToBlocks(markdown);
1376
1363
 
1377
- // Should have exactly 2 image paragraphs, no empty paragraphs
1378
- const imageParagraphs = blocks.filter(block =>
1379
- block.type === "paragraph" &&
1380
- block.content &&
1381
- Array.isArray(block.content) &&
1382
- block.content.some((item: any) => item.type === "link")
1383
- );
1384
-
1385
- const emptyParagraphs = blocks.filter(block =>
1386
- block.type === "paragraph" &&
1387
- (!block.content || block.content.length === 0)
1388
- );
1364
+ // Should have exactly 2 image blocks
1365
+ const imageBlocks = blocks.filter(block => block.type === "image");
1366
+ expect(imageBlocks).toHaveLength(2);
1389
1367
 
1390
1368
  // Check for malformed image blocks (paragraphs with just "!" but no link)
1391
1369
  const malformedBlocks = blocks.filter(block =>
@@ -1395,9 +1373,6 @@ describe("markdownToBlocks", () => {
1395
1373
  block.content.some((item: any) => item.type === "text" && item.text === "!") &&
1396
1374
  !block.content.some((item: any) => item.type === "link")
1397
1375
  );
1398
-
1399
- expect(imageParagraphs).toHaveLength(2);
1400
- expect(emptyParagraphs).toHaveLength(0);
1401
1376
  expect(malformedBlocks).toHaveLength(0);
1402
1377
 
1403
1378
  // Test round-trip conversion
@@ -1489,8 +1464,8 @@ describe("markdownToBlocks", () => {
1489
1464
  // Apply the fixMalformedImageBlocks function
1490
1465
  const fixedBlocks = fixMalformedImageBlocks(malformedBlocks);
1491
1466
 
1492
- // Should have removed the malformed image blocks (both the "!" only block and the empty block)
1493
- expect(fixedBlocks.length).toBe(2);
1467
+ // Should have removed the malformed "!" only block but kept the empty paragraph and image block
1468
+ expect(fixedBlocks.length).toBe(3);
1494
1469
  expect(fixedBlocks[0].type).toBe("heading");
1495
1470
  expect(fixedBlocks[1].type).toBe("paragraph");
1496
1471
  expect(fixedBlocks[1].content).toContainEqual(
@@ -1499,6 +1474,8 @@ describe("markdownToBlocks", () => {
1499
1474
  expect(fixedBlocks[1].content).toContainEqual(
1500
1475
  { type: "link", href: "/attachments/se2n8jaGon.png", content: [{ type: "text", text: "logs", styles: {} }] }
1501
1476
  );
1477
+ expect(fixedBlocks[2].type).toBe("paragraph");
1478
+ expect(fixedBlocks[2].content).toHaveLength(0);
1502
1479
  });
1503
1480
 
1504
1481
  it("reproduces the exact Unsplash URL issue", () => {
@@ -1525,14 +1502,9 @@ describe("markdownToBlocks", () => {
1525
1502
  // Should have at least 3 blocks
1526
1503
  expect(blocks.length).toBeGreaterThanOrEqual(3);
1527
1504
 
1528
- // Should have at least one paragraph with content (images)
1529
- const imageBlocks = blocks.filter(b =>
1530
- b.type === "paragraph" &&
1531
- b.content &&
1532
- Array.isArray(b.content) &&
1533
- b.content.some((item: any) => item.type === "link")
1534
- );
1535
- expect(imageBlocks.length).toBeGreaterThan(0);
1505
+ // Should have image blocks
1506
+ const imageBlocks = blocks.filter(b => b.type === "image");
1507
+ expect(imageBlocks.length).toBe(2);
1536
1508
 
1537
1509
  // Test round-trip conversion - check that we get the images back
1538
1510
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
@@ -9,6 +9,7 @@ import type {
9
9
  Styles,
10
10
  } from "@blocknote/core";
11
11
  import type { customSchema } from "./customSchema";
12
+ import { isStepsHeading } from "./blocks/step";
12
13
 
13
14
  // Types derived from the custom schema so the converter stays type-safe when the schema evolves.
14
15
  type Schema = typeof customSchema;
@@ -335,6 +336,14 @@ function serializeBlock(
335
336
  lines.push(...serializeChildren(block, ctx));
336
337
  return lines;
337
338
  }
339
+ case "image": {
340
+ const url = (block.props as any).url || "";
341
+ const caption = (block.props as any).caption || "";
342
+ if (url) {
343
+ lines.push(`![${caption}](${url})`);
344
+ }
345
+ return flattenWithBlankLine(lines, true);
346
+ }
338
347
  case "testStep":
339
348
  case "snippet": {
340
349
  const isSnippet = block.type === "snippet";
@@ -569,9 +578,9 @@ function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): str
569
578
  export function blocksToMarkdown(blocks: CustomEditorBlock[]): string {
570
579
  const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
571
580
  const cleaned = lines
572
- // Collapse more than two blank lines into just two for readability.
581
+ // Collapse excessive blank lines but preserve one extra for empty paragraphs.
573
582
  .join("\n")
574
- .replace(/\n{3,}/g, "\n\n")
583
+ .replace(/\n{4,}/g, "\n\n\n")
575
584
  .trimEnd();
576
585
 
577
586
  return cleaned;
@@ -1300,15 +1309,6 @@ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPar
1300
1309
  const current = blocks[i];
1301
1310
  const next = blocks[i + 1];
1302
1311
 
1303
- // Skip empty paragraphs
1304
- if (
1305
- current.type === "paragraph" &&
1306
- (!current.content || !Array.isArray(current.content) || current.content.length === 0)
1307
- ) {
1308
- i += 1;
1309
- continue;
1310
- }
1311
-
1312
1312
  // Check if current is a paragraph with just "!" - this is definitely a malformed image
1313
1313
  if (
1314
1314
  current.type === "paragraph" &&
@@ -1371,6 +1371,16 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1371
1371
  const line = lines[index];
1372
1372
  if (!line.trim()) {
1373
1373
  index += 1;
1374
+ // Count consecutive blank lines
1375
+ let blankCount = 1;
1376
+ while (index < lines.length && !lines[index].trim()) {
1377
+ blankCount++;
1378
+ index++;
1379
+ }
1380
+ // Create empty paragraph for each extra blank line beyond the first
1381
+ for (let i = 1; i < blankCount; i++) {
1382
+ blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1383
+ }
1374
1384
  continue;
1375
1385
  }
1376
1386
 
@@ -1404,7 +1414,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1404
1414
  const headingText = inlineContentToPlainText(headingBlock.content as any);
1405
1415
  const normalizedHeading = headingText.trim().toLowerCase();
1406
1416
 
1407
- if (normalizedHeading.replace(/[:\-–—]$/, "") === "steps") {
1417
+ if (isStepsHeading(normalizedHeading.replace(/[:\-–—]$/, ""))) {
1408
1418
  stepsHeadingLevel = headingLevel;
1409
1419
  } else if (
1410
1420
  stepsHeadingLevel !== null &&
@@ -1447,12 +1457,36 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1447
1457
  continue;
1448
1458
  }
1449
1459
 
1460
+ const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
1461
+ if (imageMatch) {
1462
+ blocks.push({
1463
+ type: "image",
1464
+ props: {
1465
+ url: imageMatch[2],
1466
+ caption: imageMatch[1] || "",
1467
+ name: "",
1468
+ },
1469
+ children: [],
1470
+ } as CustomPartialBlock);
1471
+ index += 1;
1472
+ continue;
1473
+ }
1474
+
1450
1475
  const paragraph = parseParagraph(lines, index);
1451
1476
  blocks.push(paragraph.block);
1452
1477
  index = paragraph.nextIndex;
1453
1478
  }
1454
1479
 
1455
- return fixMalformedImageBlocks(blocks);
1480
+ // Insert empty paragraphs between consecutive headings so users can type between them
1481
+ const result: CustomPartialBlock[] = [];
1482
+ for (let i = 0; i < blocks.length; i++) {
1483
+ result.push(blocks[i]);
1484
+ if (blocks[i].type === "heading" && blocks[i + 1]?.type === "heading") {
1485
+ result.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1486
+ }
1487
+ }
1488
+
1489
+ return fixMalformedImageBlocks(result);
1456
1490
  }
1457
1491
 
1458
1492
  function splitTableRow(line: string): string[] {
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ export {
4
4
  type CustomBlock,
5
5
  type CustomEditor,
6
6
  } from "./editor/customSchema";
7
- export { stepBlock } from "./editor/blocks/step";
7
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading } from "./editor/blocks/step";
8
8
  export { snippetBlock } from "./editor/blocks/snippet";
9
9
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
10
10