npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/effects

Configuració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/tags

Signals 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()                  // boolean

API 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 JSON

SlotDefinition

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-editor

Build artifacts are generated in dist/visual-editor.

Publishing

ng build visual-editor
cd dist/visual-editor
npm publish