кнопка +1 и ее анимация
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
|
import { Checkbox } from '@/component/ui/checkbox'
|
||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { CellsBadge } from '@/entitiy/element/ui/cellsBadge'
|
import { CellsBadge } from '@/entitiy/element/ui/cellsBadge'
|
||||||
import { ExtraSettingsBadge } from '@/entitiy/element/ui/extraSettingsBadge'
|
import { Plus, Save, SaveOff, Type } from 'lucide-react'
|
||||||
import { Save, SaveOff, Type } from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
interface TextConfig {
|
interface TextConfig {
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
showIncrementButton?: boolean // Новый параметр для отображения кнопки +1
|
||||||
targetCells: any[]
|
targetCells: any[]
|
||||||
name?: string
|
name?: string
|
||||||
id?: string // Добавляем id для совместимости с TemplateElement
|
id?: string // Добавляем id для совместимости с TemplateElement
|
||||||
@@ -49,6 +50,7 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
|||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
|
showIncrementButton: false,
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
name: '',
|
name: '',
|
||||||
},
|
},
|
||||||
@@ -65,7 +67,25 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
|||||||
const cells = config.targetCells || []
|
const cells = config.targetCells || []
|
||||||
return cells.map(target => ({ target, value: value || '' }))
|
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 }) => (
|
Preview: ({ config }) => (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{config.name && (
|
{config.name && (
|
||||||
@@ -74,12 +94,32 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
|||||||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
Render: ({ config, value, onChange }) => {
|
Render: ({ config, value, onChange }) => {
|
||||||
const [localStorageEnabled, setLocalStorageEnabled] = useState(false)
|
const [localStorageEnabled, setLocalStorageEnabled] = useState(false)
|
||||||
const [currentValue, setCurrentValue] = useState(value || '')
|
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 из сохраненных данных
|
// Инициализация состояния localStorage из сохраненных данных
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<div className="relative space-y-1">
|
<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">
|
<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>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Поле ввода */}
|
{/* Поле ввода с кнопкой */}
|
||||||
|
<div className="relative flex items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder={config.placeholder}
|
placeholder={config.placeholder}
|
||||||
value={currentValue}
|
value={displayValue}
|
||||||
onChange={e => handleValueChange(e.target.value)}
|
onChange={e => {
|
||||||
|
if (!isAnimating) {
|
||||||
|
handleValueChange(e.target.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
required={config.required}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user