Skip to content

Commit aa06993

Browse files
committed
fix(rich-markdown-editor): don't split a list when deleting an empty item
Backspacing an emptied list item in the middle of a list used ProseMirror's default lift, which pulls the item out into a top-level paragraph — splitting one list into two and stranding an empty paragraph (a visible gap). Backspace at the start of an empty list item now joins into the previous block instead, removing the item and keeping a single list. Covers bullet, ordered, and task lists.
1 parent 4a80374 commit aa06993

2 files changed

Lines changed: 56 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,41 @@ describe('divider Backspace', () => {
141141
editor.destroy()
142142
})
143143
})
144+
145+
describe('empty list-item Backspace', () => {
146+
beforeEach(() => {
147+
Element.prototype.scrollIntoView = vi.fn()
148+
})
149+
150+
it.each([
151+
['bullet list', '- one\n- two\n- three', 'bulletList'],
152+
['ordered list', '1. one\n2. two\n3. three', 'orderedList'],
153+
['task list', '- [ ] one\n- [ ] two\n- [ ] three', 'taskList'],
154+
])(
155+
'joins an empty middle %s item into the previous item instead of splitting the list',
156+
(_label, markdown, listType) => {
157+
const editor = editorWith('')
158+
editor.commands.setContent(markdown, { contentType: 'markdown' })
159+
editor.commands.focus()
160+
let from = -1
161+
let to = -1
162+
editor.state.doc.descendants((node, pos) => {
163+
if (from < 0 && node.isText && node.text === 'two') {
164+
from = pos
165+
to = pos + node.text.length
166+
}
167+
})
168+
editor.commands.setTextSelection({ from, to })
169+
editor.commands.deleteSelection()
170+
pressBackspace(editor)
171+
172+
expect(blockShape(editor).filter((type) => type === listType)).toEqual([listType])
173+
const items: string[] = []
174+
editor.state.doc.descendants((node) => {
175+
if (node.type.name === 'paragraph' && node.textContent) items.push(node.textContent)
176+
})
177+
expect(items).toEqual(['one', 'three'])
178+
editor.destroy()
179+
}
180+
)
181+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
99
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
1010
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
1111

12+
/** List-item node types whose empty-item Backspace must join rather than lift (see the Backspace docs). */
13+
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
14+
1215
/**
1316
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
1417
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
@@ -66,12 +69,15 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
6669
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
6770
*
6871
* - **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.
72+
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
73+
* *empty list item* it joins into the previous block instead of ProseMirror's default lift — lifting
74+
* an empty item out of the middle of a list splits it into two lists and strands an empty paragraph,
75+
* a visible gap; `joinBackward` removes the item and keeps one list. At the start of a block whose
76+
* previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross the leaf and
77+
* no-ops: an *empty* block is deleted (clearing the blank line between/below dividers without touching
78+
* the divider itself), while a *non-empty* block selects the leaf — so a first Backspace highlights
79+
* what a second deletes, the same highlight-before-delete affordance as clicking it and parity with
80+
* the arrow-key leaf selection.
7581
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
7682
* block is already fully selected) falls through to the default whole-document select-all, the
7783
* same scoped behavior as a code editor.
@@ -97,6 +103,12 @@ export const RichMarkdownKeymap = Extension.create({
97103
if ($from.parent.type.name === 'heading') {
98104
return editor.commands.setParagraph()
99105
}
106+
if (
107+
$from.parent.content.size === 0 &&
108+
LIST_ITEM_TYPES.has($from.node($from.depth - 1).type.name)
109+
) {
110+
return editor.commands.joinBackward()
111+
}
100112
const blockStart = $from.before($from.depth)
101113
const nodeBefore = doc.resolve(blockStart).nodeBefore
102114
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false

0 commit comments

Comments
 (0)