diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index a47fcb688ab..add5814ce20 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -153,8 +153,10 @@ Blocks are defined as separate configs of their own. | **`slug`** \* | Identifier for this block type. Will be saved on each block as the `blockType` property. | | **`fields`** \* | Array of fields to be stored in this block. | | **`labels`** | Customize the block labels that appear in the Admin dashboard. Auto-generated from slug if not defined. Alternatively you can use `admin.components.Label` for greater control. | -| **`imageURL`** | Provide a custom image thumbnail URL to help editors identify this block in the Admin UI. The image will be displayed in a 3:2 aspect ratio container and cropped using `object-fit: cover` if needed. [More details](#block-image-guidelines). | -| **`imageAltText`** | Customize this block's image thumbnail alt text. | +| **`imageURL`** | Provide a custom image thumbnail URL to help editors identify this block in the Admin UI block selection drawer. The image will be displayed in a 3:2 aspect ratio container and cropped using `object-fit: cover` if needed. [More details](#block-image-guidelines). | +| **`imageAltText`** | Alt text for the block thumbnail displayed in the block selection drawer. | +| **`iconImageURL`** | Provide a custom icon URL for this block in Lexical editor menus and toolbars (displayed at 20x20px). Use square images for best results. Falls back to `imageURL` if not provided. [More details](#block-image-guidelines). | +| **`iconImageAltText`** | Alt text for the block icon displayed in Lexical editor. Falls back to `imageAltText` if not provided. | | **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). | | **`graphQL.singularName`** | Text to use for the GraphQL schema name. Auto-generated from slug if not defined. NOTE: this is set for deprecation, prefer `interfaceName`. | | **`dbName`** | Custom table name for this block type when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from slug if not defined. | @@ -164,33 +166,58 @@ _\* An asterisk denotes that a property is required._ ### Block Image Guidelines -When providing a custom thumbnail via `imageURL`, it's important to understand how images are displayed in the Admin UI to ensure they look correct. +Blocks support two types of images to optimize display in different contexts: -**Aspect Ratio and Cropping**: +1. **`iconImageURL`** - Small icon for Lexical editor menus and toolbars (20x20px) +2. **`imageURL`** - Larger thumbnail for block selection drawer (3:2 aspect ratio) -- The image container uses a **3:2 aspect ratio** (e.g., 480x320 pixels) -- Images are scaled using `object-fit: cover`, which means: - - Images that don't match 3:2 will be **cropped** to fill the container - - The image maintains its aspect ratio while being scaled - - Cropping is centered, removing edges as needed +#### Icon Images (`iconImageURL`) -**Display Contexts**: +Used in Lexical editor slash menus, toolbar dropdowns, and inline block menus. -1. **Block Selection Drawer**: Images appear as thumbnails in a responsive grid when editors add blocks -2. **Lexical Editor**: Images are scaled down to 20x20px icons in menus and toolbars +**Requirements**: + +- Displayed at **20x20 pixels** +- **Square images** (1:1 aspect ratio) work best +- Use simple, recognizable icons or symbols +- Falls back to `imageURL` if not provided, then to default block icon + +**Recommendations**: + +- Use SVG for crisp rendering at small sizes +- Keep designs simple with bold shapes +- Ensure adequate contrast for visibility +- Provide web-optimized images (SVG, PNG with transparency) + +#### Thumbnail Images (`imageURL`) + +Used in the block selection drawer when editors add blocks to a blocks field. + +**Requirements**: + +- **3:2 aspect ratio** (e.g., 480x320, 600x400, 900x600) +- Images are scaled using `object-fit: cover` +- Non-matching aspect ratios will be **cropped** to fill the container +- Cropping is centered, removing edges as needed **Recommendations**: -- Use images with a **3:2 aspect ratio** to avoid unwanted cropping (e.g., 480x320, 600x400, 900x600) -- Keep important visual content **centered** in your image, as edges may be cropped +- Use images with **3:2 aspect ratio** to avoid unwanted cropping +- Keep important visual content **centered** in your image - Provide web-optimized images (JPEG, PNG, WebP) for faster loading -- Always include `imageAltText` for accessibility +- Always include alt text for accessibility + +#### Usage Examples -**Example**: +**Example 1: Using both icon and thumbnail** ```ts const QuoteBlock: Block = { slug: 'quote', + // Icon for Lexical editor (20x20px, square) + iconImageURL: 'https://example.com/icons/quote-icon-20x20.svg', + iconImageAltText: 'Quote icon', + // Thumbnail for block drawer (3:2 aspect ratio) imageURL: 'https://example.com/thumbnails/quote-block-480x320.jpg', imageAltText: 'Quote block with text and attribution', fields: [ @@ -203,7 +230,36 @@ const QuoteBlock: Block = { } ``` -If no `imageURL` is provided, a default placeholder graphic is displayed automatically. +**Example 2: Using only thumbnail (backwards compatible)** + +```ts +const CallToActionBlock: Block = { + slug: 'cta', + // Only thumbnail provided - will be used in both contexts + imageURL: 'https://example.com/thumbnails/cta-block-480x320.jpg', + imageAltText: 'Call to action block', + fields: [ + { + name: 'buttonText', + type: 'text', + }, + ], +} +``` + +**Example 3: Using only icon** + +```ts +const DividerBlock: Block = { + slug: 'divider', + // Only icon provided - drawer will use default placeholder + iconImageURL: 'https://example.com/icons/divider-20x20.svg', + iconImageAltText: 'Divider icon', + fields: [], +} +``` + +If neither `iconImageURL` nor `imageURL` is provided, default graphics are displayed automatically in both contexts. ### Admin Options diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts index 0f551599621..602f2cf79fb 100644 --- a/packages/payload/src/fields/config/client.ts +++ b/packages/payload/src/fields/config/client.ts @@ -107,6 +107,12 @@ export const createClientBlocks = ({ if (block.imageAltText) { clientBlock.imageAltText = block.imageAltText } + if (block.iconImageAltText) { + clientBlock.iconImageAltText = block.iconImageAltText + } + if (block.iconImageURL) { + clientBlock.iconImageURL = block.iconImageURL + } if (block.imageURL) { clientBlock.imageURL = block.imageURL } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 800263cd917..09a7c7cf6d7 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1545,9 +1545,21 @@ export type Block = { graphQL?: { singularName?: string } + /** + * Icon alt text for the block icon displayed in Lexical editor menus and toolbars (20x20px). + */ + iconImageAltText?: string + /** + * Icon image URL for the block displayed in Lexical editor menus and toolbars. + * This image will be scaled to 20x20px. Use square images for best results. + * Falls back to imageURL if not provided, then to default block icon. + */ + iconImageURL?: string imageAltText?: string /** - * Preferred aspect ratio of the image is 3 : 2 + * Thumbnail image URL for the block displayed in the Admin UI block selection drawer. + * Preferred aspect ratio of the image is 3:2 (e.g., 480x320, 600x400, 900x600). + * Images are displayed using object-fit: cover and will be cropped if aspect ratio doesn't match. */ imageURL?: string /** Customize generated GraphQL and Typescript schema names. @@ -1567,7 +1579,7 @@ export type ClientBlock = { admin?: Pick fields: ClientField[] labels?: LabelsClient -} & Pick +} & Pick export type BlocksField = { admin?: { diff --git a/packages/richtext-lexical/src/features/blocks/client/getBlockImageComponent.tsx b/packages/richtext-lexical/src/features/blocks/client/getBlockImageComponent.tsx index 6a9aea5833f..02e39da2346 100644 --- a/packages/richtext-lexical/src/features/blocks/client/getBlockImageComponent.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/getBlockImageComponent.tsx @@ -2,16 +2,36 @@ import React from 'react' import { BlockIcon } from '../../../lexical/ui/icons/Block/index.js' -export function getBlockImageComponent(imageURL?: string, imageAltText?: string) { - if (!imageURL) { +/** + * Get the appropriate icon component for a block in Lexical editor menus/toolbars. + * Priority: iconImageURL > imageURL > default BlockIcon + * + * @param iconImageURL - Preferred icon image (20x20px, typically square) + * @param iconImageAltText - Alt text for icon image + * @param imageURL - Fallback thumbnail image (3:2 aspect ratio) + * @param imageAltText - Alt text for thumbnail image (used as fallback for iconImageAltText) + */ +export function getBlockImageComponent( + iconImageURL?: string, + iconImageAltText?: string, + imageURL?: string, + imageAltText?: string, +) { + // Use iconImageURL if available, otherwise fall back to imageURL + const displayImageURL = iconImageURL || imageURL + + if (!displayImageURL) { return BlockIcon } + // Prefer iconImageAltText, fall back to imageAltText + const displayAltText = iconImageAltText || imageAltText || 'Block Image' + return () => ( {imageAltText ) diff --git a/packages/richtext-lexical/src/features/blocks/client/index.tsx b/packages/richtext-lexical/src/features/blocks/client/index.tsx index d04e6f0c244..051f09ce50b 100644 --- a/packages/richtext-lexical/src/features/blocks/client/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/index.tsx @@ -85,7 +85,12 @@ export const BlocksFeatureClient = createClientFeature( ? { items: clientBlocks.map((block) => { return { - Icon: getBlockImageComponent(block.imageURL, block.imageAltText), + Icon: getBlockImageComponent( + block.iconImageURL, + block.iconImageAltText, + block.imageURL, + block.imageAltText, + ), key: 'block-' + block.slug, keywords: ['block', 'blocks', block.slug], label: ({ i18n }) => { @@ -151,7 +156,12 @@ export const BlocksFeatureClient = createClientFeature( ChildComponent: BlockIcon, items: clientBlocks.map((block, index) => { return { - ChildComponent: getBlockImageComponent(block.imageURL, block.imageAltText), + ChildComponent: getBlockImageComponent( + block.iconImageURL, + block.iconImageAltText, + block.imageURL, + block.imageAltText, + ), isActive: undefined, // At this point, we would be inside a sub-richtext-editor. And at this point this will be run against the focused sub-editor, not the parent editor which has the actual block. Thus, no point in running this key: 'block-' + block.slug, label: ({ i18n }) => { @@ -180,8 +190,13 @@ export const BlocksFeatureClient = createClientFeature( ChildComponent: InlineBlocksIcon, items: clientInlineBlocks.map((inlineBlock, index) => { return { - ChildComponent: inlineBlock.imageURL - ? getBlockImageComponent(inlineBlock.imageURL, inlineBlock.imageAltText) + ChildComponent: inlineBlock.iconImageURL || inlineBlock.imageURL + ? getBlockImageComponent( + inlineBlock.iconImageURL, + inlineBlock.iconImageAltText, + inlineBlock.imageURL, + inlineBlock.imageAltText, + ) : InlineBlocksIcon, isActive: undefined, key: 'inlineBlock-' + inlineBlock.slug, diff --git a/test/fields/collections/Blocks/index.ts b/test/fields/collections/Blocks/index.ts index 24038e3ca94..a9a0357a8f7 100644 --- a/test/fields/collections/Blocks/index.ts +++ b/test/fields/collections/Blocks/index.ts @@ -31,6 +31,21 @@ export const getBlocksField = (prefix?: string): BlocksField => ({ }, ], }, + { + slug: prefix ? `${prefix}WithIcon` : 'withIcon', + imageURL: '/api/uploads/file/payload480x320.jpg', + imageAltText: 'Block thumbnail', + iconImageURL: '/api/uploads/file/payload20x20.png', + iconImageAltText: 'Block icon', + interfaceName: prefix ? `${prefix}WithIconBlock` : 'WithIconBlock', + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], + }, { slug: prefix ? `${prefix}NoBlockname` : 'noBlockname', interfaceName: prefix ? `${prefix}NoBlockname` : 'NoBlockname', diff --git a/test/fields/collections/Blocks/int-iconImage.spec.ts b/test/fields/collections/Blocks/int-iconImage.spec.ts new file mode 100644 index 00000000000..cb6f0e15e77 --- /dev/null +++ b/test/fields/collections/Blocks/int-iconImage.spec.ts @@ -0,0 +1,84 @@ +import { expect, it, describe, beforeAll, afterAll } from 'vitest' + +import type { Payload } from 'payload' + +import { initPayloadInt } from '../../../__helpers/shared/initPayloadInt.js' +import { blockFieldsSlug } from '../../slugs.js' + +let payload: Payload + +describe('Block iconImageURL', () => { + beforeAll(async () => { + ;({ payload } = await initPayloadInt(__dirname)) + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + it('should include iconImageURL and iconImageAltText in block schema', async () => { + const config = payload.config + const blockField = config.collections + .find((c) => c.slug === blockFieldsSlug) + ?.fields.find((f) => f.type === 'blocks' && f.name === 'blocks') + + expect(blockField).toBeDefined() + expect(blockField.type).toBe('blocks') + + const withIconBlock = blockField.blocks.find((b) => b.slug === 'withIcon') + + expect(withIconBlock).toBeDefined() + expect(withIconBlock.iconImageURL).toBe('/api/uploads/file/payload20x20.png') + expect(withIconBlock.iconImageAltText).toBe('Block icon') + expect(withIconBlock.imageURL).toBe('/api/uploads/file/payload480x320.jpg') + expect(withIconBlock.imageAltText).toBe('Block thumbnail') + }) + + it('should allow blocks with only iconImageURL', async () => { + const result = await payload.create({ + collection: blockFieldsSlug, + data: { + blocks: [ + { + blockType: 'withIcon', + title: 'Test block with icon', + }, + ], + }, + }) + + expect(result.blocks).toHaveLength(1) + expect(result.blocks[0].blockType).toBe('withIcon') + expect(result.blocks[0].title).toBe('Test block with icon') + + await payload.delete({ + collection: blockFieldsSlug, + id: result.id, + }) + }) + + it('should support backwards compatibility - blocks without iconImageURL', async () => { + const result = await payload.create({ + collection: blockFieldsSlug, + data: { + blocks: [ + { + blockType: 'content', + text: 'Test content', + }, + ], + }, + }) + + expect(result.blocks).toHaveLength(1) + expect(result.blocks[0].blockType).toBe('content') + expect(result.blocks[0].text).toBe('Test content') + + await payload.delete({ + collection: blockFieldsSlug, + id: result.id, + }) + }) +})