кнопка +1 и ее анимация
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
{/* Поле ввода */}
|
||||
<Input
|
||||
placeholder={config.placeholder}
|
||||
value={currentValue}
|
||||
onChange={e => handleValueChange(e.target.value)}
|
||||
required={config.required}
|
||||
/>
|
||||
{/* Поле ввода с кнопкой */}
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
placeholder={config.placeholder}
|
||||
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>
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user