Files
protoc-frontend/src/entitiy/element/model/implementations/TextElement.tsx

476 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
},
}