diff --git a/frontend/e2e/desktop-shell-resize.spec.ts b/frontend/e2e/desktop-shell-resize.spec.ts new file mode 100644 index 0000000..55df7b8 --- /dev/null +++ b/frontend/e2e/desktop-shell-resize.spec.ts @@ -0,0 +1,89 @@ +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test } from './fixtures' + +async function csrf(request: APIRequestContext) { + const state = await request.storageState() + return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? '' +} + +async function createTask(request: APIRequestContext, baseURL: string, title: string) { + const bootstrap = await request.get('/api/v1/bootstrap') + expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy() + const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) + const response = await request.post('/api/v1/tasks', { + data: { title, list_id: inbox.id }, + headers: { 'x-csrf-token': await csrf(request), origin: baseURL }, + }) + expect(response.ok(), await response.text()).toBeTruthy() +} + +async function dragSeparator(page: Page, label: string, deltaX: number) { + const separator = page.getByRole('separator', { name: label }) + const box = await separator.boundingBox() + expect(box).not.toBeNull() + const x = box!.x + box!.width / 2 + const y = box!.y + Math.min(120, box!.height / 2) + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x + deltaX, y, { steps: 5 }) + await page.mouse.up() +} + +test('desktop panes resize by pointer and keyboard and restore after reload', async ({ page, request, baseURL }, testInfo) => { + test.skip((page.viewportSize()?.width ?? 0) <= 930, 'desktop-only behavior') + const title = `三栏拖动-${testInfo.project.name}-${Date.now()}` + await createTask(request, baseURL!, title) + await page.goto('/') + + const sidebar = page.locator('.sidebar') + const main = page.locator('main') + const sidebarSeparator = page.getByRole('separator', { name: '调整菜单栏宽度' }) + await expect(sidebarSeparator).toBeVisible() + await expect(sidebarSeparator).toHaveAttribute('aria-valuenow', '236') + + const sidebarBefore = await sidebar.boundingBox() + await dragSeparator(page, '调整菜单栏宽度', 64) + const sidebarDraggedWidth = Number(await sidebarSeparator.getAttribute('aria-valuenow')) + expect(sidebarDraggedWidth - sidebarBefore!.width).toBeGreaterThanOrEqual(63) + expect(sidebarDraggedWidth - sidebarBefore!.width).toBeLessThanOrEqual(65) + await expect(sidebar).toHaveCSS('width', `${sidebarDraggedWidth}px`) + const sidebarAfter = await sidebar.boundingBox() + expect((await main.boundingBox())!.x).toBeCloseTo(sidebarAfter!.x + sidebarAfter!.width, 0) + + await sidebarSeparator.press('ArrowLeft') + const sidebarKeyboardWidth = sidebarDraggedWidth - 12 + await expect(sidebarSeparator).toHaveAttribute('aria-valuenow', String(sidebarKeyboardWidth)) + await expect(sidebar).toHaveCSS('width', `${sidebarKeyboardWidth}px`) + + await sidebar.getByRole('button', { name: '收集箱', exact: true }).click() + const row = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) + await expect(row).toHaveCount(1) + await row.locator('.task-main').click() + + const detail = page.getByRole('dialog', { name: '任务详情' }) + const detailSeparator = page.getByRole('separator', { name: '调整任务详情宽度' }) + await expect(detailSeparator).toBeVisible() + const detailBefore = await detail.boundingBox() + await dragSeparator(page, '调整任务详情宽度', -70) + const detailAfter = await detail.boundingBox() + const expectedDetailWidth = Math.min(detailBefore!.width + 70, (page.viewportSize()?.width ?? 0) - sidebarKeyboardWidth - 330) + expect(detailAfter!.width).toBeCloseTo(expectedDetailWidth, 0) + const mainAfter = await main.boundingBox() + expect(mainAfter!.x + mainAfter!.width).toBeCloseTo(detailAfter!.x, 0) + + const widths = await page.evaluate(() => ({ + sidebar: localStorage.getItem('dodo.desktop-pane.sidebar'), + detail: localStorage.getItem('dodo.desktop-pane.detail'), + })) + expect(widths).toEqual({ sidebar: String(Math.round(sidebarKeyboardWidth)), detail: String(Math.round(detailAfter!.width)) }) + + await page.reload() + await expect(sidebar).toHaveCSS('width', `${Math.round(sidebarKeyboardWidth)}px`) + const restoredRow = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) + await restoredRow.locator('.task-main').click() + await expect(detail).toHaveCSS('width', `${Math.round(detailAfter!.width)}px`) + + await page.setViewportSize({ width: 930, height: 900 }) + await expect(sidebarSeparator).toBeHidden() + await expect(detailSeparator).toBeHidden() +}) diff --git a/frontend/e2e/task-detail-paper-flow.spec.ts b/frontend/e2e/task-detail-paper-flow.spec.ts index f011003..47f389b 100644 --- a/frontend/e2e/task-detail-paper-flow.spec.ts +++ b/frontend/e2e/task-detail-paper-flow.spec.ts @@ -75,7 +75,7 @@ test('task detail paper flow keeps approved responsive geometry and material', a expect(Math.abs((footerBox.y + footerBox.height) - (detailBox.y + detailBox.height))).toBeLessThanOrEqual(2) if (viewport.width >= 931) { - expect(metrics.width).toBeCloseTo(350, 0) + expect(metrics.width).toBeCloseTo(Number(await page.getByRole('separator', { name: '调整任务详情宽度' }).getAttribute('aria-valuenow')), 0) const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect())) expect(columns[0].top).toBeCloseTo(columns[1].top, 0) expect(columns[1].left - columns[0].right).toBeCloseTo(10, 0) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ac8895d..4090fb6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,6 +11,7 @@ import { csrfHeader } from './lib/csrf' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { deriveMemoShellState } from './lib/app-shell-state' +import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth, type DesktopPane } from './lib/desktop-shell-resize' import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' @@ -81,6 +82,18 @@ const loading = ref(false) const taskDueNowMs = useTaskDueClock() const mobileSidebar = ref(false) const sidebarCollapsed = ref(false) +const SIDEBAR_WIDTH_STORAGE_KEY = 'dodo.desktop-pane.sidebar' +const DETAIL_WIDTH_STORAGE_KEY = 'dodo.desktop-pane.detail' +const desktopViewportWidth = ref(window.innerWidth) +const sidebarWidth = ref(readDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, 236)) +const detailWidth = ref(readDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, 350)) +const resizingPane = ref(null) +const sidebarMaxWidth = computed(() => getDesktopPaneMax('sidebar', desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)) +const detailMaxWidth = computed(() => getDesktopPaneMax('detail', desktopViewportWidth.value, sidebarCollapsed.value ? 0 : sidebarWidth.value)) +const detailSeparatorLabel = computed(() => habitDetailOpen.value && !selectedTask.value ? '调整习惯详情宽度' : '调整任务详情宽度') +const shellStyle = computed(() => ({ '--sidebar-width': `${sidebarWidth.value}px`, '--detail-width': `${detailWidth.value}px` })) +let paneResizePointerId: number | null = null +let paneResizeCaptureTarget: HTMLElement | null = null const mobileDetail = ref(false) const moreSettingsOpen = ref(false) const markdownPreview = ref(false) @@ -169,7 +182,7 @@ const memoPanel = ref | null>(null) const memoTrash = ref(false) const memoDetailOpen = ref(false) const habitDetailOpen = ref(false) -const compactLayout = ref(window.innerWidth <= 930) +const compactLayout = ref(desktopViewportWidth.value <= 930) const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value })) const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert) const showFloatingAdd = computed(() => memoShellState.value.showFab) @@ -318,6 +331,44 @@ function toggleSidebar() { sidebarCollapsed.value = !sidebarCollapsed.value } } +function stopPaneResize(event?: PointerEvent) { + if (!resizingPane.value || (event && paneResizePointerId !== null && event.pointerId !== paneResizePointerId)) return + const pane = resizingPane.value + const pointerId = paneResizePointerId + const captureTarget = paneResizeCaptureTarget + if (pane === 'sidebar') writeDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, sidebarWidth.value) + else writeDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, detailWidth.value) + resizingPane.value = null + paneResizePointerId = null + paneResizeCaptureTarget = null + if (captureTarget && pointerId !== null && captureTarget.hasPointerCapture?.(pointerId)) captureTarget.releasePointerCapture(pointerId) +} +function handlePaneLostPointerCapture(event: PointerEvent) { + if (event.pointerId === paneResizePointerId) stopPaneResize(event) +} +function movePaneResize(event: PointerEvent) { + if (!resizingPane.value || event.pointerId !== paneResizePointerId) return + if (event.buttons === 0) { stopPaneResize(); return } + const nextWidth = resizingPane.value === 'sidebar' ? event.clientX : window.innerWidth - event.clientX + if (resizingPane.value === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', nextWidth, window.innerWidth, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0) + else detailWidth.value = clampDesktopPaneWidth('detail', nextWidth, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value) +} +function startPaneResize(pane: DesktopPane, event: PointerEvent) { + if (event.button !== 0 || compactLayout.value || (pane === 'detail' && !selectedTask.value && !habitDetailOpen.value)) return + event.preventDefault() + paneResizeCaptureTarget = event.currentTarget as HTMLElement + paneResizeCaptureTarget.setPointerCapture?.(event.pointerId) + resizingPane.value = pane + paneResizePointerId = event.pointerId +} +function resizePaneWithKeyboard(pane: DesktopPane, event: KeyboardEvent) { + if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + if (pane === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value + direction * 12, window.innerWidth, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0) + else detailWidth.value = clampDesktopPaneWidth('detail', detailWidth.value - direction * 12, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value) + writeDesktopPaneWidth(window.localStorage, pane === 'sidebar' ? SIDEBAR_WIDTH_STORAGE_KEY : DETAIL_WIDTH_STORAGE_KEY, pane === 'sidebar' ? sidebarWidth.value : detailWidth.value) +} const appDialog = ref<{ show: (options: AppDialogOptions) => Promise } | null>(null) async function confirmAction(title: string, description?: string, danger = false) { @@ -1488,15 +1539,35 @@ function nextPage() { activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined } +function reconcileDesktopPaneWidths() { + if (compactLayout.value) return + if (selectedTask.value || habitDetailOpen.value) { + detailWidth.value = clampDesktopPaneWidth('detail', detailWidth.value, desktopViewportWidth.value, sidebarCollapsed.value ? 0 : sidebarWidth.value) + } + sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value, desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0) +} + +watch([selectedTask, habitDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths) + +function handlePaneResizeBlur() { stopPaneResize() } + function handleViewportResize() { - compactLayout.value = window.innerWidth <= 930 + desktopViewportWidth.value = window.innerWidth + compactLayout.value = desktopViewportWidth.value <= 930 + if (!compactLayout.value) reconcileDesktopPaneWidths() + else stopPaneResize() handleArchivedListViewportChange() } onMounted(() => { + handleViewportResize() document.addEventListener('pointerdown', handleArchivedListOutsidePointer) document.addEventListener('keydown', handleArchivedListEscape) window.addEventListener('resize', handleViewportResize) + window.addEventListener('pointermove', movePaneResize) + window.addEventListener('pointerup', stopPaneResize) + window.addEventListener('pointercancel', stopPaneResize) + window.addEventListener('blur', handlePaneResizeBlur) document.addEventListener('visibilitychange', handleTodayEnvironmentResume) window.addEventListener('focus', handleTodayEnvironmentResume) window.addEventListener('pageshow', handleTodayEnvironmentResume) @@ -1507,6 +1578,10 @@ onUnmounted(() => { document.removeEventListener('pointerdown', handleArchivedListOutsidePointer) document.removeEventListener('keydown', handleArchivedListEscape) window.removeEventListener('resize', handleViewportResize) + window.removeEventListener('pointermove', movePaneResize) + window.removeEventListener('pointerup', stopPaneResize) + window.removeEventListener('pointercancel', stopPaneResize) + window.removeEventListener('blur', handlePaneResizeBlur) document.removeEventListener('visibilitychange', handleTodayEnvironmentResume) window.removeEventListener('focus', handleTodayEnvironmentResume) window.removeEventListener('pageshow', handleTodayEnvironmentResume) @@ -1527,7 +1602,7 @@ onUnmounted(() => {

登录后继续你的清单

-
+
+ +