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.
- package/package/editor/blocks/snippet.js +6 -6
- package/package/editor/blocks/step.d.ts +9 -0
- package/package/editor/blocks/step.js +30 -0
- package/package/editor/customMarkdownConverter.js +44 -10
- package/package/editor/snippetAutocomplete.js +34 -18
- package/package/editor/stepAutocomplete.js +34 -18
- package/package/index.d.ts +1 -1
- package/package/index.js +1 -1
- package/package/styles.css +6 -1
- package/package.json +1 -1
- package/src/App.tsx +7 -2
- package/src/editor/blocks/snippet.tsx +15 -10
- package/src/editor/blocks/step.tsx +34 -0
- package/src/editor/customMarkdownConverter.test.ts +26 -88
- package/src/editor/customMarkdownConverter.ts +45 -12
- package/src/editor/snippetAutocomplete.ts +32 -18
- package/src/editor/stepAutocomplete.tsx +32 -18
- package/src/editor/styles.css +6 -1
- package/src/index.ts +1 -1
|
@@ -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 &&
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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, "&")
|
|
116
|
+
.replace(/</g, "<")
|
|
117
|
+
.replace(/>/g, ">"),
|
|
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(``);
|
|
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
|
|
466
|
+
// Collapse excessive blank lines but preserve one extra for empty paragraphs.
|
|
459
467
|
.join("\n")
|
|
460
|
-
.replace(/\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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
};
|
package/package/index.d.ts
CHANGED
|
@@ -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";
|
package/package/styles.css
CHANGED
|
@@ -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
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
|
-
|
|
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 &&
|
|
204
|
-
<div
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
{isSnippetSelected && (
|
|
204
|
+
<div className="bn-snippet__content">
|
|
205
|
+
{snippetData ? (
|
|
206
|
+
<span
|
|
207
|
+
dangerouslySetInnerHTML={{
|
|
208
|
+
__html: snippetData
|
|
209
|
+
.replace(/&/g, "&")
|
|
210
|
+
.replace(/</g, "<")
|
|
211
|
+
.replace(/>/g, ">"),
|
|
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
|
|
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
|
|
1179
|
+
// Should have two image blocks
|
|
1188
1180
|
expect(imageBlocks.length).toBe(2);
|
|
1189
1181
|
|
|
1190
|
-
// Check
|
|
1191
|
-
|
|
1192
|
-
imageBlocks.
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
|
1315
|
-
const
|
|
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
|
|
1323
|
-
expect(
|
|
1298
|
+
// Should have exactly 2 image blocks
|
|
1299
|
+
expect(imageBlocks).toHaveLength(2);
|
|
1324
1300
|
|
|
1325
1301
|
// First image with alt text
|
|
1326
|
-
expect(
|
|
1327
|
-
|
|
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(
|
|
1339
|
-
|
|
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
|
|
1378
|
-
const
|
|
1379
|
-
|
|
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
|
|
1493
|
-
expect(fixedBlocks.length).toBe(
|
|
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
|
|
1529
|
-
const imageBlocks = blocks.filter(b =>
|
|
1530
|
-
|
|
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(``);
|
|
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
|
|
580
|
+
// Collapse excessive blank lines but preserve one extra for empty paragraphs.
|
|
573
581
|
.join("\n")
|
|
574
|
-
.replace(/\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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
package/src/editor/styles.css
CHANGED
|
@@ -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
|
|