testomatio-editor-blocks 0.1.2 → 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 (36) hide show
  1. package/README.md +13 -6
  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 +1 -1
  17. package/package/index.d.ts +1 -1
  18. package/package/index.js +1 -1
  19. package/package/styles.css +57 -0
  20. package/package.json +1 -1
  21. package/src/App.tsx +143 -41
  22. package/src/editor/blocks/blocks.test.ts +22 -0
  23. package/src/editor/blocks/markdown.ts +199 -0
  24. package/src/editor/blocks/snippet.tsx +109 -0
  25. package/src/editor/blocks/step.tsx +175 -0
  26. package/src/editor/blocks/stepField.tsx +487 -0
  27. package/src/editor/customMarkdownConverter.test.ts +121 -36
  28. package/src/editor/customMarkdownConverter.ts +128 -85
  29. package/src/editor/customSchema.tsx +6 -935
  30. package/src/editor/snippetAutocomplete.test.ts +54 -0
  31. package/src/editor/snippetAutocomplete.ts +133 -0
  32. package/src/editor/stepAutocomplete.test.ts +3 -3
  33. package/src/editor/stepAutocomplete.tsx +1 -1
  34. package/src/editor/styles.css +57 -0
  35. package/src/index.ts +1 -1
  36. package/src/editor/customSchema.test.ts +0 -47
@@ -1,943 +1,14 @@
1
- import { defaultBlockSpecs, defaultProps } from "@blocknote/core";
1
+ import { defaultBlockSpecs } from "@blocknote/core";
2
2
  import { BlockNoteSchema } from "@blocknote/core";
3
- import { createReactBlockSpec } from "@blocknote/react";
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
- import type { ChangeEvent, ClipboardEvent, CSSProperties } from "react";
6
- import { useStepAutocomplete, type StepSuggestion } from "./stepAutocomplete";
7
- import { useStepImageUpload } from "./stepImageUpload";
8
-
9
- type InlineSegment = {
10
- text: string;
11
- styles: {
12
- bold: boolean;
13
- italic: boolean;
14
- underline: boolean;
15
- };
16
- };
17
-
18
- function escapeHtml(text: string): string {
19
- return text
20
- .replace(/&/g, "&")
21
- .replace(/</g, "&lt;")
22
- .replace(/>/g, "&gt;")
23
- .replace(/\"/g, "&quot;")
24
- .replace(/'/g, "&#39;");
25
- }
26
-
27
- const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
28
- function markdownToHtml(markdown: string): string {
29
- if (!markdown) {
30
- return "";
31
- }
32
-
33
- const lines = markdown.split(/\n/);
34
- const htmlLines = lines.map((line) => {
35
- const inline = parseInlineMarkdown(line);
36
- const html = inlineToHtml(inline);
37
- if (!html) {
38
- return html;
39
- }
40
-
41
- return html.replace(
42
- IMAGE_MARKDOWN_REGEX,
43
- (_match, alt = "", src = "") =>
44
- `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" class="bn-inline-image" contenteditable="false" draggable="false" />`,
45
- );
46
- });
47
- return htmlLines.join("<br />");
48
- }
49
-
50
- function parseInlineMarkdown(text: string): InlineSegment[] {
51
- if (!text) {
52
- return [];
53
- }
54
-
55
- const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
56
- const rawSegments = normalized
57
- .split(/(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/)
58
- .filter(Boolean);
59
-
60
- return rawSegments.map((segment) => {
61
- const baseStyles = { bold: false, italic: false, underline: false };
62
-
63
- if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
64
- const content = segment.slice(2, -2);
65
- return {
66
- text: restoreEscapes(content),
67
- styles: { ...baseStyles, bold: true },
68
- };
69
- }
70
-
71
- if (/^\*(.+)\*$/.test(segment) || /^_(.+)_$/.test(segment)) {
72
- const content = segment.slice(1, -1);
73
- return {
74
- text: restoreEscapes(content),
75
- styles: { ...baseStyles, italic: true },
76
- };
77
- }
78
-
79
- if (/^<u>(.+)<\/u>$/.test(segment)) {
80
- const content = segment.slice(3, -4);
81
- return {
82
- text: restoreEscapes(content),
83
- styles: { ...baseStyles, underline: true },
84
- };
85
- }
86
-
87
- return {
88
- text: restoreEscapes(segment),
89
- styles: { ...baseStyles },
90
- };
91
- });
92
- }
93
-
94
- function inlineToHtml(inline: InlineSegment[]): string {
95
- return inline
96
- .map(({ text, styles }) => {
97
- let html = escapeHtml(text);
98
- if (styles.bold) {
99
- html = `<strong>${html}</strong>`;
100
- }
101
- if (styles.italic) {
102
- html = `<em>${html}</em>`;
103
- }
104
- if (styles.underline) {
105
- html = `<u>${html}</u>`;
106
- }
107
- return html;
108
- })
109
- .join("");
110
- }
111
-
112
- function restoreEscapes(text: string): string {
113
- return text.replace(/\uE000/g, "\\");
114
- }
115
-
116
- function htmlToMarkdown(html: string): string {
117
- if (typeof document === "undefined") {
118
- return fallbackHtmlToMarkdown(html);
119
- }
120
-
121
- const temp = document.createElement("div");
122
- temp.innerHTML = html;
123
-
124
- const traverse = (node: Node): string => {
125
- if (node.nodeType === Node.TEXT_NODE) {
126
- const text = node.textContent ?? "";
127
- return escapeMarkdownText(text);
128
- }
129
-
130
- if (node.nodeType !== Node.ELEMENT_NODE) {
131
- return "";
132
- }
133
-
134
- const element = node as HTMLElement;
135
- const children = Array.from(element.childNodes)
136
- .map(traverse)
137
- .join("");
138
-
139
- switch (element.tagName.toLowerCase()) {
140
- case "strong":
141
- case "b":
142
- return children ? `**${children}**` : children;
143
- case "em":
144
- case "i":
145
- return children ? `*${children}*` : children;
146
- case "u":
147
- return children ? `<u>${children}</u>` : children;
148
- case "br":
149
- return "\n";
150
- case "div":
151
- case "p":
152
- return children + "\n";
153
- case "img": {
154
- const src = element.getAttribute("src") ?? "";
155
- const alt = element.getAttribute("alt") ?? "";
156
- return `![${alt}](${src})`;
157
- }
158
- default:
159
- return children;
160
- }
161
- };
162
-
163
- const markdown = Array.from(temp.childNodes).map(traverse).join("");
164
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
165
- }
166
-
167
- function fallbackHtmlToMarkdown(html: string): string {
168
- if (!html) {
169
- return "";
170
- }
171
-
172
- let result = html;
173
-
174
- result = result.replace(/<img[^>]*>/gi, (match) => {
175
- const src = match.match(/src="([^"]*)"/i)?.[1] ?? "";
176
- const alt = match.match(/alt="([^"]*)"/i)?.[1] ?? "";
177
- return `![${alt}](${src})`;
178
- });
179
-
180
- result = result
181
- .replace(/<br\s*\/?>/gi, "\n")
182
- .replace(/<\/?(div|p)>/gi, "\n")
183
- .replace(/<strong>(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
184
- .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `*${content}*`)
185
- .replace(/<span[^>]*>/gi, "")
186
- .replace(/<\/span>/gi, "")
187
- .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
188
-
189
- result = result.replace(/<\/?[^>]+>/g, "");
190
-
191
- return result
192
- .split("\n")
193
- .map((line) => line.trimEnd())
194
- .join("\n")
195
- .replace(/\n{3,}/g, "\n\n")
196
- .trim();
197
- }
198
-
199
- const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
200
-
201
- function escapeMarkdownText(text: string): string {
202
- return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
203
- }
204
-
205
- function normalizePlainText(text: string): string {
206
- return text.replace(/\s+/g, " ").trim().toLowerCase();
207
- }
208
-
209
- type StepFieldProps = {
210
- label: string;
211
- value: string;
212
- placeholder: string;
213
- onChange: (nextValue: string) => void;
214
- autoFocus?: boolean;
215
- multiline?: boolean;
216
- enableAutocomplete?: boolean;
217
- fieldName?: string;
218
- enableImageUpload?: boolean;
219
- onImageFile?: (file: File) => Promise<void> | void;
220
- };
221
-
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) {
234
- const editorRef = useRef<HTMLDivElement>(null);
235
- const [isFocused, setIsFocused] = useState(false);
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]);
274
-
275
- useEffect(() => {
276
- const element = editorRef.current;
277
- if (!element || isFocused) {
278
- return;
279
- }
280
-
281
- if (value.trim().length === 0) {
282
- element.innerHTML = "";
283
- setPlainTextValue("");
284
- } else {
285
- element.innerHTML = markdownToHtml(value);
286
- setPlainTextValue(element.textContent ?? "");
287
- }
288
- }, [value, isFocused]);
289
-
290
- const syncValue = useCallback(() => {
291
- const element = editorRef.current;
292
- if (!element) {
293
- return;
294
- }
295
-
296
- const markdown = htmlToMarkdown(element.innerHTML);
297
- if (markdown !== value) {
298
- onChange(markdown);
299
- }
300
- setPlainTextValue(element.innerText ?? "");
301
- if (!markdown && element.innerHTML !== "") {
302
- element.innerHTML = "";
303
- }
304
- }, [onChange, value]);
305
-
306
- useEffect(() => {
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();
315
- setIsFocused(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);
326
- }
327
-
328
- const timeout = setTimeout(focusElement, 0);
329
- return () => clearTimeout(timeout);
330
- }, [autoFocus]);
331
-
332
- const applyFormat = useCallback(
333
- (command: "bold" | "italic" | "underline") => {
334
- document.execCommand(command);
335
- syncValue();
336
- },
337
- [syncValue],
338
- );
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
-
413
- const handlePaste = useCallback(
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
-
448
- event.preventDefault();
449
- const text = event.clipboardData?.getData("text/plain") ?? "";
450
- const html = markdownToHtml(text);
451
- ensureCaretInEditor();
452
- document.execCommand("insertHTML", false, html);
453
- syncValue();
454
- },
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],
479
- );
480
-
481
- return (
482
- <div className="bn-step-field">
483
- <div className="bn-step-field__top">
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>
502
- <div className="bn-step-toolbar" aria-label={`${label} formatting`}>
503
- <button
504
- type="button"
505
- className="bn-step-toolbar__button"
506
- onMouseDown={(event) => {
507
- event.preventDefault();
508
- editorRef.current?.focus();
509
- applyFormat("bold");
510
- }}
511
- aria-label="Bold"
512
- tabIndex={-1}
513
- >
514
- B
515
- </button>
516
- <button
517
- type="button"
518
- className="bn-step-toolbar__button"
519
- onMouseDown={(event) => {
520
- event.preventDefault();
521
- editorRef.current?.focus();
522
- applyFormat("italic");
523
- }}
524
- aria-label="Italic"
525
- tabIndex={-1}
526
- >
527
- I
528
- </button>
529
- <button
530
- type="button"
531
- className="bn-step-toolbar__button"
532
- onMouseDown={(event) => {
533
- event.preventDefault();
534
- editorRef.current?.focus();
535
- applyFormat("underline");
536
- }}
537
- aria-label="Underline"
538
- tabIndex={-1}
539
- >
540
- U
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
- )}
557
- </div>
558
- </div>
559
- {enableImageUpload && (
560
- <input
561
- ref={fileInputRef}
562
- type="file"
563
- accept="image/*"
564
- style={{ display: "none" }}
565
- onChange={handleFileChange}
566
- />
567
- )}
568
- <div
569
- ref={editorRef}
570
- className="bn-step-editor"
571
- contentEditable
572
- suppressContentEditableWarning
573
- data-placeholder={placeholder}
574
- data-multiline={multiline ? "true" : "false"}
575
- data-step-field={fieldName}
576
- onFocus={() => {
577
- setIsFocused(true);
578
- setPlainTextValue(editorRef.current?.innerText ?? "");
579
- }}
580
- onBlur={() => {
581
- setIsFocused(false);
582
- syncValue();
583
- }}
584
- onInput={syncValue}
585
- onPaste={handlePaste}
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
-
631
- if (event.key === "Enter") {
632
- event.preventDefault();
633
- if (multiline && event.shiftKey) {
634
- document.execCommand("insertLineBreak");
635
- document.execCommand("insertLineBreak");
636
- } else {
637
- document.execCommand("insertLineBreak");
638
- }
639
- syncValue();
640
- }
641
- }}
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
- )}
670
- </div>
671
- );
672
- }
673
-
674
- const statusOptions = ["draft", "ready", "blocked"] as const;
675
-
676
- type Status = (typeof statusOptions)[number];
677
-
678
- const statusLabels: Record<Status, string> = {
679
- draft: "Draft",
680
- ready: "Ready",
681
- blocked: "Blocked",
682
- };
683
-
684
- const statusClassNames: Record<Status, string> = {
685
- draft: "bn-testcase--draft",
686
- ready: "bn-testcase--ready",
687
- blocked: "bn-testcase--blocked",
688
- };
689
-
690
- const testStepBlock = createReactBlockSpec(
691
- {
692
- type: "testStep",
693
- content: "none",
694
- propSchema: {
695
- stepTitle: {
696
- default: "",
697
- },
698
- stepData: {
699
- default: "",
700
- },
701
- expectedResult: {
702
- default: "",
703
- },
704
- },
705
- },
706
- {
707
- render: ({ block, editor }) => {
708
- const stepTitle = (block.props.stepTitle as string) || "";
709
- const stepData = (block.props.stepData as string) || "";
710
- const expectedResult = (block.props.expectedResult as string) || "";
711
- const showExpectedField =
712
- stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
713
- const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
714
- const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
715
- const uploadImage = useStepImageUpload();
716
-
717
- useEffect(() => {
718
- if (stepData.trim().length > 0 && !isDataVisible) {
719
- setIsDataVisible(true);
720
- }
721
- }, [isDataVisible, stepData]);
722
-
723
- useEffect(() => {
724
- if (shouldFocusDataField && isDataVisible) {
725
- const timer = setTimeout(() => setShouldFocusDataField(false), 0);
726
- return () => clearTimeout(timer);
727
- }
728
- return undefined;
729
- }, [isDataVisible, shouldFocusDataField]);
730
-
731
- const handleStepTitleChange = useCallback(
732
- (next: string) => {
733
- if (next === stepTitle) {
734
- return;
735
- }
736
-
737
- editor.updateBlock(block.id, {
738
- props: {
739
- stepTitle: next,
740
- },
741
- });
742
- },
743
- [editor, block.id, stepTitle],
744
- );
745
-
746
- const handleStepDataChange = useCallback(
747
- (next: string) => {
748
- if (next === stepData) {
749
- return;
750
- }
751
-
752
- editor.updateBlock(block.id, {
753
- props: {
754
- stepData: next,
755
- },
756
- });
757
- },
758
- [editor, block.id, stepData],
759
- );
760
-
761
- const handleShowDataField = useCallback(() => {
762
- setIsDataVisible(true);
763
- setShouldFocusDataField(true);
764
- }, []);
765
-
766
- const handleExpectedChange = useCallback(
767
- (next: string) => {
768
- if (next === expectedResult) {
769
- return;
770
- }
771
-
772
- editor.updateBlock(block.id, {
773
- props: {
774
- expectedResult: next,
775
- },
776
- });
777
- },
778
- [editor, block.id, expectedResult],
779
- );
780
-
781
- return (
782
- <div className="bn-teststep" data-block-id={block.id}>
783
- <StepField
784
- label="Step Title"
785
- value={stepTitle}
786
- placeholder="Describe the action to perform"
787
- onChange={handleStepTitleChange}
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
- }}
813
- />
814
- {!isDataVisible && (
815
- <button
816
- type="button"
817
- className="bn-teststep__toggle"
818
- onClick={handleShowDataField}
819
- aria-expanded="false"
820
- tabIndex={-1}
821
- >
822
- + Step Data
823
- </button>
824
- )}
825
- {isDataVisible && (
826
- <StepField
827
- label="Step Data"
828
- value={stepData}
829
- placeholder="Provide additional data about the step"
830
- onChange={handleStepDataChange}
831
- autoFocus={shouldFocusDataField}
832
- multiline
833
- enableImageUpload
834
- />
835
- )}
836
- {showExpectedField && (
837
- <StepField
838
- label="Expected Result"
839
- value={expectedResult}
840
- placeholder="What should happen?"
841
- onChange={handleExpectedChange}
842
- multiline
843
- enableImageUpload
844
- />
845
- )}
846
- </div>
847
- );
848
- },
849
- },
850
- );
851
-
852
- const testCaseBlock = createReactBlockSpec(
853
- {
854
- type: "testCase",
855
- content: "inline",
856
- propSchema: {
857
- textAlignment: defaultProps.textAlignment,
858
- textColor: defaultProps.textColor,
859
- backgroundColor: defaultProps.backgroundColor,
860
- status: {
861
- default: "draft" as Status,
862
- values: Array.from(statusOptions),
863
- },
864
- reference: {
865
- default: "",
866
- },
867
- },
868
- },
869
- {
870
- render: ({ block, contentRef, editor }) => {
871
- const status = block.props.status as Status;
872
-
873
- const handleStatusChange = (event: ChangeEvent<HTMLSelectElement>) => {
874
- const nextStatus = event.target.value as Status;
875
- editor.updateBlock(block.id, {
876
- props: {
877
- status: nextStatus,
878
- },
879
- });
880
- };
881
-
882
- const handleReferenceChange = (event: ChangeEvent<HTMLInputElement>) => {
883
- editor.updateBlock(block.id, {
884
- props: {
885
- reference: event.target.value,
886
- },
887
- });
888
- };
889
-
890
- const style: CSSProperties = {
891
- textAlign: block.props.textAlignment,
892
- color:
893
- block.props.textColor === "default"
894
- ? undefined
895
- : (block.props.textColor as string),
896
- backgroundColor:
897
- block.props.backgroundColor === "default"
898
- ? undefined
899
- : (block.props.backgroundColor as string),
900
- };
901
-
902
- return (
903
- <div
904
- className={"bn-testcase " + statusClassNames[status]}
905
- data-reference={block.props.reference || undefined}
906
- style={style}
907
- >
908
- <div className="bn-testcase__header">
909
- <div className="bn-testcase__meta">
910
- <span className="bn-testcase__label">Test Case</span>
911
- <input
912
- className="bn-testcase__reference"
913
- placeholder="Reference ID"
914
- value={block.props.reference}
915
- onChange={handleReferenceChange}
916
- />
917
- </div>
918
- <label className="bn-testcase__status">
919
- <span>Status:</span>
920
- <select value={status} onChange={handleStatusChange}>
921
- {statusOptions.map((option) => (
922
- <option key={option} value={option}>
923
- {statusLabels[option]}
924
- </option>
925
- ))}
926
- </select>
927
- </label>
928
- </div>
929
- <div className="bn-testcase__body" ref={contentRef} />
930
- </div>
931
- );
932
- },
933
- },
934
- );
3
+ import { stepBlock } from "./blocks/step";
4
+ import { snippetBlock } from "./blocks/snippet";
5
+ import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
935
6
 
936
7
  export const customSchema = BlockNoteSchema.create({
937
8
  blockSpecs: {
938
9
  ...defaultBlockSpecs,
939
- testCase: testCaseBlock,
940
- testStep: testStepBlock,
10
+ testStep: stepBlock,
11
+ snippet: snippetBlock,
941
12
  },
942
13
  });
943
14