feat: add SlidingNumber component and integrate scale indicator in ProgressiveImage

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-09 00:52:26 +08:00
parent 2eb3020daa
commit caa09110dd
5 changed files with 292 additions and 2 deletions

View File

@@ -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",

View 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 }

View File

@@ -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>
)
}

View File

@@ -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
View File

@@ -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