testomatio-editor-blocks 0.4.49 → 0.4.51

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.
@@ -51,10 +51,10 @@ const writeStepViewMode = (mode: StepViewMode) => {
51
51
  /**
52
52
  * Returns true when a normalised (lowercased, trailing-punctuation-stripped)
53
53
  * heading text looks like a "Steps" heading.
54
- * Accepted forms: steps, step, step(s).
54
+ * Accepted forms: steps, step, step(s), test steps, test step, test step(s).
55
55
  */
56
56
  export function isStepsHeading(text: string): boolean {
57
- return /^step(s|\(s\))?$/.test(text);
57
+ return /^(test\s+)?step(s|\(s\))?$/.test(text);
58
58
  }
59
59
 
60
60
  export const isEmptyParagraph = (b: any): boolean =>
@@ -337,12 +337,6 @@ export const stepBlock = createReactBlockSpec(
337
337
  [block.id, combinedStepValue, editor],
338
338
  );
339
339
 
340
- useEffect(() => {
341
- if (dataHasContent && !isDataVisible) {
342
- setIsDataVisible(true);
343
- }
344
- }, [dataHasContent, isDataVisible]);
345
-
346
340
  const handleStepTitleChange = useCallback(
347
341
  (next: string) => {
348
342
  if (next === stepTitle) {
@@ -380,7 +374,8 @@ export const stepBlock = createReactBlockSpec(
380
374
 
381
375
  const handleHideData = useCallback(() => {
382
376
  setIsDataVisible(false);
383
- }, []);
377
+ editor.updateBlock(block.id, { props: { stepData: "" } });
378
+ }, [editor, block.id]);
384
379
 
385
380
  const handleExpectedChange = useCallback(
386
381
  (next: string) => {
@@ -457,16 +452,8 @@ export const stepBlock = createReactBlockSpec(
457
452
  const handleHideExpected = useCallback(() => {
458
453
  setIsExpectedVisible(false);
459
454
  writeExpectedCollapsedPreference(true);
460
- }, []);
461
-
462
- useEffect(() => {
463
- if (expectedHasContent && !isExpectedVisible) {
464
- setIsExpectedVisible(true);
465
- }
466
- }, [expectedHasContent, isExpectedVisible]);
467
-
468
- const canToggleData = !dataHasContent;
469
- const canToggleExpected = !expectedHasContent;
455
+ editor.updateBlock(block.id, { props: { expectedResult: "" } });
456
+ }, [editor, block.id]);
470
457
 
471
458
  const viewToggleButton = (
472
459
  <button
@@ -523,6 +510,7 @@ export const stepBlock = createReactBlockSpec(
523
510
  placeholder={STEP_TITLE_PLACEHOLDER}
524
511
  onChange={handleStepTitleChange}
525
512
  autoFocus={stepTitle.length === 0}
513
+ multiline
526
514
  enableAutocomplete
527
515
  fieldName="title"
528
516
  suggestionFilter={(suggestion) => (suggestion as StepSuggestion).isSnippet !== true}
@@ -556,17 +544,15 @@ export const stepBlock = createReactBlockSpec(
556
544
  label="Step data"
557
545
  placeholder={STEP_DATA_PLACEHOLDER}
558
546
  labelAction={
559
- canToggleData ? (
560
- <button
561
- type="button"
562
- className="bn-step-field__dismiss"
563
- data-tooltip="Hide step data"
564
- onClick={handleHideData}
565
- aria-label="Hide step data"
566
- >
567
- ×
568
- </button>
569
- ) : undefined
547
+ <button
548
+ type="button"
549
+ className="bn-step-field__dismiss"
550
+ data-tooltip="Hide step data"
551
+ onClick={handleHideData}
552
+ aria-label="Hide step data"
553
+ >
554
+ ×
555
+ </button>
570
556
  }
571
557
  value={stepData}
572
558
  onChange={handleStepDataChange}
@@ -585,18 +571,16 @@ export const stepBlock = createReactBlockSpec(
585
571
  label="Expected result"
586
572
  placeholder={EXPECTED_RESULT_PLACEHOLDER}
587
573
  labelAction={
588
- canToggleExpected ? (
589
- <button
590
- type="button"
591
- className="bn-step-field__dismiss"
592
- data-tooltip="Hide expected result"
593
- onClick={handleHideExpected}
594
- tabIndex={-1}
595
- aria-label="Hide expected result"
596
- >
597
- ×
598
- </button>
599
- ) : undefined
574
+ <button
575
+ type="button"
576
+ className="bn-step-field__dismiss"
577
+ data-tooltip="Hide expected result"
578
+ onClick={handleHideExpected}
579
+ tabIndex={-1}
580
+ aria-label="Hide expected result"
581
+ >
582
+ ×
583
+ </button>
600
584
  }
601
585
  value={expectedResult}
602
586
  onChange={handleExpectedChange}
@@ -79,10 +79,20 @@ type ExtractedImage = {
79
79
  markdown: string;
80
80
  };
81
81
 
82
- type LinkMeta = { start: number; end: number; url: string };
83
- type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
82
+ export type LinkMeta = { start: number; end: number; url: string };
83
+ export type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
84
84
  type FormatType = "bold" | "italic" | "code";
85
85
 
86
+ type EditorSnapshot = {
87
+ text: string;
88
+ formatting: FormattingMeta[];
89
+ links: LinkMeta[];
90
+ cursorStart: number;
91
+ cursorEnd: number;
92
+ };
93
+
94
+ const UNDO_STACK_LIMIT = 100;
95
+
86
96
  function getActiveFormats(
87
97
  formatting: FormattingMeta[],
88
98
  selStart: number,
@@ -258,7 +268,7 @@ function stripInlineMarkdown(markdown: string): {
258
268
  return { plainText, links, formatting };
259
269
  }
260
270
 
261
- function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string {
271
+ export function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string {
262
272
  if (links.length === 0 && formatting.length === 0) return plainText;
263
273
 
264
274
  // Collect all marker insertions at each position in plainText space.
@@ -276,7 +286,7 @@ function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: For
276
286
  openMarker = isMultiline ? "```\n" : "`";
277
287
  closeMarker = isMultiline ? "\n```" : "`";
278
288
  } else {
279
- openMarker = fmt.type === "bold" ? "**" : "*";
289
+ openMarker = fmt.type === "bold" ? "**" : "_";
280
290
  closeMarker = openMarker;
281
291
  }
282
292
  // Opening: outer markers (bold) before inner (italic) → bold order=0, italic order=1
@@ -308,13 +318,19 @@ function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: For
308
318
  function adjustFormattingForEdit(formatting: FormattingMeta[], editPos: number, delta: number): FormattingMeta[] {
309
319
  return formatting
310
320
  .map((fmt) => {
311
- if (editPos <= fmt.start) {
312
- return { ...fmt, start: fmt.start + delta, end: fmt.end + delta };
313
- }
314
- if (editPos >= fmt.end) {
315
- return fmt;
321
+ if (delta >= 0) {
322
+ if (editPos <= fmt.start) {
323
+ return { ...fmt, start: fmt.start + delta, end: fmt.end + delta };
324
+ }
325
+ if (editPos >= fmt.end) {
326
+ return fmt;
327
+ }
328
+ return { ...fmt, end: fmt.end + delta };
316
329
  }
317
- return { ...fmt, end: fmt.end + delta };
330
+ const delEnd = editPos + Math.abs(delta);
331
+ const newStart = fmt.start < editPos ? fmt.start : fmt.start >= delEnd ? fmt.start + delta : editPos;
332
+ const newEnd = fmt.end <= editPos ? fmt.end : fmt.end >= delEnd ? fmt.end + delta : editPos;
333
+ return { ...fmt, start: newStart, end: newEnd };
318
334
  })
319
335
  .filter((fmt) => fmt.end > fmt.start);
320
336
  }
@@ -342,7 +358,18 @@ function getCaretRectInPreview(preview: HTMLElement, offset: number, textareaVal
342
358
  const range = document.createRange();
343
359
  range.setStart(textNode, localOffset);
344
360
  range.collapse(true);
345
- const rect = range.getBoundingClientRect();
361
+ let rect = range.getBoundingClientRect();
362
+
363
+ // Collapsed ranges at position 0 can return an empty rect in some browsers
364
+ if (rect.height === 0 && rect.top === 0 && rect.left === 0) {
365
+ const span = document.createElement("span");
366
+ span.textContent = "\u200B";
367
+ range.insertNode(span);
368
+ rect = span.getBoundingClientRect();
369
+ span.parentNode?.removeChild(span);
370
+ preview.normalize();
371
+ }
372
+
346
373
  const previewRect = preview.getBoundingClientRect();
347
374
  return {
348
375
  top: rect.top - previewRect.top + preview.scrollTop,
@@ -508,13 +535,19 @@ function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingM
508
535
  function adjustLinksForEdit(links: LinkMeta[], editPos: number, delta: number): LinkMeta[] {
509
536
  return links
510
537
  .map((link) => {
511
- if (editPos <= link.start) {
512
- return { ...link, start: link.start + delta, end: link.end + delta };
513
- }
514
- if (editPos >= link.end) {
515
- return link;
538
+ if (delta >= 0) {
539
+ if (editPos <= link.start) {
540
+ return { ...link, start: link.start + delta, end: link.end + delta };
541
+ }
542
+ if (editPos >= link.end) {
543
+ return link;
544
+ }
545
+ return { ...link, end: link.end + delta };
516
546
  }
517
- return { ...link, end: link.end + delta };
547
+ const delEnd = editPos + Math.abs(delta);
548
+ const newStart = link.start < editPos ? link.start : link.start >= delEnd ? link.start + delta : editPos;
549
+ const newEnd = link.end <= editPos ? link.end : link.end >= delEnd ? link.end + delta : editPos;
550
+ return { ...link, start: newStart, end: newEnd };
518
551
  })
519
552
  .filter((link) => link.end > link.start);
520
553
  }
@@ -648,8 +681,8 @@ export function StepField({
648
681
  const linkSelectionRef = useRef<{ start: number; end: number; text: string } | null>(null);
649
682
  const linksRef = useRef<LinkMeta[]>([]);
650
683
  const formattingRef = useRef<FormattingMeta[]>([]);
651
- const formattingUndoRef = useRef<Array<{ formatting: FormattingMeta[]; links: LinkMeta[] }>>([]);
652
- const formattingRedoRef = useRef<Array<{ formatting: FormattingMeta[]; links: LinkMeta[] }>>([]);
684
+ const undoStackRef = useRef<EditorSnapshot[]>([]);
685
+ const redoStackRef = useRef<EditorSnapshot[]>([]);
653
686
  const caretRef = useRef<HTMLDivElement | null>(null);
654
687
  const prevTextRef = useRef("");
655
688
  const isSyncingRef = useRef(false);
@@ -663,8 +696,36 @@ export function StepField({
663
696
  onChangeRef.current = onChange;
664
697
  }, [onChange]);
665
698
 
699
+ const pushUndoSnapshot = useCallback(
700
+ (
701
+ text: string,
702
+ formatting: FormattingMeta[],
703
+ links: LinkMeta[],
704
+ cursorStart: number,
705
+ cursorEnd: number,
706
+ ) => {
707
+ const lastSnapshot = undoStackRef.current[undoStackRef.current.length - 1];
708
+ if (
709
+ lastSnapshot &&
710
+ lastSnapshot.text === text &&
711
+ JSON.stringify(lastSnapshot.formatting) === JSON.stringify(formatting) &&
712
+ JSON.stringify(lastSnapshot.links) === JSON.stringify(links)
713
+ ) {
714
+ return;
715
+ }
716
+
717
+ undoStackRef.current = [
718
+ ...undoStackRef.current.slice(-(UNDO_STACK_LIMIT - 1)),
719
+ { text, formatting: [...formatting], links: [...links], cursorStart, cursorEnd },
720
+ ];
721
+ redoStackRef.current = [];
722
+ },
723
+ [],
724
+ );
725
+
666
726
  const handleEditorChange = useCallback((nextValue: string) => {
667
727
  if (isSyncingRef.current) return;
728
+ if (nextValue === prevTextRef.current) return;
668
729
 
669
730
  const prevText = prevTextRef.current;
670
731
  const delta = nextValue.length - prevText.length;
@@ -676,10 +737,16 @@ export function StepField({
676
737
  editPos++;
677
738
  }
678
739
 
740
+ // Capture pre-edit state for undo BEFORE mutating
741
+ const prevFormatting = [...formattingRef.current];
742
+ const prevLinks = [...linksRef.current];
743
+
679
744
  linksRef.current = adjustLinksForEdit(linksRef.current, editPos, delta);
680
745
  formattingRef.current = adjustFormattingForEdit(formattingRef.current, editPos, delta);
681
- formattingUndoRef.current = [];
682
- formattingRedoRef.current = [];
746
+
747
+ // Push pre-edit state to undo stack
748
+ pushUndoSnapshot(prevText, prevFormatting, prevLinks, editPos, editPos);
749
+
683
750
  prevTextRef.current = nextValue;
684
751
 
685
752
  const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current);
@@ -688,7 +755,7 @@ export function StepField({
688
755
  return prev === normalized ? prev : normalized;
689
756
  });
690
757
  onChangeRef.current?.(markdown);
691
- }, []);
758
+ }, [pushUndoSnapshot]);
692
759
 
693
760
  useEffect(() => {
694
761
  const container = editorContainerRef.current;
@@ -701,6 +768,12 @@ export function StepField({
701
768
  formattingRef.current = formatting;
702
769
  prevTextRef.current = plainText;
703
770
 
771
+ // Push initial state as the baseline undo snapshot
772
+ undoStackRef.current = [
773
+ { text: plainText, formatting: [...formatting], links: [...links], cursorStart: 0, cursorEnd: 0 },
774
+ ];
775
+ redoStackRef.current = [];
776
+
704
777
  const [instance] = OverType.init(container, {
705
778
  value: plainText,
706
779
  placeholder: resolvedPlaceholder,
@@ -718,9 +791,25 @@ export function StepField({
718
791
  applyFormattingHighlights(this.preview, formattingRef.current, this.textarea?.value);
719
792
  applyLinkHighlights(this.preview, linksRef.current);
720
793
  };
721
- // Apply initial highlights
722
- applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
723
- applyLinkHighlights(instance.preview, linksRef.current);
794
+ // Force a full update through the monkey-patched pipeline
795
+ instance.updatePreview();
796
+
797
+ // Safety net: re-apply formatting if the preview gets reset externally
798
+ // (e.g. by the original updatePreview being called outside our monkey-patch)
799
+ let isApplyingFormatting = false;
800
+ const formattingObserver = new MutationObserver(() => {
801
+ if (isApplyingFormatting) return;
802
+ const hasFormatting = formattingRef.current.length > 0;
803
+ const hasAnyFormattingElement =
804
+ instance.preview.querySelector("strong.step-preview-bold, em.step-preview-italic, code.step-preview-code") !== null;
805
+ if (hasFormatting && !hasAnyFormattingElement) {
806
+ isApplyingFormatting = true;
807
+ applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
808
+ applyLinkHighlights(instance.preview, linksRef.current);
809
+ isApplyingFormatting = false;
810
+ }
811
+ });
812
+ formattingObserver.observe(instance.preview, { childList: true, subtree: true });
724
813
 
725
814
  // Create custom caret element inside the wrapper
726
815
  const caretEl = document.createElement("div");
@@ -732,6 +821,7 @@ export function StepField({
732
821
  setTextareaNode(instance.textarea);
733
822
 
734
823
  return () => {
824
+ formattingObserver.disconnect();
735
825
  caretRef.current = null;
736
826
  instance.destroy();
737
827
  editorInstanceRef.current = null;
@@ -841,6 +931,7 @@ export function StepField({
841
931
  }
842
932
 
843
933
  const { plainText, links, formatting } = stripInlineMarkdown(value);
934
+
844
935
  linksRef.current = links;
845
936
  formattingRef.current = formatting;
846
937
  prevTextRef.current = plainText;
@@ -873,6 +964,21 @@ export function StepField({
873
964
  }
874
965
  }, [fieldName, textareaNode]);
875
966
 
967
+ // Block native undo/redo at the beforeinput level so the browser never
968
+ // applies its own history on the textarea — our custom stack handles it.
969
+ useEffect(() => {
970
+ if (!textareaNode) return;
971
+ const blockNativeUndoRedo = (e: InputEvent) => {
972
+ if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
973
+ e.preventDefault();
974
+ }
975
+ };
976
+ textareaNode.addEventListener("beforeinput", blockNativeUndoRedo as EventListener);
977
+ return () => {
978
+ textareaNode.removeEventListener("beforeinput", blockNativeUndoRedo as EventListener);
979
+ };
980
+ }, [textareaNode]);
981
+
876
982
  useEffect(() => {
877
983
  if (!textareaNode) {
878
984
  return;
@@ -904,6 +1010,15 @@ export function StepField({
904
1010
  const handleBlur = () => {
905
1011
  setIsFocused(false);
906
1012
  setShowAllSuggestions(false);
1013
+ // Re-apply formatting highlights after blur because OverType may
1014
+ // re-render the preview (via debounced selectionchange) and strip them.
1015
+ const instance = editorInstanceRef.current;
1016
+ if (instance) {
1017
+ requestAnimationFrame(() => {
1018
+ applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
1019
+ applyLinkHighlights(instance.preview, linksRef.current);
1020
+ });
1021
+ }
907
1022
  };
908
1023
 
909
1024
  textareaNode.addEventListener("focus", handleFocus);
@@ -1066,25 +1181,23 @@ export function StepField({
1066
1181
  );
1067
1182
 
1068
1183
  // Save current state for undo before modifying
1069
- formattingUndoRef.current = [
1070
- ...formattingUndoRef.current,
1071
- { formatting: [...formattingRef.current], links: [...linksRef.current] },
1072
- ];
1073
- formattingRedoRef.current = [];
1184
+ const currentText = instance.getValue();
1185
+ pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, start, end);
1074
1186
 
1075
1187
  if (existingIdx !== -1) {
1076
1188
  // Remove formatting
1077
1189
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
1078
1190
  } else if (start !== end) {
1079
- // Remove overlapping formatting of other types before applying new format
1191
+ // Remove overlapping formatting:
1192
+ // - Code: remove ALL overlapping formatting (code replaces bold/italic)
1193
+ // - Bold/Italic: remove only overlapping formatting of the SAME type
1080
1194
  formattingRef.current = formattingRef.current.filter(
1081
- (f) => f.type === fmtType || f.start >= end || f.end <= start,
1195
+ (f) => f.start >= end || f.end <= start || (fmtType !== "code" && f.type !== fmtType),
1082
1196
  );
1083
1197
  // Add formatting for selection
1084
1198
  formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
1085
1199
  } else {
1086
1200
  // No selection — nothing to format
1087
- formattingUndoRef.current = formattingUndoRef.current.slice(0, -1);
1088
1201
  return;
1089
1202
  }
1090
1203
 
@@ -1099,7 +1212,7 @@ export function StepField({
1099
1212
  applyFormattingHighlights(instance.preview, formattingRef.current, textareaNode?.value);
1100
1213
  applyLinkHighlights(instance.preview, linksRef.current);
1101
1214
  },
1102
- [textareaNode],
1215
+ [textareaNode, pushUndoSnapshot],
1103
1216
  );
1104
1217
 
1105
1218
  const updateActiveFormats = useCallback(() => {
@@ -1181,6 +1294,10 @@ export function StepField({
1181
1294
  return;
1182
1295
  }
1183
1296
  const currentValue = instance.getValue();
1297
+
1298
+ // Push undo snapshot before link edit
1299
+ pushUndoSnapshot(currentValue, formattingRef.current, linksRef.current, sel.start, sel.end);
1300
+
1184
1301
  const linkText = text || sel.text || url;
1185
1302
 
1186
1303
  // Replace selected text with link display text (no markdown syntax in textarea)
@@ -1212,14 +1329,21 @@ export function StepField({
1212
1329
  setCursorLink(null);
1213
1330
  requestAnimationFrame(() => textareaNode?.focus());
1214
1331
  },
1215
- [textareaNode],
1332
+ [textareaNode, pushUndoSnapshot],
1216
1333
  );
1217
1334
 
1218
1335
  const handleRemoveLink = useCallback(() => {
1336
+ const instance = editorInstanceRef.current;
1337
+
1338
+ // Push undo snapshot before link removal
1339
+ if (instance) {
1340
+ const currentText = instance.getValue();
1341
+ const cursorPos = instance.textarea?.selectionStart ?? 0;
1342
+ pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, cursorPos, cursorPos);
1343
+ }
1344
+
1219
1345
  linksRef.current = linksRef.current.filter((l) => l !== cursorLink);
1220
1346
  setCursorLink(null);
1221
-
1222
- const instance = editorInstanceRef.current;
1223
1347
  if (instance) {
1224
1348
  const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
1225
1349
  onChangeRef.current?.(markdown);
@@ -1227,7 +1351,7 @@ export function StepField({
1227
1351
  applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
1228
1352
  applyLinkHighlights(instance.preview, linksRef.current);
1229
1353
  }
1230
- }, [cursorLink]);
1354
+ }, [cursorLink, pushUndoSnapshot]);
1231
1355
 
1232
1356
  const suggestionPool = useMemo(() => {
1233
1357
  if (!suggestionFilter) {
@@ -1296,6 +1420,12 @@ export function StepField({
1296
1420
 
1297
1421
  const handleRemoveImage = useCallback(
1298
1422
  (image: ExtractedImage) => {
1423
+ // Push undo snapshot before image removal
1424
+ if (editorInstanceRef.current) {
1425
+ const currentText = editorInstanceRef.current.getValue();
1426
+ pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, image.start, image.end);
1427
+ }
1428
+
1299
1429
  const before = value.slice(0, image.start);
1300
1430
  const after = value.slice(image.end);
1301
1431
  const nextValue = `${before}${after}`.replace(/\n{3,}/g, "\n\n");
@@ -1306,7 +1436,7 @@ export function StepField({
1306
1436
  setPlainTextValue(markdownToPlainText(nextValue));
1307
1437
  setPreviewImageUrl((prev) => (prev === image.url ? null : prev));
1308
1438
  },
1309
- [value],
1439
+ [value, pushUndoSnapshot],
1310
1440
  );
1311
1441
 
1312
1442
  const handleImageClick = useCallback((url: string) => {
@@ -1344,6 +1474,10 @@ export function StepField({
1344
1474
  const escaped = escapeMarkdownText(suggestion.title);
1345
1475
  const instance = editorInstanceRef.current;
1346
1476
  if (instance) {
1477
+ // Push undo snapshot before applying suggestion
1478
+ const currentText = instance.getValue();
1479
+ const cursorPos = textareaNode?.selectionStart ?? 0;
1480
+ pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, cursorPos, cursorPos);
1347
1481
  instance.setValue(escaped);
1348
1482
  }
1349
1483
  setPlainTextValue(suggestion.title);
@@ -1359,7 +1493,7 @@ export function StepField({
1359
1493
  }
1360
1494
  });
1361
1495
  },
1362
- [onSuggestionSelect, textareaNode],
1496
+ [onSuggestionSelect, textareaNode, pushUndoSnapshot],
1363
1497
  );
1364
1498
 
1365
1499
  const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(null);
@@ -1395,55 +1529,107 @@ export function StepField({
1395
1529
  handleToolbarAction("toggleCode");
1396
1530
  return;
1397
1531
  }
1398
- if (event.key === "z" || event.key === "Z") {
1399
- const undoStack = formattingUndoRef.current;
1400
- if (undoStack.length > 0) {
1401
- event.preventDefault();
1402
- event.stopImmediatePropagation();
1403
- formattingRedoRef.current = [
1404
- ...formattingRedoRef.current,
1405
- { formatting: [...formattingRef.current], links: [...linksRef.current] },
1406
- ];
1407
- const prev = undoStack[undoStack.length - 1];
1408
- formattingUndoRef.current = undoStack.slice(0, -1);
1409
- formattingRef.current = prev.formatting;
1410
- linksRef.current = prev.links;
1411
- const instance = editorInstanceRef.current;
1412
- if (instance) {
1413
- const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
1414
- onChangeRef.current?.(markdown);
1415
- setPlainTextValue(markdownToPlainText(markdown));
1416
- applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
1417
- applyLinkHighlights(instance.preview, linksRef.current);
1418
- }
1419
- return;
1420
- }
1532
+ if (event.key === "a" || event.key === "A") {
1533
+ event.stopPropagation();
1534
+ return;
1421
1535
  }
1422
- }
1423
- if (modKey && event.shiftKey && (event.key === "z" || event.key === "Z")) {
1424
- const redoStack = formattingRedoRef.current;
1425
- if (redoStack.length > 0) {
1536
+ if (event.key === "z" || event.key === "Z") {
1426
1537
  event.preventDefault();
1427
1538
  event.stopImmediatePropagation();
1428
- formattingUndoRef.current = [
1429
- ...formattingUndoRef.current,
1430
- { formatting: [...formattingRef.current], links: [...linksRef.current] },
1431
- ];
1432
- const next = redoStack[redoStack.length - 1];
1433
- formattingRedoRef.current = redoStack.slice(0, -1);
1434
- formattingRef.current = next.formatting;
1435
- linksRef.current = next.links;
1539
+
1540
+ const stack = undoStackRef.current;
1541
+ if (stack.length === 0) return;
1542
+
1436
1543
  const instance = editorInstanceRef.current;
1437
- if (instance) {
1438
- const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
1439
- onChangeRef.current?.(markdown);
1440
- setPlainTextValue(markdownToPlainText(markdown));
1441
- applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
1442
- applyLinkHighlights(instance.preview, linksRef.current);
1544
+ if (!instance) return;
1545
+
1546
+ // Push current state to redo stack
1547
+ const currentText = instance.getValue();
1548
+ redoStackRef.current = [
1549
+ ...redoStackRef.current,
1550
+ {
1551
+ text: currentText,
1552
+ formatting: [...formattingRef.current],
1553
+ links: [...linksRef.current],
1554
+ cursorStart: textareaNode?.selectionStart ?? 0,
1555
+ cursorEnd: textareaNode?.selectionEnd ?? 0,
1556
+ },
1557
+ ];
1558
+
1559
+ // Pop from undo stack
1560
+ const prev = stack[stack.length - 1];
1561
+ undoStackRef.current = stack.slice(0, -1);
1562
+
1563
+ // Restore state
1564
+ formattingRef.current = prev.formatting;
1565
+ linksRef.current = prev.links;
1566
+ prevTextRef.current = prev.text;
1567
+
1568
+ isSyncingRef.current = true;
1569
+ instance.setValue(prev.text);
1570
+ isSyncingRef.current = false;
1571
+
1572
+ if (textareaNode) {
1573
+ textareaNode.selectionStart = prev.cursorStart;
1574
+ textareaNode.selectionEnd = prev.cursorEnd;
1443
1575
  }
1576
+
1577
+ const markdown = buildFullMarkdown(prev.text, prev.links, prev.formatting);
1578
+ onChangeRef.current?.(markdown);
1579
+ setPlainTextValue(markdownToPlainText(markdown));
1580
+ applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
1581
+ applyLinkHighlights(instance.preview, linksRef.current);
1444
1582
  return;
1445
1583
  }
1446
1584
  }
1585
+ if (modKey && event.shiftKey && (event.key === "z" || event.key === "Z")) {
1586
+ event.preventDefault();
1587
+ event.stopImmediatePropagation();
1588
+
1589
+ const stack = redoStackRef.current;
1590
+ if (stack.length === 0) return;
1591
+
1592
+ const instance = editorInstanceRef.current;
1593
+ if (!instance) return;
1594
+
1595
+ // Push current state to undo stack
1596
+ const currentText = instance.getValue();
1597
+ undoStackRef.current = [
1598
+ ...undoStackRef.current,
1599
+ {
1600
+ text: currentText,
1601
+ formatting: [...formattingRef.current],
1602
+ links: [...linksRef.current],
1603
+ cursorStart: textareaNode?.selectionStart ?? 0,
1604
+ cursorEnd: textareaNode?.selectionEnd ?? 0,
1605
+ },
1606
+ ];
1607
+
1608
+ // Pop from redo stack
1609
+ const next = stack[stack.length - 1];
1610
+ redoStackRef.current = stack.slice(0, -1);
1611
+
1612
+ // Restore state
1613
+ formattingRef.current = next.formatting;
1614
+ linksRef.current = next.links;
1615
+ prevTextRef.current = next.text;
1616
+
1617
+ isSyncingRef.current = true;
1618
+ instance.setValue(next.text);
1619
+ isSyncingRef.current = false;
1620
+
1621
+ if (textareaNode) {
1622
+ textareaNode.selectionStart = next.cursorStart;
1623
+ textareaNode.selectionEnd = next.cursorEnd;
1624
+ }
1625
+
1626
+ const markdown = buildFullMarkdown(next.text, next.links, next.formatting);
1627
+ onChangeRef.current?.(markdown);
1628
+ setPlainTextValue(markdownToPlainText(markdown));
1629
+ applyFormattingHighlights(instance.preview, next.formatting, instance.textarea?.value);
1630
+ applyLinkHighlights(instance.preview, next.links);
1631
+ return;
1632
+ }
1447
1633
 
1448
1634
  if (enableAutocomplete && shouldShowAutocomplete) {
1449
1635
  if (event.key === "ArrowDown") {