@kustomizer/visual-editor
v0.2.1
Published
Angular visual page builder for Shopify storefronts — drag & drop editor with component registry, NgRx state, SSR support, and iframe preview
Maintainers
Readme
@kustomizer/visual-editor
Angular visual page builder for Shopify storefronts. Provides a drag & drop editor engine with component registry, NgRx state management, SSR support, and iframe-based live preview.
Note: This is a paid package. A valid Kustomizer subscription is required. See LICENSE for terms.
Links: Integration Guide | Starter Template | Public Storefront Reference
Installation
npm install @kustomizer/visual-editor @ngrx/store @ngrx/effectsConfiguración Inicial
1. Configurar providers en app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import {
provideVisualEditorStore,
provideEditorComponents,
} from 'visual-editor';
// Importar definiciones de componentes
import { heroSectionDefinition } from './editor-components/hero-section.definition';
import { textBlockDefinition } from './editor-components/text-block.definition';
export const appConfig: ApplicationConfig = {
providers: [
// NgRx Store (requerido)
provideStore(),
provideStoreDevtools({ maxAge: 25 }),
// Visual Editor Store
provideVisualEditorStore(),
// Registrar componentes del editor
provideEditorComponents([
heroSectionDefinition,
textBlockDefinition,
]),
],
};Crear Componentes para el Editor
Paso 1: Crear el componente Angular
// hero-section.component.ts
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import { SlotRendererComponent, EditorElement } from 'visual-editor';
@Component({
selector: 'app-hero-section',
imports: [SlotRendererComponent],
template: `
<section
class="hero"
[style.backgroundColor]="backgroundColor()"
[style.color]="textColor()"
[style.minHeight.vh]="height()"
>
<div class="hero-content">
<h1>{{ title() }}</h1>
@if (subtitle()) {
<p>{{ subtitle() }}</p>
}
@if (ctaText()) {
<a [href]="ctaUrl()" class="cta-button">
{{ ctaText() }}
</a>
}
<!-- Renderizar children en el slot -->
@if (_children()?.length) {
<div class="hero-extra">
<lib-slot-renderer
[slot]="contentSlot"
[children]="_children()!"
[parentElementId]="_elementId()"
/>
</div>
}
</div>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeroSectionComponent {
// Props editables (vienen del editor)
readonly title = input('Welcome');
readonly subtitle = input('');
readonly backgroundColor = input('#1a1a2e');
readonly textColor = input('#ffffff');
readonly height = input(80);
readonly ctaText = input('');
readonly ctaUrl = input('/');
// Props especiales del editor (opcionales)
readonly _elementId = input('');
readonly _children = input<EditorElement[] | undefined>(undefined);
readonly _context = input<Record<string, unknown>>({});
// Definición del slot para el renderer
readonly contentSlot = {
name: 'content',
label: 'Additional Content',
constraints: { maxItems: 3 },
};
}Paso 2: Crear la definición del componente
// hero-section.definition.ts
import { ComponentDefinition } from 'visual-editor';
import { HeroSectionComponent } from './hero-section.component';
export const heroSectionDefinition: ComponentDefinition = {
// Identificador único (debe coincidir con EditorSection.type)
type: 'hero-section',
// Metadata para el panel de componentes
name: 'Hero Section',
description: 'Banner principal con título, subtítulo y CTA',
category: 'layout',
icon: 'panorama',
tags: ['hero', 'banner', 'header'],
order: 1,
// Referencia al componente Angular
component: HeroSectionComponent,
// Indica que es una sección (contenedor principal)
isSection: true,
// Permisos
draggable: true,
deletable: true,
duplicable: true,
// Schema de propiedades editables
props: {
title: {
type: 'string',
label: 'Título',
description: 'Texto principal del hero',
defaultValue: 'Welcome',
placeholder: 'Ingresa el título...',
validation: {
required: true,
maxLength: 100,
},
group: 'Contenido',
order: 1,
},
subtitle: {
type: 'string',
label: 'Subtítulo',
defaultValue: '',
placeholder: 'Texto secundario...',
validation: { maxLength: 200 },
group: 'Contenido',
order: 2,
},
backgroundColor: {
type: 'color',
label: 'Color de fondo',
defaultValue: '#1a1a2e',
group: 'Apariencia',
order: 1,
},
textColor: {
type: 'color',
label: 'Color de texto',
defaultValue: '#ffffff',
group: 'Apariencia',
order: 2,
},
height: {
type: 'range',
label: 'Altura (vh)',
defaultValue: 80,
validation: { min: 30, max: 100 },
step: 5,
group: 'Layout',
order: 1,
},
ctaText: {
type: 'string',
label: 'Texto del botón',
defaultValue: '',
placeholder: 'Shop Now',
group: 'CTA',
order: 1,
},
ctaUrl: {
type: 'url',
label: 'URL del botón',
defaultValue: '/',
placeholder: 'https://...',
group: 'CTA',
order: 2,
},
},
// Slots para componentes hijos
slots: [
{
name: 'content',
label: 'Contenido adicional',
description: 'Elementos extra debajo del CTA',
constraints: {
allowedTypes: ['text-block', 'button', 'image'],
maxItems: 3,
},
emptyPlaceholder: 'Arrastra elementos aquí',
},
],
};Usar el Editor
VisualEditorFacade
El facade es el punto de entrada principal para interactuar con el editor:
import { Component, inject } from '@angular/core';
import {
VisualEditorFacade,
DynamicRendererComponent,
} from 'visual-editor';
@Component({
selector: 'app-editor',
imports: [DynamicRendererComponent],
template: `
<div class="editor-layout">
<!-- Panel de componentes -->
<aside class="components-panel">
<h3>Secciones</h3>
@for (def of facade.availableSections(); track def.type) {
<button (click)="facade.addSection(def.type)">
{{ def.name }}
</button>
}
<h3>Elementos</h3>
@for (def of facade.availableElements(); track def.type) {
<button (click)="addElementToSelected(def.type)">
{{ def.name }}
</button>
}
</aside>
<!-- Canvas del editor -->
<main class="editor-canvas">
@for (section of facade.sections(); track section.id) {
<div
class="section-wrapper"
[class.selected]="facade.selectedSection()?.id === section.id"
(click)="facade.selectElement(section.id, null)"
>
<lib-dynamic-renderer
[element]="section"
[context]="{ isEditor: true }"
/>
</div>
}
</main>
<!-- Panel de propiedades -->
<aside class="properties-panel">
@if (facade.selectedSectionDefinition(); as def) {
<h3>{{ def.name }}</h3>
<!-- Renderizar inputs según def.props -->
}
</aside>
</div>
<!-- Controles de historial -->
<div class="toolbar">
<button [disabled]="!facade.canUndo()" (click)="facade.undo()">
Undo
</button>
<button [disabled]="!facade.canRedo()" (click)="facade.redo()">
Redo
</button>
</div>
`,
})
export class EditorComponent {
readonly facade = inject(VisualEditorFacade);
addElementToSelected(type: string): void {
const section = this.facade.selectedSection();
if (section) {
this.facade.addElement(section.id, type);
}
}
}Métodos del Facade
// === Crear ===
facade.addSection('hero-section'); // Agrega sección con props por defecto
facade.addSection('hero-section', 0); // Agrega en índice específico
facade.addElement(sectionId, 'text-block'); // Agrega elemento a sección
facade.addElement(sectionId, 'text-block', 2); // Agrega en índice específico
// === Eliminar ===
facade.removeSection(sectionId);
facade.removeElement(sectionId, elementId);
// === Mover ===
facade.moveSection(sectionId, newIndex);
facade.moveElement(sourceSectionId, targetSectionId, elementId, newIndex);
// === Selección ===
facade.selectElement(sectionId, elementId);
facade.selectElement(sectionId, null); // Selecciona solo la sección
facade.clearSelection();
// === Actualizar props ===
facade.updateSectionProps(sectionId, { title: 'New Title' });
facade.updateElementProps(sectionId, elementId, { text: 'Updated' });
// === Historial ===
facade.undo();
facade.redo();
// === Cargar/Reset ===
facade.loadSections(sectionsArray); // Carga estado inicial
facade.resetEditor(); // Limpia todo
// === Consultas ===
facade.getDefinition('hero-section'); // Obtiene ComponentDefinition
facade.searchComponents('hero'); // Busca por nombre/tagsSignals Disponibles
facade.sections() // EditorSection[]
facade.selectedSection() // EditorSection | null
facade.selectedElement() // EditorElement | null
facade.selectedSectionDefinition() // ComponentDefinition | null
facade.selectedElementDefinition() // ComponentDefinition | null
facade.availableSections() // ComponentDefinition[]
facade.availableElements() // ComponentDefinition[]
facade.canUndo() // boolean
facade.canRedo() // boolean
facade.isDragging() // booleanAPI Reference
ComponentDefinition
interface ComponentDefinition {
type: string; // Identificador único
name: string; // Nombre visible
description?: string; // Descripción
category: ComponentCategory; // 'layout' | 'content' | 'media' | 'form' | 'navigation' | 'commerce' | 'custom'
icon?: string; // Nombre de icono o URL
component: Type<unknown>; // Componente Angular
props: PropSchemaMap; // Schema de propiedades
slots?: SlotDefinition[]; // Slots para children
isSection?: boolean; // true = contenedor principal
draggable?: boolean; // Permite reordenar
deletable?: boolean; // Permite eliminar
duplicable?: boolean; // Permite duplicar
thumbnail?: string; // URL de preview
tags?: string[]; // Tags para búsqueda
order?: number; // Orden en lista (menor = primero)
}PropSchema
interface PropSchema<T = unknown> {
type: PropType; // Tipo de la propiedad
label: string; // Label en el panel
description?: string; // Tooltip/ayuda
defaultValue: T; // Valor por defecto
placeholder?: string; // Placeholder para inputs
validation?: PropValidation; // Reglas de validación
condition?: PropCondition; // Mostrar/ocultar condicionalmente
group?: string; // Agrupar en el panel
order?: number; // Orden dentro del grupo
options?: SelectOption[]; // Para type: 'select'
step?: number; // Para type: 'range'
accept?: string[]; // Para type: 'image' (mime types)
}
type PropType =
| 'string' // Input de texto
| 'number' // Input numérico
| 'boolean' // Checkbox/toggle
| 'color' // Color picker
| 'image' // Selector de imagen
| 'url' // Input de URL
| 'richtext' // Editor de texto rico
| 'select' // Dropdown
| 'range' // Slider
| 'json'; // Editor JSONSlotDefinition
interface SlotDefinition {
name: string; // ID único del slot
label: string; // Label visible
description?: string; // Descripción
constraints?: SlotConstraints; // Restricciones
emptyPlaceholder?: string; // Texto cuando está vacío
droppable?: boolean; // Permite drop (default: true)
}
interface SlotConstraints {
allowedTypes?: string[]; // Tipos permitidos
disallowedTypes?: string[]; // Tipos prohibidos
minItems?: number; // Mínimo de elementos
maxItems?: number; // Máximo de elementos
}ComponentRegistryService
Acceso directo al registro (el Facade lo usa internamente):
import { ComponentRegistryService } from 'visual-editor';
@Component({...})
export class MyComponent {
private registry = inject(ComponentRegistryService);
ngOnInit() {
// Obtener definición
const def = this.registry.get('hero-section');
// Obtener componente Angular
const component = this.registry.getComponent('hero-section');
// Obtener schema de props
const propsSchema = this.registry.getPropsSchema('hero-section');
// Obtener slots
const slots = this.registry.getSlots('hero-section');
// Generar props por defecto
const defaults = this.registry.getDefaultProps('hero-section');
// Validar props
const errors = this.registry.validateProps('hero-section', {
title: '', // Faltaría si es required
});
// Buscar componentes
const results = this.registry.search('hero');
// Filtrar por categoría
const layouts = this.registry.getByCategory('layout');
// Obtener todos
const all = this.registry.getAll();
const sections = this.registry.getSections();
const elements = this.registry.getElements();
}
}Renderizado Dinámico
DynamicRendererComponent
Renderiza cualquier EditorElement o EditorSection basándose en su type:
import { DynamicRendererComponent } from 'visual-editor';
@Component({
imports: [DynamicRendererComponent],
template: `
@for (section of sections; track section.id) {
<lib-dynamic-renderer
[element]="section"
[context]="{ mode: 'preview' }"
/>
}
`,
})
export class PreviewComponent {
sections: EditorSection[] = [];
}SlotRendererComponent
Renderiza children dentro de un slot específico:
import { SlotRendererComponent, EditorElement } from 'visual-editor';
@Component({
imports: [SlotRendererComponent],
template: `
<div class="my-component">
<h1>{{ title() }}</h1>
<!-- Slot para contenido -->
<div class="content-area">
<lib-slot-renderer
[slot]="contentSlot"
[children]="_children() ?? []"
[parentElementId]="_elementId()"
/>
</div>
</div>
`,
})
export class MyComponent {
readonly title = input('');
readonly _elementId = input('');
readonly _children = input<EditorElement[]>();
readonly contentSlot = {
name: 'content',
label: 'Content',
constraints: { allowedTypes: ['text', 'image'] },
emptyPlaceholder: 'Drop content here',
};
}Estado del Store (NgRx)
Estructura del State
interface VisualEditorState {
sections: EditorSection[]; // Secciones del editor
selectedElementId: string | null; // ID del elemento seleccionado
selectedSectionId: string | null; // ID de la sección seleccionada
isDragging: boolean; // Estado de drag
draggedElementId: string | null; // ID del elemento siendo arrastrado
history: HistoryEntry[]; // Historial para undo/redo
historyIndex: number; // Posición actual en el historial
maxHistorySize: number; // Máximo de entradas (default: 50)
}
interface EditorSection {
id: string;
type: string; // Referencia a ComponentDefinition.type
props: Record<string, unknown>;
elements: EditorElement[];
}
interface EditorElement {
id: string;
type: string; // Referencia a ComponentDefinition.type
props: Record<string, unknown>;
children?: EditorElement[]; // Para slots anidados
}Acciones Disponibles
import { VisualEditorActions } from 'visual-editor';
// Secciones
VisualEditorActions.addSection({ section, index? })
VisualEditorActions.removeSection({ sectionId })
VisualEditorActions.moveSection({ sectionId, newIndex })
VisualEditorActions.updateSection({ sectionId, changes })
VisualEditorActions.updateSectionProps({ sectionId, props })
// Elementos
VisualEditorActions.addElement({ sectionId, element, index? })
VisualEditorActions.removeElement({ sectionId, elementId })
VisualEditorActions.moveElement({ sourceSectionId, targetSectionId, elementId, newIndex })
VisualEditorActions.updateElement({ sectionId, elementId, changes })
VisualEditorActions.updateElementProps({ sectionId, elementId, props })
// Selección
VisualEditorActions.selectElement({ sectionId, elementId })
VisualEditorActions.clearSelection()
// Drag & Drop
VisualEditorActions.startDrag({ elementId })
VisualEditorActions.endDrag()
// Historial
VisualEditorActions.undo()
VisualEditorActions.redo()
VisualEditorActions.clearHistory()
// Bulk
VisualEditorActions.loadSections({ sections })
VisualEditorActions.resetEditor()Selectores
import {
selectSections,
selectSelectedElement,
selectSelectedSection,
selectSelectedElementType,
selectSelectedSectionType,
selectCanUndo,
selectCanRedo,
selectIsDragging,
selectHistory,
selectSectionById,
selectElementById,
} from 'visual-editor';Build
ng build visual-editorBuild artifacts are generated in dist/visual-editor.
Publishing
ng build visual-editor
cd dist/visual-editor
npm publish