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( ...@@ -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) {
......
This diff is collapsed.
'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() { ...@@ -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,
}) })
); );
}); });
......
This diff is collapsed.
...@@ -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