refactor(richtext-lexical): new upload node design (#13901)

This changes the design of lexical upload nodes to better show the
actual media instead of the metadata.

## Updated Design


https://github.com/user-attachments/assets/49096378-35c2-4eb0-b4b6-5f138d49bdad

Light mode:

<img width="780" height="962" alt="Screenshot 2025-09-24 at 10 11 32@2x"
src="https://github.com/user-attachments/assets/7611e659-3914-46e9-9c8c-db88c180227b"
/>


## Previous Design

> Before:
> 
> <img width="1358" height="860" alt="Screenshot 2025-09-22 at 16 01
16@2x"
src="https://github.com/user-attachments/assets/7831761c-6c3c-4072-82ed-68b88e3842b7"
/>
> 
> After:
> 
> <img width="1776" height="1632" alt="Screenshot 2025-09-22 at 16 01
00@2x"
src="https://github.com/user-attachments/assets/b434b6d5-a965-4c2b-adba-c1bf2a3be4bc"
/>
> 
> 
>
https://github.com/user-attachments/assets/f2749a38-c191-4b50-a521-8f722ed42a8f
> 



---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211429812808983
This commit is contained in:
Alessio Gravili
2025-09-24 13:22:03 -07:00
committed by GitHub
parent abbe38fbaf
commit bea77f2f24
6 changed files with 195 additions and 142 deletions

View File

@@ -4,24 +4,45 @@
.lexical-upload {
@extend %body;
@include shadow-sm;
max-width: calc(var(--base) * 15);
display: flex;
align-items: center;
background: var(--theme-input-bg);
border-radius: $style-radius-m;
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
margin-block: base(0.5);
.btn {
margin: 0;
}
&:hover {
border: 1px solid var(--theme-elevation-150);
}
img,
svg {
border-radius: $style-radius-s;
width: auto;
}
&--landscape {
img,
svg {
max-width: 450px;
min-width: 450px;
}
}
&--portrait {
img,
svg {
max-height: 450px;
min-height: 450px;
}
}
button {
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__card {
@include soft-shadow-bottom;
display: flex;
@@ -29,118 +50,117 @@
width: 100%;
}
&__topRow {
display: flex;
}
&__thumbnail {
width: calc(var(--base) * 3.25);
height: auto;
position: relative;
overflow: hidden;
flex-shrink: 0;
border-top-left-radius: $style-radius-m;
img,
svg {
&__floater {
@include shadow-lg;
position: absolute;
object-fit: cover;
width: 100%;
height: 100%;
background-color: var(--theme-elevation-800);
}
/* hidden by default */
opacity: 0;
transition:
opacity 0.15s ease,
transform 0.15s ease;
pointer-events: none;
}
&__topRowRightPanel {
flex-grow: 1;
&:hover .lexical-upload__floater,
&__media:focus-within .lexical-upload__floater {
opacity: 1;
pointer-events: auto;
}
/* --- Floating Action Buttons (top-right) ------------------------------------- */
&__overlay {
display: flex;
align-items: center;
padding: calc(var(--base) * 0.75);
justify-content: space-between;
max-width: calc(100% - #{calc(var(--base) * 3.25)});
top: calc(var(--base) * 0.5);
right: calc(var(--base) * 0.5);
padding: calc(var(--base) * 0.2) calc(var(--base) * 0.2);
background: var(--theme-elevation-50);
border-radius: $style-radius-m;
transform: translateY(-6px);
}
&:hover .lexical-upload__overlay,
&__media:focus-within .lexical-upload__overlay {
transform: translateY(0);
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: calc(var(--base) * 0.5);
flex-wrap: nowrap;
gap: calc(var(--base) * 0.3);
.lexical-upload__doc-drawer-toggler {
pointer-events: all;
}
& > *:not(:last-child) {
margin-right: calc(var(--base) * 0.25);
.btn:hover {
background: var(--theme-elevation-100);
}
}
/* --- Floating Metadata (bottom-center) ------------------------------------- */
&__metaOverlay {
display: inline-flex;
left: 50%;
bottom: 0;
width: 100%;
padding: calc(var(--base) * 0.5) calc(var(--base) * 0.75);
transform: translateX(-50%);
&__removeButton {
margin: 0;
flex-wrap: wrap;
gap: calc(var(--base) * 0.5);
row-gap: 0;
line {
stroke-width: $style-stroke-width-m;
background: color-mix(in oklab, var(--theme-elevation-50) 55%, transparent);
border-radius: 0 0 $style-radius-s $style-radius-s;
backdrop-filter: saturate(1.2) blur(8px);
}
&:disabled {
html[data-theme='light'] & {
&__metaOverlay {
background: color-mix(in oklab, var(--theme-elevation-800) 55%, transparent);
color: var(--theme-elevation-50);
}
&__collectionLabel {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__upload-drawer-toggler {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
outline: none;
line-height: inherit;
}
&__doc-drawer-toggler {
&__filename {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
text-decoration: underline;
cursor: pointer;
}
&__doc-drawer-toggler,
&__list-drawer-toggler,
&__upload-drawer-toggler {
& > * {
margin: 0;
&__collectionLabel {
color: var(--theme-elevation-500);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
@include small-break {
img,
svg {
// Allow images to shrink < 450px on small screens to
// maintain aspect ratio
min-width: unset;
min-height: unset;
}
&__metaOverlay {
gap: 0;
padding: calc(var(--base) * 0.5) calc(var(--base) * 0.6);
flex-direction: column;
button {
display: flex;
justify-content: flex-start;
}
}
&__collectionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__bottomRow {
padding: calc(var(--base) * 0.5);
border-top: 1px solid var(--theme-elevation-100);
}
h5 {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__wrap {
padding: calc(var(--base) * 0.5) calc(var(--base) * 0.5) calc(var(--base) * 0.5) var(--base);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
@include small-break {
&__topRowRightPanel {
padding: calc(var(--base) * 0.75) calc(var(--base) * 0.5);
max-width: 100%;
}
}
}

View File

@@ -12,7 +12,7 @@ import {
usePayloadAPI,
useTranslation,
} from '@payloadcms/ui'
import { $getNodeByKey } from 'lexical'
import { $getNodeByKey, type ElementFormatType } from 'lexical'
import { isImage } from 'payload/shared'
import React, { useCallback, useId, useReducer, useRef, useState } from 'react'
@@ -37,6 +37,7 @@ const initialParams = {
export type ElementProps = {
data: UploadData
format?: ElementFormatType
nodeKey: string
}
@@ -139,22 +140,32 @@ const Component: React.FC<ElementProps> = (props) => {
[editor, nodeKey],
)
const aspectRatio =
thumbnailSRC && data?.width && data?.height
? data.width > data.height
? 'landscape'
: 'portrait'
: 'landscape'
return (
<div className={baseClass} contentEditable={false} ref={uploadRef}>
<div
className={`${baseClass} ${baseClass}--${aspectRatio}`}
data-filename={data?.filename}
ref={uploadRef}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__topRow`}>
<div className={`${baseClass}__thumbnail`}>
<div className={`${baseClass}__media`}>
<Thumbnail
collectionSlug={relationTo}
fileSrc={isImage(data?.mimeType) ? thumbnailSRC : null}
height={data?.height}
size="none"
width={data?.width}
/>
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
{editor.isEditable() && (
<div className={`${baseClass}__actions`}>
<div className={`${baseClass}__overlay ${baseClass}__floater`}>
<div className={`${baseClass}__actions`} role="toolbar">
{hasExtraFields ? (
<Button
buttonStyle="icon-label"
@@ -162,10 +173,9 @@ const Component: React.FC<ElementProps> = (props) => {
disabled={readOnly}
el="button"
icon="edit"
onClick={() => {
toggleDrawer()
}}
onClick={toggleDrawer}
round
size="medium"
tooltip={t('fields:editRelationship')}
/>
) : null}
@@ -182,8 +192,10 @@ const Component: React.FC<ElementProps> = (props) => {
})
}}
round
size="medium"
tooltip={t('fields:swapUpload')}
/>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
@@ -194,18 +206,26 @@ const Component: React.FC<ElementProps> = (props) => {
removeUpload()
}}
round
size="medium"
tooltip={t('fields:removeUpload')}
/>
</div>
</div>
)}
</div>
</div>
<div className={`${baseClass}__bottomRow`}>
<div className={`${baseClass}__metaOverlay ${baseClass}__floater`}>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<strong>{data?.filename}</strong>
<strong className={`${baseClass}__filename`}>
{data?.filename || t('general:untitled')}
</strong>
</DocumentDrawerToggler>
<div className={`${baseClass}__collectionLabel`}>
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
</div>
</div>
{value ? <DocumentDrawer onSave={updateUpload} /> : null}
{hasExtraFields ? (
<FieldsDrawer

View File

@@ -65,7 +65,7 @@ export class UploadNode extends UploadServerNode {
if ((this.__data as Internal_UploadData).pending) {
return <PendingUploadComponent />
}
return <RawUploadComponent data={this.__data} nodeKey={this.getKey()} />
return <RawUploadComponent data={this.__data} format={this.__format} nodeKey={this.getKey()} />
}
override exportJSON(): SerializedUploadNode {

View File

@@ -2,6 +2,7 @@
@layer payload-default {
.thumbnail {
&:not(.thumbnail--size-none) {
min-height: 100%;
flex-shrink: 0;
align-self: stretch;
@@ -13,6 +14,7 @@
height: 100%;
object-fit: cover;
}
}
&--size-expand {
max-height: 100%;

View File

@@ -15,13 +15,23 @@ export type ThumbnailProps = {
collectionSlug?: string
doc?: Record<string, unknown>
fileSrc?: string
height?: number
imageCacheTag?: string
size?: 'expand' | 'large' | 'medium' | 'small'
size?: 'expand' | 'large' | 'medium' | 'none' | 'small'
uploadConfig?: SanitizedCollectionConfig['upload']
width?: number
}
export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
const { className = '', doc: { filename } = {}, fileSrc, imageCacheTag, size } = props
const {
className = '',
doc: { filename } = {},
fileSrc,
height,
imageCacheTag,
size,
width,
} = props
const [fileExists, setFileExists] = React.useState(undefined)
const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')
@@ -57,7 +67,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
return (
<div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />}
{fileExists && <img alt={filename as string} src={src} />}
{fileExists && <img alt={filename as string} height={height} src={src} width={width} />}
{fileExists === false && <File />}
</div>
)
@@ -69,7 +79,7 @@ type ThumbnailComponentProps = {
readonly filename: string
readonly fileSrc: string
readonly imageCacheTag?: string
readonly size?: 'expand' | 'large' | 'medium' | 'small'
readonly size?: 'expand' | 'large' | 'medium' | 'none' | 'small'
}
export function ThumbnailComponent(props: ThumbnailComponentProps) {
const { alt, className = '', filename, fileSrc, imageCacheTag, size } = props

View File

@@ -7,7 +7,6 @@ import type {
import type { BrowserContext, Locator, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { except } from 'drizzle-orm/mysql-core'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -537,13 +536,11 @@ describe('lexicalMain', () => {
const secondUploadNode = richTextField.locator('.lexical-upload').nth(1)
await secondUploadNode.scrollIntoViewIfNeeded()
await expect(secondUploadNode).toBeVisible()
// Focus the upload node
await secondUploadNode.click()
await expect(secondUploadNode.locator('.lexical-upload__bottomRow')).toContainText(
'payload-1.jpg',
)
await expect(secondUploadNode.locator('.lexical-upload__collectionLabel')).toContainText(
'Upload',
)
await expect(secondUploadNode.locator('.lexical-upload__filename')).toHaveText('payload-1.jpg')
await expect(secondUploadNode.locator('.lexical-upload__collectionLabel')).toHaveText('Upload')
})
// This reproduces https://github.com/payloadcms/payload/issues/7128
@@ -590,8 +587,10 @@ describe('lexicalMain', () => {
const newUploadNode = richTextField.locator('.lexical-upload').nth(1)
await newUploadNode.scrollIntoViewIfNeeded()
await expect(newUploadNode).toBeVisible()
await newUploadNode.click() // Focus the upload node
await newUploadNode.hover()
await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.jpg')
await expect(newUploadNode.locator('.lexical-upload__filename')).toContainText('payload.jpg')
// Click on button with class lexical-upload__upload-drawer-toggler
await newUploadNode.locator('.lexical-upload__upload-drawer-toggler').first().click()
@@ -630,6 +629,9 @@ describe('lexicalMain', () => {
.nth(1)
await reloadedUploadNode.scrollIntoViewIfNeeded()
await expect(reloadedUploadNode).toBeVisible()
await reloadedUploadNode.click() // Focus the upload node
await reloadedUploadNode.hover()
await reloadedUploadNode.locator('.lexical-upload__upload-drawer-toggler').first().click()
const reloadedUploadExtraFieldsDrawer = page
.locator('dialog[id^=drawer_1_lexical-upload-drawer-]')
@@ -1209,7 +1211,9 @@ describe('lexicalMain', () => {
await expect(slashMenuPopover).toBeHidden()
await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.png')
await newUploadNode.hover()
await expect(newUploadNode.locator('.lexical-upload__filename')).toHaveText('payload.png')
await page.keyboard.press('Enter') // floating toolbar needs to appear with enough distance to the upload node, otherwise clicking may fail
await page.keyboard.press('Enter')
@@ -1557,12 +1561,9 @@ describe('lexicalMain', () => {
// test
await navigateToLexicalFields()
const bottomOfUploadNode = page
.locator('.lexical-upload div')
.filter({ hasText: /^payload\.jpg$/ })
.first()
await bottomOfUploadNode.click()
await expectInsideSelectedDecorator(bottomOfUploadNode)
const uploadNode = page.locator('.lexical-upload[data-filename="payload.jpg"]').first()
await uploadNode.click()
await expectInsideSelectedDecorator(uploadNode)
const textNode = page.getByText('Upload Node:', { exact: true })
await textNode.click()