Fix List issues & add support for Mod keys (#4210)

* add support for mod-delete and mod-backspace

* fix backspace not working right behind a list

* move list helpers to core, add support for task lists

* add option to check for node in isAtEndOfNode

---------

Co-authored-by: bdbch <dominik@bdbch.com>
This commit is contained in:
bdbch 2023-08-03 10:19:32 +02:00 committed by GitHub
parent b581fa6523
commit d1e879dfab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 287 additions and 198 deletions

View File

@ -1,3 +1,5 @@
import { TextSelection } from '@tiptap/pm/state'
import { RawCommands } from '../types.js'
declare module '@tiptap/core' {
@ -11,17 +13,17 @@ declare module '@tiptap/core' {
}
}
export const cut: RawCommands['cut'] = (originRange, targetPos) => ({ editor }) => {
export const cut: RawCommands['cut'] = (originRange, targetPos) => ({ editor, tr }) => {
const { state } = editor
const contentSlice = state.doc.slice(originRange.from, originRange.to)
return editor
.chain()
.deleteRange(originRange)
.command(({ commands, tr }) => {
return commands.insertContentAt(tr.mapping.map(targetPos), contentSlice.content.toJSON())
})
.focus()
.run()
tr.deleteRange(originRange.from, originRange.to)
const newPos = tr.mapping.map(targetPos)
tr.insert(newPos, contentSlice.content)
tr.setSelection(new TextSelection(tr.doc.resolve(newPos - 1)))
return true
}

View File

@ -17,6 +17,8 @@ export * from './forEach.js'
export * from './insertContent.js'
export * from './insertContentAt.js'
export * from './join.js'
export * from './joinItemBackward.js'
export * from './joinItemForward.js'
export * from './keyboardShortcut.js'
export * from './lift.js'
export * from './liftEmptyBlock.js'

View File

@ -1,7 +1,19 @@
import { RawCommands } from '@tiptap/core'
import { joinPoint } from '@tiptap/pm/transform'
export const joinListItemBackward: RawCommands['splitListItem'] = () => ({
import { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinItemBackward: {
/**
* Join two nodes Forwards.
*/
joinItemBackward: () => ReturnType
}
}
}
export const joinItemBackward: RawCommands['joinItemBackward'] = () => ({
tr, state, dispatch,
}) => {
try {

View File

@ -1,7 +1,19 @@
import { RawCommands } from '@tiptap/core'
import { joinPoint } from '@tiptap/pm/transform'
export const joinListItemForward: RawCommands['splitListItem'] = () => ({
import { RawCommands } from '../types.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
joinItemForward: {
/**
* Join two nodes Forwards.
*/
joinItemForward: () => ReturnType
}
}
}
export const joinItemForward: RawCommands['joinItemForward'] = () => ({
state,
dispatch,
tr,

View File

@ -12,6 +12,7 @@ export const Keymap = Extension.create({
addKeyboardShortcuts() {
const handleBackspace = () => this.editor.commands.first(({ commands }) => [
() => commands.undoInputRule(),
// maybe convert first text block node to default node
() => commands.command(({ tr }) => {
const { selection, doc } = tr
@ -32,6 +33,7 @@ export const Keymap = Extension.create({
return commands.clearNodes()
}),
() => commands.deleteSelection(),
() => commands.joinBackward(),
() => commands.selectNodeBackward(),

View File

@ -44,6 +44,7 @@ export * from './isNodeActive.js'
export * from './isNodeEmpty.js'
export * from './isNodeSelection.js'
export * from './isTextSelection.js'
export * from './listHelpers/index.js'
export * from './posToDOMRect.js'
export * from './resolveFocusPosition.js'
export * from './selectionToInsertionEnd.js'

View File

@ -1,7 +1,25 @@
import { EditorState } from '@tiptap/pm/state'
export const istAtEndOfNode = (state: EditorState) => {
const { $from, $to } = state.selection
import { findParentNode } from './findParentNode.js'
export const isAtEndOfNode = (state: EditorState, nodeType?: string) => {
const { $from, $to, $anchor } = state.selection
if (nodeType) {
const parentNode = findParentNode(node => node.type.name === nodeType)(state.selection)
if (!parentNode) {
return false
}
const $parentPos = state.doc.resolve(parentNode.pos + 1)
if ($anchor.pos + 1 === $parentPos.end()) {
return true
}
return false
}
if ($to.parentOffset < $to.parent.nodeSize - 2 || $from.pos !== $to.pos) {
return false

View File

@ -1,8 +1,9 @@
import { getNodeType } from '@tiptap/core'
import { NodeType } from '@tiptap/pm/model'
import { EditorState } from '@tiptap/pm/state'
import { ResolvedPos } from 'prosemirror-model'
export const getCurrentListItemPos = (typeOrName: string, state: EditorState): ResolvedPos | undefined => {
import { getNodeType } from '../getNodeType.js'
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection
const nodeType = getNodeType(typeOrName, state.schema)
@ -23,8 +24,8 @@ export const getCurrentListItemPos = (typeOrName: string, state: EditorState): R
}
if (targetDepth === null) {
return
return null
}
return state.doc.resolve(currentPos)
return { $pos: state.doc.resolve(currentPos), depth: targetDepth }
}

View File

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

View File

@ -0,0 +1,76 @@
import { Node } from '@tiptap/pm/model'
import { Editor } from '../../Editor.js'
import { isAtStartOfNode } from '../isAtStartOfNode.js'
import { isNodeActive } from '../isNodeActive.js'
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 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()
}

View File

@ -0,0 +1,38 @@
import { Editor } from '../../Editor.js'
import { isAtEndOfNode } from '../isAtEndOfNode.js'
import { isNodeActive } from '../isNodeActive.js'
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
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run()
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain()
.joinForward()
.joinBackward()
.run()
}
return editor.commands.joinItemForward()
}

View File

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

View File

@ -0,0 +1,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'

View File

@ -1,7 +1,8 @@
import { getNodeType } from '@tiptap/core'
import { Node } from '@tiptap/pm/model'
import { EditorState } from '@tiptap/pm/state'
import { getNodeType } from '../getNodeType.js'
export const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
if (!node) {
return false

View File

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

View File

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

View File

@ -1,76 +0,0 @@
import { getNodeAtPosition, getNodeType } from '@tiptap/core'
import { NodeType } from '@tiptap/pm/model'
import { EditorState } from '@tiptap/pm/state'
export * from './hasListItemAfter.js'
export * from './hasListItemBefore.js'
export * from './listItemhasSublist.js'
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 }
}
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
}
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
}
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state)
const listItemPos = findListItemPos(typeOrName, state)
if (!listItemPos || !listDepth) {
return false
}
if (listDepth < listItemPos.depth) {
return true
}
return false
}

View File

@ -1,28 +1,12 @@
import {
isAtStartOfNode, isNodeActive, istAtEndOfNode, mergeAttributes, Node,
handleBackspace, handleDelete,
mergeAttributes, Node,
} from '@tiptap/core'
import { NodeType } from '@tiptap/pm/model'
import { joinListItemBackward } from './commands/joinListItemBackward.js'
import { joinListItemForward } from './commands/joinListItemForward.js'
import {
findListItemPos, hasListItemBefore, listItemHasSubList, nextListIsDeeper, nextListIsHigher,
} from './helpers/index.js'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
listItem: {
/**
* Lift the list item into a wrapping list.
*/
joinListItemForward: (typeOrName: string | NodeType) => ReturnType
joinListItemBackward: (typeOrName: string | NodeType) => ReturnType
}
}
}
export interface ListItemOptions {
HTMLAttributes: Record<string, any>,
bulletListTypeName: string
orderedListTypeName: string
}
export const ListItem = Node.create<ListItemOptions>({
@ -31,6 +15,8 @@ export const ListItem = Node.create<ListItemOptions>({
addOptions() {
return {
HTMLAttributes: {},
bulletListTypeName: 'bulletList',
orderedListTypeName: 'orderedList',
}
},
@ -50,90 +36,15 @@ export const ListItem = Node.create<ListItemOptions>({
return ['li', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addCommands() {
return {
joinListItemForward,
joinListItemBackward,
}
},
addKeyboardShortcuts() {
return {
Enter: () => this.editor.commands.splitListItem(this.name),
Tab: () => this.editor.commands.sinkListItem(this.name),
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
Delete: ({ editor }) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, this.name)) {
return false
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!istAtEndOfNode(editor.state)) {
return false
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(this.name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(this.name)
.joinBackward()
.run()
}
if (nextListIsHigher(this.name, editor.state)) {
return editor.chain()
.joinForward()
.joinBackward()
.run()
}
// check if the next node is also a listItem
return editor.commands.joinListItemForward(this.name)
},
Backspace: ({ editor }) => {
// this is required to still handle the undo handling
if (this.editor.commands.undoInputRule()) {
return true
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, this.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(this.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(this.name, editor.state, prevNode)
// if the previous item is a list item and doesn't have a sublist, join the list items
if (hasListItemBefore(this.name, editor.state) && !previousListItemHasSubList) {
return editor.commands.joinListItemBackward(this.name)
}
// otherwise in the end, a backspace should
// always just lift the list item if
// joining / merging is not possible
return editor.chain().liftListItem(this.name).run()
},
Delete: ({ editor }) => handleDelete(editor, this.name),
'Mod-Delete': ({ editor }) => handleDelete(editor, this.name),
Backspace: ({ editor }) => handleBackspace(editor, this.name, [this.options.bulletListTypeName, this.options.orderedListTypeName]),
'Mod-Backspace': ({ editor }) => handleBackspace(editor, this.name, [this.options.bulletListTypeName, this.options.orderedListTypeName]),
}
},
})

View File

@ -1,10 +1,13 @@
import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
import {
handleBackspace, handleDelete, KeyboardShortcutCommand, mergeAttributes, Node, wrappingInputRule,
} from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
export interface TaskItemOptions {
onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean
nested: boolean
HTMLAttributes: Record<string, any>
taskListTypeName: string
}
export const inputRegex = /^\s*(\[([( |x])?\])\s$/
@ -16,6 +19,7 @@ export const TaskItem = Node.create<TaskItemOptions>({
return {
nested: false,
HTMLAttributes: {},
taskListTypeName: 'taskList',
}
},
@ -69,9 +73,15 @@ export const TaskItem = Node.create<TaskItemOptions>({
},
addKeyboardShortcuts() {
const shortcuts = {
const shortcuts: {
[key: string]: KeyboardShortcutCommand
} = {
Enter: () => this.editor.commands.splitListItem(this.name),
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
Delete: ({ editor }) => handleDelete(editor, this.name),
'Mod-Delete': ({ editor }) => handleDelete(editor, this.name),
Backspace: ({ editor }) => handleBackspace(editor, this.name, [this.options.taskListTypeName]),
'Mod-Backspace': ({ editor }) => handleBackspace(editor, this.name, [this.options.taskListTypeName]),
}
if (!this.options.nested) {