холст конструктор

This commit is contained in:
2025-07-16 17:25:33 +03:00
parent b77303fa97
commit 6933fab3bb
13 changed files with 1243 additions and 996 deletions

View File

@@ -0,0 +1,256 @@
import {
Calendar,
CheckSquare,
ChevronDown,
Edit,
Eye,
EyeOff,
FileText,
Hash,
Move,
Trash2,
Type
} from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { Rnd } from 'react-rnd';
import { ElementLayout, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
import { Button } from '../ui/button';
interface VisualLayoutEditorProps {
elements: TemplateElement[];
layoutSettings: FormLayoutSettings;
onElementUpdate: (elementId: string, updates: Partial<TemplateElement>) => void;
onElementDelete: (elementId: string) => void;
onLayoutSettingsChange: (settings: FormLayoutSettings) => void;
onElementEdit?: (element: TemplateElement) => void;
}
interface DraggableElementProps {
element: TemplateElement;
isSelected: boolean;
onSelect: () => void;
onUpdate: (layout: ElementLayout) => void;
onDelete: () => void;
onEdit?: () => void;
gridSize: number;
}
const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
text: <Type className="h-4 w-4" />,
textarea: <FileText className="h-4 w-4" />,
number: <Hash className="h-4 w-4" />,
date: <Calendar className="h-4 w-4" />,
select: <ChevronDown className="h-4 w-4" />,
radio: <CheckSquare className="h-4 w-4" />,
checkbox: <CheckSquare className="h-4 w-4" />,
};
const ELEMENT_COLORS: Record<ElementType, string> = {
text: 'bg-blue-100 border-blue-300 text-blue-800',
textarea: 'bg-green-100 border-green-300 text-green-800',
number: 'bg-purple-100 border-purple-300 text-purple-800',
date: 'bg-orange-100 border-orange-300 text-orange-800',
select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
radio: 'bg-pink-100 border-pink-300 text-pink-800',
checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
};
const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
element,
isSelected,
onSelect,
onUpdate,
onDelete,
onEdit,
gridSize,
}) => {
const layout = element.layout || { x: 50, y: 50, width: 300, height: 80, zIndex: 1 };
const elementColor = ELEMENT_COLORS[element.type];
const handleDragStop = useCallback((_e: any, d: { x: number; y: number }) => {
// Вызываем onUpdate только если позиция действительно изменилась,
// чтобы избежать ложных срабатываний при клике.
if (d.x !== layout.x || d.y !== layout.y) {
onUpdate({ ...layout, x: d.x, y: d.y });
}
}, [layout, onUpdate]);
const handleResizeStop = useCallback((_e: any, _dir: any, ref: HTMLElement, _delta: any, pos: { x: number; y: number }) => {
onUpdate({
...layout,
width: parseInt(ref.style.width, 10),
height: parseInt(ref.style.height, 10),
x: pos.x,
y: pos.y,
});
}, [layout, onUpdate]);
const handleDeleteClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
}, [onDelete]);
const handleEditClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onEdit?.();
}, [onEdit]);
return (
<Rnd
dragGrid={[gridSize, gridSize]}
resizeGrid={[gridSize, gridSize]}
default={{
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height,
}}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
minWidth={150}
minHeight={40}
bounds="parent"
className={`transition-shadow duration-200 group ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 z-20' : 'z-10'}`}
onClick={onSelect}
>
<div className={`h-full rounded-lg border-2 p-3 cursor-move ${elementColor} ${isSelected ? 'shadow-xl' : 'shadow-md'} transition-all duration-200`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{ELEMENT_ICONS[element.type]}
<span className="font-medium text-sm truncate">{element.label || `${element.type} элемент`}</span>
</div>
<div className="flex items-center gap-1">
{onEdit && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-100"
onClick={handleEditClick}
>
<Edit className="h-3 w-3 text-blue-600" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-100"
onClick={handleDeleteClick}
>
<Trash2 className="h-3 w-3 text-red-600" />
</Button>
</div>
</div>
<div className="text-xs opacity-75 space-y-1">
{element.required && <div className="text-red-700 font-medium"> Обязательное</div>}
{element.targetCells && element.targetCells.length > 0 && (
<div className="font-mono truncate">
{element.targetCells.map((cell, i) => <span key={i} className="mr-1.5 p-1 bg-black/5 rounded-sm">{cell.sheet}!{cell.cell}</span>)}
</div>
)}
</div>
</div>
</Rnd>
);
});
export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
elements,
layoutSettings,
onElementUpdate,
onElementDelete,
onLayoutSettingsChange,
onElementEdit,
}) => {
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
const canvasSize = useMemo(() => {
const PADDING_Y = 200;
const baseWidth = 1200;
const baseHeight = 800;
if (elements.length === 0) {
return { width: baseWidth, height: baseHeight };
}
const contentHeight = Math.max(
0,
...elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
);
return {
width: baseWidth,
height: Math.max(baseHeight, contentHeight + PADDING_Y),
};
}, [elements]);
const GridPattern = useMemo(() => {
if (!layoutSettings.showGrid) return null;
const gridSize = layoutSettings.gridSize;
return (
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `linear-gradient(to right, #e5e7eb 1px, transparent 1px), linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`,
backgroundSize: `${gridSize}px ${gridSize}px`,
}}
/>
);
}, [layoutSettings.showGrid, layoutSettings.gridSize]);
const handleElementUpdateCallback = useCallback((elementId: string, layout: ElementLayout) => {
onElementUpdate(elementId, { layout });
}, [onElementUpdate]);
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
setSelectedElementId(null);
}
}, []);
const toggleGrid = useCallback(() => {
onLayoutSettingsChange({ ...layoutSettings, showGrid: !layoutSettings.showGrid });
}, [layoutSettings, onLayoutSettingsChange]);
return (
<div className="flex flex-col h-full bg-gray-50">
<div className="flex items-center justify-between p-2 bg-white border-b shrink-0">
<span className="text-sm text-gray-600 px-2">{elements.length} элементов на холсте</span>
<Button variant="outline" size="sm" onClick={toggleGrid}>
{layoutSettings.showGrid ? <EyeOff className="h-4 w-4 mr-2" /> : <Eye className="h-4 w-4 mr-2" />}
{layoutSettings.showGrid ? 'Скрыть сетку' : 'Показать сетку'}
</Button>
</div>
<div className="flex-1 relative overflow-auto" onClick={handleCanvasClick}>
<div
className="relative bg-white shadow-lg mx-auto my-8"
style={{ width: canvasSize.width, height: canvasSize.height }}
>
{GridPattern}
{elements.map((element) => (
<DraggableElement
key={element.id}
element={element}
isSelected={selectedElementId === element.id}
onSelect={() => setSelectedElementId(element.id)}
onUpdate={(layout) => handleElementUpdateCallback(element.id, layout)}
onDelete={() => onElementDelete(element.id)}
onEdit={onElementEdit ? () => onElementEdit(element) : undefined}
gridSize={layoutSettings.gridSize}
/>
))}
{elements.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center text-gray-400">
<Move className="h-16 w-16 mx-auto mb-4" />
<p className="text-xl font-medium mb-2">Холст пуст</p>
<p className="text-sm">Добавьте свой первый элемент</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};