@@ -11,6 +11,7 @@ import { GapCursor } from '@tiptap/pm/gapcursor'
1111import { AllSelection , NodeSelection } from '@tiptap/pm/state'
1212import { beforeEach , describe , expect , it , vi } from 'vitest'
1313import { createMarkdownEditorExtensions } from './editor-extensions'
14+ import { postProcessSerializedMarkdown } from './markdown-fidelity'
1415import { MENTION_PLUGIN_KEY } from './mention'
1516import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
1617
@@ -192,13 +193,11 @@ describe('empty wrapped-block Backspace', () => {
192193 it . each ( [
193194 [ 'bullet middle' , '- one\n- two\n- three' , 'two' , '- one\n- three' ] ,
194195 [ 'bullet first' , '- one\n- two\n- three' , 'one' , '- two\n- three' ] ,
195- [ 'bullet last' , '- one\n- two' , 'two' , '- one' ] ,
196196 [ 'ordered middle' , '1. one\n2. two\n3. three' , 'two' , '1. one\n2. three' ] ,
197197 [ 'task middle' , '- [ ] one\n- [ ] two\n- [ ] three' , 'two' , '- [ ] one\n- [ ] three' ] ,
198198 [ 'blockquote middle' , '> one\n>\n> two\n>\n> three' , 'two' , '> one\n>\n> three' ] ,
199- [ 'nested item' , '- one\n - two\n- three' , 'two' , '- one\n- three' ] ,
200199 ] ) (
201- 'removes the emptied %s cleanly — one container, no stray paragraph, round-trips' ,
200+ 'removes an emptied non-trailing %s cleanly — one container, no stray paragraph, round-trips' ,
202201 ( _label , markdown , word , expected ) => {
203202 const editor = editorWith ( '' )
204203 editor . commands . setContent ( markdown , { contentType : 'markdown' } )
@@ -213,10 +212,10 @@ describe('empty wrapped-block Backspace', () => {
213212 }
214213 )
215214
216- it ( 'leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start ' , ( ) => {
217- // Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
218- // second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
219- // it). The selection left behind must never make the next keystroke destructive .
215+ it ( 'clears a lone empty bullet before an image to a paragraph and never destroys the image' , ( ) => {
216+ // Regression: an earlier delete-and-jump path silently NodeSelected the following image, so a
217+ // second Backspace while "clearing the bullet" deleted it. The lone empty bullet now lifts into an
218+ // empty paragraph in place (Notion), the image is untouched, and no repeated Backspace destroys it .
220219 const editor = editorWith ( {
221220 type : 'doc' ,
222221 content : [
@@ -227,11 +226,11 @@ describe('empty wrapped-block Backspace', () => {
227226 editor . commands . setTextSelection ( 3 )
228227 pressBackspace ( editor )
229228
230- expect ( blockShape ( editor ) ) . toEqual ( [ 'image' , 'paragraph' ] )
229+ expect ( blockShape ( editor ) ) . toEqual ( [ 'paragraph' , ' image', 'paragraph' ] )
231230 expect ( editor . state . selection ) . not . toBeInstanceOf ( NodeSelection )
232231
233232 pressBackspace ( editor )
234- expect ( blockShape ( editor ) ) . toEqual ( [ 'image' , 'paragraph' ] )
233+ expect ( blockShape ( editor ) ) . toContain ( 'image' )
235234 editor . destroy ( )
236235 } )
237236
@@ -252,11 +251,10 @@ describe('empty wrapped-block Backspace', () => {
252251 editor . destroy ( )
253252 } )
254253
255- it ( 'never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)' , ( ) => {
256- // `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
257- // prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
258- // image directly before the emptied bullet and no textblock behind it, the backward search
259- // returns null and the gap-cursor branch takes over — the image is never silently selected.
254+ it ( 'clears a lone empty bullet after an image to a paragraph without selecting the image' , ( ) => {
255+ // With an image directly before the emptied bullet, clearing the bullet must not silently select
256+ // (and so endanger) the image. The bullet lifts into an empty paragraph in place; the image is
257+ // untouched and no repeated Backspace destroys it.
260258 const editor = editorWith ( {
261259 type : 'doc' ,
262260 content : [
@@ -268,11 +266,11 @@ describe('empty wrapped-block Backspace', () => {
268266 expect ( editor . state . selection . $from . parent . type . name ) . toBe ( 'paragraph' )
269267 pressBackspace ( editor )
270268
271- expect ( blockShape ( editor ) ) . toEqual ( [ 'image' , 'paragraph' ] )
269+ expect ( blockShape ( editor ) ) . toEqual ( [ 'image' , 'paragraph' , 'paragraph' ] )
272270 expect ( editor . state . selection ) . not . toBeInstanceOf ( NodeSelection )
273271
274272 pressBackspace ( editor )
275- expect ( blockShape ( editor ) ) . toEqual ( [ 'image' , 'paragraph' ] )
273+ expect ( blockShape ( editor ) ) . toContain ( 'image' )
276274 editor . destroy ( )
277275 } )
278276
@@ -291,7 +289,7 @@ describe('empty wrapped-block Backspace', () => {
291289 editor . destroy ( )
292290 } )
293291
294- it ( 'still prefers the previous textblock caret when one exists ( image after the bullet untouched) ' , ( ) => {
292+ it ( 'clears a lone empty bullet between a paragraph and an image to a paragraph, leaving the image ' , ( ) => {
295293 const editor = editorWith ( {
296294 type : 'doc' ,
297295 content : [
@@ -303,10 +301,138 @@ describe('empty wrapped-block Backspace', () => {
303301 editor . commands . setTextSelection ( 10 )
304302 pressBackspace ( editor )
305303
306- expect ( blockShape ( editor ) ) . toEqual ( [ 'paragraph' , 'image' , 'paragraph' ] )
304+ expect ( blockShape ( editor ) ) . toEqual ( [ 'paragraph' , 'paragraph' , ' image', 'paragraph' ] )
307305 expect ( editor . state . selection . empty ) . toBe ( true )
308306 expect ( editor . state . selection ) . not . toBeInstanceOf ( NodeSelection )
309- expect ( editor . state . selection . $from . parent . textContent ) . toBe ( 'hello' )
307+ // The cleared bullet is now an empty paragraph, and 'hello' is untouched above it.
308+ expect ( editor . state . doc . firstChild ?. textContent ) . toBe ( 'hello' )
309+ editor . destroy ( )
310+ } )
311+ } )
312+
313+ describe ( 'Notion-style list Backspace (clear / outdent)' , ( ) => {
314+ beforeEach ( ( ) => {
315+ Element . prototype . scrollIntoView = vi . fn ( )
316+ } )
317+
318+ /** Puts the caret at the very start of the item text `word`. */
319+ function caretAtStartOf ( editor : Editor , word : string ) : void {
320+ editor . state . doc . descendants ( ( node , pos ) => {
321+ if ( node . isText && node . text === word ) editor . commands . setTextSelection ( pos )
322+ } )
323+ }
324+
325+ it ( 'lifts a top-level bullet WITH TEXT into a paragraph, keeping the text (round-trips)' , ( ) => {
326+ const editor = editorWith ( '' )
327+ editor . commands . setContent ( '- one\n- two' , { contentType : 'markdown' } )
328+ editor . commands . focus ( )
329+ caretAtStartOf ( editor , 'two' )
330+ pressBackspace ( editor )
331+
332+ // A bulletList (just 'one') followed by the lifted 'two' paragraph (+ TipTap's trailing filler).
333+ expect ( blockShape ( editor ) . slice ( 0 , 2 ) ) . toEqual ( [ 'bulletList' , 'paragraph' ] )
334+ const { md, reparsed } = markdownRoundTrip ( editor )
335+ expect ( md . trim ( ) ) . toBe ( '- one\n\ntwo' )
336+ expect ( reparsed ) . toBe ( md )
337+ editor . destroy ( )
338+ } )
339+
340+ it ( 'clears an empty TRAILING bullet to a paragraph in place — no delete, caret stays on the line' , ( ) => {
341+ const editor = editorWith ( '' )
342+ editor . commands . setContent ( '- one\n- two' , { contentType : 'markdown' } )
343+ editor . commands . focus ( )
344+ emptyItem ( editor , 'two' )
345+ pressBackspace ( editor )
346+
347+ // The bullet becomes a paragraph; the list keeps only 'one'. The caret sits in the new empty
348+ // paragraph rather than jumping back into the 'one' bullet.
349+ const list = editor . getJSON ( ) . content ?. find ( ( node ) => node . type === 'bulletList' )
350+ expect ( list ?. content ) . toHaveLength ( 1 )
351+ expect ( editor . state . selection . empty ) . toBe ( true )
352+ expect ( editor . state . selection . $from . parent . type . name ) . toBe ( 'paragraph' )
353+ expect ( editor . state . selection . $from . parent . textContent ) . toBe ( '' )
354+ expect ( editor . getMarkdown ( ) . trim ( ) ) . toBe ( '- one' )
355+ editor . destroy ( )
356+ } )
357+
358+ it ( 'clears a lone empty bullet to an empty paragraph (whole doc)' , ( ) => {
359+ const editor = editorWith ( '' )
360+ editor . commands . setContent ( '- one' , { contentType : 'markdown' } )
361+ editor . commands . focus ( )
362+ emptyItem ( editor , 'one' )
363+ pressBackspace ( editor )
364+
365+ expect ( editor . getJSON ( ) . content ?. some ( ( n ) => n . type === 'bulletList' ) ) . toBe ( false )
366+ expect ( editor . state . selection . $from . parent . type . name ) . toBe ( 'paragraph' )
367+ editor . destroy ( )
368+ } )
369+
370+ it ( 'outdents a nested bullet WITH TEXT one level instead of merging it (round-trips)' , ( ) => {
371+ const editor = editorWith ( '' )
372+ editor . commands . setContent ( '- one\n - two' , { contentType : 'markdown' } )
373+ editor . commands . focus ( )
374+ caretAtStartOf ( editor , 'two' )
375+ pressBackspace ( editor )
376+
377+ const { md, reparsed } = markdownRoundTrip ( editor )
378+ expect ( md . trim ( ) ) . toBe ( '- one\n- two' )
379+ expect ( reparsed ) . toBe ( md )
380+ editor . destroy ( )
381+ } )
382+
383+ it ( 'outdents an empty nested bullet one level (round-trips)' , ( ) => {
384+ const editor = editorWith ( '' )
385+ editor . commands . setContent ( '- one\n - two\n- three' , { contentType : 'markdown' } )
386+ editor . commands . focus ( )
387+ emptyItem ( editor , 'two' )
388+ pressBackspace ( editor )
389+
390+ const { md, reparsed } = markdownRoundTrip ( editor )
391+ expect ( md . trim ( ) ) . toBe ( '- one\n- \n- three' )
392+ expect ( reparsed ) . toBe ( md )
393+ editor . destroy ( )
394+ } )
395+
396+ it ( 'clears a checklist item the same way (task item → paragraph)' , ( ) => {
397+ const editor = editorWith ( '' )
398+ editor . commands . setContent ( '- [ ] one\n- [ ] two' , { contentType : 'markdown' } )
399+ editor . commands . focus ( )
400+ caretAtStartOf ( editor , 'two' )
401+ pressBackspace ( editor )
402+
403+ expect ( blockShape ( editor ) . slice ( 0 , 2 ) ) . toEqual ( [ 'taskList' , 'paragraph' ] )
404+ expect ( editor . getMarkdown ( ) . trim ( ) ) . toBe ( '- [ ] one\n\ntwo' )
405+ editor . destroy ( )
406+ } )
407+ } )
408+
409+ describe ( 'empty nested bullet does not corrupt its parent (Enter → Tab)' , ( ) => {
410+ beforeEach ( ( ) => {
411+ Element . prototype . scrollIntoView = vi . fn ( )
412+ } )
413+
414+ it ( 'serializes a stranded empty sub-bullet away instead of turning the parent into a heading' , ( ) => {
415+ // Repro: type a bullet, Enter for a new bullet, Tab to indent it into an empty sub-bullet, then
416+ // leave it. The serialized `- one\n - ` would re-parse as `- ## one` (Setext underline). The
417+ // serialize step must strip the empty sub-bullet so the parent stays a bullet and round-trips.
418+ const editor = editorWith ( '' )
419+ editor . commands . setContent ( '- one' , { contentType : 'markdown' } )
420+ editor . commands . focus ( )
421+ let end = - 1
422+ editor . state . doc . descendants ( ( node , pos ) => {
423+ if ( node . isText && node . text === 'one' ) end = pos + 3
424+ } )
425+ editor . commands . setTextSelection ( end )
426+ pressKey ( editor , 'Enter' )
427+ pressKey ( editor , 'Tab' )
428+
429+ const saved = postProcessSerializedMarkdown ( editor . getMarkdown ( ) )
430+ expect ( saved ) . toBe ( '- one\n' )
431+
432+ // Reloading the saved markdown keeps a bullet — never a heading.
433+ editor . commands . setContent ( saved , { contentType : 'markdown' } )
434+ expect ( blockShape ( editor ) ) . not . toContain ( 'heading' )
435+ expect ( blockShape ( editor ) ) . toContain ( 'bulletList' )
310436 editor . destroy ( )
311437 } )
312438} )
@@ -341,6 +467,20 @@ describe('empty list-item Enter', () => {
341467 expect ( editor . getJSON ( ) . content ?. some ( ( node ) => node . type === 'paragraph' ) ) . toBe ( true )
342468 editor . destroy ( )
343469 } )
470+
471+ it ( 'outdents an empty NESTED item one level (Notion) instead of removing it' , ( ) => {
472+ const editor = editorWith ( '' )
473+ editor . commands . setContent ( '- one\n - two' , { contentType : 'markdown' } )
474+ editor . commands . focus ( )
475+ emptyItem ( editor , 'two' )
476+ pressKey ( editor , 'Enter' )
477+
478+ // The emptied nested item outdents to a second top-level bullet rather than being deleted.
479+ const list = editor . getJSON ( ) . content ?. find ( ( node ) => node . type === 'bulletList' )
480+ expect ( list ?. content ) . toHaveLength ( 2 )
481+ expect ( list ?. content ?. every ( ( item ) => item . type === 'listItem' ) ) . toBe ( true )
482+ editor . destroy ( )
483+ } )
344484} )
345485
346486describe ( 'verbatim block boundary (isolating)' , ( ) => {
0 commit comments