From 0b6160f6d5ecc3f3bb88307afd2b72b7f25dc23c Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 5 Jun 2025 21:08:36 +0800 Subject: [PATCH] feat: add DateRangeIndicator component and integrate into MasonryRoot - Introduced a new DateRangeIndicator component to display the date range and location of visible photos. - Integrated the DateRangeIndicator into the MasonryRoot component, enhancing the user interface for both mobile and desktop views. - Implemented a custom hook, useVisiblePhotosDateRange, to calculate the date range of currently visible photos. - Updated README to reflect the new project name and description. Signed-off-by: Innei --- .cursor/rules/project.mdc | 8 + README.md | 2 +- .../DateRangeIndicator.tsx | 106 ++++++++++ .../ui/date-range-indicator/index.ts | 1 + .../src/hooks/useVisiblePhotosDateRange.ts | 195 ++++++++++++++++++ apps/web/src/modules/gallery/MasonryRoot.tsx | 189 +++++++++++------ .../src/modules/gallery/PhotoMasonryItem.tsx | 1 + docs/font-extraction.md | 165 --------------- 8 files changed, 434 insertions(+), 233 deletions(-) create mode 100644 .cursor/rules/project.mdc create mode 100644 apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx create mode 100644 apps/web/src/components/ui/date-range-indicator/index.ts create mode 100644 apps/web/src/hooks/useVisiblePhotosDateRange.ts delete mode 100644 docs/font-extraction.md diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 00000000..11938bc1 --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,8 @@ +--- +description: +globs: +alwaysApply: true +--- +# Project + +这是一个现代化的照片画廊网站 \ No newline at end of file diff --git a/README.md b/README.md index 65ba1df5..ab6df1af 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Photo Gallery Site +# Iris Photo Gallery 一个现代化的照片画廊网站,采用 React + TypeScript 构建,支持从多种存储源(S3、GitHub)自动同步照片,具有高性能 WebGL 渲染、瀑布流布局、EXIF 信息展示、缩略图生成等功能。 diff --git a/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx b/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx new file mode 100644 index 00000000..25dd1460 --- /dev/null +++ b/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx @@ -0,0 +1,106 @@ +import { AnimatePresence, m } from 'motion/react' +import { memo } from 'react' + +import { useMobile } from '~/hooks/useMobile' +import { clsxm } from '~/lib/cn' +import { Spring } from '~/lib/spring' + +interface DateRangeIndicatorProps { + dateRange: string + location?: string + isVisible: boolean + className?: string +} + +export const DateRangeIndicator = memo( + ({ dateRange, location, isVisible, className }: DateRangeIndicatorProps) => { + // 解析日期范围,提取主要的日期信息 + const parseMainDate = (range: string) => { + // 匹配跨年日期范围格式 "2022年3月 - 2023年5月" + const crossYearMatch = range.match( + /(\d{4})年(\d+)月\s*-\s*(\d{4})年(\d+)月/, + ) + if (crossYearMatch) { + const [, startYear, startMonth, endYear, endMonth] = crossYearMatch + return `${startMonth}月 ${startYear} – ${endMonth}月 ${endYear}` + } + + // 匹配类似 "2022年3月30日 - 5月2日" 的格式 + const singleYearDayMatch = range.match( + /(\d{4})年(\d+)月(\d+)日?\s*-\s*(\d+)月(\d+)日?/, + ) + if (singleYearDayMatch) { + const [, year, startMonth, startDay, endMonth, endDay] = + singleYearDayMatch + return `${startMonth}月${startDay}日–${endMonth}月${endDay}日, ${year}` + } + + // 匹配类似 "2022年3月 - 5月" 的格式 + const monthRangeMatch = range.match(/(\d{4})年(\d+)月\s*-\s*(\d+)月/) + if (monthRangeMatch) { + const [, year, startMonth, endMonth] = monthRangeMatch + return `${startMonth}月–${endMonth}月, ${year}` + } + + // 匹配单个日期 + const singleDateMatch = range.match(/(\d{4})年(\d+)月(\d+)日/) + if (singleDateMatch) { + const [, year, month, day] = singleDateMatch + return `${month}月${day}日, ${year}` + } + + // 默认返回原始字符串 + return range + } + + const isMobile = useMobile() + const variants = isMobile + ? { + initial: { + opacity: 0, + }, + animate: { opacity: 1 }, + } + : { + initial: { + opacity: 0, + x: -20, + scale: 0.95, + }, + animate: { opacity: 1, x: 0, scale: 1 }, + } + + const formattedDate = parseMainDate(dateRange) + + return ( + + {isVisible && dateRange && ( + +
+ + {formattedDate} + + {location && ( + + {location} + + )} +
+
+ )} +
+ ) + }, +) + +DateRangeIndicator.displayName = 'DateRangeIndicator' diff --git a/apps/web/src/components/ui/date-range-indicator/index.ts b/apps/web/src/components/ui/date-range-indicator/index.ts new file mode 100644 index 00000000..7a808f75 --- /dev/null +++ b/apps/web/src/components/ui/date-range-indicator/index.ts @@ -0,0 +1 @@ +export * from './DateRangeIndicator' diff --git a/apps/web/src/hooks/useVisiblePhotosDateRange.ts b/apps/web/src/hooks/useVisiblePhotosDateRange.ts new file mode 100644 index 00000000..e5fda4b2 --- /dev/null +++ b/apps/web/src/hooks/useVisiblePhotosDateRange.ts @@ -0,0 +1,195 @@ +import { useCallback, useRef, useState } from 'react' + +import type { PhotoManifest } from '~/types/photo' + +interface DateRange { + startDate: Date | null + endDate: Date | null + formattedRange: string + location?: string +} + +interface VisibleRange { + start: number + end: number +} + +/** + * Hook to calculate the date range of currently visible photos in the viewport + * Works with masonry onRender callback + */ +export const useVisiblePhotosDateRange = (_photos: PhotoManifest[]) => { + const [dateRange, setDateRange] = useState({ + startDate: null, + endDate: null, + formattedRange: '', + location: undefined, + }) + + const currentRange = useRef({ start: 0, end: 0 }) + + const getPhotoDate = useCallback((photo: PhotoManifest): Date => { + // 优先使用 EXIF 中的拍摄时间 + if (photo.exif?.Photo?.DateTimeOriginal) { + const dateStr = photo.exif.Photo.DateTimeOriginal as unknown as string + // EXIF 日期格式通常是 "YYYY:MM:DD HH:mm:ss" + const formattedDateStr = dateStr.replace( + /^(\d{4}):(\d{2}):(\d{2})/, + '$1-$2-$3', + ) + const date = new Date(formattedDateStr) + if (!Number.isNaN(date.getTime())) { + return date + } + } + + // 回退到 lastModified + return new Date(photo.lastModified) + }, []) + + const formatDateRange = useCallback( + (startDate: Date, endDate: Date): string => { + const startYear = startDate.getFullYear() + const endYear = endDate.getFullYear() + const startMonth = startDate.getMonth() + const endMonth = endDate.getMonth() + + // 如果是同一天 + if (startDate.toDateString() === endDate.toDateString()) { + return startDate.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + } + + // 如果是同一年 + if (startYear === endYear) { + // 如果是同一个月 + if (startMonth === endMonth) { + return `${startYear}年${startDate.getMonth() + 1}月${startDate.getDate()}日 - ${endDate.getDate()}日` + } else { + return `${startYear}年${startDate.getMonth() + 1}月 - ${endDate.getMonth() + 1}月` + } + } + + // 不同年份 + return `${startYear}年${startDate.getMonth() + 1}月 - ${endYear}年${endDate.getMonth() + 1}月` + }, + [], + ) + + const extractLocation = useCallback( + (photos: PhotoManifest[]): string | undefined => { + // 尝试从照片标签中提取位置信息 + for (const photo of photos) { + // 如果照片有位置标签,优先使用 + if (photo.tags) { + const locationTag = photo.tags.find( + (tag) => + tag.includes('省') || + tag.includes('市') || + tag.includes('区') || + tag.includes('县') || + tag.includes('镇') || + tag.includes('村') || + tag.includes('街道') || + tag.includes('路') || + tag.includes('北京') || + tag.includes('上海') || + tag.includes('广州') || + tag.includes('深圳') || + tag.includes('杭州') || + tag.includes('南京') || + tag.includes('成都'), + ) + if (locationTag) { + return locationTag + } + } + } + + return undefined + }, + [], + ) + + // 计算当前可视范围内照片的日期范围 + const calculateDateRange = useCallback( + (startIndex: number, endIndex: number, items: any[]) => { + if (!items || items.length === 0) { + setDateRange({ + startDate: null, + endDate: null, + formattedRange: '', + location: undefined, + }) + return + } + + // 过滤出照片类型的items (排除header等) + const visiblePhotos = items + .slice(startIndex, endIndex + 1) + .filter( + (item): item is PhotoManifest => + item && typeof item === 'object' && 'id' in item, + ) + + if (visiblePhotos.length === 0) { + setDateRange({ + startDate: null, + endDate: null, + formattedRange: '', + location: undefined, + }) + return + } + + // 计算日期范围 + const dates = visiblePhotos + .map((photo) => getPhotoDate(photo)) + .sort((a, b) => a.getTime() - b.getTime()) + + const startDate = dates[0] + const endDate = dates.at(-1) + + if (!startDate || !endDate) { + setDateRange({ + startDate: null, + endDate: null, + formattedRange: '', + location: undefined, + }) + return + } + + const formattedRange = formatDateRange(startDate, endDate) + const location = extractLocation(visiblePhotos) + + setDateRange({ + startDate, + endDate, + formattedRange, + location, + }) + + // 更新当前范围 + currentRange.current = { start: startIndex, end: endIndex } + }, + [getPhotoDate, formatDateRange], + ) + + // 用于传递给 masonry 的 onRender 回调 + const handleRender = useCallback( + (startIndex: number, stopIndex: number, items: any[]) => { + calculateDateRange(startIndex, stopIndex, items) + }, + [calculateDateRange], + ) + + return { + dateRange, + handleRender, + currentRange: currentRange.current, + } +} diff --git a/apps/web/src/modules/gallery/MasonryRoot.tsx b/apps/web/src/modules/gallery/MasonryRoot.tsx index deb941ad..c1dfd988 100644 --- a/apps/web/src/modules/gallery/MasonryRoot.tsx +++ b/apps/web/src/modules/gallery/MasonryRoot.tsx @@ -3,11 +3,13 @@ import { AnimatePresence, m } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { gallerySettingAtom } from '~/atoms/app' -import { RootPortal } from '~/components/ui/portal' +import { DateRangeIndicator } from '~/components/ui/date-range-indicator' import { useScrollViewElement } from '~/components/ui/scroll-areas/hooks' import { useMobile } from '~/hooks/useMobile' import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback' +import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange' +import { clsxm } from '~/lib/cn' import { Spring } from '~/lib/spring' import type { PhotoManifest } from '~/types/photo' @@ -27,8 +29,11 @@ const FIRST_SCREEN_ITEMS_COUNT = 30 export const MasonryRoot = () => { const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom) const hasAnimatedRef = useRef(false) + const [showFloatingActions, setShowFloatingActions] = useState(false) const photos = usePhotos() + const { dateRange, handleRender } = useVisiblePhotosDateRange(photos) + const scrollElement = useScrollViewElement() const photoViewer = usePhotoViewer() const handleAnimationComplete = useCallback(() => { @@ -36,40 +41,84 @@ export const MasonryRoot = () => { }, []) const isMobile = useMobile() + // 监听滚动,控制浮动组件的显示 + useEffect(() => { + if (!scrollElement) return + + const handleScroll = () => { + const { scrollTop } = scrollElement + setShowFloatingActions(scrollTop > 500) + } + + scrollElement.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + scrollElement.removeEventListener('scroll', handleScroll) + } + }, [scrollElement]) + return ( -
- - {isMobile && } - - key={`${sortOrder}-${selectedTags.join(',')}`} - items={useMemo( - () => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]), - [photos, isMobile], - )} - render={useCallback( - (props) => ( - - ), - [handleAnimationComplete, photoViewer.openViewer, photos], - )} - columnWidth={isMobile ? 150 : 300} - columnGutter={4} - rowGutter={4} - itemHeightEstimate={400} - itemKey={useTypeScriptHappyCallback((data, _index) => { - if (data instanceof MasonryHeaderItem) { - return 'header' - } - return (data as PhotoManifest).id - }, [])} - /> -
+ <> + {/* 桌面端:左右分布 */} + {!isMobile && ( + <> + + + + )} + + {/* 移动端:垂直堆叠 */} + {isMobile && showFloatingActions && !!dateRange.formattedRange && ( +
+ +
+ +
+
+ )} + +
+ {isMobile && } + + key={`${sortOrder}-${selectedTags.join(',')}`} + items={useMemo( + () => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]), + [photos, isMobile], + )} + render={useCallback( + (props) => ( + + ), + [handleAnimationComplete, photoViewer.openViewer, photos], + )} + onRender={handleRender} + columnWidth={isMobile ? 150 : 300} + columnGutter={4} + rowGutter={4} + itemHeightEstimate={400} + itemKey={useTypeScriptHappyCallback((data, _index) => { + if (data instanceof MasonryHeaderItem) { + return 'header' + } + return (data as PhotoManifest).id + }, [])} + /> +
+ ) } @@ -154,42 +203,48 @@ export const MasonryItem = memo( }, ) -const FloatingActionBar = () => { - const [showFloatingActions, setShowFloatingActions] = useState(false) - const scrollElement = useScrollViewElement() - - useEffect(() => { - if (!scrollElement) return - - const handleScroll = () => { - const { scrollTop } = scrollElement - - setShowFloatingActions(scrollTop > 500) - } - - scrollElement.addEventListener('scroll', handleScroll, { passive: true }) - return () => { - scrollElement.removeEventListener('scroll', handleScroll) - } - }, [scrollElement]) +const FloatingActionBar = ({ + showFloatingActions, +}: { + showFloatingActions: boolean +}) => { + const isMobile = useMobile() + const variants = isMobile + ? { + initial: { + opacity: 0, + }, + animate: { opacity: 1 }, + } + : { + initial: { + opacity: 0, + x: 20, + y: 0, + scale: 0.95, + }, + animate: { opacity: 1, x: 0, y: 0, scale: 1 }, + } return ( - - {showFloatingActions && ( - -
- -
-
- )} -
+ {showFloatingActions && ( + + + + )}
) } diff --git a/apps/web/src/modules/gallery/PhotoMasonryItem.tsx b/apps/web/src/modules/gallery/PhotoMasonryItem.tsx index 21c153e2..cfaf3fd6 100644 --- a/apps/web/src/modules/gallery/PhotoMasonryItem.tsx +++ b/apps/web/src/modules/gallery/PhotoMasonryItem.tsx @@ -240,6 +240,7 @@ export const PhotoMasonryItem = ({ width, height: calculatedHeight, }} + data-photo-id={data.id} onClick={handleClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} diff --git a/docs/font-extraction.md b/docs/font-extraction.md deleted file mode 100644 index 969b8de6..00000000 --- a/docs/font-extraction.md +++ /dev/null @@ -1,165 +0,0 @@ -# 字体提取系统 - -## 概述 - -为了解决在某些 Linux 系统上英文字体渲染效果不佳的问题,我们开发了一个字体提取系统,可以从系统中的真实字体文件中提取字形路径,生成基于 SVG 的文本渲染器。 - -## 特性 - -- ✅ **真实字体提取**: 从系统字体文件中提取真实的字形轮廓 -- ✅ **高质量渲染**: 基于矢量路径,确保在任何尺寸下都清晰 -- ✅ **跨平台一致性**: 在所有操作系统上获得相同的渲染效果 -- ✅ **自动备份**: 提取前自动备份原有文件 -- ✅ **完整字符集**: 支持大小写字母、数字和常用符号 - -## 使用方法 - -### 提取字体 - -```bash -npm run extract:font -``` - -这个命令会: -1. 搜索系统中可用的字体文件 -2. 从字体中提取字形路径 -3. 生成新的 SVG 文本渲染器 -4. 自动备份原有文件 - -### 测试效果 - -```bash -npm run test:svg-font -``` - -生成对比图片来验证字体渲染效果。 - -### 生成 OG 图片 - -```bash -npm run build:og -``` - -使用新的字体渲染器生成 OG 图片。 - -## 支持的字体 - -系统会按优先级搜索以下字体: - -### macOS -- SF Compact (推荐) - Apple 的现代无衬线字体 -- Geneva - 经典的 macOS 字体 - -### Linux -- Liberation Sans - 开源的 Arial 替代品 -- DejaVu Sans - 高质量的开源字体 - -### 通用 -- Helvetica (TTF 格式) -- Arial - -## 技术实现 - -### 字体加载 -使用 `opentype.js` 库解析字体文件: - -```typescript -const font = await opentype.load(fontPath) -const glyph = font.charToGlyph(char) -``` - -### 路径标准化 -将字体单位转换为标准化的 SVG 路径: - -```typescript -function normalizeGlyphPath(path: opentype.Path, unitsPerEm: number): string { - const scale = 100 / unitsPerEm // 缩放到 100 单位高度 - // 转换坐标系(Y 轴翻转) - const y = 100 - cmd.y * scale - return commands.join(' ') -} -``` - -### 代码生成 -自动生成包含所有字形数据的 TypeScript 文件: - -```typescript -const HELVETICA_CHARACTERS: Record = { - 'A': { - path: 'M 10.2 100.0 L 34.3 0.0 L 34.3 0.0 ...', - width: 68.6, - height: 100, - advanceWidth: 68.6 - }, - // ... 更多字符 -} -``` - -## 字形数据结构 - -每个字符包含以下信息: - -```typescript -interface CharacterPath { - path: string // SVG 路径数据 - width: number // 字符宽度 - height: number // 字符高度(固定为 100) - advanceWidth: number // 字符间距 -} -``` - -## 渲染优化 - -### 填充渲染 -普通字体使用填充模式: -```svg - -``` - -### 粗体渲染 -粗体字体同时使用填充和描边: -```svg - -``` - -## 优势 - -1. **精确性**: 直接使用字体文件中的原始字形数据 -2. **一致性**: 在所有平台上获得相同的渲染效果 -3. **质量**: 基于矢量路径,支持任意缩放 -4. **性能**: 预处理的路径数据,运行时性能优异 -5. **兼容性**: 不依赖系统字体渲染引擎 - -## 注意事项 - -1. **字体格式**: 目前只支持 TTF 格式,不支持 TTC (TrueType Collection) -2. **字符集**: 提取的字符集是预定义的,如需新字符需要重新提取 -3. **文件大小**: 生成的文件会比原来的手工路径稍大 -4. **字体许可**: 确保使用的字体允许提取和使用 - -## 故障排除 - -### 字体未找到 -如果系统中没有找到合适的字体,可以: -1. 安装推荐的字体 -2. 修改 `HELVETICA_FONT_PATHS` 数组添加自定义路径 -3. 使用在线字体服务 - -### TTC 格式错误 -如果遇到 "Unsupported OpenType signature ttcf" 错误: -1. 寻找 TTF 格式的替代字体 -2. 使用字体转换工具将 TTC 转换为 TTF - -### 字形缺失 -如果某些字符没有字形: -1. 检查字体是否包含该字符 -2. 更换包含更完整字符集的字体 -3. 为缺失字符提供后备方案 - -## 未来改进 - -- [ ] 支持 TTC 格式字体 -- [ ] 支持更多字符和 Unicode 范围 -- [ ] 字形路径压缩优化 -- [ ] 支持字体变体(Light、Medium、Bold 等) -- [ ] 在线字体提取服务 \ No newline at end of file