snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-list
[![Version](https://img.shields.io/npm/v/@tiptap/extension-list.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-list)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-list.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-list.svg)](https://www.npmjs.com/package/@tiptap/extension-list)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,108 @@
{
"name": "@tiptap/extension-list",
"version": "3.30.3",
"description": "List extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-list"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./bullet-list": {
"types": {
"import": "./dist/bullet-list/index.d.ts",
"require": "./dist/bullet-list/index.d.cts"
},
"import": "./dist/bullet-list/index.js",
"require": "./dist/bullet-list/index.cjs"
},
"./item": {
"types": {
"import": "./dist/item/index.d.ts",
"require": "./dist/item/index.d.cts"
},
"import": "./dist/item/index.js",
"require": "./dist/item/index.cjs"
},
"./keymap": {
"types": {
"import": "./dist/keymap/index.d.ts",
"require": "./dist/keymap/index.d.cts"
},
"import": "./dist/keymap/index.js",
"require": "./dist/keymap/index.cjs"
},
"./kit": {
"types": {
"import": "./dist/kit/index.d.ts",
"require": "./dist/kit/index.d.cts"
},
"import": "./dist/kit/index.js",
"require": "./dist/kit/index.cjs"
},
"./ordered-list": {
"types": {
"import": "./dist/ordered-list/index.d.ts",
"require": "./dist/ordered-list/index.d.cts"
},
"import": "./dist/ordered-list/index.js",
"require": "./dist/ordered-list/index.cjs"
},
"./task-item": {
"types": {
"import": "./dist/task-item/index.d.ts",
"require": "./dist/task-item/index.d.cts"
},
"import": "./dist/task-item/index.js",
"require": "./dist/task-item/index.cjs"
},
"./task-list": {
"types": {
"import": "./dist/task-list/index.d.ts",
"require": "./dist/task-list/index.d.cts"
},
"import": "./dist/task-list/index.js",
"require": "./dist/task-list/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,151 @@
import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
const ListItemName = 'listItem'
const TextStyleName = 'textStyle'
export interface BulletListOptions {
/**
* The node name for the list items
* @default 'listItem'
* @example 'paragraph'
*/
itemTypeName: string
/**
* HTML attributes to add to the bullet list element
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Keep the marks when splitting the list
* @default false
* @example true
*/
keepMarks: boolean
/**
* Keep the attributes when splitting the list
* @default false
* @example true
*/
keepAttributes: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
bulletList: {
/**
* Toggle a bullet list
*/
toggleBulletList: () => ReturnType
}
}
}
/**
* Matches a bullet list to a dash or asterisk.
*/
export const bulletListInputRegex = /^\s*([-+*])\s$/
/**
* This extension allows you to create bullet lists.
* This requires the ListItem extension
* @see https://tiptap.dev/api/nodes/bullet-list
* @see https://tiptap.dev/api/nodes/list-item.
*/
export const BulletList = Node.create<BulletListOptions>({
name: 'bulletList',
addOptions() {
return {
itemTypeName: 'listItem',
HTMLAttributes: {},
keepMarks: false,
keepAttributes: false,
}
},
group: 'block list',
content() {
return `${this.options.itemTypeName}+`
},
parseHTML() {
return [{ tag: 'ul' }]
},
renderHTML({ HTMLAttributes }) {
return ['ul', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
markdownTokenName: 'list',
parseMarkdown: (token, helpers) => {
if (token.type !== 'list' || (token as any).ordered) {
return []
}
return {
type: 'bulletList',
content: token.items ? helpers.parseChildren(token.items) : [],
}
},
renderMarkdown: (node, h) => {
if (!node.content) {
return ''
}
return h.renderChildren(node.content, '\n')
},
markdownOptions: {
indentsContent: true,
},
addCommands() {
return {
toggleBulletList:
() =>
({ commands, chain }) => {
if (this.options.keepAttributes) {
return chain()
.toggleList(this.name, this.options.itemTypeName, this.options.keepMarks)
.updateAttributes(ListItemName, this.editor.getAttributes(TextStyleName))
.run()
}
return commands.toggleList(this.name, this.options.itemTypeName, this.options.keepMarks)
},
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-8': () => this.editor.commands.toggleBulletList(),
}
},
addInputRules() {
let inputRule = wrappingInputRule({
find: bulletListInputRegex,
type: this.type,
})
if (this.options.keepMarks || this.options.keepAttributes) {
inputRule = wrappingInputRule({
find: bulletListInputRegex,
type: this.type,
keepMarks: this.options.keepMarks,
keepAttributes: this.options.keepAttributes,
getAttributes: () => {
return this.editor.getAttributes(TextStyleName)
},
editor: this.editor,
})
}
return [inputRule]
},
})

View File

@@ -0,0 +1 @@
export * from './bullet-list.js'

View File

@@ -0,0 +1,24 @@
import { Extension } from '@tiptap/core'
import { handleDeleteBranchingNestedList } from './handleDeleteBranchingNestedList.js'
/**
* Creates a high-priority keymap extension that handles Delete for branching nested lists.
* Kept separate from the list item node so Enter/Tab shortcuts keep their default priority.
*/
export const createBranchingListDeleteKeymap = (itemName: string, wrapperNames: string[]) => {
return Extension.create({
name: `${itemName}BranchingDeleteKeymap`,
priority: 101,
addKeyboardShortcuts() {
const handleDelete = () =>
handleDeleteBranchingNestedList(this.editor, itemName, wrapperNames)
return {
Delete: handleDelete,
'Mod-Delete': handleDelete,
}
},
})
}

View File

@@ -0,0 +1,116 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
export type BranchingNestedListAtCursor = {
listItemDepth: number
nestedList: PMNode
nestedListPos: number
insertPos: number
items: PMNode[]
}
/**
* Resolves a branching nested list immediately after the cursor when the selection is
* collapsed at the end of a textblock inside a list item.
*
* @param state - The editor state to inspect.
* @param itemName - The list item node name (for example `listItem` or `taskItem`).
* @param wrapperNames - List wrapper node names (for example `bulletList` and `orderedList`).
* @returns Resolved positions and nodes for hoisting, or `null` when not applicable.
*
* @example
* ```ts
* const context = getBranchingNestedListAtCursor(editor.state, 'listItem', [
* 'bulletList',
* 'orderedList',
* ])
*
* if (context) {
* // cursor is at the end of Item 1 before a branching nested sublist
* }
* ```
*/
export const getBranchingNestedListAtCursor = (
state: EditorState,
itemName: string,
wrapperNames: string[],
): BranchingNestedListAtCursor | null => {
const { selection } = state
if (!selection.empty) {
return null
}
const { $from } = selection
if (!$from.parent.isTextblock) {
return null
}
if ($from.parentOffset !== $from.parent.content.size) {
return null
}
let listItemDepth = -1
for (let depth = $from.depth; depth > 0; depth -= 1) {
if ($from.node(depth).type.name === itemName) {
listItemDepth = depth
break
}
}
if (listItemDepth < 0) {
return null
}
const listItem = $from.node(listItemDepth)
const indexInListItem = $from.index(listItemDepth)
if (indexInListItem + 1 >= listItem.childCount) {
return null
}
const nextChild = listItem.child(indexInListItem + 1)
if (!wrapperNames.includes(nextChild.type.name)) {
return null
}
const itemType = state.schema.nodes[itemName]
let hasBranching = false
nextChild.forEach(child => {
if (child.type === itemType && child.childCount > 1) {
hasBranching = true
}
})
if (!hasBranching) {
return null
}
const nodeAfter = state.doc.resolve($from.after()).nodeAfter
if (!nodeAfter || !wrapperNames.includes(nodeAfter.type.name)) {
return null
}
const items: PMNode[] = []
nodeAfter.forEach(child => {
items.push(child)
})
if (items.length === 0) {
return null
}
return {
listItemDepth,
nestedList: nodeAfter,
nestedListPos: $from.after(),
insertPos: $from.after(listItemDepth),
items,
}
}

View File

@@ -0,0 +1,25 @@
import type { Editor } from '@tiptap/core'
import { hoistBranchingNestedList } from './hoistBranchingNestedList.js'
/**
* Handles Delete for a list item when a branching nested sublist follows the cursor.
*
* @param editor - The editor instance whose state should be updated.
* @param itemName - The list item node name (for example `listItem` or `taskItem`).
* @param wrapperNames - List wrapper node names (for example `bulletList` and `orderedList`).
* @returns `true` when the nested list was hoisted, otherwise `false`.
*
* @example
* ```ts
* Delete: () =>
* handleDeleteBranchingNestedList(editor, 'listItem', ['bulletList', 'orderedList']),
* ```
*/
export const handleDeleteBranchingNestedList = (
editor: Editor,
itemName: string,
wrapperNames: string[],
) => {
return hoistBranchingNestedList(editor.state, editor.view.dispatch, itemName, wrapperNames)
}

View File

@@ -0,0 +1,30 @@
import type { EditorState } from '@tiptap/pm/state'
import { getBranchingNestedListAtCursor } from './getBranchingNestedListAtCursor.js'
/**
* Returns whether the cursor is at the end of a list item textblock and the next
* sibling is a nested list that contains at least one branching list item.
*
* @param state - The editor state to inspect.
* @param itemName - The list item node name (for example `listItem` or `taskItem`).
* @param wrapperNames - List wrapper node names (for example `bulletList` and `orderedList`).
* @returns `true` when {@link hoistBranchingNestedList} should handle Delete.
*
* @example
* ```ts
* if (hasBranchingNestedListAfterCursor(editor.state, 'listItem', ['bulletList', 'orderedList'])) {
* hoistBranchingNestedList(editor.state, editor.view.dispatch, 'listItem', [
* 'bulletList',
* 'orderedList',
* ])
* }
* ```
*/
export const hasBranchingNestedListAfterCursor = (
state: EditorState,
itemName: string,
wrapperNames: string[],
): boolean => {
return getBranchingNestedListAtCursor(state, itemName, wrapperNames) !== null
}

View File

@@ -0,0 +1,56 @@
import { Fragment } from '@tiptap/pm/model'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { getBranchingNestedListAtCursor } from './getBranchingNestedListAtCursor.js'
/**
* Hoists all list items from a branching nested list after the cursor into the parent list.
*
* Use this when `joinForward` cannot restructure a nested list that contains list items
* with sublists (see issue #6906).
*
* @param state - The editor state to transform.
* @param dispatch - Optional dispatch function for the transaction.
* @param itemName - The list item node name (for example `listItem` or `taskItem`).
* @param wrapperNames - List wrapper node names (for example `bulletList` and `orderedList`).
* @returns `true` when the nested list was hoisted, otherwise `false`.
*
* @example
* ```ts
* // Cursor at the end of "Item 1" before a nested list with branching items.
* hoistBranchingNestedList(editor.state, editor.view.dispatch, 'listItem', [
* 'bulletList',
* 'orderedList',
* ])
* ```
*/
export const hoistBranchingNestedList = (
state: EditorState,
dispatch: ((tr: Transaction) => void) | undefined,
itemName: string,
wrapperNames: string[],
) => {
const context = getBranchingNestedListAtCursor(state, itemName, wrapperNames)
if (!context) {
return false
}
const { selection } = state
const { nestedList, nestedListPos, insertPos, items } = context
const tr = state.tr
tr.delete(nestedListPos, nestedListPos + nestedList.nodeSize)
const mappedInsertPos = tr.mapping.map(insertPos)
tr.insert(mappedInsertPos, Fragment.from(items))
tr.setSelection(selection.map(tr.doc, tr.mapping))
if (dispatch) {
dispatch(tr)
}
return true
}

View File

@@ -0,0 +1,7 @@
export * from './bullet-list/index.js'
export * from './item/index.js'
export * from './keymap/index.js'
export * from './kit/index.js'
export * from './ordered-list/index.js'
export * from './task-item/index.js'
export * from './task-list/index.js'

View File

@@ -0,0 +1 @@
export * from './list-item.js'

View File

@@ -0,0 +1,46 @@
import type { MarkdownParseHelpers, MarkdownToken } from '@tiptap/core'
import { describe, expect, it } from 'vite-plus/test'
import { ListItem } from '../index.js'
describe('ListItem markdown parsing', () => {
it('falls back to plain text tokens when tokenizeInline is not available', () => {
const token: MarkdownToken = {
type: 'list_item',
text: '1. Nested item',
tokens: [
{
type: 'list',
ordered: true,
raw: '1. Nested item',
},
],
}
const helpers: MarkdownParseHelpers = {
parseInline: tokens =>
tokens.map(inlineToken => ({
type: 'text',
text: inlineToken.text,
})),
parseChildren: () => [],
createTextNode: text => ({ type: 'text', text }),
createNode: (type, attrs, content) => ({ type, attrs, content }),
applyMark: (mark, content) => ({ mark, content }),
}
expect(ListItem.config.parseMarkdown?.(token, helpers)).toEqual({
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '1. Nested item',
},
],
},
],
})
})
})

View File

@@ -0,0 +1,206 @@
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from '@tiptap/core'
import { mergeAttributes, Node, renderNestedMarkdownContent } from '@tiptap/core'
import { createBranchingListDeleteKeymap } from '../helpers/createBranchingListDeleteKeymap.js'
import { getListMarker } from '../ordered-list/roman.js'
export interface ListItemOptions {
/**
* The HTML attributes for a list item node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* The node type for bulletList nodes
* @default 'bulletList'
* @example 'myCustomBulletList'
*/
bulletListTypeName: string
/**
* The node type for orderedList nodes
* @default 'orderedList'
* @example 'myCustomOrderedList'
*/
orderedListTypeName: string
}
function isSameLineOrderedListToken(token: MarkdownToken): boolean {
const nestedToken = token.tokens?.[0]
return Boolean(
token.text &&
token.tokens?.length === 1 &&
nestedToken?.type === 'list' &&
nestedToken.ordered &&
nestedToken.raw === token.text,
)
}
function parseSameLineOrderedListText(text: string, helpers: MarkdownParseHelpers): JSONContent[] {
if (helpers.tokenizeInline) {
return helpers.parseInline(helpers.tokenizeInline(text))
}
return helpers.parseInline([
{
type: 'text',
raw: text,
text,
},
])
}
/**
* This extension allows you to create list items.
* @see https://www.tiptap.dev/api/nodes/list-item
*/
export const ListItem = Node.create<ListItemOptions>({
name: 'listItem',
addOptions() {
return {
HTMLAttributes: {},
bulletListTypeName: 'bulletList',
orderedListTypeName: 'orderedList',
}
},
content: 'paragraph block*',
defining: true,
parseHTML() {
return [
{
tag: 'li',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['li', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
markdownTokenName: 'list_item',
parseMarkdown: (token, helpers) => {
if (token.type !== 'list_item') {
return []
}
const parseBlockChildren = helpers.parseBlockChildren ?? helpers.parseChildren
let content: any[] = []
if (token.tokens && token.tokens.length > 0) {
if (isSameLineOrderedListToken(token)) {
return {
type: 'listItem',
content: [
{
type: 'paragraph',
content: parseSameLineOrderedListText(token.text || '', helpers),
},
],
}
}
// Check if we have paragraph tokens (complex list items)
const hasParagraphTokens = token.tokens.some(t => t.type === 'paragraph')
if (hasParagraphTokens) {
// If we have paragraph tokens, parse them as block elements
content = parseBlockChildren(token.tokens)
} else {
// Check if the first token is a text token with nested inline tokens
const firstToken = token.tokens[0]
if (
firstToken &&
firstToken.type === 'text' &&
firstToken.tokens &&
firstToken.tokens.length > 0
) {
// Parse the inline content from the text token
const inlineContent = helpers.parseInline(firstToken.tokens)
// Start with the paragraph containing the inline content
content = [
{
type: 'paragraph',
content: inlineContent,
},
]
// If there are additional tokens after the first text token (like nested lists),
// parse them as block elements and add them
if (token.tokens.length > 1) {
const remainingTokens = token.tokens.slice(1)
const additionalContent = parseBlockChildren(remainingTokens)
content.push(...additionalContent)
}
} else {
// Fallback: parse all tokens as block elements
content = parseBlockChildren(token.tokens)
}
}
}
// Ensure we always have at least an empty paragraph
if (content.length === 0) {
content = [
{
type: 'paragraph',
content: [],
},
]
}
return {
type: 'listItem',
content,
}
},
renderMarkdown: (node, h, ctx) => {
return renderNestedMarkdownContent(
node,
h,
(context: any) => {
if (context.parentType === 'bulletList') {
return '- '
}
if (context.parentType === 'orderedList') {
const start = context.meta?.parentAttrs?.start || 1
const type = context.meta?.parentAttrs?.type as string | undefined
const index = start - 1 + (context.index || 0)
return getListMarker(type, index, '. ')
}
// Fallback to bullet list for unknown parent types
return '- '
},
ctx,
{ alignNestedToPrefix: ctx?.parentType === 'orderedList' },
)
},
addExtensions() {
return [
createBranchingListDeleteKeymap(this.name, [
this.options.bulletListTypeName,
this.options.orderedListTypeName,
]),
]
},
addKeyboardShortcuts() {
return {
Enter: () => this.editor.commands.splitListItem(this.name),
Tab: () => this.editor.commands.sinkListItem(this.name),
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
}
},
})

View File

@@ -0,0 +1,2 @@
export * from './list-keymap.js'
export * as listHelpers from './listHelpers/index.js'

View File

@@ -0,0 +1,120 @@
import { Extension } from '@tiptap/core'
import { handleBackspace, handleDelete, handleTab } from './listHelpers/index.js'
export type ListKeymapOptions = {
/**
* An array of list types. This is used for item and wrapper list matching.
* @default []
* @example [{ itemName: 'listItem', wrapperNames: ['bulletList', 'orderedList'] }]
*/
listTypes: Array<{
itemName: string
wrapperNames: string[]
}>
}
/**
* This extension registers custom keymaps to change the behaviour of the backspace and delete keys.
* By default Prosemirror keyhandling will always lift or sink items so paragraphs are joined into
* the adjacent or previous list item. This extension will prevent this behaviour and instead will
* try to join paragraphs from two list items into a single list item.
* @see https://www.tiptap.dev/api/extensions/list-keymap
*/
export const ListKeymap = Extension.create<ListKeymapOptions>({
name: 'listKeymap',
addOptions() {
return {
listTypes: [
{
itemName: 'listItem',
wrapperNames: ['bulletList', 'orderedList'],
},
{
itemName: 'taskItem',
wrapperNames: ['taskList'],
},
],
}
},
addKeyboardShortcuts() {
return {
Delete: ({ editor }) => {
let handled = false
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return
}
if (handleDelete(editor, itemName)) {
handled = true
}
})
return handled
},
'Mod-Delete': ({ editor }) => {
let handled = false
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return
}
if (handleDelete(editor, itemName)) {
handled = true
}
})
return handled
},
Backspace: ({ editor }) => {
let handled = false
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true
}
})
return handled
},
'Mod-Backspace': ({ editor }) => {
let handled = false
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true
}
})
return handled
},
Tab: ({ editor }) => {
// stop at the first handled list type so a single Tab cannot sink the block twice
for (const { itemName, wrapperNames } of this.options.listTypes) {
if (editor.state.schema.nodes[itemName] === undefined) {
continue
}
if (handleTab(editor, itemName, wrapperNames)) {
return true
}
}
return false
},
}
},
})

View File

@@ -0,0 +1,30 @@
import { getNodeType } from '@tiptap/core'
import type { NodeType } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection
const nodeType = getNodeType(typeOrName, state.schema)
let currentNode = null
let currentDepth = $from.depth
let currentPos = $from.pos
let targetDepth: number | null = null
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth)
if (currentNode.type === nodeType) {
targetDepth = currentDepth
} else {
currentDepth -= 1
currentPos -= 1
}
}
if (targetDepth === null) {
return null
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth }
}

View File

@@ -0,0 +1,16 @@
import { getNodeAtPosition } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import { findListItemPos } from './findListItemPos.js'
export const getNextListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state)
if (!listItemPos) {
return false
}
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4)
return depth
}

View File

@@ -0,0 +1,70 @@
import type { Editor } from '@tiptap/core'
import { isAtStartOfNode, isNodeActive } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { hasListBefore } from './hasListBefore.js'
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
// this is required to still handle the undo handling
if (editor.commands.undoInputRule()) {
return true
}
// if the selection is not collapsed
// we can rely on the default backspace behavior
if (editor.state.selection.from !== editor.state.selection.to) {
return false
}
// if the current item is NOT inside a list item &
// the previous item is a list (orderedList or bulletList)
// move the cursor into the list and delete the current item
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
const { $anchor } = editor.state.selection
const $listPos = editor.state.doc.resolve($anchor.before() - 1)
const listDescendants: Array<{ node: PMNode; pos: number }> = []
$listPos.node().descendants((node, pos) => {
if (node.type.name === name) {
listDescendants.push({ node, pos })
}
})
const lastItem = listDescendants.at(-1)
if (!lastItem) {
return false
}
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1)
return editor
.chain()
.cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end())
.joinForward()
.run()
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false
}
// only intercept at the start of the list item's first child
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
}
return editor.chain().liftListItem(name).run()
}

View File

@@ -0,0 +1,44 @@
import type { Editor } from '@tiptap/core'
import { isAtEndOfNode, isNodeActive } from '@tiptap/core'
import { nextListIsDeeper } from './nextListIsDeeper.js'
import { nextListIsHigher } from './nextListIsHigher.js'
export const handleDelete = (editor: Editor, name: string) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!isAtEndOfNode(editor.state, name)) {
return false
}
// if the selection is not collapsed, or not within a single node
// do nothing and proceed
const { selection } = editor.state
const { $from, $to } = selection
if (!selection.empty && $from.sameParent($to)) {
return false
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run()
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain().joinForward().joinBackward().run()
}
return editor.commands.joinItemForward()
}

View File

@@ -0,0 +1,48 @@
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 } = editor
const { selection } = state
if (!selection.empty) return false
const { $from } = selection
if ($from.parentOffset !== 0) return false
// gap cursors satisfy both checks above but resolve outside a textblock
if (!$from.parent.isTextblock) return false
// Tab inside a list item belongs to sinkListItem
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
if (!lastItem.canReplace(lastItem.childCount, lastItem.childCount, Fragment.from(block))) {
return false
}
const blockStart = $from.before()
const blockEnd = $from.after()
// step back over the closing tokens of the list and its last item
const insideLastItemEnd = blockStart - 2
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(blockStart, blockEnd).insert(insideLastItemEnd, Fragment.from(block))
// place the cursor at the start of the inserted block
tr.setSelection(TextSelection.create(tr.doc, insideLastItemEnd + 1))
tr.scrollIntoView()
}
return true
})
}

View File

@@ -0,0 +1,19 @@
import type { EditorState } from '@tiptap/pm/state'
export const hasListBefore = (
editorState: EditorState,
name: string,
parentListTypes: string[],
) => {
const { $anchor } = editorState.selection
const previousNodePos = Math.max(0, $anchor.pos - 2)
const previousNode = editorState.doc.resolve(previousNodePos).node()
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false
}
return true
}

View File

@@ -0,0 +1,17 @@
import type { EditorState } from '@tiptap/pm/state'
export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2)
if ($targetPos.index() === $targetPos.parent.childCount - 1) {
return false
}
if ($targetPos.nodeAfter?.type.name !== typeOrName) {
return false
}
return true
}

View File

@@ -0,0 +1,17 @@
import type { EditorState } from '@tiptap/pm/state'
export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection
const $targetPos = state.doc.resolve($anchor.pos - 2)
if ($targetPos.index() === 0) {
return false
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false
}
return true
}

View File

@@ -0,0 +1,11 @@
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'
export * from './listItemHasSubList.js'
export * from './nextListIsDeeper.js'
export * from './nextListIsHigher.js'

View File

@@ -0,0 +1,21 @@
import { getNodeType } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
export const listItemHasSubList = (typeOrName: string, state: EditorState, node?: PMNode) => {
if (!node) {
return false
}
const nodeType = getNodeType(typeOrName, state.schema)
let hasSubList = false
node.descendants(child => {
if (child.type === nodeType) {
hasSubList = true
}
})
return hasSubList
}

View File

@@ -0,0 +1,19 @@
import type { EditorState } from '@tiptap/pm/state'
import { findListItemPos } from './findListItemPos.js'
import { getNextListDepth } from './getNextListDepth.js'
export const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state)
const listItemPos = findListItemPos(typeOrName, state)
if (!listItemPos || !listDepth) {
return false
}
if (listDepth > listItemPos.depth) {
return true
}
return false
}

View File

@@ -0,0 +1,19 @@
import type { EditorState } from '@tiptap/pm/state'
import { findListItemPos } from './findListItemPos.js'
import { getNextListDepth } from './getNextListDepth.js'
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state)
const listItemPos = findListItemPos(typeOrName, state)
if (!listItemPos || !listDepth) {
return false
}
if (listDepth < listItemPos.depth) {
return true
}
return false
}

View File

@@ -0,0 +1,81 @@
import { Extension } from '@tiptap/core'
import type { BulletListOptions } from '../bullet-list/index.js'
import { BulletList } from '../bullet-list/index.js'
import type { ListItemOptions } from '../item/index.js'
import { ListItem } from '../item/index.js'
import type { ListKeymapOptions } from '../keymap/index.js'
import { ListKeymap } from '../keymap/index.js'
import type { OrderedListOptions } from '../ordered-list/index.js'
import { OrderedList } from '../ordered-list/index.js'
import type { TaskItemOptions } from '../task-item/index.js'
import { TaskItem } from '../task-item/index.js'
import type { TaskListOptions } from '../task-list/index.js'
import { TaskList } from '../task-list/index.js'
export interface ListKitOptions {
/**
* If set to false, the bulletList extension will not be registered
* @example table: false
*/
bulletList: Partial<BulletListOptions> | false
/**
* If set to false, the listItem extension will not be registered
*/
listItem: Partial<ListItemOptions> | false
/**
* If set to false, the listKeymap extension will not be registered
*/
listKeymap: Partial<ListKeymapOptions> | false
/**
* If set to false, the orderedList extension will not be registered
*/
orderedList: Partial<OrderedListOptions> | false
/**
* If set to false, the taskItem extension will not be registered
*/
taskItem: Partial<TaskItemOptions> | false
/**
* If set to false, the taskList extension will not be registered
*/
taskList: Partial<TaskListOptions> | false
}
/**
* The table kit is a collection of table editor extensions.
*
* It’s a good starting point for building your own table in Tiptap.
*/
export const ListKit = Extension.create<ListKitOptions>({
name: 'listKit',
addExtensions() {
const extensions = []
if (this.options.bulletList !== false) {
extensions.push(BulletList.configure(this.options.bulletList))
}
if (this.options.listItem !== false) {
extensions.push(ListItem.configure(this.options.listItem))
}
if (this.options.listKeymap !== false) {
extensions.push(ListKeymap.configure(this.options.listKeymap))
}
if (this.options.orderedList !== false) {
extensions.push(OrderedList.configure(this.options.orderedList))
}
if (this.options.taskItem !== false) {
extensions.push(TaskItem.configure(this.options.taskItem))
}
if (this.options.taskList !== false) {
extensions.push(TaskList.configure(this.options.taskList))
}
return extensions
},
})

View File

@@ -0,0 +1,13 @@
export * from './ordered-list.js'
export {
areOrderedListMarkersSequential,
buildOrderedListAttrsFromMarker,
detectMarkerType,
getListMarker,
markerToStart,
ORDERED_LIST_MARKER_PATTERN,
parseListMarker,
toRoman,
toRomanUpper,
} from './roman.js'
export { parsePlainTextOrderedListPaste } from './utils.js'

View File

@@ -0,0 +1,365 @@
import type { Node as PMNode } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state'
import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
import {
buildNestedStructure,
collectOrderedListItems,
parseListItems,
parsePlainTextOrderedListPaste,
} from './utils.js'
const ListItemName = 'listItem'
const TextStyleName = 'textStyle'
export interface OrderedListOptions {
/**
* The node type name for list items.
* @default 'listItem'
* @example 'myListItem'
*/
itemTypeName: string
/**
* The HTML attributes for an ordered list node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Keep the marks when splitting a list item.
* @default false
* @example true
*/
keepMarks: boolean
/**
* Keep the attributes when splitting a list item.
* @default false
* @example true
*/
keepAttributes: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
orderedList: {
/**
* Toggle an ordered list
* @example editor.commands.toggleOrderedList()
*/
toggleOrderedList: () => ReturnType
}
}
}
/**
* Matches an ordered list to a 1. on input (or any number followed by a dot).
*/
export const orderedListInputRegex = /^(\d+)\.\s$/
/**
* Maps CSS list-style-type values to HTML type attribute values.
* Google Docs and Word often use CSS instead of the HTML type attribute.
*/
function cssListStyleTypeToHtmlType(style: string): string | null {
const match = style.match(/list-style-type\s*:\s*([^;]+)/i)
if (!match) {
return null
}
const cssValue = match[1].trim().toLowerCase()
switch (cssValue) {
case 'upper-roman':
return 'I'
case 'lower-roman':
return 'i'
case 'upper-alpha':
case 'upper-latin':
return 'A'
case 'lower-alpha':
case 'lower-latin':
return 'a'
default:
return null
}
}
/**
* This extension allows you to create ordered lists.
* This requires the ListItem extension
* @see https://www.tiptap.dev/api/nodes/ordered-list
* @see https://www.tiptap.dev/api/nodes/list-item
*/
export const OrderedList = Node.create<OrderedListOptions>({
name: 'orderedList',
addOptions() {
return {
itemTypeName: 'listItem',
HTMLAttributes: {},
keepMarks: false,
keepAttributes: false,
}
},
group: 'block list',
content() {
return `${this.options.itemTypeName}+`
},
addAttributes() {
return {
start: {
default: 1,
parseHTML: element => {
return element.hasAttribute('start')
? parseInt(element.getAttribute('start') || '', 10)
: 1
},
},
type: {
default: null,
parseHTML: element => {
// 1. Check the HTML type attribute on <ol>
const htmlType = element.getAttribute('type')
if (htmlType) {
return htmlType
}
// 2. Check CSS list-style-type on the <ol> element's style attribute
const style = element.getAttribute('style')
if (style) {
const mappedFromOl = cssListStyleTypeToHtmlType(style)
if (mappedFromOl) {
return mappedFromOl
}
}
// 3. Check the first <li> child for list-style-type (Google Docs pattern)
const firstLi = element.querySelector('li')
if (firstLi) {
const liStyle = firstLi.getAttribute('style')
if (liStyle) {
const mappedFromLi = cssListStyleTypeToHtmlType(liStyle)
if (mappedFromLi) {
return mappedFromLi
}
}
}
return null
},
},
}
},
parseHTML() {
return [
{
tag: 'ol',
},
]
},
renderHTML({ HTMLAttributes }) {
const { start, type, ...attributesWithoutType } = HTMLAttributes
const attrs = mergeAttributes(this.options.HTMLAttributes, attributesWithoutType)
if (start !== 1) {
attrs.start = start
}
if (type && type !== '1') {
attrs.type = type
}
return ['ol', attrs, 0]
},
markdownTokenName: 'list',
parseMarkdown: (token, helpers) => {
if (token.type !== 'list' || !token.ordered) {
return []
}
const startValue = token.start || 1
const typeValue = token.typeMarker as string | undefined
const content = token.items ? parseListItems(token.items, helpers) : []
// Build attrs only when they differ from defaults
const attrs: Record<string, unknown> = {}
if (startValue !== 1) {
attrs.start = startValue
}
if (typeValue) {
attrs.type = typeValue
}
if (Object.keys(attrs).length > 0) {
return {
type: 'orderedList',
attrs,
content,
}
}
return {
type: 'orderedList',
content,
}
},
renderMarkdown: (node, h) => {
if (!node.content) {
return ''
}
return h.renderChildren(node.content, '\n')
},
markdownTokenizer: {
name: 'orderedList',
level: 'block',
// marked already breaks paragraphs before a start-of-line list marker. It
// probes this with `src.slice(1)`, so any marker it surfaces here is
// mid-line (like the "216)" in "(216) 555-1234") and must not start a list.
// We still define the callback so marked does not fall back to probing
// `tokenize`, which would re-introduce the mid-line split.
start: () => -1,
tokenize: (src: string, _tokens, lexer) => {
const lines = src.split('\n')
const [listItems, consumed] = collectOrderedListItems(lines)
if (listItems.length === 0) {
return undefined
}
// buildNestedStructure() only includes an item when item.indent matches
// the base indent it's given, so the base must be this list's own
// indentation level (that of its first collected item, e.g. 1 for a
// line with a single leading space) rather than always 0 — a nested
// list is not necessarily flush with the start of the line.
const items = buildNestedStructure(listItems, listItems[0].indent, lexer)
if (items.length === 0) {
return undefined
}
const startValue = listItems[0]?.number || 1
const typeMarker = listItems[0]?.type
return {
type: 'list',
ordered: true,
start: startValue,
typeMarker,
items,
raw: lines.slice(0, consumed).join('\n'),
} as unknown as object
},
},
markdownOptions: {
indentsContent: true,
},
addCommands() {
return {
toggleOrderedList:
() =>
({ commands, chain }) => {
if (this.options.keepAttributes) {
return chain()
.toggleList(this.name, this.options.itemTypeName, this.options.keepMarks)
.updateAttributes(ListItemName, this.editor.getAttributes(TextStyleName))
.run()
}
return commands.toggleList(this.name, this.options.itemTypeName, this.options.keepMarks)
},
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-7': () => this.editor.commands.toggleOrderedList(),
}
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handlePaste: (view, event) => {
const html = event.clipboardData?.getData('text/html')
if (html?.trim()) {
return false
}
const text = event.clipboardData?.getData('text/plain')
if (!text) {
return false
}
const orderedListContent = parsePlainTextOrderedListPaste(text)
if (!orderedListContent) {
return false
}
try {
const orderedListNode = view.state.schema.nodeFromJSON(orderedListContent)
const tr = view.state.tr.replaceSelectionWith(orderedListNode)
view.dispatch(tr)
return true
} catch {
return false
}
},
},
}),
]
},
addInputRules() {
const joinPredicate = (match: RegExpMatchArray, node: PMNode) => {
// Only join if the existing list has a default type
// (not a typed list like "a" or "i" which should stay separate)
const hasDefaultType = !node.attrs.type || node.attrs.type === '1'
return hasDefaultType && node.childCount + node.attrs.start === +match[1]
}
let inputRule = wrappingInputRule({
find: orderedListInputRegex,
type: this.type,
getAttributes: match => ({ start: +match[1] }),
joinPredicate,
})
if (this.options.keepMarks || this.options.keepAttributes) {
inputRule = wrappingInputRule({
find: orderedListInputRegex,
type: this.type,
keepMarks: this.options.keepMarks,
keepAttributes: this.options.keepAttributes,
getAttributes: match => ({ start: +match[1], ...this.editor.getAttributes(TextStyleName) }),
joinPredicate,
editor: this.editor,
})
}
return [inputRule]
},
})

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vite-plus/test'
import {
areOrderedListMarkersSequential,
detectMarkerType,
getListMarker,
markerToStart,
} from './roman.js'
describe('ordered list markers', () => {
describe('getListMarker', () => {
it('returns numeric markers for default type', () => {
expect(getListMarker(null, 0, '. ')).toBe('1. ')
expect(getListMarker(undefined, 4, '. ')).toBe('5. ')
expect(getListMarker('1', 0, '. ')).toBe('1. ')
})
it('returns lowercase alpha markers for type "a"', () => {
expect(getListMarker('a', 0, '. ')).toBe('a. ')
expect(getListMarker('a', 1, '. ')).toBe('b. ')
expect(getListMarker('a', 25, '. ')).toBe('z. ')
})
it('returns uppercase alpha markers for type "A"', () => {
expect(getListMarker('A', 0, '. ')).toBe('A. ')
expect(getListMarker('A', 1, '. ')).toBe('B. ')
expect(getListMarker('A', 25, '. ')).toBe('Z. ')
})
it('returns lowercase roman markers for type "i"', () => {
expect(getListMarker('i', 0, '. ')).toBe('i. ')
expect(getListMarker('i', 1, '. ')).toBe('ii. ')
expect(getListMarker('i', 3, '. ')).toBe('iv. ')
expect(getListMarker('i', 9, '. ')).toBe('x. ')
})
it('returns uppercase roman markers for type "I"', () => {
expect(getListMarker('I', 0, '. ')).toBe('I. ')
expect(getListMarker('I', 1, '. ')).toBe('II. ')
expect(getListMarker('I', 3, '. ')).toBe('IV. ')
expect(getListMarker('I', 9, '. ')).toBe('X. ')
})
})
describe('detectMarkerType', () => {
it('detects default type for numeric markers', () => {
expect(detectMarkerType('1')).toBeUndefined()
expect(detectMarkerType('42')).toBeUndefined()
})
it('detects lowercase alpha', () => {
expect(detectMarkerType('a')).toBe('a')
expect(detectMarkerType('b')).toBe('a')
expect(detectMarkerType('z')).toBe('a')
expect(detectMarkerType('A')).toBe('A')
expect(detectMarkerType('B')).toBe('A')
})
it('does not treat invalid roman strings as roman', () => {
expect(detectMarkerType('aa')).toBe('a')
})
it('does not treat alpha markers longer than 2 letters as alpha', () => {
expect(detectMarkerType('abc')).toBeUndefined()
expect(detectMarkerType('ABC')).toBeUndefined()
})
it('detects lowercase roman', () => {
expect(detectMarkerType('i')).toBe('i')
expect(detectMarkerType('ii')).toBe('i')
expect(detectMarkerType('iii')).toBe('i')
expect(detectMarkerType('iv')).toBe('i')
expect(detectMarkerType('v')).toBe('i')
})
it('detects uppercase roman', () => {
expect(detectMarkerType('I')).toBe('I')
expect(detectMarkerType('VI')).toBe('I')
expect(detectMarkerType('X')).toBe('I')
})
})
describe('areOrderedListMarkersSequential', () => {
it('accepts sequential markers of the same style', () => {
expect(areOrderedListMarkersSequential(['a', 'b', 'c'])).toBe(true)
expect(areOrderedListMarkersSequential(['1', '2', '3'])).toBe(true)
expect(areOrderedListMarkersSequential(['ii', 'iii', 'iv'])).toBe(true)
expect(areOrderedListMarkersSequential(['b', 'c'])).toBe(true)
})
it('rejects mixed styles', () => {
expect(areOrderedListMarkersSequential(['a', '1'])).toBe(false)
expect(areOrderedListMarkersSequential(['i', 'ii', 'a'])).toBe(false)
})
it('rejects non-sequential markers', () => {
expect(areOrderedListMarkersSequential(['a', 'c'])).toBe(false)
expect(areOrderedListMarkersSequential(['1', '3'])).toBe(false)
expect(areOrderedListMarkersSequential(['II', 'IV'])).toBe(false)
})
})
describe('markerToStart', () => {
it('parses numeric markers', () => {
expect(markerToStart('3')).toBe(3)
expect(markerToStart('42')).toBe(42)
})
it('parses alpha markers', () => {
expect(markerToStart('a')).toBe(1)
expect(markerToStart('b')).toBe(2)
expect(markerToStart('aa')).toBe(27)
})
it('parses roman markers', () => {
expect(markerToStart('i')).toBe(1)
expect(markerToStart('ii')).toBe(2)
expect(markerToStart('II')).toBe(2)
expect(markerToStart('IV')).toBe(4)
})
})
})

View File

@@ -0,0 +1,295 @@
const ROMAN_NUMERALS: [number, string][] = [
[1000, 'm'],
[900, 'cm'],
[500, 'd'],
[400, 'cd'],
[100, 'c'],
[90, 'xc'],
[50, 'l'],
[40, 'xl'],
[10, 'x'],
[9, 'ix'],
[5, 'v'],
[4, 'iv'],
[1, 'i'],
]
const ALPHA_NUMERALS = 'abcdefghijklmnopqrstuvwxyz'
/** Alpha list markers support at most 2 letters (a–z, aa–zz), matching {@link fromAlpha}. */
export const ORDERED_LIST_ALPHA_MARKER_PATTERN = '[a-zA-Z]{1,2}'
/**
* Marker segment for ordered list lines: numeric, roman, or 1–2 letter alpha.
* Roman is matched before alpha so "iii" is roman; invalid romans like "aa" fall through to alpha.
*/
export const ORDERED_LIST_MARKER_PATTERN = String.raw`\d+|[ivxlcdmIVXLCDM]+|${ORDERED_LIST_ALPHA_MARKER_PATTERN}`
/**
* Convert a number to lowercase roman numerals.
* @example toRoman(1) // 'i'
* @example toRoman(4) // 'iv'
*/
export function toRoman(num: number): string {
let remaining = num
let result = ''
for (const [value, numeral] of ROMAN_NUMERALS) {
while (remaining >= value) {
result += numeral
remaining -= value
}
}
return result
}
/**
* Convert a number to uppercase roman numerals.
* @example toRomanUpper(1) // 'I'
* @example toRomanUpper(4) // 'IV'
*/
export function toRomanUpper(num: number): string {
return toRoman(num).toUpperCase()
}
function fromRoman(roman: string): number {
const lower = roman.toLowerCase()
let index = 0
let result = 0
while (index < lower.length) {
let matched = false
for (const [value, numeral] of ROMAN_NUMERALS) {
if (lower.startsWith(numeral, index)) {
result += value
index += numeral.length
matched = true
break
}
}
if (!matched) {
return 0
}
}
return result
}
function isValidRoman(marker: string): boolean {
if (!/^[ivxlcdmIVXLCDM]+$/.test(marker)) {
return false
}
const value = fromRoman(marker)
if (value <= 0) {
return false
}
const expected = marker === marker.toLowerCase() ? toRoman(value) : toRomanUpper(value)
return expected === marker
}
function fromAlpha(marker: string): number {
const lower = marker.toLowerCase()
if (lower.length === 1) {
return lower.charCodeAt(0) - 'a'.charCodeAt(0) + 1
}
if (lower.length === 2) {
const first = lower.charCodeAt(0) - 'a'.charCodeAt(0)
const second = lower.charCodeAt(1) - 'a'.charCodeAt(0)
return (first + 1) * 26 + second + 1
}
return 0
}
function toRomanAlpha(num: number): string {
if (num <= 26) {
return ALPHA_NUMERALS[num - 1]
}
const first = Math.floor((num - 1) / 26) - 1
const second = (num - 1) % 26
if (first < 0) {
return ALPHA_NUMERALS[second]
}
return ALPHA_NUMERALS[first] + ALPHA_NUMERALS[second]
}
/**
* Extract the list marker type from a marker string.
* Supports "1", "a", "A", "i", "I" marker styles.
*
* @param marker The text content of the list marker (e.g. "a", "1", "iii", "b")
* @returns The normalized type string, or undefined for default numeric type
*/
export function detectMarkerType(marker: string): string | undefined {
if (!marker || /^\d+$/.test(marker)) {
return undefined
}
if (isValidRoman(marker)) {
return marker === marker.toLowerCase() ? 'i' : 'I'
}
if (/^[a-z]{1,2}$/.test(marker)) {
return 'a'
}
if (/^[A-Z]{1,2}$/.test(marker)) {
return 'A'
}
return undefined
}
/**
* Convert a list marker string to its numeric start position.
*
* @param marker The text content of the list marker (e.g. "3", "b", "II")
* @returns The 1-based start value for the ordered list
*/
export function markerToStart(marker: string): number {
if (/^\d+$/.test(marker)) {
return parseInt(marker, 10)
}
const type = detectMarkerType(marker)
if (type === 'i' || type === 'I') {
return fromRoman(marker)
}
if (type === 'a' || type === 'A') {
const start = fromAlpha(marker)
return start > 0 ? start : 1
}
const parsed = parseInt(marker, 10)
return Number.isNaN(parsed) ? 1 : parsed
}
function startToMarker(type: string, start: number): string {
if (type === 'numeric') {
return String(start)
}
switch (type) {
case 'a':
return toRomanAlpha(start)
case 'A':
return toRomanAlpha(start).toUpperCase()
case 'i':
return toRoman(start)
case 'I':
return toRomanUpper(start)
default:
return String(start)
}
}
/**
* Returns true when all markers share the same style and increment by 1.
* Style is inferred from the first marker so ambiguous letters (e.g. "c", "i")
* are not re-classified differently on later lines.
*/
export function areOrderedListMarkersSequential(markers: string[]): boolean {
if (markers.length === 0) {
return false
}
const firstType = detectMarkerType(markers[0]) ?? 'numeric'
const firstStart = markerToStart(markers[0])
if (firstStart < 1) {
return false
}
for (let i = 0; i < markers.length; i++) {
const expected = startToMarker(firstType, firstStart + i)
if (markers[i] !== expected) {
return false
}
}
return true
}
export interface ParsedListMarker {
type?: string
start: number
}
/**
* Parse a list marker into HTML ordered-list attrs (type + start).
*/
export function parseListMarker(marker: string): ParsedListMarker {
return {
type: detectMarkerType(marker),
start: markerToStart(marker),
}
}
/**
* Build orderedList node attrs from the first list item marker.
*/
export function buildOrderedListAttrsFromMarker(marker: string): Record<string, string | number> {
const { type, start } = parseListMarker(marker)
const attrs: Record<string, string | number> = {}
if (type) {
attrs.type = type
}
if (start !== 1) {
attrs.start = start
}
return attrs
}
/**
* Returns the list marker prefix for a given item at a given index.
*
* @param type The list type attribute (e.g. "a", "A", "i", "I", null/undefined for default)
* @param index The zero-based index of the list item
* @param separator The separator to use (default: ". ")
* @returns The marker string (e.g. "a. ", "I. ", "1. ")
*/
export function getListMarker(
type: string | null | undefined,
index: number,
separator = '. ',
): string {
const position = index + 1
if (!type || type === '1') {
return `${position}${separator}`
}
switch (type) {
case 'a':
return `${toRomanAlpha(position)}${separator}`
case 'A':
return `${toRomanAlpha(position).toUpperCase()}${separator}`
case 'i':
return `${toRoman(position)}${separator}`
case 'I':
return `${toRomanUpper(position)}${separator}`
default:
return `${position}${separator}`
}
}

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vite-plus/test'
import { parsePlainTextOrderedListPaste } from './utils.js'
describe('plain-text paste detection', () => {
it('detects single-line lowercase alpha paste', () => {
const result = parsePlainTextOrderedListPaste('a. Item 1')
expect(result).not.toBeNull()
expect(result!.attrs?.type).toBe('a')
expect(result!.attrs?.start).toBeUndefined()
expect(result!.content).toHaveLength(1)
expect(result!.content![0].content![0].content![0].text).toBe('Item 1')
})
it('detects multi-line lowercase alpha paste', () => {
const text = 'a. Item 1\nb. Item 2'
const result = parsePlainTextOrderedListPaste(text)
expect(result).not.toBeNull()
expect(result!.attrs?.type).toBe('a')
expect(result!.content).toHaveLength(2)
expect(result!.content![0].content![0].content![0].text).toBe('Item 1')
expect(result!.content![1].content![0].content![0].text).toBe('Item 2')
})
it('sets start when pasting alpha list beginning at b', () => {
const result = parsePlainTextOrderedListPaste('b. Item 1\nc. Item 2')
expect(result!.attrs?.type).toBe('a')
expect(result!.attrs?.start).toBe(2)
})
it('sets start when pasting numeric list beginning at 3', () => {
const result = parsePlainTextOrderedListPaste('3. Item 1\n4. Item 2')
expect(result!.attrs?.type).toBeUndefined()
expect(result!.attrs?.start).toBe(3)
})
it('sets type and start when pasting roman list beginning at II', () => {
const result = parsePlainTextOrderedListPaste('II. Item 1\nIII. Item 2')
expect(result!.attrs?.type).toBe('I')
expect(result!.attrs?.start).toBe(2)
})
it('detects alpha paste with paren separator', () => {
const result = parsePlainTextOrderedListPaste('a) Item 1\nb) Item 2')
expect(result).not.toBeNull()
expect(result!.content).toHaveLength(2)
})
it('detects roman numeral paste with dot separator', () => {
const result = parsePlainTextOrderedListPaste('i. Item 1\nii. Item 2')
expect(result).not.toBeNull()
expect(result!.content).toHaveLength(2)
})
it('detects roman numeral paste with paren separator', () => {
const result = parsePlainTextOrderedListPaste('I) Item 1\nII) Item 2')
expect(result).not.toBeNull()
expect(result!.content).toHaveLength(2)
})
it('detects numeric paste with dot separator', () => {
const result = parsePlainTextOrderedListPaste('1. Item 1\n2. Item 2')
expect(result).not.toBeNull()
expect(result!.content).toHaveLength(2)
})
it('does not match plain text without list markers', () => {
const result = parsePlainTextOrderedListPaste('Just some text\nAnd more text')
expect(result).toBeNull()
})
it('does not match mixed content (some lines have markers, some do not)', () => {
const result = parsePlainTextOrderedListPaste('a. Item 1\nThis is not a list item')
expect(result).toBeNull()
})
it('does not match short patterns without content after marker', () => {
const result = parsePlainTextOrderedListPaste('a. ')
expect(result).toBeNull()
})
it('does not match three-letter alpha markers', () => {
const result = parsePlainTextOrderedListPaste('abc. Something')
expect(result).toBeNull()
})
it('does not match non-sequential markers', () => {
const result = parsePlainTextOrderedListPaste('a. Item 1\nc. Item 3')
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,399 @@
import type {
JSONContent,
MarkdownLexerConfiguration,
MarkdownParseHelpers,
MarkdownToken,
} from '@tiptap/core'
import {
areOrderedListMarkersSequential,
buildOrderedListAttrsFromMarker,
detectMarkerType,
markerToStart,
ORDERED_LIST_MARKER_PATTERN,
} from './roman.js'
export { ORDERED_LIST_MARKER_PATTERN }
/**
* Matches an ordered list item line with optional leading whitespace.
* Captures: (1) indentation spaces, (2) item marker (number, letter, or roman numeral),
* (3) separator (. or )), (4) content after marker
*
* Examples: "1. Item", " a) Nested item", " I. Roman item", "iii. Another", "aa. Item 27"
*/
export const ORDERED_LIST_ITEM_REGEX = new RegExp(
`^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.*)$`,
)
/**
* Matches any line that starts with whitespace (indented content).
* Used to identify continuation content that belongs to a list item.
*/
const INDENTED_LINE_REGEX = /^\s/
/**
* This are blocks that can interrupt a paragraph, so a line starting with one of
* them can never be lazy continuation text of a list item
*/
const PARAGRAPH_INTERRUPTERS = {
heading: /^#{1,6}(?:\s|$)/,
bulletItem: /^[-+*]\s+/,
codeFence: /^(?:```|~~~)/,
blockMath: /^\$\$/,
thematicBreak: /^(?:(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})$/,
}
/**
* Represents a parsed ordered list item with indentation information
*/
export interface OrderedListItem {
indent: number
number: number
type?: string
content: string
contentLines: string[]
raw: string
}
function isOrderedListMarkerLine(line: string): boolean {
return ORDERED_LIST_ITEM_REGEX.test(line.trimStart())
}
function isBlockContentLine(line: string): boolean {
const trimmedLine = line.trimStart()
return (
PARAGRAPH_INTERRUPTERS.bulletItem.test(trimmedLine) ||
isOrderedListMarkerLine(trimmedLine) ||
PARAGRAPH_INTERRUPTERS.heading.test(trimmedLine) ||
// dash breaks are excluded: "---" directly below paragraph text is a
// setext heading underline, not a thematic break
(PARAGRAPH_INTERRUPTERS.thematicBreak.test(trimmedLine) && !trimmedLine.startsWith('-')) ||
// oxlint-disable-next-line prefer-string-starts-ends-with
/^>\s?/.test(trimmedLine) ||
PARAGRAPH_INTERRUPTERS.codeFence.test(trimmedLine) ||
PARAGRAPH_INTERRUPTERS.blockMath.test(trimmedLine)
)
}
function interruptsLazyContinuation(line: string): boolean {
return Object.values(PARAGRAPH_INTERRUPTERS).some(pattern => pattern.test(line))
}
function splitItemContent(contentLines: string[]): {
paragraphLines: string[]
blockLines: string[]
} {
const paragraphLines: string[] = []
const blockLines: string[] = []
let reachedBlockBoundary = false
contentLines.forEach(line => {
if (reachedBlockBoundary) {
blockLines.push(line)
return
}
if (line.trim() === '') {
reachedBlockBoundary = true
blockLines.push(line)
return
}
if (paragraphLines.length > 0 && isBlockContentLine(line)) {
reachedBlockBoundary = true
blockLines.push(line)
return
}
paragraphLines.push(line)
})
return {
paragraphLines,
blockLines,
}
}
/**
* Collects all ordered list items from lines, parsing them into a flat array
* with indentation information. Stops collecting continuation content when
* encountering nested list items, allowing them to be processed separately.
*
* @param lines - Array of source lines to parse
* @returns Tuple of [listItems array, number of lines consumed]
*/
export function collectOrderedListItems(lines: string[]): [OrderedListItem[], number] {
const listItems: OrderedListItem[] = []
let currentLineIndex = 0
let consumed = 0
while (currentLineIndex < lines.length) {
const line = lines[currentLineIndex]
const match = line.match(ORDERED_LIST_ITEM_REGEX)
if (!match) {
break
}
const [, indent, marker, _separator, content] = match
const indentLevel = indent.length
const number = parseInt(marker, 10)
const markerType = isNaN(number) ? detectMarkerType(marker) : undefined
const itemNumber = isNaN(number) ? markerToStart(marker) : number
const itemContentLines = [content]
let nextLineIndex = currentLineIndex + 1
const itemLines = [line]
let sawBlankLine = false
// Collect continuation lines for this item (but NOT nested list items)
while (nextLineIndex < lines.length) {
const nextLine = lines[nextLineIndex]
const nextMatch = nextLine.match(ORDERED_LIST_ITEM_REGEX)
// If it's another list item (nested or not), stop collecting
if (nextMatch) {
break
}
// Check for continuation content (non-list content)
if (nextLine.trim() === '') {
// Empty line
itemLines.push(nextLine)
itemContentLines.push('')
sawBlankLine = true
nextLineIndex += 1
} else if (nextLine.match(INDENTED_LINE_REGEX)) {
// Indented content - part of this item (but not a list item).
// Strip the indentation only up to the whitespace that is actually present,
// so an under-indented line (e.g. a single leading space) keeps its first character.
const leadingWhitespace = nextLine.length - nextLine.trimStart().length
const contentIndent = indentLevel + marker.length + 1
itemLines.push(nextLine)
itemContentLines.push(nextLine.slice(Math.min(leadingWhitespace, contentIndent)))
nextLineIndex += 1
} else {
if (sawBlankLine || interruptsLazyContinuation(nextLine)) {
break
}
itemLines.push(nextLine)
itemContentLines.push(nextLine)
nextLineIndex += 1
}
}
listItems.push({
indent: indentLevel,
number: itemNumber,
type: markerType,
content: itemContentLines.join('\n').trim(),
contentLines: itemContentLines,
raw: itemLines.join('\n'),
})
consumed = nextLineIndex
currentLineIndex = nextLineIndex
}
return [listItems, consumed]
}
const PLAIN_TEXT_ORDERED_LIST_LINE_REGEX = new RegExp(
`^(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.+)$`,
)
/**
* Parse plain-text pasted ordered list lines into JSONContent, or null if not a typed list.
*/
export function parsePlainTextOrderedListPaste(text: string): JSONContent | null {
const lines = text.split('\n').filter(l => l.trim().length > 0)
if (lines.length === 0) {
return null
}
const parsedItems: Array<{ marker: string; content: string }> = []
for (const line of lines) {
const match = line.trim().match(PLAIN_TEXT_ORDERED_LIST_LINE_REGEX)
if (!match) {
return null
}
parsedItems.push({
marker: match[1],
content: match[3],
})
}
const markers = parsedItems.map(item => item.marker)
if (!areOrderedListMarkersSequential(markers)) {
return null
}
const attrs = buildOrderedListAttrsFromMarker(parsedItems[0].marker)
return {
type: 'orderedList',
attrs,
content: parsedItems.map(item => ({
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: item.content }],
},
],
})),
}
}
/**
* Recursively builds a nested structure from a flat array of list items
* based on their indentation levels. Creates proper markdown tokens with
* nested lists where appropriate.
*
* @param items - Flat array of list items with indentation info
* @param baseIndent - The indentation level to process at this recursion level
* @param lexer - Markdown lexer for parsing inline and block content
* @returns Array of list_item tokens with proper nesting
*/
export function buildNestedStructure(
items: OrderedListItem[],
baseIndent: number,
lexer: MarkdownLexerConfiguration,
): unknown[] {
const result: unknown[] = []
let currentIndex = 0
while (currentIndex < items.length) {
const item = items[currentIndex]
if (item.indent === baseIndent) {
// This item belongs at the current level
const { paragraphLines, blockLines } = splitItemContent(item.contentLines)
const mainText = paragraphLines.join('\n').trim()
const tokens = []
// Always wrap the main text in a paragraph token
if (mainText) {
tokens.push({
type: 'paragraph',
raw: mainText,
tokens: lexer.inlineTokens(mainText),
})
}
const additionalContent = blockLines.join('\n').trim()
if (additionalContent) {
const blockTokens = lexer.blockTokens(additionalContent)
tokens.push(...blockTokens)
}
// Look ahead to find nested items at deeper indent levels
let lookAheadIndex = currentIndex + 1
const nestedItems = []
while (lookAheadIndex < items.length && items[lookAheadIndex].indent > baseIndent) {
nestedItems.push(items[lookAheadIndex])
lookAheadIndex += 1
}
// If we have nested items, recursively build their structure
if (nestedItems.length > 0) {
// Find the next indent level (immediate children)
const nextIndent = Math.min(...nestedItems.map(nestedItem => nestedItem.indent))
// Build the nested list recursively with all nested items
// The recursive call will handle further nesting
const nestedListItems = buildNestedStructure(nestedItems, nextIndent, lexer)
// Create a nested list token
tokens.push({
type: 'list',
ordered: true,
start: nestedItems[0].number,
typeMarker: nestedItems[0].type,
items: nestedListItems,
raw: nestedItems.map(nestedItem => nestedItem.raw).join('\n'),
})
}
result.push({
type: 'list_item',
raw: item.raw,
tokens,
})
// Skip the nested items we just processed
currentIndex = lookAheadIndex
} else {
// This item has deeper indent than we're currently processing
// It should be handled by a recursive call
currentIndex += 1
}
}
return result
}
/**
* Parses markdown list item tokens into Tiptap JSONContent structure,
* ensuring text content is properly wrapped in paragraph nodes.
*
* @param items - Array of markdown tokens representing list items
* @param helpers - Markdown parse helpers for recursive parsing
* @returns Array of listItem JSONContent nodes
*/
export function parseListItems(
items: MarkdownToken[],
helpers: MarkdownParseHelpers,
): JSONContent[] {
return items.map(item => {
if (item.type !== 'list_item') {
return helpers.parseChildren([item])[0]
}
// Parse the tokens within the list item
const content: JSONContent[] = []
if (item.tokens && item.tokens.length > 0) {
item.tokens.forEach(itemToken => {
// If it's already a proper block node (paragraph, list, etc.), parse it directly
if (
itemToken.type === 'paragraph' ||
itemToken.type === 'list' ||
itemToken.type === 'blockquote' ||
itemToken.type === 'code'
) {
content.push(...helpers.parseChildren([itemToken]))
} else if (itemToken.type === 'text' && itemToken.tokens) {
// If it's inline text tokens, wrap them in a paragraph
const inlineContent = helpers.parseChildren([itemToken])
content.push({
type: 'paragraph',
content: inlineContent,
})
} else {
// For any other content, try to parse it
const parsed = helpers.parseChildren([itemToken])
if (parsed.length > 0) {
content.push(...parsed)
}
}
})
}
return {
type: 'listItem',
content,
}
})
}

View File

@@ -0,0 +1 @@
export * from './task-item.js'

View File

@@ -0,0 +1,904 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { BulletList, ListItem, OrderedList, TaskItem, TaskList } from '../index.js'
describe('TaskItem', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
describe('accessibility', () => {
it('gives the checkbox label to the wrapping label element', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, TaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A list item' }] },
],
},
],
},
],
},
})
const taskItemElement = editor.view.dom.querySelector('li[data-checked]')
const labelElement = taskItemElement?.querySelector('label')
const spanElement = taskItemElement?.querySelector('label > span')
const checkbox = taskItemElement?.querySelector('input[type="checkbox"]')
expect(labelElement?.textContent?.trim()).not.toBe('')
expect(spanElement?.textContent).toBe('Task item checkbox for A list item')
expect(checkbox?.getAttribute('aria-label')).toBe('Task item checkbox for A list item')
expect(labelElement?.querySelector('input')).not.toBeNull()
})
it('hides the checkbox label text visually', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, TaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A list item' }] },
],
},
],
},
],
},
})
const spanElement = editor.view.dom.querySelector('li[data-checked] > label > span')
expect(spanElement?.style.position).toBe('absolute')
expect(spanElement?.style.width).toBe('1px')
})
it('supports a custom checkbox label', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
TaskList,
TaskItem.configure({
a11y: {
checkboxLabel: (node, checked) => (checked ? 'Done' : 'To do'),
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A list item' }] },
],
},
],
},
],
},
})
const spanElement = editor.view.dom.querySelector('li[data-checked] > label > span')
const checkbox = editor.view.dom.querySelector('input[type="checkbox"]')
expect(spanElement?.textContent).toBe('To do')
expect(checkbox?.getAttribute('aria-label')).toBe('To do')
})
it('uses the checked state for an initially checked task item', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
TaskList,
TaskItem.configure({
a11y: {
checkboxLabel: (node, checked) => (checked ? 'Done' : 'To do'),
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: true },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A list item' }] },
],
},
],
},
],
},
})
const spanElement = editor.view.dom.querySelector('li[data-checked] > label > span')
const checkbox = editor.view.dom.querySelector('input[type="checkbox"]')
expect(spanElement?.textContent).toBe('Done')
expect(checkbox?.getAttribute('aria-label')).toBe('Done')
})
it('updates the checkbox label when the node changes', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
TaskList,
TaskItem.configure({
a11y: {
checkboxLabel: (node, checked) => (checked ? 'Done' : 'To do'),
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A list item' }] },
],
},
],
},
],
},
})
const spanElement = editor.view.dom.querySelector('li[data-checked] > label > span')
expect(spanElement?.textContent).toBe('To do')
editor.chain().setTextSelection(4).updateAttributes('taskItem', { checked: true }).run()
expect(spanElement?.textContent).toBe('Done')
})
it('uses a fallback label for empty task items', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, TaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [{ type: 'paragraph' }],
},
],
},
],
},
})
const spanElement = editor.view.dom.querySelector('li[data-checked] > label > span')
expect(spanElement?.textContent).toBe('Task item checkbox for empty task item')
})
it('does not parse the checkbox label as task item content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, TaskItem],
content: `
<ul data-type="taskList">
<li data-checked="true" data-type="taskItem">
<label><input type="checkbox"><span>Task item checkbox for A list item</span></label>
<div><p>A list item</p></div>
</li>
</ul>
`,
})
expect(editor.getText()).toContain('A list item')
expect(editor.getText()).not.toContain('Task item checkbox')
})
it('still parses task items without a content wrapper', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, TaskItem],
content: `
<ul data-type="taskList">
<li data-type="taskItem"><p>A list item</p></li>
</ul>
`,
})
expect(editor.getText()).toContain('A list item')
expect(editor.getText()).not.toContain('Task item checkbox')
})
})
it('preserves custom HTML attributes on node update', () => {
// Extend TaskItem with a custom attribute
const CustomTaskItem = TaskItem.extend({
addAttributes() {
return {
...this.parent?.(),
uid: {
default: null,
parseHTML: element => element.getAttribute('data-uid'),
renderHTML: attributes => {
if (!attributes.uid) {
return {}
}
return { 'data-uid': attributes.uid }
},
},
}
},
})
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, CustomTaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false, uid: 'test-uid-123' },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test task' }] }],
},
],
},
],
},
})
// Verify the initial attribute is present - use data-checked selector as node view sets this
const taskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(taskItemElement).not.toBeNull()
expect(taskItemElement?.getAttribute('data-uid')).toBe('test-uid-123')
expect(taskItemElement?.getAttribute('data-checked')).toBe('false')
// Update the task item (toggle checked status)
// Position cursor inside the task item first
editor.chain().setTextSelection(4).updateAttributes('taskItem', { checked: true }).run()
// Verify the custom attribute is still present after update
const updatedTaskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(updatedTaskItemElement).not.toBeNull()
expect(updatedTaskItemElement?.getAttribute('data-uid')).toBe('test-uid-123')
expect(updatedTaskItemElement?.getAttribute('data-checked')).toBe('true')
})
it('removes attributes when set to null', () => {
const CustomTaskItem = TaskItem.extend({
addAttributes() {
return {
...this.parent?.(),
uid: {
default: null,
parseHTML: element => element.getAttribute('data-uid'),
renderHTML: attributes => {
if (!attributes.uid) {
return {}
}
return { 'data-uid': attributes.uid }
},
},
}
},
})
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, CustomTaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false, uid: 'test-uid-456' },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test task' }] }],
},
],
},
],
},
})
// Verify the initial attribute is present
const taskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(taskItemElement?.getAttribute('data-uid')).toBe('test-uid-456')
// Remove the uid attribute
editor.chain().setTextSelection(4).updateAttributes('taskItem', { uid: null }).run()
// Verify the attribute is removed (empty string or not present depends on renderHTML returning {})
const updatedTaskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(updatedTaskItemElement?.getAttribute('data-uid')).toBeNull()
})
it('updates multiple custom attributes simultaneously', () => {
const CustomTaskItem = TaskItem.extend({
addAttributes() {
return {
...this.parent?.(),
uid: {
default: null,
parseHTML: element => element.getAttribute('data-uid'),
renderHTML: attributes => {
if (!attributes.uid) {
return {}
}
return { 'data-uid': attributes.uid }
},
},
priority: {
default: null,
parseHTML: element => element.getAttribute('data-priority'),
renderHTML: attributes => {
if (!attributes.priority) {
return {}
}
return { 'data-priority': attributes.priority }
},
},
}
},
})
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, CustomTaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false, uid: 'task-1', priority: 'high' },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test task' }] }],
},
],
},
],
},
})
const taskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(taskItemElement?.getAttribute('data-uid')).toBe('task-1')
expect(taskItemElement?.getAttribute('data-priority')).toBe('high')
// Update all attributes
editor
.chain()
.setTextSelection(4)
.updateAttributes('taskItem', {
checked: true,
uid: 'task-updated',
priority: 'low',
})
.run()
const updatedTaskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(updatedTaskItemElement?.getAttribute('data-checked')).toBe('true')
expect(updatedTaskItemElement?.getAttribute('data-uid')).toBe('task-updated')
expect(updatedTaskItemElement?.getAttribute('data-priority')).toBe('low')
})
it('preserves HTMLAttributes from configure() options during updates', () => {
// Configure TaskItem with static HTMLAttributes
const ConfiguredTaskItem = TaskItem.configure({
HTMLAttributes: {
class: 'custom-task-item',
'data-static': 'static-value',
},
})
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, ConfiguredTaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test task' }] }],
},
],
},
],
},
})
// Verify the static attributes are present initially
const taskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(taskItemElement?.getAttribute('class')).toBe('custom-task-item')
expect(taskItemElement?.getAttribute('data-static')).toBe('static-value')
expect(taskItemElement?.getAttribute('data-checked')).toBe('false')
// Update the task item
editor.chain().setTextSelection(4).updateAttributes('taskItem', { checked: true }).run()
// Verify static attributes are still present after update
const updatedTaskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(updatedTaskItemElement?.getAttribute('class')).toBe('custom-task-item')
expect(updatedTaskItemElement?.getAttribute('data-static')).toBe('static-value')
expect(updatedTaskItemElement?.getAttribute('data-checked')).toBe('true')
})
it('restores static HTMLAttributes when dynamic attribute with same key is removed', () => {
// Configure TaskItem with a static attribute that will be overridden by dynamic attribute
const ConfiguredTaskItem = TaskItem.extend({
addAttributes() {
return {
...this.parent?.(),
customClass: {
default: null,
renderHTML: attributes => {
if (!attributes.customClass) {
return {}
}
return { class: attributes.customClass }
},
},
}
},
}).configure({
HTMLAttributes: {
class: 'static-class',
},
})
editor = new Editor({
extensions: [Document, Paragraph, Text, TaskList, ConfiguredTaskItem],
content: {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false, customClass: 'dynamic-class' },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test task' }] }],
},
],
},
],
},
})
// Dynamic class should override static class initially
const taskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(taskItemElement?.getAttribute('class')).toBe('dynamic-class')
// Remove the dynamic class attribute
editor.chain().setTextSelection(4).updateAttributes('taskItem', { customClass: null }).run()
// Static class should be restored
const updatedTaskItemElement = editor.view.dom.querySelector('li[data-checked]')
expect(updatedTaskItemElement?.getAttribute('class')).toBe('static-class')
})
it('toggles off an ordered list when the whole document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.state.selection.from).toBe(0)
expect(editor.state.selection.to).toBe(editor.state.doc.content.size)
editor.commands.toggleOrderedList()
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
})
})
it('toggles off a bullet list when the whole document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.state.selection.from).toBe(0)
expect(editor.state.selection.to).toBe(editor.state.doc.content.size)
editor.commands.toggleBulletList()
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
})
})
it('converts a bullet list to an ordered list when the whole document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.state.selection.from).toBe(0)
expect(editor.state.selection.to).toBe(editor.state.doc.content.size)
editor.commands.toggleOrderedList()
expect(editor.getJSON()).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
attrs: {
start: 1,
type: null,
},
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
})
})
it('reports ordered list toggle as available when the whole ordered list document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: {
start: 1,
type: null,
},
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.can().toggleOrderedList()).toBe(true)
})
it('reports bullet list toggle as available when the whole bullet list document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.can().toggleBulletList()).toBe(true)
})
it('reports ordered list toggle as available when the whole bullet list document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.can().toggleOrderedList()).toBe(true)
})
it('reports bullet list toggle as available when the whole ordered list document is selected', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.can().toggleBulletList()).toBe(true)
})
it('preserves text selection after toggling off an ordered list with AllSelection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.state.selection.from).toBe(0)
expect(editor.state.selection.to).toBe(editor.state.doc.content.size)
editor.commands.toggleOrderedList()
const { from, to } = editor.state.selection
expect(from).toBe(1)
expect(to).toBe(10)
})
it('preserves text selection after toggling off a bullet list with AllSelection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Test item' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.state.selection.from).toBe(0)
expect(editor.state.selection.to).toBe(editor.state.doc.content.size)
editor.commands.toggleBulletList()
const { from, to } = editor.state.selection
expect(from).toBe(1)
expect(to).toBe(10)
})
it('preserves text selection after toggling off a multi-item ordered list with AllSelection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList, BulletList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Second' }],
},
],
},
],
},
],
},
})
editor.commands.selectAll()
expect(editor.state.selection.from).toBe(0)
expect(editor.state.selection.to).toBe(editor.state.doc.content.size)
editor.commands.toggleOrderedList()
const { from, to } = editor.state.selection
expect(from).toBe(1)
expect(to).toBe(14)
})
})

View File

@@ -0,0 +1,359 @@
import type { KeyboardShortcutCommand } from '@tiptap/core'
import {
getRenderedAttributes,
mergeAttributes,
Node,
renderNestedMarkdownContent,
wrappingInputRule,
} from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { createBranchingListDeleteKeymap } from '../helpers/createBranchingListDeleteKeymap.js'
export interface TaskItemOptions {
/**
* A callback function that is called when the checkbox is clicked while the editor is in readonly mode.
* @param node The prosemirror node of the task item
* @param checked The new checked state
* @returns boolean
*/
onReadOnlyChecked?: (node: PMNode, checked: boolean) => boolean
/**
* Controls whether the task items can be nested or not.
* @default false
* @example true
*/
nested: boolean
/**
* HTML attributes to add to the task item element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* The node type for taskList nodes
* @default 'taskList'
* @example 'myCustomTaskList'
*/
taskListTypeName: string
/**
* Accessibility options for the task item.
* @default {}
* @example
* ```js
* {
* checkboxLabel: (node) => `Task item: ${node.textContent || 'empty task item'}`
* }
*/
a11y?: {
/**
* The accessible label for the checkbox. Used as the aria-label on the input and as the
* (visually hidden) text of the wrapping label element.
* @param node The prosemirror node of the task item
* @param checked The new checked state
* @returns The accessible label for the checkbox
* @example
* ```js
* TaskItem.configure({
* a11y: {
* checkboxLabel: node => `Task item: ${node.textContent || 'empty task item'}`,
* },
* })
* ```
*/
checkboxLabel?: (node: PMNode, checked: boolean) => string
}
}
/**
* Matches a task item to a - [ ] on input.
*/
export const inputRegex = /^\s*(\[([( |x])?\])\s$/
/**
* Hides the checkbox label visually while keeping it in the accessibility tree.
*/
const visuallyHiddenStyle =
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'
const getCheckboxLabel = (node: PMNode, checked: boolean, a11y: TaskItemOptions['a11y']) =>
a11y?.checkboxLabel?.(node, checked) ||
`Task item checkbox for ${node.textContent || 'empty task item'}`
/**
* This extension allows you to create task items.
* @see https://www.tiptap.dev/api/nodes/task-item
*/
export const TaskItem = Node.create<TaskItemOptions>({
name: 'taskItem',
addOptions() {
return {
nested: false,
HTMLAttributes: {},
taskListTypeName: 'taskList',
a11y: undefined,
}
},
content() {
return this.options.nested ? 'paragraph block*' : 'paragraph+'
},
defining: true,
addAttributes() {
return {
checked: {
default: false,
keepOnSplit: false,
parseHTML: element => {
const dataChecked = element.getAttribute('data-checked')
return dataChecked === '' || dataChecked === 'true'
},
renderHTML: attributes => ({
'data-checked': attributes.checked,
}),
},
}
},
parseHTML() {
return [
{
tag: `li[data-type="${this.name}"]`,
priority: 51,
contentElement: element => element.querySelector('div') ?? element,
},
]
},
renderHTML({ node, HTMLAttributes }) {
return [
'li',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name,
}),
[
'label',
[
'input',
{
type: 'checkbox',
checked: node.attrs.checked ? 'checked' : null,
},
],
['span'],
],
['div', 0],
]
},
parseMarkdown: (token, h) => {
// Parse the task item's text content into paragraph content
const content = []
// First, add the main paragraph content
if (token.tokens && token.tokens.length > 0) {
// If we have tokens, create a paragraph with the inline content
content.push(h.createNode('paragraph', {}, h.parseInline(token.tokens)))
} else if (token.text) {
// If we have raw text, create a paragraph with text node
content.push(h.createNode('paragraph', {}, [h.createNode('text', { text: token.text })]))
} else {
// Fallback: empty paragraph
content.push(h.createNode('paragraph', {}, []))
}
// Then, add any nested content (like nested task lists)
if (token.nestedTokens && token.nestedTokens.length > 0) {
const nestedContent = h.parseChildren(token.nestedTokens)
content.push(...nestedContent)
}
return h.createNode('taskItem', { checked: token.checked || false }, content)
},
renderMarkdown: (node, h) => {
const checkedChar = node.attrs?.checked ? 'x' : ' '
const prefix = `- [${checkedChar}] `
return renderNestedMarkdownContent(node, h, prefix)
},
addExtensions() {
if (!this.options.nested) {
return []
}
return [createBranchingListDeleteKeymap(this.name, [this.options.taskListTypeName])]
},
addKeyboardShortcuts() {
const shortcuts: {
[key: string]: KeyboardShortcutCommand
} = {
Enter: () => this.editor.commands.splitListItem(this.name),
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
}
if (!this.options.nested) {
return shortcuts
}
return {
...shortcuts,
Tab: () => this.editor.commands.sinkListItem(this.name),
}
},
addNodeView() {
return ({ node, HTMLAttributes, getPos, editor }) => {
const listItem = document.createElement('li')
const checkboxWrapper = document.createElement('label')
const checkboxStyler = document.createElement('span')
const checkbox = document.createElement('input')
const content = document.createElement('div')
checkboxStyler.style.cssText = visuallyHiddenStyle
const updateA11Y = (currentNode: PMNode) => {
const label = getCheckboxLabel(currentNode, currentNode.attrs.checked, this.options.a11y)
checkbox.setAttribute('aria-label', label)
checkboxStyler.textContent = label
}
updateA11Y(node)
checkboxWrapper.contentEditable = 'false'
checkbox.type = 'checkbox'
checkbox.addEventListener('mousedown', event => event.preventDefault())
checkbox.addEventListener('change', event => {
// if the editor isn’t editable and we don't have a handler for
// readonly checks we have to undo the latest change
if (!editor.isEditable && !this.options.onReadOnlyChecked) {
checkbox.checked = !checkbox.checked
return
}
const { checked } = event.target as any
if (editor.isEditable && typeof getPos === 'function') {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.command(({ tr }) => {
const position = getPos()
if (typeof position !== 'number') {
return false
}
const currentNode = tr.doc.nodeAt(position)
tr.setNodeMarkup(position, undefined, {
...currentNode?.attrs,
checked,
})
return true
})
.run()
}
if (!editor.isEditable && this.options.onReadOnlyChecked) {
// Reset state if onReadOnlyChecked returns false
if (!this.options.onReadOnlyChecked(node, checked)) {
checkbox.checked = !checkbox.checked
}
}
})
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value)
})
listItem.dataset.checked = node.attrs.checked
checkbox.checked = node.attrs.checked
checkboxWrapper.append(checkbox, checkboxStyler)
listItem.append(checkboxWrapper, content)
Object.entries(HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value)
})
// Track the keys of previously rendered HTML attributes for proper removal
let prevRenderedAttributeKeys = new Set(Object.keys(HTMLAttributes))
return {
dom: listItem,
contentDOM: content,
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
listItem.dataset.checked = updatedNode.attrs.checked
checkbox.checked = updatedNode.attrs.checked
updateA11Y(updatedNode)
// Sync all HTML attributes from the updated node
const extensionAttributes = editor.extensionManager.attributes
const newHTMLAttributes = getRenderedAttributes(updatedNode, extensionAttributes)
const newKeys = new Set(Object.keys(newHTMLAttributes))
// Remove attributes that were previously rendered but are no longer present
// If the attribute exists in static options, restore it instead of removing
const staticAttrs = this.options.HTMLAttributes
prevRenderedAttributeKeys.forEach(key => {
if (!newKeys.has(key)) {
if (key in staticAttrs) {
listItem.setAttribute(key, staticAttrs[key])
} else {
listItem.removeAttribute(key)
}
}
})
// Update or add new attributes
Object.entries(newHTMLAttributes).forEach(([key, value]) => {
if (value === null || value === undefined) {
// If the attribute exists in static options, restore it instead of removing
if (key in staticAttrs) {
listItem.setAttribute(key, staticAttrs[key])
} else {
listItem.removeAttribute(key)
}
} else {
listItem.setAttribute(key, value)
}
})
// Update the tracked keys for next update
prevRenderedAttributeKeys = newKeys
return true
},
}
}
},
addInputRules() {
return [
wrappingInputRule({
find: inputRegex,
type: this.type,
getAttributes: match => ({
checked: match[match.length - 1] === 'x',
}),
}),
]
},
})

View File

@@ -0,0 +1 @@
export * from './task-list.js'

View File

@@ -0,0 +1,194 @@
import { mergeAttributes, Node, parseIndentedBlocks } from '@tiptap/core'
export interface TaskListOptions {
/**
* The node type name for a task item.
* @default 'taskItem'
* @example 'myCustomTaskItem'
*/
itemTypeName: string
/**
* The HTML attributes for a task list node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
taskList: {
/**
* Toggle a task list
* @example editor.commands.toggleTaskList()
*/
toggleTaskList: () => ReturnType
}
}
}
/**
* This extension allows you to create task lists.
* @see https://www.tiptap.dev/api/nodes/task-list
*/
export const TaskList = Node.create<TaskListOptions>({
name: 'taskList',
addOptions() {
return {
itemTypeName: 'taskItem',
HTMLAttributes: {},
}
},
group: 'block list',
content() {
return `${this.options.itemTypeName}+`
},
parseHTML() {
return [
{
tag: `ul[data-type="${this.name}"]`,
priority: 51,
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'ul',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }),
0,
]
},
parseMarkdown: (token, h) => {
return h.createNode('taskList', {}, h.parseChildren(token.items || []))
},
renderMarkdown: (node, h) => {
if (!node.content) {
return ''
}
return h.renderChildren(node.content, '\n')
},
markdownTokenizer: {
name: 'taskList',
level: 'block',
start(src) {
// Look for the start of a task list item
const index = src.match(/^\s*[-+*]\s+\[([ xX])\]\s+/)?.index
return index !== undefined ? index : -1
},
tokenize(src, tokens, lexer) {
// Helper function to recursively parse task lists
const parseTaskListContent = (content: string): any[] | undefined => {
const nestedResult = parseIndentedBlocks(
content,
{
itemPattern: /^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,
extractItemData: match => ({
indentLevel: match[1].length,
mainContent: match[4],
checked: match[3].toLowerCase() === 'x',
}),
createToken: (data, nestedTokens) => ({
type: 'taskItem',
raw: '',
mainContent: data.mainContent,
indentLevel: data.indentLevel,
checked: data.checked,
text: data.mainContent,
tokens: lexer.inlineTokens(data.mainContent),
nestedTokens,
}),
// Allow recursive nesting
customNestedParser: parseTaskListContent,
},
lexer,
)
if (nestedResult) {
const taskListToken = {
type: 'taskList',
raw: nestedResult.raw,
items: nestedResult.items,
}
// parseIndentedBlocks only consumes checkbox items.
// Parse any remaining indented content, such as plain bullets,
// and keep it as sibling tokens.
const remainder = content.slice(nestedResult.raw.length)
if (remainder.trim()) {
return [taskListToken, ...lexer.blockTokens(remainder)]
}
return [taskListToken]
}
// Fall back to regular markdown parsing if not a task list
return lexer.blockTokens(content)
}
const result = parseIndentedBlocks(
src,
{
itemPattern: /^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,
extractItemData: match => ({
indentLevel: match[1].length,
mainContent: match[4],
checked: match[3].toLowerCase() === 'x',
}),
createToken: (data, nestedTokens) => ({
type: 'taskItem',
raw: '',
mainContent: data.mainContent,
indentLevel: data.indentLevel,
checked: data.checked,
text: data.mainContent,
tokens: lexer.inlineTokens(data.mainContent),
nestedTokens,
}),
// Use the recursive parser for nested content
customNestedParser: parseTaskListContent,
},
lexer,
)
if (!result) {
return undefined
}
return {
type: 'taskList',
raw: result.raw,
items: result.items,
}
},
},
markdownOptions: {
indentsContent: true,
},
addCommands() {
return {
toggleTaskList:
() =>
({ commands }) => {
return commands.toggleList(this.name, this.options.itemTypeName)
},
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-9': () => this.editor.commands.toggleTaskList(),
}
},
})

View File

@@ -0,0 +1,230 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { NodeSelection } from '@tiptap/pm/state'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { BulletList, ListItem } from '../../src/index.js'
import { hasBranchingNestedListAfterCursor } from '../../src/helpers/hasBranchingNestedListAfterCursor.js'
import { hoistBranchingNestedList } from '../../src/helpers/hoistBranchingNestedList.js'
const shallowNestedListContent = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Outer' }] },
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inner' }] }],
},
],
},
],
},
],
},
],
}
const branchingNestedListContent = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] },
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1.1' }] },
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 1.1.1' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Item 1.1.2' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1.2' }] }],
},
],
},
],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }],
},
],
},
],
}
const wrapperNames = ['bulletList', 'orderedList']
function findTextPosition(editor: Editor, text: string) {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (position !== null) {
return false
}
if (node.isText && node.text === text) {
position = pos + node.text.length
}
})
if (position === null) {
throw new Error(`Could not find text position for "${text}"`)
}
return position
}
function dispatchDelete(editor: Editor) {
return editor.view.someProp('handleKeyDown', f =>
f(
editor.view,
new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }),
),
)
}
describe('ListItem Delete with nested lists', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('hoists branching nested list via command', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: branchingNestedListContent,
})
editor.commands.setTextSelection(findTextPosition(editor, 'Item 1'))
const handled = hoistBranchingNestedList(
editor.state,
editor.view.dispatch,
'listItem',
wrapperNames,
)
expect(handled).toBe(true)
expect(editor.getJSON().content?.[0]?.content).toHaveLength(4)
})
it('does not treat shallow nested lists as branching', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: shallowNestedListContent,
})
editor.commands.setTextSelection(findTextPosition(editor, 'Outer'))
expect(hasBranchingNestedListAfterCursor(editor.state, 'listItem', wrapperNames)).toBe(false)
expect(hoistBranchingNestedList(editor.state, undefined, 'listItem', wrapperNames)).toBe(false)
})
it('hoists branching nested list items on Delete without node selection', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: branchingNestedListContent,
})
editor.commands.setTextSelection(findTextPosition(editor, 'Item 1'))
expect(dispatchDelete(editor)).toBe(true)
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
const topList = editor.getJSON().content?.[0]
expect(topList?.type).toBe('bulletList')
expect(topList?.content).toHaveLength(4)
const item1 = topList?.content?.[0]
expect(item1?.type).toBe('listItem')
expect(item1?.content).toHaveLength(1)
expect(item1?.content?.[0]?.type).toBe('paragraph')
expect(topList?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe('Item 1.1')
expect(topList?.content?.[2]?.content?.[0]?.content?.[0]?.text).toBe('Item 1.2')
expect(topList?.content?.[3]?.content?.[0]?.content?.[0]?.text).toBe('Item 2')
})
it('does not create node selection on a second Delete after hoisting', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: branchingNestedListContent,
})
editor.commands.setTextSelection(findTextPosition(editor, 'Item 1'))
dispatchDelete(editor)
dispatchDelete(editor)
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
const docText = JSON.stringify(editor.getJSON())
expect(docText).toContain('Item 1.1')
expect(docText).toContain('Item 1.2')
expect(docText).toContain('Item 2')
expect(editor.getJSON().content?.[0]?.content?.length).toBeGreaterThanOrEqual(3)
})
it('still allows joinForward for shallow nested lists when nested items are leaf nodes', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem],
content: shallowNestedListContent,
})
editor.commands.setTextSelection(findTextPosition(editor, 'Outer'))
expect(dispatchDelete(editor)).toBe(true)
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
const docText = JSON.stringify(editor.getJSON())
expect(docText).toContain('Outer')
expect(docText).toContain('Inner')
})
})

View File

@@ -0,0 +1,155 @@
import { Editor } from '@tiptap/core'
import Blockquote from '@tiptap/extension-blockquote'
import Document from '@tiptap/extension-document'
import HorizontalRule from '@tiptap/extension-horizontal-rule'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { BulletList, ListItem, ListKeymap, TaskItem, TaskList } from '../../src/index.js'
function findNodePosition(editor: Editor, typeName: string) {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (position !== null) {
return false
}
if (node.type.name === typeName) {
position = pos
}
})
if (position === null) {
throw new Error(`Could not find position of "${typeName}"`)
}
return position
}
function findTextStart(editor: Editor, text: string) {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (position !== null) {
return false
}
if (node.isText && node.text === text) {
position = pos
}
})
if (position === null) {
throw new Error(`Could not find text position for "${text}"`)
}
return position
}
function dispatchTab(editor: Editor) {
return editor.view.someProp('handleKeyDown', f =>
f(editor.view, new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })),
)
}
describe('ListKeymap Tab', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('does nothing at a gap cursor position', () => {
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
BulletList,
ListItem,
Blockquote,
HorizontalRule,
ListKeymap,
],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
},
],
},
{ type: 'blockquote', content: [{ type: 'horizontalRule' }] },
],
},
})
// a gap cursor before the hr passes handleTab's empty-selection and parentOffset checks
const $gap = editor.state.doc.resolve(findNodePosition(editor, 'horizontalRule'))
editor.view.dispatch(editor.state.tr.setSelection(new GapCursor($gap)))
const before = editor.getJSON()
expect(dispatchTab(editor)).toBeFalsy()
expect(editor.getJSON()).toEqual(before)
})
it('sinks a paragraph only once when list types are chained', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, BulletList, ListItem, TaskList, TaskItem, ListKeymap],
content: {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'T' }] }],
},
],
},
],
},
],
},
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
],
},
})
editor.commands.setTextSelection(findTextStart(editor, 'B'))
expect(dispatchTab(editor)).toBe(true)
const listItem = editor.getJSON().content?.[0]?.content?.[0]
// "B" becomes the last child of the list item and must not sink again into the taskItem
expect(listItem?.content?.map(node => node.type)).toEqual([
'paragraph',
'taskList',
'paragraph',
])
expect(listItem?.content?.[2]?.content?.[0]?.text).toBe('B')
const taskItem = listItem?.content?.[1]?.content?.[0]
expect(taskItem?.content).toHaveLength(1)
expect(taskItem?.content?.[0]?.content?.[0]?.text).toBe('T')
})
})

View File

@@ -0,0 +1,66 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
import { BulletList, ListItem, OrderedList } from '../../src/index.js'
describe('phone numbers starting a line', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
})
const paragraph = (text: string) => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
})
it('keeps "(216) 555-1234" as a single paragraph', () => {
// The mid-line "216)" must not split the paragraph into "(" + an ordered
// list. A list marker only starts a list at the beginning of a line.
const json = markdownManager.parse('(216) 555-1234')
expect(json).toEqual(paragraph('(216) 555-1234'))
})
it('does not split "Call me at 555) later" mid-line', () => {
const json = markdownManager.parse('Call me at 555) later')
expect(json).toEqual(paragraph('Call me at 555) later'))
})
it('still treats a leading "216) ..." as an ordered list (CommonMark)', () => {
const json = markdownManager.parse('216) 555-1234')
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 216 },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: '555-1234' }] }],
},
],
},
],
})
})
it('still splits a paragraph followed by a real ordered list', () => {
const json = markdownManager.parse('Intro\n1. First\n2. Second')
expect(json.content?.[0].type).toBe('paragraph')
expect(json.content?.[1].type).toBe('orderedList')
})
it('does not interrupt a paragraph with a non-1 ordered list', () => {
const json = markdownManager.parse('Intro\n3. Third\n4. Fourth')
expect(json.content).toHaveLength(1)
expect(json.content?.[0].type).toBe('paragraph')
})
})

View File

@@ -0,0 +1,696 @@
import type { JSONContent } from '@tiptap/core'
import { Editor } from '@tiptap/core'
import Bold from '@tiptap/extension-bold'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { ListItem, OrderedList } from '../../src/index.js'
describe('OrderedList type attribute', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
describe('markdown round-trip', () => {
it('parses markdown with lowercase alpha markers as type "a"', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('a. Item 1\nb. Item 2')
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('orderedList')
expect(json.content[0].attrs?.type).toBe('a')
expect(json.content[0].attrs?.start).toBeUndefined()
expect(json.content[0].content).toHaveLength(2)
})
it('parses markdown with alpha list starting at b', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('b. Item 1\nc. Item 2')
expect(json.content[0].attrs?.type).toBe('a')
expect(json.content[0].attrs?.start).toBe(2)
})
it('parses markdown with numeric list starting at 3', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('3. Item 1\n4. Item 2')
expect(json.content[0].attrs?.type).toBeUndefined()
expect(json.content[0].attrs?.start).toBe(3)
})
it('parses markdown with roman list starting at II', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('II. Item 1\nIII. Item 2')
expect(json.content[0].attrs?.type).toBe('I')
expect(json.content[0].attrs?.start).toBe(2)
})
it('parses markdown with uppercase alpha markers as type "A"', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('A. Item 1\nB. Item 2')
expect(json.content[0].attrs?.type).toBe('A')
})
it('parses markdown with lowercase roman markers as type "i"', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('i. Item 1\nii. Item 2\niii. Item 3')
expect(json.content[0].attrs?.type).toBe('i')
expect(json.content[0].content).toHaveLength(3)
})
it('parses markdown with uppercase roman markers as type "I"', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('I. Item 1\nII. Item 2')
expect(json.content[0].attrs?.type).toBe('I')
})
it('parses default numeric markdown markers with default type', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('1. Item 1\n2. Item 2')
expect(json.content[0].attrs?.type).toBeUndefined()
})
it('keeps the first character of an under-indented continuation line', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
// The continuation line is indented by a single space, fewer columns than the
// marker width. The leading indentation must be stripped without eating the
// first real character of the line.
const json = markdownManager.parse('1. Item one\n continued text')
const collectText = (node: JSONContent): string =>
(node.text ?? '') + (node.content ?? []).map(collectText).join('')
const text = collectText(json.content[0].content[0])
expect(text).toBe('Item one\ncontinued text')
})
it('keeps the first character of an under-indented continuation line for wider markers', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const collectText = (node: JSONContent): string =>
(node.text ?? '') + (node.content ?? []).map(collectText).join('')
const numeric = markdownManager.parse('10. Item ten\n continued text')
expect(collectText(numeric.content[0].content[0])).toBe('Item ten\ncontinued text')
const roman = markdownManager.parse('iv. Item four\n continued text')
expect(collectText(roman.content[0].content[0])).toBe('Item four\ncontinued text')
})
it('serializes an ordered list with type="a" to lowercase alpha markers', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const md = markdownManager.serialize({
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: 'a' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item A' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item B' }] }],
},
],
},
],
})
expect(md).toBe('a. Item A\nb. Item B')
})
it('serializes an ordered list with type="I" to uppercase roman markers', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const md = markdownManager.serialize({
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: 'I' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'One' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Two' }] }],
},
],
},
],
})
expect(md).toBe('I. One\nII. Two')
})
it('serializes a default ordered list with numeric markers', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const md = markdownManager.serialize({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }],
},
],
},
],
})
expect(md).toBe('1. Item 1\n2. Item 2')
})
it('round-trips markdown with type="a" preserving the type', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const original = 'a. Item A\nb. Item B'
// Parse
const json = markdownManager.parse(original)
expect(json.content[0].attrs?.type).toBe('a')
// Serialize back
const md = markdownManager.serialize(json)
expect(md).toBe(original)
})
it('round-trips markdown with type="I" preserving the type', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const original = 'I. One\nII. Two'
// Parse
const json = markdownManager.parse(original)
expect(json.content[0].attrs?.type).toBe('I')
// Serialize back
const md = markdownManager.serialize(json)
expect(md).toBe(original)
})
it('does not parse three-letter alpha markers as a list', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const json = markdownManager.parse('abc. Not a list')
expect(json.content[0].type).not.toBe('orderedList')
})
it('round-trips multi-letter alpha markers beyond 26 items', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
})
const original = 'aa. Item 27\nab. Item 28'
const json = markdownManager.parse(original)
expect(json.content[0].attrs?.type).toBe('a')
expect(json.content[0].attrs?.start).toBe(27)
const md = markdownManager.serialize(json)
expect(md).toBe(original)
})
it('parses inline formatting inside an indented ordered list item', () => {
// A single leading space before the marker (e.g. a top-level ordered
// list nested one level inside another list) previously made the
// custom ordered-list tokenizer bail out silently, falling back to a
// path that left the item's content as literal, unparsed text.
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, ListItem, OrderedList],
})
const noIndent = markdownManager.parse('1. **bold** item')
const indented = markdownManager.parse(' 1. **bold** item')
expect(indented.content).toEqual(noIndent.content)
const textNode = indented.content[0].content[0].content[0].content[0]
expect(textNode.text).toBe('bold')
expect(textNode.marks?.[0]?.type).toBe('bold')
})
})
it('creates an ordered list from typed JSON content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { type: 'a' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item A' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item B' }] }],
},
],
},
],
},
})
const json = editor.getJSON()
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('orderedList')
expect(json.content[0].attrs?.type).toBe('a')
expect(json.content[0].content).toHaveLength(2)
expect(json.content[0].content[0].content[0].content[0].text).toBe('Item A')
})
describe('joinPredicate', () => {
it('does not merge adjacent orderedLists with different type values when toggling list', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: '<ol type="a"><li><p>A</p></li></ol><p></p>',
})
// Position cursor at the empty paragraph after the typed list
editor.commands.setTextSelection(editor.state.doc.content.size)
// Toggle ordered list on the paragraph - should create NEW list, not merge
editor.commands.toggleOrderedList()
const json = editor.getJSON()
// Should have two separate orderedList nodes (types differ)
expect(json.content).toHaveLength(2)
expect(json.content[0].attrs?.type).toBe('a')
expect(json.content[1].attrs?.type).toBe(null)
})
it('does merge adjacent orderedLists when both have default type', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: '<ol><li><p>First</p></li></ol><p></p>',
})
// Position cursor at the empty paragraph
editor.commands.setTextSelection(editor.state.doc.content.size)
// Toggle ordered list - should merge with the existing list
editor.commands.toggleOrderedList()
const json = editor.getJSON()
// Should have one list with both items (merged)
expect(json.content).toHaveLength(1)
expect(json.content[0].content).toHaveLength(2)
})
})
describe('renderHTML', () => {
it('does not render a type attribute when type is null', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: null },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol><li><p>Item</p></li></ol>')
})
it('does not render a type attribute when type is "1" (default)', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: '1' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol><li><p>Item</p></li></ol>')
})
it('renders type="a" on the ol element', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: 'a' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol type="a"><li><p>Item</p></li></ol>')
})
it('renders type="A" on the ol element', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: 'A' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol type="A"><li><p>Item</p></li></ol>')
})
it('renders type="i" on the ol element', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: 'i' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol type="i"><li><p>Item</p></li></ol>')
})
it('renders type="I" on the ol element', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 1, type: 'I' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol type="I"><li><p>Item</p></li></ol>')
})
it('renders both start and type when both are set', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 5, type: 'i' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol start="5" type="i"><li><p>Item</p></li></ol>')
})
it('renders start attribute only (not default 1) when type is null', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 3, type: null },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
},
],
},
],
},
})
const html = editor.getHTML()
expect(html).toBe('<ol start="3"><li><p>Item</p></li></ol>')
})
})
describe('HTML paste round-trip', () => {
const setAndGetJSON = (html: string): JSONContent => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: html,
})
const json = editor.getJSON()
editor.destroy()
return json
}
it('parses type="a" from HTML and preserves it in getJSON', () => {
const json = setAndGetJSON('<ol type="a"><li><p>Item A</p></li></ol>')
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('orderedList')
expect(json.content[0].attrs?.type).toBe('a')
expect(json.content[0].attrs?.start).toBe(1)
})
it('parses type="A" from HTML correctly', () => {
const json = setAndGetJSON('<ol type="A"><li><p>Item</p></li></ol>')
expect(json.content[0].attrs?.type).toBe('A')
})
it('parses type="i" from HTML correctly', () => {
const json = setAndGetJSON('<ol type="i"><li><p>Item</p></li></ol>')
expect(json.content[0].attrs?.type).toBe('i')
})
it('parses type="I" from HTML correctly', () => {
const json = setAndGetJSON('<ol type="I"><li><p>Item</p></li></ol>')
expect(json.content[0].attrs?.type).toBe('I')
})
it('parses type="1" from HTML as null (default) in JSON', () => {
const json = setAndGetJSON('<ol type="1"><li><p>Item</p></li></ol>')
// Note: ProseMirror stores the raw parsed value. With type="1", the
// parseHTML returns "1" — which is stored. But it renders as no attribute
// because the HTML spec considers "1" as default.
expect(json.content[0].attrs?.type).toBe('1')
})
it('parses ol without type attribute as null in JSON', () => {
const json = setAndGetJSON('<ol><li><p>Item</p></li></ol>')
expect(json.content[0].attrs?.type).toBeNull()
})
it('parses both start and type from HTML', () => {
const json = setAndGetJSON('<ol type="I" start="5"><li><p>Item</p></li></ol>')
expect(json.content[0].attrs?.start).toBe(5)
expect(json.content[0].attrs?.type).toBe('I')
})
it('preserves type through full HTML round-trip: setContent -> getHTML', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
content: '<ol type="i"><li><p>Item</p></li></ol>',
})
const html = editor.getHTML()
expect(html).toBe('<ol type="i"><li><p>Item</p></li></ol>')
})
// ── CSS list-style-type parsing (Google Docs / Word pattern) ──
it('parses CSS list-style-type: upper-roman on ol element', () => {
const json = setAndGetJSON(
'<ol style="list-style-type: upper-roman;"><li><p>Item</p></li></ol>',
)
expect(json.content[0].attrs?.type).toBe('I')
})
it('parses CSS list-style-type: lower-roman on ol element', () => {
const json = setAndGetJSON(
'<ol style="list-style-type: lower-roman;"><li><p>Item</p></li></ol>',
)
expect(json.content[0].attrs?.type).toBe('i')
})
it('parses CSS list-style-type: upper-alpha on ol element', () => {
const json = setAndGetJSON(
'<ol style="list-style-type: upper-alpha;"><li><p>Item</p></li></ol>',
)
expect(json.content[0].attrs?.type).toBe('A')
})
it('parses CSS list-style-type: lower-alpha on ol element', () => {
const json = setAndGetJSON(
'<ol style="list-style-type: lower-alpha;"><li><p>Item</p></li></ol>',
)
expect(json.content[0].attrs?.type).toBe('a')
})
it('parses CSS list-style-type from li child (Google Docs pattern)', () => {
const json = setAndGetJSON(
'<ol><li style="list-style-type: upper-roman;"><p>Item</p></li></ol>',
)
expect(json.content[0].attrs?.type).toBe('I')
})
it('parses CSS list-style-type: decimal as default (no type)', () => {
const json = setAndGetJSON('<ol style="list-style-type: decimal;"><li><p>Item</p></li></ol>')
expect(json.content[0].attrs?.type).toBeNull()
})
})
})

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite-plus'
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
const entries = [
'src/bullet-list/index.ts',
'src/item/index.ts',
'src/keymap/index.ts',
'src/kit/index.ts',
'src/ordered-list/index.ts',
'src/task-item/index.ts',
'src/task-list/index.ts',
'src/index.ts',
]
export default defineConfig({
pack: entries.map(entry => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: { sourcemap: true },
sourcemap: true,
target: 'es2019',
format: ['esm', 'cjs'],
deps: { neverBundle: [/^[^./]/] },
outExtensions: tsupCompatibleExtensions,
})),
})