From fabeccefc2c83bf1198b3b7889c7fb93109b4cd2 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Sun, 7 Jun 2026 11:21:02 +0200 Subject: [PATCH 1/4] Add block anatomy plugin --- docs/development/block-anatomy.md | 99 +++++++++++++++ .../configure-editor-block-widths.md | 29 +++-- docs/development/index.md | 1 + docs/how-to-guides/configure-style-fields.md | 4 + packages/blocks/index.ts | 51 ++++++++ .../news/+block-anatomy-categories.feature | 1 + .../acceptance/tests/block-width.test.ts | 3 +- .../acceptance/tests/image-block.test.ts | 47 ++++--- .../tests/somersault-renderer.test.ts | 3 +- .../acceptance/tests/teaser-block.test.ts | 3 +- .../components/BlockEditor/BlocksEditor.tsx | 28 +++-- .../BlockEditor/plugins/SidebarPlugin.tsx | 36 ++++-- .../news/+ploneblock-sidebar-state.bugfix | 1 + .../news/+block-anatomy-helper.feature | 1 + packages/helpers/src/blockAnatomy.test.ts | 20 +++ packages/helpers/src/blockAnatomy.ts | 43 +++++++ packages/helpers/src/index.ts | 1 + packages/helpers/src/styleFields.test.ts | 3 +- packages/helpers/src/styleFields.ts | 3 +- packages/layout/blocks/BlockWrapper.test.tsx | 4 + packages/layout/blocks/BlockWrapper.tsx | 17 ++- packages/layout/blocks/RenderBlocks.tsx | 8 +- .../news/+block-anatomy-wrapper.feature | 1 + .../editor/block-editor-base-kit.tsx | 2 + .../components/editor/block-editor-kit.tsx | 2 + .../components/editor/editor-base-kit.tsx | 2 + .../plate/components/editor/editor-kit.tsx | 2 + .../editor/plugins/block-anatomy-base-kit.tsx | 3 + .../editor/plugins/block-anatomy-kit.tsx | 3 + .../plugins/block-anatomy-plugin.test.ts | 119 ++++++++++++++++++ .../editor/plugins/block-anatomy-plugin.ts | 81 ++++++++++++ .../editor/plugins/block-width-plugin.test.ts | 11 +- .../editor/plugins/block-width-plugin.ts | 17 +-- .../plugins/plone-block-adapter-renderer.tsx | 17 ++- .../editor/plugins/plone-block-adapter.tsx | 83 ++++++++---- .../components/editor/plugins/slash-menu.tsx | 5 +- .../editor/plugins/style-fields-plugin.ts | 5 +- .../plate/config/presets/somersault-editor.ts | 6 +- .../plate/news/+ploneblock-anatomy.breaking | 1 + .../types/news/+plate-block-category.feature | 1 + packages/types/src/config/Blocks.d.ts | 1 + 41 files changed, 655 insertions(+), 113 deletions(-) create mode 100644 docs/development/block-anatomy.md create mode 100644 packages/blocks/news/+block-anatomy-categories.feature create mode 100644 packages/cmsui/news/+ploneblock-sidebar-state.bugfix create mode 100644 packages/helpers/news/+block-anatomy-helper.feature create mode 100644 packages/helpers/src/blockAnatomy.test.ts create mode 100644 packages/helpers/src/blockAnatomy.ts create mode 100644 packages/layout/news/+block-anatomy-wrapper.feature create mode 100644 packages/plate/components/editor/plugins/block-anatomy-base-kit.tsx create mode 100644 packages/plate/components/editor/plugins/block-anatomy-kit.tsx create mode 100644 packages/plate/components/editor/plugins/block-anatomy-plugin.test.ts create mode 100644 packages/plate/components/editor/plugins/block-anatomy-plugin.ts create mode 100644 packages/plate/news/+ploneblock-anatomy.breaking create mode 100644 packages/types/news/+plate-block-category.feature diff --git a/docs/development/block-anatomy.md b/docs/development/block-anatomy.md new file mode 100644 index 000000000..6c6910e7e --- /dev/null +++ b/docs/development/block-anatomy.md @@ -0,0 +1,99 @@ +--- +myst: + html_meta: + "description": "Block anatomy contract for Plone Aurora" + "property=og:description": "Block anatomy contract for Plone Aurora" + "property=og:title": "Block anatomy" + "keywords": "Plone Aurora, blocks, Plate, block model, anatomy" +--- + +# Block anatomy + +Plone Aurora exposes a shared block anatomy contract for both public rendering and Plate/Somersault rendering. + +The outer block element receives: + +```html +class="block block- category-" +data-block-type="" +data-block-category="" +``` + +For example, a teaser block in the teaser category renders as: + +```html +
+
...
+
+``` + +## Where the contract is applied + +The anatomy contract is resolved by `resolveBlockAnatomy` in `@plone/helpers`. + +It is consumed by: + +- `BlockWrapper` in `@plone/layout` for Plone Volto block rendering +- `BlockAnatomyPlugin` in `@plone/plate` for Plate-native and Somersault editor block rendering + +This avoids duplicating class-name rules in individual blocks. + +Plone Volto block rendering and Somersault editor rendering are separate paths. +Plone Volto rendering uses `BlockWrapper`. +Somersault editor rendering goes through Plate and receives the same classes from `BlockAnatomyPlugin`. + +## Plate-native block categories + +Plate-native block categories are configured in `config.blocks.plateBlocksConfig`. + +```ts +config.blocks.plateBlocksConfig = { + p: { + category: 'text', + blockWidth: { + defaultWidth: 'narrow', + widths: ['narrow'], + }, + }, + toc: { + category: 'navigation', + blockWidth: { + defaultWidth: 'default', + widths: ['layout', 'default', 'narrow'], + }, + }, +}; +``` + +Registry-backed Plone blocks use the `category` from `config.blocks.blocksConfig`. + +## Registry-backed Plone blocks in Plate + +Registry-backed Plone blocks embedded in Plate use the `ploneBlock` node type. +The underlying Plone block type is stored in `@type`. + +```ts +{ + type: 'ploneBlock', + '@type': 'image', + children: [{ text: '' }], +} +``` + +`BlockAnatomyPlugin` resolves this as block type `image`, not `ploneBlock`. + +## Style fields are separate + +Block anatomy controls DOM classes and data attributes. +Style fields control CSS custom properties. + +For example: + +- `BlockAnatomyPlugin` adds `.block.block-teaser.category-teaser` +- `StyleFieldsPlugin` adds styles such as `--theme-color` or `--block-width` + +Keep these responsibilities separate when adding new styling behavior. diff --git a/docs/development/configure-editor-block-widths.md b/docs/development/configure-editor-block-widths.md index 01da3e65d..ce358fa5c 100644 --- a/docs/development/configure-editor-block-widths.md +++ b/docs/development/configure-editor-block-widths.md @@ -11,16 +11,19 @@ myst: This guide explains the current block width model in the Plate editor, including how shared widths are defined, how width policies are configured for Plate blocks and Plone blocks, and how the selected width is injected into rendered block styles. +For the block class-name and data-attribute contract, see {doc}`block-anatomy`. + ## How it works -The block width system is implemented by `BlockWidthPlugin` in `packages/plate/components/editor/plugins/block-width-plugin.ts`. +The editor width toolbar and Plate-native width defaults are implemented by `BlockWidthPlugin` in `packages/plate/components/editor/plugins/block-width-plugin.ts`. +Registry-backed Plone block width styles are resolved through the generic style-field runtime, using `blockWidth` as a bridged style field. The current shape is: - Widths are stored on block nodes as semantic ids such as `narrow`, `default`, `layout`, and `full`. - The available width definitions come from `config.blocks.widths`. - Each width definition is a `StyleDefinition`, so it can inject a full style object. -- The selected width is resolved to a style object and merged into the Plate element `style` prop. +- The selected width is resolved to a style object and merged into the block element `style` prop. - The toolbar uses the active block policy to show only the widths allowed for that block. - Normalization ensures a block always has a valid `blockWidth` value. - If a block does not define its own `defaultWidth`, the plugin resolves it from `config.blocks.widths`. @@ -77,7 +80,7 @@ Otherwise, the first item in `config.blocks.widths` becomes the shared default. ### How styles are injected -The plugin resolves the current `blockWidth` id against `config.blocks.widths`, then injects the matching `style` object into the Plate element. +The runtime resolves the current `blockWidth` id against `config.blocks.widths`, then injects the matching `style` object into the block element. That means this width: @@ -104,8 +107,8 @@ The layout CSS consumes that variable in `packages/layout/styles/content-area.cs So the flow is: 1. The node stores `blockWidth: 'layout'`. -2. The plugin resolves `layout` in `config.blocks.widths`. -3. The plugin injects `style={{ '--block-width': 'var(--layout-container-width)' }}`. +2. The runtime resolves `layout` in `config.blocks.widths`. +3. The runtime injects `style={{ '--block-width': 'var(--layout-container-width)' }}`. 4. CSS uses `var(--block-width)` to compute the final `max-width`. ## Configure widths for Plate blocks @@ -177,7 +180,9 @@ const ImageBlockInfo = { }; ``` -This value is registered through `config.blocks.blocksConfig`, so the width plugin can resolve it for adapted Plone blocks. +This value is registered through `config.blocks.blocksConfig`. +In the current architecture, registry-backed Plone blocks are represented in Plate as `ploneBlock` nodes. +Their `blockWidth` configuration is bridged into the generic style-field runtime, so it is resolved consistently in Plate/Somersault and public `@plone/layout` rendering. To configure another Plone block, add a `blockWidth` section to its block info object: @@ -195,13 +200,13 @@ const MyBlockInfo = { ## Resolution order -The width plugin resolves the active block policy from the registry: +Width policy resolution is split by block family: -- For Plate blocks, it reads `config.blocks.plateBlocksConfig[element.type]`. -- For adapted Plone blocks, it reads `config.blocks.blocksConfig[element['@type']]`. -- If no registry config is found, it falls back to plugin options for backward compatibility. +- For Plate-native blocks, `BlockWidthPlugin` reads `config.blocks.plateBlocksConfig[element.type]`. +- For registry-backed Plone blocks, the style-field runtime reads `config.blocks.blocksConfig[element['@type']].blockWidth`. +- If no Plate-native registry config is found, `BlockWidthPlugin` falls back to plugin options for backward compatibility. -The toolbar uses the resolved policy and the shared width definitions together: +For Plate-native blocks, the width toolbar uses the resolved policy and the shared width definitions together: - the policy determines which width ids are allowed - `config.blocks.widths` determines the labels and injected styles for those ids @@ -210,7 +215,7 @@ The toolbar uses the resolved policy and the shared width definitions together: ```{note} Widths are stored in the node as `blockWidth`. Width values should be semantic ids such as `narrow` or `layout`, not raw CSS values. -The `BlockWidthPlugin` normalizes blocks to ensure `blockWidth` is set and valid for the current block. +The `BlockWidthPlugin` normalizes Plate-native blocks to ensure `blockWidth` is set and valid for the current block. The toolbar options are sourced from `config.blocks.widths`. The actual visual width is controlled by CSS through `--block-width`. Registry-based configuration is now the preferred approach for both Plate and Plone blocks. diff --git a/docs/development/index.md b/docs/development/index.md index b04cd7580..708b4ae6a 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -18,5 +18,6 @@ This part of the documentation describes how to develop projects using Plone Aur images i18n editor-slash-menu +block-anatomy configure-editor-block-widths ``` diff --git a/docs/how-to-guides/configure-style-fields.md b/docs/how-to-guides/configure-style-fields.md index 7d8eb5bce..9822f9353 100644 --- a/docs/how-to-guides/configure-style-fields.md +++ b/docs/how-to-guides/configure-style-fields.md @@ -166,6 +166,10 @@ This works in both: - Plate and Somersault rendering - public block rendering in `@plone/layout` +Style fields only resolve semantic values into styles. +They do not add the block model class-name contract. +For `.block.block-.category-` and `data-block-*` attributes, see {doc}`../development/block-anatomy`. + ## Nested storage If the value must be stored under a nested key, use an object marker instead of `true`. diff --git a/packages/blocks/index.ts b/packages/blocks/index.ts index 70a98bd9a..72aceed56 100644 --- a/packages/blocks/index.ts +++ b/packages/blocks/index.ts @@ -54,41 +54,92 @@ export default function install(config: ConfigType) { const plateBlocksConfig = { p: { + category: 'text', blockWidth: { defaultWidth: 'narrow', widths: ['narrow'], }, }, h2: { + category: 'text', blockWidth: { defaultWidth: 'narrow', widths: ['narrow'], }, }, h3: { + category: 'text', blockWidth: { defaultWidth: 'narrow', widths: ['narrow'], }, }, h4: { + category: 'text', blockWidth: { defaultWidth: 'narrow', widths: ['narrow'], }, }, + h1: { + category: 'text', + }, + h5: { + category: 'text', + }, + h6: { + category: 'text', + }, + blockquote: { + category: 'text', + }, + code_block: { + category: 'text', + }, + toggle: { + category: 'text', + }, title: { + category: 'text', blockWidth: { defaultWidth: 'default', widths: ['default'], }, }, toc: { + category: 'navigation', blockWidth: { defaultWidth: 'default', widths: ['layout', 'default', 'narrow'], }, }, + callout: { + category: 'common', + }, + table: { + category: 'common', + }, + column_group: { + category: 'layout', + }, + column: { + category: 'layout', + }, + img: { + category: 'media', + }, + video: { + category: 'media', + }, + audio: { + category: 'media', + }, + file: { + category: 'media', + }, + media_embed: { + category: 'media', + }, }; config.blocks.plateBlocksConfig = plateBlocksConfig; diff --git a/packages/blocks/news/+block-anatomy-categories.feature b/packages/blocks/news/+block-anatomy-categories.feature new file mode 100644 index 000000000..e6c4c59c0 --- /dev/null +++ b/packages/blocks/news/+block-anatomy-categories.feature @@ -0,0 +1 @@ +Added default Plate-native block categories for the shared block anatomy class contract. @sneridagh diff --git a/packages/cmsui/acceptance/tests/block-width.test.ts b/packages/cmsui/acceptance/tests/block-width.test.ts index e4bfffc33..4def0e7a2 100644 --- a/packages/cmsui/acceptance/tests/block-width.test.ts +++ b/packages/cmsui/acceptance/tests/block-width.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '../../../tooling/playwright/test'; import type { Locator, Page } from '@playwright/test'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import { login } from '../../../tooling/playwright/login'; import { createContent } from '../../../tooling/playwright/content'; @@ -71,7 +72,7 @@ test('Somersault edit mode injects the configured CSS custom property for an exp pageId: 'width-explicit-page', contentTitle: 'Block width explicit page', paragraph: { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'image', blockWidth: 'layout', children: [{ text: '' }], diff --git a/packages/cmsui/acceptance/tests/image-block.test.ts b/packages/cmsui/acceptance/tests/image-block.test.ts index 06d9359d7..a53746043 100644 --- a/packages/cmsui/acceptance/tests/image-block.test.ts +++ b/packages/cmsui/acceptance/tests/image-block.test.ts @@ -1,21 +1,24 @@ import { expect, test } from '../../../tooling/playwright/test'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import { login } from '../../../tooling/playwright/login'; import { createContent } from '../../../tooling/playwright/content'; import { waitForPlateEditorReady } from '../../../tooling/playwright/plate'; import { getEditorHandle, getNodeByPath } from '@platejs/playwright'; import path from 'node:path'; -const IMAGE_ID = 'halfdome-local-image'; -const PAGE_ID = 'image-block-nav-page'; const UPLOAD_FIXTURE_PATH = path.resolve( process.cwd(), 'packages/tooling/playwright/fixtures/halfdome2022.jpg', ); -async function setupImageBlockPage(page: Parameters[0]['page']) { +async function setupImageBlockPage( + page: Parameters[0]['page'], + pageId: string, + imageId: string, +) { await createContent(page, { contentType: 'Image', - contentId: IMAGE_ID, + contentId: imageId, contentTitle: 'Half Dome Local', image: { sourceFilename: 'halfdome2022.jpg', @@ -26,7 +29,7 @@ async function setupImageBlockPage(page: Parameters[0]['page']) { await createContent(page, { contentType: 'Document', - contentId: PAGE_ID, + contentId: pageId, contentTitle: 'Image block nav page', transition: 'publish', bodyModifier: (body) => ({ @@ -44,7 +47,7 @@ async function setupImageBlockPage(page: Parameters[0]['page']) { children: [{ text: 'Text before image' }], }, { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'image', children: [{ text: '' }], }, @@ -61,7 +64,8 @@ async function setupImageBlockPage(page: Parameters[0]['page']) { }), }); - await page.goto(`/@@edit/${PAGE_ID}`); + await page.goto(`/@@edit/${pageId}`); + await page.reload(); await waitForPlateEditorReady(page); } @@ -69,7 +73,8 @@ test('Image block can select a pre-uploaded local image URL', async ({ page, }) => { await login(page); - await setupImageBlockPage(page); + const imageId = 'halfdome-select-image'; + await setupImageBlockPage(page, 'image-block-select-page', imageId); await page .locator('#toolbar') .getByRole('button', { name: 'Settings' }) @@ -79,11 +84,11 @@ test('Image block can select a pre-uploaded local image URL', async ({ await expect(page.locator('#sidebar form')).toHaveCount(1); const urlInput = page.getByPlaceholder('Enter an image URL'); - await urlInput.fill(`/${IMAGE_ID}`); + await urlInput.fill(`/${imageId}`); await urlInput.press('Enter'); await expect( - page.locator(`img[src*="/${IMAGE_ID}/@@images/image"]`).first(), + page.locator(`img[src*="/${imageId}/@@images/image"]`).first(), ).toBeVisible(); }); @@ -91,7 +96,8 @@ test('Image block selection, arrows, enter, and sidebar lifecycle', async ({ page, }) => { await login(page); - await setupImageBlockPage(page); + const imageId = 'halfdome-keyboard-image'; + await setupImageBlockPage(page, 'image-block-keyboard-page', imageId); await page .locator('#toolbar') .getByRole('button', { name: 'Settings' }) @@ -100,9 +106,9 @@ test('Image block selection, arrows, enter, and sidebar lifecycle', async ({ await expect(page.locator('#sidebar form')).toHaveCount(1); const urlInput = page.getByPlaceholder('Enter an image URL'); - await urlInput.fill(`/${IMAGE_ID}`); + await urlInput.fill(`/${imageId}`); await urlInput.press('Enter'); - const image = page.locator(`img[src*="/${IMAGE_ID}/@@images/image"]`).first(); + const image = page.locator(`img[src*="/${imageId}/@@images/image"]`).first(); await expect(image).toBeVisible(); // Enter from selected image should leave image unselected. @@ -133,7 +139,8 @@ test('Image block selection, arrows, enter, and sidebar lifecycle', async ({ test('Image block saves alt text in block data', async ({ page }) => { await login(page); - await setupImageBlockPage(page); + const imageId = 'halfdome-alt-image'; + await setupImageBlockPage(page, 'image-block-alt-page', imageId); await waitForPlateEditorReady(page); const editorHandle = await getEditorHandle(page); await page @@ -145,10 +152,10 @@ test('Image block saves alt text in block data', async ({ page }) => { await expect(page.locator('#sidebar form')).toHaveCount(1); const urlInput = page.getByPlaceholder('Enter an image URL'); - await urlInput.fill(`/${IMAGE_ID}`); + await urlInput.fill(`/${imageId}`); await urlInput.press('Enter'); - const image = page.locator(`img[src*="/${IMAGE_ID}/@@images/image"]`).first(); + const image = page.locator(`img[src*="/${imageId}/@@images/image"]`).first(); await expect(image).toBeVisible(); await image.click(); @@ -162,7 +169,7 @@ test('Image block saves alt text in block data', async ({ page }) => { string, unknown >; - expect(imageNode.type).toBe('unknown'); + expect(imageNode.type).toBe(PLONE_BLOCK_TYPE); expect(imageNode['@type']).toBe('image'); expect(imageNode.alt).toBe('Half Dome at sunset'); @@ -179,7 +186,11 @@ test('Image block can upload an image using the upload button', async ({ page, }) => { await login(page); - await setupImageBlockPage(page); + await setupImageBlockPage( + page, + 'image-block-upload-page', + 'halfdome-upload-image', + ); await page .locator('#toolbar') .getByRole('button', { name: 'Settings' }) diff --git a/packages/cmsui/acceptance/tests/somersault-renderer.test.ts b/packages/cmsui/acceptance/tests/somersault-renderer.test.ts index f444f23da..a9c59aac5 100644 --- a/packages/cmsui/acceptance/tests/somersault-renderer.test.ts +++ b/packages/cmsui/acceptance/tests/somersault-renderer.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../../tooling/playwright/test'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import { createContent } from '../../../tooling/playwright/content'; const PAGE_ID = 'somersault-renderer-page'; @@ -24,7 +25,7 @@ async function setupSomersaultPage(page: Parameters[0]['page']) { children: [{ text: 'Text before teaser' }], }, { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'teaser', title: 'Teaser block title', description: 'Teaser block description', diff --git a/packages/cmsui/acceptance/tests/teaser-block.test.ts b/packages/cmsui/acceptance/tests/teaser-block.test.ts index c1d33b8bb..4e95b79aa 100644 --- a/packages/cmsui/acceptance/tests/teaser-block.test.ts +++ b/packages/cmsui/acceptance/tests/teaser-block.test.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../../tooling/playwright/test'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import { login } from '../../../tooling/playwright/login'; import { createContent } from '../../../tooling/playwright/content'; import { waitForPlateEditorReady } from '../../../tooling/playwright/plate'; @@ -37,7 +38,7 @@ async function setupTeaserBlockPage(page: Parameters[0]['page']) { children: [{ text: 'Text before teaser' }], }, { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'teaser', children: [{ text: '' }], }, diff --git a/packages/cmsui/components/BlockEditor/BlocksEditor.tsx b/packages/cmsui/components/BlockEditor/BlocksEditor.tsx index e0333a178..859bceb64 100644 --- a/packages/cmsui/components/BlockEditor/BlocksEditor.tsx +++ b/packages/cmsui/components/BlockEditor/BlocksEditor.tsx @@ -1,5 +1,6 @@ import { useAtom, useAtomValue } from 'jotai'; import * as React from 'react'; +import { useLocation } from 'react-router'; import { PlateEditor, type Value } from '@plone/plate/components/editor'; import plateBlockSomersaultConfig from '@plone/plate/config/presets/somersault-editor'; import { TITLE_BLOCK_TYPE } from '@plone/plate/components/editor/plugins/title'; @@ -23,18 +24,31 @@ const BlocksEditor = () => { const somersaultBlockAtom = blockAtomFamily(SOMERSAULT_KEY); const [somersaultBlock, setSomersaultBlock] = useAtom(somersaultBlockAtom); const content = useAtomValue(formAtom); + const location = useLocation(); const metadataTitle = content?.title ?? ''; // Keep the initial Plate value stable across parent re-renders. // If we pass a freshly derived value on each change, Plate treats it as a // new controlled value and media nodes (like images) can visually blink. - const stableInitialValueRef = React.useRef(null); + const stableInitialValueRef = React.useRef<{ + key: string; + value: Value; + } | null>(null); + const stableInitialValueKey = + location.pathname ?? + (content?.['@id'] as string | undefined) ?? + (content?.id as string | undefined) ?? + metadataTitle; - if (!stableInitialValueRef.current) { - stableInitialValueRef.current = - (((somersaultBlock as any)?.value as Value | undefined) ?? []).length > 0 - ? ((somersaultBlock as any).value as Value) - : getDefaultSomersaultValue(metadataTitle); + if (stableInitialValueRef.current?.key !== stableInitialValueKey) { + stableInitialValueRef.current = { + key: stableInitialValueKey, + value: + (((somersaultBlock as any)?.value as Value | undefined) ?? []).length > + 0 + ? ((somersaultBlock as any).value as Value) + : getDefaultSomersaultValue(metadataTitle), + }; } const editorConfig = React.useMemo( @@ -52,7 +66,7 @@ const BlocksEditor = () => { return ( { setSomersaultBlock((previousBlock: Record) => ({ ...(previousBlock ?? {}), diff --git a/packages/cmsui/components/BlockEditor/plugins/SidebarPlugin.tsx b/packages/cmsui/components/BlockEditor/plugins/SidebarPlugin.tsx index 181baec07..6ad39ea67 100644 --- a/packages/cmsui/components/BlockEditor/plugins/SidebarPlugin.tsx +++ b/packages/cmsui/components/BlockEditor/plugins/SidebarPlugin.tsx @@ -7,6 +7,7 @@ import { useEditorRef, useEditorSelector, } from '@plone/plate/components/editor'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import type { BlockConfigBase } from '@plone/types'; import config from '@plone/registry'; import BlockSettingsForm from '../BlockSettingsForm'; @@ -18,10 +19,12 @@ type SelectedNativeBlock = { schema: BlockConfigBase['blockSchema']; }; -const isUnknownElement = (node: unknown) => - ElementApi.isElement(node) && node.type === 'unknown'; +const isPloneBlockElement = (node: unknown) => + ElementApi.isElement(node) && node.type === PLONE_BLOCK_TYPE; -const getUnknownFromBlockSelection = (editor: any): [any, number[]] | null => { +const getPloneBlockFromBlockSelection = ( + editor: any, +): [any, number[]] | null => { const entries = editor .getApi(BlockSelectionPlugin) @@ -30,13 +33,15 @@ const getUnknownFromBlockSelection = (editor: any): [any, number[]] | null => { for (let index = entries.length - 1; index >= 0; index -= 1) { const [node, path] = entries[index]; - if (isUnknownElement(node)) return [node, path]; + if (isPloneBlockElement(node)) return [node, path]; } return null; }; -const getUnknownFromActiveElement = (editor: any): [any, number[]] | null => { +const getPloneBlockFromActiveElement = ( + editor: any, +): [any, number[]] | null => { if (typeof document === 'undefined') return null; const activeElement = document.activeElement as HTMLElement | null; @@ -45,7 +50,7 @@ const getUnknownFromActiveElement = (editor: any): [any, number[]] | null => { try { const node = editor.api.toSlateNode(blockElement); - if (!isUnknownElement(node)) return null; + if (!isPloneBlockElement(node)) return null; const path = editor.api.findPath(node); return path ? [node, path] : null; } catch { @@ -53,15 +58,28 @@ const getUnknownFromActiveElement = (editor: any): [any, number[]] | null => { } }; +const getPloneBlockFromSelection = (editor: any): [any, number[]] | null => { + const path = editor.selection?.anchor?.path; + const rootIndex = Array.isArray(path) ? path[0] : undefined; + if (typeof rootIndex !== 'number') return null; + + const entry = editor.api.node([rootIndex]); + if (!entry) return null; + + const [node, nodePath] = entry; + return isPloneBlockElement(node) ? [node, nodePath] : null; +}; + const getSelectedNativeBlock = (editor: any): SelectedNativeBlock | null => { const entry = - getUnknownFromBlockSelection(editor) ?? - getUnknownFromActiveElement(editor) ?? + getPloneBlockFromBlockSelection(editor) ?? + getPloneBlockFromActiveElement(editor) ?? + getPloneBlockFromSelection(editor) ?? editor.api.block({ highest: true }); if (!entry) return null; const [node, path] = entry; - if (!isUnknownElement(node)) return null; + if (!isPloneBlockElement(node)) return null; const blockType = node['@type']; if (typeof blockType !== 'string') return null; diff --git a/packages/cmsui/news/+ploneblock-sidebar-state.bugfix b/packages/cmsui/news/+ploneblock-sidebar-state.bugfix new file mode 100644 index 000000000..a643a263b --- /dev/null +++ b/packages/cmsui/news/+ploneblock-sidebar-state.bugfix @@ -0,0 +1 @@ +Fixed Somersault editor sidebar state when navigating registry-backed Plone blocks and switching edited content. @sneridagh diff --git a/packages/helpers/news/+block-anatomy-helper.feature b/packages/helpers/news/+block-anatomy-helper.feature new file mode 100644 index 000000000..ec7ac3ac7 --- /dev/null +++ b/packages/helpers/news/+block-anatomy-helper.feature @@ -0,0 +1 @@ +Added shared block anatomy helpers and the `ploneBlock` node type constant. @sneridagh diff --git a/packages/helpers/src/blockAnatomy.test.ts b/packages/helpers/src/blockAnatomy.test.ts new file mode 100644 index 000000000..1caa287ff --- /dev/null +++ b/packages/helpers/src/blockAnatomy.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveBlockAnatomy } from './blockAnatomy'; + +describe('resolveBlockAnatomy', () => { + it('resolves block model class names and data attributes', () => { + expect( + resolveBlockAnatomy({ + type: 'teaser', + category: 'common', + }), + ).toEqual({ + className: 'block block-teaser category-common', + dataAttributes: { + 'data-block-type': 'teaser', + 'data-block-category': 'common', + }, + }); + }); +}); diff --git a/packages/helpers/src/blockAnatomy.ts b/packages/helpers/src/blockAnatomy.ts new file mode 100644 index 000000000..58423adaf --- /dev/null +++ b/packages/helpers/src/blockAnatomy.ts @@ -0,0 +1,43 @@ +export const PLONE_BLOCK_TYPE = 'ploneBlock'; + +type BlockAnatomyDataAttributes = Record<`data-${string}`, string>; + +export type ResolveBlockAnatomyArgs = { + category?: string; + className?: string; + type?: string; +}; + +export type ResolvedBlockAnatomy = { + className: string; + dataAttributes: BlockAnatomyDataAttributes; +}; + +const sanitizeClassNamePart = (value?: string) => + value?.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); + +export const isPloneBlockType = (type?: unknown) => type === PLONE_BLOCK_TYPE; + +export const resolveBlockAnatomy = ({ + category, + className, + type, +}: ResolveBlockAnatomyArgs): ResolvedBlockAnatomy => { + const safeType = sanitizeClassNamePart(type); + const safeCategory = sanitizeClassNamePart(category); + const classNames = [ + 'block', + safeType && `block-${safeType}`, + safeCategory && `category-${safeCategory}`, + className, + ].filter((value): value is string => !!value); + const dataAttributes: BlockAnatomyDataAttributes = {}; + + if (type) dataAttributes['data-block-type'] = type; + if (category) dataAttributes['data-block-category'] = category; + + return { + className: classNames.join(' '), + dataAttributes, + }; +}; diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 11cacefe2..d9c6264b4 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -1,6 +1,7 @@ export * from './primitives'; export * from './atoms'; export * from './blocks'; +export * from './blockAnatomy'; export * from './contents'; export * from './flattenToAppURL'; export * from './isInternalURL'; diff --git a/packages/helpers/src/styleFields.test.ts b/packages/helpers/src/styleFields.test.ts index b78d81dc1..a74a4deb3 100644 --- a/packages/helpers/src/styleFields.test.ts +++ b/packages/helpers/src/styleFields.test.ts @@ -5,6 +5,7 @@ import { getStyleFieldsFromSchema, resolveStyleFields, } from './styleFields'; +import { PLONE_BLOCK_TYPE } from './blockAnatomy'; const resolveDefinitions = vi.fn((fieldName: string) => { if (fieldName === 'blockWidth') { @@ -45,7 +46,7 @@ describe('style fields helpers', () => { expect( resolveStyleFields({ data: { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'image', blockWidth: 'full', }, diff --git a/packages/helpers/src/styleFields.ts b/packages/helpers/src/styleFields.ts index 96b2ea464..6343a3dc1 100644 --- a/packages/helpers/src/styleFields.ts +++ b/packages/helpers/src/styleFields.ts @@ -5,6 +5,7 @@ import type { JSONSchema, StyleDefinition, } from '@plone/types'; +import { PLONE_BLOCK_TYPE } from './blockAnatomy'; type DataRecord = Record; type StyleFieldConfig = { @@ -102,7 +103,7 @@ const getBlockType = (data: DataRecord) => { const plateType = data.type; const ploneType = data['@type']; - if (typeof plateType === 'string' && plateType !== 'unknown') + if (typeof plateType === 'string' && plateType !== PLONE_BLOCK_TYPE) return plateType; if (typeof ploneType === 'string') return ploneType; diff --git a/packages/layout/blocks/BlockWrapper.test.tsx b/packages/layout/blocks/BlockWrapper.test.tsx index a56f68653..7b24480ee 100644 --- a/packages/layout/blocks/BlockWrapper.test.tsx +++ b/packages/layout/blocks/BlockWrapper.test.tsx @@ -55,6 +55,7 @@ describe('BlockWrapper', () => { teaser: { id: 'teaser', title: 'Teaser', + category: 'teaser', icon: 'icon', group: 'default', restricted: false, @@ -87,6 +88,9 @@ describe('BlockWrapper', () => { const wrapper = container.firstElementChild as HTMLElement; expect(wrapper).toBeTruthy(); + expect(wrapper.className).toBe('block block-teaser category-teaser'); + expect(wrapper.getAttribute('data-block-type')).toBe('teaser'); + expect(wrapper.getAttribute('data-block-category')).toBe('teaser'); expect(wrapper.style.getPropertyValue('--theme-color')).toBe('wheat'); }); }); diff --git a/packages/layout/blocks/BlockWrapper.tsx b/packages/layout/blocks/BlockWrapper.tsx index 2fb52e1d1..8a419a023 100644 --- a/packages/layout/blocks/BlockWrapper.tsx +++ b/packages/layout/blocks/BlockWrapper.tsx @@ -4,6 +4,7 @@ import type { RenderBlocksProps } from './RenderBlocks'; import type { BlocksFormData } from '@plone/types'; import { getStyleFieldDefinitionsFromRegistry, + resolveBlockAnatomy, resolveStyleFields, } from '@plone/helpers'; import { getBlockStyleFieldConfigs } from '../helpers'; @@ -23,19 +24,15 @@ const BlockWrapper = (props: BlockWrapperProps) => { container: undefined, resolveDefinitions: getStyleFieldDefinitionsFromRegistry, }); - // TODO: Bring in the StyleWrapper helpers for calculating classes - const classNames = undefined; + const anatomy = resolveBlockAnatomy({ + type: data['@type'], + category, + }); return (
{children}
diff --git a/packages/layout/blocks/RenderBlocks.tsx b/packages/layout/blocks/RenderBlocks.tsx index 042347441..9ca22befb 100644 --- a/packages/layout/blocks/RenderBlocks.tsx +++ b/packages/layout/blocks/RenderBlocks.tsx @@ -49,6 +49,8 @@ const RenderBlocks = (props: RenderBlocksProps) => { ); } + // This branch only runs if no somersault block is found, so + // we can be sure that the blocks are not somersault blocks and render them as usual return hasBlocksData(content) ? ( {content.blocks_layout.items.map((block) => { @@ -58,7 +60,11 @@ const RenderBlocks = (props: RenderBlocksProps) => { const Block = blocksConfig[blockType]?.view || DefaultBlockView; return Block ? ( - + {/* @ts-ignore It's ok to pass the blockData as is */} ; + props: { + className?: string; + }; +}) => { + className?: string; + 'data-block-type'?: string; + 'data-block-category'?: string; +}; + +const registryBlocks = config.blocks as Record; + +const snapshotRegistryState = (): RegistryBlocksState => ({ + plateBlocksConfig: registryBlocks.plateBlocksConfig, + blocksConfig: registryBlocks.blocksConfig, +}); + +const restoreRegistryState = (state: RegistryBlocksState) => { + registryBlocks.plateBlocksConfig = state.plateBlocksConfig; + registryBlocks.blocksConfig = state.blocksConfig; +}; + +const initialRegistryState = snapshotRegistryState(); + +afterEach(() => { + restoreRegistryState(initialRegistryState); +}); + +describe('BlockAnatomyPlugin', () => { + it('injects block anatomy classes for Plate-native blocks', () => { + registryBlocks.plateBlocksConfig = { + p: { + category: 'text', + }, + }; + + const transformProps = (BaseBlockAnatomyPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + element: { + type: 'p', + children: [{ text: 'Paragraph' }], + }, + props: { + className: 'existing', + }, + }), + ).toEqual({ + className: 'existing block block-p category-text', + 'data-block-type': 'p', + 'data-block-category': 'text', + }); + }); + + it('injects block anatomy classes for registry-backed Plone blocks', () => { + registryBlocks.blocksConfig = { + image: { + category: 'media', + }, + }; + + const transformProps = (BaseBlockAnatomyPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + element: { + type: PLONE_BLOCK_TYPE, + '@type': 'image', + children: [{ text: '' }], + }, + props: {}, + }), + ).toEqual({ + className: 'block block-image category-media', + 'data-block-type': 'image', + 'data-block-category': 'media', + }); + }); + + it('does not inject anatomy classes for unconfigured Plate internals', () => { + registryBlocks.plateBlocksConfig = { + p: { + category: 'text', + }, + }; + + const transformProps = (BaseBlockAnatomyPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + element: { + type: 'td', + children: [{ text: 'Cell' }], + }, + props: { + className: 'table-cell', + }, + }), + ).toEqual({ + className: 'table-cell', + }); + }); +}); diff --git a/packages/plate/components/editor/plugins/block-anatomy-plugin.ts b/packages/plate/components/editor/plugins/block-anatomy-plugin.ts new file mode 100644 index 000000000..5523ae350 --- /dev/null +++ b/packages/plate/components/editor/plugins/block-anatomy-plugin.ts @@ -0,0 +1,81 @@ +import { PLONE_BLOCK_TYPE, resolveBlockAnatomy } from '@plone/helpers'; +import config from '@plone/registry'; +import type { BlockConfigBase, PlateBlockConfigBase } from '@plone/types'; +import { createSlatePlugin, ElementApi, type TElement } from 'platejs'; +import { toPlatePlugin } from 'platejs/react'; + +export const BLOCK_ANATOMY_KEY = 'blockAnatomy'; + +type BlockAnatomyElement = TElement & { + '@type'?: unknown; +}; + +const getElementBlockType = (element: TElement) => { + if (element.type === PLONE_BLOCK_TYPE) { + const blockType = (element as BlockAnatomyElement)['@type']; + return typeof blockType === 'string' ? blockType : undefined; + } + + return typeof element.type === 'string' ? element.type : undefined; +}; + +const getElementAnatomyConfig = (element: TElement, blockType?: string) => { + if (element.type === PLONE_BLOCK_TYPE) { + if (!blockType) return undefined; + + const blocksConfig = (config.blocks as Record) + .blocksConfig as Record | undefined; + + return { + category: blocksConfig?.[blockType]?.category, + type: blockType, + }; + } + + if (!blockType) return undefined; + + const plateBlocksConfig = (config.blocks as Record) + .plateBlocksConfig as Record | undefined; + const blockConfig = plateBlocksConfig?.[blockType]; + + if (!blockConfig) return undefined; + + return { + category: blockConfig.category, + type: blockType, + }; +}; + +export const BaseBlockAnatomyPlugin = createSlatePlugin({ + key: BLOCK_ANATOMY_KEY, + inject: { + isBlock: true, + nodeProps: { + transformProps: ({ element, props }) => { + if (!element || !ElementApi.isElement(element)) { + return props; + } + + const blockType = getElementBlockType(element); + const blockConfig = getElementAnatomyConfig(element, blockType); + + if (!blockConfig) return props; + + const anatomy = resolveBlockAnatomy({ + type: blockConfig.type, + category: blockConfig.category, + }); + + return { + ...props, + ...anatomy.dataAttributes, + className: [props.className, anatomy.className] + .filter((value): value is string => !!value) + .join(' '), + }; + }, + }, + }, +}); + +export const BlockAnatomyPlugin = toPlatePlugin(BaseBlockAnatomyPlugin); diff --git a/packages/plate/components/editor/plugins/block-width-plugin.test.ts b/packages/plate/components/editor/plugins/block-width-plugin.test.ts index 2a05176df..52a439c9e 100644 --- a/packages/plate/components/editor/plugins/block-width-plugin.test.ts +++ b/packages/plate/components/editor/plugins/block-width-plugin.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import config from '@plone/registry'; import { @@ -141,7 +142,7 @@ describe('block width plugin', () => { }); }); - it('does not use blocksConfig.blockWidth for unknown blocks in BlockWidthPlugin', () => { + it('does not use blocksConfig.blockWidth for ploneBlock nodes in BlockWidthPlugin', () => { registryBlocks.widths = [ { name: 'default', @@ -167,7 +168,7 @@ describe('block width plugin', () => { expect( getBlockWidthConfig(editor, { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'image', children: [{ text: '' }], } as any), @@ -177,7 +178,7 @@ describe('block width plugin', () => { }); }); - it('still uses blocksConfig.blockWidth as a fallback for unknown blocks in style fields', () => { + it('still uses blocksConfig.blockWidth as a fallback for ploneBlock nodes in style fields', () => { registryBlocks.widths = [ { name: 'default', @@ -205,7 +206,7 @@ describe('block width plugin', () => { expect( transformProps({ element: { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'image', children: [{ text: '' }], }, @@ -399,7 +400,7 @@ describe('block width plugin', () => { const setNodes = vi.fn(); const block = { - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': 'teaser', styles: { theme: 'sand', diff --git a/packages/plate/components/editor/plugins/block-width-plugin.ts b/packages/plate/components/editor/plugins/block-width-plugin.ts index 63670b521..2f54dee1f 100644 --- a/packages/plate/components/editor/plugins/block-width-plugin.ts +++ b/packages/plate/components/editor/plugins/block-width-plugin.ts @@ -5,6 +5,7 @@ import { type SlateEditor, type TElement, } from 'platejs'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import config from '@plone/registry'; import type { StyleDefinition } from '@plone/types'; import { toPlatePlugin } from 'platejs/react'; @@ -114,12 +115,12 @@ export const resolveBlockWidthConfig = ( editor: SlateEditor, element?: TElement | null, ): BlockWidthConfig => { - if (element?.type === 'unknown') { + if (element?.type === PLONE_BLOCK_TYPE) { return {}; } const registryConfig = - element?.type === 'unknown' + element?.type === PLONE_BLOCK_TYPE ? getPloneBlockRegistryWidthConfig(element) : getPlateBlockRegistryWidthConfig(element); @@ -172,7 +173,7 @@ export const applyBlockWidthDefaultsInValue = (value: unknown[]) => { const element = node as ValueElement; if (typeof element.type !== 'string') return; - if (element.type === 'unknown') { + if (element.type === PLONE_BLOCK_TYPE) { if (Array.isArray(element.children)) { element.children.forEach(visit); } @@ -219,7 +220,7 @@ export const withBlockWidthDefaults = ( editor: SlateEditor, element: T, ): T => { - if (element.type === 'unknown') { + if (element.type === PLONE_BLOCK_TYPE) { return element; } @@ -255,7 +256,7 @@ const withInsertedBlockWidthDefaults = ( const nextNode: TElement = children === nodes.children ? nodes : ({ ...nodes, children } as TElement); - if (!editor.api.isBlock(nextNode) || nextNode.type === 'unknown') { + if (!editor.api.isBlock(nextNode) || nextNode.type === PLONE_BLOCK_TYPE) { return nextNode; } @@ -268,7 +269,7 @@ const setBlockWidth = ( setNodesOptions?: SetNodesOptions, ) => { const matchesValue = (node: TElement) => { - if (node.type === 'unknown') return false; + if (node.type === PLONE_BLOCK_TYPE) return false; const config = getBlockWidthConfig(editor, node); @@ -300,7 +301,7 @@ export const BaseBlockWidthPlugin = createSlatePlugin({ if ( !element || !ElementApi.isElement(element) || - element.type === 'unknown' + element.type === PLONE_BLOCK_TYPE ) { return props; } @@ -344,7 +345,7 @@ export const BaseBlockWidthPlugin = createSlatePlugin({ const block = blockEntry && ElementApi.isElement(blockEntry[0]) && - blockEntry[0].type !== 'unknown' + blockEntry[0].type !== PLONE_BLOCK_TYPE ? blockEntry[0] : undefined; const { defaultWidth } = getBlockWidthConfig(editor, block); diff --git a/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx b/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx index de4812e75..a853702c2 100644 --- a/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx +++ b/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx @@ -1,7 +1,12 @@ import React from 'react'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import config from '@plone/registry'; import { createSlatePlugin, type TElement } from 'platejs'; -import { toPlatePlugin, type PlateElementProps } from 'platejs/react'; +import { + PlateElement, + toPlatePlugin, + type PlateElementProps, +} from 'platejs/react'; import { BlockInnerContainer } from '../../ui/block-inner-container'; type NativeBlockElement = TElement & { @@ -49,25 +54,25 @@ function PloneBlockAdapterRendererElement( const View = block?.view; if (!blockData || !View) { - return
{props.children}
; + return {props.children}; } return ( -
+ -
+ ); } export const BasePloneBlockAdapterRendererPlugin = createSlatePlugin({ - key: 'unknown', + key: PLONE_BLOCK_TYPE, node: { component: PloneBlockAdapterRendererElement, isVoid: true, isElement: true, - type: 'unknown', + type: PLONE_BLOCK_TYPE, }, }); diff --git a/packages/plate/components/editor/plugins/plone-block-adapter.tsx b/packages/plate/components/editor/plugins/plone-block-adapter.tsx index 0ec17b0a8..abfe93269 100644 --- a/packages/plate/components/editor/plugins/plone-block-adapter.tsx +++ b/packages/plate/components/editor/plugins/plone-block-adapter.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { BlockSelectionPlugin } from '@platejs/selection/react'; -import { isDeepEqual } from '@plone/helpers'; +import { isDeepEqual, PLONE_BLOCK_TYPE } from '@plone/helpers'; import config from '@plone/registry'; import { createSlatePlugin, ElementApi, PathApi, type TElement } from 'platejs'; import { @@ -61,6 +61,9 @@ function getBlockId(element: NativeBlockElement, path: number[]) { return explicitId ? String(explicitId) : path.join('-'); } +const getTopLevelBlockPath = (path: number[]) => + path.length > 1 ? [path[0]] : path; + function PloneBlockAdapterContent( props: PlateElementProps & { path: number[]; @@ -201,12 +204,12 @@ export function PloneBlockAdapterElement( ); } -export const BasePloneBlockAdapterPlugin = createSlatePlugin({ - // TODO: Find a better key for this plugin. Maybe just `block`? - // It is used in conversions too. - key: 'unknown', +export const BasePloneBlockKeyboardPlugin = createSlatePlugin({ + key: `${PLONE_BLOCK_TYPE}Keyboard`, handlers: { onKeyDown: ({ editor, event }: { editor: PlateEditor; event: any }) => { + if (event.defaultPrevented) return; + const nativeEvent = (event as any)?.nativeEvent ?? event; if (!nativeEvent) return; if (!editor.selection || !editor.api.isCollapsed()) return; @@ -215,16 +218,21 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ if (!currentEntry) return; const [currentNode, currentPath] = currentEntry; - const currentIsNativeUnknown = - ElementApi.isElement(currentNode) && currentNode.type === 'unknown'; + const currentBlockPath = getTopLevelBlockPath(currentPath as number[]); + const currentIsPloneBlock = + ElementApi.isElement(currentNode) && + currentNode.type === PLONE_BLOCK_TYPE; - if (nativeEvent.key === 'Enter' && currentIsNativeUnknown) { + if (nativeEvent.key === 'Enter' && currentIsPloneBlock) { event.preventDefault(); - const nextPath = PathApi.next(currentPath as number[]); + const nextPath = PathApi.next(currentBlockPath); const nextNode = editor.api.node(nextPath)?.[0]; - if (ElementApi.isElement(nextNode) && nextNode.type !== 'unknown') { + if ( + ElementApi.isElement(nextNode) && + nextNode.type !== PLONE_BLOCK_TYPE + ) { editor.tf.focus(); editor.tf.select([...nextPath, 0]); editor.tf.collapse({ edge: 'start' }); @@ -244,12 +252,12 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ return; } - if (nativeEvent.key === 'ArrowUp' && currentIsNativeUnknown) { + if (nativeEvent.key === 'ArrowUp' && currentIsPloneBlock) { event.preventDefault(); let previousPath: number[] | undefined; try { - previousPath = PathApi.previous(currentPath as number[]); + previousPath = PathApi.previous(currentBlockPath); } catch { return; } @@ -259,9 +267,10 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ editor.tf.focus(); if ( ElementApi.isElement(previousNode) && - previousNode.type === 'unknown' + previousNode.type === PLONE_BLOCK_TYPE ) { - editor.tf.select(previousPath); + editor.tf.select([...previousPath, 0]); + editor.tf.collapse({ edge: 'start' }); return; } editor.tf.select([...previousPath, 0]); @@ -269,16 +278,20 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ return; } - if (nativeEvent.key === 'ArrowDown' && currentIsNativeUnknown) { + if (nativeEvent.key === 'ArrowDown' && currentIsPloneBlock) { event.preventDefault(); - const nextPath = PathApi.next(currentPath as number[]); + const nextPath = PathApi.next(currentBlockPath); const nextNode = editor.api.node(nextPath)?.[0]; if (!nextNode) return; editor.tf.focus(); - if (ElementApi.isElement(nextNode) && nextNode.type === 'unknown') { - editor.tf.select(nextPath); + if ( + ElementApi.isElement(nextNode) && + nextNode.type === PLONE_BLOCK_TYPE + ) { + editor.tf.select([...nextPath, 0]); + editor.tf.collapse({ edge: 'start' }); return; } editor.tf.select([...nextPath, 0]); @@ -289,7 +302,7 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ if (nativeEvent.key === 'ArrowDown') { let nextPath: number[] | undefined; try { - nextPath = PathApi.next(currentPath as number[]); + nextPath = PathApi.next(currentBlockPath); } catch { return; } @@ -297,10 +310,14 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ if (!nextPath) return; const nextNode = editor.api.node(nextPath)?.[0]; - if (ElementApi.isElement(nextNode) && nextNode.type === 'unknown') { + if ( + ElementApi.isElement(nextNode) && + nextNode.type === PLONE_BLOCK_TYPE + ) { event.preventDefault(); editor.tf.focus(); - editor.tf.select(nextPath); + editor.tf.select([...nextPath, 0]); + editor.tf.collapse({ edge: 'start' }); } return; } @@ -309,7 +326,7 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ let previousPath: number[] | undefined; try { - previousPath = PathApi.previous(currentPath as number[]); + previousPath = PathApi.previous(currentBlockPath); } catch { return; } @@ -319,19 +336,24 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ const previousNode = editor.api.node(previousPath)?.[0]; if ( ElementApi.isElement(previousNode) && - previousNode.type === 'unknown' + previousNode.type === PLONE_BLOCK_TYPE ) { event.preventDefault(); editor.tf.focus(); - editor.tf.select(previousPath); + editor.tf.select([...previousPath, 0]); + editor.tf.collapse({ edge: 'start' }); } }, } as any, +}); + +export const BasePloneBlockAdapterPlugin = createSlatePlugin({ + key: PLONE_BLOCK_TYPE, node: { component: PloneBlockAdapterElement, isVoid: true, isElement: true, - type: 'unknown', + type: PLONE_BLOCK_TYPE, }, extendEditor: ({ editor }) => { const insertBreak = editor.tf.insertBreak; @@ -340,11 +362,14 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ const blockEntry = editor.api.block({ highest: true }); if (blockEntry) { const [node, path] = blockEntry; - if (ElementApi.isElement(node) && node.type === 'unknown') { + if (ElementApi.isElement(node) && node.type === PLONE_BLOCK_TYPE) { const nextPath = PathApi.next(path); const nextNode = editor.api.node(nextPath)?.[0]; - if (ElementApi.isElement(nextNode) && nextNode.type !== 'unknown') { + if ( + ElementApi.isElement(nextNode) && + nextNode.type !== PLONE_BLOCK_TYPE + ) { editor.tf.focus(); editor.tf.select([...nextPath, 0]); editor.tf.collapse({ edge: 'start' }); @@ -372,6 +397,10 @@ export const BasePloneBlockAdapterPlugin = createSlatePlugin({ }, }); +export const PloneBlockKeyboardPlugin = toPlatePlugin( + BasePloneBlockKeyboardPlugin, +); + export const PloneBlockAdapterPlugin = toPlatePlugin( BasePloneBlockAdapterPlugin, ); diff --git a/packages/plate/components/editor/plugins/slash-menu.tsx b/packages/plate/components/editor/plugins/slash-menu.tsx index d3fc6bf4f..e903301a0 100644 --- a/packages/plate/components/editor/plugins/slash-menu.tsx +++ b/packages/plate/components/editor/plugins/slash-menu.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import type { PlateEditor } from 'platejs/react'; import { AIChatPlugin } from '@platejs/ai/react'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import config from '@plone/registry'; import { BookA, @@ -82,7 +83,7 @@ const insertSomersaultNativeBlock = ( editor.tf.insertNodes( editor.api.create.block({ - type: 'unknown', + type: PLONE_BLOCK_TYPE, '@type': nativeBlockType, }), { @@ -91,7 +92,7 @@ const insertSomersaultNativeBlock = ( }, ); - if (block[0].type !== 'unknown') { + if (block[0].type !== PLONE_BLOCK_TYPE) { editor.getApi(SuggestionPlugin).suggestion.withoutSuggestions(() => { editor.tf.removeNodes({ previousEmptyBlock: true }); }); diff --git a/packages/plate/components/editor/plugins/style-fields-plugin.ts b/packages/plate/components/editor/plugins/style-fields-plugin.ts index 82e9a211c..f6dab7a60 100644 --- a/packages/plate/components/editor/plugins/style-fields-plugin.ts +++ b/packages/plate/components/editor/plugins/style-fields-plugin.ts @@ -2,6 +2,7 @@ import { applyStyleFieldDefaultsInData, getStyleFieldsFromBlockSchema, getStyleFieldDefinitionsFromRegistry, + PLONE_BLOCK_TYPE, resolveStyleFields, setStyleFieldValue, } from '@plone/helpers'; @@ -73,7 +74,7 @@ const getElementStyleFieldConfigs = ( ): Record => { if (!element) return {}; - if (element.type === 'unknown') { + if (element.type === PLONE_BLOCK_TYPE) { const blockType = (element as TElement & { '@type'?: unknown })['@type']; if (typeof blockType !== 'string') return {}; @@ -218,7 +219,7 @@ export const setStyleFieldOnEditor = ( const definitions = getStyleFieldDefinitionsFromRegistry(fieldName, { data: node as Record, blockType: - (typeof node.type === 'string' && node.type !== 'unknown' + (typeof node.type === 'string' && node.type !== PLONE_BLOCK_TYPE ? node.type : (node as TElement & { '@type'?: string })['@type']) ?? undefined, fieldName, diff --git a/packages/plate/config/presets/somersault-editor.ts b/packages/plate/config/presets/somersault-editor.ts index 85733f27b..80237ed5f 100644 --- a/packages/plate/config/presets/somersault-editor.ts +++ b/packages/plate/config/presets/somersault-editor.ts @@ -2,7 +2,10 @@ import type { PlateConfig } from '../../types'; import { BlockEditorKit } from '../../components/editor/block-editor-kit'; import { BlockFloatingToolbarButtons } from '../../components/ui/preset-block-floating-toolbar-buttons'; import { setFloatingToolbarButtons } from '../../components/editor/plugins/floating-toolbar-kit'; -import { PloneBlockAdapterPlugin } from '../../components/editor/plugins/plone-block-adapter'; +import { + PloneBlockAdapterPlugin, + PloneBlockKeyboardPlugin, +} from '../../components/editor/plugins/plone-block-adapter'; import { PlaywrightPlugin } from '@platejs/playwright'; import { TitleBlock } from '../../components/editor/plugins/title'; @@ -11,6 +14,7 @@ setFloatingToolbarButtons(BlockFloatingToolbarButtons); const native: PlateConfig = { plugins: [ + PloneBlockKeyboardPlugin, ...BlockEditorKit, TitleBlock, PloneBlockAdapterPlugin, diff --git a/packages/plate/news/+ploneblock-anatomy.breaking b/packages/plate/news/+ploneblock-anatomy.breaking new file mode 100644 index 000000000..a66bfeea0 --- /dev/null +++ b/packages/plate/news/+ploneblock-anatomy.breaking @@ -0,0 +1 @@ +Renamed adapted registry-backed block nodes from `unknown` to `ploneBlock` and added the `BlockAnatomyPlugin` class contract. @sneridagh diff --git a/packages/types/news/+plate-block-category.feature b/packages/types/news/+plate-block-category.feature new file mode 100644 index 000000000..891002e0a --- /dev/null +++ b/packages/types/news/+plate-block-category.feature @@ -0,0 +1 @@ +Added `category` support to Plate block configuration types. @sneridagh diff --git a/packages/types/src/config/Blocks.d.ts b/packages/types/src/config/Blocks.d.ts index 6bba52cea..14f32d8c8 100644 --- a/packages/types/src/config/Blocks.d.ts +++ b/packages/types/src/config/Blocks.d.ts @@ -150,6 +150,7 @@ export interface BlockConfigBase { } export interface PlateBlockConfigBase { + category?: string; blockWidth?: BlockWidthConfig; } From f0821ad1396baed4096bebe04fc97c0f2dd184f9 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Sun, 7 Jun 2026 17:10:21 +0200 Subject: [PATCH 2/4] Add more background --- docs/development/block-anatomy.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/development/block-anatomy.md b/docs/development/block-anatomy.md index 6c6910e7e..ab5db75d9 100644 --- a/docs/development/block-anatomy.md +++ b/docs/development/block-anatomy.md @@ -11,6 +11,24 @@ myst: Plone Aurora exposes a shared block anatomy contract for both public rendering and Plate/Somersault rendering. +## Plone Aurora's block model + +In the past, the Plone's block engines used different approaches, improving and iterating them over the years. +We identified several of these iterations, and defined what we called the Block Model v3. +It is the model behind Plone Aurora block anatomy contract, and from now on, it is simply referred to as the block model. +Its main goal is to keep view mode and edit mode structurally aligned so the same CSS can work in both places. +Instead of letting each block invent its own wrapper layout, the framework provides a standard two-level structure and leaves the block component focused on content and behavior. + +The important ideas are: + +- The outer container is responsible for full-width page placement, theme styling, and vertical spacing. +- The inner container controls content width, centering, and block-to-block spacing. +- Block categories drive spacing behavior between adjacent blocks, so spacing decisions stay consistent across the site. +- Blocks should stay simple and render their actual content directly, without adding extra layout wrappers unless they are genuinely needed. +- The model is opt-in, which keeps existing blocks compatible while allowing v3-capable blocks to adopt the shared structure. + +In practice, that means the block model defines the structure around a block, while the block itself stays focused on the content it renders. + The outer block element receives: ```html From 9d70e1ac5aa73fe64be9dad15dd41b7aa6d9a371 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Tue, 9 Jun 2026 12:52:47 +0200 Subject: [PATCH 3/4] Clarify BlockWidth-StyledFields purpose, adapt docs --- docs/development/block-anatomy.md | 4 +- .../configure-editor-block-widths.md | 42 +++---- docs/how-to-guides/configure-style-fields.md | 31 +++-- .../editor/plugins/block-width-plugin.test.ts | 118 +++++++++++++++++- .../editor/plugins/block-width-plugin.ts | 28 ++--- .../editor/plugins/style-fields-plugin.ts | 46 +------ 6 files changed, 168 insertions(+), 101 deletions(-) diff --git a/docs/development/block-anatomy.md b/docs/development/block-anatomy.md index ab5db75d9..b415e49f1 100644 --- a/docs/development/block-anatomy.md +++ b/docs/development/block-anatomy.md @@ -55,8 +55,8 @@ The anatomy contract is resolved by `resolveBlockAnatomy` in `@plone/helpers`. It is consumed by: -- `BlockWrapper` in `@plone/layout` for Plone Volto block rendering -- `BlockAnatomyPlugin` in `@plone/plate` for Plate-native and Somersault editor block rendering +- `BlockAnatomyPlugin` in `@plone/plate` for Plate-native blocks and registry-backed Plone blocks in Plate/Somersault rendering +- `BlockWrapper` in `@plone/layout` for Plone Volto block fallback rendering (not-Plate-native, non-Somersault) This avoids duplicating class-name rules in individual blocks. diff --git a/docs/development/configure-editor-block-widths.md b/docs/development/configure-editor-block-widths.md index ce358fa5c..fca7c823f 100644 --- a/docs/development/configure-editor-block-widths.md +++ b/docs/development/configure-editor-block-widths.md @@ -164,38 +164,34 @@ The `blockWidth` policy supports: ## Configure widths for Plone blocks -Plone blocks are configured in their block info object under `packages/blocks//index.ts`. +Plone blocks (non-plate native, registry-backed) are configured in their block schema. -Example from `packages/blocks/Image/index.ts`: +Example from `packages/blocks/Image/schema.tsx`: ```ts -const ImageBlockInfo = { - id: 'image', - title: 'Image', - // ... - blockWidth: { - defaultWidth: 'default', - widths: ['layout', 'default', 'narrow', 'full'], - }, -}; +blockWidth: { + title: 'Block width', + widget: 'width', + default: 'default', + styleField: true, +}, ``` -This value is registered through `config.blocks.blocksConfig`. -In the current architecture, registry-backed Plone blocks are represented in Plate as `ploneBlock` nodes. -Their `blockWidth` configuration is bridged into the generic style-field runtime, so it is resolved consistently in Plate/Somersault and public `@plone/layout` rendering. +This stores the selected width id in the block data as `blockWidth`. +Because the field is marked with `styleField: true`, `StyleFieldsPlugin` can resolve that stored id to the matching style definition from `config.blocks.widths`. -To configure another Plone block, add a `blockWidth` section to its block info object: +To configure another Plone block, add a `blockWidth` property to its schema: ```ts -const MyBlockInfo = { - id: 'myBlock', - title: 'My block', - // ... +properties: { blockWidth: { - defaultWidth: 'default', - widths: ['default', 'narrow'], + title: 'Block width', + widget: 'width', + default: 'default', + actions = ['narrow', 'default'], + styleField: true, }, -}; +} ``` ## Resolution order @@ -203,7 +199,7 @@ const MyBlockInfo = { Width policy resolution is split by block family: - For Plate-native blocks, `BlockWidthPlugin` reads `config.blocks.plateBlocksConfig[element.type]`. -- For registry-backed Plone blocks, the style-field runtime reads `config.blocks.blocksConfig[element['@type']].blockWidth`. +- For Plone blocks, the style-field runtime reads fields marked with `styleField` from `config.blocks.blocksConfig[element['@type']].blockSchema`. - If no Plate-native registry config is found, `BlockWidthPlugin` falls back to plugin options for backward compatibility. For Plate-native blocks, the width toolbar uses the resolved policy and the shared width definitions together: diff --git a/docs/how-to-guides/configure-style-fields.md b/docs/how-to-guides/configure-style-fields.md index 9822f9353..18f8d2300 100644 --- a/docs/how-to-guides/configure-style-fields.md +++ b/docs/how-to-guides/configure-style-fields.md @@ -9,7 +9,7 @@ myst: # Configure style fields -This guide explains how to configure block styles based on fields in the block schema in Plone Aurora. +This guide explains how to configure Plone blocks (non-plate native, registry-backed) styles based on fields in the block schema in Plone Aurora. It focuses on a `theme` field, because that is the common case for the new style field system. Use this model when you want a block to store a semantic ID, such as `default` or `sand`, and resolve that ID to a runtime style object later. @@ -43,7 +43,7 @@ And Plone Aurora can later resolve `sand` to a CSS variable: ## Mark the schema field -Configure generic style fields in the block schema. +Configure generic style fields in the Plone block schema. Add the field as usual, then mark it with `styleField: true`. ```ts @@ -161,11 +161,6 @@ At render time, Plone Aurora: 4. resolves the stored value against the returned `StyleDefinition[]` 5. injects the matching `style` object into the block wrapper -This works in both: - -- Plate and Somersault rendering -- public block rendering in `@plone/layout` - Style fields only resolve semantic values into styles. They do not add the block model class-name contract. For `.block.block-.category-` and `data-block-*` attributes, see {doc}`../development/block-anatomy`. @@ -199,21 +194,30 @@ This stores the selected value under: } ``` -Use this only when you need compatibility with an existing data shape. +Use this only when you need compatibility with an existing data shape (eg. Plone Volto's legacy blocks). For new Plone Aurora code, flat fields such as `theme` are the preferred default. ## Why `blockWidth` is different -`blockWidth` remains special. -It is intrinsic to the block wrapper and width policy of each block, so it still uses `blockWidth` configuration in `blocksConfig` and `plateBlocksConfig`. +`blockWidth` is a reserved style field with existing editor UI and shared width definitions. +Do not create a separate custom field for setting block widths. That means: - generic style fields such as `theme` are schema-driven -- `blockWidth` remains block configuration-driven +- Plate-native block widths are configuration-driven +- registry-backed Plone block widths are schema-driven + +How `blockWidth` is resolved depends on where the block is rendered: + +- In the Plate editor, Plate-native blocks use `BlockWidthPlugin`. + It reads their width policy from `config.blocks.plateBlocksConfig[element.type]`. +- In the Plate editor, registry-backed Plone blocks are adapted to `ploneBlock` nodes. + They do not use `BlockWidthPlugin`. + `StyleFieldsPlugin` reads their real Plone block type from `element['@type']`, then reads `blockWidth` from fields marked with `styleField` in that block schema. The global width definitions themselves have not changed. -They are still defined in `config.blocks.widths` and resolved through the `blockWidth` utility. +They are still defined in `config.blocks.widths` and resolved through the registered `blockWidth` style definitions. ## Theme example @@ -321,4 +325,5 @@ For generic style-backed fields: - expose its values through `choices` or `actions` (or other widget configuration) - register a `styleFieldDefinition` utility with the same field name -For `blockWidth`, keep using the existing `blockWidth` block configuration. +For registry-backed Plone blocks, define `blockWidth` in the block schema and mark it with `styleField`. +For Plate-native blocks, keep using the existing `blockWidth` configuration in `plateBlocksConfig`. diff --git a/packages/plate/components/editor/plugins/block-width-plugin.test.ts b/packages/plate/components/editor/plugins/block-width-plugin.test.ts index 52a439c9e..98b29086d 100644 --- a/packages/plate/components/editor/plugins/block-width-plugin.test.ts +++ b/packages/plate/components/editor/plugins/block-width-plugin.test.ts @@ -178,7 +178,7 @@ describe('block width plugin', () => { }); }); - it('still uses blocksConfig.blockWidth as a fallback for ploneBlock nodes in style fields', () => { + it('does not use blocksConfig.blockWidth as a fallback for ploneBlock style fields', () => { registryBlocks.widths = [ { name: 'default', @@ -200,6 +200,62 @@ describe('block width plugin', () => { }, }; + const transformProps = (BaseStyleFieldsPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + element: { + type: PLONE_BLOCK_TYPE, + '@type': 'image', + children: [{ text: '' }], + }, + props: { + style: { + color: 'red', + }, + }, + }), + ).toEqual({ + style: { + color: 'red', + }, + }); + }); + + it('uses schema-marked blockWidth style fields for ploneBlock nodes', () => { + registryBlocks.widths = [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + { + name: 'layout', + label: 'Layout', + style: { '--block-width': 'var(--layout-container-width)' }, + }, + ]; + registryBlocks.blocksConfig = { + image: { + blockSchema: { + title: 'Image', + fieldsets: [], + required: [], + properties: { + blockWidth: { + default: 'layout', + choices: [ + ['default', 'Default'], + ['layout', 'Layout'], + ], + styleField: true, + }, + }, + }, + }, + }; + const transformProps = (BaseStyleFieldsPlugin as any).inject.nodeProps .transformProps as TransformPropsFn; @@ -224,6 +280,66 @@ describe('block width plugin', () => { }); }); + it('does not leak the raw blockWidth field into ploneBlock inline styles', () => { + registryBlocks.widths = [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + ]; + registryBlocks.blocksConfig = { + image: { + blockSchema: { + title: 'Image', + fieldsets: [], + required: [], + properties: { + blockWidth: { + default: 'default', + choices: [['default', 'Default']], + styleField: true, + }, + }, + }, + }, + }; + + const blockWidthNodeProps = (BaseBlockWidthPlugin as any).inject.nodeProps; + const blockElement = { + type: PLONE_BLOCK_TYPE, + '@type': 'image', + blockWidth: 'default', + children: [{ text: '' }], + }; + const transformProps = (BaseStyleFieldsPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + blockWidthNodeProps.query({ + nodeProps: { + element: blockElement, + }, + }), + ).toBe(false); + expect(blockWidthNodeProps.transformStyle()).toEqual({}); + expect( + transformProps({ + element: blockElement, + props: { + style: { + position: 'relative', + }, + }, + }), + ).toEqual({ + style: { + position: 'relative', + '--block-width': 'var(--default-container-width)', + }, + }); + }); + it('adds the default width to the allowed list when the config omits it', () => { registryBlocks.plateBlocksConfig = { p: { diff --git a/packages/plate/components/editor/plugins/block-width-plugin.ts b/packages/plate/components/editor/plugins/block-width-plugin.ts index 2f54dee1f..07892b571 100644 --- a/packages/plate/components/editor/plugins/block-width-plugin.ts +++ b/packages/plate/components/editor/plugins/block-width-plugin.ts @@ -96,21 +96,6 @@ const getPlateBlockRegistryWidthConfig = ( return plateBlocksConfig?.[element.type]?.blockWidth ?? {}; }; -const getPloneBlockRegistryWidthConfig = ( - element?: TElement | null, -): BlockWidthConfig => { - const blockType = ( - element as (TElement & { '@type'?: unknown }) | null | undefined - )?.['@type']; - if (!blockType || typeof blockType !== 'string') return {}; - - const blocksConfig = config?.blocks?.blocksConfig as unknown as - | Record - | undefined; - - return blocksConfig?.[blockType]?.blockWidth ?? {}; -}; - export const resolveBlockWidthConfig = ( editor: SlateEditor, element?: TElement | null, @@ -119,10 +104,7 @@ export const resolveBlockWidthConfig = ( return {}; } - const registryConfig = - element?.type === PLONE_BLOCK_TYPE - ? getPloneBlockRegistryWidthConfig(element) - : getPlateBlockRegistryWidthConfig(element); + const registryConfig = getPlateBlockRegistryWidthConfig(element); if (registryConfig.defaultWidth || registryConfig.widths?.length) { return registryConfig; @@ -297,6 +279,14 @@ export const BaseBlockWidthPlugin = createSlatePlugin({ isBlock: true, nodeProps: { nodeKey: BLOCK_WIDTH_KEY, + query: ({ nodeProps }) => { + const element = nodeProps.element; + + return ( + !ElementApi.isElement(element) || element.type !== PLONE_BLOCK_TYPE + ); + }, + transformStyle: () => ({}) as CSSStyleDeclaration, transformProps: ({ editor, element, nodeValue, props }) => { if ( !element || diff --git a/packages/plate/components/editor/plugins/style-fields-plugin.ts b/packages/plate/components/editor/plugins/style-fields-plugin.ts index f6dab7a60..d274b1fc6 100644 --- a/packages/plate/components/editor/plugins/style-fields-plugin.ts +++ b/packages/plate/components/editor/plugins/style-fields-plugin.ts @@ -32,43 +32,6 @@ type ValueElement = Record & { const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); -const getGlobalWidthValues = () => - ( - (config.blocks as Record).widths as - | Array<{ name?: string }> - | undefined - ) - ?.map((definition) => definition.name) - .filter((name): name is string => !!name) ?? []; - -const getGlobalDefaultWidth = () => { - const values = getGlobalWidthValues(); - - if (!values.length) return 'default'; - if (values.includes('default')) return 'default'; - - return values[0]; -}; - -const withBlockWidthFallback = ( - styleFields: Record, - blockWidthConfig?: { - defaultWidth?: string; - widths?: readonly string[]; - }, -) => { - if (!blockWidthConfig) { - return styleFields; - } - - styleFields.blockWidth = { - defaultValue: blockWidthConfig.defaultWidth ?? getGlobalDefaultWidth(), - values: blockWidthConfig.widths ?? getGlobalWidthValues(), - }; - - return styleFields; -}; - const getElementStyleFieldConfigs = ( element?: TElement | null, ): Record => { @@ -84,12 +47,9 @@ const getElementStyleFieldConfigs = ( const currentBlockConfig = blockConfig?.[blockType]; - return withBlockWidthFallback( - getStyleFieldsFromBlockSchema( - currentBlockConfig, - element as unknown as BlocksFormData, - ), - currentBlockConfig?.blockWidth, + return getStyleFieldsFromBlockSchema( + currentBlockConfig, + element as unknown as BlocksFormData, ); } From fd0335c2f0ba23daccb304fc4bfb200169ab6600 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Sat, 13 Jun 2026 09:41:04 +0200 Subject: [PATCH 4/4] Fix tests, add blockWidth and rename unknown to ploneBlock to the migrations --- .../server/content-migrations.server.test.ts | 34 ++++++++++++++++--- .../app/config/server/migrations.server.ts | 29 +++++++++++++++- .../news/+native-blocks-to-somersault.feature | 2 +- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/aurora/app/config/server/content-migrations.server.test.ts b/apps/aurora/app/config/server/content-migrations.server.test.ts index a6b7ad05e..4f10ce16c 100644 --- a/apps/aurora/app/config/server/content-migrations.server.test.ts +++ b/apps/aurora/app/config/server/content-migrations.server.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { PLONE_BLOCK_TYPE } from '@plone/helpers'; import config from '@plone/registry'; import { SOMERSAULT_KEY } from '@plone/plate/constants'; import type { Content, ContentBase } from '@plone/types'; @@ -65,11 +66,25 @@ describe('content migrations', () => { }); }); - it('moves native blocks into the somersault field as unknown nodes', () => { + it('moves native blocks into the somersault field as ploneBlock nodes', () => { config.blocks = { blocksConfig: { listing: {}, image: {}, + schemaWidth: { + blockSchema: { + title: 'Schema width block', + fieldsets: [], + required: [], + properties: { + blockWidth: { + widget: 'width', + default: 'full', + styleField: true, + }, + }, + }, + }, }, } as typeof config.blocks; installMigrations(); @@ -92,13 +107,16 @@ describe('content migrations', () => { url: '/image', alt: 'Example image', }, + schemaWidth: { + '@type': 'schemaWidth', + }, custom: { '@type': 'custom-unregistered', foo: 'bar', }, }, blocks_layout: { - items: ['titleBlock', 'listing', 'image', 'custom'], + items: ['titleBlock', 'listing', 'image', 'schemaWidth', 'custom'], }, }; @@ -114,19 +132,27 @@ describe('content migrations', () => { }, { '@type': 'listing', + blockWidth: 'default', children: [{ text: '' }], querystring: { criteria: [], }, - type: 'unknown', + type: PLONE_BLOCK_TYPE, }, { '@type': 'image', alt: 'Example image', + blockWidth: 'default', children: [{ text: '' }], - type: 'unknown', + type: PLONE_BLOCK_TYPE, url: '/image', }, + { + '@type': 'schemaWidth', + blockWidth: 'full', + children: [{ text: '' }], + type: PLONE_BLOCK_TYPE, + }, ], }); }); diff --git a/apps/aurora/app/config/server/migrations.server.ts b/apps/aurora/app/config/server/migrations.server.ts index 275dd2086..e6ee96714 100644 --- a/apps/aurora/app/config/server/migrations.server.ts +++ b/apps/aurora/app/config/server/migrations.server.ts @@ -1,4 +1,9 @@ import config from '@plone/registry'; +import { + getStyleFieldsFromBlockSchema, + PLONE_BLOCK_TYPE, +} from '@plone/helpers'; +import type { BlockConfigBase, BlocksFormData } from '@plone/types'; import { migrateLegacyBoldInValue, migrateLegacyBlockWidthsInValue, @@ -23,6 +28,27 @@ const isRegisteredNativeBlock = (block: Record) => { return Boolean(blocksConfig?.[blockType]); }; +const DEFAULT_BLOCK_WIDTH = 'default'; + +const getMigratedPloneBlockWidth = (block: Record) => { + const blockType = block['@type']; + + if (typeof blockType !== 'string') { + return DEFAULT_BLOCK_WIDTH; + } + + const blocksConfig = config.blocks?.blocksConfig as + | Record + | undefined; + const blockConfig = blocksConfig?.[blockType]; + const styleFields = getStyleFieldsFromBlockSchema( + blockConfig, + block as BlocksFormData, + ); + + return styleFields.blockWidth?.defaultValue ?? DEFAULT_BLOCK_WIDTH; +}; + export default function install() { config.registerUtility({ name: 'somersaultBlockMigrationTitle', @@ -57,7 +83,8 @@ export default function install() { ? [ { ...block, - type: 'unknown', + blockWidth: getMigratedPloneBlockWidth(block), + type: PLONE_BLOCK_TYPE, children: [{ text: '' }], }, ] diff --git a/apps/aurora/news/+native-blocks-to-somersault.feature b/apps/aurora/news/+native-blocks-to-somersault.feature index 714598b55..20da53c7a 100644 --- a/apps/aurora/news/+native-blocks-to-somersault.feature +++ b/apps/aurora/news/+native-blocks-to-somersault.feature @@ -1 +1 @@ -Registered Aurora native blocks are now migrated into the Somersault field as `type: 'unknown'` nodes so they can be rendered by the new editor pipeline later. @sneridagh +Registered Aurora native blocks are now migrated into the Somersault field as `type: 'ploneBlock'` nodes so they can be rendered by the new editor pipeline later. @sneridagh