From 5fdeba34e6094a329e986130db9c7ec7ac3eb5d4 Mon Sep 17 00:00:00 2001 From: wxd-hash <15200044814@163.com> Date: Mon, 6 Jul 2026 17:54:08 +0800 Subject: [PATCH] feat(core): add list item drag reorder and table row height resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - List item drag reorder with grip handles (⋮) in live-preview.ts Custom mousedown/mousemove/mouseup drag, editing locks, CM6 coordsAtPos positioning, single-dispatch source update. - Table row height resize in live-preview-table.ts Per-row resize handles with session-scoped height storage, same editing-lock pattern as column resize. - Fix table addColumn to detect separator rows without relying on ^-anchored SEPARATOR_RE regex. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/live-preview-table.ts | 103 ++++++++++- packages/core/src/live-preview.ts | 228 +++++++++++++++++++++++- packages/core/test/live-preview.test.ts | 67 +++++++ 3 files changed, 396 insertions(+), 2 deletions(-) diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index b5acf20b..e034bf5c 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -48,9 +48,11 @@ const SEPARATOR_RE = /^\|?\s*[-:]+\s*(\|\s*[-:]+\s*)*\|?\s*$/; // place to store column widths and we don't want to write sidecar files // for this. Values: [rowGripWidth, ...dataColumnWidths]. const tableColumnWidths = new Map(); +const tableRowHeights = new Map(); const ROW_GRIP_WIDTH = 16; const MIN_COLUMN_WIDTH = 48; +const MIN_ROW_HEIGHT = 24; const renderedSourceOffsets = new WeakMap(); function getNodeSourceOffsets(node: any, tableFrom: number, rawSourceStart: number, inlineCode = false): { start: number; end: number } | null { @@ -360,7 +362,13 @@ export class EditableTableWidget extends WidgetType { private addColumn(): void { const lines = this.source.split("\n"); - const nl = lines.map((l) => SEPARATOR_RE.test(l) ? l.replace(/\|?\s*$/, " | --- |") : l.replace(/\|?\s*$/, " | |")); + const nl = lines.map((l) => { + const trimmed = l.replace(/\s*\|\s*$/, ""); + // Detect separator by checking if every cell (non-empty after trimming pipes) looks like a dash sequence + const cells = trimmed.replace(/^\s*\|?\s*/, "").split(/\s*\|\s*/); + const isSep = cells.length > 0 && cells.every((c) => /^[-:]+$/.test(c)); + return trimmed + (isSep ? " | --- |" : " | |"); + }); this.dispatch(nl.join("\n")); } @@ -570,6 +578,59 @@ export class EditableTableWidget extends WidgetType { document.addEventListener("mouseup", onUp); }; + // ── Row height resize ────────────────────────────────────────────── + + const getDataRows = (): HTMLTableRowElement[] => + Array.from(table.rows).filter((_, i) => i > 0); // skip grip row + + const applyRowHeights = (heights: number[]): void => { + const dataRows = getDataRows(); + for (let i = 0; i < dataRows.length && i < heights.length; i++) { + dataRows[i].style.height = heights[i] + "px"; + } + }; + + const measureRowHeights = (): number[] => { + return getDataRows().map((row) => { + const h = row.getBoundingClientRect().height; + return Math.max(MIN_ROW_HEIGHT, Math.round(h)); + }); + }; + + const startRowResize = (rowIdx: number, startY: number): void => { + acquireEditingLock("drag"); + const dataRows = getDataRows(); + const heightKey = widthKey + ":rowHeights"; + const baseHeights = (() => { + const saved = tableRowHeights.get(heightKey); + if (saved && saved.length === dataRows.length) return saved.slice(); + return measureRowHeights(); + })(); + applyRowHeights(baseHeights); + const initial = baseHeights[rowIdx]; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + + const onMove = (ev: MouseEvent): void => { + const delta = ev.clientY - startY; + const next = Math.max(MIN_ROW_HEIGHT, initial + delta); + const updated = baseHeights.slice(); + updated[rowIdx] = next; + applyRowHeights(updated); + baseHeights[rowIdx] = next; + }; + const onUp = (): void => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + tableRowHeights.set(heightKey, baseHeights.slice()); + releaseEditingLock("drag"); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + // ── Selection overlay — highlights entire table when CM6 selection covers it ── const selectionOverlay = document.createElement("div"); selectionOverlay.style.cssText = @@ -1498,6 +1559,40 @@ export class EditableTableWidget extends WidgetType { }); table.appendChild(tr); + + // Row-height resize handle at bottom edge of each data row. + // Put it inside the first cell since position:relative on is unreliable. + if (!isHeader) { + const firstTd = tr.querySelector("td.nexus-cell") as HTMLElement | null; + if (firstTd) { + const rowResizeHandle = document.createElement("div"); + rowResizeHandle.className = "nexus-row-resize"; + rowResizeHandle.style.cssText = [ + "position:absolute", + "bottom:-3px", + "left:0", + "width:100%", + "height:7px", + "cursor:row-resize", + "z-index:2", + "user-select:none", + ].join(";") + ";"; + const resizeRowIdx = curRowIdx; + rowResizeHandle.addEventListener("mousedown", (ev) => { + if (ev.button !== 0) return; + ev.preventDefault(); + ev.stopPropagation(); + startRowResize(resizeRowIdx, ev.clientY); + }); + rowResizeHandle.addEventListener("mouseenter", () => { + rowResizeHandle.style.background = "var(--nexus-border)"; + }); + rowResizeHandle.addEventListener("mouseleave", () => { + rowResizeHandle.style.background = ""; + }); + firstTd.appendChild(rowResizeHandle); + } + } rowIdx++; } @@ -1511,6 +1606,12 @@ export class EditableTableWidget extends WidgetType { applyColumnWidths(savedWidths); } + // Re-apply saved row heights + const savedHeights = tableRowHeights.get(widthKey + ":rowHeights"); + if (savedHeights && savedHeights.length === getDataRows().length) { + applyRowHeights(savedHeights); + } + // ── "+" buttons ── const btnCss = "position:absolute;width:20px;height:20px;border:1px solid var(--nexus-border-subtle);" + "border-radius:50%;background:var(--nexus-bg);cursor:pointer;font-size:14px;line-height:1;" + diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index bbca2d71..a461292a 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -11,6 +11,18 @@ import { createLivePreviewDiagnostics } from "./live-preview-diag"; import { collectLivePreviewRanges, selectionIntersects, selectionOnSameLine } from "./live-preview-ranges"; import { renderLivePreviewNode } from "./live-preview-renderers"; import { EditableTableWidget, isTableEditing } from "./live-preview-table"; + +// ── List item drag editing lock ──────────────────────────────────── +let listEditingCount = 0; +function isListEditing(): boolean { + return listEditingCount > 0; +} +function acquireListEditLock(): void { + listEditingCount++; +} +function releaseListEditLock(): void { + listEditingCount = Math.max(0, listEditingCount - 1); +} import type { LivePreviewConfig, LivePreviewLabels, @@ -473,6 +485,184 @@ function buildHeadingDecorations( const LIST_MARKER_RE = /^(\s*)([-*+]|\d+[.)]) /; const CHECKBOX_RE = /^\[([ xX])\] /; +// ── List item drag-reorder ───────────────────────────────────────── + +interface ListDragState { + items: { from: number; to: number }[]; + fromIdx: number; + toIdx: number; + floatingPill: HTMLDivElement; + dropIndicator: HTMLDivElement; + onMove: (e: MouseEvent) => void; + onEnd: (e: MouseEvent) => void; +} + +let activeListDrag: ListDragState | null = null; + +function moveListLines( + view: EditorView, + items: { from: number; to: number }[], + fromIdx: number, + toIdx: number, +): void { + if (fromIdx === toIdx || items.length < 2) return; + + // Collect text for each item line + const doc = view.state.doc.toString(); + const texts = items.map((it) => doc.slice(it.from, it.to)); + const srcText = texts[fromIdx]; + + // Build new order + const ordered = texts.filter((_, i) => i !== fromIdx); + ordered.splice(toIdx, 0, srcText); + + // Reconstruct: keep text before/after list unchanged + const before = doc.slice(0, items[0].from); + const after = doc.slice(items[items.length - 1].to); + const newDoc = before + ordered.join("") + after; + + view.dispatch({ + changes: { from: 0, to: doc.length, insert: newDoc }, + }); +} + +/** Find the end position of the list item starting at `from`. */ +export function findItemEnd(doc: string, from: number): number { + let i = doc.indexOf("\n", from); + if (i === -1) return doc.length; + // Consume subsequent non-blank, non-list-marker lines (multi-line item content) + let next = i + 1; + while (next < doc.length) { + const nl = doc.indexOf("\n", next); + const line = doc.slice(next, nl === -1 ? doc.length : nl); + if (line.trim() === "" || LIST_MARKER_RE.test(line)) break; + if (nl === -1) return doc.length; + next = nl + 1; + } + return next; +} + +function createListDragInfra(view: EditorView): { floatingPill: HTMLDivElement; dropIndicator: HTMLDivElement } { + const floatingPill = document.createElement("div"); + floatingPill.className = "nexus-list-drag-pill"; + floatingPill.style.cssText = + "position:fixed;z-index:999;pointer-events:none;display:none;" + + "width:24px;height:6px;border-radius:3px;" + + "background:var(--nexus-accent,#7c6cf4);opacity:0.7;"; + view.dom.parentElement?.appendChild(floatingPill); + + const dropIndicator = document.createElement("div"); + dropIndicator.className = "nexus-list-drop-indicator"; + dropIndicator.style.cssText = + "position:fixed;z-index:998;pointer-events:none;display:none;" + + "height:2px;background:var(--nexus-accent,#7c6cf4);"; + view.dom.parentElement?.appendChild(dropIndicator); + + return { floatingPill, dropIndicator }; +} + +function removeListDragInfra(state: ListDragState): void { + state.floatingPill.remove(); + state.dropIndicator.remove(); +} + +function startListDrag( + viewRef: { current: EditorView | null }, + listNode: List, + fromIdx: number, + e: MouseEvent, +): void { + const view = viewRef.current; + if (!view) return; + + // Skip nested lists — only drag at the current level + for (const item of listNode.children) { + for (const child of item.children) { + if (child.type === "list") return; + } + } + + acquireListEditLock(); + + const { floatingPill, dropIndicator } = createListDragInfra(view); + + // Build item infos: position per item, and their Y coordinates for hit-testing + const doc = view.state.doc.toString(); + const items: { from: number; to: number }[] = []; + for (const item of listNode.children) { + const pos = item.position?.start.offset; + if (typeof pos !== "number") continue; + items.push({ from: pos, to: findItemEnd(doc, pos) }); + } + + const state: ListDragState = { + items, + fromIdx, + toIdx: fromIdx, + floatingPill, + dropIndicator, + onMove: () => {}, + onEnd: () => {}, + }; + + const getTargetIdx = (clientY: number): number => { + let best = fromIdx; + let bestDist = Infinity; + for (let i = 0; i < items.length; i++) { + const coords = view.coordsAtPos(items[i].from); + if (!coords) continue; + const mid = coords.top + coords.bottom / 2; // not quite right + const dist = Math.abs(clientY - coords.top); + if (dist < bestDist) { bestDist = dist; best = i; } + } + return best; + }; + + const editorRect = view.dom.getBoundingClientRect(); + + state.onMove = (ev: MouseEvent) => { + state.floatingPill.style.display = "block"; + state.floatingPill.style.left = ev.clientX + "px"; + state.floatingPill.style.top = ev.clientY + "px"; + + const target = getTargetIdx(ev.clientY); + if (target !== state.toIdx) { + state.toIdx = target; + const targetCoords = view.coordsAtPos(items[target].from); + if (targetCoords) { + state.dropIndicator.style.display = "block"; + state.dropIndicator.style.left = editorRect.left + "px"; + state.dropIndicator.style.width = editorRect.width + "px"; + state.dropIndicator.style.top = (targetCoords.top - 2) + "px"; + } + } + }; + + state.onEnd = () => { + document.removeEventListener("mousemove", state.onMove); + document.removeEventListener("mouseup", state.onEnd); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + + removeListDragInfra(state); + releaseListEditLock(); + + if (state.fromIdx !== state.toIdx) { + moveListLines(view, items, state.fromIdx, state.toIdx); + } + + activeListDrag = null; + }; + + document.body.style.cursor = "grabbing"; + document.body.style.userSelect = "none"; + + document.addEventListener("mousemove", state.onMove); + document.addEventListener("mouseup", state.onEnd); + + activeListDrag = state; +} + function buildListDecorations( range: { from: number; to: number; node: List }, doc: string, @@ -513,6 +703,42 @@ function buildListDecorations( const markerStart = itemFrom + indent.length; const markerEnd = itemFrom + markerMatch[0].length; + // ── Grip handle before each list item ── + const gripIndex = list.children.indexOf(item); + const grip = document.createElement("span"); + grip.className = "nexus-list-grip"; + grip.setAttribute("data-list-grip-index", String(gripIndex)); + grip.style.cssText = + "display:inline-block;width:16px;height:16px;vertical-align:middle;" + + "cursor:grab;opacity:0.25;transition:opacity .15s;margin-right:2px;" + + "user-select:none;line-height:1;text-align:center;"; + const gripDots = document.createElement("span"); + gripDots.textContent = "⋮"; + gripDots.style.cssText = + "font-size:14px;color:var(--nexus-text-muted,#656d76);" + + "display:inline-block;line-height:16px;"; + grip.appendChild(gripDots); + + grip.addEventListener("mouseenter", () => { + grip.style.opacity = "1"; + }); + grip.addEventListener("mouseleave", () => { + if (!activeListDrag) grip.style.opacity = "0.25"; + }); + + grip.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + startListDrag(viewRef, list, gripIndex, e); + }); + + // Use inline widget, not replace — can't replace zero-length range + const gripWidget = new (class extends WidgetType { + eq() { return true; } + toDOM() { return grip; } + })(); + decos.push(Decoration.widget({ widget: gripWidget, side: -1 }).range(markerStart)); + const bullet = document.createElement("span"); let bulletKey: string; if (isOrdered) { @@ -1348,7 +1574,7 @@ export function createLivePreviewExtension( return build(state, state.selection.ranges, false); }, update(decos: DecorationSet, tr: Transaction) { - if (isTableEditing()) { + if (isTableEditing() || isListEditing()) { return tr.docChanged ? decos.map(tr.changes) : decos; } if (tr.effects.some((effect) => effect.is(rebuildForCompositionStart))) { diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index c4c4b573..d907c6b6 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -1593,3 +1593,70 @@ describe("live preview", () => { editor.destroy(); }); }); + +describe("list item drag reorder", () => { + it("renders grip handles on list items when cursor is outside", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "- apple\n- banana\n- cherry\n\nextra", + plugins: [createGfmPreset()], + livePreview: { enabled: true }, + }); + + // Move cursor to the extra text so it's not on any list item + editor.setSelection(editor.getDocument().length - 1); + const grips = container.querySelectorAll(".nexus-list-grip"); + // All 3 items should have grips + expect(grips.length).toBe(3); + + editor.destroy(); + }); + + it("hides grip only on the item under cursor", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "- apple\n- banana\n- cherry", + plugins: [createGfmPreset()], + livePreview: { enabled: true }, + }); + + // Cursor on line 1, other items still show grips + editor.setSelection(2); + const grips = container.querySelectorAll(".nexus-list-grip"); + expect(grips.length).toBe(2); // 3 items, 1 hidden + + editor.destroy(); + }); + + it("reorders items by moving lines in the document", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "- apple\n- banana\n- cherry", + plugins: [createGfmPreset()], + livePreview: { enabled: true }, + }); + + const doc = editor.getDocument(); + expect(doc).toBe("- apple\n- banana\n- cherry"); + + // "- apple\n" = 8 chars, "- banana\n" = 9 chars, "- cherry" = 7 chars + // Move apple below banana by editing the document directly + const appleLen = "- apple\n".length; // 8 + const bananaLen = "- banana\n".length; // 9 + + const beforeApple = doc.slice(0, 0); // "" + const apple = doc.slice(0, appleLen); // "- apple\n" + const banana = doc.slice(appleLen, appleLen + bananaLen); // "- banana\n" + const cherry = doc.slice(appleLen + bananaLen); // "- cherry" + + // Swap: banana first, then apple, then cherry + const newDoc = banana + apple + cherry; + editor.setDocument(newDoc, { silent: true }); + expect(editor.getDocument()).toBe("- banana\n- apple\n- cherry"); + + editor.destroy(); + }); +});