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.
@@ -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 Result*";
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 stepTitle = unescapeMarkdown(trimmed.slice(2)).trim();
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
- // Only parse as test step if there's expected result or data content
835
- if (expectedResult || stepData) {
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
- return null;
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 ![](https://placehold.co/600x400?text=Uploaded+1763329962213)";
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({ label, value, placeholder, onChange, autoFocus, multiline = false }: StepFieldProps) {
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 && !autoFocusRef.current && editorRef.current) {
247
- editorRef.current.focus();
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
- autoFocusRef.current = true;
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
- document.execCommand("insertText", false, text);
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">{label}</span>
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
- onFocus={() => setIsFocused(true)}
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![](${result.url})` : `![](${result.url})`;
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
- [+ Data]
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>