diff --git a/src/app.css b/src/app.css index 93ab57d7f3..e8f4ee137b 100644 --- a/src/app.css +++ b/src/app.css @@ -707,25 +707,29 @@ body { 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-after, -.pm-li-drop-on-left, -.pm-li-drop-on-right { +.pm-li-drop-into, +.pm-li-drop-outdent { position: relative; } +/* BEFORE/AFTER lines */ .pm-li-drop-before::before, -.pm-li-drop-after::after, -.pm-li-drop-on-left::before, -.pm-li-drop-on-right::after { +.pm-li-drop-after::after { content: ''; position: absolute; - left: -24px; /* extend line into gutter past the handle */ + left: 0; right: 0; - height: 2px; - background: currentColor; - opacity: 0.55; + height: 3px; + background: var(--pm-accent); + pointer-events: none; } .pm-li-drop-before::before { top: -2px; @@ -734,20 +738,35 @@ body { bottom: -2px; } -/* existing */ -.pm-li-drop-before { - outline: 2px solid var(--accent); - outline-offset: -2px; -} -.pm-li-drop-after { - outline: 2px solid var(--accent); - outline-offset: -2px; +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + background: var(--pm-fill-target); + border-radius: 6px; } -/* new */ -.pm-li-drop-on-left { - box-shadow: inset 4px 0 0 0 var(--accent); +.pm-li-drop-outdent::before { + content: ''; + 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; } diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index c7f256d20f..fcc62de8fb 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -1099,11 +1099,11 @@ {#if richText && showFormattingToolbar} -
+
-
+
{/if} diff --git a/src/lib/components/common/RichTextInput/listDragHandlePlugin.js b/src/lib/components/common/RichTextInput/listDragHandlePlugin.js index a5e2db5a7e..dace30197f 100644 --- a/src/lib/components/common/RichTextInput/listDragHandlePlugin.js +++ b/src/lib/components/common/RichTextInput/listDragHandlePlugin.js @@ -1,22 +1,173 @@ -// listPointerDragPlugin.js -import { Plugin, PluginKey } from 'prosemirror-state'; +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 = ['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', 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 // ignore tiny wiggles + + // 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); - // ---------- 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; + + // 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) => { @@ -33,15 +184,16 @@ export function listDragHandlePlugin(options = {}) { el.setAttribute('aria-label', 'Drag list item'); el.contentEditable = 'false'; el.innerHTML = handleInnerHTML; - el.pmGetPos = getPos; // live resolver + el.pmGetPos = getPos; return el; }, - { side: -1, ignoreSelection: true, key: `li-handle-${pos}` } + { 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); @@ -52,33 +204,46 @@ export function listDragHandlePlugin(options = {}) { } 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 side = clientY - rect.top < rect.height / 2 ? 'before' : 'after'; - return { ...li, dom, side }; + 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 shape ---------- + + // ---------- state ---------- const init = (state) => ({ decorations: buildHandleDecos(state.doc), - dragging: null, // {fromStart, startMouse: {x,y}, ghostEl} | null - dropTarget: null // {start, end, side} | null + dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active} + dropTarget: null // {start, end, mode, toPos} }); + const apply = (tr, prev) => { - let next = prev; - let decorations = prev.decorations; - if (tr.docChanged) { - decorations = buildHandleDecos(tr.doc); - } else { - decorations = decorations.map(tr.mapping, tr.doc); - } - next = { ...next, decorations }; + 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 }; @@ -87,75 +252,115 @@ export function listDragHandlePlugin(options = {}) { } return next; }; + const decorationsProp = (state) => { const ps = listPointerDragKey.getState(state); if (!ps) return null; let deco = ps.decorations; if (ps.dropTarget) { - const { start, end, side } = ps.dropTarget; - const cls = side === 'before' ? classDropBefore : classDropAfter; + 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 ---------- - function setDrag(view, dragging) { + const setDrag = (view, dragging) => view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging })); - } - function setDrop(view, drop) { + const setDrop = (view, drop) => view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop })); - } - function clearAll(view) { + 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 }; - const node = doc.nodeAt(fromStart); - if (!node || !isListItem(node)) return false; + // no-op if dropping into own range + if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize) + return { ok: true, newStart: fromStart }; - // No-op if dropping inside itself - if (toPos >= fromStart && toPos <= fromStart + node.nodeSize) return true; - - // Resolve a position inside the list_item to read its ancestry + // find item depth const $inside = doc.resolve(fromStart + 1); - - // Find the list_item and its parent list let itemDepth = -1; for (let d = $inside.depth; d > 0; d--) { - if ($inside.node(d) === node) { + if ($inside.node(d) === orig) { itemDepth = d; break; } } - if (itemDepth < 0) return false; + if (itemDepth < 0) return { ok: false }; const listDepth = itemDepth - 1; const parentList = $inside.node(listDepth); const parentListStart = $inside.before(listDepth); - // If the parent list has only this one child, delete the whole list. - // Otherwise, just delete the single list_item. + // delete item (or entire list if only child) const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart; const deleteTo = parentList.childCount === 1 ? parentListStart + parentList.nodeSize - : fromStart + node.nodeSize; + : fromStart + orig.nodeSize; let tr = state.tr.delete(deleteFrom, deleteTo); - // Map the drop position through the deletion. Use a right bias so - // dropping "after" the deleted block stays after the gap. + // Compute mapped drop point with right bias so "after" stays after 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()); - return true; + return { ok: true, newStart: mappedTo }; } - // Create & update a simple ghost box that follows the pointer function ensureGhost(view, fromStart) { const el = document.createElement('div'); el.className = classDraggingGhost; @@ -168,16 +373,15 @@ export function listDragHandlePlugin(options = {}) { el.style.width = rect.width + 'px'; el.style.pointerEvents = 'none'; el.style.opacity = '0.75'; - // lightweight content el.textContent = dom.textContent?.trim().slice(0, 80) || '…'; } document.body.appendChild(el); return el; } - function updateGhost(ghost, x, y) { - if (!ghost) return; - ghost.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; - } + 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, @@ -185,65 +389,115 @@ export function listDragHandlePlugin(options = {}) { props: { decorations: decorationsProp, handleDOMEvents: { - // Start dragging with a handle press (pointerdown => capture move/up on window) mousedown(view, event) { - const target = /** @type {HTMLElement} */ (event.target); - const handle = target.closest?.(`.${classHandle}`); + 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; - // visually select the node if allowed (optional) + try { - const { NodeSelection } = require('prosemirror-state'); - const sel = NodeSelection.create(view.state.doc, fromStart); - view.dispatch(view.state.tr.setSelection(sel)); + 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; - // Mark as active if moved beyond threshold + 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) { - setDrop(view, null); - return; + 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 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('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 toPos = - ps.dropTarget.side === 'before' ? ps.dropTarget.start : ps.dropTarget.end; - moveItem(view, ps.dragging.fromStart, toPos); + 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; }, - // Escape cancels + keydown(view, event) { if (event.key === 'Escape') { const ps = listPointerDragKey.getState(view.state);