@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`.