Commit d81b73af authored by Robert Knight's avatar Robert Knight

Implement side-by-side mode for HTML documents

Implement side-by-side mode for HTML documents, disabled by default by a
a `HTMLIntegration.sideBySideEnabled` feature flag.

For ease of testing, some helpers have been split into a separate
html-side-by-side.js module.
parent aeff9249
/**
* Attempt to guess the region of the page that contains the main content.
*
* @param {Element} root
* @return {{ left: number, right: number }|null} -
* The left/right content margins or `null` if they could not be determined
*/
export function guessMainContentArea(root) {
// Maps of (margin X coord, votes) for margin positions.
/** @type {Map<number,number>} */
const leftMarginVotes = new Map();
/** @type {Map<number,number>} */
const rightMarginVotes = new Map();
// Gather data about the paragraphs of text in the document.
//
// In future we might want to expand this to consider other text containers,
// since some pages, especially eg. in ebooks, may not have any paragraphs
// (eg. instead they may only contain tables or lists or headings).
const paragraphs = Array.from(root.querySelectorAll('p'))
.map(p => {
// Gather some data about them.
const rect = p.getBoundingClientRect();
const textLength = /** @type {string} */ (p.textContent).length;
return { rect, textLength };
})
.filter(({ rect }) => {
// Filter out hidden paragraphs
return rect.width > 0 && rect.height > 0;
})
// Select the paragraphs containing the most text.
.sort((a, b) => b.textLength - a.textLength)
.slice(0, 15);
// Let these paragraphs "vote" for what the left and right margins of the
// main content area in the document are.
paragraphs.forEach(({ rect }) => {
let leftVotes = leftMarginVotes.get(rect.left) ?? 0;
leftVotes += 1;
leftMarginVotes.set(rect.left, leftVotes);
let rightVotes = rightMarginVotes.get(rect.right) ?? 0;
rightVotes += 1;
rightMarginVotes.set(rect.right, rightVotes);
});
// Find the margin values with the most votes.
if (leftMarginVotes.size === 0 || rightMarginVotes.size === 0) {
return null;
}
const leftMargin = [...leftMarginVotes.entries()].sort((a, b) => b[1] - a[1]);
const rightMargin = [...rightMarginVotes.entries()].sort(
(a, b) => b[1] - a[1]
);
const [leftPos] = leftMargin[0];
const [rightPos] = rightMargin[0];
return { left: leftPos, right: rightPos };
}
...@@ -3,10 +3,12 @@ import scrollIntoView from 'scroll-into-view'; ...@@ -3,10 +3,12 @@ import scrollIntoView from 'scroll-into-view';
import { anchor, describe } from '../anchoring/html'; import { anchor, describe } from '../anchoring/html';
import { HTMLMetadata } from './html-metadata'; import { HTMLMetadata } from './html-metadata';
import { guessMainContentArea } from './html-side-by-side';
/** /**
* @typedef {import('../../types/annotator').Anchor} Anchor * @typedef {import('../../types/annotator').Anchor} Anchor
* @typedef {import('../../types/annotator').Integration} Integration * @typedef {import('../../types/annotator').Integration} Integration
* @typedef {import('../../types/annotator').SidebarLayout} SidebarLayout
*/ */
/** /**
...@@ -24,6 +26,14 @@ export class HTMLIntegration { ...@@ -24,6 +26,14 @@ export class HTMLIntegration {
this.describe = describe; this.describe = describe;
this._htmlMeta = new HTMLMetadata(); this._htmlMeta = new HTMLMetadata();
/**
* Whether to attempt to resize the document contents when {@link fitSideBySide}
* is called.
*
* Currently disabled by default.
*/
this.sideBySideEnabled = false;
} }
canAnnotate() { canAnnotate() {
...@@ -38,11 +48,99 @@ export class HTMLIntegration { ...@@ -38,11 +48,99 @@ export class HTMLIntegration {
return this.container; return this.container;
} }
fitSideBySide() { /**
// Not yet implemented. * @param {SidebarLayout} layout
*/
fitSideBySide(layout) {
if (!this.sideBySideEnabled) {
return false; return false;
} }
if (layout.expanded) {
this._activateSideBySide(layout.width);
return true;
} else {
this._deactivateSideBySide();
return false;
}
}
/**
* Resize the document content after side-by-side mode is activated.
*
* @param {number} sidebarWidth
*/
_activateSideBySide(sidebarWidth) {
// When side-by-side mode is activated, what we want to achieve is that the
// main content of the page is fully visible alongside the sidebar, with
// as much space given to the main content as possible. A challenge is that
// we don't know how the page will respond to reducing the width of the body.
//
// - The content might have margins which automatically get reduced as the
// available width is reduced. For example a blog post with a fixed-width
// article in the middle and `margin: auto` for both margins.
//
// In this scenario we'd want to reduce the document width by the full
// width of the sidebar.
//
// - There might be sidebars to the left and/or right of the main content
// which cause the main content to be squashed when the width is reduced.
// For example a news website with a column of ads on the right.
//
// In this scenario we'd want to not reduce the document width or reduce
// it by a smaller amount and let the Hypothesis sidebar cover up the
// document's sidebar, leaving as much space as possible to the content.
//
// Therefore what we do is to initially reduce the width of the document by
// the full width of the sidebar, then we use heuristics to analyze the
// resulting page layout and determine whether there is significant "free space"
// (ie. anything that is not the main content of the document, such as ads or
// links to related stories) to the right of the main content. If there is,
// we make the document wider again to allow more space for the main content.
//
// These heuristics assume a typical "article" page with one central block
// of content. If we can't find the "main content" then we just assume that
// everything on the page is potentially content that the user might want
// to annotate and so try to keep it all visible.
const padding = 10;
const rightMargin = sidebarWidth + padding;
// nb. Adjusting the body size this way relies on the page not setting a
// width on the body. For sites that do this won't work.
document.body.style.marginRight = `${rightMargin}px`;
const contentArea = guessMainContentArea(document.body);
if (contentArea) {
// Check if we can give the main content more space by letting the
// sidebar overlap stuff in the document to the right of the main content.
const freeSpace = Math.max(
0,
window.innerWidth - sidebarWidth - contentArea.right
);
if (freeSpace > 0) {
const adjustedMargin = Math.max(0, rightMargin - freeSpace);
document.body.style.marginRight = `${adjustedMargin}px`;
}
// If the main content appears to be right up against the edge of the
// window, add padding for readability.
if (contentArea.left < 10) {
document.body.style.marginLeft = `${padding}px`;
}
} else {
document.body.style.marginLeft = '';
document.body.style.marginRight = '';
}
}
/**
* Undo the effects of `activateSideBySide`.
*/
_deactivateSideBySide() {
document.body.style.marginLeft = '';
document.body.style.marginRight = '';
}
async getMetadata() { async getMetadata() {
return this._htmlMeta.getDocumentMetadata(); return this._htmlMeta.getDocumentMetadata();
} }
......
import { guessMainContentArea } from '../html-side-by-side';
describe('annotator/integrations/html-side-by-side', () => {
let contentElements;
function createContent(paragraphs) {
const paraElements = paragraphs.map(({ content, left, width }) => {
const el = document.createElement('p');
el.textContent = content;
el.style.position = 'absolute';
el.style.left = `${left}px`;
el.style.width = `${width}px`;
return el;
});
const root = document.createElement('div');
root.append(...paraElements);
document.body.append(root);
contentElements.push(root);
return root;
}
beforeEach(() => {
contentElements = [];
});
afterEach(() => {
contentElements.forEach(ce => ce.remove());
});
describe('guessMainContentArea', () => {
it('returns `null` if the document has no paragraphs', () => {
const content = createContent([]);
assert.isNull(guessMainContentArea(content));
});
it('returns the margins of the paragraphs with the most text', () => {
const paragraphs = [];
for (let i = 0; i < 20; i++) {
if (i % 2 === 0) {
paragraphs.push({
content: `Long paragraph ${i + 1}`,
left: 10,
width: 100,
});
} else {
paragraphs.push({
content: `Paragraph ${i + 1}`,
left: 20,
width: 200,
});
}
}
const content = createContent(paragraphs);
const area = guessMainContentArea(content);
assert.deepEqual(area, { left: 10, right: 110 });
});
it('ignores the positions of hidden paragraphs', () => {
const paragraphs = [];
for (let i = 0; i < 10; i++) {
paragraphs.push({
content: `Hidden paragraph ${i + 1}`,
left: 20,
width: 200,
});
}
for (let i = 0; i < 10; i++) {
paragraphs.push({
content: `Paragraph ${i + 1}`,
left: 10,
width: 100,
});
}
const content = createContent(paragraphs);
content.querySelectorAll('p').forEach(para => {
if (para.textContent.startsWith('Hidden')) {
para.style.display = 'none';
}
});
const area = guessMainContentArea(content);
assert.deepEqual(area, { left: 10, right: 110 });
});
});
});
...@@ -3,6 +3,7 @@ import { HTMLIntegration, $imports } from '../html'; ...@@ -3,6 +3,7 @@ import { HTMLIntegration, $imports } from '../html';
describe('HTMLIntegration', () => { describe('HTMLIntegration', () => {
let fakeHTMLAnchoring; let fakeHTMLAnchoring;
let fakeHTMLMetadata; let fakeHTMLMetadata;
let fakeGuessMainContentArea;
let fakeScrollIntoView; let fakeScrollIntoView;
beforeEach(() => { beforeEach(() => {
...@@ -18,11 +19,16 @@ describe('HTMLIntegration', () => { ...@@ -18,11 +19,16 @@ describe('HTMLIntegration', () => {
fakeScrollIntoView = sinon.stub().yields(); fakeScrollIntoView = sinon.stub().yields();
fakeGuessMainContentArea = sinon.stub().returns(null);
const HTMLMetadata = sinon.stub().returns(fakeHTMLMetadata); const HTMLMetadata = sinon.stub().returns(fakeHTMLMetadata);
$imports.$mock({ $imports.$mock({
'scroll-into-view': fakeScrollIntoView, 'scroll-into-view': fakeScrollIntoView,
'../anchoring/html': fakeHTMLAnchoring, '../anchoring/html': fakeHTMLAnchoring,
'./html-metadata': { HTMLMetadata }, './html-metadata': { HTMLMetadata },
'./html-side-by-side': {
guessMainContentArea: fakeGuessMainContentArea,
},
}); });
}); });
...@@ -58,9 +64,94 @@ describe('HTMLIntegration', () => { ...@@ -58,9 +64,94 @@ describe('HTMLIntegration', () => {
}); });
describe('#fitSideBySide', () => { describe('#fitSideBySide', () => {
it('does nothing', () => { function getMargins() {
const bodyStyle = document.body.style;
const leftMargin = bodyStyle.marginLeft
? parseInt(bodyStyle.marginLeft)
: null;
const rightMargin = bodyStyle.marginRight
? parseInt(bodyStyle.marginRight)
: null;
return [leftMargin, rightMargin];
}
const sidebarWidth = 200;
// Return a rect for content that occupies the full width of the viewport,
// minus space for the opened sidebar, as `fitSideBySide` only calls this
// after initially allocating space for the sidebar.
function fullWidthContentRect() {
return new DOMRect(
0,
0,
window.innerWidth - sidebarWidth,
window.innerHeight
);
}
function createIntegration() {
const integration = new HTMLIntegration();
integration.sideBySideEnabled = true;
return integration;
}
beforeEach(() => {
// By default, pretend that the content fills the page.
fakeGuessMainContentArea.returns(fullWidthContentRect());
});
afterEach(() => {
// Reset any styles applied by `fitSideBySide`.
document.body.style.marginLeft = '';
document.body.style.marginRight = '';
});
it('does nothing when disabled', () => {
new HTMLIntegration().fitSideBySide({}); new HTMLIntegration().fitSideBySide({});
}); });
context('when enabled', () => {
it('sets left and right margins on body element when activated', () => {
const integration = createIntegration();
integration.fitSideBySide({ expanded: true, width: sidebarWidth });
assert.deepEqual(getMargins(), [10, 210]);
});
it('allows sidebar to overlap non-main content on the side of the page', () => {
const integration = createIntegration();
const contentRect = fullWidthContentRect();
// Pretend there is some content to the right of the main content
// in the document (eg. related stories, ads).
contentRect.width -= 100;
fakeGuessMainContentArea.returns(contentRect);
integration.fitSideBySide({ expanded: true, width: sidebarWidth });
assert.deepEqual(getMargins(), [10, 110]);
});
it('does nothing if the content area cannot be determined', () => {
const integration = createIntegration();
fakeGuessMainContentArea.returns(null);
integration.fitSideBySide({ expanded: true, width: sidebarWidth });
assert.deepEqual(getMargins(), [null, null]);
});
it('resets margins on body element when side-by-side mode is deactivated', () => {
const integration = createIntegration();
integration.fitSideBySide({ expanded: true, width: sidebarWidth });
assert.notDeepEqual(getMargins(), [null, null]);
integration.fitSideBySide({ expanded: false });
assert.deepEqual(getMargins(), [null, null]);
});
});
}); });
describe('#getMetadata', () => { describe('#getMetadata', () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment