import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model"; import { splitListItem, sinkListItem, liftListItem, } from "prosemirror-schema-list"; import { Transaction, EditorState, Plugin, TextSelection, } from "prosemirror-state"; import { findParentNodeClosestToPos } from "prosemirror-utils"; import { DecorationSet, Decoration } from "prosemirror-view"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import getParentListItem from "../queries/getParentListItem"; import isInList from "../queries/isInList"; import isList from "../queries/isList"; import { Dispatch } from "../types"; import Node from "./Node"; export default class ListItem extends Node { get name() { return "list_item"; } get schema(): NodeSpec { return { content: "paragraph block*", defining: true, draggable: true, parseDOM: [{ tag: "li" }], toDOM: () => ["li", 0], }; } get plugins() { return [ new Plugin({ state: { init() { return DecorationSet.empty; }, apply: ( tr: Transaction, set: DecorationSet, oldState: EditorState, newState: EditorState ) => { const action = tr.getMeta("li"); if (!action && !tr.docChanged) { return set; } // Adjust decoration positions to changes made by the transaction set = set.map(tr.mapping, tr.doc); switch (action?.event) { case "mouseover": { const result = findParentNodeClosestToPos( newState.doc.resolve(action.pos), (node) => node.type.name === this.name || node.type.name === "checkbox_item" ); if (!result) { return set; } const list = findParentNodeClosestToPos( newState.doc.resolve(action.pos), (node) => isList(node, this.editor.schema) ); if (!list) { return set; } const start = list.node.attrs.order || 1; let listItemNumber = 0; list.node.content.forEach((li, _, index) => { if (li === result.node) { listItemNumber = index; } }); const counterLength = String(start + listItemNumber).length; return set.add(tr.doc, [ Decoration.node( result.pos, result.pos + result.node.nodeSize, { class: `hovering`, }, { hover: true, } ), Decoration.node( result.pos, result.pos + result.node.nodeSize, { class: `counter-${counterLength}`, } ), ]); } case "mouseout": { const result = findParentNodeClosestToPos( newState.doc.resolve(action.pos), (node) => node.type.name === this.name || node.type.name === "checkbox_item" ); if (!result) { return set; } return set.remove( set.find( result.pos, result.pos + result.node.nodeSize, (spec) => spec.hover ) ); } default: } return set; }, }, props: { decorations(state) { return this.getState(state); }, handleDOMEvents: { mouseover: (view, event) => { const { state, dispatch } = view; const target = event.target as HTMLElement; const li = target?.closest("li"); if (!li) { return false; } if (!view.dom.contains(li)) { return false; } const pos = view.posAtDOM(li, 0); if (!pos) { return false; } dispatch( state.tr.setMeta("li", { event: "mouseover", pos, }) ); return false; }, mouseout: (view, event) => { const { state, dispatch } = view; const target = event.target as HTMLElement; const li = target?.closest("li"); if (!li) { return false; } if (!view.dom.contains(li)) { return false; } const pos = view.posAtDOM(li, 0); if (!pos) { return false; } dispatch( state.tr.setMeta("li", { event: "mouseout", pos, }) ); return false; }, }, }, }), ]; } keys({ type }: { type: NodeType }) { return { Enter: splitListItem(type), Tab: sinkListItem(type), "Shift-Tab": liftListItem(type), "Mod-]": sinkListItem(type), "Mod-[": liftListItem(type), "Shift-Enter": (state: EditorState, dispatch: Dispatch) => { if (!isInList(state)) { return false; } if (!state.selection.empty) { return false; } const { tr, selection } = state; dispatch(tr.split(selection.to)); return true; }, "Alt-ArrowUp": (state: EditorState, dispatch: Dispatch) => { if (!state.selection.empty) { return false; } const result = getParentListItem(state); if (!result) { return false; } const [li, pos] = result; const $pos = state.doc.resolve(pos); if ( !$pos.nodeBefore || !["list_item", "checkbox_item"].includes($pos.nodeBefore.type.name) ) { return false; } const { tr } = state; const newPos = pos - $pos.nodeBefore.nodeSize; dispatch( tr .delete(pos, pos + li.nodeSize) .insert(newPos, li) .setSelection(TextSelection.near(tr.doc.resolve(newPos))) ); return true; }, "Alt-ArrowDown": (state: EditorState, dispatch: Dispatch) => { if (!state.selection.empty) { return false; } const result = getParentListItem(state); if (!result) { return false; } const [li, pos] = result; const $pos = state.doc.resolve(pos + li.nodeSize); if ( !$pos.nodeAfter || !["list_item", "checkbox_item"].includes($pos.nodeAfter.type.name) ) { return false; } const { tr } = state; const newPos = pos + li.nodeSize + $pos.nodeAfter.nodeSize; dispatch( tr .insert(newPos, li) .setSelection(TextSelection.near(tr.doc.resolve(newPos))) .delete(pos, pos + li.nodeSize) ); return true; }, }; } toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { state.renderContent(node); } parseMarkdown() { return { block: "list_item" }; } }