testomatio-editor-blocks 0.1.2 → 0.2.1

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.
Files changed (36) hide show
  1. package/README.md +13 -6
  2. package/package/editor/blocks/markdown.d.ts +5 -0
  3. package/package/editor/blocks/markdown.js +160 -0
  4. package/package/editor/blocks/snippet.d.ts +38 -0
  5. package/package/editor/blocks/snippet.js +65 -0
  6. package/package/editor/blocks/step.d.ts +32 -0
  7. package/package/editor/blocks/step.js +97 -0
  8. package/package/editor/blocks/stepField.d.ts +26 -0
  9. package/package/editor/blocks/stepField.js +316 -0
  10. package/package/editor/customMarkdownConverter.js +111 -80
  11. package/package/editor/customSchema.d.ts +31 -45
  12. package/package/editor/customSchema.js +6 -616
  13. package/package/editor/snippetAutocomplete.d.ts +28 -0
  14. package/package/editor/snippetAutocomplete.js +94 -0
  15. package/package/editor/stepAutocomplete.d.ts +1 -1
  16. package/package/editor/stepAutocomplete.js +1 -1
  17. package/package/index.d.ts +4 -1
  18. package/package/index.js +4 -1
  19. package/package/styles.css +57 -0
  20. package/package.json +1 -1
  21. package/src/App.tsx +143 -41
  22. package/src/editor/blocks/blocks.test.ts +22 -0
  23. package/src/editor/blocks/markdown.ts +199 -0
  24. package/src/editor/blocks/snippet.tsx +109 -0
  25. package/src/editor/blocks/step.tsx +175 -0
  26. package/src/editor/blocks/stepField.tsx +487 -0
  27. package/src/editor/customMarkdownConverter.test.ts +121 -36
  28. package/src/editor/customMarkdownConverter.ts +128 -85
  29. package/src/editor/customSchema.tsx +6 -935
  30. package/src/editor/snippetAutocomplete.test.ts +54 -0
  31. package/src/editor/snippetAutocomplete.ts +133 -0
  32. package/src/editor/stepAutocomplete.test.ts +3 -3
  33. package/src/editor/stepAutocomplete.tsx +1 -1
  34. package/src/editor/styles.css +57 -0
  35. package/src/index.ts +4 -1
  36. package/src/editor/customSchema.test.ts +0 -47
package/README.md CHANGED
@@ -107,8 +107,8 @@ return (
107
107
 
108
108
  4. **Blocks Available**
109
109
 
110
- - `testCase`: rich-text wrapper with status and reference metadata.
111
- - `testStep`: inline WYSIWYG inputs for Step Title, Data, and Expected Result with bold/italic/underline formatting.
110
+ - `testStep`: inline WYSIWYG inputs for Step Title, Data, and Expected Result with bold/italic/underline formatting/images.
111
+ - `snippet`: dropdown to pick a reusable snippet and editable body (no formatting buttons).
112
112
 
113
113
  ## Step Autocomplete & Image Upload Hooks
114
114
 
@@ -117,20 +117,27 @@ Configure everything via JS—no React providers required:
117
117
  ```ts
118
118
  import {
119
119
  customSchema,
120
- setGlobalStepSuggestionsFetcher,
121
- setGlobalStepImageUploadHandler,
120
+ setStepsFetcher,
122
121
  } from "testomatio-editor-blocks";
122
+ import { setSnippetFetcher } from "testomatio-editor-blocks/snippets";
123
123
 
124
124
  // Step suggestions (fetch or return an array of { id, title, ... })
125
- setGlobalStepSuggestionsFetcher(async () => {
125
+ setStepsFetcher(async () => {
126
126
  const res = await fetch("https://api.testomatio.com/v1/steps");
127
127
  return res.json();
128
128
  });
129
129
 
130
+ setSnippetFetcher(async () => {
131
+ const res = await fetch("https://api.testomatio.com/v1/snippets");
132
+ return res.json();
133
+ });
134
+
130
135
  // Image upload uses BlockNote's `uploadFile` handler you pass to `useCreateBlockNote`.
131
- // No extra setup is required for step fields.
136
+ // No extra setup is required for step/snippet fields.
132
137
  ```
133
138
 
139
+ For snippets, provide a suggestions fetcher that returns `{ id, title, body, ... }` or a JSON:API document and map it with `setSnippetFetcher`. Selecting a snippet fills its body; Markdown is wrapped with `<!-- begin snippet #id --> ... <!-- end snippet #id -->`.
140
+
134
141
  Step suggestions accept either an array of `{ id, title, ... }` or the JSON:API shape:
135
142
 
136
143
  ```json
@@ -0,0 +1,5 @@
1
+ export declare function escapeHtml(text: string): string;
2
+ export declare function markdownToHtml(markdown: string): string;
3
+ export declare function escapeMarkdownText(text: string): string;
4
+ export declare function normalizePlainText(text: string): string;
5
+ export declare function htmlToMarkdown(html: string): string;
@@ -0,0 +1,160 @@
1
+ const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
2
+ const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
3
+ export function escapeHtml(text) {
4
+ return text
5
+ .replace(/&/g, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/\"/g, "&quot;")
9
+ .replace(/'/g, "&#39;");
10
+ }
11
+ function restoreEscapes(text) {
12
+ return text.replace(/\uE000/g, "\\");
13
+ }
14
+ function parseInlineMarkdown(text) {
15
+ if (!text) {
16
+ return [];
17
+ }
18
+ const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
19
+ const rawSegments = normalized
20
+ .split(/(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/)
21
+ .filter(Boolean);
22
+ return rawSegments.map((segment) => {
23
+ const baseStyles = { bold: false, italic: false, underline: false };
24
+ if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
25
+ const content = segment.slice(2, -2);
26
+ return {
27
+ text: restoreEscapes(content),
28
+ styles: { ...baseStyles, bold: true },
29
+ };
30
+ }
31
+ if (/^\*(.+)\*$/.test(segment) || /^_(.+)_$/.test(segment)) {
32
+ const content = segment.slice(1, -1);
33
+ return {
34
+ text: restoreEscapes(content),
35
+ styles: { ...baseStyles, italic: true },
36
+ };
37
+ }
38
+ if (/^<u>(.+)<\/u>$/.test(segment)) {
39
+ const content = segment.slice(3, -4);
40
+ return {
41
+ text: restoreEscapes(content),
42
+ styles: { ...baseStyles, underline: true },
43
+ };
44
+ }
45
+ return {
46
+ text: restoreEscapes(segment),
47
+ styles: { ...baseStyles },
48
+ };
49
+ });
50
+ }
51
+ function inlineToHtml(inline) {
52
+ return inline
53
+ .map(({ text, styles }) => {
54
+ let html = escapeHtml(text);
55
+ if (styles.bold) {
56
+ html = `<strong>${html}</strong>`;
57
+ }
58
+ if (styles.italic) {
59
+ html = `<em>${html}</em>`;
60
+ }
61
+ if (styles.underline) {
62
+ html = `<u>${html}</u>`;
63
+ }
64
+ return html;
65
+ })
66
+ .join("");
67
+ }
68
+ export function markdownToHtml(markdown) {
69
+ if (!markdown) {
70
+ return "";
71
+ }
72
+ const lines = markdown.split(/\n/);
73
+ const htmlLines = lines.map((line) => {
74
+ const inline = parseInlineMarkdown(line);
75
+ const html = inlineToHtml(inline);
76
+ if (!html) {
77
+ return html;
78
+ }
79
+ return html.replace(IMAGE_MARKDOWN_REGEX, (_match, alt = "", src = "") => `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" class="bn-inline-image" contenteditable="false" draggable="false" />`);
80
+ });
81
+ return htmlLines.join("<br />");
82
+ }
83
+ export function escapeMarkdownText(text) {
84
+ return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
85
+ }
86
+ export function normalizePlainText(text) {
87
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
88
+ }
89
+ function fallbackHtmlToMarkdown(html) {
90
+ if (!html) {
91
+ return "";
92
+ }
93
+ let result = html;
94
+ result = result.replace(/<img[^>]*>/gi, (match) => {
95
+ var _a, _b, _c, _d;
96
+ const src = (_b = (_a = match.match(/src="([^"]*)"/i)) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : "";
97
+ const alt = (_d = (_c = match.match(/alt="([^"]*)"/i)) === null || _c === void 0 ? void 0 : _c[1]) !== null && _d !== void 0 ? _d : "";
98
+ return `![${alt}](${src})`;
99
+ });
100
+ result = result
101
+ .replace(/<br\s*\/?>/gi, "\n")
102
+ .replace(/<\/?(div|p)>/gi, "\n")
103
+ .replace(/<strong>(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
104
+ .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `*${content}*`)
105
+ .replace(/<span[^>]*>/gi, "")
106
+ .replace(/<\/span>/gi, "")
107
+ .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
108
+ result = result.replace(/<\/?[^>]+>/g, "");
109
+ return result
110
+ .split("\n")
111
+ .map((line) => line.trimEnd())
112
+ .join("\n")
113
+ .replace(/\n{3,}/g, "\n\n")
114
+ .trim();
115
+ }
116
+ export function htmlToMarkdown(html) {
117
+ if (typeof document === "undefined") {
118
+ return fallbackHtmlToMarkdown(html);
119
+ }
120
+ const temp = document.createElement("div");
121
+ temp.innerHTML = html;
122
+ const traverse = (node) => {
123
+ var _a, _b, _c;
124
+ if (node.nodeType === Node.TEXT_NODE) {
125
+ const text = (_a = node.textContent) !== null && _a !== void 0 ? _a : "";
126
+ return escapeMarkdownText(text);
127
+ }
128
+ if (node.nodeType !== Node.ELEMENT_NODE) {
129
+ return "";
130
+ }
131
+ const element = node;
132
+ const children = Array.from(element.childNodes)
133
+ .map(traverse)
134
+ .join("");
135
+ switch (element.tagName.toLowerCase()) {
136
+ case "strong":
137
+ case "b":
138
+ return children ? `**${children}**` : children;
139
+ case "em":
140
+ case "i":
141
+ return children ? `*${children}*` : children;
142
+ case "u":
143
+ return children ? `<u>${children}</u>` : children;
144
+ case "br":
145
+ return "\n";
146
+ case "div":
147
+ case "p":
148
+ return children + "\n";
149
+ case "img": {
150
+ const src = (_b = element.getAttribute("src")) !== null && _b !== void 0 ? _b : "";
151
+ const alt = (_c = element.getAttribute("alt")) !== null && _c !== void 0 ? _c : "";
152
+ return `![${alt}](${src})`;
153
+ }
154
+ default:
155
+ return children;
156
+ }
157
+ };
158
+ const markdown = Array.from(temp.childNodes).map(traverse).join("");
159
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
160
+ }
@@ -0,0 +1,38 @@
1
+ export declare const snippetBlock: {
2
+ config: {
3
+ readonly type: "snippet";
4
+ readonly content: "none";
5
+ readonly propSchema: {
6
+ readonly snippetId: {
7
+ readonly default: "";
8
+ };
9
+ readonly snippetTitle: {
10
+ readonly default: "";
11
+ };
12
+ readonly snippetData: {
13
+ readonly default: "";
14
+ };
15
+ readonly snippetExpectedResult: {
16
+ readonly default: "";
17
+ };
18
+ };
19
+ };
20
+ implementation: import("@blocknote/core").TiptapBlockImplementation<{
21
+ readonly type: "snippet";
22
+ readonly content: "none";
23
+ readonly propSchema: {
24
+ readonly snippetId: {
25
+ readonly default: "";
26
+ };
27
+ readonly snippetTitle: {
28
+ readonly default: "";
29
+ };
30
+ readonly snippetData: {
31
+ readonly default: "";
32
+ };
33
+ readonly snippetExpectedResult: {
34
+ readonly default: "";
35
+ };
36
+ };
37
+ }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
38
+ };
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createReactBlockSpec } from "@blocknote/react";
3
+ import { useCallback } from "react";
4
+ import { StepField } from "./stepField";
5
+ import { useSnippetAutocomplete } from "../snippetAutocomplete";
6
+ export const snippetBlock = createReactBlockSpec({
7
+ type: "snippet",
8
+ content: "none",
9
+ propSchema: {
10
+ snippetId: {
11
+ default: "",
12
+ },
13
+ snippetTitle: {
14
+ default: "",
15
+ },
16
+ snippetData: {
17
+ default: "",
18
+ },
19
+ snippetExpectedResult: {
20
+ default: "",
21
+ },
22
+ },
23
+ }, {
24
+ render: ({ block, editor }) => {
25
+ const snippetTitle = block.props.snippetTitle || "";
26
+ const snippetData = block.props.snippetData || "";
27
+ const snippetSuggestions = useSnippetAutocomplete();
28
+ const handleSnippetChange = useCallback((nextTitle) => {
29
+ if (nextTitle === snippetTitle) {
30
+ return;
31
+ }
32
+ editor.updateBlock(block.id, {
33
+ props: {
34
+ snippetTitle: nextTitle,
35
+ },
36
+ });
37
+ }, [block.id, editor, snippetTitle]);
38
+ const handleSnippetDataChange = useCallback((next) => {
39
+ if (next === snippetData) {
40
+ return;
41
+ }
42
+ editor.updateBlock(block.id, {
43
+ props: {
44
+ snippetData: next,
45
+ },
46
+ });
47
+ }, [editor, block.id, snippetData]);
48
+ const handleSnippetSelect = useCallback((suggestion) => {
49
+ var _a;
50
+ const rawBody = (_a = suggestion.body) !== null && _a !== void 0 ? _a : "";
51
+ const sanitizedBody = rawBody
52
+ .split(/\r?\n/)
53
+ .filter((line) => !/^<!--\s*(begin|end)\s+snippet/i.test(line.trim()))
54
+ .join("\n");
55
+ editor.updateBlock(block.id, {
56
+ props: {
57
+ snippetId: suggestion.id,
58
+ snippetData: sanitizedBody,
59
+ snippetTitle: suggestion.title,
60
+ },
61
+ });
62
+ }, [block.id, editor]);
63
+ return (_jsxs("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, children: [_jsx(StepField, { label: "Snippet Title", value: snippetTitle, placeholder: "Describe the reusable action", onChange: handleSnippetChange, autoFocus: snippetTitle.length === 0, enableAutocomplete: true, suggestionFilter: (suggestion) => suggestion.isSnippet === true, suggestionsOverride: snippetSuggestions, onSuggestionSelect: handleSnippetSelect, fieldName: "snippet-title", showSuggestionsOnFocus: true, enableImageUpload: false }), _jsx(StepField, { label: "Snippet Data", value: snippetData, placeholder: "Add optional data or assets for the snippet", onChange: handleSnippetDataChange, multiline: true, fieldName: "snippet-data", enableImageUpload: true })] }));
64
+ },
65
+ });
@@ -0,0 +1,32 @@
1
+ export declare const stepBlock: {
2
+ config: {
3
+ readonly type: "testStep";
4
+ readonly content: "none";
5
+ readonly propSchema: {
6
+ readonly stepTitle: {
7
+ readonly default: "";
8
+ };
9
+ readonly stepData: {
10
+ readonly default: "";
11
+ };
12
+ readonly expectedResult: {
13
+ readonly default: "";
14
+ };
15
+ };
16
+ };
17
+ implementation: import("@blocknote/core").TiptapBlockImplementation<{
18
+ readonly type: "testStep";
19
+ readonly content: "none";
20
+ readonly propSchema: {
21
+ readonly stepTitle: {
22
+ readonly default: "";
23
+ };
24
+ readonly stepData: {
25
+ readonly default: "";
26
+ };
27
+ readonly expectedResult: {
28
+ readonly default: "";
29
+ };
30
+ };
31
+ }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
32
+ };
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createReactBlockSpec } from "@blocknote/react";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { StepField } from "./stepField";
5
+ import { useStepImageUpload } from "../stepImageUpload";
6
+ export const stepBlock = createReactBlockSpec({
7
+ type: "testStep",
8
+ content: "none",
9
+ propSchema: {
10
+ stepTitle: {
11
+ default: "",
12
+ },
13
+ stepData: {
14
+ default: "",
15
+ },
16
+ expectedResult: {
17
+ default: "",
18
+ },
19
+ },
20
+ }, {
21
+ render: ({ block, editor }) => {
22
+ const stepTitle = block.props.stepTitle || "";
23
+ const stepData = block.props.stepData || "";
24
+ const expectedResult = block.props.expectedResult || "";
25
+ const showExpectedField = stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
26
+ const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
27
+ const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
28
+ const uploadImage = useStepImageUpload();
29
+ useEffect(() => {
30
+ if (stepData.trim().length > 0 && !isDataVisible) {
31
+ setIsDataVisible(true);
32
+ }
33
+ }, [isDataVisible, stepData]);
34
+ useEffect(() => {
35
+ if (shouldFocusDataField && isDataVisible) {
36
+ const timer = setTimeout(() => setShouldFocusDataField(false), 0);
37
+ return () => clearTimeout(timer);
38
+ }
39
+ return undefined;
40
+ }, [isDataVisible, shouldFocusDataField]);
41
+ const handleStepTitleChange = useCallback((next) => {
42
+ if (next === stepTitle) {
43
+ return;
44
+ }
45
+ editor.updateBlock(block.id, {
46
+ props: {
47
+ stepTitle: next,
48
+ },
49
+ });
50
+ }, [editor, block.id, stepTitle]);
51
+ const handleStepDataChange = useCallback((next) => {
52
+ if (next === stepData) {
53
+ return;
54
+ }
55
+ editor.updateBlock(block.id, {
56
+ props: {
57
+ stepData: next,
58
+ },
59
+ });
60
+ }, [editor, block.id, stepData]);
61
+ const handleShowDataField = useCallback(() => {
62
+ setIsDataVisible(true);
63
+ setShouldFocusDataField(true);
64
+ }, []);
65
+ const handleExpectedChange = useCallback((next) => {
66
+ if (next === expectedResult) {
67
+ return;
68
+ }
69
+ editor.updateBlock(block.id, {
70
+ props: {
71
+ expectedResult: next,
72
+ },
73
+ });
74
+ }, [editor, block.id, expectedResult]);
75
+ return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, children: [_jsx(StepField, { label: "Step Title", value: stepTitle, placeholder: "Describe the action to perform", onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, rightAction: !isDataVisible ? (_jsx("button", { type: "button", className: "bn-teststep__toggle", onClick: handleShowDataField, "aria-expanded": "false", tabIndex: -1, children: "+ Step Data" })) : null, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
76
+ if (!uploadImage) {
77
+ return;
78
+ }
79
+ setIsDataVisible(true);
80
+ setShouldFocusDataField(true);
81
+ try {
82
+ const result = await uploadImage(file);
83
+ if (result === null || result === void 0 ? void 0 : result.url) {
84
+ const nextValue = stepData.trim().length > 0 ? `${stepData}\n![](${result.url})` : `![](${result.url})`;
85
+ editor.updateBlock(block.id, {
86
+ props: {
87
+ stepData: nextValue,
88
+ },
89
+ });
90
+ }
91
+ }
92
+ catch (error) {
93
+ console.error("Failed to upload image to Step Data", error);
94
+ }
95
+ } }), isDataVisible && (_jsx(StepField, { label: "Step Data", value: stepData, placeholder: "Provide additional data about the step", onChange: handleStepDataChange, autoFocus: shouldFocusDataField, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true })), showExpectedField && (_jsx(StepField, { label: "Expected Result", value: expectedResult, placeholder: "What should happen?", onChange: handleExpectedChange, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true }))] }));
96
+ },
97
+ });
@@ -0,0 +1,26 @@
1
+ import { type StepSuggestion } from "../stepAutocomplete";
2
+ import { type SnippetSuggestion } from "../snippetAutocomplete";
3
+ import type { ReactNode } from "react";
4
+ type Suggestion = StepSuggestion | SnippetSuggestion;
5
+ type StepFieldProps = {
6
+ label: string;
7
+ value: string;
8
+ placeholder: string;
9
+ onChange: (nextValue: string) => void;
10
+ autoFocus?: boolean;
11
+ multiline?: boolean;
12
+ enableAutocomplete?: boolean;
13
+ fieldName?: string;
14
+ suggestionFilter?: (suggestion: Suggestion) => boolean;
15
+ suggestionsOverride?: Suggestion[];
16
+ onSuggestionSelect?: (suggestion: Suggestion) => void;
17
+ readOnly?: boolean;
18
+ showSuggestionsOnFocus?: boolean;
19
+ enableImageUpload?: boolean;
20
+ onImageFile?: (file: File) => Promise<void> | void;
21
+ rightAction?: ReactNode;
22
+ showFormattingButtons?: boolean;
23
+ showImageButton?: boolean;
24
+ };
25
+ export declare function StepField({ label, value, placeholder, onChange, autoFocus, multiline, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
26
+ export {};