feat: organize lists with drag and drop
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer } from './list-drag'
|
||||
|
||||
type ListItem = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||
|
||||
const rows: ListItem[] = [
|
||||
{ id: 'inbox', folder_id: null, name: '收集箱', is_inbox: true },
|
||||
{ id: 'a', folder_id: 'work', name: 'A', is_inbox: false },
|
||||
{ id: 'b', folder_id: 'work', name: 'B', is_inbox: false },
|
||||
{ id: 'c', folder_id: null, name: 'C', is_inbox: false },
|
||||
]
|
||||
|
||||
describe('list drag ordering', () => {
|
||||
it('moves a list into a folder and appends it to that scope', () => {
|
||||
const result = moveListToScope(rows, 'c', 'work')
|
||||
expect(result.items.map((item) => [item.id, item.folder_id])).toEqual([
|
||||
['inbox', null], ['a', 'work'], ['b', 'work'], ['c', 'work'],
|
||||
])
|
||||
expect(result.orderedIds).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('moves a list out to My Lists without including Inbox in ordering', () => {
|
||||
const result = moveListToScope(rows, 'a', null)
|
||||
expect(result.items.map((item) => [item.id, item.folder_id])).toEqual([
|
||||
['inbox', null], ['b', 'work'], ['c', null], ['a', null],
|
||||
])
|
||||
expect(result.orderedIds).toEqual(['c', 'a'])
|
||||
})
|
||||
|
||||
it('sorts within one folder before a target', () => {
|
||||
const result = moveListToScope(rows, 'b', 'work', 'a', 'before')
|
||||
expect(result.items.filter((item) => item.folder_id === 'work').map((item) => item.id)).toEqual(['b', 'a'])
|
||||
expect(result.orderedIds).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('does not allow Inbox to move', () => {
|
||||
const result = moveListToScope(rows, 'inbox', 'work')
|
||||
expect(result.items).toBe(rows)
|
||||
expect(result.orderedIds).toEqual([])
|
||||
})
|
||||
|
||||
it('finds only legal adjacent moves inside the current folder scope', () => {
|
||||
expect(getAdjacentListMove(rows, 'a', 'up')).toBeNull()
|
||||
expect(getAdjacentListMove(rows, 'a', 'down')).toEqual({ targetId: 'b', placement: 'after' })
|
||||
expect(getAdjacentListMove(rows, 'b', 'up')).toEqual({ targetId: 'a', placement: 'before' })
|
||||
expect(getAdjacentListMove(rows, 'b', 'down')).toBeNull()
|
||||
expect(getAdjacentListMove(rows, 'c', 'up')).toBeNull()
|
||||
expect(getAdjacentListMove(rows, 'inbox', 'down')).toBeNull()
|
||||
})
|
||||
|
||||
it('captures the pointer from the stable pointerdown target after currentTarget is cleared', () => {
|
||||
const setPointerCapture = vi.fn()
|
||||
const row = { setPointerCapture } as unknown as Element
|
||||
const event = {
|
||||
pointerId: 7,
|
||||
clientX: 120,
|
||||
clientY: 240,
|
||||
currentTarget: row,
|
||||
} as unknown as PointerEvent
|
||||
|
||||
const pointer = snapshotListDragPointer(event)
|
||||
Object.defineProperty(event, 'currentTarget', { value: null })
|
||||
captureListDragPointer(pointer)
|
||||
|
||||
expect(pointer).toMatchObject({ pointerId: 7, clientX: 120, clientY: 240, captureTarget: row })
|
||||
expect(setPointerCapture).toHaveBeenCalledOnce()
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('cancels a pending long press from cumulative client-coordinate movement', () => {
|
||||
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 14, y: 23 }, 5)).toBe(false)
|
||||
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 14, y: 24 }, 5)).toBe(true)
|
||||
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 4, y: 20 }, 5)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
export type DraggableList = {
|
||||
id: string
|
||||
folder_id: string | null
|
||||
is_inbox: boolean
|
||||
}
|
||||
|
||||
export type ListDragPointer = {
|
||||
pointerId: number
|
||||
clientX: number
|
||||
clientY: number
|
||||
captureTarget: Element | null
|
||||
}
|
||||
|
||||
export function snapshotListDragPointer(event: PointerEvent): ListDragPointer {
|
||||
return {
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
captureTarget: event.currentTarget as Element | null,
|
||||
}
|
||||
}
|
||||
|
||||
export function captureListDragPointer(pointer: ListDragPointer) {
|
||||
try { pointer.captureTarget?.setPointerCapture(pointer.pointerId) } catch { /* synthetic events */ }
|
||||
}
|
||||
|
||||
export function hasExceededLongPressMovement(
|
||||
start: { x: number; y: number },
|
||||
current: { x: number; y: number },
|
||||
threshold: number,
|
||||
) {
|
||||
return Math.hypot(current.x - start.x, current.y - start.y) > threshold
|
||||
}
|
||||
|
||||
export function getAdjacentListMove<T extends DraggableList>(
|
||||
items: T[],
|
||||
sourceId: string,
|
||||
direction: 'up' | 'down',
|
||||
): { targetId: string; placement: 'before' | 'after' } | null {
|
||||
const source = items.find((item) => item.id === sourceId)
|
||||
if (!source || source.is_inbox) return null
|
||||
const scope = items.filter((item) => !item.is_inbox && item.folder_id === source.folder_id)
|
||||
const index = scope.findIndex((item) => item.id === sourceId)
|
||||
const target = scope[index + (direction === 'up' ? -1 : 1)]
|
||||
if (!target) return null
|
||||
return { targetId: target.id, placement: direction === 'up' ? 'before' : 'after' }
|
||||
}
|
||||
|
||||
export function moveListToScope<T extends DraggableList>(
|
||||
items: T[],
|
||||
sourceId: string,
|
||||
folderId: string | null,
|
||||
targetId?: string,
|
||||
placement: 'before' | 'after' = 'after',
|
||||
) {
|
||||
const source = items.find((item) => item.id === sourceId)
|
||||
if (!source || source.is_inbox) return { items, orderedIds: [] as string[] }
|
||||
|
||||
const withoutSource = items.filter((item) => item.id !== sourceId)
|
||||
const moved = { ...source, folder_id: folderId }
|
||||
const scope = withoutSource.filter((item) => !item.is_inbox && item.folder_id === folderId)
|
||||
const targetIndex = targetId ? scope.findIndex((item) => item.id === targetId) : -1
|
||||
const insertAt = targetIndex < 0 ? scope.length : targetIndex + (placement === 'after' ? 1 : 0)
|
||||
scope.splice(insertAt, 0, moved)
|
||||
|
||||
const iterator = scope[Symbol.iterator]()
|
||||
const next = withoutSource.map((item) => {
|
||||
if (item.is_inbox || item.folder_id !== folderId) return item
|
||||
return iterator.next().value!
|
||||
})
|
||||
const remaining = [...iterator]
|
||||
if (remaining.length) next.push(...remaining)
|
||||
return { items: next, orderedIds: scope.map((item) => item.id) }
|
||||
}
|
||||
Reference in New Issue
Block a user