testomatio-editor-blocks 0.1.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.
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { __markdownTestUtils } from "./customSchema";
3
+
4
+ describe("customSchema markdown helpers", () => {
5
+ it("renders markdown images as <img> tags and round-trips back to markdown", () => {
6
+ const markdown = "![](/attachments/example.png)";
7
+ const html = __markdownTestUtils.markdownToHtml(markdown);
8
+
9
+ expect(html).toContain('<img src="/attachments/example.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
10
+
11
+ const roundTrip = __markdownTestUtils.htmlToMarkdown(html);
12
+ expect(roundTrip).toBe(markdown);
13
+ });
14
+
15
+ it("handles images mixed with surrounding text", () => {
16
+ const markdown = [
17
+ "Success screenshot:",
18
+ "![](/attachments/success.png)",
19
+ "Please archive it.",
20
+ ].join("\n");
21
+
22
+ const html = __markdownTestUtils.markdownToHtml(markdown);
23
+ expect(html).toContain('<img src="/attachments/success.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
24
+
25
+ const roundTrip = __markdownTestUtils.htmlToMarkdown(html);
26
+ expect(roundTrip).toBe("Success screenshot:\n![](/attachments/success.png)\nPlease archive it.");
27
+ });
28
+
29
+ it("renders expected-result style markdown with an image", () => {
30
+ const markdown = [
31
+ "Login should look like this",
32
+ "![](/login.png)",
33
+ ].join("\n");
34
+
35
+ const html = __markdownTestUtils.markdownToHtml(markdown);
36
+ expect(html).toContain('<img src="/login.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
37
+ expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
38
+ });
39
+ });
@@ -0,0 +1,594 @@
1
+ import { defaultBlockSpecs, defaultProps } from "@blocknote/core";
2
+ import { BlockNoteSchema } from "@blocknote/core";
3
+ import { createReactBlockSpec } from "@blocknote/react";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import type { ChangeEvent, ClipboardEvent, CSSProperties } from "react";
6
+
7
+ type InlineSegment = {
8
+ text: string;
9
+ styles: {
10
+ bold: boolean;
11
+ italic: boolean;
12
+ underline: boolean;
13
+ };
14
+ };
15
+
16
+ function escapeHtml(text: string): string {
17
+ return text
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/\"/g, "&quot;")
22
+ .replace(/'/g, "&#39;");
23
+ }
24
+
25
+ const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
26
+ function markdownToHtml(markdown: string): string {
27
+ if (!markdown) {
28
+ return "";
29
+ }
30
+
31
+ const lines = markdown.split(/\n/);
32
+ const htmlLines = lines.map((line) => {
33
+ const inline = parseInlineMarkdown(line);
34
+ const html = inlineToHtml(inline);
35
+ if (!html) {
36
+ return html;
37
+ }
38
+
39
+ return html.replace(
40
+ IMAGE_MARKDOWN_REGEX,
41
+ (_match, alt = "", src = "") =>
42
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" class="bn-inline-image" contenteditable="false" draggable="false" />`,
43
+ );
44
+ });
45
+ return htmlLines.join("<br />");
46
+ }
47
+
48
+ function parseInlineMarkdown(text: string): InlineSegment[] {
49
+ if (!text) {
50
+ return [];
51
+ }
52
+
53
+ const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
54
+ const rawSegments = normalized
55
+ .split(/(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/)
56
+ .filter(Boolean);
57
+
58
+ return rawSegments.map((segment) => {
59
+ const baseStyles = { bold: false, italic: false, underline: false };
60
+
61
+ if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
62
+ const content = segment.slice(2, -2);
63
+ return {
64
+ text: restoreEscapes(content),
65
+ styles: { ...baseStyles, bold: true },
66
+ };
67
+ }
68
+
69
+ if (/^\*(.+)\*$/.test(segment) || /^_(.+)_$/.test(segment)) {
70
+ const content = segment.slice(1, -1);
71
+ return {
72
+ text: restoreEscapes(content),
73
+ styles: { ...baseStyles, italic: true },
74
+ };
75
+ }
76
+
77
+ if (/^<u>(.+)<\/u>$/.test(segment)) {
78
+ const content = segment.slice(3, -4);
79
+ return {
80
+ text: restoreEscapes(content),
81
+ styles: { ...baseStyles, underline: true },
82
+ };
83
+ }
84
+
85
+ return {
86
+ text: restoreEscapes(segment),
87
+ styles: { ...baseStyles },
88
+ };
89
+ });
90
+ }
91
+
92
+ function inlineToHtml(inline: InlineSegment[]): string {
93
+ return inline
94
+ .map(({ text, styles }) => {
95
+ let html = escapeHtml(text);
96
+ if (styles.bold) {
97
+ html = `<strong>${html}</strong>`;
98
+ }
99
+ if (styles.italic) {
100
+ html = `<em>${html}</em>`;
101
+ }
102
+ if (styles.underline) {
103
+ html = `<u>${html}</u>`;
104
+ }
105
+ return html;
106
+ })
107
+ .join("");
108
+ }
109
+
110
+ function restoreEscapes(text: string): string {
111
+ return text.replace(/\uE000/g, "\\");
112
+ }
113
+
114
+ function htmlToMarkdown(html: string): string {
115
+ if (typeof document === "undefined") {
116
+ return fallbackHtmlToMarkdown(html);
117
+ }
118
+
119
+ const temp = document.createElement("div");
120
+ temp.innerHTML = html;
121
+
122
+ const traverse = (node: Node): string => {
123
+ if (node.nodeType === Node.TEXT_NODE) {
124
+ const text = node.textContent ?? "";
125
+ return escapeMarkdownText(text);
126
+ }
127
+
128
+ if (node.nodeType !== Node.ELEMENT_NODE) {
129
+ return "";
130
+ }
131
+
132
+ const element = node as HTMLElement;
133
+ const children = Array.from(element.childNodes)
134
+ .map(traverse)
135
+ .join("");
136
+
137
+ switch (element.tagName.toLowerCase()) {
138
+ case "strong":
139
+ case "b":
140
+ return children ? `**${children}**` : children;
141
+ case "em":
142
+ case "i":
143
+ return children ? `*${children}*` : children;
144
+ case "u":
145
+ return children ? `<u>${children}</u>` : children;
146
+ case "br":
147
+ return "\n";
148
+ case "div":
149
+ case "p":
150
+ return children + "\n";
151
+ case "img": {
152
+ const src = element.getAttribute("src") ?? "";
153
+ const alt = element.getAttribute("alt") ?? "";
154
+ return `![${alt}](${src})`;
155
+ }
156
+ default:
157
+ return children;
158
+ }
159
+ };
160
+
161
+ const markdown = Array.from(temp.childNodes).map(traverse).join("");
162
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
163
+ }
164
+
165
+ function fallbackHtmlToMarkdown(html: string): string {
166
+ if (!html) {
167
+ return "";
168
+ }
169
+
170
+ let result = html;
171
+
172
+ result = result.replace(/<img[^>]*>/gi, (match) => {
173
+ const src = match.match(/src="([^"]*)"/i)?.[1] ?? "";
174
+ const alt = match.match(/alt="([^"]*)"/i)?.[1] ?? "";
175
+ return `![${alt}](${src})`;
176
+ });
177
+
178
+ result = result
179
+ .replace(/<br\s*\/?>/gi, "\n")
180
+ .replace(/<\/?(div|p)>/gi, "\n")
181
+ .replace(/<strong>(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
182
+ .replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `*${content}*`)
183
+ .replace(/<span[^>]*>/gi, "")
184
+ .replace(/<\/span>/gi, "")
185
+ .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
186
+
187
+ result = result.replace(/<\/?[^>]+>/g, "");
188
+
189
+ return result
190
+ .split("\n")
191
+ .map((line) => line.trimEnd())
192
+ .join("\n")
193
+ .replace(/\n{3,}/g, "\n\n")
194
+ .trim();
195
+ }
196
+
197
+ const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
198
+
199
+ function escapeMarkdownText(text: string): string {
200
+ return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
201
+ }
202
+
203
+ type StepFieldProps = {
204
+ label: string;
205
+ value: string;
206
+ placeholder: string;
207
+ onChange: (nextValue: string) => void;
208
+ autoFocus?: boolean;
209
+ multiline?: boolean;
210
+ };
211
+
212
+ function StepField({ label, value, placeholder, onChange, autoFocus, multiline = false }: StepFieldProps) {
213
+ const editorRef = useRef<HTMLDivElement>(null);
214
+ const [isFocused, setIsFocused] = useState(false);
215
+ const autoFocusRef = useRef(false);
216
+
217
+ useEffect(() => {
218
+ const element = editorRef.current;
219
+ if (!element || isFocused) {
220
+ return;
221
+ }
222
+
223
+ if (value.trim().length === 0) {
224
+ element.innerHTML = "";
225
+ } else {
226
+ element.innerHTML = markdownToHtml(value);
227
+ }
228
+ }, [value, isFocused]);
229
+
230
+ const syncValue = useCallback(() => {
231
+ const element = editorRef.current;
232
+ if (!element) {
233
+ return;
234
+ }
235
+
236
+ const markdown = htmlToMarkdown(element.innerHTML);
237
+ if (markdown !== value) {
238
+ onChange(markdown);
239
+ }
240
+ if (!markdown && element.innerHTML !== "") {
241
+ element.innerHTML = "";
242
+ }
243
+ }, [onChange, value]);
244
+
245
+ useEffect(() => {
246
+ if (autoFocus && !autoFocusRef.current && editorRef.current) {
247
+ editorRef.current.focus();
248
+ setIsFocused(true);
249
+ autoFocusRef.current = true;
250
+ }
251
+ }, [autoFocus]);
252
+
253
+ const applyFormat = useCallback(
254
+ (command: "bold" | "italic" | "underline") => {
255
+ document.execCommand(command);
256
+ syncValue();
257
+ },
258
+ [syncValue],
259
+ );
260
+
261
+ const handlePaste = useCallback(
262
+ (event: ClipboardEvent<HTMLDivElement>) => {
263
+ event.preventDefault();
264
+ const text = event.clipboardData?.getData("text/plain") ?? "";
265
+ document.execCommand("insertText", false, text);
266
+ syncValue();
267
+ },
268
+ [syncValue],
269
+ );
270
+
271
+ return (
272
+ <div className="bn-step-field">
273
+ <div className="bn-step-field__top">
274
+ <span className="bn-step-field__label">{label}</span>
275
+ <div className="bn-step-toolbar" aria-label={`${label} formatting`}>
276
+ <button
277
+ type="button"
278
+ className="bn-step-toolbar__button"
279
+ onMouseDown={(event) => {
280
+ event.preventDefault();
281
+ editorRef.current?.focus();
282
+ applyFormat("bold");
283
+ }}
284
+ aria-label="Bold"
285
+ >
286
+ B
287
+ </button>
288
+ <button
289
+ type="button"
290
+ className="bn-step-toolbar__button"
291
+ onMouseDown={(event) => {
292
+ event.preventDefault();
293
+ editorRef.current?.focus();
294
+ applyFormat("italic");
295
+ }}
296
+ aria-label="Italic"
297
+ >
298
+ I
299
+ </button>
300
+ <button
301
+ type="button"
302
+ className="bn-step-toolbar__button"
303
+ onMouseDown={(event) => {
304
+ event.preventDefault();
305
+ editorRef.current?.focus();
306
+ applyFormat("underline");
307
+ }}
308
+ aria-label="Underline"
309
+ >
310
+ U
311
+ </button>
312
+ </div>
313
+ </div>
314
+ <div
315
+ ref={editorRef}
316
+ className="bn-step-editor"
317
+ contentEditable
318
+ suppressContentEditableWarning
319
+ data-placeholder={placeholder}
320
+ data-multiline={multiline ? "true" : "false"}
321
+ onFocus={() => setIsFocused(true)}
322
+ onBlur={() => {
323
+ setIsFocused(false);
324
+ syncValue();
325
+ }}
326
+ onInput={syncValue}
327
+ onPaste={handlePaste}
328
+ onKeyDown={(event) => {
329
+ if (event.key === "Enter") {
330
+ event.preventDefault();
331
+ if (multiline && event.shiftKey) {
332
+ document.execCommand("insertLineBreak");
333
+ document.execCommand("insertLineBreak");
334
+ } else {
335
+ document.execCommand("insertLineBreak");
336
+ }
337
+ syncValue();
338
+ }
339
+ }}
340
+ />
341
+ </div>
342
+ );
343
+ }
344
+
345
+ const statusOptions = ["draft", "ready", "blocked"] as const;
346
+
347
+ type Status = (typeof statusOptions)[number];
348
+
349
+ const statusLabels: Record<Status, string> = {
350
+ draft: "Draft",
351
+ ready: "Ready",
352
+ blocked: "Blocked",
353
+ };
354
+
355
+ const statusClassNames: Record<Status, string> = {
356
+ draft: "bn-testcase--draft",
357
+ ready: "bn-testcase--ready",
358
+ blocked: "bn-testcase--blocked",
359
+ };
360
+
361
+ const testStepBlock = createReactBlockSpec(
362
+ {
363
+ type: "testStep",
364
+ content: "none",
365
+ propSchema: {
366
+ stepTitle: {
367
+ default: "",
368
+ },
369
+ stepData: {
370
+ default: "",
371
+ },
372
+ expectedResult: {
373
+ default: "",
374
+ },
375
+ },
376
+ },
377
+ {
378
+ render: ({ block, editor }) => {
379
+ const stepTitle = (block.props.stepTitle as string) || "";
380
+ const stepData = (block.props.stepData as string) || "";
381
+ const expectedResult = (block.props.expectedResult as string) || "";
382
+ const showExpectedField =
383
+ stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
384
+ const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
385
+ const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
386
+
387
+ useEffect(() => {
388
+ if (stepData.trim().length > 0 && !isDataVisible) {
389
+ setIsDataVisible(true);
390
+ }
391
+ }, [isDataVisible, stepData]);
392
+
393
+ useEffect(() => {
394
+ if (shouldFocusDataField && isDataVisible) {
395
+ const timer = setTimeout(() => setShouldFocusDataField(false), 0);
396
+ return () => clearTimeout(timer);
397
+ }
398
+ return undefined;
399
+ }, [isDataVisible, shouldFocusDataField]);
400
+
401
+ const handleStepTitleChange = useCallback(
402
+ (next: string) => {
403
+ if (next === stepTitle) {
404
+ return;
405
+ }
406
+
407
+ editor.updateBlock(block.id, {
408
+ props: {
409
+ stepTitle: next,
410
+ },
411
+ });
412
+ },
413
+ [editor, block.id, stepTitle],
414
+ );
415
+
416
+ const handleStepDataChange = useCallback(
417
+ (next: string) => {
418
+ if (next === stepData) {
419
+ return;
420
+ }
421
+
422
+ editor.updateBlock(block.id, {
423
+ props: {
424
+ stepData: next,
425
+ },
426
+ });
427
+ },
428
+ [editor, block.id, stepData],
429
+ );
430
+
431
+ const handleShowDataField = useCallback(() => {
432
+ setIsDataVisible(true);
433
+ setShouldFocusDataField(true);
434
+ }, []);
435
+
436
+ const handleExpectedChange = useCallback(
437
+ (next: string) => {
438
+ if (next === expectedResult) {
439
+ return;
440
+ }
441
+
442
+ editor.updateBlock(block.id, {
443
+ props: {
444
+ expectedResult: next,
445
+ },
446
+ });
447
+ },
448
+ [editor, block.id, expectedResult],
449
+ );
450
+
451
+ return (
452
+ <div className="bn-teststep">
453
+ <StepField
454
+ label="Step Title"
455
+ value={stepTitle}
456
+ placeholder="Describe the action to perform"
457
+ onChange={handleStepTitleChange}
458
+ autoFocus={stepTitle.length === 0}
459
+ />
460
+ {!isDataVisible && (
461
+ <button
462
+ type="button"
463
+ className="bn-teststep__toggle"
464
+ onClick={handleShowDataField}
465
+ aria-expanded="false"
466
+ >
467
+ [+ Data]
468
+ </button>
469
+ )}
470
+ {isDataVisible && (
471
+ <StepField
472
+ label="Step Data"
473
+ value={stepData}
474
+ placeholder="Provide additional data about the step"
475
+ onChange={handleStepDataChange}
476
+ autoFocus={shouldFocusDataField}
477
+ multiline
478
+ />
479
+ )}
480
+ {showExpectedField && (
481
+ <StepField
482
+ label="Expected Result"
483
+ value={expectedResult}
484
+ placeholder="What should happen?"
485
+ onChange={handleExpectedChange}
486
+ multiline
487
+ />
488
+ )}
489
+ </div>
490
+ );
491
+ },
492
+ },
493
+ );
494
+
495
+ const testCaseBlock = createReactBlockSpec(
496
+ {
497
+ type: "testCase",
498
+ content: "inline",
499
+ propSchema: {
500
+ textAlignment: defaultProps.textAlignment,
501
+ textColor: defaultProps.textColor,
502
+ backgroundColor: defaultProps.backgroundColor,
503
+ status: {
504
+ default: "draft" as Status,
505
+ values: Array.from(statusOptions),
506
+ },
507
+ reference: {
508
+ default: "",
509
+ },
510
+ },
511
+ },
512
+ {
513
+ render: ({ block, contentRef, editor }) => {
514
+ const status = block.props.status as Status;
515
+
516
+ const handleStatusChange = (event: ChangeEvent<HTMLSelectElement>) => {
517
+ const nextStatus = event.target.value as Status;
518
+ editor.updateBlock(block.id, {
519
+ props: {
520
+ status: nextStatus,
521
+ },
522
+ });
523
+ };
524
+
525
+ const handleReferenceChange = (event: ChangeEvent<HTMLInputElement>) => {
526
+ editor.updateBlock(block.id, {
527
+ props: {
528
+ reference: event.target.value,
529
+ },
530
+ });
531
+ };
532
+
533
+ const style: CSSProperties = {
534
+ textAlign: block.props.textAlignment,
535
+ color:
536
+ block.props.textColor === "default"
537
+ ? undefined
538
+ : (block.props.textColor as string),
539
+ backgroundColor:
540
+ block.props.backgroundColor === "default"
541
+ ? undefined
542
+ : (block.props.backgroundColor as string),
543
+ };
544
+
545
+ return (
546
+ <div
547
+ className={"bn-testcase " + statusClassNames[status]}
548
+ data-reference={block.props.reference || undefined}
549
+ style={style}
550
+ >
551
+ <div className="bn-testcase__header">
552
+ <div className="bn-testcase__meta">
553
+ <span className="bn-testcase__label">Test Case</span>
554
+ <input
555
+ className="bn-testcase__reference"
556
+ placeholder="Reference ID"
557
+ value={block.props.reference}
558
+ onChange={handleReferenceChange}
559
+ />
560
+ </div>
561
+ <label className="bn-testcase__status">
562
+ <span>Status:</span>
563
+ <select value={status} onChange={handleStatusChange}>
564
+ {statusOptions.map((option) => (
565
+ <option key={option} value={option}>
566
+ {statusLabels[option]}
567
+ </option>
568
+ ))}
569
+ </select>
570
+ </label>
571
+ </div>
572
+ <div className="bn-testcase__body" ref={contentRef} />
573
+ </div>
574
+ );
575
+ },
576
+ },
577
+ );
578
+
579
+ export const customSchema = BlockNoteSchema.create({
580
+ blockSpecs: {
581
+ ...defaultBlockSpecs,
582
+ testCase: testCaseBlock,
583
+ testStep: testStepBlock,
584
+ },
585
+ });
586
+
587
+ export type CustomSchema = typeof customSchema;
588
+ export type CustomBlock = CustomSchema["Block"];
589
+ export type CustomEditor = CustomSchema["BlockNoteEditor"];
590
+
591
+ export const __markdownTestUtils = {
592
+ markdownToHtml,
593
+ htmlToMarkdown,
594
+ };