diff --git a/CHANGELOG.md b/CHANGELOG.md index 356dd88..cc50061 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] — 2026-06-21 + +### 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..63e7aea 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -1,34 +1,867 @@ +/* ============================================================ + 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, +.seg button { + font-family: var(--font-ui); +} + +: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); +} + +/* ============================================================ TOOLBAR */ .toolbar { display: flex; - gap: 8px; - padding: 8px; - background: #333; + align-items: center; + 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); +} + +/* 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; +} + +/* 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); +} + +/* 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); +} + +/* segmented control (Custom/Default mode switch) */ +.seg { + display: inline-flex; + 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: 12px; + font-weight: 510; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease; +} +.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); } -.toolbar button:hover { - background: #666; +/* 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 { - 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; + background: var(--app-bg); +} + +.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: var(--panel); + color: var(--fg); + overflow-y: auto; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.custom-sidebar-left { + border-right: 1px solid var(--border); +} + +.custom-sidebar-right { + border-left: 1px solid var(--border); +} + +.custom-sidebar .panel { + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.custom-sidebar .panel:last-child { + border-bottom: none; +} + +.custom-sidebar .panel h3 { + margin: 0 0 9px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.085em; + color: var(--fg-2); +} + +.custom-sidebar .panel h4 { + margin: 11px 0 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-3); +} + +.custom-sidebar .muted { + color: var(--fg-3); + font-size: 11.5px; + line-height: 1.45; + margin: 0 0 9px; } +/* 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 9px; +} + +.custom-sidebar .panel ul:not(.property-list) li { + position: relative; + padding: 7px 9px; + border-radius: var(--r2); + cursor: pointer; + 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 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: 7px; +} + +.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); + 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); + transform: translateY(-1px); + box-shadow: 0 4px 10px oklch(0% 0 0 / 0.06); +} + +.custom-sidebar .block:active { + cursor: grabbing; + transform: translateY(0); +} + +/* 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: var(--panel-3); + border-color: var(--border-2); +} + +/* layer tree */ +.layer .layer-row { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 0; + 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: var(--panel-2); + color: var(--fg); +} + +.layer .layer-row.active { + background: var(--select-wash); + color: var(--fg); +} + +.layer-children { + margin-left: 11px; + border-left: 1px solid var(--border); + padding-left: 4px; +} + +/* selector chips */ +.chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 0 0 9px; +} + +.chip { + display: inline-flex; + align-items: center; + 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: 3px 5px 3px 9px; + font-family: var(--font-mono); + font-size: 11px; +} + +.chip button { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 0 2px; + opacity: 0.7; +} + +.chip button:hover { + opacity: 1; +} + +/* form controls: inspector inputs/selects + trait inputs share one look */ +.ctl, +.trait input { + width: 100%; + 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); +} + +select.ctl { + -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; +} + +/* styles list (read-only) — scoped, no !important needed */ +.custom-sidebar details { + border-top: 1px solid var(--border); +} + +.custom-sidebar details:first-of-type { + border-top: 0; +} + +.custom-sidebar details summary { + list-style: none; + cursor: pointer; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--fg-2); + padding: 9px 0; +} + +.custom-sidebar details summary::-webkit-details-marker { + display: none; +} + +/* editable inspector fields (Styles + Selectors state) */ +.style-fields { + display: flex; + flex-direction: column; + gap: 9px; + padding: 4px 0 10px; +} + +.style-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.style-field > span { + font-size: 10.5px; + font-weight: 560; + color: var(--fg-3); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* range slider + value readout (font-size, line-height, spacing) */ +.range { + display: flex; + align-items: center; + gap: 10px; +} +.range input[type='range'] { + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 4px; + border-radius: 999px; + background: var(--border-2); + cursor: pointer; +} +.range input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + width: 15px; + height: 15px; + border-radius: 999px; + background: var(--accent); + border: 2px solid var(--panel); + box-shadow: 0 1px 3px oklch(0% 0 0 / 0.25); + cursor: pointer; +} +.range input[type='range']::-moz-range-thumb { + width: 13px; + height: 13px; + border-radius: 999px; + background: var(--accent); + border: 2px solid var(--panel); + cursor: pointer; +} +.range-val { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--fg-2); + min-width: 48px; + text-align: right; +} + +/* colour swatch + value readout */ +.swatch { + display: flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 4px 9px 4px 5px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--r2); +} +.swatch input[type='color'] { + width: 22px; + height: 22px; + flex: none; + padding: 0; + border: 1px solid var(--border-2); + border-radius: 5px; + background: none; + cursor: pointer; +} +.swatch input[type='color']::-webkit-color-swatch-wrapper { + padding: 0; +} +.swatch input[type='color']::-webkit-color-swatch { + border: 0; + border-radius: 4px; +} +.swatch-val { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--fg-2); + text-transform: uppercase; +} + +/* segmented control filling the field width (text-align) */ +.seg-fill { + width: 100%; +} +.seg-fill button { + flex: 1; + min-width: 0; +} + +/* 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 */ +.trait { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 11px; +} + +.trait span { + font-size: 10.5px; + font-weight: 560; + color: var(--fg-3); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.ctl:focus, +.trait input:focus, +.device-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +/* ---------- canvas + drag indicator ---------- */ +.canvas-wrap { + position: relative; + flex: 1; + min-width: 0; + display: flex; + background: var(--app-bg); +} + +.custom-canvas { + flex: 1; + display: block; + min-width: 0; +} + +/* 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; +} + +/* 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; + display: flex; + align-items: center; + gap: 7px; + background: var(--accent); + color: var(--accent-ink); + padding: 7px 12px; + border-radius: var(--r2); + font-size: 11.5px; + font-weight: 560; + letter-spacing: 0.02em; + box-shadow: var(--shadow-pop); + white-space: nowrap; + 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; + inset: 0; + background: oklch(0% 0 0 / 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.overlay-card { + 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: var(--shadow-pop); + display: flex; + flex-direction: column; +} + +.overlay-card header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.overlay-card header h3 { + margin: 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.085em; + color: var(--fg-2); +} + +.overlay-card header button { + background: transparent; + border: 0; + color: var(--fg-3); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.overlay-card header button:hover { + color: var(--fg); +} + +.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: 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 { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.asset-tile:hover { + 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: 12px; - background: #1a1a1a; - color: #0f0; + padding: 14px 16px; + background: var(--panel-2); + color: var(--fg-2); + 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, + .seg button, + .custom-sidebar .block, + .layer .layer-row, + .asset-tile { + transition: none; + } + .btn:active { + transform: none; + } + .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 6943339..d15c74b 100644 --- a/projects/demo/src/app/app.html +++ b/projects/demo/src/app/app.html @@ -1,17 +1,356 @@
- - - + + + + @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 (layerChildren(c).length) { +
+ @for (child of layerChildren(c); track child.getId()) { + + + } +
+ } +
+
+ @if (htmlOutput()) { -
{{ htmlOutput() }}
+
+
+ +

Exported markup

+ HTML + + + +
+
{{ 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..04bcda8 100644 --- a/projects/demo/src/app/app.ts +++ b/projects/demo/src/app/app.ts @@ -1,22 +1,194 @@ import { Component, inject, signal } from '@angular/core'; -import { GrapesJsEditorComponent, GrapesJsEditorService } from 'grapesjs-angular'; -import type { Editor, ProjectData } from 'grapesjs'; +import { DOCUMENT, KeyValuePipe, NgTemplateOutlet } from '@angular/common'; +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; +import { + GrapesJsEditorComponent, + GrapesJsEditorService, + GjsCanvas, + GjsBlocksProvider, + GjsLayersProvider, + GjsPagesProvider, + GjsDevicesProvider, + GjsSelectorsProvider, + GjsStylesProvider, + GjsTraitsProvider, + GjsAssetsProvider, + GjsModalProvider, + GjsContainerDirective, + type GrapesJsConfig, +} from 'grapesjs-angular'; +import type { Block, Component as GjsComponent, Editor, ProjectData, Property } 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); + 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; + protected theme = signal<'light' | 'dark'>('light'); + + /** Curated style manager so the inspector renders differentiated, typed + * controls (sliders / colour swatches / segmented / selects) instead of a + * wall of identical inputs. Drives both custom and default UI. */ + protected gjsConfig: GrapesJsConfig = { + styleManager: { + sectors: [ + { + name: 'Typography', + open: true, + properties: [ + { property: 'font-size', type: 'slider', units: ['px'], min: 8, max: 96 }, + { + property: 'font-weight', + type: 'select', + default: '400', + options: [ + { id: '300', label: 'Light' }, + { id: '400', label: 'Regular' }, + { id: '500', label: 'Medium' }, + { id: '600', label: 'Semibold' }, + { id: '700', label: 'Bold' }, + ], + }, + { property: 'color', type: 'color' }, + { property: 'line-height', type: 'slider', units: [''], min: 0.8, max: 3, step: 0.1 }, + { + property: 'text-align', + type: 'radio', + default: 'left', + options: [ + { id: 'left', label: 'Left' }, + { id: 'center', label: 'Center' }, + { id: 'right', label: 'Right' }, + { id: 'justify', label: 'Justify' }, + ], + }, + ], + }, + { + name: 'Background', + open: true, + properties: [{ property: 'background-color', type: 'color' }], + }, + { + name: 'Spacing', + open: true, + properties: [ + { property: 'padding', type: 'slider', units: ['px'], min: 0, max: 120 }, + { property: 'margin', type: 'slider', units: ['px'], min: 0, max: 120 }, + ], + }, + ], + }, + }; protected htmlOutput = signal(''); + protected customUi = signal(false); + protected dragging = signal(false); + protected draggedLabel = signal(null); + protected draggedIcon = signal(null); + protected cursorX = signal(0); + protected cursorY = signal(0); + + 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); + }; + + /** 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); + // 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)); + // 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); + + // 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 +199,110 @@ 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')); + } + + 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); + } + + 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. */ + selectLayer(c: GjsComponent): void { + this.editorService.editor()?.select(c); + } + + /** Layer-tree children, minus GrapesJS's non-layerable nodes — chiefly the + * textnodes that otherwise surface as meaningless "Box" rows. Mirrors + * GrapesJS's own layer manager, which hides them. */ + layerChildren(c: GjsComponent): GjsComponent[] { + return c.components().filter((child: GjsComponent) => child.get('layerable') !== false); + } + + // ── Style-inspector control helpers (wrap GrapesJS's dynamic Property API) ── + + /** Numeric part of a slider property's value, for the range input. */ + sliderValue(prop: Property): number { + return parseFloat(String(prop.getValue() ?? '')) || 0; + } + + /** A slider property's min/max/step (from the curated config). */ + sliderAttr(prop: Property, key: 'min' | 'max' | 'step'): number { + const v = (prop as unknown as { get(k: string): unknown }).get(key); + return typeof v === 'number' ? v : key === 'max' ? 100 : key === 'step' ? 1 : 0; + } + + /** Apply a slider value back to the property, re-attaching its unit. */ + setSlider(prop: Property, raw: string): void { + const p = prop as unknown as { getUnit?(): string; get(k: string): unknown }; + const units = p.get('units'); + const unit = p.getUnit?.() || (Array.isArray(units) ? (units[0] as string) : '') || ''; + prop.upValue(raw + unit); + } + + /** Convert a property's colour value to a hex the `` accepts. */ + colorHex(prop: Property): string { + const v = String(prop.getValue() ?? ''); + if (v[0] === '#') return v.length === 4 ? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}` : v.slice(0, 7); + const m = v.match(/\d+/g); + if (m && m.length >= 3) return '#' + m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, '0')).join(''); + return '#000000'; + } + + /** Options / labels for select & radio properties (PropertySelect API). */ + propOptions(prop: Property): unknown[] { + return (prop as unknown as { getOptions?(): unknown[] }).getOptions?.() ?? []; + } + optionId(prop: Property, opt: unknown): string { + return (prop as unknown as { getOptionId(o: unknown): string }).getOptionId(opt); + } + optionLabel(prop: Property, opt: unknown): string { + return (prop as unknown as { getOptionLabel(o: unknown): string }).getOptionLabel(opt); + } + + /** 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; + } + save(): void { const data = this.editorService.getProjectData(); if (!data) return; @@ -50,9 +326,15 @@ 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; + this.savedProject = null; editor.setComponents(SAMPLE_HTML); editor.setStyle(SAMPLE_CSS); this.htmlOutput.set(''); 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 @@ + + + diff --git a/projects/demo/src/styles.css b/projects/demo/src/styles.css index 21db5d5..54cac5c 100644 --- a/projects/demo/src/styles.css +++ b/projects/demo/src/styles.css @@ -1,7 +1,202 @@ @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); +} + +/* ============================================================ + 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; +} +/* drag-drop insertion indicator (sorter placeholder) → a clear accent line at + the actual drop point, in both custom and default UI */ +.gjs-placeholder { + border-color: var(--accent) !important; +} +.gjs-placeholder-int { + background-color: var(--accent) !important; + border-color: var(--accent) !important; +} +/* default-mode device selector — its panel is 44px but the canvas starts at + 40px (--gjs-canvas-top), so its bottom edge bled into the canvas. Pin the + panel to the bar height and center the dropdown within it. */ +.gjs-pn-devices-c { + display: flex; + align-items: center; + height: var(--gjs-canvas-top, 40px); + box-sizing: border-box; +} +.gjs-pn-devices-c .gjs-devices-c { + padding: 0 3px; +} + +*, *::before, *::after { + box-sizing: border-box; +} + html, body { margin: 0; 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 — + 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; } 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';