scratch – Rev 84

Subversion Repositories:
Rev:
window.DrawingBoard = typeof DrawingBoard !== "undefined" ? DrawingBoard : {};

/**
 * pass the id of the html element to put the drawing board into
 * and some options : {
 *      controls: array of controls to initialize with the drawingboard. 'Colors', 'Size', and 'Navigation' by default
 *              instead of simple strings, you can pass an object to define a control opts
 *              ie ['Color', { Navigation: { reset: false }}]
 *      controlsPosition: "top left" by default. Define where to put the controls: at the "top" or "bottom" of the canvas, aligned to "left"/"right"/"center"
 *      background: background of the drawing board. Give a hex color or an image url "#ffffff" (white) by default
 *      color: pencil color ("#000000" by default)
 *      size: pencil size (3 by default)
 *      webStorage: 'session', 'local' or false ('session' by default). store the current drawing in session or local storage and restore it when you come back
 *      droppable: true or false (false by default). If true, dropping an image on the canvas will include it and allow you to draw on it,
 *      errorMessage: html string to put in the board's element on browsers that don't support canvas.
 *      stretchImg: default behavior of image setting on the canvas: set to the canvas width/height or not? false by default
 * }
 */
DrawingBoard.Board = function(id, opts) {
        this.opts = this.mergeOptions(opts);

        this.ev = new DrawingBoard.Utils.MicroEvent();

        this.id = id;
        this.$el = $(document.getElementById(id));
        if (!this.$el.length)
                return false;

        var tpl = '<div class="drawing-board-canvas-wrapper"></canvas><canvas class="drawing-board-canvas"></canvas><div class="drawing-board-cursor drawing-board-utils-hidden"></div></div>';
        if (this.opts.controlsPosition.indexOf("bottom") > -1) tpl += '<div class="drawing-board-controls"></div>';
        else tpl = '<div class="drawing-board-controls"></div>' + tpl;

        this.$el.addClass('drawing-board').append(tpl);
        this.dom = {
                $canvasWrapper: this.$el.find('.drawing-board-canvas-wrapper'),
                $canvas: this.$el.find('.drawing-board-canvas'),
                $cursor: this.$el.find('.drawing-board-cursor'),
                $controls: this.$el.find('.drawing-board-controls')
        };

        $.each(['left', 'right', 'center'], $.proxy(function(n, val) {
                if (this.opts.controlsPosition.indexOf(val) > -1) {
                        this.dom.$controls.attr('data-align', val);
                        return false;
                }
        }, this));

        this.canvas = this.dom.$canvas.get(0);
        this.ctx = this.canvas && this.canvas.getContext && this.canvas.getContext('2d') ? this.canvas.getContext('2d') : null;
        this.color = this.opts.color;

        if (!this.ctx) {
                if (this.opts.errorMessage)
                        this.$el.html(this.opts.errorMessage);
                return false;
        }

        this.storage = this._getStorage();

        this.initHistory();
        //init default board values before controls are added (mostly pencil color and size)
        this.reset({ webStorage: false, history: false, background: false });
        //init controls (they will need the default board values to work like pencil color and size)
        this.initControls();
        //set board's size after the controls div is added
        this.resize();
        //reset the board to take all resized space
        this.reset({ webStorage: false, history: false, background: true });
        this.restoreWebStorage();
        this.initDropEvents();
        this.initDrawEvents();
};



DrawingBoard.Board.defaultOpts = {
        controls: ['Color', 'DrawingMode', 'Size', 'Navigation'],
        controlsPosition: "top left",
        color: "#000000",
        size: 1,
        background: "#fff",
        eraserColor: "background",
        fillTolerance: 100,
        fillHack: true, //try to prevent issues with anti-aliasing with a little hack by default
        webStorage: 'session',
        droppable: false,
        enlargeYourContainer: false,
        errorMessage: "<p>It seems you use an obsolete browser. <a href=\"http://browsehappy.com/\" target=\"_blank\">Update it</a> to start drawing.</p>",
        stretchImg: false //when setting the canvas img, strech the image at the whole canvas size when this opt is true
};



DrawingBoard.Board.prototype = {

        mergeOptions: function(opts) {
                opts = $.extend({}, DrawingBoard.Board.defaultOpts, opts);
                if (!opts.background && opts.eraserColor === "background")
                        opts.eraserColor = "transparent";
                return opts;
        },

        /**
         * Canvas reset/resize methods: put back the canvas to its default values
         *
         * depending on options, can set color, size, background back to default values
         * and store the reseted canvas in webstorage and history queue
         *
         * resize values depend on the `enlargeYourContainer` option
         */

        reset: function(opts) {
                opts = $.extend({
                        color: this.opts.color,
                        size: this.opts.size,
                        webStorage: true,
                        history: true,
                        background: false
                }, opts);

                this.setMode('pencil');

                if (opts.background) {
                        this.resetBackground(this.opts.background, $.proxy(function() {
                                if (opts.history) this.saveHistory();
                        }, this));
                }

                if (opts.color) this.setColor(opts.color);
                if (opts.size) this.ctx.lineWidth = opts.size;

                this.ctx.lineCap = "round";
                this.ctx.lineJoin = "round";
                // this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.width);

                if (opts.webStorage) this.saveWebStorage();

                // if opts.background we already dealt with the history
                if (opts.history && !opts.background) this.saveHistory();

                this.blankCanvas = this.getImg();

                this.ev.trigger('board:reset', opts);
        },

        resetBackground: function(background, callback) {
                background = background || this.opts.background;

                var bgIsColor = DrawingBoard.Utils.isColor(background);
                var prevMode = this.getMode();
                this.setMode('pencil');
                this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
                if (bgIsColor) {
                        this.ctx.fillStyle = background;
                        this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
                        this.history.initialize(this.getImg());
                        if (callback) callback();
                } else if (background)
                        this.setImg(background, {
                                callback: $.proxy(function() {
                                        this.history.initialize(this.getImg());
                                        if (callback) callback();
                                }, this)
                        });
                this.setMode(prevMode);
        },

        resize: function() {
                this.dom.$controls.toggleClass('drawing-board-controls-hidden', (!this.controls || !this.controls.length));

                var canvasWidth, canvasHeight;
                var widths = [
                        this.$el.width(),
                        DrawingBoard.Utils.boxBorderWidth(this.$el),
                        DrawingBoard.Utils.boxBorderWidth(this.dom.$canvasWrapper, true, true)
                ];
                var heights = [
                        this.$el.height(),
                        DrawingBoard.Utils.boxBorderHeight(this.$el),
                        this.dom.$controls.height(),
                        DrawingBoard.Utils.boxBorderHeight(this.dom.$controls, false, true),
                        DrawingBoard.Utils.boxBorderHeight(this.dom.$canvasWrapper, true, true)
                ];
                var that = this;
                var sum = function(values, multiplier) { //make the sum of all array values
                        multiplier = multiplier || 1;
                        var res = values[0];
                        for (var i = 1; i < values.length; i++) {
                                res = res + (values[i]*multiplier);
                        }
                        return res;
                };
                var sub = function(values) { return sum(values, -1); }; //substract all array values from the first one

                if (this.opts.enlargeYourContainer) {
                        canvasWidth = this.$el.width();
                        canvasHeight = this.$el.height();

                        this.$el.width( sum(widths) );
                        this.$el.height( sum(heights) );
                } else {
                        canvasWidth = sub(widths);
                        canvasHeight = sub(heights);
                }

                this.dom.$canvasWrapper.css('width', canvasWidth + 'px');
                this.dom.$canvasWrapper.css('height', canvasHeight + 'px');

                this.dom.$canvas.css('width', canvasWidth + 'px');
                this.dom.$canvas.css('height', canvasHeight + 'px');

                this.canvas.width = canvasWidth;
                this.canvas.height = canvasHeight;
        },



        /**
         * Controls:
         * the drawing board can has various UI elements to control it.
         * one control is represented by a class in the namespace DrawingBoard.Control
         * it must have a $el property (jQuery object), representing the html element to append on the drawing board at initialization.
         *
         */

        initControls: function() {
                this.controls = [];
                if (!this.opts.controls.length || !DrawingBoard.Control) return false;
                for (var i = 0; i < this.opts.controls.length; i++) {
                        var c = null;
                        if (typeof this.opts.controls[i] == "string")
                                c = new window['DrawingBoard']['Control'][this.opts.controls[i]](this);
                        else if (typeof this.opts.controls[i] == "object") {
                                for (var controlName in this.opts.controls[i]) break;
                                c = new window['DrawingBoard']['Control'][controlName](this, this.opts.controls[i][controlName]);
                        }
                        if (c) {
                                this.addControl(c);
                        }
                }
        },

        //add a new control or an existing one at the position you want in the UI
        //to add a totally new control, you can pass a string with the js class as 1st parameter and control options as 2nd ie "addControl('Navigation', { reset: false }"
        //the last parameter (2nd or 3rd depending on the situation) is always the position you want to place the control at
        addControl: function(control, optsOrPos, pos) {
                if (typeof control !== "string" && (typeof control !== "object" || !control instanceof DrawingBoard.Control))
                        return false;

                var opts = typeof optsOrPos == "object" ? optsOrPos : {};
                pos = pos ? pos*1 : (typeof optsOrPos == "number" ? optsOrPos : null);

                if (typeof control == "string")
                        control = new window['DrawingBoard']['Control'][control](this, opts);

                if (pos)
                        this.dom.$controls.children().eq(pos).before(control.$el);
                else
                        this.dom.$controls.append(control.$el);

                if (!this.controls)
                        this.controls = [];
                this.controls.push(control);
                this.dom.$controls.removeClass('drawing-board-controls-hidden');
        },



        /**
         * History methods: undo and redo drawed lines
         */

        initHistory: function() {
                this.history = new SimpleUndo({
                        maxLength: 30,
                        provider: $.proxy(function(done) {
                                done(this.getImg());
                        }, this),
                        onUpdate: $.proxy(function() {
                                this.ev.trigger('historyNavigation');
                        }, this)
                });
        },

        saveHistory: function() {
                this.history.save();
        },

        restoreHistory: function(image) {
                this.setImg(image, {
                        callback: $.proxy(function() {
                                this.saveWebStorage();
                        }, this)
                });
        },

        goBackInHistory: function() {
                this.history.undo($.proxy(this.restoreHistory, this));
        },

        goForthInHistory: function() {
                this.history.redo($.proxy(this.restoreHistory, this));
        },

        /**
         * Image methods: you can directly put an image on the canvas, get it in base64 data url or start a download
         */

        setImg: function(src, opts) {
                opts = $.extend({
                        stretch: this.opts.stretchImg,
                        callback: null
                }, opts);

                var ctx = this.ctx;
                var img = new Image();
                var oldGCO = ctx.globalCompositeOperation;
                img.onload = function() {
                        ctx.globalCompositeOperation = "source-over";
                        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

                        if (opts.stretch) {
                                ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);
                        } else {
                                ctx.drawImage(img, 0, 0);
                        }

                        ctx.globalCompositeOperation = oldGCO;

                        if (opts.callback) {
                                opts.callback();
                        }
                };
                img.src = src;
        },

        getImg: function() {
                return this.canvas.toDataURL("image/png");
        },

        downloadImg: function() {
                var img = this.getImg();
                img = img.replace("image/png", "image/octet-stream");
                window.location.href = img;
        },



        /**
         * WebStorage handling : save and restore to local or session storage
         */

        saveWebStorage: function() {
                if (window[this.storage]) {
                        window[this.storage].setItem('drawing-board-' + this.id, this.getImg());
                        this.ev.trigger('board:save' + this.storage.charAt(0).toUpperCase() + this.storage.slice(1), this.getImg());
                }
        },

        restoreWebStorage: function() {
                if (window[this.storage] && window[this.storage].getItem('drawing-board-' + this.id) !== null) {
                        this.setImg(window[this.storage].getItem('drawing-board-' + this.id));
                        this.ev.trigger('board:restore' + this.storage.charAt(0).toUpperCase() + this.storage.slice(1), window[this.storage].getItem('drawing-board-' + this.id));
                }
        },

        clearWebStorage: function() {
                if (window[this.storage] && window[this.storage].getItem('drawing-board-' + this.id) !== null) {
                        window[this.storage].removeItem('drawing-board-' + this.id);
                        this.ev.trigger('board:clear' + this.storage.charAt(0).toUpperCase() + this.storage.slice(1));
                }
        },

        _getStorage: function() {
                if (!this.opts.webStorage || !(this.opts.webStorage === 'session' || this.opts.webStorage === 'local')) return false;
                return this.opts.webStorage + 'Storage';
        },



        /**
         * Drop an image on the canvas to draw on it
         */

        initDropEvents: function() {
                if (!this.opts.droppable)
                        return false;

                this.dom.$canvas.on('dragover dragenter drop', function(e) {
                        e.stopPropagation();
                        e.preventDefault();
                });

                this.dom.$canvas.on('drop', $.proxy(this._onCanvasDrop, this));
        },

        _onCanvasDrop: function(e) {
                e = e.originalEvent ? e.originalEvent : e;
                var files = e.dataTransfer.files;
                if (!files || !files.length || files[0].type.indexOf('image') == -1 || !window.FileReader)
                        return false;
                var fr = new FileReader();
                fr.readAsDataURL(files[0]);
                fr.onload = $.proxy(function(ev) {
                        this.setImg(ev.target.result, {
                                callback: $.proxy(function() {
                                        this.saveHistory();
                                }, this)
                        });
                        this.ev.trigger('board:imageDropped', ev.target.result);
                        this.ev.trigger('board:userAction');
                }, this);
        },



        /**
         * set and get current drawing mode
         *
         * possible modes are "pencil" (draw normally), "eraser" (draw transparent, like, erase, you know), "filler" (paint can)
         */

        setMode: function(newMode, silent) {
                silent = silent || false;
                newMode = newMode || 'pencil';

                this.ev.unbind('board:startDrawing', $.proxy(this.fill, this));

                if (this.opts.eraserColor === "transparent")
                        this.ctx.globalCompositeOperation = newMode === "eraser" ? "destination-out" : "source-over";
                else {
                        if (newMode === "eraser") {
                                if (this.opts.eraserColor === "background" && DrawingBoard.Utils.isColor(this.opts.background))
                                        this.ctx.strokeStyle = this.opts.background;
                                else if (DrawingBoard.Utils.isColor(this.opts.eraserColor))
                                        this.ctx.strokeStyle = this.opts.eraserColor;
                        } else if (!this.mode || this.mode === "eraser") {
                                this.ctx.strokeStyle = this.color;
                        }

                        if (newMode === "filler")
                                this.ev.bind('board:startDrawing', $.proxy(this.fill, this));
                }
                this.mode = newMode;
                if (!silent)
                        this.ev.trigger('board:mode', this.mode);
        },

        getMode: function() {
                return this.mode || "pencil";
        },

        setColor: function(color) {
                var that = this;
                color = color || this.color;
                if (!DrawingBoard.Utils.isColor(color))
                        return false;
                this.color = color;
                if (this.opts.eraserColor !== "transparent" && this.mode === "eraser") {
                        var setStrokeStyle = function(mode) {
                                if (mode !== "eraser")
                                        that.strokeStyle = that.color;
                                that.ev.unbind('board:mode', setStrokeStyle);
                        };
                        this.ev.bind('board:mode', setStrokeStyle);
                } else
                        this.ctx.strokeStyle = this.color;
        },

        /**
         * Fills an area with the current stroke color.
         */
        fill: function(e) {
                if (this.getImg() === this.blankCanvas) {
                        this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
                        this.ctx.fillStyle = this.color;
                        this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
                        return;
                }

                var img = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);

                // constants identifying pixels components
                var INDEX = 0, X = 1, Y = 2, COLOR = 3;

                // target color components
                var stroke = this.ctx.strokeStyle;
                var r = parseInt(stroke.substr(1, 2), 16);
                var g = parseInt(stroke.substr(3, 2), 16);
                var b = parseInt(stroke.substr(5, 2), 16);

                // starting point
                var start = DrawingBoard.Utils.pixelAt(img, parseInt(e.coords.x, 10), parseInt(e.coords.y, 10));
                var startColor = start[COLOR];
                var tolerance = this.opts.fillTolerance;
                var useHack = this.opts.fillHack; //see https://github.com/Leimi/drawingboard.js/pull/38

                // no need to continue if starting and target colors are the same
                if (DrawingBoard.Utils.compareColors(startColor, DrawingBoard.Utils.RGBToInt(r, g, b), tolerance))
                        return;

                // pixels to evaluate
                var queue = [start];

                // loop vars
                var pixel, x, y;
                var maxX = img.width - 1;
                var maxY = img.height - 1;

                function updatePixelColor(pixel) {
                        img.data[pixel[INDEX]] = r;
                        img.data[pixel[INDEX] + 1] = g;
                        img.data[pixel[INDEX] + 2] = b;
                }

                while ((pixel = queue.pop())) {
                        if (useHack)
                                updatePixelColor(pixel);

                        if (DrawingBoard.Utils.compareColors(pixel[COLOR], startColor, tolerance)) {
                                if (!useHack)
                                        updatePixelColor(pixel);
                                if (pixel[X] > 0) // west
                                        queue.push(DrawingBoard.Utils.pixelAt(img, pixel[X] - 1, pixel[Y]));
                                if (pixel[X] < maxX) // east
                                        queue.push(DrawingBoard.Utils.pixelAt(img, pixel[X] + 1, pixel[Y]));
                                if (pixel[Y] > 0) // north
                                        queue.push(DrawingBoard.Utils.pixelAt(img, pixel[X], pixel[Y] - 1));
                                if (pixel[Y] < maxY) // south
                                        queue.push(DrawingBoard.Utils.pixelAt(img, pixel[X], pixel[Y] + 1));
                        }
                }

                this.ctx.putImageData(img, 0, 0);
        },


        /**
         * Drawing handling, with mouse or touch
         */

        initDrawEvents: function() {
                this.isDrawing = false;
                this.isMouseHovering = false;
                this.coords = {};
                this.coords.old = this.coords.current = this.coords.oldMid = { x: 0, y: 0 };

                this.dom.$canvas.on('mousedown touchstart', $.proxy(function(e) {
                        this._onInputStart(e, this._getInputCoords(e) );
                }, this));

                this.dom.$canvas.on('mousemove touchmove', $.proxy(function(e) {
                        this._onInputMove(e, this._getInputCoords(e) );
                }, this));

                this.dom.$canvas.on('mousemove', $.proxy(function(e) {

                }, this));

                this.dom.$canvas.on('mouseup touchend', $.proxy(function(e) {
                        this._onInputStop(e, this._getInputCoords(e) );
                }, this));

                this.dom.$canvas.on('mouseover', $.proxy(function(e) {
                        this._onMouseOver(e, this._getInputCoords(e) );
                }, this));

                this.dom.$canvas.on('mouseout', $.proxy(function(e) {
                        this._onMouseOut(e, this._getInputCoords(e) );

                }, this));

                $('body').on('mouseup touchend', $.proxy(function(e) {
                        this.isDrawing = false;
                }, this));

                if (window.requestAnimationFrame) requestAnimationFrame( $.proxy(this.draw, this) );
        },

        draw: function() {
                //if the pencil size is big (>10), the small crosshair makes a friend: a circle of the size of the pencil
                //todo: have the circle works on every browser - it currently should be added only when CSS pointer-events are supported
                //we assume that if requestAnimationFrame is supported, pointer-events is too, but this is terribad.
                if (window.requestAnimationFrame && this.ctx.lineWidth > 10 && this.isMouseHovering) {
                        this.dom.$cursor.css({ width: this.ctx.lineWidth + 'px', height: this.ctx.lineWidth + 'px' });
                        var transform = DrawingBoard.Utils.tpl("translateX({{x}}px) translateY({{y}}px)", { x: this.coords.current.x-(this.ctx.lineWidth/2), y: this.coords.current.y-(this.ctx.lineWidth/2) });
                        this.dom.$cursor.css({ 'transform': transform, '-webkit-transform': transform, '-ms-transform': transform });
                        this.dom.$cursor.removeClass('drawing-board-utils-hidden');
                } else {
                        this.dom.$cursor.addClass('drawing-board-utils-hidden');
                }

                if (this.isDrawing) {
                        var currentMid = this._getMidInputCoords(this.coords.current);
                        this.ctx.beginPath();
                        this.ctx.moveTo(currentMid.x, currentMid.y);
                        this.ctx.quadraticCurveTo(this.coords.old.x, this.coords.old.y, this.coords.oldMid.x, this.coords.oldMid.y);
                        this.ctx.stroke();

                        this.coords.old = this.coords.current;
                        this.coords.oldMid = currentMid;
                }

                if (window.requestAnimationFrame) requestAnimationFrame( $.proxy(function() { this.draw(); }, this) );
        },

        _onInputStart: function(e, coords) {
                this.coords.current = this.coords.old = coords;
                this.coords.oldMid = this._getMidInputCoords(coords);
                this.isDrawing = true;

                if (!window.requestAnimationFrame) this.draw();

                this.ev.trigger('board:startDrawing', {e: e, coords: coords});
                e.stopPropagation();
                e.preventDefault();
        },

        _onInputMove: function(e, coords) {
                this.coords.current = coords;
                this.ev.trigger('board:drawing', {e: e, coords: coords});

                if (!window.requestAnimationFrame) this.draw();

                e.stopPropagation();
                e.preventDefault();
        },

        _onInputStop: function(e, coords) {
                if (this.isDrawing && (!e.touches || e.touches.length === 0)) {
                        this.isDrawing = false;

                        this.saveWebStorage();
                        this.saveHistory();

                        this.ev.trigger('board:stopDrawing', {e: e, coords: coords});
                        this.ev.trigger('board:userAction');
                        e.stopPropagation();
                        e.preventDefault();
                }
        },

        _onMouseOver: function(e, coords) {
                this.isMouseHovering = true;
                this.coords.old = this._getInputCoords(e);
                this.coords.oldMid = this._getMidInputCoords(this.coords.old);

                this.ev.trigger('board:mouseOver', {e: e, coords: coords});
        },

        _onMouseOut: function(e, coords) {
                this.isMouseHovering = false;

                this.ev.trigger('board:mouseOut', {e: e, coords: coords});
        },

        _getInputCoords: function(e) {
                e = e.originalEvent ? e.originalEvent : e;
                var
                        rect = this.canvas.getBoundingClientRect(),
                        width = this.dom.$canvas.width(),
                        height = this.dom.$canvas.height()
                ;
                var x, y;
                if (e.touches && e.touches.length == 1) {
                        x = e.touches[0].pageX;
                        y = e.touches[0].pageY;
                } else {
                        x = e.pageX;
                        y = e.pageY;
                }
                x = x - this.dom.$canvas.offset().left;
                y = y - this.dom.$canvas.offset().top;
                x *= (width / rect.width);
                y *= (height / rect.height);
                return {
                        x: x,
                        y: y
                };
        },

        _getMidInputCoords: function(coords) {
                return {
                        x: this.coords.old.x + coords.x>>1,
                        y: this.coords.old.y + coords.y>>1
                };
        }
};