Files
protoc-frontend/src/components/TemplateManager/VisualLayoutEditor.tsx

334 lines
14 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 {
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>
);
};