From bea77f2f24c77088a33a27dfd11bffd8ca26cf9c Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 24 Sep 2025 13:22:03 -0700 Subject: [PATCH] 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: Screenshot 2025-09-24 at 10 11 32@2x ## Previous Design > Before: > > Screenshot 2025-09-22 at 16 01
16@2x > > After: > > Screenshot 2025-09-22 at 16 01
00@2x > > > 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 --- .../upload/client/component/index.scss | 198 ++++++++++-------- .../upload/client/component/index.tsx | 68 +++--- .../upload/client/nodes/UploadNode.tsx | 2 +- packages/ui/src/elements/Thumbnail/index.scss | 20 +- packages/ui/src/elements/Thumbnail/index.tsx | 18 +- .../collections/Lexical/e2e/main/e2e.spec.ts | 31 +-- 6 files changed, 195 insertions(+), 142 deletions(-) diff --git a/packages/richtext-lexical/src/features/upload/client/component/index.scss b/packages/richtext-lexical/src/features/upload/client/component/index.scss index 98d17efc0..18f09d7db 100644 --- a/packages/richtext-lexical/src/features/upload/client/component/index.scss +++ b/packages/richtext-lexical/src/features/upload/client/component/index.scss @@ -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%; } } } diff --git a/packages/richtext-lexical/src/features/upload/client/component/index.tsx b/packages/richtext-lexical/src/features/upload/client/component/index.tsx index 9b16e316d..10e3371ca 100644 --- a/packages/richtext-lexical/src/features/upload/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/upload/client/component/index.tsx @@ -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 = (props) => { [editor, nodeKey], ) + const aspectRatio = + thumbnailSRC && data?.width && data?.height + ? data.width > data.height + ? 'landscape' + : 'portrait' + : 'landscape' + return ( -
+
-
-
- -
-
-
- {getTranslation(relatedCollection.labels.singular, i18n)} -
- {editor.isEditable() && ( -
+
+ + + {editor.isEditable() && ( +
+
{hasExtraFields ? (
- )} +
+ )} +
+ +
+ + + {data?.filename || t('general:untitled')} + + +
+ {getTranslation(relatedCollection.labels.singular, i18n)}
-
- - {data?.filename} - -
+ {value ? : null} {hasExtraFields ? ( } - return + return } override exportJSON(): SerializedUploadNode { diff --git a/packages/ui/src/elements/Thumbnail/index.scss b/packages/ui/src/elements/Thumbnail/index.scss index d9ead3017..d73a340e0 100644 --- a/packages/ui/src/elements/Thumbnail/index.scss +++ b/packages/ui/src/elements/Thumbnail/index.scss @@ -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 { diff --git a/packages/ui/src/elements/Thumbnail/index.tsx b/packages/ui/src/elements/Thumbnail/index.tsx index f4ad4290f..047cc6ed8 100644 --- a/packages/ui/src/elements/Thumbnail/index.tsx +++ b/packages/ui/src/elements/Thumbnail/index.tsx @@ -15,13 +15,23 @@ export type ThumbnailProps = { collectionSlug?: string doc?: Record 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 = (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 = (props) => { return (
{fileExists === undefined && } - {fileExists && {filename} + {fileExists && {filename} {fileExists === false && }
) @@ -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 diff --git a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts index 0f44be890..12b3843c6 100644 --- a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts @@ -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()