diff --git a/src/blocks/BlogList/config.ts b/src/blocks/BlogList/config.ts index 8e916ac4f..61beb841f 100644 --- a/src/blocks/BlogList/config.ts +++ b/src/blocks/BlogList/config.ts @@ -1,3 +1,4 @@ +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import colorPickerField from '@/fields/color' import { getTenantFilter } from '@/utilities/collectionFilters' import { @@ -23,6 +24,7 @@ const defaultStylingFields: Field[] = [ ...rootFeatures, BlocksFeature({ blocks: [ButtonBlock, MediaBlock, GenericEmbedBlock], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/blocks/Callout/config.ts b/src/blocks/Callout/config.ts index 231426f31..f8a1290bf 100644 --- a/src/blocks/Callout/config.ts +++ b/src/blocks/Callout/config.ts @@ -1,5 +1,6 @@ import type { Block } from 'payload' +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import colorPickerField from '@/fields/color' import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical' import { BlogListBlock } from '../BlogList/config' @@ -30,6 +31,7 @@ export const CalloutBlock: Block = { MediaBlock, SingleBlogPostBlock, ], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), ] }, diff --git a/src/blocks/Content/config.ts b/src/blocks/Content/config.ts index 4cd9afb54..18ae0cb58 100644 --- a/src/blocks/Content/config.ts +++ b/src/blocks/Content/config.ts @@ -1,5 +1,6 @@ import type { Block, SelectFieldValidation } from 'payload' +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import colorPickerField from '@/fields/color' import { BlocksFeature, @@ -143,6 +144,7 @@ export const ContentBlock: Block = { SingleBlogPostBlock, SponsorsBlock, ], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx new file mode 100644 index 000000000..88d32bcb6 --- /dev/null +++ b/src/blocks/InlineMedia/Component.tsx @@ -0,0 +1,95 @@ +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..c238e8f4d --- /dev/null +++ b/src/blocks/InlineMedia/config.ts @@ -0,0 +1,77 @@ +import type { Block } from 'payload' + +export const InlineMediaBlock: Block = { + slug: 'inlineMedia', + interfaceName: 'InlineMediaBlock', + imageURL: '/thumbnail/MediaThumbnail.jpg', + fields: [ + { + name: 'media', + type: 'upload', + relationTo: 'media', + required: true, + }, + { + name: 'position', + type: 'select', + defaultValue: 'inline', + options: [ + { label: 'Inline', value: 'inline' }, + { label: 'Left', value: 'float-left' }, + { label: 'Right', value: 'float-right' }, + ], + admin: { + description: + 'Inline renders the image within the text flow. Left or Right positions the image to that 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/Events/index.ts b/src/collections/Events/index.ts index 7178b6df5..e023ca2a3 100644 --- a/src/collections/Events/index.ts +++ b/src/collections/Events/index.ts @@ -10,6 +10,7 @@ import { MediaBlock } from '@/blocks/Media/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { SingleEventBlock } from '@/blocks/SingleEvent/config' import { SponsorsBlock } from '@/blocks/Sponsors/config' +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import { eventTypesData } from '@/constants/eventTypes' import { contentHashField } from '@/fields/contentHashField' import { locationField } from '@/fields/location' @@ -165,6 +166,7 @@ export const Events: CollectionConfig = { SingleEventBlock, SponsorsBlock, ], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/collections/HomePages/index.tsx b/src/collections/HomePages/index.tsx index f87f1407f..4ba63c2aa 100644 --- a/src/collections/HomePages/index.tsx +++ b/src/collections/HomePages/index.tsx @@ -9,6 +9,7 @@ import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config' import { MediaBlock } from '@/blocks/Media/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { SponsorsBlock } from '@/blocks/Sponsors/config' +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import { DocumentBlock } from '@/blocks/Document/config' import { HeaderLexicalBlock } from '@/blocks/Header/config' @@ -111,6 +112,7 @@ export const HomePages: CollectionConfig = { SingleBlogPostBlock, SponsorsBlock, ], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index 37468509f..21ffb1c26 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -19,6 +19,7 @@ import { MediaBlock } from '@/blocks/Media/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { SingleEventBlock } from '@/blocks/SingleEvent/config' import { SponsorsBlock } from '@/blocks/Sponsors/config' +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import { populatePublishedAt } from '@/hooks/populatePublishedAt' import { getTenantAndIdFilter, getTenantFilter } from '@/utilities/collectionFilters' @@ -104,6 +105,7 @@ export const Posts: CollectionConfig<'posts'> = { SingleEventBlock, SponsorsBlock, ], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx index 115ab8008..3f8eae500 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' @@ -20,6 +21,7 @@ import { EventTableBlockComponent } from '@/blocks/EventTable/Component' import { GenericEmbedBlockComponent } from '@/blocks/GenericEmbed/Component' import { HeaderBlockComponent } from '@/blocks/Header/Component' import { ImageTextBlockComponent } from '@/blocks/ImageText/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' @@ -35,6 +37,7 @@ import type { GenericEmbedBlock as GenericEmbedBlockProps, HeaderBlock as HeaderBlockProps, ImageTextBlock as ImageTextBlockProps, + InlineMediaBlock as InlineMediaBlockProps, MediaBlock as MediaBlockProps, Page, Post, @@ -87,6 +90,7 @@ type NodeTypes = | SingleEventBlockProps | SponsorsBlockProps > + | SerializedInlineBlockNode const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { const { linkType, doc, url } = linkNode.fields @@ -140,6 +144,9 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) ), sponsorsBlock: ({ node }) => , }, + inlineBlocks: { + inlineMedia: ({ node }) => , + }, }) type Props = { diff --git a/src/constants/defaultInlineBlocks.ts b/src/constants/defaultInlineBlocks.ts new file mode 100644 index 000000000..97c30d72d --- /dev/null +++ b/src/constants/defaultInlineBlocks.ts @@ -0,0 +1,3 @@ +import { InlineMediaBlock } from '@/blocks/InlineMedia/config' + +export const DEFAULT_INLINE_BLOCKS = [InlineMediaBlock] diff --git a/src/fields/defaultLexical.ts b/src/fields/defaultLexical.ts index aa3afa8e1..7c9cb58ab 100644 --- a/src/fields/defaultLexical.ts +++ b/src/fields/defaultLexical.ts @@ -1,6 +1,7 @@ import { BlogListBlock } from '@/blocks/BlogList/config' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' +import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks' import { getTenantFilter } from '@/utilities/collectionFilters' import { validateExternalUrl } from '@/utilities/validateUrl' import { @@ -67,6 +68,7 @@ export const defaultLexical: Config['editor'] = lexicalEditor({ }), BlocksFeature({ blocks: [GenericEmbedBlock, BlogListBlock, SingleBlogPostBlock], + inlineBlocks: DEFAULT_INLINE_BLOCKS, }), FixedToolbarFeature(), OrderedListFeature(), diff --git a/src/payload-types.ts b/src/payload-types.ts index 2ae1634bb..32c38b246 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -4197,6 +4197,36 @@ export interface ButtonBlock { blockName?: string | null; blockType: 'buttonBlock'; } +/** + * 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. Left or Right positions the image to that 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` "calloutBlock".