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.
- package/package/editor/blocks/markdown.js +1 -1
- 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 +228 -85
- package/package/editor/createMarkdownPasteHandler.js +18 -0
- package/package/editor/customMarkdownConverter.js +5 -7
- package/package/styles.css +16 -3
- package/package.json +1 -1
- package/src/editor/blocks/markdown.ts +1 -1
- package/src/editor/blocks/step.tsx +26 -42
- package/src/editor/blocks/stepField.tsx +267 -81
- package/src/editor/blocks/stepFieldFormatting.test.ts +44 -0
- package/src/editor/createMarkdownPasteHandler.ts +16 -0
- package/src/editor/customMarkdownConverter.test.ts +3 -3
- package/src/editor/customMarkdownConverter.ts +5 -7
- package/src/editor/styles.css +16 -3
|
@@ -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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 (
|
|
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") {
|