476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
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 { 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
|
||
}
|
||
|
||
// Утилиты для работы с localStorage
|
||
const getLocalStorageKey = (type: string, id: string) => `${type}_${id}`
|
||
|
||
const saveToLocalStorage = (key: string, value: string) => {
|
||
try {
|
||
localStorage.setItem(key, value)
|
||
} catch (error) {
|
||
console.warn('Failed to save to localStorage:', error)
|
||
}
|
||
}
|
||
|
||
const getFromLocalStorage = (key: string): string => {
|
||
try {
|
||
return localStorage.getItem(key) || ''
|
||
} catch (error) {
|
||
console.warn('Failed to get from localStorage:', error)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
const removeFromLocalStorage = (key: string) => {
|
||
try {
|
||
localStorage.removeItem(key)
|
||
} catch (error) {
|
||
console.warn('Failed to remove from localStorage:', error)
|
||
}
|
||
}
|
||
|
||
export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||
type: 'text',
|
||
label: 'Текстовое поле',
|
||
icon: <Type className="h-4 w-4" />,
|
||
version: 1,
|
||
defaultConfig: {
|
||
placeholder: '',
|
||
required: false,
|
||
showIncrementButton: false,
|
||
targetCells: [],
|
||
name: '',
|
||
},
|
||
getInitialValue: config => {
|
||
// Используем id из config (который приходит из TemplateElement)
|
||
if (config.id) {
|
||
const key = getLocalStorageKey('text', config.id)
|
||
return getFromLocalStorage(key)
|
||
}
|
||
return ''
|
||
},
|
||
mapToCells: config => config.targetCells || [],
|
||
mapToCellValues: (config, value) => {
|
||
const cells = config.targetCells || []
|
||
return cells.map(target => ({ target, value: value || '' }))
|
||
},
|
||
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 && (
|
||
<label className="text-sm font-medium text-gray-900">
|
||
{config.name}
|
||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||
</label>
|
||
)}
|
||
<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(() => {
|
||
if (config.id) {
|
||
const key = getLocalStorageKey('text', config.id)
|
||
const savedValue = getFromLocalStorage(key)
|
||
|
||
// Если есть сохраненное значение, включаем localStorage и загружаем его
|
||
if (savedValue) {
|
||
setLocalStorageEnabled(true)
|
||
if (savedValue !== currentValue) {
|
||
setCurrentValue(savedValue)
|
||
onChange?.(savedValue)
|
||
}
|
||
}
|
||
}
|
||
}, [config.id])
|
||
|
||
// Синхронизация внешнего значения
|
||
useEffect(() => {
|
||
if (value !== currentValue) {
|
||
setCurrentValue(value || '')
|
||
}
|
||
}, [value])
|
||
|
||
const handleValueChange = (newValue: string) => {
|
||
setCurrentValue(newValue)
|
||
onChange?.(newValue)
|
||
|
||
// Сохранение в localStorage если включено
|
||
if (localStorageEnabled && config.id) {
|
||
const key = getLocalStorageKey('text', config.id)
|
||
saveToLocalStorage(key, newValue)
|
||
}
|
||
}
|
||
|
||
const handleLocalStorageToggle = () => {
|
||
const newEnabled = !localStorageEnabled
|
||
setLocalStorageEnabled(newEnabled)
|
||
|
||
if (config.id) {
|
||
const key = getLocalStorageKey('text', config.id)
|
||
|
||
if (newEnabled) {
|
||
// Включаем localStorage - сохраняем текущее значение
|
||
saveToLocalStorage(key, currentValue)
|
||
} else {
|
||
// Выключаем localStorage - удаляем сохраненное значение
|
||
removeFromLocalStorage(key)
|
||
}
|
||
}
|
||
}
|
||
|
||
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">
|
||
{/* Галочка сохранения */}
|
||
{config.id && (
|
||
<div className="group/save">
|
||
<button
|
||
onClick={handleLocalStorageToggle}
|
||
className="cursor-pointer"
|
||
>
|
||
{localStorageEnabled ? (
|
||
<Save className="h-3 w-3 text-green-600 opacity-80 transition-opacity hover:opacity-100" />
|
||
) : (
|
||
<SaveOff className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
|
||
)}
|
||
</button>
|
||
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/save:opacity-100">
|
||
<div className="font-medium">
|
||
{localStorageEnabled
|
||
? 'Автосохранение включено'
|
||
: 'Автосохранение выключено'}
|
||
</div>
|
||
<div className="text-gray-300">
|
||
{localStorageEnabled
|
||
? 'Значение сохраняется в браузере'
|
||
: 'Нажмите, чтобы включить автосохранение'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Бейдж ячеек */}
|
||
<CellsBadge targetCells={config.targetCells} className="" />
|
||
</div>
|
||
|
||
{/* Лейбл поля */}
|
||
{config.name && (
|
||
<label className="text-sm font-medium text-gray-900">
|
||
{config.name}
|
||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||
</label>
|
||
)}
|
||
|
||
{/* Поле ввода с кнопкой */}
|
||
<div className="relative flex items-center">
|
||
<Input
|
||
placeholder={config.placeholder}
|
||
value={displayValue}
|
||
onChange={e => {
|
||
if (!isAnimating) {
|
||
// Автоматически заменяем запятые на точки
|
||
const normalizedValue = e.target.value.replace(/,/g, '.')
|
||
handleValueChange(normalizedValue)
|
||
}
|
||
}}
|
||
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>
|
||
)
|
||
},
|
||
}
|