@@ -49,28 +49,38 @@ const enterProjection = (mounted: MountedMilkdownEditor, selector: "em" | "stron
4949} ;
5050
5151const getTextPosition = ( mounted : MountedMilkdownEditor , text : string ) => {
52- let position : number | null = null ;
52+ const textRanges : { end : number ; from : number ; start : number } [ ] = [ ] ;
53+ let documentText = "" ;
5354
5455 mounted . view . state . doc . descendants ( ( node , pos ) => {
5556 if ( ! node . isText ) {
5657 return true ;
5758 }
5859
59- const index = node . textContent . indexOf ( text ) ;
60-
61- if ( index === - 1 ) {
62- return true ;
63- }
60+ const start = documentText . length ;
61+ documentText += node . textContent ;
62+ textRanges . push ( {
63+ end : documentText . length ,
64+ from : pos ,
65+ start,
66+ } ) ;
6467
65- position = pos + index ;
66- return false ;
68+ return true ;
6769 } ) ;
6870
69- if ( position === null ) {
71+ const index = documentText . indexOf ( text ) ;
72+
73+ if ( index === - 1 ) {
7074 throw new Error ( `Could not find projected text: ${ text } ` ) ;
7175 }
7276
73- return position ;
77+ const range = textRanges . find ( ( { end, start } ) => start <= index && index < end ) ;
78+
79+ if ( ! range ) {
80+ throw new Error ( `Could not resolve projected text position: ${ text } ` ) ;
81+ }
82+
83+ return range . from + index - range . start ;
7484} ;
7585
7686const runCommand = async ( mounted : MountedMilkdownEditor , commandId : "edit.redo" | "edit.undo" ) =>
@@ -328,6 +338,57 @@ describe("inline source projection", () => {
328338 expect ( mounted . getMarkdown ( ) ) . toBe ( "*One* **Two**\n" ) ;
329339 } ) ;
330340
341+ it ( "preserves native undo after committing a marker deletion" , async ( ) => {
342+ const mounted = await mountEditor ( "**Bold** plain" ) ;
343+
344+ enterProjection ( mounted , "strong" ) ;
345+
346+ const sourceStart = getTextPosition ( mounted , "**Bold**" ) ;
347+
348+ setTextSelection ( mounted . view , sourceStart + 1 ) ;
349+ pressKey ( mounted . view , "Backspace" ) ;
350+ setSelectionAtDocumentEnd ( mounted . view ) ;
351+
352+ expect ( mounted . getMarkdown ( ) ) . toBe ( "*Bold* plain\n" ) ;
353+
354+ const emphasis = mounted . view . dom . querySelector ( "em" ) ;
355+
356+ expect ( emphasis ) . toBeInTheDocument ( ) ;
357+
358+ setSelectionAtTextEnd ( mounted . view , emphasis as HTMLElement ) ;
359+
360+ expect ( hasActiveInlineSourceProjection ( mounted . view . state ) ) . toBe ( true ) ;
361+ expect ( await runCommand ( mounted , "edit.undo" ) ) . toBe ( true ) ;
362+ expect ( mounted . getMarkdown ( ) ) . toBe ( "**Bold** plain\n" ) ;
363+ expect ( await runCommand ( mounted , "edit.redo" ) ) . toBe ( true ) ;
364+ expect ( mounted . getMarkdown ( ) ) . toBe ( "*Bold* plain\n" ) ;
365+ } ) ;
366+
367+ it . each ( [
368+ { commandId : "format.strong" as const , selector : "strong" } ,
369+ { commandId : "format.emphasis" as const , selector : "em" } ,
370+ ] ) (
371+ "preserves native undo after applying $commandId to a whole paragraph" ,
372+ async ( { commandId, selector } ) => {
373+ const mounted = await mountEditor ( "Plain paragraph" ) ;
374+
375+ expect ( runEditorCommand ( mounted . editor , "edit.selectAll" ) ) . toBe ( true ) ;
376+ expect ( runEditorCommand ( mounted . editor , commandId ) ) . toBe ( true ) ;
377+
378+ const formatted = mounted . view . dom . querySelector ( selector ) ;
379+
380+ expect ( formatted ) . toBeInTheDocument ( ) ;
381+
382+ setSelectionAtTextEnd ( mounted . view , formatted as HTMLElement ) ;
383+
384+ expect ( hasActiveInlineSourceProjection ( mounted . view . state ) ) . toBe ( true ) ;
385+ expect ( await runCommand ( mounted , "edit.undo" ) ) . toBe ( true ) ;
386+ expect ( mounted . getMarkdown ( ) ) . toBe ( "Plain paragraph\n" ) ;
387+ expect ( await runCommand ( mounted , "edit.redo" ) ) . toBe ( true ) ;
388+ expect ( mounted . view . dom . querySelector ( selector ) ) . toHaveTextContent ( "Plain paragraph" ) ;
389+ } ,
390+ ) ;
391+
331392 it ( "tracks real source edits as dirty without counting projection entry or commit" , async ( ) => {
332393 const onContentTransaction = vi . fn ( ) ;
333394 const mounted = await mountEditor ( "**Bold** plain" , onContentTransaction ) ;
@@ -451,20 +512,19 @@ describe("inline source projection", () => {
451512 expect ( mounted . view . state . doc . textContent ) . toBe ( "**Bolder** plain" ) ;
452513 } ) ;
453514
454- it ( "keeps native undo from running through an active projection " , async ( ) => {
515+ it ( "finalizes a clean active projection before running native undo and redo " , async ( ) => {
455516 const mounted = await mountEditor ( "**Bold** plain" ) ;
456517
457518 setSelectionAtDocumentEnd ( mounted . view ) ;
458519 typeText ( mounted . view , "!" ) ;
459520 enterProjection ( mounted , "strong" ) ;
460521
461522 expect ( await runCommand ( mounted , "edit.undo" ) ) . toBe ( true ) ;
462- expect ( hasActiveInlineSourceProjection ( mounted . view . state ) ) . toBe ( true ) ;
463- expect ( mounted . view . state . doc . textContent ) . toBe ( "**Bold** plain! " ) ;
523+ expect ( hasActiveInlineSourceProjection ( mounted . view . state ) ) . toBe ( false ) ;
524+ expect ( mounted . getMarkdown ( ) ) . toBe ( "**Bold** plain\n " ) ;
464525
465526 expect ( await runCommand ( mounted , "edit.redo" ) ) . toBe ( true ) ;
466- expect ( hasActiveInlineSourceProjection ( mounted . view . state ) ) . toBe ( true ) ;
467- expect ( mounted . view . state . doc . textContent ) . toBe ( "**Bold** plain!" ) ;
527+ expect ( mounted . getMarkdown ( ) ) . toBe ( "**Bold** plain!\n" ) ;
468528 } ) ;
469529
470530 it ( "preserves native undo and redo after projection commit" , async ( ) => {
0 commit comments