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.
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 +24 -58
  13. package/dist/components/html/index.js.map +1 -1
  14. package/dist/components/html/index.mjs +24 -58
  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 +2734 -69
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2713 -71
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +9 -4
  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 +48 -80
  51. package/src/components/icons/icons.tsx +153 -14
  52. package/src/index.ts +1 -0
@@ -0,0 +1,851 @@
1
+ import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+ import type {
4
+ VenueMapEditorProps,
5
+ VenueMap,
6
+ Floor,
7
+ MapElement,
8
+ WallNode,
9
+ Wall,
10
+ ToolMode,
11
+ FloorArea,
12
+ AreaShape,
13
+ ElementLibrary,
14
+ DomainConfig,
15
+ } from './types';
16
+ import { Toolbar } from './components/Toolbar';
17
+ import type { PaletteGroup } from './components/Toolbar';
18
+ import { EditorCanvas } from './components/EditorCanvas';
19
+ import { PropertiesPanel } from './components/PropertiesPanel';
20
+ import { FloorTabs } from './components/FloorTabs';
21
+ import { useHistory } from './hooks/useHistory';
22
+ import { useSelection } from './hooks/useSelection';
23
+ import { genId } from './utils/idGen';
24
+
25
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
26
+
27
+ function pointInPolygon(px: number, py: number, pts: [number, number][]): boolean {
28
+ let inside = false;
29
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
30
+ const [xi, yi] = pts[i], [xj, yj] = pts[j];
31
+ if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
32
+ }
33
+ return inside;
34
+ }
35
+
36
+ /** Returns the nearest point on the polygon perimeter to (px, py). */
37
+ function clampPointToPolygon(px: number, py: number, pts: [number, number][]): { x: number; y: number } {
38
+ let bestDist = Infinity, bx = pts[0][0], by = pts[0][1];
39
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
40
+ const [ax, ay] = pts[j], [bex, bey] = pts[i];
41
+ const dx = bex - ax, dy = bey - ay;
42
+ const len2 = dx * dx + dy * dy;
43
+ const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
44
+ const nx = ax + t * dx, ny = ay + t * dy;
45
+ const dist = (px - nx) ** 2 + (py - ny) ** 2;
46
+ if (dist < bestDist) { bestDist = dist; bx = nx; by = ny; }
47
+ }
48
+ return { x: bx, y: by };
49
+ }
50
+
51
+ function clampToFloor(
52
+ x: number, y: number,
53
+ w: number, h: number,
54
+ area: FloorArea,
55
+ ): { x: number; y: number } {
56
+ // Use a square hitbox of side = min(w, h) centered on the element.
57
+ // Custom shapes (e.g. SVG paths) rarely fill their full bounding box,
58
+ // so this avoids over-constraining them to the floor bounds.
59
+ const s = Math.min(w, h);
60
+ const hs = s / 2;
61
+ const cx = x + w / 2;
62
+ const cy = y + h / 2;
63
+
64
+ if (area.shape === 'rect') {
65
+ const ax = area.x ?? 0;
66
+ const ay = area.y ?? 0;
67
+ const aw = area.width ?? 0;
68
+ const ah = area.height ?? 0;
69
+ const ncx = aw >= s ? Math.max(ax + hs, Math.min(ax + aw - hs, cx)) : ax + aw / 2;
70
+ const ncy = ah >= s ? Math.max(ay + hs, Math.min(ay + ah - hs, cy)) : ay + ah / 2;
71
+ return { x: ncx - w / 2, y: ncy - h / 2 };
72
+ }
73
+ if (area.shape === 'polygon') {
74
+ const pts = area.points ?? [];
75
+ if (pts.length < 3) return { x, y };
76
+ if (pointInPolygon(cx, cy, pts)) return { x, y };
77
+ const clamped = clampPointToPolygon(cx, cy, pts);
78
+ return { x: clamped.x - w / 2, y: clamped.y - h / 2 };
79
+ }
80
+ return { x, y };
81
+ }
82
+
83
+ function createDefaultMap(): VenueMap {
84
+ return {
85
+ id: genId(),
86
+ name: 'Nuevo mapa',
87
+ floors: [
88
+ {
89
+ id: genId(),
90
+ name: 'Planta 1',
91
+ order: 0,
92
+ area: { shape: 'rect', x: 60, y: 60, width: 600, height: 400 },
93
+ wallNodes: [],
94
+ walls: [],
95
+ elements: [],
96
+ },
97
+ ],
98
+ };
99
+ }
100
+
101
+ const EMPTY_DOMAIN_CONFIG: DomainConfig = { id: '__empty__', name: '', elementTypes: [] };
102
+
103
+ function updateFloor(map: VenueMap, updatedFloor: Floor): VenueMap {
104
+ return {
105
+ ...map,
106
+ floors: map.floors.map(f => (f.id === updatedFloor.id ? updatedFloor : f)),
107
+ };
108
+ }
109
+
110
+ function rectToPolygon(area: FloorArea): FloorArea {
111
+ const ax = area.x ?? 0;
112
+ const ay = area.y ?? 0;
113
+ const aw = area.width ?? 400;
114
+ const ah = area.height ?? 300;
115
+ return {
116
+ shape: 'polygon',
117
+ points: [
118
+ [ax, ay],
119
+ [ax + aw, ay],
120
+ [ax + aw, ay + ah],
121
+ [ax, ay + ah],
122
+ ],
123
+ };
124
+ }
125
+
126
+ function polygonToRect(area: FloorArea): FloorArea {
127
+ const pts = area.points ?? [];
128
+ if (pts.length === 0) return { shape: 'rect', x: 60, y: 60, width: 400, height: 300 };
129
+ const xs = pts.map(p => p[0]);
130
+ const ys = pts.map(p => p[1]);
131
+ const minX = Math.min(...xs);
132
+ const minY = Math.min(...ys);
133
+ const maxX = Math.max(...xs);
134
+ const maxY = Math.max(...ys);
135
+ return {
136
+ shape: 'rect',
137
+ x: minX,
138
+ y: minY,
139
+ width: maxX - minX,
140
+ height: maxY - minY,
141
+ };
142
+ }
143
+
144
+ // ─── Component ────────────────────────────────────────────────────────────────
145
+
146
+ export function VenueMapEditor({
147
+ domainConfig = EMPTY_DOMAIN_CONFIG,
148
+ initialMap,
149
+ onChange,
150
+ width = '100%',
151
+ height = '600px',
152
+ gridSize = 20,
153
+ showGrid: showGridProp = true,
154
+ snapToGrid: snapEnabled = false,
155
+ readOnly = false,
156
+ fixed = false,
157
+ elementStatus,
158
+ onElementClick,
159
+ onElementTypeClick,
160
+ }: VenueMapEditorProps) {
161
+ const initialMapRef = useRef<VenueMap>(initialMap ?? createDefaultMap());
162
+
163
+ const { map, canUndo, canRedo, push, replace, undo, redo } = useHistory(
164
+ initialMapRef.current,
165
+ );
166
+ const { selectedIds, select, selectSet, clear: clearSelection } = useSelection();
167
+
168
+ const [activeFloorId, setActiveFloorId] = useState<string>(
169
+ () => initialMapRef.current.floors[0]?.id ?? '',
170
+ );
171
+ const [tool, setTool] = useState<ToolMode>('SELECT');
172
+ const [showGrid, setShowGrid] = useState(showGridProp);
173
+ const [zoom, setZoom] = useState(1);
174
+ const [activePlaceTypeId, setActivePlaceTypeId] = useState<string | null>(null);
175
+
176
+ const zoomByRef = useRef<(factor: number) => void>(() => undefined);
177
+ const resetViewRef = useRef<() => void>(() => undefined);
178
+ const importInputRef = useRef<HTMLInputElement>(null);
179
+ const libraryInputRef = useRef<HTMLInputElement>(null);
180
+
181
+ // ── elementTypeDefs: base config + library types in the map ─────────────
182
+ const buildTypeDefs = useCallback(() => {
183
+ const m = new Map(domainConfig.elementTypes.map(t => [t.id, t]));
184
+ const libs = map.libraries ?? {};
185
+ for (const group of Object.values(libs)) {
186
+ for (const t of group.objects) {
187
+ if (!m.has(t.id)) m.set(t.id, t); // base config wins on ID collision
188
+ }
189
+ }
190
+ return m;
191
+ }, [domainConfig, map.libraries]);
192
+
193
+ const elementTypeDefs = useRef(buildTypeDefs());
194
+ useEffect(() => {
195
+ elementTypeDefs.current = buildTypeDefs();
196
+ }, [buildTypeDefs]);
197
+
198
+ // ── Palette groups: base group + imported library groups ─────────────────
199
+ const paletteGroups = useMemo<PaletteGroup[]>(() => {
200
+ const groups: PaletteGroup[] = [];
201
+ // Only include the built-in group when it has at least one type
202
+ if (domainConfig.elementTypes.length > 0) {
203
+ groups.push({ id: domainConfig.id, name: domainConfig.name, types: domainConfig.elementTypes, isBase: true });
204
+ }
205
+ const libs = map.libraries ?? {};
206
+ for (const [gid, group] of Object.entries(libs)) {
207
+ groups.push({ id: gid, name: group.name, types: group.objects, isBase: false });
208
+ }
209
+ return groups;
210
+ }, [domainConfig, map.libraries]);
211
+
212
+ // Auto-select first available element type when nothing is selected
213
+ useEffect(() => {
214
+ if (activePlaceTypeId) return;
215
+ const firstType = paletteGroups[0]?.types[0];
216
+ if (firstType) setActivePlaceTypeId(firstType.id);
217
+ }, [paletteGroups, activePlaceTypeId]);
218
+
219
+ // ── Controlled map sync (feedback-loop-safe) ─────────────────────────────
220
+ // Track the last map we emitted so we can distinguish "external" prop changes
221
+ // from echoes of our own onChange calls. Without this, storing onChange output
222
+ // in parent state and passing it back as initialMap would cause an infinite loop.
223
+ const lastEmittedMap = useRef<VenueMap | undefined>(undefined);
224
+ const prevInitial = useRef(initialMap);
225
+
226
+ useEffect(() => {
227
+ lastEmittedMap.current = map;
228
+ onChange?.(map);
229
+ }, [map, onChange]);
230
+
231
+ useEffect(() => {
232
+ if (!initialMap) return;
233
+ if (initialMap === prevInitial.current) return; // same reference, nothing to do
234
+ prevInitial.current = initialMap;
235
+ if (initialMap === lastEmittedMap.current) return; // echo of our own onChange, skip
236
+ push(initialMap);
237
+ setActiveFloorId(initialMap.floors[0]?.id ?? '');
238
+ }, [initialMap, push]);
239
+
240
+ const activeFloor = map.floors.find(f => f.id === activeFloorId) ?? map.floors[0];
241
+
242
+ const replaceFloor = useCallback(
243
+ (floor: Floor) => replace(updateFloor(map, floor)),
244
+ [map, replace],
245
+ );
246
+
247
+ const pushFloor = useCallback(
248
+ (floor: Floor) => push(updateFloor(map, floor)),
249
+ [map, push],
250
+ );
251
+
252
+ // ── Area resize (handle drag) ────────────────────────────────────────────
253
+ const handleAreaResize = useCallback(
254
+ (updatedFloor: Floor) => replaceFloor(updatedFloor),
255
+ [replaceFloor],
256
+ );
257
+
258
+ // ── Area resize commit (push to history) ─────────────────────────────────
259
+ const handleAreaResizeCommit = useCallback(
260
+ (updatedFloor: Floor) => pushFloor(updatedFloor),
261
+ [pushFloor],
262
+ );
263
+
264
+ // ── Area move (body drag) ────────────────────────────────────────────────
265
+ const handleAreaMove = useCallback(
266
+ (dx: number, dy: number) => {
267
+ if (!activeFloor) return;
268
+ const area = activeFloor.area;
269
+ const newArea: FloorArea = area.shape === 'polygon'
270
+ ? { ...area, points: (area.points ?? []).map(([x, y]) => [x + dx, y + dy] as [number, number]) }
271
+ : { ...area, x: (area.x ?? 0) + dx, y: (area.y ?? 0) + dy };
272
+ replaceFloor({
273
+ ...activeFloor,
274
+ area: newArea,
275
+ wallNodes: activeFloor.wallNodes.map(n => ({ ...n, x: n.x + dx, y: n.y + dy })),
276
+ elements: activeFloor.elements.map(el => ({ ...el, x: el.x + dx, y: el.y + dy })),
277
+ });
278
+ },
279
+ [activeFloor, replaceFloor],
280
+ );
281
+
282
+ // ── Area shape toggle ────────────────────────────────────────────────────
283
+ const handleToggleAreaShape = useCallback(() => {
284
+ if (!activeFloor) return;
285
+ const { area } = activeFloor;
286
+ const newArea: FloorArea = area.shape === 'polygon'
287
+ ? polygonToRect(area)
288
+ : rectToPolygon(area);
289
+ pushFloor({ ...activeFloor, area: newArea });
290
+ }, [activeFloor, pushFloor]);
291
+
292
+ // ── Floor operations ─────────────────────────────────────────────────────
293
+
294
+ const handleAddFloor = useCallback(() => {
295
+ const maxOrder = map.floors.reduce((m, f) => Math.max(m, f.order), -1);
296
+ const newFloor: Floor = {
297
+ id: genId(),
298
+ name: `Planta ${map.floors.length + 1}`,
299
+ order: maxOrder + 1,
300
+ area: { shape: 'rect', x: 60, y: 60, width: 600, height: 400 },
301
+ wallNodes: [],
302
+ walls: [],
303
+ elements: [],
304
+ };
305
+ const newMap: VenueMap = { ...map, floors: [...map.floors, newFloor] };
306
+ push(newMap);
307
+ setActiveFloorId(newFloor.id);
308
+ }, [map, push]);
309
+
310
+ const handleRenameFloor = useCallback(
311
+ (id: string, name: string) => {
312
+ const floor = map.floors.find(f => f.id === id);
313
+ if (!floor) return;
314
+ push(updateFloor(map, { ...floor, name }));
315
+ },
316
+ [map, push],
317
+ );
318
+
319
+ const handleDeleteFloor = useCallback(
320
+ (id: string) => {
321
+ if (map.floors.length <= 1) return;
322
+ const remaining = map.floors.filter(f => f.id !== id);
323
+ const newMap: VenueMap = { ...map, floors: remaining };
324
+ push(newMap);
325
+ if (activeFloorId === id) {
326
+ setActiveFloorId(remaining[0]?.id ?? '');
327
+ }
328
+ },
329
+ [map, push, activeFloorId],
330
+ );
331
+
332
+ const handleReorderFloor = useCallback(
333
+ (id: string, direction: 'left' | 'right') => {
334
+ const sorted = map.floors.slice().sort((a, b) => a.order - b.order);
335
+ const idx = sorted.findIndex(f => f.id === id);
336
+ if (idx < 0) return;
337
+ const swapIdx = direction === 'left' ? idx - 1 : idx + 1;
338
+ if (swapIdx < 0 || swapIdx >= sorted.length) return;
339
+ const a = sorted[idx];
340
+ const b = sorted[swapIdx];
341
+ const updatedFloors = map.floors.map(f => {
342
+ if (f.id === a.id) return { ...f, order: b.order };
343
+ if (f.id === b.id) return { ...f, order: a.order };
344
+ return f;
345
+ });
346
+ push({ ...map, floors: updatedFloors });
347
+ },
348
+ [map, push],
349
+ );
350
+
351
+ // ── Export / Import ──────────────────────────────────────────────────────
352
+
353
+ const handleExportMap = useCallback(() => {
354
+ const blob = new Blob([JSON.stringify(map, null, 2)], { type: 'application/json' });
355
+ const url = URL.createObjectURL(blob);
356
+ const a = document.createElement('a');
357
+ a.href = url;
358
+ a.download = `${map.name || 'mapa'}.json`;
359
+ a.click();
360
+ URL.revokeObjectURL(url);
361
+ }, [map]);
362
+
363
+ const handleImportMap = useCallback(
364
+ (file: File) => {
365
+ const reader = new FileReader();
366
+ reader.onload = e => {
367
+ try {
368
+ const parsed = JSON.parse(e.target?.result as string) as VenueMap;
369
+ push(parsed);
370
+ setActiveFloorId(parsed.floors[0]?.id ?? '');
371
+ } catch {
372
+ // ignore parse errors
373
+ }
374
+ };
375
+ reader.readAsText(file);
376
+ },
377
+ [push],
378
+ );
379
+
380
+ const handleLoadLibrary = useCallback(
381
+ (file: File) => {
382
+ const reader = new FileReader();
383
+ reader.onload = e => {
384
+ try {
385
+ const parsed = JSON.parse(e.target?.result as string) as ElementLibrary;
386
+ // Merge into map.libraries (imported groups extend, not replace, existing ones)
387
+ const merged: ElementLibrary = { ...(map.libraries ?? {}), ...parsed };
388
+ push({ ...map, libraries: merged });
389
+ } catch {
390
+ // ignore parse errors
391
+ }
392
+ };
393
+ reader.readAsText(file);
394
+ },
395
+ [map, push],
396
+ );
397
+
398
+ const handleRemoveLibraryGroup = useCallback(
399
+ (groupId: string) => {
400
+ const libs = { ...(map.libraries ?? {}) };
401
+ delete libs[groupId];
402
+ push({ ...map, libraries: Object.keys(libs).length > 0 ? libs : undefined });
403
+ },
404
+ [map, push],
405
+ );
406
+
407
+ // ── Wall operations ──────────────────────────────────────────────────────
408
+
409
+ const DEFAULT_WALL_THICKNESS = 8;
410
+
411
+ const handleAddWall = useCallback(
412
+ (x1: number, y1: number, x2: number, y2: number,
413
+ snapStartId: string | null, snapEndId: string | null) => {
414
+ if (!activeFloor) return;
415
+ const nodes: WallNode[] = [...activeFloor.wallNodes];
416
+
417
+ let nodeAId: string;
418
+ if (snapStartId) {
419
+ nodeAId = snapStartId;
420
+ } else {
421
+ const n: WallNode = { id: genId(), x: x1, y: y1 };
422
+ nodes.push(n);
423
+ nodeAId = n.id;
424
+ }
425
+
426
+ let nodeBId: string;
427
+ if (snapEndId) {
428
+ nodeBId = snapEndId;
429
+ } else {
430
+ const n: WallNode = { id: genId(), x: x2, y: y2 };
431
+ nodes.push(n);
432
+ nodeBId = n.id;
433
+ }
434
+
435
+ const newWall: Wall = {
436
+ id: genId(),
437
+ nodeAId,
438
+ nodeBId,
439
+ thickness: DEFAULT_WALL_THICKNESS,
440
+ material: 'concrete',
441
+ };
442
+
443
+ pushFloor({ ...activeFloor, wallNodes: nodes, walls: [...activeFloor.walls, newWall] });
444
+ },
445
+ [activeFloor, pushFloor, DEFAULT_WALL_THICKNESS],
446
+ );
447
+
448
+ const handleDeleteWall = useCallback(
449
+ (wallId: string) => {
450
+ if (!activeFloor) return;
451
+ const remainingWalls = activeFloor.walls.filter(w => w.id !== wallId);
452
+ const usedNodeIds = new Set(remainingWalls.flatMap(w => [w.nodeAId, w.nodeBId]));
453
+ const remainingNodes = activeFloor.wallNodes.filter(n => usedNodeIds.has(n.id));
454
+ pushFloor({ ...activeFloor, walls: remainingWalls, wallNodes: remainingNodes });
455
+ },
456
+ [activeFloor, pushFloor],
457
+ );
458
+
459
+ // ── Element operations ───────────────────────────────────────────────────
460
+
461
+ const handleMoveElement = useCallback(
462
+ (id: string, x: number, y: number) => {
463
+ if (!activeFloor) return;
464
+ const el = activeFloor.elements.find(e => e.id === id);
465
+ if (!el) return;
466
+ const { x: cx, y: cy } = clampToFloor(x, y, el.width, el.height, activeFloor.area);
467
+ replaceFloor({
468
+ ...activeFloor,
469
+ elements: activeFloor.elements.map(e => e.id === id ? { ...e, x: cx, y: cy } : e),
470
+ });
471
+ },
472
+ [activeFloor, replaceFloor],
473
+ );
474
+
475
+ const handleMoveCommit = useCallback(
476
+ (id: string, x: number, y: number) => {
477
+ if (!activeFloor) return;
478
+ const el = activeFloor.elements.find(e => e.id === id);
479
+ if (!el) return;
480
+ const { x: cx, y: cy } = clampToFloor(x, y, el.width, el.height, activeFloor.area);
481
+ pushFloor({
482
+ ...activeFloor,
483
+ elements: activeFloor.elements.map(e => e.id === id ? { ...e, x: cx, y: cy } : e),
484
+ });
485
+ },
486
+ [activeFloor, pushFloor],
487
+ );
488
+
489
+ const handleResizeElement = useCallback(
490
+ (id: string, x: number, y: number, w: number, h: number) => {
491
+ if (!activeFloor) return;
492
+ replaceFloor({
493
+ ...activeFloor,
494
+ elements: activeFloor.elements.map(el =>
495
+ el.id === id ? { ...el, x, y, width: w, height: h } : el,
496
+ ),
497
+ });
498
+ },
499
+ [activeFloor, replaceFloor],
500
+ );
501
+
502
+ const handleResizeCommit = useCallback(
503
+ (id: string, x: number, y: number, w: number, h: number) => {
504
+ if (!activeFloor) return;
505
+ pushFloor({
506
+ ...activeFloor,
507
+ elements: activeFloor.elements.map(el =>
508
+ el.id === id ? { ...el, x, y, width: w, height: h } : el,
509
+ ),
510
+ });
511
+ },
512
+ [activeFloor, pushFloor],
513
+ );
514
+
515
+ const handleRotateElement = useCallback(
516
+ (id: string, rotation: number) => {
517
+ if (!activeFloor) return;
518
+ replaceFloor({
519
+ ...activeFloor,
520
+ elements: activeFloor.elements.map(el =>
521
+ el.id === id ? { ...el, rotation } : el,
522
+ ),
523
+ });
524
+ },
525
+ [activeFloor, replaceFloor],
526
+ );
527
+
528
+ const handleRotateCommit = useCallback(
529
+ (id: string, rotation: number) => {
530
+ if (!activeFloor) return;
531
+ pushFloor({
532
+ ...activeFloor,
533
+ elements: activeFloor.elements.map(el =>
534
+ el.id === id ? { ...el, rotation } : el,
535
+ ),
536
+ });
537
+ },
538
+ [activeFloor, pushFloor],
539
+ );
540
+
541
+ const handleDeleteElement = useCallback(
542
+ (id: string) => {
543
+ if (!activeFloor) return;
544
+ clearSelection();
545
+ pushFloor({
546
+ ...activeFloor,
547
+ elements: activeFloor.elements.filter(el => el.id !== id),
548
+ });
549
+ },
550
+ [activeFloor, pushFloor, clearSelection],
551
+ );
552
+
553
+ const handleDeleteElements = useCallback(
554
+ (ids: string[]) => {
555
+ if (!activeFloor) return;
556
+ const idSet = new Set(ids);
557
+ clearSelection();
558
+ pushFloor({
559
+ ...activeFloor,
560
+ elements: activeFloor.elements.filter(el => !idSet.has(el.id)),
561
+ });
562
+ },
563
+ [activeFloor, pushFloor, clearSelection],
564
+ );
565
+
566
+ const handleDuplicateElements = useCallback(
567
+ (ids: string[]) => {
568
+ if (!activeFloor) return;
569
+ const idSet = new Set(ids);
570
+ const copies: MapElement[] = activeFloor.elements
571
+ .filter(el => idSet.has(el.id))
572
+ .map(el => ({ ...el, id: genId(), x: el.x + 20, y: el.y + 20 }));
573
+ const newFloor = { ...activeFloor, elements: [...activeFloor.elements, ...copies] };
574
+ pushFloor(newFloor);
575
+ selectSet(copies.map(c => c.id));
576
+ },
577
+ [activeFloor, pushFloor, selectSet],
578
+ );
579
+
580
+ const handlePlaceElement = useCallback(
581
+ (canvasX: number, canvasY: number) => {
582
+ if (!activeFloor || !activePlaceTypeId) return;
583
+ const typeDef = elementTypeDefs.current.get(activePlaceTypeId);
584
+ if (!typeDef) return;
585
+
586
+ const { area } = activeFloor;
587
+ if (area.shape === 'rect') {
588
+ const ax = area.x ?? 0, ay = area.y ?? 0;
589
+ const aw = area.width ?? 0, ah = area.height ?? 0;
590
+ if (canvasX < ax || canvasX > ax + aw || canvasY < ay || canvasY > ay + ah) return;
591
+ } else if (area.shape === 'polygon') {
592
+ const pts = area.points ?? [];
593
+ if (pts.length >= 3 && !pointInPolygon(canvasX, canvasY, pts)) return;
594
+ }
595
+
596
+ const { x, y } = clampToFloor(
597
+ canvasX - typeDef.defaultWidth / 2,
598
+ canvasY - typeDef.defaultHeight / 2,
599
+ typeDef.defaultWidth,
600
+ typeDef.defaultHeight,
601
+ area,
602
+ );
603
+
604
+ const newEl: MapElement = {
605
+ id: genId(),
606
+ type: activePlaceTypeId,
607
+ x,
608
+ y,
609
+ width: typeDef.defaultWidth,
610
+ height: typeDef.defaultHeight,
611
+ rotation: 0,
612
+ };
613
+ pushFloor({ ...activeFloor, elements: [...activeFloor.elements, newEl] });
614
+ select(newEl.id, false);
615
+ },
616
+ [activeFloor, activePlaceTypeId, pushFloor, select],
617
+ );
618
+
619
+ const handleChangeLabel = useCallback(
620
+ (id: string, label: string) => {
621
+ if (!activeFloor) return;
622
+ pushFloor({
623
+ ...activeFloor,
624
+ elements: activeFloor.elements.map(el =>
625
+ el.id === id ? { ...el, label } : el,
626
+ ),
627
+ });
628
+ },
629
+ [activeFloor, pushFloor],
630
+ );
631
+
632
+ const handleChangeGeometry = useCallback(
633
+ (id: string, x: number, y: number, w: number, h: number, r: number) => {
634
+ if (!activeFloor) return;
635
+ pushFloor({
636
+ ...activeFloor,
637
+ elements: activeFloor.elements.map(el =>
638
+ el.id === id ? { ...el, x, y, width: w, height: h, rotation: r } : el,
639
+ ),
640
+ });
641
+ },
642
+ [activeFloor, pushFloor],
643
+ );
644
+
645
+ // ── Viewer element click (type-specific handler → generic fallback) ─────
646
+ const handleViewerElementClick = useCallback(
647
+ (id: string) => {
648
+ const el = activeFloor?.elements.find(e => e.id === id);
649
+ if (!el) return;
650
+ const typeHandler = onElementTypeClick?.[el.type];
651
+ if (typeHandler) {
652
+ typeHandler(el);
653
+ } else {
654
+ onElementClick?.(el);
655
+ }
656
+ },
657
+ [activeFloor, onElementClick, onElementTypeClick],
658
+ );
659
+
660
+ const hasViewerHandlers = !!(onElementClick || onElementTypeClick);
661
+
662
+ // ── Status map ───────────────────────────────────────────────────────────
663
+ const statusMap = useMemo(() => {
664
+ const m = new Map<string, string>();
665
+ (elementStatus ?? []).forEach(s => {
666
+ if (s.status === 'occupied') m.set(s.elementId, '#fca5a5');
667
+ else if (s.status === 'reserved') m.set(s.elementId, '#fde68a');
668
+ else if (s.status === 'disabled') m.set(s.elementId, '#d1d5db');
669
+ });
670
+ return m;
671
+ }, [elementStatus]);
672
+
673
+ // ── Selected elements ────────────────────────────────────────────────────
674
+ const selectedElements = activeFloor
675
+ ? activeFloor.elements.filter(el => selectedIds.has(el.id))
676
+ : [];
677
+
678
+ // ── Keyboard shortcuts ───────────────────────────────────────────────────
679
+ useEffect(() => {
680
+ const onKey = (e: KeyboardEvent) => {
681
+ const tag = (e.target as HTMLElement).tagName;
682
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
683
+
684
+ const ctrl = e.ctrlKey || e.metaKey;
685
+
686
+ if (ctrl && e.key === 'z') { e.preventDefault(); undo(); return; }
687
+ if (ctrl && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); redo(); return; }
688
+ if (ctrl && (e.key === 'd' || e.key === 'D')) {
689
+ e.preventDefault();
690
+ if (selectedIds.size > 0) handleDuplicateElements([...selectedIds]);
691
+ return;
692
+ }
693
+
694
+ if (e.key === 'Delete' || e.key === 'Backspace') {
695
+ if (selectedIds.size > 0) handleDeleteElements([...selectedIds]);
696
+ return;
697
+ }
698
+
699
+ switch (e.key) {
700
+ case 'v': case 'V': setTool('SELECT'); break;
701
+ case 'h': case 'H': setTool('PAN'); break;
702
+ case 'w': case 'W': setTool('WALL'); break;
703
+ case 'p': case 'P': setTool('PLACE'); break;
704
+ case 'e': case 'E': setTool('ERASE'); break;
705
+ case 'Escape': setTool('SELECT'); break;
706
+ case '+': case '=': zoomByRef.current(1.2); break;
707
+ case '-': case '_': zoomByRef.current(1 / 1.2); break;
708
+ }
709
+ };
710
+ window.addEventListener('keydown', onKey);
711
+ return () => window.removeEventListener('keydown', onKey);
712
+ }, [undo, redo, selectedIds, handleDuplicateElements, handleDeleteElements]);
713
+
714
+ // ── Fixed / read-only derived state ─────────────────────────────────────
715
+ const effectiveReadOnly = readOnly || fixed;
716
+ const effectiveTool: ToolMode = fixed ? 'PAN' : tool;
717
+
718
+ // ── Container style ──────────────────────────────────────────────────────
719
+ const containerStyle: CSSProperties = {
720
+ width,
721
+ height,
722
+ display: 'flex',
723
+ flexDirection: 'column',
724
+ overflow: 'hidden',
725
+ border: '1px solid #e2e8f0',
726
+ borderRadius: '0.5rem',
727
+ background: '#fff',
728
+ fontFamily: 'system-ui, sans-serif',
729
+ };
730
+
731
+ const activeAreaShape: AreaShape | undefined = activeFloor?.area.shape;
732
+
733
+ return (
734
+ <div style={containerStyle}>
735
+ {/* Hidden file inputs */}
736
+ <input
737
+ ref={importInputRef}
738
+ type="file"
739
+ accept=".json"
740
+ className="hidden"
741
+ onChange={e => {
742
+ const f = e.target.files?.[0];
743
+ if (f) handleImportMap(f);
744
+ e.target.value = '';
745
+ }}
746
+ />
747
+ <input
748
+ ref={libraryInputRef}
749
+ type="file"
750
+ accept=".json"
751
+ className="hidden"
752
+ onChange={e => {
753
+ const f = e.target.files?.[0];
754
+ if (f) handleLoadLibrary(f);
755
+ e.target.value = '';
756
+ }}
757
+ />
758
+
759
+ {/* Toolbar */}
760
+ {!effectiveReadOnly && (
761
+ <Toolbar
762
+ tool={tool}
763
+ onToolChange={t => { setTool(t); if (t !== 'PLACE') clearSelection(); }}
764
+ showGrid={showGrid}
765
+ onToggleGrid={() => setShowGrid(g => !g)}
766
+ zoom={zoom}
767
+ onZoomIn={() => zoomByRef.current(1.2)}
768
+ onZoomOut={() => zoomByRef.current(1 / 1.2)}
769
+ onResetView={() => resetViewRef.current()}
770
+ canUndo={canUndo}
771
+ canRedo={canRedo}
772
+ onUndo={undo}
773
+ onRedo={redo}
774
+ paletteGroups={paletteGroups}
775
+ activePlaceTypeId={activePlaceTypeId}
776
+ onActivePlaceTypeChange={setActivePlaceTypeId}
777
+ areaShape={activeAreaShape}
778
+ onToggleAreaShape={handleToggleAreaShape}
779
+ onExportMap={handleExportMap}
780
+ onImportMap={() => importInputRef.current?.click()}
781
+ onLoadLibrary={() => libraryInputRef.current?.click()}
782
+ onRemoveLibraryGroup={handleRemoveLibraryGroup}
783
+ />
784
+ )}
785
+
786
+ {/* Floor tabs */}
787
+ <FloorTabs
788
+ floors={map.floors}
789
+ activeFloorId={activeFloorId}
790
+ readOnly={effectiveReadOnly}
791
+ onSelect={setActiveFloorId}
792
+ onAdd={handleAddFloor}
793
+ onRename={handleRenameFloor}
794
+ onDelete={handleDeleteFloor}
795
+ onReorder={handleReorderFloor}
796
+ />
797
+
798
+ {/* Canvas + Properties panel */}
799
+ <div className="flex flex-1 min-h-0">
800
+ <div className="flex-1 min-w-0 relative">
801
+ {activeFloor && (
802
+ <EditorCanvas
803
+ key={activeFloor.id}
804
+ floor={activeFloor}
805
+ tool={effectiveTool}
806
+ gridSize={gridSize}
807
+ showGrid={showGrid}
808
+ readOnly={effectiveReadOnly}
809
+ snapEnabled={snapEnabled}
810
+ elementTypeDefs={elementTypeDefs.current}
811
+ selectedIds={selectedIds}
812
+ statusMap={statusMap}
813
+ onAreaResize={handleAreaResize}
814
+ onAreaMove={handleAreaMove}
815
+ onAreaResizeCommit={handleAreaResizeCommit}
816
+ onSelectElement={select}
817
+ onSelectSet={selectSet}
818
+ onClearSelection={clearSelection}
819
+ onMoveElement={handleMoveElement}
820
+ onMoveCommit={handleMoveCommit}
821
+ onResizeElement={handleResizeElement}
822
+ onResizeCommit={handleResizeCommit}
823
+ onRotateElement={handleRotateElement}
824
+ onRotateCommit={handleRotateCommit}
825
+ onDeleteElement={handleDeleteElement}
826
+ onPlaceElement={handlePlaceElement}
827
+ onAddWall={handleAddWall}
828
+ onDeleteWall={handleDeleteWall}
829
+ onViewerElementClick={hasViewerHandlers ? handleViewerElementClick : undefined}
830
+ onZoomChange={setZoom}
831
+ onRegisterZoomBy={fn => { zoomByRef.current = fn; }}
832
+ onRegisterResetView={fn => { resetViewRef.current = fn; }}
833
+ />
834
+ )}
835
+ </div>
836
+
837
+ {/* Properties panel */}
838
+ {!effectiveReadOnly && selectedElements.length > 0 && (
839
+ <PropertiesPanel
840
+ elements={selectedElements}
841
+ typeDefs={elementTypeDefs.current}
842
+ onChangeLabel={handleChangeLabel}
843
+ onChangeGeometry={handleChangeGeometry}
844
+ onDelete={handleDeleteElements}
845
+ onDuplicate={handleDuplicateElements}
846
+ />
847
+ )}
848
+ </div>
849
+ </div>
850
+ );
851
+ }