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 <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-05 21:08:36 +08:00
parent b51d81bd47
commit 0b6160f6d5
8 changed files with 434 additions and 233 deletions

View File

@@ -0,0 +1,8 @@
---
description:
globs:
alwaysApply: true
---
# Project
这是一个现代化的照片画廊网站

View File

@@ -1,4 +1,4 @@
# Photo Gallery Site
# Iris Photo Gallery
一个现代化的照片画廊网站,采用 React + TypeScript 构建支持从多种存储源S3、GitHub自动同步照片具有高性能 WebGL 渲染、瀑布流布局、EXIF 信息展示、缩略图生成等功能。

View File

@@ -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 (
<AnimatePresence>
{isVisible && dateRange && (
<m.div
initial={variants.initial}
animate={variants.animate}
exit={variants.initial}
transition={Spring.presets.snappy}
className={clsxm(
'border-material-opaque lg:rounded-xl border bg-black/60 p-4 shadow-2xl backdrop-blur-[70px]',
`fixed left-4 z-50 top-4 lg:top-6 lg:left-6`,
className,
)}
>
<div className="flex flex-col">
<span className="text-lg leading-tight font-bold tracking-tight text-white lg:text-4xl">
{formattedDate}
</span>
{location && (
<span className="mt-0.5 text-sm font-medium text-white/75 lg:mt-1 lg:text-lg">
{location}
</span>
)}
</div>
</m.div>
)}
</AnimatePresence>
)
},
)
DateRangeIndicator.displayName = 'DateRangeIndicator'

View File

@@ -0,0 +1 @@
export * from './DateRangeIndicator'

View File

@@ -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<DateRange>({
startDate: null,
endDate: null,
formattedRange: '',
location: undefined,
})
const currentRange = useRef<VisibleRange>({ 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,
}
}

View File

@@ -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 (
<div className="p-1 lg:p-0 [&_*]:!select-none">
<FloatingActionBar />
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
<Masonry<MasonryItemType>
key={`${sortOrder}-${selectedTags.join(',')}`}
items={useMemo(
() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
[photos, isMobile],
)}
render={useCallback(
(props) => (
<MasonryItem
{...props}
onPhotoClick={photoViewer.openViewer}
photos={photos}
hasAnimated={hasAnimatedRef.current}
onAnimationComplete={handleAnimationComplete}
/>
),
[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
}, [])}
/>
</div>
<>
{/* 桌面端:左右分布 */}
{!isMobile && (
<>
<DateRangeIndicator
dateRange={dateRange.formattedRange}
location={dateRange.location}
isVisible={showFloatingActions && !!dateRange.formattedRange}
/>
<FloatingActionBar showFloatingActions={showFloatingActions} />
</>
)}
{/* 移动端:垂直堆叠 */}
{isMobile && showFloatingActions && !!dateRange.formattedRange && (
<div className="fixed top-0 right-0 left-0 z-50">
<DateRangeIndicator
dateRange={dateRange.formattedRange}
location={dateRange.location}
isVisible={true}
className="relative top-0 left-0"
/>
<div className="flex justify-end">
<FloatingActionBar showFloatingActions={true} />
</div>
</div>
)}
<div className="p-1 lg:p-0 [&_*]:!select-none">
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
<Masonry<MasonryItemType>
key={`${sortOrder}-${selectedTags.join(',')}`}
items={useMemo(
() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
[photos, isMobile],
)}
render={useCallback(
(props) => (
<MasonryItem
{...props}
onPhotoClick={photoViewer.openViewer}
photos={photos}
hasAnimated={hasAnimatedRef.current}
onAnimationComplete={handleAnimationComplete}
/>
),
[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
}, [])}
/>
</div>
</>
)
}
@@ -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 (
<AnimatePresence>
<RootPortal>
{showFloatingActions && (
<m.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={Spring.presets.snappy}
className="fixed top-4 left-4 z-50"
>
<div className="border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]">
<ActionGroup />
</div>
</m.div>
)}
</RootPortal>
{showFloatingActions && (
<m.div
variants={variants}
initial="initial"
animate="animate"
exit="initial"
transition={Spring.presets.snappy}
className={clsxm(
'border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]',
isMobile
? 'rounded-t-none rounded-br-none -translate-y-px'
: 'fixed top-4 right-4 z-50 lg:top-6 lg:right-6',
)}
>
<ActionGroup />
</m.div>
)}
</AnimatePresence>
)
}

View File

@@ -240,6 +240,7 @@ export const PhotoMasonryItem = ({
width,
height: calculatedHeight,
}}
data-photo-id={data.id}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}

View File

@@ -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<string, CharacterPath> = {
'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
<path d="..." fill="white" />
```
### 粗体渲染
粗体字体同时使用填充和描边:
```svg
<path d="..." fill="white" stroke="white" stroke-width="1.5" />
```
## 优势
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 等)
- [ ] 在线字体提取服务