testomatio-editor-blocks 0.4.51 → 0.4.53
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/step.js +1 -1
- package/package/editor/blocks/stepField.d.ts +12 -0
- package/package/editor/blocks/stepField.js +28 -7
- package/package/editor/blocks/useAutoResize.js +36 -4
- package/package/editor/customMarkdownConverter.d.ts +1 -2
- package/package/editor/customMarkdownConverter.js +66 -50
- package/package/styles.css +6 -0
- package/package.json +1 -1
- package/src/App.tsx +23 -1
- package/src/editor/blocks/step.tsx +1 -0
- package/src/editor/blocks/stepField.tsx +39 -8
- package/src/editor/blocks/stepFieldFormatting.test.ts +62 -1
- package/src/editor/blocks/useAutoResize.ts +37 -4
- package/src/editor/customMarkdownConverter.test.ts +187 -11
- package/src/editor/customMarkdownConverter.ts +72 -52
- package/src/editor/styles.css +6 -0
|
@@ -400,7 +400,7 @@ export const stepBlock = createReactBlockSpec({
|
|
|
400
400
|
writeExpectedCollapsedPreference(true);
|
|
401
401
|
editor.updateBlock(block.id, { props: { expectedResult: "" } });
|
|
402
402
|
}, [editor, block.id]);
|
|
403
|
-
const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
|
|
403
|
+
const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, tabIndex: -1, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
|
|
404
404
|
if (!effectiveVertical) {
|
|
405
405
|
return (_jsx(StepHorizontalView, { ref: containerRef, blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: viewToggleButton }));
|
|
406
406
|
}
|
|
@@ -40,6 +40,18 @@ export type FormattingMeta = {
|
|
|
40
40
|
end: number;
|
|
41
41
|
type: "bold" | "italic" | "code";
|
|
42
42
|
};
|
|
43
|
+
type FormatType = "bold" | "italic" | "code";
|
|
44
|
+
/**
|
|
45
|
+
* Remove formatting and link entries that conflict with applying `fmtType`
|
|
46
|
+
* over the half-open range [start, end). Exclusion rules:
|
|
47
|
+
* - code: exclusive with everything — drops any overlapping formatting and links
|
|
48
|
+
* - bold/italic: coexist with each other, but exclusive with code and links
|
|
49
|
+
* — drops overlapping same-type, overlapping code, and overlapping links
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyInlineExclusion(formatting: FormattingMeta[], links: LinkMeta[], start: number, end: number, fmtType: FormatType): {
|
|
52
|
+
formatting: FormattingMeta[];
|
|
53
|
+
links: LinkMeta[];
|
|
54
|
+
};
|
|
43
55
|
export declare function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string;
|
|
44
56
|
export declare function StepField({ label, showLabel, labelToggle, labelAction, placeholder, value, onChange, autoFocus, focusSignal, multiline, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, onFieldFocus, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
45
57
|
export {};
|
|
@@ -18,6 +18,27 @@ const markdownParser = OverType.MarkdownParser;
|
|
|
18
18
|
function ImageUploadIcon() {
|
|
19
19
|
return (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M12.667 2C13.0335 2.00008 13.3474 2.13057 13.6084 2.3916C13.8694 2.65264 13.9999 2.96648 14 3.33301V12.667C13.9999 13.0335 13.8694 13.3474 13.6084 13.6084C13.3474 13.8694 13.0335 13.9999 12.667 14H3.33301C2.96648 13.9999 2.65264 13.8694 2.3916 13.6084C2.13057 13.3474 2.00008 13.0335 2 12.667V3.33301C2.00008 2.96648 2.13057 2.65264 2.3916 2.3916C2.65264 2.13057 2.96648 2.00008 3.33301 2H12.667ZM3.33301 12.667H12.667V3.33301H3.33301V12.667ZM12 11.333H4L6 8.66699L7.5 10.667L9.5 8L12 11.333ZM5.66699 4.66699C5.94455 4.66707 6.18066 4.76375 6.375 4.95801C6.56944 5.15245 6.66699 5.38921 6.66699 5.66699C6.66692 5.94463 6.56937 6.18063 6.375 6.375C6.18063 6.56937 5.94463 6.66692 5.66699 6.66699C5.38921 6.66699 5.15245 6.56944 4.95801 6.375C4.76375 6.18066 4.66707 5.94455 4.66699 5.66699C4.66699 5.38921 4.76356 5.15245 4.95801 4.95801C5.15245 4.76356 5.38921 4.66699 5.66699 4.66699Z", fill: "currentColor" }) }));
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Remove formatting and link entries that conflict with applying `fmtType`
|
|
23
|
+
* over the half-open range [start, end). Exclusion rules:
|
|
24
|
+
* - code: exclusive with everything — drops any overlapping formatting and links
|
|
25
|
+
* - bold/italic: coexist with each other, but exclusive with code and links
|
|
26
|
+
* — drops overlapping same-type, overlapping code, and overlapping links
|
|
27
|
+
*/
|
|
28
|
+
export function applyInlineExclusion(formatting, links, start, end, fmtType) {
|
|
29
|
+
const overlaps = (a) => !(a.start >= end || a.end <= start);
|
|
30
|
+
const nextFormatting = formatting.filter((f) => {
|
|
31
|
+
if (!overlaps(f))
|
|
32
|
+
return true;
|
|
33
|
+
if (fmtType === "code")
|
|
34
|
+
return false;
|
|
35
|
+
if (f.type === "code")
|
|
36
|
+
return false;
|
|
37
|
+
return f.type !== fmtType;
|
|
38
|
+
});
|
|
39
|
+
const nextLinks = links.filter((l) => !overlaps(l));
|
|
40
|
+
return { formatting: nextFormatting, links: nextLinks };
|
|
41
|
+
}
|
|
21
42
|
const UNDO_STACK_LIMIT = 100;
|
|
22
43
|
function getActiveFormats(formatting, selStart, selEnd) {
|
|
23
44
|
const active = new Set();
|
|
@@ -965,12 +986,9 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
|
|
|
965
986
|
formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
|
|
966
987
|
}
|
|
967
988
|
else if (start !== end) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
formattingRef.current = formattingRef.current.filter((f) => f.start >= end || f.end <= start || (fmtType !== "code" && f.type !== fmtType));
|
|
972
|
-
// Add formatting for selection
|
|
973
|
-
formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
|
|
989
|
+
const cleaned = applyInlineExclusion(formattingRef.current, linksRef.current, start, end, fmtType);
|
|
990
|
+
formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }];
|
|
991
|
+
linksRef.current = cleaned.links;
|
|
974
992
|
}
|
|
975
993
|
else {
|
|
976
994
|
// No selection — nothing to format
|
|
@@ -1071,7 +1089,10 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
|
|
|
1071
1089
|
const adjustedLinks = adjustLinksForEdit(linksRef.current.filter((l) => !(l.start < sel.end && l.end > sel.start)), sel.start, delta);
|
|
1072
1090
|
const newLink = { start: sel.start, end: sel.start + linkText.length, url };
|
|
1073
1091
|
linksRef.current = [...adjustedLinks, newLink];
|
|
1074
|
-
|
|
1092
|
+
// Links are exclusive with bold/italic/code: strip any formatting that
|
|
1093
|
+
// overlaps the original selection before shifting positions.
|
|
1094
|
+
const keptFormatting = formattingRef.current.filter((f) => f.start >= sel.end || f.end <= sel.start);
|
|
1095
|
+
formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta);
|
|
1075
1096
|
prevTextRef.current = nextValue;
|
|
1076
1097
|
isSyncingRef.current = true;
|
|
1077
1098
|
instance.setValue(nextValue);
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|
|
2
2
|
export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRows = 12 }) {
|
|
3
3
|
const frameRef = useRef(0);
|
|
4
4
|
useEffect(() => {
|
|
5
|
+
var _a;
|
|
5
6
|
if (!textarea || !multiline) {
|
|
6
7
|
return;
|
|
7
8
|
}
|
|
@@ -14,19 +15,50 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
|
|
|
14
15
|
textarea.style.height = `${clampedHeight}px`;
|
|
15
16
|
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
16
17
|
};
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const mutationObserver = new MutationObserver(resize);
|
|
19
|
+
mutationObserver.observe(textarea, { childList: true, characterData: true, subtree: true });
|
|
20
|
+
const resizeObserver = new ResizeObserver(resize);
|
|
21
|
+
resizeObserver.observe(textarea);
|
|
19
22
|
const handleInput = () => {
|
|
20
23
|
var _a;
|
|
21
24
|
cancelAnimationFrame((_a = frameRef.current) !== null && _a !== void 0 ? _a : 0);
|
|
22
25
|
frameRef.current = requestAnimationFrame(resize);
|
|
23
26
|
};
|
|
24
27
|
textarea.addEventListener("input", handleInput);
|
|
25
|
-
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
const initialFrame = requestAnimationFrame(() => {
|
|
30
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
31
|
+
if (!cancelled)
|
|
32
|
+
resize();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// Re-run resize once the textarea is actually laid out. During drag-drop
|
|
36
|
+
// remounts the element can be briefly detached, so the initial RAF resize
|
|
37
|
+
// sees scrollHeight === 0 and clamps to minRows.
|
|
38
|
+
const intersectionObserver = new IntersectionObserver((entries) => {
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.isIntersecting && !cancelled) {
|
|
41
|
+
resize();
|
|
42
|
+
intersectionObserver.disconnect();
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
intersectionObserver.observe(textarea);
|
|
48
|
+
if (typeof document !== "undefined" && ((_a = document.fonts) === null || _a === void 0 ? void 0 : _a.ready)) {
|
|
49
|
+
document.fonts.ready.then(() => {
|
|
50
|
+
if (!cancelled)
|
|
51
|
+
resize();
|
|
52
|
+
}).catch(() => { });
|
|
53
|
+
}
|
|
26
54
|
return () => {
|
|
27
55
|
var _a;
|
|
28
|
-
|
|
56
|
+
cancelled = true;
|
|
57
|
+
mutationObserver.disconnect();
|
|
58
|
+
resizeObserver.disconnect();
|
|
59
|
+
intersectionObserver.disconnect();
|
|
29
60
|
textarea.removeEventListener("input", handleInput);
|
|
61
|
+
cancelAnimationFrame(initialFrame);
|
|
30
62
|
cancelAnimationFrame((_a = frameRef.current) !== null && _a !== void 0 ? _a : 0);
|
|
31
63
|
};
|
|
32
64
|
}, [textarea, multiline, minRows, maxRows]);
|
|
@@ -6,8 +6,7 @@ export type CustomPartialBlock = PartialBlock<Schema["blockSchema"], Schema["inl
|
|
|
6
6
|
export declare function blocksToMarkdown(blocks: CustomEditorBlock[]): string;
|
|
7
7
|
export declare function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPartialBlock[];
|
|
8
8
|
export interface MarkdownToBlocksOptions {
|
|
9
|
-
/** When true, every blank line produces an empty paragraph block. */
|
|
10
9
|
preserveBlankLines?: boolean;
|
|
11
10
|
}
|
|
12
|
-
export declare function markdownToBlocks(markdown: string,
|
|
11
|
+
export declare function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[];
|
|
13
12
|
export {};
|
|
@@ -23,6 +23,7 @@ const headingPrefixes = {
|
|
|
23
23
|
6: "######",
|
|
24
24
|
};
|
|
25
25
|
const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
|
|
26
|
+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
|
|
26
27
|
const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
|
|
27
28
|
const HTML_UNDERLINE_REGEX = /<\/?u>/g;
|
|
28
29
|
const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
|
|
@@ -30,7 +31,17 @@ const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]
|
|
|
30
31
|
const STEP_DATA_LINE_REGEX = /^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i;
|
|
31
32
|
const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
|
|
32
33
|
function escapeMarkdown(text) {
|
|
33
|
-
|
|
34
|
+
let result = "";
|
|
35
|
+
let lastIndex = 0;
|
|
36
|
+
HTML_COMMENT_REGEX.lastIndex = 0;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
|
|
39
|
+
result += text.slice(lastIndex, match.index).replace(SPECIAL_CHAR_REGEX, "\\$1");
|
|
40
|
+
result += match[0];
|
|
41
|
+
lastIndex = match.index + match[0].length;
|
|
42
|
+
}
|
|
43
|
+
result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
|
|
44
|
+
return result;
|
|
34
45
|
}
|
|
35
46
|
function stripHtmlWrappers(text) {
|
|
36
47
|
return text
|
|
@@ -206,12 +217,6 @@ function serializeChildren(block, ctx) {
|
|
|
206
217
|
const childCtx = { ...ctx, listDepth: ctx.listDepth + 1 };
|
|
207
218
|
return serializeBlocks(block.children, childCtx);
|
|
208
219
|
}
|
|
209
|
-
function flattenWithBlankLine(lines, appendBlank = false) {
|
|
210
|
-
if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
|
|
211
|
-
return [...lines, ""];
|
|
212
|
-
}
|
|
213
|
-
return lines;
|
|
214
|
-
}
|
|
215
220
|
function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
216
221
|
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
|
|
217
222
|
const lines = [];
|
|
@@ -219,17 +224,21 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
219
224
|
switch (block.type) {
|
|
220
225
|
case "paragraph": {
|
|
221
226
|
const text = inlineToMarkdown(block.content);
|
|
222
|
-
if (text.length
|
|
223
|
-
|
|
227
|
+
if (text.length === 0) {
|
|
228
|
+
// Empty paragraph = one blank line in the output. Under the 1:1
|
|
229
|
+
// block model, this is the only mechanism that produces blank lines
|
|
230
|
+
// between top-level blocks.
|
|
231
|
+
return [""];
|
|
224
232
|
}
|
|
225
|
-
|
|
233
|
+
lines.push(ctx.insideQuote ? `> ${text}` : text);
|
|
234
|
+
return lines;
|
|
226
235
|
}
|
|
227
236
|
case "heading": {
|
|
228
237
|
const level = (_a = block.props.level) !== null && _a !== void 0 ? _a : 1;
|
|
229
238
|
const prefix = (_b = headingPrefixes[level]) !== null && _b !== void 0 ? _b : headingPrefixes[3];
|
|
230
239
|
const text = inlineToMarkdown(block.content);
|
|
231
240
|
lines.push(`${prefix} ${text}`.trimEnd());
|
|
232
|
-
return
|
|
241
|
+
return lines;
|
|
233
242
|
}
|
|
234
243
|
case "quote": {
|
|
235
244
|
const quoteContent = serializeBlocks((_c = block.children) !== null && _c !== void 0 ? _c : [], {
|
|
@@ -244,7 +253,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
244
253
|
lines.push(...quoteText);
|
|
245
254
|
}
|
|
246
255
|
lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
|
|
247
|
-
return
|
|
256
|
+
return lines;
|
|
248
257
|
}
|
|
249
258
|
case "codeBlock": {
|
|
250
259
|
const language = block.props.language || "";
|
|
@@ -255,7 +264,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
255
264
|
lines.push(body);
|
|
256
265
|
}
|
|
257
266
|
lines.push("```");
|
|
258
|
-
return
|
|
267
|
+
return lines;
|
|
259
268
|
}
|
|
260
269
|
case "bulletListItem": {
|
|
261
270
|
const text = inlineToMarkdown(block.content);
|
|
@@ -287,7 +296,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
287
296
|
const size = width ? ` =${width}x*` : "";
|
|
288
297
|
lines.push(``);
|
|
289
298
|
}
|
|
290
|
-
return
|
|
299
|
+
return lines;
|
|
291
300
|
}
|
|
292
301
|
case "file":
|
|
293
302
|
case "video":
|
|
@@ -299,7 +308,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
299
308
|
const displayUrl = caption || resolveFileDisplayUrl(url);
|
|
300
309
|
lines.push(`[](${url})`);
|
|
301
310
|
}
|
|
302
|
-
return
|
|
311
|
+
return lines;
|
|
303
312
|
}
|
|
304
313
|
case "testStep":
|
|
305
314
|
case "snippet": {
|
|
@@ -327,7 +336,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
327
336
|
if (snippetId) {
|
|
328
337
|
lines.push(`<!-- end snippet #${snippetId} -->`);
|
|
329
338
|
}
|
|
330
|
-
return
|
|
339
|
+
return lines;
|
|
331
340
|
}
|
|
332
341
|
const normalizedTitle = stepTitle
|
|
333
342
|
.split(/\r?\n/)
|
|
@@ -356,7 +365,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
356
365
|
const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
|
|
357
366
|
if (normalizedExpected.length > 0) {
|
|
358
367
|
const expectedLines = normalizedExpected.split(/\r?\n/);
|
|
359
|
-
const label = "*Expected*";
|
|
368
|
+
const label = "*Expected result*";
|
|
360
369
|
expectedLines.forEach((expectedLine, index) => {
|
|
361
370
|
const trimmedLine = expectedLine.trim();
|
|
362
371
|
if (trimmedLine.length === 0) {
|
|
@@ -370,28 +379,25 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
370
379
|
}
|
|
371
380
|
});
|
|
372
381
|
}
|
|
373
|
-
|
|
374
|
-
return lines;
|
|
375
|
-
}
|
|
376
|
-
return flattenWithBlankLine(lines, false);
|
|
382
|
+
return lines;
|
|
377
383
|
}
|
|
378
384
|
case "table": {
|
|
379
385
|
const tableContent = block.content;
|
|
380
386
|
if (!tableContent || tableContent.type !== "tableContent") {
|
|
381
|
-
return
|
|
387
|
+
return lines;
|
|
382
388
|
}
|
|
383
389
|
const rows = Array.isArray(tableContent.rows)
|
|
384
390
|
? tableContent.rows
|
|
385
391
|
: [];
|
|
386
392
|
if (rows.length === 0) {
|
|
387
|
-
return
|
|
393
|
+
return lines;
|
|
388
394
|
}
|
|
389
395
|
const columnCount = rows.reduce((max, row) => {
|
|
390
396
|
const length = Array.isArray(row.cells) ? row.cells.length : 0;
|
|
391
397
|
return Math.max(max, length);
|
|
392
398
|
}, 0);
|
|
393
399
|
if (columnCount === 0) {
|
|
394
|
-
return
|
|
400
|
+
return lines;
|
|
395
401
|
}
|
|
396
402
|
const headerRowCount = rows.length
|
|
397
403
|
? Math.min(rows.length, Math.max((_o = tableContent.headerRows) !== null && _o !== void 0 ? _o : 1, 1))
|
|
@@ -468,7 +474,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
468
474
|
formattedRows.slice(bodyStartIndex).forEach((row) => {
|
|
469
475
|
lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
|
|
470
476
|
});
|
|
471
|
-
return
|
|
477
|
+
return lines;
|
|
472
478
|
}
|
|
473
479
|
}
|
|
474
480
|
const fallbackBlock = block;
|
|
@@ -479,7 +485,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
479
485
|
}
|
|
480
486
|
}
|
|
481
487
|
lines.push(...serializeChildren(fallbackBlock, ctx));
|
|
482
|
-
return
|
|
488
|
+
return lines;
|
|
483
489
|
}
|
|
484
490
|
function serializeBlocks(blocks, ctx) {
|
|
485
491
|
const lines = [];
|
|
@@ -650,7 +656,27 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
|
|
|
650
656
|
const rawLine = lines[index];
|
|
651
657
|
const trimmed = rawLine.trim();
|
|
652
658
|
if (!trimmed) {
|
|
653
|
-
|
|
659
|
+
// Peek at the next non-blank line. If it's another item of this list
|
|
660
|
+
// (same indent level and list type), treat the blank lines as loose-list
|
|
661
|
+
// separators and consume them. Otherwise leave the blank line for the
|
|
662
|
+
// outer loop so it can become an empty paragraph block.
|
|
663
|
+
let lookahead = index + 1;
|
|
664
|
+
while (lookahead < lines.length && !lines[lookahead].trim()) {
|
|
665
|
+
lookahead += 1;
|
|
666
|
+
}
|
|
667
|
+
if (lookahead >= lines.length) {
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
const nextLine = lines[lookahead];
|
|
671
|
+
const nextIndent = countIndent(nextLine);
|
|
672
|
+
if (nextIndent < indentLevel * 2) {
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
const nextType = detectListType(nextLine.trim());
|
|
676
|
+
if (nextType !== listType) {
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
index = lookahead;
|
|
654
680
|
continue;
|
|
655
681
|
}
|
|
656
682
|
let indent = countIndent(rawLine);
|
|
@@ -1172,8 +1198,8 @@ export function fixMalformedImageBlocks(blocks) {
|
|
|
1172
1198
|
}
|
|
1173
1199
|
return result;
|
|
1174
1200
|
}
|
|
1175
|
-
export function markdownToBlocks(markdown,
|
|
1176
|
-
var _a, _b
|
|
1201
|
+
export function markdownToBlocks(markdown, _options) {
|
|
1202
|
+
var _a, _b;
|
|
1177
1203
|
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
1178
1204
|
const lines = normalized.split("\n");
|
|
1179
1205
|
const blocks = [];
|
|
@@ -1182,22 +1208,12 @@ export function markdownToBlocks(markdown, options) {
|
|
|
1182
1208
|
while (index < lines.length) {
|
|
1183
1209
|
const line = lines[index];
|
|
1184
1210
|
if (!line.trim()) {
|
|
1185
|
-
|
|
1211
|
+
// Drop blank lines until we've emitted at least one block, so leading
|
|
1212
|
+
// blanks don't produce a ghost empty paragraph at the top of the doc.
|
|
1213
|
+
if (blocks.length > 0) {
|
|
1186
1214
|
blocks.push({ type: "paragraph", content: [], children: [] });
|
|
1187
|
-
index += 1;
|
|
1188
|
-
continue;
|
|
1189
1215
|
}
|
|
1190
1216
|
index += 1;
|
|
1191
|
-
// Count consecutive blank lines
|
|
1192
|
-
let blankCount = 1;
|
|
1193
|
-
while (index < lines.length && !lines[index].trim()) {
|
|
1194
|
-
blankCount++;
|
|
1195
|
-
index++;
|
|
1196
|
-
}
|
|
1197
|
-
// Create empty paragraph for each extra blank line beyond the first
|
|
1198
|
-
for (let i = 1; i < blankCount; i++) {
|
|
1199
|
-
blocks.push({ type: "paragraph", content: [], children: [] });
|
|
1200
|
-
}
|
|
1201
1217
|
continue;
|
|
1202
1218
|
}
|
|
1203
1219
|
const snippetWrapper = stepsHeadingLevel !== null
|
|
@@ -1293,15 +1309,15 @@ export function markdownToBlocks(markdown, options) {
|
|
|
1293
1309
|
blocks.push(paragraph.block);
|
|
1294
1310
|
index = paragraph.nextIndex;
|
|
1295
1311
|
}
|
|
1296
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1312
|
+
// Drop trailing empty paragraphs so a trailing blank line in the source
|
|
1313
|
+
// doesn't leave a ghost empty block at the end of the document.
|
|
1314
|
+
while (blocks.length > 0 &&
|
|
1315
|
+
blocks[blocks.length - 1].type === "paragraph" &&
|
|
1316
|
+
(!blocks[blocks.length - 1].content ||
|
|
1317
|
+
blocks[blocks.length - 1].content.length === 0)) {
|
|
1318
|
+
blocks.pop();
|
|
1303
1319
|
}
|
|
1304
|
-
return fixMalformedImageBlocks(
|
|
1320
|
+
return fixMalformedImageBlocks(blocks);
|
|
1305
1321
|
}
|
|
1306
1322
|
function splitTableRow(line) {
|
|
1307
1323
|
let value = line.trim();
|
package/package/styles.css
CHANGED
|
@@ -1100,6 +1100,12 @@ html.dark .bn-step-image-preview__content {
|
|
|
1100
1100
|
color: rgb(146, 64, 14) !important;
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
|
+
.bn-step-editor .overtype-wrapper .overtype-preview li.bullet-list .syntax-marker,
|
|
1104
|
+
.bn-step-editor .overtype-wrapper .overtype-preview li.ordered-list .syntax-marker {
|
|
1105
|
+
color: inherit !important;
|
|
1106
|
+
opacity: 1 !important;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1103
1109
|
.bn-step-custom-caret {
|
|
1104
1110
|
display: none;
|
|
1105
1111
|
position: absolute;
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
filterSuggestionItems,
|
|
12
12
|
insertOrUpdateBlock,
|
|
13
13
|
} from "@blocknote/core";
|
|
14
|
+
import { autoPlacement, offset, shift, size } from "@floating-ui/react";
|
|
14
15
|
import {
|
|
15
16
|
blocksToMarkdown,
|
|
16
17
|
markdownToBlocks,
|
|
@@ -354,7 +355,28 @@ function CustomSlashMenu() {
|
|
|
354
355
|
return filterSuggestionItems(items, query);
|
|
355
356
|
};
|
|
356
357
|
|
|
357
|
-
return
|
|
358
|
+
return (
|
|
359
|
+
<SuggestionMenuController
|
|
360
|
+
triggerCharacter="/"
|
|
361
|
+
getItems={getItems}
|
|
362
|
+
floatingOptions={{
|
|
363
|
+
middleware: [
|
|
364
|
+
offset(10),
|
|
365
|
+
autoPlacement({
|
|
366
|
+
allowedPlacements: ["bottom-start", "top-start"],
|
|
367
|
+
}),
|
|
368
|
+
shift(),
|
|
369
|
+
size({
|
|
370
|
+
apply({ availableHeight, elements }) {
|
|
371
|
+
Object.assign(elements.floating.style, {
|
|
372
|
+
maxHeight: `${Math.max(availableHeight - 10, 0)}px`,
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
],
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
);
|
|
358
380
|
}
|
|
359
381
|
|
|
360
382
|
function App() {
|
|
@@ -463,6 +463,7 @@ export const stepBlock = createReactBlockSpec(
|
|
|
463
463
|
aria-label={forceVertical ? "Not enough space for horizontal view" : "Switch step view"}
|
|
464
464
|
onClick={forceVertical ? undefined : handleToggleView}
|
|
465
465
|
aria-disabled={forceVertical}
|
|
466
|
+
tabIndex={-1}
|
|
466
467
|
>
|
|
467
468
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
468
469
|
<mask id="mask-toggle" style={{maskType: "alpha"}} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
|
|
@@ -83,6 +83,31 @@ export type LinkMeta = { start: number; end: number; url: string };
|
|
|
83
83
|
export type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
|
|
84
84
|
type FormatType = "bold" | "italic" | "code";
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Remove formatting and link entries that conflict with applying `fmtType`
|
|
88
|
+
* over the half-open range [start, end). Exclusion rules:
|
|
89
|
+
* - code: exclusive with everything — drops any overlapping formatting and links
|
|
90
|
+
* - bold/italic: coexist with each other, but exclusive with code and links
|
|
91
|
+
* — drops overlapping same-type, overlapping code, and overlapping links
|
|
92
|
+
*/
|
|
93
|
+
export function applyInlineExclusion(
|
|
94
|
+
formatting: FormattingMeta[],
|
|
95
|
+
links: LinkMeta[],
|
|
96
|
+
start: number,
|
|
97
|
+
end: number,
|
|
98
|
+
fmtType: FormatType,
|
|
99
|
+
): { formatting: FormattingMeta[]; links: LinkMeta[] } {
|
|
100
|
+
const overlaps = (a: { start: number; end: number }) => !(a.start >= end || a.end <= start);
|
|
101
|
+
const nextFormatting = formatting.filter((f) => {
|
|
102
|
+
if (!overlaps(f)) return true;
|
|
103
|
+
if (fmtType === "code") return false;
|
|
104
|
+
if (f.type === "code") return false;
|
|
105
|
+
return f.type !== fmtType;
|
|
106
|
+
});
|
|
107
|
+
const nextLinks = links.filter((l) => !overlaps(l));
|
|
108
|
+
return { formatting: nextFormatting, links: nextLinks };
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
type EditorSnapshot = {
|
|
87
112
|
text: string;
|
|
88
113
|
formatting: FormattingMeta[];
|
|
@@ -1188,14 +1213,15 @@ export function StepField({
|
|
|
1188
1213
|
// Remove formatting
|
|
1189
1214
|
formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
|
|
1190
1215
|
} else if (start !== end) {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1216
|
+
const cleaned = applyInlineExclusion(
|
|
1217
|
+
formattingRef.current,
|
|
1218
|
+
linksRef.current,
|
|
1219
|
+
start,
|
|
1220
|
+
end,
|
|
1221
|
+
fmtType,
|
|
1196
1222
|
);
|
|
1197
|
-
|
|
1198
|
-
|
|
1223
|
+
formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }];
|
|
1224
|
+
linksRef.current = cleaned.links;
|
|
1199
1225
|
} else {
|
|
1200
1226
|
// No selection — nothing to format
|
|
1201
1227
|
return;
|
|
@@ -1314,7 +1340,12 @@ export function StepField({
|
|
|
1314
1340
|
);
|
|
1315
1341
|
const newLink: LinkMeta = { start: sel.start, end: sel.start + linkText.length, url };
|
|
1316
1342
|
linksRef.current = [...adjustedLinks, newLink];
|
|
1317
|
-
|
|
1343
|
+
// Links are exclusive with bold/italic/code: strip any formatting that
|
|
1344
|
+
// overlaps the original selection before shifting positions.
|
|
1345
|
+
const keptFormatting = formattingRef.current.filter(
|
|
1346
|
+
(f) => f.start >= sel.end || f.end <= sel.start,
|
|
1347
|
+
);
|
|
1348
|
+
formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta);
|
|
1318
1349
|
prevTextRef.current = nextValue;
|
|
1319
1350
|
|
|
1320
1351
|
isSyncingRef.current = true;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyInlineExclusion,
|
|
4
|
+
buildFullMarkdown,
|
|
5
|
+
type FormattingMeta,
|
|
6
|
+
type LinkMeta,
|
|
7
|
+
} from "./stepField";
|
|
3
8
|
|
|
4
9
|
describe("buildFullMarkdown formatting combinations", () => {
|
|
5
10
|
const noLinks: LinkMeta[] = [];
|
|
@@ -42,3 +47,59 @@ describe("buildFullMarkdown formatting combinations", () => {
|
|
|
42
47
|
expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("`hello`");
|
|
43
48
|
});
|
|
44
49
|
});
|
|
50
|
+
|
|
51
|
+
describe("applyInlineExclusion mutual-exclusion rules", () => {
|
|
52
|
+
it("applying code strips overlapping bold/italic", () => {
|
|
53
|
+
const formatting: FormattingMeta[] = [
|
|
54
|
+
{ start: 0, end: 5, type: "bold" },
|
|
55
|
+
{ start: 0, end: 5, type: "italic" },
|
|
56
|
+
];
|
|
57
|
+
const result = applyInlineExclusion(formatting, [], 0, 5, "code");
|
|
58
|
+
expect(result.formatting).toEqual([]);
|
|
59
|
+
expect(result.links).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applying code strips overlapping links", () => {
|
|
63
|
+
const links: LinkMeta[] = [{ start: 0, end: 5, url: "https://a" }];
|
|
64
|
+
const result = applyInlineExclusion([], links, 0, 5, "code");
|
|
65
|
+
expect(result.links).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("applying bold over a code range strips the code", () => {
|
|
69
|
+
const formatting: FormattingMeta[] = [{ start: 0, end: 5, type: "code" }];
|
|
70
|
+
const result = applyInlineExclusion(formatting, [], 0, 5, "bold");
|
|
71
|
+
expect(result.formatting).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("applying italic over a linked range strips the link", () => {
|
|
75
|
+
const links: LinkMeta[] = [{ start: 0, end: 5, url: "https://a" }];
|
|
76
|
+
const result = applyInlineExclusion([], links, 0, 5, "italic");
|
|
77
|
+
expect(result.links).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("applying bold preserves non-overlapping italic and non-overlapping links", () => {
|
|
81
|
+
const formatting: FormattingMeta[] = [
|
|
82
|
+
{ start: 10, end: 20, type: "italic" },
|
|
83
|
+
];
|
|
84
|
+
const links: LinkMeta[] = [{ start: 30, end: 40, url: "https://a" }];
|
|
85
|
+
const result = applyInlineExclusion(formatting, links, 0, 5, "bold");
|
|
86
|
+
expect(result.formatting).toEqual(formatting);
|
|
87
|
+
expect(result.links).toEqual(links);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("applying bold preserves overlapping italic (bold and italic coexist)", () => {
|
|
91
|
+
const formatting: FormattingMeta[] = [
|
|
92
|
+
{ start: 0, end: 10, type: "italic" },
|
|
93
|
+
];
|
|
94
|
+
const result = applyInlineExclusion(formatting, [], 0, 10, "bold");
|
|
95
|
+
expect(result.formatting).toEqual(formatting);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("applying italic drops overlapping italic (same-type replacement)", () => {
|
|
99
|
+
const formatting: FormattingMeta[] = [
|
|
100
|
+
{ start: 0, end: 10, type: "italic" },
|
|
101
|
+
];
|
|
102
|
+
const result = applyInlineExclusion(formatting, [], 2, 8, "italic");
|
|
103
|
+
expect(result.formatting).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -26,8 +26,11 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
|
|
|
26
26
|
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const mutationObserver = new MutationObserver(resize);
|
|
30
|
+
mutationObserver.observe(textarea, { childList: true, characterData: true, subtree: true });
|
|
31
|
+
|
|
32
|
+
const resizeObserver = new ResizeObserver(resize);
|
|
33
|
+
resizeObserver.observe(textarea);
|
|
31
34
|
|
|
32
35
|
const handleInput = () => {
|
|
33
36
|
cancelAnimationFrame(frameRef.current ?? 0);
|
|
@@ -35,11 +38,41 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
|
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
textarea.addEventListener("input", handleInput);
|
|
38
|
-
|
|
41
|
+
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
const initialFrame = requestAnimationFrame(() => {
|
|
44
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
45
|
+
if (!cancelled) resize();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Re-run resize once the textarea is actually laid out. During drag-drop
|
|
50
|
+
// remounts the element can be briefly detached, so the initial RAF resize
|
|
51
|
+
// sees scrollHeight === 0 and clamps to minRows.
|
|
52
|
+
const intersectionObserver = new IntersectionObserver((entries) => {
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (entry.isIntersecting && !cancelled) {
|
|
55
|
+
resize();
|
|
56
|
+
intersectionObserver.disconnect();
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
intersectionObserver.observe(textarea);
|
|
62
|
+
|
|
63
|
+
if (typeof document !== "undefined" && document.fonts?.ready) {
|
|
64
|
+
document.fonts.ready.then(() => {
|
|
65
|
+
if (!cancelled) resize();
|
|
66
|
+
}).catch(() => {});
|
|
67
|
+
}
|
|
39
68
|
|
|
40
69
|
return () => {
|
|
41
|
-
|
|
70
|
+
cancelled = true;
|
|
71
|
+
mutationObserver.disconnect();
|
|
72
|
+
resizeObserver.disconnect();
|
|
73
|
+
intersectionObserver.disconnect();
|
|
42
74
|
textarea.removeEventListener("input", handleInput);
|
|
75
|
+
cancelAnimationFrame(initialFrame);
|
|
43
76
|
cancelAnimationFrame(frameRef.current ?? 0);
|
|
44
77
|
};
|
|
45
78
|
}, [textarea, multiline, minRows, maxRows]);
|
|
@@ -46,6 +46,38 @@ describe("blocksToMarkdown", () => {
|
|
|
46
46
|
expect(blocksToMarkdown(blocks)).toBe("Hello **world**_!_");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it("preserves HTML comments without escaping", () => {
|
|
50
|
+
const blocks: CustomEditorBlock[] = [
|
|
51
|
+
{
|
|
52
|
+
id: "c1",
|
|
53
|
+
type: "paragraph",
|
|
54
|
+
props: baseProps,
|
|
55
|
+
content: [
|
|
56
|
+
{ type: "text", text: "<!-- ai/agent generated description -->", styles: {} },
|
|
57
|
+
],
|
|
58
|
+
children: [],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
expect(blocksToMarkdown(blocks)).toBe("<!-- ai/agent generated description -->");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("preserves HTML comments inline among text and still escapes stray angle brackets", () => {
|
|
66
|
+
const blocks: CustomEditorBlock[] = [
|
|
67
|
+
{
|
|
68
|
+
id: "c2",
|
|
69
|
+
type: "paragraph",
|
|
70
|
+
props: baseProps,
|
|
71
|
+
content: [
|
|
72
|
+
{ type: "text", text: "before <!-- note --> after <div>", styles: {} },
|
|
73
|
+
],
|
|
74
|
+
children: [],
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
expect(blocksToMarkdown(blocks)).toBe("before <!-- note --> after \\<div>");
|
|
79
|
+
});
|
|
80
|
+
|
|
49
81
|
it("places bold markers outside leading/trailing spaces", () => {
|
|
50
82
|
const blocks: CustomEditorBlock[] = [
|
|
51
83
|
{
|
|
@@ -165,9 +197,9 @@ describe("blocksToMarkdown", () => {
|
|
|
165
197
|
expect(blocksToMarkdown(blocks)).toBe(
|
|
166
198
|
[
|
|
167
199
|
"* Open the Login page.",
|
|
168
|
-
" *Expected*: The Login page loads successfully.",
|
|
200
|
+
" *Expected result*: The Login page loads successfully.",
|
|
169
201
|
"* Enter a valid username.",
|
|
170
|
-
" *Expected*: The username is accepted.",
|
|
202
|
+
" *Expected result*: The username is accepted.",
|
|
171
203
|
].join("\n"),
|
|
172
204
|
);
|
|
173
205
|
});
|
|
@@ -210,7 +242,7 @@ describe("blocksToMarkdown", () => {
|
|
|
210
242
|
];
|
|
211
243
|
|
|
212
244
|
expect(blocksToMarkdown(blocks)).toBe(
|
|
213
|
-
["* ", " *Expected*: Login form visible"].join("\n"),
|
|
245
|
+
["* ", " *Expected result*: Login form visible"].join("\n"),
|
|
214
246
|
);
|
|
215
247
|
});
|
|
216
248
|
|
|
@@ -323,7 +355,7 @@ describe("blocksToMarkdown", () => {
|
|
|
323
355
|
expect(blocksToMarkdown(blocks)).toBe(
|
|
324
356
|
[
|
|
325
357
|
"* **Click** the _Login_ button",
|
|
326
|
-
" *Expected*: **Success** is shown",
|
|
358
|
+
" *Expected result*: **Success** is shown",
|
|
327
359
|
" Second line with <u>underline</u>",
|
|
328
360
|
].join("\n"),
|
|
329
361
|
);
|
|
@@ -350,7 +382,7 @@ describe("blocksToMarkdown", () => {
|
|
|
350
382
|
"* Navigate to login",
|
|
351
383
|
" Open browser",
|
|
352
384
|
" Go to login page",
|
|
353
|
-
" *Expected*: Login form visible",
|
|
385
|
+
" *Expected result*: Login form visible",
|
|
354
386
|
].join("\n"),
|
|
355
387
|
);
|
|
356
388
|
});
|
|
@@ -397,7 +429,7 @@ describe("blocksToMarkdown", () => {
|
|
|
397
429
|
" asdsadas",
|
|
398
430
|
" ```",
|
|
399
431
|
" ",
|
|
400
|
-
" *Expected*: The user receives a real-time notification for the order update.",
|
|
432
|
+
" *Expected result*: The user receives a real-time notification for the order update.",
|
|
401
433
|
].join("\n"),
|
|
402
434
|
);
|
|
403
435
|
});
|
|
@@ -1041,7 +1073,7 @@ describe("markdownToBlocks", () => {
|
|
|
1041
1073
|
" asdsadas",
|
|
1042
1074
|
" ```",
|
|
1043
1075
|
" ",
|
|
1044
|
-
" *Expected*: The user receives a real-time notification for the order update.",
|
|
1076
|
+
" *Expected result*: The user receives a real-time notification for the order update.",
|
|
1045
1077
|
].join("\n"),
|
|
1046
1078
|
);
|
|
1047
1079
|
});
|
|
@@ -1082,6 +1114,11 @@ describe("markdownToBlocks", () => {
|
|
|
1082
1114
|
content: [{ type: "text", text: "Preconditions", styles: {} }],
|
|
1083
1115
|
children: [],
|
|
1084
1116
|
},
|
|
1117
|
+
{
|
|
1118
|
+
type: "paragraph",
|
|
1119
|
+
content: [],
|
|
1120
|
+
children: [],
|
|
1121
|
+
},
|
|
1085
1122
|
{
|
|
1086
1123
|
type: "bulletListItem",
|
|
1087
1124
|
props: baseProps,
|
|
@@ -1270,7 +1307,7 @@ describe("markdownToBlocks", () => {
|
|
|
1270
1307
|
expect(markdownRoundTrip).toBe(
|
|
1271
1308
|
[
|
|
1272
1309
|
"* Display the generated report.",
|
|
1273
|
-
" *Expected*: ",
|
|
1310
|
+
" *Expected result*: ",
|
|
1274
1311
|
].join("\n"),
|
|
1275
1312
|
);
|
|
1276
1313
|
});
|
|
@@ -1317,7 +1354,7 @@ describe("markdownToBlocks", () => {
|
|
|
1317
1354
|
expect(roundTrip).toBe(
|
|
1318
1355
|
[
|
|
1319
1356
|
"* Should open login screen",
|
|
1320
|
-
" *Expected*: Login should look like this",
|
|
1357
|
+
" *Expected result*: Login should look like this",
|
|
1321
1358
|
" ",
|
|
1322
1359
|
].join("\n"),
|
|
1323
1360
|
);
|
|
@@ -1456,9 +1493,9 @@ describe("markdownToBlocks", () => {
|
|
|
1456
1493
|
expect(roundTrip).toBe(
|
|
1457
1494
|
[
|
|
1458
1495
|
"* Existing email + invalid password",
|
|
1459
|
-
" *Expected*: 'Oops, wrong email or password' is displayed",
|
|
1496
|
+
" *Expected result*: 'Oops, wrong email or password' is displayed",
|
|
1460
1497
|
"* Not existing email + valid password",
|
|
1461
|
-
" *Expected*: 'Oops, wrong email or password' is displayed",
|
|
1498
|
+
" *Expected result*: 'Oops, wrong email or password' is displayed",
|
|
1462
1499
|
].join("\n"),
|
|
1463
1500
|
);
|
|
1464
1501
|
});
|
|
@@ -2525,3 +2562,142 @@ describe("steps require Steps heading", () => {
|
|
|
2525
2562
|
expect((stepBlocks[0].props as any).stepTitle).toBe("next 22");
|
|
2526
2563
|
});
|
|
2527
2564
|
});
|
|
2565
|
+
|
|
2566
|
+
describe("blank line <-> empty paragraph mapping", () => {
|
|
2567
|
+
const isEmptyParagraph = (block: CustomPartialBlock | CustomEditorBlock) =>
|
|
2568
|
+
block.type === "paragraph" &&
|
|
2569
|
+
(!block.content || (block.content as any[]).length === 0);
|
|
2570
|
+
|
|
2571
|
+
it("parses a single blank line between headings as one empty paragraph", () => {
|
|
2572
|
+
const blocks = markdownToBlocks("### A\n\n### B");
|
|
2573
|
+
expect(blocks).toHaveLength(3);
|
|
2574
|
+
expect(blocks[0].type).toBe("heading");
|
|
2575
|
+
expect(isEmptyParagraph(blocks[1])).toBe(true);
|
|
2576
|
+
expect(blocks[2].type).toBe("heading");
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
it("parses two blank lines between headings as two empty paragraphs", () => {
|
|
2580
|
+
const blocks = markdownToBlocks("### A\n\n\n### B");
|
|
2581
|
+
expect(blocks).toHaveLength(4);
|
|
2582
|
+
expect(blocks[0].type).toBe("heading");
|
|
2583
|
+
expect(isEmptyParagraph(blocks[1])).toBe(true);
|
|
2584
|
+
expect(isEmptyParagraph(blocks[2])).toBe(true);
|
|
2585
|
+
expect(blocks[3].type).toBe("heading");
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
it("drops leading and trailing blank lines", () => {
|
|
2589
|
+
const blocks = markdownToBlocks("\n\n### A\n\n");
|
|
2590
|
+
expect(blocks).toHaveLength(1);
|
|
2591
|
+
expect(blocks[0].type).toBe("heading");
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
it("serializes an empty paragraph between two blocks as one blank line", () => {
|
|
2595
|
+
const blocks: CustomEditorBlock[] = [
|
|
2596
|
+
{
|
|
2597
|
+
id: "h1",
|
|
2598
|
+
type: "heading",
|
|
2599
|
+
props: { ...baseProps, level: 3 } as any,
|
|
2600
|
+
content: [{ type: "text", text: "A", styles: {} }],
|
|
2601
|
+
children: [],
|
|
2602
|
+
},
|
|
2603
|
+
{
|
|
2604
|
+
id: "p1",
|
|
2605
|
+
type: "paragraph",
|
|
2606
|
+
props: baseProps,
|
|
2607
|
+
content: [],
|
|
2608
|
+
children: [],
|
|
2609
|
+
},
|
|
2610
|
+
{
|
|
2611
|
+
id: "h2",
|
|
2612
|
+
type: "heading",
|
|
2613
|
+
props: { ...baseProps, level: 3 } as any,
|
|
2614
|
+
content: [{ type: "text", text: "B", styles: {} }],
|
|
2615
|
+
children: [],
|
|
2616
|
+
},
|
|
2617
|
+
];
|
|
2618
|
+
expect(blocksToMarkdown(blocks)).toBe("### A\n\n### B");
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
it("round-trips a single blank line between headings", () => {
|
|
2622
|
+
const markdown = "### A\n\n### B";
|
|
2623
|
+
const blocks = markdownToBlocks(markdown);
|
|
2624
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
it("round-trips two blank lines between headings", () => {
|
|
2628
|
+
const markdown = "### A\n\n\n### B";
|
|
2629
|
+
const blocks = markdownToBlocks(markdown);
|
|
2630
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
it("round-trips headings adjacent with no blank line", () => {
|
|
2634
|
+
const markdown = "### A\n### B";
|
|
2635
|
+
const blocks = markdownToBlocks(markdown);
|
|
2636
|
+
expect(blocks.some(isEmptyParagraph)).toBe(false);
|
|
2637
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2640
|
+
it("deleting an empty paragraph removes the blank line from the output", () => {
|
|
2641
|
+
const blocks = markdownToBlocks("### A\n\n### B") as CustomEditorBlock[];
|
|
2642
|
+
const withoutEmpty = blocks.filter((block) => !isEmptyParagraph(block));
|
|
2643
|
+
expect(withoutEmpty).toHaveLength(2);
|
|
2644
|
+
expect(blocksToMarkdown(withoutEmpty)).toBe("### A\n### B");
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
it("inserting an empty paragraph adds a blank line to the output", () => {
|
|
2648
|
+
const blocks = markdownToBlocks("### A\n### B") as CustomEditorBlock[];
|
|
2649
|
+
const withEmpty: CustomEditorBlock[] = [
|
|
2650
|
+
blocks[0],
|
|
2651
|
+
{
|
|
2652
|
+
id: "inserted",
|
|
2653
|
+
type: "paragraph",
|
|
2654
|
+
props: baseProps,
|
|
2655
|
+
content: [],
|
|
2656
|
+
children: [],
|
|
2657
|
+
},
|
|
2658
|
+
blocks[1],
|
|
2659
|
+
];
|
|
2660
|
+
expect(blocksToMarkdown(withEmpty)).toBe("### A\n\n### B");
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
it("preserves blank lines between a heading and a bullet list", () => {
|
|
2664
|
+
const markdown = "### Steps\n\n* first\n* second";
|
|
2665
|
+
const blocks = markdownToBlocks(markdown);
|
|
2666
|
+
expect(isEmptyParagraph(blocks[1])).toBe(true);
|
|
2667
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
it("preserves a blank line between a bullet list and the next heading", () => {
|
|
2671
|
+
const markdown = [
|
|
2672
|
+
"### Requirements",
|
|
2673
|
+
"* first requirement",
|
|
2674
|
+
"* second requirement",
|
|
2675
|
+
"",
|
|
2676
|
+
"### Steps",
|
|
2677
|
+
"",
|
|
2678
|
+
"* do the thing",
|
|
2679
|
+
" *Expected result*: it works",
|
|
2680
|
+
].join("\n");
|
|
2681
|
+
const blocks = markdownToBlocks(markdown) as CustomEditorBlock[];
|
|
2682
|
+
const types = blocks.map((b) => b.type);
|
|
2683
|
+
// Blank line between the last bullet and the next heading must survive
|
|
2684
|
+
// parseList and become an empty paragraph.
|
|
2685
|
+
const lastBulletIdx = types.lastIndexOf("bulletListItem");
|
|
2686
|
+
expect(blocks[lastBulletIdx + 1].type).toBe("paragraph");
|
|
2687
|
+
expect((blocks[lastBulletIdx + 1].content as any[]).length).toBe(0);
|
|
2688
|
+
expect(blocksToMarkdown(blocks)).toBe(markdown);
|
|
2689
|
+
});
|
|
2690
|
+
|
|
2691
|
+
it("preserves blank lines across the user-reported screenshot example", () => {
|
|
2692
|
+
const markdown = [
|
|
2693
|
+
"### Requirements",
|
|
2694
|
+
"",
|
|
2695
|
+
"### Steps",
|
|
2696
|
+
"",
|
|
2697
|
+
"* *open* **webiste**",
|
|
2698
|
+
" step data with `image`",
|
|
2699
|
+
].join("\n");
|
|
2700
|
+
const blocks = markdownToBlocks(markdown);
|
|
2701
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
2702
|
+
});
|
|
2703
|
+
});
|
|
@@ -61,6 +61,7 @@ const headingPrefixes: Record<number, string> = {
|
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
|
|
64
|
+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
|
|
64
65
|
const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
|
|
65
66
|
const HTML_UNDERLINE_REGEX = /<\/?u>/g;
|
|
66
67
|
const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
|
|
@@ -70,7 +71,17 @@ const STEP_DATA_LINE_REGEX =
|
|
|
70
71
|
const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
|
|
71
72
|
|
|
72
73
|
function escapeMarkdown(text: string): string {
|
|
73
|
-
|
|
74
|
+
let result = "";
|
|
75
|
+
let lastIndex = 0;
|
|
76
|
+
HTML_COMMENT_REGEX.lastIndex = 0;
|
|
77
|
+
let match: RegExpExecArray | null;
|
|
78
|
+
while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
|
|
79
|
+
result += text.slice(lastIndex, match.index).replace(SPECIAL_CHAR_REGEX, "\\$1");
|
|
80
|
+
result += match[0];
|
|
81
|
+
lastIndex = match.index + match[0].length;
|
|
82
|
+
}
|
|
83
|
+
result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
|
|
84
|
+
return result;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
function stripHtmlWrappers(text: string): string {
|
|
@@ -273,13 +284,6 @@ function serializeChildren(block: CustomEditorBlock, ctx: MarkdownContext): stri
|
|
|
273
284
|
return serializeBlocks(block.children, childCtx);
|
|
274
285
|
}
|
|
275
286
|
|
|
276
|
-
function flattenWithBlankLine(lines: string[], appendBlank = false): string[] {
|
|
277
|
-
if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
|
|
278
|
-
return [...lines, ""];
|
|
279
|
-
}
|
|
280
|
-
return lines;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
287
|
function serializeBlock(
|
|
284
288
|
block: CustomEditorBlock,
|
|
285
289
|
ctx: MarkdownContext,
|
|
@@ -292,17 +296,21 @@ function serializeBlock(
|
|
|
292
296
|
switch (block.type) {
|
|
293
297
|
case "paragraph": {
|
|
294
298
|
const text = inlineToMarkdown(block.content);
|
|
295
|
-
if (text.length
|
|
296
|
-
|
|
299
|
+
if (text.length === 0) {
|
|
300
|
+
// Empty paragraph = one blank line in the output. Under the 1:1
|
|
301
|
+
// block model, this is the only mechanism that produces blank lines
|
|
302
|
+
// between top-level blocks.
|
|
303
|
+
return [""];
|
|
297
304
|
}
|
|
298
|
-
|
|
305
|
+
lines.push(ctx.insideQuote ? `> ${text}` : text);
|
|
306
|
+
return lines;
|
|
299
307
|
}
|
|
300
308
|
case "heading": {
|
|
301
309
|
const level = (block.props as any).level ?? 1;
|
|
302
310
|
const prefix = headingPrefixes[level] ?? headingPrefixes[3];
|
|
303
311
|
const text = inlineToMarkdown(block.content);
|
|
304
312
|
lines.push(`${prefix} ${text}`.trimEnd());
|
|
305
|
-
return
|
|
313
|
+
return lines;
|
|
306
314
|
}
|
|
307
315
|
case "quote": {
|
|
308
316
|
const quoteContent = serializeBlocks(block.children ?? [], {
|
|
@@ -317,7 +325,7 @@ function serializeBlock(
|
|
|
317
325
|
lines.push(...quoteText);
|
|
318
326
|
}
|
|
319
327
|
lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
|
|
320
|
-
return
|
|
328
|
+
return lines;
|
|
321
329
|
}
|
|
322
330
|
case "codeBlock": {
|
|
323
331
|
const language = (block.props as any).language || "";
|
|
@@ -328,7 +336,7 @@ function serializeBlock(
|
|
|
328
336
|
lines.push(body);
|
|
329
337
|
}
|
|
330
338
|
lines.push("```");
|
|
331
|
-
return
|
|
339
|
+
return lines;
|
|
332
340
|
}
|
|
333
341
|
case "bulletListItem": {
|
|
334
342
|
const text = inlineToMarkdown(block.content);
|
|
@@ -361,7 +369,7 @@ function serializeBlock(
|
|
|
361
369
|
const size = width ? ` =${width}x*` : "";
|
|
362
370
|
lines.push(``);
|
|
363
371
|
}
|
|
364
|
-
return
|
|
372
|
+
return lines;
|
|
365
373
|
}
|
|
366
374
|
case "file":
|
|
367
375
|
case "video":
|
|
@@ -373,7 +381,7 @@ function serializeBlock(
|
|
|
373
381
|
const displayUrl = caption || resolveFileDisplayUrl(url);
|
|
374
382
|
lines.push(`[](${url})`);
|
|
375
383
|
}
|
|
376
|
-
return
|
|
384
|
+
return lines;
|
|
377
385
|
}
|
|
378
386
|
case "testStep":
|
|
379
387
|
case "snippet": {
|
|
@@ -405,7 +413,7 @@ function serializeBlock(
|
|
|
405
413
|
lines.push(`<!-- end snippet #${snippetId} -->`);
|
|
406
414
|
}
|
|
407
415
|
|
|
408
|
-
return
|
|
416
|
+
return lines;
|
|
409
417
|
}
|
|
410
418
|
|
|
411
419
|
const normalizedTitle = stepTitle
|
|
@@ -438,7 +446,7 @@ function serializeBlock(
|
|
|
438
446
|
const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
|
|
439
447
|
if (normalizedExpected.length > 0) {
|
|
440
448
|
const expectedLines = normalizedExpected.split(/\r?\n/);
|
|
441
|
-
const label = "*Expected*";
|
|
449
|
+
const label = "*Expected result*";
|
|
442
450
|
expectedLines.forEach((expectedLine: string, index: number) => {
|
|
443
451
|
const trimmedLine = expectedLine.trim();
|
|
444
452
|
if (trimmedLine.length === 0) {
|
|
@@ -453,16 +461,12 @@ function serializeBlock(
|
|
|
453
461
|
});
|
|
454
462
|
}
|
|
455
463
|
|
|
456
|
-
|
|
457
|
-
return lines;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
return flattenWithBlankLine(lines, false);
|
|
464
|
+
return lines;
|
|
461
465
|
}
|
|
462
466
|
case "table": {
|
|
463
467
|
const tableContent = block.content as any;
|
|
464
468
|
if (!tableContent || tableContent.type !== "tableContent") {
|
|
465
|
-
return
|
|
469
|
+
return lines;
|
|
466
470
|
}
|
|
467
471
|
|
|
468
472
|
const rows: any[] = Array.isArray(tableContent.rows)
|
|
@@ -470,7 +474,7 @@ function serializeBlock(
|
|
|
470
474
|
: [];
|
|
471
475
|
|
|
472
476
|
if (rows.length === 0) {
|
|
473
|
-
return
|
|
477
|
+
return lines;
|
|
474
478
|
}
|
|
475
479
|
|
|
476
480
|
const columnCount = rows.reduce((max, row) => {
|
|
@@ -479,7 +483,7 @@ function serializeBlock(
|
|
|
479
483
|
}, 0);
|
|
480
484
|
|
|
481
485
|
if (columnCount === 0) {
|
|
482
|
-
return
|
|
486
|
+
return lines;
|
|
483
487
|
}
|
|
484
488
|
|
|
485
489
|
const headerRowCount = rows.length
|
|
@@ -570,7 +574,7 @@ function serializeBlock(
|
|
|
570
574
|
lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
|
|
571
575
|
});
|
|
572
576
|
|
|
573
|
-
return
|
|
577
|
+
return lines;
|
|
574
578
|
}
|
|
575
579
|
}
|
|
576
580
|
|
|
@@ -582,7 +586,7 @@ function serializeBlock(
|
|
|
582
586
|
}
|
|
583
587
|
}
|
|
584
588
|
lines.push(...serializeChildren(fallbackBlock, ctx));
|
|
585
|
-
return
|
|
589
|
+
return lines;
|
|
586
590
|
}
|
|
587
591
|
|
|
588
592
|
function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): string[] {
|
|
@@ -788,7 +792,27 @@ function parseList(
|
|
|
788
792
|
const trimmed = rawLine.trim();
|
|
789
793
|
|
|
790
794
|
if (!trimmed) {
|
|
791
|
-
|
|
795
|
+
// Peek at the next non-blank line. If it's another item of this list
|
|
796
|
+
// (same indent level and list type), treat the blank lines as loose-list
|
|
797
|
+
// separators and consume them. Otherwise leave the blank line for the
|
|
798
|
+
// outer loop so it can become an empty paragraph block.
|
|
799
|
+
let lookahead = index + 1;
|
|
800
|
+
while (lookahead < lines.length && !lines[lookahead].trim()) {
|
|
801
|
+
lookahead += 1;
|
|
802
|
+
}
|
|
803
|
+
if (lookahead >= lines.length) {
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
const nextLine = lines[lookahead];
|
|
807
|
+
const nextIndent = countIndent(nextLine);
|
|
808
|
+
if (nextIndent < indentLevel * 2) {
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
const nextType = detectListType(nextLine.trim());
|
|
812
|
+
if (nextType !== listType) {
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
index = lookahead;
|
|
792
816
|
continue;
|
|
793
817
|
}
|
|
794
818
|
|
|
@@ -1394,12 +1418,16 @@ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPar
|
|
|
1394
1418
|
return result;
|
|
1395
1419
|
}
|
|
1396
1420
|
|
|
1421
|
+
// The `preserveBlankLines` option is retained for backwards compatibility
|
|
1422
|
+
// but is now a no-op: blank lines in the source markdown always produce
|
|
1423
|
+
// empty paragraph blocks (except for leading/trailing blanks, which are
|
|
1424
|
+
// dropped). This gives a 1:1 mapping between blank lines and blocks so the
|
|
1425
|
+
// Rich editor can render and delete each blank line individually.
|
|
1397
1426
|
export interface MarkdownToBlocksOptions {
|
|
1398
|
-
/** When true, every blank line produces an empty paragraph block. */
|
|
1399
1427
|
preserveBlankLines?: boolean;
|
|
1400
1428
|
}
|
|
1401
1429
|
|
|
1402
|
-
export function markdownToBlocks(markdown: string,
|
|
1430
|
+
export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
|
|
1403
1431
|
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
1404
1432
|
const lines = normalized.split("\n");
|
|
1405
1433
|
const blocks: CustomPartialBlock[] = [];
|
|
@@ -1409,22 +1437,12 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
|
|
|
1409
1437
|
while (index < lines.length) {
|
|
1410
1438
|
const line = lines[index];
|
|
1411
1439
|
if (!line.trim()) {
|
|
1412
|
-
|
|
1440
|
+
// Drop blank lines until we've emitted at least one block, so leading
|
|
1441
|
+
// blanks don't produce a ghost empty paragraph at the top of the doc.
|
|
1442
|
+
if (blocks.length > 0) {
|
|
1413
1443
|
blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
|
|
1414
|
-
index += 1;
|
|
1415
|
-
continue;
|
|
1416
1444
|
}
|
|
1417
1445
|
index += 1;
|
|
1418
|
-
// Count consecutive blank lines
|
|
1419
|
-
let blankCount = 1;
|
|
1420
|
-
while (index < lines.length && !lines[index].trim()) {
|
|
1421
|
-
blankCount++;
|
|
1422
|
-
index++;
|
|
1423
|
-
}
|
|
1424
|
-
// Create empty paragraph for each extra blank line beyond the first
|
|
1425
|
-
for (let i = 1; i < blankCount; i++) {
|
|
1426
|
-
blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
|
|
1427
|
-
}
|
|
1428
1446
|
continue;
|
|
1429
1447
|
}
|
|
1430
1448
|
|
|
@@ -1540,16 +1558,18 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
|
|
|
1540
1558
|
index = paragraph.nextIndex;
|
|
1541
1559
|
}
|
|
1542
1560
|
|
|
1543
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1561
|
+
// Drop trailing empty paragraphs so a trailing blank line in the source
|
|
1562
|
+
// doesn't leave a ghost empty block at the end of the document.
|
|
1563
|
+
while (
|
|
1564
|
+
blocks.length > 0 &&
|
|
1565
|
+
blocks[blocks.length - 1].type === "paragraph" &&
|
|
1566
|
+
(!blocks[blocks.length - 1].content ||
|
|
1567
|
+
(blocks[blocks.length - 1].content as any[]).length === 0)
|
|
1568
|
+
) {
|
|
1569
|
+
blocks.pop();
|
|
1550
1570
|
}
|
|
1551
1571
|
|
|
1552
|
-
return fixMalformedImageBlocks(
|
|
1572
|
+
return fixMalformedImageBlocks(blocks);
|
|
1553
1573
|
}
|
|
1554
1574
|
|
|
1555
1575
|
function splitTableRow(line: string): string[] {
|
package/src/editor/styles.css
CHANGED
|
@@ -1100,6 +1100,12 @@ html.dark .bn-step-image-preview__content {
|
|
|
1100
1100
|
color: rgb(146, 64, 14) !important;
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
|
+
.bn-step-editor .overtype-wrapper .overtype-preview li.bullet-list .syntax-marker,
|
|
1104
|
+
.bn-step-editor .overtype-wrapper .overtype-preview li.ordered-list .syntax-marker {
|
|
1105
|
+
color: inherit !important;
|
|
1106
|
+
opacity: 1 !important;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1103
1109
|
.bn-step-custom-caret {
|
|
1104
1110
|
display: none;
|
|
1105
1111
|
position: absolute;
|