snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
2
packages/extension-list/src/keymap/index.ts
Normal file
2
packages/extension-list/src/keymap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './list-keymap.js'
|
||||
export * as listHelpers from './listHelpers/index.js'
|
||||
120
packages/extension-list/src/keymap/list-keymap.ts
Normal file
120
packages/extension-list/src/keymap/list-keymap.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
import { handleBackspace, handleDelete, handleTab } from './listHelpers/index.js'
|
||||
|
||||
export type ListKeymapOptions = {
|
||||
/**
|
||||
* An array of list types. This is used for item and wrapper list matching.
|
||||
* @default []
|
||||
* @example [{ itemName: 'listItem', wrapperNames: ['bulletList', 'orderedList'] }]
|
||||
*/
|
||||
listTypes: Array<{
|
||||
itemName: string
|
||||
wrapperNames: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension registers custom keymaps to change the behaviour of the backspace and delete keys.
|
||||
* By default Prosemirror keyhandling will always lift or sink items so paragraphs are joined into
|
||||
* the adjacent or previous list item. This extension will prevent this behaviour and instead will
|
||||
* try to join paragraphs from two list items into a single list item.
|
||||
* @see https://www.tiptap.dev/api/extensions/list-keymap
|
||||
*/
|
||||
export const ListKeymap = Extension.create<ListKeymapOptions>({
|
||||
name: 'listKeymap',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
listTypes: [
|
||||
{
|
||||
itemName: 'listItem',
|
||||
wrapperNames: ['bulletList', 'orderedList'],
|
||||
},
|
||||
{
|
||||
itemName: 'taskItem',
|
||||
wrapperNames: ['taskList'],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Delete: ({ editor }) => {
|
||||
let handled = false
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true
|
||||
}
|
||||
})
|
||||
|
||||
return handled
|
||||
},
|
||||
'Mod-Delete': ({ editor }) => {
|
||||
let handled = false
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true
|
||||
}
|
||||
})
|
||||
|
||||
return handled
|
||||
},
|
||||
Backspace: ({ editor }) => {
|
||||
let handled = false
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleBackspace(editor, itemName, wrapperNames)) {
|
||||
handled = true
|
||||
}
|
||||
})
|
||||
|
||||
return handled
|
||||
},
|
||||
'Mod-Backspace': ({ editor }) => {
|
||||
let handled = false
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleBackspace(editor, itemName, wrapperNames)) {
|
||||
handled = true
|
||||
}
|
||||
})
|
||||
|
||||
return handled
|
||||
},
|
||||
Tab: ({ editor }) => {
|
||||
// stop at the first handled list type so a single Tab cannot sink the block twice
|
||||
for (const { itemName, wrapperNames } of this.options.listTypes) {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (handleTab(editor, itemName, wrapperNames)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getNodeType } from '@tiptap/core'
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection
|
||||
const nodeType = getNodeType(typeOrName, state.schema)
|
||||
|
||||
let currentNode = null
|
||||
let currentDepth = $from.depth
|
||||
let currentPos = $from.pos
|
||||
let targetDepth: number | null = null
|
||||
|
||||
while (currentDepth > 0 && targetDepth === null) {
|
||||
currentNode = $from.node(currentDepth)
|
||||
|
||||
if (currentNode.type === nodeType) {
|
||||
targetDepth = currentDepth
|
||||
} else {
|
||||
currentDepth -= 1
|
||||
currentPos -= 1
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDepth === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { $pos: state.doc.resolve(currentPos), depth: targetDepth }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getNodeAtPosition } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
import { findListItemPos } from './findListItemPos.js'
|
||||
|
||||
export const getNextListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state)
|
||||
|
||||
if (!listItemPos) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4)
|
||||
|
||||
return depth
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { isAtStartOfNode, isNodeActive } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
import { hasListBefore } from './hasListBefore.js'
|
||||
|
||||
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
// this is required to still handle the undo handling
|
||||
if (editor.commands.undoInputRule()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// if the selection is not collapsed
|
||||
// we can rely on the default backspace behavior
|
||||
if (editor.state.selection.from !== editor.state.selection.to) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
|
||||
const { $anchor } = editor.state.selection
|
||||
|
||||
const $listPos = editor.state.doc.resolve($anchor.before() - 1)
|
||||
|
||||
const listDescendants: Array<{ node: PMNode; pos: number }> = []
|
||||
|
||||
$listPos.node().descendants((node, pos) => {
|
||||
if (node.type.name === name) {
|
||||
listDescendants.push({ node, pos })
|
||||
}
|
||||
})
|
||||
|
||||
const lastItem = listDescendants.at(-1)
|
||||
|
||||
if (!lastItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1)
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end())
|
||||
.joinForward()
|
||||
.run()
|
||||
}
|
||||
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the cursor is not at the start of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// only intercept at the start of the list item's first child
|
||||
const { $from } = editor.state.selection
|
||||
const itemDepth = $from.depth - 1
|
||||
if ($from.node(itemDepth).type !== editor.schema.nodes[name] || $from.index(itemDepth) !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.chain().liftListItem(name).run()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { isAtEndOfNode, isNodeActive } from '@tiptap/core'
|
||||
|
||||
import { nextListIsDeeper } from './nextListIsDeeper.js'
|
||||
import { nextListIsHigher } from './nextListIsHigher.js'
|
||||
|
||||
export const handleDelete = (editor: Editor, name: string) => {
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the cursor is not at the end of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtEndOfNode(editor.state, name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the selection is not collapsed, or not within a single node
|
||||
// do nothing and proceed
|
||||
const { selection } = editor.state
|
||||
const { $from, $to } = selection
|
||||
|
||||
if (!selection.empty && $from.sameParent($to)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// check if the next node is a list with a deeper depth
|
||||
if (nextListIsDeeper(name, editor.state)) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus(editor.state.selection.from + 4)
|
||||
.lift(name)
|
||||
.joinBackward()
|
||||
.run()
|
||||
}
|
||||
|
||||
if (nextListIsHigher(name, editor.state)) {
|
||||
return editor.chain().joinForward().joinBackward().run()
|
||||
}
|
||||
|
||||
return editor.commands.joinItemForward()
|
||||
}
|
||||
48
packages/extension-list/src/keymap/listHelpers/handleTab.ts
Normal file
48
packages/extension-list/src/keymap/listHelpers/handleTab.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { getPreviousBlockSibling, isNodeActive } from '@tiptap/core'
|
||||
import { Fragment } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
export const handleTab = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
const { state } = editor
|
||||
const { selection } = state
|
||||
if (!selection.empty) return false
|
||||
|
||||
const { $from } = selection
|
||||
if ($from.parentOffset !== 0) return false
|
||||
|
||||
// gap cursors satisfy both checks above but resolve outside a textblock
|
||||
if (!$from.parent.isTextblock) return false
|
||||
|
||||
// Tab inside a list item belongs to sinkListItem
|
||||
if (isNodeActive(state, name)) return false
|
||||
|
||||
const previous = getPreviousBlockSibling($from)
|
||||
if (!previous || !parentListTypes.includes(previous.type.name)) return false
|
||||
|
||||
const lastItem = previous.lastChild
|
||||
if (!lastItem || lastItem.type.name !== name) return false
|
||||
|
||||
const block = $from.parent
|
||||
|
||||
if (!lastItem.canReplace(lastItem.childCount, lastItem.childCount, Fragment.from(block))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const blockStart = $from.before()
|
||||
const blockEnd = $from.after()
|
||||
|
||||
// step back over the closing tokens of the list and its last item
|
||||
const insideLastItemEnd = blockStart - 2
|
||||
|
||||
return editor.commands.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
tr.delete(blockStart, blockEnd).insert(insideLastItemEnd, Fragment.from(block))
|
||||
// place the cursor at the start of the inserted block
|
||||
tr.setSelection(TextSelection.create(tr.doc, insideLastItemEnd + 1))
|
||||
tr.scrollIntoView()
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
export const hasListBefore = (
|
||||
editorState: EditorState,
|
||||
name: string,
|
||||
parentListTypes: string[],
|
||||
) => {
|
||||
const { $anchor } = editorState.selection
|
||||
|
||||
const previousNodePos = Math.max(0, $anchor.pos - 2)
|
||||
|
||||
const previousNode = editorState.doc.resolve(previousNodePos).node()
|
||||
|
||||
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2)
|
||||
|
||||
if ($targetPos.index() === $targetPos.parent.childCount - 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ($targetPos.nodeAfter?.type.name !== typeOrName) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - 2)
|
||||
|
||||
if ($targetPos.index() === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
11
packages/extension-list/src/keymap/listHelpers/index.ts
Normal file
11
packages/extension-list/src/keymap/listHelpers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from './findListItemPos.js'
|
||||
export * from './getNextListDepth.js'
|
||||
export * from './handleBackspace.js'
|
||||
export * from './handleDelete.js'
|
||||
export * from './handleTab.js'
|
||||
export * from './hasListBefore.js'
|
||||
export * from './hasListItemAfter.js'
|
||||
export * from './hasListItemBefore.js'
|
||||
export * from './listItemHasSubList.js'
|
||||
export * from './nextListIsDeeper.js'
|
||||
export * from './nextListIsHigher.js'
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getNodeType } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
export const listItemHasSubList = (typeOrName: string, state: EditorState, node?: PMNode) => {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeType = getNodeType(typeOrName, state.schema)
|
||||
|
||||
let hasSubList = false
|
||||
|
||||
node.descendants(child => {
|
||||
if (child.type === nodeType) {
|
||||
hasSubList = true
|
||||
}
|
||||
})
|
||||
|
||||
return hasSubList
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
import { findListItemPos } from './findListItemPos.js'
|
||||
import { getNextListDepth } from './getNextListDepth.js'
|
||||
|
||||
export const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state)
|
||||
const listItemPos = findListItemPos(typeOrName, state)
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (listDepth > listItemPos.depth) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
|
||||
import { findListItemPos } from './findListItemPos.js'
|
||||
import { getNextListDepth } from './getNextListDepth.js'
|
||||
|
||||
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state)
|
||||
const listItemPos = findListItemPos(typeOrName, state)
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user