334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
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';
|
||
import { Input } from '../ui/input';
|
||
import { Textarea } from '../ui/textarea';
|
||
|
||
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 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]);
|
||
|
||
/*
|
||
* Render a lightweight, disabled preview of the element so that the user can
|
||
* see how the real control looks directly on the canvas instead of a plain
|
||
* colored square. Keeping the elements disabled and wrapped with
|
||
* `pointer-events-none` guarantees that they do not interfere with the drag
|
||
* behaviour of Rnd and keeps rendering costs minimal.
|
||
*/
|
||
const previewControl = useMemo(() => {
|
||
switch (element.type) {
|
||
case 'text':
|
||
return <Input readOnly placeholder={element.placeholder || 'Введите текст'} className="pointer-events-none w-full bg-white" />;
|
||
case 'textarea':
|
||
return <Textarea readOnly rows={3} placeholder={element.placeholder || 'Введите текст'} className="pointer-events-none w-full resize-none bg-white" />;
|
||
case 'number':
|
||
return <Input type="number" readOnly placeholder={element.placeholder || '0'} className="pointer-events-none w-full bg-white" />;
|
||
case 'date':
|
||
return <Input type="date" readOnly className="pointer-events-none w-full bg-white" />;
|
||
case 'select':
|
||
return (
|
||
<div className="pointer-events-none w-full">
|
||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
|
||
<span className="text-gray-500">
|
||
{element.placeholder || 'Выберите значение'}
|
||
</span>
|
||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||
</div>
|
||
</div>
|
||
);
|
||
case 'radio':
|
||
return (
|
||
<div className="space-y-2 pointer-events-none">
|
||
{(element.options?.length ? element.options : [{ label: 'Вариант 1' }]).map((opt, idx) => (
|
||
<div key={idx} className="flex items-center gap-2">
|
||
<div className="w-4 h-4 border-2 border-gray-300 rounded-full bg-white" />
|
||
<span className="text-sm text-gray-900">{opt.label || opt.value || `Вариант ${idx + 1}`}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
case 'checkbox':
|
||
return (
|
||
<div className="flex items-center gap-2 pointer-events-none">
|
||
<div className="w-4 h-4 border-2 border-gray-300 rounded bg-white" />
|
||
<span className="text-sm text-gray-900">{element.placeholder || element.label || 'Отметить'}</span>
|
||
</div>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
}, [element]);
|
||
|
||
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={120}
|
||
minHeight={32}
|
||
bounds="parent"
|
||
className={`group ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 z-20' : 'z-10'}`}
|
||
onClick={onSelect}
|
||
>
|
||
<div className={`h-full cursor-move ${
|
||
isSelected
|
||
? 'bg-blue-50/20 border border-blue-200/50 rounded-md p-2'
|
||
: 'bg-transparent border border-transparent rounded-md hover:bg-gray-50/10 p-1'
|
||
}`}>
|
||
{/* Label and controls (only visible when selected or hovered) */}
|
||
<div className={`flex items-center justify-between mb-1 ${
|
||
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
|
||
}`}>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-xs text-gray-600 truncate">
|
||
{element.label || element.name || `${element.type} элемент`}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
{onEdit && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-4 w-4 cursor-pointer hover:bg-blue-100"
|
||
onClick={handleEditClick}
|
||
>
|
||
<Edit className="h-2.5 w-2.5 text-blue-600" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-4 w-4 cursor-pointer hover:bg-red-100"
|
||
onClick={handleDeleteClick}
|
||
>
|
||
<Trash2 className="h-2.5 w-2.5 text-red-600" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Real element preview */}
|
||
{previewControl && (
|
||
<div className="space-y-1 flex-1">
|
||
{element.label && (
|
||
<label className="block text-sm font-medium text-gray-900 pointer-events-none">
|
||
{element.label}
|
||
{element.required && <span className="text-red-500 ml-1">*</span>}
|
||
</label>
|
||
)}
|
||
{previewControl}
|
||
</div>
|
||
)}
|
||
|
||
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
|
||
{isSelected && (
|
||
<div className="text-xs opacity-75 space-y-1 mt-1">
|
||
{element.targetCells && element.targetCells.length > 0 && (
|
||
<div className="font-mono truncate">
|
||
{element.targetCells.map((cell, i) => (
|
||
<span key={i} className="mr-1 px-1 py-0.5 bg-black/5 rounded text-xs">{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>
|
||
);
|
||
};
|