mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: add SlidingNumber component and integrate scale indicator in ProgressiveImage
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -60,6 +60,7 @@
|
||||
"react-remove-scroll": "2.7.1",
|
||||
"react-router": "7.6.2",
|
||||
"react-scan": "0.3.4",
|
||||
"react-use-measure": "2.1.7",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"sharp": "0.34.2",
|
||||
"sonner": "2.0.5",
|
||||
|
||||
228
apps/web/src/components/ui/number/SlidingNumber.tsx
Normal file
228
apps/web/src/components/ui/number/SlidingNumber.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import type { MotionValue, SpringOptions, UseInViewOptions } from 'motion/react'
|
||||
import { m as motion, useInView, useSpring, useTransform } from 'motion/react'
|
||||
import * as React from 'react'
|
||||
import useMeasure from 'react-use-measure'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
type SlidingNumberRollerProps = {
|
||||
prevValue: number
|
||||
value: number
|
||||
place: number
|
||||
transition: SpringOptions
|
||||
}
|
||||
|
||||
function SlidingNumberRoller({
|
||||
prevValue,
|
||||
value,
|
||||
place,
|
||||
transition,
|
||||
}: SlidingNumberRollerProps) {
|
||||
const startNumber = Math.floor(prevValue / place) % 10
|
||||
const targetNumber = Math.floor(value / place) % 10
|
||||
const animatedValue = useSpring(startNumber, transition)
|
||||
|
||||
React.useEffect(() => {
|
||||
animatedValue.set(targetNumber)
|
||||
}, [targetNumber, animatedValue])
|
||||
|
||||
const [measureRef, { height }] = useMeasure()
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureRef}
|
||||
data-slot="sliding-number-roller"
|
||||
className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums"
|
||||
>
|
||||
<span className="invisible">0</span>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SlidingNumberDisplay
|
||||
key={i}
|
||||
motionValue={animatedValue}
|
||||
number={i}
|
||||
height={height}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type SlidingNumberDisplayProps = {
|
||||
motionValue: MotionValue<number>
|
||||
number: number
|
||||
height: number
|
||||
transition: SpringOptions
|
||||
}
|
||||
|
||||
function SlidingNumberDisplay({
|
||||
motionValue,
|
||||
number,
|
||||
height,
|
||||
transition,
|
||||
}: SlidingNumberDisplayProps) {
|
||||
const y = useTransform(motionValue, (latest) => {
|
||||
if (!height) return 0
|
||||
const currentNumber = latest % 10
|
||||
const offset = (10 + number - currentNumber) % 10
|
||||
let translateY = offset * height
|
||||
if (offset > 5) translateY -= 10 * height
|
||||
return translateY
|
||||
})
|
||||
|
||||
if (!height) {
|
||||
return <span className="invisible absolute">{number}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="sliding-number-display"
|
||||
style={{ y }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
transition={{ ...transition, type: 'spring' }}
|
||||
>
|
||||
{number}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
|
||||
type SlidingNumberProps = React.ComponentProps<'span'> & {
|
||||
number: number | string
|
||||
inView?: boolean
|
||||
inViewMargin?: UseInViewOptions['margin']
|
||||
inViewOnce?: boolean
|
||||
padStart?: boolean
|
||||
decimalSeparator?: string
|
||||
decimalPlaces?: number
|
||||
transition?: SpringOptions
|
||||
}
|
||||
|
||||
function SlidingNumber({
|
||||
ref,
|
||||
number,
|
||||
className,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
padStart = false,
|
||||
decimalSeparator = '.',
|
||||
decimalPlaces = 0,
|
||||
transition = {
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 0.4,
|
||||
},
|
||||
...props
|
||||
}: SlidingNumberProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null)
|
||||
React.useImperativeHandle(ref, () => localRef.current!)
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
})
|
||||
const isInView = !inView || inViewResult
|
||||
|
||||
const prevNumberRef = React.useRef<number>(0)
|
||||
|
||||
const effectiveNumber = React.useMemo(
|
||||
() => (!isInView ? 0 : Math.abs(Number(number))),
|
||||
[number, isInView],
|
||||
)
|
||||
|
||||
const formatNumber = React.useCallback(
|
||||
(num: number) =>
|
||||
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
|
||||
[decimalPlaces],
|
||||
)
|
||||
|
||||
const numberStr = formatNumber(effectiveNumber)
|
||||
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.')
|
||||
const newIntStr =
|
||||
padStart && newIntStrRaw?.length === 1 ? `0${newIntStrRaw}` : newIntStrRaw
|
||||
|
||||
const prevFormatted = formatNumber(prevNumberRef.current)
|
||||
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.')
|
||||
const prevIntStr =
|
||||
padStart && prevIntStrRaw.length === 1 ? `0${prevIntStrRaw}` : prevIntStrRaw
|
||||
|
||||
const adjustedPrevInt = React.useMemo(() => {
|
||||
return prevIntStr.length > (newIntStr?.length ?? 0)
|
||||
? prevIntStr.slice(-(newIntStr?.length ?? 0))
|
||||
: prevIntStr.padStart(newIntStr?.length ?? 0, '0')
|
||||
}, [prevIntStr, newIntStr])
|
||||
|
||||
const adjustedPrevDec = React.useMemo(() => {
|
||||
if (!newDecStrRaw) return ''
|
||||
return prevDecStrRaw.length > newDecStrRaw.length
|
||||
? prevDecStrRaw.slice(0, newDecStrRaw.length)
|
||||
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0')
|
||||
}, [prevDecStrRaw, newDecStrRaw])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView) prevNumberRef.current = effectiveNumber
|
||||
}, [effectiveNumber, isInView])
|
||||
|
||||
const intDigitCount = newIntStr?.length ?? 0
|
||||
const intPlaces = React.useMemo(
|
||||
() =>
|
||||
Array.from({ length: intDigitCount }, (_, i) =>
|
||||
Math.pow(10, intDigitCount - i - 1),
|
||||
),
|
||||
[intDigitCount],
|
||||
)
|
||||
const decPlaces = React.useMemo(
|
||||
() =>
|
||||
newDecStrRaw
|
||||
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
|
||||
Math.pow(10, newDecStrRaw.length - i - 1),
|
||||
)
|
||||
: [],
|
||||
[newDecStrRaw],
|
||||
)
|
||||
|
||||
const newDecValue = newDecStrRaw ? Number.parseInt(newDecStrRaw, 10) : 0
|
||||
const prevDecValue = adjustedPrevDec
|
||||
? Number.parseInt(adjustedPrevDec, 10)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={localRef}
|
||||
data-slot="sliding-number"
|
||||
className={clsxm('flex items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{isInView && Number(number) < 0 && <span className="mr-1">-</span>}
|
||||
|
||||
{intPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`int-${place}`}
|
||||
prevValue={Number.parseInt(adjustedPrevInt, 10)}
|
||||
value={Number.parseInt(newIntStr ?? '0', 10)}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
|
||||
{newDecStrRaw && (
|
||||
<>
|
||||
<span>{decimalSeparator}</span>
|
||||
{decPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`dec-${place}`}
|
||||
prevValue={prevDecValue}
|
||||
value={newDecValue}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps }
|
||||
@@ -1,6 +1,13 @@
|
||||
import { WebGLImageViewer } from '@photo-gallery/webgl-viewer'
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type {
|
||||
ReactZoomPanPinchRef,
|
||||
ReactZoomPanPinchState,
|
||||
@@ -18,6 +25,7 @@ import { clsxm } from '~/lib/cn'
|
||||
import { canUseWebGL } from '~/lib/feature'
|
||||
import { ImageLoaderManager } from '~/lib/image-loader-manager'
|
||||
|
||||
import { SlidingNumber } from '../number/SlidingNumber'
|
||||
import { LivePhoto } from './LivePhoto'
|
||||
import type { LoadingIndicatorRef } from './LoadingIndicator'
|
||||
import { LoadingIndicator } from './LoadingIndicator'
|
||||
@@ -77,6 +85,11 @@ export const ProgressiveImage = ({
|
||||
|
||||
const [isHighResImageRendered, setIsHighResImageRendered] = useState(false)
|
||||
|
||||
// 缩放倍率提示相关状态
|
||||
const [currentScale, setCurrentScale] = useState(1)
|
||||
const [showScaleIndicator, setShowScaleIndicator] = useState(false)
|
||||
const scaleIndicatorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const loadingIndicatorRef = useRef<LoadingIndicatorRef>(null)
|
||||
const imageLoaderManagerRef = useRef<ImageLoaderManager | null>(null)
|
||||
|
||||
@@ -137,6 +150,22 @@ export const ProgressiveImage = ({
|
||||
(originalScale: number, relativeScale: number) => {
|
||||
const isZoomed = Math.abs(relativeScale - 1) > 0.01
|
||||
|
||||
// 更新缩放倍率并显示提示
|
||||
startTransition(() => {
|
||||
setCurrentScale(originalScale)
|
||||
setShowScaleIndicator(true)
|
||||
})
|
||||
|
||||
// 清除之前的定时器
|
||||
if (scaleIndicatorTimeoutRef.current) {
|
||||
clearTimeout(scaleIndicatorTimeoutRef.current)
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后隐藏提示
|
||||
scaleIndicatorTimeoutRef.current = setTimeout(() => {
|
||||
setShowScaleIndicator(false)
|
||||
}, 500)
|
||||
|
||||
onZoomChange?.(isZoomed)
|
||||
},
|
||||
[onZoomChange],
|
||||
@@ -356,6 +385,20 @@ export const ProgressiveImage = ({
|
||||
双击或双指缩放
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩放倍率提示 */}
|
||||
<AnimatePresence>
|
||||
{showScaleIndicator && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="pointer-events-none absolute bottom-4 left-4 z-20 flex items-center gap-0.5 rounded bg-black/50 px-3 py-1 text-lg text-white"
|
||||
>
|
||||
<SlidingNumber number={currentScale} decimalPlaces={1} />x
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@photo-gallery/webgl-viewer",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"description": "",
|
||||
"author": "Innei",
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -226,6 +226,9 @@ importers:
|
||||
react-scan:
|
||||
specifier: 0.3.4
|
||||
version: 0.3.4(@types/react@19.1.6)(next@15.3.3(@babel/core@7.27.1)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-router@7.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(rollup@4.41.1)
|
||||
react-use-measure:
|
||||
specifier: 2.1.7
|
||||
version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-zoom-pan-pinch:
|
||||
specifier: 3.7.0
|
||||
version: 3.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -5425,6 +5428,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-use-measure@2.1.7:
|
||||
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
|
||||
peerDependencies:
|
||||
react: '>=16.13'
|
||||
react-dom: '>=16.13'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-zoom-pan-pinch@3.7.0:
|
||||
resolution: {integrity: sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
@@ -11833,6 +11845,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.6
|
||||
|
||||
react-use-measure@2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react-zoom-pan-pinch@3.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
Reference in New Issue
Block a user