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
4 changes: 2 additions & 2 deletions src/extensions/rich-text/rich-text-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -317,7 +317,7 @@ const RichTextKit = Extension.create<RichTextKitOptions>({
}

if (this.options.listItem !== false) {
extensions.push(ListItem.configure(this.options?.listItem))
extensions.push(RichTextListItem.configure(this.options?.listItem))
}

if (this.options.listKeymap !== false) {
Expand Down
114 changes: 114 additions & 0 deletions src/extensions/rich-text/rich-text-list-item.test.ts
Original file line number Diff line number Diff line change
@@ -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(
'<ol start="17"><li><p>Juli</p></li></ol>',
'Juli',
)

pressShiftTab(editor)

expect(editor.getHTML()).toBe('<p>17. Juli</p>')
})

test('preserves the marker of an explicitly decimal ordered list', () => {
const editor = createEditorWithCaretAfter(
'<ol start="17" type="1"><li><p>Juli</p></li></ol>',
'Juli',
)

pressShiftTab(editor)

expect(editor.getHTML()).toBe('<p>17. Juli</p>')
})

test('preserves the rendered marker matching the item position within the list', () => {
const editor = createEditorWithCaretAfter(
'<ol><li><p>one</p></li><li><p>two</p></li><li><p>three</p></li></ol>',
'two',
)

pressShiftTab(editor)

expect(editor.getHTML()).toBe(
'<ol><li><p>one</p></li></ol><p>2. two</p><ol><li><p>three</p></li></ol>',
)
})

test('inserts the marker into the first paragraph of a multi-paragraph item', () => {
const editor = createEditorWithCaretAfter(
'<ol start="17"><li><p>Juli</p><p>second paragraph</p></li></ol>',
'second paragraph',
)

pressShiftTab(editor)

expect(editor.getHTML()).toBe('<p>17. Juli</p><p>second paragraph</p>')
})
})

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('<ul><li><p>Juli</p></li></ul>', 'Juli')

pressShiftTab(editor)

expect(editor.getHTML()).toBe('<p>Juli</p>')
})

test('does not insert a marker when unindenting a nested ordered list item', () => {
const editor = createEditorWithCaretAfter(
'<ol><li><p>parent</p><ol><li><p>child</p></li></ol></li></ol>',
'child',
)

pressShiftTab(editor)

expect(editor.getHTML()).toBe('<ol><li><p>parent</p></li><li><p>child</p></li></ol>')
})
})
})
100 changes: 100 additions & 0 deletions src/extensions/rich-text/rich-text-list-item.ts
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
henningmu marked this conversation as resolved.
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. `<ol type="a">`) cannot be reconstructed as `<number>.` 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({
Comment thread
henningmu marked this conversation as resolved.
addKeyboardShortcuts() {
return {
...this.parent?.(),
'Shift-Tab': () => {
return (
liftOutOfOrderedListPreservingMarker(
this.editor,
this.name,
this.options.orderedListTypeName,
) || this.editor.commands.liftListItem(this.name)
)
},
}
},
})

export { RichTextListItem }
Loading