testomatio-editor-blocks 0.2.3 → 0.3.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.
@@ -3,3 +3,8 @@ export declare function markdownToHtml(markdown: string): string;
3
3
  export declare function escapeMarkdownText(text: string): string;
4
4
  export declare function normalizePlainText(text: string): string;
5
5
  export declare function htmlToMarkdown(html: string): string;
6
+ declare function cleanupEscapedFormatting(markdown: string): string;
7
+ export declare const __markdownStringUtils: {
8
+ cleanupEscapedFormatting: typeof cleanupEscapedFormatting;
9
+ };
10
+ export {};
@@ -1,5 +1,6 @@
1
1
  const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
2
2
  const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
3
+ const INLINE_SEGMENT_REGEX = /(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
3
4
  export function escapeHtml(text) {
4
5
  return text
5
6
  .replace(/&/g, "&amp;")
@@ -16,11 +17,16 @@ function parseInlineMarkdown(text) {
16
17
  return [];
17
18
  }
18
19
  const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
19
- const rawSegments = normalized
20
- .split(/(\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/)
21
- .filter(Boolean);
20
+ const rawSegments = normalized.split(INLINE_SEGMENT_REGEX).filter(Boolean);
22
21
  return rawSegments.map((segment) => {
23
22
  const baseStyles = { bold: false, italic: false, underline: false };
23
+ if (/^\*\*\*(.+)\*\*\*$/.test(segment) || /^___(.+)___$/.test(segment)) {
24
+ const content = segment.slice(3, -3);
25
+ return {
26
+ text: restoreEscapes(content),
27
+ styles: { bold: true, italic: true, underline: false },
28
+ };
29
+ }
24
30
  if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
25
31
  const content = segment.slice(2, -2);
26
32
  return {
@@ -106,12 +112,13 @@ function fallbackHtmlToMarkdown(html) {
106
112
  .replace(/<\/span>/gi, "")
107
113
  .replace(/<u>(.*?)<\/u>/gis, (_m, content) => `<u>${content}</u>`);
108
114
  result = result.replace(/<\/?[^>]+>/g, "");
109
- return result
115
+ const markdown = result
110
116
  .split("\n")
111
117
  .map((line) => line.trimEnd())
112
118
  .join("\n")
113
119
  .replace(/\n{3,}/g, "\n\n")
114
120
  .trim();
121
+ return cleanupEscapedFormatting(markdown);
115
122
  }
116
123
  export function htmlToMarkdown(html) {
117
124
  if (typeof document === "undefined") {
@@ -156,5 +163,23 @@ export function htmlToMarkdown(html) {
156
163
  }
157
164
  };
158
165
  const markdown = Array.from(temp.childNodes).map(traverse).join("");
159
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
166
+ return cleanupEscapedFormatting(markdown).replace(/\n{3,}/g, "\n\n").trim();
167
+ }
168
+ function cleanupEscapedFormatting(markdown) {
169
+ return markdown.replace(/(\\+)([*_]+)/g, (_match, slashes, markers) => {
170
+ if (markers.length === 0) {
171
+ return slashes + markers;
172
+ }
173
+ const shouldClean = markers.length === 3 ||
174
+ markers.length === 2 ||
175
+ markers.length === 1;
176
+ if (!shouldClean) {
177
+ return slashes + markers;
178
+ }
179
+ const hasPrintable = slashes.length % 2 === 0;
180
+ return hasPrintable ? markers : slashes + markers;
181
+ });
160
182
  }
183
+ export const __markdownStringUtils = {
184
+ cleanupEscapedFormatting,
185
+ };
@@ -67,6 +67,6 @@ export const snippetBlock = createReactBlockSpec({
67
67
  if (!hasSnippets) {
68
68
  return (_jsx("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, children: _jsx("p", { className: "bn-snippet__empty", children: "No snippets in this project." }) }));
69
69
  }
70
- return (_jsxs("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, children: [_jsx(StepField, { label: "Snippet Title", value: snippetTitle, placeholder: "Describe the reusable action", onChange: handleSnippetChange, autoFocus: snippetTitle.length === 0, enableAutocomplete: true, suggestionFilter: (suggestion) => suggestion.isSnippet === true, suggestionsOverride: snippetSuggestions, onSuggestionSelect: handleSnippetSelect, fieldName: "snippet-title", showSuggestionsOnFocus: true, enableImageUpload: false, onFieldFocus: handleFieldFocus }), _jsx(StepField, { label: "Snippet Data", value: snippetData, placeholder: "Add optional data or assets for the snippet", onChange: handleSnippetDataChange, multiline: true, fieldName: "snippet-data", enableImageUpload: true, onFieldFocus: handleFieldFocus })] }));
70
+ return (_jsxs("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, children: [_jsx(StepField, { label: "Snippet Title", value: snippetTitle, onChange: handleSnippetChange, autoFocus: snippetTitle.length === 0, enableAutocomplete: true, suggestionFilter: (suggestion) => suggestion.isSnippet === true, suggestionsOverride: snippetSuggestions, onSuggestionSelect: handleSnippetSelect, fieldName: "snippet-title", showSuggestionsOnFocus: true, enableImageUpload: false, onFieldFocus: handleFieldFocus }), _jsx(StepField, { label: "Snippet Data", value: snippetData, onChange: handleSnippetDataChange, multiline: true, fieldName: "snippet-data", enableImageUpload: true, onFieldFocus: handleFieldFocus })] }));
71
71
  },
72
72
  });
@@ -88,7 +88,7 @@ export const stepBlock = createReactBlockSpec({
88
88
  const handleFieldFocus = useCallback(() => {
89
89
  editor.setSelection(block.id, block.id);
90
90
  }, [editor, block.id]);
91
- return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, children: [_jsx(StepField, { label: "Step Title", value: stepTitle, placeholder: "Describe the action to perform", onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, rightAction: !isDataVisible ? (_jsx("button", { type: "button", className: "bn-teststep__toggle", onClick: handleShowDataField, "aria-expanded": "false", tabIndex: -1, children: "+ Step Data" })) : null, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
91
+ return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, children: [_jsx(StepField, { label: "Step Title", value: stepTitle, onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, rightAction: !isDataVisible ? (_jsx("button", { type: "button", className: "bn-teststep__toggle", onClick: handleShowDataField, "aria-expanded": "false", tabIndex: -1, children: "+ Step Data" })) : null, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
92
92
  if (!uploadImage) {
93
93
  return;
94
94
  }
@@ -108,6 +108,6 @@ export const stepBlock = createReactBlockSpec({
108
108
  catch (error) {
109
109
  console.error("Failed to upload image to Step Data", error);
110
110
  }
111
- } }), isDataVisible && (_jsx(StepField, { label: "Step Data", value: stepData, placeholder: "Provide additional data about the step", onChange: handleStepDataChange, autoFocus: shouldFocusDataField, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })), showExpectedField && (_jsx(StepField, { label: "Expected Result", value: expectedResult, placeholder: "What should happen?", onChange: handleExpectedChange, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })), _jsx("button", { type: "button", className: "bn-step-add", onClick: handleInsertNextStep, children: "+ Step" })] }));
111
+ } }), isDataVisible && (_jsx(StepField, { label: "Step Data", value: stepData, onChange: handleStepDataChange, autoFocus: shouldFocusDataField, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })), showExpectedField && (_jsx(StepField, { label: "Expected Result", value: expectedResult, onChange: handleExpectedChange, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })), _jsx("button", { type: "button", className: "bn-step-add", onClick: handleInsertNextStep, children: "+ Step" })] }));
112
112
  },
113
113
  });
@@ -1,11 +1,10 @@
1
+ import type { ReactNode } from "react";
1
2
  import { type StepSuggestion } from "../stepAutocomplete";
2
3
  import { type SnippetSuggestion } from "../snippetAutocomplete";
3
- import type { ReactNode } from "react";
4
4
  type Suggestion = StepSuggestion | SnippetSuggestion;
5
5
  type StepFieldProps = {
6
6
  label: string;
7
7
  value: string;
8
- placeholder: string;
9
8
  onChange: (nextValue: string) => void;
10
9
  autoFocus?: boolean;
11
10
  multiline?: boolean;
@@ -23,5 +22,5 @@ type StepFieldProps = {
23
22
  showImageButton?: boolean;
24
23
  onFieldFocus?: () => void;
25
24
  };
26
- export declare function StepField({ label, value, placeholder, onChange, autoFocus, multiline, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, onFieldFocus, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
25
+ export declare function StepField({ label, value, onChange, autoFocus, multiline, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, onFieldFocus, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
27
26
  export {};
@@ -1,21 +1,239 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import OverType from "overtype";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
4
  import { useStepAutocomplete } from "../stepAutocomplete";
3
5
  import { useStepImageUpload } from "../stepImageUpload";
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
- import { escapeHtml, escapeMarkdownText, htmlToMarkdown, markdownToHtml, normalizePlainText, } from "./markdown";
6
- export function StepField({ label, value, placeholder, onChange, autoFocus, multiline = false, enableAutocomplete = false, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly = false, showSuggestionsOnFocus = false, enableImageUpload = false, onImageFile, rightAction, showFormattingButtons = false, showImageButton = false, onFieldFocus, }) {
7
- const editorRef = useRef(null);
8
- const [isFocused, setIsFocused] = useState(false);
9
- const autoFocusRef = useRef(false);
10
- const [plainTextValue, setPlainTextValue] = useState("");
11
- const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
12
- const [showAllSuggestions, setShowAllSuggestions] = useState(false);
6
+ import { escapeMarkdownText, normalizePlainText } from "./markdown";
7
+ const READ_ONLY_ALLOWED_KEYS = new Set([
8
+ "ArrowDown",
9
+ "ArrowUp",
10
+ "Enter",
11
+ "Tab",
12
+ ]);
13
+ const AUTOCOMPLETE_TRIGGER_KEYS = new Set([" ", "Space"]);
14
+ const markdownParser = OverType.MarkdownParser;
15
+ function markdownToPlainText(markdown) {
16
+ var _a;
17
+ if (!markdown) {
18
+ return "";
19
+ }
20
+ try {
21
+ const html = (markdownParser === null || markdownParser === void 0 ? void 0 : markdownParser.parse) ? markdownParser.parse(markdown) : markdown;
22
+ if (typeof document === "undefined") {
23
+ return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
24
+ }
25
+ const temp = document.createElement("div");
26
+ temp.innerHTML = html;
27
+ return ((_a = temp.textContent) !== null && _a !== void 0 ? _a : "").replace(/\s+/g, " ").trim();
28
+ }
29
+ catch {
30
+ return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[[^\]]*]\([^)]+\)/g, "").replace(/[*_`~]/g, "").replace(/\s+/g, " ").trim();
31
+ }
32
+ }
33
+ export function StepField({ label, value, onChange, autoFocus, multiline = false, enableAutocomplete = false, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly = false, showSuggestionsOnFocus = false, enableImageUpload = false, onImageFile, rightAction, showFormattingButtons = false, showImageButton = false, onFieldFocus, }) {
13
34
  const stepSuggestions = useStepAutocomplete();
14
35
  const suggestions = suggestionsOverride !== null && suggestionsOverride !== void 0 ? suggestionsOverride : stepSuggestions;
15
36
  const uploadImage = useStepImageUpload();
16
37
  const fileInputRef = useRef(null);
38
+ const editorContainerRef = useRef(null);
39
+ const editorInstanceRef = useRef(null);
40
+ const [textareaNode, setTextareaNode] = useState(null);
41
+ const autoFocusRef = useRef(false);
42
+ const pendingFocusRef = useRef(false);
43
+ const initialValueRef = useRef(value);
44
+ const onChangeRef = useRef(onChange);
45
+ const [plainTextValue, setPlainTextValue] = useState(() => markdownToPlainText(value));
46
+ const [isFocused, setIsFocused] = useState(false);
47
+ const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
48
+ const [showAllSuggestions, setShowAllSuggestions] = useState(false);
17
49
  const [isUploading, setIsUploading] = useState(false);
18
- const normalizedQuery = normalizePlainText(plainTextValue);
50
+ const [previewImageUrl, setPreviewImageUrl] = useState(null);
51
+ useEffect(() => {
52
+ onChangeRef.current = onChange;
53
+ }, [onChange]);
54
+ const handleEditorChange = useCallback((nextValue) => {
55
+ var _a;
56
+ setPlainTextValue((prev) => {
57
+ const normalized = markdownToPlainText(nextValue);
58
+ return prev === normalized ? prev : normalized;
59
+ });
60
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, nextValue);
61
+ }, []);
62
+ useEffect(() => {
63
+ const container = editorContainerRef.current;
64
+ if (!container) {
65
+ return;
66
+ }
67
+ const [instance] = OverType.init(container, {
68
+ value: initialValueRef.current,
69
+ autoResize: multiline,
70
+ minHeight: multiline ? "4rem" : "2.5rem",
71
+ padding: "0.5rem 0.75rem",
72
+ fontSize: "0.95rem",
73
+ onChange: handleEditorChange,
74
+ });
75
+ editorInstanceRef.current = instance;
76
+ setTextareaNode(instance.textarea);
77
+ return () => {
78
+ instance.destroy();
79
+ editorInstanceRef.current = null;
80
+ setTextareaNode(null);
81
+ };
82
+ }, [handleEditorChange, multiline]);
83
+ useEffect(() => {
84
+ if (pendingFocusRef.current && textareaNode) {
85
+ pendingFocusRef.current = false;
86
+ textareaNode.focus();
87
+ }
88
+ }, [textareaNode]);
89
+ useEffect(() => {
90
+ const instance = editorInstanceRef.current;
91
+ if (!instance) {
92
+ setPlainTextValue((prev) => {
93
+ const normalized = markdownToPlainText(value);
94
+ return prev === normalized ? prev : normalized;
95
+ });
96
+ return;
97
+ }
98
+ if (instance.getValue() !== value) {
99
+ instance.setValue(value);
100
+ }
101
+ setPlainTextValue((prev) => {
102
+ const normalized = markdownToPlainText(value);
103
+ return prev === normalized ? prev : normalized;
104
+ });
105
+ }, [value]);
106
+ useEffect(() => {
107
+ if (!textareaNode) {
108
+ return;
109
+ }
110
+ if (fieldName) {
111
+ textareaNode.dataset.stepField = fieldName;
112
+ }
113
+ else {
114
+ delete textareaNode.dataset.stepField;
115
+ }
116
+ }, [fieldName, textareaNode]);
117
+ useEffect(() => {
118
+ if (!textareaNode) {
119
+ return;
120
+ }
121
+ textareaNode.readOnly = readOnly;
122
+ }, [readOnly, textareaNode]);
123
+ useEffect(() => {
124
+ if (!textareaNode) {
125
+ return;
126
+ }
127
+ const handleFocus = () => {
128
+ setIsFocused(true);
129
+ if (showSuggestionsOnFocus && enableAutocomplete) {
130
+ setShowAllSuggestions(true);
131
+ }
132
+ onFieldFocus === null || onFieldFocus === void 0 ? void 0 : onFieldFocus();
133
+ };
134
+ const handleBlur = () => {
135
+ setIsFocused(false);
136
+ setShowAllSuggestions(false);
137
+ };
138
+ textareaNode.addEventListener("focus", handleFocus);
139
+ textareaNode.addEventListener("blur", handleBlur);
140
+ return () => {
141
+ textareaNode.removeEventListener("focus", handleFocus);
142
+ textareaNode.removeEventListener("blur", handleBlur);
143
+ };
144
+ }, [enableAutocomplete, onFieldFocus, showSuggestionsOnFocus, textareaNode]);
145
+ useEffect(() => {
146
+ if (!autoFocus || autoFocusRef.current || !textareaNode) {
147
+ return;
148
+ }
149
+ autoFocusRef.current = true;
150
+ const focus = () => {
151
+ textareaNode.focus();
152
+ if (showSuggestionsOnFocus && enableAutocomplete) {
153
+ setShowAllSuggestions(true);
154
+ }
155
+ };
156
+ if (typeof requestAnimationFrame === "function") {
157
+ const frame = requestAnimationFrame(focus);
158
+ return () => cancelAnimationFrame(frame);
159
+ }
160
+ const timeout = setTimeout(focus, 0);
161
+ return () => clearTimeout(timeout);
162
+ }, [autoFocus, enableAutocomplete, showSuggestionsOnFocus, textareaNode]);
163
+ const insertImageMarkdown = useCallback((url) => {
164
+ var _a, _b, _c;
165
+ const instance = editorInstanceRef.current;
166
+ const textarea = textareaNode;
167
+ if (!instance || !textarea) {
168
+ return;
169
+ }
170
+ const currentValue = instance.getValue();
171
+ const start = (_a = textarea.selectionStart) !== null && _a !== void 0 ? _a : currentValue.length;
172
+ const end = (_b = textarea.selectionEnd) !== null && _b !== void 0 ? _b : currentValue.length;
173
+ const before = currentValue.slice(0, start);
174
+ const after = currentValue.slice(end);
175
+ const needsBeforeNewline = before.length > 0 && !before.endsWith("\n");
176
+ const needsAfterNewline = after.length > 0 && !after.startsWith("\n");
177
+ const insertText = `${needsBeforeNewline ? "\n" : ""}![](${url})${needsAfterNewline ? "\n" : ""}`;
178
+ const nextValue = `${before}${insertText}${after}`;
179
+ instance.setValue(nextValue);
180
+ setPlainTextValue(markdownToPlainText(nextValue));
181
+ (_c = onChangeRef.current) === null || _c === void 0 ? void 0 : _c.call(onChangeRef, nextValue);
182
+ requestAnimationFrame(() => {
183
+ textarea.selectionStart = start + insertText.length;
184
+ textarea.selectionEnd = start + insertText.length;
185
+ textarea.focus();
186
+ });
187
+ }, [textareaNode]);
188
+ useEffect(() => {
189
+ if (!textareaNode) {
190
+ return;
191
+ }
192
+ const handlePaste = async (event) => {
193
+ var _a, _b;
194
+ if (!onImageFile && !(enableImageUpload && uploadImage)) {
195
+ return;
196
+ }
197
+ const items = Array.from((_b = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.items) !== null && _b !== void 0 ? _b : []);
198
+ const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
199
+ const file = imageItem === null || imageItem === void 0 ? void 0 : imageItem.getAsFile();
200
+ if (!file) {
201
+ return;
202
+ }
203
+ event.preventDefault();
204
+ if (onImageFile) {
205
+ await onImageFile(file);
206
+ return;
207
+ }
208
+ if (enableImageUpload && uploadImage) {
209
+ try {
210
+ const result = await uploadImage(file);
211
+ if (result === null || result === void 0 ? void 0 : result.url) {
212
+ insertImageMarkdown(result.url);
213
+ }
214
+ }
215
+ catch (error) {
216
+ console.error("Failed to upload pasted image", error);
217
+ }
218
+ }
219
+ };
220
+ const listener = (event) => {
221
+ void handlePaste(event);
222
+ };
223
+ textareaNode.addEventListener("paste", listener);
224
+ return () => {
225
+ textareaNode.removeEventListener("paste", listener);
226
+ };
227
+ }, [enableImageUpload, insertImageMarkdown, onImageFile, textareaNode, uploadImage]);
228
+ const handleToolbarAction = useCallback((action) => {
229
+ var _a;
230
+ const shortcuts = (_a = editorInstanceRef.current) === null || _a === void 0 ? void 0 : _a.shortcuts;
231
+ if (!textareaNode || !(shortcuts === null || shortcuts === void 0 ? void 0 : shortcuts.handleAction)) {
232
+ return;
233
+ }
234
+ textareaNode.focus();
235
+ shortcuts.handleAction(action);
236
+ }, [textareaNode]);
19
237
  const suggestionPool = useMemo(() => {
20
238
  if (!suggestionFilter) {
21
239
  return suggestions;
@@ -23,6 +241,12 @@ export function StepField({ label, value, placeholder, onChange, autoFocus, mult
23
241
  const filtered = suggestions.filter(suggestionFilter);
24
242
  return filtered.length > 0 ? filtered : suggestions;
25
243
  }, [suggestionFilter, suggestions]);
244
+ const normalizedQuery = normalizePlainText(plainTextValue);
245
+ useEffect(() => {
246
+ if (normalizedQuery.length > 0) {
247
+ setShowAllSuggestions(false);
248
+ }
249
+ }, [normalizedQuery]);
26
250
  const filteredSuggestions = useMemo(() => {
27
251
  if (!enableAutocomplete) {
28
252
  return [];
@@ -41,169 +265,164 @@ export function StepField({ label, value, placeholder, onChange, autoFocus, mult
41
265
  useEffect(() => {
42
266
  setActiveSuggestionIndex(0);
43
267
  }, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]);
44
- useEffect(() => {
45
- if (normalizedQuery.length > 0) {
46
- setShowAllSuggestions(false);
268
+ const extractedImages = useMemo(() => {
269
+ if (!value) {
270
+ return [];
47
271
  }
48
- }, [normalizedQuery]);
49
- useEffect(() => {
272
+ const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
273
+ const results = [];
274
+ let match;
275
+ while ((match = regex.exec(value)) !== null) {
276
+ const [, alt = "", url = ""] = match;
277
+ results.push({
278
+ id: `${match.index}-${url}-${results.length}`,
279
+ url,
280
+ alt,
281
+ start: match.index,
282
+ end: match.index + match[0].length,
283
+ markdown: match[0],
284
+ });
285
+ }
286
+ return results;
287
+ }, [value]);
288
+ const handleRemoveImage = useCallback((image) => {
50
289
  var _a;
51
- const element = editorRef.current;
52
- if (!element || isFocused) {
53
- return;
290
+ const before = value.slice(0, image.start);
291
+ const after = value.slice(image.end);
292
+ const nextValue = `${before}${after}`.replace(/\n{3,}/g, "\n\n");
293
+ if (editorInstanceRef.current) {
294
+ editorInstanceRef.current.setValue(nextValue);
54
295
  }
55
- if (value.trim().length === 0) {
56
- element.innerHTML = "";
57
- setPlainTextValue("");
296
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, nextValue);
297
+ setPlainTextValue(markdownToPlainText(nextValue));
298
+ setPreviewImageUrl((prev) => (prev === image.url ? null : prev));
299
+ }, [value]);
300
+ const handleImageClick = useCallback((url) => {
301
+ setPreviewImageUrl(url);
302
+ }, []);
303
+ const focusAdjacentField = useCallback((direction) => {
304
+ if (!textareaNode || typeof document === "undefined") {
305
+ return false;
58
306
  }
59
- else {
60
- element.innerHTML = markdownToHtml(value);
61
- setPlainTextValue((_a = element.textContent) !== null && _a !== void 0 ? _a : "");
307
+ const selector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"], [data-step-field]';
308
+ const focusable = Array.from(document.querySelectorAll(selector)).filter((element) => {
309
+ if (element.getAttribute("aria-hidden") === "true" || element.tabIndex === -1 || element.hasAttribute("disabled")) {
310
+ return false;
311
+ }
312
+ const isVisible = element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
313
+ return isVisible;
314
+ });
315
+ const currentIndex = focusable.findIndex((element) => element === textareaNode);
316
+ const target = currentIndex === -1 ? null : focusable[currentIndex + direction];
317
+ if (!target) {
318
+ return false;
62
319
  }
63
- }, [value, isFocused]);
64
- const syncValue = useCallback(() => {
320
+ target.focus();
321
+ return true;
322
+ }, [textareaNode]);
323
+ const applySuggestion = useCallback((suggestion) => {
65
324
  var _a;
66
- const element = editorRef.current;
67
- if (!element) {
68
- return;
69
- }
70
- const markdown = htmlToMarkdown(element.innerHTML);
71
- if (markdown !== value) {
72
- onChange(markdown);
73
- }
74
- setPlainTextValue((_a = element.innerText) !== null && _a !== void 0 ? _a : "");
75
- if (!markdown && element.innerHTML !== "") {
76
- element.innerHTML = "";
325
+ const escaped = escapeMarkdownText(suggestion.title);
326
+ const instance = editorInstanceRef.current;
327
+ if (instance) {
328
+ instance.setValue(escaped);
77
329
  }
78
- }, [onChange, value]);
330
+ setPlainTextValue(suggestion.title);
331
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, escaped);
332
+ onSuggestionSelect === null || onSuggestionSelect === void 0 ? void 0 : onSuggestionSelect(suggestion);
333
+ setActiveSuggestionIndex(0);
334
+ setShowAllSuggestions(false);
335
+ requestAnimationFrame(() => {
336
+ textareaNode === null || textareaNode === void 0 ? void 0 : textareaNode.focus();
337
+ if (textareaNode) {
338
+ textareaNode.selectionStart = escaped.length;
339
+ textareaNode.selectionEnd = escaped.length;
340
+ }
341
+ });
342
+ }, [onSuggestionSelect, textareaNode]);
343
+ const keydownHandlerRef = useRef(null);
79
344
  useEffect(() => {
80
- if (!autoFocus || autoFocusRef.current || !editorRef.current) {
81
- return;
82
- }
83
- autoFocusRef.current = true;
84
- const element = editorRef.current;
85
- const focusElement = () => {
345
+ keydownHandlerRef.current = (event) => {
86
346
  var _a;
87
- element.focus();
88
- setIsFocused(true);
89
- if (showSuggestionsOnFocus && enableAutocomplete) {
90
- setShowAllSuggestions(true);
91
- }
92
- const selection = typeof window !== "undefined" ? (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window) : null;
93
- if (selection) {
94
- selection.selectAllChildren(element);
95
- selection.collapseToEnd();
347
+ if (readOnly) {
348
+ const openKeys = enableAutocomplete && (event.metaKey || event.ctrlKey) && AUTOCOMPLETE_TRIGGER_KEYS.has(event.code);
349
+ if (!READ_ONLY_ALLOWED_KEYS.has(event.key) && !openKeys) {
350
+ event.preventDefault();
351
+ return;
352
+ }
96
353
  }
97
- };
98
- if (typeof requestAnimationFrame === "function") {
99
- const frame = requestAnimationFrame(focusElement);
100
- return () => cancelAnimationFrame(frame);
101
- }
102
- const timeout = setTimeout(focusElement, 0);
103
- return () => clearTimeout(timeout);
104
- }, [autoFocus, enableAutocomplete, showSuggestionsOnFocus]);
105
- const ensureCaretInEditor = useCallback(() => {
106
- var _a;
107
- const element = editorRef.current;
108
- if (!element) {
109
- return false;
110
- }
111
- const selection = (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window);
112
- if (!selection) {
113
- return false;
114
- }
115
- if (selection.rangeCount === 0 || !element.contains(selection.anchorNode)) {
116
- const range = document.createRange();
117
- range.selectNodeContents(element);
118
- range.collapse(false);
119
- selection.removeAllRanges();
120
- selection.addRange(range);
121
- }
122
- element.focus();
123
- return true;
124
- }, []);
125
- const handlePaste = useCallback(async (event) => {
126
- var _a, _b, _c, _d, _e;
127
- if ((enableImageUpload && uploadImage) || onImageFile) {
128
- const items = Array.from((_a = event.clipboardData.items) !== null && _a !== void 0 ? _a : []);
129
- const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
130
- const file = imageItem === null || imageItem === void 0 ? void 0 : imageItem.getAsFile();
131
- if (file) {
132
- event.preventDefault();
133
- if (onImageFile) {
134
- await onImageFile(file);
354
+ if (enableAutocomplete && shouldShowAutocomplete) {
355
+ if (event.key === "ArrowDown") {
356
+ event.preventDefault();
357
+ setActiveSuggestionIndex((prev) => prev + 1 >= filteredSuggestions.length ? 0 : prev + 1);
135
358
  return;
136
359
  }
137
- if (enableImageUpload && uploadImage) {
138
- try {
139
- const result = await uploadImage(file);
140
- if (result === null || result === void 0 ? void 0 : result.url) {
141
- ensureCaretInEditor();
142
- const needsBreak = ((_c = (_b = editorRef.current) === null || _b === void 0 ? void 0 : _b.innerHTML) !== null && _c !== void 0 ? _c : "").trim().length > 0;
143
- const imgHtml = (needsBreak ? "<br />" : "") +
144
- `<img src="${escapeHtml(result.url)}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
145
- document.execCommand("insertHTML", false, imgHtml);
146
- syncValue();
147
- }
148
- }
149
- catch (error) {
150
- console.error("Failed to upload image from paste", error);
360
+ if (event.key === "ArrowUp") {
361
+ event.preventDefault();
362
+ setActiveSuggestionIndex((prev) => prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1);
363
+ return;
364
+ }
365
+ if (event.key === "Enter" || event.key === "Tab") {
366
+ event.preventDefault();
367
+ const suggestion = (_a = filteredSuggestions[activeSuggestionIndex]) !== null && _a !== void 0 ? _a : filteredSuggestions[0];
368
+ if (suggestion) {
369
+ applySuggestion(suggestion);
151
370
  }
152
371
  return;
153
372
  }
154
373
  }
155
- }
156
- event.preventDefault();
157
- const text = (_e = (_d = event.clipboardData) === null || _d === void 0 ? void 0 : _d.getData("text/plain")) !== null && _e !== void 0 ? _e : "";
158
- const html = markdownToHtml(text);
159
- ensureCaretInEditor();
160
- document.execCommand("insertHTML", false, html);
161
- syncValue();
162
- }, [enableImageUpload, ensureCaretInEditor, onImageFile, syncValue, uploadImage]);
163
- const applySuggestion = useCallback((suggestion) => {
164
- var _a;
165
- const escaped = escapeMarkdownText(suggestion.title);
166
- onChange(escaped);
167
- onSuggestionSelect === null || onSuggestionSelect === void 0 ? void 0 : onSuggestionSelect(suggestion);
168
- setPlainTextValue(suggestion.title);
169
- setActiveSuggestionIndex(0);
170
- setShowAllSuggestions(false);
171
- if (editorRef.current) {
172
- editorRef.current.innerHTML = markdownToHtml(escaped);
173
- editorRef.current.focus();
174
- const selection = typeof window !== "undefined" ? (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window) : null;
175
- if (selection && editorRef.current.firstChild) {
176
- const range = document.createRange();
177
- range.selectNodeContents(editorRef.current);
178
- range.collapse(false);
179
- selection.removeAllRanges();
180
- selection.addRange(range);
374
+ if (enableAutocomplete &&
375
+ (event.metaKey || event.ctrlKey) &&
376
+ (AUTOCOMPLETE_TRIGGER_KEYS.has(event.code) || AUTOCOMPLETE_TRIGGER_KEYS.has(event.key))) {
377
+ event.preventDefault();
378
+ setShowAllSuggestions(true);
379
+ return;
380
+ }
381
+ if (event.key === "Tab") {
382
+ const moved = focusAdjacentField(event.shiftKey ? -1 : 1);
383
+ if (moved) {
384
+ event.preventDefault();
385
+ event.stopImmediatePropagation();
386
+ }
181
387
  }
388
+ };
389
+ }, [activeSuggestionIndex, applySuggestion, enableAutocomplete, filteredSuggestions, focusAdjacentField, readOnly, shouldShowAutocomplete]);
390
+ useEffect(() => {
391
+ if (!textareaNode) {
392
+ return;
182
393
  }
183
- }, [onChange, onSuggestionSelect]);
394
+ const listener = (event) => {
395
+ var _a;
396
+ (_a = keydownHandlerRef.current) === null || _a === void 0 ? void 0 : _a.call(keydownHandlerRef, event);
397
+ };
398
+ const keydownOptions = { capture: true };
399
+ textareaNode.addEventListener("keydown", listener, keydownOptions);
400
+ return () => {
401
+ textareaNode.removeEventListener("keydown", listener, keydownOptions);
402
+ };
403
+ }, [textareaNode]);
404
+ const editorClassName = [
405
+ "bn-step-editor",
406
+ multiline ? "bn-step-editor--multiline" : "",
407
+ isFocused ? "bn-step-editor--focused" : "",
408
+ readOnly ? "bn-step-editor--readonly" : "",
409
+ ]
410
+ .filter(Boolean)
411
+ .join(" ");
184
412
  return (_jsxs("div", { className: "bn-step-field", children: [_jsxs("div", { className: "bn-step-field__top", children: [_jsxs("span", { className: "bn-step-field__label", children: [label, enableAutocomplete && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
185
- var _a;
186
413
  event.preventDefault();
187
414
  setShowAllSuggestions(true);
188
- (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
415
+ textareaNode === null || textareaNode === void 0 ? void 0 : textareaNode.focus();
189
416
  }, "aria-label": "Show suggestions", tabIndex: -1, children: "\u2304" }))] }), _jsxs("div", { className: "bn-step-toolbar", "aria-label": `${label} controls`, children: [showFormattingButtons && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
190
- var _a;
191
417
  event.preventDefault();
192
- (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
193
- document.execCommand("bold");
194
- syncValue();
418
+ handleToolbarAction("toggleBold");
195
419
  }, "aria-label": "Bold", tabIndex: -1, children: "B" }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
196
- var _a;
197
420
  event.preventDefault();
198
- (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
199
- document.execCommand("italic");
200
- syncValue();
421
+ handleToolbarAction("toggleItalic");
201
422
  }, "aria-label": "Italic", tabIndex: -1, children: "I" })] })), enableImageUpload && uploadImage && showImageButton && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
423
+ var _a;
202
424
  event.preventDefault();
203
- const input = fileInputRef.current;
204
- if (input) {
205
- input.click();
206
- }
425
+ (_a = fileInputRef.current) === null || _a === void 0 ? void 0 : _a.click();
207
426
  }, "aria-label": "Insert image", tabIndex: -1, disabled: isUploading, children: "Img" })), rightAction] })] }), enableImageUpload && (_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", style: { display: "none" }, onChange: async (event) => {
208
427
  var _a;
209
428
  const file = (_a = event.target.files) === null || _a === void 0 ? void 0 : _a[0];
@@ -214,17 +433,7 @@ export function StepField({ label, value, placeholder, onChange, autoFocus, mult
214
433
  setIsUploading(true);
215
434
  const response = await uploadImage(file);
216
435
  if (response === null || response === void 0 ? void 0 : response.url) {
217
- const element = editorRef.current;
218
- if (element) {
219
- const escapedUrl = escapeHtml(response.url);
220
- const needsBreak = element.innerHTML.trim().length > 0;
221
- const imgHtml = (needsBreak ? "<br />" : "") +
222
- `<img src="${escapedUrl}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
223
- element.focus();
224
- ensureCaretInEditor();
225
- document.execCommand("insertHTML", false, imgHtml);
226
- syncValue();
227
- }
436
+ insertImageMarkdown(response.url);
228
437
  }
229
438
  }
230
439
  catch (error) {
@@ -234,84 +443,22 @@ export function StepField({ label, value, placeholder, onChange, autoFocus, mult
234
443
  setIsUploading(false);
235
444
  event.target.value = "";
236
445
  }
237
- } })), _jsx("div", { ref: editorRef, className: "bn-step-editor", suppressContentEditableWarning: true, "data-placeholder": placeholder, "data-multiline": multiline ? "true" : "false", "data-step-field": fieldName, contentEditable: readOnly ? "false" : "true", onFocus: () => {
238
- var _a, _b;
239
- setIsFocused(true);
240
- if (showSuggestionsOnFocus && enableAutocomplete) {
241
- setShowAllSuggestions(true);
242
- }
243
- onFieldFocus === null || onFieldFocus === void 0 ? void 0 : onFieldFocus();
244
- setPlainTextValue((_b = (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.innerText) !== null && _b !== void 0 ? _b : "");
245
- }, onBlur: () => {
246
- setIsFocused(false);
247
- syncValue();
248
- }, onInput: readOnly ? undefined : syncValue, onPaste: readOnly ? (event) => event.preventDefault() : handlePaste, onKeyDown: (event) => {
249
- var _a, _b;
250
- if (readOnly) {
251
- const allowedKeys = new Set([
252
- "ArrowDown",
253
- "ArrowUp",
254
- "Enter",
255
- "Tab",
256
- ]);
257
- const openKeys = enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ");
258
- if (!allowedKeys.has(event.key) && !openKeys) {
259
- event.preventDefault();
260
- return;
261
- }
262
- }
263
- if ((event.key === "a" || event.key === "A") && (event.metaKey || event.ctrlKey)) {
264
- event.preventDefault();
265
- const selection = (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window);
266
- const node = editorRef.current;
267
- if (selection && node) {
268
- const range = document.createRange();
269
- range.selectNodeContents(node);
270
- selection.removeAllRanges();
271
- selection.addRange(range);
272
- }
273
- return;
274
- }
275
- if (enableAutocomplete && shouldShowAutocomplete) {
276
- if (event.key === "ArrowDown") {
277
- event.preventDefault();
278
- setActiveSuggestionIndex((prev) => prev + 1 >= filteredSuggestions.length ? 0 : prev + 1);
279
- return;
280
- }
281
- if (event.key === "ArrowUp") {
282
- event.preventDefault();
283
- setActiveSuggestionIndex((prev) => prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1);
284
- return;
285
- }
286
- if (event.key === "Enter" || event.key === "Tab") {
287
- event.preventDefault();
288
- const suggestion = (_b = filteredSuggestions[activeSuggestionIndex]) !== null && _b !== void 0 ? _b : filteredSuggestions[0];
289
- if (suggestion) {
290
- applySuggestion(suggestion);
291
- }
292
- return;
293
- }
294
- }
295
- if (enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ")) {
296
- event.preventDefault();
297
- setShowAllSuggestions(true);
298
- return;
299
- }
300
- if (event.key === "Enter") {
301
- event.preventDefault();
302
- if (multiline && event.shiftKey) {
303
- document.execCommand("insertLineBreak");
304
- document.execCommand("insertLineBreak");
446
+ } })), _jsx("div", { ref: editorContainerRef, className: editorClassName, "data-step-field": fieldName, tabIndex: -1, onFocus: (event) => {
447
+ if (event.target === editorContainerRef.current) {
448
+ if (textareaNode) {
449
+ textareaNode.focus();
305
450
  }
306
451
  else {
307
- document.execCommand("insertLineBreak");
452
+ pendingFocusRef.current = true;
308
453
  }
309
- syncValue();
310
454
  }
311
- } }), shouldShowAutocomplete && (_jsx("div", { className: "bn-step-suggestions", role: "listbox", "aria-label": `${label} suggestions`, children: filteredSuggestions.map((suggestion, index) => (_jsxs("button", { type: "button", role: "option", "aria-selected": index === activeSuggestionIndex, className: index === activeSuggestionIndex
455
+ } }), extractedImages.length > 0 && (_jsx("div", { className: "bn-step-images", role: "list", children: extractedImages.map((image) => (_jsxs("div", { className: "bn-step-image-thumb", role: "listitem", children: [_jsx("button", { type: "button", className: "bn-step-image-thumb__button", onClick: () => handleImageClick(image.url), "aria-label": "Preview image", children: _jsx("img", { src: image.url, alt: image.alt || "Step image" }) }), _jsx("button", { type: "button", className: "bn-step-image-thumb__remove", onClick: (event) => {
456
+ event.stopPropagation();
457
+ handleRemoveImage(image);
458
+ }, "aria-label": "Remove image", children: "\u00D7" })] }, image.id))) })), shouldShowAutocomplete && (_jsx("div", { className: "bn-step-suggestions", role: "listbox", "aria-label": `${label} suggestions`, children: filteredSuggestions.map((suggestion, index) => (_jsxs("button", { type: "button", role: "option", "aria-selected": index === activeSuggestionIndex, className: index === activeSuggestionIndex
312
459
  ? "bn-step-suggestion bn-step-suggestion--active"
313
460
  : "bn-step-suggestion", onMouseDown: (event) => {
314
461
  event.preventDefault();
315
462
  applySuggestion(suggestion);
316
- }, tabIndex: -1, children: [_jsx("span", { className: "bn-step-suggestion__title", children: suggestion.title }), typeof suggestion.usageCount === "number" && suggestion.usageCount > 0 && (_jsxs("span", { className: "bn-step-suggestion__meta", children: [suggestion.usageCount, " uses"] }))] }, suggestion.id))) }))] }));
463
+ }, tabIndex: -1, children: [_jsx("span", { className: "bn-step-suggestion__title", children: suggestion.title }), typeof suggestion.usageCount === "number" && suggestion.usageCount > 0 && (_jsxs("span", { className: "bn-step-suggestion__meta", children: [suggestion.usageCount, " uses"] }))] }, suggestion.id))) })), previewImageUrl && (_jsx("div", { className: "bn-step-image-preview", role: "dialog", "aria-label": "Image preview", onClick: () => setPreviewImageUrl(null), children: _jsxs("div", { className: "bn-step-image-preview__content", role: "document", onClick: (event) => event.stopPropagation(), children: [_jsx("img", { src: previewImageUrl, alt: "Full size step" }), _jsx("button", { type: "button", className: "bn-step-image-preview__close", onClick: () => setPreviewImageUrl(null), "aria-label": "Close preview", children: "\u00D7" })] }) }))] }));
317
464
  }
@@ -151,7 +151,7 @@
151
151
  border-color: rgba(16, 185, 129, 0.25);
152
152
  }
153
153
 
154
- .bn-snippet .bn-step-editor:focus-visible {
154
+ .bn-snippet .bn-step-editor.bn-step-editor--focused {
155
155
  border-color: rgba(16, 185, 129, 0.7);
156
156
  box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
157
157
  }
@@ -275,35 +275,158 @@
275
275
  }
276
276
 
277
277
  .bn-step-editor {
278
+ position: relative;
278
279
  border-radius: 0.6rem;
279
280
  border: 1px solid rgba(37, 99, 235, 0.25);
280
- padding: 0.5rem 0.75rem;
281
- font-size: 0.95rem;
282
281
  background: rgba(255, 255, 255, 0.92);
283
282
  transition:
284
283
  border-color 120ms ease,
285
284
  box-shadow 120ms ease;
286
285
  min-height: 2.5rem;
287
- white-space: pre-wrap;
288
- word-break: break-word;
286
+ font-size: 0.95rem;
287
+ overflow: hidden;
289
288
  }
290
289
 
291
- .bn-step-editor[data-multiline="true"] {
290
+ .bn-step-editor--multiline {
292
291
  min-height: 4rem;
293
292
  }
294
293
 
295
- .bn-step-editor:focus-visible {
294
+ .bn-step-editor.bn-step-editor--focused {
296
295
  outline: none;
297
296
  border-color: rgba(37, 99, 235, 0.7);
298
297
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
299
298
  }
300
299
 
301
- .bn-step-editor:empty::before {
302
- content: attr(data-placeholder);
303
- color: rgba(15, 23, 42, 0.45);
300
+ .bn-step-editor .overtype-container {
301
+ height: 100%;
302
+ }
303
+
304
+ .bn-step-editor .overtype-wrapper {
305
+ min-height: 100%;
306
+ background: transparent !important;
307
+ }
308
+
309
+ .bn-step-editor .overtype-wrapper .overtype-input,
310
+ .bn-step-editor .overtype-wrapper .overtype-preview {
311
+ padding: 0.5rem 0.75rem !important;
312
+ min-height: inherit !important;
313
+ }
314
+
315
+ .bn-step-editor .overtype-wrapper .overtype-preview {
316
+ color: #0f172a !important;
317
+ }
318
+
319
+ .bn-step-editor .overtype-wrapper .overtype-preview img {
320
+ display: block;
321
+ max-width: 100%;
322
+ border-radius: 0.65rem;
323
+ margin: 0.5rem 0;
304
324
  pointer-events: none;
305
325
  }
306
326
 
327
+ .bn-step-field:not(:focus-within) .overtype-wrapper .overtype-input::selection {
328
+ background-color: transparent !important;
329
+ }
330
+
331
+ .bn-step-field:focus-within .overtype-wrapper .overtype-input::selection {
332
+ background-color: rgba(244, 211, 94, 0.4) !important;
333
+ }
334
+
335
+ .bn-step-images {
336
+ display: flex;
337
+ flex-wrap: wrap;
338
+ gap: 0.5rem;
339
+ margin-top: 0.35rem;
340
+ }
341
+
342
+ .bn-step-image-thumb {
343
+ position: relative;
344
+ width: 4rem;
345
+ height: 4rem;
346
+ border-radius: 0.5rem;
347
+ border: 1px solid rgba(37, 99, 235, 0.25);
348
+ overflow: hidden;
349
+ background: rgba(15, 23, 42, 0.05);
350
+ }
351
+
352
+ .bn-step-image-thumb__button {
353
+ width: 100%;
354
+ height: 100%;
355
+ border: none;
356
+ padding: 0;
357
+ background: transparent;
358
+ cursor: pointer;
359
+ }
360
+
361
+ .bn-step-image-thumb__button img {
362
+ width: 100%;
363
+ height: 100%;
364
+ object-fit: cover;
365
+ display: block;
366
+ }
367
+
368
+ .bn-step-image-thumb__remove {
369
+ position: absolute;
370
+ top: 0.15rem;
371
+ right: 0.15rem;
372
+ border: none;
373
+ border-radius: 999px;
374
+ width: 1.35rem;
375
+ height: 1.35rem;
376
+ background: rgba(15, 23, 42, 0.75);
377
+ color: white;
378
+ cursor: pointer;
379
+ opacity: 0;
380
+ transition: opacity 120ms ease;
381
+ }
382
+
383
+ .bn-step-image-thumb:hover .bn-step-image-thumb__remove,
384
+ .bn-step-image-thumb__remove:focus-visible {
385
+ opacity: 1;
386
+ }
387
+
388
+ .bn-step-image-preview {
389
+ position: fixed;
390
+ inset: 0;
391
+ background: rgba(15, 23, 42, 0.75);
392
+ display: flex;
393
+ align-items: center;
394
+ justify-content: center;
395
+ padding: 1.5rem;
396
+ z-index: 15;
397
+ }
398
+
399
+ .bn-step-image-preview__content {
400
+ position: relative;
401
+ max-width: min(90vw, 800px);
402
+ width: 100%;
403
+ background: white;
404
+ border-radius: 0.75rem;
405
+ padding: 1rem;
406
+ box-shadow: 0 25px 45px rgba(15, 23, 42, 0.35);
407
+ }
408
+
409
+ .bn-step-image-preview__content img {
410
+ width: 100%;
411
+ height: auto;
412
+ display: block;
413
+ border-radius: 0.5rem;
414
+ }
415
+
416
+ .bn-step-image-preview__close {
417
+ position: absolute;
418
+ top: 0.35rem;
419
+ right: 0.35rem;
420
+ border: none;
421
+ background: rgba(15, 23, 42, 0.65);
422
+ color: white;
423
+ border-radius: 999px;
424
+ width: 2rem;
425
+ height: 2rem;
426
+ cursor: pointer;
427
+ font-size: 1.1rem;
428
+ }
429
+
307
430
  .bn-step-suggestions {
308
431
  position: absolute;
309
432
  left: 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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",
@@ -139,12 +139,13 @@ function fallbackHtmlToMarkdown(html: string): string {
139
139
 
140
140
  result = result.replace(/<\/?[^>]+>/g, "");
141
141
 
142
- return result
142
+ const markdown = result
143
143
  .split("\n")
144
144
  .map((line) => line.trimEnd())
145
145
  .join("\n")
146
146
  .replace(/\n{3,}/g, "\n\n")
147
147
  .trim();
148
+ return cleanupEscapedFormatting(markdown);
148
149
  }
149
150
 
150
151
  export function htmlToMarkdown(html: string): string {
@@ -195,5 +196,14 @@ export function htmlToMarkdown(html: string): string {
195
196
  };
196
197
 
197
198
  const markdown = Array.from(temp.childNodes).map(traverse).join("");
198
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
199
+ return cleanupEscapedFormatting(markdown).replace(/\n{3,}/g, "\n\n").trim();
200
+ }
201
+
202
+ function cleanupEscapedFormatting(markdown: string): string {
203
+ return markdown.replace(/\\([*_])([^]*?)\\\1/g, (match, marker, inner) => {
204
+ if (inner.includes("\\")) {
205
+ return match;
206
+ }
207
+ return `${marker}${inner}${marker}`;
208
+ });
199
209
  }
@@ -157,6 +157,21 @@ describe("blocksToMarkdown", () => {
157
157
  );
158
158
  });
159
159
 
160
+ it("cleans escaped formatting markers when toggling styles repeatedly", () => {
161
+ const blocks: CustomEditorBlock[] = [
162
+ {
163
+ id: "esc1",
164
+ type: "paragraph",
165
+ props: baseProps,
166
+ content: [{ type: "text", text: "text", styles: { bold: true, italic: true } }],
167
+ children: [],
168
+ },
169
+ ];
170
+
171
+ const markdown = blocksToMarkdown(blocks);
172
+ expect(markdown).toBe("***text***");
173
+ });
174
+
160
175
  it("keeps inline formatting inside step fields", () => {
161
176
  const blocks: CustomEditorBlock[] = [
162
177
  {