diff --git a/src/app.css b/src/app.css index f52b58355a..93ab57d7f3 100644 --- a/src/app.css +++ b/src/app.css @@ -661,3 +661,93 @@ body { background: #171717; color: #eee; } + +/* Position the handle relative to each LI */ +.pm-li--with-handle { + position: relative; + margin-left: 12px; /* make space for the handle */ +} + +.tiptap ul[data-type='taskList'] .pm-list-drag-handle { + margin-left: 0px; +} + +/* The drag handle itself */ +.pm-list-drag-handle { + position: absolute; + left: -36px; /* pull into the left gutter */ + top: 1px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + border-radius: 4px; + cursor: grab; + user-select: none; + opacity: 0.35; + transition: + opacity 120ms ease, + background 120ms ease; +} + +.tiptap ul[data-type='taskList'] .pm-list-drag-handle { + left: -16px; /* pull into the left gutter more to avoid the checkbox */ +} + +.pm-list-drag-handle:active { + cursor: grabbing; +} +.pm-li--with-handle:hover > .pm-list-drag-handle { + opacity: 1; +} +.pm-list-drag-handle:hover { + background: rgba(0, 0, 0, 0.06); +} + +/* Drop indicators: draw a line before/after the LI */ +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-on-left, +.pm-li-drop-on-right { + position: relative; +} + +.pm-li-drop-before::before, +.pm-li-drop-after::after, +.pm-li-drop-on-left::before, +.pm-li-drop-on-right::after { + content: ''; + position: absolute; + left: -24px; /* extend line into gutter past the handle */ + right: 0; + height: 2px; + background: currentColor; + opacity: 0.55; +} +.pm-li-drop-before::before { + top: -2px; +} +.pm-li-drop-after::after { + 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; +} + +/* new */ +.pm-li-drop-on-left { + box-shadow: inset 4px 0 0 0 var(--accent); +} +.pm-li-drop-on-right { + box-shadow: inset -4px 0 0 0 var(--accent); +} diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 468813aeaf..c7f256d20f 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -173,6 +173,7 @@ }; export let richText = true; + export let dragHandle = false; export let link = false; export let image = false; export let fileHandler = false; @@ -602,6 +603,20 @@ } }); + import { listDragHandlePlugin } from './RichTextInput/listDragHandlePlugin.js'; + + const ListItemDragHandle = Extension.create({ + name: 'listItemDragHandle', + addProseMirrorPlugins() { + return [ + listDragHandlePlugin({ + itemTypeNames: ['listItem', 'taskItem'], + getEditor: () => this.editor + }) + ]; + } + }); + onMount(async () => { content = value; @@ -658,6 +673,7 @@ StarterKit.configure({ link: link }), + ...(dragHandle ? [ListItemDragHandle] : []), Placeholder.configure({ placeholder: () => _placeholder }), SelectionDecoration, diff --git a/src/lib/components/common/RichTextInput/listDragHandlePlugin.js b/src/lib/components/common/RichTextInput/listDragHandlePlugin.js new file mode 100644 index 0000000000..a5e2db5a7e --- /dev/null +++ b/src/lib/components/common/RichTextInput/listDragHandlePlugin.js @@ -0,0 +1,259 @@ +// listPointerDragPlugin.js +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +export const listPointerDragKey = new PluginKey('listPointerDrag'); +export function listDragHandlePlugin(options = {}) { + const { + itemTypeNames = ['list_item'], // add 'taskItem' if using tiptap task-list + 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', + classDraggingGhost = 'pm-li-ghost', + dragThresholdPx = 2 // ignore tiny wiggles + } = options; + const itemTypesSet = new Set(itemTypeNames); + const isListItem = (node) => node && itemTypesSet.has(node.type.name); + // ---------- decoration builder ---------- + 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; // live resolver + return el; + }, + { side: -1, ignoreSelection: true, key: `li-handle-${pos}` } + ) + ); + }); + 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 side = clientY - rect.top < rect.height / 2 ? 'before' : 'after'; + return { ...li, dom, side }; + } + // ---------- state shape ---------- + const init = (state) => ({ + decorations: buildHandleDecos(state.doc), + dragging: null, // {fromStart, startMouse: {x,y}, ghostEl} | null + dropTarget: null // {start, end, side} | null + }); + 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 }; + 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, side } = ps.dropTarget; + const cls = side === 'before' ? classDropBefore : classDropAfter; + deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]); + } + return deco; + }; + // ---------- helpers ---------- + function setDrag(view, dragging) { + view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging })); + } + function setDrop(view, drop) { + view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop })); + } + function clearAll(view) { + view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' })); + } + function moveItem(view, fromStart, toPos) { + const { state, dispatch } = view; + const { doc } = state; + + const node = doc.nodeAt(fromStart); + if (!node || !isListItem(node)) return false; + + // 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 + 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) { + itemDepth = d; + break; + } + } + if (itemDepth < 0) return 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. + const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart; + const deleteTo = + parentList.childCount === 1 + ? parentListStart + parentList.nodeSize + : fromStart + node.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. + const mappedTo = tr.mapping.map(toPos, 1); + + tr = tr.insert(mappedTo, node); + + dispatch(tr.scrollIntoView()); + return true; + } + + // Create & update a simple ghost box that follows the pointer + 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'; + // 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)`; + } + // ---------- plugin ---------- + return new Plugin({ + key: listPointerDragKey, + state: { init: (_, state) => init(state), apply }, + 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}`); + 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)); + } 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; + } + 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) => { + window.removeEventListener('mousemove', onMove, true); + window.removeEventListener('mouseup', endDrag, true); + const ps = listPointerDragKey.getState(view.state); + if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove(); + 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); + } + 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); + if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove(); + clearAll(view); + return true; + } + return false; + } + } + } + }); +} diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index eb4fb9cfbd..1e6a6b69cf 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -1216,6 +1216,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, collaboration={true} socket={$socket} user={$user} + dragHandle={true} link={true} image={true} {files}