neogestify-ui-components 2.1.0 → 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 (29) hide show
  1. package/README.md +46 -1
  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 +18 -2
  9. package/dist/components/VenueMapEditor/index.d.ts +18 -2
  10. package/dist/components/VenueMapEditor/index.js +75 -3
  11. package/dist/components/VenueMapEditor/index.js.map +1 -1
  12. package/dist/components/VenueMapEditor/index.mjs +75 -4
  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 +468 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +467 -4
  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/components/ElementNode.tsx +22 -0
  24. package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +17 -4
  25. package/src/components/VenueMapEditor/components/Toolbar.tsx +14 -4
  26. package/src/components/VenueMapEditor/index.ts +2 -0
  27. package/src/components/VenueMapEditor/types.ts +11 -1
  28. package/src/components/VenueMapEditor/utils/svgParser.ts +33 -0
  29. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neogestify-ui-components",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
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",
@@ -0,0 +1,400 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { ElementShape, ElementTypeDef, ElementLibrary } from '../VenueMapEditor/types';
3
+ import { Button, Input, Select, TextArea } from '../html';
4
+
5
+ type InternalGroup = {
6
+ internalId: string;
7
+ name: string;
8
+ objects: ElementTypeDef[];
9
+ };
10
+
11
+ const DEFAULT_ELEMENT: ElementTypeDef = {
12
+ id: '',
13
+ label: '',
14
+ shape: 'rect',
15
+ defaultWidth: 100,
16
+ defaultHeight: 100,
17
+ color: '#cccccc',
18
+ strokeColor: '#000000',
19
+ };
20
+
21
+ const SHAPE_OPTIONS = [
22
+ { value: 'rect', label: 'Rectangle' },
23
+ { value: 'circle', label: 'Circle' },
24
+ { value: 'arrow', label: 'Arrow' },
25
+ { value: 'path', label: 'Path' },
26
+ { value: 'svg', label: 'SVG Markup' },
27
+ ];
28
+
29
+ export const ElementLibraryBuilder: React.FC = () => {
30
+ const [groups, setGroups] = useState<InternalGroup[]>([
31
+ { internalId: crypto.randomUUID(), name: 'defaultGroup', objects: [] }
32
+ ]);
33
+ const [activeGroupId, setActiveGroupId] = useState<string>(groups[0].internalId);
34
+ const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
35
+
36
+ const [activeElementIndex, setActiveElementIndex] = useState<number | null>(null);
37
+ const [currentElement, setCurrentElement] = useState<ElementTypeDef>({ ...DEFAULT_ELEMENT, id: 'rect_1', label: 'New Rect' });
38
+ const [downloadFileName, setDownloadFileName] = useState<string>("libraries");
39
+
40
+ // ─── Group management ────────────────────────────────────────────────────────
41
+
42
+ const handleAddGroup = () => {
43
+ const newGroupId = crypto.randomUUID();
44
+ const newName = `group_${groups.length + 1}`;
45
+ setGroups([...groups, { internalId: newGroupId, name: newName, objects: [] }]);
46
+ setActiveGroupId(newGroupId);
47
+ setActiveElementIndex(null);
48
+ };
49
+
50
+ const handleRemoveGroup = (id: string) => {
51
+ const newGroups = groups.filter((g) => g.internalId !== id);
52
+ setGroups(newGroups);
53
+ if (activeGroupId === id) {
54
+ if (newGroups.length > 0) {
55
+ setActiveGroupId(newGroups[0].internalId);
56
+ } else {
57
+ setActiveGroupId('');
58
+ }
59
+ setActiveElementIndex(null);
60
+ }
61
+ };
62
+
63
+ const activeGroup = groups.find((g) => g.internalId === activeGroupId);
64
+
65
+ // ─── Element management ──────────────────────────────────────────────────────
66
+
67
+ const handleSelectGroup = (gId: string) => {
68
+ setActiveGroupId(gId);
69
+ setActiveElementIndex(null);
70
+ }
71
+
72
+ const handleAddElement = () => {
73
+ if (!activeGroup) return;
74
+
75
+ const newEl = { ...DEFAULT_ELEMENT, id: `shape_${activeGroup.objects.length + 1}`, label: `Shape ${activeGroup.objects.length + 1}` };
76
+ const updatedGroups = groups.map((g) => {
77
+ if (g.internalId === activeGroupId) {
78
+ return { ...g, objects: [...g.objects, newEl] };
79
+ }
80
+ return g;
81
+ });
82
+ setGroups(updatedGroups);
83
+ setActiveElementIndex(activeGroup.objects.length);
84
+ setCurrentElement(newEl);
85
+ };
86
+
87
+ const handleSelectElement = (idx: number) => {
88
+ if (!activeGroup) return;
89
+ setActiveElementIndex(idx);
90
+ setCurrentElement(activeGroup.objects[idx]);
91
+ };
92
+
93
+ const handleRemoveElement = (idx: number) => {
94
+ if (!activeGroup) return;
95
+ const updatedGroups = groups.map((g) => {
96
+ if (g.internalId === activeGroupId) {
97
+ const newObjs = [...g.objects];
98
+ newObjs.splice(idx, 1);
99
+ return { ...g, objects: newObjs };
100
+ }
101
+ return g;
102
+ });
103
+ setGroups(updatedGroups);
104
+ if (activeElementIndex === idx) {
105
+ setActiveElementIndex(null);
106
+ } else if (activeElementIndex !== null && activeElementIndex > idx) {
107
+ setActiveElementIndex(activeElementIndex - 1);
108
+ }
109
+ };
110
+
111
+ const handleSaveElement = () => {
112
+ if (!activeGroup || activeElementIndex === null) return;
113
+ const updatedGroups = groups.map((g) => {
114
+ if (g.internalId === activeGroupId) {
115
+ const newObjs = [...g.objects];
116
+ newObjs[activeElementIndex] = { ...currentElement };
117
+ return { ...g, objects: newObjs };
118
+ }
119
+ return g;
120
+ });
121
+ setGroups(updatedGroups);
122
+ };
123
+
124
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
125
+
126
+ const handleFieldChange = (field: keyof ElementTypeDef, value: any) => {
127
+ setCurrentElement((prev) => ({ ...prev, [field]: value }));
128
+ };
129
+
130
+ const handleSvgMarkupChange = (value: string) => {
131
+ // No manual sanitization needed — JSON.stringify() will automatically escape
132
+ // double quotes and special characters when serializing the output object.
133
+ // Modifying the string here (e.g. collapsing whitespace or replacing quotes)
134
+ // would corrupt the SVG structure (paths, attributes, colors, etc.).
135
+ handleFieldChange('svgMarkup', value);
136
+ };
137
+
138
+ const generatedLib = useMemo(() => {
139
+ const lib: ElementLibrary = {};
140
+ groups.forEach((g) => {
141
+ lib[g.name] = {
142
+ name: g.name,
143
+ objects: g.objects,
144
+ };
145
+ });
146
+ return JSON.stringify(lib, null, 2);
147
+ }, [groups]);
148
+
149
+ const handleDownload = () => {
150
+ const blob = new Blob([generatedLib], { type: 'application/json' });
151
+ const url = URL.createObjectURL(blob);
152
+ const a = document.createElement('a');
153
+ a.href = url;
154
+ a.download = `${downloadFileName}.json`;
155
+ document.body.appendChild(a);
156
+ a.click();
157
+ document.body.removeChild(a);
158
+ URL.revokeObjectURL(url);
159
+ };
160
+
161
+ // ─── Render ──────────────────────────────────────────────────────────────────
162
+
163
+ return (
164
+ <div className="flex gap-4 p-4 h-full min-h-[600px] text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-900">
165
+ {/* Sidebar columns for Groups and Elements */}
166
+ <div className="w-1/4 flex flex-col gap-4 border-r dark:border-gray-700 pr-4">
167
+ <div className="flex flex-col gap-2">
168
+ <div className="flex items-center justify-between">
169
+ <h3 className="font-bold">Libraries (Groups)</h3>
170
+ <Button variant='primary' onClick={handleAddGroup}>+ Group</Button>
171
+ </div>
172
+ <div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1">
173
+ {groups.map((group) => (
174
+ <div
175
+ key={group.internalId}
176
+ className={`flex items-center justify-between p-2 rounded cursor-pointer ${activeGroupId === group.internalId ? 'bg-indigo-100 text-indigo-900 dark:bg-indigo-900/50 dark:text-indigo-100 font-semibold' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}`}
177
+ onClick={() => handleSelectGroup(group.internalId)}
178
+ >
179
+ {editingGroupId === group.internalId ? (
180
+ <Input
181
+ autoFocus
182
+ value={group.name}
183
+ onChange={(e) => {
184
+ setGroups(groups.map(g => g.internalId === group.internalId ? { ...g, name: e.target.value } : g));
185
+ }}
186
+ onBlur={() => setEditingGroupId(null)}
187
+ onKeyDown={(e) => e.key === 'Enter' && setEditingGroupId(null)}
188
+ />
189
+ ) : (
190
+ <span onDoubleClick={() => setEditingGroupId(group.internalId)}>{group.name}</span>
191
+ )}
192
+
193
+ {groups.length > 1 && (
194
+ <button onClick={(e) => { e.stopPropagation(); handleRemoveGroup(group.internalId); }} className="text-red-500 text-xs">x</button>
195
+ )}
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </div>
200
+
201
+ <hr className="dark:border-gray-700" />
202
+
203
+ <div className="flex flex-col gap-2 flex-grow overflow-hidden">
204
+ <div className="flex items-center justify-between">
205
+ <h3 className="font-bold">Elements in {activeGroup?.name || '?'}</h3>
206
+ <Button variant='secondary' onClick={handleAddElement} disabled={!activeGroup}>+ Element</Button>
207
+ </div>
208
+ <div className="flex flex-col gap-1 overflow-y-auto flex-grow pr-1">
209
+ {activeGroup?.objects.map((el, i) => (
210
+ <div
211
+ key={i}
212
+ className={`flex items-center justify-between p-2 rounded cursor-pointer ${activeElementIndex === i ? 'bg-indigo-100 text-indigo-900 dark:bg-indigo-900/50 dark:text-indigo-100 font-semibold' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}`}
213
+ onClick={() => handleSelectElement(i)}
214
+ >
215
+ <span>{el.id} ({el.shape})</span>
216
+ <button onClick={(e) => { e.stopPropagation(); handleRemoveElement(i); }} className="text-red-500 text-xs">x</button>
217
+ </div>
218
+ ))}
219
+ {(!activeGroup || activeGroup.objects.length === 0) && (
220
+ <span className="text-gray-400 dark:text-gray-500 italic text-xs">No elements yet</span>
221
+ )}
222
+ </div>
223
+ </div>
224
+ </div>
225
+
226
+ {/* Editor Section */}
227
+ <div className="flex-1 flex flex-col gap-4 px-2 overflow-y-auto">
228
+ <h3 className="font-bold text-lg">Element Editor</h3>
229
+ {activeElementIndex !== null ? (
230
+ <div className="flex flex-col gap-4 w-full max-w-2xl">
231
+ <div className="grid grid-cols-2 gap-4">
232
+ <Input
233
+ label="Element ID (unique)"
234
+ value={currentElement.id}
235
+ onChange={(e) => handleFieldChange('id', e.target.value)}
236
+ />
237
+ <Input
238
+ label="Label (display name)"
239
+ value={currentElement.label}
240
+ onChange={(e) => handleFieldChange('label', e.target.value)}
241
+ />
242
+ </div>
243
+
244
+ <div className="grid grid-cols-2 gap-4">
245
+ <Select
246
+ label="Shape"
247
+ options={SHAPE_OPTIONS}
248
+ value={currentElement.shape}
249
+ onChange={(e) => handleFieldChange('shape', e.target.value as ElementShape)}
250
+ />
251
+ <Input
252
+ label="Icon (emoji or class)"
253
+ value={currentElement.icon || ''}
254
+ onChange={(e) => handleFieldChange('icon', e.target.value)}
255
+ />
256
+ </div>
257
+
258
+ <div className="grid grid-cols-2 gap-4">
259
+ <Input
260
+ type="number"
261
+ label="Default Width"
262
+ value={currentElement.defaultWidth}
263
+ onChange={(e) => handleFieldChange('defaultWidth', parseFloat(e.target.value) || 0)}
264
+ />
265
+ <Input
266
+ type="number"
267
+ label="Default Height"
268
+ value={currentElement.defaultHeight}
269
+ onChange={(e) => handleFieldChange('defaultHeight', parseFloat(e.target.value) || 0)}
270
+ />
271
+ </div>
272
+
273
+ <div className="grid grid-cols-2 gap-4">
274
+ <div className="flex flex-col gap-1">
275
+ <label className="text-xs font-semibold text-gray-700">Fill Color</label>
276
+ <div className="flex gap-2">
277
+ <input
278
+ type="color"
279
+ className="w-8 h-8 cursor-pointer rounded"
280
+ value={currentElement.color}
281
+ onChange={(e) => handleFieldChange('color', e.target.value)}
282
+ />
283
+ <Input
284
+ value={currentElement.color}
285
+ onChange={(e) => handleFieldChange('color', e.target.value)}
286
+ />
287
+ </div>
288
+ </div>
289
+ <div className="flex flex-col gap-1">
290
+ <label className="text-xs font-semibold text-gray-700">Stroke Color</label>
291
+ <div className="flex gap-2">
292
+ <input
293
+ type="color"
294
+ className="w-8 h-8 cursor-pointer rounded"
295
+ value={currentElement.strokeColor}
296
+ onChange={(e) => handleFieldChange('strokeColor', e.target.value)}
297
+ />
298
+ <Input
299
+ value={currentElement.strokeColor}
300
+ onChange={(e) => handleFieldChange('strokeColor', e.target.value)}
301
+ />
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ {currentElement.shape === 'path' && (
307
+ <div className="flex flex-col gap-4 border dark:border-gray-700 p-4 rounded bg-gray-50 dark:bg-gray-800/50">
308
+ <h4 className="font-semibold text-sm">Path Config</h4>
309
+ <div className="grid grid-cols-2 gap-4">
310
+ <Input
311
+ label="ViewBox"
312
+ placeholder="0 0 100 100"
313
+ value={currentElement.viewBox || ''}
314
+ onChange={(e) => handleFieldChange('viewBox', e.target.value)}
315
+ />
316
+ <Select
317
+ label="Fill Rule"
318
+ options={[
319
+ { value: 'nonzero', label: 'nonzero' },
320
+ { value: 'evenodd', label: 'evenodd' }
321
+ ]}
322
+ value={currentElement.fillRule || 'nonzero'}
323
+ onChange={(e) => handleFieldChange('fillRule', e.target.value)}
324
+ />
325
+ </div>
326
+ <TextArea
327
+ label="SVG Path (d attribute)"
328
+ placeholder="M10 10 H 90 V 90 H 10 Z"
329
+ value={currentElement.svgPath || ''}
330
+ onChange={(e) => handleFieldChange('svgPath', e.target.value)}
331
+ rows={4}
332
+ />
333
+ </div>
334
+ )}
335
+
336
+ {currentElement.shape === 'svg' && (
337
+ <div className="flex flex-col gap-4 border dark:border-amber-700/50 p-4 rounded bg-amber-50 dark:bg-amber-900/10">
338
+ <h4 className="font-semibold text-sm">SVG Markup (Autosanitized)</h4>
339
+ <p className="text-xs text-amber-800 dark:text-amber-400">
340
+ Paste your raw SVG here. Double quotes will be converted to single quotes automatically to safely embed the string in JSON.
341
+ </p>
342
+ <TextArea
343
+ label="raw <svg>...</svg>"
344
+ value={currentElement.svgMarkup || ''}
345
+ onChange={(e) => handleSvgMarkupChange(e.target.value)}
346
+ rows={6}
347
+ placeholder={"<svg viewBox='0 0 100 100'><circle cx='50' cy='50' r='50'/></svg>"}
348
+ />
349
+ </div>
350
+ )}
351
+
352
+ <div className="flex justify-end gap-2 mt-4 pt-4 border-t dark:border-gray-700">
353
+ <Button onClick={handleSaveElement}>Save Changes to Element</Button>
354
+ </div>
355
+ </div>
356
+ ) : (
357
+ <div className="flex items-center justify-center h-full text-gray-400">
358
+ Select an element to edit or add a new one.
359
+ </div>
360
+ )}
361
+ </div>
362
+
363
+ {/* output section */}
364
+ <div className="w-1/3 flex flex-col gap-2 border-l dark:border-gray-700 pl-4 h-full max-h-full">
365
+ <div className="flex items-center justify-between shrink-0">
366
+ <h3 className="font-bold">Output JSON</h3>
367
+ <div className="flex items-center gap-2">
368
+ <Input
369
+ value={downloadFileName}
370
+ onChange={(e) => setDownloadFileName(e.target.value)}
371
+ placeholder="filename"
372
+ title="Filename without extension"
373
+ />
374
+ <span className="text-xs text-gray-500">.json</span>
375
+ <Button
376
+ variant="secondary"
377
+ onClick={handleDownload}
378
+ title="Download JSON file"
379
+ >
380
+ Descargar
381
+ </Button>
382
+ <Button
383
+ variant="secondary"
384
+ onClick={() => navigator.clipboard.writeText(generatedLib)}
385
+ >
386
+ Copy
387
+ </Button>
388
+ </div>
389
+ </div>
390
+ <div className="flex-1 overflow-hidden h-full pb-4">
391
+ <TextArea
392
+ readOnly
393
+ className="h-full resize-none font-mono text-xs text-green-600 dark:text-green-400 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-800"
394
+ value={generatedLib}
395
+ />
396
+ </div>
397
+ </div>
398
+ </div>
399
+ );
400
+ };
@@ -0,0 +1 @@
1
+ export * from './builder';
@@ -4,6 +4,7 @@ import type { MapElement, ElementTypeDef, ToolMode } from '../types';
4
4
  import type { PanZoomState } from '../hooks/usePanZoom';
5
5
  import { useDrag } from '../hooks/useDrag';
6
6
  import { snapToGrid } from '../utils/snapUtils';
7
+ import { parseSvgMarkup } from '../utils/svgParser';
7
8
 
8
9
  // ─── Arrow shape ──────────────────────────────────────────────────────────────
9
10
 
@@ -307,6 +308,27 @@ export function ElementNode({
307
308
  />
308
309
  </g>
309
310
  )}
311
+ {typeDef.shape === 'svg' && typeDef.svgMarkup && (() => {
312
+ const parsed = parseSvgMarkup(typeDef.svgMarkup);
313
+ const parts = parsed.viewBox.split(/[\s,]+/).map(Number);
314
+ const vw = parts[2] ?? 100;
315
+ const vh = parts[3] ?? 100;
316
+ const sx = vw > 0 ? w / vw : 1;
317
+ const sy = vh > 0 ? h / vh : 1;
318
+ const avgScale = Math.sqrt(Math.abs(sx * sy)) || 1;
319
+ return (
320
+ <g
321
+ transform={`translate(${x}, ${y}) scale(${sx}, ${sy})`}
322
+ fill={fillColor}
323
+ stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
324
+ strokeWidth={isSelected ? (sw / avgScale) * 1.5 : sw / avgScale}
325
+ style={{ cursor: bodyCursor }}
326
+ onMouseDown={tool === 'SELECT' && !onViewerClick ? handleBodyDown : undefined}
327
+ onClick={handleBodyClick}
328
+ dangerouslySetInnerHTML={{ __html: parsed.innerHtml }}
329
+ />
330
+ );
331
+ })()}
310
332
 
311
333
  {/* ── Label ── */}
312
334
  {(element.label ?? typeDef.label) && (
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from 'react';
2
2
  import type { ChangeEvent } from 'react';
3
3
  import type { MapElement, ElementTypeDef } from '../types';
4
+ import { parseSvgMarkup } from '../utils/svgParser';
4
5
 
5
6
  interface PropertiesPanelProps {
6
7
  elements: MapElement[];
@@ -112,11 +113,23 @@ export function PropertiesPanel({
112
113
  {/* Type badge */}
113
114
  {typeDef && (
114
115
  <div className="flex items-center gap-2">
115
- <span
116
- className="w-3.5 h-3.5 rounded-sm shrink-0 border"
117
- style={{ background: typeDef.color, borderColor: typeDef.strokeColor }}
118
- />
116
+ {typeDef.shape === 'svg' ? (
117
+ <svg
118
+ viewBox={(() => { try { return typeDef.svgMarkup ? parseSvgMarkup(typeDef.svgMarkup).viewBox : '0 0 100 100'; } catch { return '0 0 100 100'; } })()}
119
+ className="w-3.5 h-3.5 shrink-0 border border-slate-300 rounded-sm"
120
+ style={{ color: typeDef.strokeColor }}
121
+ dangerouslySetInnerHTML={{ __html: (() => { try { return typeDef.svgMarkup ? parseSvgMarkup(typeDef.svgMarkup).innerHtml : ''; } catch { return ''; } })() }}
122
+ />
123
+ ) : (
124
+ <span
125
+ className="w-3.5 h-3.5 rounded-sm shrink-0 border"
126
+ style={{ background: typeDef.color, borderColor: typeDef.strokeColor }}
127
+ />
128
+ )}
119
129
  <span className="text-xs font-medium text-slate-700 truncate">{typeDef.label}</span>
130
+ {typeDef.shape === 'svg' && (
131
+ <span className="text-[9px] uppercase tracking-wide text-slate-400 font-medium ml-auto">SVG</span>
132
+ )}
120
133
  </div>
121
134
  )}
122
135
 
@@ -6,6 +6,7 @@ import {
6
6
  IconDownload, IconUpload, IconLayers,
7
7
  } from '../../icons';
8
8
  import type { ToolMode, ElementTypeDef, AreaShape } from '../types';
9
+ import { parseSvgMarkup } from '../utils/svgParser';
9
10
 
10
11
  // ─── ToolButton ───────────────────────────────────────────────────────────────
11
12
 
@@ -94,10 +95,19 @@ function TypeChip({ typeDef, active, onClick }: TypeChipProps) {
94
95
  : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50',
95
96
  ].join(' ')}
96
97
  >
97
- <span
98
- className="w-2.5 h-2.5 rounded-sm shrink-0"
99
- style={{ background: typeDef.color, border: `1px solid ${typeDef.strokeColor}` }}
100
- />
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
+ )}
101
111
  {typeDef.label}
102
112
  </button>
103
113
  );
@@ -33,3 +33,5 @@ export { useLibraryStorage } from './hooks/useLibraryStorage';
33
33
  // Utils (for advanced consumers)
34
34
  export { genId } from './utils/idGen';
35
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 ───────────────────────────────────────────────────────────────
@@ -104,6 +104,16 @@ export interface ElementTypeDef {
104
104
  * Defaults to `'nonzero'`.
105
105
  */
106
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;
107
117
  }
108
118
 
109
119
  export interface DomainConfig {
@@ -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';