testomatio-editor-blocks 0.4.34 → 0.4.36

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.
@@ -212,32 +212,6 @@ export const stepBlock = createReactBlockSpec(
212
212
  return count;
213
213
  }, [block.id, documentVersion, editor.document]);
214
214
 
215
- // Check if there is a preceding "Steps" heading
216
- const hasStepsHeading = useMemo(() => {
217
- const allBlocks = editor.document;
218
- const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
219
- if (blockIndex < 0) return false;
220
-
221
- for (let i = blockIndex - 1; i >= 0; i--) {
222
- const b = allBlocks[i];
223
- if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
224
- continue;
225
- }
226
- if (b.type === "heading") {
227
- const text = (Array.isArray(b.content) ? b.content : [])
228
- .filter((n: any) => n.type === "text")
229
- .map((n: any) => n.text ?? "")
230
- .join("")
231
- .trim()
232
- .toLowerCase()
233
- .replace(/[:\-–—]$/, "");
234
- return isStepsHeading(text);
235
- }
236
- return false;
237
- }
238
- return false;
239
- }, [block.id, documentVersion, editor.document]);
240
-
241
215
  useEditorChange(() => {
242
216
  setDocumentVersion((version) => version + 1);
243
217
  }, editor);
@@ -417,17 +391,6 @@ export const stepBlock = createReactBlockSpec(
417
391
  const canToggleData = !dataHasContent;
418
392
  const canToggleExpected = !expectedHasContent;
419
393
 
420
- // Render as plain text when not under a "Steps" heading
421
- if (!hasStepsHeading) {
422
- return (
423
- <div className="bn-teststep-plain" data-block-id={block.id}>
424
- <span>{stepTitle || "(empty step)"}</span>
425
- {stepData ? <span className="bn-teststep-plain__data">{stepData}</span> : null}
426
- {expectedResult ? <span className="bn-teststep-plain__expected">{expectedResult}</span> : null}
427
- </div>
428
- );
429
- }
430
-
431
394
  if (viewMode === "horizontal") {
432
395
  return (
433
396
  <StepHorizontalView
@@ -80,7 +80,7 @@ type ExtractedImage = {
80
80
  };
81
81
 
82
82
  type LinkMeta = { start: number; end: number; url: string };
83
- type FormattingMeta = { start: number; end: number; type: "bold" | "italic" };
83
+ type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
84
84
 
85
85
 
86
86
  function stripInlineMarkdown(markdown: string): {
@@ -207,6 +207,34 @@ function stripInlineMarkdown(markdown: string): {
207
207
  }
208
208
  }
209
209
 
210
+ // Code block: ```\n...\n``` (triple backticks with newlines)
211
+ if (markdown[i] === "`" && markdown[i + 1] === "`" && markdown[i + 2] === "`") {
212
+ const contentStart = markdown[i + 3] === "\n" ? i + 4 : i + 3;
213
+ const closeIdx = markdown.indexOf("```", contentStart);
214
+ if (closeIdx !== -1) {
215
+ const contentEnd = markdown[closeIdx - 1] === "\n" ? closeIdx - 1 : closeIdx;
216
+ const inner = markdown.slice(contentStart, contentEnd);
217
+ const start = plainText.length;
218
+ plainText += inner;
219
+ formatting.push({ start, end: plainText.length, type: "code" });
220
+ i = closeIdx + 3;
221
+ continue;
222
+ }
223
+ }
224
+
225
+ // Inline code: `text`
226
+ if (markdown[i] === "`") {
227
+ const closeIdx = markdown.indexOf("`", i + 1);
228
+ if (closeIdx !== -1) {
229
+ const inner = markdown.slice(i + 1, closeIdx);
230
+ const start = plainText.length;
231
+ plainText += inner;
232
+ formatting.push({ start, end: plainText.length, type: "code" });
233
+ i = closeIdx + 1;
234
+ continue;
235
+ }
236
+ }
237
+
210
238
  plainText += markdown[i];
211
239
  i++;
212
240
  }
@@ -224,13 +252,23 @@ function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: For
224
252
  const markers: Marker[] = [];
225
253
 
226
254
  for (const fmt of formatting) {
227
- const marker = fmt.type === "bold" ? "**" : "*";
255
+ let openMarker: string;
256
+ let closeMarker: string;
257
+ if (fmt.type === "code") {
258
+ const content = plainText.slice(fmt.start, fmt.end);
259
+ const isMultiline = content.includes("\n");
260
+ openMarker = isMultiline ? "```\n" : "`";
261
+ closeMarker = isMultiline ? "\n```" : "`";
262
+ } else {
263
+ openMarker = fmt.type === "bold" ? "**" : "*";
264
+ closeMarker = openMarker;
265
+ }
228
266
  // Opening: outer markers (bold) before inner (italic) → bold order=0, italic order=1
229
267
  // Closing: inner markers (italic) before outer (bold) → italic order=0, bold order=1
230
- const openOrder = fmt.type === "bold" ? 0 : 1;
231
- const closeOrder = fmt.type === "bold" ? 1 : 0;
232
- markers.push({ pos: fmt.start, text: marker, order: openOrder });
233
- markers.push({ pos: fmt.end, text: marker, order: closeOrder });
268
+ const openOrder = fmt.type === "bold" ? 0 : fmt.type === "code" ? 2 : 1;
269
+ const closeOrder = fmt.type === "bold" ? 1 : fmt.type === "code" ? -1 : 0;
270
+ markers.push({ pos: fmt.start, text: openMarker, order: openOrder });
271
+ markers.push({ pos: fmt.end, text: closeMarker, order: closeOrder });
234
272
  }
235
273
 
236
274
  for (const link of links) {
@@ -265,7 +303,16 @@ function adjustFormattingForEdit(formatting: FormattingMeta[], editPos: number,
265
303
  .filter((fmt) => fmt.end > fmt.start);
266
304
  }
267
305
 
268
- function getCaretRectInPreview(preview: HTMLElement, offset: number): { top: number; left: number; height: number } | null {
306
+ function getCaretRectInPreview(preview: HTMLElement, offset: number, textareaValue?: string): { top: number; left: number; height: number } | null {
307
+ // Convert textarea-space offset to preview-space (strip newlines)
308
+ let nlCount = 0;
309
+ if (textareaValue) {
310
+ for (let i = 0; i < offset && i < textareaValue.length; i++) {
311
+ if (textareaValue[i] === "\n") nlCount++;
312
+ }
313
+ }
314
+ const previewOffset = offset - nlCount;
315
+
269
316
  const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT);
270
317
  let currentOffset = 0;
271
318
 
@@ -273,8 +320,8 @@ function getCaretRectInPreview(preview: HTMLElement, offset: number): { top: num
273
320
  const textNode = walker.currentNode as Text;
274
321
  const nodeLen = textNode.length;
275
322
 
276
- if (offset <= currentOffset + nodeLen) {
277
- const localOffset = offset - currentOffset;
323
+ if (previewOffset <= currentOffset + nodeLen) {
324
+ const localOffset = previewOffset - currentOffset;
278
325
  try {
279
326
  const range = document.createRange();
280
327
  range.setStart(textNode, localOffset);
@@ -297,7 +344,7 @@ function getCaretRectInPreview(preview: HTMLElement, offset: number): { top: num
297
344
  return null;
298
345
  }
299
346
 
300
- function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingMeta[]) {
347
+ function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingMeta[], textareaValue?: string) {
301
348
  if (formatting.length === 0) return;
302
349
 
303
350
  // Remove previous formatting highlights
@@ -323,10 +370,41 @@ function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingM
323
370
  parent.removeChild(el);
324
371
  }
325
372
  }
373
+ const existingCode = preview.querySelectorAll("code.step-preview-code");
374
+ for (let i = 0; i < existingCode.length; i++) {
375
+ const el = existingCode[i];
376
+ const parent = el.parentNode;
377
+ if (parent) {
378
+ while (el.firstChild) {
379
+ parent.insertBefore(el.firstChild, el);
380
+ }
381
+ parent.removeChild(el);
382
+ }
383
+ }
384
+
385
+ // After unwrapping formatting elements, merge adjacent/empty text nodes
386
+ // so the tree walker sees clean text nodes matching the original structure.
387
+ preview.normalize();
388
+
389
+ // OverType splits textarea lines into <div> elements, discarding the \n
390
+ // characters. Convert textarea-space positions (with \n) to preview-space
391
+ // positions (without \n) so we can find the correct text nodes.
392
+ function taToPreview(taPos: number): number {
393
+ if (!textareaValue) return taPos;
394
+ let nlCount = 0;
395
+ for (let i = 0; i < taPos && i < textareaValue.length; i++) {
396
+ if (textareaValue[i] === "\n") nlCount++;
397
+ }
398
+ return taPos - nlCount;
399
+ }
326
400
 
327
401
  const sorted = [...formatting].sort((a, b) => b.start - a.start);
328
402
 
329
403
  for (const fmt of sorted) {
404
+ const pStart = taToPreview(fmt.start);
405
+ const pEnd = taToPreview(fmt.end);
406
+
407
+ // Collect text nodes with their preview-space offsets
330
408
  const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT);
331
409
  let currentOffset = 0;
332
410
  let startNode: Text | null = null;
@@ -339,13 +417,13 @@ function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingM
339
417
  const nodeStart = currentOffset;
340
418
  const nodeEnd = currentOffset + textNode.length;
341
419
 
342
- if (!startNode && fmt.start >= nodeStart && fmt.start < nodeEnd) {
420
+ if (!startNode && pStart >= nodeStart && pStart < nodeEnd) {
343
421
  startNode = textNode;
344
- startLocalOffset = fmt.start - nodeStart;
422
+ startLocalOffset = pStart - nodeStart;
345
423
  }
346
- if (!endNode && fmt.end > nodeStart && fmt.end <= nodeEnd) {
424
+ if (!endNode && pEnd > nodeStart && pEnd <= nodeEnd) {
347
425
  endNode = textNode;
348
- endLocalOffset = fmt.end - nodeStart;
426
+ endLocalOffset = pEnd - nodeStart;
349
427
  }
350
428
 
351
429
  currentOffset = nodeEnd;
@@ -354,19 +432,58 @@ function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingM
354
432
 
355
433
  if (!startNode || !endNode) continue;
356
434
 
357
- try {
358
- const range = document.createRange();
359
- range.setStart(startNode, startLocalOffset);
360
- range.setEnd(endNode, endLocalOffset);
435
+ const tagName = fmt.type === "bold" ? "strong" : fmt.type === "code" ? "code" : "em";
436
+ const className = fmt.type === "bold" ? "step-preview-bold" : fmt.type === "code" ? "step-preview-code" : "step-preview-italic";
361
437
 
362
- const wrapper = document.createElement(fmt.type === "bold" ? "strong" : "em");
363
- wrapper.className = fmt.type === "bold" ? "step-preview-bold" : "step-preview-italic";
364
-
365
- const fragment = range.extractContents();
366
- wrapper.appendChild(fragment);
367
- range.insertNode(wrapper);
368
- } catch {
369
- // DOM manipulation can fail if range crosses element boundaries
438
+ // If start and end are in the same text node, wrap directly
439
+ if (startNode === endNode) {
440
+ try {
441
+ const range = document.createRange();
442
+ range.setStart(startNode, startLocalOffset);
443
+ range.setEnd(endNode, endLocalOffset);
444
+ const wrapper = document.createElement(tagName);
445
+ wrapper.className = className;
446
+ const fragment = range.extractContents();
447
+ wrapper.appendChild(fragment);
448
+ range.insertNode(wrapper);
449
+ } catch {
450
+ // DOM manipulation can fail if range crosses element boundaries
451
+ }
452
+ } else {
453
+ // Multi-node range (e.g. code spanning multiple lines/divs):
454
+ // collect all text nodes in the range, then wrap each one individually
455
+ const textNodes: { node: Text; localStart: number; localEnd: number }[] = [];
456
+ const walker2 = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT);
457
+ let collecting = false;
458
+ while (walker2.nextNode()) {
459
+ const tn = walker2.currentNode as Text;
460
+ if (tn === startNode) {
461
+ collecting = true;
462
+ textNodes.push({ node: tn, localStart: startLocalOffset, localEnd: tn.length });
463
+ } else if (tn === endNode) {
464
+ textNodes.push({ node: tn, localStart: 0, localEnd: endLocalOffset });
465
+ break;
466
+ } else if (collecting) {
467
+ textNodes.push({ node: tn, localStart: 0, localEnd: tn.length });
468
+ }
469
+ }
470
+ // Wrap in reverse order to preserve offsets
471
+ for (let ti = textNodes.length - 1; ti >= 0; ti--) {
472
+ const { node, localStart, localEnd } = textNodes[ti];
473
+ if (localStart >= localEnd) continue;
474
+ try {
475
+ const range = document.createRange();
476
+ range.setStart(node, localStart);
477
+ range.setEnd(node, localEnd);
478
+ const wrapper = document.createElement(tagName);
479
+ wrapper.className = className;
480
+ const fragment = range.extractContents();
481
+ wrapper.appendChild(fragment);
482
+ range.insertNode(wrapper);
483
+ } catch {
484
+ // skip nodes that can't be wrapped
485
+ }
486
+ }
370
487
  }
371
488
  }
372
489
  }
@@ -576,11 +693,11 @@ export function StepField({
576
693
  const originalUpdatePreview = instance.updatePreview.bind(instance);
577
694
  instance.updatePreview = function () {
578
695
  originalUpdatePreview();
579
- applyFormattingHighlights(this.preview, formattingRef.current);
696
+ applyFormattingHighlights(this.preview, formattingRef.current, this.textarea?.value);
580
697
  applyLinkHighlights(this.preview, linksRef.current);
581
698
  };
582
699
  // Apply initial highlights
583
- applyFormattingHighlights(instance.preview, formattingRef.current);
700
+ applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
584
701
  applyLinkHighlights(instance.preview, linksRef.current);
585
702
 
586
703
  // Create custom caret element inside the wrapper
@@ -633,7 +750,7 @@ export function StepField({
633
750
  return;
634
751
  }
635
752
 
636
- const rect = getCaretRectInPreview(instance.preview, pos);
753
+ const rect = getCaretRectInPreview(instance.preview, pos, instance.textarea?.value);
637
754
  if (rect) {
638
755
  caret.style.display = "block";
639
756
  caret.style.top = `${rect.top}px`;
@@ -712,7 +829,7 @@ export function StepField({
712
829
  isSyncingRef.current = false;
713
830
  } else {
714
831
  // Even if text didn't change, formatting/links might have — re-apply highlights
715
- applyFormattingHighlights(instance.preview, formatting);
832
+ applyFormattingHighlights(instance.preview, formatting, instance.textarea?.value);
716
833
  applyLinkHighlights(instance.preview, links);
717
834
  }
718
835
 
@@ -899,14 +1016,14 @@ export function StepField({
899
1016
  }, [enableImageUpload, insertImageMarkdown, onImageFile, textareaNode, uploadImage]);
900
1017
 
901
1018
  const handleToolbarAction = useCallback(
902
- (action: "toggleBold" | "toggleItalic") => {
1019
+ (action: "toggleBold" | "toggleItalic" | "toggleCode") => {
903
1020
  const instance = editorInstanceRef.current;
904
1021
  if (!textareaNode || !instance) {
905
1022
  return;
906
1023
  }
907
1024
  textareaNode.focus();
908
1025
 
909
- const fmtType: "bold" | "italic" = action === "toggleBold" ? "bold" : "italic";
1026
+ const fmtType: "bold" | "italic" | "code" = action === "toggleBold" ? "bold" : action === "toggleCode" ? "code" : "italic";
910
1027
  const start = textareaNode.selectionStart ?? 0;
911
1028
  const end = textareaNode.selectionEnd ?? 0;
912
1029
 
@@ -934,7 +1051,7 @@ export function StepField({
934
1051
  setPlainTextValue(markdownToPlainText(markdown));
935
1052
 
936
1053
  // Re-apply highlights
937
- applyFormattingHighlights(instance.preview, formattingRef.current);
1054
+ applyFormattingHighlights(instance.preview, formattingRef.current, textareaNode?.value);
938
1055
  applyLinkHighlights(instance.preview, linksRef.current);
939
1056
  },
940
1057
  [textareaNode],
@@ -1022,7 +1139,7 @@ export function StepField({
1022
1139
  const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
1023
1140
  onChangeRef.current?.(markdown);
1024
1141
  // Re-apply highlights since links changed
1025
- applyFormattingHighlights(instance.preview, formattingRef.current);
1142
+ applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
1026
1143
  applyLinkHighlights(instance.preview, linksRef.current);
1027
1144
  }
1028
1145
  }, [cursorLink]);
@@ -1187,6 +1304,12 @@ export function StepField({
1187
1304
  handleToolbarAction("toggleItalic");
1188
1305
  return;
1189
1306
  }
1307
+ if (event.key === "e" || event.key === "E") {
1308
+ event.preventDefault();
1309
+ event.stopImmediatePropagation();
1310
+ handleToolbarAction("toggleCode");
1311
+ return;
1312
+ }
1190
1313
  }
1191
1314
 
1192
1315
  if (enableAutocomplete && shouldShowAutocomplete) {
@@ -1394,6 +1517,21 @@ export function StepField({
1394
1517
  <path d="M8.66699 13.3334H4.66699V12.0001H5.95166L8.69566 4.00008H7.33366V2.66675H11.3337V4.00008H10.049L7.30499 12.0001H8.66699V13.3334Z" fill="currentColor"/>
1395
1518
  </svg>
1396
1519
  </button>
1520
+ <button
1521
+ type="button"
1522
+ className="bn-step-toolbar__button"
1523
+ data-tooltip="Code"
1524
+ onMouseDown={(event) => {
1525
+ event.preventDefault();
1526
+ handleToolbarAction("toggleCode");
1527
+ }}
1528
+ aria-label="Code"
1529
+ tabIndex={-1}
1530
+ >
1531
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
1532
+ <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"/>
1533
+ </svg>
1534
+ </button>
1397
1535
  </>
1398
1536
  )}
1399
1537
  {enableImageUpload && uploadImage && showImageButton && (