From 84dea64a9ea5d4b459135b4b1b05ae01d6de30a2 Mon Sep 17 00:00:00 2001 From: Henning Muszynski Date: Tue, 14 Jul 2026 09:41:20 +0200 Subject: [PATCH 1/2] feat(rich-text): preserve ordered list markers when unindenting out of a list Typing a number followed by a dot and a space (e.g. the German date `17. Juli`) converts the paragraph into an ordered list via the automatic input rule, swallowing the typed number into the list's `start` attribute. Unindenting the item with `Shift-Tab` then dropped the number entirely, making it impossible to recover the original text. `RichTextListItem` now extends the built-in `ListItem` extension so that when `Shift-Tab` lifts an item out of an ordered list (rather than unindenting it into a parent list), the rendered marker is re-inserted as literal text at the start of the lifted paragraph, preserving exactly what was on screen. Co-Authored-By: Claude Fable 5 --- src/extensions/rich-text/rich-text-kit.ts | 4 +- .../rich-text/rich-text-list-item.test.ts | 103 +++++++++++++++++ .../rich-text/rich-text-list-item.ts | 106 ++++++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/extensions/rich-text/rich-text-list-item.test.ts create mode 100644 src/extensions/rich-text/rich-text-list-item.ts diff --git a/src/extensions/rich-text/rich-text-kit.ts b/src/extensions/rich-text/rich-text-kit.ts index 6697c6a1..824cd9c7 100644 --- a/src/extensions/rich-text/rich-text-kit.ts +++ b/src/extensions/rich-text/rich-text-kit.ts @@ -8,7 +8,6 @@ import { HardBreak } from '@tiptap/extension-hard-break' import { History } from '@tiptap/extension-history' import { HorizontalRule } from '@tiptap/extension-horizontal-rule' import { Italic } from '@tiptap/extension-italic' -import { ListItem } from '@tiptap/extension-list-item' import { ListKeymap } from '@tiptap/extension-list-keymap' import { Paragraph } from '@tiptap/extension-paragraph' import { TableCell } from '@tiptap/extension-table-cell' @@ -32,6 +31,7 @@ import { RichTextDocument } from './rich-text-document' import { RichTextHeading, RichTextHeadingOptions } from './rich-text-heading' import { RichTextImage } from './rich-text-image' import { RichTextLink } from './rich-text-link' +import { RichTextListItem } from './rich-text-list-item' import { RichTextOrderedList } from './rich-text-ordered-list' import { RichTextStrikethrough } from './rich-text-strikethrough' import { RichTextTable } from './rich-text-table' @@ -317,7 +317,7 @@ const RichTextKit = Extension.create({ } if (this.options.listItem !== false) { - extensions.push(ListItem.configure(this.options?.listItem)) + extensions.push(RichTextListItem.configure(this.options?.listItem)) } if (this.options.listKeymap !== false) { diff --git a/src/extensions/rich-text/rich-text-list-item.test.ts b/src/extensions/rich-text/rich-text-list-item.test.ts new file mode 100644 index 00000000..b8d6e4c8 --- /dev/null +++ b/src/extensions/rich-text/rich-text-list-item.test.ts @@ -0,0 +1,103 @@ +import { Editor } from '@tiptap/core' + +import { RichTextKit } from './rich-text-kit' + +/** + * Creates a rich-text editor with the given initial content, and places the caret at the end of + * the first occurrence of the given text within the document. + * + * The `Code` extension is disabled because `prosemirror-codemark` (which it registers) loads a + * second CJS copy of `prosemirror-state` in the Vitest environment, crashing `Editor` + * instantiation with "Adding different instances of a keyed plugin"; it has no bearing on the + * list behavior under test. + */ +function createEditorWithCaretAfter(content: string, text: string): Editor { + const editor = new Editor({ + extensions: [RichTextKit.configure({ code: false })], + content, + }) + + let caretPosition: number | null = null + editor.state.doc.descendants((node, position) => { + if (caretPosition === null && node.isText && node.text?.includes(text)) { + caretPosition = position + node.text.indexOf(text) + text.length + } + }) + + if (caretPosition === null) { + throw new Error(`Text "${text}" was not found in the document`) + } + + editor.commands.setTextSelection(caretPosition) + + return editor +} + +/** + * Simulates a `Shift-Tab` key press through the editor's keymap handlers (the same code path a + * real key press takes, minus the DOM event plumbing). + */ +function pressShiftTab(editor: Editor): void { + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }) + editor.view.someProp('handleKeyDown', (handleKeyDown) => handleKeyDown(editor.view, event)) +} + +describe('Extension: RichTextListItem', () => { + describe('when `Shift-Tab` lifts an item out of an ordered list', () => { + test('preserves the rendered marker of a single-item list as literal text', () => { + const editor = createEditorWithCaretAfter( + '
  1. Juli

', + 'Juli', + ) + + pressShiftTab(editor) + + expect(editor.getHTML()).toBe('

17. Juli

') + }) + + test('preserves the rendered marker matching the item position within the list', () => { + const editor = createEditorWithCaretAfter( + '
  1. one

  2. two

  3. three

', + 'two', + ) + + pressShiftTab(editor) + + expect(editor.getHTML()).toBe( + '
  1. one

2. two

  1. three

', + ) + }) + + test('inserts the marker into the first paragraph of a multi-paragraph item', () => { + const editor = createEditorWithCaretAfter( + '
  1. Juli

    second paragraph

', + 'second paragraph', + ) + + pressShiftTab(editor) + + expect(editor.getHTML()).toBe('

17. Juli

second paragraph

') + }) + }) + + describe('when `Shift-Tab` does not lift the item out of a list', () => { + test('does not insert a marker when lifting an item out of a bullet list', () => { + const editor = createEditorWithCaretAfter('
  • Juli

', 'Juli') + + pressShiftTab(editor) + + expect(editor.getHTML()).toBe('

Juli

') + }) + + test('does not insert a marker when unindenting a nested ordered list item', () => { + const editor = createEditorWithCaretAfter( + '
  1. parent

    1. child

', + 'child', + ) + + pressShiftTab(editor) + + expect(editor.getHTML()).toBe('
  1. parent

  2. child

') + }) + }) +}) diff --git a/src/extensions/rich-text/rich-text-list-item.ts b/src/extensions/rich-text/rich-text-list-item.ts new file mode 100644 index 00000000..3f34959e --- /dev/null +++ b/src/extensions/rich-text/rich-text-list-item.ts @@ -0,0 +1,106 @@ +import { ListItem } from '@tiptap/extension-list-item' + +import type { Editor } from '@tiptap/core' + +/** + * Lifts the list item at the caret out of an ordered list while preserving the rendered list + * marker (e.g. `17.`) as literal text at the start of the lifted paragraph. + * + * This only applies when the lift would remove the item from the list altogether (i.e. the list + * is not nested within another list item), which is when the marker – rendered from the list's + * `start` attribute and the item's position – would otherwise be lost. The prime example is the + * automatic input rule for ordered lists swallowing a number the user meant as text (e.g. dates + * in German, like `17. Juli`), with `Shift-Tab` being the intuitive way to revert the conversion. + * + * @returns `true` if the marker-preserving lift was applied, `false` if the situation doesn't + * apply and the caller should fall back to the default behavior. + */ +function liftOutOfOrderedListPreservingMarker( + editor: Editor, + itemTypeName: string, + orderedListTypeName: string, +): boolean { + const { selection } = editor.state + + // Lifting a range of items merges them into a single item, making a single preserved marker + // ambiguous, so only handle a collapsed selection + if (!selection.empty) { + return false + } + + const { $from } = selection + + // Find the innermost list item the caret is placed within + let itemDepth: number | null = null + for (let depth = $from.depth; depth >= 2; depth--) { + if ($from.node(depth).type.name === itemTypeName) { + itemDepth = depth + break + } + } + + if (itemDepth === null) { + return false + } + + const list = $from.node(itemDepth - 1) + + if (list.type.name !== orderedListTypeName) { + return false + } + + // When the list is nested within another list item, lifting only unindents the item into the + // parent list (it remains a list item, and no marker is lost) + if ($from.node(itemDepth - 2).type.name === itemTypeName) { + return false + } + + // Non-decimal markers (e.g. `
    `) cannot be reconstructed as `.` text + if (list.attrs.type) { + return false + } + + if (!editor.can().liftListItem(itemTypeName)) { + return false + } + + // The number the item is rendered with, given the list start and the item's position + const marker = `${Number(list.attrs.start ?? 1) + $from.index(itemDepth - 1)}. ` + + // The position at the start of the item's first paragraph, where the marker text belongs + const markerPosition = $from.start(itemDepth) + 1 + + return editor + .chain() + .liftListItem(itemTypeName) + .command(({ tr }) => { + tr.insertText(marker, tr.mapping.map(markerPosition)) + return true + }) + .run() +} + +/** + * Custom extension that extends the built-in `ListItem` extension to preserve the rendered list + * marker as literal text when `Shift-Tab` lifts an item out of an ordered list, making sure that + * a number the user typed themselves – swallowed into the list's `start` attribute by the + * automatic input rule – is never lost. + */ +const RichTextListItem = ListItem.extend({ + addKeyboardShortcuts() { + return { + ...this.parent?.(), + 'Shift-Tab': () => { + return ( + liftOutOfOrderedListPreservingMarker( + this.editor, + this.name, + this.options.orderedListTypeName, + ) || this.editor.commands.liftListItem(this.name) + ) + }, + } + }, +}) + +export { RichTextListItem } From 2e21d758da1a78d3416db9c92c1e88d792b74799 Mon Sep 17 00:00:00 2001 From: Henning Muszynski Date: Wed, 29 Jul 2026 09:06:22 +0200 Subject: [PATCH 2/2] fix(rich-text): address list marker review feedback --- .../rich-text/rich-text-list-item.test.ts | 11 ++++++++++ .../rich-text/rich-text-list-item.ts | 22 +++++++------------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/extensions/rich-text/rich-text-list-item.test.ts b/src/extensions/rich-text/rich-text-list-item.test.ts index b8d6e4c8..5900c80b 100644 --- a/src/extensions/rich-text/rich-text-list-item.test.ts +++ b/src/extensions/rich-text/rich-text-list-item.test.ts @@ -55,6 +55,17 @@ describe('Extension: RichTextListItem', () => { expect(editor.getHTML()).toBe('

    17. Juli

    ') }) + test('preserves the marker of an explicitly decimal ordered list', () => { + const editor = createEditorWithCaretAfter( + '
    1. Juli

    ', + 'Juli', + ) + + pressShiftTab(editor) + + expect(editor.getHTML()).toBe('

    17. Juli

    ') + }) + test('preserves the rendered marker matching the item position within the list', () => { const editor = createEditorWithCaretAfter( '
    1. one

    2. two

    3. three

    ', diff --git a/src/extensions/rich-text/rich-text-list-item.ts b/src/extensions/rich-text/rich-text-list-item.ts index 3f34959e..a34e8e41 100644 --- a/src/extensions/rich-text/rich-text-list-item.ts +++ b/src/extensions/rich-text/rich-text-list-item.ts @@ -1,3 +1,4 @@ +import { findParentNode } from '@tiptap/core' import { ListItem } from '@tiptap/extension-list-item' import type { Editor } from '@tiptap/core' @@ -30,20 +31,13 @@ function liftOutOfOrderedListPreservingMarker( const { $from } = selection - // Find the innermost list item the caret is placed within - let itemDepth: number | null = null - for (let depth = $from.depth; depth >= 2; depth--) { - if ($from.node(depth).type.name === itemTypeName) { - itemDepth = depth - break - } - } + const item = findParentNode((node) => node.type.name === itemTypeName)(selection) - if (itemDepth === null) { + if (!item || item.depth < 2) { return false } - const list = $from.node(itemDepth - 1) + const list = $from.node(item.depth - 1) if (list.type.name !== orderedListTypeName) { return false @@ -51,12 +45,12 @@ function liftOutOfOrderedListPreservingMarker( // When the list is nested within another list item, lifting only unindents the item into the // parent list (it remains a list item, and no marker is lost) - if ($from.node(itemDepth - 2).type.name === itemTypeName) { + if ($from.node(item.depth - 2).type.name === itemTypeName) { return false } // Non-decimal markers (e.g. `
      `) cannot be reconstructed as `.` text - if (list.attrs.type) { + if (list.attrs.type && list.attrs.type !== '1') { return false } @@ -65,10 +59,10 @@ function liftOutOfOrderedListPreservingMarker( } // The number the item is rendered with, given the list start and the item's position - const marker = `${Number(list.attrs.start ?? 1) + $from.index(itemDepth - 1)}. ` + const marker = `${Number(list.attrs.start ?? 1) + $from.index(item.depth - 1)}. ` // The position at the start of the item's first paragraph, where the marker text belongs - const markerPosition = $from.start(itemDepth) + 1 + const markerPosition = item.start + 1 return editor .chain()