├── .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 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Demo • Join us on Slack • Login 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 |

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 |

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 |
--------------------------------------------------------------------------------