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.
- package/package/editor/blocks/snippet.js +6 -6
- package/package/editor/blocks/step.d.ts +6 -0
- package/package/editor/blocks/step.js +10 -2
- package/package/editor/customMarkdownConverter.js +46 -11
- package/package/index.d.ts +1 -1
- package/package/index.js +1 -1
- package/package.json +1 -1
- package/src/editor/blocks/snippet.tsx +15 -10
- package/src/editor/blocks/step.tsx +11 -2
- package/src/editor/customMarkdownConverter.test.ts +60 -88
- package/src/editor/customMarkdownConverter.ts +47 -13
- 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,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
|
|
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
|
|
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(``);
|
|
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
|
|
467
|
+
// Collapse excessive blank lines but preserve one extra for empty paragraphs.
|
|
459
468
|
.join("\n")
|
|
460
|
-
.replace(/\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(/[:\-–—]$/, "")
|
|
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
|
-
|
|
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();
|
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, 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
|
@@ -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
|
);
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
1213
|
+
// Should have two image blocks
|
|
1188
1214
|
expect(imageBlocks.length).toBe(2);
|
|
1189
1215
|
|
|
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: {} }]);
|
|
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
|
|
1315
|
-
const
|
|
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
|
|
1323
|
-
expect(
|
|
1332
|
+
// Should have exactly 2 image blocks
|
|
1333
|
+
expect(imageBlocks).toHaveLength(2);
|
|
1324
1334
|
|
|
1325
1335
|
// 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
|
-
});
|
|
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(
|
|
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);
|
|
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
|
|
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
|
-
);
|
|
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
|
|
1493
|
-
expect(fixedBlocks.length).toBe(
|
|
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
|
|
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);
|
|
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(``);
|
|
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
|
|
581
|
+
// Collapse excessive blank lines but preserve one extra for empty paragraphs.
|
|
573
582
|
.join("\n")
|
|
574
|
-
.replace(/\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(/[:\-–—]$/, "")
|
|
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
|
-
|
|
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
|
|