diff --git a/src/lib/editor/commands/toggleList.ts b/src/lib/editor/commands/toggleList.ts new file mode 100644 index 0000000..9364a7d --- /dev/null +++ b/src/lib/editor/commands/toggleList.ts @@ -0,0 +1,47 @@ +import type { NodeType } from 'prosemirror-model'; +import { wrapInList, liftListItem } from 'prosemirror-schema-list'; +import type { EditorState } from 'prosemirror-state'; +import { findParentNode } from 'prosemirror-utils'; +import chainTransactions from '../lib/chainTransactions'; +import isList from '../queries/isList'; +import type { Dispatch } from '../types/dispatch'; +import clearNodes from './clearNodes'; + +export default function toggleList(listType: NodeType, itemType: NodeType) { + return (state: EditorState, dispatch?: Dispatch) => { + const { schema, selection } = state; + const { $from, $to } = selection; + const range = $from.blockRange($to); + const { tr } = state; + + if (!range) { + return false; + } + + const parentList = findParentNode((node) => isList(node, schema))(selection); + + if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) { + if (parentList.node.type === listType) { + return liftListItem(itemType)(state, dispatch); + } + + if (isList(parentList.node, schema) && listType.validContent(parentList.node.content)) { + tr.setNodeMarkup(parentList.pos, listType); + + if (dispatch) { + dispatch(tr); + } + + return false; + } + } + + const canWrapInList = wrapInList(listType)(state); + + if (canWrapInList) { + return wrapInList(listType)(state, dispatch); + } + + return chainTransactions(clearNodes(), wrapInList(listType))(state, dispatch); + }; +} diff --git a/src/lib/editor/nodes/ListItem.ts b/src/lib/editor/nodes/ListItem.ts new file mode 100644 index 0000000..1daea37 --- /dev/null +++ b/src/lib/editor/nodes/ListItem.ts @@ -0,0 +1,262 @@ +import type { 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) + ) { + console.log('Node before not a list item'); + 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)) { + console.log('Node after not a list item'); + 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' }; + } +}