fix: resolve TypeScript errors in frontend build
This commit is contained in:
+151
@@ -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
@@ -0,0 +1 @@
|
||||
export * from './bullet-list.js'
|
||||
+7
@@ -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
@@ -0,0 +1 @@
|
||||
export * from './list-item.js'
|
||||
+146
@@ -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
@@ -0,0 +1,2 @@
|
||||
export * from './list-keymap.js'
|
||||
export * as listHelpers from './listHelpers/index.js'
|
||||
+106
@@ -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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
+30
@@ -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 }
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+85
@@ -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()
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+17
@@ -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
@@ -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'
|
||||
+21
@@ -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
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
+19
@@ -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
@@ -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
|
||||
},
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from './ordered-list.js'
|
||||
+223
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from './task-item.js'
|
||||
+320
@@ -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 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
@@ -0,0 +1 @@
|
||||
export * from './task-list.js'
|
||||
+183
@@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user