diff --git a/extensions/amp-viewer-integration/0.1/highlight-handler.js b/extensions/amp-viewer-integration/0.1/highlight-handler.js index 496f3fced31e..d165accb69ab 100644 --- a/extensions/amp-viewer-integration/0.1/highlight-handler.js +++ b/extensions/amp-viewer-integration/0.1/highlight-handler.js @@ -15,6 +15,7 @@ */ import {Services} from '../../../src/services'; +import {dict} from '../../../src/utils/object'; import {findSentences, markTextRangeList} from './findtext'; import {listenOnce} from '../../../src/event-helper'; import {parseJson} from '../../../src/json'; @@ -27,6 +28,13 @@ import {resetStyles} from '../../../src/style'; */ const HIGHLIGHT_DISMISS = 'highlightDismiss'; +/** + * The message name sent by AMP doc to notify the change of the state of text + * highlighting. + * @type {string} + */ +const HIGHLIGHT_STATE = 'highlightState'; + /** * The length limit of highlight param to avoid parsing * a incredibley large string as JSON. The limit is 100kB. @@ -52,6 +60,14 @@ const NUM_ALL_CHARS_LIMIT = 1500; */ let HighlightInfoDef; +/** + * The upper bound of the height of scrolling-down animation to highlighted + * texts. If the height for animation exceeds this limit, we scroll the viewport + * to the certain position before animation to control the speed of animation. + * @type {number} + */ +const SCROLL_ANIMATION_HIGHT_LIMIT = 1000; + /** * Returns highlight param in the URL hash. * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc @@ -96,8 +112,10 @@ export class HighlightHandler { * @param {!HighlightInfoDef} highlightInfo The highlighting info in JSON. */ constructor(ampdoc, highlightInfo) { - /** @const {!../../../src/service/ampdoc-impl.AmpDoc} */ + /** @private @const {!../../../src/service/ampdoc-impl.AmpDoc} */ this.ampdoc_ = ampdoc; + /** @private @const {!../../../src/service/viewer-impl.Viewer} */ + this.viewer_ = Services.viewerForDoc(ampdoc); /** @private {?Array} */ this.highlightedNodes_ = null; @@ -105,45 +123,116 @@ export class HighlightHandler { this.initHighlight_(highlightInfo); } + /** + * @param {string} state + * @param {JsonObject=} opt_params + * @private + */ + sendHighlightState_(state, opt_params) { + const params = dict({'state': state}); + for (const key in opt_params) { + params[key] = opt_params[key]; + } + this.viewer_.sendMessage(HIGHLIGHT_STATE, params); + } + /** * @param {!HighlightInfoDef} highlightInfo - * @private - */ - initHighlight_(highlightInfo) { - const ampdoc = this.ampdoc_; - const {win} = ampdoc; - const sens = findSentences(win, ampdoc.getBody(), highlightInfo.sentences); + * @private + */ + findHighlightedNodes_(highlightInfo) { + const {win} = this.ampdoc_; + const sens = findSentences( + win, this.ampdoc_.getBody(), highlightInfo.sentences); if (!sens) { return; } - const spans = markTextRangeList(win, sens); - if (spans.length <= 0) { + const nodes = markTextRangeList(win, sens); + if (!nodes || nodes.length == 0) { + return; + } + this.highlightedNodes_ = nodes; + } + + /** + * @param {!HighlightInfoDef} highlightInfo + * @private + */ + initHighlight_(highlightInfo) { + this.findHighlightedNodes_(highlightInfo); + if (!this.highlightedNodes_) { + this.sendHighlightState_('not_found'); return; } - for (let i = 0; i < spans.length; i++) { - const n = spans[i]; + const scrollTop = this.calcTopToCenterHighlightedNodes_(); + this.sendHighlightState_('found', dict({'scroll': scrollTop})); + + for (let i = 0; i < this.highlightedNodes_.length; i++) { + const n = this.highlightedNodes_[i]; n['style']['backgroundColor'] = '#ff0'; n['style']['color'] = '#333'; } - this.highlightedNodes_ = spans; - const viewer = Services.viewerForDoc(ampdoc); - const visibility = viewer.getVisibilityState(); + + const visibility = this.viewer_.getVisibilityState(); if (visibility == 'visible') { - Services.viewportForDoc(ampdoc).animateScrollIntoView(spans[0], 500); + this.centerHighlightedNodes_(); } else { + if (scrollTop > SCROLL_ANIMATION_HIGHT_LIMIT) { + Services.viewportForDoc(this.ampdoc_).setScrollTop( + scrollTop - SCROLL_ANIMATION_HIGHT_LIMIT); + } let called = false; - viewer.onVisibilityChanged(() => { + this.viewer_.onVisibilityChanged(() => { // TODO(yunabe): Unregister the handler. - if (called || viewer.getVisibilityState() != 'visible') { + if (called || this.viewer_.getVisibilityState() != 'visible') { return; } - Services.viewportForDoc(ampdoc).animateScrollIntoView(spans[0], 500); - viewer.sendMessage('highlightShown', null); + this.centerHighlightedNodes_(); called = true; }); } + listenOnce(this.ampdoc_.getBody(), 'click', + this.dismissHighlight_.bind(this)); + } + + /** + * @return {number} + * @private + */ + calcTopToCenterHighlightedNodes_() { + const nodes = this.highlightedNodes_; + if (!nodes) { + return 0; + } + const viewport = Services.viewportForDoc(this.ampdoc_); + let minTop = Number.MAX_VALUE; + let maxBottom = 0; + for (let i = 0; i < nodes.length; i++) { + const rect = viewport.getLayoutRect(nodes[i]); + minTop = Math.min(minTop, rect.top); + maxBottom = Math.max(maxBottom, rect.bottom); + } + if (minTop >= maxBottom) { + return 0; + } + const height = viewport.getHeight() - viewport.getPaddingTop(); + if (maxBottom - minTop > height) { + return minTop; + } + const pos = (maxBottom + minTop - height) / 2; + return pos > 0 ? pos : 0; + } - listenOnce(ampdoc.getBody(), 'click', this.dismissHighlight_.bind(this)); + /** + * @private + */ + centerHighlightedNodes_() { + const top = this.calcTopToCenterHighlightedNodes_(); + this.sendHighlightState_('auto_scroll'); + const viewport = Services.viewportForDoc(this.ampdoc_); + viewport.animateScrollToTop(top, 500).then(() => { + this.sendHighlightState_('shown'); + }); } /** @@ -151,7 +240,7 @@ export class HighlightHandler { */ setupMessaging(messaging) { messaging.registerHandler( - HIGHLIGHT_DISMISS, this.handleDismissHighlight_.bind(this)); + HIGHLIGHT_DISMISS, this.dismissHighlight_.bind(this)); } /** @@ -165,11 +254,4 @@ export class HighlightHandler { resetStyles(this.highlightedNodes_[i], ['backgroundColor', 'color']); } } - - /** - * @private - */ - handleDismissHighlight_() { - this.dismissHighlight_(); - } } diff --git a/extensions/amp-viewer-integration/0.1/test/test-highlight-handler.js b/extensions/amp-viewer-integration/0.1/test/test-highlight-handler.js index 63103affafdb..567c7b6f1526 100644 --- a/extensions/amp-viewer-integration/0.1/test/test-highlight-handler.js +++ b/extensions/amp-viewer-integration/0.1/test/test-highlight-handler.js @@ -86,10 +86,10 @@ describes.realWin('HighlightHandler', { ampdoc: 'single', }, }, env => { - - it('initialize with visibility=visible', () => { + let root = null; + beforeEach(() => { const {document} = env.win; - const root = document.createElement('div'); + root = document.createElement('div'); document.body.appendChild(root); const div0 = document.createElement('div'); div0.textContent = 'text in amp doc'; @@ -97,17 +97,31 @@ describes.realWin('HighlightHandler', { const div1 = document.createElement('div'); div1.textContent = 'highlighted text'; root.appendChild(div1); + }); + it('initialize with visibility=visible', () => { const {ampdoc} = env; const scrollStub = sandbox.stub( - Services.viewportForDoc(ampdoc), 'animateScrollIntoView'); + Services.viewportForDoc(ampdoc), 'animateScrollToTop'); + scrollStub.returns(Promise.reject()); + const sendMsgStub = sandbox.stub( + Services.viewerForDoc(ampdoc), 'sendMessage'); + const handler = new HighlightHandler( ampdoc,{sentences: ['amp', 'highlight']}); expect(scrollStub).to.be.calledOnce; expect(scrollStub.firstCall.args.length).to.equal(2); - expect(scrollStub.firstCall.args[0].textContent).to.equal('amp'); expect(scrollStub.firstCall.args[1]).to.equal(500); + + // For some reason, expect(args).to.deep.equal does not work. + expect(sendMsgStub.callCount).to.equal(2); + expect(sendMsgStub.firstCall.args[0]).to.equal('highlightState'); + expect(sendMsgStub.firstCall.args[1]).to.deep.equal( + {state: 'found', scroll: 0}); + expect(sendMsgStub.secondCall.args[1]).to.deep.equal( + {state: 'auto_scroll'}); + expect(root.innerHTML).to.equal( '
text in amp doc
' + @@ -129,4 +143,30 @@ describes.realWin('HighlightHandler', { '
text in amp doc
' + 'highlighted text
'); }); + + it('calcTopToCenterHighlightedNodes_ center elements', () => { + const handler = new HighlightHandler(env.ampdoc, {sentences: ['amp']}); + expect(handler.highlightedNodes_).not.to.be.null; + + const viewport = Services.viewportForDoc(env.ampdoc); + sandbox.stub(viewport, 'getLayoutRect').returns({top: 500, bottom: 550}); + sandbox.stub(viewport, 'getHeight').returns(300); + sandbox.stub(viewport, 'getPaddingTop').returns(50); + + // 525px (The center of the element) - 0.5 * 250px (window height) = 400px. + expect(handler.calcTopToCenterHighlightedNodes_()).to.equal(400); + }); + + it('calcTopToCenterHighlightedNodes_ too tall element', () => { + const handler = new HighlightHandler(env.ampdoc, {sentences: ['amp']}); + expect(handler.highlightedNodes_).not.to.be.null; + + const viewport = Services.viewportForDoc(env.ampdoc); + sandbox.stub(viewport, 'getLayoutRect').returns({top: 500, bottom: 1000}); + sandbox.stub(viewport, 'getHeight').returns(300); + sandbox.stub(viewport, 'getPaddingTop').returns(50); + + // Scroll to the top of the element because it's too tall. + expect(handler.calcTopToCenterHighlightedNodes_()).to.equal(500); + }); }); diff --git a/src/service/viewport/viewport-impl.js b/src/service/viewport/viewport-impl.js index 6d0caf1f5168..12e96889f94e 100644 --- a/src/service/viewport/viewport-impl.js +++ b/src/service/viewport/viewport-impl.js @@ -518,14 +518,28 @@ export class Viewport { offset = 0; break; } + return this.animateScrollToTop(elementRect.top + offset, duration, curve); + } + + /** + * Scrolls the viewport to top with animation. + * + * @param {number} top + * @param {number=} duration + * @param {string=} curve + * @return {!Promise} + */ + animateScrollToTop(top, + duration = 500, + curve = 'ease-in') { let newScrollTop; let curScrollTop; if (this.useLayers_) { - newScrollTop = elementRect.top + offset; + newScrollTop = top; curScrollTop = 0; } else { - const calculatedScrollTop = elementRect.top - this.paddingTop_ + offset; + const calculatedScrollTop = top - this.paddingTop_; newScrollTop = Math.max(0, calculatedScrollTop); curScrollTop = this.getScrollTop(); } @@ -812,6 +826,7 @@ export class Viewport { /** * Updates touch zoom meta data. Returns `true` if any actual * changes have been done. + * @param {string} viewportMetaString * @return {boolean} */ setViewportMetaString_(viewportMetaString) {