Unverified Commit 855d3695 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1475 from hypothesis/convert-excerpt

Convert excerpt to Preact (2/n)
parents 804c0177 533fbaf0
'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 })}
overflowThreshold={20}
>
<MarkdownView
markdown={text}
textClass={{
'annotation-body is-hidden': isHiddenByModerator,
'has-content': hasContent,
}}
/>
</Excerpt>
)}
{isEditing && <MarkdownEditor text={text} onEditText={onEditText} />}
</section>
);
}
AnnotationBody.propTypes = {
/**
* Whether to limit the height of the annotation body.
*
* If this is true and the intrinsic height exceeds a fixed threshold, the
* body is truncated. See `onCollapsibleChanged` and `onToggleCollapsed`.
*/
collapse: propTypes.bool,
/**
* Whether to show moderated content, if `isHiddenByModerator` is true or
* a placeholder otherwise.
*
* This will be `true` if the current user is a moderator of the annotation's
* group. For non-moderators the content is not exposed via the API.
*/
hasContent: propTypes.bool,
/**
* Whether to display the body in edit mode (if true) or view mode.
*/
isEditing: propTypes.bool,
/**
* `true` if the contents of this annotation body have been redacted by
* a moderator.
*/
isHiddenByModerator: propTypes.bool,
/**
* Callback invoked when the height of the rendered annotation body increases
* above or falls below the threshold at which the `collapse` prop will affect
* it.
*/
onCollapsibleChanged: propTypes.func,
/**
* Callback invoked when the user edits the content of the annotation body.
*/
onEditText: propTypes.func,
/**
* Callback invoked when the user clicks a shaded area at the bottom of a
* truncated body to indicate that they want to see the rest of the content.
*/
onToggleCollapsed: propTypes.func,
/**
* The markdown annotation body, which is either rendered as HTML (if `isEditing`
* is false) or displayed in a text area otherwise.
*/
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}
overflowThreshold={20}
>
<blockquote
className="annotation-quote"
style={applyTheme(['selectionFontFamily'], settings)}
>
{quote}
</blockquote>
</Excerpt>
</section>
);
}
AnnotationQuote.propTypes = {
/**
* If `true`, display an indicator that the annotated text was not found in
* the current version of the document.
*/
isOrphan: propTypes.bool,
/**
* The text that the annotation refers to. This is rendered as plain text
* (ie. HTML tags are rendered literally).
*/
quote: propTypes.string,
// Used for theming.
settings: propTypes.object,
};
AnnotationQuote.injectedProps = ['settings'];
module.exports = withServices(AnnotationQuote);
......@@ -562,10 +562,6 @@ function AnnotationController(
return;
}
self.canCollapseBody = canCollapse;
// This event handler is called from outside the digest cycle, so
// explicitly trigger a digest.
$scope.$digest();
};
this.setText = function(text) {
......
'use strict';
// @ngInject
function ExcerptController($element, $scope, ExcerptOverflowMonitor) {
const self = this;
const classnames = require('classnames');
const propTypes = require('prop-types');
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;
}
/**
* An optional 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) {
this.animate = true;
}
InlineControls.propTypes = {
isCollapsed: propTypes.bool,
setCollapsed: propTypes.func,
linkStyle: propTypes.object,
};
this.isExpandable = function() {
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 noop = () => {};
const overflowMonitor = new ExcerptOverflowMonitor(
{
getState: function() {
return {
animate: self.animate,
collapsedHeight: self.collapsedHeight,
collapse: self.collapse,
overflowHysteresis: self.overflowHysteresis,
};
},
contentHeight: function() {
const contentElem = $element[0].querySelector('.excerpt');
if (!contentElem) {
return null;
}
return contentElem.scrollHeight;
},
onOverflowChanged: function(overflowing) {
self.overflowing = overflowing;
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
/**
* A container which truncates its content when they exceed a specified height.
*
* The collapsed state of the container can be handled either via internal
* controls (if `inlineControls` is `true`) or by the caller using the
* `collapse` prop.
*/
function Excerpt({
children,
collapse = false,
collapsedHeight,
inlineControls = true,
onCollapsibleChanged = noop,
onToggleCollapsed = noop,
overflowThreshold = 0,
settings = {},
}) {
const [collapsedByInlineControls, setCollapsedByInlineControls] = useState(
true
);
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);
// prettier-ignore
const isCollapsible =
newContentHeight > (collapsedHeight + overflowThreshold);
onCollapsibleChanged({ collapsible: isCollapsible });
}, [collapsedHeight, onCollapsibleChanged, overflowThreshold]);
// Listen for document events which might affect whether the excerpt
// is overflowing, even if its content has not changed.
$element[0].addEventListener(
'load',
overflowMonitor.check,
false /* capture */
useLayoutEffect(() => {
const cleanup = observeElementSize(
contentElement.current,
updateContentHeight
);
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();
updateContentHeight();
return cleanup;
}, [updateContentHeight]);
// Render the (possibly truncated) content and controls for
// expanding/collapsing the content.
// prettier-ignore
const isOverflowing = contentHeight > (collapsedHeight + overflowThreshold);
const isCollapsed = inlineControls ? collapsedByInlineControls : collapse;
const isExpandable = isOverflowing && isCollapsed;
const contentStyle = {};
if (contentHeight !== 0) {
contentStyle['max-height'] = isExpandable ? collapsedHeight : contentHeight;
}
});
// 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();
const setCollapsed = collapsed =>
inlineControls
? setCollapsedByInlineControls(collapsed)
: onToggleCollapsed(collapsed);
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"
/>
{isOverflowing && inlineControls && (
<InlineControls
isCollapsed={collapsedByInlineControls}
setCollapsed={setCollapsed}
linkStyle={applyTheme(['selectionFontFamily'], settings)}
/>
)}
</div>
);
}
/**
* @description This component truncates the height of its contents to a
* specified number of lines and provides controls for expanding
* and collapsing the resulting truncated element.
Excerpt.propTypes = {
children: propTypes.object,
/**
* If `true`, the excerpt provides internal controls to expand and collapse
* the content. If `false`, the caller sets the collapsed state via the
* `collapse` prop.
*
* When using inline controls, the excerpt is initially collapsed.
*/
module.exports = {
controller: ExcerptController,
controllerAs: 'vm',
bindings: {
/** Whether or not expansion should be animated. Defaults to true. */
animate: '<?',
inlineControls: propTypes.bool,
/**
* The data which is used to generate the excerpt's content.
* When this changes, the excerpt will recompute whether the content
* is overflowing.
* If the content should be truncated if its height exceeds
* `collapsedHeight + overflowThreshold`.
*
* This prop is only used if `inlineControls` is false.
*/
contentData: '<',
collapse: propTypes.bool,
/**
* Specifies whether controls to expand and collapse
* the excerpt should be shown inside the <excerpt> component.
* If false, external controls can expand/collapse the excerpt by
* setting the 'collapse' property.
* Maximum height of the container, in pixels, when it is collapsed.
*/
inlineControls: '<',
/** Sets whether or not the excerpt is collapsed. */
collapse: '=?',
collapsedHeight: propTypes.number,
/**
* Called when the collapsibility of the excerpt (that is, whether or
* not the content height exceeds the collapsed height), changes.
*
* Note: This function is *not* called from inside a digest cycle,
* the caller is responsible for triggering any necessary digests.
* An additional margin of pixels by which the content height can exceed
* `collapsedHeight` before it becomes collapsible.
*/
onCollapsibleChanged: '&?',
/** The height of this container in pixels when collapsed.
overflowThreshold: propTypes.number,
/**
* Called when the content height exceeds or falls below `collapsedHeight + overflowThreshold`.
*/
collapsedHeight: '<',
onCollapsibleChanged: propTypes.func,
/**
* The number of pixels by which the height of the excerpt's content
* must extend beyond the collapsed height in order for truncation to
* be activated. This prevents the 'More' link from being shown to expand
* 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.
* When `inlineControls` is `false`, this function is called when the user
* requests to expand the content by clicking a zone at the bottom of the
* container.
*/
overflowHysteresis: '<?',
},
transclude: true,
template: require('../templates/excerpt.html'),
onToggleCollapsed: propTypes.func,
// Used for theming.
settings: propTypes.object,
};
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 body 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() {
onClick: '&',
},
})
.component('markdownEditor', {
.component('annotationBody', {
bindings: {
text: '<',
collapse: '<',
hasContent: '<',
isEditing: '<',
isHiddenByModerator: '<',
onCollapsibleChanged: '&',
onEditText: '&',
onToggleCollapsed: '&',
text: '<',
},
})
.component('markdownView', {
.component('annotationQuote', {
bindings: {
markdown: '<',
textClass: '<',
isOrphan: '<',
quote: '<',
},
});
});
......@@ -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',
......@@ -1275,10 +1269,8 @@ describe('annotation', function() {
// Content still present.
text: 'Some offensive content',
}),
textClass: {
'annotation-body is-hidden': true,
'has-content': true,
},
isHiddenByModerator: true,
hasContent: true,
},
{
context: 'for non-moderators',
......@@ -1287,18 +1279,17 @@ describe('annotation', function() {
tags: [],
text: '',
}),
textClass: {
'annotation-body is-hidden': true,
'has-content': false,
isHiddenByModerator: true,
hasContent: false,
},
},
].forEach(testCase => {
it(`renders hidden annotations with a custom text class (${testCase.context})`, () => {
const el = createDirective(testCase.ann).element;
].forEach(({ ann, context, isHiddenByModerator, hasContent }) => {
it(`passes moderation status to annotation body (${context})`, () => {
const el = createDirective(ann).element;
assert.match(
el.find('markdown-view').controller('markdownView'),
el.find('annotation-body').controller('annotationBody'),
sinon.match({
textClass: testCase.textClass,
isHiddenByModerator,
hasContent,
})
);
});
......
'use strict';
const angular = require('angular');
const util = require('../../directive/test/util');
const excerpt = require('../excerpt');
describe('excerpt', function() {
// ExcerptOverflowMonitor fake instance created by the current test
let fakeOverflowMonitor;
const SHORT_DIV = '<div id="foo" style="height:5px;"></div>';
const TALL_DIV = '<div id="foo" style="height:200px;">foo bar</div>';
function excerptComponent(attrs, content) {
const defaultAttrs = {
contentData: 'the content',
collapsedHeight: 40,
inlineControls: false,
};
attrs = Object.assign(defaultAttrs, attrs);
return util.createDirective(document, 'excerpt', attrs, {}, content);
const { createElement } = require('preact');
const { act } = require('preact/test-utils');
const { mount } = require('enzyme');
const Excerpt = require('../excerpt');
describe('Excerpt', () => {
const SHORT_DIV = <div id="foo" style="height: 5px;" />;
const TALL_DIV = (
<div id="foo" style="height: 200px;">
foo bar
</div>
);
const DEFAULT_CONTENT = <span className="the-content">default content</span>;
let container;
let fakeObserveElementSize;
function createExcerpt(props = {}, content = DEFAULT_CONTENT) {
return mount(
<Excerpt
collapse={true}
collapsedHeight={40}
inlineControls={false}
settings={{}}
{...props}
>
{content}
</Excerpt>,
{ attachTo: container }
);
}
before(function() {
angular.module('app', []).component('excerpt', excerpt);
beforeEach(() => {
fakeObserveElementSize = sinon.stub();
container = document.createElement('div');
document.body.appendChild(container);
Excerpt.$imports.$mock({
'../util/observe-element-size': fakeObserveElementSize,
});
});
beforeEach(function() {
function FakeOverflowMonitor(ctrl) {
fakeOverflowMonitor = this; // eslint-disable-line consistent-this
afterEach(() => {
container.remove();
});
this.ctrl = ctrl;
this.check = sinon.stub();
this.contentStyle = sinon.stub().returns({});
function getExcerptHeight(wrapper) {
return wrapper.find('.excerpt').prop('style')['max-height'];
}
angular.mock.module('app');
angular.mock.module(function($provide) {
$provide.value('ExcerptOverflowMonitor', FakeOverflowMonitor);
});
it('renders content in container', () => {
const wrapper = createExcerpt();
const contentEl = wrapper.find('[test-name="excerpt-content"]');
assert.include(contentEl.html(), 'default content');
});
context('when created', function() {
it('schedules an overflow state recalculation', function() {
excerptComponent({}, '<span id="foo"></span>');
assert.called(fakeOverflowMonitor.check);
it('truncates content if it exceeds `collapsedHeight` + `overflowThreshold`', () => {
const wrapper = createExcerpt({}, TALL_DIV);
assert.equal(getExcerptHeight(wrapper), 40);
});
it('passes input properties to overflow state recalc', function() {
const attrs = {
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('does not truncate content if it does not exceed `collapsedHeight` + `overflowThreshold`', () => {
const wrapper = createExcerpt({}, SHORT_DIV);
assert.equal(getExcerptHeight(wrapper), 5);
});
it('reports the content height to ExcerptOverflowMonitor', function() {
excerptComponent({}, TALL_DIV);
assert.deepEqual(fakeOverflowMonitor.ctrl.contentHeight(), 200);
});
});
it('updates the collapsed state when the content height changes', () => {
const wrapper = createExcerpt({}, SHORT_DIV);
assert.called(fakeObserveElementSize);
context('input changes', function() {
it('schedules an overflow state check when inputs change', function() {
const element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
element.scope.contentData = 'new-content';
element.scope.$digest();
assert.calledOnce(fakeOverflowMonitor.check);
const contentElem = fakeObserveElementSize.getCall(0).args[0];
const sizeChangedCallback = fakeObserveElementSize.getCall(0).args[1];
act(() => {
contentElem.style.height = '400px';
sizeChangedCallback();
});
wrapper.update();
it('does not schedule a state check if inputs are unchanged', function() {
const element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
element.scope.$digest();
assert.notCalled(fakeOverflowMonitor.check);
});
});
assert.equal(getExcerptHeight(wrapper), 40);
context('document events', function() {
it('schedules an overflow check when media loads', function() {
const element = excerptComponent(
{},
'<img src="https://example.com/foo.jpg">'
);
fakeOverflowMonitor.check.reset();
util.sendEvent(element[0], 'load');
assert.called(fakeOverflowMonitor.check);
act(() => {
contentElem.style.height = '10px';
sizeChangedCallback();
});
wrapper.update();
it('schedules an overflow check when the window is resized', function() {
const element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
util.sendEvent(element[0].ownerDocument.defaultView, 'resize');
assert.called(fakeOverflowMonitor.check);
});
assert.equal(getExcerptHeight(wrapper), 10);
});
context('visibility changes', function() {
it('schedules an overflow check when shown', function() {
const element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.check.reset();
it('calls `onCollapsibleChanged` when collapsibility changes', () => {
const onCollapsibleChanged = sinon.stub();
createExcerpt({ onCollapsibleChanged }, SHORT_DIV);
// 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);
});
const contentElem = fakeObserveElementSize.getCall(0).args[0];
const sizeChangedCallback = fakeObserveElementSize.getCall(0).args[1];
act(() => {
contentElem.style.height = '400px';
sizeChangedCallback();
});
context('excerpt content style', function() {
it('sets the content style using ExcerptOverflowMonitor#contentStyle()', function() {
const element = excerptComponent({}, '<span></span>');
fakeOverflowMonitor.contentStyle.returns({ 'max-height': '52px' });
element.scope.$digest();
const content = element[0].querySelector('.excerpt');
assert.equal(content.style.cssText.trim(), 'max-height: 52px;');
});
assert.calledWith(onCollapsibleChanged, { collapsible: true });
});
function isHidden(el) {
return !el.offsetParent || el.classList.contains('ng-hide');
}
it('calls `onToggleCollapsed` when user clicks in bottom area to expand excerpt', () => {
const onToggleCollapsed = sinon.stub();
const wrapper = createExcerpt({ onToggleCollapsed }, TALL_DIV);
const control = wrapper.find('.excerpt__shadow');
assert.equal(getExcerptHeight(wrapper), 40);
control.simulate('click');
assert.called(onToggleCollapsed);
});
function findVisible(el, selector) {
const elements = el.querySelectorAll(selector);
for (let i = 0; i < elements.length; i++) {
if (!isHidden(elements[i])) {
return elements[i];
}
}
return undefined;
}
context('when inline controls are enabled', () => {
it('displays inline controls if collapsed', () => {
const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
assert.isTrue(wrapper.exists('.excerpt__inline-controls'));
});
describe('inline controls', function() {
function findInlineControl(el) {
return findVisible(el, '.excerpt__toggle-link');
}
it('does not display inline controls if not collapsed', () => {
const wrapper = createExcerpt({ inlineControls: true }, SHORT_DIV);
assert.isFalse(wrapper.exists('.excerpt__inline-controls'));
});
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() {
const element = excerptComponent({ inlineControls: true }, SHORT_DIV);
const expandLink = findInlineControl(element[0]);
assert.notOk(expandLink);
});
it('toggles the expanded state when clicked', function() {
const element = excerptComponent({ inlineControls: true }, TALL_DIV);
fakeOverflowMonitor.ctrl.onOverflowChanged(true);
const expandLink = findInlineControl(element[0]);
angular.element(expandLink.querySelector('a')).click();
element.scope.$digest();
const collapseLink = findInlineControl(element[0]);
assert.equal(collapseLink.querySelector('a').textContent, 'Less');
});
});
describe('bottom area', function() {
it('expands the excerpt when clicking at the bottom if collapsed', function() {
const element = excerptComponent({ inlineControls: true }, TALL_DIV);
element.scope.$digest();
assert.isTrue(element.ctrl.collapse);
const bottomArea = element[0].querySelector('.excerpt__shadow');
angular.element(bottomArea).click();
assert.isFalse(element.ctrl.collapse);
});
});
describe('#onCollapsibleChanged', function() {
it('is called when overflow state changes', function() {
const callback = sinon.stub();
excerptComponent(
{
onCollapsibleChanged: {
args: ['collapsible'],
callback: callback,
},
},
'<span></span>'
);
fakeOverflowMonitor.ctrl.onOverflowChanged(true);
assert.calledWith(callback, true);
fakeOverflowMonitor.ctrl.onOverflowChanged(false);
assert.calledWith(callback, false);
it('toggles the expanded state when clicked', () => {
const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
const control = wrapper.find('.excerpt__inline-controls a');
assert.equal(getExcerptHeight(wrapper), 40);
control.simulate('click');
assert.equal(getExcerptHeight(wrapper), 200);
});
});
});
......@@ -130,6 +130,10 @@ function startAngularApp(config) {
// UI components
.component('annotation', require('./components/annotation'))
.component(
'annotationBody',
wrapReactComponent(require('./components/annotation-body'))
)
.component(
'annotationHeader',
wrapReactComponent(require('./components/annotation-header'))
......@@ -142,6 +146,10 @@ function startAngularApp(config) {
'annotationPublishControl',
wrapReactComponent(require('./components/annotation-publish-control'))
)
.component(
'annotationQuote',
wrapReactComponent(require('./components/annotation-quote'))
)
.component(
'annotationShareDialog',
require('./components/annotation-share-dialog')
......@@ -151,7 +159,6 @@ function startAngularApp(config) {
'annotationViewerContent',
require('./components/annotation-viewer-content')
)
.component('excerpt', require('./components/excerpt'))
.component(
'helpPanel',
wrapReactComponent(require('./components/help-panel'))
......@@ -160,14 +167,6 @@ function startAngularApp(config) {
'loggedOutMessage',
wrapReactComponent(require('./components/logged-out-message'))
)
.component(
'markdownEditor',
wrapReactComponent(require('./components/markdown-editor'))
)
.component(
'markdownView',
wrapReactComponent(require('./components/markdown-view'))
)
.component(
'moderationBanner',
wrapReactComponent(require('./components/moderation-banner'))
......@@ -232,7 +231,6 @@ function startAngularApp(config) {
// Utilities
.value('Discovery', require('../shared/discovery'))
.value('ExcerptOverflowMonitor', require('./util/excerpt-overflow-monitor'))
.value('OAuthClient', require('./util/oauth-client'))
.value('VirtualThreadList', require('./virtual-thread-list'))
.value('isSidebar', isSidebar)
......
......@@ -13,45 +13,22 @@
show-document-info="vm.showDocumentInfo">
</annotation-header>
<!-- Excerpts -->
<section class="annotation-quote-list"
ng-class="{'is-orphan' : vm.isOrphan()}"
<annotation-quote
quote="vm.quote()"
is-orphan="vm.isOrphan()"
ng-if="vm.quote()">
<excerpt collapsed-height="35"
inline-controls="true"
overflow-hysteresis="20"
content-data="selector.exact">
<blockquote class="annotation-quote"
h-branding="selectionFontFamily"
ng-bind="vm.quote()"></blockquote>
</excerpt>
</section>
</annotation-quote>
<!-- / Excerpts -->
<!-- Body -->
<section name="text" class="annotation-body">
<excerpt
inline-controls="false"
on-collapsible-changed="vm.setBodyCollapsible(collapsible)"
<annotation-body
collapse="vm.collapseBody"
collapsed-height="400"
overflow-hysteresis="20"
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"
has-content="vm.hasContent()"
is-editing="vm.editing()"
is-hidden-by-moderator="vm.isHiddenByModerator()"
on-collapsible-changed="vm.setBodyCollapsible(collapsible)"
on-edit-text="vm.setText(text)"
ng-if="vm.editing()">
</markdown-editor>
</section>
<!-- / Body -->
on-toggle-collapsed="vm.collapseBody = collapsed"
text="vm.state().text">
</annotation-body>
<!-- Tags -->
<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 @@
.excerpt {
transition: max-height $expand-duration ease-in;
overflow: hidden;
position: relative;
}
// 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