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,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
|
+
}
|