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.
- package/README.md +153 -19
- package/dist/components/ElementLibraryBuilder/index.d.mts +5 -0
- package/dist/components/ElementLibraryBuilder/index.d.ts +5 -0
- package/dist/components/ElementLibraryBuilder/index.js +689 -0
- package/dist/components/ElementLibraryBuilder/index.js.map +1 -0
- package/dist/components/ElementLibraryBuilder/index.mjs +687 -0
- package/dist/components/ElementLibraryBuilder/index.mjs.map +1 -0
- package/dist/components/VenueMapEditor/index.d.mts +66 -5
- package/dist/components/VenueMapEditor/index.d.ts +66 -5
- package/dist/components/VenueMapEditor/index.js +199 -34
- package/dist/components/VenueMapEditor/index.js.map +1 -1
- package/dist/components/VenueMapEditor/index.mjs +199 -36
- package/dist/components/VenueMapEditor/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +592 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +591 -36
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/ElementLibraryBuilder/builder.tsx +400 -0
- package/src/components/ElementLibraryBuilder/index.ts +1 -0
- package/src/components/VenueMapEditor/VenueMapEditor.tsx +79 -20
- package/src/components/VenueMapEditor/components/ElementNode.tsx +23 -0
- package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +17 -4
- package/src/components/VenueMapEditor/components/Toolbar.tsx +73 -39
- package/src/components/VenueMapEditor/hooks/useLibraryStorage.ts +46 -0
- package/src/components/VenueMapEditor/index.ts +3 -0
- package/src/components/VenueMapEditor/types.ts +45 -3
- package/src/components/VenueMapEditor/utils/svgParser.ts +33 -0
- 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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
))}
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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