neogestify-ui-components 1.2.20 → 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.
Files changed (52) hide show
  1. package/README.md +352 -2
  2. package/dist/components/VenueMapEditor/index.d.mts +202 -0
  3. package/dist/components/VenueMapEditor/index.d.ts +202 -0
  4. package/dist/components/VenueMapEditor/index.js +2684 -0
  5. package/dist/components/VenueMapEditor/index.js.map +1 -0
  6. package/dist/components/VenueMapEditor/index.mjs +2676 -0
  7. package/dist/components/VenueMapEditor/index.mjs.map +1 -0
  8. package/dist/components/alerts/index.js.map +1 -1
  9. package/dist/components/alerts/index.mjs.map +1 -1
  10. package/dist/components/html/index.d.mts +2 -0
  11. package/dist/components/html/index.d.ts +2 -0
  12. package/dist/components/html/index.js +29 -62
  13. package/dist/components/html/index.js.map +1 -1
  14. package/dist/components/html/index.mjs +29 -62
  15. package/dist/components/html/index.mjs.map +1 -1
  16. package/dist/components/icons/index.d.mts +18 -2
  17. package/dist/components/icons/index.d.ts +18 -2
  18. package/dist/components/icons/index.js +97 -11
  19. package/dist/components/icons/index.js.map +1 -1
  20. package/dist/components/icons/index.mjs +82 -12
  21. package/dist/components/icons/index.mjs.map +1 -1
  22. package/dist/context/theme/index.js.map +1 -1
  23. package/dist/context/theme/index.mjs.map +1 -1
  24. package/dist/index.d.mts +2 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +2739 -73
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2718 -75
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +11 -6
  31. package/src/components/VenueMapEditor/VenueMapEditor.tsx +851 -0
  32. package/src/components/VenueMapEditor/VenueMapViewer.tsx +13 -0
  33. package/src/components/VenueMapEditor/components/Artboard.tsx +405 -0
  34. package/src/components/VenueMapEditor/components/EditorCanvas.tsx +472 -0
  35. package/src/components/VenueMapEditor/components/ElementNode.tsx +357 -0
  36. package/src/components/VenueMapEditor/components/FloorTabs.tsx +137 -0
  37. package/src/components/VenueMapEditor/components/GridOverlay.tsx +67 -0
  38. package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +198 -0
  39. package/src/components/VenueMapEditor/components/Toolbar.tsx +254 -0
  40. package/src/components/VenueMapEditor/components/WallLayer.tsx +117 -0
  41. package/src/components/VenueMapEditor/hooks/useDrag.ts +79 -0
  42. package/src/components/VenueMapEditor/hooks/useHistory.ts +74 -0
  43. package/src/components/VenueMapEditor/hooks/usePanZoom.ts +114 -0
  44. package/src/components/VenueMapEditor/hooks/useSelection.ts +42 -0
  45. package/src/components/VenueMapEditor/index.ts +34 -0
  46. package/src/components/VenueMapEditor/types.ts +173 -0
  47. package/src/components/VenueMapEditor/utils/idGen.ts +2 -0
  48. package/src/components/VenueMapEditor/utils/snapUtils.ts +38 -0
  49. package/src/components/VenueMapEditor/utils/wallGeometry.ts +83 -0
  50. package/src/components/html/Input.tsx +54 -85
  51. package/src/components/icons/icons.tsx +153 -14
  52. 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
+ }