Commit c285a898 authored by Nick Stenning's avatar Nick Stenning

Merge pull request #2802 from hypothesis/t158-truncated_annot_design

T158 - Update truncated annotation card design
parents 9574d01e e04aba4e
......@@ -310,6 +310,14 @@ function AnnotationController(
*/
vm.cancelTimestampRefresh = undefined;
/** Determines whether controls to expand/collapse the annotation body
* are displayed adjacent to the tags field.
*/
vm.canCollapseBody = false;
/** Determines whether the annotation body should be collapsed. */
vm.collapseBody = true;
/** The domain model, contains the currently saved version of the
* annotation from the server (or in the case of new annotations that
* haven't been saved yet - the data that will be saved to the server when
......@@ -588,6 +596,11 @@ function AnnotationController(
}
};
vm.toggleCollapseBody = function(event) {
event.stopPropagation();
vm.collapseBody = !vm.collapseBody;
};
/**
* @ngdoc method
* @name annotation.AnnotationController#reply
......@@ -750,6 +763,14 @@ function AnnotationController(
vm.user = function() {
return domainModel.user;
}
/** Sets whether or not the controls for
* expanding/collapsing the body of lengthy annotations
* should be shown.
*/
vm.setBodyCollapsible = function(canCollapse) {
vm.canCollapseBody = canCollapse;
};
init();
......
'use strict';
function ExcerptController() {
var collapsed = true;
if (this.collapse === undefined) {
this.collapse = true;
}
// Enabled is a test seam: overwritten in link function.
this.enabled = function () { return true; };
if (this.animate === undefined) {
this.animate = true;
}
// Overflowing is a test seam: overwritten in link function.
this.overflowing = function () { return false; };
this.enabled = this.enabled || function () {
return true;
};
// Is the excerpt collapsed? True if no-one has toggled the excerpt open
// and the element is overflowing.
this.collapsed = function () {
if (!collapsed) {
return false;
}
return this.overflowing();
this.isExpandable = function () {
return this.overflowing() && this.collapse;
};
this.uncollapsed = function () {
return !collapsed;
this.isCollapsible = function () {
return this.overflowing() && !this.collapse;
};
this.toggle = function () {
collapsed = !collapsed;
this.toggle = function (event) {
event.stopPropagation();
this.collapse = !this.collapse;
};
return this;
this.showInlineControls = function () {
return this.overflowing() && this.inlineControls;
}
this.bottomShadowStyles = function () {
return {
'excerpt__shadow': true,
'excerpt__shadow--transparent': this.inlineControls,
'is-hidden': !this.isExpandable(),
};
}
}
function toPx(val) {
return val.toString() + 'px';
}
/**
* @ngdoc directive
* @name excerpt
* @restrict E
* @description This directive truncates its contents to a height specified in
* CSS, and provides controls for expanding and collapsing the
* resulting truncated element. For example, with the following
* template HTML:
*
* <article class="post">
* <excerpt>
* <div class="body" ng-model="post.body"></div>
* </excerpt>
* </article>
*
* You would need to define the allowable height of the excerpt in
* CSS:
*
* article.post .excerpt {
* max-height: 10em;
* }
*
* And the excerpt directive will take care of the rest.
*
* You can selectively disable truncation by providing a boolean
* expression to the `enabled` parameter, e.g.:
*
* <excerpt enabled="!post.inFull">...</excerpt>
* @description This directive truncates the height of its contents to a
* specified number of lines and provides controls for expanding
* and collapsing the resulting truncated element.
*/
// @ngInject
function excerpt() {
return {
bindToController: true,
controller: ExcerptController,
controllerAs: 'vm',
link: function (scope, elem, attrs, ctrl) {
// Test if the transcluded element is overflowing its container. We use
// clientHeight rather than offsetHeight because we assume you'll be using
// this with "overflow: hidden;" (i.e. no scrollbars) and it's usually
// much faster to calculate than offsetHeight (which includes scrollbars).
var contentElem;
ctrl.contentStyle = function contentStyle() {
if (!contentElem) {
return {};
}
var maxHeight;
if (ctrl.collapse) {
maxHeight = toPx(ctrl.collapsedHeight);
} else if (ctrl.animate) {
// animating the height change requires that the final
// height be specified exactly, rather than relying on
// auto height
maxHeight = toPx(contentElem.scrollHeight);
} else {
maxHeight = '';
}
return {
'max-height': maxHeight,
};
}
ctrl.overflowing = function overflowing() {
var excerpt = elem[0].querySelector('.excerpt');
if (!excerpt) {
if (!contentElem) {
return false;
}
return (excerpt.scrollHeight > excerpt.clientHeight);
return contentElem.scrollHeight > ctrl.collapsedHeight;
};
// If the `enabled` attr was provided, we override the enabled function.
if (attrs.enabled) {
ctrl.enabled = scope.enabled;
}
scope.$watch('vm.enabled()', function (isEnabled) {
if (isEnabled) {
contentElem = elem[0].querySelector('.excerpt');
// trigger an update of the excerpt when events happen
// outside of Angular's knowledge that might affect the content
// size. For now, the only event we handle is loading of
// embedded media or frames
contentElem.addEventListener('load', scope.$digest.bind(scope),
true /* capture. 'load' events do not bubble */);
} else {
contentElem = undefined;
}
});
scope.$watch('vm.overflowing()', function () {
if (ctrl.onCollapsibleChanged) {
ctrl.onCollapsibleChanged({collapsible: ctrl.overflowing()});
}
});
},
scope: {
/** Whether or not expansion should be animated. Defaults to true. */
animate: '=',
/** Whether or not truncation should be enabled */
enabled: '&?',
/**
* 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.
*/
inlineControls: '=',
/** Sets whether or not the excerpt is collapsed. */
collapse: '=',
/** Called when the collapsibility of the excerpt (that is, whether or
* not the content height exceeds the collapsed height), changes.
*/
onCollapsibleChanged: '&?',
/** The height of this container in pixels when collapsed.
*/
collapsedHeight: '=',
},
restrict: 'E',
transclude: true,
......
'use strict';
var assign = require('core-js/modules/$.object-assign');
var util = require('./util');
var excerpt = require('../excerpt');
describe('excerpt directive', function () {
var SHORT_DIV = '<div id="foo" style="height:5px;"></div>';
var TALL_DIV = '<div id="foo" style="height:200px;">foo bar</div>';
describe('excerpt.Controller', function () {
var ctrl;
beforeEach(function() {
ctrl = new excerpt.Controller();
ctrl.overflowing = function () { return false; };
});
function excerptDirective(attrs, content) {
var defaultAttrs = {
// disable animation so that expansion/collapse happens immediately
// when the controls are toggled in tests
animate: false,
enabled: true,
collapsedHeight: 40,
inlineControls: false,
};
attrs = assign(defaultAttrs, attrs);
return util.createDirective(document, 'excerpt', attrs, {}, content);
}
it('starts collapsed if the element is overflowing', function () {
ctrl.overflowing = function () { return true; };
function height(el) {
return el.querySelector('.excerpt').offsetHeight;
}
assert.isTrue(ctrl.collapsed());
before(function () {
angular.module('app', [])
.directive('excerpt', excerpt.directive);
});
it('does not start collapsed if the element is not overflowing', function () {
assert.isFalse(ctrl.collapsed());
beforeEach(function () {
angular.mock.module('app');
angular.mock.module('h.templates');
});
it('is not initially uncollapsed if the element is overflowing', function () {
assert.isFalse(ctrl.uncollapsed());
});
describe('enabled state', function () {
it('renders its contents in a .excerpt element by default', function () {
var element = excerptDirective({}, '<span id="foo"></span>');
it('is not initially uncollapsed if the element is not overflowing', function () {
assert.isFalse(ctrl.uncollapsed());
});
assert.equal(element.find('.excerpt #foo').length, 1);
});
describe('.toggle()', function () {
beforeEach(function () {
ctrl.overflowing = function () { return true; };
it('when enabled, renders its contents in a .excerpt element', function () {
var element = excerptDirective({enabled: true}, '<span id="foo"></span>');
assert.equal(element.find('.excerpt #foo').length, 1);
});
it('toggles the collapsed state', function () {
var a = ctrl.collapsed();
ctrl.toggle();
var b = ctrl.collapsed();
ctrl.toggle();
var c = ctrl.collapsed();
it('when disabled, renders its contents but not in a .excerpt element', function () {
var element = excerptDirective({enabled: false}, '<span id="foo"></span>');
assert.notEqual(a, b);
assert.notEqual(b, c);
assert.equal(a, c);
assert.equal(element.find('.excerpt #foo').length, 0);
assert.equal(element.find('#foo').length, 1);
});
});
});
it('truncates long contents when enabled', function () {
var element = excerptDirective({enabled: false}, TALL_DIV);
element.scope.enabled = true;
element.scope.$digest();
assert.isBelow(height(element[0]), 100);
});
});
describe('excerpt.excerpt', function () {
function excerptDirective(attrs, content) {
return util.createDirective(document, 'excerpt', attrs, {}, content);
function isHidden(el) {
return !el.offsetParent || el.classList.contains('ng-hide');
}
before(function () {
angular.module('app', [])
.directive('excerpt', excerpt.directive);
});
function findVisible(el, selector) {
var elements = el.querySelectorAll(selector);
for (var i=0; i < elements.length; i++) {
if (!isHidden(elements[i])) {
return elements[i];
}
}
return undefined;
}
beforeEach(function () {
angular.mock.module('app');
angular.mock.module('h.templates');
});
describe('inline controls', function () {
function findInlineControl(el) {
return findVisible(el, '.excerpt__toggle-link');
}
it('displays inline controls if collapsed', function () {
var element = excerptDirective({inlineControls: true},
TALL_DIV);
element.scope.$digest();
var expandLink = findInlineControl(element[0]);
assert.ok(expandLink);
assert.equal(expandLink.querySelector('a').textContent, 'More');
});
it('renders its contents in a .excerpt element by default', function () {
var element = excerptDirective({}, '<span id="foo"></span>');
it('does not display inline controls if not collapsed', function () {
var element = excerptDirective({inlineControls: true},
SHORT_DIV);
var expandLink = findInlineControl(element[0]);
assert.notOk(expandLink);
});
assert.equal(element.find('.excerpt #foo').length, 1);
it('toggles the expanded state when clicked', function () {
var element = excerptDirective({inlineControls: true},
TALL_DIV);
element.scope.$digest();
var expandLink = findInlineControl(element[0]);
angular.element(expandLink.querySelector('a')).click();
element.scope.$digest();
var collapseLink = findInlineControl(element[0]);
assert.equal(collapseLink.querySelector('a').textContent, 'Less');
});
});
it('when enabled, renders its contents in a .excerpt element', function () {
var element = excerptDirective({enabled: true}, '<span id="foo"></span>');
describe('.collapse', function () {
it('collapses the body if collapse is true', function () {
var element = excerptDirective({collapse: true}, TALL_DIV);
assert.isBelow(height(element[0]), 100);
});
assert.equal(element.find('.excerpt #foo').length, 1);
it('does not collapse the body if collapse is false', function () {
var element = excerptDirective({collapse: false}, TALL_DIV);
assert.isAbove(height(element[0]), 100);
});
});
it('when disabled, renders its contents but not in a .excerpt element', function () {
var element = excerptDirective({enabled: false}, '<span id="foo"></span>');
describe('.onCollapsibleChanged', function () {
it('reports true if excerpt is tall', function () {
var callback = sinon.stub();
var element = excerptDirective({
onCollapsibleChanged: {
args: ['collapsible'],
callback: callback,
}
}, TALL_DIV);
assert.calledWith(callback, true);
});
assert.equal(element.find('.excerpt #foo').length, 0);
assert.equal(element.find('#foo').length, 1);
it('reports false if excerpt is short', function () {
var callback = sinon.stub();
var element = excerptDirective({
onCollapsibleChanged: {
args: ['collapsible'],
callback: callback,
}
}, SHORT_DIV);
assert.calledWith(callback, false);
});
});
});
......@@ -51,15 +51,21 @@ function hyphenate(name) {
* scope when the element is linked
* @param {string} [initialHtml] - Initial inner HTML content for the directive
* element.
* @param {Object} [opts] - Object specifying options for creating the
* directive:
* 'parentElement' - The parent element for the new
* directive. Defaults to document.body
*
* @return {DOMElement} The Angular jqLite-wrapped DOM element for the component.
* The returned object has a link(scope) method which will
* re-link the component with new properties.
*/
function createDirective(document, name, attrs, initialScope, initialHtml) {
function createDirective(document, name, attrs, initialScope, initialHtml, opts) {
attrs = attrs || {};
initialScope = initialScope || {};
initialHtml = initialHtml || '';
opts = opts || {};
opts.parentElement = opts.parentElement || document.body;
// create a template consisting of a single element, the directive
// we want to create and compile it
......@@ -82,6 +88,11 @@ function createDirective(document, name, attrs, initialScope, initialHtml) {
});
templateElement.innerHTML = initialHtml;
// add the element to the document's body so that
// it responds to events, becomes visible, reports correct
// values for its dimensions etc.
opts.parentElement.appendChild(templateElement);
// setup initial scope
Object.keys(attrs).forEach(function (key) {
if (attrs[key].callback) {
......@@ -105,6 +116,7 @@ function createDirective(document, name, attrs, initialScope, initialHtml) {
element.link = linkDirective;
element.scope = childScope;
childScope.$digest();
element.ctrl = element.controller(name);
return element;
}
......
......@@ -20,22 +20,16 @@ $annotation-card-left-padding: 10px;
font-family: $sans-font-family;
font-weight: 300;
position: relative;
.reply-count {
color: $gray-light;
&:focus { outline: 0; }
}
&:hover .annotation-timestamp, &:hover .reply-count {
color: $link-color;
}
}
.annotation-timestamp {
.annotation-link {
font-size: $body1-font-size;
color: $color-gray;
&:hover { color: $link-color-hover; }
&:focus { outline: 0; }
.annotation:hover & {
color: $link-color;
}
}
.annotation-quote-list,
......@@ -78,25 +72,6 @@ $annotation-card-left-padding: 10px;
.annotation-quote-list {
margin-top: 14px;
margin-bottom: 14px;
.excerpt { max-height: 4.8em; }
.excerpt-control a {
font-style: italic;
font-family: $serif-font-family;
font-weight: normal;
}
.excerpt--collapsed:after {
height: $base-line-height;
@include background(linear-gradient(
to right,
$mask-start-color,
$mask-end-color
));
}
}
.annotation-body {
.excerpt { max-height: 16.2em; }
}
.annotation-media-embed {
......@@ -168,7 +143,6 @@ $annotation-card-left-padding: 10px;
}
}
//PRIVACY CONTROL////////////////////////////
privacy {
position: relative;
......
@import "compass/css3/images";
@at-root {
$expand-duration: .15s;
.excerpt {
position: relative;
overflow: hidden;
}
// the truncated body of the <excerpt>
.excerpt {
transition: max-height $expand-duration ease-in;
overflow: hidden;
}
.excerpt--collapsed:after {
position: absolute;
bottom: 0;
height: $base-line-height * 2; // This controls the apparent height of the gradient.
width: 100%;
content: "";
pointer-events: none;
@include background(linear-gradient(
to bottom,
$mask-start-color,
$mask-end-color
));
}
// a container which wraps the <excerpt> and contains the excerpt
// itself plus the shadow at the bottom that can be clicked to expand or
// collapse it
.excerpt__container {
position: relative;
}
.excerpt--uncollapsed {
max-height: 100% !important;
}
// inline controls for expanding and collapsing
// the <excerpt>
// -------------
.excerpt__inline-controls {
display: block;
position: absolute;
right: 0;
bottom: 0;
}
.excerpt__toggle-link {
padding-left: 15px;
background-image: linear-gradient(to right, transparent 0px, white 12px);
}
.excerpt__toggle-link > a {
color: $text-color;
font-style: italic;
font-weight: normal;
}
// a shadow displayed at the bottom of an <excerpt>s with inline controls
// disabled, which provides a hint that the excerpt is collapsed
// -------------
// the distance by which the shadow indicating a collapsed
// annotation expands beyond the left/right edges of the card.
// This value is chosen such that the shadow expands to the full width of
// the card
$shadow-h-offset: -12px;
.excerpt__shadow {
position: absolute;
left: $shadow-h-offset;
right: $shadow-h-offset;
bottom: 0;
height: 40px;
background-image: linear-gradient(to bottom,
transparent 50%, rgba(0,0,0,0.08) 95%, rgba(0,0,0,0.13) 100%);
transition: opacity $expand-duration linear;
}
.excerpt__shadow--transparent {
background-image: none;
}
.excerpt-control a {
display: block;
text-align: right;
font-weight: bold;
width: 100%;
.excerpt__shadow.is-hidden {
opacity: 0;
pointer-events: none;
}
}
......@@ -3,6 +3,11 @@
flex-grow: 1;
}
.u-layout-row {
display: flex;
flex-direction: row;
}
.u-center {
margin-left: auto;
margin-right: auto;
......@@ -13,3 +18,7 @@
flex-direction: row;
justify-content: flex-end;
}
.u-strong {
font-weight: bold;
}
......@@ -14,7 +14,7 @@
</span>
<span class="annotation-collapsed-replies">
<a class="reply-count small" href=""
<a class="annotation-link" href=""
ng-click="replyCountClick()"
ng-pluralize count="replyCount"
when="{'0': '', 'one': '1 reply', 'other': '{} replies'}"></a>
......@@ -45,7 +45,7 @@
<span class="u-flex-spacer"></span>
<!-- Timestamp -->
<a class="annotation-timestamp"
<a class="annotation-link"
target="_blank"
title="{{vm.absoluteTimestamp}}"
ng-if="!vm.editing() && vm.updated()"
......@@ -57,7 +57,9 @@
<section class="annotation-quote-list"
ng-repeat="target in vm.target() track by $index"
ng-if="vm.hasQuotes()">
<excerpt enabled="vm.feature('truncate_annotations')">
<excerpt enabled="vm.feature('truncate_annotations')"
collapsed-height="40"
inline-controls="true">
<blockquote class="annotation-quote"
ng-bind-html="selector.exact"
ng-repeat="selector in target.selector
......@@ -70,7 +72,11 @@
<!-- Body -->
<section name="text" class="annotation-body">
<excerpt enabled="vm.feature('truncate_annotations') && !vm.editing()">
<excerpt enabled="vm.feature('truncate_annotations') && !vm.editing()"
inline-controls="false"
on-collapsible-changed="vm.setBodyCollapsible(collapsible)"
collapse="vm.collapseBody"
collapsed-height="200">
<markdown ng-model="vm.form.text"
read-only="!vm.editing()"
embeds-enabled="vm.feature('embed_media')">
......@@ -94,13 +100,18 @@
</tags-input>
</div>
<div class="annotation-body tags tags-read-only"
ng-if="vm.form.tags.length && !vm.editing()">
<div class="annotation-body u-layout-row tags tags-read-only"
ng-if="(vm.canCollapseBody || vm.form.tags.length) && !vm.editing()">
<ul class="tag-list">
<li class="tag-item" ng-repeat="tag in vm.form.tags">
<a href="/stream?q=tag:'{{tag.text|urlencode}}'" target="_blank">{{tag.text}}</a>
</li>
</ul>
<div class="u-stretch"></div>
<a class="annotation-link u-strong" ng-show="vm.canCollapseBody"
ng-click="vm.toggleCollapseBody($event)"
ng-title="vm.collapseBody ? 'Show the full annotation text' : 'Show the first few lines only'"
ng-bind="vm.collapseBody ? 'More' : 'Less'"></a>
</div>
<!-- / Tags -->
......@@ -130,7 +141,7 @@
</div>
<div class="annotation-replies" ng-if="replyCount > 0">
<a class="reply-count small" href=""
<a class="annotation-link" href=""
ng-click="replyCountClick()"
ng-pluralize count="replyCount"
when="{'0': '', 'one': '1 reply', 'other': '{} replies'}"></a>
......
<div ng-transclude ng-if="!vm.enabled()"></div>
<div ng-if="vm.enabled()">
<div class="excerpt"
ng-class="{'excerpt--uncollapsed': vm.uncollapsed(),
'excerpt--collapsed': vm.collapsed()}"
ng-transclude></div>
<div class="excerpt-control">
<a ng-if="vm.collapsed()" ng-click="vm.toggle()">More</a>
<a ng-if="vm.uncollapsed()" ng-click="vm.toggle()">Less</a>
<div class="excerpt__container" ng-if="vm.enabled()">
<div class="excerpt" ng-style="vm.contentStyle()">
<div ng-transclude></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">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">Less</a>
</span>
</div>
</div>
<div ng-click="vm.toggle($event)"
ng-class="vm.bottomShadowStyles()"
title="Show the full excerpt"></div>
</div>
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