refactor(vue): share flat reorder drag

This commit is contained in:
Danila Poyarkov 2026-07-01 20:47:38 +03:00
parent 91db7c5d09
commit 1f2907f502
3 changed files with 198 additions and 54 deletions

View file

@ -46,6 +46,13 @@ export type { MenuActionNode, MenuEntry, MenuSeparatorNode } from '#vue/editor/m
/** Miscellaneous editor-shell helpers. */
export { useViewportKind } from '#vue/editor/viewport-kind/use'
export { useLayerDrag } from '#vue/primitives/LayerTree/useLayerDrag'
export { useFlatReorderDrag } from '#vue/shared/drag/useFlatReorderDrag'
export type {
FlatReorderAxis,
FlatReorderInstruction,
FlatReorderItem,
UseFlatReorderDragOptions
} from '#vue/shared/drag/useFlatReorderDrag'
export { useInlineRename } from '#vue/editor/inline-rename/use'
export { useToolbarState } from '#vue/primitives/Toolbar/useToolbarState'
export { useNodeFontStatus } from '#vue/shared/font-status/use'

View file

@ -0,0 +1,167 @@
import {
attachInstruction,
extractInstruction,
type Instruction
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/list-item'
import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
import {
draggable,
dropTargetForElements,
monitorForElements
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { onScopeDispose, ref } from 'vue'
export type FlatReorderAxis = 'vertical' | 'horizontal'
export type FlatReorderInstruction = Extract<
Instruction,
{ operation: 'reorder-before' | 'reorder-after' }
>
export interface FlatReorderItem {
id: string
}
export interface UseFlatReorderDragOptions<TItem extends FlatReorderItem> {
items: () => readonly TItem[]
onMove: (sourceId: string, targetIndex: number) => void
axis?: FlatReorderAxis
getId?: (item: TItem) => string
}
interface RegisteredItem {
element: HTMLElement
cleanup: () => void
}
function isFlatReorderInstruction(
instruction: Instruction | null
): instruction is FlatReorderInstruction {
return (
!!instruction &&
!instruction.blocked &&
(instruction.operation === 'reorder-before' || instruction.operation === 'reorder-after')
)
}
function edgeForInstruction(instruction: FlatReorderInstruction, axis: FlatReorderAxis) {
if (axis === 'vertical') {
return instruction.operation === 'reorder-before' ? 'top' : 'bottom'
}
return instruction.operation === 'reorder-before' ? 'left' : 'right'
}
export function useFlatReorderDrag<TItem extends FlatReorderItem>({
items,
onMove,
axis = 'vertical',
getId = (item) => item.id
}: UseFlatReorderDragOptions<TItem>) {
const draggingId = ref<string | null>(null)
const instruction = ref<FlatReorderInstruction | null>(null)
const instructionTargetId = ref<string | null>(null)
const registered = new Map<string, RegisteredItem>()
function clearInstruction() {
instruction.value = null
instructionTargetId.value = null
}
function cleanupItem(id: string) {
registered.get(id)?.cleanup()
registered.delete(id)
}
function setupItem(element: HTMLElement | null, item: () => FlatReorderItem) {
const { id } = item()
const current = registered.get(id)
if (current?.element === element) return
cleanupItem(id)
if (!element) return
const cleanup = combine(
draggable({
element,
getInitialData: () => ({ id }),
onDragStart: () => {
draggingId.value = id
},
onDrop: () => {
draggingId.value = null
}
}),
dropTargetForElements({
element,
getData: ({ input, element: target }) =>
attachInstruction(
{ id },
{
input,
element: target,
axis,
operations: {
'reorder-before': 'available',
'reorder-after': 'available'
}
}
),
canDrop: ({ source }) => source.data.id !== id,
onDrag: ({ self }) => {
const nextInstruction = extractInstruction(self.data)
if (!isFlatReorderInstruction(nextInstruction)) {
clearInstruction()
return
}
instruction.value = nextInstruction
instructionTargetId.value = id
},
onDragLeave: clearInstruction,
onDrop: clearInstruction,
getIsSticky: () => true
})
)
registered.set(id, { element, cleanup })
}
const cleanupMonitor = monitorForElements({
onDrop: ({ source, location }) => {
const target = location.current.dropTargets.at(0)
if (!target) return
const sourceId = typeof source.data.id === 'string' ? source.data.id : null
const targetId = typeof target.data.id === 'string' ? target.data.id : null
if (!sourceId || !targetId || sourceId === targetId) return
const dropInstruction = extractInstruction(target.data)
if (!isFlatReorderInstruction(dropInstruction)) return
const currentItems = items()
const startIndex = currentItems.findIndex((item) => getId(item) === sourceId)
const indexOfTarget = currentItems.findIndex((item) => getId(item) === targetId)
const targetIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
axis,
closestEdgeOfTarget: edgeForInstruction(dropInstruction, axis)
})
if (targetIndex !== startIndex) onMove(sourceId, targetIndex)
draggingId.value = null
clearInstruction()
}
})
onScopeDispose(() => {
cleanupMonitor()
for (const id of registered.keys()) cleanupItem(id)
})
return {
draggingId,
instruction,
instructionTargetId,
setupItem
}
}

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, type ComponentPublicInstance } from 'vue'
import { templateRef } from '@vueuse/core'
import {
ContextMenuContent,
@ -10,7 +10,7 @@ import {
} from 'reka-ui'
import type { SceneNode } from '@open-pencil/scene-graph'
import { PageListRoot, useI18n, useInlineRename } from '@open-pencil/vue'
import { PageListRoot, useFlatReorderDrag, useI18n, useInlineRename } from '@open-pencil/vue'
import Tip from '@/components/ui/Tip.vue'
import { useMenuUI } from '@/components/ui/menu'
@ -29,9 +29,12 @@ const { panels, pages: pageMessages } = useI18n()
const menuCls = useMenuUI({ content: 'min-w-36 shadow-[0_8px_30px_rgb(0_0_0/0.4)]' })
const pageActions = ref<Pick<PageActions, 'rename'> | null>(null)
const draggingPageId = ref<string | null>(null)
const dragTargetPageId = ref<string | null>(null)
const dragPlacement = ref<'above' | 'below' | null>(null)
const currentPages = ref<readonly PageItem[]>([])
const currentMovePage = ref<PageActions['move'] | null>(null)
const pageReorder = useFlatReorderDrag<PageItem>({
items: () => currentPages.value,
onMove: (pageId, index) => currentMovePage.value?.(pageId, index)
})
function setPageActions(renamePage: (pageId: string, name: string) => void) {
pageActions.value = { rename: renamePage }
@ -46,49 +49,22 @@ function startRename(pg: PageItem, renamePage: (pageId: string, name: string) =>
rename.start(pg.id, pg.name)
}
function isDraggingTarget(pg: PageItem, placement: 'above' | 'below') {
return dragTargetPageId.value === pg.id && dragPlacement.value === placement
function isDraggingTarget(pg: PageItem, operation: 'reorder-before' | 'reorder-after') {
return (
pageReorder.instructionTargetId.value === pg.id &&
pageReorder.instruction.value?.operation === operation
)
}
function resetDragState() {
draggingPageId.value = null
dragTargetPageId.value = null
dragPlacement.value = null
}
function onDragStart(event: DragEvent, pageId: string) {
draggingPageId.value = pageId
event.dataTransfer?.setData('text/plain', pageId)
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
}
function onDragOver(event: DragEvent, pageId: string) {
if (!draggingPageId.value || draggingPageId.value === pageId) return
event.preventDefault()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
dragTargetPageId.value = pageId
dragPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onDrop(
event: DragEvent,
targetPageId: string,
pages: PageItem[],
function setupPageRowRef(
value: Element | ComponentPublicInstance | null,
pg: PageItem,
pages: readonly PageItem[],
movePage: PageActions['move']
) {
event.preventDefault()
const sourcePageId = draggingPageId.value ?? event.dataTransfer?.getData('text/plain')
const placement = dragPlacement.value
resetDragState()
if (!sourcePageId || !placement || sourcePageId === targetPageId) return
const orderedIds = pages.map((page) => page.id).filter((id) => id !== sourcePageId)
const targetIndex = orderedIds.indexOf(targetPageId)
if (targetIndex === -1) return
movePage(sourcePageId, targetIndex + (placement === 'below' ? 1 : 0))
currentPages.value = pages
currentMovePage.value = movePage
pageReorder.setupItem(value instanceof HTMLElement ? value : null, () => ({ id: pg.id }))
}
</script>
@ -118,19 +94,13 @@ function onDrop(
<ContextMenuTrigger as-child>
<div
data-test-id="pages-row"
:ref="(value) => setupPageRowRef(value, pg, pages, actions.move)"
class="relative cursor-grab active:cursor-grabbing"
:class="draggingPageId === pg.id ? 'opacity-60' : ''"
:class="pageReorder.draggingId.value === pg.id ? 'opacity-60' : ''"
:data-page-id="pg.id"
draggable="true"
@dragstart="onDragStart($event, pg.id)"
@dragenter="onDragOver($event, pg.id)"
@dragover="onDragOver($event, pg.id)"
@dragleave="dragTargetPageId === pg.id && (dragTargetPageId = null)"
@drop="onDrop($event, pg.id, pages, actions.move)"
@dragend="resetDragState"
>
<div
v-if="isDraggingTarget(pg, 'above')"
v-if="isDraggingTarget(pg, 'reorder-before')"
data-test-id="pages-drop-indicator"
class="pointer-events-none absolute inset-x-1 top-0 z-10 h-0.5 rounded-full bg-accent"
/>
@ -172,7 +142,7 @@ function onDrop(
<span class="truncate">{{ pg.name }}</span>
</button>
<div
v-if="isDraggingTarget(pg, 'below')"
v-if="isDraggingTarget(pg, 'reorder-after')"
data-test-id="pages-drop-indicator"
class="pointer-events-none absolute inset-x-1 bottom-0 z-10 h-0.5 rounded-full bg-accent"
/>