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:
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user