testomatio-editor-blocks 0.4.65 → 0.4.67
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.d.ts +5 -0
- package/package/editor/blocks/step.js +273 -213
- package/package/editor/blocks/stepHorizontalView.d.ts +1 -0
- package/package/editor/blocks/stepHorizontalView.js +2 -2
- package/package/editor/blocks/testMeta.d.ts +37 -0
- package/package/editor/blocks/testMeta.js +111 -0
- package/package/editor/blocks/useDeferredMount.d.ts +26 -0
- package/package/editor/blocks/useDeferredMount.js +54 -0
- package/package/editor/createMarkdownPasteHandler.js +56 -9
- package/package/editor/customMarkdownConverter.js +127 -10
- package/package/editor/customSchema.d.ts +32 -0
- package/package/editor/customSchema.js +2 -0
- package/package/editor/testMetaFields.d.ts +17 -0
- package/package/editor/testMetaFields.js +33 -0
- package/package/index.d.ts +2 -0
- package/package/index.js +2 -0
- package/package/styles.css +215 -0
- package/package.json +1 -1
- package/src/App.tsx +54 -15
- package/src/editor/blocks/step.tsx +198 -47
- package/src/editor/blocks/stepHorizontalView.tsx +3 -0
- package/src/editor/blocks/stepNumber.test.ts +39 -0
- package/src/editor/blocks/testMeta.tsx +242 -0
- package/src/editor/blocks/useDeferredMount.ts +66 -0
- package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
- package/src/editor/createMarkdownPasteHandler.ts +60 -8
- package/src/editor/customMarkdownConverter.test.ts +135 -0
- package/src/editor/customMarkdownConverter.ts +125 -0
- package/src/editor/customSchema.tsx +2 -0
- package/src/editor/renderingPerf.test.ts +59 -0
- package/src/editor/styles.css +215 -0
- package/src/editor/testMetaFields.ts +53 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { getMetaFieldSuggestions } from "../testMetaFields";
|
|
5
|
+
const ID_KEYS = new Set(["id"]);
|
|
6
|
+
function parseMetaFields(raw) {
|
|
7
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
if (!Array.isArray(parsed)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return parsed
|
|
16
|
+
.filter((item) => item && typeof item === "object")
|
|
17
|
+
.map((item) => ({
|
|
18
|
+
key: typeof item.key === "string" ? item.key : "",
|
|
19
|
+
value: typeof item.value === "string" ? item.value : "",
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function serializeMetaFields(fields) {
|
|
27
|
+
return JSON.stringify(fields);
|
|
28
|
+
}
|
|
29
|
+
function AddFieldMenu({ kind, usedKeys, onPick }) {
|
|
30
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
31
|
+
const containerRef = useRef(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!isOpen)
|
|
34
|
+
return;
|
|
35
|
+
const handleMouseDown = (event) => {
|
|
36
|
+
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
37
|
+
setIsOpen(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
41
|
+
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
42
|
+
}, [isOpen]);
|
|
43
|
+
const available = useMemo(() => {
|
|
44
|
+
const used = new Set(usedKeys.map((k) => k.trim().toLowerCase()));
|
|
45
|
+
return getMetaFieldSuggestions(kind).filter((s) => !used.has(s.key.trim().toLowerCase()));
|
|
46
|
+
}, [kind, usedKeys]);
|
|
47
|
+
const pick = useCallback((key) => {
|
|
48
|
+
onPick(key);
|
|
49
|
+
setIsOpen(false);
|
|
50
|
+
}, [onPick]);
|
|
51
|
+
return (_jsxs("div", { className: "bn-testmeta__add-wrap", ref: containerRef, children: [_jsx("button", { type: "button", className: "bn-testmeta__add", "aria-label": "Add field", title: "Add field", onClick: () => setIsOpen((prev) => !prev), children: "+" }), isOpen && (_jsxs("div", { className: "bn-testmeta__menu", role: "listbox", children: [available.map((suggestion) => {
|
|
52
|
+
var _a;
|
|
53
|
+
return (_jsx("button", { type: "button", role: "option", className: "bn-testmeta__menu-item", onMouseDown: (event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
pick(suggestion.key);
|
|
56
|
+
}, children: (_a = suggestion.label) !== null && _a !== void 0 ? _a : suggestion.key }, suggestion.key));
|
|
57
|
+
}), _jsx("button", { type: "button", className: "bn-testmeta__menu-item bn-testmeta__menu-item--custom", onMouseDown: (event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
pick("");
|
|
60
|
+
}, children: "Custom field\u2026" })] }))] }));
|
|
61
|
+
}
|
|
62
|
+
export const testMetaBlock = createReactBlockSpec({
|
|
63
|
+
type: "testMeta",
|
|
64
|
+
content: "none",
|
|
65
|
+
propSchema: {
|
|
66
|
+
// "test" | "suite" — which keyword the comment opened with.
|
|
67
|
+
metaKind: {
|
|
68
|
+
default: "test",
|
|
69
|
+
},
|
|
70
|
+
// JSON-encoded MetaField[] so insertion order is preserved.
|
|
71
|
+
metaFields: {
|
|
72
|
+
default: "[]",
|
|
73
|
+
},
|
|
74
|
+
// true when the source comment was a one-liner (`<!-- test id: @T.. -->`).
|
|
75
|
+
metaInline: {
|
|
76
|
+
default: false,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}, {
|
|
80
|
+
render: ({ block, editor }) => {
|
|
81
|
+
const kind = block.props.metaKind === "suite" ? "suite" : "test";
|
|
82
|
+
const fields = useMemo(() => parseMetaFields(block.props.metaFields), [block.props.metaFields]);
|
|
83
|
+
const commitFields = useCallback((next) => {
|
|
84
|
+
editor.updateBlock(block.id, {
|
|
85
|
+
props: { metaFields: serializeMetaFields(next) },
|
|
86
|
+
});
|
|
87
|
+
}, [block.id, editor]);
|
|
88
|
+
const handleValueChange = useCallback((index, value) => {
|
|
89
|
+
const next = fields.map((field, i) => i === index ? { ...field, value } : field);
|
|
90
|
+
commitFields(next);
|
|
91
|
+
}, [fields, commitFields]);
|
|
92
|
+
const handleKeyChange = useCallback((index, key) => {
|
|
93
|
+
const next = fields.map((field, i) => i === index ? { ...field, key } : field);
|
|
94
|
+
commitFields(next);
|
|
95
|
+
}, [fields, commitFields]);
|
|
96
|
+
const handleRemove = useCallback((index) => {
|
|
97
|
+
commitFields(fields.filter((_, i) => i !== index));
|
|
98
|
+
}, [fields, commitFields]);
|
|
99
|
+
const handleAddField = useCallback((key) => {
|
|
100
|
+
commitFields([...fields, { key, value: "" }]);
|
|
101
|
+
}, [fields, commitFields]);
|
|
102
|
+
const usedKeys = useMemo(() => fields.map((f) => f.key), [fields]);
|
|
103
|
+
// The read-only `id` is shown inline on the header line next to the kind
|
|
104
|
+
// label; every other field renders as an editable row below.
|
|
105
|
+
const idField = fields.find((f) => ID_KEYS.has(f.key.trim().toLowerCase()));
|
|
106
|
+
const editableFields = fields
|
|
107
|
+
.map((field, index) => ({ field, index }))
|
|
108
|
+
.filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
|
|
109
|
+
return (_jsxs("div", { className: "bn-testmeta", "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField })] }), editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defers mounting of expensive block content until the element is at (or near)
|
|
3
|
+
* the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
|
|
4
|
+
* editor) render a cheap placeholder first; the real interactive content is
|
|
5
|
+
* mounted only once the block scrolls into view. This keeps pasting/loading a
|
|
6
|
+
* large document fast — only the visible steps pay the editor-init cost up
|
|
7
|
+
* front, the rest are upgraded lazily as the user scrolls.
|
|
8
|
+
*
|
|
9
|
+
* Returns a ref to attach to the wrapper element and a boolean that flips to
|
|
10
|
+
* `true` once (and stays true — we never tear an editor back down).
|
|
11
|
+
*
|
|
12
|
+
* `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
|
|
13
|
+
* `focus: true` (a click/focus on the placeholder) records that the freshly
|
|
14
|
+
* mounted content should take focus, so a single click on a preview starts
|
|
15
|
+
* editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
|
|
16
|
+
* alone via `shouldFocusOnActivate === false`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useDeferredMount<T extends HTMLElement>(options?: {
|
|
19
|
+
rootMargin?: string;
|
|
20
|
+
initiallyActive?: boolean;
|
|
21
|
+
}): {
|
|
22
|
+
ref: React.RefObject<T | null>;
|
|
23
|
+
active: boolean;
|
|
24
|
+
activate: (focus?: boolean) => void;
|
|
25
|
+
shouldFocusOnActivate: boolean;
|
|
26
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Defers mounting of expensive block content until the element is at (or near)
|
|
4
|
+
* the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
|
|
5
|
+
* editor) render a cheap placeholder first; the real interactive content is
|
|
6
|
+
* mounted only once the block scrolls into view. This keeps pasting/loading a
|
|
7
|
+
* large document fast — only the visible steps pay the editor-init cost up
|
|
8
|
+
* front, the rest are upgraded lazily as the user scrolls.
|
|
9
|
+
*
|
|
10
|
+
* Returns a ref to attach to the wrapper element and a boolean that flips to
|
|
11
|
+
* `true` once (and stays true — we never tear an editor back down).
|
|
12
|
+
*
|
|
13
|
+
* `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
|
|
14
|
+
* `focus: true` (a click/focus on the placeholder) records that the freshly
|
|
15
|
+
* mounted content should take focus, so a single click on a preview starts
|
|
16
|
+
* editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
|
|
17
|
+
* alone via `shouldFocusOnActivate === false`.
|
|
18
|
+
*/
|
|
19
|
+
export function useDeferredMount(options = {}) {
|
|
20
|
+
const { rootMargin = "300px 0px", initiallyActive = false } = options;
|
|
21
|
+
const ref = useRef(null);
|
|
22
|
+
const [active, setActive] = useState(initiallyActive);
|
|
23
|
+
const activeRef = useRef(active);
|
|
24
|
+
activeRef.current = active;
|
|
25
|
+
const focusOnActivateRef = useRef(false);
|
|
26
|
+
const activate = (focus = false) => {
|
|
27
|
+
if (activeRef.current)
|
|
28
|
+
return;
|
|
29
|
+
if (focus)
|
|
30
|
+
focusOnActivateRef.current = true;
|
|
31
|
+
setActive(true);
|
|
32
|
+
};
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (activeRef.current)
|
|
35
|
+
return;
|
|
36
|
+
const el = ref.current;
|
|
37
|
+
if (!el)
|
|
38
|
+
return;
|
|
39
|
+
// Environments without IntersectionObserver (or SSR) just mount eagerly.
|
|
40
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
41
|
+
setActive(true);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const observer = new IntersectionObserver((entries) => {
|
|
45
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
46
|
+
setActive(true);
|
|
47
|
+
observer.disconnect();
|
|
48
|
+
}
|
|
49
|
+
}, { rootMargin });
|
|
50
|
+
observer.observe(el);
|
|
51
|
+
return () => observer.disconnect();
|
|
52
|
+
}, [rootMargin]);
|
|
53
|
+
return { ref, active, activate, shouldFocusOnActivate: focusOnActivateRef.current };
|
|
54
|
+
}
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
|
|
2
|
+
// For large pastes, only the first chunk is inserted synchronously (enough to
|
|
3
|
+
// fill the viewport); the remaining blocks are streamed in during idle time so
|
|
4
|
+
// the editor stays responsive and the user sees content immediately instead of
|
|
5
|
+
// the main thread freezing while a thousand-block document is built at once.
|
|
6
|
+
const CHUNK_THRESHOLD = 150;
|
|
7
|
+
const FIRST_CHUNK = 50;
|
|
8
|
+
const REST_CHUNK = 40;
|
|
9
|
+
const scheduleIdle = typeof window !== "undefined" && typeof window.requestIdleCallback === "function"
|
|
10
|
+
? (cb) => window.requestIdleCallback(() => cb(), { timeout: 200 })
|
|
11
|
+
: (cb) => setTimeout(cb, 0);
|
|
12
|
+
function lastBlockId(blocks) {
|
|
13
|
+
var _a;
|
|
14
|
+
return blocks.length ? (_a = blocks[blocks.length - 1]) === null || _a === void 0 ? void 0 : _a.id : undefined;
|
|
15
|
+
}
|
|
2
16
|
function isInlineOnlyPaste(plainText, parsedBlocks) {
|
|
3
17
|
if (parsedBlocks.length !== 1)
|
|
4
18
|
return false;
|
|
@@ -41,21 +55,54 @@ export function createMarkdownPasteHandler(converter) {
|
|
|
41
55
|
}
|
|
42
56
|
const selection = editor.getSelection();
|
|
43
57
|
const selectedIds = (_h = (_g = selection === null || selection === void 0 ? void 0 : selection.blocks) === null || _g === void 0 ? void 0 : _g.map((block) => block.id).filter((id) => Boolean(id))) !== null && _h !== void 0 ? _h : [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
// Insert the initial set of blocks at the paste location, returning the
|
|
59
|
+
// inserted blocks so subsequent chunks can be appended after the last one.
|
|
60
|
+
const insertInitial = (blocksToInsert) => {
|
|
61
|
+
if (selectedIds.length > 0) {
|
|
62
|
+
return editor.replaceBlocks(selectedIds, blocksToInsert).insertedBlocks;
|
|
63
|
+
}
|
|
48
64
|
const cursorBlock = editor.getTextCursorPosition().block;
|
|
49
65
|
if (cursorBlock) {
|
|
50
|
-
editor.replaceBlocks([cursorBlock.id],
|
|
66
|
+
return editor.replaceBlocks([cursorBlock.id], blocksToInsert).insertedBlocks;
|
|
51
67
|
}
|
|
52
|
-
|
|
68
|
+
if (editor.document.length > 0) {
|
|
53
69
|
const reference = editor.document[editor.document.length - 1];
|
|
54
|
-
editor.insertBlocks(
|
|
70
|
+
return editor.insertBlocks(blocksToInsert, reference.id, "after");
|
|
55
71
|
}
|
|
56
|
-
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
if (parsedBlocks.length <= CHUNK_THRESHOLD) {
|
|
75
|
+
// Small paste: insert everything in one transaction (original behaviour).
|
|
76
|
+
if (insertInitial(parsedBlocks) === null)
|
|
57
77
|
return defaultPasteHandler();
|
|
58
|
-
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Large paste: render the first screenful now, stream the rest in idle
|
|
81
|
+
// time so the main thread is never blocked building the whole document.
|
|
82
|
+
const firstChunk = parsedBlocks.slice(0, FIRST_CHUNK);
|
|
83
|
+
const rest = parsedBlocks.slice(FIRST_CHUNK);
|
|
84
|
+
const inserted = insertInitial(firstChunk);
|
|
85
|
+
if (inserted === null)
|
|
86
|
+
return defaultPasteHandler();
|
|
87
|
+
let anchorId = lastBlockId(inserted);
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
const pump = () => {
|
|
90
|
+
var _a;
|
|
91
|
+
if (!anchorId || cursor >= rest.length)
|
|
92
|
+
return;
|
|
93
|
+
const batch = rest.slice(cursor, cursor + REST_CHUNK);
|
|
94
|
+
cursor += REST_CHUNK;
|
|
95
|
+
try {
|
|
96
|
+
const insertedBatch = editor.insertBlocks(batch, anchorId, "after");
|
|
97
|
+
anchorId = (_a = lastBlockId(insertedBatch)) !== null && _a !== void 0 ? _a : anchorId;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return; // stop streaming on any structural error
|
|
101
|
+
}
|
|
102
|
+
if (cursor < rest.length)
|
|
103
|
+
scheduleIdle(pump);
|
|
104
|
+
};
|
|
105
|
+
scheduleIdle(pump);
|
|
59
106
|
}
|
|
60
107
|
editor.focus();
|
|
61
108
|
return true;
|
|
@@ -224,7 +224,7 @@ function serializeChildren(block, ctx) {
|
|
|
224
224
|
return serializeBlocks(block.children, childCtx);
|
|
225
225
|
}
|
|
226
226
|
function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
227
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
|
|
227
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
|
228
228
|
const lines = [];
|
|
229
229
|
const indent = ctx.listDepth > 0 ? " ".repeat(ctx.listDepth) : "";
|
|
230
230
|
switch (block.type) {
|
|
@@ -316,19 +316,48 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
316
316
|
}
|
|
317
317
|
return lines;
|
|
318
318
|
}
|
|
319
|
+
case "testMeta": {
|
|
320
|
+
const kind = block.props.metaKind === "suite" ? "suite" : "test";
|
|
321
|
+
const inline = Boolean(block.props.metaInline);
|
|
322
|
+
let fields = [];
|
|
323
|
+
try {
|
|
324
|
+
const parsed = JSON.parse(((_e = block.props.metaFields) !== null && _e !== void 0 ? _e : "[]"));
|
|
325
|
+
if (Array.isArray(parsed)) {
|
|
326
|
+
fields = parsed
|
|
327
|
+
.filter((f) => f && typeof f === "object" && typeof f.key === "string")
|
|
328
|
+
.map((f) => ({ key: f.key.trim(), value: typeof f.value === "string" ? f.value.trim() : "" }))
|
|
329
|
+
// Skip incomplete fields: both a key and a value are required.
|
|
330
|
+
.filter((f) => f.key.length > 0 && f.value.length > 0);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
fields = [];
|
|
335
|
+
}
|
|
336
|
+
// Preserve the one-liner form only when it still fits on a single line
|
|
337
|
+
// (a one-liner holds at most one `key: value` pair).
|
|
338
|
+
if (inline && fields.length <= 1) {
|
|
339
|
+
const field = fields[0];
|
|
340
|
+
lines.push(field ? `<!-- ${kind} ${field.key}: ${field.value} -->` : `<!-- ${kind} -->`);
|
|
341
|
+
return lines;
|
|
342
|
+
}
|
|
343
|
+
lines.push(`<!-- ${kind}`);
|
|
344
|
+
fields.forEach((field) => lines.push(`${field.key}: ${field.value}`));
|
|
345
|
+
lines.push("-->");
|
|
346
|
+
return lines;
|
|
347
|
+
}
|
|
319
348
|
case "testStep":
|
|
320
349
|
case "snippet": {
|
|
321
350
|
const isSnippet = block.type === "snippet";
|
|
322
|
-
const snippetId = isSnippet ? ((
|
|
351
|
+
const snippetId = isSnippet ? ((_f = block.props.snippetId) !== null && _f !== void 0 ? _f : "").trim() : "";
|
|
323
352
|
const stepTitle = isSnippet
|
|
324
|
-
? ((
|
|
325
|
-
: ((
|
|
353
|
+
? ((_g = block.props.snippetTitle) !== null && _g !== void 0 ? _g : "").trim()
|
|
354
|
+
: ((_h = block.props.stepTitle) !== null && _h !== void 0 ? _h : "").trim();
|
|
326
355
|
const stepData = isSnippet
|
|
327
|
-
? ((
|
|
328
|
-
: ((
|
|
356
|
+
? ((_j = block.props.snippetData) !== null && _j !== void 0 ? _j : "").trim()
|
|
357
|
+
: ((_k = block.props.stepData) !== null && _k !== void 0 ? _k : "").trim();
|
|
329
358
|
const expectedResult = isSnippet
|
|
330
|
-
? ((
|
|
331
|
-
: ((
|
|
359
|
+
? ((_l = block.props.snippetExpectedResult) !== null && _l !== void 0 ? _l : "").trim()
|
|
360
|
+
: ((_m = block.props.expectedResult) !== null && _m !== void 0 ? _m : "").trim();
|
|
332
361
|
if (isSnippet) {
|
|
333
362
|
if (snippetId) {
|
|
334
363
|
lines.push(`<!-- begin snippet #${snippetId} -->`);
|
|
@@ -352,7 +381,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
352
381
|
const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
|
|
353
382
|
const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
|
|
354
383
|
if (normalizedTitle.length > 0 || hasContent) {
|
|
355
|
-
const listStyle = (
|
|
384
|
+
const listStyle = (_o = block.props.listStyle) !== null && _o !== void 0 ? _o : "bullet";
|
|
356
385
|
const prefix = listStyle === "ordered" ? `${(stepIndex !== null && stepIndex !== void 0 ? stepIndex : 0) + 1}.` : "*";
|
|
357
386
|
lines.push(normalizedTitle.length > 0 ? `${prefix} ${escapeStepContent(normalizedTitle)}` : `${prefix} `);
|
|
358
387
|
}
|
|
@@ -406,7 +435,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
406
435
|
return lines;
|
|
407
436
|
}
|
|
408
437
|
const headerRowCount = rows.length
|
|
409
|
-
? Math.min(rows.length, Math.max((
|
|
438
|
+
? Math.min(rows.length, Math.max((_p = tableContent.headerRows) !== null && _p !== void 0 ? _p : 1, 1))
|
|
410
439
|
: 0;
|
|
411
440
|
const columnAlignments = new Array(columnCount).fill("left");
|
|
412
441
|
const getCellAlignment = (cell) => {
|
|
@@ -1145,6 +1174,86 @@ function parseParagraph(lines, index) {
|
|
|
1145
1174
|
nextIndex: index + 1,
|
|
1146
1175
|
};
|
|
1147
1176
|
}
|
|
1177
|
+
const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
|
|
1178
|
+
function metaFieldsFromBody(bodyLines) {
|
|
1179
|
+
const fields = [];
|
|
1180
|
+
for (const raw of bodyLines) {
|
|
1181
|
+
const line = raw.trim();
|
|
1182
|
+
if (!line)
|
|
1183
|
+
continue;
|
|
1184
|
+
const colon = line.indexOf(":");
|
|
1185
|
+
// "Each line is `key: value`; lines without `:` are ignored."
|
|
1186
|
+
if (colon === -1)
|
|
1187
|
+
continue;
|
|
1188
|
+
const key = line.slice(0, colon).trim();
|
|
1189
|
+
const value = line.slice(colon + 1).trim();
|
|
1190
|
+
if (!key)
|
|
1191
|
+
continue;
|
|
1192
|
+
fields.push({ key, value });
|
|
1193
|
+
}
|
|
1194
|
+
return fields;
|
|
1195
|
+
}
|
|
1196
|
+
function parseMetaComment(lines, index) {
|
|
1197
|
+
const first = lines[index];
|
|
1198
|
+
const openMatch = first.match(META_COMMENT_OPEN_REGEX);
|
|
1199
|
+
if (!openMatch) {
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
const kind = openMatch[1].toLowerCase();
|
|
1203
|
+
let bodyLines = [];
|
|
1204
|
+
let inline = false;
|
|
1205
|
+
let nextIndex;
|
|
1206
|
+
// One-liner: opening and closing markers on the same line.
|
|
1207
|
+
const oneLine = first.match(/^<!--\s*(?:test|suite)\b\s*([\s\S]*?)\s*-->\s*$/i);
|
|
1208
|
+
if (oneLine) {
|
|
1209
|
+
inline = true;
|
|
1210
|
+
if (oneLine[1].trim()) {
|
|
1211
|
+
bodyLines = [oneLine[1].trim()];
|
|
1212
|
+
}
|
|
1213
|
+
nextIndex = index + 1;
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
// Block form: keyword line, fields on their own lines, closing `-->`.
|
|
1217
|
+
const afterKeyword = first.replace(/^<!--\s*(?:test|suite)\b/i, "").trim();
|
|
1218
|
+
if (afterKeyword) {
|
|
1219
|
+
bodyLines.push(afterKeyword);
|
|
1220
|
+
}
|
|
1221
|
+
let next = index + 1;
|
|
1222
|
+
let closed = false;
|
|
1223
|
+
while (next < lines.length) {
|
|
1224
|
+
const current = lines[next];
|
|
1225
|
+
if (/-->\s*$/.test(current)) {
|
|
1226
|
+
const beforeClose = current.replace(/-->\s*$/, "").trim();
|
|
1227
|
+
if (beforeClose) {
|
|
1228
|
+
bodyLines.push(beforeClose);
|
|
1229
|
+
}
|
|
1230
|
+
next += 1;
|
|
1231
|
+
closed = true;
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
bodyLines.push(current);
|
|
1235
|
+
next += 1;
|
|
1236
|
+
}
|
|
1237
|
+
if (!closed) {
|
|
1238
|
+
// Unterminated comment — let normal parsing handle these lines.
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
nextIndex = next;
|
|
1242
|
+
}
|
|
1243
|
+
const fields = metaFieldsFromBody(bodyLines);
|
|
1244
|
+
return {
|
|
1245
|
+
block: {
|
|
1246
|
+
type: "testMeta",
|
|
1247
|
+
props: {
|
|
1248
|
+
metaKind: kind,
|
|
1249
|
+
metaFields: JSON.stringify(fields),
|
|
1250
|
+
metaInline: inline,
|
|
1251
|
+
},
|
|
1252
|
+
children: [],
|
|
1253
|
+
},
|
|
1254
|
+
nextIndex,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1148
1257
|
function parseSnippetWrapper(lines, index) {
|
|
1149
1258
|
const trimmed = lines[index].trim();
|
|
1150
1259
|
const startMatch = trimmed.match(/^<!--\s*begin snippet\s*#?\s*([^\s>]+)\s*-->/i);
|
|
@@ -1257,6 +1366,14 @@ export function markdownToBlocks(markdown, _options) {
|
|
|
1257
1366
|
index += 1;
|
|
1258
1367
|
continue;
|
|
1259
1368
|
}
|
|
1369
|
+
// Test/suite metadata comments can appear anywhere (typically at the top of
|
|
1370
|
+
// a document or right after a heading), so this runs ungated.
|
|
1371
|
+
const metaComment = parseMetaComment(lines, index);
|
|
1372
|
+
if (metaComment) {
|
|
1373
|
+
blocks.push(metaComment.block);
|
|
1374
|
+
index = metaComment.nextIndex;
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1260
1377
|
const snippetWrapper = stepsHeadingLevel !== null
|
|
1261
1378
|
? parseSnippetWrapper(lines, index)
|
|
1262
1379
|
: null;
|
|
@@ -117,6 +117,38 @@ export declare const customSchema: BlockNoteSchema<import("@blocknote/core").Blo
|
|
|
117
117
|
};
|
|
118
118
|
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
119
119
|
};
|
|
120
|
+
testMeta: {
|
|
121
|
+
config: {
|
|
122
|
+
readonly type: "testMeta";
|
|
123
|
+
readonly content: "none";
|
|
124
|
+
readonly propSchema: {
|
|
125
|
+
readonly metaKind: {
|
|
126
|
+
readonly default: "test";
|
|
127
|
+
};
|
|
128
|
+
readonly metaFields: {
|
|
129
|
+
readonly default: "[]";
|
|
130
|
+
};
|
|
131
|
+
readonly metaInline: {
|
|
132
|
+
readonly default: false;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
implementation: import("@blocknote/core").TiptapBlockImplementation<{
|
|
137
|
+
readonly type: "testMeta";
|
|
138
|
+
readonly content: "none";
|
|
139
|
+
readonly propSchema: {
|
|
140
|
+
readonly metaKind: {
|
|
141
|
+
readonly default: "test";
|
|
142
|
+
};
|
|
143
|
+
readonly metaFields: {
|
|
144
|
+
readonly default: "[]";
|
|
145
|
+
};
|
|
146
|
+
readonly metaInline: {
|
|
147
|
+
readonly default: false;
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
151
|
+
};
|
|
120
152
|
paragraph: {
|
|
121
153
|
config: {
|
|
122
154
|
type: "paragraph";
|
|
@@ -2,6 +2,7 @@ import { defaultBlockSpecs } from "@blocknote/core";
|
|
|
2
2
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
3
|
import { stepBlock } from "./blocks/step";
|
|
4
4
|
import { snippetBlock } from "./blocks/snippet";
|
|
5
|
+
import { testMetaBlock } from "./blocks/testMeta";
|
|
5
6
|
import { fileBlock } from "./blocks/fileBlock";
|
|
6
7
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
7
8
|
export const customSchema = BlockNoteSchema.create({
|
|
@@ -10,6 +11,7 @@ export const customSchema = BlockNoteSchema.create({
|
|
|
10
11
|
file: fileBlock,
|
|
11
12
|
testStep: stepBlock,
|
|
12
13
|
snippet: snippetBlock,
|
|
14
|
+
testMeta: testMetaBlock,
|
|
13
15
|
},
|
|
14
16
|
});
|
|
15
17
|
export const __markdownTestUtils = {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type MetaFieldSuggestion = {
|
|
2
|
+
/** The field key that gets inserted, e.g. "priority". */
|
|
3
|
+
key: string;
|
|
4
|
+
/** Optional display label; defaults to `key`. */
|
|
5
|
+
label?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Either a flat list (applied to both test and suite blocks) or per-kind lists.
|
|
9
|
+
* Configure from the host app via `setMetaFieldSuggestions` so embedders can
|
|
10
|
+
* plug in their own set of suggested metadata fields.
|
|
11
|
+
*/
|
|
12
|
+
export type MetaFieldSuggestionsConfig = MetaFieldSuggestion[] | {
|
|
13
|
+
test?: MetaFieldSuggestion[];
|
|
14
|
+
suite?: MetaFieldSuggestion[];
|
|
15
|
+
};
|
|
16
|
+
export declare function setMetaFieldSuggestions(config: MetaFieldSuggestionsConfig | null): void;
|
|
17
|
+
export declare function getMetaFieldSuggestions(kind: "test" | "suite"): MetaFieldSuggestion[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Defaults follow the classical Testomatio markdown format. `id` is intentionally
|
|
2
|
+
// omitted: it is a read-only, system-assigned field, not something users add.
|
|
3
|
+
const DEFAULT_TEST_FIELDS = [
|
|
4
|
+
{ key: "priority" },
|
|
5
|
+
{ key: "type" },
|
|
6
|
+
{ key: "tags" },
|
|
7
|
+
{ key: "labels" },
|
|
8
|
+
{ key: "assignee" },
|
|
9
|
+
{ key: "creator" },
|
|
10
|
+
{ key: "shared" },
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_SUITE_FIELDS = [
|
|
13
|
+
{ key: "emoji" },
|
|
14
|
+
{ key: "tags" },
|
|
15
|
+
{ key: "labels" },
|
|
16
|
+
{ key: "assignee" },
|
|
17
|
+
];
|
|
18
|
+
let configured = null;
|
|
19
|
+
export function setMetaFieldSuggestions(config) {
|
|
20
|
+
configured = config;
|
|
21
|
+
}
|
|
22
|
+
export function getMetaFieldSuggestions(kind) {
|
|
23
|
+
if (configured) {
|
|
24
|
+
if (Array.isArray(configured)) {
|
|
25
|
+
return configured;
|
|
26
|
+
}
|
|
27
|
+
const list = kind === "suite" ? configured.suite : configured.test;
|
|
28
|
+
if (list) {
|
|
29
|
+
return list;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return kind === "suite" ? DEFAULT_SUITE_FIELDS : DEFAULT_TEST_FIELDS;
|
|
33
|
+
}
|
package/package/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
|
|
2
2
|
export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
|
|
3
3
|
export { snippetBlock } from "./editor/blocks/snippet";
|
|
4
|
+
export { testMetaBlock } from "./editor/blocks/testMeta";
|
|
5
|
+
export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggestion, type MetaFieldSuggestionsConfig, } from "./editor/testMetaFields";
|
|
4
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
5
7
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
6
8
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
package/package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { customSchema, } from "./editor/customSchema";
|
|
2
2
|
export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
|
|
3
3
|
export { snippetBlock } from "./editor/blocks/snippet";
|
|
4
|
+
export { testMetaBlock } from "./editor/blocks/testMeta";
|
|
5
|
+
export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
|
|
4
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
5
7
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
6
8
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|