Commit 4b0e2d7a authored by Robert Knight's avatar Robert Knight

Merge pull request #2451 from hypothesis/visual-truncation

Visually truncate long bodies and quotes
parents 5d771a9f 9be372b8
...@@ -105,6 +105,7 @@ module.exports = angular.module('h', [ ...@@ -105,6 +105,7 @@ module.exports = angular.module('h', [
.directive('annotation', require('./directive/annotation')) .directive('annotation', require('./directive/annotation'))
.directive('deepCount', require('./directive/deep-count')) .directive('deepCount', require('./directive/deep-count'))
.directive('excerpt', require('./directive/excerpt').directive)
.directive('formInput', require('./directive/form-input')) .directive('formInput', require('./directive/form-input'))
.directive('formValidate', require('./directive/form-validate')) .directive('formValidate', require('./directive/form-validate'))
.directive('groupList', require('./directive/group-list').directive) .directive('groupList', require('./directive/group-list').directive)
......
...@@ -405,8 +405,8 @@ AnnotationController = [ ...@@ -405,8 +405,8 @@ AnnotationController = [
# #
### ###
module.exports = [ module.exports = [
'$document', '$document', 'features'
($document) -> ($document, features) ->
linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) -> linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) ->
# Observe the isSidebar attribute # Observe the isSidebar attribute
attrs.$observe 'isSidebar', (value) -> attrs.$observe 'isSidebar', (value) ->
...@@ -419,6 +419,9 @@ module.exports = [ ...@@ -419,6 +419,9 @@ module.exports = [
scope.$evalAsync -> scope.$evalAsync ->
ctrl.save() ctrl.save()
# Give template access to feature flags
scope.feature = features.flagEnabled
scope.share = (event) -> scope.share = (event) ->
$container = angular.element(event.currentTarget).parent() $container = angular.element(event.currentTarget).parent()
$container.addClass('open').find('input').focus().select() $container.addClass('open').find('input').focus().select()
......
'use strict';
function ExcerptController() {
var collapsed = true;
// Enabled is a test seam: overwritten in link function.
this.enabled = function () { return true; };
// Overflowing is a test seam: overwritten in link function.
this.overflowing = function () { return false; };
// 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.uncollapsed = function () {
return !collapsed;
};
this.toggle = function () {
collapsed = !collapsed;
};
return this;
}
/**
* @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>
*/
function excerpt() {
return {
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).
ctrl.overflowing = function overflowing() {
var excerpt = elem[0].querySelector('.excerpt');
if (!excerpt) {
return false;
}
return (excerpt.scrollHeight > excerpt.clientHeight);
};
// If the `enabled` attr was provided, we override the enabled function.
if (attrs.enabled) {
ctrl.enabled = scope.enabled;
}
},
scope: {
enabled: '&?',
},
restrict: 'E',
transclude: true,
templateUrl: 'excerpt.html',
};
}
module.exports = {
directive: excerpt,
Controller: ExcerptController,
};
...@@ -13,6 +13,7 @@ describe 'annotation', -> ...@@ -13,6 +13,7 @@ describe 'annotation', ->
fakeAnnotationMapper = null fakeAnnotationMapper = null
fakeAnnotationUI = null fakeAnnotationUI = null
fakeDrafts = null fakeDrafts = null
fakeFeatures = null
fakeFlash = null fakeFlash = null
fakeGroups = null fakeGroups = null
fakeMomentFilter = null fakeMomentFilter = null
...@@ -56,6 +57,9 @@ describe 'annotation', -> ...@@ -56,6 +57,9 @@ describe 'annotation', ->
add: sandbox.stub() add: sandbox.stub()
remove: sandbox.stub() remove: sandbox.stub()
} }
fakeFeatures = {
flagEnabled: sandbox.stub().returns(true)
}
fakeFlash = sandbox.stub() fakeFlash = sandbox.stub()
fakeMomentFilter = sandbox.stub().returns('ages ago') fakeMomentFilter = sandbox.stub().returns('ages ago')
...@@ -92,6 +96,7 @@ describe 'annotation', -> ...@@ -92,6 +96,7 @@ describe 'annotation', ->
$provide.value 'annotationMapper', fakeAnnotationMapper $provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI $provide.value 'annotationUI', fakeAnnotationUI
$provide.value 'drafts', fakeDrafts $provide.value 'drafts', fakeDrafts
$provide.value 'features', fakeFeatures
$provide.value 'flash', fakeFlash $provide.value 'flash', fakeFlash
$provide.value 'momentFilter', fakeMomentFilter $provide.value 'momentFilter', fakeMomentFilter
$provide.value 'permissions', fakePermissions $provide.value 'permissions', fakePermissions
...@@ -610,7 +615,7 @@ describe("AnnotationController", -> ...@@ -610,7 +615,7 @@ describe("AnnotationController", ->
Return an annotation directive instance and stub services etc. Return an annotation directive instance and stub services etc.
### ###
createAnnotationDirective = ({annotation, personaFilter, momentFilter, createAnnotationDirective = ({annotation, personaFilter, momentFilter,
urlencodeFilter, drafts, flash, urlencodeFilter, drafts, features, flash,
permissions, session, tags, time, annotationUI, permissions, session, tags, time, annotationUI,
annotationMapper, groups, annotationMapper, groups,
documentTitleFilter, documentDomainFilter}) -> documentTitleFilter, documentDomainFilter}) ->
...@@ -622,6 +627,9 @@ describe("AnnotationController", -> ...@@ -622,6 +627,9 @@ describe("AnnotationController", ->
add: -> add: ->
remove: -> remove: ->
} }
features: features or {
flagEnabled: -> true
}
flash: flash or { flash: flash or {
info: -> info: ->
error: -> error: ->
...@@ -651,6 +659,7 @@ describe("AnnotationController", -> ...@@ -651,6 +659,7 @@ describe("AnnotationController", ->
$provide.value("momentFilter", locals.momentFilter) $provide.value("momentFilter", locals.momentFilter)
$provide.value("urlencodeFilter", locals.urlencodeFilter) $provide.value("urlencodeFilter", locals.urlencodeFilter)
$provide.value("drafts", locals.drafts) $provide.value("drafts", locals.drafts)
$provide.value("features", locals.features)
$provide.value("flash", locals.flash) $provide.value("flash", locals.flash)
$provide.value("permissions", locals.permissions) $provide.value("permissions", locals.permissions)
$provide.value("session", locals.session) $provide.value("session", locals.session)
......
'use strict';
var util = require('./util');
var excerpt = require('../excerpt');
describe('excerpt.Controller', function () {
var ctrl;
beforeEach(function() {
ctrl = new excerpt.Controller();
ctrl.overflowing = function () { return false; };
});
it('starts collapsed if the element is overflowing', function () {
ctrl.overflowing = function () { return true; };
assert.isTrue(ctrl.collapsed());
});
it('does not start collapsed if the element is not overflowing', function () {
assert.isFalse(ctrl.collapsed());
});
it('is not initially uncollapsed if the element is overflowing', function () {
assert.isFalse(ctrl.uncollapsed());
});
it('is not initially uncollapsed if the element is not overflowing', function () {
assert.isFalse(ctrl.uncollapsed());
});
describe('.toggle()', function () {
beforeEach(function () {
ctrl.overflowing = function () { return true; };
});
it('toggles the collapsed state', function () {
var a = ctrl.collapsed();
ctrl.toggle();
var b = ctrl.collapsed();
ctrl.toggle();
var c = ctrl.collapsed();
assert.notEqual(a, b);
assert.notEqual(b, c);
assert.equal(a, c);
});
});
});
describe('excerpt.excerpt', function () {
function excerptDirective(attrs, content) {
return util.createDirective(document, 'excerpt', attrs, {}, content);
}
before(function () {
angular.module('app', [])
.directive('excerpt', excerpt.directive);
});
beforeEach(function () {
angular.mock.module('app');
angular.mock.module('h.templates');
});
it('renders its contents in a .excerpt element by default', function () {
var element = excerptDirective({}, '<span id="foo"></span>');
assert.equal(element.find('.excerpt #foo').length, 1);
});
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('when disabled, renders its contents but not in a .excerpt element', function () {
var element = excerptDirective({enabled: false}, '<span id="foo"></span>');
assert.equal(element.find('.excerpt #foo').length, 0);
assert.equal(element.find('#foo').length, 1);
});
});
...@@ -15,26 +15,31 @@ function hyphenate(name) { ...@@ -15,26 +15,31 @@ function hyphenate(name) {
* attrA: 'initial-value' * attrA: 'initial-value'
* }, { * }, {
* scopePropery: scopeValue * scopePropery: scopeValue
* }); * },
* 'Hello, world!');
* *
* Will generate '<my-component attr-a="attrA"></my-component>' and * Will generate '<my-component attr-a="attrA">Hello, world!</my-component>' and
* compile and link it with the scope: * compile and link it with the scope:
* *
* { attrA: 'initial-value', scopeProperty: scopeValue } * { attrA: 'initial-value', scopeProperty: scopeValue }
* *
* @param {Document} document - The DOM Document to create the element in * @param {Document} document - The DOM Document to create the element in
* @param {string} name - The name of the directive to instantiate * @param {string} name - The name of the directive to instantiate
* @param {Object} attrs - A map of attribute names (in camelCase) to initial values. * @param {Object} [attrs] - A map of attribute names (in camelCase) to initial
* @param {Object} initialScope - A dictionary of properties to set on the * values.
* @param {Object} [initialScope] - A dictionary of properties to set on the
* scope when the element is linked * scope when the element is linked
* @param {string} [initialHtml] - Initial inner HTML content for the directive
* element.
* *
* @return {DOMElement} The Angular jqLite-wrapped DOM element for the component. * @return {DOMElement} The Angular jqLite-wrapped DOM element for the component.
* The returned object has a link(scope) method which will * The returned object has a link(scope) method which will
* re-link the component with new properties. * re-link the component with new properties.
*/ */
function createDirective(document, name, attrs, initialScope) { function createDirective(document, name, attrs, initialScope, initialHtml) {
attrs = attrs || {}; attrs = attrs || {};
initialScope = initialScope || {}; initialScope = initialScope || {};
initialHtml = initialHtml || '';
// create a template consisting of a single element, the directive // create a template consisting of a single element, the directive
// we want to create and compile it // we want to create and compile it
...@@ -53,6 +58,7 @@ function createDirective(document, name, attrs, initialScope) { ...@@ -53,6 +58,7 @@ function createDirective(document, name, attrs, initialScope) {
} }
templateElement.setAttribute(attrName, attrKey); templateElement.setAttribute(attrName, attrKey);
}); });
templateElement.innerHTML = initialHtml;
// setup initial scope // setup initial scope
Object.keys(attrs).forEach(function (key) { Object.keys(attrs).forEach(function (key) {
......
...@@ -37,6 +37,27 @@ ...@@ -37,6 +37,27 @@
.annotation-header { margin-top: 0 } .annotation-header { margin-top: 0 }
.annotation-footer { margin-bottom: 0 } .annotation-footer { margin-bottom: 0 }
.annotation-section {
.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-user { .annotation-user {
color: $text-color; color: $text-color;
font-weight: bold; font-weight: bold;
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
@import 'mixins/responsive'; @import 'mixins/responsive';
@import 'grid'; @import 'grid';
@import 'annotations'; @import 'annotations';
@import 'excerpt';
@import 'forms'; @import 'forms';
@import 'markdown-editor'; @import 'markdown-editor';
@import 'spinner'; @import 'spinner';
......
@import "compass/css3/images";
.excerpt {
position: relative;
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
));
}
.excerpt--uncollapsed {
max-height: 100% !important;
}
.excerpt-control a {
display: block;
text-align: right;
font-weight: bold;
width: 100%;
}
...@@ -32,6 +32,9 @@ $button-background-gradient: top, $button-background-start, $button-background-e ...@@ -32,6 +32,9 @@ $button-background-gradient: top, $button-background-start, $button-background-e
$error-color: #f0480c !default; $error-color: #f0480c !default;
$success-color: #1cbd41 !default; $success-color: #1cbd41 !default;
$mask-start-color: rgba($white, 0) !default;
$mask-end-color: $white !default;
@function color-weight($c, $n: 500) { @function color-weight($c, $n: 500) {
@if $n == 50 { @if $n == 50 {
@return tint($c, 85%); @return tint($c, 85%);
......
...@@ -56,12 +56,14 @@ ...@@ -56,12 +56,14 @@
<!-- Excerpts --> <!-- Excerpts -->
<section class="annotation-section" <section class="annotation-section"
ng-repeat="target in vm.annotation.target track by $index"> ng-repeat="target in vm.annotation.target track by $index">
<excerpt enabled="feature('truncate_annotations')">
<blockquote class="annotation-quote" <blockquote class="annotation-quote"
ng-hide="target.diffHTML && vm.showDiff" ng-hide="target.diffHTML && vm.showDiff"
ng-bind-html="selector.exact" ng-bind-html="selector.exact"
ng-repeat="selector in target.selector ng-repeat="selector in target.selector
| filter : {'type': 'TextQuoteSelector'} | filter : {'type': 'TextQuoteSelector'}
track by $index"></blockquote> track by $index"></blockquote>
</excerpt>
<blockquote class="annotation-quote" <blockquote class="annotation-quote"
ng-bind-html="target.diffHTML" ng-bind-html="target.diffHTML"
ng-show="target.diffHTML && vm.showDiff"></blockquote> ng-show="target.diffHTML && vm.showDiff"></blockquote>
...@@ -76,11 +78,12 @@ ...@@ -76,11 +78,12 @@
<!-- / Excerpts -- > <!-- / Excerpts -- >
<!-- Body --> <!-- Body -->
<section name="text" <section name="text" class="annotation-body">
class="annotation-body" <excerpt enabled="feature('truncate_annotations') && !vm.editing">
ng-model="vm.annotation.text" <div ng-model="vm.annotation.text"
ng-readonly="!vm.editing" ng-readonly="!vm.editing"
markdown> markdown>
</excerpt>
</section> </section>
<!-- / Body --> <!-- / Body -->
......
<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>
</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