04fd4d50d0
Co-authored-by: Cursor <cursoragent@cursor.com>
126 lines
3.4 KiB
TypeScript
126 lines
3.4 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||
import { View, Text } from '@tarojs/components';
|
||
import Taro, { useReady } from '@tarojs/taro';
|
||
|
||
type StoreRedeemMarqueeProps = {
|
||
lines: string[];
|
||
};
|
||
|
||
/** 估算单行像素宽(12px 字号,DOM 测量失败时兜底) */
|
||
function estimateTextWidth(text: string): number {
|
||
let w = 0;
|
||
for (const ch of text) {
|
||
w += /[^\x00-\xff]/.test(ch) ? 12 : 7;
|
||
}
|
||
return Math.max(Math.ceil(w), 80);
|
||
}
|
||
|
||
/**
|
||
* 门店核销动态走马灯(H5 + 微信小程序)
|
||
*
|
||
* - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽)
|
||
* - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环
|
||
* - 测量失败时用字宽估算,仍能滚动
|
||
*/
|
||
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||
const segment = useMemo(() => {
|
||
const joined = lines
|
||
.map((s) => String(s || '').trim())
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
return joined ? `${joined} ` : '';
|
||
}, [lines]);
|
||
|
||
const measureId = useMemo(
|
||
() => `sm${Math.random().toString(36).slice(2, 10)}`,
|
||
[segment],
|
||
);
|
||
|
||
const [segWidth, setSegWidth] = useState(0);
|
||
|
||
const measure = useCallback(() => {
|
||
if (!segment) {
|
||
setSegWidth(0);
|
||
return;
|
||
}
|
||
const fallback = estimateTextWidth(segment);
|
||
Taro.nextTick(() => {
|
||
try {
|
||
Taro.createSelectorQuery()
|
||
.select(`#${measureId}`)
|
||
.boundingClientRect()
|
||
.exec((res) => {
|
||
const w = Number(res?.[0]?.width || 0);
|
||
// weapp 若误测成容器宽,会接近视口;用估算兜底纠正
|
||
if (w > 8 && w < fallback * 2.5) {
|
||
setSegWidth(Math.ceil(w));
|
||
} else {
|
||
setSegWidth(fallback);
|
||
}
|
||
});
|
||
} catch {
|
||
setSegWidth(fallback);
|
||
}
|
||
});
|
||
}, [segment, measureId]);
|
||
|
||
useReady(() => {
|
||
measure();
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!segment) {
|
||
setSegWidth(0);
|
||
return;
|
||
}
|
||
setSegWidth(0);
|
||
measure();
|
||
const t1 = setTimeout(measure, 100);
|
||
const t2 = setTimeout(measure, 350);
|
||
const t3 = setTimeout(() => {
|
||
setSegWidth((prev) => (prev > 0 ? prev : estimateTextWidth(segment)));
|
||
}, 600);
|
||
return () => {
|
||
clearTimeout(t1);
|
||
clearTimeout(t2);
|
||
clearTimeout(t3);
|
||
};
|
||
}, [segment, measure]);
|
||
|
||
if (!segment) return null;
|
||
|
||
const running = segWidth > 0;
|
||
const durationSec = Math.max(10, Math.min(90, segWidth / 42));
|
||
const trackStyle: CSSProperties | undefined = running
|
||
? {
|
||
width: `${segWidth * 2}px`,
|
||
animationDuration: `${durationSec}s`,
|
||
// 微信 / Safari
|
||
WebkitAnimationDuration: `${durationSec}s`,
|
||
}
|
||
: undefined;
|
||
const segStyle: CSSProperties | undefined = running
|
||
? { width: `${segWidth}px` }
|
||
: undefined;
|
||
|
||
return (
|
||
<View className="store-detail-marquee">
|
||
<View
|
||
className={`store-detail-marquee-track${running ? ' is-running' : ''}`}
|
||
style={trackStyle}
|
||
>
|
||
<Text
|
||
id={measureId}
|
||
className="store-detail-marquee-seg store-detail-marquee-text"
|
||
style={segStyle}
|
||
>
|
||
{segment}
|
||
</Text>
|
||
<Text className="store-detail-marquee-seg store-detail-marquee-text" style={segStyle}>
|
||
{segment}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|