Skip to content

Commit f0498f2

Browse files
authored
feat(richtext-lexical): separate configuration for lexical block icons (#15632)
This PR adds a new images property to BlockConfig, allowing developers to specify separate images for different UI contexts: - **images.icon** — small icon displayed in Lexical editor slash menus and toolbars (20x20px) - **images.thumbnail** — larger thumbnail shown in the block selection drawer (3:2 aspect ratio) ## Why Currently, `imageURL` serves dual purposes: 1. Block selection drawer thumbnails (3:2 aspect ratio, e.g., 480x320px) 2. Lexical editor icons (scaled down to 20x20px) Citing documentation: > Display Contexts: > Block Selection Drawer: Images appear as thumbnails in a responsive grid when editors add blocks > Lexical Editor: Images are scaled down to 20x20px icons in menus and toolbars This creates problem , where developer needs to choose either good looking icons (but worse UX on block drawers) or good looking thumnails, but scaled down images as icons. ## Solution Add a dedicated `images` property with a fallback chain: ``` Lexical editor icon: images.icon → images.thumbnail → imageURL (deprecated) → default BlockIcon Block drawer thumbnail: images.thumbnail → imageURL (deprecated) → default placeholder ``` Now you can set different URL for block icons in lexical blocks: <img width="562" height="370" alt="image" src="https://github.com/user-attachments/assets/912e1482-0b37-40f7-a144-e21e43fc7aec" /> Example: ```ts const QuoteBlock: Block = { slug: 'quote', images: { icon: 'https://example.com/icons/quote-20x20.svg', thumbnail: { url: 'https://example.com/thumbnails/quote-480x320.jpg', alt: 'Quote block' }, }, fields: [...], } ``` ## Backwards Compatibility **Fully backwards compatible** — imageURL and imageAltText still work (marked as @deprecated), with automatic fallback to the new images property behavior.
1 parent cef2838 commit f0498f2

File tree

11 files changed

+262
-64
lines changed

11 files changed

+262
-64
lines changed

docs/fields/blocks.mdx

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -148,51 +148,91 @@ Blocks are defined as separate configs of their own.
148148
trivializes their reusability.
149149
</Banner>
150150

151-
| Option | Description |
152-
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
153-
| **`slug`** \* | Identifier for this block type. Will be saved on each block as the `blockType` property. |
154-
| **`fields`** \* | Array of fields to be stored in this block. |
155-
| **`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. |
156-
| **`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). |
157-
| **`imageAltText`** | Customize this block's image thumbnail alt text. |
158-
| **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). |
159-
| **`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`. |
160-
| **`dbName`** | Custom table name for this block type when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from slug if not defined. |
161-
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
151+
| Option | Description |
152+
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
153+
| **`slug`** \* | Identifier for this block type. Will be saved on each block as the `blockType` property. |
154+
| **`fields`** \* | Array of fields to be stored in this block. |
155+
| **`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. |
156+
| **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). |
157+
| **`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`. |
158+
| **`dbName`** | Custom table name for this block type when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from slug if not defined. |
159+
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
162160

163161
_\* An asterisk denotes that a property is required._
164162

163+
### Admin Options
164+
165+
Blocks are not fields, so they don’t inherit the base properties shared by all fields (not to be confused with the Blocks Field, documented above, which does). Here are their available admin options:
166+
167+
| Option | Description |
168+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
169+
| **`components.Block`** | Custom component for replacing the Block, including the header. |
170+
| **`components.Label`** | Custom component for replacing the Block Label. |
171+
| **`disableBlockName`** | Hide the blockName field by setting this value to `true`. |
172+
| **`group`** | Text or localization object used to group this Block in the Blocks Drawer. |
173+
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
174+
| **`images`** | Custom images for the block in different UI contexts. Supports `icon` (20x20px for Lexical menus/toolbars) and `thumbnail` (3:2 ratio for block selection drawer). Each can be a URL string or `{ url, alt }` object. [More details](#block-image-guidelines). | |
175+
165176
### Block Image Guidelines
166177

167-
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.
178+
The `images` property lets you provide custom images for each display context:
168179

169-
**Aspect Ratio and Cropping**:
180+
- **`images.icon`** - Small icon for Lexical editor menus and toolbars (displayed at 20x20px)
181+
- **`images.thumbnail`** - Larger thumbnail for block selection drawer (3:2 aspect ratio)
170182

171-
- The image container uses a **3:2 aspect ratio** (e.g., 480x320 pixels)
172-
- Images are scaled using `object-fit: cover`, which means:
173-
- Images that don't match 3:2 will be **cropped** to fill the container
174-
- The image maintains its aspect ratio while being scaled
175-
- Cropping is centered, removing edges as needed
183+
Each can be either a plain URL string or an object `{ url: string, alt?: string }`.
176184

177-
**Display Contexts**:
185+
#### Icon (`images.icon`)
178186

179-
1. **Block Selection Drawer**: Images appear as thumbnails in a responsive grid when editors add blocks
180-
2. **Lexical Editor**: Images are scaled down to 20x20px icons in menus and toolbars
187+
Used in Lexical editor slash menus, toolbar dropdowns, and inline block menus.
188+
189+
**Requirements**:
190+
191+
- Displayed at **20x20 pixels**
192+
- **Square images** (1:1 aspect ratio) work best
193+
- Falls back to `images.thumbnail`, then to default block icon
181194

182195
**Recommendations**:
183196

184-
- Use images with a **3:2 aspect ratio** to avoid unwanted cropping (e.g., 480x320, 600x400, 900x600)
185-
- Keep important visual content **centered** in your image, as edges may be cropped
197+
- Use SVG for crisp rendering at small sizes
198+
- Keep designs simple with bold shapes
199+
- Provide web-optimized images (SVG, PNG with transparency)
200+
201+
#### Thumbnail (`images.thumbnail`)
202+
203+
Used in the block selection drawer when editors add blocks to a blocks field.
204+
205+
**Requirements**:
206+
207+
- **3:2 aspect ratio** (e.g., 480x320, 600x400, 900x600)
208+
- Images are scaled using `object-fit: cover`
209+
- Non-matching aspect ratios will be **cropped** to fill the container
210+
211+
**Recommendations**:
212+
213+
- Use images with **3:2 aspect ratio** to avoid unwanted cropping
214+
- Keep important visual content **centered** in your image
186215
- Provide web-optimized images (JPEG, PNG, WebP) for faster loading
187-
- Always include `imageAltText` for accessibility
188216

189-
**Example**:
217+
#### Usage Examples
218+
219+
**Example 1: Using both icon and thumbnail with alt text**
190220

191221
```ts
192222
const QuoteBlock: Block = {
193223
slug: 'quote',
194-
imageURL: 'https://example.com/thumbnails/quote-block-480x320.jpg',
195-
imageAltText: 'Quote block with text and attribution',
224+
admin: {
225+
images: {
226+
icon: {
227+
url: 'https://example.com/icons/quote-icon-20x20.svg',
228+
alt: 'Quote icon',
229+
},
230+
thumbnail: {
231+
url: 'https://example.com/thumbnails/quote-block-480x320.jpg',
232+
alt: 'Quote block',
233+
},
234+
},
235+
},
196236
fields: [
197237
{
198238
name: 'quoteText',
@@ -203,19 +243,41 @@ const QuoteBlock: Block = {
203243
}
204244
```
205245

206-
If no `imageURL` is provided, a default placeholder graphic is displayed automatically.
246+
**Example 2: Using URL strings (no alt text)**
207247

208-
### Admin Options
248+
```ts
249+
const CallToActionBlock: Block = {
250+
slug: 'cta',
251+
admin: {
252+
images: {
253+
icon: 'https://example.com/icons/cta-20x20.svg',
254+
thumbnail: 'https://example.com/thumbnails/cta-block-480x320.jpg',
255+
},
256+
},
257+
fields: [
258+
{
259+
name: 'buttonText',
260+
type: 'text',
261+
},
262+
],
263+
}
264+
```
209265

210-
Blocks are not fields, so they don’t inherit the base properties shared by all fields (not to be confused with the Blocks Field, documented above, which does). Here are their available admin options:
266+
**Example 3: Using only icon**
267+
268+
```ts
269+
const DividerBlock: Block = {
270+
slug: 'divider',
271+
admin: {
272+
images: {
273+
icon: 'https://example.com/icons/divider-20x20.svg',
274+
},
275+
},
276+
fields: [],
277+
}
278+
```
211279

212-
| Option | Description |
213-
| ---------------------- | -------------------------------------------------------------------------- |
214-
| **`components.Block`** | Custom component for replacing the Block, including the header. |
215-
| **`components.Label`** | Custom component for replacing the Block Label. |
216-
| **`disableBlockName`** | Hide the blockName field by setting this value to `true`. |
217-
| **`group`** | Text or localization object used to group this Block in the Blocks Drawer. |
218-
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
280+
If no `images` is provided, default graphics are displayed automatically in both contexts.
219281

220282
### blockType, blockName, and block.label
221283

packages/payload/src/fields/config/client.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,16 @@ export const createClientBlocks = ({
111111
clientBlock.imageURL = block.imageURL
112112
}
113113

114-
if (block.admin?.custom || block.admin?.group) {
115-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
114+
if (block.admin?.custom || block.admin?.group || block.admin?.images) {
116115
clientBlock.admin = {}
117116
if (block.admin.custom) {
118-
clientBlock.admin!.custom = block.admin.custom
117+
clientBlock.admin.custom = block.admin.custom
119118
}
120119
if (block.admin.group) {
121-
clientBlock.admin!.group = block.admin.group
120+
clientBlock.admin.group = block.admin.group
121+
}
122+
if (block.admin.images) {
123+
clientBlock.admin.images = block.admin.images
122124
}
123125
}
124126

@@ -136,7 +138,6 @@ export const createClientBlocks = ({
136138
if (clientBlock.admin) {
137139
clientBlock.admin.disableBlockName = block.admin.disableBlockName
138140
} else {
139-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
140141
clientBlock.admin = { disableBlockName: block.admin.disableBlockName }
141142
}
142143
}

packages/payload/src/fields/config/types.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,37 @@ export type Block = {
15321532
*/
15331533
disableBlockName?: boolean
15341534
group?: Record<string, string> | string
1535+
/**
1536+
* Custom images for the block displayed in different UI contexts.
1537+
*
1538+
* @example
1539+
* // Using string URLs (simplest form)
1540+
* images: {
1541+
* icon: 'https://example.com/icon.svg',
1542+
* thumbnail: 'https://example.com/thumbnail.jpg',
1543+
* }
1544+
*
1545+
* @example
1546+
* // Using objects with alt text
1547+
* images: {
1548+
* icon: { url: 'https://example.com/icon.svg', alt: 'Quote icon' },
1549+
* thumbnail: { url: 'https://example.com/thumb.jpg', alt: 'Quote block thumbnail' },
1550+
* }
1551+
*/
1552+
images?: {
1553+
/**
1554+
* Icon image for the block in Lexical editor menus and toolbars (displayed at 20x20px).
1555+
* Use square images or SVGs for best results.
1556+
* Can be a URL string or an object with `url` and optional `alt` properties.
1557+
*/
1558+
icon?: { alt?: string; url: string } | string
1559+
/**
1560+
* Thumbnail image for the block in the Admin UI block selection drawer.
1561+
* Preferred aspect ratio is 3:2 (e.g., 480x320, 600x400).
1562+
* Can be a URL string or an object with `url` and optional `alt` properties.
1563+
*/
1564+
thumbnail?: { alt?: string; url: string } | string
1565+
}
15351566
jsx?: PayloadComponent
15361567
}
15371568
/** Extension point to add your custom data. Server only. */
@@ -1545,9 +1576,13 @@ export type Block = {
15451576
graphQL?: {
15461577
singularName?: string
15471578
}
1579+
/**
1580+
* @deprecated Use `admin.images` instead.
1581+
*/
15481582
imageAltText?: string
1583+
15491584
/**
1550-
* Preferred aspect ratio of the image is 3 : 2
1585+
* @deprecated Use `admin.images` instead. Preferred aspect ratio of the image is 3:2.
15511586
*/
15521587
imageURL?: string
15531588
/** Customize generated GraphQL and Typescript schema names.
@@ -1563,8 +1598,7 @@ export type Block = {
15631598
}
15641599

15651600
export type ClientBlock = {
1566-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
1567-
admin?: Pick<Block['admin'], 'custom' | 'disableBlockName' | 'group'>
1601+
admin?: Pick<NonNullable<Block['admin']>, 'custom' | 'disableBlockName' | 'group' | 'images'>
15681602
fields: ClientField[]
15691603
labels?: LabelsClient
15701604
} & Pick<Block, 'imageAltText' | 'imageURL' | 'jsx' | 'slug'>

packages/richtext-lexical/src/features/blocks/client/getBlockImageComponent.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
1+
import type { ClientBlock } from 'payload'
2+
13
import React from 'react'
24

3-
import { BlockIcon } from '../../../lexical/ui/icons/Block/index.js'
5+
/**
6+
* Get the appropriate icon component for a block in Lexical editor menus/toolbars.
7+
*
8+
* Priority for URL: images.icon > images.thumbnail > imageURL (deprecated)
9+
* Priority for alt: images.icon.alt > images.thumbnail.alt > imageAltText (deprecated)
10+
*/
11+
export function getBlockImageComponent(block: ClientBlock, fallback: React.ElementType) {
12+
const { admin, imageAltText, imageURL } = block
13+
const images = admin?.images
14+
15+
let displayURL: string | undefined
16+
let displayAlt: string | undefined
417

5-
export function getBlockImageComponent(imageURL?: string, imageAltText?: string) {
6-
if (!imageURL) {
7-
return BlockIcon
18+
if (images?.icon) {
19+
displayURL = typeof images.icon === 'string' ? images.icon : images.icon.url
20+
displayAlt = typeof images.icon === 'string' ? undefined : images.icon.alt
21+
} else if (images?.thumbnail) {
22+
displayURL = typeof images.thumbnail === 'string' ? images.thumbnail : images.thumbnail.url
23+
displayAlt = typeof images.thumbnail === 'string' ? undefined : images.thumbnail.alt
24+
} else {
25+
// Deprecated fallback
26+
displayURL = imageURL
27+
displayAlt = imageAltText
828
}
929

30+
if (!displayURL) {
31+
return fallback
32+
}
33+
34+
const alt = displayAlt ?? 'Block Image'
35+
1036
return () => (
1137
<img
12-
alt={imageAltText ?? 'Block Image'}
38+
alt={alt}
1339
className="lexical-block-custom-image"
14-
src={imageURL}
40+
src={displayURL}
1541
style={{ maxHeight: 20, maxWidth: 20 }}
1642
/>
1743
)

packages/richtext-lexical/src/features/blocks/client/index.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const BlocksFeatureClient = createClientFeature(
8585
? {
8686
items: clientBlocks.map((block) => {
8787
return {
88-
Icon: getBlockImageComponent(block.imageURL, block.imageAltText),
88+
Icon: getBlockImageComponent(block, BlockIcon),
8989
key: 'block-' + block.slug,
9090
keywords: ['block', 'blocks', block.slug],
9191
label: ({ i18n }) => {
@@ -151,7 +151,7 @@ export const BlocksFeatureClient = createClientFeature(
151151
ChildComponent: BlockIcon,
152152
items: clientBlocks.map((block, index) => {
153153
return {
154-
ChildComponent: getBlockImageComponent(block.imageURL, block.imageAltText),
154+
ChildComponent: getBlockImageComponent(block, BlockIcon),
155155
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
156156
key: 'block-' + block.slug,
157157
label: ({ i18n }) => {
@@ -180,9 +180,7 @@ export const BlocksFeatureClient = createClientFeature(
180180
ChildComponent: InlineBlocksIcon,
181181
items: clientInlineBlocks.map((inlineBlock, index) => {
182182
return {
183-
ChildComponent: inlineBlock.imageURL
184-
? getBlockImageComponent(inlineBlock.imageURL, inlineBlock.imageAltText)
185-
: InlineBlocksIcon,
183+
ChildComponent: getBlockImageComponent(inlineBlock, InlineBlocksIcon),
186184
isActive: undefined,
187185
key: 'inlineBlock-' + inlineBlock.slug,
188186
label: ({ i18n }) => {

packages/ui/src/elements/ItemsDrawer/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ const getItemSlug = (item: DrawerItem): string => {
6363
}
6464

6565
const getItemImageInfo = (item: DrawerItem) => {
66+
if ('admin' in item && item?.admin?.images?.thumbnail) {
67+
const thumbnail = item.admin?.images.thumbnail
68+
return {
69+
imageAltText: typeof thumbnail === 'string' ? undefined : thumbnail.alt,
70+
imageURL: typeof thumbnail === 'string' ? thumbnail : thumbnail.url,
71+
}
72+
}
6673
if ('imageURL' in item) {
6774
return {
6875
imageAltText: item.imageAltText,

0 commit comments

Comments
 (0)