testomatio-editor-blocks 0.4.0 → 0.4.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.
- package/package/editor/blocks/snippet.js +32 -138
- package/package/editor/blocks/step.js +116 -52
- package/package/editor/blocks/stepField.d.ts +4 -1
- package/package/editor/blocks/stepField.js +651 -39
- package/package/editor/blocks/stepHorizontalView.d.ts +14 -0
- package/package/editor/blocks/stepHorizontalView.js +7 -0
- package/package/editor/customMarkdownConverter.d.ts +1 -0
- package/package/editor/customMarkdownConverter.js +136 -36
- package/package/styles.css +565 -122
- package/package.json +5 -1
- package/src/editor/blocks/snippet.tsx +92 -212
- package/src/editor/blocks/step.tsx +265 -123
- package/src/editor/blocks/stepField.tsx +907 -95
- package/src/editor/blocks/stepHorizontalView.tsx +90 -0
- package/src/editor/customMarkdownConverter.test.ts +443 -1
- package/src/editor/customMarkdownConverter.ts +168 -41
- package/src/editor/styles.css +561 -133
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testomatio-editor-blocks",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"testcases",
|
|
40
40
|
"test-automation"
|
|
41
41
|
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/testomatio/block-editor"
|
|
45
|
+
},
|
|
42
46
|
"peerDependencies": {
|
|
43
47
|
"@blocknote/core": "^0.31.3",
|
|
44
48
|
"@blocknote/react": "^0.31.3",
|
|
@@ -1,177 +1,102 @@
|
|
|
1
1
|
import { createReactBlockSpec } from "@blocknote/react";
|
|
2
|
-
import OverType, { type OverType as OverTypeInstance } from "overtype";
|
|
3
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
3
|
+
import type { ChangeEvent } from "react";
|
|
5
4
|
import { useSnippetAutocomplete, type SnippetSuggestion } from "../snippetAutocomplete";
|
|
6
|
-
import type { StepSuggestion } from "../stepAutocomplete";
|
|
7
|
-
import { useStepImageUpload } from "../stepImageUpload";
|
|
8
5
|
|
|
9
|
-
type
|
|
10
|
-
label: string;
|
|
6
|
+
type SnippetDropdownProps = {
|
|
11
7
|
value: string;
|
|
12
|
-
placeholder
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
fieldName?: string;
|
|
16
|
-
enableImageUpload?: boolean;
|
|
8
|
+
placeholder: string;
|
|
9
|
+
suggestions: SnippetSuggestion[];
|
|
10
|
+
onSelect: (suggestion: SnippetSuggestion) => void;
|
|
17
11
|
};
|
|
18
12
|
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
placeholder,
|
|
23
|
-
onChange,
|
|
24
|
-
onFieldFocus,
|
|
25
|
-
fieldName,
|
|
26
|
-
enableImageUpload = false,
|
|
27
|
-
}: SnippetDataFieldProps) {
|
|
13
|
+
function SnippetDropdown({ value, placeholder, suggestions, onSelect }: SnippetDropdownProps) {
|
|
14
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
15
|
+
const [search, setSearch] = useState("");
|
|
28
16
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
29
|
-
const
|
|
30
|
-
const uploadImage = useStepImageUpload();
|
|
31
|
-
const [isFocused, setIsFocused] = useState(false);
|
|
32
|
-
const onChangeRef = useRef(onChange);
|
|
33
|
-
const initialValueRef = useRef(value);
|
|
17
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
34
18
|
|
|
35
19
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
(url: string) => {
|
|
41
|
-
const instance = instanceRef.current;
|
|
42
|
-
const textarea = instance?.textarea;
|
|
43
|
-
if (!instance || !textarea) {
|
|
44
|
-
return;
|
|
20
|
+
if (!isOpen) return;
|
|
21
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
22
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
23
|
+
setIsOpen(false);
|
|
45
24
|
}
|
|
46
|
-
|
|
47
|
-
const currentValue = instance.getValue();
|
|
48
|
-
const selectionStart = textarea.selectionStart ?? currentValue.length;
|
|
49
|
-
const selectionEnd = textarea.selectionEnd ?? currentValue.length;
|
|
50
|
-
const before = currentValue.slice(0, selectionStart);
|
|
51
|
-
const after = currentValue.slice(selectionEnd);
|
|
52
|
-
const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
|
|
53
|
-
const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
|
|
54
|
-
const markdown = `${needsNewlineBefore ? "\n" : ""}${needsNewlineAfter ? "\n" : ""}`;
|
|
55
|
-
const nextValue = `${before}${markdown}${after}`;
|
|
56
|
-
|
|
57
|
-
instance.setValue(nextValue);
|
|
58
|
-
onChangeRef.current?.(nextValue);
|
|
59
|
-
|
|
60
|
-
requestAnimationFrame(() => {
|
|
61
|
-
const cursor = selectionStart + markdown.length;
|
|
62
|
-
textarea.selectionStart = cursor;
|
|
63
|
-
textarea.selectionEnd = cursor;
|
|
64
|
-
textarea.focus();
|
|
65
|
-
});
|
|
66
|
-
},
|
|
67
|
-
[],
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const container = containerRef.current;
|
|
72
|
-
if (!container) {
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const [instance] = OverType.init(container, {
|
|
77
|
-
value: initialValueRef.current,
|
|
78
|
-
placeholder,
|
|
79
|
-
autoResize: true,
|
|
80
|
-
minHeight: "5rem",
|
|
81
|
-
toolbar: false,
|
|
82
|
-
onChange: (nextValue) => {
|
|
83
|
-
onChangeRef.current?.(nextValue);
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
instanceRef.current = instance;
|
|
88
|
-
const textarea = instance.textarea;
|
|
89
|
-
if (fieldName) {
|
|
90
|
-
textarea.dataset.stepField = fieldName;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const handleFocus = () => {
|
|
94
|
-
setIsFocused(true);
|
|
95
|
-
onFieldFocus?.();
|
|
96
25
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
textarea.addEventListener("blur", handleBlur);
|
|
101
|
-
|
|
102
|
-
return () => {
|
|
103
|
-
textarea.removeEventListener("focus", handleFocus);
|
|
104
|
-
textarea.removeEventListener("blur", handleBlur);
|
|
105
|
-
instance.destroy();
|
|
106
|
-
instanceRef.current = null;
|
|
107
|
-
};
|
|
108
|
-
}, [fieldName, onFieldFocus, placeholder]);
|
|
26
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
27
|
+
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
28
|
+
}, [isOpen]);
|
|
109
29
|
|
|
110
30
|
useEffect(() => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
31
|
+
if (isOpen) {
|
|
32
|
+
setSearch("");
|
|
33
|
+
requestAnimationFrame(() => searchRef.current?.focus());
|
|
114
34
|
}
|
|
35
|
+
}, [isOpen]);
|
|
115
36
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (!enableImageUpload || !uploadImage) {
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const textarea = instanceRef.current?.textarea;
|
|
127
|
-
if (!textarea) {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const handlePaste = async (event: ClipboardEvent) => {
|
|
132
|
-
const items = Array.from(event.clipboardData?.items ?? []);
|
|
133
|
-
const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
|
|
134
|
-
const file = imageItem?.getAsFile();
|
|
135
|
-
if (!file) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
event.preventDefault();
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
const response = await uploadImage(file);
|
|
143
|
-
if (response?.url) {
|
|
144
|
-
insertImageMarkdown(response.url);
|
|
145
|
-
}
|
|
146
|
-
} catch (error) {
|
|
147
|
-
console.error("Failed to upload pasted image", error);
|
|
148
|
-
}
|
|
149
|
-
};
|
|
37
|
+
const filtered = useMemo(() => {
|
|
38
|
+
const snippets = suggestions.filter((s) => s.isSnippet === true);
|
|
39
|
+
if (!search) return snippets;
|
|
40
|
+
const lower = search.toLowerCase();
|
|
41
|
+
return snippets.filter((s) => s.title.toLowerCase().includes(lower));
|
|
42
|
+
}, [suggestions, search]);
|
|
150
43
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
};
|
|
155
|
-
}, [enableImageUpload, insertImageMarkdown, uploadImage]);
|
|
156
|
-
|
|
157
|
-
const editorClassName = useMemo(
|
|
158
|
-
() =>
|
|
159
|
-
[
|
|
160
|
-
"bn-step-editor",
|
|
161
|
-
"bn-step-editor--multiline",
|
|
162
|
-
isFocused ? "bn-step-editor--focused" : "",
|
|
163
|
-
]
|
|
164
|
-
.filter(Boolean)
|
|
165
|
-
.join(" "),
|
|
166
|
-
[isFocused],
|
|
167
|
-
);
|
|
44
|
+
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
45
|
+
setSearch(event.target.value);
|
|
46
|
+
}, []);
|
|
168
47
|
|
|
169
48
|
return (
|
|
170
|
-
<div className="bn-
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
49
|
+
<div className="bn-snippet-dropdown" ref={containerRef}>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className="bn-snippet-dropdown__trigger"
|
|
53
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
54
|
+
>
|
|
55
|
+
<span className="bn-snippet-dropdown__text">
|
|
56
|
+
{value || placeholder}
|
|
57
|
+
</span>
|
|
58
|
+
<svg className="bn-snippet-dropdown__chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
59
|
+
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
60
|
+
</svg>
|
|
61
|
+
</button>
|
|
62
|
+
{isOpen && (
|
|
63
|
+
<div className="bn-snippet-dropdown__panel" role="listbox">
|
|
64
|
+
<div className="bn-snippet-dropdown__search">
|
|
65
|
+
<svg className="bn-snippet-dropdown__search-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
66
|
+
<path d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z" fill="currentColor"/>
|
|
67
|
+
</svg>
|
|
68
|
+
<input
|
|
69
|
+
ref={searchRef}
|
|
70
|
+
type="text"
|
|
71
|
+
className="bn-snippet-dropdown__search-input"
|
|
72
|
+
placeholder="Search"
|
|
73
|
+
value={search}
|
|
74
|
+
onChange={handleSearchChange}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="bn-snippet-dropdown__list">
|
|
78
|
+
{filtered.map((suggestion) => (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
key={suggestion.id}
|
|
82
|
+
role="option"
|
|
83
|
+
className="bn-snippet-dropdown__item"
|
|
84
|
+
onMouseDown={(event) => {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
onSelect(suggestion);
|
|
87
|
+
setIsOpen(false);
|
|
88
|
+
}}
|
|
89
|
+
tabIndex={-1}
|
|
90
|
+
>
|
|
91
|
+
{suggestion.title}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
{filtered.length === 0 && (
|
|
95
|
+
<div className="bn-snippet-dropdown__empty">No snippets found</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
175
100
|
</div>
|
|
176
101
|
);
|
|
177
102
|
}
|
|
@@ -204,39 +129,9 @@ export const snippetBlock = createReactBlockSpec(
|
|
|
204
129
|
const hasSnippets = snippetSuggestions.length > 0;
|
|
205
130
|
const isSnippetSelected = snippetId.length > 0;
|
|
206
131
|
|
|
207
|
-
const handleSnippetChange = useCallback(
|
|
208
|
-
(nextTitle: string) => {
|
|
209
|
-
if (nextTitle === snippetTitle) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
editor.updateBlock(block.id, {
|
|
214
|
-
props: {
|
|
215
|
-
snippetTitle: nextTitle,
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
},
|
|
219
|
-
[block.id, editor, snippetTitle],
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
const handleSnippetDataChange = useCallback(
|
|
223
|
-
(next: string) => {
|
|
224
|
-
if (next === snippetData) {
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
editor.updateBlock(block.id, {
|
|
229
|
-
props: {
|
|
230
|
-
snippetData: next,
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
},
|
|
234
|
-
[editor, block.id, snippetData],
|
|
235
|
-
);
|
|
236
|
-
|
|
237
132
|
const handleSnippetSelect = useCallback(
|
|
238
|
-
(suggestion: SnippetSuggestion
|
|
239
|
-
const rawBody =
|
|
133
|
+
(suggestion: SnippetSuggestion) => {
|
|
134
|
+
const rawBody = suggestion.body ?? "";
|
|
240
135
|
const sanitizedBody = rawBody
|
|
241
136
|
.split(/\r?\n/)
|
|
242
137
|
.filter((line) => !/^<!--\s*(begin|end)\s+snippet/i.test(line.trim()))
|
|
@@ -265,33 +160,18 @@ export const snippetBlock = createReactBlockSpec(
|
|
|
265
160
|
}
|
|
266
161
|
|
|
267
162
|
return (
|
|
268
|
-
<div className="bn-teststep bn-snippet" data-block-id={block.id}>
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
163
|
+
<div className="bn-teststep bn-snippet" data-block-id={block.id} onFocus={handleFieldFocus}>
|
|
164
|
+
<div className="bn-snippet__header">
|
|
165
|
+
<span className="bn-snippet__label">Snippet</span>
|
|
166
|
+
<SnippetDropdown
|
|
272
167
|
value={snippetTitle}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
suggestionFilter={(suggestion) => (suggestion as SnippetSuggestion).isSnippet === true}
|
|
277
|
-
suggestionsOverride={snippetSuggestions as unknown as StepSuggestion[]}
|
|
278
|
-
onSuggestionSelect={handleSnippetSelect}
|
|
279
|
-
fieldName="snippet-title"
|
|
280
|
-
showSuggestionsOnFocus={true}
|
|
281
|
-
enableImageUpload={false}
|
|
282
|
-
onFieldFocus={handleFieldFocus}
|
|
283
|
-
readOnly={false}
|
|
284
|
-
/>
|
|
285
|
-
) : (
|
|
286
|
-
<SnippetDataField
|
|
287
|
-
label={`Snippet: ${snippetTitle}`}
|
|
288
|
-
value={snippetData}
|
|
289
|
-
onChange={handleSnippetDataChange}
|
|
290
|
-
fieldName="snippet-data"
|
|
291
|
-
enableImageUpload
|
|
292
|
-
onFieldFocus={handleFieldFocus}
|
|
293
|
-
placeholder="Snippet data will appear here..."
|
|
168
|
+
placeholder="Select Snippet"
|
|
169
|
+
suggestions={snippetSuggestions}
|
|
170
|
+
onSelect={handleSnippetSelect}
|
|
294
171
|
/>
|
|
172
|
+
</div>
|
|
173
|
+
{isSnippetSelected && (
|
|
174
|
+
<div className="bn-snippet__content">{snippetData}</div>
|
|
295
175
|
)}
|
|
296
176
|
</div>
|
|
297
177
|
);
|