diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx new file mode 100644 index 000000000..f15b2f726 --- /dev/null +++ b/src/blocks/InlineMedia/Component.tsx @@ -0,0 +1,89 @@ +import type { InlineMediaBlock } from '@/payload-types' + +import { Media } from '@/components/Media' +import { cn } from '@/utilities/ui' + +type Props = Omit + +const widthClasses = { + '25': 'w-1/4', + '50': 'w-1/2', + '75': 'w-3/4', + '100': 'w-full', +} + +const verticalAlignClasses = { + top: 'align-top', + middle: 'align-middle', + bottom: 'align-bottom', + baseline: 'align-baseline', +} + +type WidthSize = keyof typeof widthClasses + +function isWidthSize(size: string): size is WidthSize { + return size in widthClasses +} + +export const InlineMediaComponent = ({ + media, + position = 'inline', + verticalAlign = 'middle', + size = 'original', + fixedHeight, + caption, +}: Props) => { + if (!media || typeof media === 'number' || typeof media === 'string') { + return null + } + + const isFloat = position === 'float-left' || position === 'float-right' + const resolvedSize = size ?? 'original' + + let sizeClass = '' + let imgSizeClass = 'w-auto h-auto' + let sizes = '100vw' + const isFixedHeight = resolvedSize === 'fixed-height' && fixedHeight + + if (resolvedSize === 'original') { + sizeClass = 'max-w-fit' + } else if (isWidthSize(resolvedSize)) { + sizeClass = widthClasses[resolvedSize] + imgSizeClass = 'w-full h-auto' + // Approximate sizes hint for responsive images + sizes = `${resolvedSize}vw` + } else if (isFixedHeight) { + imgSizeClass = 'h-full w-auto' + sizes = '96px' + } + + const positionClasses = isFloat + ? cn(position === 'float-left' ? 'float-left mr-2' : 'float-right ml-2', 'mb-1') + : cn('inline-block', verticalAlignClasses[verticalAlign ?? 'middle']) + + // For fixed height, wrap Media in a span with explicit height. + // Descendant selectors propagate height through Media's intermediate span and picture elements. + const mediaElement = isFixedHeight ? ( + + + + ) : ( + + ) + + return ( + + {mediaElement} + {caption && {caption}} + + ) +} diff --git a/src/blocks/InlineMedia/config.ts b/src/blocks/InlineMedia/config.ts new file mode 100644 index 000000000..e064ecc1b --- /dev/null +++ b/src/blocks/InlineMedia/config.ts @@ -0,0 +1,76 @@ +import type { Block } from 'payload' + +export const InlineMediaBlock: Block = { + slug: 'inlineMedia', + interfaceName: 'InlineMediaBlock', + fields: [ + { + name: 'media', + type: 'upload', + relationTo: 'media', + required: true, + }, + { + name: 'position', + type: 'select', + defaultValue: 'inline', + options: [ + { label: 'Inline', value: 'inline' }, + { label: 'Float left', value: 'float-left' }, + { label: 'Float right', value: 'float-right' }, + ], + admin: { + description: + 'Inline renders the image within the text flow. Float positions the image to one side with text wrapping around it.', + }, + }, + { + name: 'verticalAlign', + type: 'select', + defaultValue: 'middle', + options: [ + { label: 'Middle', value: 'middle' }, + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'Baseline', value: 'baseline' }, + ], + admin: { + description: 'Vertical alignment relative to the surrounding text.', + condition: (_, siblingData) => siblingData?.position === 'inline', + }, + }, + { + name: 'size', + type: 'select', + defaultValue: 'original', + options: [ + { label: 'Original (natural size)', value: 'original' }, + { label: '25% width', value: '25' }, + { label: '50% width', value: '50' }, + { label: '75% width', value: '75' }, + { label: '100% width', value: '100' }, + { label: 'Fixed height', value: 'fixed-height' }, + ], + admin: { + description: + 'Original uses the natural image size. Percentage widths are relative to the containing block. Fixed height lets you specify an exact pixel height.', + }, + }, + { + name: 'fixedHeight', + type: 'number', + min: 1, + admin: { + description: 'Height in pixels.', + condition: (_, siblingData) => siblingData?.size === 'fixed-height', + }, + }, + { + name: 'caption', + type: 'text', + admin: { + description: 'Optional text shown as a tooltip on hover.', + }, + }, + ], +} diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index ab03c22ce..682cf95fc 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -15,6 +15,7 @@ import { EventListBlock } from '@/blocks/EventList/config' import { EventTableBlock } from '@/blocks/EventTable/config' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config' import { HeaderLexicalBlock } from '@/blocks/Header/config' +import { InlineMediaBlock } from '@/blocks/InlineMedia/config' import { MediaBlock } from '@/blocks/Media/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { SingleEventBlock } from '@/blocks/SingleEvent/config' @@ -102,6 +103,7 @@ export const Posts: CollectionConfig<'posts'> = { SingleEventBlock, SponsorsBlock, ], + inlineBlocks: [InlineMediaBlock], }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx index 4167b351d..23aaa3f70 100644 --- a/src/components/RichText/index.tsx +++ b/src/components/RichText/index.tsx @@ -2,6 +2,7 @@ import { MediaBlockComponent } from '@/blocks/Media/Component' import { DefaultNodeTypes, SerializedBlockNode, + SerializedInlineBlockNode, SerializedLinkNode, } from '@payloadcms/richtext-lexical' import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' @@ -19,6 +20,7 @@ import { EventListBlockComponent } from '@/blocks/EventList/Component' import { EventTableBlockComponent } from '@/blocks/EventTable/Component' import { GenericEmbedBlockComponent } from '@/blocks/GenericEmbed/Component' import { HeaderBlockComponent } from '@/blocks/Header/Component' +import { InlineMediaComponent } from '@/blocks/InlineMedia/Component' import { SingleBlogPostBlockComponent } from '@/blocks/SingleBlogPost/Component' import { SingleEventBlockComponent } from '@/blocks/SingleEvent/Component' import { SponsorsBlockComponent } from '@/blocks/Sponsors/components' @@ -33,6 +35,7 @@ import type { EventTableBlock as EventTableBlockProps, GenericEmbedBlock as GenericEmbedBlockProps, HeaderBlock as HeaderBlockProps, + InlineMediaBlock as InlineMediaBlockProps, MediaBlock as MediaBlockProps, Page, Post, @@ -84,6 +87,7 @@ type NodeTypes = | SingleEventBlockProps | SponsorsBlockProps > + | SerializedInlineBlockNode const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { const { linkType, doc, url } = linkNode.fields @@ -136,6 +140,9 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) ), sponsorsBlock: ({ node }) => , }, + inlineBlocks: { + inlineMedia: ({ node }) => , + }, }) type Props = { diff --git a/src/fields/defaultLexical.ts b/src/fields/defaultLexical.ts index aa3afa8e1..3d931e3ee 100644 --- a/src/fields/defaultLexical.ts +++ b/src/fields/defaultLexical.ts @@ -1,5 +1,6 @@ import { BlogListBlock } from '@/blocks/BlogList/config' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config' +import { InlineMediaBlock } from '@/blocks/InlineMedia/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { getTenantFilter } from '@/utilities/collectionFilters' import { validateExternalUrl } from '@/utilities/validateUrl' @@ -67,6 +68,7 @@ export const defaultLexical: Config['editor'] = lexicalEditor({ }), BlocksFeature({ blocks: [GenericEmbedBlock, BlogListBlock, SingleBlogPostBlock], + inlineBlocks: [InlineMediaBlock], }), FixedToolbarFeature(), OrderedListFeature(), diff --git a/src/payload-types.ts b/src/payload-types.ts index a50191326..7a6638278 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -4843,6 +4843,36 @@ export interface SingleEventBlock { blockName?: string | null; blockType: 'singleEvent'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "InlineMediaBlock". + */ +export interface InlineMediaBlock { + media: number | Media; + /** + * Inline renders the image within the text flow. Float positions the image to one side with text wrapping around it. + */ + position?: ('inline' | 'float-left' | 'float-right') | null; + /** + * Vertical alignment relative to the surrounding text. + */ + verticalAlign?: ('middle' | 'top' | 'bottom' | 'baseline') | null; + /** + * Original uses the natural image size. Percentage widths are relative to the containing block. Fixed height lets you specify an exact pixel height. + */ + size?: ('original' | '25' | '50' | '75' | '100' | 'fixed-height') | null; + /** + * Height in pixels. + */ + fixedHeight?: number | null; + /** + * Optional text shown as a tooltip on hover. + */ + caption?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'inlineMedia'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth".