Skip to content

feat(extension-list-keymap): sink paragraph into previous list item on tab#7893

Open
iamgio wants to merge 2 commits into
ueberdosis:mainfrom
iamgio:feat/list-keymap-tab-sink-into-list
Open

feat(extension-list-keymap): sink paragraph into previous list item on tab#7893
iamgio wants to merge 2 commits into
ueberdosis:mainfrom
iamgio:feat/list-keymap-tab-sink-into-list

Conversation

@iamgio
Copy link
Copy Markdown
Contributor

@iamgio iamgio commented May 30, 2026

Changes Overview

Note: This PR is stacked on top of #7892. Once #7892 merges, the diff will narrow to just the actual Tab commit.

Add a Tab shortcut to ListKeymap 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.

Use case: a user types a continuation under a list item that should belong inside it. Today the paragraph stays at the top level and ListKeymap makes it impossible to nest.

Round-trip example

Before:                          After Tab:
<ul><li>A</li></ul>             <ul><li></ul>
<p>|</p>                          <p>A</p>
                                  <p>|</p>
                                </li></ul>
Screen.Recording.2026-05-29.at.5.10.01.PM.mov

Implementation Approach

New helper listHelpers/handleTab.ts:

const handleTab = (editor, name, parentListTypes) => {
  // bail if selection isn't empty, cursor isn't at parentOffset 0,
  // cursor is already inside a list item, the previous sibling isn't a
  // list, or the list's last child isn't an item of the expected type
  ...
  const block = $from.parent
  const blockStart = $from.before()
  const blockEnd = $from.after()
  const insideLastItemEnd = blockStart - 2

  const tr = state.tr
  tr.delete(blockStart, blockEnd).insert(insideLastItemEnd, Fragment.from(block))
  tr.setSelection(TextSelection.create(tr.doc, insideLastItemEnd + 1))
  view.dispatch(tr.scrollIntoView())
  return true
}

insideLastItemEnd = blockStart - 2 lands inside the previous list's last item at its end (one position back for the list's closing token, one more for the last item's closing token). Same PM-coordinate reasoning as the merge step in the blockquote backspace PR (#7891).

handleBackspace gains a guard that checks whether the cursor's textblock is the first child of its list item ancestor; if not, return false and let PM handle it.

The ListKeymap extension registers Tab alongside the existing Backspace / Delete handlers. ListItem.Tab (which calls sinkListItem) continues to win when the cursor is already inside a list item, because handleTab bails in that case.

Testing Done

Added Playwright tests in demos/src/Extensions/ListKeymap/index.spec.ts:

  • Tab in a paragraph after a list sinks it into the last item.
  • Tab in an empty paragraph sinks the empty paragraph in.
  • Tab targets the last item of a multi-item list.
  • Tab in a paragraph not preceded by a list does nothing.
  • Tab in the first paragraph of the document does nothing.
  • Tab from mid-paragraph does nothing.
  • Tab inside a non-first list item still nests via sinkListItem (regression check for ListItem.Tab).
  • Backspace at the start of a sunken paragraph merges into the previous textblock.
  • Tab then Backspace round-trips an empty paragraph back to the original state.

All existing list specs (BulletList, OrderedList, TaskList) continue to pass; 56 chromium tests pass overall.

pnpm check (lint + format) is clean.

Verification Steps

  1. pnpm install
  2. pnpm test:e2e --grep "ListKeymap|BulletList|OrderedList|TaskList"
  3. Manually in the ListKeymap demo at /src/Extensions/ListKeymap/React/: set content to <ul><li>A</li></ul><p></p>, focus the empty paragraph, press Tab. The paragraph sinks inside the last list item. Press Backspace. The state returns to <ul><li>A</li></ul>.

Additional Notes

  • The Backspace guard in handleBackspace is a real bug fix that would also matter on main. I bundled it here because the Tab feature is the most obvious way for users to encounter it.
  • Out of scope here, but related: there are still some brittle position offsets in hasListBefore / hasListItemBefore. Happy to follow up in a separate PR.

Checklist

  • I have created a changeset for this PR if necessary.
  • My changes do not break the library.
  • I have added tests where applicable.
  • I have followed the project guidelines.
  • I have fixed any lint issues.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 30, 2026

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit eff4214
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/6a2083d297ad68000851647b
😎 Deploy Preview https://deploy-preview-7893--tiptap-embed.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 30, 2026

🦋 Changeset detected

Latest commit: eff4214

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 73 packages
Name Type
@tiptap/extension-list Major
@tiptap/core Major
@tiptap/extension-bullet-list Major
@tiptap/extension-ordered-list Major
@tiptap/extension-list-item Major
@tiptap/extension-list-keymap Major
@tiptap/extension-task-item Major
@tiptap/extension-task-list Major
@tiptap/extension-audio Major
@tiptap/extension-blockquote Major
@tiptap/extension-bold Major
@tiptap/extension-bubble-menu Major
@tiptap/extension-code-block-lowlight Major
@tiptap/extension-code-block Major
@tiptap/extension-code Major
@tiptap/extension-collaboration-caret Major
@tiptap/extension-collaboration Major
@tiptap/extension-details Major
@tiptap/extension-document Major
@tiptap/extension-drag-handle Major
@tiptap/extension-emoji Major
@tiptap/extension-file-handler Major
@tiptap/extension-floating-menu Major
@tiptap/extension-hard-break Major
@tiptap/extension-heading Major
@tiptap/extension-highlight Major
@tiptap/extension-horizontal-rule Major
@tiptap/extension-image Major
@tiptap/extension-invisible-characters Major
@tiptap/extension-italic Major
@tiptap/extension-link Major
@tiptap/extension-mathematics Major
@tiptap/extension-mention Major
@tiptap/extension-node-range Major
@tiptap/extension-paragraph Major
@tiptap/extension-strike Major
@tiptap/extension-subscript Major
@tiptap/extension-superscript Major
@tiptap/extension-table-of-contents Major
@tiptap/extension-table Major
@tiptap/extension-text-align Major
@tiptap/extension-text-style Major
@tiptap/extension-text Major
@tiptap/extension-twitch Major
@tiptap/extension-typography Major
@tiptap/extension-underline Major
@tiptap/extension-unique-id Major
@tiptap/extension-youtube Major
@tiptap/extensions Major
@tiptap/html Major
@tiptap/markdown Major
@tiptap/react Major
@tiptap/static-renderer Major
@tiptap/suggestion Major
@tiptap/vue-2 Major
@tiptap/vue-3 Major
@tiptap/starter-kit Major
@tiptap/extension-drag-handle-react Major
@tiptap/extension-drag-handle-vue-2 Major
@tiptap/extension-drag-handle-vue-3 Major
@tiptap/extension-table-cell Major
@tiptap/extension-table-header Major
@tiptap/extension-table-row Major
@tiptap/extension-color Major
@tiptap/extension-font-family Major
@tiptap/extension-character-count Major
@tiptap/extension-dropcursor Major
@tiptap/extension-focus Major
@tiptap/extension-gapcursor Major
@tiptap/extension-history Major
@tiptap/extension-placeholder Major
@tiptap/pm Major
@tiptap/server-ai-toolkit Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@iamgio iamgio marked this pull request as ready for review May 30, 2026 00:13
Copilot AI review requested due to automatic review settings May 30, 2026 00:13
Copy link
Copy Markdown
Member

@bdbch bdbch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - however there are some changes in here that I think were meant to be in another PR?

Comment thread .changeset/2026-05-29-list-keymap-backspace-lift-first.md Outdated
Comment thread demos/src/Extensions/ListKeymap/index.spec.ts
Comment thread packages/extension-list/src/keymap/listHelpers/handleTab.ts Outdated
@iamgio
Copy link
Copy Markdown
Contributor Author

iamgio commented May 31, 2026

@bdbch yes, it's written in the blockquote at the beginning. These changes were dependent on those of my previous PR. It'll look correct once the other PR is merged

@bdbch
Copy link
Copy Markdown
Member

bdbch commented Jun 1, 2026

Alright - lets get your blockquote PR merged first then.

@iamgio iamgio force-pushed the feat/list-keymap-tab-sink-into-list branch 2 times, most recently from 5e4a19b to e678865 Compare June 1, 2026 17:34
@iamgio
Copy link
Copy Markdown
Contributor Author

iamgio commented Jun 1, 2026

@bdbch diff is clean and added a reusable getPreviousBlockSibling() function. I'll let #7891 adopt it once merged

@iamgio iamgio requested a review from bdbch June 1, 2026 17:36
@bdbch bdbch requested review from Copilot and removed request for Copilot June 3, 2026 08:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends @tiptap/extension-list’s ListKeymap to handle a common “continuation paragraph after a list” workflow by adding a Tab shortcut that moves a top-level textblock into the previous list’s last item. It also refines the Backspace handling so Backspace at the start of a non-first block inside the same list item (e.g. a second paragraph) falls back to ProseMirror’s default merge behavior.

Changes:

  • Add handleTab and wire a Tab shortcut into ListKeymap to sink a following top-level textblock into the previous list’s last item.
  • Add a core helper getPreviousBlockSibling($pos) to reliably fetch the previous block sibling around the current textblock.
  • Add Playwright coverage for the new Tab behavior and the Backspace regression/round-trip scenarios, plus a changeset documenting the user-facing additions.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/extension-list/src/keymap/listHelpers/index.ts Re-export the new handleTab helper from the list keymap helper barrel.
packages/extension-list/src/keymap/listHelpers/handleTab.ts Implements the Tab sink behavior by moving the current top-level block into the previous list’s last item and restoring selection.
packages/extension-list/src/keymap/listHelpers/handleBackspace.ts Adds a guard to only intercept Backspace at the start of the first child within a list item, preserving expected intra-item merging.
packages/extension-list/src/keymap/list-keymap.ts Registers Tab alongside the existing key handlers and routes through per-list-type handling.
packages/core/src/helpers/index.ts Exports the new core helper.
packages/core/src/helpers/getPreviousBlockSibling.ts New helper to fetch the previous block sibling for a resolved position.
demos/src/Extensions/ListKeymap/index.spec.ts Adds Playwright tests covering Tab sink behavior, precedence vs sinkListItem, and Backspace round-trips.
.changeset/2026-05-29-list-keymap-tab-sink-into-list.md Documents the new Tab behavior in ListKeymap and the new @tiptap/core helper export.

Copy link
Copy Markdown
Member

@bdbch bdbch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for coming back again - some small nitpicks:

  1. Could you implement a way to check if the sank content actually fits into the schema? otherwise this could throw an error when the content won't fit the list schema
  2. Can you add a test for the new getPreviousBlockSibling util?

Otherwise this looks fine.

…n tab

ListKeymap now registers a `Tab` shortcut that sinks a top-level
textblock into the previous list's last item when pressed at the start
of that textblock. The paragraph (and its inline content) is moved
inside the last list item as an additional block child. The handler
bails when the cursor is already inside a list item (preserving
`sinkListItem`'s nesting behavior), when there is no list before the
paragraph, or when the caret is mid-textblock.

`handleBackspace` is also tightened: pressing Backspace at the start
of a non-first child of a list item now falls through to PM's
joinBackward so the textblock merges upward inside the same item,
instead of lifting the whole item out. Makes the Tab sink and the
subsequent Backspace round-trip cleanly.

A small reusable helper `getPreviousBlockSibling($pos)` is exposed
from @tiptap/core: it 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. The same pattern lives inline in a
couple of other keymap paths (blockquote backspace from ueberdosis#7891,
hasListBefore / hasListItemBefore); migrating those callers can
happen as follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iamgio iamgio force-pushed the feat/list-keymap-tab-sink-into-list branch from e678865 to e5ae8fa Compare June 3, 2026 19:42
@iamgio iamgio requested a review from bdbch June 3, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants