testomatio-editor-blocks 0.4.79 → 0.4.82
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/autoLink.d.ts +45 -0
- package/package/editor/autoLink.js +199 -0
- package/package/editor/blocks/exampleMarker.d.ts +19 -0
- package/package/editor/blocks/exampleMarker.js +12 -0
- 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 +2 -0
- package/package/index.js +2 -0
- package/package/styles.css +65 -0
- package/package.json +1 -1
- package/src/App.tsx +33 -6
- package/src/editor/autoLink.test.ts +111 -0
- package/src/editor/autoLink.ts +248 -0
- package/src/editor/blocks/exampleMarker.tsx +24 -0
- 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 +65 -0
- package/src/index.ts +13 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
export interface LinkMatch {
|
|
3
|
+
/** Offset of the first character of the URL within the scanned string. */
|
|
4
|
+
start: number;
|
|
5
|
+
/** Offset just past the last character of the URL. */
|
|
6
|
+
end: number;
|
|
7
|
+
/** The matched URL, used verbatim as the `href`. */
|
|
8
|
+
href: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Find every `http(s)://` URL inside a plain string and return their offsets.
|
|
12
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectLinks(text: string): LinkMatch[];
|
|
15
|
+
interface AutoLinkPluginOptions {
|
|
16
|
+
/**
|
|
17
|
+
* When `true`, a plain click opens the link. When `false` (default), the
|
|
18
|
+
* platform modifier (Cmd/Ctrl) is required so plain clicks still place the
|
|
19
|
+
* text cursor for editing — the safer default inside an editor.
|
|
20
|
+
*/
|
|
21
|
+
openOnPlainClick: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* BlockNote extension that renders bare `http(s)://` URLs as clickable links.
|
|
25
|
+
*
|
|
26
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
27
|
+
* by the schema, so consumers add this to their `useCreateBlockNote` call:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* useCreateBlockNote({
|
|
31
|
+
* schema: customSchema,
|
|
32
|
+
* extensions: [autoLinkExtension()],
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* By default a link opens on Cmd/Ctrl+click (plain clicks still edit the text).
|
|
37
|
+
* Pass `{ openOnPlainClick: true }` to open on a plain click instead.
|
|
38
|
+
*/
|
|
39
|
+
export declare class AutoLinkExtension extends BlockNoteExtension {
|
|
40
|
+
static key(): string;
|
|
41
|
+
constructor(options?: Partial<AutoLinkPluginOptions>);
|
|
42
|
+
}
|
|
43
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
44
|
+
export declare const autoLinkExtension: (options?: Partial<AutoLinkPluginOptions>) => AutoLinkExtension;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
3
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
4
|
+
/**
|
|
5
|
+
* Auto-linking paints bare `http(s)://…` URLs as clickable links *without*
|
|
6
|
+
* modifying the document. Like the tag-badge decoration, the underlying text is
|
|
7
|
+
* left untouched, so markdown serialization round-trips unchanged — a bare URL
|
|
8
|
+
* stays a bare URL (which GFM auto-links anyway) instead of being rewritten into
|
|
9
|
+
* `[url](url)`.
|
|
10
|
+
*
|
|
11
|
+
* A URL is greedily matched as `http://`/`https://` followed by any run of
|
|
12
|
+
* non-whitespace, non-angle-bracket, non-quote characters; trailing sentence
|
|
13
|
+
* punctuation and unbalanced closing brackets are then trimmed so that natural
|
|
14
|
+
* prose like `see https://example.com.` or `(https://example.com)` links the URL
|
|
15
|
+
* without swallowing the surrounding punctuation.
|
|
16
|
+
*/
|
|
17
|
+
const URL_DETECT_REGEXP = /https?:\/\/[^\s<>"'`]+/gi;
|
|
18
|
+
/** Trailing characters that are almost always prose punctuation, not URL. */
|
|
19
|
+
const TRAILING_PUNCTUATION = /[.,;:!?'"”’»)\]}]$/;
|
|
20
|
+
const CLOSING_TO_OPENING = {
|
|
21
|
+
")": "(",
|
|
22
|
+
"]": "[",
|
|
23
|
+
"}": "{",
|
|
24
|
+
};
|
|
25
|
+
function countChar(text, char) {
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const c of text) {
|
|
28
|
+
if (c === char)
|
|
29
|
+
count++;
|
|
30
|
+
}
|
|
31
|
+
return count;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Strip trailing punctuation that the greedy match may have swallowed. A closing
|
|
35
|
+
* bracket is only stripped when it is *unbalanced* (no matching opener inside the
|
|
36
|
+
* URL), so real URLs such as a Wikipedia `..._(disambiguation)` link keep their
|
|
37
|
+
* parentheses.
|
|
38
|
+
*/
|
|
39
|
+
function trimTrailingPunctuation(url) {
|
|
40
|
+
let result = url;
|
|
41
|
+
// Loop because stripping a bracket can expose more punctuation and vice versa,
|
|
42
|
+
// e.g. `https://x.com/a).` → `https://x.com/a)` → `https://x.com/a`.
|
|
43
|
+
for (;;) {
|
|
44
|
+
const last = result[result.length - 1];
|
|
45
|
+
if (last === undefined)
|
|
46
|
+
break;
|
|
47
|
+
const opening = CLOSING_TO_OPENING[last];
|
|
48
|
+
if (opening) {
|
|
49
|
+
if (countChar(result, last) > countChar(result, opening)) {
|
|
50
|
+
result = result.slice(0, -1);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (TRAILING_PUNCTUATION.test(last)) {
|
|
56
|
+
result = result.slice(0, -1);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Find every `http(s)://` URL inside a plain string and return their offsets.
|
|
65
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
66
|
+
*/
|
|
67
|
+
export function detectLinks(text) {
|
|
68
|
+
const matches = [];
|
|
69
|
+
// Fresh regex per call so the shared `lastIndex` never leaks between calls.
|
|
70
|
+
const regexp = new RegExp(URL_DETECT_REGEXP.source, "gi");
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
73
|
+
const href = trimTrailingPunctuation(match[0]);
|
|
74
|
+
// The trim only ever removes characters from the end, so `start` is stable.
|
|
75
|
+
if (href.length > 0) {
|
|
76
|
+
matches.push({ start: match.index, end: match.index + href.length, href });
|
|
77
|
+
}
|
|
78
|
+
// Defensive guard against a zero-length match looping forever (a URL always
|
|
79
|
+
// starts with `http`, so this should never trigger).
|
|
80
|
+
if (regexp.lastIndex === match.index) {
|
|
81
|
+
regexp.lastIndex++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return matches;
|
|
85
|
+
}
|
|
86
|
+
const autoLinkPluginKey = new PluginKey("testomatioAutoLink");
|
|
87
|
+
/**
|
|
88
|
+
* Text carrying a real `link` mark (already an anchor) or a `code` mark
|
|
89
|
+
* (inline code, where a URL is a literal, not a link) is skipped so we never
|
|
90
|
+
* double-decorate or linkify code.
|
|
91
|
+
*/
|
|
92
|
+
function hasBlockingMark(marks) {
|
|
93
|
+
return marks.some((mark) => mark.type.name === "link" || mark.type.name === "code");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build inline decorations for every URL in the document. Code blocks are
|
|
97
|
+
* skipped wholesale; inside every other block, each text node is scanned and any
|
|
98
|
+
* URL that isn't already a link or inline code is painted.
|
|
99
|
+
*/
|
|
100
|
+
function buildLinkDecorations(doc) {
|
|
101
|
+
const decorations = [];
|
|
102
|
+
doc.descendants((node, pos) => {
|
|
103
|
+
// Never linkify inside code blocks — a URL there is source text, not a link.
|
|
104
|
+
if (node.type.spec.code) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (!node.isText || !node.text || hasBlockingMark(node.marks)) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
for (const { start, end, href } of detectLinks(node.text)) {
|
|
111
|
+
decorations.push(Decoration.inline(pos + start, pos + end, {
|
|
112
|
+
class: "bn-auto-link",
|
|
113
|
+
"data-auto-href": href,
|
|
114
|
+
title: href,
|
|
115
|
+
},
|
|
116
|
+
// Stored on the decoration spec so the click handler can resolve the
|
|
117
|
+
// href from document coordinates without trusting the DOM.
|
|
118
|
+
{ autoHref: href }));
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
});
|
|
122
|
+
return DecorationSet.create(doc, decorations);
|
|
123
|
+
}
|
|
124
|
+
/** True when the pointer event carries the platform "open link" modifier. */
|
|
125
|
+
function hasOpenModifier(event) {
|
|
126
|
+
// Cmd on macOS, Ctrl elsewhere — the same convention as VS Code / IntelliJ.
|
|
127
|
+
return event.metaKey || event.ctrlKey;
|
|
128
|
+
}
|
|
129
|
+
function findAutoHrefAt(view, pos) {
|
|
130
|
+
const decorations = autoLinkPluginKey.getState(view.state);
|
|
131
|
+
if (!decorations)
|
|
132
|
+
return null;
|
|
133
|
+
const found = decorations.find(pos, pos);
|
|
134
|
+
for (const deco of found) {
|
|
135
|
+
const href = deco.spec.autoHref;
|
|
136
|
+
if (typeof href === "string")
|
|
137
|
+
return href;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
function openHref(href) {
|
|
142
|
+
if (typeof window === "undefined")
|
|
143
|
+
return;
|
|
144
|
+
// `noopener,noreferrer` so the opened tab can't reach back via `window.opener`.
|
|
145
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
146
|
+
}
|
|
147
|
+
function autoLinkPlugin(options) {
|
|
148
|
+
return new Plugin({
|
|
149
|
+
key: autoLinkPluginKey,
|
|
150
|
+
state: {
|
|
151
|
+
init: (_config, state) => buildLinkDecorations(state.doc),
|
|
152
|
+
apply: (tr, value) => tr.docChanged ? buildLinkDecorations(tr.doc) : value,
|
|
153
|
+
},
|
|
154
|
+
props: {
|
|
155
|
+
decorations(state) {
|
|
156
|
+
return autoLinkPluginKey.getState(state);
|
|
157
|
+
},
|
|
158
|
+
handleClick(view, pos, event) {
|
|
159
|
+
if (!options.openOnPlainClick && !hasOpenModifier(event)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const href = findAutoHrefAt(view, pos);
|
|
163
|
+
if (!href)
|
|
164
|
+
return false;
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
openHref(href);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* BlockNote extension that renders bare `http(s)://` URLs as clickable links.
|
|
174
|
+
*
|
|
175
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
176
|
+
* by the schema, so consumers add this to their `useCreateBlockNote` call:
|
|
177
|
+
*
|
|
178
|
+
* ```ts
|
|
179
|
+
* useCreateBlockNote({
|
|
180
|
+
* schema: customSchema,
|
|
181
|
+
* extensions: [autoLinkExtension()],
|
|
182
|
+
* });
|
|
183
|
+
* ```
|
|
184
|
+
*
|
|
185
|
+
* By default a link opens on Cmd/Ctrl+click (plain clicks still edit the text).
|
|
186
|
+
* Pass `{ openOnPlainClick: true }` to open on a plain click instead.
|
|
187
|
+
*/
|
|
188
|
+
export class AutoLinkExtension extends BlockNoteExtension {
|
|
189
|
+
static key() {
|
|
190
|
+
return "autoLink";
|
|
191
|
+
}
|
|
192
|
+
constructor(options = {}) {
|
|
193
|
+
var _a;
|
|
194
|
+
super();
|
|
195
|
+
this.addProsemirrorPlugin(autoLinkPlugin({ openOnPlainClick: (_a = options.openOnPlainClick) !== null && _a !== void 0 ? _a : false }));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
199
|
+
export const autoLinkExtension = (options) => new AutoLinkExtension(options);
|
|
@@ -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
|
+
});
|
|
@@ -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
|
@@ -5,6 +5,8 @@ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
|
|
|
5
5
|
export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggestion, type MetaFieldSuggestionsConfig, } from "./editor/testMetaFields";
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
7
|
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
|
|
8
|
+
export { autoLinkExtension, AutoLinkExtension, detectLinks, type LinkMatch, } from "./editor/autoLink";
|
|
9
|
+
export { exampleTableHighlightExtension, ExampleTableHighlightExtension, markExampleTables, } from "./editor/exampleTableHighlight";
|
|
8
10
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
9
11
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
|
10
12
|
export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
|
|
|
5
5
|
export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
7
|
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
|
|
8
|
+
export { autoLinkExtension, AutoLinkExtension, detectLinks, } from "./editor/autoLink";
|
|
9
|
+
export { exampleTableHighlightExtension, ExampleTableHighlightExtension, markExampleTables, } from "./editor/exampleTableHighlight";
|
|
8
10
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
9
11
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|
|
10
12
|
export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/styles.css
CHANGED
|
@@ -771,6 +771,26 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
|
771
771
|
border-color: rgba(255, 255, 255, 0.12);
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
/* ============================================
|
|
775
|
+
AUTO-LINKED URLS
|
|
776
|
+
Bare `http(s)://` URLs are painted as links by a ProseMirror inline
|
|
777
|
+
decoration (see src/editor/autoLink.ts). The text stays editable and the
|
|
778
|
+
markdown is untouched; only the styling and a Cmd/Ctrl+click handler are
|
|
779
|
+
added. Opening on click is wired in the plugin, not via `pointer-events`.
|
|
780
|
+
============================================ */
|
|
781
|
+
.testomatio-editor .bn-auto-link {
|
|
782
|
+
color: var(--color-primary-700);
|
|
783
|
+
text-decoration: underline;
|
|
784
|
+
text-underline-offset: 2px;
|
|
785
|
+
cursor: pointer;
|
|
786
|
+
}
|
|
787
|
+
.testomatio-editor .bn-auto-link:hover {
|
|
788
|
+
color: var(--color-primary-600);
|
|
789
|
+
}
|
|
790
|
+
html.dark .testomatio-editor .bn-auto-link {
|
|
791
|
+
color: rgba(129, 140, 248, 1);
|
|
792
|
+
}
|
|
793
|
+
|
|
774
794
|
/* ============================================
|
|
775
795
|
TEST / SUITE METADATA BLOCK
|
|
776
796
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
|
@@ -791,6 +811,37 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
|
791
811
|
opacity: 0.5;
|
|
792
812
|
}
|
|
793
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
|
+
|
|
794
845
|
/* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
|
|
795
846
|
always share one row. */
|
|
796
847
|
.bn-testmeta__header {
|
|
@@ -1932,6 +1983,20 @@ html.dark .bn-step-editor--preview a.step-preview-link {
|
|
|
1932
1983
|
font-size: var(--badge-fz-sm);
|
|
1933
1984
|
}
|
|
1934
1985
|
|
|
1986
|
+
.bn-mantine .bn-suggestion-menu {
|
|
1987
|
+
max-width: 24rem;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
.bn-mt-suggestion-menu-item-body {
|
|
1991
|
+
min-width: 0;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
.bn-mt-suggestion-menu-item-subtitle {
|
|
1995
|
+
white-space: nowrap;
|
|
1996
|
+
overflow: hidden;
|
|
1997
|
+
text-overflow: ellipsis;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1935
2000
|
.bn-suggestion-icon {
|
|
1936
2001
|
display: inline-flex;
|
|
1937
2002
|
align-items: center;
|
package/package.json
CHANGED