refactor(vue): share flat reorder drag
This commit is contained in:
parent
91db7c5d09
commit
1f2907f502
|
|
@ -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'
|
||||
|
|
|
|||
167
packages/vue/src/shared/drag/useFlatReorderDrag.ts
Normal file
167
packages/vue/src/shared/drag/useFlatReorderDrag.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue