Last active
June 10, 2023 19:42
-
-
Save Nantris/1a7ca430c5d50c7e656d96d4570b8d4f to your computer and use it in GitHub Desktop.
Generic, extensionized version of bdbch's list fixes for TipTap v2 with additional fixes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { getNodeType, RawCommands } from '@tiptap/core'; | |
import { Node, NodeType } from '@tiptap/pm/model'; | |
import { EditorState } from '@tiptap/pm/state'; | |
/** | |
* Finds the first node of a given type or name in the current selection. | |
* @param state The editor state. | |
* @param typeOrName The node type or name. | |
* @param pos The position to start searching from. | |
* @param maxDepth The maximum depth to search. | |
* @returns The node and the depth as an array. | |
*/ | |
export const getNodeAtPosition = ( | |
state: EditorState, | |
typeOrName: string | NodeType, | |
pos: number, | |
maxDepth = 20 | |
) => { | |
const $pos = state.doc.resolve(pos); | |
let currentDepth = maxDepth; | |
let node: Node | null = null; | |
while (currentDepth > 0 && node === null) { | |
const currentNode = $pos.node(currentDepth); | |
if (currentNode?.type.name === typeOrName) { | |
node = currentNode; | |
} else { | |
currentDepth -= 1; | |
} | |
} | |
return [node, currentDepth] as [Node | null, number]; | |
}; | |
export const isAtStartOfNode = (state: EditorState) => { | |
const { $from, $to } = state.selection; | |
if ($from.parentOffset > 0 || $from.pos !== $to.pos) { | |
return false; | |
} | |
return true; | |
}; | |
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 hasPreviousListItem = (typeOrName: string, state: EditorState) => { | |
const listItemPos = findListItemPos(typeOrName, state); | |
if (!listItemPos) { | |
return false; | |
} | |
const $item = state.doc.resolve(listItemPos.$pos.pos); | |
const $prev = state.doc.resolve(listItemPos.$pos.pos - 2); | |
const prevNode = $prev.node($item.depth); | |
if (!prevNode) { | |
return false; | |
} | |
return prevNode.type.name === typeOrName; | |
}; | |
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; | |
}; | |
export const isAtEndOfNode = (state: EditorState) => { | |
const { $from, $to } = state.selection; | |
if ($to.parentOffset < $to.parent.nodeSize - 2 || $from.pos !== $to.pos) { | |
return false; | |
} | |
return true; | |
}; | |
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; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Extension, isNodeActive, Range } from '@tiptap/core'; | |
import { joinPoint } from '@tiptap/pm/transform'; | |
import { | |
findListItemPos, | |
hasPreviousListItem, | |
isAtEndOfNode, | |
isAtStartOfNode, | |
listItemHasSubList, | |
nextListIsDeeper, | |
nextListIsHigher, | |
} from './helpers'; | |
const getCurrentItemTypeName = editor => { | |
const { state } = editor; | |
const possibleListNode = state.selection.$from.node(-1); | |
if ( | |
possibleListNode && | |
(possibleListNode.type.name === 'listItem' || | |
possibleListNode.type.name === 'taskItem') | |
) { | |
return possibleListNode.type.name; | |
} | |
return null; | |
}; | |
function atStartOfSecondOrLaterParagraph(state) { | |
// get the current selection | |
const { $from } = state.selection; | |
// find the parent node and its position | |
const parentPos = $from.before($from.depth - 1); | |
const parentNode = $from.node($from.depth - 1); | |
// If the parent is not a listItem or the selected node is not a paragraph, | |
// we're not at the start of a second or later paragraph in a list item | |
if ( | |
parentNode.type.name !== 'listItem' || | |
$from.parent.type.name !== 'paragraph' | |
) { | |
return false; | |
} | |
// Calculate the position at the start of the current node | |
const startPosOfNode = $from.start($from.depth); | |
// If the cursor is not at the start of the paragraph, return false | |
if (startPosOfNode !== $from.pos) { | |
return false; | |
} | |
// Get the first child of the parent listItem | |
const { firstChild } = parentNode; | |
// If the first child of the parent listItem is the current paragraph, | |
// we're not at the start of a second or later paragraph in a list item | |
if (firstChild === $from.parent) { | |
return false; | |
} | |
return true; | |
} | |
export const ListFixesExtension = Extension.create({ | |
name: 'listFixesExtension', | |
addCommands() { | |
return { | |
joinListItemBackward: | |
() => | |
({ tr, state, dispatch }) => { | |
try { | |
const point = joinPoint(state.doc, state.selection.$from.pos, -1); | |
if (point === null || point === undefined) { | |
return false; | |
} | |
tr.join(point, 2); | |
if (dispatch) { | |
dispatch(tr); | |
} | |
return true; | |
} catch { | |
return false; | |
} | |
}, | |
joinListItemForward: | |
() => | |
({ tr, state, dispatch }) => { | |
try { | |
const point = joinPoint(state.doc, state.selection.$from.pos, +1); | |
if (point === null || point === undefined) { | |
return false; | |
} | |
tr.join(point, 2); | |
if (dispatch) { | |
dispatch(tr); | |
} | |
return true; | |
} catch { | |
return false; | |
} | |
}, | |
backspaceAwayListItem: | |
typeName => | |
({ editor, state, commands, chain }) => { | |
// this is required to still handle the undo handling | |
if (commands.undoInputRule()) { | |
return true; | |
} | |
// if the cursor is not inside the current node type | |
// do nothing and proceed | |
if (!isNodeActive(state, typeName)) { | |
return false; | |
} | |
// if the cursor is not at the start of a node | |
// do nothing and proceed | |
if (!isAtStartOfNode(state)) { | |
return false; | |
} | |
const listItemPos = findListItemPos(typeName, state); | |
if (!listItemPos) { | |
return false; | |
} | |
// avoid unindenting at start of paragraph if paragraph is not the first in the listItem | |
if (atStartOfSecondOrLaterParagraph(state)) { | |
return commands.joinBackward(); | |
} | |
const $prev = state.doc.resolve(listItemPos.$pos.pos - 2); | |
const prevNode = $prev.node(listItemPos.depth); | |
const previousListItemHasSubList = listItemHasSubList( | |
typeName, | |
state, | |
prevNode | |
); | |
// if the previous item is a list item and doesn't have a sublist, join the list items | |
if ( | |
hasPreviousListItem(typeName, state) && | |
!previousListItemHasSubList | |
) { | |
return commands.joinListItemBackward(); | |
} | |
// otherwise in the end, a backspace should | |
// always just lift the list item if | |
// joining / merging is not possible | |
return chain().liftListItem(typeName).run(); | |
}, | |
deleteAwayListItem: | |
typeName => | |
({ chain, commands, editor, state }) => { | |
// if the cursor is not inside the current node type | |
// do nothing and proceed | |
if (!isNodeActive(state, typeName)) { | |
return false; | |
} | |
// if the cursor is not at the end of a node | |
// do nothing and proceed | |
if (!isAtEndOfNode(state)) { | |
return false; | |
} | |
// check if the next node is a list with a deeper depth | |
if (nextListIsDeeper(typeName, state)) { | |
return chain() | |
.focus(state.selection.from + 4) | |
.lift(typeName) | |
.joinBackward() | |
.run(); | |
} | |
if (nextListIsHigher(typeName, state)) { | |
return chain().joinForward().joinBackward().run(); | |
} | |
// check if the next node is also a listItem | |
return commands.joinListItemForward(); | |
}, | |
}; | |
}, | |
addKeyboardShortcuts() { | |
const handleBackspace = () => | |
this.editor.commands.backspaceAwayListItem( | |
getCurrentItemTypeName(this.editor) | |
); | |
const handleDelete = () => | |
this.editor.commands.deleteAwayListItem( | |
getCurrentItemTypeName(this.editor) | |
); | |
return { | |
Delete: () => handleDelete(), | |
'Mod-Delete': () => handleDelete(), | |
'Mod-Shift-Delete': () => handleDelete(), | |
Backspace: () => handleBackspace(), | |
'Mod-Backspace': () => handleBackspace(), | |
'Mod-Shift-Backspace': () => handleBackspace(), | |
}; | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment