├── .gitignore ├── index.ts ├── renovate.json ├── src ├── transform-img │ ├── index.ts │ ├── README.md │ ├── scale-animation.ts │ ├── crop-position-animation.ts │ ├── translate-animation.ts │ ├── position-animation.ts │ ├── crop-animation.ts │ └── transform-img.ts ├── size.ts ├── testing │ ├── utils.ts │ ├── test-animation-test-controller.ts │ └── animation-test-controller.ts ├── positioned-container.ts ├── bezier-curve-utils.ts ├── object-position.ts ├── test-positioned-container.ts ├── intermediate-img.ts ├── test-object-position.ts ├── img-dimensions.ts ├── test-img-dimensions.ts └── test-intermediate-img.ts ├── CODE_OF_CONDUCT.md ├── docs ├── demo │ ├── pan │ │ ├── boats.jpg │ │ ├── README.md │ │ ├── index.css │ │ ├── index.js │ │ └── index.html │ ├── gallery │ │ ├── 01.jpg │ │ ├── 02.jpg │ │ ├── 03.jpg │ │ ├── 04.jpg │ │ ├── 05.jpg │ │ ├── 06.jpg │ │ ├── 01_small.jpg │ │ ├── 02_small.jpg │ │ ├── 03_small.jpg │ │ ├── 04_small.jpg │ │ ├── 05_small.jpg │ │ ├── 06_small.jpg │ │ ├── README.md │ │ ├── index.html │ │ ├── index.css │ │ └── index.js │ ├── hero │ │ ├── boats.jpg │ │ ├── README.md │ │ ├── index.css │ │ ├── index.js │ │ └── index.html │ ├── expand │ │ ├── boats.jpg │ │ ├── README.md │ │ ├── index.css │ │ ├── index.js │ │ └── index.html │ ├── lightbox │ │ ├── boats.jpg │ │ ├── bottle.jpg │ │ ├── README.md │ │ ├── index.css │ │ ├── index.js │ │ └── index.html │ └── zoom-crop │ │ ├── boats.jpg │ │ ├── README.md │ │ ├── index.css │ │ ├── index.js │ │ └── index.html ├── animation-test-controller.md ├── tools │ └── img-cropper │ │ ├── dropper.js │ │ ├── index.css │ │ ├── index.html │ │ ├── generate-markup.js │ │ ├── index.js │ │ └── dragger.js └── prepare-image-animation.md ├── compile ├── index.js ├── externs.js └── remove-empty-space.js ├── .github └── workflows │ └── karma.yml ├── tsconfig.json ├── README.md ├── CONTRIBUTING.md ├── karma.conf.js ├── rollup.config.js ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | package-lock.json -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export {prepareImageAnimation} from './src/transform-img/index.js'; -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/transform-img/index.ts: -------------------------------------------------------------------------------- 1 | export {prepareImageAnimation} from './transform-img.js'; -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | See https://github.com/ampproject/meta/blob/master/CODE_OF_CONDUCT.md 2 | -------------------------------------------------------------------------------- /docs/demo/pan/boats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/pan/boats.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/01.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/02.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/03.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/04.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/05.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/06.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/06.jpg -------------------------------------------------------------------------------- /docs/demo/hero/boats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/hero/boats.jpg -------------------------------------------------------------------------------- /docs/demo/expand/boats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/expand/boats.jpg -------------------------------------------------------------------------------- /docs/demo/lightbox/boats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/lightbox/boats.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/01_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/01_small.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/02_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/02_small.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/03_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/03_small.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/04_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/04_small.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/05_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/05_small.jpg -------------------------------------------------------------------------------- /docs/demo/gallery/06_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/gallery/06_small.jpg -------------------------------------------------------------------------------- /docs/demo/lightbox/bottle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/lightbox/bottle.jpg -------------------------------------------------------------------------------- /docs/demo/zoom-crop/boats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ampproject/animations/HEAD/docs/demo/zoom-crop/boats.jpg -------------------------------------------------------------------------------- /compile/index.js: -------------------------------------------------------------------------------- 1 | goog.module('global-entry-point'); 2 | 3 | const {prepareImageAnimation} = goog.require('index'); 4 | 5 | window['prepareImageAnimation'] = prepareImageAnimation; 6 | 7 | -------------------------------------------------------------------------------- /docs/demo/zoom-crop/README.md: -------------------------------------------------------------------------------- 1 | Like the hero demo, but starting zoomed in on part of the image. This uses an outer cropping container to zoom in on part of the image. 2 | 3 | See the [image crop calculator](../../tools/img-cropper) to generate the markup needed for the initial crop. 4 | -------------------------------------------------------------------------------- /docs/demo/pan/README.md: -------------------------------------------------------------------------------- 1 | A simple back and forth in place panning animation using `prepareImageAnimation`. This uses `object-position` to declare what position the animation should start/end at. This demo simply sets up an animation with infinite iterations that alternates directions. Since the demo continually plays the animation, it never calls `cleanupAnimation`. -------------------------------------------------------------------------------- /.github/workflows/karma.yml: -------------------------------------------------------------------------------- 1 | name: Karma 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [12.x] 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - run: yarn install 18 | - run: yarn test-action -------------------------------------------------------------------------------- /docs/demo/hero/README.md: -------------------------------------------------------------------------------- 1 | A demo of a hero image transition using `prepareImageAnimation`. This demo is 2 | done using pages with `position: absolute` for simplicity. Swapping in pages 3 | into the body requires more work for making sure the scroll positions are 4 | maintained correctly. 5 | 6 | The image animation in this demo is done within the target page. This ensures 7 | that the image animation works correctly even if the user scrolls the page 8 | during the animation. The animation duration is longer than what you might 9 | expect so that you can try out this functionality. -------------------------------------------------------------------------------- /src/size.ts: -------------------------------------------------------------------------------- 1 | export interface Scale { 2 | x: number, 3 | y: number, 4 | } 5 | 6 | export interface Size { 7 | width: number, 8 | height: number, 9 | } 10 | 11 | /** 12 | * @param s1 The numerator Size. 13 | * @param s2 The denominator Size. 14 | * @return A Scale with the x factor being equal to the width of s1 / width of 15 | * s2 and the y factor being equal to the height of s1 / height of s2. 16 | */ 17 | export function divideSizes(s1: Size, s2: Size): Scale { 18 | return { 19 | x: s1.width / s2.width, 20 | y: s1.height / s2.height, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /docs/demo/expand/README.md: -------------------------------------------------------------------------------- 1 | A demo of an inline expansion of an image using `prepareImageAnimation`. This 2 | demo uses various value for `object-position` to show how you can position the 3 | rendered images and how they look when animated. 4 | 5 | Note: The demo's collapse animation has a a few problems if done while the 6 | bottom of the body is visible in the viewport. The animation may be janky in 7 | Chrome. On other browsers, the page scroll position will jump at the end of the 8 | animation. You should ideally leave enough space below the last image such that 9 | you do not encounter this behavior. -------------------------------------------------------------------------------- /docs/demo/gallery/README.md: -------------------------------------------------------------------------------- 1 | A demo of an image gallery with transitions using `prepareImageAnimation`. 2 | A single image is used for the larger state, with the srcset switched out when 3 | the image animation has completed. 4 | 5 | The smaller images use a smaller `sizes` attribute, resulting in a lower 6 | resolution image. When you click on one of the smaller images, the larger 7 | version is preloaded while the transition is in process. Once the transition 8 | is complete, the larger `sizes` attribute is applied. As a result, the browser 9 | will update the `` once the larger src is finished downloading. 10 | -------------------------------------------------------------------------------- /docs/demo/lightbox/README.md: -------------------------------------------------------------------------------- 1 | Uses `prepareImageAnimation` to do a lightbox transition for images. Since the 2 | animation is a CSS based animation, you can have it run in sync with other CSS 3 | based animations (in this case a fade in/out of the lightbox background). 4 | 5 | The animation here has one slight workaround: it uses a fixed position container 6 | when the body does not have a `scrollHeight` > `window.innerHeight` (e.g. not 7 | currently scrolling). This is to prevent the animation causing overflow (and 8 | thus scrollbars showing up), which can can be a big problem on desktops (if the 9 | scrollbar is not overlaid on top of the content). 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "build", 5 | "allowSyntheticDefaultImports": false, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "declaration": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noEmitOnError": true, 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "pretty": true, 15 | "strict": true, 16 | "module": "commonjs", 17 | "target": "es2015", 18 | "lib": ["es2015", "dom"], 19 | "sourceMap": true 20 | }, 21 | "include": [ 22 | "index.ts", 23 | "src/**/*.ts", 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /compile/externs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {{ 3 | * x1: number, 4 | * y1: number, 5 | * x2: number, 6 | * y2: number, 7 | * }} 8 | */ 9 | let Curve; 10 | 11 | /** 12 | * @param {{ 13 | * transitionContainer: HTMLElement, 14 | * styleContainer: HTMLElement, 15 | * srcImg: HTMLImageElement, 16 | * targetImg: HTMLImageElement, 17 | * srcImgRect: ClientRect, 18 | * srcCropRect: ClientRect, 19 | * targetImgRect: ClientRect, 20 | * targetCropRect: ClientRect, 21 | * curve: Curve, 22 | * styles: Object, 23 | * keyframesNamespace: string, 24 | * }} options 25 | * @return {{ 26 | * applyAnimation: function(), 27 | * cleanupAnimation: function(), 28 | * }} 29 | */ 30 | window.prepareImageAnimation = function(options) {} 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Animations 2 | 3 | Some JavaScript animation helpers. 4 | 5 | ## Links 6 | 7 | * [Documentation](https://ampproject.github.io/animations/) 8 | 9 | ## Image Transform 10 | 11 | Transforms an image from one position/size to another. In addition to scaling 12 | up, this also supports changing the 'crop' of the image as defined by the 13 | `object-fit` CSS property. 14 | 15 | ## Animation Test Helpers 16 | 17 | Helps with writing tests for animations by pausing then and allowing control of 18 | the progress of animations on the page. You can pause an animation part way 19 | through and do a screenshot based test or simply validate the position or 20 | dimensions of elements. 21 | 22 | ## Developing 23 | 24 | ### Build 25 | 26 | ```shell 27 | yarn build 28 | yarn build-watch 29 | ``` 30 | 31 | ### Test 32 | 33 | ```shell 34 | yarn test 35 | yarn test-watch 36 | ``` 37 | 38 | ### Demos 39 | 40 | To build, serve, and open a browser tab with the demos: 41 | 42 | ```shell 43 | yarn demo 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/demo/pan/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | body { 18 | /* Make it so we can vertically scroll the demo */ 19 | min-height: 140vh; 20 | } 21 | 22 | figure { 23 | max-width: 400px; 24 | margin: auto; 25 | } 26 | 27 | .panorama { 28 | position: relative; 29 | height: 100px; 30 | max-width: 400px; 31 | } 32 | 33 | .panorama-start, 34 | .panorama-end { 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | width: 100%; 39 | height: 100%; 40 | object-fit: cover; 41 | } 42 | 43 | .panorama-start { 44 | object-position: top left; 45 | } 46 | 47 | .panorama-end { 48 | object-position: top right; 49 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | The AMP Project strives for a positive and growing project community that 28 | provides a safe environment for everyone. All members, committers and 29 | volunteers in the community are required to act according to the [code of 30 | conduct](CODE_OF_CONDUCT.md). 31 | -------------------------------------------------------------------------------- /src/testing/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @param img An img to wait for. 19 | * @return A Promise that resolves once the image has loaded. 20 | */ 21 | export async function imgLoadPromise(img: HTMLImageElement): Promise { 22 | if (img.complete) { 23 | return; 24 | } 25 | 26 | return new Promise((resolve, reject) => { 27 | function finish() { 28 | img.removeEventListener('load', load); 29 | img.removeEventListener('error', error); 30 | } 31 | 32 | function load() { 33 | finish(); 34 | resolve(); 35 | } 36 | 37 | function error() { 38 | finish(); 39 | reject(); 40 | } 41 | 42 | img.addEventListener('load', load); 43 | img.addEventListener('error', error); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = function (config) { 18 | config.set({ 19 | frameworks: ['mocha', 'chai', 'karma-typescript'], 20 | files: [ 21 | 'src/**/*.ts', 22 | ], 23 | preprocessors: { 24 | 'src/**/*.ts': ['karma-typescript'], 25 | }, 26 | karmaTypescriptConfig: { 27 | tsconfig: './tsconfig.json', 28 | compilerOptions: { 29 | module: 'commonjs', 30 | }, 31 | coverageOptions: {exclude: /.*/}, 32 | }, 33 | reporters: ['progress'], 34 | port: 9876, // karma web server port 35 | colors: true, 36 | logLevel: config.LOG_INFO, 37 | browsers: ['Chrome', 'Firefox'], 38 | autoWatch: false, 39 | concurrency: Infinity, 40 | customLaunchers: { 41 | FirefoxHeadless: { 42 | base: 'Firefox', 43 | flags: ['-headless'], 44 | }, 45 | }, 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /docs/demo/pan/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 18 | 19 | const srcImg = document.querySelector('.panorama-start'); 20 | const targetImg = document.querySelector('.panorama-end'); 21 | const duration = 4000; 22 | const curve = {x1: 0.42, y1: 0, x2: 0.58, y2: 1}; 23 | const styles = { 24 | animationDuration: `${duration}ms`, 25 | animationIterationCount: 'infinite', 26 | animationDirection: 'alternate', 27 | }; 28 | 29 | const { 30 | applyAnimation, 31 | } = prepareImageAnimation({ 32 | srcImg, 33 | targetImg, 34 | styles, 35 | curve, 36 | }); 37 | 38 | // Hide the original images, since we will always just play the animation back 39 | // and forth. Not really necessary since we are stacked on top of them. 40 | srcImg.hidden = true; 41 | targetImg.hidden = true; 42 | 43 | // Since we are just leaving this to bounce back and forth, we never call 44 | // cleanup. 45 | applyAnimation(); 46 | -------------------------------------------------------------------------------- /docs/demo/expand/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | html { 18 | overflow-x: hidden; 19 | } 20 | 21 | .page { 22 | overflow-anchor: none; 23 | height: 100%; 24 | max-width: 30em; 25 | margin: auto; 26 | padding: 12px; 27 | } 28 | 29 | .img-container { 30 | position: relative; 31 | height: 96px; 32 | margin: 16px 0; 33 | } 34 | 35 | .large, 36 | .small, 37 | .text { 38 | position: absolute; 39 | } 40 | 41 | .small { 42 | object-fit: cover; 43 | width: 96px; 44 | height: 96px; 45 | } 46 | 47 | .large { 48 | max-width: 100%; 49 | object-fit: cover; 50 | } 51 | 52 | .text { 53 | top: 0; 54 | left: 104px; 55 | max-height: 96px; 56 | overflow: hidden; 57 | } 58 | 59 | .align-left { 60 | object-position: top left; 61 | } 62 | 63 | .align-off-left { 64 | object-position: top 0px left -16px; 65 | } 66 | 67 | .align-right { 68 | object-position: top right; 69 | } 70 | 71 | .align-center { 72 | object-position: center; 73 | } 74 | 75 | -------------------------------------------------------------------------------- /compile/remove-empty-space.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const MagicString = require('magic-string'); 18 | const walk = require('acorn-walk'); 19 | 20 | export function removeEmptySpace() { 21 | return { 22 | name: 'remove-empty-space', 23 | transform(code) { 24 | const source = new MagicString(code); 25 | const program = this.parse(code, { ranges: true }); 26 | 27 | walk.simple(program, { 28 | TemplateLiteral(node) { 29 | const [start, end] = node.range; 30 | let literalValue = code.substring(start, end); 31 | literalValue = literalValue 32 | .replace(/\) \{/g, '){') 33 | .replace(/, /g, ',') 34 | .replace(/ = /g, '=') 35 | .replace(/\t/g, '') 36 | .replace(/[ ]{2,}/g, '') 37 | .replace(/\n/g, ''); 38 | source.overwrite(start, end, literalValue); 39 | }, 40 | }); 41 | 42 | return { 43 | code: source.toString(), 44 | map: source.generateMap(), 45 | }; 46 | }, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /docs/demo/pan/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 34 |
Some boats on Lake Tahoe
35 |
36 | 37 |

38 | This demo is mostly to show that the object-position can be animated. For a simple panning effect, using a transform: translateX(...px) would accomplish the same effect and prepareImageAnimation would be overkill. 39 |

40 | 41 | 42 | -------------------------------------------------------------------------------- /src/positioned-container.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Finds the positioned container (i.e. one that has a computed position that 19 | * is not static). If the element itself is positioned, then we return it, 20 | * otherwise, we find the first positioned parent. If there are no positioned 21 | * elements, this will return the root element. 22 | * @param element The element to get the positioned container for. 23 | * @return The positioned container. 24 | */ 25 | export function getPositionedContainer(element: Element): Element { 26 | const { position } = getComputedStyle(element); 27 | // Element is positioned, we are done. 28 | if (position != 'static') { 29 | return element; 30 | } 31 | // We can skip to the offsetParent if present, no need to check all elements 32 | // in between. If we have an offsetParent, we still need to check that it is 33 | // positioned, as it will return `document.body`, even if it is not 34 | // positioned. 35 | const parent = (element).offsetParent || element.parentElement; 36 | return parent ? getPositionedContainer(parent) : element; 37 | } -------------------------------------------------------------------------------- /src/bezier-curve-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export interface Curve { 18 | x1: number, 19 | y1: number, 20 | x2: number, 21 | y2: number, 22 | } 23 | 24 | /** 25 | * A string representation of the curve that can be used as an 26 | * `animation-timing-function`. 27 | * @param curve The curve to conver. 28 | * @return A string in the form of 'cubic-bezier(x1, y1, x2, y2)'. 29 | */ 30 | export function curveToString(curve: Curve): string { 31 | return `cubic-bezier(${curve.x1}, ${curve.y1}, ${curve.x2}, ${curve.y2})`; 32 | } 33 | 34 | /** 35 | * Gets the x/y value for the given control points for a given value of t. The 36 | * first control point is always zero and the fourth is always one. 37 | * @param c1 The second control point. 38 | * @param c2 The third control point. 39 | * @param t 40 | * @return The value at t. 41 | */ 42 | export function getCubicBezierCurveValue( 43 | c1: number, c2: number, t: number): number { 44 | const t_2 = t * t; 45 | const t_3 = t_2 * t; 46 | // Formula for 4 point bezier curve with c0 = 0 and c3 = 1. 47 | return (3 * (t - 2 * t_2 + t_3) * c1) + 48 | (3 * (t_2 - t_3) * c2) + (t_3); 49 | } 50 | 51 | -------------------------------------------------------------------------------- /docs/demo/hero/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | body { 18 | overflow: hidden; 19 | } 20 | 21 | .small { 22 | flex-shrink: 0; 23 | width: 96px; 24 | height: 96px; 25 | object-fit: cover; 26 | } 27 | 28 | .large { 29 | max-width: 100%; 30 | object-fit: cover; 31 | } 32 | 33 | .related { 34 | display: flex; 35 | align-items: center; 36 | outline: none; 37 | } 38 | 39 | .desc { 40 | padding: 0 12px; 41 | } 42 | 43 | .page { 44 | position: absolute; 45 | top: 0; 46 | left: 0; 47 | right: 0; 48 | bottom: 0; 49 | overflow-x: hidden; 50 | overflow-y: auto; 51 | max-width: 30em; 52 | margin: auto; 53 | padding: 12px; 54 | } 55 | 56 | .content-container { 57 | /** Used by the animation to know how to position. */ 58 | position: relative; 59 | } 60 | 61 | [transition] .content { 62 | opacity: 0; 63 | animation-name: fadeIn; 64 | animation-duration: 200ms; 65 | animation-delay: 100ms; 66 | animation-timing-function: ease-in; 67 | animation-fill-mode: forwards; 68 | } 69 | 70 | [transition] .hero { 71 | visibility: hidden; 72 | } 73 | 74 | @keyframes fadeIn { 75 | from { 76 | opacity: 0; 77 | } 78 | 79 | to { 80 | opacity: 1; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /docs/demo/lightbox/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | .small { 18 | flex-shrink: 0; 19 | width: 96px; 20 | height: 96px; 21 | object-fit: cover; 22 | } 23 | 24 | .related { 25 | display: flex; 26 | align-items: center; 27 | outline: none; 28 | } 29 | 30 | .desc { 31 | padding: 0 12px; 32 | } 33 | 34 | .page { 35 | max-width: 30em; 36 | margin: auto; 37 | padding: 12px; 38 | } 39 | 40 | .lightbox { 41 | position: fixed; 42 | top: 0; 43 | bottom: 0; 44 | right: 0; 45 | left: 0; 46 | } 47 | 48 | .lightbox-background { 49 | background-color: #000; 50 | animation-fill-mode: forwards; 51 | animation-timing-function: cubic-bezier(0.8, 0, 0.2, 1); 52 | } 53 | 54 | .lightbox-cover { 55 | position: fixed; 56 | top: 0; 57 | left: 0; 58 | right: 0; 59 | bottom: 0; 60 | } 61 | 62 | .lightbox-img { 63 | position: relative; 64 | object-fit: contain; 65 | width: 100%; 66 | height: 100%; 67 | } 68 | 69 | [lightbox-fade="in"] { 70 | animation-name: lightbox-fade; 71 | animation-direction: reverse; 72 | } 73 | 74 | [lightbox-fade="out"] { 75 | animation-name: lightbox-fade; 76 | } 77 | 78 | [lightbox-transition] { 79 | visibility: hidden; 80 | } 81 | 82 | @keyframes lightbox-fade { 83 | from { 84 | opacity: 1; 85 | } 86 | 87 | to { 88 | opacity: 0; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /docs/demo/zoom-crop/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | body { 18 | overflow: hidden; 19 | } 20 | 21 | .small { 22 | position: relative; 23 | flex-shrink: 0; 24 | width: 96px; 25 | height: 96px; 26 | overflow: hidden; 27 | } 28 | 29 | .small .hero { 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | width: 100%; 34 | height: 100%; 35 | object-fit: cover; 36 | } 37 | 38 | .large { 39 | max-width: 100%; 40 | object-fit: cover; 41 | } 42 | 43 | .related { 44 | display: flex; 45 | align-items: center; 46 | outline: none; 47 | } 48 | 49 | .desc { 50 | padding: 0 12px; 51 | } 52 | 53 | .page { 54 | position: absolute; 55 | top: 0; 56 | left: 0; 57 | right: 0; 58 | bottom: 0; 59 | overflow-x: hidden; 60 | overflow-y: auto; 61 | max-width: 30em; 62 | margin: auto; 63 | padding: 12px; 64 | } 65 | 66 | .content-container { 67 | /** Used by the animation to know how to position. */ 68 | position: relative; 69 | } 70 | 71 | [transition] .content { 72 | opacity: 0; 73 | animation-name: fadeIn; 74 | animation-duration: 200ms; 75 | animation-delay: 100ms; 76 | animation-timing-function: ease-in; 77 | animation-fill-mode: forwards; 78 | } 79 | 80 | [transition] .hero { 81 | visibility: hidden; 82 | } 83 | 84 | @keyframes fadeIn { 85 | from { 86 | opacity: 0; 87 | } 88 | 89 | to { 90 | opacity: 1; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /docs/demo/hero/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 18 | 19 | const duration = 600; 20 | const curve = {x1: 0.8, y1: 0, x2: 0.2, y2: 1}; 21 | const styles = { 22 | animationDuration: `${duration}ms`, 23 | }; 24 | 25 | window.toggle = function(event, targetId) { 26 | const current = event.currentTarget.closest('.page'); 27 | const target = document.getElementById(targetId); 28 | const srcImg = current.querySelector('.hero'); 29 | const targetImg = target.querySelector('.hero'); 30 | // We do the transition within the target page. This will make sure that if 31 | // the user scrolls during the animation, that the image still animates to 32 | // the correct location. Note that this element has `position: relative` 33 | // so that the animation can position correctly. 34 | const transitionContainer = target.querySelector('.content-container'); 35 | 36 | target.hidden = false; 37 | 38 | const { 39 | applyAnimation, 40 | cleanupAnimation, 41 | } = prepareImageAnimation({ 42 | transitionContainer, 43 | srcImg, 44 | targetImg, 45 | styles, 46 | curve, 47 | }); 48 | 49 | target.setAttribute('transition', ''); 50 | current.hidden = true; 51 | applyAnimation(); 52 | 53 | setTimeout(() => { 54 | target.removeAttribute('transition'); 55 | cleanupAnimation(); 56 | }, duration + 100); 57 | } 58 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import compiler from '@ampproject/rollup-plugin-closure-compiler'; 18 | import resolve from 'rollup-plugin-node-resolve'; 19 | import typescript from 'rollup-plugin-typescript'; 20 | import commonjs from 'rollup-plugin-commonjs'; 21 | import {terser} from 'rollup-plugin-terser'; 22 | import {removeEmptySpace} from './compile/remove-empty-space'; 23 | 24 | function minify() { 25 | return process.env.ROLLUP_WATCH ? [] : [ 26 | removeEmptySpace(), 27 | compiler({ 28 | compilation_level: 'ADVANCED_OPTIMIZATIONS', 29 | externs: 'compile/externs.js' 30 | }), 31 | terser(), 32 | ]; 33 | } 34 | 35 | export default [ 36 | { 37 | input: 'index.ts', 38 | output: { 39 | file: 'dist/animations.js', 40 | format: 'cjs', 41 | }, 42 | plugins: [ 43 | resolve({ preferBuiltins: true }), 44 | commonjs({ include: 'node_modules/**' }), 45 | typescript({ 46 | include: '**/*.ts', 47 | }), 48 | ...minify(), 49 | ], 50 | }, 51 | { 52 | input: 'index.ts', 53 | output: { 54 | file: 'dist/animations.mjs', 55 | format: 'esm', 56 | }, 57 | plugins: [ 58 | resolve({ preferBuiltins: true }), 59 | commonjs({ include: 'node_modules/**' }), 60 | typescript({ 61 | include: '**/*.ts', 62 | }), 63 | ...minify(), 64 | ], 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /docs/animation-test-controller.md: -------------------------------------------------------------------------------- 1 | # Animation Test Controller 2 | 3 | ## Overview 4 | 5 | Helper test functions for controlling the state of CSS-based animations during tests. This allows you to move animations to a particular point in time so that you can verify the animations are working correctly. 6 | 7 | ## Usage 8 | 9 | ```javascript 10 | import { 11 | setup as setupAnimations, 12 | tearDown as tearDownAnimations, 13 | offset, 14 | } from '@ampproject/animations/dist/src/testing/animation-test-controller.js'; 15 | … 16 | 17 | describe('Some animation', () => { 18 | before(() => { 19 | setupAnimations(); 20 | }); 21 | 22 | after(() => { 23 | tearDownAnimations(); 24 | }); 25 | 26 | it('should render recorrectly 50ms in', () => { 27 | const animatingElement = …; 28 | doSomethingStartingAnAnimation(); 29 | 30 | offset(50); 31 | const {top} = animatingElement.getBoundingClientRect(); 32 | expect(top).to.be.closeTo(100, 0.1); 33 | 34 | offset(100); // Note, sets to t=100, not 150 35 | … 36 | }); 37 | }); 38 | ``` 39 | 40 | The `offset` function takes an optional second argument that restricts the animation offsetting to just a single DOM subtree. This is useful if your test page has multiple animations, but you only really want to test a single one. For example: 41 | 42 | ```javascript 43 | it('should render the the interesting subtree correctly', () => { 44 | const interestingSubtree = document.querySelector('.interesting'); 45 | offset(50, interestingSubtree); 46 | … 47 | }); 48 | ``` 49 | 50 | ## How it works 51 | 52 | The setup call will pause all animations on the page by setting their `animation-play-state` to `paused`. This applies to both existing animations and those that are defined in the future. Once setup, the `offset` function moves all animations within a subtree to a specific time by specifying a negative `animation-duration`. This works correctly, even if you have already specified an `animation-delay` on an Element. The offset specified takes into account any existing animation delay, so it will work correctly if some of your animations already have delays. 53 | -------------------------------------------------------------------------------- /src/transform-img/README.md: -------------------------------------------------------------------------------- 1 | # Transform Img 2 | 3 | ## Overview 4 | 5 | This directory contains logic for transforming an image from one location to 6 | another. The animation is comprised of 4 parts: 7 | 8 | * Scaling the image in the x and y directions to the final scale 9 | * Translating the rendered image within the cropping container (for 10 | object-position) 11 | * Translating the cropping container in the x and y directions to the final 12 | location 13 | * Changing the crop in the x and y directions to the final crop 14 | 15 | Each of these are implemented in seprate file. 16 | 17 | ## Implementation 18 | 19 | In order to have the animation perform well, including on older devices, only 20 | CPU accelerated animatable properties are used. As a result, the 'crop' portion 21 | of the animation is implemented by scaling up a cropping container (with 22 | `overflow: hidden`) and counteracting the scaling with an inverse scale on a 23 | child container. This cannot be done easily with a CSS timing function, so a 24 | stylesheet with keyframes is generated for the crop scale / counteracting scale 25 | values. 26 | 27 | The code attempts to generate enough keyframes to keep any errors due to 28 | interpolation during the animation low, but at the same time avoid doing more 29 | work than necessary. 30 | 31 | Both the scaling of the image itself as well as the translate can be done 32 | directly with a CSS timing function, so those two operations do not generate 33 | keyframes. 34 | 35 | The animations are implemented by taking the larger size and animating using 36 | that as a reference point for scaling. That is, when the transition is where 37 | the larger image is (whether that is the starting state or ending state), the 38 | scale values will all be `1.0`. This is done to avoid rounding errors from 39 | the various scale animations from having a user perceivable impact. The errors 40 | are much less or not at all noticable for the smaller image. When we are going 41 | from a small image to a large image (say scaling by 5x), we will animate the 42 | scale from `0.2` to `1.0`. If we were to run the reverse operation, we would 43 | scale from `1.0` to `0.2`. 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ampproject/animations", 3 | "version": "0.2.2", 4 | "description": "JavaScript animation functions and helpers.", 5 | "main": "dist/animations.js", 6 | "module": "dist/animations.mjs", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/ampproject/animations.git" 10 | }, 11 | "author": "The AMP HTML Authors", 12 | "license": "Apache-2.0", 13 | "scripts": { 14 | "build": "rollup -c", 15 | "build-watch": "yarn build --watch", 16 | "clean": "rm -rf dist/ build/", 17 | "predemo": "yarn build", 18 | "demo": "http-server & opener http://localhost:8080/docs/demo & wait", 19 | "dist": "yarn clean && yarn build", 20 | "karma": "karma start --browsers Chrome,Firefox karma.conf.js", 21 | "test": "yarn karma --single-run", 22 | "test-watch": "yarn karma --auto-watch", 23 | "test-action": "karma start --single-run --browsers ChromeHeadless karma.conf.js", 24 | "size": "yarn build && yarn filesize", 25 | "prepublishOnly": "yarn dist" 26 | }, 27 | "devDependencies": { 28 | "@ampproject/filesize": "4.2.0", 29 | "@ampproject/rollup-plugin-closure-compiler": "0.26.0", 30 | "@types/chai": "4.2.11", 31 | "@types/mocha": "7.0.2", 32 | "acorn-walk": "7.1.1", 33 | "chai": "4.2.0", 34 | "http-server": "0.12.3", 35 | "karma": "5.1.0", 36 | "karma-chai": "0.1.0", 37 | "karma-chrome-launcher": "3.1.0", 38 | "karma-firefox-launcher": "1.3.0", 39 | "karma-ie-launcher": "1.0.0", 40 | "karma-mocha": "2.0.1", 41 | "karma-typescript": "5.0.3", 42 | "magic-string": "0.25.7", 43 | "mocha": "8.0.1", 44 | "opener": "1.5.1", 45 | "rollup": "2.15.0", 46 | "rollup-plugin-commonjs": "10.1.0", 47 | "rollup-plugin-node-resolve": "5.2.0", 48 | "rollup-plugin-typescript": "1.0.1", 49 | "rollup-plugin-terser": "6.1.0", 50 | "typescript": "3.9.5", 51 | "tslib": "2.0.0" 52 | }, 53 | "filesize": [ 54 | { 55 | "path": "./dist/animations.mjs", 56 | "compression": "brotli", 57 | "maxSize": "2 kB" 58 | }, 59 | { 60 | "path": "./dist/animations.js", 61 | "compression": "brotli", 62 | "maxSize": "2 kB" 63 | } 64 | ], 65 | "files": [ 66 | "dist" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /docs/demo/zoom-crop/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 18 | 19 | const duration = 600; 20 | const curve = {x1: 0.8, y1: 0, x2: 0.2, y2: 1}; 21 | const styles = { 22 | animationDuration: `${duration}ms`, 23 | }; 24 | 25 | window.toggle = function(event, targetId) { 26 | const current = event.currentTarget.closest('.page'); 27 | const target = document.getElementById(targetId); 28 | const srcImg = current.querySelector('.hero'); 29 | const srcCrop = current.querySelector('.hero-crop') || srcImg; 30 | const targetImg = target.querySelector('.hero'); 31 | const targetCrop = target.querySelector('.hero-crop') || targetImg; 32 | // We do the transition within the target page. This will make sure that if 33 | // the user scrolls during the animation, that the image still animates to 34 | // the correct location. Note that this element has `position: relative` 35 | // so that the animation can position correctly. 36 | const transitionContainer = target.querySelector('.content-container'); 37 | 38 | target.hidden = false; 39 | 40 | const { 41 | applyAnimation, 42 | cleanupAnimation, 43 | } = prepareImageAnimation({ 44 | transitionContainer, 45 | srcImg, 46 | srcCropRect: srcCrop.getBoundingClientRect(), 47 | targetImg, 48 | targetCropRect: targetCrop.getBoundingClientRect(), 49 | styles, 50 | curve, 51 | }); 52 | 53 | target.setAttribute('transition', ''); 54 | current.hidden = true; 55 | applyAnimation(); 56 | 57 | setTimeout(() => { 58 | target.removeAttribute('transition'); 59 | cleanupAnimation(); 60 | }, duration + 100); 61 | } 62 | -------------------------------------------------------------------------------- /docs/demo/gallery/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 59 |
60 | 61 | -------------------------------------------------------------------------------- /src/object-position.ts: -------------------------------------------------------------------------------- 1 | import {Size} from './size.js'; 2 | 3 | /** 4 | * @param str A string to extract a number from. 5 | * @param units The units for the number to extract. 6 | * @return A number for the first encountered with a suffix or zero if 7 | * none is found. 8 | */ 9 | function extractNumber(str: string, units: string): number { 10 | // Some various possible formats to extract from: 11 | // `10px`, `20%`, `calc(-5px - 30%)`, `calc(5px + 30%)` 12 | // Make sure to allow for spaces before the minus sign. 13 | const regExp = new RegExp('-?\\s*\\d+' + units); 14 | // Get the match, or default to 0. 15 | const numberWithSign = (str.match(regExp) || ['0'])[0]; 16 | // Remove any spaces between a minus sign and the number. 17 | const numberString = numberWithSign.replace(' ', ''); 18 | // Parse the number, which will ignore the trailing units. 19 | return parseFloat(numberString); 20 | } 21 | 22 | /** 23 | * @param str A string to extract a percentage value from. 24 | * @return A number for the first encountered pixel value found. 25 | */ 26 | function extractPx(str: string): number { 27 | return extractNumber(str, 'px'); 28 | } 29 | 30 | /** 31 | * @param str A string to extract a percentage value from. 32 | * @return A number for the first encountered percentage value found. 33 | */ 34 | function extractPercentage(str: string): number { 35 | return extractNumber(str, '%'); 36 | } 37 | 38 | /** 39 | * Gets the translate needed to position content for an image matching 40 | * `object-position: ...`. This is based on the size of the container as well 41 | * as the size of the content. 42 | * @param objectPosition A computed `object-position` property value. 43 | * @param containerRect The rect for the image container. 44 | * @param contentDimensions The dimensions for the rendered image content. 45 | * @return The amount needed to translate by to match the desired 46 | * `object-position`. 47 | * @see https://drafts.csswg.org/css-backgrounds-3/#valdef-background-position-percentage 48 | */ 49 | export function getPositioningTranslate( 50 | objectPosition: string, 51 | containerRect: Size, 52 | contentDimensions: Size 53 | ): { 54 | top: number, 55 | left: number, 56 | } { 57 | // For IE, which does not support `object-position`, default the behavior to 58 | // center. 59 | const positionStr = objectPosition || '50% 50%'; 60 | 61 | const splitIndex = positionStr.lastIndexOf('calc', 0) === 0 ? 62 | positionStr.indexOf(')') + 1 : positionStr.indexOf(' '); 63 | const xPos = positionStr.slice(0, splitIndex) || ''; 64 | const yPos = positionStr.slice(splitIndex) || ''; 65 | const xPx = extractPx(xPos); 66 | const yPx = extractPx(yPos); 67 | const xPercentage = extractPercentage(xPos) / 100; 68 | const yPercentage = extractPercentage(yPos) / 100; 69 | 70 | return { 71 | top: yPercentage * (containerRect.height - contentDimensions.height) + yPx, 72 | left: xPercentage * (containerRect.width - contentDimensions.width) + xPx, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/transform-img/scale-animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Curve, curveToString} from '../bezier-curve-utils.js'; 18 | import {Size, divideSizes} from '../size.js'; 19 | 20 | /** 21 | * Prepares a scale animation. This function sets up the animation by setting 22 | * the appropriate style properties on the desired Element. The returned style 23 | * text needs to be inserted for the animation to run. 24 | * @param options 25 | * @param options.element The element to apply the scaling to. 26 | * @param options.largerDimensions The larger of the start/end element 27 | * dimensions. 28 | * @param options.smallerDimensions The smaller of the start/end element 29 | * dimensions. 30 | * @param options.curve The timing curve for the scaling. 31 | * @param options.style The styles to apply to `element`. 32 | * @param options.keyframesPrefix A prefix to use for the generated 33 | * keyframes to ensure they do not clash with existing keyframes. 34 | * @param options.toLarger Whether or not `largerImgDimensions` are the 35 | * dimensions are we are animating to. 36 | * @return CSS style text to perform the animation. 37 | */ 38 | export function prepareScaleAnimation({ 39 | element, 40 | largerDimensions, 41 | smallerDimensions, 42 | curve, 43 | styles, 44 | keyframesPrefix, 45 | toLarger, 46 | } : { 47 | element: HTMLElement, 48 | largerDimensions: Size, 49 | smallerDimensions: Size, 50 | curve: Curve, 51 | styles: Object, 52 | keyframesPrefix: string, 53 | toLarger: boolean, 54 | }): string { 55 | const curveString = curveToString(curve); 56 | const keyframesName = `${keyframesPrefix}-scale`; 57 | 58 | const neutralScale = {x: 1, y: 1}; 59 | const scaleDown = divideSizes(smallerDimensions, largerDimensions); 60 | const startScale = toLarger ? scaleDown : neutralScale; 61 | const endScale = toLarger ? neutralScale : scaleDown; 62 | 63 | Object.assign(element.style, styles, { 64 | 'willChange': 'transform', 65 | 'transformOrigin': 'top left', 66 | 'animationName': keyframesName, 67 | 'animationTimingFunction': curveString, 68 | 'animationFillMode': 'forwards', 69 | }); 70 | 71 | return ` 72 | @keyframes ${keyframesName} { 73 | from { 74 | transform: scale(${startScale.x}, ${startScale.y}); 75 | } 76 | 77 | to { 78 | transform: scale(${endScale.x}, ${endScale.y}); 79 | } 80 | } 81 | `; 82 | } 83 | -------------------------------------------------------------------------------- /docs/tools/img-cropper/dropper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const dropArea = document.querySelector('.drop'); 18 | const imgContainer = document.querySelector('.src-img-container'); 19 | const origImg = document.querySelector('.src-img-container img'); 20 | 21 | function onDragEnter(e) { 22 | dropArea.setAttribute('drag-over', ''); 23 | 24 | window.addEventListener('dragend', function onDragEnd(e) { 25 | window.removeEventListener('dragend'); 26 | dropArea.removeAttribute('drag-over'); 27 | }); 28 | 29 | e.stopPropagation(); 30 | e.preventDefault(); 31 | } 32 | 33 | function onDragOver(e) { 34 | e.stopPropagation(); 35 | e.preventDefault(); 36 | } 37 | 38 | function onDragLeave(e) { 39 | if (e.target == dropArea || !dropArea.contains(e.target)) { 40 | dropArea.removeAttribute('drag-over'); 41 | } 42 | 43 | 44 | e.stopPropagation(); 45 | e.preventDefault(); 46 | } 47 | 48 | /** 49 | * Handles a drop event, which could potentially be an image URL (dragged from 50 | * a website) or a File (dragged from a Desktop). 51 | * @param {!Event} e 52 | */ 53 | function onDrop(e) { 54 | dropArea.removeAttribute('drag-over'); 55 | 56 | e.stopPropagation(); 57 | e.preventDefault(); 58 | 59 | const dt = e.dataTransfer; 60 | const url = dt.getData('URL'); 61 | if (url) { 62 | updateSrc(url); 63 | } else if (dt.files) { 64 | [...dt.files] 65 | .filter(f => f.type.startsWith('image/')) 66 | .filter((f, i) => i == 0) 67 | .forEach(f => { 68 | readImg(f); 69 | }); 70 | } 71 | 72 | return false; 73 | } 74 | 75 | /** 76 | * Updates the src of the image form selection. 77 | * @param {string} src The src to use. 78 | */ 79 | function updateSrc(src) { 80 | origImg.src = src; 81 | window.dispatchEvent(new CustomEvent('img-change')); 82 | } 83 | 84 | /** 85 | * Reads a file, then sets the src of the image using the contents as a data-url. 86 | * @param {!File} file The file to read. 87 | */ 88 | function readImg(file) { 89 | const reader = new FileReader(); 90 | reader.onload = function(){ 91 | const dataUrl = reader.result; 92 | updateSrc(dataUrl); 93 | }; 94 | reader.readAsDataURL(file); 95 | } 96 | 97 | dropArea.addEventListener('dragenter', onDragEnter, true); 98 | dropArea.addEventListener('dragover', onDragOver, true); 99 | dropArea.addEventListener('dragleave', onDragLeave, true); 100 | dropArea.addEventListener('drop', onDrop, true); 101 | -------------------------------------------------------------------------------- /src/test-positioned-container.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {getPositionedContainer} from './positioned-container.js'; 18 | 19 | const {expect} = chai; 20 | 21 | describe('getPositionedParent', () => { 22 | let child; 23 | let grandchild; 24 | 25 | beforeEach(() => { 26 | child = document.createElement('div'); 27 | grandchild = document.createElement('div'); 28 | child.appendChild(grandchild); 29 | document.body.appendChild(child); 30 | }); 31 | 32 | afterEach(() => { 33 | document.body.style.cssText = ''; 34 | document.body.removeChild(child); 35 | }); 36 | 37 | describe('for a positioned body', () => { 38 | beforeEach(() => { 39 | document.body.style.position = 'relative'; 40 | }); 41 | 42 | it('should return the correct element for body', () => { 43 | const container = getPositionedContainer(document.body); 44 | expect(container).to.equal(document.body); 45 | }); 46 | 47 | it('should return the correct element for a child', () => { 48 | const container = getPositionedContainer(child); 49 | expect(container).to.equal(document.body); 50 | }); 51 | 52 | it('should return the correct element for a grandchild', () => { 53 | const container = getPositionedContainer(grandchild); 54 | expect(container).to.equal(document.body); 55 | }); 56 | 57 | it('should return the correct element for a grandchild with positioned parent', () => { 58 | child.style.position = 'relative'; 59 | 60 | const container = getPositionedContainer(grandchild); 61 | expect(container).to.equal(child); 62 | }); 63 | }); 64 | 65 | describe('for a non positioned body', () => { 66 | it('should return the correct element for body', () => { 67 | const container = getPositionedContainer(document.body); 68 | expect(container).to.equal(document.documentElement); 69 | }); 70 | 71 | it('should return the correct element for a child', () => { 72 | const container = getPositionedContainer(child); 73 | expect(container).to.equal(document.documentElement); 74 | }); 75 | 76 | it('should return the correct element for a grandchild', () => { 77 | const container = getPositionedContainer(grandchild); 78 | expect(container).to.equal(document.documentElement); 79 | }); 80 | 81 | it('should return the correct element for a grandchild with positioned parent', () => { 82 | child.style.position = 'relative'; 83 | 84 | const container = getPositionedContainer(grandchild); 85 | expect(container).to.equal(child); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/transform-img/crop-position-animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Curve, curveToString} from '../bezier-curve-utils.js'; 18 | 19 | /** 20 | * Prepares an animation for position (i.e. for object-position). This function 21 | * sets up the animation by setting the appropriate style properties on the 22 | * desired Element. The returned style text needs to be inserted for the 23 | * animation to run. 24 | * @param options 25 | * @param options.element The element to apply the position to. 26 | * @param options.largerRect 27 | * rects. 28 | * @param options.largerCropRect 29 | * @param options.smallerRect 30 | * @param options.smallerCropRect 31 | * @param options.curve The timing curve for the scaling. 32 | * @param options.style The styles to apply to `element`. 33 | * @param options.keyframesPrefix A prefix to use for the generated 34 | * keyframes to ensure they do not clash with existing keyframes. 35 | * @param options.toLarger Whether or not `largerRect` / `largerCropRect` are 36 | * the positions are we are animating to. 37 | * @return CSS style text to perform the animation. 38 | */ 39 | export function prepareCropPositionAnimation({ 40 | element, 41 | largerRect, 42 | largerCropRect, 43 | smallerRect, 44 | smallerCropRect, 45 | curve, 46 | styles, 47 | keyframesPrefix, 48 | toLarger, 49 | } : { 50 | element: HTMLElement, 51 | largerRect: ClientRect, 52 | largerCropRect: ClientRect, 53 | smallerRect: ClientRect, 54 | smallerCropRect: ClientRect, 55 | curve: Curve, 56 | styles: Object, 57 | keyframesPrefix: string, 58 | toLarger: boolean, 59 | }): string { 60 | const curveString = curveToString(curve); 61 | const keyframesName = `${keyframesPrefix}-crop-position`; 62 | 63 | const largerTranslate = { 64 | top: largerRect.top - largerCropRect.top, 65 | left: largerRect.left - largerCropRect.left, 66 | }; 67 | const smallerTranslate = { 68 | top: smallerRect.top - smallerCropRect.top, 69 | left: smallerRect.left - smallerCropRect.left, 70 | }; 71 | const startTranslate = toLarger ? smallerTranslate : largerTranslate; 72 | const endTranslate = toLarger ? largerTranslate : smallerTranslate; 73 | 74 | Object.assign(element.style, styles, { 75 | 'willChange': 'transform', 76 | 'animationName': keyframesName, 77 | 'animationTimingFunction': curveString, 78 | 'animationFillMode': 'forwards', 79 | }); 80 | 81 | return ` 82 | @keyframes ${keyframesName} { 83 | from { 84 | transform: translate(${startTranslate.left}px, ${startTranslate.top}px); 85 | } 86 | 87 | to { 88 | transform: translate(${endTranslate.left}px, ${endTranslate.top}px); 89 | } 90 | } 91 | `; 92 | } 93 | -------------------------------------------------------------------------------- /src/transform-img/translate-animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Curve, curveToString} from '../bezier-curve-utils.js'; 18 | 19 | /** 20 | * Prepares a translation animation, assuming that `smallerRect` will be scaled 21 | * up to `largerRect` using `transform-origin: top left`. 22 | * This function sets up the animation by setting the appropriate style 23 | * properties on the desired Element. The returned style text needs to be 24 | * inserted for the animation to run. 25 | * @param options 26 | * @param options.element The element to apply the scaling to. 27 | * @param options.positionedParentRect The rect for the positioned parent. 28 | * We need to account for the difference of the target's top/left and where 29 | * we will position absolutely to. 30 | * @param options.largerRect The larger of the start/end scaling rects. 31 | * @param options.smallerRect The smaller of the start/end scaling rects. 32 | * @param options.curve The timing curve for the scaling. 33 | * @param options.style The styles to apply to `element`. 34 | * @param options.keyframesPrefix A prefix to use for the generated 35 | * keyframes to ensure they do not clash with existing keyframes. 36 | * @param options.toLarger Whether or not `largerRect` is the rect we are 37 | * animating to. 38 | * @return CSS style text to perform the animation. 39 | */ 40 | export function prepareTranslateAnimation({ 41 | element, 42 | positionedParentRect, 43 | largerRect, 44 | smallerRect, 45 | curve, 46 | styles, 47 | keyframesPrefix, 48 | toLarger, 49 | } : { 50 | element: HTMLElement, 51 | positionedParentRect: ClientRect, 52 | largerRect: ClientRect, 53 | smallerRect: ClientRect, 54 | curve: Curve, 55 | styles: Object, 56 | keyframesPrefix: string, 57 | toLarger: boolean, 58 | }): string { 59 | const keyframesName = `${keyframesPrefix}-translation`; 60 | 61 | const startRect = toLarger ? smallerRect : largerRect; 62 | const endRect = toLarger ? largerRect : smallerRect; 63 | const deltaLeft = startRect.left - endRect.left; 64 | const deltaTop = startRect.top - endRect.top; 65 | 66 | Object.assign(element.style, styles, { 67 | 'position': 'absolute', 68 | 'top': `${endRect.top - positionedParentRect.top}px`, 69 | 'left': `${endRect.left - positionedParentRect.left}px`, 70 | 'willChange': 'transform', 71 | 'animationName': keyframesName, 72 | 'animationTimingFunction': curveToString(curve), 73 | 'animationFillMode': 'forwards', 74 | }); 75 | 76 | return ` 77 | @keyframes ${keyframesName} { 78 | from { 79 | transform: translate(${deltaLeft}px, ${deltaTop}px); 80 | } 81 | 82 | to { 83 | transform: translate(0, 0); 84 | } 85 | } 86 | `; 87 | } 88 | -------------------------------------------------------------------------------- /docs/tools/img-cropper/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* Required styles for the img */ 18 | .crop { 19 | position: relative; 20 | overflow: hidden; 21 | } 22 | 23 | .crop img { 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | width: 100%; 28 | height: 100%; 29 | transform-origin: top left; 30 | } 31 | 32 | /* Selection styles */ 33 | .drop { 34 | display: inline-block; 35 | min-height: 100px; 36 | padding: 16px; 37 | border: 1px solid #999; 38 | background-color: #eee; 39 | } 40 | 41 | [drag-over] .src-img-container { 42 | visibility: hidden; 43 | } 44 | 45 | .src-img-container { 46 | position: relative; 47 | display: inline-block; 48 | user-select: none; 49 | } 50 | 51 | .src-img-container > img { 52 | max-width: 100%; 53 | max-height: 40vh; 54 | } 55 | 56 | .dragger { 57 | position: absolute; 58 | top: 0; 59 | left: 0; 60 | z-index: 1; 61 | padding: 6px; 62 | margin: -6px; 63 | } 64 | 65 | .dragger::after { 66 | position: relative; 67 | top: -2px; 68 | left: -2px; 69 | content: ''; 70 | display: block; 71 | z-index: 1; 72 | width: 4px; 73 | height: 4px; 74 | border-width: 1px; 75 | border-style: solid; 76 | background-color: rgb(19, 255, 235); 77 | border-radius: 50%; 78 | border-color: rgba(0, 0, 0, 0.6); 79 | box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1); 80 | } 81 | 82 | .selection-container { 83 | mix-blend-mode: darken; 84 | pointer-events: none; 85 | } 86 | 87 | .selection-rect { 88 | position: absolute; 89 | top: 0; 90 | left: 0; 91 | z-index: 1; 92 | border-width: 1px; 93 | background-color: white; 94 | box-shadow: 0 0 6px 5px rgba(0, 0, 0, 0.05); 95 | mix-blend-mode: lighten; 96 | } 97 | 98 | .selection-mask { 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | bottom: 0; 103 | right: 0; 104 | z-index: 1; 105 | background-color: rgba(0, 0, 0, 0.3); 106 | } 107 | 108 | /* Output styles */ 109 | #selectedWidth:checked ~ .renderedWidth { 110 | display: none; 111 | } 112 | 113 | .html-markup, 114 | .amp-markup { 115 | display: none; 116 | } 117 | 118 | #plainHtml:checked ~ .html-markup, 119 | #ampHtml:checked ~ .amp-markup { 120 | display: block 121 | } 122 | 123 | /* Page theming */ 124 | body { 125 | margin: 0; 126 | padding: 8px; 127 | } 128 | 129 | .section { 130 | margin-bottom: 16px; 131 | } 132 | 133 | .config { 134 | display: block; 135 | } 136 | 137 | .config > input { 138 | display: block; 139 | } 140 | 141 | .markup > code { 142 | display: block; 143 | } 144 | 145 | .markup > code > pre { 146 | display: inline-block; 147 | background-color: #eee; 148 | color: #222; 149 | padding: 8px; 150 | } -------------------------------------------------------------------------------- /docs/demo/expand/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 18 | 19 | const duration = 600; 20 | const curve = {x1: 0.42, y1: 0, x2: 0.58, y2: 1}; 21 | const styles = { 22 | animationDuration: `${duration}ms`, 23 | }; 24 | 25 | window.toggle = function(event) { 26 | const container = event.currentTarget; 27 | const expanded = container._expanded; 28 | 29 | const smallImg = container.querySelector('.small'); 30 | const largeImg = container.querySelector('.large'); 31 | const text = container.querySelector('.text'); 32 | const srcImg = expanded ? largeImg : smallImg; 33 | const targetImg = expanded ? smallImg : largeImg; 34 | const { 35 | height: startHeight, 36 | width: startWidth, 37 | } = srcImg.getBoundingClientRect(); 38 | const { 39 | height: endHeight, 40 | width: endWidth, 41 | } = targetImg.getBoundingClientRect(); 42 | const deltaHeight = endHeight - startHeight; 43 | const deltaWidth = endWidth - startWidth; 44 | 45 | // Move everything after the img container using a translate. This is to make 46 | // sure the movement performs well (comapred to translating height of the 47 | // container). 48 | Array.from(container.parentNode.children) 49 | .filter(c => { 50 | return container.compareDocumentPosition(c) === Node.DOCUMENT_POSITION_FOLLOWING; 51 | }).forEach(c => { 52 | // Add to any current offset when multiple are expanded. 53 | const deltaY = (c._deltaY || 0) + deltaHeight; 54 | c._deltaY = deltaY; 55 | 56 | Object.assign(c.style, { 57 | 'transitionProperty': 'transform', 58 | 'transitionDuration': `${duration}ms`, 59 | 'transitionTimingFunction': 'cubic-bezier(0.42, 0, 0.58, 1)', 60 | 'transform': `translateY(${deltaY}px)`, 61 | }); 62 | }); 63 | 64 | Object.assign(text.style, { 65 | 'transitionProperty': 'transform opacity', 66 | 'transitionDuration': `${duration}ms`, 67 | 'transitionTimingFunction': 'cubic-bezier(0.42, 0, 0.58, 1)', 68 | 'opacity': expanded ? '1' : '0', 69 | 'transform': expanded ? '' : `translateX(${deltaWidth}px)`, 70 | }); 71 | 72 | const { 73 | applyAnimation, 74 | cleanupAnimation, 75 | } = prepareImageAnimation({ 76 | srcImg, 77 | targetImg, 78 | styles, 79 | curve, 80 | }); 81 | 82 | container._expanded = !expanded; 83 | srcImg.style.visibility = 'hidden'; 84 | applyAnimation(); 85 | 86 | // Note: In order to allow resizing / orientation change to work correctly, 87 | // more work is needed to undo the transations and size things correctly when 88 | // no more animations are in progress. That is, changing the large image to 89 | // `position: static` (and the small one to absolute) in the expanded state, 90 | // then removing the `translateY`s applied. 91 | setTimeout(() => { 92 | targetImg.style.visibility = 'visible'; 93 | cleanupAnimation(); 94 | }, duration + 100); 95 | } 96 | -------------------------------------------------------------------------------- /docs/tools/img-cropper/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Image Cropper Utility 23 | 24 | 25 | 26 |

Image "crop" Calculator

27 | 28 |

29 | Given a source image, generates the markup necessary to "crop" part of the image, which can then be animated. See the zoom-crop demo to see how to do the animation. 30 |

31 | 32 | 33 |
34 |

Drag/drop an image below

35 |
36 |
37 | 38 |
39 |
40 |
41 |
42 |
43 |
44 |

45 | Drag to select an area to "crop", drag from a corner to adjust. Use shift-key to force a 1:1 aspect ratio. 46 |

47 |
48 | 49 | 50 |
51 |

Options

52 | 53 | 58 | 59 | 65 |
66 | 67 | 68 |
69 |

Output Preview

70 |
71 |
72 | 73 |
74 | 75 |
76 | 77 | 78 |
79 |

Image Markup

80 | 81 | 87 | 88 | 93 | 94 | 95 |
96 |

HTML:

97 |
98 | 99 |

CSS:

100 |
101 |
102 | 103 |
104 |

HTML:

105 |
106 | 107 |

CSS:

108 |
109 |
110 |
111 | 112 | 113 | -------------------------------------------------------------------------------- /docs/demo/gallery/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | .gallery { 18 | display: flex; 19 | height: 672px; 20 | max-height: 75vh; 21 | } 22 | 23 | .side { 24 | position: relative; 25 | overflow: hidden; 26 | contain: strict; 27 | flex-shrink: 0; 28 | } 29 | 30 | .small-imgs { 31 | display: flex; 32 | width: 100%; 33 | height: 100%; 34 | } 35 | 36 | .small { 37 | flex-shrink: 0; 38 | width: 96px; 39 | height: 96px; 40 | object-fit: cover; 41 | opacity: 0.8; 42 | } 43 | 44 | .small[selected] { 45 | opacity: 1; 46 | } 47 | 48 | .large { 49 | width: 100%; 50 | height: 100%; 51 | object-fit: cover; 52 | } 53 | 54 | .single-view { 55 | width: 100%; 56 | max-width: 100vh; 57 | height: 100%; 58 | background-color: #eee; 59 | } 60 | 61 | .scroll-overlap, 62 | .scroll-indicator { 63 | position: sticky; 64 | display: flex; 65 | justify-content: center; 66 | align-items: center; 67 | z-index: 1; 68 | flex-shrink: 0; 69 | background-color: white; 70 | } 71 | 72 | .gallery[transition] .large { 73 | visibility: hidden; 74 | } 75 | 76 | @media(min-width: 601px) { 77 | .gallery { 78 | flex-direction: row; 79 | } 80 | 81 | .side { 82 | order: 0; 83 | height: 100%; 84 | width: 96px; 85 | margin-right: 16px; 86 | } 87 | 88 | .small { 89 | margin: 8px 0; 90 | } 91 | 92 | .small-imgs { 93 | flex-direction: column; 94 | padding-right: 30px; 95 | overflow-y: auto; 96 | overflow-x: hidden; 97 | } 98 | 99 | .scroll-overlap, 100 | .scroll-indicator { 101 | height: 18px; 102 | width: 96px; 103 | } 104 | 105 | .scroll-indicator.start { 106 | top: 0; 107 | margin-bottom: -32px; 108 | } 109 | 110 | .scroll-indicator.end { 111 | bottom: 0; 112 | margin-top: -8px; 113 | } 114 | 115 | .scroll-overlap { 116 | position: relative; 117 | } 118 | 119 | .scroll-overlap.start { 120 | top: 14px; 121 | } 122 | 123 | .scroll-overlap.end { 124 | bottom: 16px; 125 | } 126 | } 127 | 128 | @media(max-width: 600px) { 129 | .gallery { 130 | flex-direction: column; 131 | max-height: 50vh; 132 | } 133 | 134 | .side { 135 | order: 1; 136 | height: 104px; 137 | width: 100%; 138 | margin-top: 16px; 139 | } 140 | 141 | .small { 142 | margin: 0 8px; 143 | } 144 | 145 | .small-imgs { 146 | flex-direction: row; 147 | padding-bottom: 30px; 148 | overflow-y: hidden; 149 | overflow-x: auto; 150 | } 151 | 152 | .scroll-overlap, 153 | .scroll-indicator { 154 | width: 18px; 155 | height: 96px; 156 | } 157 | 158 | .scroll-indicator > svg { 159 | transform: rotate(270deg); 160 | } 161 | 162 | .scroll-indicator.start { 163 | left: 0; 164 | margin-right: -32px; 165 | } 166 | 167 | .scroll-indicator.end { 168 | right: 0; 169 | margin-left: -8px; 170 | } 171 | 172 | .scroll-overlap { 173 | position: relative; 174 | } 175 | 176 | .scroll-overlap.start { 177 | left: 14px; 178 | } 179 | 180 | .scroll-overlap.end { 181 | right: 16px; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/intermediate-img.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview This file is used to create an image for use in an `` to 19 | * `` animation. It is implemented in a way to allow for animating the 20 | * cropping of the rendered image. Once the animation is completed, the 21 | * intermediate image can be removed to show the destination `` instead. 22 | */ 23 | 24 | import {Size} from './size.js'; 25 | import {getRenderedDimensions} from './img-dimensions.js'; 26 | import {getPositioningTranslate} from './object-position.js'; 27 | 28 | /** 29 | * Creates a replacement for a given img, which should render the same as the 30 | * source img, but implemented with a cropping container and and img using 31 | * `object-fit: fill`. This can be used to implement a transition of the image. 32 | * The crop can be transitioned by scaling up the container while scaling down 33 | * the image by the inverse amount. 34 | * @param srcImg The source img. 35 | * @param srcImgRect The rect for `srcImg`. Can be provided if already 36 | * measured. 37 | * @param imageDimensions The dimensions for the rendered image. Can be 38 | * provided if already measured. 39 | * @return The replacement container along with structural information. 40 | */ 41 | export function createIntermediateImg( 42 | srcImg: HTMLImageElement, 43 | srcImgRect: ClientRect = srcImg.getBoundingClientRect(), 44 | srcCropRect: ClientRect = srcImgRect, 45 | imagePosition: string = getComputedStyle(srcImg).getPropertyValue('object-position'), 46 | imageDimensions: Size = getRenderedDimensions(srcImg, srcImgRect), 47 | ): { 48 | translateElement: HTMLElement, 49 | scaleElement: HTMLElement, 50 | counterScaleElement: HTMLElement, 51 | cropPositionContainer: HTMLElement, 52 | imgContainer: HTMLElement, 53 | img: HTMLImageElement, 54 | } { 55 | const positioningTranslate = getPositioningTranslate(imagePosition, srcImgRect, imageDimensions); 56 | const translateElement = document.createElement('div'); 57 | const scaleElement = document.createElement('div'); 58 | const counterScaleElement = document.createElement('div'); 59 | const cropPositionContainer = document.createElement('div'); 60 | const imgContainer = document.createElement('div'); 61 | const img = srcImg.cloneNode(true); 62 | 63 | img.className = ''; 64 | img.style.cssText = ''; 65 | imgContainer.appendChild(img); 66 | cropPositionContainer.appendChild(imgContainer); 67 | counterScaleElement.appendChild(cropPositionContainer); 68 | scaleElement.appendChild(counterScaleElement); 69 | translateElement.appendChild(scaleElement); 70 | 71 | Object.assign(scaleElement.style, { 72 | 'overflow': 'hidden', 73 | 'width': `${srcCropRect.width}px`, 74 | 'height': `${srcCropRect.height}px`, 75 | }); 76 | 77 | Object.assign(imgContainer.style, { 78 | 'transform': `translate(${positioningTranslate.left}px, ${positioningTranslate.top}px)`, 79 | }); 80 | 81 | Object.assign(img.style, { 82 | 'display': 'block', 83 | 'width': `${imageDimensions.width}px`, 84 | 'height': `${imageDimensions.height}px`, 85 | }); 86 | 87 | return { 88 | translateElement, 89 | scaleElement, 90 | counterScaleElement, 91 | cropPositionContainer, 92 | imgContainer, 93 | img, 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /src/transform-img/position-animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Curve, curveToString} from '../bezier-curve-utils.js'; 18 | import {Size} from '../size.js'; 19 | import {getPositioningTranslate} from '../object-position.js'; 20 | 21 | /** 22 | * Prepares an animation for position (i.e. for object-position). This function 23 | * sets up the animation by setting the appropriate style properties on the 24 | * desired Element. The returned style text needs to be inserted for the 25 | * animation to run. 26 | * @param options 27 | * @param options.element The element to apply the position to. 28 | * @param options.largerRect The larger of the start/end element container 29 | * rects. 30 | * @param options.largerDimensions The larger of the start/end element 31 | * dimensions. 32 | * @param options.smallerDimensions The smaller of the start/end element 33 | * dimensions. 34 | * @param options.largerObjectPosition The object position for the larger 35 | * element. 36 | * @param options.smallerObjectPosition The object position for the smaller 37 | * element. 38 | * @param options.curve The timing curve for the scaling. 39 | * @param options.style The styles to apply to `element`. 40 | * @param options.keyframesPrefix A prefix to use for the generated 41 | * keyframes to ensure they do not clash with existing keyframes. 42 | * @param options.toLarger Whether or not `largerImgDimensions` are the 43 | * dimensions are we are animating to. 44 | * @return CSS style text to perform the animation. 45 | */ 46 | export function preparePositionAnimation({ 47 | element, 48 | largerRect, 49 | smallerRect, 50 | largerDimensions, 51 | smallerDimensions, 52 | largerObjectPosition, 53 | smallerObjectPosition, 54 | curve, 55 | styles, 56 | keyframesPrefix, 57 | toLarger, 58 | } : { 59 | element: HTMLElement, 60 | largerRect: ClientRect, 61 | smallerRect: ClientRect, 62 | largerDimensions: Size, 63 | smallerDimensions: Size, 64 | largerObjectPosition: string, 65 | smallerObjectPosition: string, 66 | curve: Curve, 67 | styles: Object, 68 | keyframesPrefix: string, 69 | toLarger: boolean, 70 | }): string { 71 | const curveString = curveToString(curve); 72 | const keyframesName = `${keyframesPrefix}-object-position`; 73 | 74 | const largerTranslate = getPositioningTranslate( 75 | largerObjectPosition, largerRect, largerDimensions); 76 | const smallerTranslate = getPositioningTranslate( 77 | smallerObjectPosition, smallerRect, smallerDimensions); 78 | const startTranslate = toLarger ? smallerTranslate : largerTranslate; 79 | const endTranslate = toLarger ? largerTranslate : smallerTranslate; 80 | 81 | Object.assign(element.style, styles, { 82 | 'willChange': 'transform', 83 | 'animationName': keyframesName, 84 | 'animationTimingFunction': curveString, 85 | 'animationFillMode': 'forwards', 86 | }); 87 | 88 | return ` 89 | @keyframes ${keyframesName} { 90 | from { 91 | transform: translate(${startTranslate.left}px, ${startTranslate.top}px); 92 | } 93 | 94 | to { 95 | transform: translate(${endTranslate.left}px, ${endTranslate.top}px); 96 | } 97 | } 98 | `; 99 | } 100 | -------------------------------------------------------------------------------- /docs/demo/gallery/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 18 | 19 | const duration = 350; 20 | const curve = {x1: 0, y1: 0, x2: 0.2, y2: 1}; 21 | const styles = { 22 | animationDuration: `${duration}ms`, 23 | // Make sure the transition image appears on top of other things with 24 | // a z-index. Could also put the transition in a container that has a 25 | // z-index. 26 | zIndex: 1, 27 | }; 28 | const gallery = document.querySelector('.gallery'); 29 | const targetImg = document.querySelector('.large'); 30 | const targetImgSizes = targetImg.sizes; 31 | 32 | /** 33 | * Loads an img using a srcset, with a larger `size` value. This does not 34 | * actually set the `size` attribute on the img. 35 | */ 36 | function loadLargerImgSrc(img, largerSize) { 37 | if (img._preloaded) { 38 | return; 39 | } 40 | 41 | const dummyImg = new Image(); 42 | dummyImg.srcset = img.srcset; 43 | dummyImg.sizes = largerSize; 44 | 45 | img._preloaded = true; 46 | } 47 | 48 | /** 49 | * Preloads the larger size of the img. Should be called from 50 | * mousedown and touchstart. 51 | */ 52 | window.preloadImg = function(event) { 53 | const img = event.target.closest('img'); 54 | 55 | if (!img) { 56 | return; 57 | } 58 | 59 | loadLargerImgSrc(img, targetImgSizes); 60 | }; 61 | 62 | window.expand = function(event) { 63 | const srcImg = event.target.closest('img'); 64 | 65 | if (!srcImg || srcImg.hasAttribute('selected')) { 66 | return; 67 | } 68 | 69 | if(gallery.hasAttribute('transition')) { 70 | return; 71 | } 72 | 73 | // Use the same src as the smaller img during the transition. We want to 74 | // do the transition without needing to download the larger src first. 75 | // If possible, use the currentSrc as the src during the transition. If 76 | // not, use the same srcset/sizes as `srcImg`. The `currentSrc` works 77 | // around a Chrome behavior where an inflight preload of a higher resolution 78 | // src will be preferred over an already downloaded src that matches 79 | // the `sizes` attribute. 80 | if (srcImg.currentSrc) { 81 | targetImg.src = srcImg.currentSrc; 82 | targetImg.srcset = ''; 83 | targetImg.sizes = ''; 84 | } else { 85 | targetImg.src = ''; 86 | targetImg.srcset = srcImg.srcset; 87 | targetImg.sizes = srcImg.sizes; 88 | } 89 | 90 | const { 91 | applyAnimation, 92 | cleanupAnimation, 93 | } = prepareImageAnimation({ 94 | srcImg, 95 | targetImg, 96 | styles, 97 | curve, 98 | }); 99 | 100 | // Make sure to start loading the larger src now, if we have not already. 101 | loadLargerImgSrc(srcImg, targetImgSizes); 102 | 103 | gallery.setAttribute('transition', ''); 104 | gallery.querySelector('[selected]').removeAttribute('selected'); 105 | srcImg.setAttribute('selected', true); 106 | applyAnimation(); 107 | 108 | setTimeout(() => { 109 | gallery.removeAttribute('transition'); 110 | // Change over sizes (and srcset if not already) so that the browser 111 | // will switch to the higher resolution src once it becomes available. 112 | targetImg.srcset = srcImg.srcset; 113 | targetImg.sizes = targetImgSizes; 114 | cleanupAnimation(); 115 | }, duration + 100); 116 | } 117 | -------------------------------------------------------------------------------- /docs/tools/img-cropper/generate-markup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @param {{ 19 | * originalRect: !DOMRect, 20 | * croppedRect: !DOMRect, 21 | * imgSrc: string, 22 | * outputWidth: number, 23 | * }} config 24 | * @return {{ 25 | * amp: {css: string, html: string}, 26 | * html: {css: string, html: string}, 27 | * styles: {paddingBottom: string, transform: string}, 28 | * }} 29 | */ 30 | export function generateMarkup({originalRect, croppedRect, imgSrc, outputWidth}) { 31 | const { 32 | top, 33 | left, 34 | width, 35 | height, 36 | } = croppedRect; 37 | 38 | const widthRatio = originalRect.width / width; 39 | const heightRatio = originalRect.height / height; 40 | const aspectRatio = 100 * (height / width); 41 | 42 | const yDelta = top / originalRect.height; 43 | const yTranslate = 100 * yDelta; 44 | const xDelta = left / originalRect.width; 45 | const xTranslate = 100 * xDelta; 46 | 47 | const paddingBottom = `${aspectRatio.toFixed(4)}%`; 48 | const scale = `scale(${widthRatio.toFixed(4)}, ${heightRatio.toFixed(4)})`; 49 | const translate = `translate(-${xTranslate.toFixed(4)}%, -${yTranslate.toFixed(4)}%)`; 50 | const transform = `${scale} ${translate}`; 51 | 52 | return { 53 | amp: generateAmpMarkup(width, height, transform, imgSrc, outputWidth), 54 | html: generateHtmlMarkup(paddingBottom, transform, imgSrc, outputWidth), 55 | styles: { 56 | paddingBottom, 57 | transform, 58 | }, 59 | }; 60 | } 61 | 62 | /** 63 | * @param {number} width The responsive width for the amp-img. 64 | * @param {number} height The responsive height for the amp-img. 65 | * @param {string} transform The transform to apply to the img. 66 | * @param {string} imgSrc The src for the img. 67 | * @param {number} outputWidth The width for the rendered image. 68 | */ 69 | function generateAmpMarkup(width, height, transform, imgSrc, outputWidth) { 70 | const css = ` 71 | amp-img img { 72 | transform: var(--img-transform); 73 | transform-origin: top left; 74 | }`.slice(1); 75 | 76 | const html = ` 77 | 80 | `.slice(1); 81 | 82 | return { 83 | css, 84 | html, 85 | }; 86 | } 87 | 88 | /** 89 | 90 | * @param {string} paddingBottom The padding bottom for a responsive sizer. 91 | * @param {string} transform The transform to apply to the img. 92 | * @param {string} imgSrc The src for the img. 93 | * @param {number} outputWidth The width for the rendered image. 94 | */ 95 | function generateHtmlMarkup(paddingBottom, transform, imgSrc, outputWidth) { 96 | const css = ` 97 | .crop { 98 | position: relative; 99 | overflow: hidden; 100 | } 101 | 102 | .crop > img { 103 | position: absolute; 104 | top: 0; 105 | left: 0; 106 | width: 100%; 107 | height: 100%; 108 | transform-origin: top left; 109 | }`.slice(1); 110 | 111 | const html = ` 112 |
113 |
114 | 115 |
`.slice(1); 116 | 117 | return { 118 | css, 119 | html, 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /docs/demo/lightbox/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 18 | 19 | const lightbox = document.getElementById('lightbox'); 20 | const lightboxImgContainer = document.getElementById('lightboxImgContainer'); 21 | const lightboxBackground = document.getElementById('lightboxBackground'); 22 | const lightboxTransitionContainer = document.getElementById('lightboxTransitionContainer'); 23 | 24 | const duration = 400; 25 | const curve = {x1: 0.8, y1: 0, x2: 0.2, y2: 1}; 26 | const styles = { 27 | animationDuration: `${duration}ms`, 28 | zIndex: 1, 29 | }; 30 | 31 | /** 32 | * Gets the container to do the transition in. This is either the body 33 | * or a fixed position container, depending on if the body has overflow 34 | * or not. 35 | * 36 | * This is needed as doing the transition with position absolute can cause 37 | * overflow, causing the scrollbars to show up during the animation. 38 | * Depending on the OS, this can cause layout as the scrollbar shows/hides. 39 | * 40 | * Normally, we want to do the transition in the body, so that if the user 41 | * scrolls while the transition is in progress (while the lightbox is) 42 | * closing, the transition ends up in the right place. 43 | * 44 | * Note: you will want to prevent scrolling when the lightbox is opening 45 | * to prevent scrolling during the animation. This will make sure the 46 | * animation ends in the right place (and that the user does not scroll the 47 | * body when they do not intend to). 48 | */ 49 | function getTransitionContainer(show) { 50 | if (document.body.scrollHeight <= window.innerHeight) { 51 | return lightboxTransitionContainer; 52 | } 53 | 54 | return document.body; 55 | } 56 | 57 | function updateLightbox(srcImg) { 58 | const lightboxImg = document.createElement('img'); 59 | lightboxImg.className = 'lightbox-img'; 60 | lightboxImg.src = srcImg.src; 61 | lightboxImg._originalImg = srcImg; 62 | 63 | lightboxImgContainer.innerHTML = ''; 64 | lightboxImgContainer.appendChild(lightboxImg); 65 | 66 | return lightboxImg; 67 | } 68 | 69 | function transitionLightbox(srcImg, targetImg, show) { 70 | lightbox.hidden = false; 71 | lightboxTransitionContainer.hidden = false; 72 | 73 | const { 74 | applyAnimation, 75 | cleanupAnimation, 76 | } = prepareImageAnimation({ 77 | transitionContainer: getTransitionContainer(), 78 | srcImg, 79 | targetImg, 80 | styles, 81 | curve, 82 | }); 83 | 84 | srcImg.setAttribute('lightbox-transition', ''); 85 | targetImg.setAttribute('lightbox-transition', ''); 86 | lightboxBackground.setAttribute('lightbox-fade', show ? 'in' : 'out'); 87 | lightboxBackground.style.animationDuration = `${duration}ms`; 88 | applyAnimation(); 89 | 90 | setTimeout(() => { 91 | lightbox.hidden = !show; 92 | lightboxTransitionContainer.hidden = true; 93 | srcImg.removeAttribute('lightbox-transition'); 94 | targetImg.removeAttribute('lightbox-transition'); 95 | lightboxBackground.removeAttribute('lightbox-fade'); 96 | cleanupAnimation(); 97 | }, duration + 100); 98 | } 99 | 100 | window.showLightbox = function(event) { 101 | const srcImg = event.currentTarget; 102 | const targetImg = updateLightbox(srcImg); 103 | 104 | transitionLightbox(srcImg, targetImg, true); 105 | } 106 | 107 | window.hideLightbox = function(event) { 108 | const srcImg = lightboxImgContainer.querySelector('img'); 109 | const targetImg = srcImg._originalImg; 110 | 111 | transitionLightbox(srcImg, targetImg, false); 112 | } 113 | -------------------------------------------------------------------------------- /docs/tools/img-cropper/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import './dragger.js'; 18 | import './dropper.js'; 19 | import {generateMarkup} from './generate-markup.js'; 20 | import {prepareImageAnimation} from '../../../dist/animations.mjs'; 21 | 22 | const imgContainer = document.querySelector('.src-img-container'); 23 | const origImg = document.querySelector('.target'); 24 | const imgCrop = document.querySelector('.crop'); 25 | const sizer = document.querySelector('.sizer'); 26 | const croppedImg = document.querySelector('.crop img'); 27 | let selectedWidth = 96; 28 | let renderedWidth = "96px"; 29 | let useSelectedWidth = true; 30 | let coords = {}; 31 | 32 | /** 33 | * @return {number} The width to use to size the output image. 34 | */ 35 | function getWidth() { 36 | if (useSelectedWidth) { 37 | return `${selectedWidth}px`; 38 | } 39 | 40 | return renderedWidth; 41 | } 42 | 43 | /** 44 | * Updates the selected portion of the image using the current coordinates. 45 | */ 46 | function updateSelection() { 47 | selectedWidth = coords.width; 48 | 49 | const outputWidth = getWidth(); 50 | const { 51 | amp, 52 | html, 53 | styles: { 54 | paddingBottom, 55 | transform, 56 | }, 57 | } = generateMarkup({ 58 | originalRect: origImg.getBoundingClientRect(), 59 | croppedRect: coords, 60 | imgSrc: '…', 61 | outputWidth, 62 | }); 63 | 64 | document.querySelector('.amp-markup .css').textContent = amp.css; 65 | document.querySelector('.amp-markup .html').textContent = amp.html; 66 | document.querySelector('.html-markup .css').textContent = html.css; 67 | document.querySelector('.html-markup .html').textContent = html.html; 68 | 69 | // Update the preview image. 70 | sizer.style.paddingBottom = paddingBottom; 71 | croppedImg.style.transform = transform; 72 | imgCrop.style.width = outputWidth; 73 | } 74 | 75 | // Handles newly selected coordinates. 76 | window.addEventListener('area-selected', event => { 77 | croppedImg.src = origImg.src; 78 | coords = event.detail; 79 | updateSelection(); 80 | }); 81 | 82 | // Updates the preview image to use the width from the input field. 83 | window.adjustWidth = function(newWidth) { 84 | renderedWidth = newWidth; 85 | updateSelection(); 86 | }; 87 | 88 | // Updates the preview image to use the width from the selection. 89 | window.useSelectedWidth = function(value) { 90 | useSelectedWidth = value; 91 | updateSelection(); 92 | }; 93 | 94 | // Plays a preview of what the animation looks like. 95 | window.playAnimation = function() { 96 | const animation = prepareImageAnimation({ 97 | srcImg: croppedImg, 98 | srcCropRect: imgCrop.getBoundingClientRect(), 99 | targetImg: origImg, 100 | styles: { 101 | animationDuration: '1000ms', 102 | }, 103 | }); 104 | 105 | imgContainer.style.visibility = 'hidden'; 106 | croppedImg.style.visibility = 'hidden'; 107 | animation.applyAnimation(); 108 | requestAnimationFrame(() => { 109 | setTimeout(() => { 110 | setTimeout(() => { 111 | imgContainer.style.visibility = 'visible'; 112 | croppedImg.style.visibility = 'visible'; 113 | animation.cleanupAnimation(); 114 | }, 1000); 115 | }); 116 | }); 117 | }; 118 | 119 | // Updates which output format is shown in the tool. 120 | window.updateOutputFormat = function(event) { 121 | const isAmp = event.target.id == 'ampHtml'; 122 | const query = isAmp ? '?output=amp' : ''; 123 | const url = location.origin + location.pathname + query; 124 | history.replaceState({}, '', url); 125 | } 126 | 127 | // Update the output format checkbox based on the initial query string. 128 | const ampOutput = location.search.includes('output=amp'); 129 | if (ampOutput) { 130 | document.querySelector('#ampHtml').checked = true; 131 | } -------------------------------------------------------------------------------- /src/test-object-position.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {getPositioningTranslate} from './object-position.js'; 18 | 19 | const {expect} = chai; 20 | 21 | describe('getPositioningTranslate', () => { 22 | function getComputedObjectPosition(position) { 23 | const div = document.createElement('div'); 24 | div.style.setProperty('object-position', position); 25 | 26 | document.body.appendChild(div); 27 | const computed = getComputedStyle(div).getPropertyValue('object-position'); 28 | document.body.removeChild(div); 29 | 30 | return computed; 31 | } 32 | 33 | it('should return the correct value for center', () => { 34 | const position = getComputedObjectPosition('center center'); 35 | const {top, left} = getPositioningTranslate(position, { 36 | width: 100, 37 | height: 100, 38 | }, { 39 | width: 75, 40 | height: 50, 41 | }); 42 | 43 | expect(left).to.equal(12.5); // (100 - 75) / 2 44 | expect(top).to.equal(25); // (100 - 50) / 2 45 | }); 46 | 47 | describe('top/left', () => { 48 | it('should return the correct value', () => { 49 | const position = getComputedObjectPosition('top left'); 50 | const {top, left} = getPositioningTranslate(position, { 51 | width: 100, 52 | height: 100, 53 | }, { 54 | width: 75, 55 | height: 50, 56 | }); 57 | 58 | expect(left).to.equal(0); 59 | expect(top).to.equal(0); 60 | }); 61 | 62 | it('should return the correct value for px offsets', () => { 63 | const position = getComputedObjectPosition('top 2px left 2px'); 64 | const {top, left} = getPositioningTranslate(position, { 65 | width: 100, 66 | height: 100, 67 | }, { 68 | width: 75, 69 | height: 50, 70 | }); 71 | 72 | expect(left).to.equal(2); // 0 + 2 73 | expect(top).to.equal(2); // 0 + 2 74 | }); 75 | 76 | it('should return the correct value for calc offsets', () => { 77 | const position = getComputedObjectPosition( 78 | 'top calc(10% - 2px) left calc(10% - 2px)'); 79 | const {top, left} = getPositioningTranslate(position, { 80 | width: 100, 81 | height: 100, 82 | }, { 83 | width: 75, 84 | height: 50, 85 | }); 86 | 87 | expect(left).to.equal(0.5); // 0.1 * (100 - 75) - 2 88 | expect(top).to.equal(3); // 0.1 * (100 - 50) - 2 89 | }); 90 | }); 91 | 92 | describe('bottom/right', () => { 93 | it('should return the correct value', () => { 94 | const position = getComputedObjectPosition('bottom right'); 95 | const {top, left} = getPositioningTranslate(position, { 96 | width: 100, 97 | height: 100, 98 | }, { 99 | width: 75, 100 | height: 50, 101 | }); 102 | 103 | expect(left).to.equal(25); // 100 - 75 104 | expect(top).to.equal(50); // 100 - 50 105 | }); 106 | 107 | it('should return the correct value for px offsets', () => { 108 | const position = getComputedObjectPosition('bottom -2px right -2px'); 109 | const {top, left} = getPositioningTranslate(position, { 110 | width: 100, 111 | height: 100, 112 | }, { 113 | width: 75, 114 | height: 50, 115 | }); 116 | 117 | expect(left).to.equal(27); // 100 - 75 + 2 118 | expect(top).to.equal(52); // 100 - 50 + 2 119 | }); 120 | 121 | it('should return the correct value for calc offsets', () => { 122 | const position = getComputedObjectPosition( 123 | 'bottom calc(10% + 2px) right calc(10% + 2px)'); 124 | const {top, left} = getPositioningTranslate(position, { 125 | width: 100, 126 | height: 100, 127 | }, { 128 | width: 75, 129 | height: 50, 130 | }); 131 | 132 | expect(left).to.equal(20.5); // 0.9 * (100 - 75) - 2 133 | expect(top).to.equal(43); // 0.9 * (100 - 50) - 2 134 | }); 135 | }); 136 | }); -------------------------------------------------------------------------------- /docs/demo/expand/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |

26 | Click on any of the images to toggle expand/collapse. Note that the initial state is positioned differently for each. 27 |

28 | 29 |
30 | 31 | 32 |
33 | Aligned along the left edge of the image. This expands the crop right, while scaling up the height. The initial state uses object-position: top left. 34 |
35 |
36 | 37 |

38 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. 39 |

40 | 41 |
42 | 43 | 44 |
45 | Aligned along the left edge of the image, offset by 8px. The initial state uses object-position: top left -8px;. 46 |
47 |
48 | 49 |

50 | Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? 51 |

52 | 53 |
54 | 55 | 56 |
57 | Aligned on the center of the image. This is the default behavior for object-position. 58 |
59 |
60 | 61 |

62 | Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 63 |

64 | 65 |
66 | 67 | 68 |
69 | Aligned on the right edge of the image. This expands the crop left, while moving the image to the right to align with the end state. 70 |
71 |
72 | 73 |

74 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 75 |

76 |
77 | 78 | 79 | -------------------------------------------------------------------------------- /docs/demo/lightbox/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |

26 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 27 |

28 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 29 |

30 | 31 | 37 | 38 |

39 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 40 |

41 | 42 | 48 | 49 |

50 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 51 |

52 |
53 | 54 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/img-dimensions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview Provides a function to calculate the dimensions of the 19 | * rendered image inside of an `` Element. For example, if you have an 20 | * `` with `object-fit: contain` and an image that is portrait inside of 21 | * an `` with landscape dimensions, you will have something looks like: 22 | * _____________ 23 | * | | | | 24 | * | i | r | i | 25 | * |___|_____|___| 26 | * 27 | * Where the area denoted by `r` is the rendered image and the areas denoted 28 | * by `i` are extra spacing on either side of the rendered image to center it 29 | * within the containing `` Element. 30 | * @see https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit 31 | */ 32 | 33 | import {Size} from './size.js'; 34 | 35 | /** 36 | * Constrains the size of the image to the given width and height. This either 37 | * caps the width or the height depending on the aspect ratio of original img 38 | * and if we want to have the smaller or larger dimension fit the container. 39 | * @param naturalSize The natural dimensions of the image. 40 | * @param containerSize The size of the container we want to render the image 41 | * in. 42 | * @param toMin If we should cap the smaller dimension of the image to fit the 43 | * container (`object-fit: cover`) or the larger dimension 44 | * (`object-fit: contain`). 45 | * @return The Size that the image should be rendered as. 46 | */ 47 | function constrain( 48 | naturalSize: Size, containerSize: Size, toMin: boolean): Size { 49 | const elAspectRatio = containerSize.width / containerSize.height; 50 | const naturalAspectRatio = naturalSize.width / naturalSize.height; 51 | 52 | if (naturalAspectRatio > elAspectRatio !== toMin) { 53 | return { 54 | width: containerSize.height * naturalAspectRatio, 55 | height: containerSize.height, 56 | }; 57 | } 58 | 59 | return { 60 | width: containerSize.width, 61 | height: containerSize.width / naturalAspectRatio, 62 | }; 63 | } 64 | 65 | function getDimensionsForObjectFitCover( 66 | naturalSize: Size, containerSize: Size): Size { 67 | return constrain(naturalSize, containerSize, false); 68 | } 69 | 70 | function getDimensionsForObjectFitContain( 71 | naturalSize: Size, containerSize: Size): Size { 72 | return constrain(naturalSize, containerSize, true); 73 | } 74 | 75 | function getDimensionsForObjectFitFill(containerSize: Size): Size { 76 | return containerSize; 77 | } 78 | 79 | function getDimensionsForObjectFitNone(naturalSize: Size): Size { 80 | return naturalSize; 81 | } 82 | 83 | function getDimensionsForObjectFitScaleDown( 84 | naturalSize: Size, containerSize: Size): Size { 85 | const noneSize = getDimensionsForObjectFitNone(naturalSize); 86 | const containSize = getDimensionsForObjectFitContain( 87 | naturalSize, containerSize); 88 | 89 | // Since both have the same aspect ratio, we can simply take the smaller 90 | // dimension for both. 91 | return { 92 | width: Math.min(noneSize.width, containSize.width), 93 | height: Math.min(noneSize.height, containSize.height), 94 | }; 95 | } 96 | 97 | /** 98 | * Gets the dimensions for the rendered "image" rather than the container 99 | * that constrains the size with the CSS `object-fit` property. 100 | * @param img The HTMLImageElement 101 | * @param containerSize The size of the container element. 102 | * @param objectFit An optional object-fit value to use. Defaults to the 103 | * `img`'s current `object-fit`. 104 | * @return The width/height of the "actual" image. 105 | */ 106 | export function getRenderedDimensions( 107 | img: HTMLImageElement, 108 | containerSize: Size, 109 | objectFit: string|null = getComputedStyle(img).getPropertyValue('object-fit'), 110 | ): Size { 111 | const naturalSize = { 112 | width: img.naturalWidth, 113 | height: img.naturalHeight, 114 | }; 115 | 116 | switch(objectFit) { 117 | case 'cover': 118 | return getDimensionsForObjectFitCover(naturalSize, containerSize); 119 | case 'contain': 120 | return getDimensionsForObjectFitContain(naturalSize, containerSize); 121 | case 'fill': 122 | return getDimensionsForObjectFitFill(containerSize); 123 | case 'none': 124 | return getDimensionsForObjectFitNone(naturalSize); 125 | case 'scale-down': 126 | return getDimensionsForObjectFitScaleDown(naturalSize, containerSize); 127 | case '': 128 | case null: 129 | // For browsers that do not support `object-fit`, default to `fill` 130 | // behavior. 131 | return getDimensionsForObjectFitFill(containerSize); 132 | default: 133 | throw new Error(`object-fit: ${objectFit} not supported`); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /docs/tools/img-cropper/dragger.js: -------------------------------------------------------------------------------- 1 | const srcImgContainer = document.querySelector('.src-img-container'); 2 | const selectionContainer = document.querySelector('.selection-container'); 3 | const rect = document.querySelector('.selection-rect'); 4 | const targetImg = document.querySelector('.target'); 5 | const draggers = [0, 0, 0, 0].map(() => { 6 | return createDragger(srcImgContainer); 7 | }); 8 | 9 | let dragger = draggers[2]; 10 | let mousedown = false; 11 | let x0 = 0 12 | let y0 = 0; 13 | let x1 = x0 + 96; 14 | let y1 = y0 + 96; 15 | let lastX = 0; 16 | let lastY = 0; 17 | 18 | /** 19 | * @param {number} min 20 | * @param {number} value 21 | * @param {number} max 22 | * @return {number} 23 | */ 24 | function bound(min, value, max) { 25 | return Math.max(min, Math.min(value, max)); 26 | } 27 | 28 | /** 29 | * Gets the Rect for the currently selected coordinates. 30 | * @return {{ 31 | * top: number, 32 | * bottom: number, 33 | * left: number, 34 | * right: number, 35 | * width: number, 36 | * height: number, 37 | * }} 38 | */ 39 | function getSelectedRect() { 40 | return { 41 | top: Math.min(y0, y1), 42 | bottom: Math.max(y0, y1), 43 | left: Math.min(x0, x1), 44 | right: Math.max(x0, x1), 45 | width: Math.abs(x0 - x1), 46 | height: Math.abs(y0 - y1), 47 | }; 48 | } 49 | 50 | /** 51 | * Notifies interested parties that a new area was selected. 52 | */ 53 | function notifySelected() { 54 | window.dispatchEvent(new CustomEvent('area-selected', { 55 | detail: getSelectedRect(), 56 | })); 57 | } 58 | 59 | /** 60 | * Handles a mousedown on a dragger, setting it as the current dragger. 61 | */ 62 | function mousedownDragger() { 63 | mousedown = true; 64 | 65 | dragger = event.target; 66 | event.stopPropagation(); 67 | } 68 | 69 | /** 70 | * Creates a dragger and appends it to the container. 71 | * @param {!Element} container 72 | */ 73 | function createDragger(container) { 74 | const div = document.createElement('div'); 75 | div.className = 'dragger'; 76 | div.onmousedown = mousedownDragger; 77 | container.appendChild(div); 78 | return div; 79 | } 80 | 81 | /** 82 | * Updates the UI (the draggers and the highlight rect) for the currently 83 | * selected coordinates. 84 | */ 85 | function updateSelected() { 86 | const { 87 | top, 88 | left, 89 | width, 90 | height, 91 | } = getSelectedRect(); 92 | draggers[0].style.transform = `translate(${x0}px, ${y0}px)`; 93 | draggers[1].style.transform = `translate(${x1}px, ${y0}px)`; 94 | draggers[2].style.transform = `translate(${x1}px, ${y1}px)`; 95 | draggers[3].style.transform = `translate(${x0}px, ${y1}px)`; 96 | rect.style.left = `${left}px`; 97 | rect.style.top = `${top}px`; 98 | rect.style.width = `${width}px`; 99 | rect.style.height = `${height}px`; 100 | } 101 | 102 | /** 103 | * Updates the current coordinates based on the last mouse location and whether 104 | * or not the shift key is pressed. When the shift key is pressed, a 1:1 aspect 105 | * ration is forced. 106 | * @param {boolean} shiftKey 107 | */ 108 | function updateCoordinates(shiftKey) { 109 | const draggerIndex = draggers.indexOf(dragger); 110 | const initialRect = targetImg.getBoundingClientRect(); 111 | const startX = draggerIndex == 0 || draggerIndex == 3 ? x1 : x0; 112 | const startY = draggerIndex == 0 || draggerIndex == 1 ? y1 : y0; 113 | const x = lastX - initialRect.left; 114 | const y = lastY - initialRect.top; 115 | const targetRect = targetImg.getBoundingClientRect(); 116 | const boundX = bound(0, x, targetRect.width); 117 | const boundY = bound(0, y, targetRect.height); 118 | 119 | let xDelta = startX - boundX; 120 | let yDelta = startY - boundY; 121 | 122 | // Lock aspect ratio to 1:1. 123 | if (shiftKey) { 124 | const smallerSize = Math.min(Math.abs(xDelta), Math.abs(yDelta)); 125 | xDelta = bound(-smallerSize, xDelta, smallerSize); 126 | yDelta = bound(-smallerSize, yDelta, smallerSize); 127 | } 128 | 129 | // Based on which dragger is moving, update the correct coordinates. 130 | switch(draggerIndex) { 131 | case 0: 132 | x0 = x1 - xDelta; 133 | y0 = y1 - yDelta; 134 | break; 135 | case 1: 136 | x1 = x0 - xDelta; 137 | y0 = y1 - yDelta; 138 | break; 139 | case 2: 140 | x1 = x0 - xDelta; 141 | y1 = y0 - yDelta; 142 | break; 143 | case 3: 144 | x0 = x1 - xDelta; 145 | y1 = y0 - yDelta; 146 | break; 147 | } 148 | 149 | updateSelected(); 150 | event.preventDefault(); 151 | } 152 | 153 | function resetSelection() { 154 | x0 = 0 155 | y0 = 0; 156 | x1 = x0 + 96; 157 | y1 = y0 + 96; 158 | 159 | updateSelected(); 160 | notifySelected(); 161 | } 162 | 163 | targetImg.addEventListener('load', () => { 164 | resetSelection(); 165 | }); 166 | 167 | window.addEventListener('mousedown', event => { 168 | const target = event.target.closest('.target'); 169 | 170 | if (!target) { 171 | return; 172 | } 173 | 174 | const initialRect = targetImg.getBoundingClientRect(); 175 | const x = event.x - initialRect.left; 176 | const y = event.y - initialRect.top; 177 | 178 | dragger = draggers[2]; 179 | mousedown = true; 180 | x0 = x; 181 | y0 = y; 182 | x1 = x0; 183 | y1 = y0; 184 | 185 | updateSelected(); 186 | event.preventDefault(); 187 | }); 188 | 189 | window.addEventListener('mouseup', event => { 190 | if (!mousedown) { 191 | return; 192 | } 193 | 194 | mousedown = false; 195 | notifySelected(); 196 | }); 197 | 198 | 199 | window.addEventListener('keydown', event => { 200 | if (!mousedown) { 201 | return; 202 | } 203 | 204 | updateCoordinates(event.shiftKey); 205 | }); 206 | 207 | window.addEventListener('keyup', event => { 208 | if (!mousedown) { 209 | return; 210 | } 211 | 212 | updateCoordinates(event.shiftKey); 213 | }); 214 | 215 | window.addEventListener('mousemove', event => { 216 | if (!mousedown) { 217 | return; 218 | } 219 | 220 | lastX = event.x; 221 | lastY = event.y; 222 | 223 | updateCoordinates(event.shiftKey); 224 | }); 225 | 226 | setTimeout(() => { 227 | resetSelection(); 228 | }); -------------------------------------------------------------------------------- /src/test-img-dimensions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {getRenderedDimensions} from './img-dimensions.js'; 18 | import {imgLoadPromise} from './testing/utils.js'; 19 | 20 | const {expect} = chai; 21 | const fourByThreeUri = 'data:image/gif;base64,R0lGODdhBAADAIAAAP///////ywAAAAABAADAAACA4SPVgA7'; 22 | const threeByFourUri = 'data:image/gif;base64,R0lGODdhAwAEAIAAAP///////ywAAAAAAwAEAAACA4SPVgA7'; 23 | 24 | async function updateImg(img, fit, src) { 25 | img.style.objectFit = fit; 26 | img.src = src; 27 | await imgLoadPromise(img); 28 | } 29 | 30 | function dimensions(width, height) { 31 | return { 32 | width, 33 | height, 34 | }; 35 | } 36 | 37 | describe('getDimensions', () => { 38 | let img; 39 | 40 | beforeEach(() => { 41 | img = document.createElement('img'); 42 | document.body.appendChild(img); 43 | }); 44 | 45 | afterEach(() => { 46 | document.body.removeChild(img); 47 | }); 48 | 49 | describe('object-fit: none', () => { 50 | it('should return the correct dimensions when the natural size is smaller', async () => { 51 | await updateImg(img, 'none', threeByFourUri); 52 | 53 | const {width, height} = getRenderedDimensions(img, dimensions(10, 10)); 54 | expect(width).to.equal(3); 55 | expect(height).to.equal(4); 56 | }); 57 | 58 | it('should return the correct dimensions when natural size is larger', async () => { 59 | await updateImg(img, 'none', threeByFourUri); 60 | 61 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 62 | expect(width).to.equal(3); 63 | expect(height).to.equal(4); 64 | }); 65 | }); 66 | 67 | describe('object-fit: fill', () => { 68 | it('should return the requested dimensions', async () => { 69 | await updateImg(img, 'fill', threeByFourUri); 70 | 71 | const {width, height} = getRenderedDimensions(img, dimensions(10, 10)); 72 | expect(width).to.equal(10); 73 | expect(height).to.equal(10); 74 | }); 75 | }); 76 | 77 | describe('unsupported object-fit', () => { 78 | it('should return the requested dimensions for blank', async () => { 79 | await updateImg(img, 'fill', threeByFourUri); 80 | 81 | const {width, height} = getRenderedDimensions(img, dimensions(10, 10), ''); 82 | expect(width).to.equal(10); 83 | expect(height).to.equal(10); 84 | }); 85 | 86 | it('should return the requested dimensions for null', async () => { 87 | await updateImg(img, 'fill', threeByFourUri); 88 | 89 | const {width, height} = getRenderedDimensions(img, dimensions(10, 10), null); 90 | expect(width).to.equal(10); 91 | expect(height).to.equal(10); 92 | }); 93 | }); 94 | 95 | describe('object-fit: contain', () => { 96 | it('should return the correct dimensions when constrained by width', async () => { 97 | await updateImg(img, 'contain', threeByFourUri); 98 | 99 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 100 | expect(width).to.equal(3/2); 101 | expect(height).to.equal(2); 102 | }); 103 | 104 | it('should return the correct dimensions when constrained by height', async () => { 105 | await updateImg(img, 'contain', fourByThreeUri); 106 | 107 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 108 | expect(width).to.equal(2); 109 | expect(height).to.equal(3/2); 110 | }); 111 | 112 | it('should return the correct dimensions not constrained', async () => { 113 | await updateImg(img, 'contain', fourByThreeUri); 114 | 115 | const {width, height} = getRenderedDimensions(img, dimensions(4, 3)); 116 | expect(width).to.equal(4); 117 | expect(height).to.equal(3); 118 | }); 119 | }); 120 | 121 | describe('object-fit: cover', () => { 122 | it('should return the correct dimensions when constrained by width', async () => { 123 | await updateImg(img, 'cover', threeByFourUri); 124 | 125 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 126 | expect(width).to.equal(2); 127 | expect(height).to.equal(8/3); 128 | }); 129 | 130 | it('should return the correct dimensions when constrained by height', async () => { 131 | await updateImg(img, 'cover', fourByThreeUri); 132 | 133 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 134 | expect(width).to.equal(8/3); 135 | expect(height).to.equal(2); 136 | }); 137 | 138 | it('should return the correct dimensions not constrained', async () => { 139 | await updateImg(img, 'cover', fourByThreeUri); 140 | 141 | const {width, height} = getRenderedDimensions(img, dimensions(4, 3)); 142 | expect(width).to.equal(4); 143 | expect(height).to.equal(3); 144 | }); 145 | }); 146 | 147 | describe('object-fit: scale-down', () => { 148 | it('should return the correct dimensions requested size is larger', async () => { 149 | await updateImg(img, 'scale-down', threeByFourUri); 150 | 151 | const {width, height} = getRenderedDimensions(img, dimensions(10, 10)); 152 | expect(width).to.equal(3); 153 | expect(height).to.equal(4); 154 | }); 155 | 156 | it('should return the correct dimensions when constrained by width', async () => { 157 | await updateImg(img, 'scale-down', threeByFourUri); 158 | 159 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 160 | expect(width).to.equal(3/2); 161 | expect(height).to.equal(2); 162 | }); 163 | 164 | it('should return the correct dimensions when constrained by height', async () => { 165 | await updateImg(img, 'scale-down', fourByThreeUri); 166 | 167 | const {width, height} = getRenderedDimensions(img, dimensions(2, 2)); 168 | expect(width).to.equal(2); 169 | expect(height).to.equal(3/2); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /src/transform-img/crop-animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Curve, getCubicBezierCurveValue} from '../bezier-curve-utils.js'; 18 | import {Scale, divideSizes} from '../size.js'; 19 | 20 | /** 21 | * Number of samples to use when generating the keyframes. The amount of error 22 | * for scale * counter scale when interpolating by the number of samples: 23 | * 24 | * 10: ~1.8% 25 | * 20: ~0.4% 26 | * 30: ~0.2% 27 | * 28 | * We want to keep the number of samples low with an acceptable 29 | * (non-perceivable) amount of error. 30 | */ 31 | const numSamples = 20; 32 | 33 | /** 34 | * Interpolates a value x% between a and b. 35 | * @param a The start point. 36 | * @param b The end point. 37 | * @param x A percentage of the way between `a` to `b`. 38 | */ 39 | function interpolate(a: number, b: number, x: number): number { 40 | return a + x * (b - a); 41 | } 42 | 43 | /** 44 | * Generates a CSS stylesheet for animating between two images. 45 | * @param options 46 | * @param options.startScale The starting scale for the animation. 47 | * @param options.endScale The ending scale for the animation. 48 | * @param options.curve The timing curve for how the crop should expand or 49 | * contract. 50 | * @param options.scaleKeyframesName The names for the scaling keyframes. 51 | * @param options.counterScaleKeyframesName The names for the counter-scaling 52 | * keyframes. 53 | * @return CSS style text to perform the aniamtion. 54 | */ 55 | function generateCropKeyframes({ 56 | startScale, 57 | endScale, 58 | curve, 59 | scaleKeyframesName, 60 | counterScaleKeyframesName, 61 | } : { 62 | startScale: Scale, 63 | endScale: Scale, 64 | curve: Curve, 65 | scaleKeyframesName: string, 66 | counterScaleKeyframesName: string, 67 | }): string { 68 | let scaleElementKeyframes = ''; 69 | let counterScaleKeyframes = ''; 70 | 71 | /* 72 | * Generates keyframes for the browser to interpolate from. We simply need to 73 | * make sure there are enough for this to be smooth. Note: we are generating 74 | * keyframes as a function of `t` in the Bezier curve formula and not time. 75 | * The keyframes generated will be more clustered when the output (y) value 76 | * is more rapidly changing, so we should not have too much error no matter 77 | * which two keyframes the browser interpolates between. 78 | */ 79 | for (let i = 0; i <= numSamples; i++) { 80 | const t = i * (1 / numSamples); 81 | // The progress through the animation at this point. 82 | const px = getCubicBezierCurveValue(curve.x1, curve.x2, t); 83 | // The output percentage at this point. 84 | const py = getCubicBezierCurveValue(curve.y1, curve.y2, t); 85 | const keyframePercentage = px * 100; 86 | const scaleX = interpolate(startScale.x, endScale.x, py); 87 | const scaleY = interpolate(startScale.y, endScale.y, py); 88 | const counterScaleX = 1 / scaleX; 89 | const counterScaleY = 1 / scaleY; 90 | 91 | scaleElementKeyframes += `${keyframePercentage}% { 92 | transform: scale(${scaleX}, ${scaleY}); 93 | }`; 94 | 95 | counterScaleKeyframes += `${keyframePercentage}% { 96 | transform: scale(${counterScaleX}, ${counterScaleY}); 97 | }`; 98 | } 99 | 100 | return ` 101 | @keyframes ${scaleKeyframesName} { 102 | ${scaleElementKeyframes} 103 | } 104 | 105 | @keyframes ${counterScaleKeyframesName} { 106 | ${counterScaleKeyframes} 107 | } 108 | `; 109 | } 110 | 111 | /** 112 | * Prepares a crop animation. This is done by scaling up the croping container 113 | * while scaling down a nested container to preserve the scale of the inner 114 | * content. This function sets up the animation by setting the appropriate 115 | * style properties on the desired Elements. The returned style text needs 116 | * to be inserted for the animation to run. 117 | * @param options 118 | * @param options.scaleElement The element to apply the scaling to. This should 119 | * have `overflow: hidden`, 120 | * @param options.counterScaleElement The element to counteract the scaling. 121 | * This should be a child of `scaleElement`. 122 | * @param options.largerRect The larger of the start/end cropping rects. 123 | * @param options.smallerRect The smaller of the start/end cropping rects. 124 | * @param options.curve The timing curve for how the crop should expand or 125 | * contract. 126 | * @param options.style The styles to apply to both the `scaleElement` and 127 | * `counterScaleElement`. 128 | * @param options.keyframesPrefix A prefix to use for the generated 129 | * keyframes to ensure they do not clash with existing keyframes. 130 | * @param options.toLarger Whether or not `largerRect` is the rect we are 131 | * animating to. 132 | * @return CSS style text to perform the animation. 133 | */ 134 | export function prepareCropAnimation({ 135 | scaleElement, 136 | counterScaleElement, 137 | largerRect, 138 | smallerRect, 139 | curve, 140 | styles, 141 | keyframesPrefix, 142 | toLarger, 143 | } : { 144 | scaleElement: HTMLElement, 145 | counterScaleElement: HTMLElement, 146 | largerRect: ClientRect, 147 | smallerRect: ClientRect, 148 | curve: Curve, 149 | styles: Object, 150 | keyframesPrefix: string, 151 | toLarger: boolean, 152 | }): string { 153 | const scaleKeyframesName = `${keyframesPrefix}-crop`; 154 | const counterScaleKeyframesName = `${keyframesPrefix}-counterScale`; 155 | 156 | const scaleDown = divideSizes(smallerRect, largerRect); 157 | const neutralScale = {x: 1, y: 1}; 158 | const startScale = toLarger ? scaleDown : neutralScale; 159 | const endScale = toLarger ? neutralScale : scaleDown; 160 | 161 | Object.assign(scaleElement.style, styles, { 162 | 'willChange': 'transform', 163 | 'transformOrigin': 'top left', 164 | 'animationName': scaleKeyframesName, 165 | 'animationTimingFunction': 'linear', 166 | 'animationFillMode': 'forwards', 167 | }); 168 | 169 | Object.assign(counterScaleElement.style, styles, { 170 | 'willChange': 'transform', 171 | 'transformOrigin': 'top left', 172 | 'animationName': counterScaleKeyframesName, 173 | 'animationTimingFunction': 'linear', 174 | 'animationFillMode': 'forwards', 175 | }); 176 | 177 | return generateCropKeyframes({ 178 | startScale, 179 | endScale, 180 | curve, 181 | scaleKeyframesName, 182 | counterScaleKeyframesName, 183 | }); 184 | } 185 | -------------------------------------------------------------------------------- /docs/demo/hero/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 |

28 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 29 |

30 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 31 |

32 | 33 | 39 | 40 |

41 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 42 |

43 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 44 |

45 |
46 |
47 |
48 | 49 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /docs/demo/zoom-crop/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 |

28 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 29 |

30 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 31 |

32 | 33 | 41 | 42 |

43 | See the image crop calculator to generate the markup needed for the initial crop. 44 |

45 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? 46 |

47 | At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. 48 |

49 |
50 |
51 |
52 | 53 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/testing/test-animation-test-controller.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { 18 | setup as setupAnimations, 19 | tearDown as tearDownAnimations, 20 | offset, 21 | } from './animation-test-controller.js'; 22 | 23 | const {expect} = chai; 24 | 25 | describe('Animation test controller', () => { 26 | let container; 27 | 28 | before(() => { 29 | const style = document.createElement('style'); 30 | style.textContent = ` 31 | .animate, 32 | .animate-after::after, 33 | .animate-before::before, 34 | .animate-backdrop::backdrop { 35 | animation: 1000ms foo infinite; 36 | } 37 | 38 | .delayed, 39 | .delayed-after::after, 40 | .delayed-before::before, 41 | .delayed-backdrop::backdrop { 42 | animation-delay: 125ms; 43 | } 44 | 45 | @keyframes { 46 | from: { 47 | opacity: 0; 48 | } 49 | 50 | to: { 51 | opacity: 1; 52 | } 53 | } 54 | `; 55 | 56 | document.head!.appendChild(style); 57 | }); 58 | 59 | beforeEach(() => { 60 | container = document.createElement('div'); 61 | document.body.appendChild(container); 62 | }); 63 | 64 | afterEach(() => { 65 | document.body.removeChild(container); 66 | }); 67 | 68 | describe('document', () => { 69 | it('should pause animations on existing Elements', () => { 70 | const el = document.createElement('div'); 71 | el.className = 'animate'; 72 | 73 | container.appendChild(el); 74 | setupAnimations(); 75 | 76 | expect(getComputedStyle(el).animationPlayState).to.equal('paused'); 77 | }); 78 | 79 | it('should pause animations on newly added Elements', () => { 80 | const el = document.createElement('div'); 81 | el.className = 'animate'; 82 | 83 | setupAnimations(); 84 | container.appendChild(el); 85 | 86 | expect(getComputedStyle(el).animationPlayState).to.equal('paused'); 87 | }); 88 | 89 | it('should pause animations on pseudo after', () => { 90 | const el = document.createElement('div'); 91 | el.className = 'animate-after'; 92 | 93 | setupAnimations(); 94 | container.appendChild(el); 95 | 96 | expect(getComputedStyle(el, '::after').animationPlayState).to.equal('paused'); 97 | }); 98 | 99 | it('should pause animations on pseudo before', () => { 100 | const el = document.createElement('div'); 101 | el.className = 'animate-before'; 102 | 103 | setupAnimations(); 104 | container.appendChild(el); 105 | 106 | expect(getComputedStyle(el, '::before').animationPlayState).to.equal('paused'); 107 | }); 108 | 109 | it('should pause animations on pseudo backdrop', () => { 110 | const el = document.createElement('div'); 111 | el.className = 'animate-backdrop'; 112 | 113 | setupAnimations(); 114 | container.appendChild(el); 115 | 116 | expect(getComputedStyle(el, '::backdrop').animationPlayState).to.equal('paused'); 117 | }); 118 | 119 | it('should resume animations', () => { 120 | const el = document.createElement('div'); 121 | el.className = 'animate'; 122 | 123 | container.appendChild(el); 124 | setupAnimations(); 125 | tearDownAnimations(); 126 | 127 | expect(getComputedStyle(el).animationPlayState).to.equal('running'); 128 | }); 129 | 130 | it('should offset an animation', () => { 131 | const el = document.createElement('div'); 132 | el.className = 'animate'; 133 | 134 | container.appendChild(el); 135 | setupAnimations(); 136 | 137 | offset(100); 138 | expect(getComputedStyle(el).animationDelay).to.equal('-0.1s'); 139 | }); 140 | 141 | it('should offset a delayed animation', () => { 142 | const el = document.createElement('div'); 143 | el.className = 'animate delayed'; 144 | 145 | container.appendChild(el); 146 | setupAnimations(); 147 | 148 | offset(100); 149 | expect(getComputedStyle(el).animationDelay).to.equal('0.025s'); 150 | }); 151 | 152 | it('should offset a delayed animation on a pseudo after', () => { 153 | const el = document.createElement('div'); 154 | el.className = 'animate-after delayed-after'; 155 | 156 | container.appendChild(el); 157 | setupAnimations(); 158 | 159 | offset(100); 160 | expect(getComputedStyle(el, '::after').animationDelay).to.equal('0.025s'); 161 | }); 162 | 163 | it('should offset a delayed animation on a pseudo before', () => { 164 | const el = document.createElement('div'); 165 | el.className = 'animate-before delayed-before'; 166 | 167 | container.appendChild(el); 168 | setupAnimations(); 169 | 170 | offset(100); 171 | expect(getComputedStyle(el, '::before').animationDelay).to.equal('0.025s'); 172 | }); 173 | 174 | it('should offset a delayed animation on a pseudo backdrop', () => { 175 | const el = document.createElement('div'); 176 | el.className = 'animate-backdrop delayed-backdrop'; 177 | 178 | container.appendChild(el); 179 | setupAnimations(); 180 | 181 | offset(100); 182 | expect(getComputedStyle(el, '::backdrop').animationDelay).to.equal('0.025s'); 183 | }); 184 | 185 | it('should offset multiple times', () => { 186 | const el = document.createElement('div'); 187 | el.className = 'animate delayed'; 188 | 189 | container.appendChild(el); 190 | setupAnimations(); 191 | 192 | offset(100); 193 | offset(300); 194 | expect(getComputedStyle(el).animationDelay).to.equal('-0.175s'); 195 | }); 196 | 197 | it('should offset multiple times on pseudo after', () => { 198 | const el = document.createElement('div'); 199 | el.className = 'animate-after delayed-after'; 200 | 201 | container.appendChild(el); 202 | setupAnimations(); 203 | 204 | offset(100); 205 | offset(300); 206 | expect(getComputedStyle(el, '::after').animationDelay).to.equal('-0.175s'); 207 | }); 208 | 209 | it('should offset multiple times on pseudo before', () => { 210 | const el = document.createElement('div'); 211 | el.className = 'animate-before delayed-before'; 212 | 213 | container.appendChild(el); 214 | setupAnimations(); 215 | 216 | offset(100); 217 | offset(300); 218 | expect(getComputedStyle(el, '::before').animationDelay).to.equal('-0.175s'); 219 | }); 220 | 221 | it('should offset multiple times on pseudo backdrop', () => { 222 | const el = document.createElement('div'); 223 | el.className = 'animate-backdrop delayed-backdrop'; 224 | 225 | container.appendChild(el); 226 | setupAnimations(); 227 | 228 | offset(100); 229 | offset(300); 230 | expect(getComputedStyle(el, '::backdrop').animationDelay).to.equal('-0.175s'); 231 | }); 232 | }); 233 | 234 | describe('ShadowRoots', () => { 235 | if (!('attachShadow' in Element.prototype)) { 236 | return; 237 | } 238 | 239 | it('should pause animations on existing Elements', () => { 240 | const outer = document.createElement('div'); 241 | const sr = outer.attachShadow({ mode: 'closed' }); 242 | const el = document.createElement('div'); 243 | el.className = 'animate'; 244 | 245 | sr.appendChild(el); 246 | container.appendChild(outer); 247 | setupAnimations(); 248 | 249 | expect(getComputedStyle(el).animationPlayState).to.equal('paused'); 250 | }); 251 | 252 | it('should pause animations on newly added Elements', () => { 253 | const outer = document.createElement('div'); 254 | const sr = outer.attachShadow({ mode: 'closed' }); 255 | const el = document.createElement('div'); 256 | el.className = 'animate'; 257 | 258 | setupAnimations(); 259 | sr.appendChild(el); 260 | container.appendChild(outer); 261 | 262 | expect(getComputedStyle(el).animationPlayState).to.equal('paused'); 263 | }); 264 | 265 | it('should resume animations', () => { 266 | const outer = document.createElement('div'); 267 | const sr = outer.attachShadow({ mode: 'closed' }); 268 | const el = document.createElement('div'); 269 | el.className = 'animate'; 270 | 271 | sr.appendChild(el); 272 | container.appendChild(outer); 273 | setupAnimations(); 274 | tearDownAnimations(); 275 | 276 | expect(getComputedStyle(el).animationPlayState).to.equal('running'); 277 | }); 278 | }); 279 | }); 280 | -------------------------------------------------------------------------------- /src/transform-img/transform-img.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Curve} from '../bezier-curve-utils.js'; 18 | import {Size} from '../size.js'; 19 | import {createIntermediateImg} from '../intermediate-img.js'; 20 | import {getPositionedContainer} from '../positioned-container.js'; 21 | import {getRenderedDimensions} from '../img-dimensions.js'; 22 | import {prepareCropAnimation} from './crop-animation.js'; 23 | import {prepareCropPositionAnimation} from './crop-position-animation.js'; 24 | import {prepareScaleAnimation} from './scale-animation.js'; 25 | import {preparePositionAnimation} from './position-animation.js'; 26 | import {prepareTranslateAnimation} from './translate-animation.js'; 27 | 28 | /** 29 | * @see https://developer.mozilla.org/en-US/docs/Web/CSS/single-transition-timing-function#ease-in-out 30 | */ 31 | const EASE_IN_OUT = {x1: 0.42, y1: 0, x2: 0.58, y2: 1}; 32 | 33 | /** 34 | * A counter that makes sure the keyframes names are unique. 35 | */ 36 | let keyframesPrefixCounter: number = 0; 37 | 38 | /** 39 | * Gets a prefix name to use for the keyframes, avoiding clashing with other 40 | * keyframes that may be defined. 41 | * @param namespace A namespace to use as a prefix. 42 | * @return The prefix to use for the various keyframes. 43 | */ 44 | function getKeyframesPrefix(namespace: string): string { 45 | keyframesPrefixCounter += 1; 46 | 47 | return `${namespace}-${keyframesPrefixCounter}-`; 48 | } 49 | 50 | /** 51 | * @param img An img to geth the properties for. 52 | * @param rect The ClientRect of the img. 53 | */ 54 | function getImgProperties( 55 | img: HTMLImageElement, 56 | rect: ClientRect, 57 | cropRect: ClientRect 58 | ): { 59 | objectFit: string, 60 | objectPosition: string, 61 | rect: ClientRect, 62 | cropRect: ClientRect, 63 | img: HTMLImageElement, 64 | dimensions: Size, 65 | area: number, 66 | } { 67 | const style = getComputedStyle(img); 68 | const objectFit = style.getPropertyValue('object-fit'); 69 | const objectPosition = style.getPropertyValue('object-position'); 70 | return { 71 | objectFit, 72 | objectPosition, 73 | rect, 74 | cropRect, 75 | img, 76 | dimensions: getRenderedDimensions(img, rect, objectFit), 77 | area: rect.width * rect.height, 78 | }; 79 | } 80 | 81 | /** 82 | * Prepares an animation from one image to another. Creates a temporary 83 | * transition image that is transitioned between the position, size and crop of 84 | * two images. 85 | * @param options 86 | * @param options.transitionContainer The container to place the transition 87 | * image in. This could be useful if you need to place the transition in a 88 | * container with a specific `z-index` to appear on top of other elements in 89 | * the page. It can also be useful if you want to have the transition stay 90 | * within the `ShadowRoot` of a component. Defaults to document.body. 91 | * @param options.styleContainer The container to place the generated 92 | * stylesheet in. Defaults to document.head. 93 | * @param options.srcImg The image to transition from. 94 | * @param options.targetImg The image to transition to. 95 | * @param options.srcImgRect The ClientRect for where the transition should 96 | * start from. Specifying this could be useful if you need to hide 97 | * (`display: none`) the container for `srcImg` in order to layout a page 98 | * containing `targetImg`. In that case, you can capture the rect for 99 | * `srcImg` up front and then pass it. Defaults to the current rect for 100 | * srcImg. 101 | * @param options.targetImgRect The ClientRect for where the transition should 102 | * end at. Specifying this could be useful if you have not had a chance to 103 | * perform the layout for the target yet (e.g. in a `display: none` 104 | * container), but you know where on the screen it will go. Defaults to the 105 | * current rect for targetImg. 106 | * @param options.curve Control points for a Bezier curve to use for the 107 | * animation. 108 | * @param options.styles Styles to apply to the transitioning Elements. This 109 | * should include animationDuration. It might also include animationDelay. 110 | * @param options.keyframesNamespace A namespace to use for the generated 111 | * keyframes to ensure they do not clash with existing keyframes. 112 | */ 113 | export function prepareImageAnimation({ 114 | transitionContainer = document.body, 115 | styleContainer = document.head!, 116 | srcImg, 117 | targetImg, 118 | srcImgRect = srcImg.getBoundingClientRect(), 119 | srcCropRect = srcImgRect, 120 | targetImgRect = targetImg.getBoundingClientRect(), 121 | targetCropRect = targetImgRect, 122 | curve = EASE_IN_OUT, 123 | styles, 124 | keyframesNamespace = 'img-transform', 125 | } : { 126 | transitionContainer: HTMLElement, 127 | styleContainer: Element|Document|DocumentFragment, 128 | srcImg: HTMLImageElement, 129 | targetImg: HTMLImageElement, 130 | srcImgRect?: ClientRect, 131 | srcCropRect?: ClientRect, 132 | targetImgRect?: ClientRect, 133 | targetCropRect?: ClientRect, 134 | curve?: Curve, 135 | styles: Object, 136 | keyframesNamespace?: string, 137 | }) : { 138 | applyAnimation: () => void, 139 | cleanupAnimation: () => void, 140 | } { 141 | const srcProperties = getImgProperties(srcImg, srcImgRect, srcCropRect); 142 | const targetProperties = getImgProperties( 143 | targetImg, targetImgRect, targetCropRect); 144 | const toLarger = targetProperties.area > srcProperties.area; 145 | const smallerProperties = toLarger ? srcProperties : targetProperties; 146 | const largerProperties = toLarger ? targetProperties : srcProperties; 147 | const keyframesPrefix = getKeyframesPrefix(keyframesNamespace); 148 | 149 | const { 150 | translateElement, 151 | scaleElement, 152 | counterScaleElement, 153 | cropPositionContainer, 154 | imgContainer, 155 | img, 156 | } = createIntermediateImg( 157 | largerProperties.img, 158 | largerProperties.rect, 159 | largerProperties.cropRect, 160 | largerProperties.objectPosition, 161 | largerProperties.dimensions 162 | ); 163 | const positionedParent = getPositionedContainer(transitionContainer); 164 | const positionedParentRect = positionedParent.getBoundingClientRect(); 165 | 166 | const cropStyleText = prepareCropAnimation({ 167 | scaleElement, 168 | counterScaleElement, 169 | largerRect: largerProperties.cropRect, 170 | smallerRect: smallerProperties.cropRect, 171 | curve, 172 | styles, 173 | keyframesPrefix, 174 | toLarger, 175 | }); 176 | const translateStyleText = prepareTranslateAnimation({ 177 | element: translateElement, 178 | positionedParentRect, 179 | largerRect: largerProperties.cropRect, 180 | smallerRect: smallerProperties.cropRect, 181 | curve, 182 | styles, 183 | keyframesPrefix, 184 | toLarger, 185 | }); 186 | const positionStyleText = preparePositionAnimation({ 187 | element: imgContainer, 188 | largerRect: largerProperties.rect, 189 | smallerRect: smallerProperties.rect, 190 | largerDimensions: largerProperties.dimensions, 191 | smallerDimensions: smallerProperties.dimensions, 192 | largerObjectPosition: largerProperties.objectPosition, 193 | smallerObjectPosition: smallerProperties.objectPosition, 194 | curve, 195 | styles, 196 | keyframesPrefix, 197 | toLarger, 198 | }); 199 | const cropPositionStyleText = prepareCropPositionAnimation({ 200 | element: cropPositionContainer, 201 | largerRect: largerProperties.rect, 202 | largerCropRect: largerProperties.cropRect, 203 | smallerRect: smallerProperties.rect, 204 | smallerCropRect: smallerProperties.cropRect, 205 | curve, 206 | styles, 207 | keyframesPrefix, 208 | toLarger, 209 | }); 210 | const scaleStyleText = prepareScaleAnimation({ 211 | element: img, 212 | largerDimensions: largerProperties.dimensions, 213 | smallerDimensions: smallerProperties.dimensions, 214 | curve, 215 | styles, 216 | keyframesPrefix, 217 | toLarger, 218 | }); 219 | 220 | const styleTag = document.createElement('style'); 221 | styleTag.textContent = cropStyleText + translateStyleText + 222 | positionStyleText + cropPositionStyleText + scaleStyleText; 223 | 224 | function applyAnimation() { 225 | styleContainer.appendChild(styleTag); 226 | transitionContainer.appendChild(translateElement); 227 | } 228 | 229 | function cleanupAnimation() { 230 | transitionContainer.removeChild(translateElement); 231 | styleContainer.removeChild(styleTag); 232 | } 233 | 234 | return { 235 | applyAnimation, 236 | cleanupAnimation, 237 | }; 238 | } 239 | -------------------------------------------------------------------------------- /docs/prepare-image-animation.md: -------------------------------------------------------------------------------- 1 | ## `prepareImageAnimation` 2 | 3 | Prepares an animation for an image from one size, location and crop to another. 4 | 5 | ### Typical Usage 6 | 7 | ```javascript 8 | const duration = 400; 9 | const { 10 | applyAnimation, 11 | cleanupAnimation, 12 | } = prepareImageAnimation({ 13 | srcImg, 14 | targetImg, 15 | styles: { 16 | animationDuration: `${duration}ms`, 17 | }, 18 | }); 19 | 20 | srcImg.style.visibility = 'hidden'; 21 | targetImg.style.visibility = 'hidden'; 22 | applyAnimation(); 23 | setTimeout(() => { 24 | targetImg.style.visibility = 'visible'; 25 | cleanupAnimation(); 26 | }, duration); 27 | ``` 28 | 29 | ### Demos 30 | 31 | * [Hero animation](./demo/hero) 32 | * [Lightbox](./demo/lightbox) 33 | * [Image gallery](./demo/gallery) 34 | * [Inline image expansion](./demo/expand) 35 | * [Panning an image back and forth](./demo/pan) 36 | * [Cropped image animation](./demo/zoom-crop) 37 | 38 | ### How `prepareImageAnimation` Works 39 | 40 | The animation is done by creating a temporary `` element that is animated between the source and the target. Once the animation is completed, the temporary `` is removed. The animation is done using `position: absolute`, to allow the image to move as the user scrolls. 41 | 42 | In order to animate the crop and rendered image position, the function looks at how the source and target images are rendered using the size, [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) and ['object-position'](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) properties. It then animates between the two states, which may cause the cropping to change as the animation proceeds. See the [hero animation demo](./demo/hero) for an example of this in action. 43 | 44 | The animation is first prepared, then applied and finally cleaned up. The creation and application are two different steps, which can be useful if you want to avoid [layout thrashing](https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing#avoid_forced_synchronous_layouts) using a library like [fastdom](https://github.com/wilsonpage/fastdom). 45 | 46 | ### Function signature 47 | 48 | ```javascript 49 | function prepareImageAnimation({ 50 | transitionContainer = document.body, 51 | styleContainer = document.head!, 52 | srcImg, 53 | targetImg, 54 | srcImgRect = srcImg.getBoundingClientRect(), 55 | targetImgRect = targetImg.getBoundingClientRect(), 56 | curve = EASE_IN_OUT, 57 | styles, 58 | keyframesNamespace = 'img-transform', 59 | } : { 60 | transitionContainer: HTMLElement, 61 | styleContainer: Element|Document|DocumentFragment, 62 | srcImg: HTMLImageElement, 63 | targetImg: HTMLImageElement, 64 | srcImgRect?: ClientRect, 65 | targetImgRect?: ClientRect, 66 | curve?: Curve, 67 | styles: Object, 68 | keyframesNamespace?: string, 69 | }) : { 70 | applyAnimation: () => void, 71 | cleanupAnimation: () => void, 72 | } 73 | ``` 74 | 75 | ### Return Value 76 | 77 | #### `applyAnimation` 78 | 79 | Applies the animation by inserting the temporary transition `` into the `transitionContainer` as well as inserting a dynamically generated stylesheet into `styleContainer`. 80 | 81 | #### `cleanupAnimation` 82 | 83 | Undoes the effects of `applyAnimation`. 84 | 85 | ### Parameters 86 | 87 | #### `transitionContainer` 88 | 89 | This option defaults to `document.body` and is where the the animating `` is placed. Two cases where you might not want this to be the body are: 90 | 91 | 1. The body is not the scrolling container. 92 | 2. The body is the scrolling container, but is not currently scrolling. 93 | 94 | When the body is not the scrolling container, you will want to place the animating `` somewhere in the scrolling container. As an exmaple, the [hero animation demo](./demo/hero) places the transition image on the newly active page. The structure looks like: 95 | 96 | ```html 97 |
98 |
99 | … content 100 |
101 |
102 | ``` 103 | 104 | The demo uses `.content-container` as `transitionContainer`. Since the `transitionContainer` moves as the user scrolls, the animation moves in sync. Note that the `transitionContainer` may actually be a descendent of `content-container`, as `prepareImageAnimation` looks for the first positioned ancestor. 105 | 106 | #### `styleContainer` 107 | 108 | This defaults to `document.head` and is where generated CSS for the animation is placed. If you want the animation to be placed within [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) (i.e. specifying a `transitionContainer` within a `ShadowRoot`), then you will want the `ShadowRoot` to be the `styleContainer`. 109 | 110 | #### `srcImg` 111 | 112 | An `` to animate from. This is used to determine the position, size, and the `object-fit` property to start the animation with. 113 | 114 | 115 | #### `targetImg` 116 | 117 | An `` to animate to. This is used to determine the position, size, and the `object-fit` property to end the animation with. 118 | 119 | #### `srcImgRect` 120 | 121 | Defaults to `srcImg.getBoundingClientRect()`. If the `srcImg` is not laid out at the time you call `prepareImageAnimation`, you will want to capture the `ClientRect` beforehand and provide it to the call. 122 | 123 | One situtation this might be useful is if you are doing an animation between pages, where the content is in the `body` itself rather than in a separate scrolling container. For example. consider the following page structure: 124 | 125 | ```html 126 | 127 |
128 | … 129 |
130 | 134 | 135 | ``` 136 | 137 | To figure out where the `hero` will be positioned, we need to layout the target page (e.g. by adding `hidden` to the current page and removing it from the target page). However, hiding the current page will mean `prepareImageAnimation` will no longer know where to start the animation. By providing `srcImgRect`, the animation can know where to start from. 138 | 139 | #### `targetImgRect` 140 | 141 | Defaults to `targetImg.getBoundingClientRect()`. If you know where the `targetImg` will be rendered, but you have not laid out the containing content, you can provide it to `prepareImageAnimation`. You can use this to avoid a forced layout in some situations, for example in the [hero animation demo](./demo/hero), we do something like: 142 | 143 | ```javascript 144 | // Layout the the target so that we know where targetImg is 145 | target.hidden = false; 146 | 147 | // Forced style calc + layout when we go to measure things 148 | const { 149 | applyAnimation, 150 | cleanupAnimation, 151 | } = prepareImageAnimation(…); 152 | 153 | // Regular style calc + layout for mutations 154 | current.hidden = true; 155 | applyAnimation(); 156 | ``` 157 | 158 | The forced style calculation caused by `prepareImageAnimation` can be avoided if you already know where `targetImg` will be positioned. Note that in this case, you will still need to provide a `targetImg` to the function so that the animation knows the `object-fit` property to animate to. 159 | 160 | #### `srcCropRect` 161 | 162 | Defaults to `srcImgRect`. If you want to crop your image using a wrapping element, you can specify this to control the initial crop of the image. 163 | 164 | See: 165 | 166 | - [zoom/crop animation demo](./demo/zoom-crop) 167 | - [image crop calculator](./tools/img-cropper) 168 | 169 | #### `targetCropRect` 170 | 171 | Defaults to `targetImgRect`. If you want to crop your image using a wrapping element, you can specify this to control the ending crop of the image. 172 | 173 | See: 174 | 175 | - [zoom/crop animation demo](./demo/zoom-crop) 176 | - [image crop calculator](./tools/img-cropper) 177 | 178 | 179 | #### `curve` 180 | 181 | This option defaults to the built-in `ease-in-out` transition timing function (`{x1: 0.42, y1: 0, x2: 0.58, y2: 1}`). This is an object with the control points for a [`cubic-bezier()`](https://developer.mozilla.org/en-US/docs/Web/CSS/single-transition-timing-function#The_cubic-bezier()_class_of_timing_functions) curve and is used to determine the animation progress for the position, size and crop at any given time. 182 | 183 | #### `styles` 184 | 185 | An object of styles to apply to the animating elements. At the minimum, this should include `animationDuration`. Other useful properties may include `animationDelay` (if you want to synchronize this with another animation, which should start earlier) and `z-index`. 186 | 187 | #### `keyframesNamespace` 188 | 189 | This option defaults to `'img-transform'`. In order to play the animation, CSS keyframes need to be dynamically created. The prefix is used to make sure that the generated names will not colide with any other keyframes present. It is very unlikely that this needs to be specified. 190 | 191 | ### Using different resolution images 192 | 193 | If you are doing an animation from a smaller image to a larger image, you may want to use a low resolution image for the smaller image to make it load faster and save bandwidth. The [image gallery demo](./demo/gallery) outlines an approach to accomplish this. In short, you will want to perform the following steps: 194 | 195 | 1. Start preloading the higher resolution image (e.g. on `mousedown`/`touchstart` or when starting the animation) 196 | 1. Set the `src` for `targetImg` (either via `src` or `srcset`/`sizes`) to the lower resolution image 197 | 1. Perform the image animation 198 | 1. Once the higher resolution image has finished downloaded, set the `src` for `targetImg` to the higher resolution image 199 | 200 | The [image gallery demo code](./demo/gallery/index.js) implements this approach using `srcset`. 201 | -------------------------------------------------------------------------------- /src/testing/animation-test-controller.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 The AMP HTML Authors. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS-IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview Helpers for controlling animations within a test. This 19 | * supports pausing all CSS-based animations on the page and then controlling 20 | * them by manually moving the time offset within the animation. 21 | * 22 | * During the test setup, you should call `setup()` and during the test tear 23 | * down, you should call `tearDown()`. During a test case, you can call 24 | * `offset(time)` to move all animations to a specific time. Note that this 25 | * does not move them forward by `time` but rather to `time`. 26 | * 27 | * Note: for animationend events to fire, you will need to wait for the browser 28 | * to render (e.g. using requestAnimationFrame + setTimeout). 29 | */ 30 | 31 | /** 32 | * Sets all animations in a DOM subtree to a certain time into the animation. 33 | * This is done by adding CSS rules to target the Elements and their pseudo 34 | * Elements to apply a negative animation-delay. Note: if you depend on any 35 | * events that trigger off of an animation ending, you must allow the JavaScript 36 | * execution loop to end (i.e. by using setTimeout) before they trigger. 37 | * @param offset The animation offset, in milliseconds. 38 | * @param root The optional root to set animation offsets for. 39 | */ 40 | export function offset(offset: number, root: ParentNode|undefined = document) { 41 | const elements = getAllElements(root); 42 | 43 | elements.forEach(resetDelay); 44 | elements.forEach(el => applyOffsets(el, offset)); 45 | } 46 | 47 | let active: boolean = false; 48 | 49 | /** 50 | * Initializes, stopping all animations and allowing them to be offset. 51 | */ 52 | export function setup() { 53 | active = true; 54 | 55 | getAllDocs() 56 | .filter(doc => !styleMap.get(doc)) 57 | .forEach(attachStyleElement); 58 | } 59 | 60 | /** 61 | * Tears down, resuming all animations. 62 | */ 63 | export function tearDown() { 64 | active = false; 65 | 66 | const allDocs = getAllDocs(); 67 | 68 | allDocs 69 | .map(doc => styleMap.get(doc)) 70 | .filter(style => style!.parentNode) 71 | .forEach(style => { 72 | style!.parentNode!.removeChild(style!); 73 | }); 74 | allDocs.forEach(doc => styleMap.delete(doc)); 75 | } 76 | 77 | /** 78 | * Gets all the Documents or ShadowRoots present. 79 | */ 80 | function getAllDocs(): Array { 81 | const allShadowRoots = getAllElements(document) 82 | .map(getShadowRoot) 83 | .filter(sr => sr); 84 | 85 | return (>allShadowRoots).concat(document); 86 | } 87 | 88 | /** 89 | * Capture all ShadowRoots as they are created and attach a style stopper 90 | * to them if we are active. 91 | */ 92 | function captureShadowRoots() { 93 | after(Element.prototype, 'createShadowRoot', attachStyleElement); 94 | after(Element.prototype, 'attachShadow', attachStyleElement); 95 | } 96 | 97 | /** 98 | * Used to get ShadowRoots for Elements, even those created in closed mode. 99 | */ 100 | const shadowRootMap: WeakMap = new WeakMap(); 101 | 102 | /** 103 | * Keeps track of the