холст конструктор
This commit is contained in:
256
src/components/TemplateManager/VisualLayoutEditor.tsx
Normal file
256
src/components/TemplateManager/VisualLayoutEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user