fix(rich-markdown-editor): eliminate empty-block / boundary-key structural bugs (#5517)

* 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.

* fix(rich-markdown-editor): generalize empty-wrapped-block boundary keys

Replace the joinBackward empty-list-item Backspace branch with a walk-up-and-delete
that removes the whole emptied wrapper, and extend it to task items and blockquotes.
joinBackward left a stray empty paragraph inside the previous item (flat lists) or
no-op'd entirely on nested items (leaving them stuck); blockquotes were never handled
and split in two. Add an Enter handler so an empty non-trailing list/task item is
removed rather than split into two lists around a stranded, non-round-trippable empty
paragraph; a trailing empty item still exits the list via the default.

Covers bullet/ordered/task/blockquote at every position; non-empty items and the
double-Enter list-exit are unaffected.

* fix(rich-markdown-editor): isolate verbatim block nodes from boundary joins

footnoteDef and rawHtmlBlock hold exact source text but were neither isolating nor
atom, so a single Backspace/Delete at their boundary let ProseMirror's default join
merge their raw markdown into an adjacent paragraph as HTML-escaped prose — silently
destroying the node and corrupting saved markdown. Mark the block variants isolating
so boundary keys can't cross their edge. Round-trip and in-place editing are unchanged.

* fix(rich-markdown-editor): escape leading block markers in paragraph serialization

A paragraph beginning with #, -, +, 1., 1), or a bare --- serialized unescaped, so it
silently re-parsed into a heading / list / thematic break on the next load (reachable
via type-a-marker then undo). The upstream serializer escapes inline delimiters (* _ `
[ ] ~, so * bullets and > quotes already round-trip) but not these block-starting
markers. BlockSafeParagraph wraps the paragraph renderer with a leading-marker guard;
escaping is idempotent (parsing consumes the backslash) and never over-escapes non-markers
like #hashtag or -5.

* fix(rich-markdown-editor): strip leading paragraph indent; keep empty paragraphs  -free

A paragraph beginning with a 4-space/tab indent re-parsed as an indented code block on the
next load. Leading whitespace never renders in a paragraph (CommonMark strips up to three
leading spaces; four or more become code), so BlockSafeParagraph now strips it — lossless
and idempotent. Composes with the existing leading-marker escaping (e.g. '   # x' → '\# x').

Also locks in that consecutive empty paragraphs serialize via blank lines rather than the
upstream ' ' marker: replacing StarterKit's Paragraph already dropped the   path,
and the round-trip preserves the empty-paragraph count without tripping the read-only
safety gate (which flags   as a stable-loss pattern). Added tests for both.
This commit is contained in:
Waleed
2026-07-08 15:14:15 -07:00
committed by GitHub
parent d30eb36d9a
commit 1236474efb
5 changed files with 335 additions and 9 deletions
@@ -1,6 +1,7 @@
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
import { Code } from '@tiptap/extension-code'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import {
renderTableToMarkdown,
Table,
@@ -57,6 +58,39 @@ const PipeSafeTable = Table.extend({
.replace(/\n+$/, ''),
})
/**
* Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
* block on the next load:
*
* - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
* leading spaces, and four or more would re-parse the paragraph as an indented code block), so
* removing it is lossless and makes the round-trip idempotent.
* - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
* paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
* delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
* block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
* ProseMirror text never carries it and re-serialization is stable.
*/
function guardParagraphLeading(text: string): string {
const stripped = text.replace(/^[ \t]+/, '')
if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
return `\\${stripped}`
}
const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
}
/**
* Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
* otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
* thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
* paragraph renders as just its inline children; this override wraps that with the leading guard.
*/
const BlockSafeParagraph = Paragraph.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
guardParagraphLeading(h.renderChildren(node.content ?? [])),
})
/**
* Node-view variants the live editor injects in place of the headless defaults — the code-block
* language picker, the resizable image, and the mention chip. The mention chip pulls the block registry
@@ -92,7 +126,9 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
underline: false,
codeBlock: false,
code: false,
paragraph: false,
}),
BlockSafeParagraph,
InlineCode,
codeBlock,
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
@@ -33,12 +33,37 @@ function firstPosOf(editor: Editor, type: string): number {
return pos
}
function pressBackspace(editor: Editor): void {
function pressKey(editor: Editor, key: string): void {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true })
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
)
}
function pressBackspace(editor: Editor): void {
pressKey(editor, 'Backspace')
}
/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */
function emptyItem(editor: Editor, word: string): void {
let from = -1
let to = -1
editor.state.doc.descendants((node, pos) => {
if (from < 0 && node.isText && node.text === word) {
from = pos
to = pos + word.length
}
})
editor.commands.setTextSelection({ from, to })
editor.commands.deleteSelection()
}
/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */
function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } {
const md = editor.getMarkdown()
editor.commands.setContent(md, { contentType: 'markdown' })
return { md, reparsed: editor.getMarkdown() }
}
describe('suggestion-aware arrow keymap', () => {
beforeEach(() => {
// The suggestion render lifecycle uses these; jsdom lacks them.
@@ -141,3 +166,94 @@ describe('divider Backspace', () => {
editor.destroy()
})
})
describe('empty wrapped-block Backspace', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it.each([
['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
['bullet last', '- one\n- two', 'two', '- one'],
['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
])(
'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
(_label, markdown, word, expected) => {
const editor = editorWith('')
editor.commands.setContent(markdown, { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, word)
pressBackspace(editor)
const { md, reparsed } = markdownRoundTrip(editor)
expect(md.trim()).toBe(expected)
expect(reparsed).toBe(md)
editor.destroy()
}
)
})
describe('empty list-item Enter', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => {
const editor = editorWith('')
editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, 'two')
pressKey(editor, 'Enter')
const { md, reparsed } = markdownRoundTrip(editor)
expect(md.trim()).toBe('- one\n- three')
expect(reparsed).toBe(md)
editor.destroy()
})
it('leaves an empty TRAILING item to the default (exits the list)', () => {
const editor = editorWith('')
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
editor.commands.focus()
emptyItem(editor, 'two')
pressKey(editor, 'Enter')
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
expect(list?.content).toHaveLength(1)
expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
editor.destroy()
})
})
describe('verbatim block boundary (isolating)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function caretIntoNode(editor: Editor, nodeType: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1)
})
}
it.each([
['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'],
['raw HTML block', 'body\n\n<div>\nhello\n</div>', 'rawHtmlBlock'],
])(
'Backspace at the start of a %s does not merge across its boundary and destroy it',
(_label, markdown, nodeType) => {
const editor = editorWith('')
editor.commands.setContent(markdown, { contentType: 'markdown' })
editor.commands.focus()
expect(blockShape(editor)).toContain(nodeType)
caretIntoNode(editor, nodeType)
pressBackspace(editor)
expect(blockShape(editor)).toContain(nodeType)
editor.destroy()
}
)
})
@@ -1,7 +1,8 @@
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
import type { ResolvedPos } from '@tiptap/pm/model'
import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { MENTION_PLUGIN_KEY } from './mention'
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
@@ -9,6 +10,47 @@ import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
/**
* Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
* block out of one of these splits the container in two and strands an empty paragraph — a visible gap
* that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
*/
const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
/** Item node types a list is built from, used to detect an empty item's position within its list. */
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
function isInsideWrapper($from: ResolvedPos): boolean {
for (let depth = $from.depth - 1; depth >= 1; depth--) {
if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true
}
return false
}
/**
* Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
* child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
* whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
* item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
*/
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
let depth = $from.depth
while (depth > 1 && $from.node(depth - 1).childCount === 1) depth--
const start = $from.before(depth)
const end = $from.after(depth)
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(start, end)
tr.setSelection(Selection.near(tr.doc.resolve(start), -1))
dispatch(tr.scrollIntoView())
}
return true
})
}
/**
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
* 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
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
*
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a
* block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross
* the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below dividers
* without touching the divider itself), while a *non-empty* block selects the leaf — so a first
* Backspace highlights what a second deletes, the same highlight-before-delete affordance as clicking
* it and parity with the arrow-key leaf selection.
* no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
* *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
* {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
* the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
* that also re-parses to a different markdown document), while the default `joinBackward` alternately
* no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
* previous item. At the start of a block whose previous sibling is a divider or image, where
* ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
* the blank line between/below dividers without touching the divider itself), while a *non-empty*
* block selects the leaf — so a first Backspace highlights what a second deletes, the same
* highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
* - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
* removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
* empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
* default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
* block is already fully selected) falls through to the default whole-document select-all, the
* same scoped behavior as a code editor.
@@ -97,6 +148,9 @@ export const RichMarkdownKeymap = Extension.create({
if ($from.parent.type.name === 'heading') {
return editor.commands.setParagraph()
}
if ($from.parent.content.size === 0 && isInsideWrapper($from)) {
return removeEmptyWrappedBlock(editor, $from)
}
const blockStart = $from.before($from.depth)
const nodeBefore = doc.resolve(blockStart).nodeBefore
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false
@@ -113,6 +167,18 @@ export const RichMarkdownKeymap = Extension.create({
}
return editor.commands.setNodeSelection(leafStart)
},
Enter: ({ editor }) => {
const { selection } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
if ($from.parent.content.size !== 0) return false
const itemDepth = $from.depth - 1
if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false
const listDepth = itemDepth - 1
const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1
if (isTrailingItem) return false
return removeEmptyWrappedBlock(editor, $from)
},
'Mod-a': ({ editor }) => {
const { $from } = editor.state.selection
if ($from.parent.type.name !== 'codeBlock') return false
@@ -167,6 +167,10 @@ function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) {
marks: '',
code: true,
defining: !inline,
// Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from
// joining across their edge, which would otherwise merge their raw markdown into an adjacent
// paragraph as HTML-escaped prose and destroy the node (silent data loss on save).
isolating: !inline,
selectable: true,
atom: false,
parseHTML() {
@@ -330,3 +330,107 @@ describe('link href sanitization — dangerous schemes from file content are neu
expect(hrefs).toContain('mailto:x@y.com')
})
})
describe('paragraph leading guard (marker escaping + indent stripping)', () => {
/** Serialize a doc whose first paragraph literally starts with `text`, then re-parse its first node. */
function serializeParagraph(text: string): {
md: string
reparsedType: string
idempotent: boolean
} {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] },
{ contentType: 'json' }
)
const md = postProcessSerializedMarkdown(editor.getMarkdown())
editor.commands.setContent(md, { contentType: 'markdown' })
const reparsedType = editor.getJSON().content?.[0]?.type ?? ''
const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
editor.destroy()
editor = null
return { md, reparsedType, idempotent }
}
it.each([
['# note', '\\# note'],
['###### note', '\\###### note'],
['#', '\\#'],
['- item', '\\- item'],
['+ item', '\\+ item'],
['1. step', '1\\. step'],
['1) step', '1\\) step'],
['---', '\\---'],
['- - -', '\\- - -'],
])('escapes a paragraph starting with %j so it stays a paragraph', (text, expectedMd) => {
const { md, reparsedType, idempotent } = serializeParagraph(text)
expect(md.trim()).toBe(expectedMd)
expect(reparsedType).toBe('paragraph')
expect(idempotent).toBe(true)
})
it.each([
['#hashtag'], // no space after # → not a heading
['-5 degrees'], // no space after - → not a bullet
['plain text'],
])('does not over-escape %j', (text) => {
const { md, reparsedType, idempotent } = serializeParagraph(text)
expect(md.trim()).toBe(text)
expect(reparsedType).toBe('paragraph')
expect(idempotent).toBe(true)
})
it.each([
[' four spaces', 'four spaces'],
['\ttab indent', 'tab indent'],
[' eight spaces', 'eight spaces'],
[' # indented marker', '\\# indented marker'],
])(
'strips leading indent so %j stays a paragraph instead of an indented code block',
(text, expectedMd) => {
const { md, reparsedType, idempotent } = serializeParagraph(text)
expect(md.trim()).toBe(expectedMd)
expect(reparsedType).toBe('paragraph')
expect(idempotent).toBe(true)
}
)
})
describe('consecutive empty paragraphs', () => {
/** Doc with `a`, then `count` empty paragraphs, then `b`; serialized and round-tripped. */
function serializeEmpties(count: number) {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
const emptyParas = Array.from({ length: count }, () => ({ type: 'paragraph', content: [] }))
editor.commands.setContent(
{
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
...emptyParas,
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
],
},
{ contentType: 'json' }
)
const md = postProcessSerializedMarkdown(editor.getMarkdown())
editor.commands.setContent(md, { contentType: 'markdown' })
const emptyCount = (editor.getJSON().content ?? []).filter(
(n) => n.type === 'paragraph' && !n.content?.length
).length
const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
editor.destroy()
editor = null
return { md, emptyCount, idempotent }
}
it.each([[1], [2], [3], [4]])(
'preserves %i empty paragraph(s) via blank lines (no &nbsp;, idempotent, no read-only trigger)',
(count) => {
const { md, emptyCount, idempotent } = serializeEmpties(count)
expect(md).not.toContain('&nbsp;')
expect(md).not.toContain(String.fromCharCode(0x00a0))
expect(emptyCount).toBe(count)
expect(idempotent).toBe(true)
}
)
})