Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/2026-05-29-list-keymap-tab-sink-into-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tiptap/extension-list': minor
'@tiptap/core': minor
---

`ListKeymap` now registers a `Tab` shortcut that sinks a top-level textblock into the previous list's last item. Pressing Tab at the start of a paragraph immediately following a bullet/ordered/task list moves the paragraph (and its inline content) inside the last list item, where it becomes an additional block child. The handler stays out of the way when the cursor is already inside a list item (so `sinkListItem`'s nesting behavior is preserved), when there is no list before the paragraph, or when the caret is mid-textblock.

`@tiptap/core` also exposes a new `getPreviousBlockSibling($pos)` helper that returns the block-level sibling immediately before the cursor's textblock (or null when the cursor is at the first child of its block parent). Useful when writing custom keymaps that need to react to what's just before the current block.
98 changes: 98 additions & 0 deletions demos/src/Extensions/ListKeymap/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ test.describe(`${demoPath}/${demoName}`, () => {
)
}

const placeCaretInEmptyParagraph = async (
editor: Awaited<ReturnType<typeof getEditor>>,
content: string,
) => {
await editor.evaluate(
(el: any, { content: innerContent }: any) => {
el.editor.commands.setContent(innerContent)
let caret = -1
el.editor.state.doc.descendants((node: any, pos: number) => {
if (caret !== -1) return
if (node.type.name === 'paragraph' && node.content.size === 0) {
caret = pos + 1
}
})
if (caret === -1) throw new Error('Could not find an empty paragraph in the document')
el.editor.chain().focus().setTextSelection({ from: caret, to: caret }).run()
},
{ content },
)
}

test('backspace at the start of a non-first item lifts it out of the list', async ({
page,
}) => {
Expand Down Expand Up @@ -70,6 +91,83 @@ test.describe(`${demoPath}/${demoName}`, () => {
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>AB</p></li></ul>')
})

test('tab in a paragraph after a list sinks it into the last item', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<ul><li><p>A</p></li></ul><p>B</p>', 'B')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>A</p><p>B</p></li></ul>')
})

test('tab in an empty paragraph after a list sinks the empty paragraph in', async ({
page,
}) => {
const editor = await getEditor(page)
await placeCaretInEmptyParagraph(editor, '<ul><li><p>A</p></li></ul><p></p>')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>A</p><p></p></li></ul>')
})

test('tab targets the last item of a multi-item list', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<ul><li><p>A</p></li><li><p>X</p></li></ul><p>B</p>', 'B')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>A</p></li><li><p>X</p><p>B</p></li></ul>')
})

test('tab in a paragraph not preceded by a list does nothing', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<p>A</p><p>B</p>', 'B')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<p>A</p><p>B</p>')
})

test('tab in the first paragraph of the document does nothing', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<p>A</p>', 'A')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<p>A</p>')
})

test('tab from mid-paragraph does nothing', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<ul><li><p>A</p></li></ul><p>Hello</p>', 'ello')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>A</p></li></ul><p>Hello</p>')
})

test('tab inside a non-first list item still nests via sinkListItem', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<ul><li><p>A</p></li><li><p>B</p></li></ul>', 'B')
await editor.press('Tab')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>A</p><ul><li><p>B</p></li></ul></li></ul>')
})

test('backspace at the start of a sunken paragraph merges into the previous textblock', async ({
page,
}) => {
const editor = await getEditor(page)
await placeCaretBefore(editor, '<ul><li><p>A</p><p>B</p></li></ul>', 'B')
await editor.press('Backspace')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>AB</p></li></ul>')
})

test('tab then backspace round-trips an empty paragraph', async ({ page }) => {
const editor = await getEditor(page)
await placeCaretInEmptyParagraph(editor, '<ul><li><p>A</p></li></ul><p></p>')
await editor.press('Tab')
await editor.press('Backspace')
const html = await editor.evaluate((el: any) => el.editor.getHTML())
expect(html).toBe('<ul><li><p>A</p></li></ul>')
})
})
})
})
110 changes: 110 additions & 0 deletions packages/core/__tests__/getPreviousBlockSibling.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { getPreviousBlockSibling, getSchemaByResolvedExtensions } from '@tiptap/core'
import Blockquote from '@tiptap/extension-blockquote'
import BulletList from '@tiptap/extension-bullet-list'
import Document from '@tiptap/extension-document'
import ListItem from '@tiptap/extension-list-item'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Node } from '@tiptap/pm/model'
import { describe, expect, it } from 'vitest'

const schema = getSchemaByResolvedExtensions([
Document,
Paragraph,
Text,
Blockquote,
BulletList,
ListItem,
])

/**
* Resolves the position right before the first occurrence of `target` text.
*/
const resolveBefore = (doc: Node, target: string) => {
let pos = -1

doc.descendants((node, nodePos) => {
if (pos !== -1 || !node.isText || !node.text) {
return
}

const offset = node.text.indexOf(target)

if (offset !== -1) {
pos = nodePos + offset
}
})

if (pos === -1) {
throw new Error(`Could not find "${target}" in the document`)
}

return doc.resolve(pos)
}

describe('getPreviousBlockSibling', () => {
it('returns the previous block-level sibling of a top-level paragraph', () => {
const doc = Node.fromJSON(schema, {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
},
],
},
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
],
})

const previous = getPreviousBlockSibling(resolveBefore(doc, 'B'))

expect(previous?.type.name).toBe('bulletList')
})

it('returns the previous sibling within the same parent', () => {
const doc = Node.fromJSON(schema, {
type: 'doc',
content: [
{
type: 'blockquote',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
],
},
],
})

const previous = getPreviousBlockSibling(resolveBefore(doc, 'B'))

expect(previous?.type.name).toBe('paragraph')
expect(previous?.textContent).toBe('A')
})

it('returns null when the cursor is in the first child of its block parent', () => {
const doc = Node.fromJSON(schema, {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
})

expect(getPreviousBlockSibling(resolveBefore(doc, 'A'))).toBeNull()
})

it('returns null at the top of the document', () => {
const doc = Node.fromJSON(schema, {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
],
})

// resolve(0) sits before the very first node, so there is no parent depth
// above it to read a sibling from.
expect(getPreviousBlockSibling(doc.resolve(0))).toBeNull()
})
})
34 changes: 34 additions & 0 deletions packages/core/src/helpers/getPreviousBlockSibling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Node, ResolvedPos } from '@tiptap/pm/model'

/**
* Returns the block-level sibling immediately before the cursor's textblock
* (or null when the cursor is at the first child of its block parent).
*
* Equivalent to walking up to the position's direct block parent and reading
* the child at the index just before the cursor's textblock.
*
* @param $pos The resolved position to look around
* @returns The previous block-level sibling, or null
* @example ```js
* // Cursor in a top-level paragraph after a list:
* // <ul><li>A</li></ul><p>|B</p>
* getPreviousBlockSibling($from) // <ul>
*
* // Cursor in the second paragraph of a list item:
* // <ul><li><p>A</p><p>|B</p></li></ul>
* getPreviousBlockSibling($from) // <p>A</p>
*
* // Cursor in the first child of its block parent:
* // <doc><p>|A</p></doc>
* getPreviousBlockSibling($from) // null
* ```
*/
export const getPreviousBlockSibling = ($pos: ResolvedPos): Node | null => {
const parentDepth = $pos.depth - 1
if (parentDepth < 0) return null

const index = $pos.index(parentDepth)
if (index === 0) return null

return $pos.node(parentDepth).child(index - 1)
}
1 change: 1 addition & 0 deletions packages/core/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './getMarkType.js'
export * from './getNodeAtPosition.js'
export * from './getNodeAttributes.js'
export * from './getNodeType.js'
export * from './getPreviousBlockSibling.js'
export * from './getRenderedAttributes.js'
export * from './getSchema.js'
export * from './getSchemaByResolvedExtensions.js'
Expand Down
17 changes: 16 additions & 1 deletion packages/extension-list/src/keymap/list-keymap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Extension } from '@tiptap/core'

import { handleBackspace, handleDelete } from './listHelpers/index.js'
import { handleBackspace, handleDelete, handleTab } from './listHelpers/index.js'

export type ListKeymapOptions = {
/**
Expand Down Expand Up @@ -99,6 +99,21 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
}
})

return handled
},
Tab: ({ editor }) => {
let handled = false

this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return
}

if (handleTab(editor, itemName, wrapperNames)) {
handled = true
}
})

return handled
},
}
Expand Down
10 changes: 10 additions & 0 deletions packages/extension-list/src/keymap/listHelpers/handleBackspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
return false
}

// Only intercept at the very start of the list item (its first child).
// For a cursor at the start of a later child (e.g. a second paragraph
// inside the same item), let joinBackward merge upward within the item
// instead of lifting the whole item out.
const { $from } = editor.state.selection
const itemDepth = $from.depth - 1
if ($from.node(itemDepth).type !== editor.schema.nodes[name] || $from.index(itemDepth) !== 0) {
return false
}

// At the start of a list item, lift it out. Top-level items split the
// wrapping list around them; nested items get promoted into the outer
// list. A second backspace then falls through to the merge branch above.
Expand Down
47 changes: 47 additions & 0 deletions packages/extension-list/src/keymap/listHelpers/handleTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Editor } from '@tiptap/core'
import { getPreviousBlockSibling, isNodeActive } from '@tiptap/core'
import { Fragment } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'

export const handleTab = (editor: Editor, name: string, parentListTypes: string[]) => {
const { state, view } = editor
const { selection } = state
if (!selection.empty) return false

const { $from } = selection
if ($from.parentOffset !== 0) return false

// Bail when the cursor is already inside a list item. ListItem and TaskItem
// own Tab themselves (sinkListItem) and we should not double-handle it.
if (isNodeActive(state, name)) return false

const previous = getPreviousBlockSibling($from)
if (!previous || !parentListTypes.includes(previous.type.name)) return false

const lastItem = previous.lastChild
if (!lastItem || lastItem.type.name !== name) return false

const block = $from.parent

// Bail when the block wouldn't fit the list item's schema.
if (!lastItem.canReplace(lastItem.childCount, lastItem.childCount, Fragment.from(block))) {
return false
}

const blockStart = $from.before()
const blockEnd = $from.after()

// `blockStart` sits in the shared parent right after the previous list.
// Walk back two positions to land inside the previous list's last item at
// its end (one for the closing token of the list, one for the closing
// token of the last item).
const insideLastItemEnd = blockStart - 2

const tr = state.tr
tr.delete(blockStart, blockEnd).insert(insideLastItemEnd, Fragment.from(block))
// Cursor lands right inside the inserted block at its start: one position
// past the insertion point steps over the block's opening token.
tr.setSelection(TextSelection.create(tr.doc, insideLastItemEnd + 1))
view.dispatch(tr.scrollIntoView())
return true
}
1 change: 1 addition & 0 deletions packages/extension-list/src/keymap/listHelpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './findListItemPos.js'
export * from './getNextListDepth.js'
export * from './handleBackspace.js'
export * from './handleDelete.js'
export * from './handleTab.js'
export * from './hasListBefore.js'
export * from './hasListItemAfter.js'
export * from './hasListItemBefore.js'
Expand Down
Loading