mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
8
.cursor/rules/project.mdc
Normal file
8
.cursor/rules/project.mdc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Project
|
||||||
|
|
||||||
|
这是一个现代化的照片画廊网站
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Photo Gallery Site
|
# Iris Photo Gallery
|
||||||
|
|
||||||
一个现代化的照片画廊网站,采用 React + TypeScript 构建,支持从多种存储源(S3、GitHub)自动同步照片,具有高性能 WebGL 渲染、瀑布流布局、EXIF 信息展示、缩略图生成等功能。
|
一个现代化的照片画廊网站,采用 React + TypeScript 构建,支持从多种存储源(S3、GitHub)自动同步照片,具有高性能 WebGL 渲染、瀑布流布局、EXIF 信息展示、缩略图生成等功能。
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
1
apps/web/src/components/ui/date-range-indicator/index.ts
Normal file
1
apps/web/src/components/ui/date-range-indicator/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './DateRangeIndicator'
|
||||||
195
apps/web/src/hooks/useVisiblePhotosDateRange.ts
Normal file
195
apps/web/src/hooks/useVisiblePhotosDateRange.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import { AnimatePresence, m } from 'motion/react'
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { gallerySettingAtom } from '~/atoms/app'
|
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 { useScrollViewElement } from '~/components/ui/scroll-areas/hooks'
|
||||||
import { useMobile } from '~/hooks/useMobile'
|
import { useMobile } from '~/hooks/useMobile'
|
||||||
import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||||
import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
|
import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
|
||||||
|
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
|
||||||
|
import { clsxm } from '~/lib/cn'
|
||||||
import { Spring } from '~/lib/spring'
|
import { Spring } from '~/lib/spring'
|
||||||
import type { PhotoManifest } from '~/types/photo'
|
import type { PhotoManifest } from '~/types/photo'
|
||||||
|
|
||||||
@@ -27,8 +29,11 @@ const FIRST_SCREEN_ITEMS_COUNT = 30
|
|||||||
export const MasonryRoot = () => {
|
export const MasonryRoot = () => {
|
||||||
const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom)
|
const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom)
|
||||||
const hasAnimatedRef = useRef(false)
|
const hasAnimatedRef = useRef(false)
|
||||||
|
const [showFloatingActions, setShowFloatingActions] = useState(false)
|
||||||
|
|
||||||
const photos = usePhotos()
|
const photos = usePhotos()
|
||||||
|
const { dateRange, handleRender } = useVisiblePhotosDateRange(photos)
|
||||||
|
const scrollElement = useScrollViewElement()
|
||||||
|
|
||||||
const photoViewer = usePhotoViewer()
|
const photoViewer = usePhotoViewer()
|
||||||
const handleAnimationComplete = useCallback(() => {
|
const handleAnimationComplete = useCallback(() => {
|
||||||
@@ -36,40 +41,84 @@ export const MasonryRoot = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
const isMobile = useMobile()
|
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 (
|
return (
|
||||||
<div className="p-1 lg:p-0 [&_*]:!select-none">
|
<>
|
||||||
<FloatingActionBar />
|
{/* 桌面端:左右分布 */}
|
||||||
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
|
{!isMobile && (
|
||||||
<Masonry<MasonryItemType>
|
<>
|
||||||
key={`${sortOrder}-${selectedTags.join(',')}`}
|
<DateRangeIndicator
|
||||||
items={useMemo(
|
dateRange={dateRange.formattedRange}
|
||||||
() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
|
location={dateRange.location}
|
||||||
[photos, isMobile],
|
isVisible={showFloatingActions && !!dateRange.formattedRange}
|
||||||
)}
|
/>
|
||||||
render={useCallback(
|
<FloatingActionBar showFloatingActions={showFloatingActions} />
|
||||||
(props) => (
|
</>
|
||||||
<MasonryItem
|
)}
|
||||||
{...props}
|
|
||||||
onPhotoClick={photoViewer.openViewer}
|
{/* 移动端:垂直堆叠 */}
|
||||||
photos={photos}
|
{isMobile && showFloatingActions && !!dateRange.formattedRange && (
|
||||||
hasAnimated={hasAnimatedRef.current}
|
<div className="fixed top-0 right-0 left-0 z-50">
|
||||||
onAnimationComplete={handleAnimationComplete}
|
<DateRangeIndicator
|
||||||
/>
|
dateRange={dateRange.formattedRange}
|
||||||
),
|
location={dateRange.location}
|
||||||
[handleAnimationComplete, photoViewer.openViewer, photos],
|
isVisible={true}
|
||||||
)}
|
className="relative top-0 left-0"
|
||||||
columnWidth={isMobile ? 150 : 300}
|
/>
|
||||||
columnGutter={4}
|
<div className="flex justify-end">
|
||||||
rowGutter={4}
|
<FloatingActionBar showFloatingActions={true} />
|
||||||
itemHeightEstimate={400}
|
</div>
|
||||||
itemKey={useTypeScriptHappyCallback((data, _index) => {
|
</div>
|
||||||
if (data instanceof MasonryHeaderItem) {
|
)}
|
||||||
return 'header'
|
|
||||||
}
|
<div className="p-1 lg:p-0 [&_*]:!select-none">
|
||||||
return (data as PhotoManifest).id
|
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
|
||||||
}, [])}
|
<Masonry<MasonryItemType>
|
||||||
/>
|
key={`${sortOrder}-${selectedTags.join(',')}`}
|
||||||
</div>
|
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 FloatingActionBar = ({
|
||||||
const [showFloatingActions, setShowFloatingActions] = useState(false)
|
showFloatingActions,
|
||||||
const scrollElement = useScrollViewElement()
|
}: {
|
||||||
|
showFloatingActions: boolean
|
||||||
useEffect(() => {
|
}) => {
|
||||||
if (!scrollElement) return
|
const isMobile = useMobile()
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
const { scrollTop } = scrollElement
|
|
||||||
|
|
||||||
setShowFloatingActions(scrollTop > 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
|
|
||||||
return () => {
|
|
||||||
scrollElement.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
}, [scrollElement])
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<RootPortal>
|
{showFloatingActions && (
|
||||||
{showFloatingActions && (
|
<m.div
|
||||||
<m.div
|
variants={variants}
|
||||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
initial="initial"
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate="animate"
|
||||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
exit="initial"
|
||||||
transition={Spring.presets.snappy}
|
transition={Spring.presets.snappy}
|
||||||
className="fixed top-4 left-4 z-50"
|
className={clsxm(
|
||||||
>
|
'border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]',
|
||||||
<div className="border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]">
|
isMobile
|
||||||
<ActionGroup />
|
? 'rounded-t-none rounded-br-none -translate-y-px'
|
||||||
</div>
|
: 'fixed top-4 right-4 z-50 lg:top-6 lg:right-6',
|
||||||
</m.div>
|
)}
|
||||||
)}
|
>
|
||||||
</RootPortal>
|
<ActionGroup />
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export const PhotoMasonryItem = ({
|
|||||||
width,
|
width,
|
||||||
height: calculatedHeight,
|
height: calculatedHeight,
|
||||||
}}
|
}}
|
||||||
|
data-photo-id={data.id}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
|||||||
@@ -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 等)
|
|
||||||
- [ ] 在线字体提取服务
|
|
||||||
Reference in New Issue
Block a user