11import type { Editor } from '@tiptap/core'
22import { Extension } from '@tiptap/core'
33import { GapCursor } from '@tiptap/pm/gapcursor'
4- import { NodeSelection , Plugin , PluginKey } from '@tiptap/pm/state'
4+ import type { ResolvedPos } from '@tiptap/pm/model'
5+ import { NodeSelection , Plugin , PluginKey , Selection } from '@tiptap/pm/state'
56import { Decoration , DecorationSet } from '@tiptap/pm/view'
67import { MENTION_PLUGIN_KEY } from './mention'
78import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
89
910/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
1011const SELECTABLE_LEAVES = new Set ( [ 'horizontalRule' , 'image' ] )
1112
13+ /**
14+ * Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
15+ * block out of one of these splits the container in two and strands an empty paragraph — a visible gap
16+ * that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
17+ */
18+ const WRAPPER_TYPES = new Set ( [ 'listItem' , 'taskItem' , 'blockquote' ] )
19+
20+ /** Item node types a list is built from, used to detect an empty item's position within its list. */
21+ const LIST_ITEM_TYPES = new Set ( [ 'listItem' , 'taskItem' ] )
22+
23+ /** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
24+ function isInsideWrapper ( $from : ResolvedPos ) : boolean {
25+ for ( let depth = $from . depth - 1 ; depth >= 1 ; depth -- ) {
26+ if ( WRAPPER_TYPES . has ( $from . node ( depth ) . type . name ) ) return true
27+ }
28+ return false
29+ }
30+
31+ /**
32+ * Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
33+ * child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
34+ * whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
35+ * container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
36+ * document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
37+ * item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
38+ */
39+ function removeEmptyWrappedBlock ( editor : Editor , $from : ResolvedPos ) : boolean {
40+ let depth = $from . depth
41+ while ( depth > 1 && $from . node ( depth - 1 ) . childCount === 1 ) depth --
42+ const start = $from . before ( depth )
43+ const end = $from . after ( depth )
44+ return editor . commands . command ( ( { tr, dispatch } ) => {
45+ if ( dispatch ) {
46+ tr . delete ( start , end )
47+ tr . setSelection ( Selection . near ( tr . doc . resolve ( start ) , - 1 ) )
48+ dispatch ( tr . scrollIntoView ( ) )
49+ }
50+ return true
51+ } )
52+ }
53+
1254/**
1355 * True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
1456 * the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
@@ -66,12 +108,21 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
66108 * Editor-specific keyboard behavior layered on top of StarterKit's defaults:
67109 *
68110 * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
69- * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a
70- * block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross
71- * the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below dividers
72- * without touching the divider itself), while a *non-empty* block selects the leaf — so a first
73- * Backspace highlights what a second deletes, the same highlight-before-delete affordance as clicking
74- * it and parity with the arrow-key leaf selection.
111+ * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
112+ * *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
113+ * {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
114+ * the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
115+ * that also re-parses to a different markdown document), while the default `joinBackward` alternately
116+ * no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
117+ * previous item. At the start of a block whose previous sibling is a divider or image, where
118+ * ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
119+ * the blank line between/below dividers without touching the divider itself), while a *non-empty*
120+ * block selects the leaf — so a first Backspace highlights what a second deletes, the same
121+ * highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
122+ * - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
123+ * removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
124+ * empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
125+ * default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
75126 * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
76127 * block is already fully selected) falls through to the default whole-document select-all, the
77128 * same scoped behavior as a code editor.
@@ -97,6 +148,9 @@ export const RichMarkdownKeymap = Extension.create({
97148 if ( $from . parent . type . name === 'heading' ) {
98149 return editor . commands . setParagraph ( )
99150 }
151+ if ( $from . parent . content . size === 0 && isInsideWrapper ( $from ) ) {
152+ return removeEmptyWrappedBlock ( editor , $from )
153+ }
100154 const blockStart = $from . before ( $from . depth )
101155 const nodeBefore = doc . resolve ( blockStart ) . nodeBefore
102156 if ( ! nodeBefore || ! SELECTABLE_LEAVES . has ( nodeBefore . type . name ) ) return false
@@ -113,6 +167,18 @@ export const RichMarkdownKeymap = Extension.create({
113167 }
114168 return editor . commands . setNodeSelection ( leafStart )
115169 } ,
170+ Enter : ( { editor } ) => {
171+ const { selection } = editor . state
172+ if ( ! selection . empty || selection . $from . parentOffset !== 0 ) return false
173+ const { $from } = selection
174+ if ( $from . parent . content . size !== 0 ) return false
175+ const itemDepth = $from . depth - 1
176+ if ( itemDepth < 1 || ! LIST_ITEM_TYPES . has ( $from . node ( itemDepth ) . type . name ) ) return false
177+ const listDepth = itemDepth - 1
178+ const isTrailingItem = $from . index ( listDepth ) === $from . node ( listDepth ) . childCount - 1
179+ if ( isTrailingItem ) return false
180+ return removeEmptyWrappedBlock ( editor , $from )
181+ } ,
116182 'Mod-a' : ( { editor } ) => {
117183 const { $from } = editor . state . selection
118184 if ( $from . parent . type . name !== 'codeBlock' ) return false
0 commit comments