Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 73 additions & 17 deletions docs/fields/blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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: [
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions packages/payload/src/fields/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 14 additions & 2 deletions packages/payload/src/fields/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -1567,7 +1579,7 @@ export type ClientBlock = {
admin?: Pick<Block['admin'], 'custom' | 'disableBlockName' | 'group'>
fields: ClientField[]
labels?: LabelsClient
} & Pick<Block, 'imageAltText' | 'imageURL' | 'jsx' | 'slug'>
} & Pick<Block, 'iconImageAltText' | 'iconImageURL' | 'imageAltText' | 'imageURL' | 'jsx' | 'slug'>

export type BlocksField = {
admin?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => (
<img
alt={imageAltText ?? 'Block Image'}
alt={displayAltText}
className="lexical-block-custom-image"
src={imageURL}
src={displayImageURL}
style={{ maxHeight: 20, maxWidth: 20 }}
/>
)
Expand Down
23 changes: 19 additions & 4 deletions packages/richtext-lexical/src/features/blocks/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions test/fields/collections/Blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
84 changes: 84 additions & 0 deletions test/fields/collections/Blocks/int-iconImage.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
Loading