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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neogestify-ui-components",
|
|
3
|
-
"version": "2.
|
|
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';
|
|
@@ -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 ──────────────────────────────────────────────────────
|
|
@@ -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
|
|
|
@@ -298,6 +299,7 @@ export function ElementNode({
|
|
|
298
299
|
<path
|
|
299
300
|
d={typeDef.svgPath}
|
|
300
301
|
fill={fillColor}
|
|
302
|
+
fillRule={typeDef.fillRule ?? 'nonzero'}
|
|
301
303
|
stroke={isSelected ? '#3b82f6' : typeDef.strokeColor}
|
|
302
304
|
strokeWidth={isSelected ? customPath.strokeWidth * 1.5 : customPath.strokeWidth}
|
|
303
305
|
style={{ cursor: bodyCursor }}
|
|
@@ -306,6 +308,27 @@ export function ElementNode({
|
|
|
306
308
|
/>
|
|
307
309
|
</g>
|
|
308
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
|
+
})()}
|
|
309
332
|
|
|
310
333
|
{/* ── Label ── */}
|
|
311
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
|
|