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.
- package/README.md +13 -6
- package/package/editor/blocks/markdown.d.ts +5 -0
- package/package/editor/blocks/markdown.js +160 -0
- package/package/editor/blocks/snippet.d.ts +38 -0
- package/package/editor/blocks/snippet.js +65 -0
- package/package/editor/blocks/step.d.ts +32 -0
- package/package/editor/blocks/step.js +97 -0
- package/package/editor/blocks/stepField.d.ts +26 -0
- package/package/editor/blocks/stepField.js +316 -0
- package/package/editor/customMarkdownConverter.js +111 -80
- package/package/editor/customSchema.d.ts +31 -45
- package/package/editor/customSchema.js +6 -616
- package/package/editor/snippetAutocomplete.d.ts +28 -0
- package/package/editor/snippetAutocomplete.js +94 -0
- package/package/editor/stepAutocomplete.d.ts +1 -1
- package/package/editor/stepAutocomplete.js +1 -1
- package/package/index.d.ts +1 -1
- package/package/index.js +1 -1
- package/package/styles.css +57 -0
- package/package.json +1 -1
- package/src/App.tsx +143 -41
- package/src/editor/blocks/blocks.test.ts +22 -0
- package/src/editor/blocks/markdown.ts +199 -0
- package/src/editor/blocks/snippet.tsx +109 -0
- package/src/editor/blocks/step.tsx +175 -0
- package/src/editor/blocks/stepField.tsx +487 -0
- package/src/editor/customMarkdownConverter.test.ts +121 -36
- package/src/editor/customMarkdownConverter.ts +128 -85
- package/src/editor/customSchema.tsx +6 -935
- package/src/editor/snippetAutocomplete.test.ts +54 -0
- package/src/editor/snippetAutocomplete.ts +133 -0
- package/src/editor/stepAutocomplete.test.ts +3 -3
- package/src/editor/stepAutocomplete.tsx +1 -1
- package/src/editor/styles.css +57 -0
- package/src/index.ts +1 -1
- package/src/editor/customSchema.test.ts +0 -47
|
@@ -1,943 +1,14 @@
|
|
|
1
|
-
import { defaultBlockSpecs
|
|
1
|
+
import { defaultBlockSpecs } from "@blocknote/core";
|
|
2
2
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
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, "<")
|
|
22
|
-
.replace(/>/g, ">")
|
|
23
|
-
.replace(/\"/g, """)
|
|
24
|
-
.replace(/'/g, "'");
|
|
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 ``;
|
|
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 ``;
|
|
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` : ``;
|
|
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
|
-
|
|
940
|
-
|
|
10
|
+
testStep: stepBlock,
|
|
11
|
+
snippet: snippetBlock,
|
|
941
12
|
},
|
|
942
13
|
});
|
|
943
14
|
|