feat(ngdocs): support popover, foldouts and foldover annotations

This commit is contained in:
Matias Niemelä
2013-06-06 01:28:50 -04:00
committed by Misko Hevery
parent 07ef1667db
commit ef22968810
13 changed files with 636 additions and 6 deletions

View File

@@ -0,0 +1,186 @@
describe('Docs Annotations', function() {
beforeEach(module('docsApp'));
var body;
beforeEach(function() {
body = angular.element(document.body);
body.html('');
});
describe('popover directive', function() {
var $scope, element;
beforeEach(inject(function($rootScope, $compile) {
$scope = $rootScope.$new();
element = angular.element(
'<div style="margin:200px;" data-title="title_text" data-content="content_text" popover></div>'
);
element.attr('id','idx');
body.append(element);
$compile(element)($scope);
$scope.$apply();
}));
it('should be hidden by default', inject(function(popoverElement) {
expect(popoverElement.visible()).toBe(false);
}));
it('should capture the click event and set the title and content and position the tip', inject(function(popoverElement) {
element.triggerHandler('click');
expect(popoverElement.isSituatedAt(element)).toBe(true);
expect(popoverElement.visible()).toBe(true);
expect(popoverElement.title()).toBe('title_text');
expect(popoverElement.content()).toContain('content_text');
expect(popoverElement.besideElement.attr('id')).toBe('idx');
}));
it('should hide and clear the title and content if the same element is clicked again', inject(function(popoverElement) {
//show the element
element.triggerHandler('click');
expect(popoverElement.isSituatedAt(element)).toBe(true);
//hide the element
element.triggerHandler('click');
expect(popoverElement.isSituatedAt(element)).toBe(false);
expect(popoverElement.visible()).toBe(false);
expect(popoverElement.title()).toBe('');
expect(popoverElement.content()).toBe('');
}));
it('should parse markdown content', inject(function(popoverElement, $compile) {
element = angular.element(
'<div style="margin:200px;" data-title="#title_text" data-content="#heading" popover></div>'
);
body.append(element);
$compile(element)($scope);
$scope.$apply();
element.triggerHandler('click');
expect(popoverElement.title()).toBe('#title_text');
expect(popoverElement.content()).toBe('<h1 id="heading">heading</h1>');
}));
});
describe('foldout directive', function() {
var $scope, parent, element, url, window;
beforeEach(function() {
module(function($provide, $animationProvider) {
$provide.value('$window', window = angular.mock.createMockWindow());
$animationProvider.register('foldout-enter', function($window) {
return {
start : function(element, done) {
$window.setTimeout(done, 1000);
}
}
});
$animationProvider.register('foldout-hide', function($window) {
return {
start : function(element, done) {
$window.setTimeout(done, 500);
}
}
});
$animationProvider.register('foldout-show', function($window) {
return {
start : function(element, done) {
$window.setTimeout(done, 200);
}
}
});
});
inject(function($rootScope, $compile, $templateCache) {
url = '/page.html';
$scope = $rootScope.$new();
parent = angular.element('<div class="parent"></div>');
element = angular.element('<div data-url="' + url + '" foldout></div>');
body.append(parent);
parent.append(element);
$compile(parent)($scope);
$scope.$apply();
});
});
it('should inform that it is loading', inject(function($httpBackend) {
$httpBackend.expect('GET', url).respond('hello');
element.triggerHandler('click');
var kids = body.children();
var foldout = angular.element(kids[kids.length-1]);
expect(foldout.html()).toContain('loading');
}));
it('should download a foldout HTML page and animate the contents', inject(function($httpBackend) {
$httpBackend.expect('GET', url).respond('hello');
element.triggerHandler('click');
$httpBackend.flush();
window.setTimeout.expect(1).process();
window.setTimeout.expect(1000).process();
var kids = body.children();
var foldout = angular.element(kids[kids.length-1]);
expect(foldout.text()).toContain('hello');
}));
it('should hide then show when clicked again', inject(function($httpBackend) {
$httpBackend.expect('GET', url).respond('hello');
//enter
element.triggerHandler('click');
$httpBackend.flush();
window.setTimeout.expect(1).process();
window.setTimeout.expect(1000).process();
//hide
element.triggerHandler('click');
window.setTimeout.expect(1).process();
window.setTimeout.expect(500).process();
//show
element.triggerHandler('click');
window.setTimeout.expect(1).process();
window.setTimeout.expect(200).process();
}));
});
describe('DocsController fold', function() {
var window, $scope, ctrl;
beforeEach(function() {
module(function($provide, $animationProvider) {
$provide.value('$window', window = angular.mock.createMockWindow());
});
inject(function($rootScope, $controller, $location, $cookies, sections) {
$scope = $rootScope.$new();
ctrl = $controller('DocsController',{
$scope : $scope,
$location : $location,
$window : window,
$cookies : $cookies,
sections : sections
});
});
});
it('should download and reveal the foldover container', inject(function($compile, $httpBackend) {
var url = '/page.html';
var fullUrl = '/notes/' + url;
$httpBackend.expect('GET', fullUrl).respond('hello');
var element = angular.element('<div ng-include="docs_fold"></div>');
$compile(element)($scope);
$scope.$apply();
$scope.fold(url);
$httpBackend.flush();
}));
});
});

View File

@@ -96,9 +96,12 @@ directive.code = function() {
directive.prettyprint = ['reindentCode', function(reindentCode) {
return {
restrict: 'C',
terminal: true,
compile: function(element) {
element.html(window.prettyPrintOne(reindentCode(element.html()), undefined, true));
var html = element.html();
//ensure that angular won't compile {{ curly }} values
html = html.replace(/\{\{/g, '<span>{{</span>')
.replace(/\}\}/g, '<span>}}</span>');
element.html(window.prettyPrintOne(reindentCode(html), undefined, true));
}
};
}];

View File

@@ -198,6 +198,133 @@ directive.table = function() {
};
};
var popoverElement = function() {
var object = {
init : function() {
this.element = angular.element(
'<div class="popover popover-incode top">' +
'<div class="arrow"></div>' +
'<div class="popover-inner">' +
'<div class="popover-title"><code></code></div>' +
'<div class="popover-content"></div>' +
'</div>' +
'</div>'
);
this.node = this.element[0];
this.element.css({
'display':'block',
'position':'absolute'
});
angular.element(document.body).append(this.element);
var inner = this.element.children()[1];
this.titleElement = angular.element(inner.childNodes[0].firstChild);
this.contentElement = angular.element(inner.childNodes[1]);
//stop the click on the tooltip
this.element.bind('click', function(event) {
event.preventDefault();
event.stopPropagation();
});
var self = this;
angular.element(document.body).bind('click',function(event) {
if(self.visible()) self.hide();
});
},
show : function(x,y) {
this.element.addClass('visible');
this.position(x || 0, y || 0);
},
hide : function() {
this.element.removeClass('visible');
this.position(-9999,-9999);
},
visible : function() {
return this.position().y >= 0;
},
isSituatedAt : function(element) {
return this.besideElement ? element[0] == this.besideElement[0] : false;
},
title : function(value) {
return this.titleElement.html(value);
},
content : function(value) {
if(value && value.length > 0) {
value = new Showdown.converter().makeHtml(value);
}
return this.contentElement.html(value);
},
positionArrow : function(position) {
this.node.className = 'popover ' + position;
},
positionAway : function() {
this.besideElement = null;
this.hide();
},
positionBeside : function(element) {
this.besideElement = element;
var elm = element[0];
var x = elm.offsetLeft;
var y = elm.offsetTop;
x -= 30;
y -= this.node.offsetHeight + 10;
this.show(x,y);
},
position : function(x,y) {
if(x != null && y != null) {
this.element.css('left',x + 'px');
this.element.css('top', y + 'px');
}
else {
return {
x : this.node.offsetLeft,
y : this.node.offsetTop
};
}
}
};
object.init();
object.hide();
return object;
};
directive.popover = ['popoverElement', function(popover) {
return {
restrict: 'A',
priority : 500,
link: function(scope, element, attrs) {
element.bind('click',function(event) {
event.preventDefault();
event.stopPropagation();
if(popover.isSituatedAt(element) && popover.visible()) {
popover.title('');
popover.content('');
popover.positionAway();
}
else {
popover.title(attrs.title);
popover.content(attrs.content);
popover.positionBeside(element);
}
});
}
}
}];
directive.tabPane = function() {
return {
require: '^tabbable',
@@ -208,5 +335,49 @@ directive.tabPane = function() {
};
};
directive.foldout = ['$http', '$animator','$window', function($http, $animator, $window) {
return {
restrict: 'A',
priority : 500,
link: function(scope, element, attrs) {
var animator = $animator(scope, { ngAnimate: "'foldout'" });
var container, loading, url = attrs.url;
if(/\/build\//.test($window.location.href)) {
url = '/build/docs' + url;
}
element.bind('click',function() {
scope.$apply(function() {
if(!container) {
if(loading) return;
angular.module('bootstrap', []).directive(directive);
loading = true;
var par = element.parent();
container = angular.element('<div class="foldout">loading...</div>');
animator.enter(container, null, par);
$http.get(url, { cache : true }).success(function(html) {
loading = false;
html = '<div class="foldout-inner">' +
'<div calss="foldout-arrow"></div>' +
html +
'</div>';
container.html(html);
//avoid showing the element if the user has already closed it
if(container.css('display') == 'block') {
container.css('display','none');
animator.show(container);
}
});
}
else {
container.css('display') == 'none' ? animator.show(container) : animator.hide(container);
}
});
});
}
}
}];
angular.module('bootstrap', []).directive(directive).factory('popoverElement', popoverElement);

View File

View File

@@ -196,6 +196,28 @@ describe('ngdoc', function() {
});
describe('inline annotations', function() {
it('should convert inline docs annotations into proper HTML', function() {
expect(new Doc().markdown(
"<pre>\n//!annotate supertext\n<br />\n</pre>"
)
).toContain('data-popover data-content="supertext"')
});
it('should allow for a custom regular expression for matching', function() {
expect(new Doc().markdown(
"<pre>\n//!annotate=\"soon\" supertext\n<p>soon</p>\n</pre>"
)
).toContain('data-popover data-content="supertext" data-title="Info">soon</div>')
});
it('should allow for a custom title to be set', function() {
expect(new Doc().markdown(
"<pre>\n//!annotate=\"soon\" coming soon|supertext\n<p>soon</p>\n</pre>"
)
).toContain('data-popover data-content="supertext" data-title="coming soon">soon</div>')
});
});
});
describe('trim', function() {

View File

@@ -8,7 +8,12 @@ exports.htmlEscape = htmlEscape;
//////////////////////////////////////////////////////////
function htmlEscape(text){
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{\{/g, '<span>{{</span>')
.replace(/\}\}/g, '<span>}}</span>');
}

View File

@@ -44,6 +44,7 @@ writer.makeDir('build/docs/', true).then(function() {
function writeTheRest(writesFuture) {
var metadata = ngdoc.metadata(docs);
writesFuture.push(writer.symlink('../../docs/content/notes', 'build/docs/notes', 'dir'));
writesFuture.push(writer.symlinkTemplate('css', 'dir'));
writesFuture.push(writer.symlink('../../docs/img', 'build/docs/img', 'dir'));
writesFuture.push(writer.symlinkTemplate('js', 'dir'));

View File

@@ -225,6 +225,44 @@ Doc.prototype = {
text = text.replace(/(?:<p>)?(REPLACEME\d+)(?:<\/p>)?/g, function(_, id) {
return placeholderMap[id];
});
//!annotate CONTENT
//!annotate="REGEX" CONTENT
//!annotate="REGEX" TITLE|CONTENT
text = text.replace(/\n?\/\/!annotate\s*(?:=\s*['"](.+?)['"])?\s+(.+?)\n\s*(.+?\n)/img,
function(_, pattern, content, line) {
var pattern = new RegExp(pattern || '.+');
var title, text, split = content.split(/\|/);
if(split.length > 1) {
text = split[1];
title = split[0];
}
else {
title = 'Info';
text = content;
}
return "\n" + line.replace(pattern, function(match) {
return '<div class="nocode nocode-content" data-popover ' +
'data-content="' + text + '" ' +
'data-title="' + title + '">' +
match +
'</div>';
});
}
);
//!details /path/to/local/docs/file.html
//!details="REGEX" /path/to/local/docs/file.html
text = text.replace(/\/\/!details\s*(?:=\s*['"](.+?)['"])?\s+(.+?)\n\s*(.+?\n)/img,
function(_, pattern, url, line) {
url = '/notes/' + url;
var pattern = new RegExp(pattern || '.+');
return line.replace(pattern, function(match) {
return '<div class="nocode nocode-content" data-foldout data-url="' + url + '">' + match + '</div>';
});
}
);
return text;
},

View File

@@ -79,3 +79,26 @@
-o-transition: color 0 ease-in; /* opera is special :) */
transition: none;
}
.foldout-show, .foldout-enter, .foldout-hide {
-webkit-transition:0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
-moz-transition:0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
-o-transition:0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
transition:0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
}
.foldout-show, .foldout-enter {
opacity:0;
}
.foldout-show.foldout-show-active, .foldout-hide.foldout-hide-active {
opacity:1;
}
.foldout-hide {
opacity:1;
}
.foldout-hide.foldout-hide-active {
opacity:0;
}

View File

@@ -303,3 +303,162 @@ ul.events > li > h3 {
top:0;
right:0;
}
.nocode-content {
cursor:pointer;
display:inline-block;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
-webkit-transition:0.5s linear all;
-moz-transition:0.5s linear all;
-o-transition:0.5s linear all;
transition:0.5s linear all;
color: #223f7a;
background:#ddd;
border: 1px solid #ccc;
}
.nocode-content:hover {
background-color: #99c2ff;
border: 1px solid #e1e1e8;
}
.popover-incode .popover-inner {
width:auto;
min-width:200px;
max-width:500px;
}
.popover-incode {
-webkit-transition:0.2s linear opacity;
-moz-transition:0.2s linear opacity;
-o-transition:0.2s linear opacity;
transition:0.2s linear opacity;
opacity:0;
}
.popover-incode.visible {
opacity:1;
}
.popover-incode code,
.popover-incode pre {
white-space:nowrap;
}
.popover-incode .arrow {
left:50px!important;
}
.foldover-content {
display:none;
}
.foldout:after {
content:"";
position:absolute;
left:50%;
top:-1px;
margin-left:-10px;
border-width:10px;
border-style:solid;
border-color:#f7f7f9 transparent transparent;
}
.foldout:before {
content:"";
position:absolute;
left:50%;
top:0px;
margin-left:-10px;
border-width:10px;
border-style:solid;
border-color:#bbb transparent transparent;
}
.foldout {
padding:8px 15px 5px;
position:relative;
background:#eee;
white-space:normal;
box-shadow:inset 0 0 20px #ccc;
border-top:1px solid #bbb;
}
.prettyprint {
padding-right:0!important;
padding-bottom:0!important;
}
pre ol li {
padding-bottom:2px;
padding-right:5px;
}
#docs-fold {
position:absolute;
top:0;
right:0;
width:500px;
min-height:100%;
padding-top:50px;
padding:50px 20px 20px 20px;
background:white;
border-left:1px solid #999;
box-shadow:0 0 10px #555;
z-index:1002;
}
#docs-fold.fold-show {
-webkit-transition:0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
-moz-transition:0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
-o-transition:0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
transition:0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) all;
}
#docs-fold.fold-show {
right:-200px;
opacity:0;
}
#docs-fold.fold-show.fold-show-active {
right:0;
opacity:1;
}
#docs-fold-overlay {
background:rgba(255,255,255,0.5);
position:fixed;
left:0;
bottom:0;
right:0;
top:0;
z-index:1001;
cursor:pointer;
}
.fixed_body {
position:fixed;
top:0;
z-index:1000;
left:0;
right:0;
}
#docs-fold-close {
z-index: 1029;
position: absolute;
left: -30px;
top: 60px;
cursor:pointer;
text-align: center;
width:50px;
line-height:50px;
font-size: 2em;
background: #fff;
box-shadow:-6px 0 5px #555;
display:block;
border-radius:10px;
}

View File

@@ -198,6 +198,15 @@
</div>
</header>
<div id="docs-fold-overlay" ng-show="docs_fold" ng-click="fold(null)"></div>
<div id="docs-fold" ng-show="docs_fold" ng-animate="'fold'">
<div id="docs-fold-close" ng-click="fold(null)">
<span class="icon-remove-sign"></span>
</div>
<div ng-include="docs_fold"></div>
</div>
<div ng-class="{fixed_body:docs_fold}">
<div role="main" class="container">
<div class="row clear-navbar"></div>
@@ -359,6 +368,7 @@
</p>
</div>
</footer>
</div>
</body>
</html>

View File

@@ -411,6 +411,18 @@ docsApp.serviceFactory.sections = function sections() {
docsApp.controller.DocsController = function($scope, $location, $window, $cookies, sections) {
$scope.fold = function(url) {
if(url) {
$scope.docs_fold = '/notes/' + url;
if(/\/build/.test($window.location.href)) {
$scope.docs_fold = '/build/docs' + $scope.docs_fold;
}
window.scrollTo(0,0);
}
else {
$scope.docs_fold = null;
}
};
var OFFLINE_COOKIE_NAME = 'ng-offline',
DOCS_PATH = /^\/(api)|(guide)|(cookbook)|(misc)|(tutorial)/,
INDEX_PATH = /^(\/|\/index[^\.]*.html)$/,

View File

@@ -33,11 +33,11 @@
* <ANY ng-directive ng-animate="{event1: 'animation-name', event2: 'animation-name-2'}"></ANY>
*
* <!-- you can also use a short hand -->
* //!annotate="animation" ngAnimate|This *expands* to `{ enter: 'animation-enter', leave: 'animation-leave', ...}`</strong>
* <ANY ng-directive ng-animate=" 'animation' "></ANY>
* <!-- which expands to -->
* <ANY ng-directive ng-animate="{ enter: 'animation-enter', leave: 'animation-leave', ...}"></ANY>
*
* <!-- keep in mind that ng-animate can take expressions -->
* //!annotate="computeCurrentAnimation\(\)" Scope Function|This will be called each time the scope changes...
* <ANY ng-directive ng-animate=" computeCurrentAnimation() "></ANY>
* </pre>
*