testomatio-editor-blocks 0.4.70 → 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.
@@ -106,6 +106,22 @@ export const testMetaBlock = createReactBlockSpec({
106
106
  const editableFields = fields
107
107
  .map((field, index) => ({ field, index }))
108
108
  .filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
109
- return (_jsxs("div", { className: "bn-testmeta", "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField })] }), editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
109
+ // Compact (reading) view: collapse the rows into a single truncated line
110
+ // built only from fields that actually have a value — keys without values
111
+ // are an editing-only concern and never appear in the compact summary (the
112
+ // expanded rows still show them so they can be filled in). The
113
+ // expand/collapse toggle pinned to the far right reveals the full rows.
114
+ const summaryText = editableFields
115
+ .filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
116
+ .map(({ field }) => `${field.key}: ${field.value}`)
117
+ .join(" · ");
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);
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))) }))] }));
110
126
  },
111
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.
@@ -752,8 +776,69 @@ html.dark .bn-teststep__view-toggle--compact svg {
752
776
  margin-left: 8px;
753
777
  }
754
778
 
755
- .bn-testmeta__header .bn-testmeta__add-wrap {
779
+ /* Add button + expand/collapse toggle live in a right-pinned action group. */
780
+ .bn-testmeta__header .bn-testmeta__actions {
756
781
  margin-left: auto;
782
+ display: inline-flex;
783
+ align-items: center;
784
+ gap: 2px;
785
+ flex-shrink: 0;
786
+ }
787
+
788
+ /* Compact one-liner: the metadata rendered as truncated read text, click to
789
+ expand. Takes the remaining header width and ellipsizes when it overflows. */
790
+ .bn-testmeta__summary {
791
+ flex: 1 1 auto;
792
+ min-width: 0;
793
+ overflow: hidden;
794
+ white-space: nowrap;
795
+ text-overflow: ellipsis;
796
+ text-align: left;
797
+ font-family: inherit;
798
+ font-size: 13px;
799
+ color: var(--text-muted);
800
+ background: transparent;
801
+ border: none;
802
+ padding: 0;
803
+ cursor: pointer;
804
+ }
805
+
806
+ .bn-testmeta__summary:hover {
807
+ color: var(--text-primary);
808
+ }
809
+
810
+ .bn-testmeta__summary--empty {
811
+ font-style: italic;
812
+ opacity: 0.7;
813
+ }
814
+
815
+ .bn-testmeta__toggle {
816
+ width: 24px;
817
+ height: 24px;
818
+ display: inline-flex;
819
+ align-items: center;
820
+ justify-content: center;
821
+ color: var(--text-muted);
822
+ background: transparent;
823
+ border: none;
824
+ border-radius: 6px;
825
+ cursor: pointer;
826
+ padding: 0;
827
+ flex-shrink: 0;
828
+ transition: background-color 120ms ease;
829
+ }
830
+
831
+ .bn-testmeta__toggle:hover {
832
+ background: var(--step-bg-button-hover);
833
+ color: var(--text-primary);
834
+ }
835
+
836
+ .bn-testmeta__toggle svg {
837
+ transition: transform 150ms ease;
838
+ }
839
+
840
+ .bn-testmeta__toggle--expanded svg {
841
+ transform: rotate(180deg);
757
842
  }
758
843
 
759
844
  .bn-testmeta__label {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.70",
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)}`;
@@ -187,9 +187,26 @@ export const testMetaBlock = createReactBlockSpec(
187
187
  .map((field, index) => ({ field, index }))
188
188
  .filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
189
189
 
190
+ // Compact (reading) view: collapse the rows into a single truncated line
191
+ // built only from fields that actually have a value — keys without values
192
+ // are an editing-only concern and never appear in the compact summary (the
193
+ // expanded rows still show them so they can be filled in). The
194
+ // expand/collapse toggle pinned to the far right reveals the full rows.
195
+ const summaryText = editableFields
196
+ .filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
197
+ .map(({ field }) => `${field.key}: ${field.value}`)
198
+ .join(" · ");
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);
206
+
190
207
  return (
191
208
  <div
192
- className="bn-testmeta"
209
+ className={`bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`}
193
210
  data-block-id={block.id}
194
211
  data-kind={kind}
195
212
  contentEditable={false}
@@ -199,10 +216,34 @@ export const testMetaBlock = createReactBlockSpec(
199
216
  <div className="bn-testmeta__header">
200
217
  <span className="bn-testmeta__label">{kind.toUpperCase()}</span>
201
218
  {idField?.value && <span className="bn-testmeta__id">{idField.value}</span>}
202
- <AddFieldMenu kind={kind} usedKeys={usedKeys} onPick={handleAddField} />
219
+ {!expanded && (
220
+ <button
221
+ type="button"
222
+ className="bn-testmeta__summary"
223
+ title={summaryText || "No metadata yet"}
224
+ onClick={() => setExpanded(true)}
225
+ >
226
+ {summaryText || <span className="bn-testmeta__summary--empty">No metadata</span>}
227
+ </button>
228
+ )}
229
+ <div className="bn-testmeta__actions">
230
+ {expanded && <AddFieldMenu kind={kind} usedKeys={usedKeys} onPick={handleAddField} />}
231
+ <button
232
+ type="button"
233
+ className={`bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`}
234
+ aria-expanded={expanded}
235
+ aria-label={expanded ? "Collapse metadata" : "Expand metadata"}
236
+ title={expanded ? "Collapse" : "Expand"}
237
+ onClick={() => setExpanded((prev) => !prev)}
238
+ >
239
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
240
+ <path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
241
+ </svg>
242
+ </button>
243
+ </div>
203
244
  </div>
204
245
 
205
- {editableFields.length > 0 && (
246
+ {expanded && editableFields.length > 0 && (
206
247
  <div className="bn-testmeta__rows">
207
248
  {editableFields.map(({ field, index }) => (
208
249
  <div className="bn-testmeta__row" key={index}>
@@ -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.
@@ -752,8 +776,69 @@ html.dark .bn-teststep__view-toggle--compact svg {
752
776
  margin-left: 8px;
753
777
  }
754
778
 
755
- .bn-testmeta__header .bn-testmeta__add-wrap {
779
+ /* Add button + expand/collapse toggle live in a right-pinned action group. */
780
+ .bn-testmeta__header .bn-testmeta__actions {
756
781
  margin-left: auto;
782
+ display: inline-flex;
783
+ align-items: center;
784
+ gap: 2px;
785
+ flex-shrink: 0;
786
+ }
787
+
788
+ /* Compact one-liner: the metadata rendered as truncated read text, click to
789
+ expand. Takes the remaining header width and ellipsizes when it overflows. */
790
+ .bn-testmeta__summary {
791
+ flex: 1 1 auto;
792
+ min-width: 0;
793
+ overflow: hidden;
794
+ white-space: nowrap;
795
+ text-overflow: ellipsis;
796
+ text-align: left;
797
+ font-family: inherit;
798
+ font-size: 13px;
799
+ color: var(--text-muted);
800
+ background: transparent;
801
+ border: none;
802
+ padding: 0;
803
+ cursor: pointer;
804
+ }
805
+
806
+ .bn-testmeta__summary:hover {
807
+ color: var(--text-primary);
808
+ }
809
+
810
+ .bn-testmeta__summary--empty {
811
+ font-style: italic;
812
+ opacity: 0.7;
813
+ }
814
+
815
+ .bn-testmeta__toggle {
816
+ width: 24px;
817
+ height: 24px;
818
+ display: inline-flex;
819
+ align-items: center;
820
+ justify-content: center;
821
+ color: var(--text-muted);
822
+ background: transparent;
823
+ border: none;
824
+ border-radius: 6px;
825
+ cursor: pointer;
826
+ padding: 0;
827
+ flex-shrink: 0;
828
+ transition: background-color 120ms ease;
829
+ }
830
+
831
+ .bn-testmeta__toggle:hover {
832
+ background: var(--step-bg-button-hover);
833
+ color: var(--text-primary);
834
+ }
835
+
836
+ .bn-testmeta__toggle svg {
837
+ transition: transform 150ms ease;
838
+ }
839
+
840
+ .bn-testmeta__toggle--expanded svg {
841
+ transform: rotate(180deg);
757
842
  }
758
843
 
759
844
  .bn-testmeta__label {
@@ -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,