testomatio-editor-blocks 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package/editor/blocks/snippet.js +42 -23
- package/package/editor/blocks/step.js +166 -35
- package/package/editor/blocks/stepField.d.ts +9 -1
- package/package/editor/blocks/stepField.js +664 -34
- package/package/editor/blocks/stepHorizontalView.d.ts +14 -0
- package/package/editor/blocks/stepHorizontalView.js +7 -0
- package/package/editor/blocks/useAutoResize.d.ts +8 -0
- package/package/editor/blocks/useAutoResize.js +31 -0
- package/package/editor/customMarkdownConverter.d.ts +1 -0
- package/package/editor/customMarkdownConverter.js +260 -31
- package/package/styles.css +706 -130
- package/package.json +9 -2
- package/src/App.tsx +1 -1
- package/src/editor/blocks/markdown.ts +27 -7
- package/src/editor/blocks/snippet.tsx +117 -61
- package/src/editor/blocks/step.tsx +325 -87
- package/src/editor/blocks/stepField.tsx +1396 -299
- package/src/editor/blocks/stepHorizontalView.tsx +90 -0
- package/src/editor/blocks/useAutoResize.ts +44 -0
- package/src/editor/customMarkdownConverter.test.ts +542 -3
- package/src/editor/customMarkdownConverter.ts +310 -36
- package/src/editor/customSchema.test.ts +35 -0
- package/src/editor/markdownToBlocks.test.ts +119 -0
- package/src/editor/styles.css +827 -128
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testomatio-editor-blocks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./package/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build:package": "node scripts/build-package.mjs",
|
|
30
30
|
"lint": "eslint .",
|
|
31
31
|
"test": "vitest",
|
|
32
|
-
"test:run": "vitest run",
|
|
32
|
+
"test:run": "vitest run --reporter=verbose",
|
|
33
33
|
"preview": "vite preview"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"testcases",
|
|
40
40
|
"test-automation"
|
|
41
41
|
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/testomatio/block-editor"
|
|
45
|
+
},
|
|
42
46
|
"peerDependencies": {
|
|
43
47
|
"@blocknote/core": "^0.31.3",
|
|
44
48
|
"@blocknote/react": "^0.31.3",
|
|
@@ -64,5 +68,8 @@
|
|
|
64
68
|
"typescript-eslint": "^8.45.0",
|
|
65
69
|
"vite": "^7.1.7",
|
|
66
70
|
"vitest": "^3.2.4"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"overtype": "^2.0.6"
|
|
67
74
|
}
|
|
68
75
|
}
|
package/src/App.tsx
CHANGED
|
@@ -664,7 +664,7 @@ function App() {
|
|
|
664
664
|
<h2>Autocomplete Steps</h2>
|
|
665
665
|
</div>
|
|
666
666
|
<p className="app__panel-text">
|
|
667
|
-
|
|
667
|
+
Markdown format supported
|
|
668
668
|
</p>
|
|
669
669
|
<ol className="app__step-list">
|
|
670
670
|
{(DEMO_STEP_FIXTURES.data ?? []).map((step) => (
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
2
2
|
const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
|
|
3
|
+
const INLINE_SEGMENT_REGEX =
|
|
4
|
+
/(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
|
|
3
5
|
|
|
4
6
|
export function escapeHtml(text: string): string {
|
|
5
7
|
return text
|
|
@@ -29,13 +31,19 @@ function parseInlineMarkdown(text: string): InlineSegment[] {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
|
|
32
|
-
const rawSegments = normalized
|
|
33
|
-
.split(/(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/)
|
|
34
|
-
.filter(Boolean);
|
|
34
|
+
const rawSegments = normalized.split(INLINE_SEGMENT_REGEX).filter(Boolean);
|
|
35
35
|
|
|
36
36
|
return rawSegments.map((segment) => {
|
|
37
37
|
const baseStyles = { bold: false, italic: false, underline: false };
|
|
38
38
|
|
|
39
|
+
if (/^\*\*\*(.+)\*\*\*$/.test(segment) || /^___(.+)___$/.test(segment)) {
|
|
40
|
+
const content = segment.slice(3, -3);
|
|
41
|
+
return {
|
|
42
|
+
text: restoreEscapes(content),
|
|
43
|
+
styles: { bold: true, italic: true, underline: false },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
|
|
40
48
|
const content = segment.slice(2, -2);
|
|
41
49
|
return {
|
|
@@ -200,10 +208,22 @@ export function htmlToMarkdown(html: string): string {
|
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
function cleanupEscapedFormatting(markdown: string): string {
|
|
203
|
-
return markdown.replace(
|
|
204
|
-
if (
|
|
205
|
-
return
|
|
211
|
+
return markdown.replace(/(\\+)([*_]+)/g, (_match, slashes, markers) => {
|
|
212
|
+
if (markers.length === 0) {
|
|
213
|
+
return slashes + markers;
|
|
214
|
+
}
|
|
215
|
+
const shouldClean =
|
|
216
|
+
markers.length === 3 ||
|
|
217
|
+
markers.length === 2 ||
|
|
218
|
+
markers.length === 1;
|
|
219
|
+
if (!shouldClean) {
|
|
220
|
+
return slashes + markers;
|
|
206
221
|
}
|
|
207
|
-
|
|
222
|
+
const hasPrintable = slashes.length % 2 === 0;
|
|
223
|
+
return hasPrintable ? markers : slashes + markers;
|
|
208
224
|
});
|
|
209
225
|
}
|
|
226
|
+
|
|
227
|
+
export const __markdownStringUtils = {
|
|
228
|
+
cleanupEscapedFormatting,
|
|
229
|
+
};
|
|
@@ -1,8 +1,105 @@
|
|
|
1
1
|
import { createReactBlockSpec } from "@blocknote/react";
|
|
2
|
-
import { useCallback } from "react";
|
|
3
|
-
import {
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { ChangeEvent } from "react";
|
|
4
4
|
import { useSnippetAutocomplete, type SnippetSuggestion } from "../snippetAutocomplete";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
type SnippetDropdownProps = {
|
|
7
|
+
value: string;
|
|
8
|
+
placeholder: string;
|
|
9
|
+
suggestions: SnippetSuggestion[];
|
|
10
|
+
onSelect: (suggestion: SnippetSuggestion) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function SnippetDropdown({ value, placeholder, suggestions, onSelect }: SnippetDropdownProps) {
|
|
14
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
15
|
+
const [search, setSearch] = useState("");
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isOpen) return;
|
|
21
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
22
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
23
|
+
setIsOpen(false);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
27
|
+
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
28
|
+
}, [isOpen]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isOpen) {
|
|
32
|
+
setSearch("");
|
|
33
|
+
requestAnimationFrame(() => searchRef.current?.focus());
|
|
34
|
+
}
|
|
35
|
+
}, [isOpen]);
|
|
36
|
+
|
|
37
|
+
const filtered = useMemo(() => {
|
|
38
|
+
const snippets = suggestions.filter((s) => s.isSnippet === true);
|
|
39
|
+
if (!search) return snippets;
|
|
40
|
+
const lower = search.toLowerCase();
|
|
41
|
+
return snippets.filter((s) => s.title.toLowerCase().includes(lower));
|
|
42
|
+
}, [suggestions, search]);
|
|
43
|
+
|
|
44
|
+
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
45
|
+
setSearch(event.target.value);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="bn-snippet-dropdown" ref={containerRef}>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className="bn-snippet-dropdown__trigger"
|
|
53
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
54
|
+
>
|
|
55
|
+
<span className="bn-snippet-dropdown__text">
|
|
56
|
+
{value || placeholder}
|
|
57
|
+
</span>
|
|
58
|
+
<svg className="bn-snippet-dropdown__chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
59
|
+
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
60
|
+
</svg>
|
|
61
|
+
</button>
|
|
62
|
+
{isOpen && (
|
|
63
|
+
<div className="bn-snippet-dropdown__panel" role="listbox">
|
|
64
|
+
<div className="bn-snippet-dropdown__search">
|
|
65
|
+
<svg className="bn-snippet-dropdown__search-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
66
|
+
<path d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z" fill="currentColor"/>
|
|
67
|
+
</svg>
|
|
68
|
+
<input
|
|
69
|
+
ref={searchRef}
|
|
70
|
+
type="text"
|
|
71
|
+
className="bn-snippet-dropdown__search-input"
|
|
72
|
+
placeholder="Search"
|
|
73
|
+
value={search}
|
|
74
|
+
onChange={handleSearchChange}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="bn-snippet-dropdown__list">
|
|
78
|
+
{filtered.map((suggestion) => (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
key={suggestion.id}
|
|
82
|
+
role="option"
|
|
83
|
+
className="bn-snippet-dropdown__item"
|
|
84
|
+
onMouseDown={(event) => {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
onSelect(suggestion);
|
|
87
|
+
setIsOpen(false);
|
|
88
|
+
}}
|
|
89
|
+
tabIndex={-1}
|
|
90
|
+
>
|
|
91
|
+
{suggestion.title}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
{filtered.length === 0 && (
|
|
95
|
+
<div className="bn-snippet-dropdown__empty">No snippets found</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
6
103
|
|
|
7
104
|
export const snippetBlock = createReactBlockSpec(
|
|
8
105
|
{
|
|
@@ -27,42 +124,14 @@ export const snippetBlock = createReactBlockSpec(
|
|
|
27
124
|
render: ({ block, editor }) => {
|
|
28
125
|
const snippetTitle = (block.props.snippetTitle as string) || "";
|
|
29
126
|
const snippetData = (block.props.snippetData as string) || "";
|
|
127
|
+
const snippetId = (block.props.snippetId as string) || "";
|
|
30
128
|
const snippetSuggestions = useSnippetAutocomplete();
|
|
31
129
|
const hasSnippets = snippetSuggestions.length > 0;
|
|
32
|
-
|
|
33
|
-
const handleSnippetChange = useCallback(
|
|
34
|
-
(nextTitle: string) => {
|
|
35
|
-
if (nextTitle === snippetTitle) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
editor.updateBlock(block.id, {
|
|
40
|
-
props: {
|
|
41
|
-
snippetTitle: nextTitle,
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
},
|
|
45
|
-
[block.id, editor, snippetTitle],
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
const handleSnippetDataChange = useCallback(
|
|
49
|
-
(next: string) => {
|
|
50
|
-
if (next === snippetData) {
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
editor.updateBlock(block.id, {
|
|
55
|
-
props: {
|
|
56
|
-
snippetData: next,
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
},
|
|
60
|
-
[editor, block.id, snippetData],
|
|
61
|
-
);
|
|
130
|
+
const isSnippetSelected = snippetId.length > 0;
|
|
62
131
|
|
|
63
132
|
const handleSnippetSelect = useCallback(
|
|
64
|
-
(suggestion: SnippetSuggestion
|
|
65
|
-
const rawBody =
|
|
133
|
+
(suggestion: SnippetSuggestion) => {
|
|
134
|
+
const rawBody = suggestion.body ?? "";
|
|
66
135
|
const sanitizedBody = rawBody
|
|
67
136
|
.split(/\r?\n/)
|
|
68
137
|
.filter((line) => !/^<!--\s*(begin|end)\s+snippet/i.test(line.trim()))
|
|
@@ -91,32 +160,19 @@ export const snippetBlock = createReactBlockSpec(
|
|
|
91
160
|
}
|
|
92
161
|
|
|
93
162
|
return (
|
|
94
|
-
<div className="bn-teststep bn-snippet" data-block-id={block.id}>
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
enableImageUpload={false}
|
|
108
|
-
onFieldFocus={handleFieldFocus}
|
|
109
|
-
/>
|
|
110
|
-
<StepField
|
|
111
|
-
label="Snippet Data"
|
|
112
|
-
value={snippetData}
|
|
113
|
-
placeholder="Add optional data or assets for the snippet"
|
|
114
|
-
onChange={handleSnippetDataChange}
|
|
115
|
-
multiline
|
|
116
|
-
fieldName="snippet-data"
|
|
117
|
-
enableImageUpload
|
|
118
|
-
onFieldFocus={handleFieldFocus}
|
|
119
|
-
/>
|
|
163
|
+
<div className="bn-teststep bn-snippet" data-block-id={block.id} onFocus={handleFieldFocus}>
|
|
164
|
+
<div className="bn-snippet__header">
|
|
165
|
+
<span className="bn-snippet__label">Snippet</span>
|
|
166
|
+
<SnippetDropdown
|
|
167
|
+
value={snippetTitle}
|
|
168
|
+
placeholder="Select Snippet"
|
|
169
|
+
suggestions={snippetSuggestions}
|
|
170
|
+
onSelect={handleSnippetSelect}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
{isSnippetSelected && (
|
|
174
|
+
<div className="bn-snippet__content">{snippetData}</div>
|
|
175
|
+
)}
|
|
120
176
|
</div>
|
|
121
177
|
);
|
|
122
178
|
},
|