Spaces:
Paused
Paused
| 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'], | |
| // Tiptap editor getter (required for indent/outdent) | |
| getEditor = null, | |
| // UI copy / classes | |
| 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', | |
| // Behavior | |
| dragThresholdPx = 2, | |
| intoThresholdX = 28, // X ≥ this → treat as “into” (indent) | |
| outdentThresholdX = 10 // X ≤ this → “outdent” | |
| } = 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; | |
| // Prefer tiptap names first, then ProseMirror snake_case | |
| 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; | |
| } | |
| // Find the nearest enclosing list container at/around a pos | |
| 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 // allow passing type name directly | |
| : 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; | |
| // Deep‑normalize children recursively | |
| const normalizeNode = (node, parentTargetListNode) => { | |
| console.log( | |
| 'Normalizing node', | |
| node.type.name, | |
| 'for parent list', | |
| parentTargetListNode?.type?.name | |
| ); | |
| if (isListNode(node)) { | |
| // Normalize each list item inside | |
| const normalizedItems = []; | |
| node.content.forEach((li) => { | |
| normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode)); | |
| }); | |
| return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks); | |
| } | |
| // Not a list node → but may contain lists deeper | |
| 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); | |
| } | |
| // leaf | |
| 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) { | |
| // If changing type, ensure no disallowed marks are kept | |
| 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 { | |
| // Fallback – wrap content if schema requires a block | |
| 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); | |
| } | |
| // ---------- decorations ---------- | |
| 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 = /** @type {Element} */ (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 }; | |
| } | |
| // ---------- state ---------- | |
| const init = (state) => ({ | |
| decorations: buildHandleDecos(state.doc), | |
| dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active} | |
| dropTarget: null // {start, end, mode, toPos} | |
| }); | |
| 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; | |
| }; | |
| // ---------- helpers ---------- | |
| 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 }; | |
| // no-op if dropping into own range | |
| if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize) | |
| return { ok: true, newStart: fromStart }; | |
| // find item depth | |
| 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); | |
| // delete item (or entire list if only child) | |
| 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); | |
| // Compute mapped drop point with right bias so "after" stays after | |
| const mappedTo = tr.mapping.map(toPos, 1); | |
| // Detect enclosing list at destination, then normalize the item type | |
| 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); | |
| // If direct insert fails (e.g., not inside a list), try wrapping in a list | |
| 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 = /** @type {Element} */ (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)`; | |
| }; | |
| // ---------- plugin ---------- | |
| return new Plugin({ | |
| key: listPointerDragKey, | |
| state: { init: (_, state) => init(state), apply }, | |
| props: { | |
| decorations: decorationsProp, | |
| handleDOMEvents: { | |
| mousedown(view, event) { | |
| const t = /** @type {HTMLElement} */ (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); | |
| // for before/after: obvious | |
| // for into/outdent: we still insert AFTER target and then run sink/lift | |
| const toPos = | |
| info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; // into/outdent insert after target | |
| 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(); | |
| // Helper: figure out the list item node type name at/around a pos | |
| 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) { | |
| // Select the moved node so sink/lift applies to it | |
| 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(); // finalize focus/selection | |
| } | |
| } | |
| } | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
| }); | |
| } | |