From 836b6b9dbcccd89ad5805f0ebdfbeb48de8bf8e9 Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 13:22:13 -0800 Subject: [PATCH 01/10] Remove unsued var --- packages/react/cypress/component/VisualWrapper.cy.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/cypress/component/VisualWrapper.cy.tsx b/packages/react/cypress/component/VisualWrapper.cy.tsx index 671e4bd..8d3de66 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'), From 5bb073893960aadfafc7624f081638ca7328be0e Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 13:22:29 -0800 Subject: [PATCH 02/10] Try to improve native image size test stability --- packages/react/cypress/support/commands.ts | 1 + 1 file changed, 1 insertion(+) 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') From 31b1fc5da8ffe9f022086bdf38c674656ba3757e Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 14:06:48 -0800 Subject: [PATCH 03/10] Support objects for image src --- .../cypress/component/ReactVisual.cy.tsx | 72 +++++++++++++++++++ packages/react/src/PictureImage.tsx | 30 ++++++-- packages/react/src/types/reactVisualTypes.ts | 19 +++-- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/packages/react/cypress/component/ReactVisual.cy.tsx b/packages/react/cypress/component/ReactVisual.cy.tsx index c4359bb..5807bc4 100644 --- a/packages/react/cypress/component/ReactVisual.cy.tsx +++ b/packages/react/cypress/component/ReactVisual.cy.tsx @@ -176,4 +176,76 @@ describe('sources', () => { }) + it.only('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) + }} + videoLoader={({ src, type, media, width }) => { + return media?.includes('landscape') ? + src.landscape.url : src.portrait.url + }} + width='100%' + 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') + + // 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') + + }) + }) diff --git a/packages/react/src/PictureImage.tsx b/packages/react/src/PictureImage.tsx index ed7a385..1b28a29 100644 --- a/packages/react/src/PictureImage.tsx +++ b/packages/react/src/PictureImage.tsx @@ -1,12 +1,13 @@ import type { ReactElement } from 'react' import type { PictureImageProps } from './types/pictureImageTypes' -import type { ImageLoader, SourceMedia, SourceType } from './types/reactVisualTypes' +import type { AssetLoader, SourceMedia, SourceType } from './types/reactVisualTypes' import { deviceSizes, imageSizes } from './lib/sizes' type ImageSrc = PictureImageProps['src'] type SourcesProps = { widths: number[] imageLoader: Required['imageLoader'] + sizes: PictureImageProps['sizes'] src: ImageSrc type?: SourceType media?: SourceMedia @@ -46,6 +47,9 @@ export default function PictureImage( ...deviceSizes, ] + // Make the img src url + const srcUrl = makeSrcUrl(src, imageLoader) + // 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 @@ -63,19 +67,33 @@ export default function PictureImage( ))} {/* The main */} ) } +// 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: AssetLoader | undefined +): string { + if (typeof src == 'string') return src + if (!imageLoader) { + throw "An `imageLoader` is required when `src` isn't a string" + } + return imageLoader({ src, width: 1920 }) +} + // 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( @@ -101,11 +119,11 @@ function makeSourceVariants( // Make a source tag with srcset for the provided type and/or media attribute function Source({ - widths, imageLoader, src, type, media + widths, imageLoader, sizes, src, type, media }: SourcesProps): ReactElement { const srcSet = makeSrcSet(widths, imageLoader, { src, type, media }) return ( - + ) } @@ -114,7 +132,7 @@ function Source({ // function to customize the image returned. function makeSrcSet( widths: number[], - imageLoader: ImageLoader, + imageLoader: AssetLoader, params: { src: ImageSrc type?: SourceType diff --git a/packages/react/src/types/reactVisualTypes.ts b/packages/react/src/types/reactVisualTypes.ts index 5c19185..520a4de 100644 --- a/packages/react/src/types/reactVisualTypes.ts +++ b/packages/react/src/types/reactVisualTypes.ts @@ -1,9 +1,13 @@ import type { CSSProperties } from 'react' -export type ReactVisualProps = { +// Sources can be simple strings or arbitrary objects +type AssetSrc = string | any - image?: string - video?: string +// https://chat.openai.com/share/103de5c4-0af0-44c1-8f36-46743a0f964c +export type ReactVisualProps= { + + image?: AssetSrc + video?: AssetSrc expand?: boolean aspect?: number // An explict aspect ratio @@ -14,7 +18,8 @@ export type ReactVisualProps = { priority?: boolean sizes?: string - imageLoader?: ImageLoader + imageLoader?: AssetLoader + videoLoader?: AssetLoader sourceTypes?: SourceType[] sourceMedia?: SourceMedia[] @@ -26,13 +31,15 @@ export type ReactVisualProps = { style?: CSSProperties } -export type ImageLoader = ({ src, width, type, media }: { - src: string +// The callback that is used to produce asset URL strings +export type AssetLoader = ({ src, width, type, media }: { + src: AssetSrc width: number type?: SourceType media?: SourceMedia }) => string + export type ObjectFitOption = 'cover' | 'contain' export type SourceType = 'image/jpeg' | 'image/png' | 'image/gif' | From abfaaf4b78ce50f9a7f0bd32aad408540f66b4cc Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 14:29:31 -0800 Subject: [PATCH 04/10] Render default srcset on a --- .../cypress/component/ReactVisual.cy.tsx | 16 +++++++--- packages/react/src/PictureImage.tsx | 30 +++++++++---------- packages/react/src/types/reactVisualTypes.ts | 13 +++++--- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/react/cypress/component/ReactVisual.cy.tsx b/packages/react/cypress/component/ReactVisual.cy.tsx index 5807bc4..38ef35c 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,9 +179,12 @@ 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.only('supports rendering object based sources', () => { + it('supports rendering object based sources', () => { // Start at a landscape viewport cy.viewport(500, 400) diff --git a/packages/react/src/PictureImage.tsx b/packages/react/src/PictureImage.tsx index 1b28a29..d676a7e 100644 --- a/packages/react/src/PictureImage.tsx +++ b/packages/react/src/PictureImage.tsx @@ -50,12 +50,10 @@ export default function PictureImage( // Make the img src url const srcUrl = makeSrcUrl(src, imageLoader) - // 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 }) - - // Additional sources to create + // 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 @@ -75,7 +73,7 @@ export default function PictureImage( ) @@ -94,8 +92,7 @@ function makeSrcUrl( return imageLoader({ src, width: 1920 }) } -// 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. +// Make an array of all the source variants to make function makeSourceVariants( sourceTypes: SourceType[] | undefined, sourceMedia: SourceMedia[] | undefined @@ -104,14 +101,15 @@ function makeSourceVariants( media?: SourceMedia key: string }[] { + const typesWithUntypedFallback = [...(sourceTypes || []), undefined] 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 - }) + 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/types/reactVisualTypes.ts b/packages/react/src/types/reactVisualTypes.ts index 520a4de..22967c4 100644 --- a/packages/react/src/types/reactVisualTypes.ts +++ b/packages/react/src/types/reactVisualTypes.ts @@ -3,7 +3,6 @@ import type { CSSProperties } from 'react' // Sources can be simple strings or arbitrary objects type AssetSrc = string | any -// https://chat.openai.com/share/103de5c4-0af0-44c1-8f36-46743a0f964c export type ReactVisualProps= { image?: AssetSrc @@ -42,10 +41,16 @@ export type AssetLoader = ({ src, width, type, media }: { 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 { From f5f3ce6a17cd70fdedbf1b5d2e48c1bc93cd2086 Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 15:54:41 -0800 Subject: [PATCH 05/10] Adding support for videoLoader --- .../cypress/component/ReactVisual.cy.tsx | 33 ++++++++++------ packages/react/src/LazyVideo.tsx | 32 ++++++++++++++-- packages/react/src/PictureImage.tsx | 38 ++++--------------- packages/react/src/ReactVisual.tsx | 3 ++ packages/react/src/lib/sizes.ts | 1 - packages/react/src/lib/sources.ts | 32 ++++++++++++++++ packages/react/src/types/lazyVideoTypes.ts | 24 ++++++------ packages/react/src/types/pictureImageTypes.ts | 2 +- packages/react/src/types/reactVisualTypes.ts | 13 +++++-- 9 files changed, 115 insertions(+), 63 deletions(-) create mode 100644 packages/react/src/lib/sources.ts diff --git a/packages/react/cypress/component/ReactVisual.cy.tsx b/packages/react/cypress/component/ReactVisual.cy.tsx index 38ef35c..0894937 100644 --- a/packages/react/cypress/component/ReactVisual.cy.tsx +++ b/packages/react/cypress/component/ReactVisual.cy.tsx @@ -200,16 +200,16 @@ describe('sources', () => { aspect: 1, } }} - // video={{ - // landscape: { - // url: 'https://placehold.co/500x250.mp4?text=landscape+video', - // aspect: 2, - // }, - // portrait: { - // url: 'https://placehold.co/500x500.mp4?text=portrait+video', - // aspect: 1, - // } - // }} + video={{ + landscape: { + url: 'https://placehold.co/500x250.mp4?text=landscape+video', + aspect: 2, + }, + portrait: { + url: 'https://placehold.co/500x500.mp4?text=portrait+video', + aspect: 1, + } + }} sourceTypes={['image/webp', 'image/jpeg']} sourceMedia={['(orientation: landscape)', '(orientation: portrait)']} imageLoader={({ src, type, media, width }) => { @@ -232,7 +232,7 @@ describe('sources', () => { return `https://placehold.co/${dimensions}${ext}?text=`+ encodeURIComponent(text) }} - videoLoader={({ src, type, media, width }) => { + videoLoader={({ src, media }) => { return media?.includes('landscape') ? src.landscape.url : src.portrait.url }} @@ -248,12 +248,23 @@ describe('sources', () => { .should('contain', 'https://placehold.co/640x320') .should('contain', 'landscape') + // ... and a landscape video + cy.get('video').its('[0].currentSrc') + .should('contain', 'https://placehold.co/500x250.mp4') + .should('contain', 'landscape') + // 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') + // ... portrait video should also load + cy.get('video').its('[0].currentSrc') + .should('contain', 'https://placehold.co/500x500.mp4') + .should('contain', 'portrait') + + }) }) diff --git a/packages/react/src/LazyVideo.tsx b/packages/react/src/LazyVideo.tsx index 9c47932..a928293 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 +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 d676a7e..68e12a3 100644 --- a/packages/react/src/PictureImage.tsx +++ b/packages/react/src/PictureImage.tsx @@ -1,10 +1,11 @@ import type { ReactElement } from 'react' import type { PictureImageProps } from './types/pictureImageTypes' -import type { AssetLoader, SourceMedia, SourceType } from './types/reactVisualTypes' +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'] @@ -54,14 +55,14 @@ export default function PictureImage( // 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) + 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 }) => ( @@ -130,7 +108,7 @@ function Source({ // function to customize the image returned. function makeSrcSet( widths: number[], - imageLoader: AssetLoader, + imageLoader: ImageLoader, params: { src: ImageSrc type?: SourceType diff --git a/packages/react/src/ReactVisual.tsx b/packages/react/src/ReactVisual.tsx index 6834e90..94aa0e9 100644 --- a/packages/react/src/ReactVisual.tsx +++ b/packages/react/src/ReactVisual.tsx @@ -24,6 +24,7 @@ export default function ReactVisual( priority, sizes, imageLoader, + videoLoader, sourceTypes, sourceMedia, paused, @@ -72,6 +73,8 @@ export default function ReactVisual( priority, noPoster: !!image, // Use `image` as poster frame paused, + sourceMedia, + videoLoader, }} /> } 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/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 export type ObjectFitOption = 'cover' | 'contain' From 59ffc301c49111c302c4c2c74859eae12e51152e Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 15:56:40 -0800 Subject: [PATCH 06/10] Dropping tests of responsive videos See https://github.com/BKWLD/react-visual/issues/35 --- .../cypress/component/ReactVisual.cy.tsx | 25 ------------------- packages/react/src/LazyVideo.tsx | 4 ++- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/react/cypress/component/ReactVisual.cy.tsx b/packages/react/cypress/component/ReactVisual.cy.tsx index 0894937..31d8c0d 100644 --- a/packages/react/cypress/component/ReactVisual.cy.tsx +++ b/packages/react/cypress/component/ReactVisual.cy.tsx @@ -200,16 +200,6 @@ describe('sources', () => { aspect: 1, } }} - video={{ - landscape: { - url: 'https://placehold.co/500x250.mp4?text=landscape+video', - aspect: 2, - }, - portrait: { - url: 'https://placehold.co/500x500.mp4?text=portrait+video', - aspect: 1, - } - }} sourceTypes={['image/webp', 'image/jpeg']} sourceMedia={['(orientation: landscape)', '(orientation: portrait)']} imageLoader={({ src, type, media, width }) => { @@ -232,10 +222,6 @@ describe('sources', () => { return `https://placehold.co/${dimensions}${ext}?text=`+ encodeURIComponent(text) }} - videoLoader={({ src, media }) => { - return media?.includes('landscape') ? - src.landscape.url : src.portrait.url - }} width='100%' alt=''/>) @@ -248,23 +234,12 @@ describe('sources', () => { .should('contain', 'https://placehold.co/640x320') .should('contain', 'landscape') - // ... and a landscape video - cy.get('video').its('[0].currentSrc') - .should('contain', 'https://placehold.co/500x250.mp4') - .should('contain', 'landscape') - // 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') - // ... portrait video should also load - cy.get('video').its('[0].currentSrc') - .should('contain', 'https://placehold.co/500x500.mp4') - .should('contain', 'portrait') - - }) }) diff --git a/packages/react/src/LazyVideo.tsx b/packages/react/src/LazyVideo.tsx index a928293..10c2eea 100644 --- a/packages/react/src/LazyVideo.tsx +++ b/packages/react/src/LazyVideo.tsx @@ -99,7 +99,9 @@ export default function LazyVideo({ ) } -// Make a video source tag +// 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 { From d56513480b41a66dbc581d0f9f6a5b4f107c972c Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 17:21:02 -0800 Subject: [PATCH 07/10] Support responsive aspect via function --- .../cypress/component/ReactVisual.cy.tsx | 17 +++- .../cypress/component/VisualWrapper.cy.tsx | 28 +++++++ packages/react/src/ReactVisual.tsx | 9 ++- packages/react/src/VisualWrapper.tsx | 78 +++++++++++++++++-- packages/react/src/lib/styles.ts | 10 +++ packages/react/src/types/reactVisualTypes.ts | 9 ++- .../react/src/types/visualWrapperTypes.ts | 17 ++++ 7 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/types/visualWrapperTypes.ts diff --git a/packages/react/cypress/component/ReactVisual.cy.tsx b/packages/react/cypress/component/ReactVisual.cy.tsx index 31d8c0d..fdb5645 100644 --- a/packages/react/cypress/component/ReactVisual.cy.tsx +++ b/packages/react/cypress/component/ReactVisual.cy.tsx @@ -192,11 +192,11 @@ describe('sources', () => { cy.mount( { return `https://placehold.co/${dimensions}${ext}?text=`+ encodeURIComponent(text) }} - width='100%' + 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 @@ -234,12 +239,18 @@ describe('sources', () => { .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 8d3de66..a7d7827 100644 --- a/packages/react/cypress/component/VisualWrapper.cy.tsx +++ b/packages/react/cypress/component/VisualWrapper.cy.tsx @@ -45,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/src/ReactVisual.tsx b/packages/react/src/ReactVisual.tsx index 94aa0e9..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 @@ -42,6 +43,9 @@ export default function ReactVisual( width, height, aspect, + sourceMedia, + image, + video, className, style, dataAttributes: collectDataAttributes(props), @@ -59,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, } }} /> } 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/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/reactVisualTypes.ts b/packages/react/src/types/reactVisualTypes.ts index 9a13e36..e8608dd 100644 --- a/packages/react/src/types/reactVisualTypes.ts +++ b/packages/react/src/types/reactVisualTypes.ts @@ -9,7 +9,7 @@ export type ReactVisualProps= { video?: AssetSrc expand?: boolean - aspect?: number // An explict aspect ratio + aspect?: number | AspectCalculator // An explict aspect ratio width?: number | string height?: number | string fit?: ObjectFitOption | ObjectFit @@ -44,6 +44,13 @@ export type VideoLoader = ({ src, media }: { 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' 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 +} From 1f8f1df89d897a2722d71ed278abdface7e5f6ae Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 17:35:44 -0800 Subject: [PATCH 08/10] Documenting new responsive features --- packages/react/README.md | 131 ++++++++++++++---- .../cypress/component/ReactVisual.cy.tsx | 2 +- 2 files changed, 103 insertions(+), 30 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 185c91e..253b502 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,7 +42,7 @@ export default function ResponsiveExample() { return ( { const height = media?.includes('landscape') ? width * 0.5 : width @@ -58,30 +58,103 @@ 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 +165,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 fdb5645..caca052 100644 --- a/packages/react/cypress/component/ReactVisual.cy.tsx +++ b/packages/react/cypress/component/ReactVisual.cy.tsx @@ -200,7 +200,7 @@ describe('sources', () => { aspect: 1, } }} - sourceTypes={['image/webp', 'image/jpeg']} + sourceTypes={['image/webp']} sourceMedia={['(orientation: landscape)', '(orientation: portrait)']} imageLoader={({ src, type, media, width }) => { From 730a0cac3664f9b1ba73636f625274380a9b6247 Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 17:39:52 -0800 Subject: [PATCH 09/10] Updating docs since source srcsets were improved --- README.md | 20 +++++++++----------- packages/react/README.md | 10 ++++++++-- 2 files changed, 17 insertions(+), 13 deletions(-) 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 253b502..318d795 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -46,7 +46,7 @@ export default function ResponsiveExample() { 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' : '.jpg' + const ext = type?.includes('webp') ? '.webp' : '' return `https://placehold.co/${width}x${height}${ext}` }} width='100%' @@ -68,11 +68,17 @@ The above would produce: type='image/webp' media='(orientation:portrait)' srcset='https://placehold.co/640x640.webp 640w, https://placehold.co/750x750.webp 750w, https://placehold.co/828x828.webp 828w, https://placehold.co/1080x1080.webp 1080w, https://placehold.co/1200x1200.webp 1200w, https://placehold.co/1920x1920.webp 1920w, https://placehold.co/2048x2048.webp 2048w, https://placehold.co/3840x3840.webp 3840w'> + + Example of responsive images
From 4cf383ab916f211b507772ae401144cf6d6e58b5 Mon Sep 17 00:00:00 2001 From: Robert Reinhard Date: Wed, 22 Nov 2023 17:55:05 -0800 Subject: [PATCH 10/10] Fix values that were breaking markdown tables --- packages/react/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 318d795..53f9ea4 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -171,15 +171,15 @@ For more examples, read [the Cypress component tests](./cypress/component). | Prop | Type | Description | -- | -- | -- -| `image` | `string | object` | URL to an image asset. -| `video` | `string | object` | 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 | function` | 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`.