Skip to content

Commit

Permalink
Scroll to highlighted elements so that they are centered.
Browse files Browse the repository at this point in the history
  • Loading branch information
yunabe committed Jun 5, 2018
1 parent 25c220a commit ebd5cf1
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 35 deletions.
138 changes: 110 additions & 28 deletions extensions/amp-viewer-integration/0.1/highlight-handler.js
Expand Up @@ -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';
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -96,62 +112,135 @@ 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<!Element>} */
this.highlightedNodes_ = null;

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');
});
}

/**
* @param {!./messaging/messaging.Messaging} messaging
*/
setupMessaging(messaging) {
messaging.registerHandler(
HIGHLIGHT_DISMISS, this.handleDismissHighlight_.bind(this));
HIGHLIGHT_DISMISS, this.dismissHighlight_.bind(this));
}

/**
Expand All @@ -165,11 +254,4 @@ export class HighlightHandler {
resetStyles(this.highlightedNodes_[i], ['backgroundColor', 'color']);
}
}

/**
* @private
*/
handleDismissHighlight_() {
this.dismissHighlight_();
}
}
Expand Up @@ -86,28 +86,42 @@ 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';
root.appendChild(div0);
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(
'<div>text in <span style="background-color: rgb(255, 255, 0); ' +
'color: rgb(51, 51, 51);">amp</span> doc</div><div>' +
Expand All @@ -129,4 +143,30 @@ describes.realWin('HighlightHandler', {
'<div>text in <span style="">amp</span> doc</div><div>' +
'<span style="">highlight</span>ed text</div>');
});

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);
});
});
19 changes: 17 additions & 2 deletions src/service/viewport/viewport-impl.js
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit ebd5cf1

Please sign in to comment.