Timothy Jaeryang Baek 2 semanas atrás
pai
commit
aeb5288a3c

+ 41 - 22
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-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+	background: var(--pm-fill-target);
+	border-radius: 6px;
 }
-.pm-li-drop-after {
-	outline: 2px solid var(--accent);
-	outline-offset: -2px;
+
+.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);
 }
 
-/* new */
-.pm-li-drop-on-left {
-	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-on-right {
-	box-shadow: inset -4px 0 0 0 var(--accent);
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+	position: relative;
+	z-index: 0;
 }

+ 2 - 2
src/lib/components/common/RichTextInput.svelte

@@ -1099,11 +1099,11 @@
 </script>
 
 {#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} />
 	</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} />
 	</div>
 {/if}

+ 328 - 74
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 inside itself
-		if (toPos >= fromStart && toPos <= fromStart + node.nodeSize) return true;
+		// no-op if dropping into own range
+		if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
+			return { ok: true, newStart: fromStart };
 
-		// 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);