From 4c8f9bfac8d9bf7706f45bd81a679c666c6e83f1 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Tue, 9 Jun 2015 17:39:17 -0700 Subject: [PATCH] Truncate long bodies and quotes Introduces auto-truncation of long bodies and quotes which expand when you click on a "More" link. Feature flagged as 'truncate_annotations'. --- h/features.py | 1 + h/static/scripts/app.coffee | 1 + h/static/scripts/directive/annotation.coffee | 7 +- h/static/scripts/directive/excerpt.js | 95 +++++++++++++++++++ .../directive/test/annotation-test.coffee | 11 ++- .../scripts/directive/test/excerpt-test.js | 86 +++++++++++++++++ h/static/scripts/directive/test/util.js | 18 ++-- h/static/styles/annotations.scss | 21 ++++ h/static/styles/common.scss | 1 + h/static/styles/excerpt.scss | 31 ++++++ h/static/styles/variables.scss | 3 + h/templates/app.html.jinja2 | 3 + h/templates/client/annotation.html | 21 ++-- h/templates/client/excerpt.html | 12 +++ 14 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 h/static/scripts/directive/excerpt.js create mode 100644 h/static/scripts/directive/test/excerpt-test.js create mode 100644 h/static/styles/excerpt.scss create mode 100644 h/templates/client/excerpt.html diff --git a/h/features.py b/h/features.py index 45f634fa274..8148fef04a4 100644 --- a/h/features.py +++ b/h/features.py @@ -14,6 +14,7 @@ 'streamer': "Enable 'live streaming' for annotations via the websocket?", 'search_normalized': "Assume all data has normalized URI fields?", 'show_unanchored_annotations': "Show annotations that fail to anchor?", + 'truncate_annotations': "Truncate long quotes and bodies in annotations?", } diff --git a/h/static/scripts/app.coffee b/h/static/scripts/app.coffee index d62b960127c..262902f5efd 100644 --- a/h/static/scripts/app.coffee +++ b/h/static/scripts/app.coffee @@ -105,6 +105,7 @@ module.exports = angular.module('h', [ .directive('annotation', require('./directive/annotation')) .directive('deepCount', require('./directive/deep-count')) +.directive('excerpt', require('./directive/excerpt').directive) .directive('formInput', require('./directive/form-input')) .directive('formValidate', require('./directive/form-validate')) .directive('groupList', require('./directive/group-list').directive) diff --git a/h/static/scripts/directive/annotation.coffee b/h/static/scripts/directive/annotation.coffee index d5690fb1c37..0ecbdb56354 100644 --- a/h/static/scripts/directive/annotation.coffee +++ b/h/static/scripts/directive/annotation.coffee @@ -405,8 +405,8 @@ AnnotationController = [ # ### module.exports = [ - '$document', - ($document) -> + '$document', 'features' + ($document, features) -> linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) -> # Observe the isSidebar attribute attrs.$observe 'isSidebar', (value) -> @@ -419,6 +419,9 @@ module.exports = [ scope.$evalAsync -> ctrl.save() + # Give template access to feature flags + scope.feature = features.flagEnabled + scope.share = (event) -> $container = angular.element(event.currentTarget).parent() $container.addClass('open').find('input').focus().select() diff --git a/h/static/scripts/directive/excerpt.js b/h/static/scripts/directive/excerpt.js new file mode 100644 index 00000000000..2b199c474a7 --- /dev/null +++ b/h/static/scripts/directive/excerpt.js @@ -0,0 +1,95 @@ +'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: + * + *
+ * + *
+ *
+ *
+ * + * 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.: + * + * ... + */ +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, +}; diff --git a/h/static/scripts/directive/test/annotation-test.coffee b/h/static/scripts/directive/test/annotation-test.coffee index 44e904ddf2b..e0e63260226 100644 --- a/h/static/scripts/directive/test/annotation-test.coffee +++ b/h/static/scripts/directive/test/annotation-test.coffee @@ -13,6 +13,7 @@ describe 'annotation', -> fakeAnnotationMapper = null fakeAnnotationUI = null fakeDrafts = null + fakeFeatures = null fakeFlash = null fakeGroups = null fakeMomentFilter = null @@ -56,6 +57,9 @@ describe 'annotation', -> add: sandbox.stub() remove: sandbox.stub() } + fakeFeatures = { + flagEnabled: sandbox.stub().returns(true) + } fakeFlash = sandbox.stub() fakeMomentFilter = sandbox.stub().returns('ages ago') @@ -92,6 +96,7 @@ describe 'annotation', -> $provide.value 'annotationMapper', fakeAnnotationMapper $provide.value 'annotationUI', fakeAnnotationUI $provide.value 'drafts', fakeDrafts + $provide.value 'features', fakeFeatures $provide.value 'flash', fakeFlash $provide.value 'momentFilter', fakeMomentFilter $provide.value 'permissions', fakePermissions @@ -610,7 +615,7 @@ describe("AnnotationController", -> Return an annotation directive instance and stub services etc. ### createAnnotationDirective = ({annotation, personaFilter, momentFilter, - urlencodeFilter, drafts, flash, + urlencodeFilter, drafts, features, flash, permissions, session, tags, time, annotationUI, annotationMapper, groups, documentTitleFilter, documentDomainFilter}) -> @@ -622,6 +627,9 @@ describe("AnnotationController", -> add: -> remove: -> } + features: features or { + flagEnabled: -> true + } flash: flash or { info: -> error: -> @@ -651,6 +659,7 @@ describe("AnnotationController", -> $provide.value("momentFilter", locals.momentFilter) $provide.value("urlencodeFilter", locals.urlencodeFilter) $provide.value("drafts", locals.drafts) + $provide.value("features", locals.features) $provide.value("flash", locals.flash) $provide.value("permissions", locals.permissions) $provide.value("session", locals.session) diff --git a/h/static/scripts/directive/test/excerpt-test.js b/h/static/scripts/directive/test/excerpt-test.js new file mode 100644 index 00000000000..90c0b7615b7 --- /dev/null +++ b/h/static/scripts/directive/test/excerpt-test.js @@ -0,0 +1,86 @@ +'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({}, ''); + + assert.equal(element.find('.excerpt #foo').length, 1); + }); + + it('when enabled, renders its contents in a .excerpt element', function () { + var element = excerptDirective({enabled: true}, ''); + + 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}, ''); + + assert.equal(element.find('.excerpt #foo').length, 0); + assert.equal(element.find('#foo').length, 1); + }); +}); diff --git a/h/static/scripts/directive/test/util.js b/h/static/scripts/directive/test/util.js index aaba50a108b..05ca38659d3 100644 --- a/h/static/scripts/directive/test/util.js +++ b/h/static/scripts/directive/test/util.js @@ -15,26 +15,31 @@ function hyphenate(name) { * attrA: 'initial-value' * }, { * scopePropery: scopeValue - * }); + * }, + * 'Hello, world!'); * - * Will generate '' and + * Will generate 'Hello, world!' and * compile and link it with the scope: * * { attrA: 'initial-value', scopeProperty: scopeValue } * * @param {Document} document - The DOM Document to create the element in * @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} initialScope - A dictionary of properties to set on the - * scope when the element is linked + * @param {Object} [attrs] - A map of attribute names (in camelCase) to initial + * values. + * @param {Object} [initialScope] - A dictionary of properties to set on the + * 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. * The returned object has a link(scope) method which will * re-link the component with new properties. */ -function createDirective(document, name, attrs, initialScope) { +function createDirective(document, name, attrs, initialScope, initialHtml) { attrs = attrs || {}; initialScope = initialScope || {}; + initialHtml = initialHtml || ''; // create a template consisting of a single element, the directive // we want to create and compile it @@ -53,6 +58,7 @@ function createDirective(document, name, attrs, initialScope) { } templateElement.setAttribute(attrName, attrKey); }); + templateElement.innerHTML = initialHtml; // setup initial scope Object.keys(attrs).forEach(function (key) { diff --git a/h/static/styles/annotations.scss b/h/static/styles/annotations.scss index 01959125871..1fa546778ac 100644 --- a/h/static/styles/annotations.scss +++ b/h/static/styles/annotations.scss @@ -37,6 +37,27 @@ .annotation-header { margin-top: 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 { color: $text-color; font-weight: bold; diff --git a/h/static/styles/common.scss b/h/static/styles/common.scss index 5f604dd8302..797df8ea00c 100644 --- a/h/static/styles/common.scss +++ b/h/static/styles/common.scss @@ -5,6 +5,7 @@ @import 'mixins/responsive'; @import 'grid'; @import 'annotations'; +@import 'excerpt'; @import 'forms'; @import 'markdown-editor'; @import 'spinner'; diff --git a/h/static/styles/excerpt.scss b/h/static/styles/excerpt.scss new file mode 100644 index 00000000000..5e5b0335f19 --- /dev/null +++ b/h/static/styles/excerpt.scss @@ -0,0 +1,31 @@ +@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%; +} diff --git a/h/static/styles/variables.scss b/h/static/styles/variables.scss index 5a052135230..c8e10079c9d 100644 --- a/h/static/styles/variables.scss +++ b/h/static/styles/variables.scss @@ -31,6 +31,9 @@ $button-background-gradient: top, $button-background-start, $button-background-e $error-color: #f0480c !default; $success-color: #1cbd41 !default; +$mask-start-color: rgba($white, 0) !default; +$mask-end-color: $white !default; + @function color-weight($c, $n: 500) { @if $n == 50 { @return tint($c, 85%); diff --git a/h/templates/app.html.jinja2 b/h/templates/app.html.jinja2 index c9703f95d5f..63dd6420054 100644 --- a/h/templates/app.html.jinja2 +++ b/h/templates/app.html.jinja2 @@ -93,6 +93,9 @@ + diff --git a/h/templates/client/annotation.html b/h/templates/client/annotation.html index 2c2137f8988..4ecbf59ba33 100644 --- a/h/templates/client/annotation.html +++ b/h/templates/client/annotation.html @@ -56,12 +56,14 @@
-
+ +
+
@@ -76,11 +78,12 @@ -
+ +
+
diff --git a/h/templates/client/excerpt.html b/h/templates/client/excerpt.html new file mode 100644 index 00000000000..c6171ae15f8 --- /dev/null +++ b/h/templates/client/excerpt.html @@ -0,0 +1,12 @@ +
+
+
+ +
+ More + Less +
+