├── .babelrc ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .storybook ├── addons.js └── config.js ├── README.md ├── index.d.ts ├── package.json ├── src ├── Img.js └── index.js ├── stories ├── index.stories.js └── vinylbaseImgs.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["airbnb"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["blazity"], 3 | "rules": { 4 | "import/no-extraneous-dependencies": 0, 5 | "jsx-a11y/alt-text": 0, 6 | "react/no-danger": 0, 7 | "react/forbid-prop-types": 0, 8 | "no-unused-expressions": 0 9 | }, 10 | "overrides": [ 11 | { 12 | "files": "**/*.stories.js", 13 | "rules": { 14 | "import/extensions": 0, 15 | "import/no-extraneous-dependencies": 0, 16 | "import/no-unresolved": 0 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - next 7 | jobs: 8 | release: 9 | name: Build & Release 10 | runs-on: ubuntu-18.04 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 14 18 | - name: Install dependencies 19 | run: yarn 20 | - name: Build 21 | run: yarn build 22 | - name: Release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | run: npx semantic-release 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /*.js 3 | /*.js.map 4 | storybook-static -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .storybook 3 | stories -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context('../stories', true, /.stories.js$/); 5 | function loadStories() { 6 | req.keys().forEach((filename) => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

@graphcms/react-image

2 | 3 |

Universal lazy-loading, auto-compressed images with React and Hygraph.

4 | 5 |

6 | 7 | Version 8 | 9 | 10 | Downloads/week 11 | 12 | 13 | Forks on GitHub 14 | 15 | minified + gzip size 16 |
17 | DemoJoin us on SlackLogin to Hygraph@hygraphcom 18 |

19 | 20 | * Resize large images to the size needed by your design. 21 | * Generate multiple smaller images to make sure devices download the optimal-sized one. 22 | * Automatically compress and optimize your image with the powerful Filestack API. 23 | * Efficiently lazy load images to speed initial page load and save bandwidth. 24 | * Use the "blur-up" technique or solid background color to show a preview of the image while it loads. 25 | * Hold the image position so your page doesn't jump while images load. 26 | 27 | ## Quickstart 28 | 29 | Here's an example using a static asset object. 30 | 31 | ```jsx 32 | import React from "react"; 33 | import Image from "@graphcms/react-image"; 34 | 35 | const IndexPage = () => { 36 | const asset = { 37 | handle: "uQrLj1QRWKJnlQv1sEmC", 38 | width: 800, 39 | height: 800 40 | } 41 | 42 | return 43 | } 44 | ``` 45 | 46 | ## Install 47 | 48 | ```bash 49 | npm install @graphcms/react-image 50 | ``` 51 | 52 | ## Props 53 | 54 | | Name | Type | Description | 55 | | ----------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 56 | | `image` | `object` | An object of shape `{ handle, width, height }`. Handle is an identifier required to display the image and both `width` and `height` are required to display a correct placeholder and aspect ratio for the image. You can get all 3 by just putting all 3 in your image-getting query. | 57 | | `maxWidth` | `number` | Maximum width you'd like your image to take up. (ex. If your image container is resizing dynamically up to a width of 1200, put it as a `maxWidth`) | 58 | | `fadeIn` | `bool` | Do you want your image to fade in on load? Defaults to `true` | 59 | | `fit` | `"clip"\|"crop"\|"scale"\|"max"` | When resizing the image, how would you like it to fit the new dimensions? Defaults to `crop`. You can read more about resizing [here](https://www.filestack.com/docs/api/processing/#resize) | 60 | | `withWebp` | `bool` | If webp is supported by the browser, the images will be served with `.webp` extension. (Recommended) | 61 | | `transforms` | `array` | Array of `string`s, each representing a separate Filestack transform, eg. `['sharpen=amount:5', 'quality=value:75']` | 62 | | `title` | `string` | Passed to the `img` element | 63 | | `alt` | `string` | Passed to the `img` element | 64 | | `className` | `string\|object` | Passed to the wrapper div. Object is needed to support Glamor's css prop | 65 | | `outerWrapperClassName` | `string\|object` | Passed to the outer wrapper div. Object is needed to support Glamor's css prop | 66 | | `style` | `object` | Spread into the default styles in the wrapper div | 67 | | `position` | `string` | Defaults to `relative`. Pass in `absolute` to make the component `absolute` positioned | 68 | | `blurryPlaceholder` | `bool` | Would you like to display a blurry placeholder for your loading image? Defaults to `true`. | 69 | | `backgroundColor` | `string\|bool` | Set a colored background placeholder. If true, uses "lightgray" for the color. You can also pass in any valid color string. | 70 | | `onLoad` | `func` | A callback that is called when the full-size image has loaded. | 71 | | `baseURI` | `string` | Set the base src from where the images are requested. Base URI Defaults to `https://media.graphassets.com` | 72 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | type ClassName = string | Record 4 | 5 | export interface GraphImageProp { 6 | handle: string 7 | height: number 8 | width: number 9 | } 10 | 11 | export interface GraphImageProps { 12 | /** 13 | * Passed to the img element 14 | */ 15 | title?: string 16 | /** 17 | * Passed to the img element 18 | */ 19 | alt?: string 20 | /** 21 | * Passed to the wrapper `div`. Object is needed to support Glamor's css prop 22 | */ 23 | className?: ClassName 24 | /** 25 | * Passed to the outer wrapper `div`. Object is needed to support Glamor's css prop 26 | */ 27 | outerWrapperClassName?: ClassName 28 | /** 29 | * Spread into the default styles in the wrapper `div` 30 | */ 31 | style?: React.CSSProperties 32 | /** 33 | * An object of shape `{ handle, width, height }`. Handle is an identifier required to display the image 34 | * and both `width` and `height` are required to display a correct placeholder and aspect ratio for the image. 35 | * You can get all 3 by just putting all 3 in your image-getting query. 36 | */ 37 | image: GraphImageProp 38 | /** 39 | * When resizing the image, how would you like it to fit the new dimensions? 40 | * Defaults to crop. You can read more about resizing [here](https://www.filestack.com/docs/image-transformations/resize) 41 | */ 42 | fit?: 'clip' | 'crop' | 'scale' | 'max' 43 | /** 44 | * Maximum width you'd like your image to take up. 45 | * (ex. If your image container is resizing dynamically up to a width of 1200, put it as a `maxWidth`) 46 | */ 47 | maxWidth?: number 48 | /** 49 | * If webp is supported by the browser, the images will be served with `.webp` extension. (Recommended) 50 | */ 51 | withWebp?: boolean 52 | /** 53 | * Array of `string`s, each representing a separate Filestack transform, 54 | * eg. `['sharpen=amount:5', 'quality=value:75']` 55 | */ 56 | transforms?: string[] 57 | /** 58 | * A callback that is called when the full-size image has loaded. 59 | */ 60 | onLoad?: () => void 61 | /** 62 | * Would you like to display a blurry placeholder for your loading image? Defaults to `true`. 63 | */ 64 | blurryPlaceholder?: boolean 65 | /** 66 | * Set a colored background placeholder. If true, uses "lightgray" for the color. 67 | * You can also pass in any valid color string. 68 | */ 69 | backgroundColor?: string | boolean 70 | /** 71 | * Do you want your image to fade in on load? Defaults to `true` 72 | */ 73 | fadeIn?: boolean 74 | /** 75 | * Set the base src from where the images are requested. 76 | * Base URI Defaults to `https://media.graphassets.com` 77 | */ 78 | baseURI?: string 79 | } 80 | 81 | export default class GraphImage extends React.Component {} 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphcms/react-image", 3 | "version": "0.0.0-semantically-released", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "author": "Hygraph", 7 | "contributors": [ 8 | "Hugo Meissner " 9 | ], 10 | "scripts": { 11 | "build": "babel src --out-dir ./ --source-maps", 12 | "storybook": "start-storybook -p 6006", 13 | "build-storybook": "build-storybook", 14 | "prepare": "npm run build" 15 | }, 16 | "release": { 17 | "branches": [ 18 | "main" 19 | ], 20 | "plugins": [ 21 | "@semantic-release/commit-analyzer", 22 | "@semantic-release/release-notes-generator", 23 | "@semantic-release/npm", 24 | "@semantic-release/github" 25 | ] 26 | }, 27 | "devDependencies": { 28 | "@storybook/addon-actions": "^3.3.6", 29 | "@storybook/addon-links": "^3.3.6", 30 | "@storybook/react": "^3.3.6", 31 | "babel-cli": "^6.26.0", 32 | "babel-core": "^6.26.0", 33 | "babel-preset-airbnb": "^2.4.0", 34 | "eslint": "^4.14.0", 35 | "eslint-config-blazity": "^1.0.4", 36 | "prettier": "^2.2.1", 37 | "react": "^16.2.0", 38 | "react-dom": "^16.2.0" 39 | }, 40 | "peerDependencies": { 41 | "react": "^16.x.x || ^15.6.0", 42 | "react-dom": "^16.x.x || ^15.6.0" 43 | }, 44 | "dependencies": { 45 | "intersection-observer": "^0.5.0", 46 | "prop-types": "^15.6.0" 47 | }, 48 | "publishConfig": { 49 | "access": "public" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Img.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Img = props => { 5 | const { opacity, onLoad, transitionDelay, ...otherProps } = props 6 | return ( 7 | 23 | ) 24 | } 25 | 26 | Img.defaultProps = { 27 | transitionDelay: '', 28 | onLoad: null 29 | } 30 | 31 | Img.propTypes = { 32 | opacity: PropTypes.oneOf([0, 1]).isRequired, 33 | transitionDelay: PropTypes.string, 34 | onLoad: PropTypes.func 35 | } 36 | 37 | export default Img 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Img from './Img' 4 | 5 | if (typeof window !== 'undefined') { 6 | require('intersection-observer') 7 | } 8 | 9 | // Cache if we've intersected an image before so we don't 10 | // lazy-load & fade in on subsequent mounts. 11 | const imageCache = {} 12 | const inImageCache = ({ handle }, shouldCache) => { 13 | if (imageCache[handle]) { 14 | return true 15 | } 16 | if (shouldCache) { 17 | imageCache[handle] = true 18 | } 19 | return false 20 | } 21 | 22 | // Add IntersectionObserver to component 23 | const listeners = [] 24 | let io 25 | const getIO = () => { 26 | if (typeof io === 'undefined' && typeof window !== 'undefined') { 27 | io = new IntersectionObserver( 28 | entries => { 29 | entries.forEach(entry => { 30 | listeners.forEach(listener => { 31 | if (listener[0] === entry.target) { 32 | // Edge doesn't currently support isIntersecting, so also test for an intersectionRatio > 0 33 | if (entry.isIntersecting || entry.intersectionRatio > 0) { 34 | // when we intersect we cache the intersecting image for subsequent mounts 35 | io.unobserve(listener[0]) 36 | listener[1]() 37 | } 38 | } 39 | }) 40 | }) 41 | }, 42 | { rootMargin: '200px' } 43 | ) 44 | } 45 | 46 | return io 47 | } 48 | const listenToIntersections = (element, callback) => { 49 | getIO().observe(element) 50 | listeners.push([element, callback]) 51 | } 52 | 53 | const bgColor = backgroundColor => 54 | typeof backgroundColor === 'boolean' ? 'lightgray' : backgroundColor 55 | 56 | // We always keep the resize transform to have matching sizes + aspect ratio 57 | // If used with native height & width from Hygraph it produces no transform 58 | const resizeImage = ({ width, height, fit }) => 59 | `resize=w:${width},h:${height},fit:${fit}` 60 | 61 | // Filestack supports serving modern formats (like WebP) for supported browsers. 62 | // See: https://www.filestack.com/docs/api/processing/#auto-image-conversion 63 | const compressAndWebp = webp => `${webp ? 'auto_image/' : ''}compress` 64 | 65 | const constructURL = (handle, withWebp, baseURI) => resize => transforms => 66 | [baseURI, resize, ...transforms, compressAndWebp(withWebp), handle].join('/') 67 | 68 | // responsiveness transforms 69 | const responsiveSizes = size => [ 70 | size / 4, 71 | size / 2, 72 | size, 73 | size * 1.5, 74 | size * 2, 75 | size * 3 76 | ] 77 | 78 | const getWidths = (width, maxWidth) => { 79 | const sizes = responsiveSizes(maxWidth).filter(size => size < width) 80 | // Add the original width to ensure the largest image possible 81 | // is available for small images. 82 | const finalSizes = [...sizes, width] 83 | return finalSizes 84 | } 85 | 86 | const srcSet = (srcBase, srcWidths, fit, transforms) => 87 | srcWidths 88 | .map( 89 | width => 90 | `${srcBase([`resize=w:${Math.floor(width)},fit:${fit}`])( 91 | transforms 92 | )} ${Math.floor(width)}w` 93 | ) 94 | .join(',\n') 95 | 96 | const imgSizes = maxWidth => `(max-width: ${maxWidth}px) 100vw, ${maxWidth}px` 97 | 98 | class GraphImage extends React.Component { 99 | constructor(props) { 100 | super(props) 101 | 102 | let isVisible = true 103 | let imgLoaded = true 104 | let IOSupported = false 105 | 106 | const seenBefore = inImageCache(props) 107 | 108 | if ( 109 | !seenBefore && 110 | typeof window !== 'undefined' && 111 | window.IntersectionObserver 112 | ) { 113 | isVisible = false 114 | imgLoaded = false 115 | IOSupported = true 116 | } 117 | 118 | // Never render image while server rendering 119 | if (typeof window === 'undefined') { 120 | isVisible = false 121 | imgLoaded = false 122 | } 123 | 124 | this.state = { 125 | isVisible, 126 | imgLoaded, 127 | IOSupported 128 | } 129 | 130 | this.handleRef = this.handleRef.bind(this) 131 | this.onImageLoaded = this.onImageLoaded.bind(this) 132 | } 133 | 134 | onImageLoaded() { 135 | if (this.state.IOSupported) { 136 | this.setState( 137 | () => ({ 138 | imgLoaded: true 139 | }), 140 | () => { 141 | inImageCache(this.props.image, true) 142 | } 143 | ) 144 | } 145 | if (this.props.onLoad) { 146 | this.props.onLoad() 147 | } 148 | } 149 | 150 | handleRef(ref) { 151 | if (this.state.IOSupported && ref) { 152 | listenToIntersections(ref, () => { 153 | this.setState({ isVisible: true, imgLoaded: false }) 154 | }) 155 | } 156 | } 157 | 158 | render() { 159 | const { 160 | title, 161 | alt, 162 | className, 163 | outerWrapperClassName, 164 | style, 165 | image: { width, height, handle }, 166 | fit, 167 | maxWidth, 168 | withWebp, 169 | transforms, 170 | blurryPlaceholder, 171 | backgroundColor, 172 | fadeIn, 173 | baseURI 174 | } = this.props 175 | 176 | if (width && height && handle) { 177 | // unify after webp + blur resolved 178 | const srcBase = constructURL(handle, withWebp, baseURI) 179 | const thumbBase = constructURL(handle, false, baseURI) 180 | 181 | // construct the final image url 182 | const sizedSrc = srcBase(resizeImage({ width, height, fit })) 183 | const finalSrc = sizedSrc(transforms) 184 | 185 | // construct blurry placeholder url 186 | const thumbSize = { width: 20, height: 20, fit: 'crop' } 187 | const thumbSrc = thumbBase(resizeImage(thumbSize))(['blur=amount:2']) 188 | 189 | // construct srcSet if maxWidth provided 190 | const srcSetImgs = srcSet( 191 | srcBase, 192 | getWidths(width, maxWidth), 193 | fit, 194 | transforms 195 | ) 196 | const sizes = imgSizes(maxWidth) 197 | 198 | // The outer div is necessary to reset the z-index to 0. 199 | return ( 200 |
208 |
218 | {/* Preserve the aspect ratio. */} 219 |
225 | 226 | {/* Show the blurry thumbnail image. */} 227 | {blurryPlaceholder && ( 228 | {alt} 235 | )} 236 | 237 | {/* Show a solid background color. */} 238 | {backgroundColor && ( 239 |
252 | )} 253 | 254 | {/* Once the image is visible, start downloading the image */} 255 | {this.state.isVisible && ( 256 | {alt} 265 | )} 266 |
267 |
268 | ) 269 | } 270 | 271 | return null 272 | } 273 | } 274 | 275 | GraphImage.defaultProps = { 276 | title: '', 277 | alt: '', 278 | className: '', 279 | outerWrapperClassName: '', 280 | style: {}, 281 | fit: 'crop', 282 | maxWidth: 800, 283 | withWebp: true, 284 | transforms: [], 285 | blurryPlaceholder: true, 286 | backgroundColor: '', 287 | fadeIn: true, 288 | onLoad: null, 289 | baseURI: 'https://media.graphassets.com' 290 | } 291 | 292 | GraphImage.propTypes = { 293 | title: PropTypes.string, 294 | alt: PropTypes.string, 295 | // Support Glamor's css prop for classname 296 | className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), 297 | outerWrapperClassName: PropTypes.oneOfType([ 298 | PropTypes.string, 299 | PropTypes.object 300 | ]), 301 | style: PropTypes.object, 302 | image: PropTypes.shape({ 303 | handle: PropTypes.string, 304 | height: PropTypes.number, 305 | width: PropTypes.number 306 | }).isRequired, 307 | fit: PropTypes.oneOf(['clip', 'crop', 'scale', 'max']), 308 | maxWidth: PropTypes.number, 309 | withWebp: PropTypes.bool, 310 | transforms: PropTypes.arrayOf(PropTypes.string), 311 | onLoad: PropTypes.func, 312 | blurryPlaceholder: PropTypes.bool, 313 | backgroundColor: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 314 | fadeIn: PropTypes.bool, 315 | baseURI: PropTypes.string 316 | } 317 | 318 | export default GraphImage 319 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | 4 | import GraphImage from '../src' 5 | import vinylbaseImgs from './vinylbaseImgs' 6 | 7 | storiesOf('Image', module).add('image', () => ( 8 |
16 | {vinylbaseImgs.map(image => ( 17 | 33 | ))} 34 |
35 | )) 36 | 37 | storiesOf('Cache', module).add('cache', () => ( 38 |

39 | This story exist only for the purpose of showing you that going back and 40 | forth between it and the Image will not trigger reloading images with blur 41 | up if they have already been seen in the viewport 42 |

43 | )) 44 | -------------------------------------------------------------------------------- /stories/vinylbaseImgs.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | height: 1280, 4 | width: 1920, 5 | handle: '2D1bXQZLTiGY6Uz8LUqB' 6 | }, 7 | { 8 | height: 3648, 9 | width: 4668, 10 | handle: 'kgxzNRIIQUanwoBugK5O' 11 | }, 12 | { 13 | height: 643, 14 | width: 900, 15 | handle: 'VUq7qyCwTJO0IJIg2jue' 16 | }, 17 | { 18 | height: 1051, 19 | width: 760, 20 | handle: 'qmQQ5LjQTaeaV4dws5Yd' 21 | }, 22 | { 23 | height: 678, 24 | width: 1024, 25 | handle: 'kKrlEYHfSom2KbUuDeiw' 26 | }, 27 | { 28 | height: 2324, 29 | width: 4132, 30 | handle: 'Z4F6bgdCR5A7ZVPdrxAt' 31 | }, 32 | { 33 | height: 853, 34 | width: 1280, 35 | handle: 'lN2YLfrdRWaljuodxGgF' 36 | }, 37 | { 38 | height: 853, 39 | width: 1280, 40 | handle: 'b2sn5DePTFqcPXjki3LA' 41 | }, 42 | { 43 | height: 1253, 44 | width: 1920, 45 | handle: 'Ducs8STVFnKARmynogjB' 46 | }, 47 | { 48 | height: 1919, 49 | width: 1280, 50 | handle: 'nuIwV79JQvOLCORW3Ihh' 51 | }, 52 | { 53 | height: 853, 54 | width: 1280, 55 | handle: 'PiLkIXwrSVWFIuTJHUaj' 56 | }, 57 | { 58 | height: 853, 59 | width: 1280, 60 | handle: 'ghWMqyD9RLCdYjd1hWsM' 61 | }, 62 | { 63 | height: 1440, 64 | width: 1920, 65 | handle: 'AzYrkR9HTSGOGLTZsRGv' 66 | }, 67 | { 68 | height: 853, 69 | width: 1280, 70 | handle: 'C3LgXPmaQGKcCtpDQE7O' 71 | }, 72 | { 73 | height: 720, 74 | width: 1280, 75 | handle: 'NKL9Dwt8SCCxGv9rPjlK' 76 | }, 77 | { 78 | height: 847, 79 | width: 1280, 80 | handle: 'I3LiBEqlSFaHWZJUslLQ' 81 | }, 82 | { 83 | height: 853, 84 | width: 1280, 85 | handle: 'MtF5PNN0QCWl2kAx3NzW' 86 | }, 87 | { 88 | height: 1920, 89 | width: 1280, 90 | handle: 'XacKGYYQSCqrhpMVwdc5' 91 | }, 92 | { 93 | height: 1300, 94 | width: 1920, 95 | handle: 'uJvoWbdQTsG7vwpDkDJz' 96 | }, 97 | { 98 | height: 853, 99 | width: 1280, 100 | handle: 'jECsR7xhQAufnTicbZ3h' 101 | }, 102 | { 103 | height: 960, 104 | width: 1280, 105 | handle: 'IbzauVP0Qhm2uGHHiuHk' 106 | }, 107 | { 108 | height: 853, 109 | width: 1280, 110 | handle: '85KjTIxgSnWmeORvGDLF' 111 | }, 112 | { 113 | height: 826, 114 | width: 1280, 115 | handle: 'D9urIavbQu63X2bPvtrc' 116 | }, 117 | { 118 | height: 853, 119 | width: 1280, 120 | handle: 'FfCOR3KqTA2gnsZ87PEg' 121 | }, 122 | { 123 | height: 829, 124 | width: 1280, 125 | handle: 'oegJr5bSk685KtsHkm1Q' 126 | } 127 | ] 128 | --------------------------------------------------------------------------------