| | import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state'; |
| | import { Decoration, DecorationSet } from 'prosemirror-view'; |
| | import { Fragment } from 'prosemirror-model'; |
| |
|
| | export const listPointerDragKey = new PluginKey('listPointerDrag'); |
| |
|
| | export function listDragHandlePlugin(options = {}) { |
| | const { |
| | itemTypeNames = ['listItem', 'taskItem', 'list_item'], |
| |
|
| | |
| | getEditor = null, |
| |
|
| | |
| | handleTitle = 'Drag to move', |
| | handleInnerHTML = '⋮⋮', |
| | classItemWithHandle = 'pm-li--with-handle', |
| | classHandle = 'pm-list-drag-handle', |
| | classDropBefore = 'pm-li-drop-before', |
| | classDropAfter = 'pm-li-drop-after', |
| | classDropInto = 'pm-li-drop-into', |
| | classDropOutdent = 'pm-li-drop-outdent', |
| | classDraggingGhost = 'pm-li-ghost', |
| |
|
| | |
| | dragThresholdPx = 2, |
| | intoThresholdX = 28, |
| | outdentThresholdX = 10 |
| | } = options; |
| |
|
| | const itemTypesSet = new Set(itemTypeNames); |
| | const isListItem = (node) => node && itemTypesSet.has(node.type.name); |
| |
|
| | const listTypeNames = new Set([ |
| | 'bulletList', |
| | 'orderedList', |
| | 'taskList', |
| | 'bullet_list', |
| | 'ordered_list' |
| | ]); |
| |
|
| | const isListNode = (node) => node && listTypeNames.has(node.type.name); |
| |
|
| | function listTypeToItemTypeName(listNode) { |
| | const name = listNode?.type?.name; |
| | if (!name) return null; |
| |
|
| | |
| | if (name === 'taskList') { |
| | return itemTypesSet.has('taskItem') ? 'taskItem' : null; |
| | } |
| | if (name === 'orderedList' || name === 'bulletList') { |
| | return itemTypesSet.has('listItem') |
| | ? 'listItem' |
| | : itemTypesSet.has('list_item') |
| | ? 'list_item' |
| | : null; |
| | } |
| | if (name === 'ordered_list' || name === 'bullet_list') { |
| | return itemTypesSet.has('list_item') |
| | ? 'list_item' |
| | : itemTypesSet.has('listItem') |
| | ? 'listItem' |
| | : null; |
| | } |
| | return null; |
| | } |
| |
|
| | |
| | function getEnclosingListAt(doc, pos) { |
| | const $pos = doc.resolve(Math.max(1, Math.min(pos, doc.content.size - 1))); |
| | for (let d = $pos.depth; d >= 0; d--) { |
| | const n = $pos.node(d); |
| | if (isListNode(n)) { |
| | const start = $pos.before(d); |
| | return { node: n, depth: d, start, end: start + n.nodeSize }; |
| | } |
| | } |
| | return null; |
| | } |
| |
|
| | function normalizeItemForList(state, itemNode, targetListNodeOrType) { |
| | const schema = state.schema; |
| |
|
| | const targetListNode = targetListNodeOrType; |
| | const wantedItemTypeName = |
| | typeof targetListNode === 'string' |
| | ? targetListNode |
| | : listTypeToItemTypeName(targetListNode); |
| |
|
| | if (!wantedItemTypeName) return itemNode; |
| | const wantedType = schema.nodes[wantedItemTypeName]; |
| | if (!wantedType) return itemNode; |
| |
|
| | const wantedListType = schema.nodes[targetListNode.type.name]; |
| | if (!wantedListType) return itemNode; |
| |
|
| | |
| | const normalizeNode = (node, parentTargetListNode) => { |
| | console.log( |
| | 'Normalizing node', |
| | node.type.name, |
| | 'for parent list', |
| | parentTargetListNode?.type?.name |
| | ); |
| | if (isListNode(node)) { |
| | |
| | const normalizedItems = []; |
| | node.content.forEach((li) => { |
| | normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode)); |
| | }); |
| | return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks); |
| | } |
| |
|
| | |
| | if (node.content && node.content.size > 0) { |
| | const nChildren = []; |
| | node.content.forEach((ch) => { |
| | nChildren.push(normalizeNode(ch, parentTargetListNode)); |
| | }); |
| | return node.type.create(node.attrs, Fragment.from(nChildren), node.marks); |
| | } |
| |
|
| | |
| | return node; |
| | }; |
| |
|
| | const normalizedContent = []; |
| | itemNode.content.forEach((child) => { |
| | normalizedContent.push(normalizeNode(child, targetListNode)); |
| | }); |
| |
|
| | const newAttrs = {}; |
| | if (wantedType.attrs) { |
| | for (const key in wantedType.attrs) { |
| | if (Object.prototype.hasOwnProperty.call(itemNode.attrs || {}, key)) { |
| | newAttrs[key] = itemNode.attrs[key]; |
| | } else { |
| | const spec = wantedType.attrs[key]; |
| | newAttrs[key] = typeof spec?.default !== 'undefined' ? spec.default : null; |
| | } |
| | } |
| | } |
| |
|
| | if (wantedItemTypeName !== itemNode.type.name) { |
| | |
| | const allowed = wantedType.spec?.marks; |
| | const marks = allowed ? itemNode.marks.filter((m) => allowed.includes(m.type.name)) : []; |
| |
|
| | console.log(normalizedContent); |
| | return wantedType.create(newAttrs, Fragment.from(normalizedContent), marks); |
| | } |
| |
|
| | try { |
| | return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks); |
| | } catch { |
| | |
| | const para = schema.nodes.paragraph; |
| | if (para) { |
| | const wrapped = |
| | itemNode.content.firstChild?.type === para |
| | ? Fragment.from(normalizedContent) |
| | : Fragment.from([para.create(null, normalizedContent)]); |
| | return wantedType.create(newAttrs, wrapped, itemNode.marks); |
| | } |
| | } |
| |
|
| | return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks); |
| | } |
| | |
| | function buildHandleDecos(doc) { |
| | const decos = []; |
| | doc.descendants((node, pos) => { |
| | if (!isListItem(node)) return; |
| | decos.push(Decoration.node(pos, pos + node.nodeSize, { class: classItemWithHandle })); |
| | decos.push( |
| | Decoration.widget( |
| | pos + 1, |
| | (view, getPos) => { |
| | const el = document.createElement('span'); |
| | el.className = classHandle; |
| | el.setAttribute('title', handleTitle); |
| | el.setAttribute('role', 'button'); |
| | el.setAttribute('aria-label', 'Drag list item'); |
| | el.contentEditable = 'false'; |
| | el.innerHTML = handleInnerHTML; |
| | el.pmGetPos = getPos; |
| | return el; |
| | }, |
| | { side: -1, ignoreSelection: true } |
| | ) |
| | ); |
| | }); |
| | return DecorationSet.create(doc, decos); |
| | } |
| |
|
| | function findListItemAround($pos) { |
| | for (let d = $pos.depth; d > 0; d--) { |
| | const node = $pos.node(d); |
| | if (isListItem(node)) { |
| | const start = $pos.before(d); |
| | return { depth: d, node, start, end: start + node.nodeSize }; |
| | } |
| | } |
| | return null; |
| | } |
| |
|
| | function infoFromCoords(view, clientX, clientY) { |
| | const result = view.posAtCoords({ left: clientX, top: clientY }); |
| | if (!result) return null; |
| | const $pos = view.state.doc.resolve(result.pos); |
| | const li = findListItemAround($pos); |
| | if (!li) return null; |
| |
|
| | const dom = (view.nodeDOM(li.start)); |
| | if (!(dom instanceof Element)) return null; |
| |
|
| | const rect = dom.getBoundingClientRect(); |
| | const isRTL = getComputedStyle(dom).direction === 'rtl'; |
| | const xFromLeft = isRTL ? rect.right - clientX : clientX - rect.left; |
| | const yInTopHalf = clientY - rect.top < rect.height / 2; |
| |
|
| | const mode = |
| | xFromLeft <= outdentThresholdX |
| | ? 'outdent' |
| | : xFromLeft >= intoThresholdX |
| | ? 'into' |
| | : yInTopHalf |
| | ? 'before' |
| | : 'after'; |
| |
|
| | return { ...li, dom, mode }; |
| | } |
| |
|
| | |
| | const init = (state) => ({ |
| | decorations: buildHandleDecos(state.doc), |
| | dragging: null, |
| | dropTarget: null |
| | }); |
| |
|
| | const apply = (tr, prev) => { |
| | let decorations = tr.docChanged |
| | ? buildHandleDecos(tr.doc) |
| | : prev.decorations.map(tr.mapping, tr.doc); |
| | let next = { ...prev, decorations }; |
| | const meta = tr.getMeta(listPointerDragKey); |
| | if (meta) { |
| | if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging }; |
| | if (meta.type === 'set-drop') next = { ...next, dropTarget: meta.drop }; |
| | if (meta.type === 'clear') next = { ...next, dragging: null, dropTarget: null }; |
| | } |
| | return next; |
| | }; |
| |
|
| | const decorationsProp = (state) => { |
| | const ps = listPointerDragKey.getState(state); |
| | if (!ps) return null; |
| | let deco = ps.decorations; |
| | if (ps.dropTarget) { |
| | const { start, end, mode } = ps.dropTarget; |
| | const cls = |
| | mode === 'before' |
| | ? classDropBefore |
| | : mode === 'after' |
| | ? classDropAfter |
| | : mode === 'into' |
| | ? classDropInto |
| | : classDropOutdent; |
| | deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]); |
| | } |
| | return deco; |
| | }; |
| |
|
| | |
| | const setDrag = (view, dragging) => |
| | view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging })); |
| | const setDrop = (view, drop) => |
| | view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop })); |
| | const clearAll = (view) => |
| | view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' })); |
| |
|
| | function moveItem(view, fromStart, toPos) { |
| | const { state, dispatch } = view; |
| | const { doc } = state; |
| | const orig = doc.nodeAt(fromStart); |
| | if (!orig || !isListItem(orig)) return { ok: false }; |
| |
|
| | |
| | if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize) |
| | return { ok: true, newStart: fromStart }; |
| |
|
| | |
| | const $inside = doc.resolve(fromStart + 1); |
| | let itemDepth = -1; |
| | for (let d = $inside.depth; d > 0; d--) { |
| | if ($inside.node(d) === orig) { |
| | itemDepth = d; |
| | break; |
| | } |
| | } |
| | if (itemDepth < 0) return { ok: false }; |
| |
|
| | const listDepth = itemDepth - 1; |
| | const parentList = $inside.node(listDepth); |
| | const parentListStart = $inside.before(listDepth); |
| |
|
| | |
| | const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart; |
| | const deleteTo = |
| | parentList.childCount === 1 |
| | ? parentListStart + parentList.nodeSize |
| | : fromStart + orig.nodeSize; |
| |
|
| | let tr = state.tr.delete(deleteFrom, deleteTo); |
| |
|
| | |
| | const mappedTo = tr.mapping.map(toPos, 1); |
| |
|
| | |
| | const listAtDest = getEnclosingListAt(tr.doc, mappedTo); |
| | const nodeToInsert = listAtDest ? normalizeItemForList(state, orig, listAtDest.node) : orig; |
| |
|
| | try { |
| | tr = tr.insert(mappedTo, nodeToInsert); |
| | } catch (e) { |
| | console.log('Direct insert failed, trying to wrap in list', e); |
| | |
| | const schema = state.schema; |
| | const wrapName = |
| | parentList.type.name === 'taskList' |
| | ? schema.nodes.taskList |
| | ? 'taskList' |
| | : null |
| | : parentList.type.name === 'orderedList' || parentList.type.name === 'ordered_list' |
| | ? schema.nodes.orderedList |
| | ? 'orderedList' |
| | : schema.nodes.ordered_list |
| | ? 'ordered_list' |
| | : null |
| | : schema.nodes.bulletList |
| | ? 'bulletList' |
| | : schema.nodes.bullet_list |
| | ? 'bullet_list' |
| | : null; |
| |
|
| | if (wrapName) { |
| | const wrapType = schema.nodes[wrapName]; |
| | if (wrapType) { |
| | const frag = wrapType.create(null, normalizeItemForList(state, orig, wrapType)); |
| | tr = tr.insert(mappedTo, frag); |
| | } else { |
| | return { ok: false }; |
| | } |
| | } else { |
| | return { ok: false }; |
| | } |
| | } |
| |
|
| | dispatch(tr.scrollIntoView()); |
| | return { ok: true, newStart: mappedTo }; |
| | } |
| |
|
| | function ensureGhost(view, fromStart) { |
| | const el = document.createElement('div'); |
| | el.className = classDraggingGhost; |
| | const dom = (view.nodeDOM(fromStart)); |
| | const rect = dom instanceof Element ? dom.getBoundingClientRect() : null; |
| | if (rect) { |
| | el.style.position = 'fixed'; |
| | el.style.left = rect.left + 'px'; |
| | el.style.top = rect.top + 'px'; |
| | el.style.width = rect.width + 'px'; |
| | el.style.pointerEvents = 'none'; |
| | el.style.opacity = '0.75'; |
| | el.textContent = dom.textContent?.trim().slice(0, 80) || '…'; |
| | } |
| | document.body.appendChild(el); |
| | return el; |
| | } |
| | const updateGhost = (ghost, dx, dy) => { |
| | if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`; |
| | }; |
| |
|
| | |
| | return new Plugin({ |
| | key: listPointerDragKey, |
| | state: { init: (_, state) => init(state), apply }, |
| | props: { |
| | decorations: decorationsProp, |
| | handleDOMEvents: { |
| | mousedown(view, event) { |
| | const t = (event.target); |
| | const handle = t.closest?.(`.${classHandle}`); |
| | if (!handle) return false; |
| | event.preventDefault(); |
| |
|
| | const getPos = handle.pmGetPos; |
| | if (typeof getPos !== 'function') return true; |
| |
|
| | const posInside = getPos(); |
| | const fromStart = posInside - 1; |
| |
|
| | try { |
| | view.dispatch( |
| | view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart)) |
| | ); |
| | } catch {} |
| |
|
| | const startMouse = { x: event.clientX, y: event.clientY }; |
| | const ghostEl = ensureGhost(view, fromStart); |
| | setDrag(view, { fromStart, startMouse, ghostEl, active: false }); |
| |
|
| | const onMove = (e) => { |
| | const ps = listPointerDragKey.getState(view.state); |
| | if (!ps?.dragging) return; |
| |
|
| | const dx = e.clientX - ps.dragging.startMouse.x; |
| | const dy = e.clientY - ps.dragging.startMouse.y; |
| |
|
| | if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) { |
| | setDrag(view, { ...ps.dragging, active: true }); |
| | } |
| | updateGhost(ps.dragging.ghostEl, dx, dy); |
| |
|
| | const info = infoFromCoords(view, e.clientX, e.clientY); |
| | if (!info) return setDrop(view, null); |
| |
|
| | |
| | |
| | const toPos = |
| | info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; |
| |
|
| | const prev = listPointerDragKey.getState(view.state)?.dropTarget; |
| | if ( |
| | !prev || |
| | prev.start !== info.start || |
| | prev.end !== info.end || |
| | prev.mode !== info.mode |
| | ) { |
| | setDrop(view, { start: info.start, end: info.end, mode: info.mode, toPos }); |
| | } |
| | }; |
| |
|
| | const endDrag = () => { |
| | window.removeEventListener('mousemove', onMove, true); |
| | window.removeEventListener('mouseup', endDrag, true); |
| |
|
| | const ps = listPointerDragKey.getState(view.state); |
| | if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove(); |
| |
|
| | |
| | const getListItemTypeNameAt = (doc, pos) => { |
| | const direct = doc.nodeAt(pos); |
| | if (direct && isListItem(direct)) return direct.type.name; |
| |
|
| | const $pos = doc.resolve(Math.min(pos + 1, doc.content.size)); |
| | for (let d = $pos.depth; d > 0; d--) { |
| | const n = $pos.node(d); |
| | if (isListItem(n)) return n.type.name; |
| | } |
| |
|
| | const prefs = ['taskItem', 'listItem', 'list_item']; |
| | for (const p of prefs) if (itemTypesSet.has(p)) return p; |
| | return Array.from(itemTypesSet)[0]; |
| | }; |
| |
|
| | if (ps?.dragging && ps?.dropTarget && ps.dragging.active) { |
| | const { fromStart } = ps.dragging; |
| | const { toPos, mode } = ps.dropTarget; |
| |
|
| | const res = moveItem(view, fromStart, toPos); |
| |
|
| | if (res.ok && typeof res.newStart === 'number' && getEditor) { |
| | const editor = getEditor(); |
| | if (editor?.commands) { |
| | |
| | editor.commands.setNodeSelection(res.newStart); |
| |
|
| | const typeName = getListItemTypeNameAt(view.state.doc, res.newStart); |
| | const chain = editor.chain().focus(); |
| |
|
| | if (mode === 'into') { |
| | if (editor.can().sinkListItem?.(typeName)) chain.sinkListItem(typeName).run(); |
| | else chain.run(); |
| | } else { |
| | chain.run(); |
| | } |
| | } |
| | } |
| | } |
| |
|
| | clearAll(view); |
| | }; |
| |
|
| | window.addEventListener('mousemove', onMove, true); |
| | window.addEventListener('mouseup', endDrag, true); |
| | return true; |
| | }, |
| |
|
| | keydown(view, event) { |
| | if (event.key === 'Escape') { |
| | const ps = listPointerDragKey.getState(view.state); |
| | if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove(); |
| | clearAll(view); |
| | return true; |
| | } |
| | return false; |
| | } |
| | } |
| | } |
| | }); |
| | } |
| |
|