fix: resolve TypeScript errors in frontend build

This commit is contained in:
Hiro
2026-03-30 23:16:07 +00:00
parent b733306773
commit 24925e1acb
2941 changed files with 418042 additions and 49 deletions
+151
View File
@@ -0,0 +1,151 @@
import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
const ListItemName = 'listItem'
const TextStyleName = 'textStyle'
export interface BulletListOptions {
/**
* The node name for the list items
* @default 'listItem'
* @example 'paragraph'
*/
itemTypeName: string
/**
* HTML attributes to add to the bullet list element
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Keep the marks when splitting the list
* @default false
* @example true
*/
keepMarks: boolean
/**
* Keep the attributes when splitting the list
* @default false
* @example true
*/
keepAttributes: boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
bulletList: {
/**
* Toggle a bullet list
*/
toggleBulletList: () => ReturnType
}
}
}
/**
* Matches a bullet list to a dash or asterisk.
*/
export const bulletListInputRegex = /^\s*([-+*])\s$/
/**
* This extension allows you to create bullet lists.
* This requires the ListItem extension
* @see https://tiptap.dev/api/nodes/bullet-list
* @see https://tiptap.dev/api/nodes/list-item.
*/
export const BulletList = Node.create<BulletListOptions>({
name: 'bulletList',
addOptions() {
return {
itemTypeName: 'listItem',
HTMLAttributes: {},
keepMarks: false,
keepAttributes: false,
}
},
group: 'block list',
content() {
return `${this.options.itemTypeName}+`
},
parseHTML() {
return [{ tag: 'ul' }]
},
renderHTML({ HTMLAttributes }) {
return ['ul', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
markdownTokenName: 'list',
parseMarkdown: (token, helpers) => {
if (token.type !== 'list' || (token as any).ordered) {
return []
}
return {
type: 'bulletList',
content: token.items ? helpers.parseChildren(token.items) : [],
}
},
renderMarkdown: (node, h) => {
if (!node.content) {
return ''
}
return h.renderChildren(node.content, '\n')
},
markdownOptions: {
indentsContent: true,
},
addCommands() {
return {
toggleBulletList:
() =>
({ commands, chain }) => {
if (this.options.keepAttributes) {
return chain()
.toggleList(this.name, this.options.itemTypeName, this.options.keepMarks)
.updateAttributes(ListItemName, this.editor.getAttributes(TextStyleName))
.run()
}
return commands.toggleList(this.name, this.options.itemTypeName, this.options.keepMarks)
},
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-8': () => this.editor.commands.toggleBulletList(),
}
},
addInputRules() {
let inputRule = wrappingInputRule({
find: bulletListInputRegex,
type: this.type,
})
if (this.options.keepMarks || this.options.keepAttributes) {
inputRule = wrappingInputRule({
find: bulletListInputRegex,
type: this.type,
keepMarks: this.options.keepMarks,
keepAttributes: this.options.keepAttributes,
getAttributes: () => {
return this.editor.getAttributes(TextStyleName)
},
editor: this.editor,
})
}
return [inputRule]
},
})
+1
View File
@@ -0,0 +1 @@
export * from './bullet-list.js'
+7
View File
@@ -0,0 +1,7 @@
export * from './bullet-list/index.js'
export * from './item/index.js'
export * from './keymap/index.js'
export * from './kit/index.js'
export * from './ordered-list/index.js'
export * from './task-item/index.js'
export * from './task-list/index.js'
+1
View File
@@ -0,0 +1 @@
export * from './list-item.js'
+146
View File
@@ -0,0 +1,146 @@
import { mergeAttributes, Node, renderNestedMarkdownContent } from '@tiptap/core'
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
}
/**
* 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) {
// 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
return `${start + context.index}. `
}
// Fallback to bullet list for unknown parent types
return '- '
},
ctx,
)
},
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
View File
@@ -0,0 +1,2 @@
export * from './list-keymap.js'
export * as listHelpers from './listHelpers/index.js'
+106
View File
@@ -0,0 +1,106 @@
import { Extension } from '@tiptap/core'
import { handleBackspace, handleDelete } 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
},
}
},
})
@@ -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,85 @@
import type { Editor } from '@tiptap/core'
import { isAtStartOfNode, isNodeActive } from '@tiptap/core'
import type { Node } from '@tiptap/pm/model'
import { findListItemPos } from './findListItemPos.js'
import { hasListBefore } from './hasListBefore.js'
import { hasListItemBefore } from './hasListItemBefore.js'
import { listItemHasSubList } from './listItemHasSubList.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: Node; 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
}
const listItemPos = findListItemPos(name, editor.state)
if (!listItemPos) {
return false
}
const $prev = editor.state.doc.resolve(listItemPos.$pos.pos - 2)
const prevNode = $prev.node(listItemPos.depth)
const previousListItemHasSubList = listItemHasSubList(name, editor.state, prevNode)
// if the previous item is a list item and doesn't have a sublist, join the list items
if (hasListItemBefore(name, editor.state) && !previousListItemHasSubList) {
return editor.commands.joinItemBackward()
}
// otherwise in the end, a backspace should
// always just lift the list item if
// joining / merging is not possible
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()
}
@@ -0,0 +1,15 @@
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
}
+10
View File
@@ -0,0 +1,10 @@
export * from './findListItemPos.js'
export * from './getNextListDepth.js'
export * from './handleBackspace.js'
export * from './handleDelete.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 } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
export const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
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
View File
@@ -0,0 +1,81 @@
import { Extension } from '@tiptap/core'
import type { BulletListOptions } from '../bullet-list/index.js'
import { BulletList } from '../bullet-list/index.js'
import type { ListItemOptions } from '../item/index.js'
import { ListItem } from '../item/index.js'
import type { ListKeymapOptions } from '../keymap/index.js'
import { ListKeymap } from '../keymap/index.js'
import type { OrderedListOptions } from '../ordered-list/index.js'
import { OrderedList } from '../ordered-list/index.js'
import type { TaskItemOptions } from '../task-item/index.js'
import { TaskItem } from '../task-item/index.js'
import type { TaskListOptions } from '../task-list/index.js'
import { TaskList } from '../task-list/index.js'
export interface ListKitOptions {
/**
* If set to false, the bulletList extension will not be registered
* @example table: false
*/
bulletList: Partial<BulletListOptions> | false
/**
* If set to false, the listItem extension will not be registered
*/
listItem: Partial<ListItemOptions> | false
/**
* If set to false, the listKeymap extension will not be registered
*/
listKeymap: Partial<ListKeymapOptions> | false
/**
* If set to false, the orderedList extension will not be registered
*/
orderedList: Partial<OrderedListOptions> | false
/**
* If set to false, the taskItem extension will not be registered
*/
taskItem: Partial<TaskItemOptions> | false
/**
* If set to false, the taskList extension will not be registered
*/
taskList: Partial<TaskListOptions> | false
}
/**
* The table kit is a collection of table editor extensions.
*
* Its 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
},
})
+1
View File
@@ -0,0 +1 @@
export * from './ordered-list.js'
+223
View File
@@ -0,0 +1,223 @@
import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
import { buildNestedStructure, collectOrderedListItems, parseListItems } 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$/
/**
* 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 => element.getAttribute('type'),
},
}
},
parseHTML() {
return [
{
tag: 'ol',
},
]
},
renderHTML({ HTMLAttributes }) {
const { start, ...attributesWithoutStart } = HTMLAttributes
return start === 1
? ['ol', mergeAttributes(this.options.HTMLAttributes, attributesWithoutStart), 0]
: ['ol', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
markdownTokenName: 'list',
parseMarkdown: (token, helpers) => {
if (token.type !== 'list' || !token.ordered) {
return []
}
const startValue = token.start || 1
const content = token.items ? parseListItems(token.items, helpers) : []
if (startValue !== 1) {
return {
type: 'orderedList',
attrs: { start: startValue },
content,
}
}
return {
type: 'orderedList',
content,
}
},
renderMarkdown: (node, h) => {
if (!node.content) {
return ''
}
return h.renderChildren(node.content, '\n')
},
markdownTokenizer: {
name: 'orderedList',
level: 'block',
start: (src: string) => {
const match = src.match(/^(\s*)(\d+)\.\s+/)
const index = match?.index
return index !== undefined ? index : -1
},
tokenize: (src: string, _tokens, lexer) => {
const lines = src.split('\n')
const [listItems, consumed] = collectOrderedListItems(lines)
if (listItems.length === 0) {
return undefined
}
const items = buildNestedStructure(listItems, 0, lexer)
if (items.length === 0) {
return undefined
}
const startValue = listItems[0]?.number || 1
return {
type: 'list',
ordered: true,
start: startValue,
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(),
}
},
addInputRules() {
let inputRule = wrappingInputRule({
find: orderedListInputRegex,
type: this.type,
getAttributes: match => ({ start: +match[1] }),
joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1],
})
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: (match, node) => node.childCount + node.attrs.start === +match[1],
editor: this.editor,
})
}
return [inputRule]
},
})
+234
View File
@@ -0,0 +1,234 @@
import type { JSONContent, MarkdownLexerConfiguration, MarkdownParseHelpers, MarkdownToken } from '@tiptap/core'
/**
* Matches an ordered list item line with optional leading whitespace.
* Captures: (1) indentation spaces, (2) item number, (3) content after marker
* Example matches: "1. Item", " 2. Nested item", " 3. Deeply nested"
*/
const ORDERED_LIST_ITEM_REGEX = /^(\s*)(\d+)\.\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/
/**
* Represents a parsed ordered list item with indentation information
*/
export interface OrderedListItem {
indent: number
number: number
content: string
raw: string
}
/**
* 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, number, content] = match
const indentLevel = indent.length
let itemContent = content
let nextLineIndex = currentLineIndex + 1
const itemLines = [line]
// 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)
itemContent += '\n'
nextLineIndex += 1
} else if (nextLine.match(INDENTED_LINE_REGEX)) {
// Indented content - part of this item (but not a list item)
itemLines.push(nextLine)
itemContent += `\n${nextLine.slice(indentLevel + 2)}` // Remove list marker indent
nextLineIndex += 1
} else {
// Non-indented line means end of list
break
}
}
listItems.push({
indent: indentLevel,
number: parseInt(number, 10),
content: itemContent.trim(),
raw: itemLines.join('\n'),
})
consumed = nextLineIndex
currentLineIndex = nextLineIndex
}
return [listItems, consumed]
}
/**
* 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 contentLines = item.content.split('\n')
const mainText = contentLines[0]?.trim() || ''
const tokens = []
// Always wrap the main text in a paragraph token
if (mainText) {
tokens.push({
type: 'paragraph',
raw: mainText,
tokens: lexer.inlineTokens(mainText),
})
}
// Handle additional content after the main text
const additionalContent = contentLines.slice(1).join('\n').trim()
if (additionalContent) {
// Parse as block tokens (handles mixed unordered lists, etc.)
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,
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
View File
@@ -0,0 +1 @@
export * from './task-item.js'
+320
View File
@@ -0,0 +1,320 @@
import type { KeyboardShortcutCommand } from '@tiptap/core'
import {
getRenderedAttributes,
mergeAttributes,
Node,
renderNestedMarkdownContent,
wrappingInputRule,
} from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
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: ProseMirrorNode, 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?: {
checkboxLabel?: (node: ProseMirrorNode, checked: boolean) => string
}
}
/**
* Matches a task item to a - [ ] on input.
*/
export const inputRegex = /^\s*(\[([( |x])?\])\s$/
/**
* 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,
},
]
},
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)
},
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')
const updateA11Y = (currentNode: ProseMirrorNode) => {
checkbox.ariaLabel =
this.options.a11y?.checkboxLabel?.(currentNode, checkbox.checked) ||
`Task item checkbox for ${currentNode.textContent || 'empty task item'}`
}
updateA11Y(node)
checkboxWrapper.contentEditable = 'false'
checkbox.type = 'checkbox'
checkbox.addEventListener('mousedown', event => event.preventDefault())
checkbox.addEventListener('change', event => {
// if the editor isnt 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
View File
@@ -0,0 +1 @@
export * from './task-list.js'
+183
View File
@@ -0,0 +1,183 @@
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) {
// Return as task list token
return [
{
type: 'taskList',
raw: nestedResult.raw,
items: nestedResult.items,
},
]
}
// 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(),
}
},
})