Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}
)
})
Original file line number Diff line number Diff line change
@@ -1,14 +1,56 @@
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'

/** 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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading