neogestify-ui-components 2.0.1 → 2.1.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 CHANGED
@@ -380,7 +380,9 @@ function App() {
380
380
  |------|------|---------|-------------|
381
381
  | `initialMap` | `VenueMap` | mapa vacío | Mapa inicial. Se puede actualizar desde fuera para recargar el editor. |
382
382
  | `onChange` | `(map: VenueMap) => void` | — | Se llama en cada cambio del estado interno. |
383
- | `domainConfig` | `DomainConfig` | vacío | Tipos de elementos predefinidos disponibles en la paleta (opcional). |
383
+ | `domainConfigs` | `DomainConfig[]` | `[]` | Array de catálogos de tipos predefinidos. Cada uno aparece como una pestaña separada en la paleta. |
384
+ | `domainConfig` | `DomainConfig` | — | **Obsoleto** — usa `domainConfigs`. Catálogo único (se convierte internamente a un array de un elemento). |
385
+ | `libraryStorageKey` | `string` | `'venueMapEditor:libraries'` | Clave de `localStorage` donde se persisten las librerías importadas. Pasa `''` para deshabilitar la persistencia. |
384
386
  | `width` | `string \| number` | `'100%'` | Ancho del componente. |
385
387
  | `height` | `string \| number` | `'600px'` | Alto del componente. |
386
388
  | `gridSize` | `number` | `20` | Tamaño de la cuadrícula en unidades de canvas. |
@@ -434,9 +436,60 @@ const estados: ElementStatus[] = [
434
436
 
435
437
  ---
436
438
 
439
+ ### Múltiples catálogos de elementos (domainConfigs)
440
+
441
+ Pasa varios `DomainConfig` vía la prop `domainConfigs`. Cada catálogo aparece como una **pestaña separada** en la paleta — los tipos nunca se mezclan entre tabs.
442
+
443
+ ```tsx
444
+ import { VenueMapEditor } from 'neogestify-ui-components/VenueMapEditor';
445
+ import type { DomainConfig } from 'neogestify-ui-components/VenueMapEditor';
446
+
447
+ const mobiliario: DomainConfig = {
448
+ id: 'furniture',
449
+ name: 'Mobiliario',
450
+ elementTypes: [
451
+ { id: 'CHAIR', label: 'Silla', shape: 'circle', defaultWidth: 30, defaultHeight: 30, color: '#fef3c7', strokeColor: '#d97706' },
452
+ { id: 'TABLE_RECT', label: 'Mesa rect.', shape: 'rect', defaultWidth: 100, defaultHeight: 60, color: '#fef3c7', strokeColor: '#d97706' },
453
+ ],
454
+ };
455
+
456
+ const iluminacion: DomainConfig = {
457
+ id: 'lighting',
458
+ name: 'Iluminación',
459
+ elementTypes: [
460
+ { id: 'SPOT_LIGHT', label: 'Foco', shape: 'circle', defaultWidth: 40, defaultHeight: 40, color: '#fef9c3', strokeColor: '#ca8a04' },
461
+ { id: 'STRIP_LIGHT', label: 'Tira LED', shape: 'rect', defaultWidth: 120, defaultHeight: 15, color: '#fef9c3', strokeColor: '#ca8a04' },
462
+ ],
463
+ };
464
+
465
+ <VenueMapEditor domainConfigs={[mobiliario, iluminacion]} />
466
+ ```
467
+
468
+ La paleta mostrará:
469
+
470
+ ```
471
+ [ Mobiliario ] [ Iluminación ]
472
+ ─────────────────────────────
473
+ [Silla] [Mesa rect.]
474
+ ```
475
+
476
+ ---
477
+
437
478
  ### Crear una librería de elementos (JSON)
438
479
 
439
- Los elementos que aparecen en la paleta del editor se definen en archivos JSON que el usuario carga desde el botón **⊞** (Cargar librería) de la barra de herramientas. Una vez cargada, la librería queda **embebida dentro del propio mapa** y se exporta junto a él — no se pierde al reabrir el archivo.
480
+ Los elementos que aparecen en la paleta también se pueden definir en archivos JSON que el usuario carga desde el botón **⊞** (Cargar librería).
481
+
482
+ **Persistencia automática:** las librerías importadas se guardan en `localStorage` bajo la clave `libraryStorageKey` (por defecto `'venueMapEditor:libraries'`). Al recargar la página se restauran automáticamente **antes** de que el mapa renderice, evitando errores de "tipo de elemento desconocido".
483
+
484
+ **Merge inteligente al importar:** si un grupo con el mismo `id` ya existe, se añaden únicamente los elementos cuyo `id` no esté duplicado. Los elementos existentes nunca se sobreescriben.
485
+
486
+ ```tsx
487
+ // Cambiar la clave de almacenamiento (útil con múltiples editores en la misma app)
488
+ <VenueMapEditor libraryStorageKey="mi-proyecto:libs" />
489
+
490
+ // Deshabilitar persistencia
491
+ <VenueMapEditor libraryStorageKey="" />
492
+ ```
440
493
 
441
494
  #### Formato del JSON
442
495
 
@@ -527,17 +580,18 @@ Ahora puedes definir cualquier figura SVG usando un path:
527
580
 
528
581
  #### Propiedades de cada objeto
529
582
 
530
- | Campo | Tipo | Descripción |
531
- |-------|------|-------------|
532
- | `id` | `string` | Identificador único del tipo. Debe ser **único en toda la librería**. Se usa como key en `onElementTypeClick`. |
533
- | `label` | `string` | Nombre visible en la paleta. |
534
- | `shape` | `"rect" \| "circle" \| "arrow" \| "path"` | Forma del objeto en el canvas. |
535
- | `defaultWidth` | `number` | Ancho inicial al colocar el elemento (unidades de canvas ≈ píxeles a zoom 1×). |
536
- | `defaultHeight` | `number` | Alto inicial. |
537
- | `color` | `string` | Color de relleno (cualquier valor CSS: `#hex`, `rgb()`, `hsl()`, etc.). |
538
- | `strokeColor` | `string` | Color del borde. |
539
- | `svgPath` | `string` | **Requerido si `shape="path"`**. Atributo `d` del path SVG. |
540
- | `viewBox` | `string` | **Opcional si `shape="path"`**. ViewBox del path (default: `"0 0 100 100"`). |
583
+ | Campo | Tipo | Requerido | Descripción |
584
+ |-------|------|-----------|-------------|
585
+ | `id` | `string` | ✓ | Identificador único del tipo. Se usa como key en `onElementTypeClick`. |
586
+ | `label` | `string` | ✓ | Nombre visible en la paleta y en el canvas. |
587
+ | `shape` | `"rect" \| "circle" \| "arrow" \| "path"` | ✓ | Forma del objeto. |
588
+ | `defaultWidth` | `number` | ✓ | Ancho inicial al colocar el elemento (unidades de canvas ≈ px a zoom 1×). |
589
+ | `defaultHeight` | `number` | ✓ | Alto inicial. |
590
+ | `color` | `string` | ✓ | Color de relleno (cualquier valor CSS: `#hex`, `rgb()`, `hsl()`, etc.). |
591
+ | `strokeColor` | `string` | ✓ | Color del borde. |
592
+ | `svgPath` | `string` | solo para `shape:"path"` | Atributo `d` de un `<path>` SVG. Se escala automáticamente al bounding box del elemento. |
593
+ | `viewBox` | `string` | | Espacio de coordenadas del `svgPath`. Formato: `"minX minY w h"`. Default: `"0 0 100 100"`. |
594
+ | `fillRule` | `"nonzero" \| "evenodd"` | — | Regla de relleno SVG. Usa `"evenodd"` para crear huecos con sub-paths (engranajes, letras, donuts). Default: `"nonzero"`. |
541
595
 
542
596
  #### Formas disponibles
543
597
 
@@ -546,15 +600,50 @@ Ahora puedes definir cualquier figura SVG usando un path:
546
600
  | `rect` | Rectángulo | Mesas, espacios de parqueo, habitaciones |
547
601
  | `circle` | Elipse (círculo si `width === height`) | Mesas redondas, columnas, plantas |
548
602
  | `arrow` | Flecha apuntando a la derecha | Entradas, salidas, sentidos de circulación |
549
- | `path` | Forma SVG personalizada | Logos, iconos, estrellas, formas complejas |
603
+ | `path` | Forma SVG personalizada libre | Cualquier figura: estrella, engranaje, piano, logo... |
604
+
605
+ #### Formas personalizadas con `shape: "path"`
606
+
607
+ El campo `svgPath` acepta el atributo `d` de cualquier `<path>` SVG estándar. El sistema escala la figura para que ocupe exactamente el bounding box `width × height` del elemento. Puedes diseñar tus formas con Inkscape, Figma u otro editor vectorial y copiar el `d=` directamente.
550
608
 
551
- #### Colisión con el piso
609
+ ```json
610
+ {
611
+ "especiales": {
612
+ "name": "Especiales",
613
+ "objects": [
614
+ {
615
+ "id": "STAR",
616
+ "label": "Estrella",
617
+ "shape": "path",
618
+ "viewBox": "0 0 100 100",
619
+ "svgPath": "M50 5 L61 35 L95 35 L68 57 L79 91 L50 70 L21 91 L32 57 L5 35 L39 35 Z",
620
+ "defaultWidth": 60,
621
+ "defaultHeight": 60,
622
+ "color": "#facc15",
623
+ "strokeColor": "#ca8a04"
624
+ },
625
+ {
626
+ "id": "GEAR",
627
+ "label": "Engranaje",
628
+ "shape": "path",
629
+ "viewBox": "0 0 100 100",
630
+ "fillRule": "evenodd",
631
+ "svgPath": "M36.61,17.66 L44.13,5.39 L55.87,5.39 L63.39,17.66 A35,35 0 0,1 77.40,14.30 L85.70,22.60 L82.34,36.61 A35,35 0 0,1 94.61,44.13 L94.61,55.87 L82.34,63.39 A35,35 0 0,1 85.70,77.40 L77.40,85.70 L63.39,82.34 A35,35 0 0,1 55.87,94.61 L44.13,94.61 L36.61,82.34 A35,35 0 0,1 22.60,85.70 L14.30,77.40 L17.66,63.39 A35,35 0 0,1 5.39,55.87 L5.39,44.13 L17.66,36.61 A35,35 0 0,1 14.30,22.60 L22.60,14.30 Z M65,50 A15,15 0 1,0 35,50 A15,15 0 1,0 65,50 Z",
632
+ "defaultWidth": 70,
633
+ "defaultHeight": 70,
634
+ "color": "#94a3b8",
635
+ "strokeColor": "#334155"
636
+ }
637
+ ]
638
+ }
639
+ }
640
+ ```
552
641
 
553
- La detección de colisión con el piso usa un **cuadrado de lado `min(width, height)` centrado en el elemento**, en vez del bounding box completo. Esto evita que formas que no llenan su bounding box (estrellas, iconos, logos) queden excesivamente restringidas al moverse cerca del borde del piso.
642
+ > **Hitbox de piso:** para formas personalizadas que no llenan su bounding box (estrellas, logos, etc.), la detección de bordes usa un cuadrado de lado `min(width, height)` centrado en el elemento esto evita que la figura quede demasiado restringida al área del piso.
554
643
 
555
644
  #### Varios grupos en un archivo
556
645
 
557
- Un mismo archivo puede tener tantos grupos como necesites. Cada grupo aparece como una sección separada en la paleta. Se pueden cargar múltiples archivos — los grupos se acumulan. Cada grupo importado muestra un botón **×** para eliminarlo.
646
+ Un mismo archivo puede tener tantos grupos como necesites. Cada grupo aparece como una **pestaña separada** en la paleta. Se pueden cargar múltiples archivos — los grupos se acumulan. Cada grupo importado muestra un botón **×** en su pestaña para eliminarlo.
558
647
 
559
648
  ```json
560
649
  {
@@ -677,9 +766,9 @@ Los elementos y paredes siempre se mantienen dentro del piso al moverlos o coloc
677
766
 
678
767
  | Botón | Función |
679
768
  |-------|---------|
680
- | ⬇ Exportar mapa | Descarga el estado actual como `.json` (incluye las librerías embebidas). |
769
+ | ⬇ Exportar mapa | Descarga el estado actual como `.json` (incluye las librerías embebidas para portabilidad). |
681
770
  | ⬆ Importar mapa | Carga un `.json` exportado previamente, reemplazando el mapa actual. |
682
- | ⊞ Cargar librería | Carga un `.json` de elementos y añade sus grupos a la paleta sin reemplazar los existentes. |
771
+ | ⊞ Cargar librería | Carga un `.json` de elementos. Los grupos se añaden a la paleta como nuevas pestañas. Si el grupo ya existe, sólo se añaden los objetos con `id` nuevo (sin sobrescribir). La librería se persiste automáticamente en `localStorage`. |
683
772
 
684
773
  ---
685
774
 
@@ -82,6 +82,13 @@ interface ElementTypeDef {
82
82
  * Defaults to `"0 0 100 100"` when omitted.
83
83
  */
84
84
  viewBox?: string;
85
+ /**
86
+ * SVG fill rule for `shape === 'path'`.
87
+ * Use `'evenodd'` when the path contains sub-paths that should appear as holes
88
+ * (e.g. a gear with a circular cutout, a donut, a letter with counter-forms).
89
+ * Defaults to `'nonzero'`.
90
+ */
91
+ fillRule?: 'nonzero' | 'evenodd';
85
92
  }
86
93
  interface DomainConfig {
87
94
  id: string;
@@ -101,10 +108,35 @@ interface ElementStatus {
101
108
  }
102
109
  interface VenueMapEditorProps {
103
110
  /**
104
- * Optional built-in element type catalog.
105
- * If omitted the palette is empty until the user imports a library JSON.
111
+ * One or more built-in element type catalogs shown as separate palette groups.
112
+ * Each `DomainConfig` becomes its own named section in the element palette.
113
+ * Takes precedence over the legacy `domainConfig` singular prop.
114
+ *
115
+ * @example
116
+ * ```tsx
117
+ * <VenueMapEditor
118
+ * domainConfigs={[furnitureConfig, lightingConfig, audioConfig]}
119
+ * />
120
+ * ```
121
+ */
122
+ domainConfigs?: DomainConfig[];
123
+ /**
124
+ * @deprecated Use `domainConfigs` (array) instead.
125
+ * Single built-in element type catalog. Ignored when `domainConfigs` is provided.
106
126
  */
107
127
  domainConfig?: DomainConfig;
128
+ /**
129
+ * localStorage key used to persist user-imported libraries across sessions.
130
+ * Libraries are loaded **synchronously** on mount so all type definitions are
131
+ * available before the map renders — preventing "unknown element type" errors.
132
+ *
133
+ * Set to `''` to disable persistence (libraries are lost on page reload).
134
+ * Defaults to `'venueMapEditor:libraries'`.
135
+ *
136
+ * Multiple editor instances on the same page should use different keys if
137
+ * they manage independent library sets.
138
+ */
139
+ libraryStorageKey?: string;
108
140
  /**
109
141
  * Map to render. When this prop changes (by reference) from outside the
110
142
  * component, the editor resets its history to the new map — allowing the
@@ -145,7 +177,7 @@ interface VenueMapEditorProps {
145
177
  }
146
178
  type VenueMapViewerProps = VenueMapEditorProps;
147
179
 
148
- declare function VenueMapEditor({ domainConfig, initialMap, onChange, width, height, gridSize, showGrid: showGridProp, snapToGrid: snapEnabled, readOnly, fixed, elementStatus, onElementClick, onElementTypeClick, }: VenueMapEditorProps): react_jsx_runtime.JSX.Element;
180
+ declare function VenueMapEditor({ domainConfigs, domainConfig, libraryStorageKey, initialMap, onChange, width, height, gridSize, showGrid: showGridProp, snapToGrid: snapEnabled, readOnly, fixed, elementStatus, onElementClick, onElementTypeClick, }: VenueMapEditorProps): react_jsx_runtime.JSX.Element;
149
181
 
150
182
  declare function VenueMapViewer({ elementStatus, onElementClick, ...rest }: VenueMapViewerProps): react_jsx_runtime.JSX.Element;
151
183
 
@@ -175,6 +207,19 @@ declare function usePanZoom(initialZoom?: number, leftClickPan?: boolean): {
175
207
  resetView: () => void;
176
208
  };
177
209
 
210
+ /**
211
+ * Persists an `ElementLibrary` in `localStorage` and exposes it as React state.
212
+ *
213
+ * - Initialises **synchronously** from localStorage so type definitions are
214
+ * available before the map renders (no flash of unknown element types).
215
+ * - Gracefully degrades when localStorage is unavailable (SSR, private mode,
216
+ * storage quota exceeded) — falls back to plain in-memory state.
217
+ *
218
+ * @param storageKey localStorage key to read/write.
219
+ * Pass `''` or `undefined` to disable persistence entirely.
220
+ */
221
+ declare function useLibraryStorage(storageKey: string | undefined): [ElementLibrary, (libs: ElementLibrary) => void];
222
+
178
223
  /** Generates a globally-unique id using the Web Crypto API. */
179
224
  declare const genId: () => string;
180
225
 
@@ -199,4 +244,4 @@ declare const findNearestNode: (x: number, y: number, nodes: Array<{
199
244
  y: number;
200
245
  } | null;
201
246
 
202
- export { type AreaShape, type DomainConfig, type ElementGroup, type ElementLibrary, type ElementShape, type ElementStatus, type ElementTypeDef, type Floor, type FloorArea, type MapElement, type PaletteGroup, type PanZoomState, type ToolMode, type VenueMap, VenueMapEditor, type VenueMapEditorProps, VenueMapViewer, type VenueMapViewerProps, type Wall, type WallMaterial, type WallNode, findNearestNode, genId, snapPoint, snapToGrid, usePanZoom };
247
+ export { type AreaShape, type DomainConfig, type ElementGroup, type ElementLibrary, type ElementShape, type ElementStatus, type ElementTypeDef, type Floor, type FloorArea, type MapElement, type PaletteGroup, type PanZoomState, type ToolMode, type VenueMap, VenueMapEditor, type VenueMapEditorProps, VenueMapViewer, type VenueMapViewerProps, type Wall, type WallMaterial, type WallNode, findNearestNode, genId, snapPoint, snapToGrid, useLibraryStorage, usePanZoom };
@@ -82,6 +82,13 @@ interface ElementTypeDef {
82
82
  * Defaults to `"0 0 100 100"` when omitted.
83
83
  */
84
84
  viewBox?: string;
85
+ /**
86
+ * SVG fill rule for `shape === 'path'`.
87
+ * Use `'evenodd'` when the path contains sub-paths that should appear as holes
88
+ * (e.g. a gear with a circular cutout, a donut, a letter with counter-forms).
89
+ * Defaults to `'nonzero'`.
90
+ */
91
+ fillRule?: 'nonzero' | 'evenodd';
85
92
  }
86
93
  interface DomainConfig {
87
94
  id: string;
@@ -101,10 +108,35 @@ interface ElementStatus {
101
108
  }
102
109
  interface VenueMapEditorProps {
103
110
  /**
104
- * Optional built-in element type catalog.
105
- * If omitted the palette is empty until the user imports a library JSON.
111
+ * One or more built-in element type catalogs shown as separate palette groups.
112
+ * Each `DomainConfig` becomes its own named section in the element palette.
113
+ * Takes precedence over the legacy `domainConfig` singular prop.
114
+ *
115
+ * @example
116
+ * ```tsx
117
+ * <VenueMapEditor
118
+ * domainConfigs={[furnitureConfig, lightingConfig, audioConfig]}
119
+ * />
120
+ * ```
121
+ */
122
+ domainConfigs?: DomainConfig[];
123
+ /**
124
+ * @deprecated Use `domainConfigs` (array) instead.
125
+ * Single built-in element type catalog. Ignored when `domainConfigs` is provided.
106
126
  */
107
127
  domainConfig?: DomainConfig;
128
+ /**
129
+ * localStorage key used to persist user-imported libraries across sessions.
130
+ * Libraries are loaded **synchronously** on mount so all type definitions are
131
+ * available before the map renders — preventing "unknown element type" errors.
132
+ *
133
+ * Set to `''` to disable persistence (libraries are lost on page reload).
134
+ * Defaults to `'venueMapEditor:libraries'`.
135
+ *
136
+ * Multiple editor instances on the same page should use different keys if
137
+ * they manage independent library sets.
138
+ */
139
+ libraryStorageKey?: string;
108
140
  /**
109
141
  * Map to render. When this prop changes (by reference) from outside the
110
142
  * component, the editor resets its history to the new map — allowing the
@@ -145,7 +177,7 @@ interface VenueMapEditorProps {
145
177
  }
146
178
  type VenueMapViewerProps = VenueMapEditorProps;
147
179
 
148
- declare function VenueMapEditor({ domainConfig, initialMap, onChange, width, height, gridSize, showGrid: showGridProp, snapToGrid: snapEnabled, readOnly, fixed, elementStatus, onElementClick, onElementTypeClick, }: VenueMapEditorProps): react_jsx_runtime.JSX.Element;
180
+ declare function VenueMapEditor({ domainConfigs, domainConfig, libraryStorageKey, initialMap, onChange, width, height, gridSize, showGrid: showGridProp, snapToGrid: snapEnabled, readOnly, fixed, elementStatus, onElementClick, onElementTypeClick, }: VenueMapEditorProps): react_jsx_runtime.JSX.Element;
149
181
 
150
182
  declare function VenueMapViewer({ elementStatus, onElementClick, ...rest }: VenueMapViewerProps): react_jsx_runtime.JSX.Element;
151
183
 
@@ -175,6 +207,19 @@ declare function usePanZoom(initialZoom?: number, leftClickPan?: boolean): {
175
207
  resetView: () => void;
176
208
  };
177
209
 
210
+ /**
211
+ * Persists an `ElementLibrary` in `localStorage` and exposes it as React state.
212
+ *
213
+ * - Initialises **synchronously** from localStorage so type definitions are
214
+ * available before the map renders (no flash of unknown element types).
215
+ * - Gracefully degrades when localStorage is unavailable (SSR, private mode,
216
+ * storage quota exceeded) — falls back to plain in-memory state.
217
+ *
218
+ * @param storageKey localStorage key to read/write.
219
+ * Pass `''` or `undefined` to disable persistence entirely.
220
+ */
221
+ declare function useLibraryStorage(storageKey: string | undefined): [ElementLibrary, (libs: ElementLibrary) => void];
222
+
178
223
  /** Generates a globally-unique id using the Web Crypto API. */
179
224
  declare const genId: () => string;
180
225
 
@@ -199,4 +244,4 @@ declare const findNearestNode: (x: number, y: number, nodes: Array<{
199
244
  y: number;
200
245
  } | null;
201
246
 
202
- export { type AreaShape, type DomainConfig, type ElementGroup, type ElementLibrary, type ElementShape, type ElementStatus, type ElementTypeDef, type Floor, type FloorArea, type MapElement, type PaletteGroup, type PanZoomState, type ToolMode, type VenueMap, VenueMapEditor, type VenueMapEditorProps, VenueMapViewer, type VenueMapViewerProps, type Wall, type WallMaterial, type WallNode, findNearestNode, genId, snapPoint, snapToGrid, usePanZoom };
247
+ export { type AreaShape, type DomainConfig, type ElementGroup, type ElementLibrary, type ElementShape, type ElementStatus, type ElementTypeDef, type Floor, type FloorArea, type MapElement, type PaletteGroup, type PanZoomState, type ToolMode, type VenueMap, VenueMapEditor, type VenueMapEditorProps, VenueMapViewer, type VenueMapViewerProps, type Wall, type WallMaterial, type WallNode, findNearestNode, genId, snapPoint, snapToGrid, useLibraryStorage, usePanZoom };
@@ -4,6 +4,33 @@ var react = require('react');
4
4
  var jsxRuntime = require('react/jsx-runtime');
5
5
 
6
6
  // src/components/VenueMapEditor/VenueMapEditor.tsx
7
+ function useLibraryStorage(storageKey) {
8
+ const [libs, setLibs] = react.useState(() => {
9
+ if (!storageKey) return {};
10
+ try {
11
+ const raw = localStorage.getItem(storageKey);
12
+ return raw ? JSON.parse(raw) : {};
13
+ } catch {
14
+ return {};
15
+ }
16
+ });
17
+ const setAndPersist = react.useCallback(
18
+ (newLibs) => {
19
+ setLibs(newLibs);
20
+ if (!storageKey) return;
21
+ try {
22
+ if (Object.keys(newLibs).length === 0) {
23
+ localStorage.removeItem(storageKey);
24
+ } else {
25
+ localStorage.setItem(storageKey, JSON.stringify(newLibs));
26
+ }
27
+ } catch {
28
+ }
29
+ },
30
+ [storageKey]
31
+ );
32
+ return [libs, setAndPersist];
33
+ }
7
34
  function IconCursor({ className }) {
8
35
  return /* @__PURE__ */ jsxRuntime.jsx("svg", { viewBox: "0 0 16 16", className, fill: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M2 1l12 5.5-5.5 1.5L7 13.5 2 1z" }) });
9
36
  }
@@ -130,6 +157,19 @@ function Toolbar({
130
157
  onLoadLibrary,
131
158
  onRemoveLibraryGroup
132
159
  }) {
160
+ const [activeGroupId, setActiveGroupId] = react.useState(
161
+ () => paletteGroups[0]?.id ?? null
162
+ );
163
+ react.useEffect(() => {
164
+ if (paletteGroups.length === 0) {
165
+ setActiveGroupId(null);
166
+ return;
167
+ }
168
+ if (!paletteGroups.find((g) => g.id === activeGroupId)) {
169
+ setActiveGroupId(paletteGroups[0].id);
170
+ }
171
+ }, [paletteGroups, activeGroupId]);
172
+ const activeGroup = paletteGroups.find((g) => g.id === activeGroupId) ?? null;
133
173
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col bg-white border-b border-slate-200 shadow-sm shrink-0", children: [
134
174
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5 px-2 py-1.5", children: [
135
175
  /* @__PURE__ */ jsxRuntime.jsx(ToolButton, { title: "Seleccionar (V)", active: tool === "SELECT", onClick: () => onToolChange("SELECT"), children: /* @__PURE__ */ jsxRuntime.jsx(IconCursor, { className: "w-4 h-4" }) }),
@@ -167,21 +207,34 @@ function Toolbar({
167
207
  /* @__PURE__ */ jsxRuntime.jsx(ToolButton, { title: areaShape === "polygon" ? "Cambiar a rect\xE1ngulo" : "Cambiar a pol\xEDgono", onClick: () => onToggleAreaShape?.(), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-medium", children: areaShape === "polygon" ? "Poly" : "Rect" }) })
168
208
  ] })
169
209
  ] }),
170
- tool === "PLACE" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-stretch gap-0 border-t border-slate-100 bg-slate-50 overflow-x-auto", children: paletteGroups.map((group, gi) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center shrink-0", children: [
171
- gi > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-px self-stretch bg-slate-200 mx-1" }),
172
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5 px-1.5 shrink-0", children: [
173
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[10px] text-slate-400 font-medium whitespace-nowrap select-none", children: group.name }),
174
- !group.isBase && onRemoveLibraryGroup && /* @__PURE__ */ jsxRuntime.jsx(
175
- "button",
176
- {
177
- title: `Eliminar grupo "${group.name}"`,
178
- onClick: () => onRemoveLibraryGroup(group.id),
179
- className: "text-slate-300 hover:text-red-400 leading-none text-xs ml-0.5 transition-colors",
180
- children: "\xD7"
181
- }
182
- )
183
- ] }),
184
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-1 px-1 py-1.5", children: group.types.map((typeDef) => /* @__PURE__ */ jsxRuntime.jsx(
210
+ tool === "PLACE" && paletteGroups.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col border-t border-slate-100", children: [
211
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-end gap-0 overflow-x-auto bg-slate-50 border-b border-slate-200 px-2 pt-1", children: paletteGroups.map((group) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center shrink-0", children: /* @__PURE__ */ jsxRuntime.jsxs(
212
+ "button",
213
+ {
214
+ onClick: () => setActiveGroupId(group.id),
215
+ className: [
216
+ "flex items-center gap-1 px-3 py-1 text-xs font-medium rounded-t border-x border-t transition-colors whitespace-nowrap",
217
+ group.id === activeGroupId ? "bg-white border-slate-200 text-slate-800 -mb-px pb-[5px]" : "bg-slate-50 border-transparent text-slate-400 hover:text-slate-600 hover:bg-slate-100"
218
+ ].join(" "),
219
+ children: [
220
+ group.name || "Sin nombre",
221
+ !group.isBase && onRemoveLibraryGroup && /* @__PURE__ */ jsxRuntime.jsx(
222
+ "span",
223
+ {
224
+ role: "button",
225
+ title: `Eliminar "${group.name}"`,
226
+ onClick: (e) => {
227
+ e.stopPropagation();
228
+ onRemoveLibraryGroup(group.id);
229
+ },
230
+ className: "ml-0.5 text-slate-300 hover:text-red-400 transition-colors leading-none",
231
+ children: "\xD7"
232
+ }
233
+ )
234
+ ]
235
+ }
236
+ ) }, group.id)) }),
237
+ activeGroup && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-1 flex-wrap px-2 py-1.5 bg-white min-h-[36px]", children: activeGroup.types.map((typeDef) => /* @__PURE__ */ jsxRuntime.jsx(
185
238
  TypeChip,
186
239
  {
187
240
  typeDef,
@@ -190,7 +243,7 @@ function Toolbar({
190
243
  },
191
244
  typeDef.id
192
245
  )) })
193
- ] }, group.id)) })
246
+ ] })
194
247
  ] });
195
248
  }
196
249
  var ZOOM_MIN = 0.1;
@@ -1130,6 +1183,7 @@ function ElementNode({
1130
1183
  {
1131
1184
  d: typeDef.svgPath,
1132
1185
  fill: fillColor,
1186
+ fillRule: typeDef.fillRule ?? "nonzero",
1133
1187
  stroke: isSelected ? "#3b82f6" : typeDef.strokeColor,
1134
1188
  strokeWidth: isSelected ? customPath.strokeWidth * 1.5 : customPath.strokeWidth,
1135
1189
  style: { cursor: bodyCursor },
@@ -1977,7 +2031,23 @@ function createDefaultMap() {
1977
2031
  ]
1978
2032
  };
1979
2033
  }
1980
- var EMPTY_DOMAIN_CONFIG = { id: "__empty__", name: "", elementTypes: [] };
2034
+ var DEFAULT_LIBRARY_KEY = "venueMapEditor:libraries";
2035
+ function mergeLibraries(existing, incoming) {
2036
+ const result = { ...existing };
2037
+ for (const [groupId, incomingGroup] of Object.entries(incoming)) {
2038
+ if (result[groupId]) {
2039
+ const existingIds = new Set(result[groupId].objects.map((o) => o.id));
2040
+ const newObjects = incomingGroup.objects.filter((o) => !existingIds.has(o.id));
2041
+ result[groupId] = {
2042
+ ...result[groupId],
2043
+ objects: [...result[groupId].objects, ...newObjects]
2044
+ };
2045
+ } else {
2046
+ result[groupId] = incomingGroup;
2047
+ }
2048
+ }
2049
+ return result;
2050
+ }
1981
2051
  function updateFloor(map, updatedFloor) {
1982
2052
  return {
1983
2053
  ...map,
@@ -2017,7 +2087,9 @@ function polygonToRect(area) {
2017
2087
  };
2018
2088
  }
2019
2089
  function VenueMapEditor({
2020
- domainConfig = EMPTY_DOMAIN_CONFIG,
2090
+ domainConfigs,
2091
+ domainConfig,
2092
+ libraryStorageKey = DEFAULT_LIBRARY_KEY,
2021
2093
  initialMap,
2022
2094
  onChange,
2023
2095
  width = "100%",
@@ -2031,7 +2103,13 @@ function VenueMapEditor({
2031
2103
  onElementClick,
2032
2104
  onElementTypeClick
2033
2105
  }) {
2106
+ const effectiveConfigs = react.useMemo(() => {
2107
+ if (domainConfigs && domainConfigs.length > 0) return domainConfigs;
2108
+ if (domainConfig) return [domainConfig];
2109
+ return [];
2110
+ }, [domainConfigs, domainConfig]);
2034
2111
  const initialMapRef = react.useRef(initialMap ?? createDefaultMap());
2112
+ const [persistedLibs, setPersistedLibs] = useLibraryStorage(libraryStorageKey);
2035
2113
  const { map, canUndo, canRedo, push, replace, undo, redo } = useHistory(
2036
2114
  initialMapRef.current
2037
2115
  );
@@ -2047,31 +2125,40 @@ function VenueMapEditor({
2047
2125
  const resetViewRef = react.useRef(() => void 0);
2048
2126
  const importInputRef = react.useRef(null);
2049
2127
  const libraryInputRef = react.useRef(null);
2128
+ const effectiveLibs = react.useMemo(() => ({
2129
+ ...map.libraries ?? {},
2130
+ ...persistedLibs
2131
+ }), [map.libraries, persistedLibs]);
2050
2132
  const buildTypeDefs = react.useCallback(() => {
2051
- const m = new Map(domainConfig.elementTypes.map((t) => [t.id, t]));
2052
- const libs = map.libraries ?? {};
2053
- for (const group of Object.values(libs)) {
2133
+ const m = /* @__PURE__ */ new Map();
2134
+ for (const cfg of effectiveConfigs) {
2135
+ for (const t of cfg.elementTypes) {
2136
+ if (!m.has(t.id)) m.set(t.id, t);
2137
+ }
2138
+ }
2139
+ for (const group of Object.values(effectiveLibs)) {
2054
2140
  for (const t of group.objects) {
2055
2141
  if (!m.has(t.id)) m.set(t.id, t);
2056
2142
  }
2057
2143
  }
2058
2144
  return m;
2059
- }, [domainConfig, map.libraries]);
2145
+ }, [effectiveConfigs, effectiveLibs]);
2060
2146
  const elementTypeDefs = react.useRef(buildTypeDefs());
2061
2147
  react.useEffect(() => {
2062
2148
  elementTypeDefs.current = buildTypeDefs();
2063
2149
  }, [buildTypeDefs]);
2064
2150
  const paletteGroups = react.useMemo(() => {
2065
2151
  const groups = [];
2066
- if (domainConfig.elementTypes.length > 0) {
2067
- groups.push({ id: domainConfig.id, name: domainConfig.name, types: domainConfig.elementTypes, isBase: true });
2152
+ for (const cfg of effectiveConfigs) {
2153
+ if (cfg.elementTypes.length > 0) {
2154
+ groups.push({ id: cfg.id, name: cfg.name, types: cfg.elementTypes, isBase: true });
2155
+ }
2068
2156
  }
2069
- const libs = map.libraries ?? {};
2070
- for (const [gid, group] of Object.entries(libs)) {
2157
+ for (const [gid, group] of Object.entries(effectiveLibs)) {
2071
2158
  groups.push({ id: gid, name: group.name, types: group.objects, isBase: false });
2072
2159
  }
2073
2160
  return groups;
2074
- }, [domainConfig, map.libraries]);
2161
+ }, [effectiveConfigs, effectiveLibs]);
2075
2162
  react.useEffect(() => {
2076
2163
  if (activePlaceTypeId) return;
2077
2164
  const firstType = paletteGroups[0]?.types[0];
@@ -2211,22 +2298,27 @@ function VenueMapEditor({
2211
2298
  reader.onload = (e) => {
2212
2299
  try {
2213
2300
  const parsed = JSON.parse(e.target?.result);
2214
- const merged = { ...map.libraries ?? {}, ...parsed };
2215
- push({ ...map, libraries: merged });
2301
+ const mergedPersisted = mergeLibraries(persistedLibs, parsed);
2302
+ setPersistedLibs(mergedPersisted);
2303
+ const mergedMap = mergeLibraries(map.libraries ?? {}, parsed);
2304
+ push({ ...map, libraries: mergedMap });
2216
2305
  } catch {
2217
2306
  }
2218
2307
  };
2219
2308
  reader.readAsText(file);
2220
2309
  },
2221
- [map, push]
2310
+ [map, push, persistedLibs, setPersistedLibs]
2222
2311
  );
2223
2312
  const handleRemoveLibraryGroup = react.useCallback(
2224
2313
  (groupId) => {
2314
+ const newPersistedLibs = { ...persistedLibs };
2315
+ delete newPersistedLibs[groupId];
2316
+ setPersistedLibs(newPersistedLibs);
2225
2317
  const libs = { ...map.libraries ?? {} };
2226
2318
  delete libs[groupId];
2227
2319
  push({ ...map, libraries: Object.keys(libs).length > 0 ? libs : void 0 });
2228
2320
  },
2229
- [map, push]
2321
+ [map, push, persistedLibs, setPersistedLibs]
2230
2322
  );
2231
2323
  const DEFAULT_WALL_THICKNESS = 8;
2232
2324
  const handleAddWall = react.useCallback(
@@ -2679,6 +2771,7 @@ exports.findNearestNode = findNearestNode;
2679
2771
  exports.genId = genId;
2680
2772
  exports.snapPoint = snapPoint;
2681
2773
  exports.snapToGrid = snapToGrid;
2774
+ exports.useLibraryStorage = useLibraryStorage;
2682
2775
  exports.usePanZoom = usePanZoom;
2683
2776
  //# sourceMappingURL=index.js.map
2684
2777
  //# sourceMappingURL=index.js.map