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.
@@ -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
+ };
@@ -1,8 +1,131 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createReactBlockSpec } from "@blocknote/react";
3
- import { useCallback } from "react";
3
+ import OverType from "overtype";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { StepField } from "./stepField";
5
6
  import { useSnippetAutocomplete } from "../snippetAutocomplete";
7
+ import { useStepImageUpload } from "../stepImageUpload";
8
+ function SnippetDataField({ label, value, placeholder, onChange, onFieldFocus, fieldName, enableImageUpload = false, }) {
9
+ const containerRef = useRef(null);
10
+ const instanceRef = useRef(null);
11
+ const uploadImage = useStepImageUpload();
12
+ const [isFocused, setIsFocused] = useState(false);
13
+ const onChangeRef = useRef(onChange);
14
+ const initialValueRef = useRef(value);
15
+ useEffect(() => {
16
+ onChangeRef.current = onChange;
17
+ }, [onChange]);
18
+ const insertImageMarkdown = useCallback((url) => {
19
+ var _a, _b, _c;
20
+ const instance = instanceRef.current;
21
+ const textarea = instance === null || instance === void 0 ? void 0 : instance.textarea;
22
+ if (!instance || !textarea) {
23
+ return;
24
+ }
25
+ const currentValue = instance.getValue();
26
+ const selectionStart = (_a = textarea.selectionStart) !== null && _a !== void 0 ? _a : currentValue.length;
27
+ const selectionEnd = (_b = textarea.selectionEnd) !== null && _b !== void 0 ? _b : currentValue.length;
28
+ const before = currentValue.slice(0, selectionStart);
29
+ const after = currentValue.slice(selectionEnd);
30
+ const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
31
+ const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
32
+ const markdown = `${needsNewlineBefore ? "\n" : ""}![](${url})${needsNewlineAfter ? "\n" : ""}`;
33
+ const nextValue = `${before}${markdown}${after}`;
34
+ instance.setValue(nextValue);
35
+ (_c = onChangeRef.current) === null || _c === void 0 ? void 0 : _c.call(onChangeRef, nextValue);
36
+ requestAnimationFrame(() => {
37
+ const cursor = selectionStart + markdown.length;
38
+ textarea.selectionStart = cursor;
39
+ textarea.selectionEnd = cursor;
40
+ textarea.focus();
41
+ });
42
+ }, []);
43
+ useEffect(() => {
44
+ const container = containerRef.current;
45
+ if (!container) {
46
+ return;
47
+ }
48
+ const [instance] = OverType.init(container, {
49
+ value: initialValueRef.current,
50
+ placeholder,
51
+ autoResize: true,
52
+ minHeight: "5rem",
53
+ toolbar: false,
54
+ onChange: (nextValue) => {
55
+ var _a;
56
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, nextValue);
57
+ },
58
+ });
59
+ instanceRef.current = instance;
60
+ const textarea = instance.textarea;
61
+ if (fieldName) {
62
+ textarea.dataset.stepField = fieldName;
63
+ }
64
+ const handleFocus = () => {
65
+ setIsFocused(true);
66
+ onFieldFocus === null || onFieldFocus === void 0 ? void 0 : onFieldFocus();
67
+ };
68
+ const handleBlur = () => setIsFocused(false);
69
+ textarea.addEventListener("focus", handleFocus);
70
+ textarea.addEventListener("blur", handleBlur);
71
+ return () => {
72
+ textarea.removeEventListener("focus", handleFocus);
73
+ textarea.removeEventListener("blur", handleBlur);
74
+ instance.destroy();
75
+ instanceRef.current = null;
76
+ };
77
+ }, [fieldName, onFieldFocus, placeholder]);
78
+ useEffect(() => {
79
+ const instance = instanceRef.current;
80
+ if (!instance) {
81
+ return;
82
+ }
83
+ if (instance.getValue() !== value) {
84
+ instance.setValue(value);
85
+ }
86
+ }, [value]);
87
+ useEffect(() => {
88
+ var _a;
89
+ if (!enableImageUpload || !uploadImage) {
90
+ return;
91
+ }
92
+ const textarea = (_a = instanceRef.current) === null || _a === void 0 ? void 0 : _a.textarea;
93
+ if (!textarea) {
94
+ return;
95
+ }
96
+ const handlePaste = async (event) => {
97
+ var _a, _b;
98
+ const items = Array.from((_b = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.items) !== null && _b !== void 0 ? _b : []);
99
+ const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
100
+ const file = imageItem === null || imageItem === void 0 ? void 0 : imageItem.getAsFile();
101
+ if (!file) {
102
+ return;
103
+ }
104
+ event.preventDefault();
105
+ try {
106
+ const response = await uploadImage(file);
107
+ if (response === null || response === void 0 ? void 0 : response.url) {
108
+ insertImageMarkdown(response.url);
109
+ }
110
+ }
111
+ catch (error) {
112
+ console.error("Failed to upload pasted image", error);
113
+ }
114
+ };
115
+ textarea.addEventListener("paste", handlePaste);
116
+ return () => {
117
+ textarea.removeEventListener("paste", handlePaste);
118
+ };
119
+ }, [enableImageUpload, insertImageMarkdown, uploadImage]);
120
+ const editorClassName = useMemo(() => [
121
+ "bn-step-editor",
122
+ "bn-step-editor--multiline",
123
+ isFocused ? "bn-step-editor--focused" : "",
124
+ ]
125
+ .filter(Boolean)
126
+ .join(" "), [isFocused]);
127
+ return (_jsxs("div", { className: "bn-step-field", children: [_jsx("div", { className: "bn-step-field__top", children: _jsx("span", { className: "bn-step-field__label", children: label }) }), _jsx("div", { ref: containerRef, className: editorClassName, "data-step-field": fieldName })] }));
128
+ }
6
129
  export const snippetBlock = createReactBlockSpec({
7
130
  type: "snippet",
8
131
  content: "none",
@@ -24,8 +147,10 @@ export const snippetBlock = createReactBlockSpec({
24
147
  render: ({ block, editor }) => {
25
148
  const snippetTitle = block.props.snippetTitle || "";
26
149
  const snippetData = block.props.snippetData || "";
150
+ const snippetId = block.props.snippetId || "";
27
151
  const snippetSuggestions = useSnippetAutocomplete();
28
152
  const hasSnippets = snippetSuggestions.length > 0;
153
+ const isSnippetSelected = snippetId.length > 0;
29
154
  const handleSnippetChange = useCallback((nextTitle) => {
30
155
  if (nextTitle === snippetTitle) {
31
156
  return;
@@ -67,6 +192,6 @@ export const snippetBlock = createReactBlockSpec({
67
192
  if (!hasSnippets) {
68
193
  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
194
  }
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 })] }));
195
+ return (_jsx("div", { className: "bn-teststep bn-snippet", "data-block-id": block.id, children: !isSnippetSelected ? (_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, readOnly: false })) : (_jsx(SnippetDataField, { label: `Snippet: ${snippetTitle}`, value: snippetData, onChange: handleSnippetDataChange, fieldName: "snippet-data", enableImageUpload: true, onFieldFocus: handleFieldFocus, placeholder: "Snippet data will appear here..." })) }));
71
196
  },
72
197
  });
@@ -1,8 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createReactBlockSpec } from "@blocknote/react";
3
- import { useCallback, useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { StepField } from "./stepField";
5
5
  import { useStepImageUpload } from "../stepImageUpload";
6
+ const EXPECTED_COLLAPSED_KEY = "bn-expected-collapsed";
7
+ const readExpectedCollapsedPreference = () => {
8
+ if (typeof window === "undefined") {
9
+ return false;
10
+ }
11
+ try {
12
+ return window.localStorage.getItem(EXPECTED_COLLAPSED_KEY) === "true";
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ };
18
+ const writeExpectedCollapsedPreference = (collapsed) => {
19
+ if (typeof window === "undefined") {
20
+ return;
21
+ }
22
+ try {
23
+ window.localStorage.setItem(EXPECTED_COLLAPSED_KEY, collapsed ? "true" : "false");
24
+ }
25
+ catch {
26
+ //
27
+ }
28
+ };
6
29
  export const stepBlock = createReactBlockSpec({
7
30
  type: "testStep",
8
31
  content: "none",
@@ -22,22 +45,25 @@ export const stepBlock = createReactBlockSpec({
22
45
  const stepTitle = block.props.stepTitle || "";
23
46
  const stepData = block.props.stepData || "";
24
47
  const expectedResult = block.props.expectedResult || "";
25
- const showExpectedField = stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
26
- const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
48
+ const expectedHasContent = expectedResult.trim().length > 0;
49
+ const storedExpectedCollapsed = useMemo(() => readExpectedCollapsedPreference(), []);
50
+ const dataHasContent = stepData.trim().length > 0;
51
+ const [isExpectedVisible, setIsExpectedVisible] = useState(expectedHasContent ? true : !storedExpectedCollapsed);
52
+ const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
27
53
  const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
28
54
  const uploadImage = useStepImageUpload();
55
+ // Calculate step number based on position in document
56
+ const stepNumber = useMemo(() => {
57
+ const allBlocks = editor.document;
58
+ const stepBlocks = allBlocks.filter((b) => b.type === "testStep");
59
+ const index = stepBlocks.findIndex((b) => b.id === block.id);
60
+ return index >= 0 ? index + 1 : 1;
61
+ }, [editor.document, block.id]);
29
62
  useEffect(() => {
30
- if (stepData.trim().length > 0 && !isDataVisible) {
63
+ if (dataHasContent && !isDataVisible) {
31
64
  setIsDataVisible(true);
32
65
  }
33
- }, [isDataVisible, stepData]);
34
- useEffect(() => {
35
- if (shouldFocusDataField && isDataVisible) {
36
- const timer = setTimeout(() => setShouldFocusDataField(false), 0);
37
- return () => clearTimeout(timer);
38
- }
39
- return undefined;
40
- }, [isDataVisible, shouldFocusDataField]);
66
+ }, [dataHasContent, isDataVisible]);
41
67
  const handleStepTitleChange = useCallback((next) => {
42
68
  if (next === stepTitle) {
43
69
  return;
@@ -58,10 +84,13 @@ export const stepBlock = createReactBlockSpec({
58
84
  },
59
85
  });
60
86
  }, [editor, block.id, stepData]);
61
- const handleShowDataField = useCallback(() => {
87
+ const handleShowData = useCallback(() => {
62
88
  setIsDataVisible(true);
63
89
  setShouldFocusDataField(true);
64
90
  }, []);
91
+ const handleHideData = useCallback(() => {
92
+ setIsDataVisible(false);
93
+ }, []);
65
94
  const handleExpectedChange = useCallback((next) => {
66
95
  if (next === expectedResult) {
67
96
  return;
@@ -88,7 +117,25 @@ export const stepBlock = createReactBlockSpec({
88
117
  const handleFieldFocus = useCallback(() => {
89
118
  editor.setSelection(block.id, block.id);
90
119
  }, [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) => {
120
+ const [dataFocusSignal] = useState(0);
121
+ const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
122
+ const handleShowExpected = useCallback(() => {
123
+ setIsExpectedVisible(true);
124
+ setExpectedFocusSignal((value) => value + 1);
125
+ writeExpectedCollapsedPreference(false);
126
+ }, []);
127
+ const handleHideExpected = useCallback(() => {
128
+ setIsExpectedVisible(false);
129
+ writeExpectedCollapsedPreference(true);
130
+ }, []);
131
+ useEffect(() => {
132
+ if (expectedHasContent && !isExpectedVisible) {
133
+ setIsExpectedVisible(true);
134
+ }
135
+ }, [expectedHasContent, isExpectedVisible]);
136
+ const canToggleData = !dataHasContent;
137
+ const canToggleExpected = !expectedHasContent;
138
+ return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, children: [_jsx(StepField, { label: `Step ${stepNumber}`, value: stepTitle, onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
92
139
  if (!uploadImage) {
93
140
  return;
94
141
  }
@@ -108,6 +155,26 @@ export const stepBlock = createReactBlockSpec({
108
155
  catch (error) {
109
156
  console.error("Failed to upload image to Step Data", error);
110
157
  }
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" })] }));
158
+ } }), isDataVisible ? (_jsx(StepField, { label: "Step Data", labelToggle: canToggleData
159
+ ? {
160
+ onClick: handleHideData,
161
+ expanded: true,
162
+ }
163
+ : undefined, value: stepData, onChange: handleStepDataChange, autoFocus: shouldFocusDataField, focusSignal: dataFocusSignal, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })) : (_jsx("div", { className: "bn-step-field bn-step-field--collapsed", children: _jsx("span", { className: "bn-step-field__label bn-step-field__label--toggle", role: "button", tabIndex: -1, onClick: handleShowData, onKeyDown: (event) => {
164
+ if (event.key === "Enter" || event.key === " ") {
165
+ event.preventDefault();
166
+ handleShowData();
167
+ }
168
+ }, "aria-expanded": "false", children: "Step Data" }) })), isExpectedVisible ? (_jsx(StepField, { label: "Expected Result", labelToggle: canToggleExpected
169
+ ? {
170
+ onClick: handleHideExpected,
171
+ expanded: true,
172
+ }
173
+ : undefined, value: expectedResult, onChange: handleExpectedChange, multiline: true, focusSignal: expectedFocusSignal, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })) : (_jsx("div", { className: "bn-step-field bn-step-field--collapsed", children: _jsx("span", { className: "bn-step-field__label bn-step-field__label--toggle", role: "button", tabIndex: -1, onClick: handleShowExpected, onKeyDown: (event) => {
174
+ if (event.key === "Enter" || event.key === " ") {
175
+ event.preventDefault();
176
+ handleShowExpected();
177
+ }
178
+ }, "aria-expanded": "false", children: "Expected Result" }) })), _jsx("button", { type: "button", className: "bn-step-add", onClick: handleInsertNextStep, children: "+ Step" })] }));
112
179
  },
113
180
  });
@@ -1,13 +1,17 @@
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
+ labelToggle?: {
8
+ onClick: () => void;
9
+ expanded: boolean;
10
+ };
7
11
  value: string;
8
- placeholder: string;
9
12
  onChange: (nextValue: string) => void;
10
13
  autoFocus?: boolean;
14
+ focusSignal?: number;
11
15
  multiline?: boolean;
12
16
  enableAutocomplete?: boolean;
13
17
  fieldName?: string;
@@ -23,5 +27,5 @@ type StepFieldProps = {
23
27
  showImageButton?: boolean;
24
28
  onFieldFocus?: () => void;
25
29
  };
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;
30
+ export declare function StepField({ label, labelToggle, value, onChange, autoFocus, focusSignal, multiline, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, onFieldFocus, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
27
31
  export {};