testomatio-editor-blocks 0.1.1 → 0.2.0

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 (41) hide show
  1. package/README.md +13 -11
  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 +15 -2
  17. package/package/editor/stepImageUpload.d.ts +1 -1
  18. package/package/editor/stepImageUpload.js +4 -9
  19. package/package/index.d.ts +2 -2
  20. package/package/index.js +2 -2
  21. package/package/styles.css +57 -0
  22. package/package.json +1 -1
  23. package/src/App.tsx +161 -45
  24. package/src/editor/blocks/blocks.test.ts +22 -0
  25. package/src/editor/blocks/markdown.ts +199 -0
  26. package/src/editor/blocks/snippet.tsx +109 -0
  27. package/src/editor/blocks/step.tsx +175 -0
  28. package/src/editor/blocks/stepField.tsx +487 -0
  29. package/src/editor/customMarkdownConverter.test.ts +121 -36
  30. package/src/editor/customMarkdownConverter.ts +128 -85
  31. package/src/editor/customSchema.tsx +6 -935
  32. package/src/editor/snippetAutocomplete.test.ts +54 -0
  33. package/src/editor/snippetAutocomplete.ts +133 -0
  34. package/src/editor/stepAutocomplete.test.ts +20 -0
  35. package/src/editor/stepAutocomplete.tsx +15 -2
  36. package/src/editor/stepImageUpload.test.ts +25 -0
  37. package/src/editor/stepImageUpload.ts +11 -0
  38. package/src/editor/styles.css +57 -0
  39. package/src/index.ts +2 -2
  40. package/src/editor/customSchema.test.ts +0 -47
  41. package/src/editor/stepImageUpload.tsx +0 -19
@@ -0,0 +1,199 @@
1
+ const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
2
+ const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
3
+
4
+ export function escapeHtml(text: string): string {
5
+ return text
6
+ .replace(/&/g, "&")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/\"/g, "&quot;")
10
+ .replace(/'/g, "&#39;");
11
+ }
12
+
13
+ type InlineSegment = {
14
+ text: string;
15
+ styles: {
16
+ bold: boolean;
17
+ italic: boolean;
18
+ underline: boolean;
19
+ };
20
+ };
21
+
22
+ function restoreEscapes(text: string): string {
23
+ return text.replace(/\uE000/g, "\\");
24
+ }
25
+
26
+ function parseInlineMarkdown(text: string): InlineSegment[] {
27
+ if (!text) {
28
+ return [];
29
+ }
30
+
31
+ const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
32
+ const rawSegments = normalized
33
+ .split(/(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/)
34
+ .filter(Boolean);
35
+
36
+ return rawSegments.map((segment) => {
37
+ const baseStyles = { bold: false, italic: false, underline: false };
38
+
39
+ if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
40
+ const content = segment.slice(2, -2);
41
+ return {
42
+ text: restoreEscapes(content),
43
+ styles: { ...baseStyles, bold: true },
44
+ };
45
+ }
46
+
47
+ if (/^\*(.+)\*$/.test(segment) || /^_(.+)_$/.test(segment)) {
48
+ const content = segment.slice(1, -1);
49
+ return {
50
+ text: restoreEscapes(content),
51
+ styles: { ...baseStyles, italic: true },
52
+ };
53
+ }
54
+
55
+ if (/^<u>(.+)<\/u>$/.test(segment)) {
56
+ const content = segment.slice(3, -4);
57
+ return {
58
+ text: restoreEscapes(content),
59
+ styles: { ...baseStyles, underline: true },
60
+ };
61
+ }
62
+
63
+ return {
64
+ text: restoreEscapes(segment),
65
+ styles: { ...baseStyles },
66
+ };
67
+ });
68
+ }
69
+
70
+ function inlineToHtml(inline: InlineSegment[]): string {
71
+ return inline
72
+ .map(({ text, styles }) => {
73
+ let html = escapeHtml(text);
74
+ if (styles.bold) {
75
+ html = `<strong>${html}</strong>`;
76
+ }
77
+ if (styles.italic) {
78
+ html = `<em>${html}</em>`;
79
+ }
80
+ if (styles.underline) {
81
+ html = `<u>${html}</u>`;
82
+ }
83
+ return html;
84
+ })
85
+ .join("");
86
+ }
87
+
88
+ export function markdownToHtml(markdown: string): string {
89
+ if (!markdown) {
90
+ return "";
91
+ }
92
+
93
+ const lines = markdown.split(/\n/);
94
+ const htmlLines = lines.map((line) => {
95
+ const inline = parseInlineMarkdown(line);
96
+ const html = inlineToHtml(inline);
97
+ if (!html) {
98
+ return html;
99
+ }
100
+
101
+ return html.replace(
102
+ IMAGE_MARKDOWN_REGEX,
103
+ (_match, alt = "", src = "") =>
104
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" class="bn-inline-image" contenteditable="false" draggable="false" />`,
105
+ );
106
+ });
107
+ return htmlLines.join("<br />");
108
+ }
109
+
110
+ export function escapeMarkdownText(text: string): string {
111
+ return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
112
+ }
113
+
114
+ export function normalizePlainText(text: string): string {
115
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
116
+ }
117
+
118
+ function fallbackHtmlToMarkdown(html: string): string {
119
+ if (!html) {
120
+ return "";
121
+ }
122
+
123
+ let result = html;
124
+
125
+ result = result.replace(/<img[^>]*>/gi, (match) => {
126
+ const src = match.match(/src="([^"]*)"/i)?.[1] ?? "";
127
+ const alt = match.match(/alt="([^"]*)"/i)?.[1] ?? "";
128
+ return `![${alt}](${src})`;
129
+ });
130
+
131
+ result = result
132
+ .replace(/<br\s*\/?>/gi, "\n")
133
+ .replace(/<\/?(div|p)>/gi, "\n")
134
+ .replace(/<strong>(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
135
+ .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `*${content}*`)
136
+ .replace(/<span[^>]*>/gi, "")
137
+ .replace(/<\/span>/gi, "")
138
+ .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
139
+
140
+ result = result.replace(/<\/?[^>]+>/g, "");
141
+
142
+ return result
143
+ .split("\n")
144
+ .map((line) => line.trimEnd())
145
+ .join("\n")
146
+ .replace(/\n{3,}/g, "\n\n")
147
+ .trim();
148
+ }
149
+
150
+ export function htmlToMarkdown(html: string): string {
151
+ if (typeof document === "undefined") {
152
+ return fallbackHtmlToMarkdown(html);
153
+ }
154
+
155
+ const temp = document.createElement("div");
156
+ temp.innerHTML = html;
157
+
158
+ const traverse = (node: Node): string => {
159
+ if (node.nodeType === Node.TEXT_NODE) {
160
+ const text = node.textContent ?? "";
161
+ return escapeMarkdownText(text);
162
+ }
163
+
164
+ if (node.nodeType !== Node.ELEMENT_NODE) {
165
+ return "";
166
+ }
167
+
168
+ const element = node as HTMLElement;
169
+ const children = Array.from(element.childNodes)
170
+ .map(traverse)
171
+ .join("");
172
+
173
+ switch (element.tagName.toLowerCase()) {
174
+ case "strong":
175
+ case "b":
176
+ return children ? `**${children}**` : children;
177
+ case "em":
178
+ case "i":
179
+ return children ? `*${children}*` : children;
180
+ case "u":
181
+ return children ? `<u>${children}</u>` : children;
182
+ case "br":
183
+ return "\n";
184
+ case "div":
185
+ case "p":
186
+ return children + "\n";
187
+ case "img": {
188
+ const src = element.getAttribute("src") ?? "";
189
+ const alt = element.getAttribute("alt") ?? "";
190
+ return `![${alt}](${src})`;
191
+ }
192
+ default:
193
+ return children;
194
+ }
195
+ };
196
+
197
+ const markdown = Array.from(temp.childNodes).map(traverse).join("");
198
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
199
+ }
@@ -0,0 +1,109 @@
1
+ import { createReactBlockSpec } from "@blocknote/react";
2
+ import { useCallback } from "react";
3
+ import { StepField } from "./stepField";
4
+ import { useSnippetAutocomplete, type SnippetSuggestion } from "../snippetAutocomplete";
5
+ import type { StepSuggestion } from "../stepAutocomplete";
6
+
7
+ export const snippetBlock = createReactBlockSpec(
8
+ {
9
+ type: "snippet",
10
+ content: "none",
11
+ propSchema: {
12
+ snippetId: {
13
+ default: "",
14
+ },
15
+ snippetTitle: {
16
+ default: "",
17
+ },
18
+ snippetData: {
19
+ default: "",
20
+ },
21
+ snippetExpectedResult: {
22
+ default: "",
23
+ },
24
+ },
25
+ },
26
+ {
27
+ render: ({ block, editor }) => {
28
+ const snippetTitle = (block.props.snippetTitle as string) || "";
29
+ const snippetData = (block.props.snippetData as string) || "";
30
+ const snippetSuggestions = useSnippetAutocomplete();
31
+
32
+ const handleSnippetChange = useCallback(
33
+ (nextTitle: string) => {
34
+ if (nextTitle === snippetTitle) {
35
+ return;
36
+ }
37
+
38
+ editor.updateBlock(block.id, {
39
+ props: {
40
+ snippetTitle: nextTitle,
41
+ },
42
+ });
43
+ },
44
+ [block.id, editor, snippetTitle],
45
+ );
46
+
47
+ const handleSnippetDataChange = useCallback(
48
+ (next: string) => {
49
+ if (next === snippetData) {
50
+ return;
51
+ }
52
+
53
+ editor.updateBlock(block.id, {
54
+ props: {
55
+ snippetData: next,
56
+ },
57
+ });
58
+ },
59
+ [editor, block.id, snippetData],
60
+ );
61
+
62
+ const handleSnippetSelect = useCallback(
63
+ (suggestion: SnippetSuggestion | StepSuggestion) => {
64
+ const rawBody = (suggestion as SnippetSuggestion).body ?? "";
65
+ const sanitizedBody = rawBody
66
+ .split(/\r?\n/)
67
+ .filter((line) => !/^<!--\s*(begin|end)\s+snippet/i.test(line.trim()))
68
+ .join("\n");
69
+ editor.updateBlock(block.id, {
70
+ props: {
71
+ snippetId: suggestion.id,
72
+ snippetData: sanitizedBody,
73
+ snippetTitle: suggestion.title,
74
+ },
75
+ });
76
+ },
77
+ [block.id, editor],
78
+ );
79
+
80
+ return (
81
+ <div className="bn-teststep bn-snippet" data-block-id={block.id}>
82
+ <StepField
83
+ label="Snippet Title"
84
+ value={snippetTitle}
85
+ placeholder="Describe the reusable action"
86
+ onChange={handleSnippetChange}
87
+ autoFocus={snippetTitle.length === 0}
88
+ enableAutocomplete
89
+ suggestionFilter={(suggestion) => (suggestion as SnippetSuggestion).isSnippet === true}
90
+ suggestionsOverride={snippetSuggestions as unknown as StepSuggestion[]}
91
+ onSuggestionSelect={handleSnippetSelect}
92
+ fieldName="snippet-title"
93
+ showSuggestionsOnFocus
94
+ enableImageUpload={false}
95
+ />
96
+ <StepField
97
+ label="Snippet Data"
98
+ value={snippetData}
99
+ placeholder="Add optional data or assets for the snippet"
100
+ onChange={handleSnippetDataChange}
101
+ multiline
102
+ fieldName="snippet-data"
103
+ enableImageUpload
104
+ />
105
+ </div>
106
+ );
107
+ },
108
+ },
109
+ );
@@ -0,0 +1,175 @@
1
+ import { createReactBlockSpec } from "@blocknote/react";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { StepField } from "./stepField";
4
+ import { useStepImageUpload } from "../stepImageUpload";
5
+ import type { StepSuggestion } from "../stepAutocomplete";
6
+
7
+ export const stepBlock = createReactBlockSpec(
8
+ {
9
+ type: "testStep",
10
+ content: "none",
11
+ propSchema: {
12
+ stepTitle: {
13
+ default: "",
14
+ },
15
+ stepData: {
16
+ default: "",
17
+ },
18
+ expectedResult: {
19
+ default: "",
20
+ },
21
+ },
22
+ },
23
+ {
24
+ render: ({ block, editor }) => {
25
+ const stepTitle = (block.props.stepTitle as string) || "";
26
+ const stepData = (block.props.stepData as string) || "";
27
+ const expectedResult = (block.props.expectedResult as string) || "";
28
+ const showExpectedField =
29
+ stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
30
+ const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
31
+ const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
32
+ const uploadImage = useStepImageUpload();
33
+
34
+ useEffect(() => {
35
+ if (stepData.trim().length > 0 && !isDataVisible) {
36
+ setIsDataVisible(true);
37
+ }
38
+ }, [isDataVisible, stepData]);
39
+
40
+ useEffect(() => {
41
+ if (shouldFocusDataField && isDataVisible) {
42
+ const timer = setTimeout(() => setShouldFocusDataField(false), 0);
43
+ return () => clearTimeout(timer);
44
+ }
45
+ return undefined;
46
+ }, [isDataVisible, shouldFocusDataField]);
47
+
48
+ const handleStepTitleChange = useCallback(
49
+ (next: string) => {
50
+ if (next === stepTitle) {
51
+ return;
52
+ }
53
+
54
+ editor.updateBlock(block.id, {
55
+ props: {
56
+ stepTitle: next,
57
+ },
58
+ });
59
+ },
60
+ [editor, block.id, stepTitle],
61
+ );
62
+
63
+ const handleStepDataChange = useCallback(
64
+ (next: string) => {
65
+ if (next === stepData) {
66
+ return;
67
+ }
68
+
69
+ editor.updateBlock(block.id, {
70
+ props: {
71
+ stepData: next,
72
+ },
73
+ });
74
+ },
75
+ [editor, block.id, stepData],
76
+ );
77
+
78
+ const handleShowDataField = useCallback(() => {
79
+ setIsDataVisible(true);
80
+ setShouldFocusDataField(true);
81
+ }, []);
82
+
83
+ const handleExpectedChange = useCallback(
84
+ (next: string) => {
85
+ if (next === expectedResult) {
86
+ return;
87
+ }
88
+
89
+ editor.updateBlock(block.id, {
90
+ props: {
91
+ expectedResult: next,
92
+ },
93
+ });
94
+ },
95
+ [editor, block.id, expectedResult],
96
+ );
97
+
98
+ return (
99
+ <div className="bn-teststep" data-block-id={block.id}>
100
+ <StepField
101
+ label="Step Title"
102
+ value={stepTitle}
103
+ placeholder="Describe the action to perform"
104
+ onChange={handleStepTitleChange}
105
+ autoFocus={stepTitle.length === 0}
106
+ enableAutocomplete
107
+ fieldName="title"
108
+ suggestionFilter={(suggestion) => (suggestion as StepSuggestion).isSnippet !== true}
109
+ rightAction={
110
+ !isDataVisible ? (
111
+ <button
112
+ type="button"
113
+ className="bn-teststep__toggle"
114
+ onClick={handleShowDataField}
115
+ aria-expanded="false"
116
+ tabIndex={-1}
117
+ >
118
+ + Step Data
119
+ </button>
120
+ ) : null
121
+ }
122
+ enableImageUpload={false}
123
+ showFormattingButtons
124
+ onImageFile={async (file) => {
125
+ if (!uploadImage) {
126
+ return;
127
+ }
128
+
129
+ setIsDataVisible(true);
130
+ setShouldFocusDataField(true);
131
+ try {
132
+ const result = await uploadImage(file);
133
+ if (result?.url) {
134
+ const nextValue = stepData.trim().length > 0 ? `${stepData}\n![](${result.url})` : `![](${result.url})`;
135
+ editor.updateBlock(block.id, {
136
+ props: {
137
+ stepData: nextValue,
138
+ },
139
+ });
140
+ }
141
+ } catch (error) {
142
+ console.error("Failed to upload image to Step Data", error);
143
+ }
144
+ }}
145
+ />
146
+ {isDataVisible && (
147
+ <StepField
148
+ label="Step Data"
149
+ value={stepData}
150
+ placeholder="Provide additional data about the step"
151
+ onChange={handleStepDataChange}
152
+ autoFocus={shouldFocusDataField}
153
+ multiline
154
+ enableImageUpload
155
+ showFormattingButtons
156
+ showImageButton
157
+ />
158
+ )}
159
+ {showExpectedField && (
160
+ <StepField
161
+ label="Expected Result"
162
+ value={expectedResult}
163
+ placeholder="What should happen?"
164
+ onChange={handleExpectedChange}
165
+ multiline
166
+ enableImageUpload
167
+ showFormattingButtons
168
+ showImageButton
169
+ />
170
+ )}
171
+ </div>
172
+ );
173
+ },
174
+ },
175
+ );