This commit is contained in:
Timothy Jaeryang Baek 2025-09-22 23:28:19 -04:00
parent 2771c26729
commit aeb5288a3c
3 changed files with 372 additions and 99 deletions

View file

@ -707,25 +707,29 @@ body {
background: rgba(0, 0, 0, 0.06); background: rgba(0, 0, 0, 0.06);
} }
/* Drop indicators: draw a line before/after the LI */ :root {
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
}
.pm-li-drop-before, .pm-li-drop-before,
.pm-li-drop-after, .pm-li-drop-after,
.pm-li-drop-on-left, .pm-li-drop-into,
.pm-li-drop-on-right { .pm-li-drop-outdent {
position: relative; position: relative;
} }
/* BEFORE/AFTER lines */
.pm-li-drop-before::before, .pm-li-drop-before::before,
.pm-li-drop-after::after, .pm-li-drop-after::after {
.pm-li-drop-on-left::before,
.pm-li-drop-on-right::after {
content: ''; content: '';
position: absolute; position: absolute;
left: -24px; /* extend line into gutter past the handle */ left: 0;
right: 0; right: 0;
height: 2px; height: 3px;
background: currentColor; background: var(--pm-accent);
opacity: 0.55; pointer-events: none;
} }
.pm-li-drop-before::before { .pm-li-drop-before::before {
top: -2px; top: -2px;
@ -734,20 +738,35 @@ body {
bottom: -2px; bottom: -2px;
} }
/* existing */ .pm-li-drop-before,
.pm-li-drop-before { .pm-li-drop-after,
outline: 2px solid var(--accent); .pm-li-drop-into,
outline-offset: -2px; .pm-li-drop-outdent {
} background: var(--pm-fill-target);
.pm-li-drop-after { border-radius: 6px;
outline: 2px solid var(--accent);
outline-offset: -2px;
} }
/* new */ .pm-li-drop-outdent::before {
.pm-li-drop-on-left { content: '';
box-shadow: inset 4px 0 0 0 var(--accent); position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 3px;
background: color-mix(in oklab, Highlight 35%, transparent);
} }
.pm-li-drop-on-right {
box-shadow: inset -4px 0 0 0 var(--accent); .pm-li--with-handle:has(.pm-li-drop-before),
.pm-li--with-handle:has(.pm-li-drop-after),
.pm-li--with-handle:has(.pm-li-drop-into),
.pm-li--with-handle:has(.pm-li-drop-outdent) {
background: var(--pm-fill-ancestor);
border-radius: 6px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
z-index: 0;
} }

View file

@ -1099,11 +1099,11 @@
</script> </script>
{#if richText && showFormattingToolbar} {#if richText && showFormattingToolbar}
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0"> <div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} /> <FormattingButtons {editor} />
</div> </div>
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0"> <div bind:this={floatingMenuElement} id="floating-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} /> <FormattingButtons {editor} />
</div> </div>
{/if} {/if}

View file

@ -1,22 +1,173 @@
// listPointerDragPlugin.js import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state';
import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import { Fragment } from 'prosemirror-model';
export const listPointerDragKey = new PluginKey('listPointerDrag'); export const listPointerDragKey = new PluginKey('listPointerDrag');
export function listDragHandlePlugin(options = {}) { export function listDragHandlePlugin(options = {}) {
const { const {
itemTypeNames = ['list_item'], // add 'taskItem' if using tiptap task-list itemTypeNames = ['listItem', 'taskItem', 'list_item'],
// Tiptap editor getter (required for indent/outdent)
getEditor = null,
// UI copy / classes
handleTitle = 'Drag to move', handleTitle = 'Drag to move',
handleInnerHTML = '⋮⋮', handleInnerHTML = '⋮⋮',
classItemWithHandle = 'pm-li--with-handle', classItemWithHandle = 'pm-li--with-handle',
classHandle = 'pm-list-drag-handle', classHandle = 'pm-list-drag-handle',
classDropBefore = 'pm-li-drop-before', classDropBefore = 'pm-li-drop-before',
classDropAfter = 'pm-li-drop-after', classDropAfter = 'pm-li-drop-after',
classDropInto = 'pm-li-drop-into',
classDropOutdent = 'pm-li-drop-outdent',
classDraggingGhost = 'pm-li-ghost', classDraggingGhost = 'pm-li-ghost',
dragThresholdPx = 2 // ignore tiny wiggles
// Behavior
dragThresholdPx = 2,
intoThresholdX = 28, // X ≥ this → treat as “into” (indent)
outdentThresholdX = 10 // X ≤ this → “outdent”
} = options; } = options;
const itemTypesSet = new Set(itemTypeNames); const itemTypesSet = new Set(itemTypeNames);
const isListItem = (node) => node && itemTypesSet.has(node.type.name); const isListItem = (node) => node && itemTypesSet.has(node.type.name);
// ---------- decoration builder ----------
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;
// Deepnormalize 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) { function buildHandleDecos(doc) {
const decos = []; const decos = [];
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
@ -33,15 +184,16 @@ export function listDragHandlePlugin(options = {}) {
el.setAttribute('aria-label', 'Drag list item'); el.setAttribute('aria-label', 'Drag list item');
el.contentEditable = 'false'; el.contentEditable = 'false';
el.innerHTML = handleInnerHTML; el.innerHTML = handleInnerHTML;
el.pmGetPos = getPos; // live resolver el.pmGetPos = getPos;
return el; return el;
}, },
{ side: -1, ignoreSelection: true, key: `li-handle-${pos}` } { side: -1, ignoreSelection: true }
) )
); );
}); });
return DecorationSet.create(doc, decos); return DecorationSet.create(doc, decos);
} }
function findListItemAround($pos) { function findListItemAround($pos) {
for (let d = $pos.depth; d > 0; d--) { for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d); const node = $pos.node(d);
@ -52,33 +204,46 @@ export function listDragHandlePlugin(options = {}) {
} }
return null; return null;
} }
function infoFromCoords(view, clientX, clientY) { function infoFromCoords(view, clientX, clientY) {
const result = view.posAtCoords({ left: clientX, top: clientY }); const result = view.posAtCoords({ left: clientX, top: clientY });
if (!result) return null; if (!result) return null;
const $pos = view.state.doc.resolve(result.pos); const $pos = view.state.doc.resolve(result.pos);
const li = findListItemAround($pos); const li = findListItemAround($pos);
if (!li) return null; if (!li) return null;
const dom = /** @type {Element} */ (view.nodeDOM(li.start)); const dom = /** @type {Element} */ (view.nodeDOM(li.start));
if (!(dom instanceof Element)) return null; if (!(dom instanceof Element)) return null;
const rect = dom.getBoundingClientRect(); const rect = dom.getBoundingClientRect();
const side = clientY - rect.top < rect.height / 2 ? 'before' : 'after'; const isRTL = getComputedStyle(dom).direction === 'rtl';
return { ...li, dom, side }; 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 shape ----------
// ---------- state ----------
const init = (state) => ({ const init = (state) => ({
decorations: buildHandleDecos(state.doc), decorations: buildHandleDecos(state.doc),
dragging: null, // {fromStart, startMouse: {x,y}, ghostEl} | null dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active}
dropTarget: null // {start, end, side} | null dropTarget: null // {start, end, mode, toPos}
}); });
const apply = (tr, prev) => { const apply = (tr, prev) => {
let next = prev; let decorations = tr.docChanged
let decorations = prev.decorations; ? buildHandleDecos(tr.doc)
if (tr.docChanged) { : prev.decorations.map(tr.mapping, tr.doc);
decorations = buildHandleDecos(tr.doc); let next = { ...prev, decorations };
} else {
decorations = decorations.map(tr.mapping, tr.doc);
}
next = { ...next, decorations };
const meta = tr.getMeta(listPointerDragKey); const meta = tr.getMeta(listPointerDragKey);
if (meta) { if (meta) {
if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging }; if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
@ -87,75 +252,115 @@ export function listDragHandlePlugin(options = {}) {
} }
return next; return next;
}; };
const decorationsProp = (state) => { const decorationsProp = (state) => {
const ps = listPointerDragKey.getState(state); const ps = listPointerDragKey.getState(state);
if (!ps) return null; if (!ps) return null;
let deco = ps.decorations; let deco = ps.decorations;
if (ps.dropTarget) { if (ps.dropTarget) {
const { start, end, side } = ps.dropTarget; const { start, end, mode } = ps.dropTarget;
const cls = side === 'before' ? classDropBefore : classDropAfter; const cls =
mode === 'before'
? classDropBefore
: mode === 'after'
? classDropAfter
: mode === 'into'
? classDropInto
: classDropOutdent;
deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]); deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
} }
return deco; return deco;
}; };
// ---------- helpers ---------- // ---------- helpers ----------
function setDrag(view, dragging) { const setDrag = (view, dragging) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging })); view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
} const setDrop = (view, drop) =>
function setDrop(view, drop) {
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop })); view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
} const clearAll = (view) =>
function clearAll(view) {
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' })); view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
}
function moveItem(view, fromStart, toPos) { function moveItem(view, fromStart, toPos) {
const { state, dispatch } = view; const { state, dispatch } = view;
const { doc } = state; const { doc } = state;
const orig = doc.nodeAt(fromStart);
if (!orig || !isListItem(orig)) return { ok: false };
const node = doc.nodeAt(fromStart); // no-op if dropping into own range
if (!node || !isListItem(node)) return false; if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
return { ok: true, newStart: fromStart };
// No-op if dropping inside itself // find item depth
if (toPos >= fromStart && toPos <= fromStart + node.nodeSize) return true;
// Resolve a position inside the list_item to read its ancestry
const $inside = doc.resolve(fromStart + 1); const $inside = doc.resolve(fromStart + 1);
// Find the list_item and its parent list
let itemDepth = -1; let itemDepth = -1;
for (let d = $inside.depth; d > 0; d--) { for (let d = $inside.depth; d > 0; d--) {
if ($inside.node(d) === node) { if ($inside.node(d) === orig) {
itemDepth = d; itemDepth = d;
break; break;
} }
} }
if (itemDepth < 0) return false; if (itemDepth < 0) return { ok: false };
const listDepth = itemDepth - 1; const listDepth = itemDepth - 1;
const parentList = $inside.node(listDepth); const parentList = $inside.node(listDepth);
const parentListStart = $inside.before(listDepth); const parentListStart = $inside.before(listDepth);
// If the parent list has only this one child, delete the whole list. // delete item (or entire list if only child)
// Otherwise, just delete the single list_item.
const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart; const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
const deleteTo = const deleteTo =
parentList.childCount === 1 parentList.childCount === 1
? parentListStart + parentList.nodeSize ? parentListStart + parentList.nodeSize
: fromStart + node.nodeSize; : fromStart + orig.nodeSize;
let tr = state.tr.delete(deleteFrom, deleteTo); let tr = state.tr.delete(deleteFrom, deleteTo);
// Map the drop position through the deletion. Use a right bias so // Compute mapped drop point with right bias so "after" stays after
// dropping "after" the deleted block stays after the gap.
const mappedTo = tr.mapping.map(toPos, 1); const mappedTo = tr.mapping.map(toPos, 1);
tr = tr.insert(mappedTo, node); // 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()); dispatch(tr.scrollIntoView());
return true; return { ok: true, newStart: mappedTo };
} }
// Create & update a simple ghost box that follows the pointer
function ensureGhost(view, fromStart) { function ensureGhost(view, fromStart) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = classDraggingGhost; el.className = classDraggingGhost;
@ -168,16 +373,15 @@ export function listDragHandlePlugin(options = {}) {
el.style.width = rect.width + 'px'; el.style.width = rect.width + 'px';
el.style.pointerEvents = 'none'; el.style.pointerEvents = 'none';
el.style.opacity = '0.75'; el.style.opacity = '0.75';
// lightweight content
el.textContent = dom.textContent?.trim().slice(0, 80) || '…'; el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
} }
document.body.appendChild(el); document.body.appendChild(el);
return el; return el;
} }
function updateGhost(ghost, x, y) { const updateGhost = (ghost, dx, dy) => {
if (!ghost) return; if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`;
ghost.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; };
}
// ---------- plugin ---------- // ---------- plugin ----------
return new Plugin({ return new Plugin({
key: listPointerDragKey, key: listPointerDragKey,
@ -185,65 +389,115 @@ export function listDragHandlePlugin(options = {}) {
props: { props: {
decorations: decorationsProp, decorations: decorationsProp,
handleDOMEvents: { handleDOMEvents: {
// Start dragging with a handle press (pointerdown => capture move/up on window)
mousedown(view, event) { mousedown(view, event) {
const target = /** @type {HTMLElement} */ (event.target); const t = /** @type {HTMLElement} */ (event.target);
const handle = target.closest?.(`.${classHandle}`); const handle = t.closest?.(`.${classHandle}`);
if (!handle) return false; if (!handle) return false;
event.preventDefault(); event.preventDefault();
const getPos = handle.pmGetPos; const getPos = handle.pmGetPos;
if (typeof getPos !== 'function') return true; if (typeof getPos !== 'function') return true;
const posInside = getPos(); const posInside = getPos();
const fromStart = posInside - 1; const fromStart = posInside - 1;
// visually select the node if allowed (optional)
try { try {
const { NodeSelection } = require('prosemirror-state'); view.dispatch(
const sel = NodeSelection.create(view.state.doc, fromStart); view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart))
view.dispatch(view.state.tr.setSelection(sel)); );
} catch {} } catch {}
const startMouse = { x: event.clientX, y: event.clientY }; const startMouse = { x: event.clientX, y: event.clientY };
const ghostEl = ensureGhost(view, fromStart); const ghostEl = ensureGhost(view, fromStart);
setDrag(view, { fromStart, startMouse, ghostEl, active: false }); setDrag(view, { fromStart, startMouse, ghostEl, active: false });
const onMove = (e) => { const onMove = (e) => {
const ps = listPointerDragKey.getState(view.state); const ps = listPointerDragKey.getState(view.state);
if (!ps?.dragging) return; if (!ps?.dragging) return;
const dx = e.clientX - ps.dragging.startMouse.x; const dx = e.clientX - ps.dragging.startMouse.x;
const dy = e.clientY - ps.dragging.startMouse.y; const dy = e.clientY - ps.dragging.startMouse.y;
// Mark as active if moved beyond threshold
if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) { if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
setDrag(view, { ...ps.dragging, active: true }); setDrag(view, { ...ps.dragging, active: true });
} }
updateGhost(ps.dragging.ghostEl, dx, dy); updateGhost(ps.dragging.ghostEl, dx, dy);
const info = infoFromCoords(view, e.clientX, e.clientY); const info = infoFromCoords(view, e.clientX, e.clientY);
if (!info) { if (!info) return setDrop(view, null);
setDrop(view, null);
return; // 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 toPos = info.side === 'before' ? info.start : info.end;
const same =
ps.dropTarget &&
ps.dropTarget.start === info.start &&
ps.dropTarget.end === info.end &&
ps.dropTarget.side === info.side;
if (!same) setDrop(view, { start: info.start, end: info.end, side: info.side, toPos });
}; };
const endDrag = (e) => {
const endDrag = () => {
window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mousemove', onMove, true);
window.removeEventListener('mouseup', endDrag, true); window.removeEventListener('mouseup', endDrag, true);
const ps = listPointerDragKey.getState(view.state); const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove(); 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) { if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
const toPos = const { fromStart } = ps.dragging;
ps.dropTarget.side === 'before' ? ps.dropTarget.start : ps.dropTarget.end; const { toPos, mode } = ps.dropTarget;
moveItem(view, ps.dragging.fromStart, toPos);
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); clearAll(view);
}; };
window.addEventListener('mousemove', onMove, true); window.addEventListener('mousemove', onMove, true);
window.addEventListener('mouseup', endDrag, true); window.addEventListener('mouseup', endDrag, true);
return true; return true;
}, },
// Escape cancels
keydown(view, event) { keydown(view, event) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
const ps = listPointerDragKey.getState(view.state); const ps = listPointerDragKey.getState(view.state);