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
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:
1143
packages/extension-list/CHANGELOG.md
Normal file
1143
packages/extension-list/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-list/README.md
Normal file
18
packages/extension-list/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-list
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-list)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-list)
|
||||
[](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).
|
||||
108
packages/extension-list/package.json
Normal file
108
packages/extension-list/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
151
packages/extension-list/src/bullet-list/bullet-list.ts
Normal file
151
packages/extension-list/src/bullet-list/bullet-list.ts
Normal 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]
|
||||
},
|
||||
})
|
||||
1
packages/extension-list/src/bullet-list/index.ts
Normal file
1
packages/extension-list/src/bullet-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './bullet-list.js'
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
7
packages/extension-list/src/index.ts
Normal file
7
packages/extension-list/src/index.ts
Normal 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'
|
||||
1
packages/extension-list/src/item/index.ts
Normal file
1
packages/extension-list/src/item/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './list-item.js'
|
||||
46
packages/extension-list/src/item/list-item.test.ts
Normal file
46
packages/extension-list/src/item/list-item.test.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
206
packages/extension-list/src/item/list-item.ts
Normal file
206
packages/extension-list/src/item/list-item.ts
Normal 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),
|
||||
}
|
||||
},
|
||||
})
|
||||
2
packages/extension-list/src/keymap/index.ts
Normal file
2
packages/extension-list/src/keymap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './list-keymap.js'
|
||||
export * as listHelpers from './listHelpers/index.js'
|
||||
120
packages/extension-list/src/keymap/list-keymap.ts
Normal file
120
packages/extension-list/src/keymap/list-keymap.ts
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
48
packages/extension-list/src/keymap/listHelpers/handleTab.ts
Normal file
48
packages/extension-list/src/keymap/listHelpers/handleTab.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
11
packages/extension-list/src/keymap/listHelpers/index.ts
Normal file
11
packages/extension-list/src/keymap/listHelpers/index.ts
Normal 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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
81
packages/extension-list/src/kit/index.ts
Normal file
81
packages/extension-list/src/kit/index.ts
Normal 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
|
||||
},
|
||||
})
|
||||
13
packages/extension-list/src/ordered-list/index.ts
Normal file
13
packages/extension-list/src/ordered-list/index.ts
Normal 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'
|
||||
365
packages/extension-list/src/ordered-list/ordered-list.ts
Normal file
365
packages/extension-list/src/ordered-list/ordered-list.ts
Normal 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]
|
||||
},
|
||||
})
|
||||
122
packages/extension-list/src/ordered-list/roman.test.ts
Normal file
122
packages/extension-list/src/ordered-list/roman.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
295
packages/extension-list/src/ordered-list/roman.ts
Normal file
295
packages/extension-list/src/ordered-list/roman.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
105
packages/extension-list/src/ordered-list/utils.test.ts
Normal file
105
packages/extension-list/src/ordered-list/utils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
399
packages/extension-list/src/ordered-list/utils.ts
Normal file
399
packages/extension-list/src/ordered-list/utils.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
1
packages/extension-list/src/task-item/index.ts
Normal file
1
packages/extension-list/src/task-item/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './task-item.js'
|
||||
904
packages/extension-list/src/task-item/task-item.test.ts
Normal file
904
packages/extension-list/src/task-item/task-item.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
359
packages/extension-list/src/task-item/task-item.ts
Normal file
359
packages/extension-list/src/task-item/task-item.ts
Normal 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',
|
||||
}),
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
1
packages/extension-list/src/task-list/index.ts
Normal file
1
packages/extension-list/src/task-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './task-list.js'
|
||||
194
packages/extension-list/src/task-list/task-list.ts
Normal file
194
packages/extension-list/src/task-list/task-list.ts
Normal 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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
230
packages/extension-list/tests/integration/listItemDelete.test.ts
Normal file
230
packages/extension-list/tests/integration/listItemDelete.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
155
packages/extension-list/tests/integration/listKeymapTab.test.ts
Normal file
155
packages/extension-list/tests/integration/listKeymapTab.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
27
packages/extension-list/vite.config.ts
Normal file
27
packages/extension-list/vite.config.ts
Normal 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,
|
||||
})),
|
||||
})
|
||||
Reference in New Issue
Block a user