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
@@ -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,
@@ -5,6 +6,7 @@ import {
5
6
  IconDownload, IconUpload, IconLayers,
6
7
  } from '../../icons';
7
8
  import type { ToolMode, ElementTypeDef, AreaShape } from '../types';
9
+ import { parseSvgMarkup } from '../utils/svgParser';
8
10
 
9
11
  // ─── ToolButton ───────────────────────────────────────────────────────────────
10
12
 
@@ -93,10 +95,19 @@ function TypeChip({ typeDef, active, onClick }: TypeChipProps) {
93
95
  : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50',
94
96
  ].join(' ')}
95
97
  >
96
- <span
97
- className="w-2.5 h-2.5 rounded-sm shrink-0"
98
- style={{ background: typeDef.color, border: `1px solid ${typeDef.strokeColor}` }}
99
- />
98
+ {typeDef.shape === 'svg' && typeDef.svgMarkup ? (
99
+ <svg
100
+ viewBox={parseSvgMarkup(typeDef.svgMarkup).viewBox}
101
+ className="w-2.5 h-2.5 shrink-0"
102
+ style={{ color: typeDef.strokeColor }}
103
+ dangerouslySetInnerHTML={{ __html: parseSvgMarkup(typeDef.svgMarkup).innerHtml }}
104
+ />
105
+ ) : (
106
+ <span
107
+ className="w-2.5 h-2.5 rounded-sm shrink-0"
108
+ style={{ background: typeDef.color, border: `1px solid ${typeDef.strokeColor}` }}
109
+ />
110
+ )}
100
111
  {typeDef.label}
101
112
  </button>
102
113
  );
@@ -127,6 +138,21 @@ export function Toolbar({
127
138
  onLoadLibrary,
128
139
  onRemoveLibraryGroup,
129
140
  }: ToolbarProps) {
141
+ // Active palette tab — track by group ID
142
+ const [activeGroupId, setActiveGroupId] = useState<string | null>(
143
+ () => paletteGroups[0]?.id ?? null,
144
+ );
145
+
146
+ // When groups change (library imported / removed), make sure active tab is still valid
147
+ useEffect(() => {
148
+ if (paletteGroups.length === 0) { setActiveGroupId(null); return; }
149
+ if (!paletteGroups.find(g => g.id === activeGroupId)) {
150
+ setActiveGroupId(paletteGroups[0].id);
151
+ }
152
+ }, [paletteGroups, activeGroupId]);
153
+
154
+ const activeGroup = paletteGroups.find(g => g.id === activeGroupId) ?? null;
155
+
130
156
  return (
131
157
  <div className="flex flex-col bg-white border-b border-slate-200 shadow-sm shrink-0">
132
158
  {/* ── Main row ── */}
@@ -210,43 +236,51 @@ export function Toolbar({
210
236
  )}
211
237
  </div>
212
238
 
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
- ))}
239
+ {/* ── Element palette (only when PLACE is active and there are groups) ── */}
240
+ {tool === 'PLACE' && paletteGroups.length > 0 && (
241
+ <div className="flex flex-col border-t border-slate-100">
242
+ {/* Tab bar — one tab per group */}
243
+ <div className="flex items-end gap-0 overflow-x-auto bg-slate-50 border-b border-slate-200 px-2 pt-1">
244
+ {paletteGroups.map(group => (
245
+ <div key={group.id} className="flex items-center shrink-0">
246
+ <button
247
+ onClick={() => setActiveGroupId(group.id)}
248
+ className={[
249
+ 'flex items-center gap-1 px-3 py-1 text-xs font-medium rounded-t border-x border-t transition-colors whitespace-nowrap',
250
+ group.id === activeGroupId
251
+ ? 'bg-white border-slate-200 text-slate-800 -mb-px pb-[5px]'
252
+ : 'bg-slate-50 border-transparent text-slate-400 hover:text-slate-600 hover:bg-slate-100',
253
+ ].join(' ')}
254
+ >
255
+ {group.name || 'Sin nombre'}
256
+ {!group.isBase && onRemoveLibraryGroup && (
257
+ <span
258
+ role="button"
259
+ title={`Eliminar "${group.name}"`}
260
+ onClick={e => { e.stopPropagation(); onRemoveLibraryGroup(group.id); }}
261
+ className="ml-0.5 text-slate-300 hover:text-red-400 transition-colors leading-none"
262
+ >
263
+ ×
264
+ </span>
265
+ )}
266
+ </button>
247
267
  </div>
268
+ ))}
269
+ </div>
270
+
271
+ {/* Active group chips */}
272
+ {activeGroup && (
273
+ <div className="flex items-center gap-1 flex-wrap px-2 py-1.5 bg-white min-h-[36px]">
274
+ {activeGroup.types.map(typeDef => (
275
+ <TypeChip
276
+ key={typeDef.id}
277
+ typeDef={typeDef}
278
+ active={activePlaceTypeId === typeDef.id}
279
+ onClick={() => onActivePlaceTypeChange(typeDef.id)}
280
+ />
281
+ ))}
248
282
  </div>
249
- ))}
283
+ )}
250
284
  </div>
251
285
  )}
252
286
  </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,7 +28,10 @@ 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';
34
35
  export { snapToGrid, snapPoint, findNearestNode } from './utils/snapUtils';
36
+ export { parseSvgMarkup } from './utils/svgParser';
37
+ export type { ParsedSvgMarkup } from './utils/svgParser';
@@ -2,7 +2,7 @@
2
2
 
3
3
  export type WallMaterial = 'concrete' | 'brick' | 'glass' | 'drywall' | 'wood';
4
4
  export type AreaShape = 'rect' | 'polygon';
5
- export type ElementShape = 'rect' | 'circle' | 'arrow' | 'path';
5
+ export type ElementShape = 'rect' | 'circle' | 'arrow' | 'path' | 'svg';
6
6
  export type ToolMode = 'SELECT' | 'WALL' | 'PLACE' | 'PAN' | 'ERASE';
7
7
 
8
8
  // ─── Wall graph ───────────────────────────────────────────────────────────────
@@ -97,6 +97,23 @@ 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';
107
+ /**
108
+ * Complete SVG markup for `shape === 'svg'`.
109
+ * Must be a valid `<svg>` element with a `viewBox` attribute.
110
+ * The inner content is extracted and rendered inside the element's bounding box,
111
+ * automatically scaled from the viewBox to `width × height`.
112
+ *
113
+ * @example
114
+ * svgMarkup: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="45" fill="none" stroke="currentColor" stroke-width="5"/><path d="M50 10 L90 90 L10 90 Z"/></svg>'
115
+ */
116
+ svgMarkup?: string;
100
117
  }
101
118
 
102
119
  export interface DomainConfig {
@@ -127,10 +144,35 @@ export interface ElementStatus {
127
144
 
128
145
  export interface VenueMapEditorProps {
129
146
  /**
130
- * Optional built-in element type catalog.
131
- * If omitted the palette is empty until the user imports a library JSON.
147
+ * One or more built-in element type catalogs shown as separate palette groups.
148
+ * Each `DomainConfig` becomes its own named section in the element palette.
149
+ * Takes precedence over the legacy `domainConfig` singular prop.
150
+ *
151
+ * @example
152
+ * ```tsx
153
+ * <VenueMapEditor
154
+ * domainConfigs={[furnitureConfig, lightingConfig, audioConfig]}
155
+ * />
156
+ * ```
157
+ */
158
+ domainConfigs?: DomainConfig[];
159
+ /**
160
+ * @deprecated Use `domainConfigs` (array) instead.
161
+ * Single built-in element type catalog. Ignored when `domainConfigs` is provided.
132
162
  */
133
163
  domainConfig?: DomainConfig;
164
+ /**
165
+ * localStorage key used to persist user-imported libraries across sessions.
166
+ * Libraries are loaded **synchronously** on mount so all type definitions are
167
+ * available before the map renders — preventing "unknown element type" errors.
168
+ *
169
+ * Set to `''` to disable persistence (libraries are lost on page reload).
170
+ * Defaults to `'venueMapEditor:libraries'`.
171
+ *
172
+ * Multiple editor instances on the same page should use different keys if
173
+ * they manage independent library sets.
174
+ */
175
+ libraryStorageKey?: string;
134
176
  /**
135
177
  * Map to render. When this prop changes (by reference) from outside the
136
178
  * component, the editor resets its history to the new map — allowing the
@@ -0,0 +1,33 @@
1
+ export interface ParsedSvgMarkup {
2
+ viewBox: string;
3
+ innerHtml: string;
4
+ }
5
+
6
+ const DANGEROUS_TAGS = /\b(script|iframe|object|embed|link|style|meta)\b/gi;
7
+ const DANGEROUS_ATTRS = /\bon\w+\s*=/gi;
8
+ const DANGEROUS_HREF = /\bhref\s*=\s*["']?\s*javascript:/gi;
9
+ const DANGEROUS_XLINK = /\bxlink:href\s*=\s*["']?\s*javascript:/gi;
10
+
11
+ function sanitize(html: string): string {
12
+ return html
13
+ .replace(DANGEROUS_TAGS, '')
14
+ .replace(DANGEROUS_ATTRS, '')
15
+ .replace(DANGEROUS_HREF, '')
16
+ .replace(DANGEROUS_XLINK, '');
17
+ }
18
+
19
+ const VIEWBOX_RE = /viewBox\s*=\s*"([^"]+)"/i;
20
+ const SVG_OPEN_END_RE = /<svg[^>]*>/i;
21
+
22
+ export function parseSvgMarkup(markup: string): ParsedSvgMarkup {
23
+ const viewBoxMatch = markup.match(VIEWBOX_RE);
24
+ const viewBox = viewBoxMatch?.[1] ?? '0 0 100 100';
25
+
26
+ const svgOpenMatch = markup.match(SVG_OPEN_END_RE);
27
+ const afterOpen = svgOpenMatch ? markup.slice(svgOpenMatch.index! + svgOpenMatch[0].length) : markup;
28
+
29
+ const closeIdx = afterOpen.lastIndexOf('</svg>');
30
+ const inner = closeIdx >= 0 ? afterOpen.slice(0, closeIdx) : afterOpen;
31
+
32
+ return { viewBox, innerHtml: sanitize(inner) };
33
+ }
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from './components/icons/index';
4
4
  export * from './components/alerts/index';
5
5
  export * from './context/theme/index';
6
6
  export * from './components/VenueMapEditor/index';
7
+ export * from './components/ElementLibraryBuilder/index';