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/README.md +144 -14
- package/dist/components/VenueMapEditor/index.d.mts +49 -4
- package/dist/components/VenueMapEditor/index.d.ts +49 -4
- package/dist/components/VenueMapEditor/index.js +124 -31
- package/dist/components/VenueMapEditor/index.js.map +1 -1
- package/dist/components/VenueMapEditor/index.mjs +125 -33
- package/dist/components/VenueMapEditor/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +124 -31
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/VenueMapEditor/VenueMapEditor.tsx +79 -20
- package/src/components/VenueMapEditor/components/ElementNode.tsx +1 -0
- package/src/components/VenueMapEditor/components/Toolbar.tsx +59 -35
- package/src/components/VenueMapEditor/hooks/useLibraryStorage.ts +46 -0
- package/src/components/VenueMapEditor/index.ts +1 -0
- package/src/components/VenueMapEditor/types.ts +34 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neogestify-ui-components",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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(
|
|
184
|
-
const
|
|
185
|
-
|
|
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);
|
|
235
|
+
if (!m.has(t.id)) m.set(t.id, t);
|
|
188
236
|
}
|
|
189
237
|
}
|
|
190
238
|
return m;
|
|
191
|
-
}, [
|
|
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:
|
|
246
|
+
// ── Palette groups: all domain configs + all library groups ───────────────
|
|
199
247
|
const paletteGroups = useMemo<PaletteGroup[]>(() => {
|
|
200
248
|
const groups: PaletteGroup[] = [];
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
for (const [gid, group] of Object.entries(
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
216
|
-
{
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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
|