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