Skip to content

Instantly share code, notes, and snippets.

@Nantris
Last active June 10, 2023 19:42
Show Gist options
  • Save Nantris/1a7ca430c5d50c7e656d96d4570b8d4f to your computer and use it in GitHub Desktop.
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
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;
};
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