neogestify-ui-components 2.0.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neogestify-ui-components",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
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",
@@ -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 ──────────────────────────────────────────────────────
@@ -298,6 +298,7 @@ export function ElementNode({
298
298
  <path
299
299
  d={typeDef.svgPath}
300
300
  fill={fillColor}
301
+ fillRule={typeDef.fillRule ?? 'nonzero'}
301
302
  stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
302
303
  strokeWidth={isSelected ? customPath.strokeWidth * 1.5 : customPath.strokeWidth}
303
304
  style={{ cursor: bodyCursor }}
@@ -1,3 +1,4 @@
1
+ import { useState, useEffect } from 'react';
1
2
  import type { ReactNode } from 'react';
2
3
  import {
3
4
  IconCursor, IconGrid, IconHand, IconReset, IconZoomIn, IconZoomOut,
@@ -127,6 +128,21 @@ export function Toolbar({
127
128
  onLoadLibrary,
128
129
  onRemoveLibraryGroup,
129
130
  }: ToolbarProps) {
131
+ // Active palette tab — track by group ID
132
+ const [activeGroupId, setActiveGroupId] = useState<string | null>(
133
+ () => paletteGroups[0]?.id ?? null,
134
+ );
135
+
136
+ // When groups change (library imported / removed), make sure active tab is still valid
137
+ useEffect(() => {
138
+ if (paletteGroups.length === 0) { setActiveGroupId(null); return; }
139
+ if (!paletteGroups.find(g => g.id === activeGroupId)) {
140
+ setActiveGroupId(paletteGroups[0].id);
141
+ }
142
+ }, [paletteGroups, activeGroupId]);
143
+
144
+ const activeGroup = paletteGroups.find(g => g.id === activeGroupId) ?? null;
145
+
130
146
  return (
131
147
  <div className="flex flex-col bg-white border-b border-slate-200 shadow-sm shrink-0">
132
148
  {/* ── Main row ── */}
@@ -210,43 +226,51 @@ export function Toolbar({
210
226
  )}
211
227
  </div>
212
228
 
213
- {/* ── Element palette (only when PLACE is active) ── */}
214
- {tool === 'PLACE' && (
215
- <div className="flex items-stretch gap-0 border-t border-slate-100 bg-slate-50 overflow-x-auto">
216
- {paletteGroups.map((group, gi) => (
217
- <div key={group.id} className="flex items-center shrink-0">
218
- {/* Group divider (not before the first group) */}
219
- {gi > 0 && (
220
- <div className="w-px self-stretch bg-slate-200 mx-1" />
221
- )}
222
- {/* Group label + optional remove button */}
223
- <div className="flex items-center gap-0.5 px-1.5 shrink-0">
224
- <span className="text-[10px] text-slate-400 font-medium whitespace-nowrap select-none">
225
- {group.name}
226
- </span>
227
- {!group.isBase && onRemoveLibraryGroup && (
228
- <button
229
- title={`Eliminar grupo "${group.name}"`}
230
- onClick={() => onRemoveLibraryGroup(group.id)}
231
- className="text-slate-300 hover:text-red-400 leading-none text-xs ml-0.5 transition-colors"
232
- >
233
- ×
234
- </button>
235
- )}
236
- </div>
237
- {/* Chips */}
238
- <div className="flex items-center gap-1 px-1 py-1.5">
239
- {group.types.map(typeDef => (
240
- <TypeChip
241
- key={typeDef.id}
242
- typeDef={typeDef}
243
- active={activePlaceTypeId === typeDef.id}
244
- onClick={() => onActivePlaceTypeChange(typeDef.id)}
245
- />
246
- ))}
229
+ {/* ── Element palette (only when PLACE is active and there are groups) ── */}
230
+ {tool === 'PLACE' && paletteGroups.length > 0 && (
231
+ <div className="flex flex-col border-t border-slate-100">
232
+ {/* Tab bar — one tab per group */}
233
+ <div className="flex items-end gap-0 overflow-x-auto bg-slate-50 border-b border-slate-200 px-2 pt-1">
234
+ {paletteGroups.map(group => (
235
+ <div key={group.id} className="flex items-center shrink-0">
236
+ <button
237
+ onClick={() => setActiveGroupId(group.id)}
238
+ className={[
239
+ 'flex items-center gap-1 px-3 py-1 text-xs font-medium rounded-t border-x border-t transition-colors whitespace-nowrap',
240
+ group.id === activeGroupId
241
+ ? 'bg-white border-slate-200 text-slate-800 -mb-px pb-[5px]'
242
+ : 'bg-slate-50 border-transparent text-slate-400 hover:text-slate-600 hover:bg-slate-100',
243
+ ].join(' ')}
244
+ >
245
+ {group.name || 'Sin nombre'}
246
+ {!group.isBase && onRemoveLibraryGroup && (
247
+ <span
248
+ role="button"
249
+ title={`Eliminar "${group.name}"`}
250
+ onClick={e => { e.stopPropagation(); onRemoveLibraryGroup(group.id); }}
251
+ className="ml-0.5 text-slate-300 hover:text-red-400 transition-colors leading-none"
252
+ >
253
+ ×
254
+ </span>
255
+ )}
256
+ </button>
247
257
  </div>
258
+ ))}
259
+ </div>
260
+
261
+ {/* Active group chips */}
262
+ {activeGroup && (
263
+ <div className="flex items-center gap-1 flex-wrap px-2 py-1.5 bg-white min-h-[36px]">
264
+ {activeGroup.types.map(typeDef => (
265
+ <TypeChip
266
+ key={typeDef.id}
267
+ typeDef={typeDef}
268
+ active={activePlaceTypeId === typeDef.id}
269
+ onClick={() => onActivePlaceTypeChange(typeDef.id)}
270
+ />
271
+ ))}
248
272
  </div>
249
- ))}
273
+ )}
250
274
  </div>
251
275
  )}
252
276
  </div>
@@ -0,0 +1,46 @@
1
+ import { useState, useCallback } from 'react';
2
+ import type { ElementLibrary } from '../types';
3
+
4
+ /**
5
+ * Persists an `ElementLibrary` in `localStorage` and exposes it as React state.
6
+ *
7
+ * - Initialises **synchronously** from localStorage so type definitions are
8
+ * available before the map renders (no flash of unknown element types).
9
+ * - Gracefully degrades when localStorage is unavailable (SSR, private mode,
10
+ * storage quota exceeded) — falls back to plain in-memory state.
11
+ *
12
+ * @param storageKey localStorage key to read/write.
13
+ * Pass `''` or `undefined` to disable persistence entirely.
14
+ */
15
+ export function useLibraryStorage(
16
+ storageKey: string | undefined,
17
+ ): [ElementLibrary, (libs: ElementLibrary) => void] {
18
+ const [libs, setLibs] = useState<ElementLibrary>(() => {
19
+ if (!storageKey) return {};
20
+ try {
21
+ const raw = localStorage.getItem(storageKey);
22
+ return raw ? (JSON.parse(raw) as ElementLibrary) : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ });
27
+
28
+ const setAndPersist = useCallback(
29
+ (newLibs: ElementLibrary) => {
30
+ setLibs(newLibs);
31
+ if (!storageKey) return;
32
+ try {
33
+ if (Object.keys(newLibs).length === 0) {
34
+ localStorage.removeItem(storageKey);
35
+ } else {
36
+ localStorage.setItem(storageKey, JSON.stringify(newLibs));
37
+ }
38
+ } catch {
39
+ // localStorage unavailable — state update still works
40
+ }
41
+ },
42
+ [storageKey],
43
+ );
44
+
45
+ return [libs, setAndPersist];
46
+ }
@@ -28,6 +28,7 @@ export type { PaletteGroup } from './components/Toolbar';
28
28
  // Hooks (for advanced consumers)
29
29
  export { usePanZoom } from './hooks/usePanZoom';
30
30
  export type { PanZoomState } from './hooks/usePanZoom';
31
+ export { useLibraryStorage } from './hooks/useLibraryStorage';
31
32
 
32
33
  // Utils (for advanced consumers)
33
34
  export { genId } from './utils/idGen';
@@ -97,6 +97,13 @@ export interface ElementTypeDef {
97
97
  * Defaults to `"0 0 100 100"` when omitted.
98
98
  */
99
99
  viewBox?: string;
100
+ /**
101
+ * SVG fill rule for `shape === 'path'`.
102
+ * Use `'evenodd'` when the path contains sub-paths that should appear as holes
103
+ * (e.g. a gear with a circular cutout, a donut, a letter with counter-forms).
104
+ * Defaults to `'nonzero'`.
105
+ */
106
+ fillRule?: 'nonzero' | 'evenodd';
100
107
  }
101
108
 
102
109
  export interface DomainConfig {
@@ -127,10 +134,35 @@ export interface ElementStatus {
127
134
 
128
135
  export interface VenueMapEditorProps {
129
136
  /**
130
- * Optional built-in element type catalog.
131
- * If omitted the palette is empty until the user imports a library JSON.
137
+ * One or more built-in element type catalogs shown as separate palette groups.
138
+ * Each `DomainConfig` becomes its own named section in the element palette.
139
+ * Takes precedence over the legacy `domainConfig` singular prop.
140
+ *
141
+ * @example
142
+ * ```tsx
143
+ * <VenueMapEditor
144
+ * domainConfigs={[furnitureConfig, lightingConfig, audioConfig]}
145
+ * />
146
+ * ```
147
+ */
148
+ domainConfigs?: DomainConfig[];
149
+ /**
150
+ * @deprecated Use `domainConfigs` (array) instead.
151
+ * Single built-in element type catalog. Ignored when `domainConfigs` is provided.
132
152
  */
133
153
  domainConfig?: DomainConfig;
154
+ /**
155
+ * localStorage key used to persist user-imported libraries across sessions.
156
+ * Libraries are loaded **synchronously** on mount so all type definitions are
157
+ * available before the map renders — preventing "unknown element type" errors.
158
+ *
159
+ * Set to `''` to disable persistence (libraries are lost on page reload).
160
+ * Defaults to `'venueMapEditor:libraries'`.
161
+ *
162
+ * Multiple editor instances on the same page should use different keys if
163
+ * they manage independent library sets.
164
+ */
165
+ libraryStorageKey?: string;
134
166
  /**
135
167
  * Map to render. When this prop changes (by reference) from outside the
136
168
  * component, the editor resets its history to the new map — allowing the