testomatio-editor-blocks 0.4.71 → 0.4.72
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/testMeta.js +7 -4
- package/package/editor/tagBadge.d.ts +35 -0
- package/package/editor/tagBadge.js +114 -0
- package/package/index.d.ts +1 -0
- package/package/index.js +1 -0
- package/package/styles.css +24 -0
- package/package.json +1 -1
- package/src/App.tsx +2 -0
- package/src/editor/blocks/testMeta.tsx +7 -4
- package/src/editor/styles.css +24 -0
- package/src/editor/tagBadge.test.ts +75 -0
- package/src/editor/tagBadge.ts +143 -0
- package/src/index.ts +8 -0
|
@@ -115,10 +115,13 @@ export const testMetaBlock = createReactBlockSpec({
|
|
|
115
115
|
.filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
|
|
116
116
|
.map(({ field }) => `${field.key}: ${field.value}`)
|
|
117
117
|
.join(" · ");
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
118
|
+
// Default to the compact (collapsed) view whenever the block carries any
|
|
119
|
+
// field — this keeps suite blocks compact too, even when their fields have
|
|
120
|
+
// no value yet (e.g. a seeded `emoji:` row) and so produce an empty
|
|
121
|
+
// summary. Only a genuinely empty block (no fields at all) starts expanded
|
|
122
|
+
// so it's immediately editable. `expanded` is UI-only state — never
|
|
123
|
+
// serialized.
|
|
124
|
+
const [expanded, setExpanded] = useState(() => fields.length === 0);
|
|
122
125
|
return (_jsxs("div", { className: `bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`, "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 }), !expanded && (_jsx("button", { type: "button", className: "bn-testmeta__summary", title: summaryText || "No metadata yet", onClick: () => setExpanded(true), children: summaryText || _jsx("span", { className: "bn-testmeta__summary--empty", children: "No metadata" }) })), _jsxs("div", { className: "bn-testmeta__actions", children: [expanded && _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField }), _jsx("button", { type: "button", className: `bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Collapse metadata" : "Expand metadata", title: expanded ? "Collapse" : "Expand", onClick: () => setExpanded((prev) => !prev), children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 6L8 10L12 6", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), expanded && 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))) }))] }));
|
|
123
126
|
},
|
|
124
127
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
3
|
+
export declare const TAGS_DETECT_REGEXP: RegExp;
|
|
4
|
+
export interface TagMatch {
|
|
5
|
+
/** Offset of the `@` within the scanned string (the leading space is excluded). */
|
|
6
|
+
start: number;
|
|
7
|
+
/** Offset just past the last character of the tag. */
|
|
8
|
+
end: number;
|
|
9
|
+
/** The matched tag text, including the leading `@`. */
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
14
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectTags(text: string): TagMatch[];
|
|
17
|
+
/**
|
|
18
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
19
|
+
*
|
|
20
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
21
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* useCreateBlockNote({
|
|
25
|
+
* schema: customSchema,
|
|
26
|
+
* extensions: [tagBadgeExtension()],
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare class TagBadgeExtension extends BlockNoteExtension {
|
|
31
|
+
static key(): string;
|
|
32
|
+
constructor();
|
|
33
|
+
}
|
|
34
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
35
|
+
export declare const tagBadgeExtension: () => TagBadgeExtension;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
3
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
4
|
+
/**
|
|
5
|
+
* Tags are tokens that start with `@` and may contain alphanumerics plus a few
|
|
6
|
+
* symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
|
|
7
|
+
*
|
|
8
|
+
* This is a faithful JS port of the backend (Ruby) tag regexes:
|
|
9
|
+
* TAG_PREFIX_REGEXP = /(?:^|[ \t])/
|
|
10
|
+
* TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
|
|
11
|
+
* TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
|
|
12
|
+
*
|
|
13
|
+
* `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
|
|
14
|
+
* staying faithful to the allowed characters. A tag must be preceded by the
|
|
15
|
+
* start of the string or whitespace — so email-like text (`user@example.com`)
|
|
16
|
+
* is correctly ignored.
|
|
17
|
+
*/
|
|
18
|
+
const TAG_ALLOWED_SYMBOLS = String.raw `[\w=\-_().:&]*[\w)]`;
|
|
19
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
20
|
+
export const TAGS_DETECT_REGEXP = new RegExp(String.raw `(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`, "g");
|
|
21
|
+
/**
|
|
22
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
23
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
24
|
+
*/
|
|
25
|
+
export function detectTags(text) {
|
|
26
|
+
const matches = [];
|
|
27
|
+
// Use a fresh regex each call so the shared `lastIndex` state never leaks
|
|
28
|
+
// between invocations.
|
|
29
|
+
const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
32
|
+
const tag = match[1];
|
|
33
|
+
// The match may include a leading space/tab consumed by the prefix; the tag
|
|
34
|
+
// itself starts that many characters into the overall match.
|
|
35
|
+
const start = match.index + (match[0].length - tag.length);
|
|
36
|
+
matches.push({ start, end: start + tag.length, tag });
|
|
37
|
+
// Defensive guard against a zero-length match looping forever (a tag always
|
|
38
|
+
// starts with `@`, so this should never trigger).
|
|
39
|
+
if (regexp.lastIndex === match.index) {
|
|
40
|
+
regexp.lastIndex++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return matches;
|
|
44
|
+
}
|
|
45
|
+
const tagBadgePluginKey = new PluginKey("testomatioTagBadge");
|
|
46
|
+
/**
|
|
47
|
+
* Build inline decorations for every `@tag` found inside heading blocks. Tags
|
|
48
|
+
* are only *painted* — the underlying text is untouched, so markdown
|
|
49
|
+
* serialization round-trips unchanged.
|
|
50
|
+
*/
|
|
51
|
+
function buildHeadingTagDecorations(doc) {
|
|
52
|
+
const decorations = [];
|
|
53
|
+
doc.descendants((node, pos) => {
|
|
54
|
+
if (node.type.name !== "heading") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// Walk the heading's inline children so positions stay correct even when it
|
|
58
|
+
// contains links or inline images alongside text.
|
|
59
|
+
node.forEach((child, offset) => {
|
|
60
|
+
if (!child.isText || !child.text) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// `pos + 1` steps inside the heading content node; `offset` is the child's
|
|
64
|
+
// position relative to the node's content.
|
|
65
|
+
const base = pos + 1 + offset;
|
|
66
|
+
for (const { start, end } of detectTags(child.text)) {
|
|
67
|
+
decorations.push(Decoration.inline(base + start, base + end, {
|
|
68
|
+
class: "bn-tag-badge",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// Headings only hold inline content; no need to descend further.
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
return DecorationSet.create(doc, decorations);
|
|
76
|
+
}
|
|
77
|
+
function tagBadgePlugin() {
|
|
78
|
+
return new Plugin({
|
|
79
|
+
key: tagBadgePluginKey,
|
|
80
|
+
state: {
|
|
81
|
+
init: (_config, state) => buildHeadingTagDecorations(state.doc),
|
|
82
|
+
apply: (tr, value) => tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
|
|
83
|
+
},
|
|
84
|
+
props: {
|
|
85
|
+
decorations(state) {
|
|
86
|
+
return tagBadgePluginKey.getState(state);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
93
|
+
*
|
|
94
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
95
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* useCreateBlockNote({
|
|
99
|
+
* schema: customSchema,
|
|
100
|
+
* extensions: [tagBadgeExtension()],
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export class TagBadgeExtension extends BlockNoteExtension {
|
|
105
|
+
static key() {
|
|
106
|
+
return "tagBadge";
|
|
107
|
+
}
|
|
108
|
+
constructor() {
|
|
109
|
+
super();
|
|
110
|
+
this.addProsemirrorPlugin(tagBadgePlugin());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
114
|
+
export const tagBadgeExtension = () => new TagBadgeExtension();
|
package/package/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
|
|
|
4
4
|
export { testMetaBlock } 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
|
+
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
|
|
7
8
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
8
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
|
9
10
|
export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
|
|
|
4
4
|
export { testMetaBlock } from "./editor/blocks/testMeta";
|
|
5
5
|
export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
|
+
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
|
|
7
8
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
8
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|
|
9
10
|
export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/styles.css
CHANGED
|
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
723
723
|
--prev-level: 18px;
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/* ============================================
|
|
727
|
+
TAG BADGES IN HEADINGS
|
|
728
|
+
`@tag` tokens inside headings are painted as compact badges by a ProseMirror
|
|
729
|
+
inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
|
|
730
|
+
the styling is applied.
|
|
731
|
+
============================================ */
|
|
732
|
+
.testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
733
|
+
font-size: 0.78em;
|
|
734
|
+
font-weight: 500;
|
|
735
|
+
line-height: 1.2;
|
|
736
|
+
background: var(--bg-muted);
|
|
737
|
+
color: var(--text-muted);
|
|
738
|
+
border: 1px solid var(--border-light);
|
|
739
|
+
border-radius: 6px;
|
|
740
|
+
padding: 0.05em 0.4em;
|
|
741
|
+
vertical-align: middle;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
}
|
|
744
|
+
html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
745
|
+
background: rgba(255, 255, 255, 0.08);
|
|
746
|
+
color: rgba(255, 255, 255, 0.6);
|
|
747
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
748
|
+
}
|
|
749
|
+
|
|
726
750
|
/* ============================================
|
|
727
751
|
TEST / SUITE METADATA BLOCK
|
|
728
752
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./editor/customMarkdownConverter";
|
|
21
21
|
import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
|
|
22
22
|
import { customSchema, type CustomEditor } from "./editor/customSchema";
|
|
23
|
+
import { tagBadgeExtension } from "./editor/tagBadge";
|
|
23
24
|
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
|
|
24
25
|
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
|
|
25
26
|
import { setImageUploadHandler } from "./editor/stepImageUpload";
|
|
@@ -404,6 +405,7 @@ function CustomSlashMenu() {
|
|
|
404
405
|
function App() {
|
|
405
406
|
const editor = useCreateBlockNote({
|
|
406
407
|
schema: customSchema,
|
|
408
|
+
extensions: [tagBadgeExtension()],
|
|
407
409
|
pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
|
|
408
410
|
uploadFile: async (file: File) => {
|
|
409
411
|
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
|
|
@@ -196,10 +196,13 @@ export const testMetaBlock = createReactBlockSpec(
|
|
|
196
196
|
.filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
|
|
197
197
|
.map(({ field }) => `${field.key}: ${field.value}`)
|
|
198
198
|
.join(" · ");
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
199
|
+
// Default to the compact (collapsed) view whenever the block carries any
|
|
200
|
+
// field — this keeps suite blocks compact too, even when their fields have
|
|
201
|
+
// no value yet (e.g. a seeded `emoji:` row) and so produce an empty
|
|
202
|
+
// summary. Only a genuinely empty block (no fields at all) starts expanded
|
|
203
|
+
// so it's immediately editable. `expanded` is UI-only state — never
|
|
204
|
+
// serialized.
|
|
205
|
+
const [expanded, setExpanded] = useState(() => fields.length === 0);
|
|
203
206
|
|
|
204
207
|
return (
|
|
205
208
|
<div
|
package/src/editor/styles.css
CHANGED
|
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
723
723
|
--prev-level: 18px;
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/* ============================================
|
|
727
|
+
TAG BADGES IN HEADINGS
|
|
728
|
+
`@tag` tokens inside headings are painted as compact badges by a ProseMirror
|
|
729
|
+
inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
|
|
730
|
+
the styling is applied.
|
|
731
|
+
============================================ */
|
|
732
|
+
.testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
733
|
+
font-size: 0.78em;
|
|
734
|
+
font-weight: 500;
|
|
735
|
+
line-height: 1.2;
|
|
736
|
+
background: var(--bg-muted);
|
|
737
|
+
color: var(--text-muted);
|
|
738
|
+
border: 1px solid var(--border-light);
|
|
739
|
+
border-radius: 6px;
|
|
740
|
+
padding: 0.05em 0.4em;
|
|
741
|
+
vertical-align: middle;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
}
|
|
744
|
+
html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
745
|
+
background: rgba(255, 255, 255, 0.08);
|
|
746
|
+
color: rgba(255, 255, 255, 0.6);
|
|
747
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
748
|
+
}
|
|
749
|
+
|
|
726
750
|
/* ============================================
|
|
727
751
|
TEST / SUITE METADATA BLOCK
|
|
728
752
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectTags } from "./tagBadge";
|
|
3
|
+
|
|
4
|
+
describe("detectTags", () => {
|
|
5
|
+
it("detects a single tag at the start of the string", () => {
|
|
6
|
+
expect(detectTags("@smoke")).toEqual([{ start: 0, end: 6, tag: "@smoke" }]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("detects a tag that follows a space", () => {
|
|
10
|
+
// "Login flow @smoke" — the @ is at index 11.
|
|
11
|
+
expect(detectTags("Login flow @smoke")).toEqual([
|
|
12
|
+
{ start: 11, end: 17, tag: "@smoke" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("detects multiple tags in one title", () => {
|
|
17
|
+
const matches = detectTags("Login flow @smoke @regression");
|
|
18
|
+
expect(matches).toEqual([
|
|
19
|
+
{ start: 11, end: 17, tag: "@smoke" },
|
|
20
|
+
{ start: 18, end: 29, tag: "@regression" },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects tags that contain allowed symbols", () => {
|
|
25
|
+
expect(detectTags("@severity:high")).toEqual([
|
|
26
|
+
{ start: 0, end: 14, tag: "@severity:high" },
|
|
27
|
+
]);
|
|
28
|
+
expect(detectTags("@T1234abcd")).toEqual([
|
|
29
|
+
{ start: 0, end: 10, tag: "@T1234abcd" },
|
|
30
|
+
]);
|
|
31
|
+
expect(detectTags("@a-b_c")).toEqual([{ start: 0, end: 6, tag: "@a-b_c" }]);
|
|
32
|
+
expect(detectTags("@group(1)")).toEqual([
|
|
33
|
+
{ start: 0, end: 9, tag: "@group(1)" },
|
|
34
|
+
]);
|
|
35
|
+
expect(detectTags("@key=value")).toEqual([
|
|
36
|
+
{ start: 0, end: 10, tag: "@key=value" },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("detects a tag after a tab character", () => {
|
|
41
|
+
expect(detectTags("title\t@flaky")).toEqual([
|
|
42
|
+
{ start: 6, end: 12, tag: "@flaky" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not match an email address", () => {
|
|
47
|
+
// The `@` is preceded by a word character, not start/whitespace.
|
|
48
|
+
expect(detectTags("Contact user@example.com")).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not match a bare @ with no body", () => {
|
|
52
|
+
expect(detectTags("just an @ symbol")).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not match @ in the middle of a word", () => {
|
|
56
|
+
expect(detectTags("foo@bar")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns an empty array for text without tags", () => {
|
|
60
|
+
expect(detectTags("A plain heading title")).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("ignores a trailing dot that is not a valid tag terminator", () => {
|
|
64
|
+
// The tag must end with a word char or `)`, so the trailing `.` is excluded.
|
|
65
|
+
expect(detectTags("end of @smoke.")).toEqual([
|
|
66
|
+
{ start: 7, end: 13, tag: "@smoke" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not keep stale regex state across calls", () => {
|
|
71
|
+
// Guards against a shared lastIndex leaking between invocations.
|
|
72
|
+
expect(detectTags("@one")).toEqual([{ start: 0, end: 4, tag: "@one" }]);
|
|
73
|
+
expect(detectTags("@two")).toEqual([{ start: 0, end: 4, tag: "@two" }]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
* Tags are tokens that start with `@` and may contain alphanumerics plus a few
|
|
8
|
+
* symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
|
|
9
|
+
*
|
|
10
|
+
* This is a faithful JS port of the backend (Ruby) tag regexes:
|
|
11
|
+
* TAG_PREFIX_REGEXP = /(?:^|[ \t])/
|
|
12
|
+
* TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
|
|
13
|
+
* TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
|
|
14
|
+
*
|
|
15
|
+
* `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
|
|
16
|
+
* staying faithful to the allowed characters. A tag must be preceded by the
|
|
17
|
+
* start of the string or whitespace — so email-like text (`user@example.com`)
|
|
18
|
+
* is correctly ignored.
|
|
19
|
+
*/
|
|
20
|
+
const TAG_ALLOWED_SYMBOLS = String.raw`[\w=\-_().:&]*[\w)]`;
|
|
21
|
+
|
|
22
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
23
|
+
export const TAGS_DETECT_REGEXP = new RegExp(
|
|
24
|
+
String.raw`(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`,
|
|
25
|
+
"g",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface TagMatch {
|
|
29
|
+
/** Offset of the `@` within the scanned string (the leading space is excluded). */
|
|
30
|
+
start: number;
|
|
31
|
+
/** Offset just past the last character of the tag. */
|
|
32
|
+
end: number;
|
|
33
|
+
/** The matched tag text, including the leading `@`. */
|
|
34
|
+
tag: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
39
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
40
|
+
*/
|
|
41
|
+
export function detectTags(text: string): TagMatch[] {
|
|
42
|
+
const matches: TagMatch[] = [];
|
|
43
|
+
// Use a fresh regex each call so the shared `lastIndex` state never leaks
|
|
44
|
+
// between invocations.
|
|
45
|
+
const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
|
|
46
|
+
let match: RegExpExecArray | null;
|
|
47
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
48
|
+
const tag = match[1];
|
|
49
|
+
// The match may include a leading space/tab consumed by the prefix; the tag
|
|
50
|
+
// itself starts that many characters into the overall match.
|
|
51
|
+
const start = match.index + (match[0].length - tag.length);
|
|
52
|
+
matches.push({ start, end: start + tag.length, tag });
|
|
53
|
+
// Defensive guard against a zero-length match looping forever (a tag always
|
|
54
|
+
// starts with `@`, so this should never trigger).
|
|
55
|
+
if (regexp.lastIndex === match.index) {
|
|
56
|
+
regexp.lastIndex++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return matches;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tagBadgePluginKey = new PluginKey<DecorationSet>("testomatioTagBadge");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build inline decorations for every `@tag` found inside heading blocks. Tags
|
|
66
|
+
* are only *painted* — the underlying text is untouched, so markdown
|
|
67
|
+
* serialization round-trips unchanged.
|
|
68
|
+
*/
|
|
69
|
+
function buildHeadingTagDecorations(doc: PMNode): DecorationSet {
|
|
70
|
+
const decorations: Decoration[] = [];
|
|
71
|
+
|
|
72
|
+
doc.descendants((node, pos) => {
|
|
73
|
+
if (node.type.name !== "heading") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Walk the heading's inline children so positions stay correct even when it
|
|
78
|
+
// contains links or inline images alongside text.
|
|
79
|
+
node.forEach((child, offset) => {
|
|
80
|
+
if (!child.isText || !child.text) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// `pos + 1` steps inside the heading content node; `offset` is the child's
|
|
84
|
+
// position relative to the node's content.
|
|
85
|
+
const base = pos + 1 + offset;
|
|
86
|
+
for (const { start, end } of detectTags(child.text)) {
|
|
87
|
+
decorations.push(
|
|
88
|
+
Decoration.inline(base + start, base + end, {
|
|
89
|
+
class: "bn-tag-badge",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Headings only hold inline content; no need to descend further.
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return DecorationSet.create(doc, decorations);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tagBadgePlugin(): Plugin<DecorationSet> {
|
|
103
|
+
return new Plugin<DecorationSet>({
|
|
104
|
+
key: tagBadgePluginKey,
|
|
105
|
+
state: {
|
|
106
|
+
init: (_config, state) => buildHeadingTagDecorations(state.doc),
|
|
107
|
+
apply: (tr, value) =>
|
|
108
|
+
tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
|
|
109
|
+
},
|
|
110
|
+
props: {
|
|
111
|
+
decorations(state) {
|
|
112
|
+
return tagBadgePluginKey.getState(state);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
120
|
+
*
|
|
121
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
122
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* useCreateBlockNote({
|
|
126
|
+
* schema: customSchema,
|
|
127
|
+
* extensions: [tagBadgeExtension()],
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export class TagBadgeExtension extends BlockNoteExtension {
|
|
132
|
+
static key() {
|
|
133
|
+
return "tagBadge";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
constructor() {
|
|
137
|
+
super();
|
|
138
|
+
this.addProsemirrorPlugin(tagBadgePlugin());
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
143
|
+
export const tagBadgeExtension = () => new TagBadgeExtension();
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,14 @@ export {
|
|
|
15
15
|
} from "./editor/testMetaFields";
|
|
16
16
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
17
17
|
|
|
18
|
+
export {
|
|
19
|
+
tagBadgeExtension,
|
|
20
|
+
TagBadgeExtension,
|
|
21
|
+
TAGS_DETECT_REGEXP,
|
|
22
|
+
detectTags,
|
|
23
|
+
type TagMatch,
|
|
24
|
+
} from "./editor/tagBadge";
|
|
25
|
+
|
|
18
26
|
export {
|
|
19
27
|
blocksToMarkdown,
|
|
20
28
|
markdownToBlocks,
|