кнопка +1 и ее анимация

This commit is contained in:
2025-07-28 10:15:50 +03:00
parent dc667092b3
commit 9322971c66

View File

@@ -1,13 +1,14 @@
import { Checkbox } from '@/component/ui/checkbox'
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { CellsBadge } from '@/entitiy/element/ui/cellsBadge'
import { ExtraSettingsBadge } from '@/entitiy/element/ui/extraSettingsBadge'
import { Save, SaveOff, Type } from 'lucide-react'
import { Plus, Save, SaveOff, Type } from 'lucide-react'
import { useEffect, useState } from 'react'
interface TextConfig {
placeholder?: string
required?: boolean
showIncrementButton?: boolean // Новый параметр для отображения кнопки +1
targetCells: any[]
name?: string
id?: string // Добавляем id для совместимости с TemplateElement
@@ -49,6 +50,7 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
defaultConfig: {
placeholder: '',
required: false,
showIncrementButton: false,
targetCells: [],
name: '',
},
@@ -65,7 +67,25 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: () => <ExtraSettingsBadge />,
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="showIncrementButton"
checked={config.showIncrementButton || false}
onCheckedChange={checked =>
onChange({ ...config, showIncrementButton: !!checked })
}
/>
<label
htmlFor="showIncrementButton"
className="text-sm font-medium text-gray-900"
>
Показывать кнопку +1
</label>
</div>
</div>
),
Preview: ({ config }) => (
<div className="space-y-1">
{config.name && (
@@ -74,12 +94,32 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
{config.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<Input placeholder={config.placeholder} className="w-full" />
<div className="relative flex items-center">
<Input
placeholder={config.placeholder}
className={`w-full ${config.showIncrementButton ? 'pr-10' : ''}`}
/>
{config.showIncrementButton && (
<button
className="absolute right-1 flex h-8 w-8 items-center justify-center rounded-md bg-blue-500 text-white"
disabled
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</div>
),
Render: ({ config, value, onChange }) => {
const [localStorageEnabled, setLocalStorageEnabled] = useState(false)
const [currentValue, setCurrentValue] = useState(value || '')
const [isAnimating, setIsAnimating] = useState(false)
const [rollingDigits, setRollingDigits] = useState<string[]>([])
const [flyingDigit, setFlyingDigit] = useState<{
show: boolean
x: number
y: number
}>({ show: false, x: 0, y: 0 })
// Инициализация состояния localStorage из сохраненных данных
useEffect(() => {
@@ -133,8 +173,218 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
}
}
const handleIncrementClick = () => {
if (isAnimating) return
const currentNum = parseFloat(currentValue) || 0
const newNum = currentNum + 1
const currentStr = currentNum.toString()
const newStr = newNum.toString()
setIsAnimating(true)
// Получаем позицию кнопки для анимации вылета
const buttonElement = document.querySelector(
'[data-increment-button]'
) as HTMLElement
if (buttonElement) {
const rect = buttonElement.getBoundingClientRect()
const inputElement = buttonElement
.closest('.relative')
?.querySelector('input')
const inputRect = inputElement?.getBoundingClientRect()
if (inputRect) {
setFlyingDigit({
show: true,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
}
}
// Проверяем, увеличивается ли количество разрядов
const isAddingDigit = newStr.length > currentStr.length
if (isAddingDigit) {
// Если добавляется разряд, делаем анимацию сдвига
setRollingDigits(currentStr.split(''))
// Анимация сдвига при добавлении разряда
const duration = 800
const startTime = Date.now()
const animateSlide = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Убираем летящую цифру через 400мс
if (elapsed > 400) {
setFlyingDigit(prev => ({ ...prev, show: false }))
}
if (progress < 1) {
// Плавное появление новой цифры слева и изменение старых
const easeOutQuad = (t: number) => t * (2 - t)
const smoothProgress = easeOutQuad(progress)
// Разбираем целевое число на цифры
const targetDigits = newStr.split('')
const currentDigits = currentStr.split('')
if (smoothProgress > 0.2) {
// Создаем анимированный массив цифр
const animatedDigits = targetDigits.map((targetDigit, index) => {
if (index === 0) {
// Первая цифра (новая) - плавно появляется
return targetDigit
} else {
// Остальные цифры - плавно меняются от старых к новым
const oldDigit = currentDigits[index - 1] || '0'
const oldNum = parseInt(oldDigit)
const targetNum = parseInt(targetDigit)
// Интерполяция между старой и новой цифрой
const currentNum =
oldNum + (targetNum - oldNum) * smoothProgress
// В конце анимации фиксируем на целевой цифре
if (progress > 0.9) {
return targetDigit
}
return Math.round(currentNum).toString()
}
})
setRollingDigits(animatedDigits)
}
} else {
// Финал
setRollingDigits([])
setIsAnimating(false)
handleValueChange(newStr)
}
if (progress < 1) {
requestAnimationFrame(animateSlide)
}
}
requestAnimationFrame(animateSlide)
} else {
// Обычная анимация для случаев без добавления разряда
const maxLength = Math.max(currentStr.length, newStr.length)
const currentPadded = currentStr.padStart(maxLength, '0')
const newPadded = newStr.padStart(maxLength, '0')
// Создаем массив с начальными значениями
setRollingDigits(currentPadded.split(''))
// Анимация плавного перехода цифр
const duration = 800
const startTime = Date.now()
const animateTransition = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Убираем летящую цифру через 400мс
if (elapsed > 400) {
setFlyingDigit(prev => ({ ...prev, show: false }))
}
if (progress < 1) {
// Используем функцию сглаживания для более плавной анимации
const easeOutQuad = (t: number) => t * (2 - t)
const smoothProgress = easeOutQuad(progress)
const newRolling = currentPadded
.split('')
.map((oldDigit, index) => {
const targetDigit = newPadded[index]
// Если цифра не изменилась, оставляем как есть
if (oldDigit === targetDigit) {
return oldDigit
}
// Для изменившихся цифр - прямой переход с интерполяцией
const oldNum = parseInt(oldDigit)
const targetNum = parseInt(targetDigit)
// Прямая интерполяция между старой и новой цифрой
const currentNum =
oldNum + (targetNum - oldNum) * smoothProgress
// В конце анимации фиксируем на целевой цифре
if (progress > 0.95) {
return targetDigit
}
return Math.round(currentNum).toString()
})
setRollingDigits(newRolling)
} else {
// Финал
setRollingDigits([])
setIsAnimating(false)
handleValueChange(newStr)
}
if (progress < 1) {
requestAnimationFrame(animateTransition)
}
}
requestAnimationFrame(animateTransition)
}
}
// Определяем отображаемое значение
const displayValue =
isAnimating && rollingDigits.length > 0
? rollingDigits.join('').replace(/^0+/, '') || '0'
: currentValue
return (
<div className="relative space-y-1">
{/* Летящая единичка */}
{flyingDigit.show && (
<>
<style>
{`
@keyframes flyToInput {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-150%, -100%) scale(1.2);
opacity: 0.8;
}
100% {
transform: translate(-300%, -150%) scale(0.8);
opacity: 0;
}
}
`}
</style>
<div
className="pointer-events-none fixed z-50 text-2xl font-bold text-blue-500"
style={{
left: flyingDigit.x,
top: flyingDigit.y,
transform: 'translate(-50%, -50%)',
animation: 'flyToInput 400ms ease-out forwards',
}}
>
+1
</div>
</>
)}
{/* Бейджи в правом верхнем углу */}
<div className="absolute right-0 top-0 z-10 flex items-center gap-1">
{/* Галочка сохранения */}
@@ -177,13 +427,46 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
</label>
)}
{/* Поле ввода */}
{/* Поле ввода с кнопкой */}
<div className="relative flex items-center">
<Input
placeholder={config.placeholder}
value={currentValue}
onChange={e => handleValueChange(e.target.value)}
value={displayValue}
onChange={e => {
if (!isAnimating) {
handleValueChange(e.target.value)
}
}}
required={config.required}
className={`${config.showIncrementButton ? 'pr-10' : ''} ${
isAnimating
? 'font-mono text-lg font-bold tracking-wider text-blue-600'
: ''
} transition-all duration-300`}
readOnly={isAnimating}
/>
{/* Кнопка +1 */}
{config.showIncrementButton && (
<button
data-increment-button
onClick={handleIncrementClick}
disabled={isAnimating}
className={`absolute right-1 z-10 flex h-8 w-8 items-center justify-center rounded-md transition-all duration-300 hover:bg-blue-600 disabled:opacity-75 ${
isAnimating
? 'scale-125 bg-green-500 shadow-xl ring-4 ring-green-300 ring-opacity-50'
: 'scale-100 bg-blue-500 hover:shadow-lg'
}`}
title="Увеличить на 1"
>
<Plus
className={`h-4 w-4 text-white transition-all duration-500 ${
isAnimating ? 'rotate-180 scale-150' : 'rotate-0 scale-100'
}`}
/>
</button>
)}
</div>
</div>
)
},