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.
@@ -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
- // Nothing readable to summarise (no fields, or only empty values) -> start
119
- // expanded so the block is immediately editable. `expanded` is UI-only
120
- // state never serialized.
121
- const [expanded, setExpanded] = useState(() => summaryText.length === 0);
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();
@@ -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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.71",
3
+ "version": "0.4.72",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
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
- // Nothing readable to summarise (no fields, or only empty values) -> start
200
- // expanded so the block is immediately editable. `expanded` is UI-only
201
- // state never serialized.
202
- const [expanded, setExpanded] = useState(() => summaryText.length === 0);
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
@@ -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,