From f05cf9efbdd84559cb74b33fb5871884b229ac78 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 10:02:32 -0400 Subject: [PATCH 01/18] feat(custom-ui): React-parity provider layer for 2.0.0-beta.2 Add nine components mirroring @grapesjs/react's render-props surface (blocks, layers, selectors, styles, traits, pages, devices, assets, modal), a custom-UI mount point, and the *gjsContainer directive for projecting GrapesJS-owned elements. Editor component gains async CDN plugin loading, editorCreated/projectUpdated outputs, grapesjsCss/waitReady inputs, SSR safety, and a height/width:100% host-fill default. Service adds isInitialized; editorReady now fires on onReady() (breaking vs beta.1). Selectors and Styles providers self-trigger their initial custom event on wire() so their panels render in custom-UI mode, where GrapesJS skips the default panel render that would otherwise fire it. --- projects/grapesjs-angular/package.json | 2 +- .../custom-ui/assets-provider.component.ts | 46 +++ .../custom-ui/blocks-provider.component.ts | 58 ++++ .../src/lib/custom-ui/custom-provider-base.ts | 29 ++ .../src/lib/custom-ui/custom-ui.spec.ts | 309 ++++++++++++++++++ .../custom-ui/devices-provider.component.ts | 46 +++ .../src/lib/custom-ui/gjs-canvas.component.ts | 26 ++ .../lib/custom-ui/gjs-container.directive.ts | 35 ++ .../custom-ui/layers-provider.component.ts | 36 ++ .../lib/custom-ui/modal-provider.component.ts | 45 +++ .../lib/custom-ui/pages-provider.component.ts | 50 +++ .../custom-ui/selectors-provider.component.ts | 54 +++ .../custom-ui/styles-provider.component.ts | 39 +++ .../custom-ui/traits-provider.component.ts | 36 ++ .../src/lib/grapesjs-editor.component.spec.ts | 132 ++++++-- .../src/lib/grapesjs-editor.component.ts | 170 +++++++++- .../src/lib/grapesjs-editor.service.spec.ts | 25 ++ .../src/lib/grapesjs-editor.service.ts | 9 +- .../src/lib/grapesjs-editor.types.ts | 2 + .../grapesjs-angular/src/lib/utils/dom.ts | 35 ++ .../grapesjs-angular/src/lib/utils/plugins.ts | 75 +++++ projects/grapesjs-angular/src/public-api.ts | 19 +- 22 files changed, 1234 insertions(+), 44 deletions(-) create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/assets-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/blocks-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/custom-provider-base.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/custom-ui.spec.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/devices-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/gjs-canvas.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/gjs-container.directive.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/layers-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/modal-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/pages-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/selectors-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/styles-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/custom-ui/traits-provider.component.ts create mode 100644 projects/grapesjs-angular/src/lib/utils/dom.ts create mode 100644 projects/grapesjs-angular/src/lib/utils/plugins.ts diff --git a/projects/grapesjs-angular/package.json b/projects/grapesjs-angular/package.json index 1b7bdb6..de55e97 100644 --- a/projects/grapesjs-angular/package.json +++ b/projects/grapesjs-angular/package.json @@ -1,6 +1,6 @@ { "name": "@ilq/grapesjs-angular", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Angular 20+ wrapper for the GrapesJS web builder framework. Signals-based, standalone, zoneless-compatible.", "keywords": ["grapesjs", "angular", "page-builder", "wysiwyg", "drag-and-drop", "site-builder"], "author": "Internet Liquid LLC", diff --git a/projects/grapesjs-angular/src/lib/custom-ui/assets-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/assets-provider.component.ts new file mode 100644 index 0000000..fd0cf02 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/assets-provider.component.ts @@ -0,0 +1,46 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Asset, AssetsCustomData, Editor } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface AssetsState { + open: boolean; + assets: Asset[]; + types: string[]; + select: (asset: Asset, complete?: boolean) => void; + close: () => void; + container: HTMLElement | undefined; +} + +@Component({ + selector: 'gjs-assets-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsAssetsProvider }], +}) +export class GjsAssetsProvider extends GjsCustomProviderBase { + readonly customFlag = 'assetManager' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: AssetsState }>; + + // Start with a closed default so consumers can render a hidden panel. + readonly state = signal({ + open: false, + assets: [], + types: [], + select: () => undefined, + close: () => undefined, + container: undefined, + }); + + wire(editor: Editor): void { + editor.on(editor.Assets.events.custom, ({ open, assets, types, select, close, container }: AssetsCustomData) => { + this.state.set({ open, assets, types, select, close, container }); + }); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/blocks-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/blocks-provider.component.ts new file mode 100644 index 0000000..81bd799 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/blocks-provider.component.ts @@ -0,0 +1,58 @@ +import { + Component, + ContentChild, + TemplateRef, + signal, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Block, BlocksCustomData, Editor } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export type MapCategoryBlocks = Map; + +export interface BlocksState { + blocks: Block[]; + dragStart: (block: Block, ev?: Event) => void; + dragStop: (cancel?: boolean) => void; + container: HTMLElement | undefined; + mapCategoryBlocks: MapCategoryBlocks; +} + +@Component({ + selector: 'gjs-blocks-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsBlocksProvider }], +}) +export class GjsBlocksProvider extends GjsCustomProviderBase { + readonly customFlag = 'blockManager' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: BlocksState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + editor.on(editor.Blocks.events.custom, (payload: BlocksCustomData) => { + const mapCategoryBlocks: MapCategoryBlocks = new Map(); + for (const block of payload.blocks) { + const label = block.getCategoryLabel(); + const existing = mapCategoryBlocks.get(label); + if (existing) existing.push(block); + else mapCategoryBlocks.set(label, [block]); + } + this.state.set({ + blocks: payload.blocks, + dragStart: payload.dragStart, + dragStop: payload.dragStop, + container: payload.container, + mapCategoryBlocks, + }); + }); + editor.Blocks.__trgCustom(); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/custom-provider-base.ts b/projects/grapesjs-angular/src/lib/custom-ui/custom-provider-base.ts new file mode 100644 index 0000000..c769654 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/custom-provider-base.ts @@ -0,0 +1,29 @@ +import type { Editor } from 'grapesjs'; + +/** + * Names of the GrapesJS init-config sections that support a `custom: true` flag + * for replacing the manager's default UI. `null` is used for providers that only + * observe manager events without disabling the built-in UI (e.g. Pages, Devices). + */ +export type CustomFlag = + | 'blockManager' + | 'layerManager' + | 'selectorManager' + | 'styleManager' + | 'traitManager' + | 'assetManager' + | 'modal'; + +/** + * Abstract base for every `` component. `` discovers + * providers via `@ContentChildren(GjsCustomProviderBase)`, reads each one's + * `customFlag` to build the init config, then calls `wire(editor)` on each after + * initialisation to attach manager-specific event listeners. + * + * Providers register themselves under this token via `useExisting` in their + * component `providers` array. + */ +export abstract class GjsCustomProviderBase { + abstract readonly customFlag: CustomFlag | null; + abstract wire(editor: Editor): void; +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/custom-ui.spec.ts b/projects/grapesjs-angular/src/lib/custom-ui/custom-ui.spec.ts new file mode 100644 index 0000000..9295888 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/custom-ui.spec.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, Type } from '@angular/core'; +import { GrapesJsEditorComponent } from '../grapesjs-editor.component'; +import { GrapesJsEditorService } from '../grapesjs-editor.service'; +import { GjsBlocksProvider } from './blocks-provider.component'; +import { GjsLayersProvider } from './layers-provider.component'; +import { GjsSelectorsProvider } from './selectors-provider.component'; +import { GjsStylesProvider } from './styles-provider.component'; +import { GjsTraitsProvider } from './traits-provider.component'; +import { GjsPagesProvider } from './pages-provider.component'; +import { GjsDevicesProvider } from './devices-provider.component'; +import { GjsAssetsProvider } from './assets-provider.component'; +import { GjsModalProvider } from './modal-provider.component'; +import { GjsCanvas } from './gjs-canvas.component'; + +@Component({ + selector: 'custom-ui-test-host', + standalone: true, + imports: [ + GrapesJsEditorComponent, + GjsCanvas, + GjsBlocksProvider, + GjsLayersProvider, + GjsSelectorsProvider, + GjsStylesProvider, + GjsTraitsProvider, + GjsPagesProvider, + GjsDevicesProvider, + GjsAssetsProvider, + GjsModalProvider, + ], + template: ``, +}) +class CustomUiTestHost {} + +function createMockEditor() { + const listeners = new Map(); + const readyHandlers: Function[] = []; + const managerStub = (customEvent: string, extra: Record = {}) => ({ + events: { custom: customEvent, all: customEvent }, + __trgCustom: vi.fn(), + ...extra, + }); + return { + on: vi.fn((event: string, handler: Function) => { + const arr = listeners.get(event) ?? []; + arr.push(handler); + listeners.set(event, arr); + }), + onReady: vi.fn((handler: Function) => readyHandlers.push(handler)), + destroy: vi.fn(), + getProjectData: vi.fn(() => ({ pages: [] })), + Blocks: managerStub('block:custom'), + Layers: { ...managerStub('layer:custom'), getRoot: vi.fn(() => ({ id: 'root' })) }, + Selectors: { + ...managerStub('selector:custom'), + getSelected: vi.fn(() => []), + getStates: vi.fn(() => []), + getState: vi.fn(() => ''), + getSelectedTargets: vi.fn(() => []), + addSelected: vi.fn(), + removeSelected: vi.fn(), + setState: vi.fn(), + }, + Styles: { ...managerStub('style:custom'), getSectors: vi.fn(() => []) }, + Traits: { ...managerStub('trait:custom'), getCurrent: vi.fn(() => []) }, + Pages: { + events: { all: 'page:all' }, + getAll: vi.fn(() => []), + getSelected: vi.fn(() => undefined), + select: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + }, + Devices: { + events: { all: 'device:all' }, + getDevices: vi.fn(() => []), + getSelected: vi.fn(() => ({ id: 'desktop' })), + select: vi.fn(), + }, + Assets: managerStub('asset:custom'), + _fire: (event: string, ...args: unknown[]) => listeners.get(event)?.forEach(h => h(...args)), + _fireReady: () => readyHandlers.forEach(h => h()), + }; +} + +function createMockService(mockEditor: ReturnType) { + return { + init: vi.fn().mockReturnValue(mockEditor), + destroy: vi.fn(), + }; +} + +async function flushInit(fixture: ComponentFixture) { + fixture.detectChanges(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + fixture.detectChanges(); +} + +async function mountHostWithTemplate( + template: string, + mockService: { init: ReturnType; destroy: ReturnType }, +): Promise> { + await TestBed.configureTestingModule({ + imports: [CustomUiTestHost as Type], + providers: [{ provide: GrapesJsEditorService, useValue: mockService }], + }).compileComponents(); + TestBed.overrideComponent(CustomUiTestHost, { set: { template } }); + const fx = TestBed.createComponent(CustomUiTestHost); + await flushInit(fx); + return fx; +} + +describe('Custom UI — provider discovery and config merging', () => { + let mockEditor: ReturnType; + let mockService: ReturnType; + + beforeEach(async () => { + mockEditor = createMockEditor(); + mockService = createMockService(mockEditor); + await TestBed.resetTestingModule(); + }); + + it('BlocksProvider sets blockManager.custom=true in init config', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].blockManager).toEqual({ custom: true }); + }); + + it('LayersProvider sets layerManager.custom=true', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].layerManager).toEqual({ custom: true }); + }); + + it('SelectorsProvider sets selectorManager.custom=true', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].selectorManager).toEqual({ custom: true }); + }); + + it('StylesProvider sets styleManager.custom=true', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].styleManager).toEqual({ custom: true }); + }); + + it('TraitsProvider sets traitManager.custom=true', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].traitManager).toEqual({ custom: true }); + }); + + it('AssetsProvider sets assetManager.custom=true', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].assetManager).toEqual({ custom: true }); + }); + + it('ModalProvider sets modal.custom=true', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].modal).toEqual({ custom: true }); + }); + + it('PagesProvider does NOT flip a custom flag (observer-only)', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + const cfg = mockService.init.mock.calls[0][0]; + expect(cfg.blockManager).toBeUndefined(); + expect(cfg.layerManager).toBeUndefined(); + }); + + it('DevicesProvider does NOT flip a custom flag (observer-only)', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + expect(mockService.init.mock.calls[0][0].blockManager).toBeUndefined(); + }); + + it('multiple providers merge their flags into one config without interfering', async () => { + await mountHostWithTemplate( + ` + + + + + + `, + mockService, + ); + const cfg = mockService.init.mock.calls[0][0]; + expect(cfg.blockManager).toEqual({ custom: true }); + expect(cfg.layerManager).toEqual({ custom: true }); + expect(cfg.styleManager).toEqual({ custom: true }); + expect(cfg.selectorManager).toBeUndefined(); + }); + + it(' swaps the container, sets customUI=true, and empties default panels', async () => { + await mountHostWithTemplate( + ``, + mockService, + ); + const cfg = mockService.init.mock.calls[0][0]; + expect(cfg.customUI).toBe(true); + expect(cfg.panels).toEqual({ defaults: [] }); + expect((cfg.container as HTMLElement).tagName.toLowerCase()).toBe('gjs-canvas'); + }); +}); + +describe('Custom UI — provider wire() state updates', () => { + it('BlocksProvider.state updates when custom event fires, with category map built', () => { + const provider = new GjsBlocksProvider(); + const editor = createMockEditor(); + provider.wire(editor as any); + const block = (cat: string, id: string) => ({ + getCategoryLabel: () => cat, + getId: () => id, + }) as any; + const blocks = [block('basic', 'a'), block('basic', 'b'), block('typo', 'c')]; + editor._fire('block:custom', { + blocks, + container: document.createElement('div'), + dragStart: () => {}, + dragStop: () => {}, + drag: () => {}, + bm: {} as any, + }); + const state = provider.state(); + expect(state).not.toBeNull(); + expect(state!.blocks).toHaveLength(3); + expect(state!.mapCategoryBlocks.get('basic')).toHaveLength(2); + expect(state!.mapCategoryBlocks.get('typo')).toHaveLength(1); + }); + + it('LayersProvider.state exposes root and container on custom event', () => { + const provider = new GjsLayersProvider(); + const editor = createMockEditor(); + provider.wire(editor as any); + const container = document.createElement('div'); + editor._fire('layer:custom', { container, root: { id: 'root' } as any }); + const state = provider.state(); + expect(state!.container).toBe(container); + expect(state!.root).toEqual({ id: 'root' }); + }); + + it('PagesProvider.state populates immediately and on page:all events', () => { + const provider = new GjsPagesProvider(); + const editor = createMockEditor(); + provider.wire(editor as any); + expect(provider.state()).not.toBeNull(); + expect(provider.state()!.pages).toEqual([]); + editor.Pages.getAll = vi.fn(() => [{ id: 'p1' }]) as any; + editor._fire('page:all'); + expect(provider.state()!.pages).toEqual([{ id: 'p1' }]); + }); + + it('SelectorsProvider.wire() triggers the initial custom event so the panel renders before any selection', () => { + // In custom-UI mode GrapesJS skips its default panel render, so it never + // fires selector:custom on init. The provider must self-trigger or its + // projected never instantiates (panel renders empty). + const provider = new GjsSelectorsProvider(); + const editor = createMockEditor(); + provider.wire(editor as any); + expect(editor.Selectors.__trgCustom).toHaveBeenCalledTimes(1); + }); + + it('StylesProvider.wire() triggers the initial custom event so the panel renders before any selection', () => { + const provider = new GjsStylesProvider(); + const editor = createMockEditor(); + provider.wire(editor as any); + expect(editor.Styles.__trgCustom).toHaveBeenCalledTimes(1); + }); + + it('ModalProvider.state flips open on modal event', () => { + const provider = new GjsModalProvider(); + const editor = createMockEditor(); + provider.wire(editor as any); + expect(provider.state()!.open).toBe(false); + editor._fire('modal', { + open: true, + title: document.createTextNode('Hi'), + content: document.createTextNode('body'), + attributes: {}, + close: () => {}, + }); + expect(provider.state()!.open).toBe(true); + }); +}); diff --git a/projects/grapesjs-angular/src/lib/custom-ui/devices-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/devices-provider.component.ts new file mode 100644 index 0000000..b49d855 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/devices-provider.component.ts @@ -0,0 +1,46 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Device, Editor } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface DevicesState { + devices: Device[]; + selected: string; + select: (deviceId: string) => void; +} + +@Component({ + selector: 'gjs-devices-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsDevicesProvider }], +}) +export class GjsDevicesProvider extends GjsCustomProviderBase { + // Devices doesn't flip a custom flag — it's an observer-only provider. + readonly customFlag = null; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: DevicesState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + const { Devices } = editor; + const event = Devices.events.all; + + const push = () => { + this.state.set({ + devices: Devices.getDevices(), + selected: (Devices.getSelected()?.id as string | undefined) ?? '', + select: (id: string) => Devices.select(id), + }); + }; + + editor.on(event, push); + push(); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/gjs-canvas.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/gjs-canvas.component.ts new file mode 100644 index 0000000..1a93069 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/gjs-canvas.component.ts @@ -0,0 +1,26 @@ +import { Component, ElementRef, inject } from '@angular/core'; + +/** + * Mount-point for GrapesJS's canvas when a consumer is composing a custom UI. + * + * When projected as a content child of ``, the editor uses this + * element as the GrapesJS `container` and enables `customUI: true` with empty + * default panels — letting consumers lay out their own sidebars and toolbars + * around the canvas using Angular templates. + * + * ```html + * + * + * ... + * + * ``` + */ +@Component({ + selector: 'gjs-canvas', + standalone: true, + template: '', + styles: [':host { display: block; width: 100%; height: 100%; }'], +}) +export class GjsCanvas { + readonly elementRef = inject(ElementRef); +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/gjs-container.directive.ts b/projects/grapesjs-angular/src/lib/custom-ui/gjs-container.directive.ts new file mode 100644 index 0000000..4053062 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/gjs-container.directive.ts @@ -0,0 +1,35 @@ +import { Directive, ElementRef, Input, OnChanges, SimpleChanges, inject } from '@angular/core'; + +/** + * Append a GrapesJS-owned `HTMLElement` (e.g. the block-manager container + * surfaced on a provider's state) into the directive's host element. + * + * ```html + * + * + *
+ *
+ *
+ * ``` + * + * Consumers who render blocks themselves don't need this — it's a convenience + * for mounting the default container into a chosen slot without building a + * portal system. + */ +@Directive({ + selector: '[gjsContainer]', + standalone: true, +}) +export class GjsContainerDirective implements OnChanges { + @Input('gjsContainer') container?: HTMLElement | null; + + private host = inject(ElementRef); + + ngOnChanges(changes: SimpleChanges): void { + if (!('container' in changes)) return; + const el = this.host.nativeElement; + // Clear any previously attached container before appending the new one. + while (el.firstChild) el.removeChild(el.firstChild); + if (this.container) el.appendChild(this.container); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/layers-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/layers-provider.component.ts new file mode 100644 index 0000000..6ddbdfe --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/layers-provider.component.ts @@ -0,0 +1,36 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Component as GjsComponent, Editor, LayerCustomEventData } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface LayersState { + root?: GjsComponent; + container: HTMLElement | undefined; +} + +@Component({ + selector: 'gjs-layers-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsLayersProvider }], +}) +export class GjsLayersProvider extends GjsCustomProviderBase { + readonly customFlag = 'layerManager' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: LayersState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + const { Layers } = editor; + editor.on(Layers.events.custom, ({ container }: LayerCustomEventData) => { + this.state.set({ root: Layers.getRoot(), container }); + }); + Layers.__trgCustom({}); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/modal-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/modal-provider.component.ts new file mode 100644 index 0000000..b1c9928 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/modal-provider.component.ts @@ -0,0 +1,45 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Editor, ModalEventData } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface ModalState { + open: boolean; + /** Modal title — a DOM `Node` surfaced by GrapesJS, or the initial empty string. */ + title: Node | string; + /** Modal content — a DOM `Node` surfaced by GrapesJS, or the initial empty string. */ + content: Node | string; + attributes: Record; + close: () => void; +} + +@Component({ + selector: 'gjs-modal-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsModalProvider }], +}) +export class GjsModalProvider extends GjsCustomProviderBase { + readonly customFlag = 'modal' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: ModalState }>; + + readonly state = signal({ + open: false, + title: '', + content: '', + attributes: {}, + close: () => undefined, + }); + + wire(editor: Editor): void { + editor.on('modal', ({ open, title, content, attributes, close }: ModalEventData) => { + this.state.set({ open, title, content, attributes, close }); + }); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/pages-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/pages-provider.component.ts new file mode 100644 index 0000000..62f8427 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/pages-provider.component.ts @@ -0,0 +1,50 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Editor, Page } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface PagesState { + pages: Page[]; + selected?: Page; + select: Editor['Pages']['select']; + add: Editor['Pages']['add']; + remove: Editor['Pages']['remove']; +} + +@Component({ + selector: 'gjs-pages-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsPagesProvider }], +}) +export class GjsPagesProvider extends GjsCustomProviderBase { + // Pages doesn't flip a custom flag — it's an observer-only provider. + readonly customFlag = null; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: PagesState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + const { Pages } = editor; + const event = Pages.events.all; + + const push = () => { + this.state.set({ + pages: Pages.getAll(), + selected: Pages.getSelected(), + select: (...args) => Pages.select(...args), + add: (...args) => Pages.add(...args), + remove: (...args) => Pages.remove(...args), + }); + }; + + editor.on(event, push); + push(); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/selectors-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/selectors-provider.component.ts new file mode 100644 index 0000000..8e81f6a --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/selectors-provider.component.ts @@ -0,0 +1,54 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Editor, Selector, SelectorCustomEventData, State } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface SelectorsState { + selectors: Selector[]; + states: State[]; + selectedState: string; + targets: string[]; + addSelector: Editor['Selectors']['addSelected']; + removeSelector: Editor['Selectors']['removeSelected']; + setState: Editor['Selectors']['setState']; + container: HTMLElement | undefined; +} + +@Component({ + selector: 'gjs-selectors-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsSelectorsProvider }], +}) +export class GjsSelectorsProvider extends GjsCustomProviderBase { + readonly customFlag = 'selectorManager' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: SelectorsState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + const { Selectors } = editor; + editor.on(Selectors.events.custom, ({ container }: SelectorCustomEventData) => { + this.state.set({ + selectors: Selectors.getSelected(), + states: Selectors.getStates(), + selectedState: Selectors.getState(), + targets: Selectors.getSelectedTargets().map((t) => t.getSelectorsString()), + addSelector: (...args) => Selectors.addSelected(...args), + removeSelector: (...args) => Selectors.removeSelected(...args), + setState: (...args) => Selectors.setState(...args), + container, + }); + }); + // Custom-UI mode skips GrapesJS's default panel render, which is where the + // initial selector:custom would normally fire. Trigger it ourselves so the + // projected template renders its empty state before any component is selected. + Selectors.__trgCustom(); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/styles-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/styles-provider.component.ts new file mode 100644 index 0000000..b16a619 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/styles-provider.component.ts @@ -0,0 +1,39 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Editor, Sector, StyleManagerCustomEventData } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface StylesState { + sectors: Sector[]; + container: HTMLElement | undefined; +} + +@Component({ + selector: 'gjs-styles-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsStylesProvider }], +}) +export class GjsStylesProvider extends GjsCustomProviderBase { + readonly customFlag = 'styleManager' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: StylesState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + const { Styles } = editor; + editor.on(Styles.events.custom, ({ container }: StyleManagerCustomEventData) => { + this.state.set({ sectors: Styles.getSectors({ visible: true }), container }); + }); + // Custom-UI mode skips GrapesJS's default panel render, which is where the + // initial style:custom would normally fire. Trigger it ourselves so the + // projected template renders its empty state before any component is selected. + Styles.__trgCustom(); + } +} diff --git a/projects/grapesjs-angular/src/lib/custom-ui/traits-provider.component.ts b/projects/grapesjs-angular/src/lib/custom-ui/traits-provider.component.ts new file mode 100644 index 0000000..3fb390c --- /dev/null +++ b/projects/grapesjs-angular/src/lib/custom-ui/traits-provider.component.ts @@ -0,0 +1,36 @@ +import { Component, ContentChild, TemplateRef, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Editor, Trait, TraitCustomData } from 'grapesjs'; +import { GjsCustomProviderBase } from './custom-provider-base'; + +export interface TraitsState { + traits: Trait[]; + container: HTMLElement | undefined; +} + +@Component({ + selector: 'gjs-traits-provider', + standalone: true, + imports: [NgTemplateOutlet], + template: ` + @if (state(); as ctx) { + + } + `, + providers: [{ provide: GjsCustomProviderBase, useExisting: GjsTraitsProvider }], +}) +export class GjsTraitsProvider extends GjsCustomProviderBase { + readonly customFlag = 'traitManager' as const; + + @ContentChild(TemplateRef) tpl!: TemplateRef<{ $implicit: TraitsState }>; + + readonly state = signal(null); + + wire(editor: Editor): void { + const { Traits } = editor; + editor.on(Traits.events.custom, ({ container }: TraitCustomData) => { + this.state.set({ traits: Traits.getCurrent(), container }); + }); + Traits.__trgCustom(); + } +} diff --git a/projects/grapesjs-angular/src/lib/grapesjs-editor.component.spec.ts b/projects/grapesjs-angular/src/lib/grapesjs-editor.component.spec.ts index 916e720..4d256d5 100644 --- a/projects/grapesjs-angular/src/lib/grapesjs-editor.component.spec.ts +++ b/projects/grapesjs-angular/src/lib/grapesjs-editor.component.spec.ts @@ -1,26 +1,59 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PLATFORM_ID } from '@angular/core'; import { GrapesJsEditorComponent } from './grapesjs-editor.component'; import { GrapesJsEditorService } from './grapesjs-editor.service'; import { GRAPES_JS_DEFAULT_CONFIG } from './grapesjs-editor.tokens'; -function createMockService() { +function createMockEditor() { + const eventHandlers = new Map(); + const readyHandlers: Function[] = []; return { - init: vi.fn().mockReturnValue({ - on: vi.fn(), - destroy: vi.fn(), + on: vi.fn((event: string, handler: Function) => { + eventHandlers.set(event, handler); }), + onReady: vi.fn((handler: Function) => { + readyHandlers.push(handler); + }), + destroy: vi.fn(), + getProjectData: vi.fn(() => ({ pages: [] })), + _fireEvent: (event: string, ...args: unknown[]) => { + const handler = eventHandlers.get(event); + if (handler) handler(...args); + }, + _fireReady: () => readyHandlers.forEach(h => h()), + }; +} + +function createMockService(mockEditor: ReturnType) { + return { + init: vi.fn().mockReturnValue(mockEditor), destroy: vi.fn(), }; } +/** + * The component's ngAfterViewInit is async (awaits initPlugins). Angular fires it + * during detectChanges, but the awaited microtasks resolve after. Flush them. + */ +async function flushInit(fixture: ComponentFixture) { + fixture.detectChanges(); + // Two flushes: one for the loadStyle promise (if used), one for initPlugins. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + fixture.detectChanges(); +} + describe('GrapesJsEditorComponent', () => { let component: GrapesJsEditorComponent; let fixture: ComponentFixture; let mockService: ReturnType; + let mockEditor: ReturnType; beforeEach(async () => { - mockService = createMockService(); + mockEditor = createMockEditor(); + mockService = createMockService(mockEditor); await TestBed.configureTestingModule({ imports: [GrapesJsEditorComponent], @@ -38,38 +71,52 @@ describe('GrapesJsEditorComponent', () => { fixture.detectChanges(); }); - it('calls editorService.init() in ngAfterViewInit', () => { - fixture.detectChanges(); + it('calls editorService.init() in ngAfterViewInit', async () => { + await flushInit(fixture); expect(mockService.init).toHaveBeenCalled(); const config = mockService.init.mock.calls[0][0]; expect(config.container).toBeInstanceOf(HTMLDivElement); }); - it('calls editorService.destroy() in ngOnDestroy', () => { - fixture.detectChanges(); + it('calls editorService.destroy() in ngOnDestroy', async () => { + await flushInit(fixture); component.ngOnDestroy(); expect(mockService.destroy).toHaveBeenCalled(); }); - it('emits editorReady when the GrapesJS load event fires', () => { - const editorReadySpy = vi.fn(); - component.editorReady.subscribe(editorReadySpy); + it('emits editorCreated synchronously with the editor instance', async () => { + const createdSpy = vi.fn(); + component.editorCreated.subscribe(createdSpy); + await flushInit(fixture); + expect(createdSpy).toHaveBeenCalledWith(mockEditor); + }); - const mockEditor = { - on: vi.fn(), - destroy: vi.fn(), - }; - mockService.init.mockReturnValue(mockEditor); + it('emits editorLoaded when the GrapesJS load event fires', async () => { + const loadedSpy = vi.fn(); + component.editorLoaded.subscribe(loadedSpy); + await flushInit(fixture); + mockEditor._fireEvent('load'); + expect(loadedSpy).toHaveBeenCalledWith(mockEditor); + }); - fixture.detectChanges(); + it('emits editorReady after editor.onReady fires', async () => { + const readySpy = vi.fn(); + component.editorReady.subscribe(readySpy); + await flushInit(fixture); + expect(readySpy).not.toHaveBeenCalled(); + mockEditor._fireReady(); + expect(readySpy).toHaveBeenCalledWith(mockEditor); + }); - // Find the 'load' handler and call it - const loadCall = mockEditor.on.mock.calls.find( - (call: unknown[]) => call[0] === 'load' - ); - expect(loadCall).toBeDefined(); - loadCall![1](); - expect(editorReadySpy).toHaveBeenCalledWith(mockEditor); + it('emits projectUpdated on editor update event with project data', async () => { + const updatedSpy = vi.fn(); + component.projectUpdated.subscribe(updatedSpy); + await flushInit(fixture); + mockEditor._fireEvent('update'); + expect(updatedSpy).toHaveBeenCalledWith({ + data: { pages: [] }, + editor: mockEditor, + }); }); it('merges GRAPES_JS_DEFAULT_CONFIG with the config input, input wins on conflicts', async () => { @@ -79,8 +126,8 @@ describe('GrapesJsEditorComponent', () => { plugins: ['defaultPlugin' as any], }; - // Recreate TestBed with default config - mockService = createMockService(); + mockEditor = createMockEditor(); + mockService = createMockService(mockEditor); await TestBed.resetTestingModule(); await TestBed.configureTestingModule({ imports: [GrapesJsEditorComponent], @@ -95,15 +142,38 @@ describe('GrapesJsEditorComponent', () => { component.config = { height: '800px' }; component.plugins = ['inputPlugin' as any]; - fixture.detectChanges(); + await flushInit(fixture); const config = mockService.init.mock.calls[0][0]; - // Input wins on conflicts expect(config.height).toBe('800px'); - // Default config values preserved when not overridden expect(config.width).toBe('100%'); - // Plugins are merged (default + input) expect(config.plugins).toContain('defaultPlugin'); expect(config.plugins).toContain('inputPlugin'); }); + + it('passes function plugins through unchanged to editorService.init', async () => { + const fnPlugin = () => {}; + component.plugins = [fnPlugin as any]; + await flushInit(fixture); + const config = mockService.init.mock.calls[0][0]; + expect(config.plugins).toContain(fnPlugin); + }); + + it('skips init() on non-browser platforms (SSR safe)', async () => { + mockEditor = createMockEditor(); + mockService = createMockService(mockEditor); + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [GrapesJsEditorComponent], + providers: [ + { provide: GrapesJsEditorService, useValue: mockService }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }).compileComponents(); + + const ssrFixture = TestBed.createComponent(GrapesJsEditorComponent); + await flushInit(ssrFixture); + + expect(mockService.init).not.toHaveBeenCalled(); + }); }); diff --git a/projects/grapesjs-angular/src/lib/grapesjs-editor.component.ts b/projects/grapesjs-angular/src/lib/grapesjs-editor.component.ts index c523b03..a79f5fd 100644 --- a/projects/grapesjs-angular/src/lib/grapesjs-editor.component.ts +++ b/projects/grapesjs-angular/src/lib/grapesjs-editor.component.ts @@ -1,63 +1,213 @@ import { Component as NgComponent, + AfterContentInit, AfterViewInit, OnDestroy, Input, Output, EventEmitter, ViewChild, + ContentChild, + ContentChildren, + QueryList, ElementRef, ChangeDetectionStrategy, + ChangeDetectorRef, + PLATFORM_ID, + TemplateRef, inject, } from '@angular/core'; -import type { Editor, ProjectData, Component, Block, Plugin } from 'grapesjs'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import type { Editor, ProjectData, Component, Block } from 'grapesjs'; import { GrapesJsEditorService } from './grapesjs-editor.service'; import { GRAPES_JS_DEFAULT_CONFIG } from './grapesjs-editor.tokens'; import type { GrapesJsConfig } from './grapesjs-editor.types'; +import { initPlugins, type PluginTypeToLoad } from './utils/plugins'; +import { loadStyle } from './utils/dom'; +import { GjsCanvas } from './custom-ui/gjs-canvas.component'; +import { GjsCustomProviderBase, type CustomFlag } from './custom-ui/custom-provider-base'; + +export interface ProjectUpdatePayload { + data: ProjectData; + editor: Editor; +} @NgComponent({ selector: 'gjs-editor', standalone: true, + imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, - template: `
`, + template: ` + @if (waitReady && !ready) { +
+ @if (isTemplate(waitReady)) { + + } +
+ } + +
+ `, styles: [` - :host { display: block; width: 100%; height: 100%; } + :host { display: block; width: 100%; height: 100%; position: relative; } .gjs-editor-host { width: 100%; height: 100%; } + .gjs-editor-host--hidden { opacity: 0; width: 0; height: 0; overflow: hidden; } + .gjs-editor-placeholder { width: 100%; height: 100%; } `], }) -export class GrapesJsEditorComponent implements AfterViewInit, OnDestroy { +export class GrapesJsEditorComponent implements AfterContentInit, AfterViewInit, OnDestroy { @Input() config: GrapesJsConfig = {}; - @Input() plugins: Plugin[] = []; + @Input() plugins: PluginTypeToLoad[] = []; + /** + * URL of the GrapesJS core CSS to inject asynchronously before init. + * @example 'https://unpkg.com/grapesjs/dist/css/grapes.min.css' + */ + @Input() grapesjsCss?: string; + /** + * When truthy, hides the editor host until `editor.onReady()` fires. + * Pass a `TemplateRef` to render a custom placeholder in its place. + */ + @Input() waitReady?: boolean | TemplateRef; + /** Emits synchronously after `grapesjs.init()` returns. */ + @Output() editorCreated = new EventEmitter(); + /** Emits on GrapesJS `'load'` event. */ + @Output() editorLoaded = new EventEmitter(); + /** Emits once `editor.onReady()` fires — post-mount, post-storage-load. */ @Output() editorReady = new EventEmitter(); + /** Emits on every GrapesJS `'update'` event with the latest project data. */ + @Output() projectUpdated = new EventEmitter(); @Output() projectSaved = new EventEmitter(); @Output() projectLoaded = new EventEmitter(); @Output() componentSelected = new EventEmitter(); @Output() blockAdded = new EventEmitter(); @ViewChild('gjsContainer', { static: true }) - private container!: ElementRef; + private defaultContainer!: ElementRef; + + @ContentChild(GjsCanvas) private projectedCanvas?: GjsCanvas; + @ContentChildren(GjsCustomProviderBase, { descendants: true }) + private projectedProviders!: QueryList; private editorService = inject(GrapesJsEditorService); private defaultConfig = inject(GRAPES_JS_DEFAULT_CONFIG, { optional: true }); + private platformId = inject(PLATFORM_ID); + private cdr = inject(ChangeDetectorRef); + + /** Internal mirror of service `isReady` for OnPush template bindings. */ + protected ready = false; + protected hasCustomCanvas = false; + + private customFlags: Partial> = {}; + private providers: readonly GjsCustomProviderBase[] = []; + + isTemplate(val: unknown): val is TemplateRef { + return val instanceof TemplateRef; + } + + ngAfterContentInit(): void { + this.hasCustomCanvas = !!this.projectedCanvas; + this.providers = this.projectedProviders?.toArray() ?? []; + this.customFlags = {}; + for (const p of this.providers) { + if (p.customFlag) this.customFlags[p.customFlag] = true; + } + } + + async ngAfterViewInit(): Promise { + // SSR guard — do not touch DOM APIs or load GrapesJS on the server. + if (!isPlatformBrowser(this.platformId)) { + return; + } + + if (this.grapesjsCss) { + try { + await loadStyle(this.grapesjsCss); + } catch (err) { + console.warn('[gjs-editor] Failed to load grapesjsCss:', err); + } + } + + const mergedInputPlugins: PluginTypeToLoad[] = [ + ...(this.defaultConfig?.plugins ?? []) as PluginTypeToLoad[], + ...this.plugins, + ]; + const { plugins: resolvedPlugins, pluginOptions } = await initPlugins(mergedInputPlugins); - ngAfterViewInit(): void { const mergedConfig: GrapesJsConfig = { + // Fill the host element by default — without this GrapesJS falls back to + // its built-in `height: '900px'` and overflows whatever container we mount + // it in. Consumer config below can still override. + height: '100%', + width: '100%', ...this.defaultConfig, ...this.config, - container: this.container.nativeElement, - plugins: [...(this.defaultConfig?.plugins ?? []), ...this.plugins], + container: this.projectedCanvas?.elementRef.nativeElement ?? this.defaultContainer.nativeElement, + plugins: resolvedPlugins, + pluginsOpts: { + ...this.defaultConfig?.pluginsOpts, + ...this.config.pluginsOpts, + ...pluginOptions, + }, }; + + // Merge provider-requested custom UI flags into their manager config sections. + if (this.customFlags.blockManager) { + mergedConfig.blockManager = { ...mergedConfig.blockManager, custom: true }; + } + if (this.customFlags.layerManager) { + mergedConfig.layerManager = { ...mergedConfig.layerManager, custom: true }; + } + if (this.customFlags.selectorManager) { + mergedConfig.selectorManager = { ...mergedConfig.selectorManager, custom: true }; + } + if (this.customFlags.styleManager) { + mergedConfig.styleManager = { ...mergedConfig.styleManager, custom: true }; + } + if (this.customFlags.traitManager) { + mergedConfig.traitManager = { ...mergedConfig.traitManager, custom: true }; + } + if (this.customFlags.assetManager) { + mergedConfig.assetManager = { ...mergedConfig.assetManager, custom: true }; + } + if (this.customFlags.modal) { + mergedConfig.modal = { ...mergedConfig.modal, custom: true }; + } + if (this.projectedCanvas) { + (mergedConfig as unknown as { customUI: boolean }).customUI = true; + mergedConfig.panels = { ...mergedConfig.panels, defaults: [] }; + } + const editor = this.editorService.init(mergedConfig); + this.editorCreated.emit(editor); - editor.on('load', () => this.editorReady.emit(editor)); + editor.on('load', () => this.editorLoaded.emit(editor)); editor.on('storage:end:store', (data: unknown) => this.projectSaved.emit(data as ProjectData)); editor.on('storage:end:load', (data: unknown) => this.projectLoaded.emit(data as ProjectData)); editor.on('component:selected', (component: Component) => this.componentSelected.emit(component)); editor.on('block:drag:stop', (_component: unknown, block: Block) => this.blockAdded.emit(block)); + editor.on('update', () => this.projectUpdated.emit({ data: editor.getProjectData(), editor })); + + // Hand the live editor to each projected provider so it can subscribe to + // its manager's custom event and populate its state signal. + for (const p of this.providers) { + p.wire(editor); + } + + editor.onReady(() => { + this.ready = true; + this.cdr.markForCheck(); + this.editorReady.emit(editor); + }); } ngOnDestroy(): void { + if (!isPlatformBrowser(this.platformId)) return; this.editorService.destroy(); } } diff --git a/projects/grapesjs-angular/src/lib/grapesjs-editor.service.spec.ts b/projects/grapesjs-angular/src/lib/grapesjs-editor.service.spec.ts index ac3f8d7..bfccc7d 100644 --- a/projects/grapesjs-angular/src/lib/grapesjs-editor.service.spec.ts +++ b/projects/grapesjs-angular/src/lib/grapesjs-editor.service.spec.ts @@ -15,10 +15,14 @@ import grapesjs from 'grapesjs'; function createMockEditor() { const eventHandlers = new Map(); + const readyHandlers: Function[] = []; return { on: vi.fn((event: string, handler: Function) => { eventHandlers.set(event, handler); }), + onReady: vi.fn((handler: Function) => { + readyHandlers.push(handler); + }), destroy: vi.fn(), getHtml: vi.fn(() => '
test
'), getCss: vi.fn(() => '.test { color: red; }'), @@ -38,6 +42,9 @@ function createMockEditor() { const handler = eventHandlers.get(event); if (handler) handler(...args); }, + _fireReady: () => { + readyHandlers.forEach(h => h()); + }, }; } @@ -105,4 +112,22 @@ describe('GrapesJsEditorService', () => { mockEditor._fireEvent('component:selected', mockComponent); expect(service.selectedComponent()).toBe(mockComponent); }); + + it('isInitialized flips true after init() and back to false after destroy()', () => { + expect(service.isInitialized()).toBe(false); + service.init({ container: document.createElement('div') }); + expect(service.isInitialized()).toBe(true); + service.destroy(); + expect(service.isInitialized()).toBe(false); + }); + + it('isReady flips true when editor.onReady fires and resets on destroy', () => { + expect(service.isReady()).toBe(false); + service.init({ container: document.createElement('div') }); + expect(service.isReady()).toBe(false); + mockEditor._fireReady(); + expect(service.isReady()).toBe(true); + service.destroy(); + expect(service.isReady()).toBe(false); + }); }); diff --git a/projects/grapesjs-angular/src/lib/grapesjs-editor.service.ts b/projects/grapesjs-angular/src/lib/grapesjs-editor.service.ts index 4b308b6..cb0e772 100644 --- a/projects/grapesjs-angular/src/lib/grapesjs-editor.service.ts +++ b/projects/grapesjs-angular/src/lib/grapesjs-editor.service.ts @@ -27,9 +27,14 @@ export class GrapesJsEditorService { readonly commands: Signal | null> = computed(() => this._editor()?.Commands ?? null); // Convenience signals for common state - readonly isReady = computed(() => this._editor() !== null); + readonly isInitialized = computed(() => this._editor() !== null); readonly selectedComponent = signal(null); + // Flips true once editor.onReady() has fired (editor mounted and storage loaded). + // Distinct from `isInitialized`, which only reports whether init() has been called. + private _isReady = signal(false); + readonly isReady = this._isReady.asReadonly(); + init(config: GrapesJsConfig): Editor { if (this._editor()) { console.warn('[GrapesJsEditorService] Editor already initialised. Call destroy() first.'); @@ -38,6 +43,7 @@ export class GrapesJsEditorService { const editor = grapesjs.init(config); editor.on('component:selected', (c: Component) => this.selectedComponent.set(c)); editor.on('component:deselected', () => this.selectedComponent.set(null)); + editor.onReady(() => this._isReady.set(true)); this._editor.set(editor); return editor; } @@ -46,6 +52,7 @@ export class GrapesJsEditorService { this._editor()?.destroy(); this._editor.set(null); this.selectedComponent.set(null); + this._isReady.set(false); } getHtml(): string | null { diff --git a/projects/grapesjs-angular/src/lib/grapesjs-editor.types.ts b/projects/grapesjs-angular/src/lib/grapesjs-editor.types.ts index ba45969..da3ab9e 100644 --- a/projects/grapesjs-angular/src/lib/grapesjs-editor.types.ts +++ b/projects/grapesjs-angular/src/lib/grapesjs-editor.types.ts @@ -1,5 +1,7 @@ import type { EditorConfig, Editor, ProjectData, Component, Block, Plugin } from 'grapesjs'; +export type { PluginToLoad, PluginTypeToLoad, GrapesPlugin } from './utils/plugins'; + /** Config passed to grapesjs.init() — re-exported with `container` made optional * since the component sets it from the ViewChild */ export type GrapesJsConfig = Omit & { diff --git a/projects/grapesjs-angular/src/lib/utils/dom.ts b/projects/grapesjs-angular/src/lib/utils/dom.ts new file mode 100644 index 0000000..2784ed5 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/utils/dom.ts @@ -0,0 +1,35 @@ +const isString = (value: unknown): value is string => typeof value === 'string'; + +export const loadStyle = (href: string): Promise => { + return new Promise((resolve, reject) => { + if (document.querySelector(`link[href="${href}"]`)) { + return resolve(); + } + const link = document.createElement('link'); + link.href = href; + link.rel = 'stylesheet'; + link.onload = () => resolve(); + link.onerror = () => reject(new Error(`Failed to load stylesheet: ${href}`)); + document.head.appendChild(link); + }); +}; + +type ScriptToLoad = { id: string; src: string }; + +export const loadScript = (src: string | ScriptToLoad): Promise => { + const scriptToLoad: ScriptToLoad = isString(src) ? { id: src, src } : src; + return new Promise((res, rej) => { + if (document.querySelector(`script[src="${scriptToLoad.src}"]`)) { + return res(scriptToLoad.id); + } + const script = document.createElement('script'); + script.src = scriptToLoad.src; + script.onload = () => res(scriptToLoad.id); + script.onerror = () => rej(scriptToLoad.id); + document.head.appendChild(script); + }); +}; + +export const loadScripts = (scripts: ScriptToLoad[]) => { + return Promise.allSettled(scripts.map(loadScript)); +}; diff --git a/projects/grapesjs-angular/src/lib/utils/plugins.ts b/projects/grapesjs-angular/src/lib/utils/plugins.ts new file mode 100644 index 0000000..5bdd213 --- /dev/null +++ b/projects/grapesjs-angular/src/lib/utils/plugins.ts @@ -0,0 +1,75 @@ +import type { Plugin, PluginOptions } from 'grapesjs'; +import { loadScripts } from './dom'; + +export type GrapesPlugin = string | Plugin; + +export type PluginToLoad = { + id: string; + src: string; + options?: PluginOptions; +}; + +export type PluginTypeToLoad = GrapesPlugin | PluginToLoad | false | null | undefined; + +const isPluginToLoad = (plugin: PluginTypeToLoad): plugin is PluginToLoad => { + return !!(plugin && typeof plugin === 'object' && !Array.isArray(plugin)); +}; + +async function loadPlugins(plugins: PluginToLoad[]) { + const scripts = plugins.map(({ id, src }) => ({ id, src })); + const pluginsMap = plugins.reduce>((res, item) => { + res[item.id] = item; + return res; + }, {}); + const loaded: PluginToLoad[] = []; + const failed: PluginToLoad[] = []; + const results = await loadScripts(scripts); + results.forEach(result => { + if (result.status === 'fulfilled') { + loaded.push(pluginsMap[result.value]); + } else { + failed.push(pluginsMap[result.reason]); + } + }); + + return { loaded, failed }; +} + +export async function initPlugins(plugins: PluginTypeToLoad[]) { + const pluginsToInit: PluginTypeToLoad[] = [...plugins]; + const pluginOptions: Record = {}; + + if (pluginsToInit.length) { + const pluginToLoadMap: Record = {}; + const pluginsToLoad: PluginToLoad[] = []; + + pluginsToInit.forEach((plugin, index) => { + if (isPluginToLoad(plugin)) { + pluginToLoadMap[plugin.id] = { index }; + pluginsToLoad.push(plugin); + } + }); + + if (pluginsToLoad.length) { + const { loaded } = await loadPlugins(pluginsToLoad); + loaded.forEach(({ id, options }) => { + pluginToLoadMap[id].loaded = true; + pluginOptions[id] = options || {}; + }); + } + + Object.keys(pluginToLoadMap).forEach(id => { + const plugin = pluginToLoadMap[id]; + if (plugin.loaded) { + pluginsToInit[plugin.index] = id; + } else { + pluginsToInit[plugin.index] = false; + } + }); + } + + return { + plugins: pluginsToInit.filter(Boolean) as GrapesPlugin[], + pluginOptions, + }; +} diff --git a/projects/grapesjs-angular/src/public-api.ts b/projects/grapesjs-angular/src/public-api.ts index afe6be3..e86b085 100644 --- a/projects/grapesjs-angular/src/public-api.ts +++ b/projects/grapesjs-angular/src/public-api.ts @@ -3,7 +3,7 @@ */ // Component -export { GrapesJsEditorComponent } from './lib/grapesjs-editor.component'; +export { GrapesJsEditorComponent, type ProjectUpdatePayload } from './lib/grapesjs-editor.component'; // Service export { GrapesJsEditorService } from './lib/grapesjs-editor.service'; @@ -17,4 +17,21 @@ export type { GrapesJsModuleConfig, GrapesJsEditorRef, StorageConfig, + PluginToLoad, + PluginTypeToLoad, + GrapesPlugin, } from './lib/grapesjs-editor.types'; + +// Custom UI surface +export { GjsCustomProviderBase, type CustomFlag } from './lib/custom-ui/custom-provider-base'; +export { GjsCanvas } from './lib/custom-ui/gjs-canvas.component'; +export { GjsContainerDirective } from './lib/custom-ui/gjs-container.directive'; +export { GjsBlocksProvider, type BlocksState, type MapCategoryBlocks } from './lib/custom-ui/blocks-provider.component'; +export { GjsLayersProvider, type LayersState } from './lib/custom-ui/layers-provider.component'; +export { GjsSelectorsProvider, type SelectorsState } from './lib/custom-ui/selectors-provider.component'; +export { GjsStylesProvider, type StylesState } from './lib/custom-ui/styles-provider.component'; +export { GjsTraitsProvider, type TraitsState } from './lib/custom-ui/traits-provider.component'; +export { GjsPagesProvider, type PagesState } from './lib/custom-ui/pages-provider.component'; +export { GjsDevicesProvider, type DevicesState } from './lib/custom-ui/devices-provider.component'; +export { GjsAssetsProvider, type AssetsState } from './lib/custom-ui/assets-provider.component'; +export { GjsModalProvider, type ModalState } from './lib/custom-ui/modal-provider.component'; From 45936e8600e17648252f95a9f6f17808476afa1b Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 10:02:32 -0400 Subject: [PATCH 02/18] docs: custom-UI demo, README, and CHANGELOG for 2.0.0-beta.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the demo to showcase every provider with a full custom editor shell (pages, blocks with drag ghost, layers tree, selectors, styles, traits, assets/modal overlays) alongside the default-UI mode. Layers rows now select via editor.select(component) — a GrapesJS Component has no .select() method, so the previous call threw at runtime. Raise anyComponentStyle budget to fit the demo stylesheet; rename the private workspace package to grapesjs-angular-workspace. --- CHANGELOG.md | 83 +++++- README.md | 150 ++++++++++- angular.json | 4 +- package.json | 4 +- projects/demo/src/app/app.css | 457 ++++++++++++++++++++++++++++++++- projects/demo/src/app/app.html | 254 +++++++++++++++++- projects/demo/src/app/app.ts | 103 +++++++- projects/demo/src/styles.css | 16 ++ 8 files changed, 1037 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 356dd88..d2b4b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,78 @@ # Changelog -## 2.0.0-alpha.1 +All notable changes to `@ilq/grapesjs-angular` will be documented in this file. -Initial release of `@ilq/grapesjs-angular` — a ground-up Angular 20+ wrapper for GrapesJS. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- Standalone `` component with OnPush change detection -- `GrapesJsEditorService` with signals for editor state and all GrapesJS managers -- `provideGrapesJs()` for app-wide default configuration -- Zoneless-compatible (no Zone.js dependency) -- Inputs: `config`, `plugins` -- Outputs: `editorReady`, `projectSaved`, `projectLoaded`, `componentSelected`, `blockAdded` -- Service methods: `init()`, `destroy()`, `getHtml()`, `getCss()`, `getProjectData()`, `loadProjectData()` +## [Unreleased] + +### Added + +### Changed + +### Fixed + +### Removed + +## [2.0.0-beta.2] — TBD + +### Added + +- `` mount-point component enabling custom UI mode — when projected into ``, swaps the editor's default container and disables default panels. +- Nine provider components mirroring the `@grapesjs/react` render-props surface, each exposing typed manager state to a projected ``: + - `` — `BlocksState` (blocks, drag handlers, container, category map) + - `` — `LayersState` (root component, container) + - `` — `SelectorsState` (selectors, states, targets, mutators, container) + - `` — `StylesState` (visible sectors, container) + - `` — `TraitsState` (current traits, container) + - `` — `PagesState` (pages, selected, mutators) + - `` — `DevicesState` (devices, selected, select) + - `` — `AssetsState` (open, assets, types, select, close, container) + - `` — `ModalState` (open, title, content, attributes, close) +- `*gjsContainer` directive for appending a GrapesJS-owned `HTMLElement` into a host element (Angular equivalent of React's portal `Container`). +- New component outputs: `editorCreated` (fires synchronously after `grapesjs.init()` returns), `projectUpdated` (fires on every `'update'` event with latest `ProjectData`). +- New component inputs: `grapesjsCss` (async `` injection before init), `waitReady` (`boolean | TemplateRef` — hide/replace the editor host until `editor.onReady()` fires). +- New service signal `isInitialized` — `true` after `grapesjs.init()` returns. +- Async CDN plugin loading — `plugins` input now accepts `{ id, src, options }` descriptors alongside function references and global names. +- Public types: `PluginToLoad`, `PluginTypeToLoad`, `GrapesPlugin`, `ProjectUpdatePayload`. + +### Changed + +- **BREAKING vs `2.0.0-beta.1`** — `editorReady` output semantics changed and the earlier-firing name was moved: + - Output `editorReady` (previously fired on GrapesJS `'load'`) is renamed to `editorLoaded` — same firing point, clearer name. + - Output `editorReady` now fires on `editor.onReady()` (post-mount, post-storage-load) to match `@grapesjs/react` `onReady` semantics. +- **BREAKING vs `2.0.0-beta.1`** — service signal `isReady` semantics changed: + - Signal `isReady` (previously flipped after init) is renamed to `isInitialized`. + - Signal `isReady` now reflects the post-`editor.onReady()` state. +- `plugins` input widened from `Plugin[]` to `PluginTypeToLoad[]` — source-compatible; existing `Plugin[]` assignments still compile. +- `` now defaults the GrapesJS init config to `height: '100%'` and `width: '100%'` so the editor fills its host element. GrapesJS's internal default of `height: 900px` was overflowing flex layouts. Consumer config still overrides. + +### Fixed + +- `` and `` now render their projected template on init. In custom-UI mode GrapesJS skips its default panel render — the only place it fires the initial `selector:custom` / `style:custom` event — so these panels stayed empty until a component was selected. Both providers now self-trigger the event on `wire()`, matching ``. +- SSR-safe: `` no longer calls `grapesjs.init()` or touches DOM APIs when `PLATFORM_ID === 'server'` — the host renders inert and `ngOnDestroy` short-circuits. + +## [2.0.0-beta.1] — 2026-04-23 + +Initial public release on npm. Angular 20+ wrapper for [GrapesJS](https://grapesjs.com/), signals-based, standalone, zoneless-compatible. + +- `` standalone component with `config` and `plugins` inputs, `editorReady` / `projectSaved` / `projectLoaded` / `componentSelected` / `blockAdded` outputs. +- `GrapesJsEditorService` exposing every GrapesJS manager as a signal, plus `isReady`, `selectedComponent`, and `getHtml` / `getCss` / `getProjectData` / `loadProjectData` helpers. +- `provideGrapesJs(config)` environment provider + `GRAPES_JS_DEFAULT_CONFIG` injection token. + +## [2.0.0-alpha.1] + +Pre-release development milestone — not published to npm. + +- Standalone `` component with OnPush change detection. +- `GrapesJsEditorService` with signals for editor state and all GrapesJS managers. +- `provideGrapesJs()` for app-wide default configuration. +- Zoneless-compatible (no Zone.js dependency). + +--- + +Keep the `[Unreleased]` section up-to-date as changes land; move its entries under a new version heading at release time. + +[Unreleased]: https://github.com/internetliquid/grapesjs-angular/compare/v2.0.0-beta.2...HEAD +[2.0.0-beta.2]: https://github.com/internetliquid/grapesjs-angular/compare/v2.0.0-beta.1...v2.0.0-beta.2 +[2.0.0-beta.1]: https://github.com/internetliquid/grapesjs-angular/releases/tag/v2.0.0-beta.1 diff --git a/README.md b/README.md index 4f7b345..7adc379 100644 --- a/README.md +++ b/README.md @@ -88,20 +88,63 @@ export class MyComponent { ### Inputs -| Input | Type | Default | Description | -| --------- | --------------- | ------- | -------------------------------------------------- | -| `config` | `GrapesJsConfig`| `{}` | GrapesJS editor config (merged with global config) | -| `plugins` | `Plugin[]` | `[]` | Additional plugins (appended to global plugins) | +| Input | Type | Default | Description | +| --- | --- | --- | --- | +| `config` | `GrapesJsConfig` | `{}` | GrapesJS editor config (merged with global config) | +| `plugins` | `PluginTypeToLoad[]` | `[]` | Plugins as functions, global names, or `{ id, src, options }` for async CDN loading | +| `grapesjsCss` | `string \| undefined` | — | URL of the GrapesJS core CSS to inject asynchronously before init | +| `waitReady` | `boolean \| TemplateRef` | — | Hide the editor host until `editorReady` fires (`editor.onReady()`); pass a `TemplateRef` to show a placeholder | ### Outputs -| Output | Payload | Description | -| ------------------- | -------------- | ---------------------------------------- | -| `editorReady` | `Editor` | Emitted when the editor has loaded | -| `projectSaved` | `ProjectData` | Emitted after project storage completes | -| `projectLoaded` | `ProjectData` | Emitted after project data is loaded | -| `componentSelected` | `Component` | Emitted when a component is selected | -| `blockAdded` | `Block` | Emitted when a block is dropped on canvas| +| Output | Payload | Description | +| --- | --- | --- | +| `editorCreated` | `Editor` | Emitted synchronously after `grapesjs.init()` (pre-`load`) | +| `editorLoaded` | `Editor` | Emitted on the GrapesJS `'load'` event | +| `editorReady` | `Editor` | Emitted once `editor.onReady()` fires (post-mount, post-storage) | +| `projectUpdated` | `{ data: ProjectData; editor: Editor }` | Emitted on every editor `'update'` event | +| `projectSaved` | `ProjectData` | Emitted after project storage completes | +| `projectLoaded` | `ProjectData` | Emitted after project data is loaded | +| `componentSelected` | `Component` | Emitted when a component is selected | +| `blockAdded` | `Block` | Emitted when a block is dropped on canvas | + +### Editor lifecycle + +Three distinct phases — safe content manipulation should wait for `editorReady`: + +1. **`editorCreated`** — the `Editor` instance exists; managers are accessible but the canvas hasn't loaded. +2. **`editorLoaded`** — GrapesJS `'load'` event; the core editor UI is up. +3. **`editorReady`** — `editor.onReady()` callback; mounted, panels built, storage data loaded. Safe to read or manipulate project content. + +### Async plugin & CSS loading + +Plugins can be a mix of function references, global names, and async CDN descriptors: + +```typescript +@Component({ + template: ` + + + `, +}) +export class MyEditor { + editorPlugins: PluginTypeToLoad[] = [ + gjsBlocksBasic, // function reference + 'grapesjs-plugin-forms', // global name + { // CDN descriptor + id: 'grapesjs-preset-webpage', + src: 'https://unpkg.com/grapesjs-preset-webpage', + options: { /* ... */ }, + }, + ]; +} +``` + +### SSR + +`` is SSR-safe: when rendered on the server (`PLATFORM_ID === 'server'`) it renders an inert host and never calls `grapesjs.init()`. Combine with `waitReady` to render a placeholder during server rendering and hydration. ## Service API @@ -112,7 +155,8 @@ export class MyComponent { | Signal | Type | Description | | -------------------- | ------------------------------- | ------------------------------------ | | `editor` | `Signal` | The GrapesJS editor instance | -| `isReady` | `Signal` | Whether the editor is initialised | +| `isInitialized` | `Signal` | Whether the editor is initialised | +| `isReady` | `Signal` | Whether `editor.onReady()` has fired | | `selectedComponent` | `Signal` | Currently selected component | | `blockManager` | `Signal` | Block manager | | `styleManager` | `Signal` | Style manager | @@ -136,6 +180,86 @@ export class MyComponent { | `getProjectData()` | `ProjectData \| null`| Get the full project data | | `loadProjectData(data)` | `void` | Load project data into the editor| +## Custom UI + +By default `` renders the full GrapesJS UI (panels, block manager, style manager, etc.) inside its host element. For full control of the editor shell — composing your own sidebars and toolbars with Angular templates — project `` plus one or more provider components as content children: + +```typescript +import { + GrapesJsEditorComponent, + GjsCanvas, + GjsBlocksProvider, + GjsLayersProvider, + GjsPagesProvider, + GjsContainerDirective, +} from '@ilq/grapesjs-angular'; + +@Component({ + imports: [ + GrapesJsEditorComponent, + GjsCanvas, + GjsBlocksProvider, + GjsLayersProvider, + GjsPagesProvider, + GjsContainerDirective, + ], + template: ` + + + + + `, +}) +export class MyEditor {} +``` + +When `` is present the editor uses it as the GrapesJS container, sets `customUI: true`, and disables the default panels. Each provider discovered via content projection flips the matching `custom*` flag in the init config, then subscribes to its manager's `custom` event and exposes the live state to your projected ``. + +### Providers + +| Component | State shape | Flips `custom` flag on | Notes | +| --- | --- | --- | --- | +| `` | — | — | Swaps the editor's container and disables default panels | +| `` | `BlocksState` | `blockManager` | `{ blocks, dragStart, dragStop, container, mapCategoryBlocks }` | +| `` | `LayersState` | `layerManager` | `{ root, container }` | +| `` | `SelectorsState` | `selectorManager` | `{ selectors, states, selectedState, targets, addSelector, removeSelector, setState, container }` | +| `` | `StylesState` | `styleManager` | `{ sectors, container }` | +| `` | `TraitsState` | `traitManager` | `{ traits, container }` | +| `` | `AssetsState` | `assetManager` | `{ open, assets, types, select, close, container }` | +| `` | `ModalState` | `modal` | `{ open, title, content, attributes, close }` | +| `` | `PagesState` | — | `{ pages, selected, select, add, remove }` (observer-only) | +| `` | `DevicesState` | — | `{ devices, selected, select }` (observer-only) | + +### `*gjsContainer` + +For providers that expose a `container` (the GrapesJS-owned default panel element), use `[gjsContainer]="ctx.container"` on any host element to mount the default panel inside your own layout. This is a one-directive alternative to building a portal system. + ## Multi-Editor `GrapesJsEditorService` is provided in root, so by default all `` instances share one service. For multiple independent editors, provide a separate service per editor: @@ -172,6 +296,8 @@ Each instance of this component gets its own `GrapesJsEditorService`. Inspired by the original [`@rakutentech/grapesjs-angular`](https://github.com/rakutentech/grapesjs-angular), now archived. +The API surface is informed by [`@grapesjs/react`](https://github.com/GrapesJS/react), the official React wrapper, and portions of the plugin and stylesheet loader utilities are adapted from it under the MIT license. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/angular.json b/angular.json index 9103bc1..9009dcf 100644 --- a/angular.json +++ b/angular.json @@ -63,8 +63,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumWarning": "8kB", + "maximumError": "16kB" } ], "outputHashing": "all" diff --git a/package.json b/package.json index d534767..2f76ec7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "grapesjs-angular", - "version": "0.0.0", + "name": "grapesjs-angular-workspace", + "version": "0.0.1", "scripts": { "ng": "ng", "build:lib": "ng build grapesjs-angular && cp README.md LICENSE dist/grapesjs-angular/", diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index 7f2d333..ce70459 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -1,5 +1,6 @@ .toolbar { display: flex; + align-items: center; gap: 8px; padding: 8px; background: #333; @@ -19,10 +20,464 @@ background: #666; } +.toolbar .device-select { + margin-left: 8px; + padding: 6px 10px; + background: #444; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + font-size: 13px; +} + +.toolbar button.toggle { + margin-left: auto; + background: #276ef1; + border-color: #276ef1; +} + +.toolbar button.toggle:hover { + background: #1e5acc; +} + .editor-container { - height: calc(100vh - 50px); + /* Grid track from gives this a real pixel height; turning it + into a flex column lets the inner gjs-editor stretch reliably. */ + display: flex; + min-height: 0; + overflow: hidden; +} + +.editor-container > gjs-editor { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +/* ───── Custom UI mode ───── */ + +.custom-ui-editor { + display: block; + height: 100%; +} + +.custom-layout { + display: flex; + height: 100%; + width: 100%; +} + +.custom-sidebar { + width: 280px; + background: #1e1e1e; + color: #eee; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 0; +} + +.custom-sidebar-left { + border-right: 1px solid #2b2b2b; +} + +.custom-sidebar-right { + border-left: 1px solid #2b2b2b; +} + +.custom-sidebar .panel { + padding: 12px 0; + border-bottom: 1px solid #2b2b2b; +} + +.custom-sidebar .panel:last-child { + border-bottom: none; +} + +.custom-sidebar .panel h3 { + margin: 0 0 8px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #9aa; +} + +.custom-sidebar .panel h4 { + margin: 12px 0 4px; + font-size: 11px; + text-transform: uppercase; + color: #778; +} + +.custom-sidebar .panel .muted { + color: #888; + font-size: 12px; + margin: 0 0 8px; +} + +.custom-sidebar .panel ul { + list-style: none; + padding: 0; + margin: 0 0 8px; +} + +.custom-sidebar .panel li { + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} + +.custom-sidebar .panel li.active, +.custom-sidebar .panel li:hover { + background: #2a2a2a; +} + +.custom-sidebar .block-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.custom-sidebar .block { + background: #2a2a2a; + color: #ddd; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 12px 8px; + font-size: 12px; + cursor: grab; + transition: transform .05s ease, box-shadow .1s ease; +} + +.custom-sidebar .block:hover { + background: #333; +} + +.custom-sidebar .block:active { + cursor: grabbing; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.custom-sidebar .panel button { + background: #2a2a2a; + color: #ddd; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; +} + +.custom-sidebar .panel button:hover { + background: #333; +} + +/* Layer tree */ +.layer .layer-row { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 0; + color: #ddd; + padding: 4px 6px; + font-size: 12px; + border-radius: 3px; + cursor: pointer; +} + +.layer .layer-row:hover { + background: #2a2a2a; +} + +.layer-children { + margin-left: 12px; + border-left: 1px solid #2b2b2b; + padding-left: 4px; +} + +/* Selectors chips */ +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0 0 8px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: #2a2a2a; + border: 1px solid #3a3a3a; + color: #ddd; + border-radius: 999px; + padding: 2px 4px 2px 8px; + font-size: 11px; +} + +.chip button { + background: transparent; + border: 0; + color: #aaa; + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 0 4px; +} + +.chip button:hover { + color: #fff; +} + +.state-select { + width: 100%; + background: #2a2a2a; + color: #ddd; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; +} + +/* Styles list */ +.property-list { + margin: 6px 0 0 0 !important; + padding-left: 12px !important; + list-style: disc !important; +} + +.property-list li { + padding: 2px 0 !important; + cursor: default !important; + font-size: 11px !important; + background: transparent !important; +} + +.property-list code { + background: #141414; + padding: 1px 4px; + border-radius: 3px; + font-size: 10px; + color: #9cd; +} + +.custom-sidebar details { + margin-bottom: 6px; +} + +.custom-sidebar details summary { + cursor: pointer; + font-size: 12px; + color: #ccc; + padding: 2px 0; +} + +/* Traits inputs */ +.trait { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; +} + +.trait span { + font-size: 11px; + color: #9aa; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.trait input { + background: #2a2a2a; + color: #ddd; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 5px 8px; + font-size: 12px; +} + +.trait input:focus { + outline: none; + border-color: #276ef1; +} + +/* Canvas + drag indicator */ +.canvas-wrap { + position: relative; + flex: 1; + min-width: 0; + display: flex; +} + +.custom-canvas { + flex: 1; + display: block; + min-width: 0; +} + +/* Overlay layered on top of the GrapesJS iframe so the drop indicator is + actually visible. The iframe creates its own painting layer, so without an + explicit z-index the indicator paints behind the iframe content and only + the outer edges (where the iframe doesn't extend) remain visible. */ +.drop-indicator { + position: absolute; + inset: 0; + z-index: 20; + border: 2px dashed transparent; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + transition: border-color .12s ease, background-color .12s ease; +} + +.drop-indicator .drop-hint { + background: #276ef1; + color: #fff; + border-radius: 999px; + padding: 6px 14px; + font-size: 13px; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0; + transform: translateY(-4px); + transition: opacity .12s ease, transform .12s ease; +} + +.custom-layout.is-dragging .drop-indicator { + border-color: #276ef1; + background-color: rgba(39, 110, 241, 0.06); +} + +.custom-layout.is-dragging .drop-indicator .drop-hint { + opacity: 1; + transform: translateY(0); +} + +/* Cue at the cursor too — the overlay outline isn't always in peripheral + vision when the user has eyes on the block they're dragging. */ +.custom-layout.is-dragging, +.custom-layout.is-dragging * { + cursor: grabbing !important; +} + +/* Source block dims while it's being dragged — visually pairs the source + tile with the floating ghost so the user sees the lift. */ +.custom-sidebar .block.is-source { + opacity: 0.35; + box-shadow: none; + transform: none; +} + +/* Floating ghost label that follows the cursor during a drag — bound to + .drag-ghost via cursorX/Y signals updated on `mousemove`. */ +.drag-ghost { + position: fixed; + z-index: 9999; + pointer-events: none; + background: #276ef1; + color: #fff; + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); + white-space: nowrap; + user-select: none; +} + +/* Floating overlays (Assets, Modal) */ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.overlay-card { + background: #1e1e1e; + color: #eee; + border-radius: 6px; + width: 90%; + max-width: 720px; + max-height: 80vh; + overflow: auto; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; +} + +.overlay-card header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #2b2b2b; +} + +.overlay-card header h3 { + margin: 0; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #9aa; +} + +.overlay-card header button { + background: transparent; + border: 0; + color: #aaa; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.overlay-card header button:hover { + color: #fff; +} + +.overlay-card .modal-body, +.overlay-card .asset-grid { + padding: 16px; +} + +.asset-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.asset-tile { + aspect-ratio: 1; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 0; + cursor: pointer; + overflow: hidden; +} + +.asset-tile img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.asset-tile:hover { + border-color: #276ef1; } +/* HTML preview output */ .html-output { margin: 0; padding: 12px; diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index 6943339..c9aa099 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -2,16 +2,260 @@ + @if (customUi()) { + + + + + + } +
- - + @if (!customUi()) { + + + } @else { + +
+ + +
+ + +
+ + +
+ + + + @if (ctx.open) { +
+
+
+

Assets

+ +
+ @if (!ctx.assets.length) { +

No assets yet.

+ } +
+ @for (asset of ctx.assets; track asset.getSrc()) { + + } +
+
+
+ } +
+
+ + + + @if (ctx.open) { +
+
+
+

+ @if (isNode(ctx.title)) { + + } @else { + {{ ctx.title }} + } +

+ +
+ +
+
+ } +
+
+
+ }
+ +
+ + @if (c.components()?.length) { +
+ @for (child of c.components(); track child.getId()) { + + + } +
+ } +
+
+ @if (htmlOutput()) {
{{ htmlOutput() }}
} + +@if (dragging() && draggedLabel(); as label) { +
+ {{ label }} +
+} diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index b782011..8ecbeaa 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -1,22 +1,104 @@ import { Component, inject, signal } from '@angular/core'; -import { GrapesJsEditorComponent, GrapesJsEditorService } from 'grapesjs-angular'; -import type { Editor, ProjectData } from 'grapesjs'; +import { KeyValuePipe, NgTemplateOutlet } from '@angular/common'; +import { + GrapesJsEditorComponent, + GrapesJsEditorService, + GjsCanvas, + GjsBlocksProvider, + GjsLayersProvider, + GjsPagesProvider, + GjsDevicesProvider, + GjsSelectorsProvider, + GjsStylesProvider, + GjsTraitsProvider, + GjsAssetsProvider, + GjsModalProvider, + GjsContainerDirective, +} from 'grapesjs-angular'; +import type { Block, Component as GjsComponent, Editor, ProjectData } from 'grapesjs'; import { SAMPLE_HTML, SAMPLE_CSS } from './sample-content'; @Component({ selector: 'demo-root', standalone: true, - imports: [GrapesJsEditorComponent], + imports: [ + KeyValuePipe, + NgTemplateOutlet, + GrapesJsEditorComponent, + GjsCanvas, + GjsBlocksProvider, + GjsLayersProvider, + GjsPagesProvider, + GjsDevicesProvider, + GjsSelectorsProvider, + GjsStylesProvider, + GjsTraitsProvider, + GjsAssetsProvider, + GjsModalProvider, + GjsContainerDirective, + ], templateUrl: './app.html', styleUrl: './app.css', }) export class App { private editorService = inject(GrapesJsEditorService); protected htmlOutput = signal(''); + protected customUi = signal(false); + protected dragging = signal(false); + protected draggedLabel = signal(null); + protected cursorX = signal(0); + protected cursorY = signal(0); + + private frameEl: HTMLIFrameElement | null = null; + private frameDoc: Document | null = null; + + private trackCursor = (e: MouseEvent) => { + this.cursorX.set(e.clientX); + this.cursorY.set(e.clientY); + }; + + /** mousemove inside the canvas iframe — its clientX/Y is iframe-local, so we + * translate to viewport coords using the iframe's bounding rect. */ + private trackCursorInFrame = (e: MouseEvent) => { + if (!this.frameEl) return; + const rect = this.frameEl.getBoundingClientRect(); + this.cursorX.set(rect.left + e.clientX); + this.cursorY.set(rect.top + e.clientY); + }; onEditorReady(editor: Editor): void { editor.setComponents(SAMPLE_HTML); editor.setStyle(SAMPLE_CSS); + + // Capture once for cross-frame mousemove tracking during drags. + this.frameEl = editor.Canvas.getFrameEl() ?? null; + + editor.on('block:drag:start', () => this.dragging.set(true)); + editor.on('block:drag:stop', () => { + this.dragging.set(false); + this.draggedLabel.set(null); + document.removeEventListener('mousemove', this.trackCursor); + this.frameDoc?.removeEventListener('mousemove', this.trackCursorInFrame); + this.frameDoc = null; + }); + } + + /** Block-button mousedown wrapper: capture the label for the floating ghost, + * start tracking the cursor in BOTH the host document and the canvas + * iframe's document (so the ghost still tracks once the cursor crosses + * into the iframe), then forward to GrapesJS via the provider. */ + startBlockDrag(block: Block, ev: MouseEvent, dragStart: (b: Block, e?: Event) => void): void { + this.draggedLabel.set(block.getLabel()); + this.cursorX.set(ev.clientX); + this.cursorY.set(ev.clientY); + document.addEventListener('mousemove', this.trackCursor); + + // The iframe's contentDocument can be replaced (e.g. on Reset), so re-grab + // it lazily here rather than caching at editorReady time. + this.frameDoc = this.frameEl?.contentDocument ?? null; + this.frameDoc?.addEventListener('mousemove', this.trackCursorInFrame); + + dragStart(block, ev); } onProjectSaved(data: ProjectData): void { @@ -27,6 +109,21 @@ export class App { console.log('[Demo] Project loaded', data); } + toggleCustomUi(): void { + this.customUi.update((v) => !v); + } + + /** Layers-panel row click. A GrapesJS Component has no `.select()` — selection + * goes through the editor, which fires component:selected and refreshes the + * Selectors / Styles / Traits panels. */ + selectLayer(c: GjsComponent): void { + this.editorService.editor()?.select(c); + } + + isNode(value: unknown): value is Node { + return value instanceof Node; + } + save(): void { const data = this.editorService.getProjectData(); if (!data) return; diff --git a/projects/demo/src/styles.css b/projects/demo/src/styles.css index 21db5d5..968b692 100644 --- a/projects/demo/src/styles.css +++ b/projects/demo/src/styles.css @@ -1,7 +1,23 @@ @import 'grapesjs/dist/css/grapes.min.css'; +*, *::before, *::after { + box-sizing: border-box; +} + html, body { margin: 0; padding: 0; height: 100%; + overflow: hidden; +} + +/* Pin to the viewport regardless of body or ancestor sizing — + percentage height chains through nested flex/grid have been unreliable + here, so fixed positioning is the cheapest definite constraint. */ +demo-root { + position: fixed; + inset: 0; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; } From 2ccdfafa770bc78b982d1806f65f6bb420bd7cd7 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 10:49:36 -0400 Subject: [PATCH 03/18] docs(demo): add redesign spec and reference mockup Tokenized design-system spec (light/dark) and the interactive editor-chrome mockup it derives from, as the visual source of truth for the demo redesign. --- projects/demo/REDESIGN-SPEC.md | 320 +++++++++++ projects/demo/redesign-mockup.html | 822 +++++++++++++++++++++++++++++ 2 files changed, 1142 insertions(+) create mode 100644 projects/demo/REDESIGN-SPEC.md create mode 100644 projects/demo/redesign-mockup.html diff --git a/projects/demo/REDESIGN-SPEC.md b/projects/demo/REDESIGN-SPEC.md new file mode 100644 index 0000000..a9bc0fd --- /dev/null +++ b/projects/demo/REDESIGN-SPEC.md @@ -0,0 +1,320 @@ +# GrapesJS-Angular demo — visual redesign spec + +Bring the demo app (`projects/demo`) up to a single, consistent, tokenized +design system with light + dark themes. The interactive reference mockup is +`redesign-mockup.html` (the editor-chrome redesign that this spec is derived +from) — treat it as the **visual source of truth**. This spec maps that mockup +onto the real Angular demo. + +Repo: `grapesjs-angular` · App under test: `projects/demo/src/...` + +--- + +## 1. Why / what's wrong today + +The demo currently runs **three clashing visual systems**: a flat `#333` toolbar +with `#555` buttons, near-black custom-UI rails (`#1e1e1e`/`#2a2a2a`), and +GrapesJS's un-themed stock panels in default mode. There are ~17 one-off hex +values and no shared variables (e.g. `app.css` has `#333 #555 #666 #444 #1e1e1e +#2a2a2a #3a3a3a #2b2b2b #9aa #778 #888 #ccc #ddd #141414 #9cd #276ef1`, plus +`#0f0` terminal-green for the HTML output). Save / Get HTML / Reset are visually +identical, so there's no action hierarchy. + +Goal: one token set drives the toolbar, **both** custom rails, the export output, +**and** the GrapesJS default-mode panels, with a working light/dark toggle. + +--- + +## 2. Scope + +### P0 — must ship +1. Token system (Section 4) in **global** `styles.css`, light + `[data-theme="dark"]`. +2. Rewrite `projects/demo/src/app/app.css` to consume tokens — **zero** hardcoded + hex left (kill `#0f0`, `#276ef1`, all the greys). +3. Light/dark theme toggle (Section 8), persisted, defaulting to OS preference. +4. Toolbar button hierarchy (Section 6): one primary, neutral secondaries, quiet + destructive Reset. +5. Theme the GrapesJS **default mode** via its `--gjs-*` CSS variables in global + `styles.css` (Section 9) so toggling Custom → Default no longer jump-cuts. +6. Typography: Inter (UI) + JetBrains Mono (IDs, selectors, style values, HTML + output) — Section 5. + +### P1 — nice, do if cheap +- Export output as a slide-up dock with a Copy button + light syntax tint + (replaces the bare `
`).
+- Convert the single Custom/Default `.toggle` button into a real 2-segment
+  control; same for the device picker.
+- Selection-badge / block hover-lift polish to match the mockup.
+
+### Non-goals
+- No new runtime dependencies (web fonts via `` are fine; see Section 5).
+- Don't restructure `app.html` control-flow, the `gjs-*` providers, or `app.ts`
+  editor logic. You may **add** CSS classes/attributes and the theme-toggle
+  control only.
+- The canvas document (the page being built) stays as-is — it is user content,
+  not editor chrome. Don't theme the iframe contents.
+
+---
+
+## 3. Angular gotcha — global vs component CSS (read before coding)
+
+The demo uses Angular's default (emulated) view encapsulation, so selectors in
+`app.css` are attribute-scoped and only match **Angular-rendered** DOM.
+
+- The **custom-UI** panels are rendered by the providers' Angular templates —
+  style them in `app.css` (scoped is fine).
+- The **default-mode** GrapesJS panels are created by GrapesJS's own JS — they
+  have **no** Angular scoping attributes, so `app.css` rules will NOT reach them.
+  All GrapesJS theming (`--gjs-*` vars and any `.gjs-*` overrides) must live in
+  **global `styles.css`** (unscoped).
+- The design **tokens** must be global too — put them on `:root` in `styles.css`
+  so both the component CSS and the GrapesJS vars can reference them.
+
+---
+
+## 4. Design tokens — paste into global `styles.css`
+
+Add **after** the `@import 'grapesjs/dist/css/grapes.min.css';` line so overrides win.
+
+```css
+:root {
+  /* surfaces */
+  --app-bg:    oklch(95.5% 0.005 250);   /* canvas backdrop */
+  --panel:     oklch(99.2% 0.002 250);   /* toolbar + rails */
+  --panel-2:   oklch(97% 0.004 250);     /* insets, tiles, inputs */
+  --panel-3:   oklch(94.2% 0.006 250);   /* hover */
+  /* text */
+  --fg:        oklch(26% 0.02 255);
+  --fg-2:      oklch(47% 0.016 255);     /* muted */
+  --fg-3:      oklch(60% 0.012 255);     /* faint / captions */
+  /* lines */
+  --border:    oklch(89% 0.007 255);
+  --border-2:  oklch(83% 0.009 255);
+  /* accent — GrapesJS "grape" violet; the ONE brand signal */
+  --accent:        oklch(52% 0.17 300);  /* fill (primary button) */
+  --accent-strong: oklch(47% 0.16 300);  /* accent text/icons on light */
+  --accent-ink:    oklch(99% 0.01 300);  /* text on accent fill */
+  --accent-wash:   color-mix(in oklab, var(--accent) 14%, transparent);
+  /* canvas selection — deliberately a DIFFERENT hue from accent */
+  --select:      oklch(60% 0.17 250);
+  --select-wash: color-mix(in oklab, var(--select) 12%, transparent);
+  /* status */
+  --success:   oklch(58% 0.13 155);
+  --warn:      oklch(66% 0.15 52);
+  --danger:    oklch(57% 0.19 25);
+  /* effects */
+  --shadow-pop: 0 12px 32px oklch(20% 0.03 255 / .18), 0 2px 6px oklch(20% 0.03 255 / .12);
+  /* type */
+  --font-ui:   'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+  --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
+  /* radii */
+  --r1: 6px; --r2: 8px; --r3: 10px; --r4: 14px;
+}
+
+html[data-theme="dark"] {
+  --app-bg:    oklch(16.5% 0.012 260);
+  --panel:     oklch(21.5% 0.013 260);
+  --panel-2:   oklch(25.5% 0.013 260);
+  --panel-3:   oklch(30% 0.015 260);
+  --fg:        oklch(93% 0.01 250);
+  --fg-2:      oklch(71% 0.014 250);
+  --fg-3:      oklch(56% 0.014 250);
+  --border:    oklch(30% 0.014 260);
+  --border-2:  oklch(38% 0.016 260);
+  --accent:        oklch(72% 0.16 300);
+  --accent-strong: oklch(78% 0.15 300);
+  --accent-ink:    oklch(22% 0.05 300);
+  --accent-wash:   color-mix(in oklab, var(--accent) 18%, transparent);
+  --select:      oklch(70% 0.15 250);
+  --select-wash: color-mix(in oklab, var(--select) 18%, transparent);
+  --success:   oklch(72% 0.14 155);
+  --warn:      oklch(76% 0.15 62);
+  --danger:    oklch(68% 0.17 25);
+  --shadow-pop: 0 16px 40px oklch(0% 0 0 / .5), 0 2px 8px oklch(0% 0 0 / .4);
+}
+```
+
+Also set the app surface + font globally:
+
+```css
+html, body { background: var(--app-bg); color: var(--fg); }
+body { font-family: var(--font-ui); -webkit-font-smoothing: antialiased; }
+```
+
+**Token-usage rules** (carry over from the mockup):
+- Accent appears at most ~2 places per view: the **primary** toolbar button and
+  the **active page** marker. Do NOT tint every panel header with accent.
+- Canvas/component **selection** uses `--select` (blue), never `--accent`. The
+  two must never be the same colour.
+- Status colours only for real status (errors, save success). Default panel
+  header icons are `--fg-3`, not accent.
+
+---
+
+## 5. Typography
+
+Add to `projects/demo/src/index.html` `` (or self-host the woff2 under
+`public/` if you prefer no CDN):
+
+```html
+
+
+
+```
+
+- UI text: `var(--font-ui)`.
+- Mono (`var(--font-mono)`): the HTML export output, selector chips, style
+  property values, layer tag labels, any IDs/hashes. Use
+  `font-variant-numeric: tabular-nums` on numerics.
+- Uppercase panel labels (`.panel h3`/`h4`): keep uppercase but set
+  `letter-spacing: 0.085em` (current is fine; ensure ≥ 0.06em). Size 11px.
+
+---
+
+## 6. Toolbar (`app.css` `.toolbar` + `app.html` minimal class adds)
+
+Real toolbar markup today: `Save`, `Get HTML`, `Reset` buttons, the
+`gjs-devices-provider` select, and the Custom/Default `.toggle` button. There is
+**no logo** in the real toolbar — nothing to remove there (the mockup's wordmark
+is demo chrome only; optionally add a plain text `GrapesJS Angular` label, no
+icon tile).
+
+| Element | Treatment |
+|---|---|
+| `.toolbar` | `background: var(--panel)`; `border-bottom: 1px solid var(--border)`; height ~52px; `align-items:center; gap:6px; padding:0 14px` |
+| `Get HTML` (export = primary) | accent fill: `background:var(--accent); color:var(--accent-ink); border:0`. Add class `btn-primary` in `app.html`. |
+| `Save` (secondary) | `background:var(--panel-2); border:1px solid var(--border); color:var(--fg)`; hover `--panel-3` |
+| `Reset` (quiet/destructive) | ghost: transparent bg/border; text `--fg-2`; hover bg `--panel-2`, text `--danger` |
+| `.toggle` (Custom/Default) | neutral segmented look (not accent). P1: make it a real 2-segment control. |
+| `.device-select` | styled `select`: `--panel-2` bg, `--border`, `--r2`, custom chevron, mono optional |
+| theme toggle (new) | 32×32 icon button, sun/moon, `--fg-2`, hover `--panel-2` (Section 8) |
+
+Buttons share: height 32px, `border-radius: var(--r2)`, `font: 510 12.5px ...`,
+`:active { transform: translateY(1px) }`, focus ring
+`box-shadow: 0 0 0 3px var(--accent-wash)`.
+
+---
+
+## 7. Custom-UI panels — token map (`app.css`)
+
+Replace every hardcoded value with the token. Key selectors that exist today:
+
+| Selector(s) | Replace with |
+|---|---|
+| `.custom-sidebar`, `.custom-sidebar-left/right` | bg `--panel`; borders `--border` |
+| `.panel`, `.panel:last-child` border | `--border` |
+| `.panel h3` | `--fg-2`, uppercase, `letter-spacing:.085em`, 11px |
+| `.panel h4` | `--fg-3` |
+| `.muted` | `--fg-3` |
+| `.panel li` / `:hover` / `.active` | text `--fg-2`; hover/active bg `--panel-2`, text `--fg`; active gets a 2.5px `--accent` left bar |
+| `.block` | bg `--panel-2`, border `--border`, text `--fg-2`, `--r2`; hover bg `--panel-3`, border `--border-2`, `translateY(-1px)` |
+| `.block.is-source` | `opacity:.35` (keep) |
+| `.panel button` (e.g. `+ Page`) | secondary button tokens |
+| `.layer-row` / `:hover` / active | text `--fg-2`; hover `--panel-2`; active bg `--select-wash`, text `--fg`; tag label mono `--fg-3` |
+| `.layer-children` border | `--border` |
+| `.chip` | bg `--accent-wash`, text `--accent-strong`, border `color-mix(--accent 35%, transparent)`, mono, pill |
+| `.state-select`, `.trait input`, selects | `.ctl` style: `--panel-2`/`--border`/`--r2`; focus border `--accent` + `--accent-wash` ring |
+| `.property-list` / `code` | mono; remove the `!important` pile-up by scoping; value text `--accent-strong` or `--fg`; code bg `--panel-2` |
+| `.trait span` | `--fg-3`, uppercase, `letter-spacing:.05em` |
+| `.drop-indicator` border + `.drop-hint` | `--accent` (action) — was `#276ef1` |
+| `.drag-ghost` | bg `--accent`, text `--accent-ink` — was `#276ef1` |
+| `.overlay` | `background: oklch(0% 0 0 / .5)` |
+| `.overlay-card` | bg `--panel`, border `--border`, `--shadow-pop`, `--r3` |
+| `.asset-tile` / `:hover` | bg `--panel-2`, border `--border`; hover border `--accent` |
+| `.html-output` | bg `--panel-2`, text `--fg-2`, mono — **delete `color:#0f0`** |
+
+Selection of a component on the canvas (GrapesJS selection outline) → `--select`
+(see Section 9 for default mode).
+
+---
+
+## 8. Theme toggle (`app.ts` + `app.html`)
+
+`app.ts`:
+```ts
+import { DOCUMENT } from '@angular/common';
+// ...
+private doc = inject(DOCUMENT);
+protected theme = signal<'light' | 'dark'>('light');
+
+constructor() {
+  const saved = localStorage.getItem('gjs-demo-theme');
+  const initial = saved ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+  this.setTheme(initial as 'light' | 'dark');
+}
+toggleTheme(): void { this.setTheme(this.theme() === 'dark' ? 'light' : 'dark'); }
+private setTheme(t: 'light' | 'dark'): void {
+  this.theme.set(t);
+  this.doc.documentElement.setAttribute('data-theme', t);
+  try { localStorage.setItem('gjs-demo-theme', t); } catch {}
+}
+```
+`app.html` — add to the toolbar:
+```html
+
+```
+(Prefer inline monoline SVGs over glyphs — see the mockup's sun/moon paths.) Set
+`data-theme` on `` (documentElement), not on `demo-root`, so the GrapesJS
+panels inherit it.
+
+---
+
+## 9. GrapesJS default-mode theming (global `styles.css`)
+
+GrapesJS exposes CSS custom properties. **Verify the exact names for the
+installed version** first:
+
+```
+grep -o '\-\-gjs-[a-z0-9-]*' node_modules/grapesjs/dist/css/grapes.min.css | sort -u
+```
+
+Then map our tokens onto them globally (typical set — adjust to what the grep
+returns):
+
+```css
+:root {
+  --gjs-primary-color:    var(--panel);     /* panel backgrounds */
+  --gjs-secondary-color:  var(--fg-2);      /* icons / labels */
+  --gjs-tertiary-color:   var(--accent);    /* links / active */
+  --gjs-quaternary-color: var(--accent-strong); /* hover/active accent */
+  --gjs-font-color:        var(--fg);
+  --gjs-font-color-active: var(--fg);
+  --gjs-main-dark-color:   var(--panel-2);
+  --gjs-main-light-color:  var(--panel);
+}
+```
+
+For anything the variables don't reach (some elements use fixed colours), add a
+small set of scoped `.gjs-*` overrides in **global** `styles.css` — e.g.
+`.gjs-pn-panel`, `.gjs-sm-sector-title`, `.gjs-block`, `.gjs-layer-title`,
+toolbars, and the canvas selection/badge colours (target the badge/highlight to
+`--select`). Keep it to what's needed to make the two modes read as one product;
+don't rebuild GrapesJS.
+
+Acceptance for this section: flipping Custom → Default in either theme shows the
+same palette, type, and border language — no stock-grey panels.
+
+---
+
+## 10. Details / a11y
+- Contrast: body text ≥ 4.5:1, large/UI ≥ 3:1, in both themes (the tokens are
+  tuned for this; verify after wiring).
+- Visible focus ring on all interactive controls: `0 0 0 3px var(--accent-wash)`.
+- Respect `prefers-reduced-motion` for the hover lifts / dock slide.
+- Keep existing keyboard/drag behaviour intact.
+
+---
+
+## 11. Acceptance criteria
+- [ ] `npm run build` (or `ng build demo`) passes; app runs via the demo's start script.
+- [ ] No hardcoded hex in `app.css` except inside `var(--token)` definitions
+      (`grep -nE '#[0-9a-fA-F]{3,8}' projects/demo/src/app/app.css` → only token defs, ideally none).
+- [ ] `#0f0` and `#276ef1` are gone.
+- [ ] Theme toggle flips the whole editor (toolbar + both rails + GrapesJS
+      panels) and persists across reload.
+- [ ] Custom and Default modes share one visual language in both themes.
+- [ ] Save / Get HTML / Reset have clear primary / secondary / quiet hierarchy.
+- [ ] HTML export output is mono on a panel surface (no terminal green).
+- [ ] No console errors; drag-ghost, drop-indicator, asset/modal overlays still work.
diff --git a/projects/demo/redesign-mockup.html b/projects/demo/redesign-mockup.html
new file mode 100644
index 0000000..b399dcf
--- /dev/null
+++ b/projects/demo/redesign-mockup.html
@@ -0,0 +1,822 @@
+
+
+
+
+
+GrapesJS Angular — Editor
+
+
+
+
+
+
+
+ + +
+
+ GrapesJS Angular + v1.0 +
+ + + +
+ + + +
+ + + + + + + + +
+ + +
+ + + + + + + + + +
+ + +
+ + + + + +
+
+
+
+ + home.html + 1080 px +
+
+ +
+

Build Something Amazing

+

A simple, mobile-friendly starter. Drag, drop, and customise every block to make it your own.

+ Explore Features +
+ +
+

Features

+
+
+

Responsive Design

+

Looks great on any device — phone, tablet, or desktop. Layouts adapt to every screen size.

+
+
+

Easy to Customise

+

Change colours, text, and layout with the visual editor. No code required to make it yours.

+
+
+

Clean Export

+

Plain HTML and CSS with no heavy dependencies. Your site loads quickly and runs anywhere.

+
+
+
+ +
+

About Us

+

We believe building for the web should feel direct. Edit the copy, rearrange sections, or drop in your own components — the canvas is yours.

+
+ +
+
+
+
+ + + + +
+ + +
+ Selected: +
body
+
+ 100% + Saved ⌘S +
+
+ +
+ + +
+
+
+ +

Exported markup

+ HTML + + + +
+

+  
+
+ +
+ + + + From d3187976486976ad071b886d8cec6ec08bcf97f5 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 10:51:52 -0400 Subject: [PATCH 04/18] style(demo): add tokenized design system (light + dark) to global styles.css MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One :root token set (surfaces, text, lines, accent, selection, status, shadow, type, radii) plus a [data-theme=dark] override, defined after the grapes.min.css import so overrides win. Sets global body background/color/font. Nothing consumes the accent/panel tokens yet — wired in following commits. --- projects/demo/src/styles.css | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/projects/demo/src/styles.css b/projects/demo/src/styles.css index 968b692..b47131c 100644 --- a/projects/demo/src/styles.css +++ b/projects/demo/src/styles.css @@ -1,5 +1,68 @@ @import 'grapesjs/dist/css/grapes.min.css'; +/* ============================================================ + DESIGN TOKENS — one system drives the toolbar, both custom + rails, the HTML export output, and the GrapesJS default-mode + panels (themed further down). Defined after the grapes import + so our values win. Light is the default; [data-theme="dark"] + on swaps the palette. + ============================================================ */ +:root { + /* surfaces */ + --app-bg: oklch(95.5% 0.005 250); /* canvas backdrop */ + --panel: oklch(99.2% 0.002 250); /* toolbar + rails */ + --panel-2: oklch(97% 0.004 250); /* insets, tiles, inputs */ + --panel-3: oklch(94.2% 0.006 250); /* hover */ + /* text */ + --fg: oklch(26% 0.02 255); + --fg-2: oklch(47% 0.016 255); /* muted */ + --fg-3: oklch(60% 0.012 255); /* faint / captions */ + /* lines */ + --border: oklch(89% 0.007 255); + --border-2: oklch(83% 0.009 255); + /* accent — GrapesJS "grape" violet; the ONE brand signal */ + --accent: oklch(52% 0.17 300); /* fill (primary button) */ + --accent-strong: oklch(47% 0.16 300); /* accent text/icons on light */ + --accent-ink: oklch(99% 0.01 300); /* text on accent fill */ + --accent-wash: color-mix(in oklab, var(--accent) 14%, transparent); + /* canvas selection — deliberately a DIFFERENT hue from accent */ + --select: oklch(60% 0.17 250); + --select-wash: color-mix(in oklab, var(--select) 12%, transparent); + /* status */ + --success: oklch(58% 0.13 155); + --warn: oklch(66% 0.15 52); + --danger: oklch(57% 0.19 25); + /* effects */ + --shadow-pop: 0 12px 32px oklch(20% 0.03 255 / .18), 0 2px 6px oklch(20% 0.03 255 / .12); + /* type */ + --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + /* radii */ + --r1: 6px; --r2: 8px; --r3: 10px; --r4: 14px; +} + +html[data-theme="dark"] { + --app-bg: oklch(16.5% 0.012 260); + --panel: oklch(21.5% 0.013 260); + --panel-2: oklch(25.5% 0.013 260); + --panel-3: oklch(30% 0.015 260); + --fg: oklch(93% 0.01 250); + --fg-2: oklch(71% 0.014 250); + --fg-3: oklch(56% 0.014 250); + --border: oklch(30% 0.014 260); + --border-2: oklch(38% 0.016 260); + --accent: oklch(72% 0.16 300); + --accent-strong: oklch(78% 0.15 300); + --accent-ink: oklch(22% 0.05 300); + --accent-wash: color-mix(in oklab, var(--accent) 18%, transparent); + --select: oklch(70% 0.15 250); + --select-wash: color-mix(in oklab, var(--select) 18%, transparent); + --success: oklch(72% 0.14 155); + --warn: oklch(76% 0.15 62); + --danger: oklch(68% 0.17 25); + --shadow-pop: 0 16px 40px oklch(0% 0 0 / .5), 0 2px 8px oklch(0% 0 0 / .4); +} + *, *::before, *::after { box-sizing: border-box; } @@ -9,6 +72,14 @@ html, body { padding: 0; height: 100%; overflow: hidden; + background: var(--app-bg); + color: var(--fg); +} + +body { + font-family: var(--font-ui); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } /* Pin to the viewport regardless of body or ancestor sizing — From 0bf02856ffbe89132c35bc4e74a352b02bf09cc6 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 10:51:52 -0400 Subject: [PATCH 05/18] style(demo): load Inter + JetBrains Mono web fonts UI in Inter, mono surfaces (IDs, selectors, style values, HTML export) in JetBrains Mono, per the redesign spec. preconnect + display=swap. --- projects/demo/src/index.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/projects/demo/src/index.html b/projects/demo/src/index.html index db04c40..8c2d093 100644 --- a/projects/demo/src/index.html +++ b/projects/demo/src/index.html @@ -6,6 +6,12 @@ + + + From 03a93760c1f12364a344a70379128f2c06ae7666 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 10:57:28 -0400 Subject: [PATCH 06/18] feat(demo): add persisted light/dark theme toggle Toolbar icon button (inline sun/moon) flips data-theme on documentElement so both custom rails and the GrapesJS panels inherit it. Initial theme = saved choice, else OS prefers-color-scheme; persisted to localStorage. Editor logic untouched. --- projects/demo/src/app/app.html | 19 +++++++++++++++++++ projects/demo/src/app/app.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index c9aa099..0aac5cc 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -19,6 +19,25 @@ + +
diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index 8ecbeaa..911654e 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -1,5 +1,5 @@ import { Component, inject, signal } from '@angular/core'; -import { KeyValuePipe, NgTemplateOutlet } from '@angular/common'; +import { DOCUMENT, KeyValuePipe, NgTemplateOutlet } from '@angular/common'; import { GrapesJsEditorComponent, GrapesJsEditorService, @@ -42,6 +42,8 @@ import { SAMPLE_HTML, SAMPLE_CSS } from './sample-content'; }) export class App { private editorService = inject(GrapesJsEditorService); + private doc = inject(DOCUMENT); + protected theme = signal<'light' | 'dark'>('light'); protected htmlOutput = signal(''); protected customUi = signal(false); protected dragging = signal(false); @@ -109,10 +111,36 @@ export class App { console.log('[Demo] Project loaded', data); } + constructor() { + let saved: string | null = null; + try { + saved = localStorage.getItem('gjs-demo-theme'); + } catch { + /* storage unavailable (private mode / SSR) — fall back to OS preference */ + } + const prefersDark = + typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches; + this.setTheme((saved === 'light' || saved === 'dark' ? saved : prefersDark ? 'dark' : 'light')); + } + toggleCustomUi(): void { this.customUi.update((v) => !v); } + toggleTheme(): void { + this.setTheme(this.theme() === 'dark' ? 'light' : 'dark'); + } + + private setTheme(t: 'light' | 'dark'): void { + this.theme.set(t); + this.doc.documentElement.setAttribute('data-theme', t); + try { + localStorage.setItem('gjs-demo-theme', t); + } catch { + /* ignore persistence failures */ + } + } + /** Layers-panel row click. A GrapesJS Component has no `.select()` — selection * goes through the editor, which fires component:selected and refreshes the * Selectors / Styles / Traits panels. */ From 1e5b0071fbf63dbc3621b5e1b4f6b6c7c829842e Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 11:04:55 -0400 Subject: [PATCH 07/18] style(demo): rewrite custom-UI chrome to consume design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolbar gains a clear action hierarchy (Get HTML primary / Save secondary / Reset quiet-destructive) and a token-styled device select. Every custom-rail surface (panels, page list with accent active-bar, block tiles, layer tree, selector chips, state/trait controls, read-only style list, drag ghost, drop indicator, asset/modal overlays, HTML export) now maps to tokens — zero hardcoded hex, #0f0 and #276ef1 gone, and the .property-list !important pile-up is removed via scoping. Adds focus-visible rings and a prefers-reduced-motion guard. --- projects/demo/src/app/app.css | 571 +++++++++++++++++++++++---------- projects/demo/src/app/app.html | 6 +- 2 files changed, 399 insertions(+), 178 deletions(-) diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index ce70459..36c245f 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -1,51 +1,163 @@ +/* ============================================================ + Demo editor chrome — consumes the global design tokens from + styles.css (one system: toolbar + both custom rails + export + output, themed light/dark). Zero hardcoded colours here; the + GrapesJS default-mode panels are themed in global styles.css. + ============================================================ */ + +/* ---------- shared icon + control primitives ---------- */ +.ico { + width: 16px; + height: 16px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.btn, +.icon-btn, +.toggle { + font-family: var(--font-ui); +} + +:where(.btn, .icon-btn, .toggle, .device-select, .state-select, .trait input):focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +/* ============================================================ TOOLBAR */ .toolbar { display: flex; align-items: center; - gap: 8px; - padding: 8px; - background: #333; + gap: 6px; + height: 53px; + padding: 0 14px; + background: var(--panel); + border-bottom: 1px solid var(--border); } -.toolbar button { - padding: 6px 16px; - background: #555; - color: #fff; - border: 1px solid #666; - border-radius: 4px; +.btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 32px; + padding: 0 14px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--r2); + color: var(--fg); + font-size: 12.5px; + font-weight: 510; + letter-spacing: 0.01em; cursor: pointer; - font-size: 14px; + white-space: nowrap; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.04s ease; +} +.btn:hover { + background: var(--panel-3); + border-color: var(--border-2); +} +.btn:active { + transform: translateY(1px); } -.toolbar button:hover { - background: #666; +/* primary = the export action */ +.btn-primary { + background: var(--accent); + color: var(--accent-ink); + border-color: transparent; + box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.25); +} +.btn-primary:hover { + background: color-mix(in oklab, var(--accent) 88%, black); + border-color: transparent; } -.toolbar .device-select { - margin-left: 8px; - padding: 6px 10px; - background: #444; - color: #fff; - border: 1px solid #555; - border-radius: 4px; - font-size: 13px; +/* quiet / ghost (Reset), turns destructive on hover */ +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--fg-2); +} +.btn-ghost:hover { + background: var(--panel-2); + border-color: transparent; +} +.btn-ghost.btn-danger:hover { + color: var(--danger); } -.toolbar button.toggle { - margin-left: auto; - background: #276ef1; - border-color: #276ef1; +/* 32x32 icon button (theme toggle) */ +.icon-btn { + width: 32px; + height: 32px; + display: grid; + place-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--r2); + color: var(--fg-2); + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; +} +.icon-btn:hover { + background: var(--panel-2); + color: var(--fg); } -.toolbar button.toggle:hover { - background: #1e5acc; +/* Custom/Default switch — neutral, pushed to the right edge */ +.toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 14px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--r2); + color: var(--fg-2); + font-size: 12.5px; + font-weight: 510; + cursor: pointer; + white-space: nowrap; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} +.toggle:hover { + background: var(--panel-3); + border-color: var(--border-2); + color: var(--fg); +} + +/* device picker (custom mode only) */ +.device-select { + height: 32px; + padding: 0 28px 0 10px; + background-color: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--r2); + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 9px center; } +/* ============================================================ LAYOUT */ .editor-container { /* Grid track from gives this a real pixel height; turning it into a flex column lets the inner gjs-editor stretch reliably. */ display: flex; min-height: 0; overflow: hidden; + background: var(--app-bg); } .editor-container > gjs-editor { @@ -55,7 +167,6 @@ } /* ───── Custom UI mode ───── */ - .custom-ui-editor { display: block; height: 100%; @@ -69,26 +180,26 @@ .custom-sidebar { width: 280px; - background: #1e1e1e; - color: #eee; + background: var(--panel); + color: var(--fg); overflow-y: auto; - padding: 12px; + padding: 0; display: flex; flex-direction: column; gap: 0; } .custom-sidebar-left { - border-right: 1px solid #2b2b2b; + border-right: 1px solid var(--border); } .custom-sidebar-right { - border-left: 1px solid #2b2b2b; + border-left: 1px solid var(--border); } .custom-sidebar .panel { - padding: 12px 0; - border-bottom: 1px solid #2b2b2b; + padding: 12px 14px; + border-bottom: 1px solid var(--border); } .custom-sidebar .panel:last-child { @@ -96,221 +207,303 @@ } .custom-sidebar .panel h3 { - margin: 0 0 8px; - font-size: 13px; + margin: 0 0 9px; + font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.08em; - color: #9aa; + letter-spacing: 0.085em; + color: var(--fg-2); } .custom-sidebar .panel h4 { - margin: 12px 0 4px; - font-size: 11px; + margin: 11px 0 6px; + font-size: 10px; + font-weight: 600; text-transform: uppercase; - color: #778; + letter-spacing: 0.08em; + color: var(--fg-3); } -.custom-sidebar .panel .muted { - color: #888; - font-size: 12px; - margin: 0 0 8px; +.custom-sidebar .muted { + color: var(--fg-3); + font-size: 11.5px; + line-height: 1.45; + margin: 0 0 9px; } -.custom-sidebar .panel ul { +/* page list (interactive) — scoped so it never touches .property-list */ +.custom-sidebar .panel ul:not(.property-list) { list-style: none; padding: 0; - margin: 0 0 8px; + margin: 0 0 9px; } -.custom-sidebar .panel li { - padding: 6px 8px; - border-radius: 4px; +.custom-sidebar .panel ul:not(.property-list) li { + position: relative; + padding: 7px 9px; + border-radius: var(--r2); cursor: pointer; - font-size: 13px; + font-size: 12.5px; + font-weight: 500; + color: var(--fg-2); + transition: background 0.12s ease, color 0.12s ease; +} + +.custom-sidebar .panel ul:not(.property-list) li:hover { + background: var(--panel-2); + color: var(--fg); +} + +.custom-sidebar .panel ul:not(.property-list) li.active { + background: var(--panel-2); + color: var(--fg); } -.custom-sidebar .panel li.active, -.custom-sidebar .panel li:hover { - background: #2a2a2a; +.custom-sidebar .panel ul:not(.property-list) li.active::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2.5px; + border-radius: 2px; + background: var(--accent); } +/* blocks */ .custom-sidebar .block-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 6px; + gap: 7px; } .custom-sidebar .block { - background: #2a2a2a; - color: #ddd; - border: 1px solid #3a3a3a; - border-radius: 4px; - padding: 12px 8px; - font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + background: var(--panel-2); + color: var(--fg-2); + border: 1px solid var(--border); + border-radius: var(--r2); + padding: 14px 8px; + font-size: 11.5px; + font-weight: 500; cursor: grab; - transition: transform .05s ease, box-shadow .1s ease; + user-select: none; + transition: transform 0.08s ease, box-shadow 0.12s ease, border-color 0.12s ease, background 0.12s ease; } .custom-sidebar .block:hover { - background: #333; + background: var(--panel-3); + border-color: var(--border-2); + transform: translateY(-1px); + box-shadow: 0 4px 10px oklch(0% 0 0 / 0.06); } .custom-sidebar .block:active { cursor: grabbing; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + transform: translateY(0); } -.custom-sidebar .panel button { - background: #2a2a2a; - color: #ddd; - border: 1px solid #3a3a3a; - border-radius: 4px; - padding: 6px 10px; +/* secondary buttons inside panels (e.g. "+ Page") — direct children only, + so block tiles (nested under .category) keep their own styling */ +.custom-sidebar .panel > button { + height: 30px; + padding: 0 12px; + background: var(--panel-2); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r2); cursor: pointer; font-size: 12px; + font-weight: 510; + transition: background 0.12s ease, border-color 0.12s ease; } -.custom-sidebar .panel button:hover { - background: #333; +.custom-sidebar .panel > button:hover { + background: var(--panel-3); + border-color: var(--border-2); } -/* Layer tree */ +/* layer tree */ .layer .layer-row { display: block; width: 100%; text-align: left; background: transparent; border: 0; - color: #ddd; - padding: 4px 6px; - font-size: 12px; - border-radius: 3px; + color: var(--fg-2); + padding: 5px 8px; + font-size: 12.5px; + border-radius: var(--r1); cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; } .layer .layer-row:hover { - background: #2a2a2a; + background: var(--panel-2); + color: var(--fg); +} + +.layer .layer-row.active { + background: var(--select-wash); + color: var(--fg); } .layer-children { - margin-left: 12px; - border-left: 1px solid #2b2b2b; + margin-left: 11px; + border-left: 1px solid var(--border); padding-left: 4px; } -/* Selectors chips */ +/* selector chips */ .chips { display: flex; flex-wrap: wrap; - gap: 4px; - margin: 0 0 8px; + gap: 5px; + margin: 0 0 9px; } .chip { display: inline-flex; align-items: center; - gap: 4px; - background: #2a2a2a; - border: 1px solid #3a3a3a; - color: #ddd; + gap: 5px; + background: var(--accent-wash); + border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent); + color: var(--accent-strong); border-radius: 999px; - padding: 2px 4px 2px 8px; + padding: 3px 5px 3px 9px; + font-family: var(--font-mono); font-size: 11px; } .chip button { background: transparent; border: 0; - color: #aaa; + color: inherit; cursor: pointer; font-size: 12px; line-height: 1; - padding: 0 4px; + padding: 0 2px; + opacity: 0.7; } .chip button:hover { - color: #fff; + opacity: 1; } -.state-select { +/* form controls: state select + trait inputs share one .ctl-like look */ +.state-select, +.trait input { width: 100%; - background: #2a2a2a; - color: #ddd; - border: 1px solid #3a3a3a; - border-radius: 4px; - padding: 4px 6px; - font-size: 12px; -} - -/* Styles list */ -.property-list { - margin: 6px 0 0 0 !important; - padding-left: 12px !important; - list-style: disc !important; + height: 30px; + background: var(--panel-2); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r2); + padding: 0 9px; + font-size: 12.5px; + font-family: var(--font-ui); } -.property-list li { - padding: 2px 0 !important; - cursor: default !important; - font-size: 11px !important; - background: transparent !important; +.state-select { + -webkit-appearance: none; + appearance: none; + padding-right: 28px; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 9px center; } -.property-list code { - background: #141414; - padding: 1px 4px; - border-radius: 3px; - font-size: 10px; - color: #9cd; +/* styles list (read-only) — scoped, no !important needed */ +.custom-sidebar details { + border-top: 1px solid var(--border); } -.custom-sidebar details { - margin-bottom: 6px; +.custom-sidebar details:first-of-type { + border-top: 0; } .custom-sidebar details summary { + list-style: none; cursor: pointer; - font-size: 12px; - color: #ccc; - padding: 2px 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--fg-2); + padding: 9px 0; } -/* Traits inputs */ +.custom-sidebar details summary::-webkit-details-marker { + display: none; +} + +.custom-sidebar .property-list { + margin: 2px 0 8px; + padding: 0; + list-style: none; + font-family: var(--font-mono); +} + +.custom-sidebar .property-list li { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 5px 0; + font-size: 11.5px; + border-bottom: 1px dotted var(--border); +} + +.custom-sidebar .property-list li:last-child { + border-bottom: 0; +} + +.custom-sidebar .property-list li span { + color: var(--fg-2); +} + +.custom-sidebar .property-list code { + background: var(--panel-2); + color: var(--accent-strong); + padding: 1px 5px; + border-radius: var(--r1); + font-family: var(--font-mono); + font-size: 11px; +} + +/* traits */ .trait { display: flex; flex-direction: column; - gap: 2px; - margin-bottom: 8px; + gap: 6px; + margin-bottom: 11px; } .trait span { - font-size: 11px; - color: #9aa; + font-size: 10.5px; + font-weight: 560; + color: var(--fg-3); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.07em; } -.trait input { - background: #2a2a2a; - color: #ddd; - border: 1px solid #3a3a3a; - border-radius: 4px; - padding: 5px 8px; - font-size: 12px; -} - -.trait input:focus { +.trait input:focus, +.state-select:focus, +.device-select:focus { outline: none; - border-color: #276ef1; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); } -/* Canvas + drag indicator */ +/* ---------- canvas + drag indicator ---------- */ .canvas-wrap { position: relative; flex: 1; min-width: 0; display: flex; + background: var(--app-bg); } .custom-canvas { @@ -332,25 +525,27 @@ display: flex; align-items: center; justify-content: center; - transition: border-color .12s ease, background-color .12s ease; + transition: border-color 0.12s ease, background-color 0.12s ease; } .drop-indicator .drop-hint { - background: #276ef1; - color: #fff; + background: var(--accent); + color: var(--accent-ink); border-radius: 999px; padding: 6px 14px; - font-size: 13px; - letter-spacing: 0.04em; + font-size: 11px; + font-weight: 560; + letter-spacing: 0.03em; text-transform: uppercase; opacity: 0; transform: translateY(-4px); - transition: opacity .12s ease, transform .12s ease; + transition: opacity 0.12s ease, transform 0.12s ease; + box-shadow: var(--shadow-pop); } .custom-layout.is-dragging .drop-indicator { - border-color: #276ef1; - background-color: rgba(39, 110, 241, 0.06); + border-color: var(--accent); + background-color: var(--accent-wash); } .custom-layout.is-dragging .drop-indicator .drop-hint { @@ -379,23 +574,23 @@ position: fixed; z-index: 9999; pointer-events: none; - background: #276ef1; - color: #fff; + background: var(--accent); + color: var(--accent-ink); padding: 6px 12px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; + border-radius: var(--r1); + font-size: 11px; + font-weight: 560; letter-spacing: 0.02em; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); + box-shadow: var(--shadow-pop); white-space: nowrap; user-select: none; } -/* Floating overlays (Assets, Modal) */ +/* ---------- floating overlays (Assets, Modal) ---------- */ .overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: oklch(0% 0 0 / 0.5); display: flex; align-items: center; justify-content: center; @@ -403,14 +598,15 @@ } .overlay-card { - background: #1e1e1e; - color: #eee; - border-radius: 6px; + background: var(--panel); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r3); width: 90%; max-width: 720px; max-height: 80vh; overflow: auto; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-pop); display: flex; flex-direction: column; } @@ -420,21 +616,22 @@ align-items: center; justify-content: space-between; padding: 12px 16px; - border-bottom: 1px solid #2b2b2b; + border-bottom: 1px solid var(--border); } .overlay-card header h3 { margin: 0; - font-size: 14px; + font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.08em; - color: #9aa; + letter-spacing: 0.085em; + color: var(--fg-2); } .overlay-card header button { background: transparent; border: 0; - color: #aaa; + color: var(--fg-3); font-size: 20px; line-height: 1; cursor: pointer; @@ -442,7 +639,7 @@ } .overlay-card header button:hover { - color: #fff; + color: var(--fg); } .overlay-card .modal-body, @@ -458,12 +655,13 @@ .asset-tile { aspect-ratio: 1; - background: #2a2a2a; - border: 1px solid #3a3a3a; - border-radius: 4px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--r2); padding: 0; cursor: pointer; overflow: hidden; + transition: border-color 0.12s ease; } .asset-tile img { @@ -474,16 +672,39 @@ } .asset-tile:hover { - border-color: #276ef1; + border-color: var(--accent); } -/* HTML preview output */ +/* ---------- HTML export output ---------- */ .html-output { margin: 0; - padding: 12px; - background: #1a1a1a; - color: #0f0; + padding: 14px 16px; + background: var(--panel-2); + color: var(--fg-2); + border-top: 1px solid var(--border); + font-family: var(--font-mono); font-size: 12px; - max-height: 200px; + line-height: 1.6; + max-height: 230px; overflow: auto; } + +/* ---------- a11y: respect reduced-motion ---------- */ +@media (prefers-reduced-motion: reduce) { + .btn, + .icon-btn, + .toggle, + .custom-sidebar .block, + .layer .layer-row, + .drop-indicator, + .drop-indicator .drop-hint, + .asset-tile { + transition: none; + } + .btn:active { + transform: none; + } + .custom-sidebar .block:hover { + transform: none; + } +} diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index 0aac5cc..77d3f7e 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -1,7 +1,7 @@
- - - + + + @if (customUi()) { From bdaa7eb8a58cf5084c7443a3585c9e9c82879f67 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 11:12:11 -0400 Subject: [PATCH 08/18] style(demo): theme GrapesJS default-mode panels to match custom UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map the engine's --gjs-* variables (verified against the installed build) onto our design tokens so they re-resolve on theme flip, plus minimal scoped .gjs-* overrides for the colours GrapesJS hardcodes (panel/title/field borders, canvas selection badge -> --select). Flipping Custom <-> Default in either light or dark now reads as one product — no stock-grey panels. --- projects/demo/src/styles.css | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/projects/demo/src/styles.css b/projects/demo/src/styles.css index b47131c..ef7560e 100644 --- a/projects/demo/src/styles.css +++ b/projects/demo/src/styles.css @@ -63,6 +63,93 @@ html[data-theme="dark"] { --shadow-pop: 0 16px 40px oklch(0% 0 0 / .5), 0 2px 8px oklch(0% 0 0 / .4); } +/* ============================================================ + GrapesJS DEFAULT-MODE theming — map the engine's own CSS + variables (names verified against the installed grapesjs + build) onto our tokens, so flipping Custom <-> Default in + either theme reads as one product. These reference our tokens, + so they re-resolve automatically when [data-theme] flips. + GrapesJS ships a dark theme where *-light-color vars are + white-alpha overlays (invisible on a light panel) and + *-dark-color vars are black-alpha insets — both are remapped + to real surface/text tokens here. + ============================================================ */ +:root { + --gjs-primary-color: var(--panel); /* panel backgrounds */ + --gjs-secondary-color: var(--fg-2); /* icons / labels */ + --gjs-tertiary-color: var(--accent); /* links / active */ + --gjs-quaternary-color: var(--accent-strong);/* hover / active accent */ + --gjs-font-color: var(--fg); + --gjs-font-color-active: var(--fg); + --gjs-main-color: var(--panel); + --gjs-main-dark-color: var(--panel-2); /* inputs, insets */ + --gjs-secondary-dark-color: var(--panel-2); /* category titles, layer rows */ + --gjs-main-light-color: var(--border); /* was white-alpha highlight */ + --gjs-secondary-light-color: var(--fg-2); /* muted text — was invisible on white */ + --gjs-soft-light-color: var(--panel-2); + --gjs-light-border: var(--border); + --gjs-arrow-color: var(--fg-2); + --gjs-color-highlight: var(--select); /* canvas selection highlight */ + --gjs-color-blue: var(--select); + --gjs-placeholder-background-color: var(--accent); + --gjs-main-font: var(--font-ui); + --gjs-font-size: 12px; +} + +/* Overrides for colours GrapesJS hardcodes (baked rgba, not var-driven). + Scoped to .gjs-* so they only reach default-mode panels. */ +.gjs-pn-panel, +.gjs-pn-views, +.gjs-pn-views-container, +.gjs-pn-options, +.gjs-pn-commands { + border-color: var(--border) !important; +} +.gjs-category-title, +.gjs-layer-title, +.gjs-block-category .gjs-title, +.gjs-sm-sector-title, +.gjs-trait-category .gjs-title, +.gjs-clm-tags-field, +.gjs-sm-sector .gjs-sm-title { + border-bottom: 1px solid var(--border); + color: var(--fg-2); +} +.gjs-layer-item, +.gjs-block { + border-color: var(--border); +} +.gjs-sm-sector, +.gjs-clm-tags, +.gjs-trt-traits { + border-color: var(--border); +} +/* form fields: kill the baked black/white-alpha border + shadow */ +.gjs-field, +.gjs-sm-field, +.gjs-clm-field, +.gjs-clm-select, +.gjs-sm-field input, +.gjs-field input, +.gjs-field select { + border: 1px solid var(--border) !important; + box-shadow: none !important; + color: var(--fg); +} +/* canvas selection chrome → --select (distinct from accent) */ +.gjs-comp-selected, +.gjs-comp-selected-parent { + outline-color: var(--select) !important; +} +.gjs-badge, +.gjs-toolbar { + background-color: var(--select) !important; + color: var(--accent-ink) !important; +} +.gjs-resizer-h { + border-color: var(--select) !important; +} + *, *::before, *::after { box-sizing: border-box; } From 4e843e1d5970995052fd2b607c18a522a596ad84 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 11:24:12 -0400 Subject: [PATCH 09/18] feat(demo): segmented mode switch and slide-up HTML export dock Replace the single Custom/Default button with a real 2-segment control, and the bare
 export with a slide-up dock (header + HTML pill + Copy + close) on a panel surface. Reuses existing editor logic via a thin setCustomUi(boolean) and a copyHtml() clipboard helper. Slide animation respects prefers-reduced-motion.
---
 projects/demo/src/app/app.css  | 112 ++++++++++++++++++++++++++++-----
 projects/demo/src/app/app.html |  25 ++++++--
 projects/demo/src/app/app.ts   |   9 ++-
 3 files changed, 124 insertions(+), 22 deletions(-)

diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css
index 36c245f..0aea56d 100644
--- a/projects/demo/src/app/app.css
+++ b/projects/demo/src/app/app.css
@@ -19,11 +19,11 @@
 
 .btn,
 .icon-btn,
-.toggle {
+.seg button {
   font-family: var(--font-ui);
 }
 
-:where(.btn, .icon-btn, .toggle, .device-select, .state-select, .trait input):focus-visible {
+:where(.btn, .icon-btn, .seg button, .device-select, .state-select, .trait input):focus-visible {
   outline: none;
   border-color: var(--accent);
   box-shadow: 0 0 0 3px var(--accent-wash);
@@ -109,27 +109,44 @@
   color: var(--fg);
 }
 
-/* Custom/Default switch — neutral, pushed to the right edge */
-.toggle {
-  margin-left: auto;
+/* segmented control (Custom/Default mode switch) */
+.seg {
   display: inline-flex;
-  align-items: center;
-  height: 32px;
-  padding: 0 14px;
+  gap: 2px;
+  padding: 2px;
   background: var(--panel-2);
   border: 1px solid var(--border);
   border-radius: var(--r2);
+}
+.mode-seg {
+  margin-left: auto;
+}
+.seg button {
+  height: 26px;
+  min-width: 32px;
+  padding: 0 11px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: transparent;
+  border: 0;
+  border-radius: 6px;
   color: var(--fg-2);
-  font-size: 12.5px;
+  font-size: 12px;
   font-weight: 510;
   cursor: pointer;
-  white-space: nowrap;
-  transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease;
+  transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
 }
-.toggle:hover {
-  background: var(--panel-3);
-  border-color: var(--border-2);
+.seg button:hover {
+  color: var(--fg);
+}
+.seg button.on {
+  background: var(--panel);
   color: var(--fg);
+  box-shadow: 0 1px 2px oklch(0% 0 0 / 0.08);
+}
+html[data-theme="dark"] .seg button.on {
+  background: var(--panel-3);
 }
 
 /* device picker (custom mode only) */
@@ -675,13 +692,73 @@
   border-color: var(--accent);
 }
 
+/* ---------- HTML export dock (slide-up) ---------- */
+.export-dock {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1100;
+  background: var(--panel);
+  border-top: 1px solid var(--border-2);
+  box-shadow: 0 -16px 40px oklch(0% 0 0 / 0.14);
+  animation: export-dock-up 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes export-dock-up {
+  from {
+    transform: translateY(100%);
+  }
+  to {
+    transform: translateY(0);
+  }
+}
+
+.export-dock-hd {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--border);
+}
+
+.export-dock-hd .ico {
+  color: var(--accent-strong);
+}
+
+.export-dock-hd h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: 0.01em;
+  color: var(--fg);
+}
+
+.export-dock-hd .lang {
+  font-family: var(--font-mono);
+  font-size: 10px;
+  color: var(--fg-3);
+  background: var(--panel-2);
+  border: 1px solid var(--border);
+  padding: 1px 7px;
+  border-radius: 999px;
+}
+
+.export-dock-hd .sp {
+  flex: 1;
+}
+
+.export-dock-hd .btn {
+  height: 28px;
+  padding: 0 12px;
+}
+
 /* ---------- HTML export output ---------- */
 .html-output {
   margin: 0;
   padding: 14px 16px;
   background: var(--panel-2);
   color: var(--fg-2);
-  border-top: 1px solid var(--border);
   font-family: var(--font-mono);
   font-size: 12px;
   line-height: 1.6;
@@ -693,7 +770,7 @@
 @media (prefers-reduced-motion: reduce) {
   .btn,
   .icon-btn,
-  .toggle,
+  .seg button,
   .custom-sidebar .block,
   .layer .layer-row,
   .drop-indicator,
@@ -707,4 +784,7 @@
   .custom-sidebar .block:hover {
     transform: none;
   }
+  .export-dock {
+    animation: none;
+  }
 }
diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html
index 77d3f7e..786ce1b 100644
--- a/projects/demo/src/app/app.html
+++ b/projects/demo/src/app/app.html
@@ -16,9 +16,10 @@
       
     
   }
-  
+  
+ + +
+ +
+
{{ htmlOutput() }}
+
} @if (dragging() && draggedLabel(); as label) { diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index 911654e..5347216 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -123,8 +123,8 @@ export class App { this.setTheme((saved === 'light' || saved === 'dark' ? saved : prefersDark ? 'dark' : 'light')); } - toggleCustomUi(): void { - this.customUi.update((v) => !v); + setCustomUi(custom: boolean): void { + this.customUi.set(custom); } toggleTheme(): void { @@ -175,6 +175,11 @@ export class App { this.downloadFile('grapesjs-export.html', fullHtml, 'text/html'); } + copyHtml(): void { + const html = this.htmlOutput(); + if (html) navigator.clipboard?.writeText(html); + } + reset(): void { const editor = this.editorService.editor(); if (!editor) return; From 055722dbfbd7e0fdef45efdce00e5a5f10eddec9 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 12:21:40 -0400 Subject: [PATCH 10/18] fix(demo): persist project across Custom/Default switch + auto-select drops Each Custom<->Default toggle recreates the gjs-editor, and onEditorReady was unconditionally re-seeding the sample, wiping edits. Now snapshot the project on switch (setCustomUi) and restore it in the new instance; seed the sample only on first load and on Reset. Also select the dropped component on block:drag:stop so its styles/traits populate without a second click. Verified: a component added in one mode survives the switch (and back) into the recreated editor. --- projects/demo/src/app/app.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index 5347216..513ab2e 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -54,6 +54,10 @@ export class App { private frameEl: HTMLIFrameElement | null = null; private frameDoc: Document | null = null; + /** Holds the project across a Custom <-> Default UI switch (each switch + * recreates the editor). null = first load, so seed the sample content. */ + private savedProject: ProjectData | null = null; + private trackCursor = (e: MouseEvent) => { this.cursorX.set(e.clientX); this.cursorY.set(e.clientY); @@ -69,19 +73,28 @@ export class App { }; onEditorReady(editor: Editor): void { - editor.setComponents(SAMPLE_HTML); - editor.setStyle(SAMPLE_CSS); + // Restore the in-progress project when switching Custom <-> Default UI; + // only seed the sample on first load (savedProject === null). + if (this.savedProject) { + editor.loadProjectData(this.savedProject); + } else { + editor.setComponents(SAMPLE_HTML); + editor.setStyle(SAMPLE_CSS); + } // Capture once for cross-frame mousemove tracking during drags. this.frameEl = editor.Canvas.getFrameEl() ?? null; editor.on('block:drag:start', () => this.dragging.set(true)); - editor.on('block:drag:stop', () => { + editor.on('block:drag:stop', (component?: GjsComponent) => { this.dragging.set(false); this.draggedLabel.set(null); document.removeEventListener('mousemove', this.trackCursor); this.frameDoc?.removeEventListener('mousemove', this.trackCursorInFrame); this.frameDoc = null; + // Auto-select the dropped element so its styles/traits populate without + // a second click. `component` is undefined when the drop was cancelled. + if (component) editor.select(component); }); } @@ -124,6 +137,10 @@ export class App { } setCustomUi(custom: boolean): void { + if (custom === this.customUi()) return; + // Snapshot the project before the switch tears down this editor instance, + // so the next one restores it (see onEditorReady) instead of re-seeding. + this.savedProject = this.editorService.getProjectData(); this.customUi.set(custom); } @@ -183,6 +200,7 @@ export class App { reset(): void { const editor = this.editorService.editor(); if (!editor) return; + this.savedProject = null; editor.setComponents(SAMPLE_HTML); editor.setStyle(SAMPLE_CSS); this.htmlOutput.set(''); From ecd78e693f7cd8971dff1233e420834402c8c098 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 12:36:17 -0400 Subject: [PATCH 11/18] feat(demo): make the Styles inspector editable + polish Selectors panel The custom-UI right rail was read-only, so the State dropdown had nothing to affect. Each style property now renders an editable control (select for select/radio types via getOptions, text input otherwise, read-only value for composite/stack shorthands), bound to property.upValue(). Editing drives the canvas; switching State now writes state-specific rules (e.g. :hover). Selectors panel gets a mono targets readout, styled chips, a labeled State field, and a proper empty state. Both panels gate on the current selection. Verified live: editing font-size/text-align updates the canvas; selecting hover state + editing creates a :hover rule. --- projects/demo/src/app/app.css | 59 ++++++++++++--------- projects/demo/src/app/app.html | 96 +++++++++++++++++++++------------- projects/demo/src/app/app.ts | 3 ++ 3 files changed, 99 insertions(+), 59 deletions(-) diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index 0aea56d..0a968c6 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -23,7 +23,7 @@ font-family: var(--font-ui); } -:where(.btn, .icon-btn, .seg button, .device-select, .state-select, .trait input):focus-visible { +:where(.btn, .icon-btn, .seg button, .device-select, .ctl, .trait input):focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); @@ -409,8 +409,8 @@ html[data-theme="dark"] .seg button.on { opacity: 1; } -/* form controls: state select + trait inputs share one .ctl-like look */ -.state-select, +/* form controls: inspector inputs/selects + trait inputs share one look */ +.ctl, .trait input { width: 100%; height: 30px; @@ -423,7 +423,7 @@ html[data-theme="dark"] .seg button.on { font-family: var(--font-ui); } -.state-select { +select.ctl { -webkit-appearance: none; appearance: none; padding-right: 28px; @@ -457,37 +457,48 @@ html[data-theme="dark"] .seg button.on { display: none; } -.custom-sidebar .property-list { - margin: 2px 0 8px; - padding: 0; - list-style: none; - font-family: var(--font-mono); +/* editable inspector fields (Styles + Selectors state) */ +.style-fields { + display: flex; + flex-direction: column; + gap: 9px; + padding: 4px 0 10px; } -.custom-sidebar .property-list li { +.style-field { display: flex; - justify-content: space-between; - gap: 10px; - padding: 5px 0; - font-size: 11.5px; - border-bottom: 1px dotted var(--border); + flex-direction: column; + gap: 5px; } -.custom-sidebar .property-list li:last-child { - border-bottom: 0; +.style-field > span { + font-size: 10.5px; + font-weight: 560; + color: var(--fg-3); + text-transform: uppercase; + letter-spacing: 0.06em; } -.custom-sidebar .property-list li span { +/* read-only value for composite/stack properties (shorthands we don't edit) */ +.style-ro { + font-family: var(--font-mono); + font-size: 11.5px; color: var(--fg-2); + padding: 5px 0 1px; + word-break: break-all; } -.custom-sidebar .property-list code { - background: var(--panel-2); - color: var(--accent-strong); - padding: 1px 5px; - border-radius: var(--r1); +/* selector targets readout */ +.targets { font-family: var(--font-mono); font-size: 11px; + color: var(--fg-2); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--r2); + padding: 7px 9px; + margin-bottom: 9px; + word-break: break-all; } /* traits */ @@ -506,8 +517,8 @@ html[data-theme="dark"] .seg button.on { letter-spacing: 0.07em; } +.ctl:focus, .trait input:focus, -.state-select:focus, .device-select:focus { outline: none; border-color: var(--accent); diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index 786ce1b..33d0c82 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -125,26 +125,33 @@

Layers

Selectors

-

{{ ctx.targets.join(' ') || '— no selection —' }}

-
- @for (s of ctx.selectors; track s.cid) { - - {{ s.getFullName() }} - - - } -
- + @if (!selectedComponent()) { +

Select an element to edit its selectors and state.

+ } @else { +
{{ ctx.targets.join(' ') || '—' }}
+
+ @for (s of ctx.selectors; track s.cid) { + + {{ s.getFullName() }} + + + } +
+ + }
@@ -153,21 +160,40 @@

Selectors

Styles

- @if (!ctx.sectors.length) { -

Select a component to inspect its styles.

- } - @for (sector of ctx.sectors; track sector.cid) { -
- {{ sector.getName() }} -
    - @for (prop of sector.getProperties(); track prop.cid) { -
  • - {{ prop.getName() }}: - {{ prop.getValue() ?? '—' }} -
  • - } -
-
+ @if (!selectedComponent()) { +

Select an element to edit its styles.

+ } @else { + @for (sector of ctx.sectors; track sector.cid) { +
+ {{ sector.getName() }} +
+ @for (prop of sector.getProperties(); track prop.cid) { + + } +
+
+ } }
diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index 513ab2e..f790f94 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -43,6 +43,9 @@ import { SAMPLE_HTML, SAMPLE_CSS } from './sample-content'; export class App { private editorService = inject(GrapesJsEditorService); private doc = inject(DOCUMENT); + /** The component currently selected on the canvas (null = nothing selected). + * Drives the inspector's empty state. */ + protected selectedComponent = this.editorService.selectedComponent; protected theme = signal<'light' | 'dark'>('light'); protected htmlOutput = signal(''); protected customUi = signal(false); From 5dabd77ad3523d6dd7f4a5fe453884956fec7d17 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 12:50:45 -0400 Subject: [PATCH 12/18] feat(demo): icon block tiles in the custom-UI block panel Render each block's own GrapesJS media SVG as the tile icon (fill=currentColor, so it themes), memoized per block id through DomSanitizer. Block tiles become icon-over-label, matching the reference mockup. --- projects/demo/src/app/app.css | 22 ++++++++++++++++++++-- projects/demo/src/app/app.html | 3 ++- projects/demo/src/app/app.ts | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index 0a968c6..b2d7f02 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -296,20 +296,38 @@ html[data-theme="dark"] .seg button.on { .custom-sidebar .block { display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 7px; + height: 62px; background: var(--panel-2); color: var(--fg-2); border: 1px solid var(--border); border-radius: var(--r2); - padding: 14px 8px; - font-size: 11.5px; + font-size: 11px; font-weight: 500; cursor: grab; user-select: none; transition: transform 0.08s ease, box-shadow 0.12s ease, border-color 0.12s ease, background 0.12s ease; } +.custom-sidebar .block-ico { + display: grid; + place-items: center; + color: var(--fg-3); +} + +.custom-sidebar .block-ico svg { + width: 22px; + height: 22px; + display: block; +} + +.custom-sidebar .block:hover .block-ico { + color: var(--accent-strong); +} + .custom-sidebar .block:hover { background: var(--panel-3); border-color: var(--border-2); diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index 33d0c82..f8a00bb 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -89,7 +89,8 @@

{{ entry.key }}

[class.is-source]="dragging() && draggedLabel() === block.getLabel()" (mousedown)="startBlockDrag(block, $event, ctx.dragStart)" (mouseup)="ctx.dragStop()"> - {{ block.getLabel() }} + + {{ block.getLabel() }} } diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index f790f94..f9bef05 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -1,5 +1,6 @@ import { Component, inject, signal } from '@angular/core'; import { DOCUMENT, KeyValuePipe, NgTemplateOutlet } from '@angular/common'; +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; import { GrapesJsEditorComponent, GrapesJsEditorService, @@ -43,6 +44,10 @@ import { SAMPLE_HTML, SAMPLE_CSS } from './sample-content'; export class App { private editorService = inject(GrapesJsEditorService); private doc = inject(DOCUMENT); + private sanitizer = inject(DomSanitizer); + /** Memoized block icons — the media SVG is trusted (GrapesJS-authored) and + * block-stable, so sanitize once per block id rather than every render. */ + private blockIconCache = new Map(); /** The component currently selected on the canvas (null = nothing selected). * Drives the inspector's empty state. */ protected selectedComponent = this.editorService.selectedComponent; @@ -168,6 +173,17 @@ export class App { this.editorService.editor()?.select(c); } + /** The block's icon (its GrapesJS `media` SVG), for the block tiles. */ + blockIcon(block: Block): SafeHtml { + const id = block.getId(); + let icon = this.blockIconCache.get(id); + if (!icon) { + icon = this.sanitizer.bypassSecurityTrustHtml(String(block.get('media') ?? '')); + this.blockIconCache.set(id, icon); + } + return icon; + } + isNode(value: unknown): value is Node { return value instanceof Node; } From e3190747a97049ac5e10ac17514a17d8a26ab736 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 15:19:53 -0400 Subject: [PATCH 13/18] fix(demo): reset block-drag state on sorter:drag:end + ghost shows block icon In custom-UI mode the block drag ends via sorter:drag:end, NOT block:drag:stop (which the cleanup was listening for). So dragging never reset: the source button stayed dimmed/'active', the floating ghost stuck at a stale position (looking like 'no ghost' on later drags), and the drop never auto-selected. Clean up on sorter:drag:end instead, and auto-select the dropped element via component:add while the drag is active. The ghost now carries the block's icon so it's clearly visible as you drag. Verified via the editor event lifecycle: block:drag:start sets dragging; a component added mid-drag auto-selects; sorter:drag:end resets dragging/label/icon. --- projects/demo/src/app/app.css | 20 +++++++++++++++++--- projects/demo/src/app/app.html | 5 +++-- projects/demo/src/app/app.ts | 29 ++++++++++++++++++++--------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index b2d7f02..dd00ce3 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -620,11 +620,14 @@ select.ctl { position: fixed; z-index: 9999; pointer-events: none; + display: flex; + align-items: center; + gap: 7px; background: var(--accent); color: var(--accent-ink); - padding: 6px 12px; - border-radius: var(--r1); - font-size: 11px; + padding: 7px 12px; + border-radius: var(--r2); + font-size: 11.5px; font-weight: 560; letter-spacing: 0.02em; box-shadow: var(--shadow-pop); @@ -632,6 +635,17 @@ select.ctl { user-select: none; } +.drag-ghost-ico { + display: grid; + place-items: center; +} + +.drag-ghost-ico svg { + width: 15px; + height: 15px; + display: block; +} + /* ---------- floating overlays (Assets, Modal) ---------- */ .overlay { position: fixed; diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index f8a00bb..8f135c4 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -317,8 +317,9 @@

Exported markup

@if (dragging() && draggedLabel(); as label) {
+ [style.left.px]="cursorX() + 14" + [style.top.px]="cursorY() + 14"> + {{ label }}
} diff --git a/projects/demo/src/app/app.ts b/projects/demo/src/app/app.ts index f9bef05..3812c8d 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -56,6 +56,7 @@ export class App { protected customUi = signal(false); protected dragging = signal(false); protected draggedLabel = signal(null); + protected draggedIcon = signal(null); protected cursorX = signal(0); protected cursorY = signal(0); @@ -94,24 +95,34 @@ export class App { this.frameEl = editor.Canvas.getFrameEl() ?? null; editor.on('block:drag:start', () => this.dragging.set(true)); - editor.on('block:drag:stop', (component?: GjsComponent) => { - this.dragging.set(false); - this.draggedLabel.set(null); - document.removeEventListener('mousemove', this.trackCursor); - this.frameDoc?.removeEventListener('mousemove', this.trackCursorInFrame); - this.frameDoc = null; - // Auto-select the dropped element so its styles/traits populate without - // a second click. `component` is undefined when the drop was cancelled. - if (component) editor.select(component); + // In custom-UI mode the block drag ends via the sorter — `block:drag:stop` + // does NOT fire here — so clean up on `sorter:drag:end` instead. + editor.on('sorter:drag:end', () => this.endBlockDrag()); + // Auto-select whatever the drag just added, so its styles/traits populate + // without a second click. This fires before sorter:drag:end (while the drag + // is still flagged), and is a no-op for non-drag additions (e.g. seeding). + editor.on('component:add', (component: GjsComponent) => { + if (this.dragging()) editor.select(component); }); } + /** Reset all drag state. Called on sorter:drag:end (drop or cancel). */ + private endBlockDrag(): void { + this.dragging.set(false); + this.draggedLabel.set(null); + this.draggedIcon.set(null); + document.removeEventListener('mousemove', this.trackCursor); + this.frameDoc?.removeEventListener('mousemove', this.trackCursorInFrame); + this.frameDoc = null; + } + /** Block-button mousedown wrapper: capture the label for the floating ghost, * start tracking the cursor in BOTH the host document and the canvas * iframe's document (so the ghost still tracks once the cursor crosses * into the iframe), then forward to GrapesJS via the provider. */ startBlockDrag(block: Block, ev: MouseEvent, dragStart: (b: Block, e?: Event) => void): void { this.draggedLabel.set(block.getLabel()); + this.draggedIcon.set(this.blockIcon(block)); this.cursorX.set(ev.clientX); this.cursorY.set(ev.clientY); document.addEventListener('mousemove', this.trackCursor); From a03345c951be971cee01122eb5268335a88bc270 Mon Sep 17 00:00:00 2001 From: Julian Fraser Date: Sat, 20 Jun 2026 15:44:34 -0400 Subject: [PATCH 14/18] fix(demo): hide textnode layers + use GrapesJS's real drop indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers: filter out non-layerable nodes (textnodes that surfaced as meaningless 'Box' rows), mirroring GrapesJS's own layer manager. Verified: 0 'Box' rows remain. Drag: drop the demo's full-canvas 'DROP HERE' overlay (it covered the real indicator and the badge sat centered, not at the cursor). Rely on GrapesJS's own .gjs-placeholder — a precise insertion line at the drop point — themed to --accent in global styles.css. The floating ghost (icon + label) still follows the cursor. --- projects/demo/src/app/app.css | 48 +++------------------------------- projects/demo/src/app/app.html | 7 ++--- projects/demo/src/app/app.ts | 7 +++++ projects/demo/src/styles.css | 9 +++++++ 4 files changed, 21 insertions(+), 50 deletions(-) diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index dd00ce3..5ce4725 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -558,49 +558,9 @@ select.ctl { min-width: 0; } -/* Overlay layered on top of the GrapesJS iframe so the drop indicator is - actually visible. The iframe creates its own painting layer, so without an - explicit z-index the indicator paints behind the iframe content and only - the outer edges (where the iframe doesn't extend) remain visible. */ -.drop-indicator { - position: absolute; - inset: 0; - z-index: 20; - border: 2px dashed transparent; - pointer-events: none; - display: flex; - align-items: center; - justify-content: center; - transition: border-color 0.12s ease, background-color 0.12s ease; -} - -.drop-indicator .drop-hint { - background: var(--accent); - color: var(--accent-ink); - border-radius: 999px; - padding: 6px 14px; - font-size: 11px; - font-weight: 560; - letter-spacing: 0.03em; - text-transform: uppercase; - opacity: 0; - transform: translateY(-4px); - transition: opacity 0.12s ease, transform 0.12s ease; - box-shadow: var(--shadow-pop); -} - -.custom-layout.is-dragging .drop-indicator { - border-color: var(--accent); - background-color: var(--accent-wash); -} - -.custom-layout.is-dragging .drop-indicator .drop-hint { - opacity: 1; - transform: translateY(0); -} - -/* Cue at the cursor too — the overlay outline isn't always in peripheral - vision when the user has eyes on the block they're dragging. */ +/* While dragging a block, show the grabbing cursor everywhere. The actual + insertion indicator is GrapesJS's own themed `.gjs-placeholder` (see global + styles.css) — a precise line at the drop point, not a full-canvas overlay. */ .custom-layout.is-dragging, .custom-layout.is-dragging * { cursor: grabbing !important; @@ -816,8 +776,6 @@ select.ctl { .seg button, .custom-sidebar .block, .layer .layer-row, - .drop-indicator, - .drop-indicator .drop-hint, .asset-tile { transition: none; } diff --git a/projects/demo/src/app/app.html b/projects/demo/src/app/app.html index 8f135c4..c9dd329 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -116,9 +116,6 @@

Layers

-