1
\$\begingroup\$

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:

Sample chapter Sample index

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:

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 <a class="anchor" id="..."></a>, 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="curChapterIndexTmpl.html" type="text/ng-template">
        <li><a ng-click="navigate(paragraph.Id)" href="javascript:void(0)">{{paragraph.Title}}</a></li>
        <ng-include ng-repeat="paragraph in paragraph.Paragraphs" src="'curChapterIndexTmpl.html'"></ng-include>
    </script>

    <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 initially worked with self-closing divs (<div ... />). 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.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.