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.
- package/README.md +46 -1
- 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 +18 -2
- package/dist/components/VenueMapEditor/index.d.ts +18 -2
- package/dist/components/VenueMapEditor/index.js +75 -3
- package/dist/components/VenueMapEditor/index.js.map +1 -1
- package/dist/components/VenueMapEditor/index.mjs +75 -4
- 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 +468 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +467 -4
- 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/components/ElementNode.tsx +22 -0
- package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +17 -4
- package/src/components/VenueMapEditor/components/Toolbar.tsx +14 -4
- package/src/components/VenueMapEditor/index.ts +2 -0
- package/src/components/VenueMapEditor/types.ts +11 -1
- package/src/components/VenueMapEditor/utils/svgParser.ts +33 -0
- 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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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