neogestify-ui-components 1.2.21 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +352 -2
- package/dist/components/VenueMapEditor/index.d.mts +202 -0
- package/dist/components/VenueMapEditor/index.d.ts +202 -0
- package/dist/components/VenueMapEditor/index.js +2684 -0
- package/dist/components/VenueMapEditor/index.js.map +1 -0
- package/dist/components/VenueMapEditor/index.mjs +2676 -0
- package/dist/components/VenueMapEditor/index.mjs.map +1 -0
- package/dist/components/alerts/index.js.map +1 -1
- package/dist/components/alerts/index.mjs.map +1 -1
- package/dist/components/html/index.d.mts +2 -0
- package/dist/components/html/index.d.ts +2 -0
- package/dist/components/html/index.js +24 -58
- package/dist/components/html/index.js.map +1 -1
- package/dist/components/html/index.mjs +24 -58
- package/dist/components/html/index.mjs.map +1 -1
- package/dist/components/icons/index.d.mts +18 -2
- package/dist/components/icons/index.d.ts +18 -2
- package/dist/components/icons/index.js +97 -11
- package/dist/components/icons/index.js.map +1 -1
- package/dist/components/icons/index.mjs +82 -12
- package/dist/components/icons/index.mjs.map +1 -1
- package/dist/context/theme/index.js.map +1 -1
- package/dist/context/theme/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2734 -69
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2713 -71
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/components/VenueMapEditor/VenueMapEditor.tsx +851 -0
- package/src/components/VenueMapEditor/VenueMapViewer.tsx +13 -0
- package/src/components/VenueMapEditor/components/Artboard.tsx +405 -0
- package/src/components/VenueMapEditor/components/EditorCanvas.tsx +472 -0
- package/src/components/VenueMapEditor/components/ElementNode.tsx +357 -0
- package/src/components/VenueMapEditor/components/FloorTabs.tsx +137 -0
- package/src/components/VenueMapEditor/components/GridOverlay.tsx +67 -0
- package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +198 -0
- package/src/components/VenueMapEditor/components/Toolbar.tsx +254 -0
- package/src/components/VenueMapEditor/components/WallLayer.tsx +117 -0
- package/src/components/VenueMapEditor/hooks/useDrag.ts +79 -0
- package/src/components/VenueMapEditor/hooks/useHistory.ts +74 -0
- package/src/components/VenueMapEditor/hooks/usePanZoom.ts +114 -0
- package/src/components/VenueMapEditor/hooks/useSelection.ts +42 -0
- package/src/components/VenueMapEditor/index.ts +34 -0
- package/src/components/VenueMapEditor/types.ts +173 -0
- package/src/components/VenueMapEditor/utils/idGen.ts +2 -0
- package/src/components/VenueMapEditor/utils/snapUtils.ts +38 -0
- package/src/components/VenueMapEditor/utils/wallGeometry.ts +83 -0
- package/src/components/html/Input.tsx +48 -80
- package/src/components/icons/icons.tsx +153 -14
- package/src/index.ts +1 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react';
|
|
2
|
+
import type { RefObject, MouseEvent as ReactMouseEvent } from 'react';
|
|
3
|
+
import type { MapElement, ElementTypeDef, ToolMode } from '../types';
|
|
4
|
+
import type { PanZoomState } from '../hooks/usePanZoom';
|
|
5
|
+
import { useDrag } from '../hooks/useDrag';
|
|
6
|
+
import { snapToGrid } from '../utils/snapUtils';
|
|
7
|
+
|
|
8
|
+
// ─── Arrow shape ──────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function arrowPath(x: number, y: number, w: number, h: number): string {
|
|
11
|
+
const headW = Math.min(w * 0.4, h * 0.9);
|
|
12
|
+
const tailH = h * 0.45;
|
|
13
|
+
const yt = y + (h - tailH) / 2;
|
|
14
|
+
const yb = y + (h + tailH) / 2;
|
|
15
|
+
return [
|
|
16
|
+
`M ${x} ${yt}`,
|
|
17
|
+
`L ${x + w - headW} ${yt}`,
|
|
18
|
+
`L ${x + w - headW} ${y}`,
|
|
19
|
+
`L ${x + w} ${y + h / 2}`,
|
|
20
|
+
`L ${x + w - headW} ${y + h}`,
|
|
21
|
+
`L ${x + w - headW} ${yb}`,
|
|
22
|
+
`L ${x} ${yb}`,
|
|
23
|
+
'Z',
|
|
24
|
+
].join(' ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Resize-handle geometry ───────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
type HandleType = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
|
30
|
+
|
|
31
|
+
const HANDLE_CURSORS: Record<HandleType, string> = {
|
|
32
|
+
nw: 'nwse-resize', ne: 'nesw-resize',
|
|
33
|
+
se: 'nwse-resize', sw: 'nesw-resize',
|
|
34
|
+
n: 'ns-resize', s: 'ns-resize',
|
|
35
|
+
e: 'ew-resize', w: 'ew-resize',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const MIN_SIZE = 10;
|
|
39
|
+
|
|
40
|
+
/** Rotate a vector (dx, dy) by -θ (degrees) to map canvas delta → local delta. */
|
|
41
|
+
function rotateDelta(dx: number, dy: number, deg: number): [number, number] {
|
|
42
|
+
const r = -deg * (Math.PI / 180);
|
|
43
|
+
return [dx * Math.cos(r) - dy * Math.sin(r), dx * Math.sin(r) + dy * Math.cos(r)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
interface ElementNodeProps {
|
|
49
|
+
element: MapElement;
|
|
50
|
+
typeDef: ElementTypeDef;
|
|
51
|
+
isSelected: boolean;
|
|
52
|
+
tool: ToolMode;
|
|
53
|
+
zoom: number;
|
|
54
|
+
svgRef: RefObject<SVGSVGElement | null>;
|
|
55
|
+
panZoomRef: { current: PanZoomState };
|
|
56
|
+
snapEnabled: boolean;
|
|
57
|
+
gridSize: number;
|
|
58
|
+
statusFill?: string;
|
|
59
|
+
onSelect: (multi: boolean) => void;
|
|
60
|
+
onMove: (x: number, y: number) => void;
|
|
61
|
+
onMoveCommit: (x: number, y: number) => void;
|
|
62
|
+
onResize: (x: number, y: number, w: number, h: number) => void;
|
|
63
|
+
onResizeCommit: (x: number, y: number, w: number, h: number) => void;
|
|
64
|
+
onRotate: (rotation: number) => void;
|
|
65
|
+
onRotateCommit: (rotation: number) => void;
|
|
66
|
+
onDelete: () => void;
|
|
67
|
+
onViewerClick?: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export function ElementNode({
|
|
73
|
+
element,
|
|
74
|
+
typeDef,
|
|
75
|
+
isSelected,
|
|
76
|
+
tool,
|
|
77
|
+
zoom,
|
|
78
|
+
svgRef,
|
|
79
|
+
panZoomRef,
|
|
80
|
+
snapEnabled,
|
|
81
|
+
gridSize,
|
|
82
|
+
statusFill,
|
|
83
|
+
onSelect,
|
|
84
|
+
onMove,
|
|
85
|
+
onMoveCommit,
|
|
86
|
+
onResize,
|
|
87
|
+
onResizeCommit,
|
|
88
|
+
onRotate,
|
|
89
|
+
onRotateCommit,
|
|
90
|
+
onDelete,
|
|
91
|
+
onViewerClick,
|
|
92
|
+
}: ElementNodeProps) {
|
|
93
|
+
const { x, y, width: w, height: h, rotation } = element;
|
|
94
|
+
const cx = x + w / 2;
|
|
95
|
+
const cy = y + h / 2;
|
|
96
|
+
|
|
97
|
+
const sw = 1.5 / zoom;
|
|
98
|
+
const hs = 7 / zoom; // handle half-size
|
|
99
|
+
const rotOffset = 22 / zoom; // rotate handle distance above bbox
|
|
100
|
+
const fontSize = Math.max(9 / zoom, Math.min(13 / zoom, h * 0.35));
|
|
101
|
+
|
|
102
|
+
// ── Move drag ───────────────────────────────────────────────────────────────
|
|
103
|
+
const startPos = useRef({ elX: 0, elY: 0, mouseX: 0, mouseY: 0 });
|
|
104
|
+
const lastMovePos = useRef({ x: 0, y: 0 });
|
|
105
|
+
|
|
106
|
+
const { handleMouseDown: handleBodyDown } = useDrag(svgRef, panZoomRef, {
|
|
107
|
+
onDragStart: (mx, my) => {
|
|
108
|
+
startPos.current = { elX: element.x, elY: element.y, mouseX: mx, mouseY: my };
|
|
109
|
+
lastMovePos.current = { x: element.x, y: element.y };
|
|
110
|
+
},
|
|
111
|
+
onDragMove: (_dx, _dy, canvasX, canvasY) => {
|
|
112
|
+
let nx = startPos.current.elX + (canvasX - startPos.current.mouseX);
|
|
113
|
+
let ny = startPos.current.elY + (canvasY - startPos.current.mouseY);
|
|
114
|
+
if (snapEnabled) { nx = snapToGrid(nx, gridSize); ny = snapToGrid(ny, gridSize); }
|
|
115
|
+
lastMovePos.current = { x: nx, y: ny };
|
|
116
|
+
onMove(nx, ny);
|
|
117
|
+
},
|
|
118
|
+
onDragEnd: () => {
|
|
119
|
+
onMoveCommit(lastMovePos.current.x, lastMovePos.current.y);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Resize drag ─────────────────────────────────────────────────────────────
|
|
124
|
+
const activeHandle = useRef<HandleType | null>(null);
|
|
125
|
+
const startGeom = useRef({ x: 0, y: 0, w: 0, h: 0, mouseX: 0, mouseY: 0 });
|
|
126
|
+
const lastResizeGeom = useRef({ x: 0, y: 0, w: 0, h: 0 });
|
|
127
|
+
|
|
128
|
+
const { handleMouseDown: handleHandleDown } = useDrag(svgRef, panZoomRef, {
|
|
129
|
+
onDragStart: (mx, my) => {
|
|
130
|
+
startGeom.current = { x: element.x, y: element.y, w: element.width, h: element.height, mouseX: mx, mouseY: my };
|
|
131
|
+
lastResizeGeom.current = { x: element.x, y: element.y, w: element.width, h: element.height };
|
|
132
|
+
},
|
|
133
|
+
onDragMove: (_dx, _dy, canvasX, canvasY) => {
|
|
134
|
+
const type = activeHandle.current;
|
|
135
|
+
if (!type) return;
|
|
136
|
+
const totalDx = canvasX - startGeom.current.mouseX;
|
|
137
|
+
const totalDy = canvasY - startGeom.current.mouseY;
|
|
138
|
+
const [ldx, ldy] = rotateDelta(totalDx, totalDy, rotation);
|
|
139
|
+
const { x: sx, y: sy, w: sw_, h: sh } = startGeom.current;
|
|
140
|
+
const right = sx + sw_;
|
|
141
|
+
const bottom = sy + sh;
|
|
142
|
+
|
|
143
|
+
let nx = sx, ny = sy, nw = sw_, nh = sh;
|
|
144
|
+
switch (type) {
|
|
145
|
+
case 'nw': nw = Math.max(MIN_SIZE, sw_ - ldx); nh = Math.max(MIN_SIZE, sh - ldy); nx = right - nw; ny = bottom - nh; break;
|
|
146
|
+
case 'n': nh = Math.max(MIN_SIZE, sh - ldy); ny = bottom - nh; break;
|
|
147
|
+
case 'ne': nw = Math.max(MIN_SIZE, sw_ + ldx); nh = Math.max(MIN_SIZE, sh - ldy); ny = bottom - nh; break;
|
|
148
|
+
case 'e': nw = Math.max(MIN_SIZE, sw_ + ldx); break;
|
|
149
|
+
case 'se': nw = Math.max(MIN_SIZE, sw_ + ldx); nh = Math.max(MIN_SIZE, sh + ldy); break;
|
|
150
|
+
case 's': nh = Math.max(MIN_SIZE, sh + ldy); break;
|
|
151
|
+
case 'sw': nw = Math.max(MIN_SIZE, sw_ - ldx); nh = Math.max(MIN_SIZE, sh + ldy); nx = right - nw; break;
|
|
152
|
+
case 'w': nw = Math.max(MIN_SIZE, sw_ - ldx); nx = right - nw; break;
|
|
153
|
+
}
|
|
154
|
+
if (snapEnabled) {
|
|
155
|
+
nw = Math.max(MIN_SIZE, snapToGrid(nw, gridSize));
|
|
156
|
+
nh = Math.max(MIN_SIZE, snapToGrid(nh, gridSize));
|
|
157
|
+
}
|
|
158
|
+
lastResizeGeom.current = { x: nx, y: ny, w: nw, h: nh };
|
|
159
|
+
onResize(nx, ny, nw, nh);
|
|
160
|
+
},
|
|
161
|
+
onDragEnd: () => {
|
|
162
|
+
const { x: rx, y: ry, w: rw, h: rh } = lastResizeGeom.current;
|
|
163
|
+
onResizeCommit(rx, ry, rw, rh);
|
|
164
|
+
activeHandle.current = null;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const startHandleDrag = useCallback(
|
|
169
|
+
(e: ReactMouseEvent, type: HandleType) => {
|
|
170
|
+
activeHandle.current = type;
|
|
171
|
+
handleHandleDown(e);
|
|
172
|
+
},
|
|
173
|
+
[handleHandleDown],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// ── Rotate drag ─────────────────────────────────────────────────────────────
|
|
177
|
+
const rotStart = useRef({ angleOffset: 0 });
|
|
178
|
+
|
|
179
|
+
const handleRotateDown = useCallback(
|
|
180
|
+
(e: ReactMouseEvent) => {
|
|
181
|
+
e.stopPropagation();
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
const svgRect = svgRef.current?.getBoundingClientRect();
|
|
184
|
+
if (!svgRect) return;
|
|
185
|
+
const { panX, panY, zoom: z } = panZoomRef.current;
|
|
186
|
+
const mcx = (e.clientX - svgRect.left - panX) / z;
|
|
187
|
+
const mcy = (e.clientY - svgRect.top - panY) / z;
|
|
188
|
+
const initAngle = Math.atan2(mcy - cy, mcx - cx) * (180 / Math.PI);
|
|
189
|
+
rotStart.current.angleOffset = element.rotation - initAngle;
|
|
190
|
+
|
|
191
|
+
// Track the live rotation so onUp always commits the final value,
|
|
192
|
+
// not the stale element.rotation captured in the useCallback closure.
|
|
193
|
+
let currentRot = element.rotation;
|
|
194
|
+
|
|
195
|
+
const onMove = (ev: MouseEvent) => {
|
|
196
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
197
|
+
if (!rect) return;
|
|
198
|
+
const { panX: px, panY: py, zoom: z2 } = panZoomRef.current;
|
|
199
|
+
const mx2 = (ev.clientX - rect.left - px) / z2;
|
|
200
|
+
const my2 = (ev.clientY - rect.top - py) / z2;
|
|
201
|
+
let newRot = Math.atan2(my2 - cy, mx2 - cx) * (180 / Math.PI) + rotStart.current.angleOffset;
|
|
202
|
+
if (ev.shiftKey) newRot = Math.round(newRot / 15) * 15;
|
|
203
|
+
currentRot = newRot;
|
|
204
|
+
onRotate(newRot);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const onUp = () => {
|
|
208
|
+
onRotateCommit(currentRot);
|
|
209
|
+
window.removeEventListener('mousemove', onMove);
|
|
210
|
+
window.removeEventListener('mouseup', onUp);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
window.addEventListener('mousemove', onMove);
|
|
214
|
+
window.addEventListener('mouseup', onUp);
|
|
215
|
+
},
|
|
216
|
+
[cx, cy, element.rotation, panZoomRef, svgRef, onRotate, onRotateCommit],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// ── Body click (select / erase) ─────────────────────────────────────────────
|
|
220
|
+
const handleBodyClick = useCallback(
|
|
221
|
+
(e: ReactMouseEvent) => {
|
|
222
|
+
e.stopPropagation();
|
|
223
|
+
if (onViewerClick) { onViewerClick(); return; }
|
|
224
|
+
if (tool === 'ERASE') { onDelete(); return; }
|
|
225
|
+
if (tool === 'SELECT') { onSelect(e.ctrlKey || e.metaKey || e.shiftKey); }
|
|
226
|
+
},
|
|
227
|
+
[tool, onDelete, onSelect, onViewerClick],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// ── Derived ─────────────────────────────────────────────────────────────────
|
|
231
|
+
const fillColor = statusFill ?? typeDef.color;
|
|
232
|
+
const bodyCursor = onViewerClick ? 'pointer' : tool === 'ERASE' ? 'crosshair' : tool === 'SELECT' ? 'move' : 'default';
|
|
233
|
+
|
|
234
|
+
// ── Custom SVG path geometry ─────────────────────────────────────────────────
|
|
235
|
+
// Parse the viewBox to get the coordinate space the path was designed in.
|
|
236
|
+
const customPath = typeDef.shape === 'path' && typeDef.svgPath
|
|
237
|
+
? (() => {
|
|
238
|
+
const parts = (typeDef.viewBox ?? '0 0 100 100').split(/[\s,]+/).map(Number);
|
|
239
|
+
const vw = parts[2] ?? 100;
|
|
240
|
+
const vh = parts[3] ?? 100;
|
|
241
|
+
const scaleX = vw > 0 ? w / vw : 1;
|
|
242
|
+
const scaleY = vh > 0 ? h / vh : 1;
|
|
243
|
+
// Compensate stroke so it renders as ~sw in canvas units regardless of shape scale
|
|
244
|
+
const avgScale = Math.sqrt(Math.abs(scaleX * scaleY)) || 1;
|
|
245
|
+
return { scaleX, scaleY, strokeWidth: sw / avgScale };
|
|
246
|
+
})()
|
|
247
|
+
: null;
|
|
248
|
+
|
|
249
|
+
const handles: Array<{ type: HandleType; hx: number; hy: number }> = [
|
|
250
|
+
{ type: 'nw', hx: x, hy: y },
|
|
251
|
+
{ type: 'n', hx: x+w/2, hy: y },
|
|
252
|
+
{ type: 'ne', hx: x+w, hy: y },
|
|
253
|
+
{ type: 'e', hx: x+w, hy: y+h/2 },
|
|
254
|
+
{ type: 'se', hx: x+w, hy: y+h },
|
|
255
|
+
{ type: 's', hx: x+w/2, hy: y+h },
|
|
256
|
+
{ type: 'sw', hx: x, hy: y+h },
|
|
257
|
+
{ type: 'w', hx: x, hy: y+h/2 },
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<g transform={`rotate(${rotation}, ${cx}, ${cy})`}>
|
|
262
|
+
{/* ── Shape ── */}
|
|
263
|
+
{typeDef.shape === 'rect' && (
|
|
264
|
+
<rect
|
|
265
|
+
x={x} y={y} width={w} height={h}
|
|
266
|
+
fill={fillColor}
|
|
267
|
+
stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
|
|
268
|
+
strokeWidth={isSelected ? sw * 1.5 : sw}
|
|
269
|
+
style={{ cursor: bodyCursor }}
|
|
270
|
+
onMouseDown={tool === 'SELECT' && !onViewerClick ? handleBodyDown : undefined}
|
|
271
|
+
onClick={handleBodyClick}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
274
|
+
{typeDef.shape === 'circle' && (
|
|
275
|
+
<ellipse
|
|
276
|
+
cx={cx} cy={cy} rx={w / 2} ry={h / 2}
|
|
277
|
+
fill={fillColor}
|
|
278
|
+
stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
|
|
279
|
+
strokeWidth={isSelected ? sw * 1.5 : sw}
|
|
280
|
+
style={{ cursor: bodyCursor }}
|
|
281
|
+
onMouseDown={tool === 'SELECT' && !onViewerClick ? handleBodyDown : undefined}
|
|
282
|
+
onClick={handleBodyClick}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
{typeDef.shape === 'arrow' && (
|
|
286
|
+
<path
|
|
287
|
+
d={arrowPath(x, y, w, h)}
|
|
288
|
+
fill={fillColor}
|
|
289
|
+
stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
|
|
290
|
+
strokeWidth={isSelected ? sw * 1.5 : sw}
|
|
291
|
+
style={{ cursor: bodyCursor }}
|
|
292
|
+
onMouseDown={tool === 'SELECT' && !onViewerClick ? handleBodyDown : undefined}
|
|
293
|
+
onClick={handleBodyClick}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
{typeDef.shape === 'path' && customPath && typeDef.svgPath && (
|
|
297
|
+
<g transform={`translate(${x}, ${y}) scale(${customPath.scaleX}, ${customPath.scaleY})`}>
|
|
298
|
+
<path
|
|
299
|
+
d={typeDef.svgPath}
|
|
300
|
+
fill={fillColor}
|
|
301
|
+
stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
|
|
302
|
+
strokeWidth={isSelected ? customPath.strokeWidth * 1.5 : customPath.strokeWidth}
|
|
303
|
+
style={{ cursor: bodyCursor }}
|
|
304
|
+
onMouseDown={tool === 'SELECT' && !onViewerClick ? handleBodyDown : undefined}
|
|
305
|
+
onClick={handleBodyClick}
|
|
306
|
+
/>
|
|
307
|
+
</g>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
{/* ── Label ── */}
|
|
311
|
+
{(element.label ?? typeDef.label) && (
|
|
312
|
+
<text
|
|
313
|
+
x={cx} y={cy}
|
|
314
|
+
textAnchor="middle"
|
|
315
|
+
dominantBaseline="central"
|
|
316
|
+
fontSize={fontSize}
|
|
317
|
+
fill={typeDef.strokeColor}
|
|
318
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
319
|
+
>
|
|
320
|
+
{element.label ?? typeDef.label}
|
|
321
|
+
</text>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{/* ── Selection overlays ── */}
|
|
325
|
+
{isSelected && tool === 'SELECT' && (
|
|
326
|
+
<>
|
|
327
|
+
{/* Rotate line + handle */}
|
|
328
|
+
<line
|
|
329
|
+
x1={cx} y1={y}
|
|
330
|
+
x2={cx} y2={y - rotOffset}
|
|
331
|
+
stroke="#3b82f6" strokeWidth={sw}
|
|
332
|
+
style={{ pointerEvents: 'none' }}
|
|
333
|
+
/>
|
|
334
|
+
<circle
|
|
335
|
+
cx={cx} cy={y - rotOffset} r={hs * 0.8}
|
|
336
|
+
fill="white" stroke="#3b82f6" strokeWidth={sw}
|
|
337
|
+
style={{ cursor: 'grab' }}
|
|
338
|
+
onMouseDown={handleRotateDown}
|
|
339
|
+
/>
|
|
340
|
+
|
|
341
|
+
{/* Resize handles */}
|
|
342
|
+
{handles.map(({ type, hx, hy }) => (
|
|
343
|
+
<rect
|
|
344
|
+
key={type}
|
|
345
|
+
x={hx - hs} y={hy - hs}
|
|
346
|
+
width={hs * 2} height={hs * 2}
|
|
347
|
+
rx={1 / zoom}
|
|
348
|
+
fill="white" stroke="#3b82f6" strokeWidth={sw}
|
|
349
|
+
style={{ cursor: HANDLE_CURSORS[type] }}
|
|
350
|
+
onMouseDown={e => startHandleDrag(e, type)}
|
|
351
|
+
/>
|
|
352
|
+
))}
|
|
353
|
+
</>
|
|
354
|
+
)}
|
|
355
|
+
</g>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from 'react';
|
|
2
|
+
import type { KeyboardEvent } from 'react';
|
|
3
|
+
import type { Floor } from '../types';
|
|
4
|
+
|
|
5
|
+
interface FloorTabsProps {
|
|
6
|
+
floors: Floor[];
|
|
7
|
+
activeFloorId: string;
|
|
8
|
+
readOnly: boolean;
|
|
9
|
+
onSelect: (id: string) => void;
|
|
10
|
+
onAdd: () => void;
|
|
11
|
+
onRename: (id: string, name: string) => void;
|
|
12
|
+
onDelete: (id: string) => void;
|
|
13
|
+
onReorder: (id: string, direction: 'left' | 'right') => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FloorTabs({
|
|
17
|
+
floors,
|
|
18
|
+
activeFloorId,
|
|
19
|
+
readOnly,
|
|
20
|
+
onSelect,
|
|
21
|
+
onAdd,
|
|
22
|
+
onRename,
|
|
23
|
+
onDelete,
|
|
24
|
+
onReorder,
|
|
25
|
+
}: FloorTabsProps) {
|
|
26
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
27
|
+
const [editValue, setEditValue] = useState('');
|
|
28
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
|
|
30
|
+
const sorted = floors.slice().sort((a, b) => a.order - b.order);
|
|
31
|
+
|
|
32
|
+
const startEditing = useCallback((floor: Floor) => {
|
|
33
|
+
if (readOnly) return;
|
|
34
|
+
setEditingId(floor.id);
|
|
35
|
+
setEditValue(floor.name);
|
|
36
|
+
setTimeout(() => inputRef.current?.select(), 0);
|
|
37
|
+
}, [readOnly]);
|
|
38
|
+
|
|
39
|
+
const commitEdit = useCallback(() => {
|
|
40
|
+
if (editingId && editValue.trim()) {
|
|
41
|
+
onRename(editingId, editValue.trim());
|
|
42
|
+
}
|
|
43
|
+
setEditingId(null);
|
|
44
|
+
}, [editingId, editValue, onRename]);
|
|
45
|
+
|
|
46
|
+
const cancelEdit = useCallback(() => {
|
|
47
|
+
setEditingId(null);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
|
51
|
+
if (e.key === 'Enter') { e.preventDefault(); commitEdit(); }
|
|
52
|
+
if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
|
|
53
|
+
}, [commitEdit, cancelEdit]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b border-slate-200 bg-slate-50 text-xs overflow-x-auto">
|
|
57
|
+
{sorted.map(floor => {
|
|
58
|
+
const isActive = floor.id === activeFloorId;
|
|
59
|
+
const idx = sorted.indexOf(floor);
|
|
60
|
+
const canMoveLeft = idx > 0;
|
|
61
|
+
const canMoveRight = idx < sorted.length - 1;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
key={floor.id}
|
|
66
|
+
className={[
|
|
67
|
+
'flex items-center gap-0.5 px-2 py-1 rounded-t border transition-colors shrink-0',
|
|
68
|
+
isActive
|
|
69
|
+
? 'bg-white border-slate-300 text-slate-800 font-medium'
|
|
70
|
+
: 'border-transparent text-slate-500 hover:text-slate-700 cursor-pointer',
|
|
71
|
+
].join(' ')}
|
|
72
|
+
onClick={() => !isActive && onSelect(floor.id)}
|
|
73
|
+
>
|
|
74
|
+
{!readOnly && isActive && canMoveLeft && (
|
|
75
|
+
<button
|
|
76
|
+
className="text-slate-400 hover:text-slate-700 px-0.5 leading-none"
|
|
77
|
+
onClick={e => { e.stopPropagation(); onReorder(floor.id, 'left'); }}
|
|
78
|
+
title="Mover a la izquierda"
|
|
79
|
+
>
|
|
80
|
+
◀
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{editingId === floor.id ? (
|
|
85
|
+
<input
|
|
86
|
+
ref={inputRef}
|
|
87
|
+
value={editValue}
|
|
88
|
+
onChange={e => setEditValue(e.target.value)}
|
|
89
|
+
onBlur={commitEdit}
|
|
90
|
+
onKeyDown={handleKeyDown}
|
|
91
|
+
onClick={e => e.stopPropagation()}
|
|
92
|
+
className="w-24 border border-blue-400 rounded px-1 text-xs outline-none"
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<span
|
|
96
|
+
onDoubleClick={e => { e.stopPropagation(); startEditing(floor); }}
|
|
97
|
+
className="select-none"
|
|
98
|
+
>
|
|
99
|
+
{floor.name}
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{!readOnly && isActive && canMoveRight && (
|
|
104
|
+
<button
|
|
105
|
+
className="text-slate-400 hover:text-slate-700 px-0.5 leading-none"
|
|
106
|
+
onClick={e => { e.stopPropagation(); onReorder(floor.id, 'right'); }}
|
|
107
|
+
title="Mover a la derecha"
|
|
108
|
+
>
|
|
109
|
+
▶
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{!readOnly && floors.length > 1 && (
|
|
114
|
+
<button
|
|
115
|
+
className="text-slate-400 hover:text-red-500 px-0.5 leading-none"
|
|
116
|
+
onClick={e => { e.stopPropagation(); onDelete(floor.id); }}
|
|
117
|
+
title="Eliminar planta"
|
|
118
|
+
>
|
|
119
|
+
×
|
|
120
|
+
</button>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
|
|
126
|
+
{!readOnly && (
|
|
127
|
+
<button
|
|
128
|
+
className="flex items-center justify-center w-6 h-6 rounded border border-dashed border-slate-300 text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-colors shrink-0"
|
|
129
|
+
onClick={onAdd}
|
|
130
|
+
title="Añadir planta"
|
|
131
|
+
>
|
|
132
|
+
+
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
interface GridOverlayProps {
|
|
2
|
+
gridSize: number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Infinite grid rendered as SVG `<pattern>` tiles.
|
|
7
|
+
*
|
|
8
|
+
* Two tiers:
|
|
9
|
+
* - Minor lines every `gridSize` units (very light)
|
|
10
|
+
* - Major lines every `5 × gridSize` units (slightly darker)
|
|
11
|
+
*
|
|
12
|
+
* Because both patterns use `patternUnits="userSpaceOnUse"` and the component
|
|
13
|
+
* is rendered inside a `<g transform="translate scale">`, the grid automatically
|
|
14
|
+
* scales and pans with the canvas.
|
|
15
|
+
*/
|
|
16
|
+
export function GridOverlay({ gridSize }: GridOverlayProps) {
|
|
17
|
+
const majorSize = gridSize * 5;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<defs>
|
|
22
|
+
{/* Minor grid */}
|
|
23
|
+
<pattern
|
|
24
|
+
id="vme-grid-minor"
|
|
25
|
+
width={gridSize}
|
|
26
|
+
height={gridSize}
|
|
27
|
+
patternUnits="userSpaceOnUse"
|
|
28
|
+
>
|
|
29
|
+
<path
|
|
30
|
+
d={`M ${gridSize} 0 L 0 0 0 ${gridSize}`}
|
|
31
|
+
fill="none"
|
|
32
|
+
stroke="#e2e8f0"
|
|
33
|
+
strokeWidth={0.5}
|
|
34
|
+
/>
|
|
35
|
+
</pattern>
|
|
36
|
+
|
|
37
|
+
{/* Major grid — tiles the minor pattern, then overlays thicker lines */}
|
|
38
|
+
<pattern
|
|
39
|
+
id="vme-grid-major"
|
|
40
|
+
width={majorSize}
|
|
41
|
+
height={majorSize}
|
|
42
|
+
patternUnits="userSpaceOnUse"
|
|
43
|
+
>
|
|
44
|
+
<rect
|
|
45
|
+
width={majorSize}
|
|
46
|
+
height={majorSize}
|
|
47
|
+
fill="url(#vme-grid-minor)"
|
|
48
|
+
/>
|
|
49
|
+
<path
|
|
50
|
+
d={`M ${majorSize} 0 L 0 0 0 ${majorSize}`}
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke="#cbd5e1"
|
|
53
|
+
strokeWidth={1}
|
|
54
|
+
/>
|
|
55
|
+
</pattern>
|
|
56
|
+
</defs>
|
|
57
|
+
|
|
58
|
+
<rect
|
|
59
|
+
x={-50000}
|
|
60
|
+
y={-50000}
|
|
61
|
+
width={100000}
|
|
62
|
+
height={100000}
|
|
63
|
+
fill="url(#vme-grid-major)"
|
|
64
|
+
/>
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|