浏览代码

enh: note drag handle

Timothy Jaeryang Baek 2 周之前
父节点
当前提交
e4e97e727e

+ 90 - 0
src/app.css

@@ -661,3 +661,93 @@ body {
 	background: #171717;
 	background: #171717;
 	color: #eee;
 	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);
+}

+ 16 - 0
src/lib/components/common/RichTextInput.svelte

@@ -173,6 +173,7 @@
 	};
 	};
 
 
 	export let richText = true;
 	export let richText = true;
+	export let dragHandle = false;
 	export let link = false;
 	export let link = false;
 	export let image = false;
 	export let image = false;
 	export let fileHandler = 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 () => {
 	onMount(async () => {
 		content = value;
 		content = value;
 
 
@@ -658,6 +673,7 @@
 				StarterKit.configure({
 				StarterKit.configure({
 					link: link
 					link: link
 				}),
 				}),
+				...(dragHandle ? [ListItemDragHandle] : []),
 				Placeholder.configure({ placeholder: () => _placeholder }),
 				Placeholder.configure({ placeholder: () => _placeholder }),
 				SelectionDecoration,
 				SelectionDecoration,
 
 

+ 259 - 0
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;
+				}
+			}
+		}
+	});
+}

+ 1 - 0
src/lib/components/notes/NoteEditor.svelte

@@ -1216,6 +1216,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
 							collaboration={true}
 							collaboration={true}
 							socket={$socket}
 							socket={$socket}
 							user={$user}
 							user={$user}
+							dragHandle={true}
 							link={true}
 							link={true}
 							image={true}
 							image={true}
 							{files}
 							{files}