diff --git a/docs/Hooks-and-Events.md b/docs/Hooks-and-Events.md index 1aeaa3494..82ddec21b 100644 --- a/docs/Hooks-and-Events.md +++ b/docs/Hooks-and-Events.md @@ -1,9 +1,10 @@ # Hooks and Events -The visual editor exposes extension points through three mechanisms: +The visual editor exposes extension points through four mechanisms: -- **PHP filters** — `applyFilters('ap.visual-editor.*', ...)` for value transformation (resource map, site-editor entities, etc.) +- **PHP filters** — `applyFilters('ap.visual-editor.*', ...)` for value transformation (resource map, site-editor entities, server-rendered block HTML, etc.) - **PHP actions** — `doAction('ap.visual-editor.*', ...)` for side effects +- **JavaScript filters** — `addFilter('ap.visual-editor.*', ...)` via `@wordpress/hooks` for editor-side extension (document panels, background controls, canvas styles) - **Browser events** — `CustomEvent` dispatched on `window` for client-side integration (autosave, change, save) All PHP hooks use the global helpers from [`artisanpack-ui/hooks`](https://github.com/ArtisanPack-UI/hooks). All browser events are plain `CustomEvent` instances dispatched on `window`. @@ -91,6 +92,137 @@ See the [`artisanpack-ui/icons`](https://github.com/ArtisanPack-UI/icons) docs f --- +### `ap.visual-editor.rendered-block` + +Last-mile PHP filter applied to the rendered HTML of every block (static or dynamic) inside `packages/visual-editor-renderer-blade`'s `BlockRenderer::renderBlock()`. Runs on the server, at render-time, on every request that emits post content — not at save-time and not in JavaScript. Callbacks decorate output without each host having to fork the renderer. + +**Signature:** `string $html, string $blockName, array $attributes -> string` + +```php +addFilter('ap.visual-editor.rendered-block', function (string $html, string $name, array $attributes): string { + if ('artisanpack/group' !== $name) { + return $html; + } + // …wrap, sanitize, inject data-* attributes, etc. + return $html; +}, 10, 3); +``` + +**Recursion:** the renderer walks inner blocks through the same code path, so the filter fires once per block **at every level** of the tree. A callback that wraps a container block will also wrap every descendant unless it gates on `$name` / `$attributes`. + +**Attributes shape:** `$attributes` is post-normalization and may already carry package-internal `_resolved*` side-channel keys (site-meta, loginout). Treat those as read-only; they're not stable public attributes. + +--- + +## JavaScript filters + +Editor-side filters run through `@wordpress/hooks` inside the browser. Register callbacks with `addFilter(name, namespace, callback)` at editor bootstrap (the moment the app imports your package's entry module is enough — the editor evaluates each filter on every relevant render). + +### `ap.visual-editor.background-controls` + +Contribute panels to the shared background / appearance area of any block that opts into a background support (`supports.background` for image / gradient backgrounds, or `supports.color.background` for the color background). Applied by the built-in `editor.BlockEdit` HOC so external packages don't have to enumerate target blocks or wrap `BlockEdit` themselves — the target-block decision lives in the editor. + +**Signature:** `BackgroundControl[] -> BackgroundControl[]` + +```ts +type BackgroundControl = { + id: string // stable, namespaced (e.g. 'liquid-glass') + label: string // panel heading (translate before returning) + priority?: number // sort key, default 10, lower first + render: () => ReactNode +} + +type BackgroundControlContext = { + // Frozen — call setAttributes to modify. + attributes: Readonly> + setAttributes: (attrs: Record) => void + clientId: string + blockName: string + // Deep-cloned from the block-type registry — treat as read-only. + blockSupports: Readonly> +} +``` + +**Example:** + +```ts +import { addFilter } from '@wordpress/hooks' + +addFilter( + 'ap.visual-editor.background-controls', + 'artisanpack-ui/liquid-glass', + (controls, { attributes, setAttributes, blockSupports }) => { + // The HOC gates on either `supports.background` (image / gradient + // background) or `supports.color.background` (color background), + // so a block with only text-color support won't fire this filter. + // Narrow further to the shape your control cares about: + const hasBackground = + Boolean(blockSupports.background) || + (typeof blockSupports.color === 'object' && + blockSupports.color !== null && + (blockSupports.color as Record).background !== + false) + + if (! hasBackground) { + return controls + } + + return [ + ...controls, + { + id: 'liquid-glass', + label: 'Liquid Glass', + priority: 20, + render: () => ( + setAttributes({ liquidGlass })} + /> + ), + }, + ] + }, +) +``` + +Controls are deduped by `id` **before** sorting — a later `addFilter` at the same id overrides an earlier one regardless of priority, mirroring how `@wordpress/hooks` composes filters. Surviving controls are then sorted by `priority` (default `10`, lower first) with ties falling back to registration order. + +If a filter callback throws, the exception is caught, logged to `console.error`, and the block renders without any contributed panels for that render — one buggy third-party filter can't trip Gutenberg's per-block crash boundary. Callbacks with a non-numeric `priority` are silently rejected. + +Attributes are declared the standard Gutenberg way (`blocks.registerBlockType` filter). For static blocks, save-side rendering continues to go through `blocks.getSaveContent.extraProps`; for dynamic blocks, use the block's PHP render or the `ap.visual-editor.rendered-block` PHP filter (see the PHP filters section above). + +--- + +### `ap.visual-editor.document-panels` + +Contribute panels to the Document tab of the inspector sidebar. Runs against an empty descriptor list at inspector render time; the return value replaces it. + +**Signature:** `DocumentPanelSpec[] -> DocumentPanelSpec[]` + +```ts +type DocumentPanelSpec = { + id: string + title: string + initialOpen?: boolean + order?: number // sort key, default 100, lower first + render: () => ReactNode +} +``` + +Descriptors are sorted by `order` (default `100`, lower first) with ties falling back to registration order and deduped by `id` (last-wins). The slot-fill companion `` mirrors the WordPress component of the same name for panels rendered as part of the editor tree instead of registered at bootstrap. + +--- + +### `ap.visual-editor.canvas-styles` + +Contribute additional stylesheets injected into the block canvas iframe. Applied once at module-load time inside the editor bundle and frozen for the session — register your filter before importing the editor entry module. + +**Signature:** `CanvasStyle[] -> CanvasStyle[]` where `CanvasStyle = { css: string }`. + +Entries whose `css` isn't a string are dropped. Non-array return values fall back to the built-in list. + +--- + ## Browser events The editor dispatches three `CustomEvent`s on `window` whenever content state changes. diff --git a/resources/js/visual-editor/background-controls/__tests__/background-controls.test.ts b/resources/js/visual-editor/background-controls/__tests__/background-controls.test.ts new file mode 100644 index 000000000..b398989a3 --- /dev/null +++ b/resources/js/visual-editor/background-controls/__tests__/background-controls.test.ts @@ -0,0 +1,340 @@ +/** + * Tests for the `ap.visual-editor.background-controls` filter helper + * (#649). + * + * Focuses on the pure list-shaping contract: applies the filter with + * the given context, drops malformed descriptors, sorts by `priority` + * (default 10, lower first), dedupes by `id` (last-wins). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type FilterCallback = (value: unknown, ...args: unknown[]) => unknown; + +const filters = new Map(); + +vi.mock('@wordpress/hooks', () => ({ + addFilter: (hook: string, _id: string, callback: FilterCallback): void => { + if (!filters.has(hook)) { + filters.set(hook, []); + } + filters.get(hook)!.push(callback); + }, + applyFilters: (hook: string, value: unknown, ...args: unknown[]): unknown => { + const callbacks = filters.get(hook) ?? []; + return callbacks.reduce( + (acc, callback) => callback(acc, ...args), + value + ); + }, +})); + +beforeEach(() => { + filters.clear(); + vi.resetModules(); +}); + +async function loadModule(): Promise< + typeof import('../background-controls') +> { + return await import('../background-controls'); +} + +function registerFilter(callback: FilterCallback): void { + filters.set('ap.visual-editor.background-controls', [ + ...(filters.get('ap.visual-editor.background-controls') ?? []), + callback, + ]); +} + +const CONTEXT = { + attributes: {}, + setAttributes: () => undefined, + clientId: 'client-1', + blockName: 'artisanpack/group', + blockSupports: { background: true } as Record, +}; + +describe('getFilteredBackgroundControls', () => { + it('returns an empty list when no callbacks are registered', async () => { + const { getFilteredBackgroundControls } = await loadModule(); + + expect(getFilteredBackgroundControls(CONTEXT)).toEqual([]); + }); + + it('passes the context to the filter callback verbatim', async () => { + let received: unknown = null; + + registerFilter((value, context) => { + received = context; + return value; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + getFilteredBackgroundControls(CONTEXT); + + expect(received).toBe(CONTEXT); + }); + + it('returns controls contributed by the filter', async () => { + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'liquid-glass', + label: 'Liquid Glass', + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + const controls = getFilteredBackgroundControls(CONTEXT); + expect(controls.map((c) => c.id)).toEqual(['liquid-glass']); + }); + + it('sorts by priority (lower first, default 10)', async () => { + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { id: 'late', label: 'Late', priority: 30, render: () => null }, + { id: 'default', label: 'Default', render: () => null }, + { + id: 'early', + label: 'Early', + priority: 5, + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + expect( + getFilteredBackgroundControls(CONTEXT).map((c) => c.id) + ).toEqual(['early', 'default', 'late']); + }); + + it('is stable for controls with equal priority', async () => { + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { id: 'a', label: 'A', priority: 10, render: () => null }, + { id: 'b', label: 'B', priority: 10, render: () => null }, + { id: 'c', label: 'C', priority: 10, render: () => null }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + expect( + getFilteredBackgroundControls(CONTEXT).map((c) => c.id) + ).toEqual(['a', 'b', 'c']); + }); + + it('dedupes by id, last-registered wins, then sorts by priority', async () => { + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'dup', + label: 'First', + priority: 10, + render: () => null, + }, + { + id: 'other', + label: 'Other', + priority: 15, + render: () => null, + }, + { + id: 'dup', + label: 'Second', + priority: 20, + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + const result = getFilteredBackgroundControls(CONTEXT); + expect(result.map((c) => c.id)).toEqual(['other', 'dup']); + expect(result.find((c) => c.id === 'dup')?.label).toBe('Second'); + }); + + it('respects registration order over priority for duplicate ids', async () => { + // Regression: dedupe must run BEFORE sort so a later-registered + // override wins even when the earlier registration has a lower + // priority. Otherwise "last-wins mirroring @wordpress/hooks" + // becomes "highest-priority-wins". + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'glass', + label: 'Package A', + priority: 5, + render: () => null, + }, + { + id: 'glass', + label: 'Package B (override)', + priority: 20, + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + const result = getFilteredBackgroundControls(CONTEXT); + expect(result).toHaveLength(1); + expect(result[0]?.label).toBe('Package B (override)'); + }); + + it('respects registration order over priority in the opposite direction', async () => { + // Symmetric to the previous test — the docstring promises + // last-wins regardless of relative priorities. + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'glass', + label: 'Package A', + priority: 20, + render: () => null, + }, + { + id: 'glass', + label: 'Package B (override)', + priority: 5, + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + const result = getFilteredBackgroundControls(CONTEXT); + expect(result).toHaveLength(1); + expect(result[0]?.label).toBe('Package B (override)'); + }); + + it('drops malformed entries', async () => { + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + null, + undefined, + 42, + 'string', + { id: 'no-label', render: () => null }, + { id: 'no-render', label: 'No render' }, + { label: 'No id', render: () => null }, + { id: '', label: 'Empty id', render: () => null }, + { + id: 'valid', + label: 'Valid', + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + expect( + getFilteredBackgroundControls(CONTEXT).map((c) => c.id) + ).toEqual(['valid']); + }); + + it('drops entries whose priority is not a finite number', async () => { + // `NaN` / non-numeric priorities produce engine-dependent sort + // order because the comparator returns `NaN` for every pair + // touching the offender. Reject them at validation time. + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'nan', + label: 'NaN priority', + priority: Number.NaN, + render: () => null, + }, + { + id: 'string', + label: 'String priority', + priority: 'high' as unknown as number, + render: () => null, + }, + { + id: 'infinity', + label: 'Infinity priority', + priority: Number.POSITIVE_INFINITY, + render: () => null, + }, + { + id: 'valid-omitted', + label: 'Valid (no priority)', + render: () => null, + }, + { + id: 'valid-explicit', + label: 'Valid (finite)', + priority: 15, + render: () => null, + }, + ]; + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + expect( + getFilteredBackgroundControls(CONTEXT).map((c) => c.id) + ).toEqual(['valid-omitted', 'valid-explicit']); + }); + + it('swallows a thrown filter callback and logs to console.error', async () => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + registerFilter(() => { + throw new Error('boom'); + }); + + const { getFilteredBackgroundControls } = await loadModule(); + + expect(getFilteredBackgroundControls(CONTEXT)).toEqual([]); + expect(errorSpy).toHaveBeenCalledOnce(); + + errorSpy.mockRestore(); + }); + + it('falls back to an empty list when a callback returns a non-array', async () => { + registerFilter(() => 'not-an-array'); + + const { getFilteredBackgroundControls } = await loadModule(); + + expect(getFilteredBackgroundControls(CONTEXT)).toEqual([]); + }); + + it('exposes the filter name as a constant', async () => { + const { BACKGROUND_CONTROLS_FILTER } = await loadModule(); + + expect(BACKGROUND_CONTROLS_FILTER).toBe( + 'ap.visual-editor.background-controls' + ); + }); +}); diff --git a/resources/js/visual-editor/background-controls/__tests__/with-background-controls.test.tsx b/resources/js/visual-editor/background-controls/__tests__/with-background-controls.test.tsx new file mode 100644 index 000000000..45cc96c0b --- /dev/null +++ b/resources/js/visual-editor/background-controls/__tests__/with-background-controls.test.tsx @@ -0,0 +1,443 @@ +/** + * Tests for the `editor.BlockEdit` HOC that mounts the + * `ap.visual-editor.background-controls` filter (#649). + * + * The HOC gates on whether the block type declares a background-related + * support. These tests exercise the AC "at least one test demonstrating + * an external filter adds a control to a supported block and does not + * add it to an unsupported block", plus the priority contract. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +type FilterCallback = (value: unknown, ...args: unknown[]) => unknown; + +const filters = new Map(); + +vi.mock('@wordpress/hooks', () => ({ + addFilter: (hook: string, _id: string, callback: FilterCallback): void => { + if (!filters.has(hook)) { + filters.set(hook, []); + } + filters.get(hook)!.push(callback); + }, + applyFilters: (hook: string, value: unknown, ...args: unknown[]): unknown => { + const callbacks = filters.get(hook) ?? []; + return callbacks.reduce( + (acc, callback) => callback(acc, ...args), + value + ); + }, +})); + +// The HOC only uses `InspectorControls` as a pass-through wrapper. +// Rendering a plain `
` is enough for the DOM assertions below and +// keeps the block-editor stylesheets / slot-fill machinery out of the +// test environment. +vi.mock('@wordpress/block-editor', () => ({ + InspectorControls: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('@wordpress/components', () => ({ + PanelBody: ({ + title, + children, + }: { + title?: string; + initialOpen?: boolean; + children?: ReactNode; + }) => ( +
+ {children} +
+ ), +})); + +// `@wordpress/compose`'s real `createHigherOrderComponent` reaches into +// the `element` package for `Symbol.for( 'react.element' )` lookups +// that aren't needed here. A minimal HOC wrapper is enough. +vi.mock('@wordpress/compose', () => ({ + createHigherOrderComponent: + (fn: (Comp: React.ComponentType) => React.ComponentType) => + (Comp: React.ComponentType) => + fn(Comp), +})); + +const registeredBlockSupports = new Map>(); + +vi.mock('@wordpress/blocks', () => ({ + getBlockType: ( + name: string + ): { supports?: Record } | undefined => { + const supports = registeredBlockSupports.get(name); + if (!supports) { + return undefined; + } + return { supports }; + }, +})); + +function registerFilter(callback: FilterCallback): void { + filters.set('ap.visual-editor.background-controls', [ + ...(filters.get('ap.visual-editor.background-controls') ?? []), + callback, + ]); +} + +beforeEach(() => { + filters.clear(); + registeredBlockSupports.clear(); + vi.resetModules(); +}); + +async function loadHOC(): Promise< + typeof import('../with-background-controls') +> { + return await import('../with-background-controls'); +} + +const InnerEdit = (props: { name: string }): JSX.Element => ( +
+ inner +
+); + +describe('withBackgroundControls', () => { + it('adds a filter-registered control to a block that supports background', async () => { + registeredBlockSupports.set('artisanpack/group', { background: true }); + + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'liquid-glass', + label: 'Liquid Glass', + render: () => ( + glass + ), + }, + ]; + }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + undefined} + /> + ); + + expect(screen.getByTestId('inner-edit')).toBeInTheDocument(); + expect(screen.getByTestId('inspector-controls')).toBeInTheDocument(); + expect(screen.getByTestId('glass-panel')).toBeInTheDocument(); + expect( + screen.getByTestId('panel-body').getAttribute('data-title') + ).toBe('Liquid Glass'); + }); + + it('does not render the inspector wrapper on a block that does not support background', async () => { + registeredBlockSupports.set('artisanpack/separator', { + color: { background: false }, + }); + + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'liquid-glass', + label: 'Liquid Glass', + render: () => ( + glass + ), + }, + ]; + }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + undefined} + /> + ); + + expect(screen.getByTestId('inner-edit')).toBeInTheDocument(); + expect(screen.queryByTestId('inspector-controls')).toBeNull(); + expect(screen.queryByTestId('glass-panel')).toBeNull(); + }); + + it('renders nothing extra when no filter callback returns controls', async () => { + registeredBlockSupports.set('artisanpack/group', { background: true }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + undefined} + /> + ); + + expect(screen.getByTestId('inner-edit')).toBeInTheDocument(); + expect(screen.queryByTestId('inspector-controls')).toBeNull(); + }); + + it('passes the block context to filter callbacks', async () => { + registeredBlockSupports.set('artisanpack/group', { background: true }); + + let received: unknown = null; + const setAttributes = (): void => undefined; + const attributes = { style: { background: { color: '#fff' } } }; + + registerFilter((value, context) => { + received = context; + return value; + }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + + ); + + expect(received).toEqual({ + attributes, + setAttributes, + clientId: 'c-4', + blockName: 'artisanpack/group', + blockSupports: { background: true }, + }); + }); + + it('freezes context.attributes so filter callbacks cannot mutate them in place', async () => { + registeredBlockSupports.set('artisanpack/group', { background: true }); + + let mutationError: unknown = null; + const attributes = { liquidGlass: { blur: 4 } }; + + registerFilter((value, context) => { + const ctx = context as { attributes: Record }; + try { + (ctx.attributes as { liquidGlass?: unknown }).liquidGlass = { + blur: 999, + }; + } catch (error) { + mutationError = error; + } + return value; + }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + undefined} + /> + ); + + // In strict mode the assignment throws `TypeError`; in sloppy + // mode it silently no-ops. Either way, the source `attributes` + // reference the parent handed us must NOT reflect the mutation. + expect(attributes.liquidGlass.blur).toBe(4); + // If it did throw, it was the expected TypeError from freeze. + if (mutationError !== null) { + expect(mutationError).toBeInstanceOf(TypeError); + } + }); + + it('deep-clones context.blockSupports so a filter callback cannot corrupt the block registry', async () => { + // Live-reference mutation of `getBlockType(name).supports` would + // silently break every other block-support gate in the session. + const registered = { color: { background: true, text: true } }; + registeredBlockSupports.set('artisanpack/group', registered); + + registerFilter((value, context) => { + const ctx = context as { + blockSupports: Record; + }; + const color = ctx.blockSupports.color as Record; + try { + color.background = false; + } catch { + // frozen — expected + } + return value; + }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + undefined} + /> + ); + + // The registered object must NOT have been touched. + expect(registered.color.background).toBe(true); + }); + + it('always returns a Fragment so BlockEdit does not remount when the controls list transitions from empty to non-empty', async () => { + // React reconciler treats a top-level switch between + // `` and `<>...` as a change in + // parent slot type, forcing a remount that discards RichText + // cursor, drag state, and child hook state. Verify the HOC + // always renders BlockEdit inside a stable Fragment. + registeredBlockSupports.set('artisanpack/group', { background: true }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + const { rerender, getByTestId, queryByTestId } = render( + undefined} + /> + ); + + const innerBefore = getByTestId('inner-edit'); + expect(queryByTestId('inspector-controls')).toBeNull(); + + // Register a filter and force a re-render — controls list + // transitions from [] to [glass]. + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'glass', + label: 'Glass', + render: () => glass, + }, + ]; + }); + + rerender( + undefined} + /> + ); + + // Same DOM node — no remount. + expect(getByTestId('inner-edit')).toBe(innerBefore); + expect(getByTestId('inspector-controls')).toBeInTheDocument(); + expect(getByTestId('glass-panel')).toBeInTheDocument(); + }); + + it('renders controls in priority order (lower first)', async () => { + registeredBlockSupports.set('artisanpack/group', { background: true }); + + registerFilter((value) => { + const list = value as unknown[]; + return [ + ...list, + { + id: 'late', + label: 'Late', + priority: 30, + render: () => late, + }, + { + id: 'early', + label: 'Early', + priority: 5, + render: () => ( + early + ), + }, + ]; + }); + + const { withBackgroundControls } = await loadHOC(); + const Wrapped = withBackgroundControls(InnerEdit); + + render( + undefined} + /> + ); + + const panels = screen.getAllByTestId('panel-body'); + expect(panels.map((p) => p.getAttribute('data-title'))).toEqual([ + 'Early', + 'Late', + ]); + }); +}); + +describe('blockSupportsBackground', () => { + it('accepts `supports.background: true`', async () => { + const { blockSupportsBackground } = await loadHOC(); + expect(blockSupportsBackground({ background: true })).toBe(true); + }); + + it('accepts `supports.background` as an object', async () => { + const { blockSupportsBackground } = await loadHOC(); + expect( + blockSupportsBackground({ background: { backgroundImage: true } }) + ).toBe(true); + }); + + it('accepts `supports.color: true`', async () => { + const { blockSupportsBackground } = await loadHOC(); + expect(blockSupportsBackground({ color: true })).toBe(true); + }); + + it('accepts `supports.color` as an object without an explicit `background: false`', async () => { + const { blockSupportsBackground } = await loadHOC(); + expect(blockSupportsBackground({ color: {} })).toBe(true); + expect( + blockSupportsBackground({ color: { text: true } }) + ).toBe(true); + }); + + it('rejects `supports.color.background: false`', async () => { + const { blockSupportsBackground } = await loadHOC(); + expect( + blockSupportsBackground({ color: { background: false } }) + ).toBe(false); + }); + + it('rejects an empty supports object', async () => { + const { blockSupportsBackground } = await loadHOC(); + expect(blockSupportsBackground({})).toBe(false); + }); +}); diff --git a/resources/js/visual-editor/background-controls/background-controls.ts b/resources/js/visual-editor/background-controls/background-controls.ts new file mode 100644 index 000000000..3b3002ed9 --- /dev/null +++ b/resources/js/visual-editor/background-controls/background-controls.ts @@ -0,0 +1,192 @@ +/** + * `ap.visual-editor.background-controls` — filter that lets external + * packages contribute controls to the shared background / appearance + * area of any block that opts into a background support (#649). + * + * External packages register their controls via `@wordpress/hooks`: + * + * addFilter( + * 'ap.visual-editor.background-controls', + * 'my-package/glass', + * (controls, { attributes, setAttributes, blockSupports }) => { + * if ( ! blockSupports.background ) { + * return controls; + * } + * return [ + * ...controls, + * { + * id: 'my-package/glass', + * label: 'Glass effect', + * priority: 20, + * render: () => , + * }, + * ]; + * }, + * ); + * + * The editor calls `getFilteredBackgroundControls(context)` from the + * `editor.BlockEdit` HOC in `with-background-controls.tsx` on every + * render of a supporting block. Sorting, de-duplication, and the + * "priority defaults to 10, lower first" contract live here so the HOC + * stays about placement only. + * + * @package @artisanpack-ui/visual-editor + * @since 1.4.0 + */ + +import { applyFilters } from '@wordpress/hooks'; +import type { ReactNode } from 'react'; + +/** Filter name — exported so hosts can type against a constant. */ +export const BACKGROUND_CONTROLS_FILTER = 'ap.visual-editor.background-controls'; + +/** Default priority for controls that don't set one. */ +export const DEFAULT_BACKGROUND_CONTROL_PRIORITY = 10; + +/** + * Descriptor a package returns from the filter for each control it wants + * to render in the block's background panel. + */ +export interface BackgroundControl { + /** + * Stable, namespaced identifier — used as the React `key` and to + * dedupe two packages racing on the same slot (last-wins, mirroring + * `@wordpress/hooks`). + */ + id: string; + /** Visible section heading. Should already be translated by the caller. */ + label: string; + /** + * Sort key. Lower renders earlier. Defaults to + * {@link DEFAULT_BACKGROUND_CONTROL_PRIORITY} when omitted. + */ + priority?: number; + /** React render callback. Invoked once per render, no arguments. */ + render: () => ReactNode; +} + +/** + * Context passed to filter callbacks so a package can decide whether to + * contribute a control (and what to render) based on the currently- + * selected block. + * + * All fields except `setAttributes` are **read-only** — the HOC freezes + * `attributes` and hands out a defensive clone of `blockSupports`, but + * treat every field as immutable regardless. Mutating `attributes` + * bypasses React reconciliation and undo history; mutating + * `blockSupports` would (if it weren't cloned) corrupt the shared + * `@wordpress/blocks` registry that gate-checks every other feature in + * the editor. + */ +export interface BackgroundControlContext { + /** + * Frozen block attributes. Treat as read-only. To modify, call + * `setAttributes({...})` — the standard Gutenberg pattern. + */ + attributes: Readonly>; + /** Standard Gutenberg attribute setter for the selected block. */ + setAttributes: (attrs: Record) => void; + /** Selected block's clientId. */ + clientId: string; + /** Selected block's registered name (e.g. `artisanpack/group`). */ + blockName: string; + /** + * Deep-cloned `supports` object from the block type. Packages use + * `blockSupports.background` (or a nested key like + * `blockSupports.color?.background`) to gate their contribution. + * Treat as read-only. + */ + blockSupports: Readonly>; +} + +function isBackgroundControl(value: unknown): value is BackgroundControl { + if ( + value === null || + typeof value !== 'object' || + typeof (value as BackgroundControl).id !== 'string' || + (value as BackgroundControl).id === '' || + typeof (value as BackgroundControl).label !== 'string' || + typeof (value as BackgroundControl).render !== 'function' + ) { + return false; + } + + // `priority` is optional, but if present it must be a finite number. + // A `NaN` or string priority would make the sort comparator return + // `NaN`, giving engine-dependent order across browsers. + const priority = (value as BackgroundControl).priority; + if (priority !== undefined && !Number.isFinite(priority)) { + return false; + } + + return true; +} + +/** + * Apply the filter, drop malformed entries, dedupe by `id` (last-wins), + * then sort by `priority` (default `10`, lower first). Dedupe happens + * BEFORE sort so "last-wins" tracks registration order, not sort order + * — a later `addFilter` with a lower priority still overrides an + * earlier registration at the same id. + * + * If a filter callback throws (a third-party bug), the exception is + * caught, logged, and the function returns an empty list. Without the + * guard, one bad callback would trip Gutenberg's `BlockCrashBoundary` + * on every affected block for the rest of the session. + * + * Kept pure so the HOC can call it inside render without scheduling + * work. + */ +export function getFilteredBackgroundControls( + context: BackgroundControlContext +): BackgroundControl[] { + let raw: unknown; + + try { + raw = applyFilters( + BACKGROUND_CONTROLS_FILTER, + [] as BackgroundControl[], + context + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + '[artisanpack-ui/visual-editor] A ' + + `"${BACKGROUND_CONTROLS_FILTER}" filter callback threw. ` + + 'Skipping background controls for this render.', + error + ); + return []; + } + + if (!Array.isArray(raw)) { + return []; + } + + const valid = raw.filter(isBackgroundControl); + + // Dedupe by id — two packages racing on the same identifier, or a + // single package re-registering after HMR, would otherwise render + // twice with duplicate React keys. Last-wins mirrors how + // `@wordpress/hooks` composes filters: a later `addFilter` at the + // same namespace/priority overrides an earlier one. Doing the + // dedupe here (pre-sort) means the surviving entry is always the + // last one the filter chain contributed, regardless of its + // priority relative to the earlier duplicate. + const dedupedByRegistration = new Map(); + + for (const control of valid) { + if (dedupedByRegistration.has(control.id)) { + dedupedByRegistration.delete(control.id); + } + dedupedByRegistration.set(control.id, control); + } + + // ES2019 mandates `Array.prototype.sort` be stable, so ties fall + // back to insertion (= registration) order automatically. + return Array.from(dedupedByRegistration.values()).sort((a, b) => { + const priorityA = a.priority ?? DEFAULT_BACKGROUND_CONTROL_PRIORITY; + const priorityB = b.priority ?? DEFAULT_BACKGROUND_CONTROL_PRIORITY; + return priorityA - priorityB; + }); +} diff --git a/resources/js/visual-editor/background-controls/index.ts b/resources/js/visual-editor/background-controls/index.ts new file mode 100644 index 000000000..cb91c0ac7 --- /dev/null +++ b/resources/js/visual-editor/background-controls/index.ts @@ -0,0 +1,12 @@ +export { + BACKGROUND_CONTROLS_FILTER, + DEFAULT_BACKGROUND_CONTROL_PRIORITY, + getFilteredBackgroundControls, + type BackgroundControl, + type BackgroundControlContext, +} from './background-controls'; +export { + blockSupportsBackground, + registerBackgroundControls, + withBackgroundControls, +} from './with-background-controls'; diff --git a/resources/js/visual-editor/background-controls/with-background-controls.tsx b/resources/js/visual-editor/background-controls/with-background-controls.tsx new file mode 100644 index 000000000..b32a8c819 --- /dev/null +++ b/resources/js/visual-editor/background-controls/with-background-controls.tsx @@ -0,0 +1,204 @@ +/** + * `editor.BlockEdit` HOC — mount the + * `ap.visual-editor.background-controls` filter on every block that + * opts into a background support (#649). + * + * Centralizes the target-block decision inside the editor so external + * packages contributing a background/appearance control (Liquid Glass, + * noise, gradient mesh, etc.) don't have to enumerate blocks and wrap + * `editor.BlockEdit` themselves. They just add a filter callback + * returning a `BackgroundControl` descriptor; this HOC decides the + * gating and the placement. + * + * Placement: a full-width `PanelBody` per control inside the default + * `InspectorControls` group. We considered `group="color"` (which sits + * next to the built-in Background color picker) but that slot renders + * inside a `ToolsPanel` that either drops non-`ToolsPanelItem` children + * or squeezes them into a narrow column — the same trap `liquid-glass` + * hit before it moved to a plain `PanelBody`. The default slot gives + * every control room to render normally and keeps the API surface + * simple (packages return a single `render` — no ToolsPanel wiring + * required). + * + * @package @artisanpack-ui/visual-editor + * @since 1.4.0 + */ + +import { InspectorControls } from '@wordpress/block-editor'; +import { PanelBody } from '@wordpress/components'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { getBlockType } from '@wordpress/blocks'; +import { addFilter } from '@wordpress/hooks'; +import type { ComponentType } from 'react'; + +import { + getFilteredBackgroundControls, + type BackgroundControlContext, +} from './background-controls'; + +const FILTER_HOOK = 'editor.BlockEdit'; +const FILTER_NAMESPACE = + 'artisanpack-ui/visual-editor/background-controls'; + +const REGISTERED_KEY = Symbol.for( + 'artisanpack-ui.visual-editor.background-controls.registered' +); + +interface GlobalSentinelHost { + [REGISTERED_KEY]?: boolean; +} + +interface BlockEditProps { + name: string; + clientId?: string; + attributes: Record; + setAttributes: (updates: Record) => void; + [key: string]: unknown; +} + +/** + * Resolve the block type's `supports` object as a defensive deep + * clone. Returns an empty object when the block type is unknown so + * callers can uniformly probe keys without null-guarding first. + * + * The clone matters — the raw `supports` object is a live reference to + * the `@wordpress/blocks` registry, and this value is handed to every + * third-party `ap.visual-editor.background-controls` filter callback + * as `context.blockSupports`. A callback that mutates the received + * object would (without the clone) silently tamper with the shared + * registry entry every other block-support consumer reads from. + */ +function resolveBlockSupports(name: string): Record { + const blockType = getBlockType(name); + + if (!blockType) { + return {}; + } + + const supports = (blockType as { supports?: unknown }).supports; + + if (supports === null || typeof supports !== 'object') { + return {}; + } + + // `structuredClone` is available in every browser that ships a + // modern Gutenberg (Safari 15.4+, Chrome/Edge 98+, Firefox 94+), + // matches Node ≥17 for tests, and is the correct primitive here: + // block `supports` objects are plain data (no functions, no + // symbols) so structuredClone reproduces them losslessly. + return structuredClone(supports) as Record; +} + +/** + * A block opts into background controls when it declares either + * `supports.background` (image / gradient background support) or + * `supports.color.background` (color background — the default when + * `supports.color` is enabled without an explicit `background: false` + * override). + * + * The check is deliberately permissive: package authors gate their own + * contribution inside the filter callback using the resolved + * `blockSupports` object, so it's fine for the HOC to run for a + * slightly broader set of blocks than any single package cares about. + */ +export function blockSupportsBackground( + supports: Record +): boolean { + if (supports.background) { + return true; + } + + const color = supports.color; + + if (color === true) { + return true; + } + + if (color !== null && typeof color === 'object') { + const colorBackground = (color as Record).background; + // `undefined` means "inherit the default", which for the color + // support is `true`. Only an explicit `false` disables it. + return colorBackground !== false; + } + + return false; +} + +export const withBackgroundControls = createHigherOrderComponent( + (BlockEdit: ComponentType) => { + function BackgroundControlsBlockEdit( + props: BlockEditProps + ): JSX.Element { + const blockSupports = resolveBlockSupports(props.name); + + if (!blockSupportsBackground(blockSupports)) { + return ; + } + + // `Object.freeze` on a shallow copy is enough to signal + // read-only at the top level and to defend against the most + // common footgun (a filter callback assigning to + // `context.attributes.foo = ...`). Deep-freezing would + // require walking the tree on every render — too expensive + // for a hot path; the type is `Readonly<...>` and the + // context docstring calls it out. + const context: BackgroundControlContext = { + attributes: Object.freeze({ ...props.attributes }), + setAttributes: props.setAttributes, + clientId: props.clientId ?? '', + blockName: props.name, + blockSupports: Object.freeze(blockSupports), + }; + + const controls = getFilteredBackgroundControls(context); + + // Always return the Fragment shape so the child `BlockEdit` + // keeps a stable parent slot type as `controls` transitions + // from empty to non-empty (e.g. HMR, lazy-loaded package + // bundles, filter callbacks reading external stores). A + // top-level switch between `` and a Fragment + // would remount BlockEdit and lose RichText cursor, drag, + // and child hook state. + return ( + <> + + {controls.length > 0 && ( + + {controls.map((control) => ( + + {control.render()} + + ))} + + )} + + ); + } + + BackgroundControlsBlockEdit.displayName = + 'BackgroundControlsBlockEdit'; + + return BackgroundControlsBlockEdit; + }, + 'withBackgroundControls' +); + +/** + * Register the `editor.BlockEdit` HOC at most once per page. Idempotent + * — safe to call from both the post-editor and site-editor bootstrap + * paths. + */ +export function registerBackgroundControls(): void { + const host = globalThis as unknown as GlobalSentinelHost; + + if (host[REGISTERED_KEY]) { + return; + } + + addFilter(FILTER_HOOK, FILTER_NAMESPACE, withBackgroundControls); + host[REGISTERED_KEY] = true; +} diff --git a/resources/js/visual-editor/editor/editor-app.tsx b/resources/js/visual-editor/editor/editor-app.tsx index fea0c42e6..8c31a0359 100644 --- a/resources/js/visual-editor/editor/editor-app.tsx +++ b/resources/js/visual-editor/editor/editor-app.tsx @@ -44,6 +44,7 @@ import { registerResponsiveAttribute } from '../responsive/register-attribute'; import { registerResponsiveAttributesFilter } from '../responsive/with-responsive-attributes'; import { registerStateAttribute } from '../states/register-attribute'; import { registerStateAttributesFilter } from '../states/with-state-attributes'; +import { registerBackgroundControls } from '../background-controls'; import { registerBoxShadows } from '../box-shadows/register'; import { registerGradientBorders } from '../gradient-borders/register'; import { registerStateStylesFilters } from '../states/with-state-styles'; @@ -174,6 +175,16 @@ function registerOnce(): void { disableContrastCheckerOnBlocks(); registerContrastWarning(); registerSyncedPatternIndicator(); + // #649 — register the background-controls BlockEdit HOC FIRST so + // it wraps innermost. `@wordpress/hooks` composes filters in + // registration order, so the LAST-registered HOC wraps outermost; + // registering background-controls before the responsive + state + // HOCs below makes those wrap around it, which means the + // `context.attributes` this HOC hands to filter callbacks is the + // breakpoint-merged / state-resolved view — not the raw idle + // attributes — and `context.setAttributes` routes writes through + // the responsive/state wrappers. + registerBackgroundControls(); // #487 — register the responsive feature filters BEFORE blocks load // so opted-in blocks pick up the auto-injected `responsive` // attribute at registration time and the BlockEdit HOC wraps every diff --git a/resources/js/visual-editor/site-editor/__tests__/site-editor-app.test.tsx b/resources/js/visual-editor/site-editor/__tests__/site-editor-app.test.tsx index d3f25ed8c..e2fdda4c7 100644 --- a/resources/js/visual-editor/site-editor/__tests__/site-editor-app.test.tsx +++ b/resources/js/visual-editor/site-editor/__tests__/site-editor-app.test.tsx @@ -10,6 +10,16 @@ vi.mock('../../blocks', () => ({ registerArtisanPackBlocks: (): void => undefined, })); +// #649 — same reason as the BlockEditorBoundary + BlockLibrarySidebar +// stubs below: the real background-controls barrel pulls +// `@wordpress/block-editor` (and its `block-compare` → `diff` subpath) +// into the graph, which doesn't resolve under jsdom. The shell test +// only checks that `ensureEditorBoot()` runs the registrars; the HOC +// itself has its own focused test suite. +vi.mock('../../background-controls', () => ({ + registerBackgroundControls: (): void => undefined, +})); + // #436: the shell now imports `BlockEditorBoundary` directly to wrap // the D2 canvas + inspector in one `BlockEditorProvider`. The real // boundary pulls `@wordpress/block-editor` (and its `block-compare` → diff --git a/resources/js/visual-editor/site-editor/site-editor-app.tsx b/resources/js/visual-editor/site-editor/site-editor-app.tsx index d06128844..e761edbde 100644 --- a/resources/js/visual-editor/site-editor/site-editor-app.tsx +++ b/resources/js/visual-editor/site-editor/site-editor-app.tsx @@ -45,6 +45,7 @@ import { } from './template-parts-section'; import { usePersistedToggle } from './use-persisted-toggle'; import { useSiteEditorRouting } from './use-site-editor-routing'; +import { registerBackgroundControls } from '../background-controls'; import { registerArtisanPackBlocks } from '../blocks'; import { registerBoxShadows } from '../box-shadows/register'; import { registerGradientBorders } from '../gradient-borders/register'; @@ -161,6 +162,13 @@ function ensureEditorBoot(): void { // from the first render. Idempotent across HMR. registerSyncedPatternIndicator(); + // #649 — register the background-controls BlockEdit HOC so external + // packages can contribute panels to any block that opts into a + // background support via the shared + // `ap.visual-editor.background-controls` filter. Idempotent across + // the post-editor and site-editor bootstrap paths. + registerBackgroundControls(); + // #490 — gradient border feature filters; same "before // registerArtisanPackBlocks" placement as the post-editor so opted-in // blocks pick up the routed `border.gradient` path at registration.