feat: add resizable desktop panes
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 3m32s

This commit is contained in:
2026-09-20 11:33:43 +08:00
parent 3a23c6cd44
commit 8ab5ad05a0
7 changed files with 259 additions and 19 deletions
+80 -3
View File
@@ -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<DesktopPane | null>(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<InstanceType<typeof MemoPanel> | 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<boolean | string | null> } | 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(() => {
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
</form>
</div>
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar, 'pane-resizing': Boolean(resizingPane) }" :style="shellStyle">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
@@ -1598,6 +1673,7 @@ onUnmounted(() => {
</template>
</AppSheet>
</aside>
<div v-if="!sidebarCollapsed" class="pane-resizer pane-resizer--sidebar" role="separator" aria-label="调整菜单栏宽度" aria-orientation="vertical" aria-valuemin="190" :aria-valuemax="sidebarMaxWidth" :aria-valuenow="sidebarWidth" tabindex="0" @pointerdown="startPaneResize('sidebar',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('sidebar',$event)" />
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
@@ -1656,6 +1732,7 @@ onUnmounted(() => {
</div>
</template>
</main>
<div v-if="selectedTask || habitDetailOpen" class="pane-resizer pane-resizer--detail" role="separator" :aria-label="detailSeparatorLabel" aria-orientation="vertical" aria-valuemin="300" :aria-valuemax="detailMaxWidth" :aria-valuenow="detailWidth" tabindex="0" @pointerdown="startPaneResize('detail',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('detail',$event)" />
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>