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.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.0",
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 { StepField } from "./stepField";
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 SnippetDataFieldProps = {
10
- label: string;
6
+ type SnippetDropdownProps = {
11
7
  value: string;
12
- placeholder?: string;
13
- onChange: (value: string) => void;
14
- onFieldFocus?: () => void;
15
- fieldName?: string;
16
- enableImageUpload?: boolean;
8
+ placeholder: string;
9
+ suggestions: SnippetSuggestion[];
10
+ onSelect: (suggestion: SnippetSuggestion) => void;
17
11
  };
18
12
 
19
- function SnippetDataField({
20
- label,
21
- value,
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 instanceRef = useRef<OverTypeInstance | null>(null);
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
- onChangeRef.current = onChange;
37
- }, [onChange]);
38
-
39
- const insertImageMarkdown = useCallback(
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" : ""}![](${url})${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
- const handleBlur = () => setIsFocused(false);
98
-
99
- textarea.addEventListener("focus", handleFocus);
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
- const instance = instanceRef.current;
112
- if (!instance) {
113
- return;
31
+ if (isOpen) {
32
+ setSearch("");
33
+ requestAnimationFrame(() => searchRef.current?.focus());
114
34
  }
35
+ }, [isOpen]);
115
36
 
116
- if (instance.getValue() !== value) {
117
- instance.setValue(value);
118
- }
119
- }, [value]);
120
-
121
- useEffect(() => {
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
- textarea.addEventListener("paste", handlePaste as unknown as EventListener);
152
- return () => {
153
- textarea.removeEventListener("paste", handlePaste as unknown as EventListener);
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-step-field">
171
- <div className="bn-step-field__top">
172
- <span className="bn-step-field__label">{label}</span>
173
- </div>
174
- <div ref={containerRef} className={editorClassName} data-step-field={fieldName} />
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 | StepSuggestion) => {
239
- const rawBody = (suggestion as SnippetSuggestion).body ?? "";
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
- {!isSnippetSelected ? (
270
- <StepField
271
- label="Snippet Title"
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
- onChange={handleSnippetChange}
274
- autoFocus={snippetTitle.length === 0}
275
- enableAutocomplete={true}
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
  );