Explorar o código

feat(editor): add drag-to-reorder with MoveBlockOp and native HTML5 drag

- Replace static i-lucide-dot gutter icon with draggable i-lucide-grip-vertical
  handle that appears on hover; add plus button for block insertion
- Add MoveBlockOp type and computeInverse (swaps fromIndex/toIndex)
- Add moveBlock() pure function to editor-operations.ts
- Wire @dragstart/@dragover/@drop events in Editor.vue with drop
  target highlighting; record reorder as MoveBlockOp in OperationHistory
- Export MoveBlockOp type and moveBlock function from public API
- Add 5 tests: moveBlock forward/backward/no-op, inverse swap, integration
- Document drag-to-reorder in README with code example
Zander Hawke hai 3 días
pai
achega
92265b805e

+ 16 - 0
packages/editor/README.md

@@ -323,6 +323,22 @@ Each pattern spec defines:
 | `termination` | `"delimiter" \| "boundary"` | `delimiter` matches until `end` is found; `boundary` matches a single word |
 | `priority` | `number` (optional) | Higher priority wins when multiple specs match |
 
+## Drag-to-Reorder
+
+Each block has a draggable handle (`i-lucide-grip-vertical`) in its left gutter that appears on hover. Drag it to reorder blocks. A plus button also appears on hover to insert a new empty block at that position.
+
+- **Native HTML5 drag** — no external drag library required
+- **Undo support** — each drag records a `MoveBlockOp` in the shell's `OperationHistory` (inverse swaps `fromIndex` / `toIndex`)
+- **`moveBlock(blocks, fromIndex, toIndex)`** — pure function in `editor-operations.ts`
+- **`MoveBlockOp`** — operation type with `fromIndex`, `toIndex`, `blockId`, `content`, `depth`, and `timestamp`
+
+```typescript
+import { moveBlock } from "@enesis/editor"
+import type { MoveBlockOp } from "@enesis/editor"
+
+const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
+```
+
 ## Development
 
 ```bash

+ 101 - 4
packages/editor/src/components/Editor.vue

@@ -30,6 +30,7 @@ import {
   indentBlock as opIndentBlock,
   insertBlock as opInsertBlock,
   mergePrevious as opMergePrevious,
+  moveBlock,
   outdentBlock as opOutdentBlock,
   splitBlocks as opSplitBlocks,
 } from "@/lib/editor-operations"
@@ -98,6 +99,76 @@ function focusZone(index: number) {
   zone.focus()
 }
 
+// ── Drag-to-reorder ────────────────────────────────────────────────
+
+const dragState = ref<{
+  fromIndex: number
+  blockId: string
+  content: string
+  depth: number
+} | null>(null)
+const dropTargetIndex = ref<number | null>(null)
+
+function onDragStart(
+  index: number,
+  blockId: string,
+  content: string,
+  depth: number,
+  event: DragEvent,
+) {
+  dragState.value = { fromIndex: index, blockId, content, depth }
+  event.dataTransfer?.setData("text/plain", blockId)
+  if (event.dataTransfer) {
+    event.dataTransfer.effectAllowed = "move"
+  }
+}
+
+function onDragOver(index: number, event: DragEvent) {
+  if (!dragState.value || dragState.value.fromIndex === index) {
+    dropTargetIndex.value = null
+    return
+  }
+  dropTargetIndex.value = index
+  event.dataTransfer!.dropEffect = "move"
+}
+
+function onDragLeave(index: number) {
+  if (dropTargetIndex.value === index) {
+    dropTargetIndex.value = null
+  }
+}
+
+function onDragEnd() {
+  dragState.value = null
+  dropTargetIndex.value = null
+}
+
+function onDrop(targetIndex: number) {
+  const state = dragState.value
+  if (!state || state.fromIndex === targetIndex) {
+    dragState.value = null
+    dropTargetIndex.value = null
+    return
+  }
+
+  blocks.value = moveBlock(blocks.value, state.fromIndex, targetIndex)
+  void history.execute({
+    type: "move-block",
+    fromIndex: state.fromIndex,
+    toIndex: targetIndex,
+    blockId: state.blockId,
+    content: state.content,
+    depth: state.depth,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+  onBlockContentChange()
+
+  dragState.value = null
+  dropTargetIndex.value = null
+}
+
 // ── Undo / Redo history ──────────────────────────────────────────
 
 const history: OperationHistory = createOperationHistory(100)
@@ -185,6 +256,10 @@ function applyOperation(op: EditorOperation): void {
       if (setBlock) focusInfo = { id: setBlock.id, pos: "end" }
       break
     }
+    case "move-block": {
+      blocks.value = moveBlock(blocks.value, op.fromIndex, op.toIndex)
+      break
+    }
   }
   onBlockContentChange()
 
@@ -639,13 +714,35 @@ defineExpose({ undo, redo, canUndo, canRedo })
         @navigate-up="onZoneNavigateUp(i)"
         @navigate-down="onZoneNavigateDown(i)"
       />
-      <div class="flex items-start">
+      <div
+        class="flex items-start group/block"
+        :data-block-index="i"
+        @dragover.prevent="onDragOver(i, $event)"
+        @dragleave="onDragLeave(i)"
+        @drop="onDrop(i)"
+      >
         <div
-          class="flex items-center justify-end h-7 select-none pointer-events-none"
+          class="flex items-center justify-end h-7 select-none"
+          :class="dropTargetIndex === i ? 'bg-(--ui-primary)/10 rounded-l' : ''"
           :style="{ width: (block.depth + 1) * 20 + 'px' }"
-          aria-hidden="true"
         >
-          <UIcon name="i-lucide-dot" class="size-6 text-muted" />
+          <div
+            draggable="true"
+            class="flex items-center justify-center size-6 opacity-0 group-hover/block:opacity-100 cursor-grab active:cursor-grabbing rounded hover:bg-(--ui-bg-elevated) transition-opacity"
+            @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
+            @dragend="onDragEnd"
+          >
+            <UIcon name="i-lucide-grip-vertical" class="size-4 text-(--ui-text-muted)" />
+          </div>
+          <UButton
+            icon="i-lucide-plus"
+            color="neutral"
+            variant="ghost"
+            size="xs"
+            class="opacity-0 group-hover/block:opacity-100 size-5 min-w-0 p-0 transition-opacity"
+            :ui="{ base: 'size-5' }"
+            @click="onZoneActivate(i, '')"
+          />
         </div>
 
         <EditorBlock

+ 23 - 0
packages/editor/src/components/__tests__/editor-integration.test.ts

@@ -14,6 +14,7 @@ import {
 } from "@/lib/operation-history"
 import { createPasteHandler } from "@/composables/usePasteHandler"
 import { insertBlock } from "@/lib/editor-operations"
+import { moveBlock } from "@/lib/editor-operations"
 import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
 import { contentToDoc } from "@/lib/content-model"
 import { schema } from "@/lib/schema"
@@ -360,6 +361,28 @@ describe("paste handling", () => {
   })
 })
 
+describe("drag reorder", () => {
+  it("reorders blocks via moveBlock", () => {
+    const blocks: Array<{ id: string; depth: number; content: string }> = [
+      { id: "a", depth: 0, content: "first" },
+      { id: "b", depth: 1, content: "second" },
+      { id: "c", depth: 2, content: "third" },
+    ]
+
+    // Move block 0 to index 2
+    const reordered = moveBlock(blocks, 0, 2)
+    expect(reordered[0].id).toBe("b")
+    expect(reordered[1].id).toBe("c")
+    expect(reordered[2].id).toBe("a")
+
+    // Move block 2 back to index 0 (inverse)
+    const restored = moveBlock(reordered, 2, 0)
+    expect(restored[0].id).toBe("a")
+    expect(restored[1].id).toBe("b")
+    expect(restored[2].id).toBe("c")
+  })
+})
+
 function stableId(prefix: string): () => string {
   let n = 0
   return () => `${prefix}-${n++}`

+ 2 - 0
packages/editor/src/index.ts

@@ -21,6 +21,7 @@ export type {
   IndentBlockOp,
   InsertBlockOp,
   MergeBlockOp,
+  MoveBlockOp,
   OperationHistory,
   OutdentBlockOp,
   SetBlockContentOp,
@@ -31,6 +32,7 @@ export {
   createOperationHistory,
   setTimeSource,
 } from "@/lib/operation-history"
+export { moveBlock } from "@/lib/editor-operations"
 export type {
   EditorTheme,
   ThemeColors,

+ 38 - 0
packages/editor/src/lib/__tests__/editor-operations.test.ts

@@ -5,6 +5,7 @@ import {
   indentBlock,
   insertBlock,
   mergePrevious,
+  moveBlock,
   outdentBlock,
   setBlockContent,
   splitBlocks,
@@ -295,3 +296,40 @@ describe("setBlockContent", () => {
     expect(blocks[0].content).toBe("x")
   })
 })
+
+describe("moveBlock", () => {
+  it("moves a block from lower to higher index", () => {
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", content: "first" }),
+      block({ id: "b", content: "second" }),
+      block({ id: "c", content: "third" }),
+    ]
+    const result = moveBlock(blocks, 0, 2)
+    expect(result[0].id).toBe("b")
+    expect(result[1].id).toBe("c")
+    expect(result[2].id).toBe("a")
+  })
+
+  it("moves a block from higher to lower index", () => {
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", content: "first" }),
+      block({ id: "b", content: "second" }),
+      block({ id: "c", content: "third" }),
+    ]
+    const result = moveBlock(blocks, 2, 0)
+    expect(result[0].id).toBe("c")
+    expect(result[1].id).toBe("a")
+    expect(result[2].id).toBe("b")
+  })
+
+  it("returns the same array when fromIndex equals toIndex", () => {
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", content: "x" }),
+      block({ id: "b", content: "y" }),
+    ]
+    const result = moveBlock(blocks, 1, 1)
+    expect(result).toHaveLength(2)
+    expect(result[0].id).toBe("a")
+    expect(result[1].id).toBe("b")
+  })
+})

+ 22 - 0
packages/editor/src/lib/__tests__/operation-history.test.ts

@@ -357,4 +357,26 @@ describe("createOperationHistory", () => {
     expect(h.canUndo).toBe(true)
     expect(h.canUndo).toBe(true) // peek did not pop
   })
+
+  it("move-block inverse swaps fromIndex and toIndex", () => {
+    const op: EditorOperation = {
+      type: "move-block",
+      fromIndex: 0,
+      toIndex: 3,
+      blockId: "test-block",
+      content: "moved content",
+      depth: 1,
+      timestamp: 1000,
+    }
+    const inverse = computeInverse(op)
+    expect(inverse).toEqual({
+      type: "move-block",
+      fromIndex: 3,
+      toIndex: 0,
+      blockId: "test-block",
+      content: "moved content",
+      depth: 1,
+      timestamp: 1000,
+    })
+  })
 })

+ 16 - 0
packages/editor/src/lib/editor-operations.ts

@@ -163,3 +163,19 @@ export function setBlockContent(
 ): EditorBlockData[] {
   return blocks.map((b) => (b.id === blockId ? { ...b, content: newContent } : { ...b }))
 }
+
+/**
+ * Move a block from one index to another.
+ * Returns the reordered array. No-op if fromIndex === toIndex.
+ */
+export function moveBlock(
+  blocks: EditorBlockData[],
+  fromIndex: number,
+  toIndex: number,
+): EditorBlockData[] {
+  if (fromIndex === toIndex) return blocks
+  const newBlocks = [...blocks]
+  const [moved] = newBlocks.splice(fromIndex, 1)
+  if (moved) newBlocks.splice(toIndex, 0, moved)
+  return newBlocks
+}

+ 37 - 9
packages/editor/src/lib/operation-history.ts

@@ -106,6 +106,21 @@ export interface SetBlockContentOp {
   timestamp: number
 }
 
+export interface MoveBlockOp {
+  type: "move-block"
+  /** Source index of the block before the move */
+  fromIndex: number
+  /** Target index where the block was moved */
+  toIndex: number
+  /** ID of the moved block */
+  blockId: string
+  /** Content of the moved block */
+  content: string
+  /** Depth of the moved block */
+  depth: number
+  timestamp: number
+}
+
 export type EditorOperation =
   | SplitBlockOp
   | MergeBlockOp
@@ -114,6 +129,7 @@ export type EditorOperation =
   | IndentBlockOp
   | OutdentBlockOp
   | SetBlockContentOp
+  | MoveBlockOp
 
 // ── Execute result ───────────────────────────────────────────────────
 
@@ -203,16 +219,28 @@ export function computeInverse(op: EditorOperation): EditorOperation {
         timestamp: now,
       }
 
-    case "set-block-content":
-      return {
-        type: "set-block-content",
-        blockId: op.blockId,
-        previousContent: op.newContent,
-        newContent: op.previousContent,
-        timestamp: now,
-      }
+      case "set-block-content":
+        return {
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: op.newContent,
+          newContent: op.previousContent,
+          timestamp: now,
+        }
+
+      // Inverse of move is move in the opposite direction.
+      case "move-block":
+        return {
+          type: "move-block",
+          fromIndex: op.toIndex,
+          toIndex: op.fromIndex,
+          blockId: op.blockId,
+          content: op.content,
+          depth: op.depth,
+          timestamp: now,
+        }
+    }
   }
-}
 
 // ── Operation history ────────────────────────────────────────────────