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..5900c80b --- /dev/null +++ b/src/extensions/rich-text/rich-text-list-item.test.ts @@ -0,0 +1,114 @@ +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 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

', + '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') + + 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..a34e8e41 --- /dev/null +++ b/src/extensions/rich-text/rich-text-list-item.ts @@ -0,0 +1,100 @@ +import { findParentNode } from '@tiptap/core' +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 + + const item = findParentNode((node) => node.type.name === itemTypeName)(selection) + + if (!item || item.depth < 2) { + return false + } + + const list = $from.node(item.depth - 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(item.depth - 2).type.name === itemTypeName) { + return false + } + + // Non-decimal markers (e.g. `
    `) cannot be reconstructed as `.` text + if (list.attrs.type && list.attrs.type !== '1') { + 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(item.depth - 1)}. ` + + // The position at the start of the item's first paragraph, where the marker text belongs + const markerPosition = item.start + 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 }