Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/blocks/BlogList/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks'
import colorPickerField from '@/fields/color'
import { getTenantFilter } from '@/utilities/collectionFilters'
import {
Expand All @@ -23,6 +24,7 @@ const defaultStylingFields: Field[] = [
...rootFeatures,
BlocksFeature({
blocks: [ButtonBlock, MediaBlock, GenericEmbedBlock],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
HorizontalRuleFeature(),
InlineToolbarFeature(),
Expand Down
2 changes: 2 additions & 0 deletions src/blocks/Callout/config.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -30,6 +31,7 @@ export const CalloutBlock: Block = {
MediaBlock,
SingleBlogPostBlock,
],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
]
},
Expand Down
2 changes: 2 additions & 0 deletions src/blocks/Content/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Block, SelectFieldValidation } from 'payload'

import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks'
import colorPickerField from '@/fields/color'
import {
BlocksFeature,
Expand Down Expand Up @@ -143,6 +144,7 @@ export const ContentBlock: Block = {
SingleBlogPostBlock,
SponsorsBlock,
],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
HorizontalRuleFeature(),
InlineToolbarFeature(),
Expand Down
95 changes: 95 additions & 0 deletions src/blocks/InlineMedia/Component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { InlineMediaBlock } from '@/payload-types'

import { Media } from '@/components/Media'
import { cn } from '@/utilities/ui'

type Props = Omit<InlineMediaBlock, 'blockType' | 'id'>

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'
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this being calculated/ is it needed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the attribute — a hint that tells the browser which source to pick from a responsive srcset before layout is known. For fixed-height images the rendered width depends on the image's aspect ratio, which we don't have at render time without reading the media dimensions. 96px is a conservative fallback so the browser doesn't load the full-resolution source. It's imperfect but errs on the side of a smaller download; the browser will still render at the correct size.

}

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 ? (
<span
className="block [&>span]:h-full [&_picture]:h-full"
style={{ height: `${fixedHeight}px` }}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ You could make this a class name like you are above and remove the inline style.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>
<Media
htmlElement="span"
resource={media}
imgClassName={imgSizeClass}
pictureClassName="my-0"
sizes={sizes}
/>
</span>
) : (
<Media
htmlElement="span"
resource={media}
imgClassName={imgSizeClass}
pictureClassName="my-0"
sizes={sizes}
/>
)

return (
<span className={cn(positionClasses, sizeClass)}>
{mediaElement}
{caption && <span className="block text-xs text-gray-500 mt-0.5">{caption}</span>}
</span>
)
}
77 changes: 77 additions & 0 deletions src/blocks/InlineMedia/config.ts
Original file line number Diff line number Diff line change
@@ -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' },
Comment on lines +49 to +52
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it matters, but maybe we add a w in front of of each value to ensure it's a string. Doesn't seem to be an issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea, to make it clear that these are strings but we actually use these for the sizes attribute in the component so we'd have to strip off the 'w' prefix which I think would be more confusing.

{ 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.',
},
},
],
}
2 changes: 2 additions & 0 deletions src/collections/Events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -165,6 +166,7 @@ export const Events: CollectionConfig = {
SingleEventBlock,
SponsorsBlock,
],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
HorizontalRuleFeature(),
InlineToolbarFeature(),
Expand Down
2 changes: 2 additions & 0 deletions src/collections/HomePages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -111,6 +112,7 @@ export const HomePages: CollectionConfig = {
SingleBlogPostBlock,
SponsorsBlock,
],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
HorizontalRuleFeature(),
InlineToolbarFeature(),
Expand Down
2 changes: 2 additions & 0 deletions src/collections/Posts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -104,6 +105,7 @@ export const Posts: CollectionConfig<'posts'> = {
SingleEventBlock,
SponsorsBlock,
],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
HorizontalRuleFeature(),
InlineToolbarFeature(),
Expand Down
7 changes: 7 additions & 0 deletions src/components/RichText/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -35,6 +37,7 @@ import type {
GenericEmbedBlock as GenericEmbedBlockProps,
HeaderBlock as HeaderBlockProps,
ImageTextBlock as ImageTextBlockProps,
InlineMediaBlock as InlineMediaBlockProps,
MediaBlock as MediaBlockProps,
Page,
Post,
Expand Down Expand Up @@ -87,6 +90,7 @@ type NodeTypes =
| SingleEventBlockProps
| SponsorsBlockProps
>
| SerializedInlineBlockNode<InlineMediaBlockProps>

const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
const { linkType, doc, url } = linkNode.fields
Expand Down Expand Up @@ -140,6 +144,9 @@ const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters })
),
sponsorsBlock: ({ node }) => <SponsorsBlockComponent {...node.fields} isLayoutBlock={false} />,
},
inlineBlocks: {
inlineMedia: ({ node }) => <InlineMediaComponent {...node.fields} />,
},
})

type Props = {
Expand Down
3 changes: 3 additions & 0 deletions src/constants/defaultInlineBlocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { InlineMediaBlock } from '@/blocks/InlineMedia/config'

export const DEFAULT_INLINE_BLOCKS = [InlineMediaBlock]
2 changes: 2 additions & 0 deletions src/fields/defaultLexical.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -67,6 +68,7 @@ export const defaultLexical: Config['editor'] = lexicalEditor({
}),
BlocksFeature({
blocks: [GenericEmbedBlock, BlogListBlock, SingleBlogPostBlock],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
FixedToolbarFeature(),
OrderedListFeature(),
Expand Down
30 changes: 30 additions & 0 deletions src/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
Loading