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;
&__floater {
@include shadow-lg;
position: absolute;
/* hidden by default */
opacity: 0;
transition:
opacity 0.15s ease,
transform 0.15s ease;
pointer-events: none;
}
&__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 {
position: absolute;
object-fit: cover;
width: 100%;
height: 100%;
background-color: var(--theme-elevation-800);
}
&:hover .lexical-upload__floater,
&__media:focus-within .lexical-upload__floater {
opacity: 1;
pointer-events: auto;
}
&__topRowRightPanel {
flex-grow: 1;
/* --- 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);
}
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);
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;
}
&__doc-drawer-toggler,
&__list-drawer-toggler,
&__upload-drawer-toggler {
& > * {
margin: 0;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
cursor: pointer;
}
&__collectionLabel {
color: var(--theme-elevation-500);
font-size: 0.9em;
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);
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 {
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`}>
<Thumbnail
collectionSlug={relationTo}
fileSrc={isImage(data?.mimeType) ? thumbnailSRC : null}
/>
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
{editor.isEditable() && (
<div className={`${baseClass}__actions`}>
<div className={`${baseClass}__media`}>
<Thumbnail
collectionSlug={relationTo}
fileSrc={isImage(data?.mimeType) ? thumbnailSRC : null}
height={data?.height}
size="none"
width={data?.width}
/>
{editor.isEditable() && (
<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 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 className={`${baseClass}__bottomRow`}>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<strong>{data?.filename}</strong>
</DocumentDrawerToggler>
</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,16 +2,18 @@
@layer payload-default {
.thumbnail {
min-height: 100%;
flex-shrink: 0;
align-self: stretch;
overflow: hidden;
&:not(.thumbnail--size-none) {
min-height: 100%;
flex-shrink: 0;
align-self: stretch;
overflow: hidden;
img,
svg {
width: 100%;
height: 100%;
object-fit: cover;
img,
svg {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&--size-expand {

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