| 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; |
| } |
| } |
| } |
| }); |
| } |
|
|