From fd21741e0812ab0e6f91ac71b7a7167ad494b3b7 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:41:49 -0600 Subject: [PATCH] feat: add resizable persisted sidebar width --- client/src/components/SelectionActionBar.vue | 330 +++++++++--------- .../components/ui/sidebar/SidebarProvider.vue | 18 +- .../src/components/ui/sidebar/SidebarRail.vue | 80 ++++- .../components/ui/sidebar/useSidebarWidth.ts | 69 ++++ client/src/components/ui/sidebar/utils.ts | 4 + .../book/components/BookQuickView.vue | 25 +- 6 files changed, 352 insertions(+), 174 deletions(-) create mode 100644 client/src/components/ui/sidebar/useSidebarWidth.ts diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue index 62cebc993..262f28bf7 100644 --- a/client/src/components/SelectionActionBar.vue +++ b/client/src/components/SelectionActionBar.vue @@ -25,6 +25,16 @@ const emit = defineEmits<{ const confirmingDelete = ref(false) const exportMenuOpen = ref(false) +function onExport(allFormats: boolean) { + emit('export', allFormats) + exportMenuOpen.value = false +} + +function onConfirmDelete() { + emit('delete') + confirmingDelete.value = false +} + watch( () => props.visible, (v) => { @@ -45,183 +55,191 @@ watch( leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4" > - -
- - - + +
diff --git a/client/src/components/ui/sidebar/SidebarProvider.vue b/client/src/components/ui/sidebar/SidebarProvider.vue index aa60b7e34..5c039978a 100644 --- a/client/src/components/ui/sidebar/SidebarProvider.vue +++ b/client/src/components/ui/sidebar/SidebarProvider.vue @@ -4,14 +4,8 @@ import { defaultDocument, useEventListener, useMediaQuery, useVModel } from '@vu import { TooltipProvider } from 'reka-ui' import { computed, ref } from 'vue' import { cn } from '@/lib/utils' -import { - provideSidebarContext, - SIDEBAR_COOKIE_MAX_AGE, - SIDEBAR_COOKIE_NAME, - SIDEBAR_KEYBOARD_SHORTCUT, - SIDEBAR_WIDTH, - SIDEBAR_WIDTH_ICON, -} from './utils' +import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH_ICON } from './utils' +import { useSidebarWidth } from './useSidebarWidth' const props = withDefaults( defineProps<{ @@ -63,6 +57,8 @@ useEventListener('keydown', (event: KeyboardEvent) => { // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = computed(() => (open.value ? 'expanded' : 'collapsed')) +const { widthPx, setWidth, minWidthPx, maxWidthPx } = useSidebarWidth() +const desktopSidebarWidth = computed(() => `${widthPx.value}px`) provideSidebarContext({ state, @@ -72,6 +68,10 @@ provideSidebarContext({ openMobile, setOpenMobile, toggleSidebar, + widthPx, + setWidth, + minWidthPx, + maxWidthPx, }) @@ -80,7 +80,7 @@ provideSidebarContext({
import type { HTMLAttributes } from 'vue' +import { onBeforeUnmount, ref } from 'vue' import { cn } from '@/lib/utils' import { useSidebar } from './utils' @@ -7,7 +8,80 @@ const props = defineProps<{ class?: HTMLAttributes['class'] }>() -const { toggleSidebar } = useSidebar() +const { toggleSidebar, setWidth, widthPx, state } = useSidebar() + +const DRAG_THRESHOLD_PX = 3 + +const activePointerId = ref(null) +const dragStartX = ref(0) +const dragStartWidth = ref(0) +const dragSide = ref<'left' | 'right'>('left') +const isResizeGesture = ref(false) +const didDrag = ref(false) + +function resolveSide(target: EventTarget | null): 'left' | 'right' { + const element = target instanceof HTMLElement ? target : null + const side = element?.closest('[data-side]')?.getAttribute('data-side') + return side === 'right' ? 'right' : 'left' +} + +function onPointerMove(event: PointerEvent) { + if (activePointerId.value === null || event.pointerId !== activePointerId.value) return + if (!isResizeGesture.value) return + + const deltaX = event.clientX - dragStartX.value + if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) { + didDrag.value = true + } + + const nextWidth = dragSide.value === 'left' ? dragStartWidth.value + deltaX : dragStartWidth.value - deltaX + setWidth(nextWidth) +} + +function cleanupDragListeners() { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerEnd) + window.removeEventListener('pointercancel', onPointerEnd) +} + +function resetDragState() { + activePointerId.value = null + isResizeGesture.value = false + didDrag.value = false +} + +function onPointerEnd(event: PointerEvent) { + if (activePointerId.value === null || event.pointerId !== activePointerId.value) return + + const shouldToggleSidebar = event.type === 'pointerup' && !didDrag.value + cleanupDragListeners() + resetDragState() + + if (shouldToggleSidebar) { + toggleSidebar() + } +} + +function onPointerDown(event: PointerEvent) { + if (event.button !== 0) return + + activePointerId.value = event.pointerId + dragStartX.value = event.clientX + dragStartWidth.value = widthPx.value + dragSide.value = resolveSide(event.currentTarget) + isResizeGesture.value = state.value === 'expanded' + didDrag.value = false + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerEnd) + window.addEventListener('pointercancel', onPointerEnd) + event.preventDefault() +} + +onBeforeUnmount(() => { + cleanupDragListeners() + resetDragState() +})