listDragHandlePlugin.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state';
  2. import { Decoration, DecorationSet } from 'prosemirror-view';
  3. import { Fragment } from 'prosemirror-model';
  4. export const listPointerDragKey = new PluginKey('listPointerDrag');
  5. export function listDragHandlePlugin(options = {}) {
  6. const {
  7. itemTypeNames = ['listItem', 'taskItem', 'list_item'],
  8. // Tiptap editor getter (required for indent/outdent)
  9. getEditor = null,
  10. // UI copy / classes
  11. handleTitle = 'Drag to move',
  12. handleInnerHTML = '⋮⋮',
  13. classItemWithHandle = 'pm-li--with-handle',
  14. classHandle = 'pm-list-drag-handle',
  15. classDropBefore = 'pm-li-drop-before',
  16. classDropAfter = 'pm-li-drop-after',
  17. classDropInto = 'pm-li-drop-into',
  18. classDropOutdent = 'pm-li-drop-outdent',
  19. classDraggingGhost = 'pm-li-ghost',
  20. // Behavior
  21. dragThresholdPx = 2,
  22. intoThresholdX = 28, // X ≥ this → treat as “into” (indent)
  23. outdentThresholdX = 10 // X ≤ this → “outdent”
  24. } = options;
  25. const itemTypesSet = new Set(itemTypeNames);
  26. const isListItem = (node) => node && itemTypesSet.has(node.type.name);
  27. const listTypeNames = new Set([
  28. 'bulletList',
  29. 'orderedList',
  30. 'taskList',
  31. 'bullet_list',
  32. 'ordered_list'
  33. ]);
  34. const isListNode = (node) => node && listTypeNames.has(node.type.name);
  35. function listTypeToItemTypeName(listNode) {
  36. const name = listNode?.type?.name;
  37. if (!name) return null;
  38. // Prefer tiptap names first, then ProseMirror snake_case
  39. if (name === 'taskList') {
  40. return itemTypesSet.has('taskItem') ? 'taskItem' : null;
  41. }
  42. if (name === 'orderedList' || name === 'bulletList') {
  43. return itemTypesSet.has('listItem')
  44. ? 'listItem'
  45. : itemTypesSet.has('list_item')
  46. ? 'list_item'
  47. : null;
  48. }
  49. if (name === 'ordered_list' || name === 'bullet_list') {
  50. return itemTypesSet.has('list_item')
  51. ? 'list_item'
  52. : itemTypesSet.has('listItem')
  53. ? 'listItem'
  54. : null;
  55. }
  56. return null;
  57. }
  58. // Find the nearest enclosing list container at/around a pos
  59. function getEnclosingListAt(doc, pos) {
  60. const $pos = doc.resolve(Math.max(1, Math.min(pos, doc.content.size - 1)));
  61. for (let d = $pos.depth; d >= 0; d--) {
  62. const n = $pos.node(d);
  63. if (isListNode(n)) {
  64. const start = $pos.before(d);
  65. return { node: n, depth: d, start, end: start + n.nodeSize };
  66. }
  67. }
  68. return null;
  69. }
  70. function normalizeItemForList(state, itemNode, targetListNodeOrType) {
  71. const schema = state.schema;
  72. const targetListNode = targetListNodeOrType;
  73. const wantedItemTypeName =
  74. typeof targetListNode === 'string'
  75. ? targetListNode // allow passing type name directly
  76. : listTypeToItemTypeName(targetListNode);
  77. if (!wantedItemTypeName) return itemNode;
  78. const wantedType = schema.nodes[wantedItemTypeName];
  79. if (!wantedType) return itemNode;
  80. const wantedListType = schema.nodes[targetListNode.type.name];
  81. if (!wantedListType) return itemNode;
  82. // Deep‑normalize children recursively
  83. const normalizeNode = (node, parentTargetListNode) => {
  84. console.log(
  85. 'Normalizing node',
  86. node.type.name,
  87. 'for parent list',
  88. parentTargetListNode?.type?.name
  89. );
  90. if (isListNode(node)) {
  91. // Normalize each list item inside
  92. const normalizedItems = [];
  93. node.content.forEach((li) => {
  94. normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode));
  95. });
  96. return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks);
  97. }
  98. // Not a list node → but may contain lists deeper
  99. if (node.content && node.content.size > 0) {
  100. const nChildren = [];
  101. node.content.forEach((ch) => {
  102. nChildren.push(normalizeNode(ch, parentTargetListNode));
  103. });
  104. return node.type.create(node.attrs, Fragment.from(nChildren), node.marks);
  105. }
  106. // leaf
  107. return node;
  108. };
  109. const normalizedContent = [];
  110. itemNode.content.forEach((child) => {
  111. normalizedContent.push(normalizeNode(child, targetListNode));
  112. });
  113. const newAttrs = {};
  114. if (wantedType.attrs) {
  115. for (const key in wantedType.attrs) {
  116. if (Object.prototype.hasOwnProperty.call(itemNode.attrs || {}, key)) {
  117. newAttrs[key] = itemNode.attrs[key];
  118. } else {
  119. const spec = wantedType.attrs[key];
  120. newAttrs[key] = typeof spec?.default !== 'undefined' ? spec.default : null;
  121. }
  122. }
  123. }
  124. if (wantedItemTypeName !== itemNode.type.name) {
  125. // If changing type, ensure no disallowed marks are kept
  126. const allowed = wantedType.spec?.marks;
  127. const marks = allowed ? itemNode.marks.filter((m) => allowed.includes(m.type.name)) : [];
  128. console.log(normalizedContent);
  129. return wantedType.create(newAttrs, Fragment.from(normalizedContent), marks);
  130. }
  131. try {
  132. return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
  133. } catch {
  134. // Fallback – wrap content if schema requires a block
  135. const para = schema.nodes.paragraph;
  136. if (para) {
  137. const wrapped =
  138. itemNode.content.firstChild?.type === para
  139. ? Fragment.from(normalizedContent)
  140. : Fragment.from([para.create(null, normalizedContent)]);
  141. return wantedType.create(newAttrs, wrapped, itemNode.marks);
  142. }
  143. }
  144. return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
  145. }
  146. // ---------- decorations ----------
  147. function buildHandleDecos(doc) {
  148. const decos = [];
  149. doc.descendants((node, pos) => {
  150. if (!isListItem(node)) return;
  151. decos.push(Decoration.node(pos, pos + node.nodeSize, { class: classItemWithHandle }));
  152. decos.push(
  153. Decoration.widget(
  154. pos + 1,
  155. (view, getPos) => {
  156. const el = document.createElement('span');
  157. el.className = classHandle;
  158. el.setAttribute('title', handleTitle);
  159. el.setAttribute('role', 'button');
  160. el.setAttribute('aria-label', 'Drag list item');
  161. el.contentEditable = 'false';
  162. el.innerHTML = handleInnerHTML;
  163. el.pmGetPos = getPos;
  164. return el;
  165. },
  166. { side: -1, ignoreSelection: true }
  167. )
  168. );
  169. });
  170. return DecorationSet.create(doc, decos);
  171. }
  172. function findListItemAround($pos) {
  173. for (let d = $pos.depth; d > 0; d--) {
  174. const node = $pos.node(d);
  175. if (isListItem(node)) {
  176. const start = $pos.before(d);
  177. return { depth: d, node, start, end: start + node.nodeSize };
  178. }
  179. }
  180. return null;
  181. }
  182. function infoFromCoords(view, clientX, clientY) {
  183. const result = view.posAtCoords({ left: clientX, top: clientY });
  184. if (!result) return null;
  185. const $pos = view.state.doc.resolve(result.pos);
  186. const li = findListItemAround($pos);
  187. if (!li) return null;
  188. const dom = /** @type {Element} */ (view.nodeDOM(li.start));
  189. if (!(dom instanceof Element)) return null;
  190. const rect = dom.getBoundingClientRect();
  191. const isRTL = getComputedStyle(dom).direction === 'rtl';
  192. const xFromLeft = isRTL ? rect.right - clientX : clientX - rect.left;
  193. const yInTopHalf = clientY - rect.top < rect.height / 2;
  194. const mode =
  195. xFromLeft <= outdentThresholdX
  196. ? 'outdent'
  197. : xFromLeft >= intoThresholdX
  198. ? 'into'
  199. : yInTopHalf
  200. ? 'before'
  201. : 'after';
  202. return { ...li, dom, mode };
  203. }
  204. // ---------- state ----------
  205. const init = (state) => ({
  206. decorations: buildHandleDecos(state.doc),
  207. dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active}
  208. dropTarget: null // {start, end, mode, toPos}
  209. });
  210. const apply = (tr, prev) => {
  211. let decorations = tr.docChanged
  212. ? buildHandleDecos(tr.doc)
  213. : prev.decorations.map(tr.mapping, tr.doc);
  214. let next = { ...prev, decorations };
  215. const meta = tr.getMeta(listPointerDragKey);
  216. if (meta) {
  217. if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
  218. if (meta.type === 'set-drop') next = { ...next, dropTarget: meta.drop };
  219. if (meta.type === 'clear') next = { ...next, dragging: null, dropTarget: null };
  220. }
  221. return next;
  222. };
  223. const decorationsProp = (state) => {
  224. const ps = listPointerDragKey.getState(state);
  225. if (!ps) return null;
  226. let deco = ps.decorations;
  227. if (ps.dropTarget) {
  228. const { start, end, mode } = ps.dropTarget;
  229. const cls =
  230. mode === 'before'
  231. ? classDropBefore
  232. : mode === 'after'
  233. ? classDropAfter
  234. : mode === 'into'
  235. ? classDropInto
  236. : classDropOutdent;
  237. deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
  238. }
  239. return deco;
  240. };
  241. // ---------- helpers ----------
  242. const setDrag = (view, dragging) =>
  243. view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
  244. const setDrop = (view, drop) =>
  245. view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
  246. const clearAll = (view) =>
  247. view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
  248. function moveItem(view, fromStart, toPos) {
  249. const { state, dispatch } = view;
  250. const { doc } = state;
  251. const orig = doc.nodeAt(fromStart);
  252. if (!orig || !isListItem(orig)) return { ok: false };
  253. // no-op if dropping into own range
  254. if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
  255. return { ok: true, newStart: fromStart };
  256. // find item depth
  257. const $inside = doc.resolve(fromStart + 1);
  258. let itemDepth = -1;
  259. for (let d = $inside.depth; d > 0; d--) {
  260. if ($inside.node(d) === orig) {
  261. itemDepth = d;
  262. break;
  263. }
  264. }
  265. if (itemDepth < 0) return { ok: false };
  266. const listDepth = itemDepth - 1;
  267. const parentList = $inside.node(listDepth);
  268. const parentListStart = $inside.before(listDepth);
  269. // delete item (or entire list if only child)
  270. const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
  271. const deleteTo =
  272. parentList.childCount === 1
  273. ? parentListStart + parentList.nodeSize
  274. : fromStart + orig.nodeSize;
  275. let tr = state.tr.delete(deleteFrom, deleteTo);
  276. // Compute mapped drop point with right bias so "after" stays after
  277. const mappedTo = tr.mapping.map(toPos, 1);
  278. // Detect enclosing list at destination, then normalize the item type
  279. const listAtDest = getEnclosingListAt(tr.doc, mappedTo);
  280. const nodeToInsert = listAtDest ? normalizeItemForList(state, orig, listAtDest.node) : orig;
  281. try {
  282. tr = tr.insert(mappedTo, nodeToInsert);
  283. } catch (e) {
  284. console.log('Direct insert failed, trying to wrap in list', e);
  285. // If direct insert fails (e.g., not inside a list), try wrapping in a list
  286. const schema = state.schema;
  287. const wrapName =
  288. parentList.type.name === 'taskList'
  289. ? schema.nodes.taskList
  290. ? 'taskList'
  291. : null
  292. : parentList.type.name === 'orderedList' || parentList.type.name === 'ordered_list'
  293. ? schema.nodes.orderedList
  294. ? 'orderedList'
  295. : schema.nodes.ordered_list
  296. ? 'ordered_list'
  297. : null
  298. : schema.nodes.bulletList
  299. ? 'bulletList'
  300. : schema.nodes.bullet_list
  301. ? 'bullet_list'
  302. : null;
  303. if (wrapName) {
  304. const wrapType = schema.nodes[wrapName];
  305. if (wrapType) {
  306. const frag = wrapType.create(null, normalizeItemForList(state, orig, wrapType));
  307. tr = tr.insert(mappedTo, frag);
  308. } else {
  309. return { ok: false };
  310. }
  311. } else {
  312. return { ok: false };
  313. }
  314. }
  315. dispatch(tr.scrollIntoView());
  316. return { ok: true, newStart: mappedTo };
  317. }
  318. function ensureGhost(view, fromStart) {
  319. const el = document.createElement('div');
  320. el.className = classDraggingGhost;
  321. const dom = /** @type {Element} */ (view.nodeDOM(fromStart));
  322. const rect = dom instanceof Element ? dom.getBoundingClientRect() : null;
  323. if (rect) {
  324. el.style.position = 'fixed';
  325. el.style.left = rect.left + 'px';
  326. el.style.top = rect.top + 'px';
  327. el.style.width = rect.width + 'px';
  328. el.style.pointerEvents = 'none';
  329. el.style.opacity = '0.75';
  330. el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
  331. }
  332. document.body.appendChild(el);
  333. return el;
  334. }
  335. const updateGhost = (ghost, dx, dy) => {
  336. if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`;
  337. };
  338. // ---------- plugin ----------
  339. return new Plugin({
  340. key: listPointerDragKey,
  341. state: { init: (_, state) => init(state), apply },
  342. props: {
  343. decorations: decorationsProp,
  344. handleDOMEvents: {
  345. mousedown(view, event) {
  346. const t = /** @type {HTMLElement} */ (event.target);
  347. const handle = t.closest?.(`.${classHandle}`);
  348. if (!handle) return false;
  349. event.preventDefault();
  350. const getPos = handle.pmGetPos;
  351. if (typeof getPos !== 'function') return true;
  352. const posInside = getPos();
  353. const fromStart = posInside - 1;
  354. try {
  355. view.dispatch(
  356. view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart))
  357. );
  358. } catch {}
  359. const startMouse = { x: event.clientX, y: event.clientY };
  360. const ghostEl = ensureGhost(view, fromStart);
  361. setDrag(view, { fromStart, startMouse, ghostEl, active: false });
  362. const onMove = (e) => {
  363. const ps = listPointerDragKey.getState(view.state);
  364. if (!ps?.dragging) return;
  365. const dx = e.clientX - ps.dragging.startMouse.x;
  366. const dy = e.clientY - ps.dragging.startMouse.y;
  367. if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
  368. setDrag(view, { ...ps.dragging, active: true });
  369. }
  370. updateGhost(ps.dragging.ghostEl, dx, dy);
  371. const info = infoFromCoords(view, e.clientX, e.clientY);
  372. if (!info) return setDrop(view, null);
  373. // for before/after: obvious
  374. // for into/outdent: we still insert AFTER target and then run sink/lift
  375. const toPos =
  376. info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; // into/outdent insert after target
  377. const prev = listPointerDragKey.getState(view.state)?.dropTarget;
  378. if (
  379. !prev ||
  380. prev.start !== info.start ||
  381. prev.end !== info.end ||
  382. prev.mode !== info.mode
  383. ) {
  384. setDrop(view, { start: info.start, end: info.end, mode: info.mode, toPos });
  385. }
  386. };
  387. const endDrag = () => {
  388. window.removeEventListener('mousemove', onMove, true);
  389. window.removeEventListener('mouseup', endDrag, true);
  390. const ps = listPointerDragKey.getState(view.state);
  391. if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
  392. // Helper: figure out the list item node type name at/around a pos
  393. const getListItemTypeNameAt = (doc, pos) => {
  394. const direct = doc.nodeAt(pos);
  395. if (direct && isListItem(direct)) return direct.type.name;
  396. const $pos = doc.resolve(Math.min(pos + 1, doc.content.size));
  397. for (let d = $pos.depth; d > 0; d--) {
  398. const n = $pos.node(d);
  399. if (isListItem(n)) return n.type.name;
  400. }
  401. const prefs = ['taskItem', 'listItem', 'list_item'];
  402. for (const p of prefs) if (itemTypesSet.has(p)) return p;
  403. return Array.from(itemTypesSet)[0];
  404. };
  405. if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
  406. const { fromStart } = ps.dragging;
  407. const { toPos, mode } = ps.dropTarget;
  408. const res = moveItem(view, fromStart, toPos);
  409. if (res.ok && typeof res.newStart === 'number' && getEditor) {
  410. const editor = getEditor();
  411. if (editor?.commands) {
  412. // Select the moved node so sink/lift applies to it
  413. editor.commands.setNodeSelection(res.newStart);
  414. const typeName = getListItemTypeNameAt(view.state.doc, res.newStart);
  415. const chain = editor.chain().focus();
  416. if (mode === 'into') {
  417. if (editor.can().sinkListItem?.(typeName)) chain.sinkListItem(typeName).run();
  418. else chain.run();
  419. } else {
  420. chain.run(); // finalize focus/selection
  421. }
  422. }
  423. }
  424. }
  425. clearAll(view);
  426. };
  427. window.addEventListener('mousemove', onMove, true);
  428. window.addEventListener('mouseup', endDrag, true);
  429. return true;
  430. },
  431. keydown(view, event) {
  432. if (event.key === 'Escape') {
  433. const ps = listPointerDragKey.getState(view.state);
  434. if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
  435. clearAll(view);
  436. return true;
  437. }
  438. return false;
  439. }
  440. }
  441. }
  442. });
  443. }