2025-12-23 18:56:41 +01:00

389 lines
12 KiB
TypeScript

import type { TeactNode } from '../../../lib/teact/teact';
import {
memo, useEffect,
useMemo, useRef, useState } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { REM } from '../../common/helpers/mediaDimensions';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import usePrevious from '../../../hooks/usePrevious';
import useResizeObserver from '../../../hooks/useResizeObserver';
import AnimatedCounter from '../../common/AnimatedCounter';
import Icon from '../../common/icons/Icon';
import Sparkles from '../../common/Sparkles';
import styles from './StarSlider.module.scss';
type OwnProps = {
maxValue: number;
defaultValue: number;
minValue?: number;
minAllowedValue?: number;
className?: string;
floatingBadgeDescription?: TeactNode;
shouldUseDynamicColor?: boolean;
shouldAllowCustomValue?: boolean;
onChange: (value: number) => void;
onBadgeClick?: NoneToVoidFunction;
};
const DEFAULT_POINTS = [50, 100, 500, 1000, 2000, 5000, 10000];
const LARGE_STEP = 10000;
const THUMB_SIZE = 1.875 * REM;
const CORNER_BEAK_WIDTH = 44;
const DEFAULT_BEAK_WIDTH = 52;
const BEAK_HEIGHT = 32;
const BEAK_OFFSET_END = 10;
const TIP_OFFSET_END = 0;
const BADGE_HORIZONTAL_PADDING = 2 * REM;
const BADGE_ICON_SIZE = 1.5 * REM;
const BADGE_TITLE_GAP = 0.125 * REM;
const DRAG_DISTANCE_THRESHOLD = 5;
const BADGE_WIDTH_DELTA = 6;
let textMeasureCanvas: HTMLCanvasElement | undefined;
function getTextWidth(text: string, font: string): number {
if (!textMeasureCanvas) {
textMeasureCanvas = document.createElement('canvas');
}
const ctx = textMeasureCanvas.getContext('2d')!;
ctx.font = font;
return ctx.measureText(text).width;
}
const SLIDER_COLORS = [
'#955CDB', // Purple
'#955CDB', // Purple
'#46A3EB', // Blue
'#40A920', // Green
'#E29A09', // Yellow
'#ED771E', // Orange
'#E14542', // Red
'#596473', // Silver (100% only)
];
function getColorForProgress(progress: number): string {
if (progress >= 1) return SLIDER_COLORS[SLIDER_COLORS.length - 1];
const regularColorsCount = SLIDER_COLORS.length - 1;
const index = Math.floor(progress * regularColorsCount);
return SLIDER_COLORS[Math.min(index, regularColorsCount - 1)];
}
const StarSlider = ({
maxValue,
defaultValue,
minValue,
minAllowedValue,
className,
floatingBadgeDescription,
shouldUseDynamicColor,
shouldAllowCustomValue,
onChange,
onBadgeClick,
}: OwnProps) => {
const containerRef = useRef<HTMLDivElement>();
const floatingBadgeContentRef = useRef<HTMLDivElement>();
const lang = useLang();
const min = minValue ?? 1;
const points = useMemo(() => {
const result = [];
for (let i = 0; i < DEFAULT_POINTS.length; i++) {
if (DEFAULT_POINTS[i] <= min) continue;
if (DEFAULT_POINTS[i] < maxValue) {
result.push(DEFAULT_POINTS[i]);
}
if (DEFAULT_POINTS[i] >= maxValue) {
result.push(maxValue);
return result;
}
}
const lastPoint = DEFAULT_POINTS[DEFAULT_POINTS.length - 1];
let nextPoint = lastPoint + LARGE_STEP;
while (nextPoint < maxValue) {
result.push(nextPoint);
nextPoint += LARGE_STEP;
}
result.push(maxValue);
return result;
}, [maxValue, min]);
const [value, setValue] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const [badgeWidth, setBadgeWidth] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef<number | undefined>();
const prevBadgeWidth = usePrevious(badgeWidth);
const badgeText = lang.number(getValue(points, value, min));
const minAllowedProgress = minAllowedValue !== undefined
? getProgress(points, minAllowedValue, min) : 0;
useEffect(() => {
setValue(getProgress(points, defaultValue, min));
}, [defaultValue, points, min]);
useEffect(() => {
if (!floatingBadgeContentRef.current) return;
const titleEl = floatingBadgeContentRef.current.querySelector(`.${styles.floatingBadgeTitle}`);
if (!titleEl) return;
const computedStyle = getComputedStyle(titleEl);
const font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
const textWidth = getTextWidth(badgeText, font);
const titleWidth = BADGE_ICON_SIZE + BADGE_TITLE_GAP + textWidth;
const descriptionEl = floatingBadgeContentRef.current.querySelector(`.${styles.floatingBadgeDescription}`);
const descriptionWidth = descriptionEl?.scrollWidth || 0;
const contentWidth = Math.max(titleWidth, descriptionWidth);
const newBadgeWidth = contentWidth + BADGE_HORIZONTAL_PADDING;
setBadgeWidth((currentWidth) => {
if (Math.abs(newBadgeWidth - currentWidth) < BADGE_WIDTH_DELTA) {
return currentWidth;
}
return newBadgeWidth;
});
}, [badgeText, floatingBadgeDescription]);
const handleContainerResize = useLastCallback((entry: ResizeObserverEntry) => {
setContainerWidth(entry.contentRect.width);
});
useResizeObserver(containerRef, handleContainerResize);
const progress = value / points.length;
const {
minBadgeX, maxBadgeX, beakOffset, beakTipOffset, beakWidth: currentBeakWidth,
} = useMemo(() => {
return calcBadgePosition(containerWidth, badgeWidth, progress);
}, [containerWidth, badgeWidth, progress]);
const beakPath = useMemo(() => {
return generateBeakPath(currentBeakWidth, beakTipOffset);
}, [currentBeakWidth, beakTipOffset]);
const handleChange = useLastCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = Number(event.currentTarget.value);
const clampedValue = Math.max(rawValue, minAllowedProgress);
setValue(clampedValue);
const resultValue = getValue(points, clampedValue, min);
onChange(resultValue);
});
const handlePointerDown = useLastCallback((e: React.PointerEvent<HTMLInputElement>) => {
startXRef.current = e.clientX;
setIsDragging(false);
});
const handlePointerMove = useLastCallback((e: React.PointerEvent<HTMLInputElement>) => {
if (startXRef.current === undefined) return;
const distance = Math.abs(e.clientX - startXRef.current);
if (distance >= DRAG_DISTANCE_THRESHOLD) {
setIsDragging(true);
}
});
const handlePointerUp = useLastCallback(() => {
startXRef.current = undefined;
setIsDragging(false);
});
const dynamicColor = shouldUseDynamicColor ? getColorForProgress(progress) : undefined;
const badgeStyle = buildStyle(
Boolean(badgeWidth) && `width: ${badgeWidth}px`,
);
const rootStyle = buildStyle(
`--progress: ${progress}`,
`--min-badge-x: ${minBadgeX}px`,
`--max-badge-x: ${maxBadgeX}px`,
dynamicColor && `--dynamic-color: ${dynamicColor}`,
);
return (
<div
ref={containerRef}
className={buildClassName(styles.root, isDragging && styles.dragging, className)}
style={rootStyle}
>
<div className={styles.floatingBadgeWrapper}>
<div
className={buildClassName(styles.floatingBadge, onBadgeClick && styles.clickable)}
onClick={onBadgeClick}
>
<div
className={buildClassName(
styles.floatingBadgeText,
shouldUseDynamicColor && styles.dynamicColor,
(!prevBadgeWidth || prevBadgeWidth === 0) && styles.noTransition,
)}
style={badgeStyle}
>
<Sparkles preset="button" className={styles.floatingBadgeSparkles} />
<div
ref={floatingBadgeContentRef}
className={buildClassName(
styles.floatingBadgeContent,
floatingBadgeDescription && styles.withDescription,
)}
>
<div className={styles.floatingBadgeTitle}>
<Icon name="star" className={styles.floatingBadgeIcon} />
<AnimatedCounter text={badgeText} />
</div>
<div className={styles.floatingBadgeDescription}>
{floatingBadgeDescription}
</div>
</div>
</div>
<svg
className={styles.floatingBadgeTriangle}
width={currentBeakWidth}
height={BEAK_HEIGHT}
viewBox={`0 0 ${currentBeakWidth} ${BEAK_HEIGHT}`}
fill="none"
aria-hidden="true"
role="presentation"
style={`transform: translate(calc(-50% + ${beakOffset}px), 33%)`}
>
{!shouldUseDynamicColor && (
<defs>
<linearGradient id="StarBadgeTriangle" x1="0" x2="1" y1="0" y2="0">
<stop offset="-50%" stop-color="#FFAA00" />
<stop offset="150%" stop-color="#FFCD3A" />
</linearGradient>
</defs>
)}
<path
className={styles.floatingBadgeTrianglePath}
d={beakPath}
fill={dynamicColor || 'url(#StarBadgeTriangle)'}
/>
</svg>
</div>
</div>
<div className={buildClassName(styles.progress, shouldUseDynamicColor && styles.dynamicColor)}>
<Sparkles preset="progress" className={styles.sparkles} />
</div>
<input
className={styles.slider}
type="range"
min={0}
max={points.length}
defaultValue={getProgress(points, defaultValue, min)}
step="any"
onChange={handleChange}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
{shouldAllowCustomValue && (
<Icon name="add" className={styles.customValueIcon} />
)}
</div>
);
};
function getProgress(points: number[], value: number, minValue: number) {
const pointIndex = points.findIndex((point) => value <= point);
const prevPoint = points[pointIndex - 1] || minValue;
const nextPoint = points[pointIndex] || points[points.length - 1];
if (nextPoint === prevPoint) return pointIndex;
const progress = (value - prevPoint) / (nextPoint - prevPoint);
return pointIndex + progress;
}
function getValue(points: number[], progress: number, minValue: number) {
const pointIndex = Math.floor(progress);
const prevPoint = points[pointIndex - 1] || minValue;
const nextPoint = points[pointIndex] || points[points.length - 1];
const value = prevPoint + (nextPoint - prevPoint) * (progress - pointIndex);
return Math.round(value);
}
function calcBadgePosition(
containerWidth: number,
badgeWidth: number,
progress: number,
) {
const halfBadgeWidth = badgeWidth / 2;
const cornerBeakHalfWidth = CORNER_BEAK_WIDTH / 2;
const maxBeakOffset = halfBadgeWidth - cornerBeakHalfWidth;
const minBadgeX = halfBadgeWidth;
const maxBadgeX = containerWidth - halfBadgeWidth;
const thumbHalf = THUMB_SIZE / 2;
const trackLength = containerWidth - THUMB_SIZE;
const distanceFromLeft = progress * trackLength;
const distanceFromRight = trackLength - distanceFromLeft;
const isLeftSide = distanceFromLeft < distanceFromRight;
const distanceToEdge = isLeftSide ? distanceFromLeft : distanceFromRight;
const direction = isLeftSide ? -1 : 1;
let beakOffset = 0;
let beakTipOffset = 0;
let beakWidth = DEFAULT_BEAK_WIDTH;
const beakOffsetStart = halfBadgeWidth - thumbHalf;
if (distanceToEdge < beakOffsetStart) {
if (distanceToEdge > BEAK_OFFSET_END) {
const t = (beakOffsetStart - distanceToEdge) / (beakOffsetStart - BEAK_OFFSET_END);
beakOffset = direction * t * maxBeakOffset;
} else if (distanceToEdge > TIP_OFFSET_END) {
beakOffset = direction * maxBeakOffset;
const t = (BEAK_OFFSET_END - distanceToEdge) / (BEAK_OFFSET_END - TIP_OFFSET_END);
const tExp = 1 - (1 - t) * (1 - t) * (1 - t);
beakWidth = DEFAULT_BEAK_WIDTH - tExp * (DEFAULT_BEAK_WIDTH - CORNER_BEAK_WIDTH);
beakTipOffset = direction * t * (beakWidth / 2);
} else {
beakOffset = direction * maxBeakOffset;
beakWidth = CORNER_BEAK_WIDTH;
beakTipOffset = direction * (beakWidth / 2);
}
}
return {
minBadgeX,
maxBadgeX,
beakOffset,
beakTipOffset,
beakWidth,
};
}
const TIP_RADIUS = 2;
function generateBeakPath(beakWidth: number, tipOffset: number): string {
const tipX = beakWidth / 2 + tipOffset;
const r = TIP_RADIUS;
const y = BEAK_HEIGHT - r;
return `M 0 0 L ${beakWidth} 0 L ${tipX + r} ${y} Q ${tipX} ${BEAK_HEIGHT} ${tipX - r} ${y} Z`;
}
export default memo(StarSlider);