Files
BoxGenerator/src/components/LayoutStep.tsx
Халимов Рустам 5cb362dccc 2
2026-01-10 20:06:56 +03:00

471 lines
24 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 React, { useRef, useState, useMemo } from 'react';
import { AppConfig, LayoutSplits, Partition } from '../types';
// ИСПРАВЛЕНИЕ: Добавлена иконка Move в импорты
import { Grid, MousePointer2, Trash2, RotateCcw, X, Plus, Move } from 'lucide-react';
interface Props {
config: AppConfig;
splits: LayoutSplits;
onChange: (splits: LayoutSplits) => void;
}
type EditMode = 'lines' | 'cells';
type Axis = 'x' | 'y';
// Тип для перетаскивания
type DragTarget =
| { type: 'main'; axis: Axis; index: number }
| { type: 'partition'; cellKey: string; id: string; axis: Axis };
export const LayoutStep: React.FC<Props> = ({ config, splits, onChange }) => {
const svgRef = useRef<SVGSVGElement>(null);
const [mode, setMode] = useState<EditMode>('lines');
// Мышь и состояния
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [isButtonHovered, setIsButtonHovered] = useState(false);
// Main Grid States
const [phantomMainAxis, setPhantomMainAxis] = useState<Axis | null>(null);
const [hoveredMainSplit, setHoveredMainSplit] = useState<{ axis: Axis; index: number } | null>(null);
// Partition States
const [hoveredCell, setHoveredCell] = useState<{ i: number; j: number } | null>(null);
const [hoveredPartition, setHoveredPartition] = useState<{ id: string; cellKey: string } | null>(null);
const [phantomPartition, setPhantomPartition] = useState<{ axis: Axis; offset: number } | null>(null);
// Selection & Dragging
const [selectedPartitionId, setSelectedPartitionId] = useState<string | null>(null);
const [dragging, setDragging] = useState<DragTarget | null>(null);
// --- Safe Data Access ---
const safeX = Array.isArray(splits?.x) ? splits.x : [];
const safeY = Array.isArray(splits?.y) ? splits.y : [];
const safePartitions = splits?.partitions || {};
// --- Dimensions & Aspect Ratio ---
const drawerW = Math.max(1, config.drawer.width || 300);
const drawerD = Math.max(1, config.drawer.depth || 400);
const aspectRatio = drawerD / drawerW;
// ViewBox (internal SVG coordinates)
const viewBoxW = 1000;
const viewBoxH = viewBoxW * aspectRatio;
const sortedX = useMemo(() => [0, ...safeX, 1].sort((a, b) => a - b), [safeX]);
const sortedY = useMemo(() => [0, ...safeY, 1].sort((a, b) => a - b), [safeY]);
// --- Helper: Get Partition Data ---
const getSelectedPartition = () => {
if (!selectedPartitionId) return null;
for (const key in safePartitions) {
const part = safePartitions[key].find(p => p.id === selectedPartitionId);
if (part) return { key, part };
}
return null;
};
const selectedData = getSelectedPartition();
// --- ACTIONS: MAIN GRID ---
const removeMainSplit = (axis: Axis, index: number) => {
const newSplits = { ...splits, x: [...safeX], y: [...safeY] };
newSplits[axis] = newSplits[axis].filter((_, i) => i !== index);
onChange(newSplits);
setHoveredMainSplit(null);
setDragging(null);
setIsButtonHovered(false);
};
// --- ACTIONS: PARTITIONS ---
const createPartition = (i: number, j: number, axis: Axis, offset: number) => {
const key = `${i}-${j}`;
const current = safePartitions[key] || [];
const newPart: Partition = {
id: Math.random().toString(36).substr(2, 9),
axis,
offset,
height: config.drawer.height || 80,
rounded: false
};
onChange({ ...splits, partitions: { ...safePartitions, [key]: [...current, newPart] } });
setSelectedPartitionId(newPart.id);
};
const updatePartition = (key: string, id: string, updates: Partial<Partition>) => {
const current = safePartitions[key] || [];
const updated = current.map(p => p.id === id ? { ...p, ...updates } : p);
onChange({ ...splits, partitions: { ...safePartitions, [key]: updated } });
};
const removePartition = (key: string, id: string) => {
const current = safePartitions[key] || [];
onChange({ ...splits, partitions: { ...safePartitions, [key]: current.filter(p => p.id !== id) } });
if (selectedPartitionId === id) setSelectedPartitionId(null);
setHoveredPartition(null);
};
// --- MOUSE HANDLER ---
const handleMouseMove = (e: React.MouseEvent) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
if (rect.width === 0) return;
// Normalize coordinates (0 to 1)
const nx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const ny = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
setMousePos({ x: nx, y: ny });
// --- DRAGGING LOGIC ---
if (dragging) {
if (dragging.type === 'main') {
const newSplits = { ...splits, x: [...safeX], y: [...safeY] };
const val = dragging.axis === 'x' ? nx : ny;
newSplits[dragging.axis][dragging.index] = val;
onChange(newSplits);
} else {
// Dragging Partition
const pDrag = dragging as { type: 'partition'; cellKey: string; id: string; axis: Axis };
const [iStr, jStr] = pDrag.cellKey.split('-');
const i = parseInt(iStr); const j = parseInt(jStr);
const cellX1 = sortedX[i]; const cellX2 = sortedX[i+1];
const cellY1 = sortedY[j]; const cellY2 = sortedY[j+1];
let newOffset = 0;
if (pDrag.axis === 'x') {
newOffset = (nx - cellX1) / (cellX2 - cellX1);
} else {
newOffset = (ny - cellY1) / (cellY2 - cellY1);
}
newOffset = Math.max(0.05, Math.min(0.95, newOffset));
updatePartition(pDrag.cellKey, pDrag.id, { offset: newOffset });
}
return;
}
if (isButtonHovered) return;
// --- HOVER LOGIC: MAIN LINES ---
if (mode === 'lines') {
setHoveredMainSplit(null);
const SNAP = 0.015;
// Phantom Line
if (nx > SNAP && nx < 1-SNAP && ny > SNAP && ny < 1-SNAP) {
const closeToX = safeX.some(val => Math.abs(nx - val) < SNAP);
const closeToY = safeY.some(val => Math.abs(ny - val) < SNAP);
if (!closeToX && !closeToY) {
const distRight = 1 - nx; const distBottom = 1 - ny;
setPhantomMainAxis(Math.min(nx, distRight) < Math.min(ny, distBottom) ? 'y' : 'x');
} else { setPhantomMainAxis(null); }
}
}
// --- HOVER LOGIC: PARTITIONS ---
else if (mode === 'cells') {
setHoveredPartition(null);
setPhantomPartition(null);
setHoveredCell(null);
// 1. Find which cell we are in
let cellIndex = null;
for(let i=0; i<sortedX.length-1; i++) {
if (nx >= sortedX[i] && nx <= sortedX[i+1]) {
for(let j=0; j<sortedY.length-1; j++) {
if (ny >= sortedY[j] && ny <= sortedY[j+1]) {
cellIndex = { i, j };
break;
}
}
}
}
if (cellIndex) {
setHoveredCell(cellIndex);
const key = `${cellIndex.i}-${cellIndex.j}`;
const parts = safePartitions[key] || [];
const cx1 = sortedX[cellIndex.i]; const cx2 = sortedX[cellIndex.i+1];
const cy1 = sortedY[cellIndex.j]; const cy2 = sortedY[cellIndex.j+1];
const cw = cx2 - cx1; const ch = cy2 - cy1;
// Local coords in cell (0..1)
const lx = (nx - cx1) / cw;
const ly = (ny - cy1) / ch;
// Check existing partitions
let foundPart = null;
const PART_SNAP = 0.05;
for (const p of parts) {
if (p.axis === 'x') {
if (Math.abs(lx - p.offset) < PART_SNAP * (aspectRatio > 1 ? 1 : aspectRatio)) foundPart = p;
} else {
if (Math.abs(ly - p.offset) < PART_SNAP * (aspectRatio < 1 ? 1 : 1/aspectRatio)) foundPart = p;
}
}
if (foundPart) {
setHoveredPartition({ id: foundPart.id, cellKey: key });
} else {
// Show Phantom Partition
const distLeft = lx; const distRight = 1 - lx;
const distTop = ly; const distBottom = 1 - ly;
const minX = Math.min(distLeft, distRight);
const minY = Math.min(distTop, distBottom);
const axis = minX < minY ? 'y' : 'x';
if (lx > 0.05 && lx < 0.95 && ly > 0.05 && ly < 0.95) {
setPhantomPartition({ axis, offset: axis === 'x' ? lx : ly });
}
}
}
}
};
const handleMouseDown = (e: React.MouseEvent) => {
if (isButtonHovered) return;
if (mode === 'lines') {
if (hoveredMainSplit) {
if (e.button === 0) setDragging({ type: 'main', ...hoveredMainSplit });
else if (e.button === 2) removeMainSplit(hoveredMainSplit.axis, hoveredMainSplit.index);
} else if (phantomMainAxis) {
const val = phantomMainAxis === 'x' ? mousePos.x : mousePos.y;
const newSplits = { ...splits, x: [...safeX], y: [...safeY] };
newSplits[phantomMainAxis] = [...newSplits[phantomMainAxis], val];
onChange(newSplits);
setDragging({ type: 'main', axis: phantomMainAxis, index: newSplits[phantomMainAxis].length - 1 });
}
}
else if (mode === 'cells') {
if (hoveredPartition) {
const key = hoveredPartition.cellKey;
const parts = safePartitions[key] || [];
const part = parts.find(p => p.id === hoveredPartition.id);
if (part) {
if (e.button === 0) {
setDragging({ type: 'partition', cellKey: key, id: part.id, axis: part.axis });
setSelectedPartitionId(part.id);
} else if (e.button === 2) {
removePartition(key, part.id);
}
}
} else if (hoveredCell && phantomPartition) {
if (e.button === 0) {
createPartition(hoveredCell.i, hoveredCell.j, phantomPartition.axis, phantomPartition.offset);
}
} else {
setSelectedPartitionId(null);
}
}
};
const handleMainSplitHover = (e: React.MouseEvent, axis: Axis, index: number) => {
if (mode !== 'lines' || dragging) return;
e.stopPropagation();
setHoveredMainSplit({ axis, index });
setPhantomMainAxis(null);
};
return (
<div className="bg-slate-900 p-4 rounded-xl shadow-lg border border-slate-800 h-full flex flex-col relative overflow-hidden">
{/* HEADER */}
<div className="flex justify-between items-center mb-2 z-20 shrink-0">
<h2 className="text-lg font-bold flex items-center gap-2 text-primary">
<Grid size={20} /> 2. Редактор макета
</h2>
<div className="flex bg-slate-800 p-1 rounded-lg border border-slate-700">
<button onClick={() => { setMode('lines'); setSelectedPartitionId(null); }}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${mode === 'lines' ? 'bg-primary text-white shadow' : 'text-gray-400 hover:text-gray-200'}`}>
<Move size={14}/> Границы
</button>
<button onClick={() => setMode('cells')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-bold transition-all ${mode === 'cells' ? 'bg-primary text-white shadow' : 'text-gray-400 hover:text-gray-200'}`}>
<Grid size={14}/> Внутри ячеек
</button>
</div>
<button onClick={() => onChange({ x: [], y: [], partitions: {} })} className="px-3 py-1.5 text-xs text-red-400 border border-slate-700 rounded hover:bg-slate-800 transition-colors flex items-center gap-1">
<RotateCcw size={14} /> Сброс
</button>
</div>
{/* --- INSTRUCTIONS --- */}
<div className="bg-slate-800/50 rounded-lg px-3 py-2 mb-2 flex items-center gap-3 text-[11px] text-gray-300 border border-slate-700/50 shrink-0">
<MousePointer2 size={14} className="text-primary" />
{mode === 'lines' ? (
<div className="flex gap-3">
<span><b className="text-blue-400">ЛКМ:</b> Создать/Тянуть линию</span>
<span><b className="text-red-400">ПКМ:</b> Удалить</span>
</div>
) : (
<div className="flex gap-3">
<span><b className="text-green-400">ЛКМ в ячейке:</b> Создать стенку</span>
<span><b className="text-purple-400">Драг:</b> Двигать</span>
<span><b className="text-red-400">2КМ:</b> Удалить</span>
</div>
)}
</div>
{/* --- CANVAS --- */}
<div className="flex-1 bg-slate-800/30 rounded-lg flex flex-col items-center justify-center relative overflow-hidden border border-slate-700/50 min-h-0 w-full">
<div className="relative w-full h-full flex items-center justify-center p-4">
<div
className="relative shadow-2xl bg-[#1e293b] border border-slate-600 rounded-sm overflow-hidden"
style={{
width: aspectRatio > 1 ? 'auto' : '100%',
height: aspectRatio > 1 ? '100%' : 'auto',
aspectRatio: `${1/aspectRatio}`,
maxHeight: '100%', maxWidth: '100%',
cursor: mode === 'lines' ? (dragging ? 'grabbing' : hoveredMainSplit ? 'col-resize' : 'crosshair')
: (dragging ? 'grabbing' : hoveredPartition ? 'grab' : hoveredCell ? 'crosshair' : 'default'),
}}
>
<svg
ref={svgRef}
viewBox={`0 0 ${viewBoxW} ${viewBoxH}`}
className="w-full h-full touch-none block"
preserveAspectRatio="none"
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={() => setDragging(null)}
onMouseLeave={() => setDragging(null)}
onContextMenu={(e) => e.preventDefault()}
>
<defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(255,255,255,0.03)" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* --- CELLS & PARTITIONS --- */}
{sortedX.slice(0, -1).map((x1, i) => {
const x2 = sortedX[i + 1];
return sortedY.slice(0, -1).map((y1, j) => {
const y2 = sortedY[j + 1];
const cellX = x1 * viewBoxW; const cellY = y1 * viewBoxH;
const cellW = (x2 - x1) * viewBoxW; const cellH = (y2 - y1) * viewBoxH;
const key = `${i}-${j}`;
const parts = safePartitions[key] || [];
const isHovered = hoveredCell?.i === i && hoveredCell?.j === j && mode === 'cells';
const isAnyPartSelected = parts.some(p => p.id === selectedPartitionId);
return (
<g key={`cell-${key}`}>
{/* Highlight Cell */}
{isHovered && <rect x={cellX} y={cellY} width={cellW} height={cellH} fill="rgba(16, 185, 129, 0.05)" stroke="#10b981" strokeWidth="2" strokeDasharray="4,4" className="pointer-events-none"/>}
{isAnyPartSelected && mode === 'cells' && <rect x={cellX} y={cellY} width={cellW} height={cellH} fill="transparent" stroke="#a855f7" strokeWidth="2" className="pointer-events-none opacity-50"/>}
{/* Existing Partitions */}
{parts.map(p => {
const isSelected = selectedPartitionId === p.id;
const isHoveredPart = hoveredPartition?.id === p.id;
let lx1, ly1, lx2, ly2;
if (p.axis === 'x') { const px = cellX + (cellW * p.offset); lx1 = px; ly1 = cellY; lx2 = px; ly2 = cellY + cellH; }
else { const py = cellY + (cellH * p.offset); lx1 = cellX; ly1 = py; lx2 = cellX + cellW; ly2 = py; }
return (
<g key={p.id} onDoubleClick={(e) => { e.stopPropagation(); removePartition(key, p.id); }}>
<line x1={lx1} y1={ly1} x2={lx2} y2={ly2} stroke="transparent" strokeWidth="30" />
<line x1={lx1} y1={ly1} x2={lx2} y2={ly2} stroke={isSelected ? "#a855f7" : (isHoveredPart ? "#d8b4fe" : "#7e22ce")} strokeWidth={isSelected ? 6 : 4} strokeLinecap="round" />
</g>
);
})}
{/* Phantom Partition */}
{isHovered && phantomPartition && !hoveredPartition && !dragging && (
<g className="pointer-events-none opacity-60">
{phantomPartition.axis === 'x' ?
<line x1={cellX + (cellW * phantomPartition.offset)} y1={cellY} x2={cellX + (cellW * phantomPartition.offset)} y2={cellY + cellH} stroke="#34d399" strokeWidth="3" strokeDasharray="6,6"/> :
<line x1={cellX} y1={cellY + (cellH * phantomPartition.offset)} x2={cellX + cellW} y2={cellY + (cellH * phantomPartition.offset)} stroke="#34d399" strokeWidth="3" strokeDasharray="6,6"/>
}
</g>
)}
</g>
);
});
})}
{/* --- MAIN GRID X --- */}
{safeX.map((x, i) => {
const isHovered = hoveredMainSplit?.axis === 'x' && hoveredMainSplit.index === i;
return (
<g key={`x-${i}`}
onMouseMove={(e) => { if(mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredMainSplit({axis: 'x', index: i}); setPhantomMainAxis(null); }}}
onDoubleClick={(e) => { e.stopPropagation(); removeMainSplit('x', i); }}
>
<line x1={x * viewBoxW} y1="0" x2={x * viewBoxW} y2="100%" stroke="transparent" strokeWidth="40" className={mode === 'lines' ? "cursor-col-resize" : ""} />
<line x1={x * viewBoxW} y1="0" x2={x * viewBoxW} y2="100%" stroke={isHovered ? "#f59e0b" : "#64748b"} strokeWidth={isHovered ? 6 : 4} className="pointer-events-none" />
</g>
);
})}
{/* --- MAIN GRID Y --- */}
{safeY.map((y, i) => {
const isHovered = hoveredMainSplit?.axis === 'y' && hoveredMainSplit.index === i;
return (
<g key={`y-${i}`}
onMouseMove={(e) => { if(mode === 'lines' && !dragging) { e.stopPropagation(); setHoveredMainSplit({axis: 'y', index: i}); setPhantomMainAxis(null); }}}
onDoubleClick={(e) => { e.stopPropagation(); removeMainSplit('y', i); }}
>
<line x1="0" y1={y * viewBoxH} x2="100%" y2={y * viewBoxH} stroke="transparent" strokeWidth="40" className={mode === 'lines' ? "cursor-row-resize" : ""} />
<line x1="0" y1={y * viewBoxH} x2="100%" y2={y * viewBoxH} stroke={isHovered ? "#f59e0b" : "#64748b"} strokeWidth={isHovered ? 6 : 4} className="pointer-events-none" />
</g>
);
})}
{/* PHANTOMS */}
{mode === 'lines' && !hoveredMainSplit && !dragging && phantomMainAxis === 'x' && <line x1={mousePos.x * viewBoxW} y1="0" x2={mousePos.x * viewBoxW} y2="100%" stroke="#3b82f6" strokeWidth="4" strokeDasharray="8,8" className="pointer-events-none opacity-50"/>}
{mode === 'lines' && !hoveredMainSplit && !dragging && phantomMainAxis === 'y' && <line x1="0" y1={mousePos.y * viewBoxH} x2="100%" y2={mousePos.y * viewBoxH} stroke="#3b82f6" strokeWidth="4" strokeDasharray="8,8" className="pointer-events-none opacity-50"/>}
</svg>
</div>
</div>
{/* --- SIDEBAR --- */}
{mode === 'cells' && selectedData && (
<div className="absolute top-0 right-0 bottom-0 w-72 bg-slate-900 border-l border-slate-700 p-4 shadow-2xl flex flex-col z-30 animate-in slide-in-from-right duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Grid size={16} className="text-purple-400"/> Настройки стенки
</h3>
<button onClick={() => setSelectedPartitionId(null)} className="text-gray-400 hover:text-white"><X size={20}/></button>
</div>
<div className="bg-slate-800 p-4 rounded border border-slate-700 space-y-6">
<div>
<div className="flex justify-between text-xs text-gray-300 mb-2">
<span>Высота</span> <span className="font-mono bg-slate-900 px-1.5 py-0.5 rounded text-xs">{selectedData.part.height} мм</span>
</div>
<input type="range" min="5" max={config.drawer.height || 100} step="1" value={selectedData.part.height}
onChange={(e) => updatePartition(selectedData.key, selectedData.part.id, { height: parseFloat(e.target.value) })}
className="w-full h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-purple-500"
/>
</div>
<div className="flex items-center justify-between">
<label htmlFor="rounded-check" className="text-xs text-gray-300 cursor-pointer select-none">Скруглить края</label>
<input type="checkbox" id="rounded-check" checked={selectedData.part.rounded}
onChange={(e) => updatePartition(selectedData.key, selectedData.part.id, { rounded: e.target.checked })}
className="w-4 h-4 rounded bg-slate-700 border-slate-600 text-purple-500 focus:ring-0 cursor-pointer"
/>
</div>
<button onClick={() => removePartition(selectedData.key, selectedData.part.id)} className="w-full py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 rounded text-xs flex items-center justify-center gap-2 transition-colors mt-4">
<Trash2 size={14}/> Удалить
</button>
</div>
<div className="mt-auto text-[10px] text-gray-500 text-center leading-relaxed">
Выделите стенку для настройки.<br/>Двойной клик удаляет её.
</div>
</div>
)}
</div>
</div>
</div>
);
};