testomatio-editor-blocks 0.4.46 → 0.4.48

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.
@@ -22,7 +22,17 @@ export declare function canInsertStepOrSnippet(editor: {
22
22
  */
23
23
  export declare function addStepsBlock(editor: {
24
24
  document: any[];
25
- insertBlocks: (blocks: any[], referenceId: string, placement: string) => any[];
25
+ insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
26
+ }): string | null;
27
+ /**
28
+ * Programmatically add an empty snippet block to the editor.
29
+ * - If a "Steps" heading exists, inserts after the last step/snippet under it.
30
+ * - Otherwise, appends a "Steps" heading + empty snippet at the end.
31
+ * Returns the inserted snippet's block ID (for focusing), or null.
32
+ */
33
+ export declare function addSnippetBlock(editor: {
34
+ document: any[];
35
+ insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
26
36
  }): string | null;
27
37
  export declare const stepBlock: {
28
38
  config: {
@@ -142,6 +142,62 @@ export function addStepsBlock(editor) {
142
142
  const inserted = editor.insertBlocks([stepsHeading, emptyStep], lastBlock.id, "after");
143
143
  return (_d = (_c = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null;
144
144
  }
145
+ /**
146
+ * Programmatically add an empty snippet block to the editor.
147
+ * - If a "Steps" heading exists, inserts after the last step/snippet under it.
148
+ * - Otherwise, appends a "Steps" heading + empty snippet at the end.
149
+ * Returns the inserted snippet's block ID (for focusing), or null.
150
+ */
151
+ export function addSnippetBlock(editor) {
152
+ var _a, _b, _c, _d;
153
+ const allBlocks = editor.document;
154
+ const emptySnippet = {
155
+ type: "snippet",
156
+ props: { snippetId: "", snippetTitle: "", snippetData: "", snippetExpectedResult: "" },
157
+ children: [],
158
+ };
159
+ let stepsHeadingIndex = -1;
160
+ for (let i = 0; i < allBlocks.length; i++) {
161
+ const b = allBlocks[i];
162
+ if (b.type !== "heading")
163
+ continue;
164
+ const text = (Array.isArray(b.content) ? b.content : [])
165
+ .filter((n) => n.type === "text")
166
+ .map((n) => { var _a; return (_a = n.text) !== null && _a !== void 0 ? _a : ""; })
167
+ .join("")
168
+ .trim()
169
+ .toLowerCase()
170
+ .replace(/[:\-–—]$/, "");
171
+ if (isStepsHeading(text)) {
172
+ stepsHeadingIndex = i;
173
+ break;
174
+ }
175
+ }
176
+ if (stepsHeadingIndex >= 0) {
177
+ let lastIndex = stepsHeadingIndex;
178
+ for (let i = stepsHeadingIndex + 1; i < allBlocks.length; i++) {
179
+ const b = allBlocks[i];
180
+ if (b.type === "testStep" || b.type === "snippet") {
181
+ lastIndex = i;
182
+ continue;
183
+ }
184
+ if (isEmptyParagraph(b))
185
+ continue;
186
+ break;
187
+ }
188
+ const inserted = editor.insertBlocks([emptySnippet], allBlocks[lastIndex].id, "after");
189
+ return (_b = (_a = inserted === null || inserted === void 0 ? void 0 : inserted[0]) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null;
190
+ }
191
+ const lastBlock = allBlocks[allBlocks.length - 1];
192
+ const stepsHeading = {
193
+ type: "heading",
194
+ props: { level: 3 },
195
+ content: [{ type: "text", text: "Steps" }],
196
+ children: [],
197
+ };
198
+ const inserted = editor.insertBlocks([stepsHeading, emptySnippet], lastBlock.id, "after");
199
+ return (_d = (_c = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null;
200
+ }
145
201
  export const stepBlock = createReactBlockSpec({
146
202
  type: "testStep",
147
203
  content: "none",
@@ -18,6 +18,17 @@ const markdownParser = OverType.MarkdownParser;
18
18
  function ImageUploadIcon() {
19
19
  return (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M12.667 2C13.0335 2.00008 13.3474 2.13057 13.6084 2.3916C13.8694 2.65264 13.9999 2.96648 14 3.33301V12.667C13.9999 13.0335 13.8694 13.3474 13.6084 13.6084C13.3474 13.8694 13.0335 13.9999 12.667 14H3.33301C2.96648 13.9999 2.65264 13.8694 2.3916 13.6084C2.13057 13.3474 2.00008 13.0335 2 12.667V3.33301C2.00008 2.96648 2.13057 2.65264 2.3916 2.3916C2.65264 2.13057 2.96648 2.00008 3.33301 2H12.667ZM3.33301 12.667H12.667V3.33301H3.33301V12.667ZM12 11.333H4L6 8.66699L7.5 10.667L9.5 8L12 11.333ZM5.66699 4.66699C5.94455 4.66707 6.18066 4.76375 6.375 4.95801C6.56944 5.15245 6.66699 5.38921 6.66699 5.66699C6.66692 5.94463 6.56937 6.18063 6.375 6.375C6.18063 6.56937 5.94463 6.66692 5.66699 6.66699C5.38921 6.66699 5.15245 6.56944 4.95801 6.375C4.76375 6.18066 4.66707 5.94455 4.66699 5.66699C4.66699 5.38921 4.76356 5.15245 4.95801 4.95801C5.15245 4.76356 5.38921 4.66699 5.66699 4.66699Z", fill: "currentColor" }) }));
20
20
  }
21
+ function getActiveFormats(formatting, selStart, selEnd) {
22
+ const active = new Set();
23
+ if (selStart === selEnd)
24
+ return active;
25
+ for (const f of formatting) {
26
+ if (selStart < f.end && selEnd > f.start) {
27
+ active.add(f.type);
28
+ }
29
+ }
30
+ return active;
31
+ }
21
32
  function stripInlineMarkdown(markdown) {
22
33
  const links = [];
23
34
  const formatting = [];
@@ -248,8 +259,6 @@ function getCaretRectInPreview(preview, offset, textareaValue) {
248
259
  return null;
249
260
  }
250
261
  function applyFormattingHighlights(preview, formatting, textareaValue) {
251
- if (formatting.length === 0)
252
- return;
253
262
  // Remove previous formatting highlights
254
263
  const existingBold = preview.querySelectorAll("strong.step-preview-bold");
255
264
  for (let i = 0; i < existingBold.length; i++) {
@@ -287,6 +296,8 @@ function applyFormattingHighlights(preview, formatting, textareaValue) {
287
296
  // After unwrapping formatting elements, merge adjacent/empty text nodes
288
297
  // so the tree walker sees clean text nodes matching the original structure.
289
298
  preview.normalize();
299
+ if (formatting.length === 0)
300
+ return;
290
301
  // OverType splits textarea lines into <div> elements, discarding the \n
291
302
  // characters. Convert textarea-space positions (with \n) to preview-space
292
303
  // positions (without \n) so we can find the correct text nodes.
@@ -500,10 +511,14 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
500
511
  const linkSelectionRef = useRef(null);
501
512
  const linksRef = useRef([]);
502
513
  const formattingRef = useRef([]);
514
+ const formattingUndoRef = useRef([]);
515
+ const formattingRedoRef = useRef([]);
503
516
  const caretRef = useRef(null);
504
517
  const prevTextRef = useRef("");
505
518
  const isSyncingRef = useRef(false);
506
519
  const [cursorLink, setCursorLink] = useState(null);
520
+ const [activeFormats, setActiveFormats] = useState(new Set());
521
+ const [linkActive, setLinkActive] = useState(false);
507
522
  const Components = useComponentsContext();
508
523
  const resolvedPlaceholder = placeholder !== null && placeholder !== void 0 ? placeholder : "";
509
524
  useEffect(() => {
@@ -523,6 +538,8 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
523
538
  }
524
539
  linksRef.current = adjustLinksForEdit(linksRef.current, editPos, delta);
525
540
  formattingRef.current = adjustFormattingForEdit(formattingRef.current, editPos, delta);
541
+ formattingUndoRef.current = [];
542
+ formattingRedoRef.current = [];
526
543
  prevTextRef.current = nextValue;
527
544
  const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current);
528
545
  setPlainTextValue((prev) => {
@@ -831,38 +848,95 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
831
848
  };
832
849
  }, [enableImageUpload, insertImageMarkdown, onImageFile, textareaNode, uploadImage]);
833
850
  const handleToolbarAction = useCallback((action) => {
834
- var _a, _b, _c;
851
+ var _a, _b, _c, _d, _e, _f, _g;
835
852
  const instance = editorInstanceRef.current;
836
853
  if (!textareaNode || !instance) {
837
854
  return;
838
855
  }
839
856
  textareaNode.focus();
840
857
  const fmtType = action === "toggleBold" ? "bold" : action === "toggleCode" ? "code" : "italic";
841
- const start = (_a = textareaNode.selectionStart) !== null && _a !== void 0 ? _a : 0;
842
- const end = (_b = textareaNode.selectionEnd) !== null && _b !== void 0 ? _b : 0;
858
+ const rawStart = (_a = textareaNode.selectionStart) !== null && _a !== void 0 ? _a : 0;
859
+ const rawEnd = (_b = textareaNode.selectionEnd) !== null && _b !== void 0 ? _b : 0;
860
+ // Trim leading/trailing whitespace from the selection so that
861
+ // formatting markers wrap only the meaningful content.
862
+ const selectedText = textareaNode.value.slice(rawStart, rawEnd);
863
+ const leadingWs = (_d = (_c = selectedText.match(/^(\s*)/)) === null || _c === void 0 ? void 0 : _c[1].length) !== null && _d !== void 0 ? _d : 0;
864
+ const trailingWs = (_f = (_e = selectedText.match(/(\s*)$/)) === null || _e === void 0 ? void 0 : _e[1].length) !== null && _f !== void 0 ? _f : 0;
865
+ const start = rawStart + leadingWs;
866
+ const end = rawEnd - trailingWs;
867
+ // If selection is all whitespace, nothing to format
868
+ if (start >= end)
869
+ return;
843
870
  // Check if selection is already formatted
844
871
  const existingIdx = formattingRef.current.findIndex((f) => f.type === fmtType && f.start <= start && f.end >= end);
872
+ // Save current state for undo before modifying
873
+ formattingUndoRef.current = [
874
+ ...formattingUndoRef.current,
875
+ { formatting: [...formattingRef.current], links: [...linksRef.current] },
876
+ ];
877
+ formattingRedoRef.current = [];
845
878
  if (existingIdx !== -1) {
846
879
  // Remove formatting
847
880
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
848
881
  }
849
882
  else if (start !== end) {
883
+ // Remove overlapping formatting of other types before applying new format
884
+ formattingRef.current = formattingRef.current.filter((f) => f.type === fmtType || f.start >= end || f.end <= start);
850
885
  // Add formatting for selection
851
886
  formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
852
887
  }
853
888
  else {
854
889
  // No selection — nothing to format
890
+ formattingUndoRef.current = formattingUndoRef.current.slice(0, -1);
855
891
  return;
856
892
  }
857
893
  const currentValue = instance.getValue();
858
894
  prevTextRef.current = currentValue;
859
895
  const markdown = buildFullMarkdown(currentValue, linksRef.current, formattingRef.current);
860
- (_c = onChangeRef.current) === null || _c === void 0 ? void 0 : _c.call(onChangeRef, markdown);
896
+ (_g = onChangeRef.current) === null || _g === void 0 ? void 0 : _g.call(onChangeRef, markdown);
861
897
  setPlainTextValue(markdownToPlainText(markdown));
862
898
  // Re-apply highlights
863
899
  applyFormattingHighlights(instance.preview, formattingRef.current, textareaNode === null || textareaNode === void 0 ? void 0 : textareaNode.value);
864
900
  applyLinkHighlights(instance.preview, linksRef.current);
865
901
  }, [textareaNode]);
902
+ const updateActiveFormats = useCallback(() => {
903
+ var _a, _b;
904
+ if (!textareaNode)
905
+ return;
906
+ const selStart = (_a = textareaNode.selectionStart) !== null && _a !== void 0 ? _a : 0;
907
+ const selEnd = (_b = textareaNode.selectionEnd) !== null && _b !== void 0 ? _b : 0;
908
+ const next = getActiveFormats(formattingRef.current, selStart, selEnd);
909
+ setActiveFormats((prev) => {
910
+ if (prev.size === next.size && [...prev].every((t) => next.has(t)))
911
+ return prev;
912
+ return next;
913
+ });
914
+ const hasLink = selStart !== selEnd && linksRef.current.some((l) => selStart < l.end && selEnd > l.start);
915
+ setLinkActive(hasLink);
916
+ }, [textareaNode]);
917
+ useEffect(() => {
918
+ if (!textareaNode)
919
+ return;
920
+ const onSelectionChange = () => {
921
+ if (document.activeElement === textareaNode) {
922
+ updateActiveFormats();
923
+ }
924
+ };
925
+ const onBlur = () => {
926
+ setActiveFormats(new Set());
927
+ setLinkActive(false);
928
+ };
929
+ document.addEventListener("selectionchange", onSelectionChange);
930
+ textareaNode.addEventListener("keyup", updateActiveFormats);
931
+ textareaNode.addEventListener("mouseup", updateActiveFormats);
932
+ textareaNode.addEventListener("blur", onBlur);
933
+ return () => {
934
+ document.removeEventListener("selectionchange", onSelectionChange);
935
+ textareaNode.removeEventListener("keyup", updateActiveFormats);
936
+ textareaNode.removeEventListener("mouseup", updateActiveFormats);
937
+ textareaNode.removeEventListener("blur", onBlur);
938
+ };
939
+ }, [textareaNode, updateActiveFormats]);
866
940
  const linkPopoverRef = useRef(null);
867
941
  // Close link popover on outside click
868
942
  useEffect(() => {
@@ -1044,7 +1118,7 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
1044
1118
  const keydownHandlerRef = useRef(null);
1045
1119
  useEffect(() => {
1046
1120
  keydownHandlerRef.current = (event) => {
1047
- var _a, _b;
1121
+ var _a, _b, _c, _d, _e, _f;
1048
1122
  if (readOnly) {
1049
1123
  const openKeys = enableAutocomplete && (event.metaKey || event.ctrlKey) && AUTOCOMPLETE_TRIGGER_KEYS.has(event.code);
1050
1124
  if (!READ_ONLY_ALLOWED_KEYS.has(event.key) && !openKeys) {
@@ -1073,6 +1147,54 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
1073
1147
  handleToolbarAction("toggleCode");
1074
1148
  return;
1075
1149
  }
1150
+ if (event.key === "z" || event.key === "Z") {
1151
+ const undoStack = formattingUndoRef.current;
1152
+ if (undoStack.length > 0) {
1153
+ event.preventDefault();
1154
+ event.stopImmediatePropagation();
1155
+ formattingRedoRef.current = [
1156
+ ...formattingRedoRef.current,
1157
+ { formatting: [...formattingRef.current], links: [...linksRef.current] },
1158
+ ];
1159
+ const prev = undoStack[undoStack.length - 1];
1160
+ formattingUndoRef.current = undoStack.slice(0, -1);
1161
+ formattingRef.current = prev.formatting;
1162
+ linksRef.current = prev.links;
1163
+ const instance = editorInstanceRef.current;
1164
+ if (instance) {
1165
+ const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
1166
+ (_b = onChangeRef.current) === null || _b === void 0 ? void 0 : _b.call(onChangeRef, markdown);
1167
+ setPlainTextValue(markdownToPlainText(markdown));
1168
+ applyFormattingHighlights(instance.preview, formattingRef.current, (_c = instance.textarea) === null || _c === void 0 ? void 0 : _c.value);
1169
+ applyLinkHighlights(instance.preview, linksRef.current);
1170
+ }
1171
+ return;
1172
+ }
1173
+ }
1174
+ }
1175
+ if (modKey && event.shiftKey && (event.key === "z" || event.key === "Z")) {
1176
+ const redoStack = formattingRedoRef.current;
1177
+ if (redoStack.length > 0) {
1178
+ event.preventDefault();
1179
+ event.stopImmediatePropagation();
1180
+ formattingUndoRef.current = [
1181
+ ...formattingUndoRef.current,
1182
+ { formatting: [...formattingRef.current], links: [...linksRef.current] },
1183
+ ];
1184
+ const next = redoStack[redoStack.length - 1];
1185
+ formattingRedoRef.current = redoStack.slice(0, -1);
1186
+ formattingRef.current = next.formatting;
1187
+ linksRef.current = next.links;
1188
+ const instance = editorInstanceRef.current;
1189
+ if (instance) {
1190
+ const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
1191
+ (_d = onChangeRef.current) === null || _d === void 0 ? void 0 : _d.call(onChangeRef, markdown);
1192
+ setPlainTextValue(markdownToPlainText(markdown));
1193
+ applyFormattingHighlights(instance.preview, formattingRef.current, (_e = instance.textarea) === null || _e === void 0 ? void 0 : _e.value);
1194
+ applyLinkHighlights(instance.preview, linksRef.current);
1195
+ }
1196
+ return;
1197
+ }
1076
1198
  }
1077
1199
  if (enableAutocomplete && shouldShowAutocomplete) {
1078
1200
  if (event.key === "ArrowDown") {
@@ -1087,7 +1209,7 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
1087
1209
  }
1088
1210
  if (event.key === "Enter" || event.key === "Tab") {
1089
1211
  event.preventDefault();
1090
- const suggestion = (_b = filteredSuggestions[activeSuggestionIndex]) !== null && _b !== void 0 ? _b : filteredSuggestions[0];
1212
+ const suggestion = (_f = filteredSuggestions[activeSuggestionIndex]) !== null && _f !== void 0 ? _f : filteredSuggestions[0];
1091
1213
  if (suggestion) {
1092
1214
  applySuggestion(suggestion);
1093
1215
  }
@@ -1162,20 +1284,20 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
1162
1284
  }, tabIndex: -1, children: "Edit link" }), _jsx("a", { className: "bn-step-link-tooltip__btn", href: cursorLink.url, target: "_blank", rel: "noopener noreferrer", onMouseDown: (event) => event.stopPropagation(), tabIndex: -1, children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": "true", children: [_jsx("path", { d: "M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" }), _jsx("path", { d: "M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" })] }) }), _jsx("button", { type: "button", className: "bn-step-link-tooltip__btn bn-step-link-tooltip__btn--danger", onMouseDown: (event) => {
1163
1285
  event.preventDefault();
1164
1286
  handleRemoveLink();
1165
- }, tabIndex: -1, children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M7 3h2a1 1 0 0 0-2 0ZM6 3a2 2 0 1 1 4 0h4a.5.5 0 0 1 0 1h-.564l-1.205 8.838A2.5 2.5 0 0 1 9.754 15H6.246a2.5 2.5 0 0 1-2.477-2.162L2.564 4H2a.5.5 0 0 1 0-1h4Zm1 3.5a.5.5 0 0 0-1 0v5a.5.5 0 0 0 1 0v-5ZM9.5 6a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.5-.5Z" }) }) })] })), showToolbar && (_jsxs("div", { className: "bn-step-toolbar", "aria-label": `${label} controls`, children: [showFormattingButtons && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bn-step-toolbar__button", "data-tooltip": "Bold", onMouseDown: (event) => {
1287
+ }, tabIndex: -1, children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M7 3h2a1 1 0 0 0-2 0ZM6 3a2 2 0 1 1 4 0h4a.5.5 0 0 1 0 1h-.564l-1.205 8.838A2.5 2.5 0 0 1 9.754 15H6.246a2.5 2.5 0 0 1-2.477-2.162L2.564 4H2a.5.5 0 0 1 0-1h4Zm1 3.5a.5.5 0 0 0-1 0v5a.5.5 0 0 0 1 0v-5ZM9.5 6a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.5-.5Z" }) }) })] })), showToolbar && (_jsxs("div", { className: "bn-step-toolbar", "aria-label": `${label} controls`, children: [showFormattingButtons && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `bn-step-toolbar__button${activeFormats.has("bold") ? " bn-step-toolbar__button--active" : ""}`, "data-tooltip": "Bold", onMouseDown: (event) => {
1166
1288
  event.preventDefault();
1167
1289
  handleToolbarAction("toggleBold");
1168
- }, "aria-label": "Bold", tabIndex: -1, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 2.66675H8.33333C8.92064 2.66677 9.49502 2.83918 9.98525 3.1626C10.4755 3.48602 10.86 3.94622 11.0911 4.48613C11.3223 5.02604 11.3898 5.62192 11.2855 6.19988C11.1811 6.77783 10.9094 7.31244 10.504 7.73741C11.0752 8.06825 11.5213 8.57823 11.7733 9.18833C12.0252 9.79844 12.0689 10.4746 11.8976 11.1121C11.7263 11.7495 11.3495 12.3127 10.8256 12.7143C10.3018 13.1159 9.66008 13.3335 9 13.3334H4V12.0001H4.66667V4.00008H4V2.66675ZM6 7.33341H8.33333C8.77536 7.33341 9.19928 7.15782 9.51184 6.84526C9.8244 6.5327 10 6.10878 10 5.66675C10 5.22472 9.8244 4.8008 9.51184 4.48824C9.19928 4.17568 8.77536 4.00008 8.33333 4.00008H6V7.33341ZM6 8.66675V12.0001H9C9.44203 12.0001 9.86595 11.8245 10.1785 11.5119C10.4911 11.1994 10.6667 10.7754 10.6667 10.3334C10.6667 9.89139 10.4911 9.46746 10.1785 9.1549C9.86595 8.84234 9.44203 8.66675 9 8.66675H6Z", fill: "currentColor" }) }) }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", "data-tooltip": "Italic", onMouseDown: (event) => {
1290
+ }, "aria-label": "Bold", tabIndex: -1, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 2.66675H8.33333C8.92064 2.66677 9.49502 2.83918 9.98525 3.1626C10.4755 3.48602 10.86 3.94622 11.0911 4.48613C11.3223 5.02604 11.3898 5.62192 11.2855 6.19988C11.1811 6.77783 10.9094 7.31244 10.504 7.73741C11.0752 8.06825 11.5213 8.57823 11.7733 9.18833C12.0252 9.79844 12.0689 10.4746 11.8976 11.1121C11.7263 11.7495 11.3495 12.3127 10.8256 12.7143C10.3018 13.1159 9.66008 13.3335 9 13.3334H4V12.0001H4.66667V4.00008H4V2.66675ZM6 7.33341H8.33333C8.77536 7.33341 9.19928 7.15782 9.51184 6.84526C9.8244 6.5327 10 6.10878 10 5.66675C10 5.22472 9.8244 4.8008 9.51184 4.48824C9.19928 4.17568 8.77536 4.00008 8.33333 4.00008H6V7.33341ZM6 8.66675V12.0001H9C9.44203 12.0001 9.86595 11.8245 10.1785 11.5119C10.4911 11.1994 10.6667 10.7754 10.6667 10.3334C10.6667 9.89139 10.4911 9.46746 10.1785 9.1549C9.86595 8.84234 9.44203 8.66675 9 8.66675H6Z", fill: "currentColor" }) }) }), _jsx("button", { type: "button", className: `bn-step-toolbar__button${activeFormats.has("italic") ? " bn-step-toolbar__button--active" : ""}`, "data-tooltip": "Italic", onMouseDown: (event) => {
1169
1291
  event.preventDefault();
1170
1292
  handleToolbarAction("toggleItalic");
1171
- }, "aria-label": "Italic", tabIndex: -1, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M8.66699 13.3334H4.66699V12.0001H5.95166L8.69566 4.00008H7.33366V2.66675H11.3337V4.00008H10.049L7.30499 12.0001H8.66699V13.3334Z", fill: "currentColor" }) }) }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", "data-tooltip": "Code", onMouseDown: (event) => {
1293
+ }, "aria-label": "Italic", tabIndex: -1, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M8.66699 13.3334H4.66699V12.0001H5.95166L8.69566 4.00008H7.33366V2.66675H11.3337V4.00008H10.049L7.30499 12.0001H8.66699V13.3334Z", fill: "currentColor" }) }) }), _jsx("button", { type: "button", className: `bn-step-toolbar__button${activeFormats.has("code") ? " bn-step-toolbar__button--active" : ""}`, "data-tooltip": "Code", onMouseDown: (event) => {
1172
1294
  event.preventDefault();
1173
1295
  handleToolbarAction("toggleCode");
1174
1296
  }, "aria-label": "Code", tabIndex: -1, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M10.333 12.6667L14 8.00008L10.333 3.33341L9.15833 4.28341L12.1583 8.00008L9.15833 11.7167L10.333 12.6667ZM5.66699 12.6667L6.84166 11.7167L3.84166 8.00008L6.84166 4.28341L5.66699 3.33341L2 8.00008L5.66699 12.6667Z", fill: "currentColor" }) }) })] })), enableImageUpload && uploadImage && showImageButton && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", "data-tooltip": "Insert image", onMouseDown: (event) => {
1175
1297
  var _a;
1176
1298
  event.preventDefault();
1177
1299
  (_a = fileInputRef.current) === null || _a === void 0 ? void 0 : _a.click();
1178
- }, "aria-label": "Insert image", tabIndex: -1, disabled: isUploading, children: _jsx(ImageUploadIcon, {}) })), showFormattingButtons && Components && (_jsxs(Components.Generic.Popover.Root, { opened: showLinkPopover, position: "top", children: [_jsx(Components.Generic.Popover.Trigger, { children: _jsx("button", { type: "button", className: "bn-step-toolbar__button", "data-tooltip": "Insert link", onMouseDown: (event) => {
1300
+ }, "aria-label": "Insert image", tabIndex: -1, disabled: isUploading, children: _jsx(ImageUploadIcon, {}) })), showFormattingButtons && Components && (_jsxs(Components.Generic.Popover.Root, { opened: showLinkPopover, position: "top", children: [_jsx(Components.Generic.Popover.Trigger, { children: _jsx("button", { type: "button", className: `bn-step-toolbar__button${linkActive ? " bn-step-toolbar__button--active" : ""}`, "data-tooltip": "Insert link", onMouseDown: (event) => {
1179
1301
  event.preventDefault();
1180
1302
  if (showLinkPopover) {
1181
1303
  setShowLinkPopover(false);
@@ -685,12 +685,9 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
685
685
  if (detectedType !== listType) {
686
686
  break;
687
687
  }
688
- // Only try to parse as testStep for top-level items (indentLevel === 0)
689
- // Under a Steps heading (allowEmptySteps=true): always try for both bullet and numbered
690
- // Outside Steps heading: only if the item looks like a test step (has Expected markers or indented data)
691
- if (indentLevel === 0 && (allowEmptySteps || isLikelyStep(lines, index))) {
692
- const looksLikeTestStep = listType === "bullet" ||
693
- (listType === "numbered" && (allowEmptySteps || isLikelyStep(lines, index)));
688
+ // Only try to parse as testStep for top-level items under a Steps heading
689
+ if (indentLevel === 0 && allowEmptySteps) {
690
+ const looksLikeTestStep = listType === "bullet" || listType === "numbered";
694
691
  if (looksLikeTestStep) {
695
692
  const nextStep = parseTestStep(lines, index, allowEmptySteps);
696
693
  if (nextStep) {
@@ -736,32 +733,6 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
736
733
  }
737
734
  return { items, nextIndex: index };
738
735
  }
739
- function isLikelyStep(lines, index) {
740
- // Look ahead to see if there's indented content or expected result
741
- // Look ahead through subsequent lines for expected result markers or indented content
742
- for (let i = index + 1; i < lines.length; i++) {
743
- const line = lines[i];
744
- const trimmed = line.trim();
745
- // Stop at blank lines
746
- if (!trimmed)
747
- break;
748
- // Check for indented content (step data) first — indented lines indicate a test step
749
- const hasIndent = /^\s{2,}/.test(line);
750
- if (hasIndent)
751
- return true;
752
- // Stop at new list items, headings, or other block-level elements (only if not indented)
753
- if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed))
754
- break;
755
- if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::"))
756
- break;
757
- // Check for expected result markers
758
- if (EXPECTED_LABEL_REGEX.test(trimmed))
759
- return true;
760
- if (trimmed.match(/^\*[^*]*expected/i))
761
- return true;
762
- }
763
- return false;
764
- }
765
736
  function parseTestStep(lines, index, allowEmpty = false, snippetId) {
766
737
  const current = lines[index];
767
738
  const trimmed = current.trim();
@@ -1225,14 +1196,16 @@ export function markdownToBlocks(markdown, options) {
1225
1196
  }
1226
1197
  continue;
1227
1198
  }
1228
- const snippetWrapper = parseSnippetWrapper(lines, index);
1199
+ const snippetWrapper = stepsHeadingLevel !== null
1200
+ ? parseSnippetWrapper(lines, index)
1201
+ : null;
1229
1202
  if (snippetWrapper) {
1230
1203
  blocks.push(snippetWrapper.block);
1231
1204
  index = snippetWrapper.nextIndex;
1232
1205
  continue;
1233
1206
  }
1234
- const stepLikeBlock = (stepsHeadingLevel !== null || isLikelyStep(lines, index))
1235
- ? parseTestStep(lines, index, stepsHeadingLevel !== null)
1207
+ const stepLikeBlock = stepsHeadingLevel !== null
1208
+ ? parseTestStep(lines, index, true)
1236
1209
  : null;
1237
1210
  if (stepLikeBlock) {
1238
1211
  blocks.push(stepLikeBlock.block);
@@ -1,5 +1,5 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
- export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock } from "./editor/blocks/step";
2
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
5
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
package/package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
- export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock } from "./editor/blocks/step";
2
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
5
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
@@ -942,6 +942,11 @@ html.dark .bn-step-image-preview__content {
942
942
  background: var(--step-bg-button-hover);
943
943
  }
944
944
 
945
+ .bn-step-toolbar__button--active {
946
+ background: var(--step-bg-button-hover);
947
+ color: var(--step-input-border-focus);
948
+ }
949
+
945
950
  [data-tooltip] {
946
951
  position: relative;
947
952
  }
@@ -1074,8 +1079,7 @@ html.dark .bn-step-image-preview__content {
1074
1079
  }
1075
1080
 
1076
1081
  .bn-step-editor .overtype-wrapper .overtype-preview strong.step-preview-bold {
1077
- -webkit-text-stroke: 0.5px currentColor;
1078
- font-weight: inherit !important;
1082
+ font-weight: 600 !important;
1079
1083
  color: inherit !important;
1080
1084
  }
1081
1085
 
@@ -1085,9 +1089,10 @@ html.dark .bn-step-image-preview__content {
1085
1089
  }
1086
1090
 
1087
1091
  .bn-step-editor .overtype-wrapper .overtype-preview code.step-preview-code {
1088
- background-color: rgba(135, 131, 120, 0.15) !important;
1092
+ background-color: transparent !important;
1089
1093
  border-radius: 3px !important;
1090
- color: inherit !important;
1094
+ font-family: "Fira Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
1095
+ color: rgb(146, 64, 14) !important;
1091
1096
  }
1092
1097
 
1093
1098
  .bn-step-custom-caret {
@@ -1334,6 +1339,11 @@ html.dark .bn-step-image-preview__content {
1334
1339
  pointer-events: none;
1335
1340
  }
1336
1341
 
1342
+ .bn-suggestion-menu .mantine-Badge-label {
1343
+ text-transform: none;
1344
+ font-size: var(--badge-fz-sm);
1345
+ }
1346
+
1337
1347
  .bn-suggestion-icon {
1338
1348
  display: inline-flex;
1339
1349
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.46",
3
+ "version": "0.4.48",
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",
package/src/App.tsx CHANGED
@@ -15,13 +15,14 @@ import {
15
15
  blocksToMarkdown,
16
16
  markdownToBlocks,
17
17
  type CustomEditorBlock,
18
- type CustomPartialBlock,
18
+
19
19
  } from "./editor/customMarkdownConverter";
20
20
  import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
21
21
  import { customSchema, type CustomEditor } from "./editor/customSchema";
22
22
  import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
23
23
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
24
24
  import { setImageUploadHandler } from "./editor/stepImageUpload";
25
+ import { canInsertStepOrSnippet, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
25
26
  import "./App.css";
26
27
 
27
28
  const focusStepField = (
@@ -291,7 +292,18 @@ function CustomSlashMenu() {
291
292
  }
292
293
 
293
294
  const getItems = async (query: string) => {
294
- const defaultItems = getDefaultReactSlashMenuItems(editor);
295
+ const isMac =
296
+ typeof navigator !== "undefined" &&
297
+ (/Mac/.test(navigator.platform) ||
298
+ (/AppleWebKit/.test(navigator.userAgent) &&
299
+ /Mobile\/\w+/.test(navigator.userAgent)));
300
+
301
+ const defaultItems = getDefaultReactSlashMenuItems(editor).map((item) => {
302
+ if (item.badge && isMac) {
303
+ return { ...item, badge: item.badge.replace("Alt", "Option") };
304
+ }
305
+ return item;
306
+ });
295
307
 
296
308
  const stepItem = {
297
309
  key: "test_step" as any,
@@ -334,7 +346,12 @@ function CustomSlashMenu() {
334
346
  },
335
347
  };
336
348
 
337
- return filterSuggestionItems([...defaultItems, stepItem, snippetItem], query);
349
+ const currentBlock = editor.getTextCursorPosition().block;
350
+ const canInsert = canInsertStepOrSnippet(editor, currentBlock.id);
351
+ const items = canInsert
352
+ ? [...defaultItems, stepItem, snippetItem]
353
+ : defaultItems;
354
+ return filterSuggestionItems(items, query);
338
355
  };
339
356
 
340
357
  return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
@@ -451,52 +468,14 @@ function App() {
451
468
  };
452
469
  }, [editor, uploadStepImage]);
453
470
 
454
- const createTestStepBlock = useMemo<() => CustomPartialBlock>(() => {
455
- return () => ({
456
- type: "testStep",
457
- props: {
458
- stepTitle: "",
459
- stepData: "",
460
- expectedResult: "",
461
- },
462
- children: [],
463
- });
464
- }, []);
465
-
466
- const createSnippetBlock = useMemo<() => CustomPartialBlock>(() => {
467
- return () => ({
468
- type: "snippet",
469
- props: {
470
- snippetId: "",
471
- snippetTitle: "",
472
- snippetData: "",
473
- snippetExpectedResult: "",
474
- },
475
- children: [],
476
- });
477
- }, []);
478
-
479
- const insertBlockAfterSelection = (createBlock: () => CustomPartialBlock, focusFieldName?: string) => {
480
- const selection = editor.getSelection();
481
- const selectedBlocks = selection?.blocks ?? [];
482
- const selectedBlock = selectedBlocks[selectedBlocks.length - 1];
483
- const documentBlocks = editor.document;
484
- const fallbackBlock = documentBlocks[documentBlocks.length - 1];
485
- const referenceId = selectedBlock?.id ?? fallbackBlock?.id;
486
-
487
- if (!referenceId) {
488
- return;
489
- }
490
-
491
- const inserted = editor.insertBlocks([createBlock()], referenceId, "after");
492
- const firstInserted = inserted[0];
493
- if (firstInserted && focusFieldName) {
494
- focusStepField(editor, firstInserted.id, focusFieldName);
495
- }
471
+ const insertTestStep = () => {
472
+ const id = addStepsBlock(editor);
473
+ if (id) focusStepField(editor, id, "title");
474
+ };
475
+ const insertSnippet = () => {
476
+ const id = addSnippetBlock(editor);
477
+ if (id) focusStepField(editor, id, "snippet-title");
496
478
  };
497
-
498
- const insertTestStep = () => insertBlockAfterSelection(createTestStepBlock, "title");
499
- const insertSnippet = () => insertBlockAfterSelection(createSnippetBlock, "snippet-title");
500
479
 
501
480
  const handleCopyMarkdown = async () => {
502
481
  if (conversionError) {