testomatio-editor-blocks 0.4.23 → 0.4.25

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,4 +1,13 @@
1
1
  export declare const isEmptyParagraph: (b: any) => boolean;
2
+ /**
3
+ * Check whether a step or snippet can be inserted at / after the given block.
4
+ * Returns true only when walking backwards from `referenceBlockId` (skipping
5
+ * other steps, snippets, and empty paragraphs) reaches a heading whose text
6
+ * is "steps".
7
+ */
8
+ export declare function canInsertStepOrSnippet(editor: {
9
+ document: any[];
10
+ }, referenceBlockId: string): boolean;
2
11
  export declare const stepBlock: {
3
12
  config: {
4
13
  readonly type: "testStep";
@@ -47,6 +47,36 @@ export const isEmptyParagraph = (b) => b.type === "paragraph" &&
47
47
  (!Array.isArray(b.content) ||
48
48
  b.content.length === 0 ||
49
49
  b.content.every((n) => { var _a; return n.type === "text" && !((_a = n.text) === null || _a === void 0 ? void 0 : _a.trim()); }));
50
+ /**
51
+ * Check whether a step or snippet can be inserted at / after the given block.
52
+ * Returns true only when walking backwards from `referenceBlockId` (skipping
53
+ * other steps, snippets, and empty paragraphs) reaches a heading whose text
54
+ * is "steps".
55
+ */
56
+ export function canInsertStepOrSnippet(editor, referenceBlockId) {
57
+ const allBlocks = editor.document;
58
+ const blockIndex = allBlocks.findIndex((b) => b.id === referenceBlockId);
59
+ if (blockIndex < 0)
60
+ return false;
61
+ for (let i = blockIndex; i >= 0; i--) {
62
+ const b = allBlocks[i];
63
+ if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
64
+ continue;
65
+ }
66
+ if (b.type === "heading") {
67
+ const text = (Array.isArray(b.content) ? b.content : [])
68
+ .filter((n) => n.type === "text")
69
+ .map((n) => { var _a; return (_a = n.text) !== null && _a !== void 0 ? _a : ""; })
70
+ .join("")
71
+ .trim()
72
+ .toLowerCase()
73
+ .replace(/[:\-–—]$/, "");
74
+ return text === "steps";
75
+ }
76
+ return false;
77
+ }
78
+ return false;
79
+ }
50
80
  export const stepBlock = createReactBlockSpec({
51
81
  type: "testStep",
52
82
  content: "none",
@@ -257,6 +257,14 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
257
257
  lines.push(...serializeChildren(block, ctx));
258
258
  return lines;
259
259
  }
260
+ case "image": {
261
+ const url = block.props.url || "";
262
+ const caption = block.props.caption || "";
263
+ if (url) {
264
+ lines.push(`![${caption}](${url})`);
265
+ }
266
+ return flattenWithBlankLine(lines, true);
267
+ }
260
268
  case "testStep":
261
269
  case "snippet": {
262
270
  const isSnippet = block.type === "snippet";
@@ -455,9 +463,9 @@ function serializeBlocks(blocks, ctx) {
455
463
  export function blocksToMarkdown(blocks) {
456
464
  const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
457
465
  const cleaned = lines
458
- // Collapse more than two blank lines into just two for readability.
466
+ // Collapse excessive blank lines but preserve one extra for empty paragraphs.
459
467
  .join("\n")
460
- .replace(/\n{3,}/g, "\n\n")
468
+ .replace(/\n{4,}/g, "\n\n\n")
461
469
  .trimEnd();
462
470
  return cleaned;
463
471
  }
@@ -1086,12 +1094,6 @@ export function fixMalformedImageBlocks(blocks) {
1086
1094
  while (i < blocks.length) {
1087
1095
  const current = blocks[i];
1088
1096
  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
1097
  // Check if current is a paragraph with just "!" - this is definitely a malformed image
1096
1098
  if (current.type === "paragraph" &&
1097
1099
  current.content &&
@@ -1133,7 +1135,7 @@ export function fixMalformedImageBlocks(blocks) {
1133
1135
  return result;
1134
1136
  }
1135
1137
  export function markdownToBlocks(markdown) {
1136
- var _a, _b;
1138
+ var _a, _b, _c;
1137
1139
  const normalized = markdown.replace(/\r\n/g, "\n");
1138
1140
  const lines = normalized.split("\n");
1139
1141
  const blocks = [];
@@ -1143,6 +1145,16 @@ export function markdownToBlocks(markdown) {
1143
1145
  const line = lines[index];
1144
1146
  if (!line.trim()) {
1145
1147
  index += 1;
1148
+ // Count consecutive blank lines
1149
+ let blankCount = 1;
1150
+ while (index < lines.length && !lines[index].trim()) {
1151
+ blankCount++;
1152
+ index++;
1153
+ }
1154
+ // Create empty paragraph for each extra blank line beyond the first
1155
+ for (let i = 1; i < blankCount; i++) {
1156
+ blocks.push({ type: "paragraph", content: [], children: [] });
1157
+ }
1146
1158
  continue;
1147
1159
  }
1148
1160
  const snippetWrapper = parseSnippetWrapper(lines, index);
@@ -1202,11 +1214,33 @@ export function markdownToBlocks(markdown) {
1202
1214
  index = nextIndex;
1203
1215
  continue;
1204
1216
  }
1217
+ const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
1218
+ if (imageMatch) {
1219
+ blocks.push({
1220
+ type: "image",
1221
+ props: {
1222
+ url: imageMatch[2],
1223
+ caption: imageMatch[1] || "",
1224
+ name: "",
1225
+ },
1226
+ children: [],
1227
+ });
1228
+ index += 1;
1229
+ continue;
1230
+ }
1205
1231
  const paragraph = parseParagraph(lines, index);
1206
1232
  blocks.push(paragraph.block);
1207
1233
  index = paragraph.nextIndex;
1208
1234
  }
1209
- return fixMalformedImageBlocks(blocks);
1235
+ // Insert empty paragraphs between consecutive headings so users can type between them
1236
+ const result = [];
1237
+ for (let i = 0; i < blocks.length; i++) {
1238
+ result.push(blocks[i]);
1239
+ if (blocks[i].type === "heading" && ((_c = blocks[i + 1]) === null || _c === void 0 ? void 0 : _c.type) === "heading") {
1240
+ result.push({ type: "paragraph", content: [], children: [] });
1241
+ }
1242
+ }
1243
+ return fixMalformedImageBlocks(result);
1210
1244
  }
1211
1245
  function splitTableRow(line) {
1212
1246
  let value = line.trim();
@@ -1,24 +1,31 @@
1
1
  import { useEffect, useState } from "react";
2
2
  let globalFetcher = null;
3
3
  let cachedSuggestions = [];
4
+ let inflightPromise = null;
4
5
  export function setSnippetFetcher(fetcher) {
5
6
  globalFetcher = fetcher;
6
7
  cachedSuggestions = [];
8
+ inflightPromise = null;
7
9
  }
8
10
  export function useSnippetAutocomplete() {
9
11
  const [suggestions, setSuggestions] = useState(() => {
10
- if (cachedSuggestions.length > 0) {
12
+ if (cachedSuggestions.length > 0)
11
13
  return cachedSuggestions;
12
- }
13
- if (globalFetcher) {
14
- const result = globalFetcher();
15
- if (!result || typeof result.then !== "function") {
16
- const normalized = normalizeSnippetSuggestions(result);
17
- cachedSuggestions = normalized;
18
- return normalized;
14
+ if (!globalFetcher)
15
+ return [];
16
+ const result = globalFetcher();
17
+ if (result && typeof result.then === "function") {
18
+ if (!inflightPromise) {
19
+ inflightPromise = result
20
+ .then((r) => normalizeSnippetSuggestions(r))
21
+ .then((items) => { cachedSuggestions = items; inflightPromise = null; return items; })
22
+ .catch((error) => { inflightPromise = null; console.error("Failed to fetch snippet suggestions", error); return []; });
19
23
  }
24
+ return [];
20
25
  }
21
- return [];
26
+ const normalized = normalizeSnippetSuggestions(result);
27
+ cachedSuggestions = normalized;
28
+ return normalized;
22
29
  });
23
30
  useEffect(() => {
24
31
  if (suggestions.length > 0) {
@@ -28,15 +35,24 @@ export function useSnippetAutocomplete() {
28
35
  return;
29
36
  }
30
37
  let cancelled = false;
31
- Promise.resolve(globalFetcher())
32
- .then((result) => normalizeSnippetSuggestions(result))
33
- .then((items) => {
34
- if (cancelled)
35
- return;
36
- cachedSuggestions = items;
37
- setSuggestions(items);
38
- })
39
- .catch((error) => console.error("Failed to fetch snippet suggestions", error));
38
+ if (!inflightPromise) {
39
+ inflightPromise = Promise.resolve(globalFetcher())
40
+ .then((result) => normalizeSnippetSuggestions(result))
41
+ .then((items) => {
42
+ cachedSuggestions = items;
43
+ inflightPromise = null;
44
+ return items;
45
+ })
46
+ .catch((error) => {
47
+ inflightPromise = null;
48
+ console.error("Failed to fetch snippet suggestions", error);
49
+ return [];
50
+ });
51
+ }
52
+ inflightPromise.then((items) => {
53
+ if (!cancelled)
54
+ setSuggestions(items);
55
+ });
40
56
  return () => {
41
57
  cancelled = true;
42
58
  };
@@ -1,24 +1,31 @@
1
1
  import { useEffect, useState } from "react";
2
2
  let globalFetcher = null;
3
3
  let cachedSuggestions = [];
4
+ let inflightPromise = null;
4
5
  export function setStepsFetcher(fetcher) {
5
6
  globalFetcher = fetcher;
6
7
  cachedSuggestions = [];
8
+ inflightPromise = null;
7
9
  }
8
10
  export function useStepAutocomplete() {
9
11
  const [suggestions, setSuggestions] = useState(() => {
10
- if (cachedSuggestions.length > 0) {
12
+ if (cachedSuggestions.length > 0)
11
13
  return cachedSuggestions;
12
- }
13
- if (globalFetcher) {
14
- const result = globalFetcher();
15
- if (!result || typeof result.then !== "function") {
16
- const normalized = normalizeStepSuggestions(result);
17
- cachedSuggestions = normalized;
18
- return normalized;
14
+ if (!globalFetcher)
15
+ return [];
16
+ const result = globalFetcher();
17
+ if (result && typeof result.then === "function") {
18
+ if (!inflightPromise) {
19
+ inflightPromise = result
20
+ .then((r) => normalizeStepSuggestions(r))
21
+ .then((items) => { cachedSuggestions = items; inflightPromise = null; return items; })
22
+ .catch((error) => { inflightPromise = null; console.error("Failed to fetch step suggestions", error); return []; });
19
23
  }
24
+ return [];
20
25
  }
21
- return [];
26
+ const normalized = normalizeStepSuggestions(result);
27
+ cachedSuggestions = normalized;
28
+ return normalized;
22
29
  });
23
30
  useEffect(() => {
24
31
  if (suggestions.length > 0) {
@@ -28,15 +35,24 @@ export function useStepAutocomplete() {
28
35
  return;
29
36
  }
30
37
  let cancelled = false;
31
- Promise.resolve(globalFetcher())
32
- .then((result) => normalizeStepSuggestions(result))
33
- .then((items) => {
34
- if (cancelled)
35
- return;
36
- cachedSuggestions = items;
37
- setSuggestions(items);
38
- })
39
- .catch((error) => console.error("Failed to fetch step suggestions", error));
38
+ if (!inflightPromise) {
39
+ inflightPromise = Promise.resolve(globalFetcher())
40
+ .then((result) => normalizeStepSuggestions(result))
41
+ .then((items) => {
42
+ cachedSuggestions = items;
43
+ inflightPromise = null;
44
+ return items;
45
+ })
46
+ .catch((error) => {
47
+ inflightPromise = null;
48
+ console.error("Failed to fetch step suggestions", error);
49
+ return [];
50
+ });
51
+ }
52
+ inflightPromise.then((items) => {
53
+ if (!cancelled)
54
+ setSuggestions(items);
55
+ });
40
56
  return () => {
41
57
  cancelled = true;
42
58
  };
@@ -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 } 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 } 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";
@@ -515,7 +515,6 @@ html.dark .bn-step-image-preview__content {
515
515
  display: flex;
516
516
  flex-direction: column;
517
517
  gap: 4px;
518
- overflow: hidden;
519
518
  }
520
519
 
521
520
  .bn-teststep__horizontal-col .bn-step-field__top {
@@ -1212,6 +1211,12 @@ html.dark .bn-step-image-preview__content {
1212
1211
  border-radius: 999px;
1213
1212
  width: 1.35rem;
1214
1213
  height: 1.35rem;
1214
+ display: flex;
1215
+ align-items: center;
1216
+ justify-content: center;
1217
+ padding: 0;
1218
+ font-size: 0.85rem;
1219
+ line-height: 1;
1215
1220
  background: var(--overlay-dark);
1216
1221
  color: var(--color-white);
1217
1222
  cursor: pointer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
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
@@ -21,6 +21,7 @@ import { customSchema, type CustomEditor } from "./editor/customSchema";
21
21
  import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
22
22
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
23
23
  import { setImageUploadHandler } from "./editor/stepImageUpload";
24
+ import { canInsertStepOrSnippet } from "./editor/blocks/step";
24
25
  import "./App.css";
25
26
 
26
27
  const focusStepField = (
@@ -333,7 +334,11 @@ function CustomSlashMenu() {
333
334
  },
334
335
  };
335
336
 
336
- return filterSuggestionItems([...defaultItems, stepItem, snippetItem], query);
337
+ const cursorBlock = editor.getTextCursorPosition()?.block;
338
+ const canInsert = cursorBlock ? canInsertStepOrSnippet(editor, cursorBlock.id) : false;
339
+ const customItems = canInsert ? [stepItem, snippetItem] : [];
340
+
341
+ return filterSuggestionItems([...defaultItems, ...customItems], query);
337
342
  };
338
343
 
339
344
  return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
@@ -519,7 +524,7 @@ function App() {
519
524
  const fallbackBlock = documentBlocks[documentBlocks.length - 1];
520
525
  const referenceId = selectedBlock?.id ?? fallbackBlock?.id;
521
526
 
522
- if (!referenceId) {
527
+ if (!referenceId || !canInsertStepOrSnippet(editor, referenceId)) {
523
528
  return;
524
529
  }
525
530
 
@@ -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
  );
@@ -53,6 +53,40 @@ export const isEmptyParagraph = (b: any): boolean =>
53
53
  b.content.length === 0 ||
54
54
  b.content.every((n: any) => n.type === "text" && !n.text?.trim()));
55
55
 
56
+ /**
57
+ * Check whether a step or snippet can be inserted at / after the given block.
58
+ * Returns true only when walking backwards from `referenceBlockId` (skipping
59
+ * other steps, snippets, and empty paragraphs) reaches a heading whose text
60
+ * is "steps".
61
+ */
62
+ export function canInsertStepOrSnippet(
63
+ editor: { document: any[] },
64
+ referenceBlockId: string,
65
+ ): boolean {
66
+ const allBlocks = editor.document;
67
+ const blockIndex = allBlocks.findIndex((b: any) => b.id === referenceBlockId);
68
+ if (blockIndex < 0) return false;
69
+
70
+ for (let i = blockIndex; i >= 0; i--) {
71
+ const b = allBlocks[i];
72
+ if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
73
+ continue;
74
+ }
75
+ if (b.type === "heading") {
76
+ const text = (Array.isArray(b.content) ? b.content : [])
77
+ .filter((n: any) => n.type === "text")
78
+ .map((n: any) => n.text ?? "")
79
+ .join("")
80
+ .trim()
81
+ .toLowerCase()
82
+ .replace(/[:\-–—]$/, "");
83
+ return text === "steps";
84
+ }
85
+ return false;
86
+ }
87
+ return false;
88
+ }
89
+
56
90
  export const stepBlock = createReactBlockSpec(
57
91
  {
58
92
  type: "testStep",
@@ -1173,36 +1173,17 @@ describe("markdownToBlocks", () => {
1173
1173
 
1174
1174
  const blocks = markdownToBlocks(markdown);
1175
1175
 
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
- );
1176
+ // Find the image blocks
1177
+ const imageBlocks = blocks.filter(block => block.type === "image");
1186
1178
 
1187
- // Should have two paragraph blocks with images
1179
+ // Should have two image blocks
1188
1180
  expect(imageBlocks.length).toBe(2);
1189
1181
 
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: {} }]);
1182
+ // Check image block props
1183
+ expect((imageBlocks[0].props as any).url).toBe("/attachments/se2n8jaGon.png");
1184
+ expect((imageBlocks[0].props as any).caption).toBe("logs");
1185
+ expect((imageBlocks[1].props as any).url).toBe("/attachments/p5DgklVeMg.png");
1186
+ expect((imageBlocks[1].props as any).caption).toBe("");
1206
1187
 
1207
1188
  // Test round-trip conversion
1208
1189
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
@@ -1311,47 +1292,19 @@ describe("markdownToBlocks", () => {
1311
1292
 
1312
1293
  const blocks = markdownToBlocks(markdown);
1313
1294
 
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
- );
1295
+ // Find image blocks
1296
+ const imageBlocks = blocks.filter(block => block.type === "image");
1321
1297
 
1322
- // Should have exactly 2 image paragraphs
1323
- expect(imageParagraphs).toHaveLength(2);
1298
+ // Should have exactly 2 image blocks
1299
+ expect(imageBlocks).toHaveLength(2);
1324
1300
 
1325
1301
  // 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
- });
1302
+ expect((imageBlocks[0].props as any).url).toBe("/attachments/se2n8jaGon.png");
1303
+ expect((imageBlocks[0].props as any).caption).toBe("logs");
1336
1304
 
1337
1305
  // 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);
1306
+ expect((imageBlocks[1].props as any).url).toBe("/attachments/p5DgklVeMg.png");
1307
+ expect((imageBlocks[1].props as any).caption).toBe("");
1355
1308
 
1356
1309
  // Test round-trip conversion
1357
1310
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
@@ -1374,18 +1327,9 @@ describe("markdownToBlocks", () => {
1374
1327
 
1375
1328
  const blocks = markdownToBlocks(markdown);
1376
1329
 
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
- );
1330
+ // Should have exactly 2 image blocks
1331
+ const imageBlocks = blocks.filter(block => block.type === "image");
1332
+ expect(imageBlocks).toHaveLength(2);
1389
1333
 
1390
1334
  // Check for malformed image blocks (paragraphs with just "!" but no link)
1391
1335
  const malformedBlocks = blocks.filter(block =>
@@ -1395,9 +1339,6 @@ describe("markdownToBlocks", () => {
1395
1339
  block.content.some((item: any) => item.type === "text" && item.text === "!") &&
1396
1340
  !block.content.some((item: any) => item.type === "link")
1397
1341
  );
1398
-
1399
- expect(imageParagraphs).toHaveLength(2);
1400
- expect(emptyParagraphs).toHaveLength(0);
1401
1342
  expect(malformedBlocks).toHaveLength(0);
1402
1343
 
1403
1344
  // Test round-trip conversion
@@ -1489,8 +1430,8 @@ describe("markdownToBlocks", () => {
1489
1430
  // Apply the fixMalformedImageBlocks function
1490
1431
  const fixedBlocks = fixMalformedImageBlocks(malformedBlocks);
1491
1432
 
1492
- // Should have removed the malformed image blocks (both the "!" only block and the empty block)
1493
- expect(fixedBlocks.length).toBe(2);
1433
+ // Should have removed the malformed "!" only block but kept the empty paragraph and image block
1434
+ expect(fixedBlocks.length).toBe(3);
1494
1435
  expect(fixedBlocks[0].type).toBe("heading");
1495
1436
  expect(fixedBlocks[1].type).toBe("paragraph");
1496
1437
  expect(fixedBlocks[1].content).toContainEqual(
@@ -1499,6 +1440,8 @@ describe("markdownToBlocks", () => {
1499
1440
  expect(fixedBlocks[1].content).toContainEqual(
1500
1441
  { type: "link", href: "/attachments/se2n8jaGon.png", content: [{ type: "text", text: "logs", styles: {} }] }
1501
1442
  );
1443
+ expect(fixedBlocks[2].type).toBe("paragraph");
1444
+ expect(fixedBlocks[2].content).toHaveLength(0);
1502
1445
  });
1503
1446
 
1504
1447
  it("reproduces the exact Unsplash URL issue", () => {
@@ -1525,14 +1468,9 @@ describe("markdownToBlocks", () => {
1525
1468
  // Should have at least 3 blocks
1526
1469
  expect(blocks.length).toBeGreaterThanOrEqual(3);
1527
1470
 
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);
1471
+ // Should have image blocks
1472
+ const imageBlocks = blocks.filter(b => b.type === "image");
1473
+ expect(imageBlocks.length).toBe(2);
1536
1474
 
1537
1475
  // Test round-trip conversion - check that we get the images back
1538
1476
  const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
@@ -335,6 +335,14 @@ function serializeBlock(
335
335
  lines.push(...serializeChildren(block, ctx));
336
336
  return lines;
337
337
  }
338
+ case "image": {
339
+ const url = (block.props as any).url || "";
340
+ const caption = (block.props as any).caption || "";
341
+ if (url) {
342
+ lines.push(`![${caption}](${url})`);
343
+ }
344
+ return flattenWithBlankLine(lines, true);
345
+ }
338
346
  case "testStep":
339
347
  case "snippet": {
340
348
  const isSnippet = block.type === "snippet";
@@ -569,9 +577,9 @@ function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): str
569
577
  export function blocksToMarkdown(blocks: CustomEditorBlock[]): string {
570
578
  const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
571
579
  const cleaned = lines
572
- // Collapse more than two blank lines into just two for readability.
580
+ // Collapse excessive blank lines but preserve one extra for empty paragraphs.
573
581
  .join("\n")
574
- .replace(/\n{3,}/g, "\n\n")
582
+ .replace(/\n{4,}/g, "\n\n\n")
575
583
  .trimEnd();
576
584
 
577
585
  return cleaned;
@@ -1300,15 +1308,6 @@ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPar
1300
1308
  const current = blocks[i];
1301
1309
  const next = blocks[i + 1];
1302
1310
 
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
1311
  // Check if current is a paragraph with just "!" - this is definitely a malformed image
1313
1312
  if (
1314
1313
  current.type === "paragraph" &&
@@ -1371,6 +1370,16 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1371
1370
  const line = lines[index];
1372
1371
  if (!line.trim()) {
1373
1372
  index += 1;
1373
+ // Count consecutive blank lines
1374
+ let blankCount = 1;
1375
+ while (index < lines.length && !lines[index].trim()) {
1376
+ blankCount++;
1377
+ index++;
1378
+ }
1379
+ // Create empty paragraph for each extra blank line beyond the first
1380
+ for (let i = 1; i < blankCount; i++) {
1381
+ blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1382
+ }
1374
1383
  continue;
1375
1384
  }
1376
1385
 
@@ -1447,12 +1456,36 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1447
1456
  continue;
1448
1457
  }
1449
1458
 
1459
+ const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
1460
+ if (imageMatch) {
1461
+ blocks.push({
1462
+ type: "image",
1463
+ props: {
1464
+ url: imageMatch[2],
1465
+ caption: imageMatch[1] || "",
1466
+ name: "",
1467
+ },
1468
+ children: [],
1469
+ } as CustomPartialBlock);
1470
+ index += 1;
1471
+ continue;
1472
+ }
1473
+
1450
1474
  const paragraph = parseParagraph(lines, index);
1451
1475
  blocks.push(paragraph.block);
1452
1476
  index = paragraph.nextIndex;
1453
1477
  }
1454
1478
 
1455
- return fixMalformedImageBlocks(blocks);
1479
+ // Insert empty paragraphs between consecutive headings so users can type between them
1480
+ const result: CustomPartialBlock[] = [];
1481
+ for (let i = 0; i < blocks.length; i++) {
1482
+ result.push(blocks[i]);
1483
+ if (blocks[i].type === "heading" && blocks[i + 1]?.type === "heading") {
1484
+ result.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1485
+ }
1486
+ }
1487
+
1488
+ return fixMalformedImageBlocks(result);
1456
1489
  }
1457
1490
 
1458
1491
  function splitTableRow(line: string): string[] {
@@ -33,26 +33,31 @@ type SnippetInput = SnippetSuggestion[] | SnippetJsonApiDocument | SnippetJsonAp
33
33
 
34
34
  let globalFetcher: SnippetSuggestionsFetcher | null = null;
35
35
  let cachedSuggestions: SnippetSuggestion[] = [];
36
+ let inflightPromise: Promise<SnippetSuggestion[]> | null = null;
36
37
 
37
38
  export function setSnippetFetcher(fetcher: SnippetSuggestionsFetcher | null) {
38
39
  globalFetcher = fetcher;
39
40
  cachedSuggestions = [];
41
+ inflightPromise = null;
40
42
  }
41
43
 
42
44
  export function useSnippetAutocomplete(): SnippetSuggestion[] {
43
45
  const [suggestions, setSuggestions] = useState<SnippetSuggestion[]>(() => {
44
- if (cachedSuggestions.length > 0) {
45
- return cachedSuggestions;
46
- }
47
- if (globalFetcher) {
48
- const result = globalFetcher();
49
- if (!result || typeof (result as Promise<unknown>).then !== "function") {
50
- const normalized = normalizeSnippetSuggestions(result as SnippetInput);
51
- cachedSuggestions = normalized;
52
- return normalized;
46
+ if (cachedSuggestions.length > 0) return cachedSuggestions;
47
+ if (!globalFetcher) return [];
48
+ const result = globalFetcher();
49
+ if (result && typeof (result as Promise<unknown>).then === "function") {
50
+ if (!inflightPromise) {
51
+ inflightPromise = (result as Promise<SnippetInput>)
52
+ .then((r) => normalizeSnippetSuggestions(r))
53
+ .then((items) => { cachedSuggestions = items; inflightPromise = null; return items; })
54
+ .catch((error) => { inflightPromise = null; console.error("Failed to fetch snippet suggestions", error); return [] as SnippetSuggestion[]; });
53
55
  }
56
+ return [];
54
57
  }
55
- return [];
58
+ const normalized = normalizeSnippetSuggestions(result as SnippetInput);
59
+ cachedSuggestions = normalized;
60
+ return normalized;
56
61
  });
57
62
 
58
63
  useEffect(() => {
@@ -64,14 +69,23 @@ export function useSnippetAutocomplete(): SnippetSuggestion[] {
64
69
  }
65
70
 
66
71
  let cancelled = false;
67
- Promise.resolve(globalFetcher())
68
- .then((result) => normalizeSnippetSuggestions(result))
69
- .then((items) => {
70
- if (cancelled) return;
71
- cachedSuggestions = items;
72
- setSuggestions(items);
73
- })
74
- .catch((error) => console.error("Failed to fetch snippet suggestions", error));
72
+ if (!inflightPromise) {
73
+ inflightPromise = Promise.resolve(globalFetcher())
74
+ .then((result) => normalizeSnippetSuggestions(result))
75
+ .then((items) => {
76
+ cachedSuggestions = items;
77
+ inflightPromise = null;
78
+ return items;
79
+ })
80
+ .catch((error) => {
81
+ inflightPromise = null;
82
+ console.error("Failed to fetch snippet suggestions", error);
83
+ return [] as SnippetSuggestion[];
84
+ });
85
+ }
86
+ inflightPromise.then((items) => {
87
+ if (!cancelled) setSuggestions(items);
88
+ });
75
89
 
76
90
  return () => {
77
91
  cancelled = true;
@@ -39,26 +39,31 @@ type StepInput = StepSuggestion[] | StepJsonApiDocument | StepJsonApiResource[]
39
39
 
40
40
  let globalFetcher: StepSuggestionsFetcher | null = null;
41
41
  let cachedSuggestions: StepSuggestion[] = [];
42
+ let inflightPromise: Promise<StepSuggestion[]> | null = null;
42
43
 
43
44
  export function setStepsFetcher(fetcher: StepSuggestionsFetcher | null) {
44
45
  globalFetcher = fetcher;
45
46
  cachedSuggestions = [];
47
+ inflightPromise = null;
46
48
  }
47
49
 
48
50
  export function useStepAutocomplete(): StepSuggestion[] {
49
51
  const [suggestions, setSuggestions] = useState<StepSuggestion[]>(() => {
50
- if (cachedSuggestions.length > 0) {
51
- return cachedSuggestions;
52
- }
53
- if (globalFetcher) {
54
- const result = globalFetcher();
55
- if (!result || typeof (result as Promise<unknown>).then !== "function") {
56
- const normalized = normalizeStepSuggestions(result as StepInput);
57
- cachedSuggestions = normalized;
58
- return normalized;
52
+ if (cachedSuggestions.length > 0) return cachedSuggestions;
53
+ if (!globalFetcher) return [];
54
+ const result = globalFetcher();
55
+ if (result && typeof (result as Promise<unknown>).then === "function") {
56
+ if (!inflightPromise) {
57
+ inflightPromise = (result as Promise<StepInput>)
58
+ .then((r) => normalizeStepSuggestions(r))
59
+ .then((items) => { cachedSuggestions = items; inflightPromise = null; return items; })
60
+ .catch((error) => { inflightPromise = null; console.error("Failed to fetch step suggestions", error); return [] as StepSuggestion[]; });
59
61
  }
62
+ return [];
60
63
  }
61
- return [];
64
+ const normalized = normalizeStepSuggestions(result as StepInput);
65
+ cachedSuggestions = normalized;
66
+ return normalized;
62
67
  });
63
68
 
64
69
  useEffect(() => {
@@ -70,14 +75,23 @@ export function useStepAutocomplete(): StepSuggestion[] {
70
75
  }
71
76
 
72
77
  let cancelled = false;
73
- Promise.resolve(globalFetcher())
74
- .then((result) => normalizeStepSuggestions(result))
75
- .then((items) => {
76
- if (cancelled) return;
77
- cachedSuggestions = items;
78
- setSuggestions(items);
79
- })
80
- .catch((error) => console.error("Failed to fetch step suggestions", error));
78
+ if (!inflightPromise) {
79
+ inflightPromise = Promise.resolve(globalFetcher())
80
+ .then((result) => normalizeStepSuggestions(result))
81
+ .then((items) => {
82
+ cachedSuggestions = items;
83
+ inflightPromise = null;
84
+ return items;
85
+ })
86
+ .catch((error) => {
87
+ inflightPromise = null;
88
+ console.error("Failed to fetch step suggestions", error);
89
+ return [] as StepSuggestion[];
90
+ });
91
+ }
92
+ inflightPromise.then((items) => {
93
+ if (!cancelled) setSuggestions(items);
94
+ });
81
95
 
82
96
  return () => {
83
97
  cancelled = true;
@@ -515,7 +515,6 @@ html.dark .bn-step-image-preview__content {
515
515
  display: flex;
516
516
  flex-direction: column;
517
517
  gap: 4px;
518
- overflow: hidden;
519
518
  }
520
519
 
521
520
  .bn-teststep__horizontal-col .bn-step-field__top {
@@ -1212,6 +1211,12 @@ html.dark .bn-step-image-preview__content {
1212
1211
  border-radius: 999px;
1213
1212
  width: 1.35rem;
1214
1213
  height: 1.35rem;
1214
+ display: flex;
1215
+ align-items: center;
1216
+ justify-content: center;
1217
+ padding: 0;
1218
+ font-size: 0.85rem;
1219
+ line-height: 1;
1215
1220
  background: var(--overlay-dark);
1216
1221
  color: var(--color-white);
1217
1222
  cursor: pointer;
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 } from "./editor/blocks/step";
8
8
  export { snippetBlock } from "./editor/blocks/snippet";
9
9
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
10
10