testomatio-editor-blocks 0.1.0 → 0.1.2
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/README.md +46 -0
- package/package/editor/customMarkdownConverter.js +28 -17
- package/package/editor/customSchema.js +263 -16
- package/package/editor/stepAutocomplete.d.ts +35 -0
- package/package/editor/stepAutocomplete.js +97 -0
- package/package/editor/stepImageUpload.d.ts +5 -0
- package/package/editor/stepImageUpload.js +7 -0
- package/package/index.d.ts +2 -0
- package/package/index.js +2 -0
- package/package/styles.css +52 -0
- package/package.json +1 -1
- package/src/App.css +41 -0
- package/src/App.tsx +266 -8
- package/src/editor/customMarkdownConverter.test.ts +59 -25
- package/src/editor/customMarkdownConverter.ts +30 -17
- package/src/editor/customSchema.test.ts +8 -0
- package/src/editor/customSchema.tsx +369 -12
- package/src/editor/stepAutocomplete.test.ts +103 -0
- package/src/editor/stepAutocomplete.tsx +143 -0
- package/src/editor/stepImageUpload.test.ts +25 -0
- package/src/editor/stepImageUpload.ts +11 -0
- package/src/editor/styles.css +52 -0
- package/src/index.ts +15 -0
|
@@ -351,7 +351,7 @@ function serializeBlock(
|
|
|
351
351
|
const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
|
|
352
352
|
if (normalizedExpected.length > 0) {
|
|
353
353
|
const expectedLines = normalizedExpected.split(/\r?\n/);
|
|
354
|
-
const label = "*Expected
|
|
354
|
+
const label = "*Expected*";
|
|
355
355
|
expectedLines.forEach((expectedLine: string, index: number) => {
|
|
356
356
|
const trimmedLine = expectedLine.trim();
|
|
357
357
|
if (trimmedLine.length === 0) {
|
|
@@ -750,7 +750,17 @@ function parseTestStep(lines: string[], index: number): { block: CustomPartialBl
|
|
|
750
750
|
return null;
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
-
const
|
|
753
|
+
const rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
|
|
754
|
+
const titleImages: string[] = [];
|
|
755
|
+
const titleWithPlaceholders = rawTitle
|
|
756
|
+
.replace(/!\[[^\]]*\]\(([^)]+)\)/g, (match) => {
|
|
757
|
+
titleImages.push(match);
|
|
758
|
+
return "!";
|
|
759
|
+
})
|
|
760
|
+
.replace(/\s{2,}/g, " ")
|
|
761
|
+
.trim();
|
|
762
|
+
|
|
763
|
+
const isLikelyStep = /^step\b/i.test(titleWithPlaceholders) || titleImages.length > 0;
|
|
754
764
|
const stepDataLines: string[] = [];
|
|
755
765
|
let expectedResult = "";
|
|
756
766
|
let next = index + 1;
|
|
@@ -831,23 +841,26 @@ function parseTestStep(lines: string[], index: number): { block: CustomPartialBl
|
|
|
831
841
|
.join("\n")
|
|
832
842
|
.trim();
|
|
833
843
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
return {
|
|
837
|
-
block: {
|
|
838
|
-
type: "testStep",
|
|
839
|
-
props: {
|
|
840
|
-
stepTitle,
|
|
841
|
-
stepData,
|
|
842
|
-
expectedResult,
|
|
843
|
-
},
|
|
844
|
-
children: [],
|
|
845
|
-
},
|
|
846
|
-
nextIndex: next,
|
|
847
|
-
};
|
|
844
|
+
if (!isLikelyStep && !expectedResult && stepDataLines.length === 0) {
|
|
845
|
+
return null;
|
|
848
846
|
}
|
|
849
847
|
|
|
850
|
-
|
|
848
|
+
const stepDataWithImages = [stepData, titleImages.join("\n")]
|
|
849
|
+
.filter(Boolean)
|
|
850
|
+
.join(stepData ? "\n" : "");
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
block: {
|
|
854
|
+
type: "testStep",
|
|
855
|
+
props: {
|
|
856
|
+
stepTitle: titleWithPlaceholders,
|
|
857
|
+
stepData: stepDataWithImages,
|
|
858
|
+
expectedResult,
|
|
859
|
+
},
|
|
860
|
+
children: [],
|
|
861
|
+
},
|
|
862
|
+
nextIndex: next,
|
|
863
|
+
};
|
|
851
864
|
}
|
|
852
865
|
|
|
853
866
|
function parseTestCase(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
|
|
@@ -36,4 +36,12 @@ describe("customSchema markdown helpers", () => {
|
|
|
36
36
|
expect(html).toContain('<img src="/login.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
37
37
|
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it("keeps inline images when mixed inside a single line of markdown text", () => {
|
|
41
|
+
const markdown = "* asdsadsad aaaaa asd ";
|
|
42
|
+
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
43
|
+
|
|
44
|
+
expect(html).toContain('<img src="https://placehold.co/600x400?text=Uploaded+1763329962213" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
45
|
+
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
46
|
+
});
|
|
39
47
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { defaultBlockSpecs, defaultProps } from "@blocknote/core";
|
|
2
2
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
3
|
import { createReactBlockSpec } from "@blocknote/react";
|
|
4
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import type { ChangeEvent, ClipboardEvent, CSSProperties } from "react";
|
|
6
|
+
import { useStepAutocomplete, type StepSuggestion } from "./stepAutocomplete";
|
|
7
|
+
import { useStepImageUpload } from "./stepImageUpload";
|
|
6
8
|
|
|
7
9
|
type InlineSegment = {
|
|
8
10
|
text: string;
|
|
@@ -200,6 +202,10 @@ function escapeMarkdownText(text: string): string {
|
|
|
200
202
|
return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
|
|
201
203
|
}
|
|
202
204
|
|
|
205
|
+
function normalizePlainText(text: string): string {
|
|
206
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
207
|
+
}
|
|
208
|
+
|
|
203
209
|
type StepFieldProps = {
|
|
204
210
|
label: string;
|
|
205
211
|
value: string;
|
|
@@ -207,12 +213,64 @@ type StepFieldProps = {
|
|
|
207
213
|
onChange: (nextValue: string) => void;
|
|
208
214
|
autoFocus?: boolean;
|
|
209
215
|
multiline?: boolean;
|
|
216
|
+
enableAutocomplete?: boolean;
|
|
217
|
+
fieldName?: string;
|
|
218
|
+
enableImageUpload?: boolean;
|
|
219
|
+
onImageFile?: (file: File) => Promise<void> | void;
|
|
210
220
|
};
|
|
211
221
|
|
|
212
|
-
function StepField({
|
|
222
|
+
function StepField({
|
|
223
|
+
label,
|
|
224
|
+
value,
|
|
225
|
+
placeholder,
|
|
226
|
+
onChange,
|
|
227
|
+
autoFocus,
|
|
228
|
+
multiline = false,
|
|
229
|
+
enableAutocomplete = false,
|
|
230
|
+
fieldName,
|
|
231
|
+
enableImageUpload = false,
|
|
232
|
+
onImageFile,
|
|
233
|
+
}: StepFieldProps) {
|
|
213
234
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
214
235
|
const [isFocused, setIsFocused] = useState(false);
|
|
215
236
|
const autoFocusRef = useRef(false);
|
|
237
|
+
const [plainTextValue, setPlainTextValue] = useState("");
|
|
238
|
+
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
|
|
239
|
+
const [showAllSuggestions, setShowAllSuggestions] = useState(false);
|
|
240
|
+
const suggestions = useStepAutocomplete();
|
|
241
|
+
const uploadImage = useStepImageUpload();
|
|
242
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
243
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
244
|
+
const normalizedQuery = normalizePlainText(plainTextValue);
|
|
245
|
+
const filteredSuggestions = useMemo(() => {
|
|
246
|
+
if (!enableAutocomplete) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const pool = showAllSuggestions || !normalizedQuery
|
|
251
|
+
? suggestions
|
|
252
|
+
: suggestions.filter((item) => normalizePlainText(item.title).startsWith(normalizedQuery));
|
|
253
|
+
|
|
254
|
+
return pool.slice(0, 8);
|
|
255
|
+
}, [enableAutocomplete, normalizedQuery, showAllSuggestions, suggestions]);
|
|
256
|
+
const hasExactMatch = filteredSuggestions.some(
|
|
257
|
+
(item) => normalizePlainText(item.title) === normalizedQuery,
|
|
258
|
+
);
|
|
259
|
+
const shouldShowAutocomplete =
|
|
260
|
+
enableAutocomplete &&
|
|
261
|
+
isFocused &&
|
|
262
|
+
filteredSuggestions.length > 0 &&
|
|
263
|
+
(!hasExactMatch || showAllSuggestions) &&
|
|
264
|
+
(showAllSuggestions || normalizedQuery.length >= 1);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
setActiveSuggestionIndex(0);
|
|
267
|
+
}, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]);
|
|
268
|
+
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (normalizedQuery.length > 0) {
|
|
271
|
+
setShowAllSuggestions(false);
|
|
272
|
+
}
|
|
273
|
+
}, [normalizedQuery]);
|
|
216
274
|
|
|
217
275
|
useEffect(() => {
|
|
218
276
|
const element = editorRef.current;
|
|
@@ -222,8 +280,10 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
222
280
|
|
|
223
281
|
if (value.trim().length === 0) {
|
|
224
282
|
element.innerHTML = "";
|
|
283
|
+
setPlainTextValue("");
|
|
225
284
|
} else {
|
|
226
285
|
element.innerHTML = markdownToHtml(value);
|
|
286
|
+
setPlainTextValue(element.textContent ?? "");
|
|
227
287
|
}
|
|
228
288
|
}, [value, isFocused]);
|
|
229
289
|
|
|
@@ -237,17 +297,36 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
237
297
|
if (markdown !== value) {
|
|
238
298
|
onChange(markdown);
|
|
239
299
|
}
|
|
300
|
+
setPlainTextValue(element.innerText ?? "");
|
|
240
301
|
if (!markdown && element.innerHTML !== "") {
|
|
241
302
|
element.innerHTML = "";
|
|
242
303
|
}
|
|
243
304
|
}, [onChange, value]);
|
|
244
305
|
|
|
245
306
|
useEffect(() => {
|
|
246
|
-
if (autoFocus
|
|
247
|
-
|
|
307
|
+
if (!autoFocus || autoFocusRef.current || !editorRef.current) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
autoFocusRef.current = true;
|
|
312
|
+
const element = editorRef.current;
|
|
313
|
+
const focusElement = () => {
|
|
314
|
+
element.focus();
|
|
248
315
|
setIsFocused(true);
|
|
249
|
-
|
|
316
|
+
const selection = typeof window !== "undefined" ? window.getSelection?.() : null;
|
|
317
|
+
if (selection) {
|
|
318
|
+
selection.selectAllChildren(element);
|
|
319
|
+
selection.collapseToEnd();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (typeof requestAnimationFrame === "function") {
|
|
324
|
+
const frame = requestAnimationFrame(focusElement);
|
|
325
|
+
return () => cancelAnimationFrame(frame);
|
|
250
326
|
}
|
|
327
|
+
|
|
328
|
+
const timeout = setTimeout(focusElement, 0);
|
|
329
|
+
return () => clearTimeout(timeout);
|
|
251
330
|
}, [autoFocus]);
|
|
252
331
|
|
|
253
332
|
const applyFormat = useCallback(
|
|
@@ -258,20 +337,168 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
258
337
|
[syncValue],
|
|
259
338
|
);
|
|
260
339
|
|
|
340
|
+
const ensureCaretInEditor = useCallback(() => {
|
|
341
|
+
const element = editorRef.current;
|
|
342
|
+
if (!element) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const selection = window.getSelection?.();
|
|
347
|
+
if (!selection) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (selection.rangeCount === 0 || !element.contains(selection.anchorNode)) {
|
|
352
|
+
const range = document.createRange();
|
|
353
|
+
range.selectNodeContents(element);
|
|
354
|
+
range.collapse(false);
|
|
355
|
+
selection.removeAllRanges();
|
|
356
|
+
selection.addRange(range);
|
|
357
|
+
}
|
|
358
|
+
element.focus();
|
|
359
|
+
return true;
|
|
360
|
+
}, []);
|
|
361
|
+
|
|
362
|
+
const insertImageAtCursor = useCallback(
|
|
363
|
+
(url: string) => {
|
|
364
|
+
const element = editorRef.current;
|
|
365
|
+
if (!element) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const escapedUrl = escapeHtml(url);
|
|
370
|
+
const imgHtml = `<img src="${escapedUrl}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
|
|
371
|
+
element.focus();
|
|
372
|
+
ensureCaretInEditor();
|
|
373
|
+
document.execCommand("insertHTML", false, imgHtml);
|
|
374
|
+
syncValue();
|
|
375
|
+
},
|
|
376
|
+
[ensureCaretInEditor, syncValue],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const handleImagePick = useCallback(async () => {
|
|
380
|
+
if (!enableImageUpload || !uploadImage) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const fileInput = fileInputRef.current;
|
|
385
|
+
if (fileInput) {
|
|
386
|
+
fileInput.click();
|
|
387
|
+
}
|
|
388
|
+
}, [enableImageUpload, uploadImage]);
|
|
389
|
+
|
|
390
|
+
const handleFileChange = useCallback(
|
|
391
|
+
async (event: ChangeEvent<HTMLInputElement>) => {
|
|
392
|
+
const file = event.target.files?.[0];
|
|
393
|
+
if (!file || !uploadImage) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
setIsUploading(true);
|
|
399
|
+
const response = await uploadImage(file);
|
|
400
|
+
if (response?.url) {
|
|
401
|
+
insertImageAtCursor(response.url);
|
|
402
|
+
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error("Failed to upload image", error);
|
|
405
|
+
} finally {
|
|
406
|
+
setIsUploading(false);
|
|
407
|
+
event.target.value = "";
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
[insertImageAtCursor, uploadImage],
|
|
411
|
+
);
|
|
412
|
+
|
|
261
413
|
const handlePaste = useCallback(
|
|
262
|
-
(event: ClipboardEvent<HTMLDivElement>) => {
|
|
414
|
+
async (event: ClipboardEvent<HTMLDivElement>) => {
|
|
415
|
+
if ((enableImageUpload && uploadImage) || onImageFile) {
|
|
416
|
+
const items = Array.from(event.clipboardData.items ?? []);
|
|
417
|
+
const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
|
|
418
|
+
const file = imageItem?.getAsFile();
|
|
419
|
+
if (file) {
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
if (onImageFile) {
|
|
422
|
+
await onImageFile(file);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (enableImageUpload && uploadImage) {
|
|
426
|
+
try {
|
|
427
|
+
setIsUploading(true);
|
|
428
|
+
const result = await uploadImage(file);
|
|
429
|
+
if (result?.url) {
|
|
430
|
+
ensureCaretInEditor();
|
|
431
|
+
document.execCommand(
|
|
432
|
+
"insertHTML",
|
|
433
|
+
false,
|
|
434
|
+
`<img src="${escapeHtml(result.url)}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`,
|
|
435
|
+
);
|
|
436
|
+
syncValue();
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error("Failed to upload image from paste", error);
|
|
440
|
+
} finally {
|
|
441
|
+
setIsUploading(false);
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
263
448
|
event.preventDefault();
|
|
264
449
|
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
265
|
-
|
|
450
|
+
const html = markdownToHtml(text);
|
|
451
|
+
ensureCaretInEditor();
|
|
452
|
+
document.execCommand("insertHTML", false, html);
|
|
266
453
|
syncValue();
|
|
267
454
|
},
|
|
268
|
-
[syncValue],
|
|
455
|
+
[enableImageUpload, ensureCaretInEditor, syncValue, uploadImage],
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const applySuggestion = useCallback(
|
|
459
|
+
(suggestion: StepSuggestion) => {
|
|
460
|
+
const escaped = escapeMarkdownText(suggestion.title);
|
|
461
|
+
onChange(escaped);
|
|
462
|
+
setPlainTextValue(suggestion.title);
|
|
463
|
+
setActiveSuggestionIndex(0);
|
|
464
|
+
setShowAllSuggestions(false);
|
|
465
|
+
if (editorRef.current) {
|
|
466
|
+
editorRef.current.innerHTML = markdownToHtml(escaped);
|
|
467
|
+
editorRef.current.focus();
|
|
468
|
+
const selection = typeof window !== "undefined" ? window.getSelection?.() : null;
|
|
469
|
+
if (selection && editorRef.current.firstChild) {
|
|
470
|
+
const range = document.createRange();
|
|
471
|
+
range.selectNodeContents(editorRef.current);
|
|
472
|
+
range.collapse(false);
|
|
473
|
+
selection.removeAllRanges();
|
|
474
|
+
selection.addRange(range);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
[onChange],
|
|
269
479
|
);
|
|
270
480
|
|
|
271
481
|
return (
|
|
272
482
|
<div className="bn-step-field">
|
|
273
483
|
<div className="bn-step-field__top">
|
|
274
|
-
<span className="bn-step-field__label">
|
|
484
|
+
<span className="bn-step-field__label">
|
|
485
|
+
{label}
|
|
486
|
+
{enableAutocomplete && (
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
className="bn-step-toolbar__button"
|
|
490
|
+
onMouseDown={(event) => {
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
setShowAllSuggestions(true);
|
|
493
|
+
editorRef.current?.focus();
|
|
494
|
+
}}
|
|
495
|
+
aria-label="Show step suggestions"
|
|
496
|
+
tabIndex={-1}
|
|
497
|
+
>
|
|
498
|
+
⌄
|
|
499
|
+
</button>
|
|
500
|
+
)}
|
|
501
|
+
</span>
|
|
275
502
|
<div className="bn-step-toolbar" aria-label={`${label} formatting`}>
|
|
276
503
|
<button
|
|
277
504
|
type="button"
|
|
@@ -282,6 +509,7 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
282
509
|
applyFormat("bold");
|
|
283
510
|
}}
|
|
284
511
|
aria-label="Bold"
|
|
512
|
+
tabIndex={-1}
|
|
285
513
|
>
|
|
286
514
|
B
|
|
287
515
|
</button>
|
|
@@ -294,6 +522,7 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
294
522
|
applyFormat("italic");
|
|
295
523
|
}}
|
|
296
524
|
aria-label="Italic"
|
|
525
|
+
tabIndex={-1}
|
|
297
526
|
>
|
|
298
527
|
I
|
|
299
528
|
</button>
|
|
@@ -306,11 +535,36 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
306
535
|
applyFormat("underline");
|
|
307
536
|
}}
|
|
308
537
|
aria-label="Underline"
|
|
538
|
+
tabIndex={-1}
|
|
309
539
|
>
|
|
310
540
|
U
|
|
311
541
|
</button>
|
|
542
|
+
{enableImageUpload && uploadImage && (
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
className="bn-step-toolbar__button"
|
|
546
|
+
onMouseDown={(event) => {
|
|
547
|
+
event.preventDefault();
|
|
548
|
+
handleImagePick();
|
|
549
|
+
}}
|
|
550
|
+
aria-label="Insert image"
|
|
551
|
+
tabIndex={-1}
|
|
552
|
+
disabled={isUploading}
|
|
553
|
+
>
|
|
554
|
+
Img
|
|
555
|
+
</button>
|
|
556
|
+
)}
|
|
312
557
|
</div>
|
|
313
558
|
</div>
|
|
559
|
+
{enableImageUpload && (
|
|
560
|
+
<input
|
|
561
|
+
ref={fileInputRef}
|
|
562
|
+
type="file"
|
|
563
|
+
accept="image/*"
|
|
564
|
+
style={{ display: "none" }}
|
|
565
|
+
onChange={handleFileChange}
|
|
566
|
+
/>
|
|
567
|
+
)}
|
|
314
568
|
<div
|
|
315
569
|
ref={editorRef}
|
|
316
570
|
className="bn-step-editor"
|
|
@@ -318,7 +572,11 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
318
572
|
suppressContentEditableWarning
|
|
319
573
|
data-placeholder={placeholder}
|
|
320
574
|
data-multiline={multiline ? "true" : "false"}
|
|
321
|
-
|
|
575
|
+
data-step-field={fieldName}
|
|
576
|
+
onFocus={() => {
|
|
577
|
+
setIsFocused(true);
|
|
578
|
+
setPlainTextValue(editorRef.current?.innerText ?? "");
|
|
579
|
+
}}
|
|
322
580
|
onBlur={() => {
|
|
323
581
|
setIsFocused(false);
|
|
324
582
|
syncValue();
|
|
@@ -326,6 +584,50 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
326
584
|
onInput={syncValue}
|
|
327
585
|
onPaste={handlePaste}
|
|
328
586
|
onKeyDown={(event) => {
|
|
587
|
+
if ((event.key === "a" || event.key === "A") && (event.metaKey || event.ctrlKey)) {
|
|
588
|
+
event.preventDefault();
|
|
589
|
+
const selection = window.getSelection?.();
|
|
590
|
+
const node = editorRef.current;
|
|
591
|
+
if (selection && node) {
|
|
592
|
+
const range = document.createRange();
|
|
593
|
+
range.selectNodeContents(node);
|
|
594
|
+
selection.removeAllRanges();
|
|
595
|
+
selection.addRange(range);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (enableAutocomplete && shouldShowAutocomplete) {
|
|
601
|
+
if (event.key === "ArrowDown") {
|
|
602
|
+
event.preventDefault();
|
|
603
|
+
setActiveSuggestionIndex((prev) =>
|
|
604
|
+
prev + 1 >= filteredSuggestions.length ? 0 : prev + 1,
|
|
605
|
+
);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (event.key === "ArrowUp") {
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
setActiveSuggestionIndex((prev) =>
|
|
611
|
+
prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1,
|
|
612
|
+
);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
616
|
+
event.preventDefault();
|
|
617
|
+
const suggestion = filteredSuggestions[activeSuggestionIndex] ?? filteredSuggestions[0];
|
|
618
|
+
if (suggestion) {
|
|
619
|
+
applySuggestion(suggestion);
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ")) {
|
|
626
|
+
event.preventDefault();
|
|
627
|
+
setShowAllSuggestions(true);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
329
631
|
if (event.key === "Enter") {
|
|
330
632
|
event.preventDefault();
|
|
331
633
|
if (multiline && event.shiftKey) {
|
|
@@ -338,6 +640,33 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
|
|
|
338
640
|
}
|
|
339
641
|
}}
|
|
340
642
|
/>
|
|
643
|
+
{shouldShowAutocomplete && (
|
|
644
|
+
<div className="bn-step-suggestions" role="listbox" aria-label={`${label} suggestions`}>
|
|
645
|
+
{filteredSuggestions.map((suggestion, index) => (
|
|
646
|
+
<button
|
|
647
|
+
type="button"
|
|
648
|
+
key={suggestion.id}
|
|
649
|
+
role="option"
|
|
650
|
+
aria-selected={index === activeSuggestionIndex}
|
|
651
|
+
className={
|
|
652
|
+
index === activeSuggestionIndex
|
|
653
|
+
? "bn-step-suggestion bn-step-suggestion--active"
|
|
654
|
+
: "bn-step-suggestion"
|
|
655
|
+
}
|
|
656
|
+
onMouseDown={(event) => {
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
applySuggestion(suggestion);
|
|
659
|
+
}}
|
|
660
|
+
tabIndex={-1}
|
|
661
|
+
>
|
|
662
|
+
<span className="bn-step-suggestion__title">{suggestion.title}</span>
|
|
663
|
+
{typeof suggestion.usageCount === "number" && suggestion.usageCount > 0 && (
|
|
664
|
+
<span className="bn-step-suggestion__meta">{suggestion.usageCount} uses</span>
|
|
665
|
+
)}
|
|
666
|
+
</button>
|
|
667
|
+
))}
|
|
668
|
+
</div>
|
|
669
|
+
)}
|
|
341
670
|
</div>
|
|
342
671
|
);
|
|
343
672
|
}
|
|
@@ -383,6 +712,7 @@ const testStepBlock = createReactBlockSpec(
|
|
|
383
712
|
stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
|
|
384
713
|
const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
|
|
385
714
|
const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
|
|
715
|
+
const uploadImage = useStepImageUpload();
|
|
386
716
|
|
|
387
717
|
useEffect(() => {
|
|
388
718
|
if (stepData.trim().length > 0 && !isDataVisible) {
|
|
@@ -449,13 +779,37 @@ const testStepBlock = createReactBlockSpec(
|
|
|
449
779
|
);
|
|
450
780
|
|
|
451
781
|
return (
|
|
452
|
-
<div className="bn-teststep">
|
|
782
|
+
<div className="bn-teststep" data-block-id={block.id}>
|
|
453
783
|
<StepField
|
|
454
784
|
label="Step Title"
|
|
455
785
|
value={stepTitle}
|
|
456
786
|
placeholder="Describe the action to perform"
|
|
457
787
|
onChange={handleStepTitleChange}
|
|
458
788
|
autoFocus={stepTitle.length === 0}
|
|
789
|
+
enableAutocomplete
|
|
790
|
+
fieldName="title"
|
|
791
|
+
enableImageUpload={false}
|
|
792
|
+
onImageFile={async (file) => {
|
|
793
|
+
if (!uploadImage) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
setIsDataVisible(true);
|
|
798
|
+
setShouldFocusDataField(true);
|
|
799
|
+
try {
|
|
800
|
+
const result = await uploadImage(file);
|
|
801
|
+
if (result?.url) {
|
|
802
|
+
const nextValue = stepData.trim().length > 0 ? `${stepData}\n` : ``;
|
|
803
|
+
editor.updateBlock(block.id, {
|
|
804
|
+
props: {
|
|
805
|
+
stepData: nextValue,
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
} catch (error) {
|
|
810
|
+
console.error("Failed to upload image to Step Data", error);
|
|
811
|
+
}
|
|
812
|
+
}}
|
|
459
813
|
/>
|
|
460
814
|
{!isDataVisible && (
|
|
461
815
|
<button
|
|
@@ -463,8 +817,9 @@ const testStepBlock = createReactBlockSpec(
|
|
|
463
817
|
className="bn-teststep__toggle"
|
|
464
818
|
onClick={handleShowDataField}
|
|
465
819
|
aria-expanded="false"
|
|
820
|
+
tabIndex={-1}
|
|
466
821
|
>
|
|
467
|
-
|
|
822
|
+
+ Step Data
|
|
468
823
|
</button>
|
|
469
824
|
)}
|
|
470
825
|
{isDataVisible && (
|
|
@@ -475,6 +830,7 @@ const testStepBlock = createReactBlockSpec(
|
|
|
475
830
|
onChange={handleStepDataChange}
|
|
476
831
|
autoFocus={shouldFocusDataField}
|
|
477
832
|
multiline
|
|
833
|
+
enableImageUpload
|
|
478
834
|
/>
|
|
479
835
|
)}
|
|
480
836
|
{showExpectedField && (
|
|
@@ -484,6 +840,7 @@ const testStepBlock = createReactBlockSpec(
|
|
|
484
840
|
placeholder="What should happen?"
|
|
485
841
|
onChange={handleExpectedChange}
|
|
486
842
|
multiline
|
|
843
|
+
enableImageUpload
|
|
487
844
|
/>
|
|
488
845
|
)}
|
|
489
846
|
</div>
|