testomatio-editor-blocks 0.4.48 → 0.4.50
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.
- package/package/editor/blocks/step.d.ts +1 -1
- package/package/editor/blocks/step.js +8 -18
- package/package/editor/blocks/stepField.d.ts +11 -0
- package/package/editor/blocks/stepField.js +227 -84
- package/package/editor/customMarkdownConverter.js +6 -0
- package/package/styles.css +12 -3
- package/package.json +1 -1
- package/src/editor/blocks/step.tsx +26 -42
- package/src/editor/blocks/stepField.tsx +266 -80
- package/src/editor/blocks/stepFieldFormatting.test.ts +44 -0
- package/src/editor/customMarkdownConverter.test.ts +20 -0
- package/src/editor/customMarkdownConverter.ts +7 -0
- package/src/editor/styles.css +12 -3
|
@@ -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.
|
|
@@ -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 (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
|
652
|
-
const
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
|
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.
|
|
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 === "
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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") {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildFullMarkdown, type FormattingMeta, type LinkMeta } from "./stepField";
|
|
3
|
+
|
|
4
|
+
describe("buildFullMarkdown formatting combinations", () => {
|
|
5
|
+
const noLinks: LinkMeta[] = [];
|
|
6
|
+
|
|
7
|
+
it("applies bold and italic to the same range", () => {
|
|
8
|
+
const formatting: FormattingMeta[] = [
|
|
9
|
+
{ start: 0, end: 5, type: "bold" },
|
|
10
|
+
{ start: 0, end: 5, type: "italic" },
|
|
11
|
+
];
|
|
12
|
+
expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("***hello***");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("preserves word-level bold when sentence-level bold is applied", () => {
|
|
16
|
+
// User bolds "adipiscing", then bolds entire sentence.
|
|
17
|
+
// The word-level bold should be merged into sentence-level bold,
|
|
18
|
+
// not create nested **...**adipiscing**...** markers.
|
|
19
|
+
const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit";
|
|
20
|
+
const formatting: FormattingMeta[] = [
|
|
21
|
+
{ start: 0, end: text.length, type: "bold" },
|
|
22
|
+
];
|
|
23
|
+
expect(buildFullMarkdown(text, noLinks, formatting)).toBe(
|
|
24
|
+
`**${text}**`,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("bold on word + italic on sentence produces correct markdown", () => {
|
|
29
|
+
const text = "hello world";
|
|
30
|
+
const formatting: FormattingMeta[] = [
|
|
31
|
+
{ start: 0, end: 5, type: "bold" },
|
|
32
|
+
{ start: 0, end: 11, type: "italic" },
|
|
33
|
+
];
|
|
34
|
+
expect(buildFullMarkdown(text, noLinks, formatting)).toBe("***hello** world*");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("code formatting removes bold and italic", () => {
|
|
38
|
+
// When code is applied, bold/italic should not appear
|
|
39
|
+
const formatting: FormattingMeta[] = [
|
|
40
|
+
{ start: 0, end: 5, type: "code" },
|
|
41
|
+
];
|
|
42
|
+
expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("`hello`");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1046,6 +1046,26 @@ describe("markdownToBlocks", () => {
|
|
|
1046
1046
|
);
|
|
1047
1047
|
});
|
|
1048
1048
|
|
|
1049
|
+
it("does not include content after a blank line in step data", () => {
|
|
1050
|
+
const markdown = [
|
|
1051
|
+
"### Steps",
|
|
1052
|
+
"",
|
|
1053
|
+
"* step",
|
|
1054
|
+
" expected",
|
|
1055
|
+
"",
|
|
1056
|
+
" ",
|
|
1057
|
+
].join("\n");
|
|
1058
|
+
|
|
1059
|
+
const blocks = markdownToBlocks(markdown);
|
|
1060
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1061
|
+
expect(stepBlocks).toHaveLength(1);
|
|
1062
|
+
expect(stepBlocks[0].props).toMatchObject({
|
|
1063
|
+
stepTitle: "step",
|
|
1064
|
+
stepData: "expected",
|
|
1065
|
+
expectedResult: "",
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1049
1069
|
it("parses bullet lists written with asterisk markers", () => {
|
|
1050
1070
|
const markdown = [
|
|
1051
1071
|
"### Preconditions",
|
|
@@ -941,6 +941,7 @@ function parseTestStep(
|
|
|
941
941
|
let expectedResult = "";
|
|
942
942
|
let next = index + 1;
|
|
943
943
|
let inExpectedResult = false;
|
|
944
|
+
let blankLineSeenOutsideCodeBlock = false;
|
|
944
945
|
const stepIndent = current.length - current.trimStart().length;
|
|
945
946
|
|
|
946
947
|
while (next < lines.length) {
|
|
@@ -955,6 +956,7 @@ function parseTestStep(
|
|
|
955
956
|
expectedResult += "\n";
|
|
956
957
|
} else {
|
|
957
958
|
stepDataLines.push("");
|
|
959
|
+
blankLineSeenOutsideCodeBlock = true;
|
|
958
960
|
}
|
|
959
961
|
}
|
|
960
962
|
next += 1;
|
|
@@ -1066,6 +1068,11 @@ function parseTestStep(
|
|
|
1066
1068
|
continue;
|
|
1067
1069
|
}
|
|
1068
1070
|
|
|
1071
|
+
// After a blank line outside a code block, stop adding to step data
|
|
1072
|
+
if (blankLineSeenOutsideCodeBlock) {
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1069
1076
|
if (STEP_DATA_LINE_REGEX.test(rawTrimmed)) {
|
|
1070
1077
|
const content = unescapeMarkdown(rawTrimmed);
|
|
1071
1078
|
stepDataLines.push(content);
|
package/src/editor/styles.css
CHANGED
|
@@ -1079,7 +1079,8 @@ html.dark .bn-step-image-preview__content {
|
|
|
1079
1079
|
}
|
|
1080
1080
|
|
|
1081
1081
|
.bn-step-editor .overtype-wrapper .overtype-preview strong.step-preview-bold {
|
|
1082
|
-
|
|
1082
|
+
-webkit-text-stroke: 0.5px currentColor;
|
|
1083
|
+
font-weight: inherit !important;
|
|
1083
1084
|
color: inherit !important;
|
|
1084
1085
|
}
|
|
1085
1086
|
|
|
@@ -1090,8 +1091,8 @@ html.dark .bn-step-image-preview__content {
|
|
|
1090
1091
|
|
|
1091
1092
|
.bn-step-editor .overtype-wrapper .overtype-preview code.step-preview-code {
|
|
1092
1093
|
background-color: transparent !important;
|
|
1093
|
-
|
|
1094
|
-
font-
|
|
1094
|
+
font-family: inherit !important;
|
|
1095
|
+
font-size: inherit !important;
|
|
1095
1096
|
color: rgb(146, 64, 14) !important;
|
|
1096
1097
|
}
|
|
1097
1098
|
|
|
@@ -1356,3 +1357,11 @@ html.dark .bn-step-image-preview__content {
|
|
|
1356
1357
|
.bn-testcase--draft {
|
|
1357
1358
|
--status-color: var(--status-default);
|
|
1358
1359
|
}
|
|
1360
|
+
|
|
1361
|
+
/* Prevent unnecessary horizontal scrollbar on BlockNote tables.
|
|
1362
|
+
ProseMirror sets overflow-x: auto on .tableWrapper, but the
|
|
1363
|
+
table-widgets-container extends slightly past the wrapper edge,
|
|
1364
|
+
triggering a scrollbar even when table content fits. */
|
|
1365
|
+
.bn-editor [data-content-type="table"] .tableWrapper {
|
|
1366
|
+
overflow-x: hidden;
|
|
1367
|
+
}
|