feat: organize lists with drag and drop
This commit is contained in:
+145
-7
@@ -9,6 +9,7 @@ import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, group
|
||||
import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse } from './lib/completion-motion'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||
@@ -39,6 +40,14 @@ const activeView = ref<View>(restoredNavigation.view)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const sidebarCreateOpen = ref(false)
|
||||
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
|
||||
const listMoveMenuOpen = ref(false)
|
||||
const listDrag = ref<{ id: string; pointerId: number; startX: number; startY: number; offsetY: number; lastX: number; lastY: number } | null>(null)
|
||||
const listDropFolderId = ref<string | null | undefined>(undefined)
|
||||
const listReorderTarget = ref('')
|
||||
const listReorderPlacement = ref<'before' | 'after'>('after')
|
||||
let listHandleLongPressTimer: number | undefined
|
||||
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
|
||||
let suppressListClickId = ''
|
||||
const query = ref('')
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
@@ -792,8 +801,137 @@ function runSidebarCreate(kind: 'folder' | 'list') {
|
||||
sidebarCreateOpen.value = false
|
||||
kind === 'folder' ? void createFolder() : void createList(null)
|
||||
}
|
||||
function closeSidebarAction() { sidebarAction.value = null }
|
||||
function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.value = false }
|
||||
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
|
||||
|
||||
function beginListDrag(list: TaskList, pointer: ListDragPointer) {
|
||||
if (list.is_inbox) return
|
||||
clearListHandlePress()
|
||||
listDrag.value = { id: list.id, pointerId: pointer.pointerId, startX: pointer.clientX, startY: pointer.clientY, offsetY: 0, lastX: pointer.clientX, lastY: pointer.clientY }
|
||||
listDropFolderId.value = list.folder_id
|
||||
listReorderTarget.value = list.id
|
||||
captureListDragPointer(pointer)
|
||||
}
|
||||
function startListHandlePress(list: TaskList, event: PointerEvent) {
|
||||
const pointer = snapshotListDragPointer(event)
|
||||
if (event.pointerType !== 'touch') {
|
||||
beginListDrag(list, pointer)
|
||||
return
|
||||
}
|
||||
clearListHandlePress()
|
||||
listHandlePending = { id: list.id, pointer }
|
||||
listHandleLongPressTimer = window.setTimeout(() => beginListDrag(list, pointer), 450)
|
||||
}
|
||||
function moveListHandle(list: TaskList, event: PointerEvent) {
|
||||
if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId) {
|
||||
if (hasExceededLongPressMovement(
|
||||
{ x: listHandlePending.pointer.clientX, y: listHandlePending.pointer.clientY },
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
5,
|
||||
)) clearListHandlePress()
|
||||
return
|
||||
}
|
||||
moveListDrag(list, event)
|
||||
}
|
||||
function clearListHandlePress() {
|
||||
if (listHandleLongPressTimer) window.clearTimeout(listHandleLongPressTimer)
|
||||
listHandleLongPressTimer = undefined
|
||||
listHandlePending = undefined
|
||||
}
|
||||
function resolveListDrop(event: PointerEvent) {
|
||||
const activeId = listDrag.value?.id
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
const row = elements.map((element) => element.closest<HTMLElement>('[data-list-id]')).find((element) => element?.dataset.listId !== activeId)
|
||||
if (row?.dataset.listId) {
|
||||
const target = lists.value.find((item) => item.id === row.dataset.listId)
|
||||
if (target && !target.is_inbox) {
|
||||
listDropFolderId.value = target.folder_id
|
||||
listReorderTarget.value = target.id
|
||||
const rect = row.getBoundingClientRect()
|
||||
listReorderPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
return
|
||||
}
|
||||
}
|
||||
const folder = elements.map((element) => element.closest<HTMLElement>('[data-folder-id]')).find(Boolean)
|
||||
if (folder?.dataset.folderId) {
|
||||
listDropFolderId.value = folder.dataset.folderId
|
||||
listReorderTarget.value = ''
|
||||
expandedFolders.value = new Set(expandedFolders.value).add(folder.dataset.folderId)
|
||||
return
|
||||
}
|
||||
if (elements.some((element) => element.closest('.list-root-drop'))) {
|
||||
listDropFolderId.value = null
|
||||
listReorderTarget.value = ''
|
||||
}
|
||||
}
|
||||
function moveListDrag(list: TaskList, event: PointerEvent) {
|
||||
const drag = listDrag.value
|
||||
if (!drag || drag.id !== list.id) return
|
||||
drag.offsetY = event.clientY - drag.startY
|
||||
drag.lastX = event.clientX
|
||||
drag.lastY = event.clientY
|
||||
resolveListDrop(event)
|
||||
}
|
||||
async function persistListMove(list: TaskList, folderId: string | null, targetId?: string, placement: 'before' | 'after' = 'after') {
|
||||
if (list.is_inbox) return
|
||||
const previous = lists.value
|
||||
const result = moveListToScope(previous, list.id, folderId, targetId, placement)
|
||||
if (result.items === previous) return
|
||||
lists.value = result.items
|
||||
if (folderId) expandedFolders.value = new Set(expandedFolders.value).add(folderId)
|
||||
try {
|
||||
if (list.folder_id !== folderId) {
|
||||
await api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
|
||||
} else {
|
||||
await api('/lists/reorder', { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
|
||||
}
|
||||
toast(folderId ? '清单已移动' : '清单已移出文件夹')
|
||||
} catch (reason) {
|
||||
lists.value = previous
|
||||
fail(reason)
|
||||
}
|
||||
}
|
||||
function finishListDrag(list: TaskList, event: PointerEvent) {
|
||||
clearListHandlePress()
|
||||
const drag = listDrag.value
|
||||
if (!drag || drag.id !== list.id) return
|
||||
const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 5
|
||||
const folderId = listDropFolderId.value ?? null
|
||||
const targetId = listReorderTarget.value && listReorderTarget.value !== list.id ? listReorderTarget.value : undefined
|
||||
const placement = listReorderPlacement.value
|
||||
cancelListDrag()
|
||||
if (!moved && folderId === list.folder_id && !targetId) return
|
||||
suppressListClickId = list.id
|
||||
window.setTimeout(() => { if (suppressListClickId === list.id) suppressListClickId = '' }, 400)
|
||||
void persistListMove(list, folderId, targetId, placement)
|
||||
}
|
||||
function selectListUnlessDragged(list: TaskList) {
|
||||
if (suppressListClickId === list.id) { suppressListClickId = ''; return }
|
||||
void switchView('tasks', list.id)
|
||||
}
|
||||
function cancelListDrag() {
|
||||
clearListHandlePress()
|
||||
listDrag.value = null
|
||||
listDropFolderId.value = undefined
|
||||
listReorderTarget.value = ''
|
||||
}
|
||||
function openListMoveMenu() { listMoveMenuOpen.value = !listMoveMenuOpen.value }
|
||||
function moveListFromMenu(folderId: string | null) {
|
||||
const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null
|
||||
if (!item) return
|
||||
listMoveMenuOpen.value = false
|
||||
void persistListMove(item, folderId)
|
||||
closeSidebarAction()
|
||||
}
|
||||
function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') {
|
||||
return getAdjacentListMove(lists.value, item.id, direction) !== null
|
||||
}
|
||||
function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
|
||||
const move = getAdjacentListMove(lists.value, item.id, direction)
|
||||
if (!move) return
|
||||
void persistListMove(item, item.folder_id, move.targetId, move.placement)
|
||||
closeSidebarAction()
|
||||
}
|
||||
function formatDue(value: string | null, hasTime = true) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
@@ -837,13 +975,13 @@ onMounted(bootstrap)
|
||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
|
||||
</nav>
|
||||
<div class="section-title"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
|
||||
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
|
||||
<div class="folders">
|
||||
<div v-for="folder in folders" :key="folder.id" class="folder-block">
|
||||
<div class="folder-row"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><Ellipsis/></button></span></div>
|
||||
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
|
||||
<div v-for="folder in folders" :key="folder.id" class="folder-block" :data-folder-id="folder.id">
|
||||
<div class="folder-row" :class="{'list-drop-target':listDrag&&listDropFolderId===folder.id&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><Ellipsis/></button></span></div>
|
||||
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
|
||||
</div>
|
||||
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
|
||||
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
|
||||
<template v-if="archivedLists.length">
|
||||
<div class="section-title"><span>已归档清单</span></div>
|
||||
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div>
|
||||
@@ -853,7 +991,7 @@ onMounted(bootstrap)
|
||||
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</nav>
|
||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
|
||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><template v-if="sidebarAction.kind==='lists'"><button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')">上移</button><button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')">下移</button><button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/>移动到文件夹</button><div v-if="listMoveMenuOpen" class="list-move-menu" role="menu" aria-label="选择目标文件夹"><button v-if="(sidebarAction.item as TaskList).folder_id" role="menuitem" @click="moveListFromMenu(null)">移出文件夹</button><button v-for="folder in folders" :key="folder.id" role="menuitem" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)">{{folder.name}}</button></div></template><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -676,6 +676,54 @@ describe('sidebar layout', () => {
|
||||
expect(app).toContain(':title="list.name" :aria-label="list.name"')
|
||||
})
|
||||
|
||||
it('supports dragging movable lists into folders, out to My Lists, and within one scope', () => {
|
||||
expect(app).toContain('class="section-title list-root-drop"')
|
||||
expect(app).toContain(':data-folder-id="folder.id"')
|
||||
expect(app).toContain(':data-list-id="list.id"')
|
||||
expect(app).toContain('class="list-drag-handle"')
|
||||
expect(app).toContain("api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })")
|
||||
expect(app).toContain("} else {\n await api('/lists/reorder'")
|
||||
expect(app).toContain('lists.value = previous')
|
||||
expect(app).toContain('expandedFolders.value = new Set(expandedFolders.value).add(folderId)')
|
||||
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
|
||||
})
|
||||
|
||||
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
|
||||
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
|
||||
expect(app).not.toContain('function startListLongPress')
|
||||
expect(app).not.toContain('listLongPressTimer')
|
||||
expect(app).toContain('@pointerdown.stop="startListHandlePress(list,$event)"')
|
||||
expect(app).toContain('listHandlePending = { id: list.id, pointer }')
|
||||
expect(app).toContain('window.setTimeout(() => beginListDrag(list, pointer), 450)')
|
||||
expect(app).toContain('@pointermove.stop="moveListHandle(list,$event)"')
|
||||
expect(app).toContain("if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId)")
|
||||
expect(app).toContain('hasExceededLongPressMovement(')
|
||||
expect(app).toContain('clearListHandlePress()')
|
||||
expect(app).toContain('moveListDrag(list, event)')
|
||||
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
|
||||
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
|
||||
expect(app).toContain('listHandlePending = undefined')
|
||||
expect(app).toContain('aria-label="上移清单"')
|
||||
expect(app).toContain('aria-label="下移清单"')
|
||||
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
|
||||
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
|
||||
expect(app).toContain('aria-label="移动到文件夹"')
|
||||
expect(app).toContain('role="menu"')
|
||||
expect(app).toContain('role="menuitem"')
|
||||
expect(app).toContain('移出文件夹')
|
||||
})
|
||||
|
||||
it('shows drag lift, folder highlighting, and insertion targets', () => {
|
||||
expect(css).toContain('.list-row.list-dragging{')
|
||||
expect(css).toContain('.folder-row.list-drop-target{')
|
||||
expect(css).toContain('.list-root-drop.list-drop-target{')
|
||||
expect(css).toContain('.list-row.list-reorder-target{')
|
||||
expect(css).toContain('.list-drag-handle{width:44px;min-width:44px;height:44px;')
|
||||
expect(css).toContain('.list-row{touch-action:pan-y}')
|
||||
expect(css).not.toContain('.list-drag-handle{display:none}')
|
||||
expect(css).not.toContain('.list-row.list-dragging{touch-action:none}')
|
||||
})
|
||||
|
||||
it('only restores archived lists from the explicit restore action', () => {
|
||||
expect(app).toContain('class="list-row-main archived-row-label"')
|
||||
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')
|
||||
|
||||
Reference in New Issue
Block a user