Commit d1485d0b authored by Robert Knight's avatar Robert Knight

Convert `Excerpt` component to Preact

Convert the `Excerpt` component to Preact. Rather than convert the
existing implementation verbatim, this is a completely new implementation which
should be easier to understand and use. Instead of requiring callers to
provide an input property which represents the displayed data, which triggers a
re-measurement if it changes, the new implementation observes the DOM
directly for size changes.

This component renders caller-provided content (ie. it accepts a `children`
prop), which is not supported by the Preact <-> Angular bridge. Therefore it was
also necessary to create components (`AnnotationBody`, `AnnotationQuote`) that
encapsulate uses of `Excerpt` inside the `<annotation>` component.

 - Add `observe-element-size` utility module to watch for changes in
   the size of a DOM node using APIs available in the current browser

 - Add new `Excerpt` implementation and remove the old one

 - Remove `excerpt-overflow-monitor` utility that is not used by the new
   implementation

 - Add `AnnotationBody` component to render an annotation's markup body
   inside a (new) excerpt and convert the Angular template for
   `<annotation>` (annotation.html) to use it.

 - Add `AnnotationQuote` component to render an annotation's quote
   inside an excerpt and convert `annotation.html` to use it
parent d9abd85d
'use strict';
const { createElement } = require('preact');
const propTypes = require('prop-types');
const Excerpt = require('./excerpt');
const MarkdownEditor = require('./markdown-editor');
const MarkdownView = require('./markdown-view');
/**
* Display the rendered content of an annotation.
*/
function AnnotationBody({
collapse,
isEditing,
isHiddenByModerator,
hasContent,
onCollapsibleChanged,
onEditText,
onToggleCollapsed,
text,
}) {
return (
<section className="annotation-body">
{!isEditing && (
<Excerpt
collapse={collapse}
collapsedHeight={400}
inlineControls={false}
onCollapsibleChanged={onCollapsibleChanged}
onToggleCollapsed={collapsed => onToggleCollapsed({ collapsed })}
overflowHystersis={20}
>
<MarkdownView
markdown={text}
textClass={{
'annotation-body is-hidden': isHiddenByModerator,
'has-content': hasContent,
}}
/>
</Excerpt>
)}
{isEditing && <MarkdownEditor text={text} onEditText={onEditText} />}
</section>
);
}
AnnotationBody.propTypes = {
collapse: propTypes.bool,
hasContent: propTypes.bool,
isEditing: propTypes.bool,
isHiddenByModerator: propTypes.bool,
onCollapsibleChanged: propTypes.func,
onEditText: propTypes.func,
onToggleCollapsed: propTypes.func,
text: propTypes.string,
};
module.exports = AnnotationBody;
'use strict';
const classnames = require('classnames');
const { createElement } = require('preact');
const propTypes = require('prop-types');
const { withServices } = require('../util/service-context');
const { applyTheme } = require('../util/theme');
const Excerpt = require('./excerpt');
/**
* Display the selected text from the document associated with an annotation.
*/
function AnnotationQuote({ isOrphan, quote, settings = {} }) {
return (
<section
className={classnames('annotation-quote-list', isOrphan && 'is-orphan')}
>
<Excerpt
collapsedHeight={35}
inlineControls={true}
overflowHystersis={20}
>
<blockquote
className="annotation-quote"
style={applyTheme(['selectionFontFamily'], settings)}
>
{quote}
</blockquote>
</Excerpt>
</section>
);
}
AnnotationQuote.propTypes = {
isOrphan: propTypes.bool,
quote: propTypes.string,
// Used for theming.
settings: propTypes.object,
};
AnnotationQuote.injectedProps = ['settings'];
module.exports = withServices(AnnotationQuote);
...@@ -562,10 +562,6 @@ function AnnotationController( ...@@ -562,10 +562,6 @@ function AnnotationController(
return; return;
} }
self.canCollapseBody = canCollapse; self.canCollapseBody = canCollapse;
// This event handler is called from outside the digest cycle, so
// explicitly trigger a digest.
$scope.$digest();
}; };
this.setText = function(text) { this.setText = function(text) {
......
'use strict'; 'use strict';
// @ngInject const classnames = require('classnames');
function ExcerptController($element, $scope, ExcerptOverflowMonitor) { const propTypes = require('prop-types');
const self = this; const { createElement } = require('preact');
const {
useCallback,
useLayoutEffect,
useRef,
useState,
} = require('preact/hooks');
const { applyTheme } = require('../util/theme');
const { withServices } = require('../util/service-context');
const observeElementSize = require('../util/observe-element-size');
if (this.collapse === undefined) { /**
this.collapse = true; * A toggle link at the bottom of an excerpt which controls whether it is
} * expanded or collapsed.
*/
function InlineControls({ isCollapsed, setCollapsed, linkStyle = {} }) {
const toggleTitle = isCollapsed
? 'Show the full excerpt'
: 'Show the first few lines only';
const toggleLabel = isCollapsed ? 'More' : 'Less';
return (
<div className="excerpt__inline-controls">
<span className="excerpt__toggle-link">
<a
href="#"
onClick={() => setCollapsed(!isCollapsed)}
title={toggleTitle}
style={linkStyle}
>
{toggleLabel}
</a>
</span>
</div>
);
}
if (this.animate === undefined) { InlineControls.propTypes = {
this.animate = true; isCollapsed: propTypes.bool,
} setCollapsed: propTypes.func,
linkStyle: propTypes.object,
};
this.isExpandable = function() { const noop = () => {};
return this.overflowing && this.collapse;
};
this.isCollapsible = function() {
return this.overflowing && !this.collapse;
};
this.toggle = function(event) {
// When the user clicks a link explicitly to toggle the collapsed state,
// the event is not propagated.
event.stopPropagation();
this.collapse = !this.collapse;
};
this.expand = function() {
// When the user expands the excerpt 'implicitly' by clicking at the bottom
// of the collapsed excerpt, the event is allowed to propagate. For
// annotation cards, this causes clicking on a quote to scroll the view to
// the selected annotation.
this.collapse = false;
};
this.showInlineControls = function() {
return this.overflowing && this.inlineControls;
};
this.bottomShadowStyles = function() {
return {
excerpt__shadow: true,
'excerpt__shadow--transparent': this.inlineControls,
'is-hidden': !this.isExpandable(),
};
};
// Test if the element or any of its parents have been hidden by
// an 'ng-show' directive
function isElementHidden() {
let el = $element[0];
while (el) {
if (el.classList.contains('ng-hide')) {
return true;
}
el = el.parentElement;
}
return false;
}
const overflowMonitor = new ExcerptOverflowMonitor( /**
{ * A container which truncates its content when they exceed a specified height.
getState: function() { *
return { * The collapsed state of the container can be handled either via internal
animate: self.animate, * controls (if `inlineControls` is `true`) or by the caller using the
collapsedHeight: self.collapsedHeight, * `collapse` prop.
collapse: self.collapse, */
overflowHysteresis: self.overflowHysteresis, function Excerpt({
}; children,
}, collapse = false,
contentHeight: function() { collapsedHeight,
const contentElem = $element[0].querySelector('.excerpt'); inlineControls = true,
if (!contentElem) { onCollapsibleChanged = noop,
return null; onToggleCollapsed = noop,
} overflowHysteresis = 0,
return contentElem.scrollHeight; settings = {},
}, }) {
onOverflowChanged: function(overflowing) { const [collapsedByInlineControls, setCollapsedByInlineControls] = useState(
self.overflowing = overflowing; true
if (self.onCollapsibleChanged) {
self.onCollapsibleChanged({ collapsible: overflowing });
}
// Even though this change happens outside the framework, we use
// $digest() rather than $apply() here to avoid a large number of full
// digest cycles if many excerpts update their overflow state at the
// same time. The onCollapsibleChanged() handler, if any, is
// responsible for triggering any necessary digests in parent scopes.
$scope.$digest();
},
},
window.requestAnimationFrame
); );
this.contentStyle = overflowMonitor.contentStyle; // Container for the excerpt's content.
const contentElement = useRef(null);
// Measured height of `contentElement` in pixels.
const [contentHeight, setContentHeight] = useState(0);
// Update the measured height of the content after the initial render and
// when the size of the content element changes.
const updateContentHeight = useCallback(() => {
const newContentHeight = contentElement.current.clientHeight;
setContentHeight(newContentHeight);
const isCollapsible =
newContentHeight > collapsedHeight + overflowHysteresis;
onCollapsibleChanged({ collapsible: isCollapsible });
}, [collapsedHeight, onCollapsibleChanged, overflowHysteresis]);
useLayoutEffect(() => {
const cleanup = observeElementSize(
contentElement.current,
updateContentHeight
);
updateContentHeight();
return cleanup;
}, [updateContentHeight]);
// Render the (possibly truncated) content and controls for
// expanding/collapsing the content.
const overflowing = contentHeight > collapsedHeight + overflowHysteresis;
const isCollapsed = inlineControls ? collapsedByInlineControls : collapse;
const isExpandable = overflowing && isCollapsed;
const contentStyle = {};
if (contentHeight !== 0) {
contentStyle['max-height'] = isExpandable ? collapsedHeight : contentHeight;
}
// Listen for document events which might affect whether the excerpt const setCollapsed = collapsed =>
// is overflowing, even if its content has not changed. inlineControls
$element[0].addEventListener( ? setCollapsedByInlineControls(collapsed)
'load', : onToggleCollapsed(collapsed);
overflowMonitor.check,
false /* capture */ return (
<div className="excerpt" style={contentStyle}>
<div test-name="excerpt-content" ref={contentElement}>
{children}
</div>
<div
onClick={() => setCollapsed(false)}
className={classnames({
excerpt__shadow: true,
'excerpt__shadow--transparent': inlineControls,
'is-hidden': !isExpandable,
})}
title="Show the full excerpt"
/>
{overflowing && inlineControls && (
<InlineControls
isCollapsed={collapsedByInlineControls}
setCollapsed={setCollapsed}
linkStyle={applyTheme(['selectionFontFamily'], settings)}
/>
)}
</div>
); );
window.addEventListener('resize', overflowMonitor.check);
$scope.$on('$destroy', function() {
window.removeEventListener('resize', overflowMonitor.check);
});
// Watch for changes to the visibility of the excerpt.
// Unfortunately there is no DOM API for this, so we rely on a digest
// being triggered after the visibility changes.
$scope.$watch(isElementHidden, function(hidden) {
if (!hidden) {
overflowMonitor.check();
}
});
// Watch input properties which may affect the overflow state
$scope.$watch('vm.contentData', overflowMonitor.check);
// Trigger an initial calculation of the overflow state.
//
// This is performed asynchronously so that the content of the <excerpt>
// has settled - ie. all Angular directives have been fully applied and
// the DOM has stopped changing. This may take several $digest cycles.
overflowMonitor.check();
} }
/** Excerpt.propTypes = {
* @description This component truncates the height of its contents to a /**
* specified number of lines and provides controls for expanding * The content to render inside the container.
* and collapsing the resulting truncated element. */
*/ children: propTypes.object,
module.exports = {
controller: ExcerptController, /**
controllerAs: 'vm', * If `true`, the excerpt provides internal controls to expand and collapse
bindings: { * the content. If `false`, the caller sets the collapsed state via the
/** Whether or not expansion should be animated. Defaults to true. */ * `collapse` prop.
animate: '<?', *
/** * When using inline controls, the excerpt is initially collapsed.
* The data which is used to generate the excerpt's content. */
* When this changes, the excerpt will recompute whether the content inlineControls: propTypes.bool,
* is overflowing.
*/ /**
contentData: '<', * If the content should be truncated if its height exceeds
/** * `collapsedHeight + overflowHysteresis`.
* Specifies whether controls to expand and collapse */
* the excerpt should be shown inside the <excerpt> component. collapse: propTypes.bool,
* If false, external controls can expand/collapse the excerpt by
* setting the 'collapse' property. /**
*/ * Maximum height of the container when it is collapsed.
inlineControls: '<', */
/** Sets whether or not the excerpt is collapsed. */ collapsedHeight: propTypes.number,
collapse: '=?',
/** /**
* Called when the collapsibility of the excerpt (that is, whether or * An additional margin of pixels by which the content height can exceed
* not the content height exceeds the collapsed height), changes. * `collapsedHeight` before it becomes collapsible.
* */
* Note: This function is *not* called from inside a digest cycle, overflowHysteresis: propTypes.number,
* the caller is responsible for triggering any necessary digests.
*/ /**
onCollapsibleChanged: '&?', * Called when the content height exceeds or falls below `collapsedHeight + overflowHysteresis`.
/** The height of this container in pixels when collapsed. */
*/ onCollapsibleChanged: propTypes.func,
collapsedHeight: '<',
/** /**
* The number of pixels by which the height of the excerpt's content * When `inlineControls` is `false`, this function is called when the user
* must extend beyond the collapsed height in order for truncation to * requests to expand the content by clicking a zone at the bottom of the
* be activated. This prevents the 'More' link from being shown to expand * container.
* the excerpt if it has only been truncated by a very small amount, such */
* that expanding the excerpt would reveal no extra lines of text. onToggleCollapsed: propTypes.func,
*/
overflowHysteresis: '<?', // Used for theming.
}, settings: propTypes.object,
transclude: true,
template: require('../templates/excerpt.html'),
}; };
Excerpt.injectedProps = ['settings'];
module.exports = withServices(Excerpt);
'use strict';
const { createElement } = require('preact');
const { mount } = require('enzyme');
const AnnotationBody = require('../annotation-body');
const mockImportedComponents = require('./mock-imported-components');
describe('AnnotationBody', () => {
function createBody(props = {}) {
return mount(<AnnotationBody text="test comment" {...props} />);
}
beforeEach(() => {
AnnotationBody.$imports.$mock(mockImportedComponents());
});
afterEach(() => {
AnnotationBody.$imports.$restore();
});
it('displays the comment if `isEditing` is false', () => {
const wrapper = createBody({ isEditing: false });
assert.isFalse(wrapper.exists('MarkdownEditor'));
assert.isTrue(wrapper.exists('MarkdownView'));
});
it('displays an editor if `isEditing` is true', () => {
const wrapper = createBody({ isEditing: true });
assert.isTrue(wrapper.exists('MarkdownEditor'));
assert.isFalse(wrapper.exists('MarkdownView'));
});
});
'use strict';
const { createElement } = require('preact');
const { mount } = require('enzyme');
const AnnotationQuote = require('../annotation-quote');
const mockImportedComponents = require('./mock-imported-components');
describe('AnnotationQuote', () => {
function createQuote(props) {
return mount(
<AnnotationQuote quote="test quote" settings={{}} {...props} />
);
}
beforeEach(() => {
AnnotationQuote.$imports.$mock(mockImportedComponents());
});
afterEach(() => {
AnnotationQuote.$imports.$restore();
});
it('renders the quote', () => {
const wrapper = createQuote();
const quote = wrapper.find('blockquote');
assert.equal(quote.text(), 'test quote');
});
});
...@@ -156,16 +156,22 @@ describe('annotation', function() { ...@@ -156,16 +156,22 @@ describe('annotation', function() {
onClick: '&', onClick: '&',
}, },
}) })
.component('markdownEditor', { .component('annotationBody', {
bindings: { bindings: {
text: '<', collapse: '<',
hasContent: '<',
isEditing: '<',
isHiddenByModerator: '<',
onCollapsibleChanged: '&',
onEditText: '&', onEditText: '&',
onToggleCollapsed: '&',
text: '<',
}, },
}) })
.component('markdownView', { .component('annotationQuote', {
bindings: { bindings: {
markdown: '<', isOrphan: '<',
textClass: '<', quote: '<',
}, },
}); });
}); });
...@@ -1256,18 +1262,6 @@ describe('annotation', function() { ...@@ -1256,18 +1262,6 @@ describe('annotation', function() {
}); });
}); });
it('renders quotes as plain text', function() {
const ann = fixtures.defaultAnnotation();
ann.target[0].selector = [
{
type: 'TextQuoteSelector',
exact: '<<-&->>',
},
];
const el = createDirective(ann).element;
assert.equal(el[0].querySelector('blockquote').textContent, '<<-&->>');
});
[ [
{ {
context: 'for moderators', context: 'for moderators',
...@@ -1275,10 +1269,8 @@ describe('annotation', function() { ...@@ -1275,10 +1269,8 @@ describe('annotation', function() {
// Content still present. // Content still present.
text: 'Some offensive content', text: 'Some offensive content',
}), }),
textClass: { isHiddenByModerator: true,
'annotation-body is-hidden': true, hasContent: true,
'has-content': true,
},
}, },
{ {
context: 'for non-moderators', context: 'for non-moderators',
...@@ -1287,18 +1279,17 @@ describe('annotation', function() { ...@@ -1287,18 +1279,17 @@ describe('annotation', function() {
tags: [], tags: [],
text: '', text: '',
}), }),
textClass: { isHiddenByModerator: true,
'annotation-body is-hidden': true, hasContent: false,
'has-content': false,
},
}, },
].forEach(testCase => { ].forEach(({ ann, context, isHiddenByModerator, hasContent }) => {
it(`renders hidden annotations with a custom text class (${testCase.context})`, () => { it(`passes moderation status to annotation body (${context})`, () => {
const el = createDirective(testCase.ann).element; const el = createDirective(ann).element;
assert.match( assert.match(
el.find('markdown-view').controller('markdownView'), el.find('annotation-body').controller('annotationBody'),
sinon.match({ sinon.match({
textClass: testCase.textClass, isHiddenByModerator,
hasContent,
}) })
); );
}); });
......
'use strict'; 'use strict';
const angular = require('angular'); const { createElement } = require('preact');
const { act } = require('preact/test-utils');
const util = require('../../directive/test/util'); const { mount } = require('enzyme');
const excerpt = require('../excerpt');
const Excerpt = require('../excerpt');
describe('excerpt', function() {
// ExcerptOverflowMonitor fake instance created by the current test describe('Excerpt', () => {
let fakeOverflowMonitor; const SHORT_DIV = <div id="foo" style="height: 5px;" />;
const TALL_DIV = (
const SHORT_DIV = '<div id="foo" style="height:5px;"></div>'; <div id="foo" style="height: 200px;">
const TALL_DIV = '<div id="foo" style="height:200px;">foo bar</div>'; foo bar
</div>
function excerptComponent(attrs, content) { );
const defaultAttrs = { const DEFAULT_CONTENT = <span className="the-content">default content</span>;
contentData: 'the content',
collapsedHeight: 40, let container;
inlineControls: false, let fakeObserveElementSize;
};
attrs = Object.assign(defaultAttrs, attrs); function createExcerpt(props = {}, content = DEFAULT_CONTENT) {
return util.createDirective(document, 'excerpt', attrs, {}, content); return mount(
<Excerpt
collapse={true}
collapsedHeight={40}
inlineControls={false}
settings={{}}
{...props}
>
{content}
</Excerpt>,
{ attachTo: container }
);
} }
before(function() { beforeEach(() => {
angular.module('app', []).component('excerpt', excerpt); fakeObserveElementSize = sinon.stub();
}); container = document.createElement('div');
document.body.appendChild(container);
beforeEach(function() {
function FakeOverflowMonitor(ctrl) {
fakeOverflowMonitor = this; // eslint-disable-line consistent-this
this.ctrl = ctrl; Excerpt.$imports.$mock({
this.check = sinon.stub(); '../util/observe-element-size': fakeObserveElementSize,
this.contentStyle = sinon.stub().returns({});
}
angular.mock.module('app');
angular.mock.module(function($provide) {
$provide.value('ExcerptOverflowMonitor', FakeOverflowMonitor);
}); });
}); });
context('when created', function() { afterEach(() => {
it('schedules an overflow state recalculation', function() { container.remove();
excerptComponent({}, '<span id="foo"></span>'); });
assert.called(fakeOverflowMonitor.check);
});
it('passes input properties to overflow state recalc', function() { function getExcerptHeight(wrapper) {
const attrs = { return wrapper.find('.excerpt').prop('style')['max-height'];
animate: false, }
collapsedHeight: 40,
inlineControls: false,
overflowHysteresis: 20,
};
excerptComponent(attrs, '<span></span>');
assert.deepEqual(fakeOverflowMonitor.ctrl.getState(), {
animate: attrs.animate,
collapsedHeight: attrs.collapsedHeight,
collapse: true,
overflowHysteresis: attrs.overflowHysteresis,
});
});
it('reports the content height to ExcerptOverflowMonitor', function() { it('renders content in container', () => {
excerptComponent({}, TALL_DIV); const wrapper = createExcerpt();
assert.deepEqual(fakeOverflowMonitor.ctrl.contentHeight(), 200); const contentEl = wrapper.find('[test-name="excerpt-content"]');
}); assert.include(contentEl.html(), 'default content');
}); });
context('input changes', function() { it('truncates content if it exceeds `collapsedHeight` + `overflowHysteresis`', () => {
it('schedules an overflow state check when inputs change', function() { const wrapper = createExcerpt({}, TALL_DIV);
const element = excerptComponent({}, '<span></span>'); assert.equal(getExcerptHeight(wrapper), 40);
fakeOverflowMonitor.check.reset(); });
element.scope.contentData = 'new-content';
element.scope.$digest();
assert.calledOnce(fakeOverflowMonitor.check);
});
it('does not schedule a state check if inputs are unchanged', function() { it('does not truncate content if it does not exceed `collapsedHeight` + `overflowHysteresis`', () => {
const element = excerptComponent({}, '<span></span>'); const wrapper = createExcerpt({}, SHORT_DIV);
fakeOverflowMonitor.check.reset(); assert.equal(getExcerptHeight(wrapper), 5);
element.scope.$digest();
assert.notCalled(fakeOverflowMonitor.check);
});
}); });
context('document events', function() { it('updates the collapsed state when the content height changes', () => {
it('schedules an overflow check when media loads', function() { const wrapper = createExcerpt({}, SHORT_DIV);
const element = excerptComponent( assert.called(fakeObserveElementSize);
{},
'<img src="https://example.com/foo.jpg">'
);
fakeOverflowMonitor.check.reset();
util.sendEvent(element[0], 'load');
assert.called(fakeOverflowMonitor.check);
});
it('schedules an overflow check when the window is resized', function() { const contentElem = fakeObserveElementSize.getCall(0).args[0];
const element = excerptComponent({}, '<span></span>'); const sizeChangedCallback = fakeObserveElementSize.getCall(0).args[1];
fakeOverflowMonitor.check.reset(); act(() => {
util.sendEvent(element[0].ownerDocument.defaultView, 'resize'); contentElem.style.height = '400px';
assert.called(fakeOverflowMonitor.check); sizeChangedCallback();
}); });
}); wrapper.update();
context('visibility changes', function() { assert.equal(getExcerptHeight(wrapper), 40);
it('schedules an overflow check when shown', function() {
const element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
// ng-hide is the class used by the ngShow and ngHide directives
// to show or hide elements. For now, this is the only way of hiding
// or showing excerpts that we need to support.
element[0].classList.add('ng-hide');
element.scope.$digest();
assert.notCalled(fakeOverflowMonitor.check);
element[0].classList.remove('ng-hide');
element.scope.$digest();
assert.called(fakeOverflowMonitor.check);
});
});
context('excerpt content style', function() { act(() => {
it('sets the content style using ExcerptOverflowMonitor#contentStyle()', function() { contentElem.style.height = '10px';
const element = excerptComponent({}, '<span></span>'); sizeChangedCallback();
fakeOverflowMonitor.contentStyle.returns({ 'max-height': '52px' });
element.scope.$digest();
const content = element[0].querySelector('.excerpt');
assert.equal(content.style.cssText.trim(), 'max-height: 52px;');
}); });
}); wrapper.update();
function isHidden(el) { assert.equal(getExcerptHeight(wrapper), 10);
return !el.offsetParent || el.classList.contains('ng-hide'); });
}
function findVisible(el, selector) { it('calls `onCollapsibleChanged` when collapsibility changes', () => {
const elements = el.querySelectorAll(selector); const onCollapsibleChanged = sinon.stub();
for (let i = 0; i < elements.length; i++) { createExcerpt({ onCollapsibleChanged }, SHORT_DIV);
if (!isHidden(elements[i])) {
return elements[i];
}
}
return undefined;
}
describe('inline controls', function() { const contentElem = fakeObserveElementSize.getCall(0).args[0];
function findInlineControl(el) { const sizeChangedCallback = fakeObserveElementSize.getCall(0).args[1];
return findVisible(el, '.excerpt__toggle-link'); act(() => {
} contentElem.style.height = '400px';
sizeChangedCallback();
it('displays inline controls if collapsed', function() {
const element = excerptComponent({ inlineControls: true }, TALL_DIV);
fakeOverflowMonitor.ctrl.onOverflowChanged(true);
const expandLink = findInlineControl(element[0]);
assert.ok(expandLink);
assert.equal(expandLink.querySelector('a').textContent, 'More');
}); });
it('does not display inline controls if not collapsed', function() { assert.calledWith(onCollapsibleChanged, { collapsible: true });
const element = excerptComponent({ inlineControls: true }, SHORT_DIV); });
const expandLink = findInlineControl(element[0]);
assert.notOk(expandLink);
});
it('toggles the expanded state when clicked', function() { it('calls `onToggleCollapsed` when user clicks in bottom area to expand excerpt', () => {
const element = excerptComponent({ inlineControls: true }, TALL_DIV); const onToggleCollapsed = sinon.stub();
fakeOverflowMonitor.ctrl.onOverflowChanged(true); const wrapper = createExcerpt({ onToggleCollapsed }, TALL_DIV);
const expandLink = findInlineControl(element[0]); const control = wrapper.find('.excerpt__shadow');
angular.element(expandLink.querySelector('a')).click(); assert.equal(getExcerptHeight(wrapper), 40);
element.scope.$digest(); control.simulate('click');
const collapseLink = findInlineControl(element[0]); assert.called(onToggleCollapsed);
assert.equal(collapseLink.querySelector('a').textContent, 'Less');
});
}); });
describe('bottom area', function() { context('when inline controls are enabled', () => {
it('expands the excerpt when clicking at the bottom if collapsed', function() { it('displays inline controls if collapsed', () => {
const element = excerptComponent({ inlineControls: true }, TALL_DIV); const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
element.scope.$digest(); assert.isTrue(wrapper.exists('.excerpt__inline-controls'));
assert.isTrue(element.ctrl.collapse); });
const bottomArea = element[0].querySelector('.excerpt__shadow');
angular.element(bottomArea).click(); it('does not display inline controls if not collapsed', () => {
assert.isFalse(element.ctrl.collapse); const wrapper = createExcerpt({ inlineControls: true }, SHORT_DIV);
assert.isFalse(wrapper.exists('.excerpt__inline-controls'));
}); });
});
describe('#onCollapsibleChanged', function() { it('toggles the expanded state when clicked', () => {
it('is called when overflow state changes', function() { const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
const callback = sinon.stub(); const control = wrapper.find('.excerpt__inline-controls a');
excerptComponent( assert.equal(getExcerptHeight(wrapper), 40);
{ control.simulate('click');
onCollapsibleChanged: { assert.equal(getExcerptHeight(wrapper), 200);
args: ['collapsible'],
callback: callback,
},
},
'<span></span>'
);
fakeOverflowMonitor.ctrl.onOverflowChanged(true);
assert.calledWith(callback, true);
fakeOverflowMonitor.ctrl.onOverflowChanged(false);
assert.calledWith(callback, false);
}); });
}); });
}); });
...@@ -130,6 +130,10 @@ function startAngularApp(config) { ...@@ -130,6 +130,10 @@ function startAngularApp(config) {
// UI components // UI components
.component('annotation', require('./components/annotation')) .component('annotation', require('./components/annotation'))
.component(
'annotationBody',
wrapReactComponent(require('./components/annotation-body'))
)
.component( .component(
'annotationHeader', 'annotationHeader',
wrapReactComponent(require('./components/annotation-header')) wrapReactComponent(require('./components/annotation-header'))
...@@ -142,6 +146,10 @@ function startAngularApp(config) { ...@@ -142,6 +146,10 @@ function startAngularApp(config) {
'annotationPublishControl', 'annotationPublishControl',
wrapReactComponent(require('./components/annotation-publish-control')) wrapReactComponent(require('./components/annotation-publish-control'))
) )
.component(
'annotationQuote',
wrapReactComponent(require('./components/annotation-quote'))
)
.component( .component(
'annotationShareDialog', 'annotationShareDialog',
require('./components/annotation-share-dialog') require('./components/annotation-share-dialog')
...@@ -151,7 +159,6 @@ function startAngularApp(config) { ...@@ -151,7 +159,6 @@ function startAngularApp(config) {
'annotationViewerContent', 'annotationViewerContent',
require('./components/annotation-viewer-content') require('./components/annotation-viewer-content')
) )
.component('excerpt', require('./components/excerpt'))
.component( .component(
'helpPanel', 'helpPanel',
wrapReactComponent(require('./components/help-panel')) wrapReactComponent(require('./components/help-panel'))
...@@ -160,14 +167,6 @@ function startAngularApp(config) { ...@@ -160,14 +167,6 @@ function startAngularApp(config) {
'loggedOutMessage', 'loggedOutMessage',
wrapReactComponent(require('./components/logged-out-message')) wrapReactComponent(require('./components/logged-out-message'))
) )
.component(
'markdownEditor',
wrapReactComponent(require('./components/markdown-editor'))
)
.component(
'markdownView',
wrapReactComponent(require('./components/markdown-view'))
)
.component( .component(
'moderationBanner', 'moderationBanner',
wrapReactComponent(require('./components/moderation-banner')) wrapReactComponent(require('./components/moderation-banner'))
...@@ -232,7 +231,6 @@ function startAngularApp(config) { ...@@ -232,7 +231,6 @@ function startAngularApp(config) {
// Utilities // Utilities
.value('Discovery', require('../shared/discovery')) .value('Discovery', require('../shared/discovery'))
.value('ExcerptOverflowMonitor', require('./util/excerpt-overflow-monitor'))
.value('OAuthClient', require('./util/oauth-client')) .value('OAuthClient', require('./util/oauth-client'))
.value('VirtualThreadList', require('./virtual-thread-list')) .value('VirtualThreadList', require('./virtual-thread-list'))
.value('isSidebar', isSidebar) .value('isSidebar', isSidebar)
......
...@@ -13,45 +13,22 @@ ...@@ -13,45 +13,22 @@
show-document-info="vm.showDocumentInfo"> show-document-info="vm.showDocumentInfo">
</annotation-header> </annotation-header>
<!-- Excerpts --> <annotation-quote
<section class="annotation-quote-list" quote="vm.quote()"
ng-class="{'is-orphan' : vm.isOrphan()}" is-orphan="vm.isOrphan()"
ng-if="vm.quote()"> ng-if="vm.quote()">
<excerpt collapsed-height="35" </annotation-quote>
inline-controls="true"
overflow-hysteresis="20"
content-data="selector.exact">
<blockquote class="annotation-quote"
h-branding="selectionFontFamily"
ng-bind="vm.quote()"></blockquote>
</excerpt>
</section>
<!-- / Excerpts --> <annotation-body
collapse="vm.collapseBody"
<!-- Body --> has-content="vm.hasContent()"
<section name="text" class="annotation-body"> is-editing="vm.editing()"
<excerpt is-hidden-by-moderator="vm.isHiddenByModerator()"
inline-controls="false" on-collapsible-changed="vm.setBodyCollapsible(collapsible)"
on-collapsible-changed="vm.setBodyCollapsible(collapsible)" on-edit-text="vm.setText(text)"
collapse="vm.collapseBody" on-toggle-collapsed="vm.collapseBody = collapsed"
collapsed-height="400" text="vm.state().text">
overflow-hysteresis="20" </annotation-body>
content-data="vm.state().text"
ng-if="!vm.editing()">
<markdown-view
markdown="vm.state().text"
text-class="{'annotation-body is-hidden':vm.isHiddenByModerator(),
'has-content':vm.hasContent()}">
</markdown-view>
</excerpt>
<markdown-editor
text="vm.state().text"
on-edit-text="vm.setText(text)"
ng-if="vm.editing()">
</markdown-editor>
</section>
<!-- / Body -->
<!-- Tags --> <!-- Tags -->
<div class="annotation-body form-field" ng-if="vm.editing()"> <div class="annotation-body form-field" ng-if="vm.editing()">
......
<div class="excerpt__container">
<div class="excerpt" ng-style="vm.contentStyle()">
<div ng-transclude></div>
<div ng-click="vm.expand()"
ng-class="vm.bottomShadowStyles()"
title="Show the full excerpt"></div>
<div class="excerpt__inline-controls"
ng-show="vm.showInlineControls()">
<span class="excerpt__toggle-link" ng-show="vm.isExpandable()">
<a ng-click="vm.toggle($event)"
title="Show the full excerpt"
h-branding="accentColor, selectionFontFamily">More</a>
</span>
<span class="excerpt__toggle-link" ng-show="vm.isCollapsible()">
<a ng-click="vm.toggle($event)"
title="Show the first few lines only"
h-branding="accentColor, selectionFontFamily">Less</a>
</span>
</div>
</div>
</div>
'use strict';
function toPx(val) {
return val.toString() + 'px';
}
/**
* Interface used by ExcerptOverflowMonitor to retrieve the state of the
* <excerpt> and report when the state changes.
*
* interface Excerpt {
* getState(): State;
* contentHeight(): number | undefined;
* onOverflowChanged(): void;
* }
*/
/**
* A helper for the <excerpt> component which handles determinination of the
* overflow state and content styling given the current state of the component
* and the height of its contents.
*
* When the state of the excerpt or its content changes, the component should
* call check() to schedule an async update of the overflow state.
*
* @param {Excerpt} excerpt - Interface used to query the current state of the
* excerpt and notify it when the overflow state changes.
* @param {(callback) => number} requestAnimationFrame -
* Function called to schedule an async recalculation of the overflow
* state.
*/
function ExcerptOverflowMonitor(excerpt, requestAnimationFrame) {
let pendingUpdate = false;
// Last-calculated overflow state
let prevOverflowing;
function update() {
const state = excerpt.getState();
if (!pendingUpdate) {
return;
}
pendingUpdate = false;
const hysteresisPx = state.overflowHysteresis || 0;
const overflowing =
excerpt.contentHeight() > state.collapsedHeight + hysteresisPx;
if (overflowing === prevOverflowing) {
return;
}
prevOverflowing = overflowing;
excerpt.onOverflowChanged(overflowing);
}
/**
* Schedule a deferred check of whether the content is collapsed.
*/
function check() {
if (pendingUpdate) {
return;
}
pendingUpdate = true;
requestAnimationFrame(update);
}
/**
* Returns an object mapping CSS properties to values that should be applied
* to an excerpt's content element in order to truncate it based on the
* current overflow state.
*/
function contentStyle() {
const state = excerpt.getState();
let maxHeight = '';
if (prevOverflowing) {
if (state.collapse) {
maxHeight = toPx(state.collapsedHeight);
} else if (state.animate) {
// Animating the height change requires that the final
// height be specified exactly, rather than relying on
// auto height
maxHeight = toPx(excerpt.contentHeight());
}
} else if (typeof prevOverflowing === 'undefined' && state.collapse) {
// If the excerpt is collapsed but the overflowing state has not yet
// been computed then the exact max height is unknown, but it will be
// in the range [state.collapsedHeight, state.collapsedHeight +
// state.overflowHysteresis]
//
// Here we guess that the final content height is most likely to be
// either less than `collapsedHeight` or more than `collapsedHeight` +
// `overflowHysteresis`, in which case it will be truncated to
// `collapsedHeight`.
maxHeight = toPx(state.collapsedHeight);
}
return {
'max-height': maxHeight,
};
}
this.contentStyle = contentStyle;
this.check = check;
}
module.exports = ExcerptOverflowMonitor;
'use strict';
const ExcerptOverflowMonitor = require('../excerpt-overflow-monitor');
describe('ExcerptOverflowMonitor', function() {
let contentHeight;
let ctrl;
let monitor;
let state;
beforeEach(function() {
contentHeight = 0;
state = {
animate: true,
collapsedHeight: 100,
collapse: true,
overflowHysteresis: 20,
};
ctrl = {
getState: function() {
return state;
},
contentHeight: function() {
return contentHeight;
},
onOverflowChanged: sinon.stub(),
};
monitor = new ExcerptOverflowMonitor(ctrl, function(callback) {
callback();
});
});
describe('overflow state', function() {
it('overflows if height > collaped height + hysteresis', function() {
contentHeight = 200;
monitor.check();
assert.calledWith(ctrl.onOverflowChanged, true);
});
it('does not overflow if height < collapsed height', function() {
contentHeight = 80;
monitor.check();
assert.calledWith(ctrl.onOverflowChanged, false);
});
it('does not overflow if height is in [collapsed height, collapsed height + hysteresis]', function() {
contentHeight = 110;
monitor.check();
assert.calledWith(ctrl.onOverflowChanged, false);
});
});
context('#contentStyle', function() {
it('sets max-height if collapsed and overflowing', function() {
contentHeight = 200;
monitor.check();
assert.deepEqual(monitor.contentStyle(), { 'max-height': '100px' });
});
it('sets max height to empty if not overflowing', function() {
contentHeight = 80;
monitor.check();
assert.deepEqual(monitor.contentStyle(), { 'max-height': '' });
});
it('sets max-height if overflow state is unknown', function() {
// Before the initial overflow check, the state is unknown
assert.deepEqual(monitor.contentStyle(), { 'max-height': '100px' });
});
});
context('#check', function() {
it('calls onOverflowChanged() if state changed', function() {
contentHeight = 200;
monitor.check();
ctrl.onOverflowChanged = sinon.stub();
contentHeight = 250;
monitor.check();
assert.notCalled(ctrl.onOverflowChanged);
});
});
});
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
.excerpt { .excerpt {
transition: max-height $expand-duration ease-in; transition: max-height $expand-duration ease-in;
overflow: hidden; overflow: hidden;
position: relative;
} }
// a container which wraps the <excerpt> and contains the excerpt // a container which wraps the <excerpt> and contains the excerpt
......
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