neogestify-ui-components 2.0.1 → 2.2.1

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 (31) hide show
  1. package/README.md +153 -19
  2. package/dist/components/ElementLibraryBuilder/index.d.mts +5 -0
  3. package/dist/components/ElementLibraryBuilder/index.d.ts +5 -0
  4. package/dist/components/ElementLibraryBuilder/index.js +689 -0
  5. package/dist/components/ElementLibraryBuilder/index.js.map +1 -0
  6. package/dist/components/ElementLibraryBuilder/index.mjs +687 -0
  7. package/dist/components/ElementLibraryBuilder/index.mjs.map +1 -0
  8. package/dist/components/VenueMapEditor/index.d.mts +66 -5
  9. package/dist/components/VenueMapEditor/index.d.ts +66 -5
  10. package/dist/components/VenueMapEditor/index.js +199 -34
  11. package/dist/components/VenueMapEditor/index.js.map +1 -1
  12. package/dist/components/VenueMapEditor/index.mjs +199 -36
  13. package/dist/components/VenueMapEditor/index.mjs.map +1 -1
  14. package/dist/index.d.mts +2 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +592 -34
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +591 -36
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +1 -1
  21. package/src/components/ElementLibraryBuilder/builder.tsx +400 -0
  22. package/src/components/ElementLibraryBuilder/index.ts +1 -0
  23. package/src/components/VenueMapEditor/VenueMapEditor.tsx +79 -20
  24. package/src/components/VenueMapEditor/components/ElementNode.tsx +23 -0
  25. package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +17 -4
  26. package/src/components/VenueMapEditor/components/Toolbar.tsx +73 -39
  27. package/src/components/VenueMapEditor/hooks/useLibraryStorage.ts +46 -0
  28. package/src/components/VenueMapEditor/index.ts +3 -0
  29. package/src/components/VenueMapEditor/types.ts +45 -3
  30. package/src/components/VenueMapEditor/utils/svgParser.ts +33 -0
  31. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neogestify-ui-components",
3
- "version": "2.0.1",
3
+ "version": "2.2.1",
4
4
  "description": "Biblioteca de componentes UI reutilizables con React, Tailwind y SweetAlert, con VenueMapEditor o editor de mapas basico",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -0,0 +1,400 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { ElementShape, ElementTypeDef, ElementLibrary } from '../VenueMapEditor/types';
3
+ import { Button, Input, Select, TextArea } from '../html';
4
+
5
+ type InternalGroup = {
6
+ internalId: string;
7
+ name: string;
8
+ objects: ElementTypeDef[];
9
+ };
10
+
11
+ const DEFAULT_ELEMENT: ElementTypeDef = {
12
+ id: '',
13
+ label: '',
14
+ shape: 'rect',
15
+ defaultWidth: 100,
16
+ defaultHeight: 100,
17
+ color: '#cccccc',
18
+ strokeColor: '#000000',
19
+ };
20
+
21
+ const SHAPE_OPTIONS = [
22
+ { value: 'rect', label: 'Rectangle' },
23
+ { value: 'circle', label: 'Circle' },
24
+ { value: 'arrow', label: 'Arrow' },
25
+ { value: 'path', label: 'Path' },
26
+ { value: 'svg', label: 'SVG Markup' },
27
+ ];
28
+
29
+ export const ElementLibraryBuilder: React.FC = () => {
30
+ const [groups, setGroups] = useState<InternalGroup[]>([
31
+ { internalId: crypto.randomUUID(), name: 'defaultGroup', objects: [] }
32
+ ]);
33
+ const [activeGroupId, setActiveGroupId] = useState<string>(groups[0].internalId);
34
+ const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
35
+
36
+ const [activeElementIndex, setActiveElementIndex] = useState<number | null>(null);
37
+ const [currentElement, setCurrentElement] = useState<ElementTypeDef>({ ...DEFAULT_ELEMENT, id: 'rect_1', label: 'New Rect' });
38
+ const [downloadFileName, setDownloadFileName] = useState<string>("libraries");
39
+
40
+ // ─── Group management ────────────────────────────────────────────────────────
41
+
42
+ const handleAddGroup = () => {
43
+ const newGroupId = crypto.randomUUID();
44
+ const newName = `group_${groups.length + 1}`;
45
+ setGroups([...groups, { internalId: newGroupId, name: newName, objects: [] }]);
46
+ setActiveGroupId(newGroupId);
47
+ setActiveElementIndex(null);
48
+ };
49
+
50
+ const handleRemoveGroup = (id: string) => {
51
+ const newGroups = groups.filter((g) => g.internalId !== id);
52
+ setGroups(newGroups);
53
+ if (activeGroupId === id) {
54
+ if (newGroups.length > 0) {
55
+ setActiveGroupId(newGroups[0].internalId);
56
+ } else {
57
+ setActiveGroupId('');
58
+ }
59
+ setActiveElementIndex(null);
60
+ }
61
+ };
62
+
63
+ const activeGroup = groups.find((g) => g.internalId === activeGroupId);
64
+
65
+ // ─── Element management ──────────────────────────────────────────────────────
66
+
67
+ const handleSelectGroup = (gId: string) => {
68
+ setActiveGroupId(gId);
69
+ setActiveElementIndex(null);
70
+ }
71
+
72
+ const handleAddElement = () => {
73
+ if (!activeGroup) return;
74
+
75
+ const newEl = { ...DEFAULT_ELEMENT, id: `shape_${activeGroup.objects.length + 1}`, label: `Shape ${activeGroup.objects.length + 1}` };
76
+ const updatedGroups = groups.map((g) => {
77
+ if (g.internalId === activeGroupId) {
78
+ return { ...g, objects: [...g.objects, newEl] };
79
+ }
80
+ return g;
81
+ });
82
+ setGroups(updatedGroups);
83
+ setActiveElementIndex(activeGroup.objects.length);
84
+ setCurrentElement(newEl);
85
+ };
86
+
87
+ const handleSelectElement = (idx: number) => {
88
+ if (!activeGroup) return;
89
+ setActiveElementIndex(idx);
90
+ setCurrentElement(activeGroup.objects[idx]);
91
+ };
92
+
93
+ const handleRemoveElement = (idx: number) => {
94
+ if (!activeGroup) return;
95
+ const updatedGroups = groups.map((g) => {
96
+ if (g.internalId === activeGroupId) {
97
+ const newObjs = [...g.objects];
98
+ newObjs.splice(idx, 1);
99
+ return { ...g, objects: newObjs };
100
+ }
101
+ return g;
102
+ });
103
+ setGroups(updatedGroups);
104
+ if (activeElementIndex === idx) {
105
+ setActiveElementIndex(null);
106
+ } else if (activeElementIndex !== null && activeElementIndex > idx) {
107
+ setActiveElementIndex(activeElementIndex - 1);
108
+ }
109
+ };
110
+
111
+ const handleSaveElement = () => {
112
+ if (!activeGroup || activeElementIndex === null) return;
113
+ const updatedGroups = groups.map((g) => {
114
+ if (g.internalId === activeGroupId) {
115
+ const newObjs = [...g.objects];
116
+ newObjs[activeElementIndex] = { ...currentElement };
117
+ return { ...g, objects: newObjs };
118
+ }
119
+ return g;
120
+ });
121
+ setGroups(updatedGroups);
122
+ };
123
+
124
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
125
+
126
+ const handleFieldChange = (field: keyof ElementTypeDef, value: any) => {
127
+ setCurrentElement((prev) => ({ ...prev, [field]: value }));
128
+ };
129
+
130
+ const handleSvgMarkupChange = (value: string) => {
131
+ // No manual sanitization needed — JSON.stringify() will automatically escape
132
+ // double quotes and special characters when serializing the output object.
133
+ // Modifying the string here (e.g. collapsing whitespace or replacing quotes)
134
+ // would corrupt the SVG structure (paths, attributes, colors, etc.).
135
+ handleFieldChange('svgMarkup', value);
136
+ };
137
+
138
+ const generatedLib = useMemo(() => {
139
+ const lib: ElementLibrary = {};
140
+ groups.forEach((g) => {
141
+ lib[g.name] = {
142
+ name: g.name,
143
+ objects: g.objects,
144
+ };
145
+ });
146
+ return JSON.stringify(lib, null, 2);
147
+ }, [groups]);
148
+
149
+ const handleDownload = () => {
150
+ const blob = new Blob([generatedLib], { type: 'application/json' });
151
+ const url = URL.createObjectURL(blob);
152
+ const a = document.createElement('a');
153
+ a.href = url;
154
+ a.download = `${downloadFileName}.json`;
155
+ document.body.appendChild(a);
156
+ a.click();
157
+ document.body.removeChild(a);
158
+ URL.revokeObjectURL(url);
159
+ };
160
+
161
+ // ─── Render ──────────────────────────────────────────────────────────────────
162
+
163
+ return (
164
+ <div className="flex gap-4 p-4 h-full min-h-[600px] text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-900">
165
+ {/* Sidebar columns for Groups and Elements */}
166
+ <div className="w-1/4 flex flex-col gap-4 border-r dark:border-gray-700 pr-4">
167
+ <div className="flex flex-col gap-2">
168
+ <div className="flex items-center justify-between">
169
+ <h3 className="font-bold">Libraries (Groups)</h3>
170
+ <Button variant='primary' onClick={handleAddGroup}>+ Group</Button>
171
+ </div>
172
+ <div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1">
173
+ {groups.map((group) => (
174
+ <div
175
+ key={group.internalId}
176
+ className={`flex items-center justify-between p-2 rounded cursor-pointer ${activeGroupId === group.internalId ? 'bg-indigo-100 text-indigo-900 dark:bg-indigo-900/50 dark:text-indigo-100 font-semibold' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}`}
177
+ onClick={() => handleSelectGroup(group.internalId)}
178
+ >
179
+ {editingGroupId === group.internalId ? (
180
+ <Input
181
+ autoFocus
182
+ value={group.name}
183
+ onChange={(e) => {
184
+ setGroups(groups.map(g => g.internalId === group.internalId ? { ...g, name: e.target.value } : g));
185
+ }}
186
+ onBlur={() => setEditingGroupId(null)}
187
+ onKeyDown={(e) => e.key === 'Enter' && setEditingGroupId(null)}
188
+ />
189
+ ) : (
190
+ <span onDoubleClick={() => setEditingGroupId(group.internalId)}>{group.name}</span>
191
+ )}
192
+
193
+ {groups.length > 1 && (
194
+ <button onClick={(e) => { e.stopPropagation(); handleRemoveGroup(group.internalId); }} className="text-red-500 text-xs">x</button>
195
+ )}
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </div>
200
+
201
+ <hr className="dark:border-gray-700" />
202
+
203
+ <div className="flex flex-col gap-2 flex-grow overflow-hidden">
204
+ <div className="flex items-center justify-between">
205
+ <h3 className="font-bold">Elements in {activeGroup?.name || '?'}</h3>
206
+ <Button variant='secondary' onClick={handleAddElement} disabled={!activeGroup}>+ Element</Button>
207
+ </div>
208
+ <div className="flex flex-col gap-1 overflow-y-auto flex-grow pr-1">
209
+ {activeGroup?.objects.map((el, i) => (
210
+ <div
211
+ key={i}
212
+ className={`flex items-center justify-between p-2 rounded cursor-pointer ${activeElementIndex === i ? 'bg-indigo-100 text-indigo-900 dark:bg-indigo-900/50 dark:text-indigo-100 font-semibold' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}`}
213
+ onClick={() => handleSelectElement(i)}
214
+ >
215
+ <span>{el.id} ({el.shape})</span>
216
+ <button onClick={(e) => { e.stopPropagation(); handleRemoveElement(i); }} className="text-red-500 text-xs">x</button>
217
+ </div>
218
+ ))}
219
+ {(!activeGroup || activeGroup.objects.length === 0) && (
220
+ <span className="text-gray-400 dark:text-gray-500 italic text-xs">No elements yet</span>
221
+ )}
222
+ </div>
223
+ </div>
224
+ </div>
225
+
226
+ {/* Editor Section */}
227
+ <div className="flex-1 flex flex-col gap-4 px-2 overflow-y-auto">
228
+ <h3 className="font-bold text-lg">Element Editor</h3>
229
+ {activeElementIndex !== null ? (
230
+ <div className="flex flex-col gap-4 w-full max-w-2xl">
231
+ <div className="grid grid-cols-2 gap-4">
232
+ <Input
233
+ label="Element ID (unique)"
234
+ value={currentElement.id}
235
+ onChange={(e) => handleFieldChange('id', e.target.value)}
236
+ />
237
+ <Input
238
+ label="Label (display name)"
239
+ value={currentElement.label}
240
+ onChange={(e) => handleFieldChange('label', e.target.value)}
241
+ />
242
+ </div>
243
+
244
+ <div className="grid grid-cols-2 gap-4">
245
+ <Select
246
+ label="Shape"
247
+ options={SHAPE_OPTIONS}
248
+ value={currentElement.shape}
249
+ onChange={(e) => handleFieldChange('shape', e.target.value as ElementShape)}
250
+ />
251
+ <Input
252
+ label="Icon (emoji or class)"
253
+ value={currentElement.icon || ''}
254
+ onChange={(e) => handleFieldChange('icon', e.target.value)}
255
+ />
256
+ </div>
257
+
258
+ <div className="grid grid-cols-2 gap-4">
259
+ <Input
260
+ type="number"
261
+ label="Default Width"
262
+ value={currentElement.defaultWidth}
263
+ onChange={(e) => handleFieldChange('defaultWidth', parseFloat(e.target.value) || 0)}
264
+ />
265
+ <Input
266
+ type="number"
267
+ label="Default Height"
268
+ value={currentElement.defaultHeight}
269
+ onChange={(e) => handleFieldChange('defaultHeight', parseFloat(e.target.value) || 0)}
270
+ />
271
+ </div>
272
+
273
+ <div className="grid grid-cols-2 gap-4">
274
+ <div className="flex flex-col gap-1">
275
+ <label className="text-xs font-semibold text-gray-700">Fill Color</label>
276
+ <div className="flex gap-2">
277
+ <input
278
+ type="color"
279
+ className="w-8 h-8 cursor-pointer rounded"
280
+ value={currentElement.color}
281
+ onChange={(e) => handleFieldChange('color', e.target.value)}
282
+ />
283
+ <Input
284
+ value={currentElement.color}
285
+ onChange={(e) => handleFieldChange('color', e.target.value)}
286
+ />
287
+ </div>
288
+ </div>
289
+ <div className="flex flex-col gap-1">
290
+ <label className="text-xs font-semibold text-gray-700">Stroke Color</label>
291
+ <div className="flex gap-2">
292
+ <input
293
+ type="color"
294
+ className="w-8 h-8 cursor-pointer rounded"
295
+ value={currentElement.strokeColor}
296
+ onChange={(e) => handleFieldChange('strokeColor', e.target.value)}
297
+ />
298
+ <Input
299
+ value={currentElement.strokeColor}
300
+ onChange={(e) => handleFieldChange('strokeColor', e.target.value)}
301
+ />
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ {currentElement.shape === 'path' && (
307
+ <div className="flex flex-col gap-4 border dark:border-gray-700 p-4 rounded bg-gray-50 dark:bg-gray-800/50">
308
+ <h4 className="font-semibold text-sm">Path Config</h4>
309
+ <div className="grid grid-cols-2 gap-4">
310
+ <Input
311
+ label="ViewBox"
312
+ placeholder="0 0 100 100"
313
+ value={currentElement.viewBox || ''}
314
+ onChange={(e) => handleFieldChange('viewBox', e.target.value)}
315
+ />
316
+ <Select
317
+ label="Fill Rule"
318
+ options={[
319
+ { value: 'nonzero', label: 'nonzero' },
320
+ { value: 'evenodd', label: 'evenodd' }
321
+ ]}
322
+ value={currentElement.fillRule || 'nonzero'}
323
+ onChange={(e) => handleFieldChange('fillRule', e.target.value)}
324
+ />
325
+ </div>
326
+ <TextArea
327
+ label="SVG Path (d attribute)"
328
+ placeholder="M10 10 H 90 V 90 H 10 Z"
329
+ value={currentElement.svgPath || ''}
330
+ onChange={(e) => handleFieldChange('svgPath', e.target.value)}
331
+ rows={4}
332
+ />
333
+ </div>
334
+ )}
335
+
336
+ {currentElement.shape === 'svg' && (
337
+ <div className="flex flex-col gap-4 border dark:border-amber-700/50 p-4 rounded bg-amber-50 dark:bg-amber-900/10">
338
+ <h4 className="font-semibold text-sm">SVG Markup (Autosanitized)</h4>
339
+ <p className="text-xs text-amber-800 dark:text-amber-400">
340
+ Paste your raw SVG here. Double quotes will be converted to single quotes automatically to safely embed the string in JSON.
341
+ </p>
342
+ <TextArea
343
+ label="raw <svg>...</svg>"
344
+ value={currentElement.svgMarkup || ''}
345
+ onChange={(e) => handleSvgMarkupChange(e.target.value)}
346
+ rows={6}
347
+ placeholder={"<svg viewBox='0 0 100 100'><circle cx='50' cy='50' r='50'/></svg>"}
348
+ />
349
+ </div>
350
+ )}
351
+
352
+ <div className="flex justify-end gap-2 mt-4 pt-4 border-t dark:border-gray-700">
353
+ <Button onClick={handleSaveElement}>Save Changes to Element</Button>
354
+ </div>
355
+ </div>
356
+ ) : (
357
+ <div className="flex items-center justify-center h-full text-gray-400">
358
+ Select an element to edit or add a new one.
359
+ </div>
360
+ )}
361
+ </div>
362
+
363
+ {/* output section */}
364
+ <div className="w-1/3 flex flex-col gap-2 border-l dark:border-gray-700 pl-4 h-full max-h-full">
365
+ <div className="flex items-center justify-between shrink-0">
366
+ <h3 className="font-bold">Output JSON</h3>
367
+ <div className="flex items-center gap-2">
368
+ <Input
369
+ value={downloadFileName}
370
+ onChange={(e) => setDownloadFileName(e.target.value)}
371
+ placeholder="filename"
372
+ title="Filename without extension"
373
+ />
374
+ <span className="text-xs text-gray-500">.json</span>
375
+ <Button
376
+ variant="secondary"
377
+ onClick={handleDownload}
378
+ title="Download JSON file"
379
+ >
380
+ Descargar
381
+ </Button>
382
+ <Button
383
+ variant="secondary"
384
+ onClick={() => navigator.clipboard.writeText(generatedLib)}
385
+ >
386
+ Copy
387
+ </Button>
388
+ </div>
389
+ </div>
390
+ <div className="flex-1 overflow-hidden h-full pb-4">
391
+ <TextArea
392
+ readOnly
393
+ className="h-full resize-none font-mono text-xs text-green-600 dark:text-green-400 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-800"
394
+ value={generatedLib}
395
+ />
396
+ </div>
397
+ </div>
398
+ </div>
399
+ );
400
+ };
@@ -0,0 +1 @@
1
+ export * from './builder';
@@ -13,6 +13,7 @@ import type {
13
13
  ElementLibrary,
14
14
  DomainConfig,
15
15
  } from './types';
16
+ import { useLibraryStorage } from './hooks/useLibraryStorage';
16
17
  import { Toolbar } from './components/Toolbar';
17
18
  import type { PaletteGroup } from './components/Toolbar';
18
19
  import { EditorCanvas } from './components/EditorCanvas';
@@ -98,7 +99,30 @@ function createDefaultMap(): VenueMap {
98
99
  };
99
100
  }
100
101
 
101
- const EMPTY_DOMAIN_CONFIG: DomainConfig = { id: '__empty__', name: '', elementTypes: [] };
102
+ const DEFAULT_LIBRARY_KEY = 'venueMapEditor:libraries';
103
+
104
+ /**
105
+ * Deep-merges `incoming` into `existing`:
106
+ * - New groups are added as-is.
107
+ * - Existing groups get their `objects` extended with only the elements
108
+ * whose `id` is not already present (existing elements are never overwritten).
109
+ */
110
+ function mergeLibraries(existing: ElementLibrary, incoming: ElementLibrary): ElementLibrary {
111
+ const result: ElementLibrary = { ...existing };
112
+ for (const [groupId, incomingGroup] of Object.entries(incoming)) {
113
+ if (result[groupId]) {
114
+ const existingIds = new Set(result[groupId].objects.map(o => o.id));
115
+ const newObjects = incomingGroup.objects.filter(o => !existingIds.has(o.id));
116
+ result[groupId] = {
117
+ ...result[groupId],
118
+ objects: [...result[groupId].objects, ...newObjects],
119
+ };
120
+ } else {
121
+ result[groupId] = incomingGroup;
122
+ }
123
+ }
124
+ return result;
125
+ }
102
126
 
103
127
  function updateFloor(map: VenueMap, updatedFloor: Floor): VenueMap {
104
128
  return {
@@ -144,7 +168,9 @@ function polygonToRect(area: FloorArea): FloorArea {
144
168
  // ─── Component ────────────────────────────────────────────────────────────────
145
169
 
146
170
  export function VenueMapEditor({
147
- domainConfig = EMPTY_DOMAIN_CONFIG,
171
+ domainConfigs,
172
+ domainConfig,
173
+ libraryStorageKey = DEFAULT_LIBRARY_KEY,
148
174
  initialMap,
149
175
  onChange,
150
176
  width = '100%',
@@ -158,8 +184,18 @@ export function VenueMapEditor({
158
184
  onElementClick,
159
185
  onElementTypeClick,
160
186
  }: VenueMapEditorProps) {
187
+ // Normalise: domainConfigs takes precedence; fall back to legacy domainConfig
188
+ const effectiveConfigs = useMemo<DomainConfig[]>(() => {
189
+ if (domainConfigs && domainConfigs.length > 0) return domainConfigs;
190
+ if (domainConfig) return [domainConfig];
191
+ return [];
192
+ }, [domainConfigs, domainConfig]);
161
193
  const initialMapRef = useRef<VenueMap>(initialMap ?? createDefaultMap());
162
194
 
195
+ // ── Persisted libraries — loaded synchronously from localStorage BEFORE the
196
+ // map renders so all element type definitions are available immediately.
197
+ const [persistedLibs, setPersistedLibs] = useLibraryStorage(libraryStorageKey);
198
+
163
199
  const { map, canUndo, canRedo, push, replace, undo, redo } = useHistory(
164
200
  initialMapRef.current,
165
201
  );
@@ -178,36 +214,50 @@ export function VenueMapEditor({
178
214
  const importInputRef = useRef<HTMLInputElement>(null);
179
215
  const libraryInputRef = useRef<HTMLInputElement>(null);
180
216
 
181
- // ── elementTypeDefs: base config + library types in the map ─────────────
217
+ // ── Effective library: persistedLibs (localStorage) merged with map.libraries
218
+ // Persisted libs take priority so users can override map-embedded types.
219
+ const effectiveLibs = useMemo<ElementLibrary>(() => ({
220
+ ...(map.libraries ?? {}),
221
+ ...persistedLibs,
222
+ }), [map.libraries, persistedLibs]);
223
+
224
+ // ── elementTypeDefs: all domain configs + all libraries ───────────────────
225
+ // Priority: domainConfigs first (base wins), then library types.
182
226
  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)) {
227
+ const m = new Map<string, import('./types').ElementTypeDef>();
228
+ for (const cfg of effectiveConfigs) {
229
+ for (const t of cfg.elementTypes) {
230
+ if (!m.has(t.id)) m.set(t.id, t);
231
+ }
232
+ }
233
+ for (const group of Object.values(effectiveLibs)) {
186
234
  for (const t of group.objects) {
187
- if (!m.has(t.id)) m.set(t.id, t); // base config wins on ID collision
235
+ if (!m.has(t.id)) m.set(t.id, t);
188
236
  }
189
237
  }
190
238
  return m;
191
- }, [domainConfig, map.libraries]);
239
+ }, [effectiveConfigs, effectiveLibs]);
192
240
 
193
241
  const elementTypeDefs = useRef(buildTypeDefs());
194
242
  useEffect(() => {
195
243
  elementTypeDefs.current = buildTypeDefs();
196
244
  }, [buildTypeDefs]);
197
245
 
198
- // ── Palette groups: base group + imported library groups ─────────────────
246
+ // ── Palette groups: all domain configs + all library groups ───────────────
199
247
  const paletteGroups = useMemo<PaletteGroup[]>(() => {
200
248
  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 });
249
+ // One group per DomainConfig that has at least one type
250
+ for (const cfg of effectiveConfigs) {
251
+ if (cfg.elementTypes.length > 0) {
252
+ groups.push({ id: cfg.id, name: cfg.name, types: cfg.elementTypes, isBase: true });
253
+ }
204
254
  }
205
- const libs = map.libraries ?? {};
206
- for (const [gid, group] of Object.entries(libs)) {
255
+ // One group per library group (persistedLibs take precedence, already merged)
256
+ for (const [gid, group] of Object.entries(effectiveLibs)) {
207
257
  groups.push({ id: gid, name: group.name, types: group.objects, isBase: false });
208
258
  }
209
259
  return groups;
210
- }, [domainConfig, map.libraries]);
260
+ }, [effectiveConfigs, effectiveLibs]);
211
261
 
212
262
  // Auto-select first available element type when nothing is selected
213
263
  useEffect(() => {
@@ -383,25 +433,34 @@ export function VenueMapEditor({
383
433
  reader.onload = e => {
384
434
  try {
385
435
  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 });
436
+ // 1. Persist to localStorage new groups added, existing groups get
437
+ // only the new element IDs appended (no overwrites).
438
+ const mergedPersisted = mergeLibraries(persistedLibs, parsed);
439
+ setPersistedLibs(mergedPersisted);
440
+ // 2. Same deep-merge into map.libraries for portability.
441
+ const mergedMap = mergeLibraries(map.libraries ?? {}, parsed);
442
+ push({ ...map, libraries: mergedMap });
389
443
  } catch {
390
444
  // ignore parse errors
391
445
  }
392
446
  };
393
447
  reader.readAsText(file);
394
448
  },
395
- [map, push],
449
+ [map, push, persistedLibs, setPersistedLibs],
396
450
  );
397
451
 
398
452
  const handleRemoveLibraryGroup = useCallback(
399
453
  (groupId: string) => {
454
+ // Remove from localStorage
455
+ const newPersistedLibs = { ...persistedLibs };
456
+ delete newPersistedLibs[groupId];
457
+ setPersistedLibs(newPersistedLibs);
458
+ // Remove from map.libraries too
400
459
  const libs = { ...(map.libraries ?? {}) };
401
460
  delete libs[groupId];
402
461
  push({ ...map, libraries: Object.keys(libs).length > 0 ? libs : undefined });
403
462
  },
404
- [map, push],
463
+ [map, push, persistedLibs, setPersistedLibs],
405
464
  );
406
465
 
407
466
  // ── Wall operations ──────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import type { MapElement, ElementTypeDef, ToolMode } from '../types';
4
4
  import type { PanZoomState } from '../hooks/usePanZoom';
5
5
  import { useDrag } from '../hooks/useDrag';
6
6
  import { snapToGrid } from '../utils/snapUtils';
7
+ import { parseSvgMarkup } from '../utils/svgParser';
7
8
 
8
9
  // ─── Arrow shape ──────────────────────────────────────────────────────────────
9
10
 
@@ -298,6 +299,7 @@ export function ElementNode({
298
299
  <path
299
300
  d={typeDef.svgPath}
300
301
  fill={fillColor}
302
+ fillRule={typeDef.fillRule ?? 'nonzero'}
301
303
  stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
302
304
  strokeWidth={isSelected ? customPath.strokeWidth * 1.5 : customPath.strokeWidth}
303
305
  style={{ cursor: bodyCursor }}
@@ -306,6 +308,27 @@ export function ElementNode({
306
308
  />
307
309
  </g>
308
310
  )}
311
+ {typeDef.shape === 'svg' && typeDef.svgMarkup && (() => {
312
+ const parsed = parseSvgMarkup(typeDef.svgMarkup);
313
+ const parts = parsed.viewBox.split(/[\s,]+/).map(Number);
314
+ const vw = parts[2] ?? 100;
315
+ const vh = parts[3] ?? 100;
316
+ const sx = vw > 0 ? w / vw : 1;
317
+ const sy = vh > 0 ? h / vh : 1;
318
+ const avgScale = Math.sqrt(Math.abs(sx * sy)) || 1;
319
+ return (
320
+ <g
321
+ transform={`translate(${x}, ${y}) scale(${sx}, ${sy})`}
322
+ fill={fillColor}
323
+ stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
324
+ strokeWidth={isSelected ? (sw / avgScale) * 1.5 : sw / avgScale}
325
+ style={{ cursor: bodyCursor }}
326
+ onMouseDown={tool === 'SELECT' && !onViewerClick ? handleBodyDown : undefined}
327
+ onClick={handleBodyClick}
328
+ dangerouslySetInnerHTML={{ __html: parsed.innerHtml }}
329
+ />
330
+ );
331
+ })()}
309
332
 
310
333
  {/* ── Label ── */}
311
334
  {(element.label ?? typeDef.label) && (
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from 'react';
2
2
  import type { ChangeEvent } from 'react';
3
3
  import type { MapElement, ElementTypeDef } from '../types';
4
+ import { parseSvgMarkup } from '../utils/svgParser';
4
5
 
5
6
  interface PropertiesPanelProps {
6
7
  elements: MapElement[];
@@ -112,11 +113,23 @@ export function PropertiesPanel({
112
113
  {/* Type badge */}
113
114
  {typeDef && (
114
115
  <div className="flex items-center gap-2">
115
- <span
116
- className="w-3.5 h-3.5 rounded-sm shrink-0 border"
117
- style={{ background: typeDef.color, borderColor: typeDef.strokeColor }}
118
- />
116
+ {typeDef.shape === 'svg' ? (
117
+ <svg
118
+ viewBox={(() => { try { return typeDef.svgMarkup ? parseSvgMarkup(typeDef.svgMarkup).viewBox : '0 0 100 100'; } catch { return '0 0 100 100'; } })()}
119
+ className="w-3.5 h-3.5 shrink-0 border border-slate-300 rounded-sm"
120
+ style={{ color: typeDef.strokeColor }}
121
+ dangerouslySetInnerHTML={{ __html: (() => { try { return typeDef.svgMarkup ? parseSvgMarkup(typeDef.svgMarkup).innerHtml : ''; } catch { return ''; } })() }}
122
+ />
123
+ ) : (
124
+ <span
125
+ className="w-3.5 h-3.5 rounded-sm shrink-0 border"
126
+ style={{ background: typeDef.color, borderColor: typeDef.strokeColor }}
127
+ />
128
+ )}
119
129
  <span className="text-xs font-medium text-slate-700 truncate">{typeDef.label}</span>
130
+ {typeDef.shape === 'svg' && (
131
+ <span className="text-[9px] uppercase tracking-wide text-slate-400 font-medium ml-auto">SVG</span>
132
+ )}
120
133
  </div>
121
134
  )}
122
135