testomatio-editor-blocks 0.4.80 → 0.4.83
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/exampleMarker.d.ts +19 -0
- package/package/editor/blocks/exampleMarker.js +12 -0
- package/package/editor/blocks/step.js +87 -16
- package/package/editor/customMarkdownConverter.js +22 -0
- package/package/editor/customSchema.d.ts +12 -0
- package/package/editor/customSchema.js +2 -0
- package/package/editor/exampleTableHighlight.d.ts +46 -0
- package/package/editor/exampleTableHighlight.js +131 -0
- package/package/index.d.ts +1 -0
- package/package/index.js +1 -0
- package/package/styles.css +31 -0
- package/package.json +1 -1
- package/src/App.tsx +2 -1
- package/src/editor/blocks/exampleMarker.tsx +24 -0
- package/src/editor/blocks/step.tsx +117 -44
- package/src/editor/customMarkdownConverter.test.ts +28 -0
- package/src/editor/customMarkdownConverter.ts +28 -0
- package/src/editor/customSchema.tsx +2 -0
- package/src/editor/exampleTableHighlight.test.ts +66 -0
- package/src/editor/exampleTableHighlight.ts +148 -0
- package/src/editor/styles.css +31 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read-only marker rendered from the `<!-- example -->` HTML comment that
|
|
3
|
+
* precedes a data/examples table in a testomat.io test file. It shows a compact
|
|
4
|
+
* "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
|
|
5
|
+
* marker reads as a UI label instead of raw comment text. It has no fields and
|
|
6
|
+
* round-trips back to `<!-- example -->` on serialize.
|
|
7
|
+
*/
|
|
8
|
+
export declare const exampleMarkerBlock: {
|
|
9
|
+
config: {
|
|
10
|
+
readonly type: "exampleMarker";
|
|
11
|
+
readonly content: "none";
|
|
12
|
+
readonly propSchema: {};
|
|
13
|
+
};
|
|
14
|
+
implementation: import("@blocknote/core").TiptapBlockImplementation<{
|
|
15
|
+
readonly type: "exampleMarker";
|
|
16
|
+
readonly content: "none";
|
|
17
|
+
readonly propSchema: {};
|
|
18
|
+
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
3
|
+
/**
|
|
4
|
+
* A read-only marker rendered from the `<!-- example -->` HTML comment that
|
|
5
|
+
* precedes a data/examples table in a testomat.io test file. It shows a compact
|
|
6
|
+
* "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
|
|
7
|
+
* marker reads as a UI label instead of raw comment text. It has no fields and
|
|
8
|
+
* round-trips back to `<!-- example -->` on serialize.
|
|
9
|
+
*/
|
|
10
|
+
export const exampleMarkerBlock = createReactBlockSpec({ type: "exampleMarker", content: "none", propSchema: {} }, {
|
|
11
|
+
render: () => (_jsx("div", { className: "bn-example-marker", contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: _jsx("span", { className: "bn-example-marker__label", children: "examples" }) })),
|
|
12
|
+
});
|
|
@@ -313,23 +313,62 @@ function TestStepBlock({ block, editor }) {
|
|
|
313
313
|
focusOnMountRef.current = true;
|
|
314
314
|
setEditing(true);
|
|
315
315
|
}, []);
|
|
316
|
-
// Mousedown rather than click so the editor mounts before focus settles, and
|
|
317
|
-
// preventDefault so the browser doesn't move focus to <body> when the preview
|
|
318
|
-
// (the mousedown target) unmounts mid-click — that stray blur would otherwise
|
|
319
|
-
// immediately tear the new editor back down.
|
|
320
|
-
const beginEditingFromPointer = useCallback((event) => {
|
|
321
|
-
event.preventDefault();
|
|
322
|
-
beginEditing();
|
|
323
|
-
}, [beginEditing]);
|
|
324
316
|
const endEditing = useCallback(() => setEditing(false), []);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
317
|
+
// `editing` read from a ref so the single mousedown guard below (attached once)
|
|
318
|
+
// always sees the current state without re-binding.
|
|
319
|
+
const editingRef = useRef(editing);
|
|
320
|
+
editingRef.current = editing;
|
|
321
|
+
// Zero-box anchor (display: contents) used only to locate this block's node-view
|
|
322
|
+
// element; it adds no layout box, so the step renders exactly as before.
|
|
323
|
+
const anchorRef = useRef(null);
|
|
324
|
+
// The step is a ProseMirror atom node (content: "none") whose node-view
|
|
325
|
+
// (`.bn-block-content`) is wider than the visible step, so the empty area around
|
|
326
|
+
// it — and, in the collapsed preview, the whole strip to the right of the text —
|
|
327
|
+
// is inert atom surface. A plain mousedown there makes ProseMirror node-select the
|
|
328
|
+
// block (the stray blue outline) instead of editing it. One native listener on the
|
|
329
|
+
// node-view guards every mousedown in both the preview and editing states before
|
|
330
|
+
// ProseMirror sees it. Native (not onMouseDown) is required because React 18
|
|
331
|
+
// delegates at the app root, so its handlers run after ProseMirror's own.
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
var _a;
|
|
334
|
+
const blockEl = (_a = anchorRef.current) === null || _a === void 0 ? void 0 : _a.closest(".bn-block-content");
|
|
335
|
+
if (!blockEl) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const handleMouseDown = (event) => {
|
|
339
|
+
const target = event.target;
|
|
340
|
+
// The real editor surface, form controls, and popovers/dialogs need the
|
|
341
|
+
// native mousedown (caret placement, typing, focus) to behave normally.
|
|
342
|
+
if (target === null || target === void 0 ? void 0 : target.closest('.overtype-container, textarea, input, [role="dialog"], .bn-popover-content, .bn-form-popover')) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// Buttons and links (toolbar, view toggle, action bar, autocomplete
|
|
346
|
+
// suggestions): preventDefault keeps the focused field from blurring — a blur
|
|
347
|
+
// node-selects the atom and can trip the focusout teardown on re-render — but
|
|
348
|
+
// we don't stopPropagation, so their own click/mousedown handlers still run.
|
|
349
|
+
if (target === null || target === void 0 ? void 0 : target.closest("button, a[href]")) {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Everything else — field labels, the timeline, the header, surrounding
|
|
354
|
+
// padding, and the empty node-view strip — is inert atom chrome. Swallow the
|
|
355
|
+
// mousedown so ProseMirror can't node-select the block; in the collapsed
|
|
356
|
+
// preview a click there means "edit this step", so begin editing.
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
event.stopPropagation();
|
|
359
|
+
if (!editingRef.current) {
|
|
360
|
+
beginEditing();
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
blockEl.addEventListener("mousedown", handleMouseDown);
|
|
364
|
+
return () => blockEl.removeEventListener("mousedown", handleMouseDown);
|
|
365
|
+
}, [beginEditing]);
|
|
366
|
+
return (_jsx("div", { ref: anchorRef, style: { display: "contents" }, children: editing ? (
|
|
367
|
+
// Empty steps mounted eagerly (freshly inserted) auto-focus their title. A
|
|
368
|
+
// preview upgraded by a click focuses its field too, so a single click starts
|
|
369
|
+
// editing. The editor tears back down to a preview when focus leaves the step
|
|
370
|
+
// (see TestStepContent's blur handling).
|
|
371
|
+
_jsx(TestStepContent, { block: block, editor: editor, stepNumber: stepNumber, viewMode: viewMode, autoFocusEnabled: isEmptyStep, focusOnMount: focusOnMountRef.current, onEditEnd: endEditing })) : (_jsx("div", { className: "bn-teststep-preview-wrapper", tabIndex: 0, onFocusCapture: beginEditing, children: _jsx(TestStepPreview, { blockId: block.id, stepNumber: stepNumber, viewMode: viewMode, stepTitle: block.props.stepTitle || "", stepData: block.props.stepData || "", expectedResult: block.props.expectedResult || "" }) })) }));
|
|
333
372
|
}
|
|
334
373
|
function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled = false, focusOnMount = false, onEditEnd, }) {
|
|
335
374
|
// When a preview is upgraded by a click, focus its primary field once on
|
|
@@ -347,6 +386,12 @@ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled
|
|
|
347
386
|
const uploadImage = useStepImageUpload();
|
|
348
387
|
const containerRef = useRef(null);
|
|
349
388
|
const [forceVertical, setForceVertical] = useState(false);
|
|
389
|
+
// Set for a moment while a view-mode toggle is in flight. Switching into or
|
|
390
|
+
// out of the horizontal layout swaps the whole subtree, so the OverType editor
|
|
391
|
+
// unmounts and its blur would otherwise trip the focusout teardown below and
|
|
392
|
+
// collapse the step to a preview. The flag tells the teardown to re-focus the
|
|
393
|
+
// freshly mounted editor instead of ending the edit.
|
|
394
|
+
const viewTransitionRef = useRef(false);
|
|
350
395
|
useEffect(() => {
|
|
351
396
|
var _a;
|
|
352
397
|
const el = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.parentElement;
|
|
@@ -382,12 +427,24 @@ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled
|
|
|
382
427
|
// popover that portals to <body>, e.g. the link editor) has settled
|
|
383
428
|
// before we decide whether editing has really ended.
|
|
384
429
|
requestAnimationFrame(() => {
|
|
430
|
+
var _a;
|
|
385
431
|
const active = document.activeElement;
|
|
386
432
|
if (active &&
|
|
387
433
|
(root.contains(active) ||
|
|
388
434
|
active.closest(".bn-popover-content, .bn-form-popover, [role='dialog']"))) {
|
|
389
435
|
return;
|
|
390
436
|
}
|
|
437
|
+
// A view-mode toggle remounted the editor; the blur isn't the user
|
|
438
|
+
// leaving the step. Keep editing and move focus into the freshly
|
|
439
|
+
// mounted layout instead of collapsing to a preview.
|
|
440
|
+
if (viewTransitionRef.current) {
|
|
441
|
+
viewTransitionRef.current = false;
|
|
442
|
+
const live = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.querySelector("textarea");
|
|
443
|
+
if (live) {
|
|
444
|
+
live.focus();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
391
448
|
onEditEnd();
|
|
392
449
|
});
|
|
393
450
|
};
|
|
@@ -502,6 +559,20 @@ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled
|
|
|
502
559
|
else {
|
|
503
560
|
next = "vertical";
|
|
504
561
|
}
|
|
562
|
+
// Only switching into or out of the horizontal layout swaps the subtree and
|
|
563
|
+
// remounts the editor; vertical↔compact reuse the same editor instance and
|
|
564
|
+
// never blur. Flag just the remounting transitions so the focusout teardown
|
|
565
|
+
// re-focuses the new editor instead of collapsing — and so a genuine
|
|
566
|
+
// click-away after a non-remounting toggle still tears down normally. Cleared
|
|
567
|
+
// on a timer as a safety net in case the expected blur never arrives.
|
|
568
|
+
if (viewMode === "horizontal" || next === "horizontal") {
|
|
569
|
+
viewTransitionRef.current = true;
|
|
570
|
+
if (typeof window !== "undefined") {
|
|
571
|
+
window.setTimeout(() => {
|
|
572
|
+
viewTransitionRef.current = false;
|
|
573
|
+
}, 300);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
505
576
|
writeStepViewMode(next);
|
|
506
577
|
// The shared useStepViewMode hook (in every step, including this one)
|
|
507
578
|
// listens for this event and re-reads the mode, so we don't track it
|
|
@@ -354,6 +354,8 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
354
354
|
}
|
|
355
355
|
return lines;
|
|
356
356
|
}
|
|
357
|
+
case "exampleMarker":
|
|
358
|
+
return ["<!-- example -->"];
|
|
357
359
|
case "testMeta": {
|
|
358
360
|
const kind = block.props.metaKind === "suite" ? "suite" : "test";
|
|
359
361
|
const inline = Boolean(block.props.metaInline);
|
|
@@ -1223,6 +1225,18 @@ function parseParagraph(lines, index) {
|
|
|
1223
1225
|
};
|
|
1224
1226
|
}
|
|
1225
1227
|
const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
|
|
1228
|
+
// Marks the data/examples table that follows in a testomat.io test file. Accepts
|
|
1229
|
+
// both the singular `example` and plural `examples` spellings.
|
|
1230
|
+
const EXAMPLE_MARKER_REGEX = /^<!--\s*examples?\s*-->\s*$/i;
|
|
1231
|
+
function parseExampleMarker(lines, index) {
|
|
1232
|
+
if (!EXAMPLE_MARKER_REGEX.test(lines[index].trim())) {
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
return {
|
|
1236
|
+
block: { type: "exampleMarker", props: {}, children: [] },
|
|
1237
|
+
nextIndex: index + 1,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1226
1240
|
// Keys whose value is a YAML-style list (`key:` followed by indented `- item`
|
|
1227
1241
|
// lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
|
|
1228
1242
|
// round-trip back to a list on serialize; everything else stays a flat line.
|
|
@@ -1440,6 +1454,14 @@ export function markdownToBlocks(markdown, _options) {
|
|
|
1440
1454
|
index = metaComment.nextIndex;
|
|
1441
1455
|
continue;
|
|
1442
1456
|
}
|
|
1457
|
+
// Caught before parseParagraph so the `<!-- example -->` marker renders as
|
|
1458
|
+
// an "examples" panel instead of raw comment text.
|
|
1459
|
+
const exampleMarker = parseExampleMarker(lines, index);
|
|
1460
|
+
if (exampleMarker) {
|
|
1461
|
+
blocks.push(exampleMarker.block);
|
|
1462
|
+
index = exampleMarker.nextIndex;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1443
1465
|
const snippetWrapper = stepsHeadingLevel !== null
|
|
1444
1466
|
? parseSnippetWrapper(lines, index)
|
|
1445
1467
|
: null;
|
|
@@ -149,6 +149,18 @@ export declare const customSchema: BlockNoteSchema<import("@blocknote/core").Blo
|
|
|
149
149
|
};
|
|
150
150
|
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
151
151
|
};
|
|
152
|
+
exampleMarker: {
|
|
153
|
+
config: {
|
|
154
|
+
readonly type: "exampleMarker";
|
|
155
|
+
readonly content: "none";
|
|
156
|
+
readonly propSchema: {};
|
|
157
|
+
};
|
|
158
|
+
implementation: import("@blocknote/core").TiptapBlockImplementation<{
|
|
159
|
+
readonly type: "exampleMarker";
|
|
160
|
+
readonly content: "none";
|
|
161
|
+
readonly propSchema: {};
|
|
162
|
+
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
163
|
+
};
|
|
152
164
|
paragraph: {
|
|
153
165
|
config: {
|
|
154
166
|
type: "paragraph";
|
|
@@ -3,6 +3,7 @@ import { BlockNoteSchema } from "@blocknote/core";
|
|
|
3
3
|
import { stepBlock } from "./blocks/step";
|
|
4
4
|
import { snippetBlock } from "./blocks/snippet";
|
|
5
5
|
import { testMetaBlock } from "./blocks/testMeta";
|
|
6
|
+
import { exampleMarkerBlock } from "./blocks/exampleMarker";
|
|
6
7
|
import { fileBlock } from "./blocks/fileBlock";
|
|
7
8
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
8
9
|
export const customSchema = BlockNoteSchema.create({
|
|
@@ -12,6 +13,7 @@ export const customSchema = BlockNoteSchema.create({
|
|
|
12
13
|
testStep: stepBlock,
|
|
13
14
|
snippet: snippetBlock,
|
|
14
15
|
testMeta: testMetaBlock,
|
|
16
|
+
exampleMarker: exampleMarkerBlock,
|
|
15
17
|
},
|
|
16
18
|
});
|
|
17
19
|
export const __markdownTestUtils = {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
/**
|
|
3
|
+
* An "example table" is a data table that belongs to a testomat.io `<!-- example -->`
|
|
4
|
+
* marker: it sits between that marker and the start of the next test — i.e. the next
|
|
5
|
+
* `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — or the end of the
|
|
6
|
+
* document. Such tables are painted with a gray cell background so they read as the
|
|
7
|
+
* marker's data table.
|
|
8
|
+
*
|
|
9
|
+
* Given the block content-type names in document order, returns a boolean[] where
|
|
10
|
+
* entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
|
|
11
|
+
* `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
|
|
12
|
+
* paragraphs, …) leaves the region unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function markExampleTables(types: readonly string[]): boolean[];
|
|
17
|
+
/**
|
|
18
|
+
* Example tables only occur in a *suite* document — one that opens with a
|
|
19
|
+
* `<!-- suite ... -->` comment (a `testMeta` block whose `metaKind` is `"suite"`).
|
|
20
|
+
* Gating on the first block lets the plugin skip the whole scan for any other
|
|
21
|
+
* document (a lone test, plain notes, …) instead of walking it on every change.
|
|
22
|
+
*
|
|
23
|
+
* Pure so it can be unit-tested directly.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isSuiteDocument(firstBlockType: string | undefined, firstBlockMetaKind: string | undefined): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* BlockNote extension that grays the cells of the table following an
|
|
28
|
+
* `<!-- example -->` marker (bounded by the next test/suite comment or the end of
|
|
29
|
+
* the document).
|
|
30
|
+
*
|
|
31
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried by
|
|
32
|
+
* the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* useCreateBlockNote({
|
|
36
|
+
* schema: customSchema,
|
|
37
|
+
* extensions: [exampleTableHighlightExtension()],
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare class ExampleTableHighlightExtension extends BlockNoteExtension {
|
|
42
|
+
static key(): string;
|
|
43
|
+
constructor();
|
|
44
|
+
}
|
|
45
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
46
|
+
export declare const exampleTableHighlightExtension: () => ExampleTableHighlightExtension;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
3
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
4
|
+
/**
|
|
5
|
+
* An "example table" is a data table that belongs to a testomat.io `<!-- example -->`
|
|
6
|
+
* marker: it sits between that marker and the start of the next test — i.e. the next
|
|
7
|
+
* `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — or the end of the
|
|
8
|
+
* document. Such tables are painted with a gray cell background so they read as the
|
|
9
|
+
* marker's data table.
|
|
10
|
+
*
|
|
11
|
+
* Given the block content-type names in document order, returns a boolean[] where
|
|
12
|
+
* entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
|
|
13
|
+
* `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
|
|
14
|
+
* paragraphs, …) leaves the region unchanged.
|
|
15
|
+
*
|
|
16
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
17
|
+
*/
|
|
18
|
+
export function markExampleTables(types) {
|
|
19
|
+
const flags = [];
|
|
20
|
+
let active = false;
|
|
21
|
+
for (const name of types) {
|
|
22
|
+
if (name === "exampleMarker") {
|
|
23
|
+
active = true;
|
|
24
|
+
flags.push(false);
|
|
25
|
+
}
|
|
26
|
+
else if (name === "testMeta") {
|
|
27
|
+
active = false;
|
|
28
|
+
flags.push(false);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
flags.push(name === "table" && active);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return flags;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Example tables only occur in a *suite* document — one that opens with a
|
|
38
|
+
* `<!-- suite ... -->` comment (a `testMeta` block whose `metaKind` is `"suite"`).
|
|
39
|
+
* Gating on the first block lets the plugin skip the whole scan for any other
|
|
40
|
+
* document (a lone test, plain notes, …) instead of walking it on every change.
|
|
41
|
+
*
|
|
42
|
+
* Pure so it can be unit-tested directly.
|
|
43
|
+
*/
|
|
44
|
+
export function isSuiteDocument(firstBlockType, firstBlockMetaKind) {
|
|
45
|
+
return firstBlockType === "testMeta" && firstBlockMetaKind === "suite";
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build node decorations that add `.bn-example-table` to every table in an example
|
|
49
|
+
* region. Tables are only *painted* — the document is untouched, so markdown
|
|
50
|
+
* serialization round-trips unchanged.
|
|
51
|
+
*
|
|
52
|
+
* The `<!-- example -->` marker, the metadata comment, and the table each become a
|
|
53
|
+
* top-level block whose content node is named by its block type (`exampleMarker`,
|
|
54
|
+
* `testMeta`, `table`), so a single document-order walk yields them directly.
|
|
55
|
+
*
|
|
56
|
+
* Nothing is highlighted unless the document is a suite (see `isSuiteDocument`).
|
|
57
|
+
*/
|
|
58
|
+
function buildExampleTableDecorations(doc) {
|
|
59
|
+
const entries = [];
|
|
60
|
+
let firstBlockType;
|
|
61
|
+
let firstBlockMetaKind;
|
|
62
|
+
doc.descendants((node, pos, parent) => {
|
|
63
|
+
var _a;
|
|
64
|
+
// The first content node encountered under a blockContainer is the document's
|
|
65
|
+
// first block — capture its type/metaKind for the suite gate below.
|
|
66
|
+
if (firstBlockType === undefined && (parent === null || parent === void 0 ? void 0 : parent.type.name) === "blockContainer") {
|
|
67
|
+
firstBlockType = node.type.name;
|
|
68
|
+
const metaKind = (_a = node.attrs) === null || _a === void 0 ? void 0 : _a.metaKind;
|
|
69
|
+
firstBlockMetaKind = typeof metaKind === "string" ? metaKind : undefined;
|
|
70
|
+
}
|
|
71
|
+
const name = node.type.name;
|
|
72
|
+
if (name === "exampleMarker" || name === "testMeta" || name === "table") {
|
|
73
|
+
entries.push({ pos, size: node.nodeSize, name });
|
|
74
|
+
// No need to descend into table cells or the marker's (empty) internals.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// Descend through the blockGroup / blockContainer wrappers to reach content.
|
|
78
|
+
return undefined;
|
|
79
|
+
});
|
|
80
|
+
if (!isSuiteDocument(firstBlockType, firstBlockMetaKind)) {
|
|
81
|
+
return DecorationSet.empty;
|
|
82
|
+
}
|
|
83
|
+
const flags = markExampleTables(entries.map((entry) => entry.name));
|
|
84
|
+
const decorations = entries
|
|
85
|
+
.filter((_, index) => flags[index])
|
|
86
|
+
.map((entry) => Decoration.node(entry.pos, entry.pos + entry.size, {
|
|
87
|
+
class: "bn-example-table",
|
|
88
|
+
}));
|
|
89
|
+
return DecorationSet.create(doc, decorations);
|
|
90
|
+
}
|
|
91
|
+
const exampleTablePluginKey = new PluginKey("testomatioExampleTable");
|
|
92
|
+
function exampleTablePlugin() {
|
|
93
|
+
return new Plugin({
|
|
94
|
+
key: exampleTablePluginKey,
|
|
95
|
+
state: {
|
|
96
|
+
init: (_config, state) => buildExampleTableDecorations(state.doc),
|
|
97
|
+
apply: (tr, value) => tr.docChanged ? buildExampleTableDecorations(tr.doc) : value,
|
|
98
|
+
},
|
|
99
|
+
props: {
|
|
100
|
+
decorations(state) {
|
|
101
|
+
return exampleTablePluginKey.getState(state);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* BlockNote extension that grays the cells of the table following an
|
|
108
|
+
* `<!-- example -->` marker (bounded by the next test/suite comment or the end of
|
|
109
|
+
* the document).
|
|
110
|
+
*
|
|
111
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried by
|
|
112
|
+
* the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* useCreateBlockNote({
|
|
116
|
+
* schema: customSchema,
|
|
117
|
+
* extensions: [exampleTableHighlightExtension()],
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export class ExampleTableHighlightExtension extends BlockNoteExtension {
|
|
122
|
+
static key() {
|
|
123
|
+
return "exampleTableHighlight";
|
|
124
|
+
}
|
|
125
|
+
constructor() {
|
|
126
|
+
super();
|
|
127
|
+
this.addProsemirrorPlugin(exampleTablePlugin());
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
131
|
+
export const exampleTableHighlightExtension = () => new ExampleTableHighlightExtension();
|
package/package/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggest
|
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
7
|
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
|
|
8
8
|
export { autoLinkExtension, AutoLinkExtension, detectLinks, type LinkMatch, } from "./editor/autoLink";
|
|
9
|
+
export { exampleTableHighlightExtension, ExampleTableHighlightExtension, markExampleTables, } from "./editor/exampleTableHighlight";
|
|
9
10
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
10
11
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
|
11
12
|
export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/test
|
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
7
|
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
|
|
8
8
|
export { autoLinkExtension, AutoLinkExtension, detectLinks, } from "./editor/autoLink";
|
|
9
|
+
export { exampleTableHighlightExtension, ExampleTableHighlightExtension, markExampleTables, } from "./editor/exampleTableHighlight";
|
|
9
10
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
10
11
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|
|
11
12
|
export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/styles.css
CHANGED
|
@@ -811,6 +811,37 @@ html.dark .testomatio-editor .bn-auto-link {
|
|
|
811
811
|
opacity: 0.5;
|
|
812
812
|
}
|
|
813
813
|
|
|
814
|
+
/* Compact marker for `<!-- example -->` — a cropped variant of the testMeta bar
|
|
815
|
+
(fit-content width, not full editor width) labelling the table below it. */
|
|
816
|
+
.bn-example-marker {
|
|
817
|
+
display: inline-flex;
|
|
818
|
+
align-items: center;
|
|
819
|
+
width: fit-content;
|
|
820
|
+
box-sizing: border-box;
|
|
821
|
+
padding: 4px 12px;
|
|
822
|
+
background: var(--bg-muted);
|
|
823
|
+
border-top: 3px solid var(--color-slate-400);
|
|
824
|
+
margin-top: 1rem;
|
|
825
|
+
opacity: 0.6;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
.bn-example-marker__label {
|
|
829
|
+
font-size: 11px;
|
|
830
|
+
font-weight: 600;
|
|
831
|
+
letter-spacing: 0.04em;
|
|
832
|
+
text-transform: uppercase;
|
|
833
|
+
color: var(--text-muted);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* Gray cells for the data table that follows an `<!-- example -->` marker. The
|
|
837
|
+
`.bn-example-table` class is applied by a ProseMirror decoration (see
|
|
838
|
+
exampleTableHighlight.ts), bounded to tables between the marker and the next
|
|
839
|
+
test/suite comment (or end of document). */
|
|
840
|
+
.bn-editor [data-content-type="table"].bn-example-table td,
|
|
841
|
+
.bn-editor [data-content-type="table"].bn-example-table th {
|
|
842
|
+
background: var(--bg-muted);
|
|
843
|
+
}
|
|
844
|
+
|
|
814
845
|
/* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
|
|
815
846
|
always share one row. */
|
|
816
847
|
.bn-testmeta__header {
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler"
|
|
|
22
22
|
import { customSchema, type CustomEditor } from "./editor/customSchema";
|
|
23
23
|
import { tagBadgeExtension } from "./editor/tagBadge";
|
|
24
24
|
import { autoLinkExtension } from "./editor/autoLink";
|
|
25
|
+
import { exampleTableHighlightExtension } from "./editor/exampleTableHighlight";
|
|
25
26
|
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
|
|
26
27
|
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
|
|
27
28
|
import {
|
|
@@ -500,7 +501,7 @@ function CustomSlashMenu() {
|
|
|
500
501
|
function App() {
|
|
501
502
|
const editor = useCreateBlockNote({
|
|
502
503
|
schema: customSchema,
|
|
503
|
-
extensions: [tagBadgeExtension(), autoLinkExtension()],
|
|
504
|
+
extensions: [tagBadgeExtension(), autoLinkExtension(), exampleTableHighlightExtension()],
|
|
504
505
|
pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
|
|
505
506
|
uploadFile: async (file: File) => {
|
|
506
507
|
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A read-only marker rendered from the `<!-- example -->` HTML comment that
|
|
5
|
+
* precedes a data/examples table in a testomat.io test file. It shows a compact
|
|
6
|
+
* "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
|
|
7
|
+
* marker reads as a UI label instead of raw comment text. It has no fields and
|
|
8
|
+
* round-trips back to `<!-- example -->` on serialize.
|
|
9
|
+
*/
|
|
10
|
+
export const exampleMarkerBlock = createReactBlockSpec(
|
|
11
|
+
{ type: "exampleMarker", content: "none", propSchema: {} },
|
|
12
|
+
{
|
|
13
|
+
render: () => (
|
|
14
|
+
<div
|
|
15
|
+
className="bn-example-marker"
|
|
16
|
+
contentEditable={false}
|
|
17
|
+
suppressContentEditableWarning
|
|
18
|
+
draggable={false}
|
|
19
|
+
>
|
|
20
|
+
<span className="bn-example-marker__label">examples</span>
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
},
|
|
24
|
+
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { StepField, StepFieldPreview } from "./stepField";
|
|
4
4
|
import { StepHorizontalView } from "./stepHorizontalView";
|
|
5
5
|
import { useStepImageUpload } from "../stepImageUpload";
|
|
@@ -375,53 +375,95 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) {
|
|
|
375
375
|
setEditing(true);
|
|
376
376
|
}, []);
|
|
377
377
|
|
|
378
|
-
// Mousedown rather than click so the editor mounts before focus settles, and
|
|
379
|
-
// preventDefault so the browser doesn't move focus to <body> when the preview
|
|
380
|
-
// (the mousedown target) unmounts mid-click — that stray blur would otherwise
|
|
381
|
-
// immediately tear the new editor back down.
|
|
382
|
-
const beginEditingFromPointer = useCallback(
|
|
383
|
-
(event: ReactMouseEvent) => {
|
|
384
|
-
event.preventDefault();
|
|
385
|
-
beginEditing();
|
|
386
|
-
},
|
|
387
|
-
[beginEditing],
|
|
388
|
-
);
|
|
389
|
-
|
|
390
378
|
const endEditing = useCallback(() => setEditing(false), []);
|
|
391
379
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
380
|
+
// `editing` read from a ref so the single mousedown guard below (attached once)
|
|
381
|
+
// always sees the current state without re-binding.
|
|
382
|
+
const editingRef = useRef(editing);
|
|
383
|
+
editingRef.current = editing;
|
|
384
|
+
|
|
385
|
+
// Zero-box anchor (display: contents) used only to locate this block's node-view
|
|
386
|
+
// element; it adds no layout box, so the step renders exactly as before.
|
|
387
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
388
|
+
|
|
389
|
+
// The step is a ProseMirror atom node (content: "none") whose node-view
|
|
390
|
+
// (`.bn-block-content`) is wider than the visible step, so the empty area around
|
|
391
|
+
// it — and, in the collapsed preview, the whole strip to the right of the text —
|
|
392
|
+
// is inert atom surface. A plain mousedown there makes ProseMirror node-select the
|
|
393
|
+
// block (the stray blue outline) instead of editing it. One native listener on the
|
|
394
|
+
// node-view guards every mousedown in both the preview and editing states before
|
|
395
|
+
// ProseMirror sees it. Native (not onMouseDown) is required because React 18
|
|
396
|
+
// delegates at the app root, so its handlers run after ProseMirror's own.
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const blockEl = anchorRef.current?.closest(".bn-block-content") as HTMLElement | null;
|
|
399
|
+
if (!blockEl) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
403
|
+
const target = event.target as HTMLElement | null;
|
|
404
|
+
// The real editor surface, form controls, and popovers/dialogs need the
|
|
405
|
+
// native mousedown (caret placement, typing, focus) to behave normally.
|
|
406
|
+
if (
|
|
407
|
+
target?.closest(
|
|
408
|
+
'.overtype-container, textarea, input, [role="dialog"], .bn-popover-content, .bn-form-popover',
|
|
409
|
+
)
|
|
410
|
+
) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// Buttons and links (toolbar, view toggle, action bar, autocomplete
|
|
414
|
+
// suggestions): preventDefault keeps the focused field from blurring — a blur
|
|
415
|
+
// node-selects the atom and can trip the focusout teardown on re-render — but
|
|
416
|
+
// we don't stopPropagation, so their own click/mousedown handlers still run.
|
|
417
|
+
if (target?.closest("button, a[href]")) {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Everything else — field labels, the timeline, the header, surrounding
|
|
422
|
+
// padding, and the empty node-view strip — is inert atom chrome. Swallow the
|
|
423
|
+
// mousedown so ProseMirror can't node-select the block; in the collapsed
|
|
424
|
+
// preview a click there means "edit this step", so begin editing.
|
|
425
|
+
event.preventDefault();
|
|
426
|
+
event.stopPropagation();
|
|
427
|
+
if (!editingRef.current) {
|
|
428
|
+
beginEditing();
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
blockEl.addEventListener("mousedown", handleMouseDown);
|
|
432
|
+
return () => blockEl.removeEventListener("mousedown", handleMouseDown);
|
|
433
|
+
}, [beginEditing]);
|
|
409
434
|
|
|
410
435
|
return (
|
|
411
|
-
<div
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
436
|
+
<div ref={anchorRef} style={{ display: "contents" }}>
|
|
437
|
+
{editing ? (
|
|
438
|
+
// Empty steps mounted eagerly (freshly inserted) auto-focus their title. A
|
|
439
|
+
// preview upgraded by a click focuses its field too, so a single click starts
|
|
440
|
+
// editing. The editor tears back down to a preview when focus leaves the step
|
|
441
|
+
// (see TestStepContent's blur handling).
|
|
442
|
+
<TestStepContent
|
|
443
|
+
block={block}
|
|
444
|
+
editor={editor}
|
|
445
|
+
stepNumber={stepNumber}
|
|
446
|
+
viewMode={viewMode}
|
|
447
|
+
autoFocusEnabled={isEmptyStep}
|
|
448
|
+
focusOnMount={focusOnMountRef.current}
|
|
449
|
+
onEditEnd={endEditing}
|
|
450
|
+
/>
|
|
451
|
+
) : (
|
|
452
|
+
<div
|
|
453
|
+
className="bn-teststep-preview-wrapper"
|
|
454
|
+
tabIndex={0}
|
|
455
|
+
onFocusCapture={beginEditing}
|
|
456
|
+
>
|
|
457
|
+
<TestStepPreview
|
|
458
|
+
blockId={block.id}
|
|
459
|
+
stepNumber={stepNumber}
|
|
460
|
+
viewMode={viewMode}
|
|
461
|
+
stepTitle={(block.props.stepTitle as string) || ""}
|
|
462
|
+
stepData={(block.props.stepData as string) || ""}
|
|
463
|
+
expectedResult={(block.props.expectedResult as string) || ""}
|
|
464
|
+
/>
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
425
467
|
</div>
|
|
426
468
|
);
|
|
427
469
|
}
|
|
@@ -460,6 +502,12 @@ function TestStepContent({
|
|
|
460
502
|
const uploadImage = useStepImageUpload();
|
|
461
503
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
462
504
|
const [forceVertical, setForceVertical] = useState(false);
|
|
505
|
+
// Set for a moment while a view-mode toggle is in flight. Switching into or
|
|
506
|
+
// out of the horizontal layout swaps the whole subtree, so the OverType editor
|
|
507
|
+
// unmounts and its blur would otherwise trip the focusout teardown below and
|
|
508
|
+
// collapse the step to a preview. The flag tells the teardown to re-focus the
|
|
509
|
+
// freshly mounted editor instead of ending the edit.
|
|
510
|
+
const viewTransitionRef = useRef(false);
|
|
463
511
|
|
|
464
512
|
useEffect(() => {
|
|
465
513
|
const el = containerRef.current?.parentElement;
|
|
@@ -504,6 +552,17 @@ function TestStepContent({
|
|
|
504
552
|
) {
|
|
505
553
|
return;
|
|
506
554
|
}
|
|
555
|
+
// A view-mode toggle remounted the editor; the blur isn't the user
|
|
556
|
+
// leaving the step. Keep editing and move focus into the freshly
|
|
557
|
+
// mounted layout instead of collapsing to a preview.
|
|
558
|
+
if (viewTransitionRef.current) {
|
|
559
|
+
viewTransitionRef.current = false;
|
|
560
|
+
const live = containerRef.current?.querySelector("textarea");
|
|
561
|
+
if (live) {
|
|
562
|
+
(live as HTMLTextAreaElement).focus();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
507
566
|
onEditEnd();
|
|
508
567
|
});
|
|
509
568
|
};
|
|
@@ -642,6 +701,20 @@ function TestStepContent({
|
|
|
642
701
|
} else {
|
|
643
702
|
next = "vertical";
|
|
644
703
|
}
|
|
704
|
+
// Only switching into or out of the horizontal layout swaps the subtree and
|
|
705
|
+
// remounts the editor; vertical↔compact reuse the same editor instance and
|
|
706
|
+
// never blur. Flag just the remounting transitions so the focusout teardown
|
|
707
|
+
// re-focuses the new editor instead of collapsing — and so a genuine
|
|
708
|
+
// click-away after a non-remounting toggle still tears down normally. Cleared
|
|
709
|
+
// on a timer as a safety net in case the expected blur never arrives.
|
|
710
|
+
if (viewMode === "horizontal" || next === "horizontal") {
|
|
711
|
+
viewTransitionRef.current = true;
|
|
712
|
+
if (typeof window !== "undefined") {
|
|
713
|
+
window.setTimeout(() => {
|
|
714
|
+
viewTransitionRef.current = false;
|
|
715
|
+
}, 300);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
645
718
|
writeStepViewMode(next);
|
|
646
719
|
// The shared useStepViewMode hook (in every step, including this one)
|
|
647
720
|
// listens for this event and re-reads the mode, so we don't track it
|
|
@@ -3182,6 +3182,34 @@ describe("test/suite metadata comments", () => {
|
|
|
3182
3182
|
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3183
3183
|
});
|
|
3184
3184
|
|
|
3185
|
+
it("parses an <!-- example --> marker into an exampleMarker block", () => {
|
|
3186
|
+
const blocks = markdownToBlocks("<!-- example -->");
|
|
3187
|
+
expect(blocks).toEqual([
|
|
3188
|
+
{
|
|
3189
|
+
type: "exampleMarker",
|
|
3190
|
+
props: {},
|
|
3191
|
+
children: [],
|
|
3192
|
+
},
|
|
3193
|
+
]);
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
it("parses the plural <!-- examples --> spelling too", () => {
|
|
3197
|
+
const blocks = markdownToBlocks("<!-- examples -->");
|
|
3198
|
+
expect(blocks).toEqual([
|
|
3199
|
+
{
|
|
3200
|
+
type: "exampleMarker",
|
|
3201
|
+
props: {},
|
|
3202
|
+
children: [],
|
|
3203
|
+
},
|
|
3204
|
+
]);
|
|
3205
|
+
});
|
|
3206
|
+
|
|
3207
|
+
it("round-trips an <!-- example --> marker", () => {
|
|
3208
|
+
const markdown = "<!-- example -->";
|
|
3209
|
+
const blocks = markdownToBlocks(markdown);
|
|
3210
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3211
|
+
});
|
|
3212
|
+
|
|
3185
3213
|
it("parses a multi-line suite block with ordered fields", () => {
|
|
3186
3214
|
const markdown = [
|
|
3187
3215
|
"<!-- suite",
|
|
@@ -425,6 +425,8 @@ function serializeBlock(
|
|
|
425
425
|
}
|
|
426
426
|
return lines;
|
|
427
427
|
}
|
|
428
|
+
case "exampleMarker":
|
|
429
|
+
return ["<!-- example -->"];
|
|
428
430
|
case "testMeta": {
|
|
429
431
|
const kind = (block.props as any).metaKind === "suite" ? "suite" : "test";
|
|
430
432
|
const inline = Boolean((block.props as any).metaInline);
|
|
@@ -1435,6 +1437,23 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
|
|
|
1435
1437
|
|
|
1436
1438
|
const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
|
|
1437
1439
|
|
|
1440
|
+
// Marks the data/examples table that follows in a testomat.io test file. Accepts
|
|
1441
|
+
// both the singular `example` and plural `examples` spellings.
|
|
1442
|
+
const EXAMPLE_MARKER_REGEX = /^<!--\s*examples?\s*-->\s*$/i;
|
|
1443
|
+
|
|
1444
|
+
function parseExampleMarker(
|
|
1445
|
+
lines: string[],
|
|
1446
|
+
index: number,
|
|
1447
|
+
): { block: CustomPartialBlock; nextIndex: number } | null {
|
|
1448
|
+
if (!EXAMPLE_MARKER_REGEX.test(lines[index].trim())) {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
return {
|
|
1452
|
+
block: { type: "exampleMarker", props: {}, children: [] } as CustomPartialBlock,
|
|
1453
|
+
nextIndex: index + 1,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1438
1457
|
// Keys whose value is a YAML-style list (`key:` followed by indented `- item`
|
|
1439
1458
|
// lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
|
|
1440
1459
|
// round-trip back to a list on serialize; everything else stays a flat line.
|
|
@@ -1691,6 +1710,15 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp
|
|
|
1691
1710
|
continue;
|
|
1692
1711
|
}
|
|
1693
1712
|
|
|
1713
|
+
// Caught before parseParagraph so the `<!-- example -->` marker renders as
|
|
1714
|
+
// an "examples" panel instead of raw comment text.
|
|
1715
|
+
const exampleMarker = parseExampleMarker(lines, index);
|
|
1716
|
+
if (exampleMarker) {
|
|
1717
|
+
blocks.push(exampleMarker.block);
|
|
1718
|
+
index = exampleMarker.nextIndex;
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1694
1722
|
const snippetWrapper = stepsHeadingLevel !== null
|
|
1695
1723
|
? parseSnippetWrapper(lines, index)
|
|
1696
1724
|
: null;
|
|
@@ -3,6 +3,7 @@ import { BlockNoteSchema } from "@blocknote/core";
|
|
|
3
3
|
import { stepBlock } from "./blocks/step";
|
|
4
4
|
import { snippetBlock } from "./blocks/snippet";
|
|
5
5
|
import { testMetaBlock } from "./blocks/testMeta";
|
|
6
|
+
import { exampleMarkerBlock } from "./blocks/exampleMarker";
|
|
6
7
|
import { fileBlock } from "./blocks/fileBlock";
|
|
7
8
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ export const customSchema = BlockNoteSchema.create({
|
|
|
13
14
|
testStep: stepBlock,
|
|
14
15
|
snippet: snippetBlock,
|
|
15
16
|
testMeta: testMetaBlock,
|
|
17
|
+
exampleMarker: exampleMarkerBlock,
|
|
16
18
|
},
|
|
17
19
|
});
|
|
18
20
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isSuiteDocument, markExampleTables } from "./exampleTableHighlight";
|
|
3
|
+
|
|
4
|
+
describe("isSuiteDocument", () => {
|
|
5
|
+
it("is true only when the first block is a suite testMeta", () => {
|
|
6
|
+
expect(isSuiteDocument("testMeta", "suite")).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("is false for a document opening with a test (not a suite)", () => {
|
|
10
|
+
expect(isSuiteDocument("testMeta", "test")).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("is false when the first block is not a testMeta", () => {
|
|
14
|
+
expect(isSuiteDocument("heading", undefined)).toBe(false);
|
|
15
|
+
expect(isSuiteDocument("paragraph", "suite")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("is false for an empty document", () => {
|
|
19
|
+
expect(isSuiteDocument(undefined, undefined)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("markExampleTables", () => {
|
|
24
|
+
it("flags a table that follows an example marker", () => {
|
|
25
|
+
expect(markExampleTables(["exampleMarker", "paragraph", "table"])).toEqual([
|
|
26
|
+
false,
|
|
27
|
+
false,
|
|
28
|
+
true,
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("stops the region at the next test/suite comment", () => {
|
|
33
|
+
// First table is in the example region; the testMeta closes it, so the
|
|
34
|
+
// second table (belonging to the next test) is not flagged.
|
|
35
|
+
expect(
|
|
36
|
+
markExampleTables(["exampleMarker", "table", "testMeta", "table"]),
|
|
37
|
+
).toEqual([false, true, false, false]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not flag tables without a preceding example marker", () => {
|
|
41
|
+
expect(markExampleTables(["table"])).toEqual([false]);
|
|
42
|
+
expect(markExampleTables(["testMeta", "table"])).toEqual([false, false]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps the region open across headings and other blocks", () => {
|
|
46
|
+
expect(
|
|
47
|
+
markExampleTables(["exampleMarker", "heading", "paragraph", "table"]),
|
|
48
|
+
).toEqual([false, false, false, true]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("flags every table in the region until the next test", () => {
|
|
52
|
+
expect(
|
|
53
|
+
markExampleTables(["exampleMarker", "table", "table", "testMeta", "table"]),
|
|
54
|
+
).toEqual([false, true, true, false, false]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reopens the region when a new example marker appears", () => {
|
|
58
|
+
expect(
|
|
59
|
+
markExampleTables(["exampleMarker", "table", "testMeta", "exampleMarker", "table"]),
|
|
60
|
+
).toEqual([false, true, false, false, true]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns an empty array for no blocks", () => {
|
|
64
|
+
expect(markExampleTables([])).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
3
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
4
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An "example table" is a data table that belongs to a testomat.io `<!-- example -->`
|
|
8
|
+
* marker: it sits between that marker and the start of the next test — i.e. the next
|
|
9
|
+
* `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — or the end of the
|
|
10
|
+
* document. Such tables are painted with a gray cell background so they read as the
|
|
11
|
+
* marker's data table.
|
|
12
|
+
*
|
|
13
|
+
* Given the block content-type names in document order, returns a boolean[] where
|
|
14
|
+
* entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
|
|
15
|
+
* `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
|
|
16
|
+
* paragraphs, …) leaves the region unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
19
|
+
*/
|
|
20
|
+
export function markExampleTables(types: readonly string[]): boolean[] {
|
|
21
|
+
const flags: boolean[] = [];
|
|
22
|
+
let active = false;
|
|
23
|
+
for (const name of types) {
|
|
24
|
+
if (name === "exampleMarker") {
|
|
25
|
+
active = true;
|
|
26
|
+
flags.push(false);
|
|
27
|
+
} else if (name === "testMeta") {
|
|
28
|
+
active = false;
|
|
29
|
+
flags.push(false);
|
|
30
|
+
} else {
|
|
31
|
+
flags.push(name === "table" && active);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return flags;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Example tables only occur in a *suite* document — one that opens with a
|
|
39
|
+
* `<!-- suite ... -->` comment (a `testMeta` block whose `metaKind` is `"suite"`).
|
|
40
|
+
* Gating on the first block lets the plugin skip the whole scan for any other
|
|
41
|
+
* document (a lone test, plain notes, …) instead of walking it on every change.
|
|
42
|
+
*
|
|
43
|
+
* Pure so it can be unit-tested directly.
|
|
44
|
+
*/
|
|
45
|
+
export function isSuiteDocument(
|
|
46
|
+
firstBlockType: string | undefined,
|
|
47
|
+
firstBlockMetaKind: string | undefined,
|
|
48
|
+
): boolean {
|
|
49
|
+
return firstBlockType === "testMeta" && firstBlockMetaKind === "suite";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build node decorations that add `.bn-example-table` to every table in an example
|
|
54
|
+
* region. Tables are only *painted* — the document is untouched, so markdown
|
|
55
|
+
* serialization round-trips unchanged.
|
|
56
|
+
*
|
|
57
|
+
* The `<!-- example -->` marker, the metadata comment, and the table each become a
|
|
58
|
+
* top-level block whose content node is named by its block type (`exampleMarker`,
|
|
59
|
+
* `testMeta`, `table`), so a single document-order walk yields them directly.
|
|
60
|
+
*
|
|
61
|
+
* Nothing is highlighted unless the document is a suite (see `isSuiteDocument`).
|
|
62
|
+
*/
|
|
63
|
+
function buildExampleTableDecorations(doc: PMNode): DecorationSet {
|
|
64
|
+
const entries: { pos: number; size: number; name: string }[] = [];
|
|
65
|
+
let firstBlockType: string | undefined;
|
|
66
|
+
let firstBlockMetaKind: string | undefined;
|
|
67
|
+
|
|
68
|
+
doc.descendants((node, pos, parent) => {
|
|
69
|
+
// The first content node encountered under a blockContainer is the document's
|
|
70
|
+
// first block — capture its type/metaKind for the suite gate below.
|
|
71
|
+
if (firstBlockType === undefined && parent?.type.name === "blockContainer") {
|
|
72
|
+
firstBlockType = node.type.name;
|
|
73
|
+
const metaKind = node.attrs?.metaKind;
|
|
74
|
+
firstBlockMetaKind = typeof metaKind === "string" ? metaKind : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const name = node.type.name;
|
|
78
|
+
if (name === "exampleMarker" || name === "testMeta" || name === "table") {
|
|
79
|
+
entries.push({ pos, size: node.nodeSize, name });
|
|
80
|
+
// No need to descend into table cells or the marker's (empty) internals.
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
// Descend through the blockGroup / blockContainer wrappers to reach content.
|
|
84
|
+
return undefined;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!isSuiteDocument(firstBlockType, firstBlockMetaKind)) {
|
|
88
|
+
return DecorationSet.empty;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const flags = markExampleTables(entries.map((entry) => entry.name));
|
|
92
|
+
const decorations = entries
|
|
93
|
+
.filter((_, index) => flags[index])
|
|
94
|
+
.map((entry) =>
|
|
95
|
+
Decoration.node(entry.pos, entry.pos + entry.size, {
|
|
96
|
+
class: "bn-example-table",
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return DecorationSet.create(doc, decorations);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const exampleTablePluginKey = new PluginKey<DecorationSet>("testomatioExampleTable");
|
|
104
|
+
|
|
105
|
+
function exampleTablePlugin(): Plugin<DecorationSet> {
|
|
106
|
+
return new Plugin<DecorationSet>({
|
|
107
|
+
key: exampleTablePluginKey,
|
|
108
|
+
state: {
|
|
109
|
+
init: (_config, state) => buildExampleTableDecorations(state.doc),
|
|
110
|
+
apply: (tr, value) =>
|
|
111
|
+
tr.docChanged ? buildExampleTableDecorations(tr.doc) : value,
|
|
112
|
+
},
|
|
113
|
+
props: {
|
|
114
|
+
decorations(state) {
|
|
115
|
+
return exampleTablePluginKey.getState(state);
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* BlockNote extension that grays the cells of the table following an
|
|
123
|
+
* `<!-- example -->` marker (bounded by the next test/suite comment or the end of
|
|
124
|
+
* the document).
|
|
125
|
+
*
|
|
126
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried by
|
|
127
|
+
* the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
128
|
+
*
|
|
129
|
+
* ```ts
|
|
130
|
+
* useCreateBlockNote({
|
|
131
|
+
* schema: customSchema,
|
|
132
|
+
* extensions: [exampleTableHighlightExtension()],
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export class ExampleTableHighlightExtension extends BlockNoteExtension {
|
|
137
|
+
static key() {
|
|
138
|
+
return "exampleTableHighlight";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
constructor() {
|
|
142
|
+
super();
|
|
143
|
+
this.addProsemirrorPlugin(exampleTablePlugin());
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
148
|
+
export const exampleTableHighlightExtension = () => new ExampleTableHighlightExtension();
|
package/src/editor/styles.css
CHANGED
|
@@ -811,6 +811,37 @@ html.dark .testomatio-editor .bn-auto-link {
|
|
|
811
811
|
opacity: 0.5;
|
|
812
812
|
}
|
|
813
813
|
|
|
814
|
+
/* Compact marker for `<!-- example -->` — a cropped variant of the testMeta bar
|
|
815
|
+
(fit-content width, not full editor width) labelling the table below it. */
|
|
816
|
+
.bn-example-marker {
|
|
817
|
+
display: inline-flex;
|
|
818
|
+
align-items: center;
|
|
819
|
+
width: fit-content;
|
|
820
|
+
box-sizing: border-box;
|
|
821
|
+
padding: 4px 12px;
|
|
822
|
+
background: var(--bg-muted);
|
|
823
|
+
border-top: 3px solid var(--color-slate-400);
|
|
824
|
+
margin-top: 1rem;
|
|
825
|
+
opacity: 0.6;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
.bn-example-marker__label {
|
|
829
|
+
font-size: 11px;
|
|
830
|
+
font-weight: 600;
|
|
831
|
+
letter-spacing: 0.04em;
|
|
832
|
+
text-transform: uppercase;
|
|
833
|
+
color: var(--text-muted);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* Gray cells for the data table that follows an `<!-- example -->` marker. The
|
|
837
|
+
`.bn-example-table` class is applied by a ProseMirror decoration (see
|
|
838
|
+
exampleTableHighlight.ts), bounded to tables between the marker and the next
|
|
839
|
+
test/suite comment (or end of document). */
|
|
840
|
+
.bn-editor [data-content-type="table"].bn-example-table td,
|
|
841
|
+
.bn-editor [data-content-type="table"].bn-example-table th {
|
|
842
|
+
background: var(--bg-muted);
|
|
843
|
+
}
|
|
844
|
+
|
|
814
845
|
/* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
|
|
815
846
|
always share one row. */
|
|
816
847
|
.bn-testmeta__header {
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,12 @@ export {
|
|
|
30
30
|
type LinkMatch,
|
|
31
31
|
} from "./editor/autoLink";
|
|
32
32
|
|
|
33
|
+
export {
|
|
34
|
+
exampleTableHighlightExtension,
|
|
35
|
+
ExampleTableHighlightExtension,
|
|
36
|
+
markExampleTables,
|
|
37
|
+
} from "./editor/exampleTableHighlight";
|
|
38
|
+
|
|
33
39
|
export {
|
|
34
40
|
blocksToMarkdown,
|
|
35
41
|
markdownToBlocks,
|