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

View File

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

View File

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

View File

@@ -2,16 +2,18 @@
@layer payload-default { @layer payload-default {
.thumbnail { .thumbnail {
min-height: 100%; &:not(.thumbnail--size-none) {
flex-shrink: 0; min-height: 100%;
align-self: stretch; flex-shrink: 0;
overflow: hidden; align-self: stretch;
overflow: hidden;
img, img,
svg { svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
}
} }
&--size-expand { &--size-expand {

View File

@@ -15,13 +15,23 @@ export type ThumbnailProps = {
collectionSlug?: string collectionSlug?: string
doc?: Record<string, unknown> doc?: Record<string, unknown>
fileSrc?: string fileSrc?: string
height?: number
imageCacheTag?: string imageCacheTag?: string
size?: 'expand' | 'large' | 'medium' | 'small' size?: 'expand' | 'large' | 'medium' | 'none' | 'small'
uploadConfig?: SanitizedCollectionConfig['upload'] uploadConfig?: SanitizedCollectionConfig['upload']
width?: number
} }
export const Thumbnail: React.FC<ThumbnailProps> = (props) => { 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 [fileExists, setFileExists] = React.useState(undefined)
const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ') const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')
@@ -57,7 +67,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
return ( return (
<div className={classNames}> <div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />} {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 />} {fileExists === false && <File />}
</div> </div>
) )
@@ -69,7 +79,7 @@ type ThumbnailComponentProps = {
readonly filename: string readonly filename: string
readonly fileSrc: string readonly fileSrc: string
readonly imageCacheTag?: string readonly imageCacheTag?: string
readonly size?: 'expand' | 'large' | 'medium' | 'small' readonly size?: 'expand' | 'large' | 'medium' | 'none' | 'small'
} }
export function ThumbnailComponent(props: ThumbnailComponentProps) { export function ThumbnailComponent(props: ThumbnailComponentProps) {
const { alt, className = '', filename, fileSrc, imageCacheTag, size } = props 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 type { BrowserContext, Locator, Page } from '@playwright/test'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { except } from 'drizzle-orm/mysql-core'
import path from 'path' import path from 'path'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -537,13 +536,11 @@ describe('lexicalMain', () => {
const secondUploadNode = richTextField.locator('.lexical-upload').nth(1) const secondUploadNode = richTextField.locator('.lexical-upload').nth(1)
await secondUploadNode.scrollIntoViewIfNeeded() await secondUploadNode.scrollIntoViewIfNeeded()
await expect(secondUploadNode).toBeVisible() await expect(secondUploadNode).toBeVisible()
// Focus the upload node
await secondUploadNode.click()
await expect(secondUploadNode.locator('.lexical-upload__bottomRow')).toContainText( await expect(secondUploadNode.locator('.lexical-upload__filename')).toHaveText('payload-1.jpg')
'payload-1.jpg', await expect(secondUploadNode.locator('.lexical-upload__collectionLabel')).toHaveText('Upload')
)
await expect(secondUploadNode.locator('.lexical-upload__collectionLabel')).toContainText(
'Upload',
)
}) })
// This reproduces https://github.com/payloadcms/payload/issues/7128 // This reproduces https://github.com/payloadcms/payload/issues/7128
@@ -590,8 +587,10 @@ describe('lexicalMain', () => {
const newUploadNode = richTextField.locator('.lexical-upload').nth(1) const newUploadNode = richTextField.locator('.lexical-upload').nth(1)
await newUploadNode.scrollIntoViewIfNeeded() await newUploadNode.scrollIntoViewIfNeeded()
await expect(newUploadNode).toBeVisible() 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 // Click on button with class lexical-upload__upload-drawer-toggler
await newUploadNode.locator('.lexical-upload__upload-drawer-toggler').first().click() await newUploadNode.locator('.lexical-upload__upload-drawer-toggler').first().click()
@@ -630,6 +629,9 @@ describe('lexicalMain', () => {
.nth(1) .nth(1)
await reloadedUploadNode.scrollIntoViewIfNeeded() await reloadedUploadNode.scrollIntoViewIfNeeded()
await expect(reloadedUploadNode).toBeVisible() await expect(reloadedUploadNode).toBeVisible()
await reloadedUploadNode.click() // Focus the upload node
await reloadedUploadNode.hover()
await reloadedUploadNode.locator('.lexical-upload__upload-drawer-toggler').first().click() await reloadedUploadNode.locator('.lexical-upload__upload-drawer-toggler').first().click()
const reloadedUploadExtraFieldsDrawer = page const reloadedUploadExtraFieldsDrawer = page
.locator('dialog[id^=drawer_1_lexical-upload-drawer-]') .locator('dialog[id^=drawer_1_lexical-upload-drawer-]')
@@ -1209,7 +1211,9 @@ describe('lexicalMain', () => {
await expect(slashMenuPopover).toBeHidden() 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') // floating toolbar needs to appear with enough distance to the upload node, otherwise clicking may fail
await page.keyboard.press('Enter') await page.keyboard.press('Enter')
@@ -1557,12 +1561,9 @@ describe('lexicalMain', () => {
// test // test
await navigateToLexicalFields() await navigateToLexicalFields()
const bottomOfUploadNode = page const uploadNode = page.locator('.lexical-upload[data-filename="payload.jpg"]').first()
.locator('.lexical-upload div') await uploadNode.click()
.filter({ hasText: /^payload\.jpg$/ }) await expectInsideSelectedDecorator(uploadNode)
.first()
await bottomOfUploadNode.click()
await expectInsideSelectedDecorator(bottomOfUploadNode)
const textNode = page.getByText('Upload Node:', { exact: true }) const textNode = page.getByText('Upload Node:', { exact: true })
await textNode.click() await textNode.click()