|
|
@@ -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
|