Read Measure

Karl Dubost a remarqué une chose intéressante : alors que la lecture papier offre un repère visuel, physique, du nombre de pages qu'il reste à lire dans un livre ou un chapître d'un livre, cette information est perdue avec un livre numérique. Comment pourrait-on retrouver ce repère quantitatif en HTML / CSS / javascript ?

Peut-être un début de quelque chose dans le développement d'une liseuse pour Firefox OS...

Représentation visuelle du nombre de pages lues dans un livre.
/**
 *  http://www.la-grange.net/2013/04/08/mesure-lecture
 *
 *  @param {int[]} chapters Nombre de pages par chapitre.
 */
var ReadMeasureModel = function (chapters) {
    this._pages = 0;
    this._page = 0;
    this._chapters = [];
    this._chapter = 0;
    
    for (var i = 0, n = chapters.length; i < n; i++) {
        this._chapters.push({
            start: this._pages + 1,
            pages: chapters[i]
        });
        this._pages += chapters[i];
    }
};

ReadMeasureModel.__signals__ = ['page-changed'];

ReadMeasureModel.prototype = {
    // fix constructor
    constructor: ReadMeasureModel,
    /**
     *  Set current page.
     *  @param {int} page
     */
    current: function (page) {
        if (page < 0 || page > this.count() || page === this._page) {
            return;
        }
        if (page === 0) {
            this._page = 0;
            this._chapter = 0;
        } else {
            this._page = page;
            this._chapter = this._findChapter();
        }
        this.emit('page-changed');
    },
    
    /**
     *  Find the chapter of the current page.
     *  @return {int}
     */
    _findChapter: function () {
        // XXX Very bad !
        for (var i = 0, n = this._chapters.length; i < n; i++) {
            if (this._page >= this._chapters[i].start &&
                    this._page < this._chapters[i].start + this._chapters[i].pages) {
                return i;
            }
        }
        return 0;
    },
    
    /**
     *  Get the number of pages of a chapter. If the chapter index is not 
     *  specified, number of pages of current chapter is returned.
     *  @param {int} [idx = null] Index of requested chapter.
     *  @return {int}
     */
    countChapter: function (idx) {
        if (idx) {
            if (this._chapters[idx - 1]) {
                return this._chapters[idx - 1].pages;
            }
            return 0;
        }
        if (this._chapter) {
            return this._chapters[this._chapter].pages;
        }
        return 0;
    },
    
    /**
     *  Get the number of chapters in the book.
     *  @return {int}
     */
    countChapters: function () {
        return this._chapters.length;
    },
    
    /**
     *  Get the number of pages in the book.
     *  @return {int}
     */
    count: function () {
        return this._pages;
    },
    
    /**
     *  Get index of current read chapter.
     *  @return {int}
     */
    chapter: function () {
        return this._chapter + 1;
    },
    
    /**
     *  Returns the number of page read.
     *  @return {int}
     */
    read: function () {
        return this._page;
    },
    
    /**
     *  Returns remaining pages to read.
     *  @return {int}
     */
    remain: function () {
        return this.count() - this.read();
    },
    
    /**
     *  Returns the percent of pages read.
     *  @return {float} Beetween 0 and 100.
     */
    readPortion: function () {
        if (this.read() > 0) {
            return ((this.read() / this.count()) * 100).toFixed(2);
        }
        return 0;
    },
    
    /**
     *  Returns the percent of reamining pages to read.
     *  @return {float} Beetween 0 and 100.
     */
    remainPortion: function () {
        if (this.read() > 0) {
            return ((this.remain() / this.count()) * 100).toFixed(2);
        }
        return 0;
    },
    
    /**
     *  Returns ne number of pages read in current chapter.
     *  @return {int}
     */
    readInChapter: function () {
        if (this.read() > 0) {
            return this.read() - (this._chapters[this._chapter].start - 1);
        }
        return 0;
    },
    
    /**
     *  Returns the percent of pages read in current chapter.
     *  @return {float} Beetween 0 and 100.
     */
    readPortionInChapter: function () {
        if (this.read() > 0) {
            return ((this.readInChapter() / this._chapters[this._chapter].pages) *
                    100).toFixed(2);
        }
        return 0;
    },
    
    remainInChapter: function () {
        if (this.read() > 0) {
            return this._chapters[this._chapter].pages - this.readInChapter();
        }
        return 0;
    },
    
    /**
     *  Returns the percent of reamining pages to read in current chapter.
     *  @return {float} Beetween 0 and 100.
     */
    remainPortionInChapter: function () {
        if (this.read() > 0) {
            return ((this.remainInChapter() / this._chapters[this._chapter].pages) *
                    100).toFixed(2);
        }
        return 0;
    },
    
    // signals API
    // XXX Could be much better
    
    /**
     *  Add event listener.
     *  @param {String} name Event name.
     *  @param {Function} listener Event listener.
     *  @return {int} Listener id.
     */
    on: function (name, listener) {
        if (!this._listeners) {
            this._listeners = {};
            this._listenersId = 0;
        }
        this._listenersId++;
        this._listeners[this._listenersId] = {
            name: name,
            listener: listener
        };
        return this._listenersId;
    },
    
    /**
     *  Remove event listener.
     *  @param {int} id Listener id.
     */
    off: function (id) {
        if (!this._listeners || !this._listeners[id]) {
            return;
        }
        delete this._listeners[id];
    },
    
    /**
     *  Emit an event.
     *  @param {String} name Event name
     */
    emit: function (name) {
        if (!this._listeners) {
            return;
        }
        for (var id in this._listeners) {
            if (this._listeners[id].name === name) {
                this._listeners[id].listener(this);
            }
        }
    }
};

/**
 *  @param {ReadMeasureModel} model
 */
var ReadMeasureView = function (model) {
    this._model = model;
    this._build();
    this._model.on('page-changed', this.onPageChanged.bind(this));
};

ReadMeasureView.prototype = {
    
    constructor: ReadMeasureView,
    
    /**
     *  @private
     */
    _build: function () {
        this._element = document.createElement('div');
        this._element.className = 'read-measure';
        
        this._progress = document.createElement('progress');
        this._progress.max = this._model.count();
        this._progress.value = this._model.read();
        this._element.appendChild(this._progress);
        
        this._overlay = document.createElement('div');
        var i = 0,
            span = document.createElement('span'),
            n = this._model.countChapters(),
            c = null;
        
        for (; i < n; i++) {
            c = span.cloneNode(true);
            c.style.width = ((this._model.countChapter(i+1) / this._progress.max) * 100) + '%';
            this._overlay.appendChild(c);
        }
        this._element.appendChild(this._overlay);
    },
    
    /**
     *  Returns HTML element.
     *  @return {HTMLDivElement}.
     */
    getElement: function () {
        return this._element;
    },
    
    /**
     *  Model change listener.
     *  @param {ReadMeasureModel} model Model changed.
     */
    onPageChanged: function (model) {
        this._progress.value = model.read();
    }
};
Javascript.
/*

Rule Measure
================

CSS rules to style Read Measure component.

*/

.read-measure {
    position: relative;
    overflow: hidden;
    /* box */
    border-width: 1px;
    border-style: solid;
    width: 100%;
    /* colors */
    border-color: #f00;
}

/*

Progress
-------------
*/

.read-measure > progress {
    /* box */
    border: 0;
    display: block;
    width: 100%;
    height: 20px;
    margin: 0;
    padding: 0;
    /* colors */
    background-color: #f5f5f5;
}

/*
XXX cannot declare ".read-measure > progress::-webkit-progress-bar" and
".read-measure > progress::progress-bar" : all ruleset is then ignored by Firefox.
*/
.read-measure > progress::-moz-progress-bar {
    /* colors */
    background-color: #F5AFAF;
}
.read-measure > progress::-webkit-progress-bar,
.read-measure > progress::progress-bar {
    /* colors */
    background-color: #F5AFAF;
}

/*

Chapter markers
------------------
*/

.read-measure > div {
    /* box */
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}

.read-measure > div > span {
    /* box */
    display: inline-block;
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
    height: 100%;
    border-width: 0 1px 0 0;
    border-style: solid;
    /* colors */
    border-color: #f00;
}

.read-measure > div > span:last-child {
    border-width: 0;
}
CSS