@kustomizer/visual-editor 0.0.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
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# Visual Editor
|
|
2
|
+
|
|
3
|
+
Librería Angular para crear editores visuales drag & drop estilo Shopify Customizer. Permite registrar componentes personalizados desde la aplicación consumidora con schema de propiedades y soporte para slots/children.
|
|
4
|
+
|
|
5
|
+
## Instalación
|
|
6
|
+
|
|
7
|
+
La librería requiere NgRx como peer dependency:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @ngrx/store @ngrx/effects
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuración Inicial
|
|
14
|
+
|
|
15
|
+
### 1. Configurar providers en app.config.ts
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { ApplicationConfig } from '@angular/core';
|
|
19
|
+
import { provideStore } from '@ngrx/store';
|
|
20
|
+
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
|
21
|
+
import {
|
|
22
|
+
provideVisualEditorStore,
|
|
23
|
+
provideEditorComponents,
|
|
24
|
+
} from '@kustomizer/visual-editor';
|
|
25
|
+
|
|
26
|
+
// Importar definiciones de componentes
|
|
27
|
+
import { heroSectionDefinition } from './editor-components/hero-section.definition';
|
|
28
|
+
import { textBlockDefinition } from './editor-components/text-block.definition';
|
|
29
|
+
|
|
30
|
+
export const appConfig: ApplicationConfig = {
|
|
31
|
+
providers: [
|
|
32
|
+
// NgRx Store (requerido)
|
|
33
|
+
provideStore(),
|
|
34
|
+
provideStoreDevtools({ maxAge: 25 }),
|
|
35
|
+
|
|
36
|
+
// Visual Editor Store
|
|
37
|
+
provideVisualEditorStore(),
|
|
38
|
+
|
|
39
|
+
// Registrar componentes del editor
|
|
40
|
+
provideEditorComponents([
|
|
41
|
+
heroSectionDefinition,
|
|
42
|
+
textBlockDefinition,
|
|
43
|
+
]),
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Crear Componentes para el Editor
|
|
49
|
+
|
|
50
|
+
### Paso 1: Crear el componente Angular
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// hero-section.component.ts
|
|
54
|
+
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
|
|
55
|
+
import { SlotRendererComponent, EditorElement } from '@kustomizer/visual-editor';
|
|
56
|
+
|
|
57
|
+
@Component({
|
|
58
|
+
selector: 'app-hero-section',
|
|
59
|
+
imports: [SlotRendererComponent],
|
|
60
|
+
template: `
|
|
61
|
+
<section
|
|
62
|
+
class="hero"
|
|
63
|
+
[style.backgroundColor]="backgroundColor()"
|
|
64
|
+
[style.color]="textColor()"
|
|
65
|
+
[style.minHeight.vh]="height()"
|
|
66
|
+
>
|
|
67
|
+
<div class="hero-content">
|
|
68
|
+
<h1>{{ title() }}</h1>
|
|
69
|
+
@if (subtitle()) {
|
|
70
|
+
<p>{{ subtitle() }}</p>
|
|
71
|
+
}
|
|
72
|
+
@if (ctaText()) {
|
|
73
|
+
<a [href]="ctaUrl()" class="cta-button">
|
|
74
|
+
{{ ctaText() }}
|
|
75
|
+
</a>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
<!-- Renderizar children en el slot -->
|
|
79
|
+
@if (_children()?.length) {
|
|
80
|
+
<div class="hero-extra">
|
|
81
|
+
<lib-slot-renderer
|
|
82
|
+
[slot]="contentSlot"
|
|
83
|
+
[children]="_children()!"
|
|
84
|
+
[parentElementId]="_elementId()"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
}
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
`,
|
|
91
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
92
|
+
})
|
|
93
|
+
export class HeroSectionComponent {
|
|
94
|
+
// Props editables (vienen del editor)
|
|
95
|
+
readonly title = input('Welcome');
|
|
96
|
+
readonly subtitle = input('');
|
|
97
|
+
readonly backgroundColor = input('#1a1a2e');
|
|
98
|
+
readonly textColor = input('#ffffff');
|
|
99
|
+
readonly height = input(80);
|
|
100
|
+
readonly ctaText = input('');
|
|
101
|
+
readonly ctaUrl = input('/');
|
|
102
|
+
|
|
103
|
+
// Props especiales del editor (opcionales)
|
|
104
|
+
readonly _elementId = input('');
|
|
105
|
+
readonly _children = input<EditorElement[] | undefined>(undefined);
|
|
106
|
+
readonly _context = input<Record<string, unknown>>({});
|
|
107
|
+
|
|
108
|
+
// Definición del slot para el renderer
|
|
109
|
+
readonly contentSlot = {
|
|
110
|
+
name: 'content',
|
|
111
|
+
label: 'Additional Content',
|
|
112
|
+
constraints: { maxItems: 3 },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Paso 2: Crear la definición del componente
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// hero-section.definition.ts
|
|
121
|
+
import { ComponentDefinition } from '@kustomizer/visual-editor';
|
|
122
|
+
import { HeroSectionComponent } from './hero-section.component';
|
|
123
|
+
|
|
124
|
+
export const heroSectionDefinition: ComponentDefinition = {
|
|
125
|
+
// Identificador único (debe coincidir con EditorSection.type)
|
|
126
|
+
type: 'hero-section',
|
|
127
|
+
|
|
128
|
+
// Metadata para el panel de componentes
|
|
129
|
+
name: 'Hero Section',
|
|
130
|
+
description: 'Banner principal con título, subtítulo y CTA',
|
|
131
|
+
category: 'layout',
|
|
132
|
+
icon: 'panorama',
|
|
133
|
+
tags: ['hero', 'banner', 'header'],
|
|
134
|
+
order: 1,
|
|
135
|
+
|
|
136
|
+
// Referencia al componente Angular
|
|
137
|
+
component: HeroSectionComponent,
|
|
138
|
+
|
|
139
|
+
// Indica que es una sección (contenedor principal)
|
|
140
|
+
isSection: true,
|
|
141
|
+
|
|
142
|
+
// Permisos
|
|
143
|
+
draggable: true,
|
|
144
|
+
deletable: true,
|
|
145
|
+
duplicable: true,
|
|
146
|
+
|
|
147
|
+
// Schema de propiedades editables
|
|
148
|
+
props: {
|
|
149
|
+
title: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
label: 'Título',
|
|
152
|
+
description: 'Texto principal del hero',
|
|
153
|
+
defaultValue: 'Welcome',
|
|
154
|
+
placeholder: 'Ingresa el título...',
|
|
155
|
+
validation: {
|
|
156
|
+
required: true,
|
|
157
|
+
maxLength: 100,
|
|
158
|
+
},
|
|
159
|
+
group: 'Contenido',
|
|
160
|
+
order: 1,
|
|
161
|
+
},
|
|
162
|
+
subtitle: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
label: 'Subtítulo',
|
|
165
|
+
defaultValue: '',
|
|
166
|
+
placeholder: 'Texto secundario...',
|
|
167
|
+
validation: { maxLength: 200 },
|
|
168
|
+
group: 'Contenido',
|
|
169
|
+
order: 2,
|
|
170
|
+
},
|
|
171
|
+
backgroundColor: {
|
|
172
|
+
type: 'color',
|
|
173
|
+
label: 'Color de fondo',
|
|
174
|
+
defaultValue: '#1a1a2e',
|
|
175
|
+
group: 'Apariencia',
|
|
176
|
+
order: 1,
|
|
177
|
+
},
|
|
178
|
+
textColor: {
|
|
179
|
+
type: 'color',
|
|
180
|
+
label: 'Color de texto',
|
|
181
|
+
defaultValue: '#ffffff',
|
|
182
|
+
group: 'Apariencia',
|
|
183
|
+
order: 2,
|
|
184
|
+
},
|
|
185
|
+
height: {
|
|
186
|
+
type: 'range',
|
|
187
|
+
label: 'Altura (vh)',
|
|
188
|
+
defaultValue: 80,
|
|
189
|
+
validation: { min: 30, max: 100 },
|
|
190
|
+
step: 5,
|
|
191
|
+
group: 'Layout',
|
|
192
|
+
order: 1,
|
|
193
|
+
},
|
|
194
|
+
ctaText: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
label: 'Texto del botón',
|
|
197
|
+
defaultValue: '',
|
|
198
|
+
placeholder: 'Shop Now',
|
|
199
|
+
group: 'CTA',
|
|
200
|
+
order: 1,
|
|
201
|
+
},
|
|
202
|
+
ctaUrl: {
|
|
203
|
+
type: 'url',
|
|
204
|
+
label: 'URL del botón',
|
|
205
|
+
defaultValue: '/',
|
|
206
|
+
placeholder: 'https://...',
|
|
207
|
+
group: 'CTA',
|
|
208
|
+
order: 2,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
// Slots para componentes hijos
|
|
213
|
+
slots: [
|
|
214
|
+
{
|
|
215
|
+
name: 'content',
|
|
216
|
+
label: 'Contenido adicional',
|
|
217
|
+
description: 'Elementos extra debajo del CTA',
|
|
218
|
+
constraints: {
|
|
219
|
+
allowedTypes: ['text-block', 'button', 'image'],
|
|
220
|
+
maxItems: 3,
|
|
221
|
+
},
|
|
222
|
+
emptyPlaceholder: 'Arrastra elementos aquí',
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Usar el Editor
|
|
229
|
+
|
|
230
|
+
### VisualEditorFacade
|
|
231
|
+
|
|
232
|
+
El facade es el punto de entrada principal para interactuar con el editor:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
import { Component, inject } from '@angular/core';
|
|
236
|
+
import {
|
|
237
|
+
VisualEditorFacade,
|
|
238
|
+
DynamicRendererComponent,
|
|
239
|
+
} from '@kustomizer/visual-editor';
|
|
240
|
+
|
|
241
|
+
@Component({
|
|
242
|
+
selector: 'app-editor',
|
|
243
|
+
imports: [DynamicRendererComponent],
|
|
244
|
+
template: `
|
|
245
|
+
<div class="editor-layout">
|
|
246
|
+
<!-- Panel de componentes -->
|
|
247
|
+
<aside class="components-panel">
|
|
248
|
+
<h3>Secciones</h3>
|
|
249
|
+
@for (def of facade.availableSections(); track def.type) {
|
|
250
|
+
<button (click)="facade.addSection(def.type)">
|
|
251
|
+
{{ def.name }}
|
|
252
|
+
</button>
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
<h3>Elementos</h3>
|
|
256
|
+
@for (def of facade.availableElements(); track def.type) {
|
|
257
|
+
<button (click)="addElementToSelected(def.type)">
|
|
258
|
+
{{ def.name }}
|
|
259
|
+
</button>
|
|
260
|
+
}
|
|
261
|
+
</aside>
|
|
262
|
+
|
|
263
|
+
<!-- Canvas del editor -->
|
|
264
|
+
<main class="editor-canvas">
|
|
265
|
+
@for (section of facade.sections(); track section.id) {
|
|
266
|
+
<div
|
|
267
|
+
class="section-wrapper"
|
|
268
|
+
[class.selected]="facade.selectedSection()?.id === section.id"
|
|
269
|
+
(click)="facade.selectElement(section.id, null)"
|
|
270
|
+
>
|
|
271
|
+
<lib-dynamic-renderer
|
|
272
|
+
[element]="section"
|
|
273
|
+
[context]="{ isEditor: true }"
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
}
|
|
277
|
+
</main>
|
|
278
|
+
|
|
279
|
+
<!-- Panel de propiedades -->
|
|
280
|
+
<aside class="properties-panel">
|
|
281
|
+
@if (facade.selectedSectionDefinition(); as def) {
|
|
282
|
+
<h3>{{ def.name }}</h3>
|
|
283
|
+
<!-- Renderizar inputs según def.props -->
|
|
284
|
+
}
|
|
285
|
+
</aside>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<!-- Controles de historial -->
|
|
289
|
+
<div class="toolbar">
|
|
290
|
+
<button [disabled]="!facade.canUndo()" (click)="facade.undo()">
|
|
291
|
+
Undo
|
|
292
|
+
</button>
|
|
293
|
+
<button [disabled]="!facade.canRedo()" (click)="facade.redo()">
|
|
294
|
+
Redo
|
|
295
|
+
</button>
|
|
296
|
+
</div>
|
|
297
|
+
`,
|
|
298
|
+
})
|
|
299
|
+
export class EditorComponent {
|
|
300
|
+
readonly facade = inject(VisualEditorFacade);
|
|
301
|
+
|
|
302
|
+
addElementToSelected(type: string): void {
|
|
303
|
+
const section = this.facade.selectedSection();
|
|
304
|
+
if (section) {
|
|
305
|
+
this.facade.addElement(section.id, type);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Métodos del Facade
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
// === Crear ===
|
|
315
|
+
facade.addSection('hero-section'); // Agrega sección con props por defecto
|
|
316
|
+
facade.addSection('hero-section', 0); // Agrega en índice específico
|
|
317
|
+
facade.addElement(sectionId, 'text-block'); // Agrega elemento a sección
|
|
318
|
+
facade.addElement(sectionId, 'text-block', 2); // Agrega en índice específico
|
|
319
|
+
|
|
320
|
+
// === Eliminar ===
|
|
321
|
+
facade.removeSection(sectionId);
|
|
322
|
+
facade.removeElement(sectionId, elementId);
|
|
323
|
+
|
|
324
|
+
// === Mover ===
|
|
325
|
+
facade.moveSection(sectionId, newIndex);
|
|
326
|
+
facade.moveElement(sourceSectionId, targetSectionId, elementId, newIndex);
|
|
327
|
+
|
|
328
|
+
// === Selección ===
|
|
329
|
+
facade.selectElement(sectionId, elementId);
|
|
330
|
+
facade.selectElement(sectionId, null); // Selecciona solo la sección
|
|
331
|
+
facade.clearSelection();
|
|
332
|
+
|
|
333
|
+
// === Actualizar props ===
|
|
334
|
+
facade.updateSectionProps(sectionId, { title: 'New Title' });
|
|
335
|
+
facade.updateElementProps(sectionId, elementId, { text: 'Updated' });
|
|
336
|
+
|
|
337
|
+
// === Historial ===
|
|
338
|
+
facade.undo();
|
|
339
|
+
facade.redo();
|
|
340
|
+
|
|
341
|
+
// === Cargar/Reset ===
|
|
342
|
+
facade.loadSections(sectionsArray); // Carga estado inicial
|
|
343
|
+
facade.resetEditor(); // Limpia todo
|
|
344
|
+
|
|
345
|
+
// === Consultas ===
|
|
346
|
+
facade.getDefinition('hero-section'); // Obtiene ComponentDefinition
|
|
347
|
+
facade.searchComponents('hero'); // Busca por nombre/tags
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Signals Disponibles
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
facade.sections() // EditorSection[]
|
|
354
|
+
facade.selectedSection() // EditorSection | null
|
|
355
|
+
facade.selectedElement() // EditorElement | null
|
|
356
|
+
facade.selectedSectionDefinition() // ComponentDefinition | null
|
|
357
|
+
facade.selectedElementDefinition() // ComponentDefinition | null
|
|
358
|
+
facade.availableSections() // ComponentDefinition[]
|
|
359
|
+
facade.availableElements() // ComponentDefinition[]
|
|
360
|
+
facade.canUndo() // boolean
|
|
361
|
+
facade.canRedo() // boolean
|
|
362
|
+
facade.isDragging() // boolean
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## API Reference
|
|
366
|
+
|
|
367
|
+
### ComponentDefinition
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
interface ComponentDefinition {
|
|
371
|
+
type: string; // Identificador único
|
|
372
|
+
name: string; // Nombre visible
|
|
373
|
+
description?: string; // Descripción
|
|
374
|
+
category: ComponentCategory; // 'layout' | 'content' | 'media' | 'form' | 'navigation' | 'commerce' | 'custom'
|
|
375
|
+
icon?: string; // Nombre de icono o URL
|
|
376
|
+
component: Type<unknown>; // Componente Angular
|
|
377
|
+
props: PropSchemaMap; // Schema de propiedades
|
|
378
|
+
slots?: SlotDefinition[]; // Slots para children
|
|
379
|
+
isSection?: boolean; // true = contenedor principal
|
|
380
|
+
draggable?: boolean; // Permite reordenar
|
|
381
|
+
deletable?: boolean; // Permite eliminar
|
|
382
|
+
duplicable?: boolean; // Permite duplicar
|
|
383
|
+
thumbnail?: string; // URL de preview
|
|
384
|
+
tags?: string[]; // Tags para búsqueda
|
|
385
|
+
order?: number; // Orden en lista (menor = primero)
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### PropSchema
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
interface PropSchema<T = unknown> {
|
|
393
|
+
type: PropType; // Tipo de la propiedad
|
|
394
|
+
label: string; // Label en el panel
|
|
395
|
+
description?: string; // Tooltip/ayuda
|
|
396
|
+
defaultValue: T; // Valor por defecto
|
|
397
|
+
placeholder?: string; // Placeholder para inputs
|
|
398
|
+
validation?: PropValidation; // Reglas de validación
|
|
399
|
+
condition?: PropCondition; // Mostrar/ocultar condicionalmente
|
|
400
|
+
group?: string; // Agrupar en el panel
|
|
401
|
+
order?: number; // Orden dentro del grupo
|
|
402
|
+
options?: SelectOption[]; // Para type: 'select'
|
|
403
|
+
step?: number; // Para type: 'range'
|
|
404
|
+
accept?: string[]; // Para type: 'image' (mime types)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
type PropType =
|
|
408
|
+
| 'string' // Input de texto
|
|
409
|
+
| 'number' // Input numérico
|
|
410
|
+
| 'boolean' // Checkbox/toggle
|
|
411
|
+
| 'color' // Color picker
|
|
412
|
+
| 'image' // Selector de imagen
|
|
413
|
+
| 'url' // Input de URL
|
|
414
|
+
| 'richtext' // Editor de texto rico
|
|
415
|
+
| 'select' // Dropdown
|
|
416
|
+
| 'range' // Slider
|
|
417
|
+
| 'json'; // Editor JSON
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### SlotDefinition
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
interface SlotDefinition {
|
|
424
|
+
name: string; // ID único del slot
|
|
425
|
+
label: string; // Label visible
|
|
426
|
+
description?: string; // Descripción
|
|
427
|
+
constraints?: SlotConstraints; // Restricciones
|
|
428
|
+
emptyPlaceholder?: string; // Texto cuando está vacío
|
|
429
|
+
droppable?: boolean; // Permite drop (default: true)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
interface SlotConstraints {
|
|
433
|
+
allowedTypes?: string[]; // Tipos permitidos
|
|
434
|
+
disallowedTypes?: string[]; // Tipos prohibidos
|
|
435
|
+
minItems?: number; // Mínimo de elementos
|
|
436
|
+
maxItems?: number; // Máximo de elementos
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### ComponentRegistryService
|
|
441
|
+
|
|
442
|
+
Acceso directo al registro (el Facade lo usa internamente):
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import { ComponentRegistryService } from '@kustomizer/visual-editor';
|
|
446
|
+
|
|
447
|
+
@Component({...})
|
|
448
|
+
export class MyComponent {
|
|
449
|
+
private registry = inject(ComponentRegistryService);
|
|
450
|
+
|
|
451
|
+
ngOnInit() {
|
|
452
|
+
// Obtener definición
|
|
453
|
+
const def = this.registry.get('hero-section');
|
|
454
|
+
|
|
455
|
+
// Obtener componente Angular
|
|
456
|
+
const component = this.registry.getComponent('hero-section');
|
|
457
|
+
|
|
458
|
+
// Obtener schema de props
|
|
459
|
+
const propsSchema = this.registry.getPropsSchema('hero-section');
|
|
460
|
+
|
|
461
|
+
// Obtener slots
|
|
462
|
+
const slots = this.registry.getSlots('hero-section');
|
|
463
|
+
|
|
464
|
+
// Generar props por defecto
|
|
465
|
+
const defaults = this.registry.getDefaultProps('hero-section');
|
|
466
|
+
|
|
467
|
+
// Validar props
|
|
468
|
+
const errors = this.registry.validateProps('hero-section', {
|
|
469
|
+
title: '', // Faltaría si es required
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Buscar componentes
|
|
473
|
+
const results = this.registry.search('hero');
|
|
474
|
+
|
|
475
|
+
// Filtrar por categoría
|
|
476
|
+
const layouts = this.registry.getByCategory('layout');
|
|
477
|
+
|
|
478
|
+
// Obtener todos
|
|
479
|
+
const all = this.registry.getAll();
|
|
480
|
+
const sections = this.registry.getSections();
|
|
481
|
+
const elements = this.registry.getElements();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## Renderizado Dinámico
|
|
487
|
+
|
|
488
|
+
### DynamicRendererComponent
|
|
489
|
+
|
|
490
|
+
Renderiza cualquier `EditorElement` o `EditorSection` basándose en su `type`:
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import { DynamicRendererComponent } from '@kustomizer/visual-editor';
|
|
494
|
+
|
|
495
|
+
@Component({
|
|
496
|
+
imports: [DynamicRendererComponent],
|
|
497
|
+
template: `
|
|
498
|
+
@for (section of sections; track section.id) {
|
|
499
|
+
<lib-dynamic-renderer
|
|
500
|
+
[element]="section"
|
|
501
|
+
[context]="{ mode: 'preview' }"
|
|
502
|
+
/>
|
|
503
|
+
}
|
|
504
|
+
`,
|
|
505
|
+
})
|
|
506
|
+
export class PreviewComponent {
|
|
507
|
+
sections: EditorSection[] = [];
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### SlotRendererComponent
|
|
512
|
+
|
|
513
|
+
Renderiza children dentro de un slot específico:
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
import { SlotRendererComponent, EditorElement } from '@kustomizer/visual-editor';
|
|
517
|
+
|
|
518
|
+
@Component({
|
|
519
|
+
imports: [SlotRendererComponent],
|
|
520
|
+
template: `
|
|
521
|
+
<div class="my-component">
|
|
522
|
+
<h1>{{ title() }}</h1>
|
|
523
|
+
|
|
524
|
+
<!-- Slot para contenido -->
|
|
525
|
+
<div class="content-area">
|
|
526
|
+
<lib-slot-renderer
|
|
527
|
+
[slot]="contentSlot"
|
|
528
|
+
[children]="_children() ?? []"
|
|
529
|
+
[parentElementId]="_elementId()"
|
|
530
|
+
/>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
`,
|
|
534
|
+
})
|
|
535
|
+
export class MyComponent {
|
|
536
|
+
readonly title = input('');
|
|
537
|
+
readonly _elementId = input('');
|
|
538
|
+
readonly _children = input<EditorElement[]>();
|
|
539
|
+
|
|
540
|
+
readonly contentSlot = {
|
|
541
|
+
name: 'content',
|
|
542
|
+
label: 'Content',
|
|
543
|
+
constraints: { allowedTypes: ['text', 'image'] },
|
|
544
|
+
emptyPlaceholder: 'Drop content here',
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
## Estado del Store (NgRx)
|
|
550
|
+
|
|
551
|
+
### Estructura del State
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
interface VisualEditorState {
|
|
555
|
+
sections: EditorSection[]; // Secciones del editor
|
|
556
|
+
selectedElementId: string | null; // ID del elemento seleccionado
|
|
557
|
+
selectedSectionId: string | null; // ID de la sección seleccionada
|
|
558
|
+
isDragging: boolean; // Estado de drag
|
|
559
|
+
draggedElementId: string | null; // ID del elemento siendo arrastrado
|
|
560
|
+
history: HistoryEntry[]; // Historial para undo/redo
|
|
561
|
+
historyIndex: number; // Posición actual en el historial
|
|
562
|
+
maxHistorySize: number; // Máximo de entradas (default: 50)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
interface EditorSection {
|
|
566
|
+
id: string;
|
|
567
|
+
type: string; // Referencia a ComponentDefinition.type
|
|
568
|
+
props: Record<string, unknown>;
|
|
569
|
+
elements: EditorElement[];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
interface EditorElement {
|
|
573
|
+
id: string;
|
|
574
|
+
type: string; // Referencia a ComponentDefinition.type
|
|
575
|
+
props: Record<string, unknown>;
|
|
576
|
+
children?: EditorElement[]; // Para slots anidados
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Acciones Disponibles
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
import { VisualEditorActions } from '@kustomizer/visual-editor';
|
|
584
|
+
|
|
585
|
+
// Secciones
|
|
586
|
+
VisualEditorActions.addSection({ section, index? })
|
|
587
|
+
VisualEditorActions.removeSection({ sectionId })
|
|
588
|
+
VisualEditorActions.moveSection({ sectionId, newIndex })
|
|
589
|
+
VisualEditorActions.updateSection({ sectionId, changes })
|
|
590
|
+
VisualEditorActions.updateSectionProps({ sectionId, props })
|
|
591
|
+
|
|
592
|
+
// Elementos
|
|
593
|
+
VisualEditorActions.addElement({ sectionId, element, index? })
|
|
594
|
+
VisualEditorActions.removeElement({ sectionId, elementId })
|
|
595
|
+
VisualEditorActions.moveElement({ sourceSectionId, targetSectionId, elementId, newIndex })
|
|
596
|
+
VisualEditorActions.updateElement({ sectionId, elementId, changes })
|
|
597
|
+
VisualEditorActions.updateElementProps({ sectionId, elementId, props })
|
|
598
|
+
|
|
599
|
+
// Selección
|
|
600
|
+
VisualEditorActions.selectElement({ sectionId, elementId })
|
|
601
|
+
VisualEditorActions.clearSelection()
|
|
602
|
+
|
|
603
|
+
// Drag & Drop
|
|
604
|
+
VisualEditorActions.startDrag({ elementId })
|
|
605
|
+
VisualEditorActions.endDrag()
|
|
606
|
+
|
|
607
|
+
// Historial
|
|
608
|
+
VisualEditorActions.undo()
|
|
609
|
+
VisualEditorActions.redo()
|
|
610
|
+
VisualEditorActions.clearHistory()
|
|
611
|
+
|
|
612
|
+
// Bulk
|
|
613
|
+
VisualEditorActions.loadSections({ sections })
|
|
614
|
+
VisualEditorActions.resetEditor()
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Selectores
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
import {
|
|
621
|
+
selectSections,
|
|
622
|
+
selectSelectedElement,
|
|
623
|
+
selectSelectedSection,
|
|
624
|
+
selectSelectedElementType,
|
|
625
|
+
selectSelectedSectionType,
|
|
626
|
+
selectCanUndo,
|
|
627
|
+
selectCanRedo,
|
|
628
|
+
selectIsDragging,
|
|
629
|
+
selectHistory,
|
|
630
|
+
selectSectionById,
|
|
631
|
+
selectElementById,
|
|
632
|
+
} from '@kustomizer/visual-editor';
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
## Build
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
ng build visual-editor
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Los artefactos se generan en `dist/visual-editor`.
|