I have been working for weeks on a "BookViewer" AngularJS directive. I would like to write some blog posts on the lessons I learned in writing the directive. Before doing this I would like to ask for some feedback on how I wrote the directive. I ran into a lot trouble, learned a lot, but in the end I am quite happy with the result. I used AngularJS+Typescript+Angular-UI Bootstrap. This is my first attempt to implement a large directive.
The code can be found on GitHub. A live version can be found here.
The visual result of using the directive is as follows:
It is possible to switch between a text and index view by setting the indexmode attribute on the directive which I control from a button in the navigation bar, external from the directive.
I made some decisions where I don't know if these are the best decisions. For example:
Using Typescript in the directive
I could not get the directive working with a Typescript class for the controller and the link function. So I use anonymous functions, and use Typescript only for interface definitions and type-checking. In my link function I need to process the DOM, I do this in an anonymous function in the timeout otherwise the DOM is not ready yet. In the anonymous function I need to call other functions. I could not find another way than defining this function on the scope in the controller function. Is it possible to use functions and variables in an instance of a Typescript class?
directive.link = ($scope: IBookViewerScope, $element, $attrs) : void => {
$timeout(() => {
$scope.processDom();
:
}, 0);
};
The controller function creates a lot of variables and functions in the scope, this was the only way I found to have access to the variables and functions in $timeout callbacks like above. Is this the best approach?
angular.module('directive.bookviewer', [])
.constant('bookviewerConfig', {
})
.controller('bookviewerController',
['$scope', '$element', '$attrs', '$sce', '$location', '$anchorScroll', '$timeout', '$log',
function($scope:IBookViewerScope, $element, $attrs, $sce, \
$location:ng.ILocationService, $anchorScroll, $timeout, $log) : void {
$scope.savedElement = $element; // keep track of element for usage in later code
$scope.savedAttrs = $attrs; // keep track of attributes for usage in later code
$scope.trustAsHtml = (html:string) : any => {
return $sce.trustAsHtml(html);
};
$scope.indexOpenChapterIndex = (chapterIndexId:string) => { ... }
:
}]);
Handling fixed bar at top of page
I use a fixed bar at the top. To compensate for this in jumping around in the book I added anchor tags , and in my directive link function I put on each "anchor" element the following code:
$element.css({
display: "block",
position: "relative",
top: offset
});
where offset is -heightOfFixedBar. Problem is that the top of the chapter was still at the wrong offset, so I added a div with the same height as the fixed bar at the top:
<div style="margin: 51px 0 0 0;"/>
<bookviewer booktoc="vm.toc" chaptercontent="vm.chapter"
indexmode="vm.indexmode" offset-element-id="'topnavbar'"
anchorid="vm.anchorid" on-navigate="vm.navigate(chapterid, anchorid)"
on-select="vm.select(text)"/>
</div>
All navigation is done by watching
Initially I started with explicit navigation. I had callback functions connected to links in the html and in those functions I did the scrolling to anchors or callbacks to the function to load the contents of another chapter. I got into a lot of trouble with this, because I also had to respond to changes in the bound parameters of the directive. I ended up with a set of watch functions defined on the scope:
// toggle between text/index view
$scope.$watch('indexmode', (newValue, oldValue) => { ... }
// respond to click on links
$scope.$watch(() => { return $location.hash(); }, () => { ... }
// respond to content changes (new chapter content) and anchor changes
$scope.$watch('[chaptercontent.Id, anchorid]', function (newValues, oldValues) { ...}
Although I am intending to use only a single instance of the bookviewer per page, it is a bit of a pity to depend on $location.hash() within a directive. Problem is: I could not find another way while keeping the next/prev button of the browser working.
Rendering with dynamic templates
I do all the rendering with templates defined in the directive.templateUrl.
<script id="otherChapterIndexTmpl.html" type="text/ng-template">
<li><a ng-click="navigate(paragraph.Id)" href="javascript:void(0)">{{paragraph.Title}}</a></li>
</script>
<script id="paragraphTmpl.html" type="text/ng-template">
<a class="anchor" id="{{paragraph.Id}}"></a>
<h4>{{paragraph.Title}}</h4>
<div class="paragraph-text" ng-bind-html="trustAsHtml(paragraph.Content)"></div>
<ng-include ng-repeat="paragraph in paragraph.Paragraphs" src="'paragraphTmpl.html'"></ng-include>
</script>
<div class="bookchapter" ng-hide="indexmode">
<a class="anchor" id="{{chaptercontent.Id}}"></a>
<h3>{{chaptercontent.Title}}</h3>
<div class="chapter-text" ng-bind-html="trustAsHtml(chaptercontent.Content)"></div>
<ng-include ng-repeat="paragraph in chaptercontent.Paragraphs" src="'paragraphTmpl.html'"></ng-include>
</div>
<div id="bookindex" ng-show="indexmode">
<h1>Book index</h1>
<accordion close-others="true">
<accordion-group ng-repeat="tocChapter in booktoc.Chapters" is-open="indexOpenChapterIndex('i-' + tocChapter.Id)">
<a class="anchor" id="i-{{tocChapter.Id}}"></a>
<accordion-heading>
<a ng-click="navigate(tocChapter.Id)" href="javascript:void(0)">{{tocChapter.Title}}</a>
<i class="pull-right glyphicon" ng-class="{'glyphicon-chevron-down': isopen, 'glyphicon-chevron-right': !isopen}"></i>
</accordion-heading>
<ul ng-if="tocChapter.Id === $parent.chaptercontent.Id">
<ng-include ng-repeat="paragraph in $parent.chaptercontent.Paragraphs" src="'curChapterIndexTmpl.html'"></ng-include>
</ul>
<ul ng-if="tocChapter.Id !== $parent.chaptercontent.Id">
<ng-include ng-repeat="paragraph in tocChapter.Paragraphs" src="'otherChapterIndexTmpl.html'"></ng-include>
</ul>
</accordion-group>
</accordion>
</div>
I must say: I learned my lessons there. I initialy worked with self-closing divs (). This worked when working with jQuery. When I removed jQuery and depended on the AngularJS JQLite implementation, a lot went wrong. So NEVER use self-closing divs. It isn't even supported in the html5 spec. See this.
I tried to get the code working in Plunker (see this). The solution was to create a "gh-pages" branch of the git repository, so the example is now visible here.
I hope that this is the appropriate place for asking for feedback, and that I will not be flamed by doing so.