testomatio-editor-blocks 0.2.3 → 0.4.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.
@@ -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 {
@@ -139,12 +147,13 @@ function fallbackHtmlToMarkdown(html: string): string {
139
147
 
140
148
  result = result.replace(/<\/?[^>]+>/g, "");
141
149
 
142
- return result
150
+ const markdown = result
143
151
  .split("\n")
144
152
  .map((line) => line.trimEnd())
145
153
  .join("\n")
146
154
  .replace(/\n{3,}/g, "\n\n")
147
155
  .trim();
156
+ return cleanupEscapedFormatting(markdown);
148
157
  }
149
158
 
150
159
  export function htmlToMarkdown(html: string): string {
@@ -195,5 +204,26 @@ export function htmlToMarkdown(html: string): string {
195
204
  };
196
205
 
197
206
  const markdown = Array.from(temp.childNodes).map(traverse).join("");
198
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
207
+ return cleanupEscapedFormatting(markdown).replace(/\n{3,}/g, "\n\n").trim();
208
+ }
209
+
210
+ function cleanupEscapedFormatting(markdown: string): string {
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;
221
+ }
222
+ const hasPrintable = slashes.length % 2 === 0;
223
+ return hasPrintable ? markers : slashes + markers;
224
+ });
199
225
  }
226
+
227
+ export const __markdownStringUtils = {
228
+ cleanupEscapedFormatting,
229
+ };
@@ -1,8 +1,180 @@
1
1
  import { createReactBlockSpec } from "@blocknote/react";
2
- import { useCallback } from "react";
2
+ import OverType, { type OverType as OverTypeInstance } from "overtype";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { StepField } from "./stepField";
4
5
  import { useSnippetAutocomplete, type SnippetSuggestion } from "../snippetAutocomplete";
5
6
  import type { StepSuggestion } from "../stepAutocomplete";
7
+ import { useStepImageUpload } from "../stepImageUpload";
8
+
9
+ type SnippetDataFieldProps = {
10
+ label: string;
11
+ value: string;
12
+ placeholder?: string;
13
+ onChange: (value: string) => void;
14
+ onFieldFocus?: () => void;
15
+ fieldName?: string;
16
+ enableImageUpload?: boolean;
17
+ };
18
+
19
+ function SnippetDataField({
20
+ label,
21
+ value,
22
+ placeholder,
23
+ onChange,
24
+ onFieldFocus,
25
+ fieldName,
26
+ enableImageUpload = false,
27
+ }: SnippetDataFieldProps) {
28
+ const containerRef = useRef<HTMLDivElement>(null);
29
+ const instanceRef = useRef<OverTypeInstance | null>(null);
30
+ const uploadImage = useStepImageUpload();
31
+ const [isFocused, setIsFocused] = useState(false);
32
+ const onChangeRef = useRef(onChange);
33
+ const initialValueRef = useRef(value);
34
+
35
+ useEffect(() => {
36
+ onChangeRef.current = onChange;
37
+ }, [onChange]);
38
+
39
+ const insertImageMarkdown = useCallback(
40
+ (url: string) => {
41
+ const instance = instanceRef.current;
42
+ const textarea = instance?.textarea;
43
+ if (!instance || !textarea) {
44
+ return;
45
+ }
46
+
47
+ const currentValue = instance.getValue();
48
+ const selectionStart = textarea.selectionStart ?? currentValue.length;
49
+ const selectionEnd = textarea.selectionEnd ?? currentValue.length;
50
+ const before = currentValue.slice(0, selectionStart);
51
+ const after = currentValue.slice(selectionEnd);
52
+ const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
53
+ const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
54
+ const markdown = `${needsNewlineBefore ? "\n" : ""}![](${url})${needsNewlineAfter ? "\n" : ""}`;
55
+ const nextValue = `${before}${markdown}${after}`;
56
+
57
+ instance.setValue(nextValue);
58
+ onChangeRef.current?.(nextValue);
59
+
60
+ requestAnimationFrame(() => {
61
+ const cursor = selectionStart + markdown.length;
62
+ textarea.selectionStart = cursor;
63
+ textarea.selectionEnd = cursor;
64
+ textarea.focus();
65
+ });
66
+ },
67
+ [],
68
+ );
69
+
70
+ useEffect(() => {
71
+ const container = containerRef.current;
72
+ if (!container) {
73
+ return;
74
+ }
75
+
76
+ const [instance] = OverType.init(container, {
77
+ value: initialValueRef.current,
78
+ placeholder,
79
+ autoResize: true,
80
+ minHeight: "5rem",
81
+ toolbar: false,
82
+ onChange: (nextValue) => {
83
+ onChangeRef.current?.(nextValue);
84
+ },
85
+ });
86
+
87
+ instanceRef.current = instance;
88
+ const textarea = instance.textarea;
89
+ if (fieldName) {
90
+ textarea.dataset.stepField = fieldName;
91
+ }
92
+
93
+ const handleFocus = () => {
94
+ setIsFocused(true);
95
+ onFieldFocus?.();
96
+ };
97
+ const handleBlur = () => setIsFocused(false);
98
+
99
+ textarea.addEventListener("focus", handleFocus);
100
+ textarea.addEventListener("blur", handleBlur);
101
+
102
+ return () => {
103
+ textarea.removeEventListener("focus", handleFocus);
104
+ textarea.removeEventListener("blur", handleBlur);
105
+ instance.destroy();
106
+ instanceRef.current = null;
107
+ };
108
+ }, [fieldName, onFieldFocus, placeholder]);
109
+
110
+ useEffect(() => {
111
+ const instance = instanceRef.current;
112
+ if (!instance) {
113
+ return;
114
+ }
115
+
116
+ if (instance.getValue() !== value) {
117
+ instance.setValue(value);
118
+ }
119
+ }, [value]);
120
+
121
+ useEffect(() => {
122
+ if (!enableImageUpload || !uploadImage) {
123
+ return;
124
+ }
125
+
126
+ const textarea = instanceRef.current?.textarea;
127
+ if (!textarea) {
128
+ return;
129
+ }
130
+
131
+ const handlePaste = async (event: ClipboardEvent) => {
132
+ const items = Array.from(event.clipboardData?.items ?? []);
133
+ const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
134
+ const file = imageItem?.getAsFile();
135
+ if (!file) {
136
+ return;
137
+ }
138
+
139
+ event.preventDefault();
140
+
141
+ try {
142
+ const response = await uploadImage(file);
143
+ if (response?.url) {
144
+ insertImageMarkdown(response.url);
145
+ }
146
+ } catch (error) {
147
+ console.error("Failed to upload pasted image", error);
148
+ }
149
+ };
150
+
151
+ textarea.addEventListener("paste", handlePaste as unknown as EventListener);
152
+ return () => {
153
+ textarea.removeEventListener("paste", handlePaste as unknown as EventListener);
154
+ };
155
+ }, [enableImageUpload, insertImageMarkdown, uploadImage]);
156
+
157
+ const editorClassName = useMemo(
158
+ () =>
159
+ [
160
+ "bn-step-editor",
161
+ "bn-step-editor--multiline",
162
+ isFocused ? "bn-step-editor--focused" : "",
163
+ ]
164
+ .filter(Boolean)
165
+ .join(" "),
166
+ [isFocused],
167
+ );
168
+
169
+ return (
170
+ <div className="bn-step-field">
171
+ <div className="bn-step-field__top">
172
+ <span className="bn-step-field__label">{label}</span>
173
+ </div>
174
+ <div ref={containerRef} className={editorClassName} data-step-field={fieldName} />
175
+ </div>
176
+ );
177
+ }
6
178
 
7
179
  export const snippetBlock = createReactBlockSpec(
8
180
  {
@@ -27,8 +199,10 @@ export const snippetBlock = createReactBlockSpec(
27
199
  render: ({ block, editor }) => {
28
200
  const snippetTitle = (block.props.snippetTitle as string) || "";
29
201
  const snippetData = (block.props.snippetData as string) || "";
202
+ const snippetId = (block.props.snippetId as string) || "";
30
203
  const snippetSuggestions = useSnippetAutocomplete();
31
204
  const hasSnippets = snippetSuggestions.length > 0;
205
+ const isSnippetSelected = snippetId.length > 0;
32
206
 
33
207
  const handleSnippetChange = useCallback(
34
208
  (nextTitle: string) => {
@@ -92,31 +266,33 @@ export const snippetBlock = createReactBlockSpec(
92
266
 
93
267
  return (
94
268
  <div className="bn-teststep bn-snippet" data-block-id={block.id}>
95
- <StepField
96
- label="Snippet Title"
97
- value={snippetTitle}
98
- placeholder="Describe the reusable action"
99
- onChange={handleSnippetChange}
100
- autoFocus={snippetTitle.length === 0}
101
- enableAutocomplete
102
- suggestionFilter={(suggestion) => (suggestion as SnippetSuggestion).isSnippet === true}
103
- suggestionsOverride={snippetSuggestions as unknown as StepSuggestion[]}
104
- onSuggestionSelect={handleSnippetSelect}
105
- fieldName="snippet-title"
106
- showSuggestionsOnFocus
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
- />
269
+ {!isSnippetSelected ? (
270
+ <StepField
271
+ label="Snippet Title"
272
+ value={snippetTitle}
273
+ onChange={handleSnippetChange}
274
+ autoFocus={snippetTitle.length === 0}
275
+ enableAutocomplete={true}
276
+ suggestionFilter={(suggestion) => (suggestion as SnippetSuggestion).isSnippet === true}
277
+ suggestionsOverride={snippetSuggestions as unknown as StepSuggestion[]}
278
+ onSuggestionSelect={handleSnippetSelect}
279
+ fieldName="snippet-title"
280
+ showSuggestionsOnFocus={true}
281
+ enableImageUpload={false}
282
+ onFieldFocus={handleFieldFocus}
283
+ readOnly={false}
284
+ />
285
+ ) : (
286
+ <SnippetDataField
287
+ label={`Snippet: ${snippetTitle}`}
288
+ value={snippetData}
289
+ onChange={handleSnippetDataChange}
290
+ fieldName="snippet-data"
291
+ enableImageUpload
292
+ onFieldFocus={handleFieldFocus}
293
+ placeholder="Snippet data will appear here..."
294
+ />
295
+ )}
120
296
  </div>
121
297
  );
122
298
  },
@@ -1,9 +1,33 @@
1
1
  import { createReactBlockSpec } from "@blocknote/react";
2
- import { useCallback, useEffect, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
3
  import { StepField } from "./stepField";
4
4
  import { useStepImageUpload } from "../stepImageUpload";
5
5
  import type { StepSuggestion } from "../stepAutocomplete";
6
6
 
7
+ const EXPECTED_COLLAPSED_KEY = "bn-expected-collapsed";
8
+
9
+ const readExpectedCollapsedPreference = (): boolean => {
10
+ if (typeof window === "undefined") {
11
+ return false;
12
+ }
13
+ try {
14
+ return window.localStorage.getItem(EXPECTED_COLLAPSED_KEY) === "true";
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
19
+
20
+ const writeExpectedCollapsedPreference = (collapsed: boolean) => {
21
+ if (typeof window === "undefined") {
22
+ return;
23
+ }
24
+ try {
25
+ window.localStorage.setItem(EXPECTED_COLLAPSED_KEY, collapsed ? "true" : "false");
26
+ } catch {
27
+ //
28
+ }
29
+ };
30
+
7
31
  export const stepBlock = createReactBlockSpec(
8
32
  {
9
33
  type: "testStep",
@@ -25,25 +49,32 @@ export const stepBlock = createReactBlockSpec(
25
49
  const stepTitle = (block.props.stepTitle as string) || "";
26
50
  const stepData = (block.props.stepData as string) || "";
27
51
  const expectedResult = (block.props.expectedResult as string) || "";
28
- const showExpectedField =
29
- stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
30
- const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
52
+ const expectedHasContent = expectedResult.trim().length > 0;
53
+ const storedExpectedCollapsed = useMemo(
54
+ () => readExpectedCollapsedPreference(),
55
+ [],
56
+ );
57
+ const dataHasContent = stepData.trim().length > 0;
58
+ const [isExpectedVisible, setIsExpectedVisible] = useState(
59
+ expectedHasContent ? true : !storedExpectedCollapsed,
60
+ );
61
+ const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
31
62
  const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
32
63
  const uploadImage = useStepImageUpload();
33
64
 
34
- useEffect(() => {
35
- if (stepData.trim().length > 0 && !isDataVisible) {
36
- setIsDataVisible(true);
37
- }
38
- }, [isDataVisible, stepData]);
65
+ // Calculate step number based on position in document
66
+ const stepNumber = useMemo(() => {
67
+ const allBlocks = editor.document;
68
+ const stepBlocks = allBlocks.filter((b) => b.type === "testStep");
69
+ const index = stepBlocks.findIndex((b) => b.id === block.id);
70
+ return index >= 0 ? index + 1 : 1;
71
+ }, [editor.document, block.id]);
39
72
 
40
73
  useEffect(() => {
41
- if (shouldFocusDataField && isDataVisible) {
42
- const timer = setTimeout(() => setShouldFocusDataField(false), 0);
43
- return () => clearTimeout(timer);
74
+ if (dataHasContent && !isDataVisible) {
75
+ setIsDataVisible(true);
44
76
  }
45
- return undefined;
46
- }, [isDataVisible, shouldFocusDataField]);
77
+ }, [dataHasContent, isDataVisible]);
47
78
 
48
79
  const handleStepTitleChange = useCallback(
49
80
  (next: string) => {
@@ -75,11 +106,15 @@ export const stepBlock = createReactBlockSpec(
75
106
  [editor, block.id, stepData],
76
107
  );
77
108
 
78
- const handleShowDataField = useCallback(() => {
109
+ const handleShowData = useCallback(() => {
79
110
  setIsDataVisible(true);
80
111
  setShouldFocusDataField(true);
81
112
  }, []);
82
113
 
114
+ const handleHideData = useCallback(() => {
115
+ setIsDataVisible(false);
116
+ }, []);
117
+
83
118
  const handleExpectedChange = useCallback(
84
119
  (next: string) => {
85
120
  if (next === expectedResult) {
@@ -117,31 +152,40 @@ export const stepBlock = createReactBlockSpec(
117
152
  editor.setSelection(block.id, block.id);
118
153
  }, [editor, block.id]);
119
154
 
155
+ const [dataFocusSignal] = useState(0);
156
+ const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
157
+
158
+ const handleShowExpected = useCallback(() => {
159
+ setIsExpectedVisible(true);
160
+ setExpectedFocusSignal((value) => value + 1);
161
+ writeExpectedCollapsedPreference(false);
162
+ }, []);
163
+
164
+ const handleHideExpected = useCallback(() => {
165
+ setIsExpectedVisible(false);
166
+ writeExpectedCollapsedPreference(true);
167
+ }, []);
168
+
169
+ useEffect(() => {
170
+ if (expectedHasContent && !isExpectedVisible) {
171
+ setIsExpectedVisible(true);
172
+ }
173
+ }, [expectedHasContent, isExpectedVisible]);
174
+
175
+ const canToggleData = !dataHasContent;
176
+ const canToggleExpected = !expectedHasContent;
177
+
120
178
  return (
121
179
  <div className="bn-teststep" data-block-id={block.id}>
122
- <StepField
123
- label="Step Title"
124
- value={stepTitle}
125
- placeholder="Describe the action to perform"
180
+ <StepField
181
+ label={`Step ${stepNumber}`}
182
+ value={stepTitle}
126
183
  onChange={handleStepTitleChange}
127
184
  autoFocus={stepTitle.length === 0}
128
185
  enableAutocomplete
129
186
  fieldName="title"
130
187
  suggestionFilter={(suggestion) => (suggestion as StepSuggestion).isSnippet !== true}
131
188
  onFieldFocus={handleFieldFocus}
132
- rightAction={
133
- !isDataVisible ? (
134
- <button
135
- type="button"
136
- className="bn-teststep__toggle"
137
- onClick={handleShowDataField}
138
- aria-expanded="false"
139
- tabIndex={-1}
140
- >
141
- + Step Data
142
- </button>
143
- ) : null
144
- }
145
189
  enableImageUpload={false}
146
190
  showFormattingButtons
147
191
  onImageFile={async (file) => {
@@ -166,32 +210,84 @@ export const stepBlock = createReactBlockSpec(
166
210
  }
167
211
  }}
168
212
  />
169
- {isDataVisible && (
213
+ {isDataVisible ? (
170
214
  <StepField
171
215
  label="Step Data"
216
+ labelToggle={
217
+ canToggleData
218
+ ? {
219
+ onClick: handleHideData,
220
+ expanded: true,
221
+ }
222
+ : undefined
223
+ }
172
224
  value={stepData}
173
- placeholder="Provide additional data about the step"
174
225
  onChange={handleStepDataChange}
175
226
  autoFocus={shouldFocusDataField}
227
+ focusSignal={dataFocusSignal}
176
228
  multiline
177
229
  enableImageUpload
178
230
  showFormattingButtons
179
231
  showImageButton
180
232
  onFieldFocus={handleFieldFocus}
181
233
  />
234
+ ) : (
235
+ <div className="bn-step-field bn-step-field--collapsed">
236
+ <span
237
+ className="bn-step-field__label bn-step-field__label--toggle"
238
+ role="button"
239
+ tabIndex={-1}
240
+ onClick={handleShowData}
241
+ onKeyDown={(event) => {
242
+ if (event.key === "Enter" || event.key === " ") {
243
+ event.preventDefault();
244
+ handleShowData();
245
+ }
246
+ }}
247
+ aria-expanded="false"
248
+ >
249
+ Step Data
250
+ </span>
251
+ </div>
182
252
  )}
183
- {showExpectedField && (
253
+ {isExpectedVisible ? (
184
254
  <StepField
185
255
  label="Expected Result"
256
+ labelToggle={
257
+ canToggleExpected
258
+ ? {
259
+ onClick: handleHideExpected,
260
+ expanded: true,
261
+ }
262
+ : undefined
263
+ }
186
264
  value={expectedResult}
187
- placeholder="What should happen?"
188
265
  onChange={handleExpectedChange}
189
266
  multiline
267
+ focusSignal={expectedFocusSignal}
190
268
  enableImageUpload
191
269
  showFormattingButtons
192
270
  showImageButton
193
271
  onFieldFocus={handleFieldFocus}
194
272
  />
273
+ ) : (
274
+ <div className="bn-step-field bn-step-field--collapsed">
275
+ <span
276
+ className="bn-step-field__label bn-step-field__label--toggle"
277
+ role="button"
278
+ tabIndex={-1}
279
+ onClick={handleShowExpected}
280
+ onKeyDown={(event) => {
281
+ if (event.key === "Enter" || event.key === " ") {
282
+ event.preventDefault();
283
+ handleShowExpected();
284
+ }
285
+ }}
286
+ aria-expanded="false"
287
+ >
288
+ Expected Result
289
+ </span>
290
+ </div>
195
291
  )}
196
292
  <button type="button" className="bn-step-add" onClick={handleInsertNextStep}>
197
293
  + Step