diff --git a/README.md b/README.md index ce7ed8d..d4c69e8 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,16 @@ import Visual from '@react-visual/react' export default function ResponsiveExample() { return ( { - const ext = type?.includes("webp") ? ".webp" : ".jpg"; - const height = media?.includes("landscape") ? width * 0.5 : width; - return `https://placehold.co/${width}x${height}${ext}`; + image='https://placehold.co/200x200' + sourceTypes={['image/webp']} + sourceMedia={['(orientation:landscape)', '(orientation:portrait)']} + imageLoader={({ src, type, media, width }) => { + const height = media?.includes('landscape') ? width * 0.5 : width + const ext = type?.includes('webp') ? '.webp' : '' + return `https://placehold.co/${width}x${height}${ext}` }} - aspect={300 / 150} - sizes="100vw" - alt="Example of responsive images" - /> + width='100%' + alt='Example of responsive images'/> ) } ``` diff --git a/packages/react/README.md b/packages/react/README.md index 185c91e..53f9ea4 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -33,7 +33,7 @@ export default function VideoExample() { } ``` -Generate multiple landscape and portrait sources in webp and avif using an image CDN to create a srcset. +Generate multiple landscape and portrait sources using an image CDN to create a srcset. ```jsx import Visual from '@react-visual/react' @@ -42,11 +42,11 @@ export default function ResponsiveExample() { return ( { const height = media?.includes('landscape') ? width * 0.5 : width - const ext = type?.includes('webp') ? '.webp' : '.jpg' + const ext = type?.includes('webp') ? '.webp' : '' return `https://placehold.co/${width}x${height}${ext}` }} width='100%' @@ -58,30 +58,109 @@ export default function ResponsiveExample() { The above would produce: ```html - - - - - - Example of responsive images - +
+ + + + + + Example of responsive images + +
+``` + +Accept objects from a CMS to produce responsive assets at different aspect ratios. + +```jsx +import Visual from '@react-visual/react' + +export default function ResponsiveExample() { + return ( + { + + // Choose the right source + const asset = media?.includes('landscape') ? + src.landscape : src.portrait + + // Make the dimensions + const dimensions = `${width}x${width / asset.aspect}` + + // Choose the right format + const ext = type?.includes('webp') ? '.webp' : '.jpg' + + // Make the url + return `https://placehold.co/${dimensions}${ext}` + + }} + aspect={({ image, media }) => { + return media?.includes('landscape') ? + image.landscape.aspect : + image.portrait.aspect + }} + alt='Example of responsive images'/> + ) +} +``` + +This produces: + +```html +
+ + + + Example of responsive images + + +
``` For more examples, read [the Cypress component tests](./cypress/component). @@ -92,15 +171,15 @@ For more examples, read [the Cypress component tests](./cypress/component). | Prop | Type | Description | -- | -- | -- -| `image` | `string` | URL to an image asset. -| `video` | `string` | URL to a video asset asset. +| `image` | `string`, `object` | URL to an image asset. +| `video` | `string`, `object` | URL to a video asset asset. ### Layout | Prop | Type | Description | -- | -- | -- | `expand` | `boolean` | Make the Visual fill it's container via CSS using absolute positioning. -| `aspect` | `number` | Force the Visual to a specific aspect ratio. +| `aspect` | `number`, `function` | Force the Visual to a specific aspect ratio. | `width` | `number`, `string` | A CSS dimension value or a px number. | `height` | `number`, `string` | A CSS dimension value or a px number. | `fit` | `string` | An [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) value that is applied to the assets. Defaults to `cover`. diff --git a/packages/react/cypress/component/ReactVisual.cy.tsx b/packages/react/cypress/component/ReactVisual.cy.tsx index c4359bb..caca052 100644 --- a/packages/react/cypress/component/ReactVisual.cy.tsx +++ b/packages/react/cypress/component/ReactVisual.cy.tsx @@ -136,11 +136,16 @@ describe('sources', () => { return `https://placehold.co/${width}x${width}${ext}` }} aspect={1} + width='50%' + sizes='50vw' alt=''/>) - // Should be webp source + // Should be webp source at reduced width cy.get('img').its('[0].currentSrc') - .should('eq', 'https://placehold.co/640x640.webp') + .should('eq', 'https://placehold.co/256x256.webp') + + // It should also have a fallback source + cy.get('source:not([type])').should('have.length', 1) }) @@ -151,7 +156,7 @@ describe('sources', () => { cy.mount( { @@ -174,6 +179,78 @@ describe('sources', () => { cy.get('img').its('[0].currentSrc') .should('eq', 'https://placehold.co/640x640.webp') + // There should be fallback sources (non-web) for each orientation + cy.get('source').should('have.length', 4) + cy.get('source:not([type])').should('have.length', 2) + }) + + it('supports rendering object based sources', () => { + + // Start at a landscape viewport + cy.viewport(500, 400) + + cy.mount( { + + // Choose the right source + const asset = media?.includes('landscape') ? + src.landscape : src.portrait + + // Make the dimensions + const dimensions = `${width}x${width / asset.aspect}` + + // Choose the right format + const ext = type?.includes('webp') ? '.webp' : '.jpg' + + // Get text message from src url + const text = (new URL(asset.url)).searchParams.get('text') + + `\\n${dimensions}${ext}` + + // Make the url + return `https://placehold.co/${dimensions}${ext}?text=`+ + encodeURIComponent(text) + }} + aspect={({ image, media }) => { + return media?.includes('landscape') ? + image.landscape.aspect : + image.portrait.aspect + }} + data-cy='react-visual' + alt=''/>) + + // Generates a default from the first asset found + cy.get('img').invoke('attr', 'src') + .should('contain', 'https://placehold.co/1920x1920') + + // Expect a landscape image + cy.get('img').its('[0].currentSrc') + .should('contain', 'https://placehold.co/640x320') + .should('contain', 'landscape') + + // Check that the aspect is informing the size, not the image size + cy.get('[data-cy=react-visual]').hasDimensions(500, 250) + + // Switch to portrait, which should load the other source + cy.viewport(500, 600) + cy.get('img').its('[0].currentSrc') + .should('contain', 'https://placehold.co/640x640') + .should('contain', 'portrait') + + // Check aspect again + cy.get('[data-cy=react-visual]').hasDimensions(500, 500) + }) }) diff --git a/packages/react/cypress/component/VisualWrapper.cy.tsx b/packages/react/cypress/component/VisualWrapper.cy.tsx index 671e4bd..a7d7827 100644 --- a/packages/react/cypress/component/VisualWrapper.cy.tsx +++ b/packages/react/cypress/component/VisualWrapper.cy.tsx @@ -5,7 +5,6 @@ const sharedProps = { style: { background: 'black', color: 'white' }, className: 'wrapper', } -const style = { background: 'black' } // Viewport sizes const VW = Cypress.config('viewportWidth'), @@ -46,6 +45,34 @@ it('supports aspect', () => { cy.get('.wrapper').hasDimensions(VW, VH / 2) }) +it('supports respponsive aspect function', () => { + cy.mount( { + return media?.includes('landscape') ? + image.landscape.aspect : + image.portrait.aspect + }} + />) + cy.viewport(500, 400) + cy.get('.wrapper').hasDimensions(500, 250) + cy.viewport(400, 500) + cy.get('.wrapper').hasDimensions(400, 400) +}) + + it('supports children', () => { cy.mount(

Hey

diff --git a/packages/react/cypress/support/commands.ts b/packages/react/cypress/support/commands.ts index 4e56bf4..97921f7 100644 --- a/packages/react/cypress/support/commands.ts +++ b/packages/react/cypress/support/commands.ts @@ -13,6 +13,7 @@ Cypress.Commands.add('hasDimensions', Cypress.Commands.add('imgLoaded', { prevSubject: true }, (subject) => { + cy.wait(100) // Wait a tick to solve for inexplicable flake cy.wrap(subject) .should('be.visible') .and('have.prop', 'naturalWidth') diff --git a/packages/react/src/LazyVideo.tsx b/packages/react/src/LazyVideo.tsx index 9c47932..10c2eea 100644 --- a/packages/react/src/LazyVideo.tsx +++ b/packages/react/src/LazyVideo.tsx @@ -4,12 +4,20 @@ import { useInView } from 'react-intersection-observer' import { useEffect, type ReactElement, useRef, useCallback } from 'react' import type { LazyVideoProps } from './types/lazyVideoTypes'; - +import type { SourceMedia } from './types/reactVisualTypes' +import { makeSourceVariants } from './lib/sources' import { fillStyles, transparentGif } from './lib/styles' +type VideoSourceProps = { + src: Required['src'] + videoLoader: LazyVideoProps['videoLoader'] + media?: SourceMedia +} + // An video rendered within a Visual that supports lazy loading export default function LazyVideo({ - src, alt, fit, position, priority, noPoster, paused + src, sourceMedia, videoLoader, + alt, fit, position, priority, noPoster, paused, }: LazyVideoProps): ReactElement { // Make a ref to the video so it can be controlled @@ -53,6 +61,10 @@ export default function LazyVideo({ // Simplify logic for whether to load sources const shouldLoad = priority || inView + // Make source variants + const sourceVariants = makeSourceVariants({ sourceMedia }) + + // Render video tag return ( ) } + +// Make a video source tag. Note, media attribute on source isn't supported +// in Chrome. This will need to be converted to a JS solution at some point. +// https://github.com/BKWLD/react-visual/issues/35 +function Source({ + videoLoader, src, media +}: VideoSourceProps): ReactElement { + const srcUrl = videoLoader ? + videoLoader({ src, media }) : + src + return ( + + ) +} diff --git a/packages/react/src/PictureImage.tsx b/packages/react/src/PictureImage.tsx index ed7a385..68e12a3 100644 --- a/packages/react/src/PictureImage.tsx +++ b/packages/react/src/PictureImage.tsx @@ -2,11 +2,13 @@ import type { ReactElement } from 'react' import type { PictureImageProps } from './types/pictureImageTypes' import type { ImageLoader, SourceMedia, SourceType } from './types/reactVisualTypes' import { deviceSizes, imageSizes } from './lib/sizes' +import { makeSourceVariants } from './lib/sources' type ImageSrc = PictureImageProps['src'] -type SourcesProps = { +type ImageSourceProps = { widths: number[] imageLoader: Required['imageLoader'] + sizes: PictureImageProps['sizes'] src: ImageSrc type?: SourceType media?: SourceMedia @@ -46,66 +48,58 @@ export default function PictureImage( ...deviceSizes, ] - // Make the img srcset. When I had a single with no type or media - // attribute, the srcset would not affect the image loaded. Thus, I'm - // applying it to the img tag - const srcSet = imageLoader && makeSrcSet(srcsetWidths, imageLoader, { src }) + // Make the img src url + const srcUrl = makeSrcUrl(src, imageLoader) - // Additional sources to create - const sourceVariants = makeSourceVariants(sourceTypes, sourceMedia) + // Make array or props that will be used to make s. A `null` type is + // always added to create fallback sources for native mime-type of the + // uploaded image. Additionally, this how a is create to store the + // srcset when no `sourceTypes` were specified. + const sourceVariants = makeSourceVariants({ sourceTypes, sourceMedia }) // Always wrap in picture element for standard DOM structure return ( {/* Make s */} - {imageLoader && sourceVariants?.map(({ type, media, key }) => ( + {imageLoader && sourceVariants.map(({ type, media, key }) => ( ))} {/* The main */} ) } -// Make an array of all the source variants to make. If these arrays are -// empty, I add an `undefined` so my ability to loop though isn't blocked. -function makeSourceVariants( - sourceTypes: SourceType[] | undefined, - sourceMedia: SourceMedia[] | undefined -): { - type?: SourceType - media?: SourceMedia - key: string -}[] { - const variants = [] - for (const type of (sourceTypes || [ undefined ])) { - for (const media of (sourceMedia || [ undefined ])) { - if (!type && !media) continue - variants.push({ - type, media, - key: `${type}-${media}` // Make a key for React looping - }) - } +// Make the src value, using imageLoader if the src is not a string. +// Using a 1920 width in this case. +function makeSrcUrl( + src: ImageSrc, + imageLoader: ImageLoader | undefined +): string { + if (typeof src == 'string') return src + if (!imageLoader) { + throw "An `imageLoader` is required when `src` isn't a string" } - return variants + return imageLoader({ src, width: 1920 }) } // Make a source tag with srcset for the provided type and/or media attribute function Source({ - widths, imageLoader, src, type, media -}: SourcesProps): ReactElement { + widths, imageLoader, sizes, src, type, media +}: ImageSourceProps): ReactElement { const srcSet = makeSrcSet(widths, imageLoader, { src, type, media }) return ( - + ) } diff --git a/packages/react/src/ReactVisual.tsx b/packages/react/src/ReactVisual.tsx index 6834e90..3247881 100644 --- a/packages/react/src/ReactVisual.tsx +++ b/packages/react/src/ReactVisual.tsx @@ -6,6 +6,7 @@ import PictureImage from './PictureImage' import { collectDataAttributes } from './lib/attributes' import { ReactVisualProps } from './types/reactVisualTypes' +import { fillStyles } from './lib/styles' export default function ReactVisual( props: ReactVisualProps @@ -24,6 +25,7 @@ export default function ReactVisual( priority, sizes, imageLoader, + videoLoader, sourceTypes, sourceMedia, paused, @@ -41,6 +43,9 @@ export default function ReactVisual( width, height, aspect, + sourceMedia, + image, + video, className, style, dataAttributes: collectDataAttributes(props), @@ -58,8 +63,9 @@ export default function ReactVisual( sourceTypes, sourceMedia, style: { // Expand to wrapper when wrapper has layout - width: expand || width || aspect ? '100%': undefined, - height: expand || height ? '100%' : undefined, + ...(aspect || expand ? fillStyles : undefined), + width: width ? '100%': undefined, + height: height ? '100%' : undefined, } }} /> } @@ -72,6 +78,8 @@ export default function ReactVisual( priority, noPoster: !!image, // Use `image` as poster frame paused, + sourceMedia, + videoLoader, }} /> }
diff --git a/packages/react/src/VisualWrapper.tsx b/packages/react/src/VisualWrapper.tsx index 98326d2..9877067 100644 --- a/packages/react/src/VisualWrapper.tsx +++ b/packages/react/src/VisualWrapper.tsx @@ -1,11 +1,32 @@ import type { CSSProperties, ReactElement } from 'react' -import { fillStyles } from './lib/styles' +import { fillStyles, cx } from './lib/styles' import { isNumeric } from './lib/values' +import type { VisualWrapperProps } from './types/visualWrapperTypes' +import type { AspectCalculator } from './types/reactVisualTypes' + +type MakeResponsiveAspectsProps = Pick & { + sourceMedia: Required['sourceMedia'] + aspectCalculator: AspectCalculator +} // Wraps media elements and applys layout and other functionality export default function VisualWrapper({ - expand, width, height, aspect, children, className, style, dataAttributes -}: any): ReactElement { + expand, width, height, + aspect, sourceMedia, image, video, + children, className, style, dataAttributes +}: VisualWrapperProps): ReactElement { + + // If aspect is a function, invoke it to determine the aspect ratio + let aspectRatio, aspectStyleTag, aspectClasses + if (typeof aspect == 'function' && sourceMedia?.length) { + ({ aspectStyleTag, aspectClasses } = makeResponsiveAspects({ + aspectCalculator: aspect, + sourceMedia, image, video + })) + console.log(aspectClasses, aspectStyleTag ) + } else aspectRatio = aspect // Make the wrapper style. If expanding, use normal fill rules. Otherwise, // apply width, height and aspect @@ -13,17 +34,62 @@ export default function VisualWrapper({ position: 'relative', // For expanded elements width: isNumeric(width) ? `${width}px` : width, height: isNumeric(height) ? `${height}px` : height, - aspectRatio: aspect, - maxWidth: '100%', // Don't exceed container width + aspectRatio, + maxWidth: '100%', // Never exceed container width } as CSSProperties // Render wrapping component return (
{ children } + { aspectStyleTag }
) } + +// Create a style tag that applies responsive aspect ratio values +function makeResponsiveAspects({ + aspectCalculator, sourceMedia, image, video +}: MakeResponsiveAspectsProps): { + aspectClasses: string + aspectStyleTag: ReactElement +} { + + // Make CSS classes and related rules that are specific to the query and + // aspect value. + const styles = sourceMedia.map(mediaQuery => { + + // Calculate the asepct for this query state + const aspect = aspectCalculator({ media: mediaQuery, image, video }) + + // Make a CSS class name from the media query string + const mediaClass = mediaQuery + .replace(/[^\w]/ig, '-') // Replace special chars with "-" + const cssClass = `rv-${mediaClass}-${aspect}` + .replace(/\-{2,}/g, '-') // Reduce multiples of `-` + + // Make the CSS rule + const cssRule = `@media ${mediaQuery} { + .${cssClass} { + aspect-ratio: ${aspect}; + } + }` + return { cssClass, cssRule} + }) + + // Make an array of the classes to add + const aspectClasses = styles.map(({ cssClass }) => cssClass).join(' ') + + // Make the style tag + const aspectStyleTag = ( + + ) + + // Return completed objects + return { aspectClasses, aspectStyleTag} +} diff --git a/packages/react/src/lib/sizes.ts b/packages/react/src/lib/sizes.ts index c088e4d..49beb64 100644 --- a/packages/react/src/lib/sizes.ts +++ b/packages/react/src/lib/sizes.ts @@ -3,7 +3,6 @@ // https://nextjs.org/docs/pages/api-reference/components/image#imagesizes export const imageSizes = [16, 32, 48, 64, 96, 128, 256, 384] - // Based on // https://nextjs.org/docs/pages/api-reference/components/image#devicesizes export const deviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840] diff --git a/packages/react/src/lib/sources.ts b/packages/react/src/lib/sources.ts new file mode 100644 index 0000000..0b9f171 --- /dev/null +++ b/packages/react/src/lib/sources.ts @@ -0,0 +1,32 @@ +import type { SourceMedia, SourceType } from '../types/reactVisualTypes' + +// Make an array of all the source variants to make +export function makeSourceVariants({ + sourceTypes, + sourceMedia, +} : { + sourceTypes?: SourceType[] + sourceMedia?: SourceMedia[] +}): { + type?: SourceType + media?: SourceMedia + key: string +}[] { + const variants = [] + + // Append an untyped fallback to serve the default format + const typesWithUntypedFallback = [...(sourceTypes || []), undefined] + + // Loop through mimeTypes and media queries and produce the source variant + // objects + for (const type of typesWithUntypedFallback) { + if (sourceMedia?.length) { + for (const media of sourceMedia) { + variants.push({ type, media, key: `${type}-${media}` }) + } + } else { + variants.push({ type, key: type || 'fallback' }) + } + } + return variants +} diff --git a/packages/react/src/lib/styles.ts b/packages/react/src/lib/styles.ts index 8aab650..42516a7 100644 --- a/packages/react/src/lib/styles.ts +++ b/packages/react/src/lib/styles.ts @@ -11,3 +11,13 @@ export const fillStyles = { // Transparent gif to use own image as poster // https://stackoverflow.com/a/13139830/59160 export const transparentGif = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + +// Combine classes +// https://dev.to/gugaguichard/replace-clsx-classnames-or-classcat-with-your-own-little-helper-3bf +export function cx(...args: unknown[]) { + return args + .flat() + .filter(x => typeof x === 'string') + .join(' ') + .trim() +} diff --git a/packages/react/src/types/lazyVideoTypes.ts b/packages/react/src/types/lazyVideoTypes.ts index 07df5c6..43ed705 100644 --- a/packages/react/src/types/lazyVideoTypes.ts +++ b/packages/react/src/types/lazyVideoTypes.ts @@ -1,21 +1,19 @@ -import type { CSSProperties } from 'react' +import { ReactVisualProps } from './reactVisualTypes' -export type LazyVideoProps = { - - // Source props - src: HTMLVideoElement['src'] - alt: string - - // Don't lazy load - priority?: boolean +export type LazyVideoProps = Pick & { + src: Required['video'] // Use a transparent gif poster image noPoster?: boolean // Controls autoplaying and play state paused?: boolean - - // Display props - fit?: CSSProperties['objectFit'] - position?: CSSProperties['objectPosition'] } diff --git a/packages/react/src/types/pictureImageTypes.ts b/packages/react/src/types/pictureImageTypes.ts index b1be789..f3f381d 100644 --- a/packages/react/src/types/pictureImageTypes.ts +++ b/packages/react/src/types/pictureImageTypes.ts @@ -1,4 +1,4 @@ -import { ReactVisualProps } from './reactVisualTypes'; +import { ReactVisualProps } from './reactVisualTypes' export type PictureImageProps = Pick string +// The callback that is used to produce video URLs +export type VideoLoader = ({ src, media }: { + src: AssetSrc + media?: SourceMedia +}) => string + +// Callback for producing the aspect ratio +export type AspectCalculator = ({ media, image, video }: { + media: SourceMedia + image?: AssetSrc + video?: AssetSrc +}) => number + export type ObjectFitOption = 'cover' | 'contain' -export type SourceType = 'image/jpeg' | 'image/png' | 'image/gif' | - 'image/avif' | 'image/webp' | string +export type SourceType = 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/avif' + | 'image/webp' + | string -export type SourceMedia = 'orientation:landscape' | 'orientation:portrait' | string +export type SourceMedia = 'orientation:landscape' + | 'orientation:portrait' + | string // Deprecated export enum ObjectFit { diff --git a/packages/react/src/types/visualWrapperTypes.ts b/packages/react/src/types/visualWrapperTypes.ts new file mode 100644 index 0000000..63f8975 --- /dev/null +++ b/packages/react/src/types/visualWrapperTypes.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import type { ReactVisualProps } from './reactVisualTypes' + +export type VisualWrapperProps = Pick & { + dataAttributes?: object + children?: ReactNode +}