diff --git a/css/amp.css b/css/amp.css index a31bf1415362..da8d2990854f 100644 --- a/css/amp.css +++ b/css/amp.css @@ -107,6 +107,10 @@ i-amp-sizer { display: none; } +.-amp-ghost { + visibility: hidden !important; +} + .-amp-layout { /* Just state. */ } diff --git a/examples/everything.amp.html b/examples/everything.amp.html index e93274591495..0cbbb8b8a3d8 100644 --- a/examples/everything.amp.html +++ b/examples/everything.amp.html @@ -12,6 +12,7 @@ + diff --git a/extensions/amp-image-lightbox/0.1/amp-image-lightbox.css b/extensions/amp-image-lightbox/0.1/amp-image-lightbox.css new file mode 100644 index 000000000000..29abaa6e3756 --- /dev/null +++ b/extensions/amp-image-lightbox/0.1/amp-image-lightbox.css @@ -0,0 +1,105 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* Non-overridable properties */ +amp-image-lightbox { + position: fixed !important; + z-index: 1000 !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + right: 0 !important; + margin: 0 !important; + padding: 0 !important; + /* This is necessary due to crbug/248522 where touch events fail after + transform */ + overflow: hidden; + transform: translate3d(0, 0, 0); +} + +/* Overridable properties */ +amp-image-lightbox { + background: #222; + color: #eee; +} + + +.-amp-image-lightbox-container { + position: absolute; + z-index: 0; + top: 0; + left: 0; + right: 0; + bottom: 0; + /* This is necessary due to crbug/248522 where touch events fail after + transform */ + overflow: hidden; + transform: translate3d(0, 0, 0); +} + + +.-amp-image-lightbox-trans { + pointer-events: none !important; + position: fixed; + z-index: 1001; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + + +.-amp-image-lightbox-caption { + position: absolute !important; + z-index: 2 !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; +} + +.-amp-image-lightbox-view-mode .-amp-image-lightbox-caption { + visibility: hidden; +} + +/* Overridable properties */ +.amp-image-lightbox-caption { + background: rgba(0, 0, 0, 0.2); + max-height: 25%; + padding: 8px; +} + + +.-amp-image-lightbox-viewer { + position: absolute; + z-index: 1; + top: 0; + left: 0; + right: 0; + bottom: 0; + /* This is necessary due to crbug/248522 where touch events fail after + transform */ + overflow: hidden; + transform: translate3d(0, 0, 0); +} + + +.-amp-image-lightbox-viewer-image { + position: absolute; + z-index: 1; + display: block; + transform-origin: 50% 50%; +} diff --git a/extensions/amp-image-lightbox/0.1/amp-image-lightbox.js b/extensions/amp-image-lightbox/0.1/amp-image-lightbox.js new file mode 100644 index 000000000000..30e0fd2746e5 --- /dev/null +++ b/extensions/amp-image-lightbox/0.1/amp-image-lightbox.js @@ -0,0 +1,549 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Animation} from '../../../src/animation'; +import {Gestures} from '../../../src/gesture'; +import {DoubletapRecognizer, SwipeXYRecognizer, TapRecognizer, + TapzoomRecognizer} from '../../../src/gesture-recognizers'; +import {Layout} from '../../../src/layout'; +import {assert} from '../../../src/asserts'; +import {bezierCurve} from '../../../src/curve'; +import {continueMotion} from '../../../src/motion'; +import {historyFor} from '../../../src/history'; +import {isLoaded, loadPromise} from '../../../src/event-helper'; +import {layoutRectFromDomRect, layoutRectLtwh} from '../../../src/layout-rect'; +import {parseSrcset} from '../../../src/srcset'; +import {timer} from '../../../src/timer'; +import * as dom from '../../../src/dom'; +import * as st from '../../../src/style'; +import * as tr from '../../../src/transition'; + + +/** @private @const {!Object} */ +const SUPPORTED_ELEMENTS_ = { + 'amp-img': true, + 'amp-anim': true +}; + +/** @private @const {!Curve} */ +const ENTER_CURVE_ = bezierCurve(0.4, -0.3, 0.2, 1); + +/** @private @const {!Curve} */ +const EXIT_CURVE_ = bezierCurve(0.4, 0, 0.2, 1); + +/** @private @const {!Curve} */ +const PAN_ZOOM_CURVE_ = bezierCurve(0.4, 0, 0.2, 1.4); + + +/** + * This class is responsible providing all operations necessary for viewing + * an image, such as full-bleed display, zoom and pan, etc. + * @package Visible for testing only! + * TODO(dvoytenko): move to the separate file once build system is ready. + */ +export class ImageViewer { + /** + * @param {!AmpImageLightbox} lightbox + */ + constructor(lightbox) { + /** @private {!AmpImageLightbox} */ + this.lightbox_ = lightbox; + + /** @private {!Element} */ + this.viewer_ = document.createElement('div'); + this.viewer_.classList.add('-amp-image-lightbox-viewer'); + + /** @private {!Element} */ + this.image_ = document.createElement('img'); + this.image_.classList.add('-amp-image-lightbox-viewer-image'); + this.viewer_.appendChild(this.image_); + + /** @private {?Srcset} */ + this.srcset_ = null; + + /** @private {number} */ + this.sourceWidth_ = 0; + + /** @private {number} */ + this.sourceHeight_ = 0; + + /** @private {!LayoutRect} */ + this.viewerBox_ = layoutRectLtwh(0, 0, 0, 0); + + /** @private {!LayoutRect} */ + this.imageBox_ = layoutRectLtwh(0, 0, 0, 0); + + /** @private {number} */ + this.scale_ = 1; + + /** @private {number} */ + this.maxSeenScale_ = 1; + } + + /** + * Returns the root element of the image viewer. + * @return {!Element} + */ + getElement() { + return this.viewer_; + } + + /** + * Returns the img element of the image viewer. + * @return {!Element} + */ + getImage() { + return this.image_; + } + + /** + * Returns the boundaries of the viewer. + * @return {!LayoutRect} + */ + getViewerBox() { + return this.viewerBox_; + } + + /** + * Returns the boundaries of the image element. + * @return {!LayoutRect} + */ + getImageBox() { + return this.imageBox_; + } + + /** + * Resets the image viewer to the initial state. + */ + reset() { + this.image_.setAttribute('src', ''); + this.srcset_ = null; + this.imageBox_ = layoutRectLtwh(0, 0, 0, 0); + this.sourceWidth_ = 0; + this.sourceHeight_ = 0; + this.maxSeenScale_ = 1; + } + + /** + * Initializes the image viewer to the target image element such as + * "amp-img". The target image element may or may not yet have the img + * element initialized. + * @param {!Element} sourceElement + * @param {?Element} sourceImage + */ + init(sourceElement, sourceImage) { + this.sourceWidth_ = sourceElement.offsetWidth; + this.sourceHeight_ = sourceElement.offsetHeight; + this.srcset_ = parseSrcset(sourceElement.getAttribute('srcset') || + sourceElement.getAttribute('src')); + if (sourceImage && isLoaded(sourceImage) && sourceImage.src) { + // Set src provisionally to the known loaded value for fast display. + // It will be updated later. + this.image_.setAttribute('src', sourceImage.src); + } + } + + /** + * Measures the image viewer and image sizes and positioning. + * @return {!Promise} + */ + measure() { + this.viewerBox_ = layoutRectFromDomRect(this.viewer_. + getBoundingClientRect()); + + let sf = Math.min(this.viewerBox_.width / this.sourceWidth_, + this.viewerBox_.height / this.sourceHeight_); + let width = Math.min(this.sourceWidth_ * sf, this.viewerBox_.width); + let height = Math.min(this.sourceHeight_ * sf, this.viewerBox_.height); + + // TODO(dvoytenko): This is to reduce very small expansions that often + // look like a stutter. To be evaluated if this is still the right + // idea. + if (width - this.sourceWidth_ <= 16) { + width = this.sourceWidth_; + height = this.sourceHeight_; + } + + this.imageBox_ = layoutRectLtwh( + Math.round((this.viewerBox_.width - width) / 2), + Math.round((this.viewerBox_.height - height) / 2), + Math.round(width), + Math.round(height)); + + st.setStyles(this.image_, { + top: st.px(this.imageBox_.top), + left: st.px(this.imageBox_.left), + width: st.px(this.imageBox_.width), + height: st.px(this.imageBox_.height) + }); + + // TODO(dvoytenko): update pan/zoom info. + + return this.updateSrc_(); + } + + /** + * @return {!Promise} + * @private + */ + updateSrc_() { + this.maxSeenScale_ = Math.max(this.maxSeenScale_, this.scale_); + let width = this.imageBox_.width * this.maxSeenScale_; + let src = this.srcset_.select(width, this.lightbox_.getDpr()).url; + if (src == this.image_.getAttribute('src')) { + return Promise.resolve(); + } + // Notice that we will wait until the next event cycle to set the "src". + // This ensures that the already available image will show immediately + // and then naturally upgrade to a higher quality image. + return timer.promise(1).then(() => { + this.image_.setAttribute('src', src); + return loadPromise(this.image_); + }); + } +} + + +/** + * This class implements "amp-image-lightbox" extension element. + */ +class AmpImageLightbox extends AMP.BaseElement { + + /** @override */ + isLayoutSupported(layout) { + return layout == Layout.NODISPLAY; + } + + /** @override */ + isReadyToBuild() { + return true; + } + + /** @override */ + buildCallback() { + + /** @private {number} */ + this.historyId_ = -1; + + /** @private {boolean} */ + this.active_ = false; + + /** @private {boolean} */ + this.entering_ = false; + + /** @private {?Element} */ + this.sourceElement_ = null; + + /** @private {?Element} */ + this.sourceImage_ = null; + + /** @private {?Unlisten} */ + this.unlistenViewport_ = null; + + /** @private {!Element} */ + this.container_ = document.createElement('div'); + this.container_.classList.add('-amp-image-lightbox-container'); + this.element.appendChild(this.container_); + + /** @private {!ImageViewer} */ + this.imageViewer_ = new ImageViewer(this); + this.container_.appendChild(this.imageViewer_.getElement()); + + /** @private {!Element} */ + this.captionElement_ = document.createElement('div'); + this.captionElement_.classList.add('amp-image-lightbox-caption'); + this.captionElement_.classList.add('-amp-image-lightbox-caption'); + this.container_.appendChild(this.captionElement_); + + this.element.addEventListener('click', (e) => { + if (!this.entering_ && + !this.imageViewer_.getImage().contains(e.target)) { + this.close(); + } + }); + } + + /** @override */ + activate(invocation) { + if (this.active_) { + return; + } + + let source = invocation.source; + assert(source && SUPPORTED_ELEMENTS_[source.tagName.toLowerCase()], + 'Unsupported element: %s', source.tagName); + + this.active_ = true; + this.reset_(); + this.init_(source); + + // Prepare to enter in lightbox + this.requestFullOverlay(); + + this.enter_(); + + this.unlistenViewport_ = this.getViewport().onChanged(() => { + if (this.active_) { + this.imageViewer_.measure(); + } + }); + + this.getHistory_().push(this.close.bind(this)).then((historyId) => { + this.historyId_ = historyId; + }); + } + + /** + * Closes the lightbox. + */ + close() { + if (!this.active_) { + return; + } + this.active_ = false; + this.entering_ = false; + + this.exit_(); + + if (this.unlistenViewport_) { + this.unlistenViewport_(); + this.unlistenViewport_ = null; + } + + this.cancelFullOverlay(); + if (this.historyId_ != -1) { + this.getHistory_().pop(this.historyId_); + } + } + + /** + * Toggles the view mode. + * @param {boolean=} opt_on + */ + toggleViewMode(opt_on) { + if (opt_on !== undefined) { + this.container_.classList.toggle('-amp-image-lightbox-view-mode', opt_on); + } else { + this.container_.classList.toggle('-amp-image-lightbox-view-mode'); + } + } + + /** + * @param {!Element} sourceElement + * @param {!Element} sourceImage + * @private + */ + init_(sourceElement) { + this.sourceElement_ = sourceElement; + + // Initialize the viewer. + this.sourceImage_ = dom.elementByTag(sourceElement, 'img'); + this.imageViewer_.init(this.sourceElement_, this.sourceImage_); + + // Discover caption. + let caption = null; + + // 1. Check
and
. + if (!caption) { + let figure = dom.closestByTag(sourceElement, 'figure'); + if (figure) { + caption = dom.elementByTag(figure, 'figcaption'); + } + } + + // 2. Check "aria-describedby". + if (!caption) { + let describedBy = sourceElement.getAttribute('aria-describedby'); + caption = document.getElementById(describedBy); + } + + if (caption) { + dom.copyChildren(caption, this.captionElement_); + } + } + + /** @private */ + reset_() { + this.imageViewer_.reset(); + dom.removeChildren(this.captionElement_); + this.sourceElement_ = null; + this.sourceImage_ = null; + } + + /** + * @return {!Promise} + * @private + */ + enter_() { + this.entering_ = true; + + st.setStyles(this.element, { + opacity: 0, + display: '' + }); + this.imageViewer_.measure(); + + let anim = new Animation(); + let dur = 700; + + // Lightbox background fades in. + anim.add(0, tr.setStyles(this.element, { + opacity: tr.numeric(0, 1) + }), 0.6, ENTER_CURVE_); + + // Try to transition from the source image. + let transLayer = null; + if (this.sourceImage_ && isLoaded(this.sourceImage_) && + this.sourceImage_.src) { + transLayer = document.createElement('div'); + transLayer.classList.add('-amp-image-lightbox-trans'); + document.body.appendChild(transLayer); + + let rect = layoutRectFromDomRect(this.sourceImage_. + getBoundingClientRect()); + let clone = this.sourceImage_.cloneNode(true); + st.setStyles(clone, { + position: 'absolute', + top: st.px(rect.top), + left: st.px(rect.left), + width: st.px(rect.width), + height: st.px(rect.height) + }); + transLayer.appendChild(clone); + + this.sourceImage_.classList.add('-amp-ghost'); + + // Move the image to the location given by the lightbox. + let imageBox = this.imageViewer_.getImageBox(); + let dx = imageBox.left - rect.left; + let dy = imageBox.top - rect.top; + anim.add(0, tr.setStyles(clone, { + transform: tr.translate(tr.numeric(0, dx), tr.numeric(0, dy)) + }), 0.8, ENTER_CURVE_); + + // Fade in the container. This will mostly affect the caption. + st.setStyles(this.container_, {opacity: 0}); + anim.add(0.8, tr.setStyles(this.container_, { + opacity: tr.numeric(0, 1) + }), 0.1, ENTER_CURVE_); + + // At the end, fade out the transition image. + anim.add(0.9, tr.setStyles(transLayer, { + opacity: tr.numeric(1, 0.01) + }), 0.1, EXIT_CURVE_); + + // Duration will be somewhere between 300ms and 700ms depending on + // how far the image needs to move. + dur = Math.max(Math.min(Math.abs(dy) / 250 * dur, dur), 300); + } + + return anim.start(dur).thenAlways(() => { + this.entering_ = false; + st.setStyles(this.element, {opacity: ''}); + st.setStyles(this.container_, {opacity: ''}); + if (transLayer) { + document.body.removeChild(transLayer); + } + }); + } + + /** + * @return {!Promise} + * @private + */ + exit_() { + let image = this.imageViewer_.getImage(); + let imageBox = this.imageViewer_.getImageBox(); + + let anim = new Animation(); + let dur = 600; + + // Lightbox background fades out. + anim.add(0, tr.setStyles(this.element, { + opacity: tr.numeric(1, 0) + }), 0.9, EXIT_CURVE_); + + // Try to transition to the source image. + let transLayer = null; + if (isLoaded(image) && image.src && this.sourceImage_) { + transLayer = document.createElement('div'); + transLayer.classList.add('-amp-image-lightbox-trans'); + document.body.appendChild(transLayer); + + let rect = layoutRectFromDomRect(this.sourceImage_. + getBoundingClientRect()); + let newLeft = imageBox.left + (imageBox.width - rect.width) / 2; + let newTop = imageBox.top + (imageBox.height - rect.height) / 2; + let clone = image.cloneNode(true); + st.setStyles(clone, { + position: 'absolute', + top: st.px(newTop), + left: st.px(newLeft), + width: st.px(rect.width), + height: st.px(rect.height), + transform: '' + }); + transLayer.appendChild(clone); + + // Fade out the container. + anim.add(0, tr.setStyles(this.container_, { + opacity: tr.numeric(1, 0) + }), 0.1, EXIT_CURVE_); + + // Move the image back to where it is in the article. + let dx = rect.left - newLeft; + let dy = rect.top - newTop; + let move = tr.setStyles(clone, { + transform: tr.translate(tr.numeric(0, dx), tr.numeric(0, dy)) + }); + anim.add(0.1, (time, complete) => { + move(time); + if (complete) { + this.sourceImage_.classList.remove('-amp-ghost'); + } + }, 0.7, EXIT_CURVE_); + + // Fade out the transition image. + anim.add(0.8, tr.setStyles(transLayer, { + opacity: tr.numeric(1, 0.01) + }), 0.2, EXIT_CURVE_); + + // Duration will be somewhere between 300ms and 700ms depending on + // how far the image needs to move. + dur = Math.max(Math.min(Math.abs(dy) / 250 * dur, dur), 300); + } + + return anim.start(dur).thenAlways(() => { + if (this.sourceImage_) { + this.sourceImage_.classList.remove('-amp-ghost'); + } + st.setStyles(this.element, { + display: 'none', + opacity: '' + }); + st.setStyles(this.container_, {opacity: ''}); + document.body.removeChild(transLayer); + this.reset_(); + }); + } + + /** @private {!History} */ + getHistory_() { + return historyFor(this.element.ownerDocument.defaultView); + } +} + +AMP.registerElement('amp-image-lightbox', AmpImageLightbox, $CSS$); diff --git a/extensions/amp-image-lightbox/0.1/test/test-amp-image-lightbox.js b/extensions/amp-image-lightbox/0.1/test/test-amp-image-lightbox.js new file mode 100644 index 000000000000..3d39d5f6cd1d --- /dev/null +++ b/extensions/amp-image-lightbox/0.1/test/test-amp-image-lightbox.js @@ -0,0 +1,303 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Timer} from '../../../../src/timer'; +import {createIframePromise} from '../../../../testing/iframe'; +require('../../../../build/all/v0/amp-image-lightbox-0.1.max'); +import {ImageViewer} + from '../../../../build/all/v0/amp-image-lightbox-0.1.max'; +import {adopt} from '../../../../src/runtime'; +import {parseSrcset} from '../../../../src/srcset'; +import * as sinon from 'sinon'; + +adopt(window); + + +describe('amp-image-lightbox component', () => { + + function getImageLightbox() { + return createIframePromise().then(iframe => { + var el = iframe.doc.createElement('amp-image-lightbox'); + el.setAttribute('layout', 'nodisplay'); + iframe.doc.body.appendChild(el); + return new Timer(window).promise(16).then(() => { + el.implementation_.buildCallback(); + return el; + }); + }); + } + + it('should render correctly', () => { + return getImageLightbox().then(lightbox => { + let container = lightbox.querySelector('.-amp-image-lightbox-container'); + expect(container).to.not.equal(null); + + let caption = container.querySelector('.-amp-image-lightbox-caption'); + expect(caption).to.not.equal(null); + expect(caption.classList.contains('amp-image-lightbox-caption')).to. + equal(true); + + let viewer = container.querySelector('.-amp-image-lightbox-viewer'); + expect(viewer).to.not.equal(null); + + let image = viewer.querySelector('.-amp-image-lightbox-viewer-image'); + expect(image).to.not.equal(null); + + // Very important. Image must have transform-origin=50% 50%. + let win = image.ownerDocument.defaultView; + expect(win.getComputedStyle(image)['transform-origin']).to.equal( + '50% 50%'); + }); + }); + + it('should activate all steps', () => { + return getImageLightbox().then(lightbox => { + let impl = lightbox.implementation_; + let requestFullOverlay = sinon.spy(); + impl.requestFullOverlay = requestFullOverlay; + let viewportOnChanged = sinon.spy(); + impl.getViewport = () => {return {onChanged: viewportOnChanged}}; + let historyPush = sinon.spy(); + impl.getHistory_ = () => { + return {push: () => { + historyPush(); + return Promise.resolve(11); + }}; + }; + let enter = sinon.spy(); + impl.enter_ = enter; + + let ampImage = document.createElement('amp-img'); + ampImage.setAttribute('src', 'data:'); + impl.activate({source: ampImage}); + + expect(requestFullOverlay.callCount).to.equal(1); + expect(viewportOnChanged.callCount).to.equal(1); + expect(impl.unlistenViewport_).to.not.equal(null); + expect(historyPush.callCount).to.equal(1); + expect(enter.callCount).to.equal(1); + expect(impl.sourceElement_).to.equal(ampImage); + }); + }); + + it('should deactivate all steps', () => { + return getImageLightbox().then(lightbox => { + let impl = lightbox.implementation_; + impl.active_ = true; + impl.historyId_ = 11; + let cancelFullOverlay = sinon.spy(); + impl.cancelFullOverlay = cancelFullOverlay; + let viewportOnChangedUnsubscribed = sinon.spy(); + impl.unlistenViewport_ = viewportOnChangedUnsubscribed; + let historyPop = sinon.spy(); + impl.getHistory_ = () => { + return {pop: historyPop}; + }; + let exit = sinon.spy(); + impl.exit_ = exit; + + let ampImage = document.createElement('amp-img'); + ampImage.setAttribute('src', 'data:'); + impl.close(); + + expect(impl.active_).to.equal(false); + expect(exit.callCount).to.equal(1); + expect(viewportOnChangedUnsubscribed.callCount).to.equal(1); + expect(impl.unlistenViewport_).to.equal(null); + expect(cancelFullOverlay.callCount).to.equal(1); + expect(historyPop.callCount).to.equal(1); + }); + }); +}); + + +describe('amp-image-lightbox image viewer', () => { + + let sandbox; + let clock; + let lightbox; + let lightboxMock; + let imageViewer; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + clock = sandbox.useFakeTimers(); + + lightbox = { + getDpr: () => 1 + }; + lightboxMock = sandbox.mock(lightbox); + + imageViewer = new ImageViewer(lightbox); + document.body.appendChild(imageViewer.getElement()); + }); + + afterEach(() => { + document.body.removeChild(imageViewer.getElement()); + lightboxMock.verify(); + lightboxMock.restore(); + lightboxMock = null; + clock.restore(); + clock = null; + sandbox.restore(); + sandbox = null; + }); + + + it('should have 0 initial dimensions', () => { + expect(imageViewer.getImage().src).to.equal(''); + expect(imageViewer.getViewerBox().width).to.equal(0); + expect(imageViewer.getImageBox().width).to.equal(0); + expect(imageViewer.sourceWidth_).to.equal(0); + expect(imageViewer.sourceHeight_).to.equal(0); + }); + + + it('should init to the source element without image', () => { + let sourceElement = { + offsetWidth: 101, + offsetHeight: 201, + getAttribute: (name) => { + if (name == 'src') { + return 'image1'; + } + return undefined; + } + }; + + imageViewer.init(sourceElement, null); + + expect(imageViewer.sourceWidth_).to.equal(101); + expect(imageViewer.sourceHeight_).to.equal(201); + expect(imageViewer.srcset_.getLast().url).to.equal('image1'); + expect(imageViewer.getImage().src).to.equal(''); + }); + + it('should init to the source element with unloaded image', () => { + let sourceElement = { + offsetWidth: 101, + offsetHeight: 201, + getAttribute: (name) => { + if (name == 'src') { + return 'image1'; + } + return undefined; + } + }; + let sourceImage = { + complete: false, + src: 'image1-smaller' + }; + + imageViewer.init(sourceElement, sourceImage); + + expect(imageViewer.getImage().src).to.equal(''); + }); + + it('should init to the source element with loaded image', () => { + let sourceElement = { + offsetWidth: 101, + offsetHeight: 201, + getAttribute: (name) => { + if (name == 'src') { + return 'image1'; + } + return undefined; + } + }; + let sourceImage = { + complete: true, + src: 'image1-smaller' + }; + + imageViewer.init(sourceElement, sourceImage); + + expect(imageViewer.getImage().getAttribute('src')).to. + equal('image1-smaller'); + }); + + it('should reset', () => { + imageViewer.sourceWidth_ = 101; + imageViewer.sourceHeight_ = 201; + imageViewer.getImage().setAttribute('src', 'image1'); + + imageViewer.reset(); + + expect(imageViewer.sourceWidth_).to.equal(0); + expect(imageViewer.sourceHeight_).to.equal(0); + expect(imageViewer.getImage().getAttribute('src')).to.equal(''); + expect(imageViewer.srcset_).to.equal(null); + expect(imageViewer.imageBox_.width).to.equal(0); + }); + + + it('should measure horiz aspect ratio and assign image.src', () => { + imageViewer.getElement().style.width = '100px'; + imageViewer.getElement().style.height = '200px'; + imageViewer.srcset_ = parseSrcset('image1'); + imageViewer.sourceWidth_ = 80; + imageViewer.sourceHeight_ = 60; + + let promise = imageViewer.measure(); + + expect(imageViewer.viewerBox_.width).to.equal(100); + expect(imageViewer.viewerBox_.height).to.equal(200); + + expect(imageViewer.imageBox_.width).to.equal(100); + expect(imageViewer.imageBox_.height).to.equal(75); + expect(imageViewer.imageBox_.left).to.equal(0); + expect(imageViewer.imageBox_.top).to.be.closeTo(62.5, 1); + + expect(imageViewer.getImage().style.left).to.equal('0px'); + expect(imageViewer.getImage().style.top).to.equal('63px'); + expect(imageViewer.getImage().style.width).to.equal('100px'); + expect(imageViewer.getImage().style.height).to.equal('75px'); + + clock.tick(10); + let checkSrc = () => { + expect(imageViewer.getImage().getAttribute('src')).to.equal('image1'); + }; + return promise.then(checkSrc, checkSrc); + }); + + it('should measure vert aspect ratio but small height', () => { + imageViewer.getElement().style.width = '100px'; + imageViewer.getElement().style.height = '200px'; + imageViewer.srcset_ = parseSrcset('image1'); + imageViewer.sourceWidth_ = 80; + imageViewer.sourceHeight_ = 120; + + imageViewer.measure(); + expect(imageViewer.imageBox_.width).to.equal(100); + expect(imageViewer.imageBox_.height).to.equal(150); + expect(imageViewer.imageBox_.left).to.equal(0); + expect(imageViewer.imageBox_.top).to.be.closeTo(25, 1); + }); + + it('should measure vert aspect ratio but high height', () => { + imageViewer.getElement().style.width = '100px'; + imageViewer.getElement().style.height = '200px'; + imageViewer.srcset_ = parseSrcset('image1'); + imageViewer.sourceWidth_ = 40; + imageViewer.sourceHeight_ = 100; + + imageViewer.measure(); + expect(imageViewer.imageBox_.width).to.be.closeTo(80, 1); + expect(imageViewer.imageBox_.height).to.equal(200); + expect(imageViewer.imageBox_.left).to.be.closeTo(10, 1); + expect(imageViewer.imageBox_.top).to.equal(0); + }); +}); diff --git a/extensions/amp-image-lightbox/amp-image-lightbox.md b/extensions/amp-image-lightbox/amp-image-lightbox.md new file mode 100644 index 000000000000..98d8dca8eae1 --- /dev/null +++ b/extensions/amp-image-lightbox/amp-image-lightbox.md @@ -0,0 +1,53 @@ + + +### `amp-image-lightbox` + +The `amp-image-lightbox` component allows for a “image lightbox” or similar +experience - where upon user interaction an image expands to fill the +viewport, until it is closed again by the user. + +#### Behavior + +The typical scenario looks like this: + + + + + +The `amp-image-lightbox` is activated using `on` action on the `amp-img` element +by referencing the lightbox element's ID. When activated, it places the image in +the center of the full-viewport lightbox. Notice that any number of images in +the article can use the same `amp-image-lightbox`. The `amp-image-lightbox` +element itself must be empty and have `layout=nodisplay` set. + +The `amp-image-lightbox` also can optionally display a caption for the image +at the bottom of the viewport. The caption is discovered as following: +1. The contents of the `
` element when image is in the `figure` tag. +2. The contents of the element whose ID is specified by the image's + `aria-describedby` attribute. + +Among other things the `amp-image-lightbox` allows the following user manipulations: +zooming, panning, showing/hiding of the description. + +#### Styling + +The `amp-image-lightbox` component can be styled with standard CSS. Some of the +properties that can be styled are `background` and `color`. + +The `amp-image-lightbox-caption` class is also available to style the caption +section. diff --git a/gulpfile.js b/gulpfile.js index 4b6c1990fe4d..9586047b0223 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -64,6 +64,7 @@ function buildExtensions(options) { buildExtension('amp-carousel', '0.1', false, options); buildExtension('amp-fit-text', '0.1', true, options); buildExtension('amp-iframe', '0.1', false, options); + buildExtension('amp-image-lightbox', '0.1', true, options); buildExtension('amp-instagram', '0.1', false, options); buildExtension('amp-lightbox', '0.1', false, options); buildExtension('amp-slides', '0.1', false, options); diff --git a/test/manual/amp-image-lightbox.amp.html b/test/manual/amp-image-lightbox.amp.html new file mode 100644 index 000000000000..862c1722cc13 --- /dev/null +++ b/test/manual/amp-image-lightbox.amp.html @@ -0,0 +1,82 @@ + + + + + AMP #0 + + + + + + + + + +
+

Image Lightbox

+ +

+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +

+ +
+ +
+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque + inermis reprehendunt. +
+
+ + + +

+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +

+ +

+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +

+ + + +
+ Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +
+ +

+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +

+ +

+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +

+ +

+ Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. +

+ +
+ + +