From 47f54ad11c272b25db0aa7c41bfaa7405021f1ba Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 5 Jun 2025 21:51:15 +0800 Subject: [PATCH] feat: enhance gallery settings with adjustable column layout - Replaced the gallery settings atom with atomWithStorage to persist settings across sessions. - Added a new AdjustColumnsButton component to allow users to dynamically adjust the number of columns in the gallery based on device type. - Implemented responsive column width calculation in the MasonryRoot component to accommodate the new column settings. - Updated the ActionGroup component to include the new AdjustColumnsButton for better user interaction. Signed-off-by: Innei --- apps/web/src/atoms/app.ts | 6 +- apps/web/src/components/ui/slider.tsx | 178 +++++++++++++++++++ apps/web/src/modules/gallery/ActionGroup.tsx | 98 ++++++++++ apps/web/src/modules/gallery/MasonryRoot.tsx | 38 +++- 4 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/ui/slider.tsx diff --git a/apps/web/src/atoms/app.ts b/apps/web/src/atoms/app.ts index 8b05d0f4..4d24ba92 100644 --- a/apps/web/src/atoms/app.ts +++ b/apps/web/src/atoms/app.ts @@ -1,9 +1,11 @@ -import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' export type GallerySortBy = 'date' export type GallerySortOrder = 'asc' | 'desc' -export const gallerySettingAtom = atom({ + +export const gallerySettingAtom = atomWithStorage('gallery-settings', { sortBy: 'date' as GallerySortBy, sortOrder: 'desc' as GallerySortOrder, selectedTags: [] as string[], + columns: 'auto' as number | 'auto', // 自定义列数,auto 表示自动计算 }) diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx new file mode 100644 index 00000000..05a04805 --- /dev/null +++ b/apps/web/src/components/ui/slider.tsx @@ -0,0 +1,178 @@ +import { useCallback, useRef, useState } from 'react' + +import { clsxm } from '~/lib/cn' + +interface SliderProps { + value: number | 'auto' + onChange: (value: number | 'auto') => void + min: number + max: number + step?: number + autoLabel?: string + className?: string + disabled?: boolean +} + +export const Slider = ({ + value, + onChange, + min, + max, + step = 1, + autoLabel = '自动', + className, + disabled = false, +}: SliderProps) => { + const [isDragging, setIsDragging] = useState(false) + const sliderRef = useRef(null) + const trackRef = useRef(null) + + // 将值转换为位置百分比 + const getPositionFromValue = useCallback( + (val: number | 'auto') => { + if (val === 'auto') return 5 // 自动档位置稍微偏右一点 + // 数值档从 15% 开始到 100% + return 15 + ((val - min) / (max - min)) * 85 + }, + [min, max], + ) + + // 将位置百分比转换为值 + const getValueFromPosition = useCallback( + (position: number) => { + if (position <= 12) return 'auto' // 左侧 12% 区域为自动档 + const normalizedPosition = (position - 15) / 85 // 从 15% 开始的 85% 区域为数值 + const rawValue = min + Math.max(0, normalizedPosition) * (max - min) + return Math.round(Math.max(min, rawValue) / step) * step + }, + [min, max, step], + ) + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (disabled) return + + event.preventDefault() + setIsDragging(true) + + const updateValue = (clientX: number) => { + if (!trackRef.current) return + + const rect = trackRef.current.getBoundingClientRect() + const position = ((clientX - rect.left) / rect.width) * 100 + const clampedPosition = Math.max(0, Math.min(100, position)) + const newValue = getValueFromPosition(clampedPosition) + + if (newValue !== value) { + onChange(newValue) + } + } + + updateValue(event.clientX) + + const handlePointerMove = (e: PointerEvent) => { + updateValue(e.clientX) + } + + const handlePointerUp = () => { + setIsDragging(false) + document.removeEventListener('pointermove', handlePointerMove) + document.removeEventListener('pointerup', handlePointerUp) + } + + document.addEventListener('pointermove', handlePointerMove) + document.addEventListener('pointerup', handlePointerUp) + }, + [disabled, value, onChange, getValueFromPosition], + ) + + const position = getPositionFromValue(value) + + return ( +
+ {/* 标签 */} +
+ {autoLabel} + {max} +
+ + {/* 滑块轨道 */} +
+ {/* 背景轨道 */} +
+ {/* 自动档区域指示 */} +
+ + {/* 激活区域 */} +
+
+ + {/* 滑块把手 */} +
+ + {/* 数值刻度 */} +
+
+ + 自动 + +
+
+ {Array.from({ length: max - min + 1 }, (_, i) => min + i).map( + (num) => ( + + {num} + + ), + )} +
+
+
+ + {/* 当前值显示 */} +
+ {value === 'auto' ? autoLabel : `${value} 列`} +
+
+ ) +} diff --git a/apps/web/src/modules/gallery/ActionGroup.tsx b/apps/web/src/modules/gallery/ActionGroup.tsx index 91b772b8..46325772 100644 --- a/apps/web/src/modules/gallery/ActionGroup.tsx +++ b/apps/web/src/modules/gallery/ActionGroup.tsx @@ -1,5 +1,6 @@ import { siteConfig } from '@config' import { useAtom } from 'jotai' +import { useRef, useState } from 'react' import { gallerySettingAtom } from '~/atoms/app' import { Button } from '~/components/ui/button' @@ -10,7 +11,9 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '~/components/ui/dropdown-menu' +import { Slider } from '~/components/ui/slider' import { photoLoader } from '~/data/photos' +import { useMobile } from '~/hooks/useMobile' const allTags = photoLoader.getAllTags() @@ -111,6 +114,8 @@ export const ActionGroup = () => { + + {/* 排序按钮 */} @@ -151,3 +156,96 @@ export const ActionGroup = () => {
) } + +const AdjustColumnsButton = () => { + const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom) + const isMobile = useMobile() + + const setColumns = (columns: number | 'auto') => { + setGallerySetting({ + ...gallerySetting, + columns, + }) + } + // 根据设备类型提供不同的列数范围 + const columnRange = isMobile + ? { min: 2, max: 4 } // 移动端适合的列数范围 + : { min: 2, max: 8 } // 桌面端适合的列数范围 + const dropdownMenuTriggerRef = useRef(null) + + const [triggerRectPosition, setTriggerRectPosition] = useState<{ + top: number + left: number + height: number + width: number + } | null>(null) + const [open, setOpen] = useState(false) + return ( + <> + + + {/* 列数控制按钮 */} + { + if (!open) { + setTriggerRectPosition(null) + } + setOpen(open) + }} + > + + + + 列数设置 + +
+ +
+
+
+ + ) +} diff --git a/apps/web/src/modules/gallery/MasonryRoot.tsx b/apps/web/src/modules/gallery/MasonryRoot.tsx index c1dfd988..d4b5f184 100644 --- a/apps/web/src/modules/gallery/MasonryRoot.tsx +++ b/apps/web/src/modules/gallery/MasonryRoot.tsx @@ -27,9 +27,10 @@ type MasonryItemType = PhotoManifest | MasonryHeaderItem const FIRST_SCREEN_ITEMS_COUNT = 30 export const MasonryRoot = () => { - const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom) + const { columns } = useAtomValue(gallerySettingAtom) const hasAnimatedRef = useRef(false) const [showFloatingActions, setShowFloatingActions] = useState(false) + const [containerWidth, setContainerWidth] = useState(0) const photos = usePhotos() const { dateRange, handleRender } = useVisiblePhotosDateRange(photos) @@ -41,6 +42,38 @@ export const MasonryRoot = () => { }, []) const isMobile = useMobile() + // 监听容器宽度变化 + useEffect(() => { + const updateContainerWidth = () => { + setContainerWidth(window.innerWidth) + } + + updateContainerWidth() + window.addEventListener('resize', updateContainerWidth) + + return () => { + window.removeEventListener('resize', updateContainerWidth) + } + }, []) + + // 动态计算列宽 + const columnWidth = useMemo(() => { + if (columns === 'auto') { + return isMobile ? 150 : 300 // 自动模式使用默认列宽 + } + + // 自定义列数模式:根据容器宽度和列数计算列宽 + const availableWidth = containerWidth - (isMobile ? 8 : 32) // 移动端和桌面端的 padding 不同 + const gutter = 4 // 列间距 + const calculatedWidth = (availableWidth - (columns - 1) * gutter) / columns + + // 根据设备类型设置最小和最大列宽 + const minWidth = isMobile ? 120 : 200 + const maxWidth = isMobile ? 250 : 500 + + return Math.max(Math.min(calculatedWidth, maxWidth), minWidth) + }, [isMobile, columns, containerWidth]) + // 监听滚动,控制浮动组件的显示 useEffect(() => { if (!scrollElement) return @@ -88,7 +121,6 @@ export const MasonryRoot = () => {
{isMobile && } - key={`${sortOrder}-${selectedTags.join(',')}`} items={useMemo( () => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]), [photos, isMobile], @@ -106,7 +138,7 @@ export const MasonryRoot = () => { [handleAnimationComplete, photoViewer.openViewer, photos], )} onRender={handleRender} - columnWidth={isMobile ? 150 : 300} + columnWidth={columnWidth} columnGutter={4} rowGutter={4} itemHeightEstimate={400}