testomatio-editor-blocks 0.4.52 → 0.4.54
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/markdown.js +97 -36
- package/package/editor/blocks/stepField.d.ts +12 -0
- package/package/editor/blocks/stepField.js +37 -13
- package/package/editor/blocks/useAutoResize.d.ts +3 -4
- package/package/editor/blocks/useAutoResize.js +70 -26
- package/package/editor/customMarkdownConverter.js +88 -28
- package/package/styles.css +6 -0
- package/package.json +1 -1
- package/src/editor/blocks/markdown.ts +110 -40
- package/src/editor/blocks/stepField.tsx +48 -15
- package/src/editor/blocks/stepFieldFormatting.test.ts +62 -1
- package/src/editor/blocks/useAutoResize.ts +73 -30
- package/src/editor/customMarkdownConverter.test.ts +32 -11
- package/src/editor/customMarkdownConverter.ts +102 -29
- package/src/editor/markdownToBlocks.test.ts +39 -0
- package/src/editor/styles.css +6 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+?)(?:\s+=\d+x(?:\d+|\*))?\)/g;
|
|
2
2
|
const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
|
|
3
|
-
const INLINE_SEGMENT_REGEX =
|
|
4
|
-
/(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
|
|
5
3
|
|
|
6
4
|
export function escapeHtml(text: string): string {
|
|
7
5
|
return text
|
|
@@ -25,53 +23,125 @@ function restoreEscapes(text: string): string {
|
|
|
25
23
|
return text.replace(/\uE000/g, "\\");
|
|
26
24
|
}
|
|
27
25
|
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
function findItalicClose(
|
|
27
|
+
text: string,
|
|
28
|
+
start: number,
|
|
29
|
+
marker: "*" | "_",
|
|
30
|
+
): number {
|
|
31
|
+
let j = start;
|
|
32
|
+
while (j < text.length) {
|
|
33
|
+
const ch = text[j];
|
|
34
|
+
if (ch === "\uE000") {
|
|
35
|
+
j += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if ((ch === "*" || ch === "_") && text[j + 1] === ch) {
|
|
39
|
+
const close = text.indexOf(ch + ch, j + 2);
|
|
40
|
+
if (close === -1) {
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
j = close + 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === marker) {
|
|
47
|
+
return j;
|
|
48
|
+
}
|
|
49
|
+
j += 1;
|
|
31
50
|
}
|
|
51
|
+
return -1;
|
|
52
|
+
}
|
|
32
53
|
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
function parseInlineSegments(
|
|
55
|
+
normalized: string,
|
|
56
|
+
outer: { bold: boolean; italic: boolean; underline: boolean },
|
|
57
|
+
): InlineSegment[] {
|
|
58
|
+
const result: InlineSegment[] = [];
|
|
59
|
+
let buffer = "";
|
|
60
|
+
|
|
61
|
+
const pushPlain = () => {
|
|
62
|
+
if (!buffer) return;
|
|
63
|
+
result.push({ text: restoreEscapes(buffer), styles: { ...outer } });
|
|
64
|
+
buffer = "";
|
|
65
|
+
};
|
|
35
66
|
|
|
36
|
-
|
|
37
|
-
|
|
67
|
+
const wrap = (
|
|
68
|
+
inner: string,
|
|
69
|
+
add: Partial<{ bold: boolean; italic: boolean; underline: boolean }>,
|
|
70
|
+
) => {
|
|
71
|
+
pushPlain();
|
|
72
|
+
result.push(...parseInlineSegments(inner, { ...outer, ...add }));
|
|
73
|
+
};
|
|
38
74
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < normalized.length) {
|
|
77
|
+
if (normalized.startsWith("***", i)) {
|
|
78
|
+
const end = normalized.indexOf("***", i + 3);
|
|
79
|
+
if (end !== -1) {
|
|
80
|
+
wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
|
|
81
|
+
i = end + 3;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
45
84
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
85
|
+
if (normalized.startsWith("___", i)) {
|
|
86
|
+
const end = normalized.indexOf("___", i + 3);
|
|
87
|
+
if (end !== -1) {
|
|
88
|
+
wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
|
|
89
|
+
i = end + 3;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
53
92
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
93
|
+
if (normalized.startsWith("**", i)) {
|
|
94
|
+
const end = normalized.indexOf("**", i + 2);
|
|
95
|
+
if (end !== -1) {
|
|
96
|
+
wrap(normalized.slice(i + 2, end), { bold: true });
|
|
97
|
+
i = end + 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
61
100
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
101
|
+
if (normalized.startsWith("__", i)) {
|
|
102
|
+
const end = normalized.indexOf("__", i + 2);
|
|
103
|
+
if (end !== -1) {
|
|
104
|
+
wrap(normalized.slice(i + 2, end), { bold: true });
|
|
105
|
+
i = end + 2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (normalized.startsWith("<u>", i)) {
|
|
110
|
+
const end = normalized.indexOf("</u>", i + 3);
|
|
111
|
+
if (end !== -1) {
|
|
112
|
+
wrap(normalized.slice(i + 3, end), { underline: true });
|
|
113
|
+
i = end + 4;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
69
116
|
}
|
|
117
|
+
if (normalized[i] === "*" || normalized[i] === "_") {
|
|
118
|
+
const marker = normalized[i] as "*" | "_";
|
|
119
|
+
const end = findItalicClose(normalized, i + 1, marker);
|
|
120
|
+
if (end !== -1) {
|
|
121
|
+
wrap(normalized.slice(i + 1, end), { italic: true });
|
|
122
|
+
i = end + 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
buffer += normalized[i];
|
|
128
|
+
i += 1;
|
|
129
|
+
}
|
|
70
130
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
131
|
+
pushPlain();
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseInlineMarkdown(text: string): InlineSegment[] {
|
|
136
|
+
if (!text) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
|
|
141
|
+
return parseInlineSegments(normalized, {
|
|
142
|
+
bold: false,
|
|
143
|
+
italic: false,
|
|
144
|
+
underline: false,
|
|
75
145
|
});
|
|
76
146
|
}
|
|
77
147
|
|
|
@@ -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[];
|
|
@@ -920,6 +945,15 @@ export function StepField({
|
|
|
920
945
|
textareaNode.focus();
|
|
921
946
|
}, [focusSignal, textareaNode]);
|
|
922
947
|
|
|
948
|
+
useAutoResize({
|
|
949
|
+
textarea: textareaNode,
|
|
950
|
+
enabled: multiline,
|
|
951
|
+
onResize: useCallback(() => {
|
|
952
|
+
const instance = editorInstanceRef.current as (OverTypeInstance & { _updateAutoHeight?: () => void }) | null;
|
|
953
|
+
instance?._updateAutoHeight?.();
|
|
954
|
+
}, []),
|
|
955
|
+
});
|
|
956
|
+
|
|
923
957
|
useEffect(() => {
|
|
924
958
|
const instance = editorInstanceRef.current;
|
|
925
959
|
if (!instance) {
|
|
@@ -987,13 +1021,6 @@ export function StepField({
|
|
|
987
1021
|
textareaNode.readOnly = readOnly;
|
|
988
1022
|
}, [readOnly, textareaNode]);
|
|
989
1023
|
|
|
990
|
-
useAutoResize({
|
|
991
|
-
textarea: textareaNode,
|
|
992
|
-
multiline,
|
|
993
|
-
minRows: 3,
|
|
994
|
-
maxRows: 16,
|
|
995
|
-
});
|
|
996
|
-
|
|
997
1024
|
useEffect(() => {
|
|
998
1025
|
if (!textareaNode) {
|
|
999
1026
|
return;
|
|
@@ -1188,14 +1215,15 @@ export function StepField({
|
|
|
1188
1215
|
// Remove formatting
|
|
1189
1216
|
formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
|
|
1190
1217
|
} else if (start !== end) {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1218
|
+
const cleaned = applyInlineExclusion(
|
|
1219
|
+
formattingRef.current,
|
|
1220
|
+
linksRef.current,
|
|
1221
|
+
start,
|
|
1222
|
+
end,
|
|
1223
|
+
fmtType,
|
|
1196
1224
|
);
|
|
1197
|
-
|
|
1198
|
-
|
|
1225
|
+
formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }];
|
|
1226
|
+
linksRef.current = cleaned.links;
|
|
1199
1227
|
} else {
|
|
1200
1228
|
// No selection — nothing to format
|
|
1201
1229
|
return;
|
|
@@ -1314,7 +1342,12 @@ export function StepField({
|
|
|
1314
1342
|
);
|
|
1315
1343
|
const newLink: LinkMeta = { start: sel.start, end: sel.start + linkText.length, url };
|
|
1316
1344
|
linksRef.current = [...adjustedLinks, newLink];
|
|
1317
|
-
|
|
1345
|
+
// Links are exclusive with bold/italic/code: strip any formatting that
|
|
1346
|
+
// overlaps the original selection before shifting positions.
|
|
1347
|
+
const keptFormatting = formattingRef.current.filter(
|
|
1348
|
+
(f) => f.start >= sel.end || f.end <= sel.start,
|
|
1349
|
+
);
|
|
1350
|
+
formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta);
|
|
1318
1351
|
prevTextRef.current = nextValue;
|
|
1319
1352
|
|
|
1320
1353
|
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
|
+
});
|
|
@@ -1,46 +1,89 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
|
|
3
3
|
type Options = {
|
|
4
4
|
textarea: HTMLTextAreaElement | null;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
maxRows?: number;
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
onResize: () => void;
|
|
8
7
|
};
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
// Shared across all hook instances: one fonts.ready promise per document,
|
|
10
|
+
// fanned out to every registered callback so N fields cost 1 .then().
|
|
11
|
+
const fontsReadyCallbacks = new Set<() => void>();
|
|
12
|
+
let fontsReadyAttached = false;
|
|
13
|
+
|
|
14
|
+
function registerFontsReady(cb: () => void): () => void {
|
|
15
|
+
if (typeof document === "undefined" || !document.fonts?.ready) {
|
|
16
|
+
return () => {};
|
|
17
|
+
}
|
|
18
|
+
fontsReadyCallbacks.add(cb);
|
|
19
|
+
if (!fontsReadyAttached) {
|
|
20
|
+
fontsReadyAttached = true;
|
|
21
|
+
document.fonts.ready
|
|
22
|
+
.then(() => {
|
|
23
|
+
for (const fn of fontsReadyCallbacks) fn();
|
|
24
|
+
})
|
|
25
|
+
.catch(() => {});
|
|
26
|
+
}
|
|
27
|
+
return () => {
|
|
28
|
+
fontsReadyCallbacks.delete(cb);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useAutoResize({ textarea, enabled = true, onResize }: Options) {
|
|
11
33
|
const frameRef = useRef<number>(0);
|
|
34
|
+
const onResizeRef = useRef(onResize);
|
|
12
35
|
|
|
13
36
|
useEffect(() => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const resize = () => {
|
|
19
|
-
textarea.style.height = "auto";
|
|
20
|
-
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight || "16");
|
|
21
|
-
const minHeight = lineHeight * minRows;
|
|
22
|
-
const maxHeight = lineHeight * maxRows;
|
|
23
|
-
|
|
24
|
-
const clampedHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
|
25
|
-
textarea.style.height = `${clampedHeight}px`;
|
|
26
|
-
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
27
|
-
};
|
|
37
|
+
onResizeRef.current = onResize;
|
|
38
|
+
}, [onResize]);
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!textarea || !enabled) return;
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
|
|
45
|
+
// All callers go through this coalescing scheduler: repeated pings in a
|
|
46
|
+
// single frame collapse to one reflow.
|
|
47
|
+
const schedule = () => {
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
if (frameRef.current) return;
|
|
50
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
51
|
+
frameRef.current = 0;
|
|
52
|
+
if (!cancelled) onResizeRef.current();
|
|
53
|
+
});
|
|
35
54
|
};
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
// Initial pass after layout.
|
|
57
|
+
schedule();
|
|
58
|
+
|
|
59
|
+
// One-shot: re-run once the textarea actually enters the layout tree.
|
|
60
|
+
// This is the piece that fixes the drag-drop remount and
|
|
61
|
+
// snippet-insert-while-hidden cases OverType itself cannot recover from.
|
|
62
|
+
const intersectionObserver = new IntersectionObserver((entries) => {
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.isIntersecting) {
|
|
65
|
+
schedule();
|
|
66
|
+
intersectionObserver.disconnect();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
intersectionObserver.observe(textarea);
|
|
72
|
+
|
|
73
|
+
const unregisterFonts = registerFontsReady(schedule);
|
|
39
74
|
|
|
40
75
|
return () => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
76
|
+
cancelled = true;
|
|
77
|
+
intersectionObserver.disconnect();
|
|
78
|
+
unregisterFonts();
|
|
79
|
+
if (frameRef.current) {
|
|
80
|
+
cancelAnimationFrame(frameRef.current);
|
|
81
|
+
frameRef.current = 0;
|
|
82
|
+
}
|
|
44
83
|
};
|
|
45
|
-
}, [textarea,
|
|
84
|
+
}, [textarea, enabled]);
|
|
85
|
+
|
|
86
|
+
return useCallback(() => {
|
|
87
|
+
onResizeRef.current();
|
|
88
|
+
}, []);
|
|
46
89
|
}
|
|
@@ -197,9 +197,9 @@ describe("blocksToMarkdown", () => {
|
|
|
197
197
|
expect(blocksToMarkdown(blocks)).toBe(
|
|
198
198
|
[
|
|
199
199
|
"* Open the Login page.",
|
|
200
|
-
" *Expected*: The Login page loads successfully.",
|
|
200
|
+
" *Expected result*: The Login page loads successfully.",
|
|
201
201
|
"* Enter a valid username.",
|
|
202
|
-
" *Expected*: The username is accepted.",
|
|
202
|
+
" *Expected result*: The username is accepted.",
|
|
203
203
|
].join("\n"),
|
|
204
204
|
);
|
|
205
205
|
});
|
|
@@ -242,7 +242,7 @@ describe("blocksToMarkdown", () => {
|
|
|
242
242
|
];
|
|
243
243
|
|
|
244
244
|
expect(blocksToMarkdown(blocks)).toBe(
|
|
245
|
-
["* ", " *Expected*: Login form visible"].join("\n"),
|
|
245
|
+
["* ", " *Expected result*: Login form visible"].join("\n"),
|
|
246
246
|
);
|
|
247
247
|
});
|
|
248
248
|
|
|
@@ -355,7 +355,7 @@ describe("blocksToMarkdown", () => {
|
|
|
355
355
|
expect(blocksToMarkdown(blocks)).toBe(
|
|
356
356
|
[
|
|
357
357
|
"* **Click** the _Login_ button",
|
|
358
|
-
" *Expected*: **Success** is shown",
|
|
358
|
+
" *Expected result*: **Success** is shown",
|
|
359
359
|
" Second line with <u>underline</u>",
|
|
360
360
|
].join("\n"),
|
|
361
361
|
);
|
|
@@ -382,7 +382,7 @@ describe("blocksToMarkdown", () => {
|
|
|
382
382
|
"* Navigate to login",
|
|
383
383
|
" Open browser",
|
|
384
384
|
" Go to login page",
|
|
385
|
-
" *Expected*: Login form visible",
|
|
385
|
+
" *Expected result*: Login form visible",
|
|
386
386
|
].join("\n"),
|
|
387
387
|
);
|
|
388
388
|
});
|
|
@@ -429,7 +429,7 @@ describe("blocksToMarkdown", () => {
|
|
|
429
429
|
" asdsadas",
|
|
430
430
|
" ```",
|
|
431
431
|
" ",
|
|
432
|
-
" *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.",
|
|
433
433
|
].join("\n"),
|
|
434
434
|
);
|
|
435
435
|
});
|
|
@@ -1073,7 +1073,7 @@ describe("markdownToBlocks", () => {
|
|
|
1073
1073
|
" asdsadas",
|
|
1074
1074
|
" ```",
|
|
1075
1075
|
" ",
|
|
1076
|
-
" *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.",
|
|
1077
1077
|
].join("\n"),
|
|
1078
1078
|
);
|
|
1079
1079
|
});
|
|
@@ -1307,7 +1307,7 @@ describe("markdownToBlocks", () => {
|
|
|
1307
1307
|
expect(markdownRoundTrip).toBe(
|
|
1308
1308
|
[
|
|
1309
1309
|
"* Display the generated report.",
|
|
1310
|
-
" *Expected*: ",
|
|
1310
|
+
" *Expected result*: ",
|
|
1311
1311
|
].join("\n"),
|
|
1312
1312
|
);
|
|
1313
1313
|
});
|
|
@@ -1354,7 +1354,7 @@ describe("markdownToBlocks", () => {
|
|
|
1354
1354
|
expect(roundTrip).toBe(
|
|
1355
1355
|
[
|
|
1356
1356
|
"* Should open login screen",
|
|
1357
|
-
" *Expected*: Login should look like this",
|
|
1357
|
+
" *Expected result*: Login should look like this",
|
|
1358
1358
|
" ",
|
|
1359
1359
|
].join("\n"),
|
|
1360
1360
|
);
|
|
@@ -1493,9 +1493,9 @@ describe("markdownToBlocks", () => {
|
|
|
1493
1493
|
expect(roundTrip).toBe(
|
|
1494
1494
|
[
|
|
1495
1495
|
"* Existing email + invalid password",
|
|
1496
|
-
" *Expected*: 'Oops, wrong email or password' is displayed",
|
|
1496
|
+
" *Expected result*: 'Oops, wrong email or password' is displayed",
|
|
1497
1497
|
"* Not existing email + valid password",
|
|
1498
|
-
" *Expected*: 'Oops, wrong email or password' is displayed",
|
|
1498
|
+
" *Expected result*: 'Oops, wrong email or password' is displayed",
|
|
1499
1499
|
].join("\n"),
|
|
1500
1500
|
);
|
|
1501
1501
|
});
|
|
@@ -2667,6 +2667,27 @@ describe("blank line <-> empty paragraph mapping", () => {
|
|
|
2667
2667
|
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
2668
2668
|
});
|
|
2669
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
|
+
|
|
2670
2691
|
it("preserves blank lines across the user-reported screenshot example", () => {
|
|
2671
2692
|
const markdown = [
|
|
2672
2693
|
"### Requirements",
|