dfcreative/color-tool

View on GitHub
index.js

Summary

Maintainability
B
5 hrs
Test Coverage
/**
 * Picker - color picker component
 * @module  picky
 */

var Emitter = require('events');
var renderRange = require('color-ranger');
var extend = require('xtend/mutable');
var on = require('emmy/on');
var emit = require('emmy/emit');
var throttle = require('emmy/throttle');
var getUid = require('get-uid');
var spaces = require('color-space');
var Slidy = require('slidy');
var isArray = require('mutype/is-array');
var isNumber = require('mutype/is-number');
// var browser = require('detect-browser');


module.exports = Picker;


var doc = document, win = window;


/**
 * Create a web-worker
 * @link see references in color-ranger tests
 */
var isWorkerAvailable = !!win.Worker;
if (isWorkerAvailable) {
    var work = require('webworkify');
    var worker = work(require('color-ranger/worker'));
}


/**
 * @abstract
 *
 * This is a main picker class.
 * It provides an abstract interface for any color picker.
 */
function Picker (target, options) {
    var self = this;

    //force constructor
    if (!(self instanceof Picker)) return new Picker(target, options);

    //ensure target & options
    if (!options) {
        if (target instanceof Element) {
            options = {};
        }
        else {
            options = target;
            target = doc.createElement('div');
        }
    }


    //get preferred element
    self.element = target;

    //add class
    self.element.classList.add('picky');

    //take over options
    extend(self, options);

    //generate uid for worker
    self.id = getUid();

    // Virtual canvas for painting color ranges
    self.cnv = doc.createElement('canvas');
    self.cnv.className = 'picky-bg';
    self.cnv.width = self.canvasWidth;
    self.cnv.height = self.canvasHeight;
    self.ctx = self.cnv.getContext('2d');

    self.element.appendChild(self.cnv);

    //save channel indexes
    var space = spaces[self.space];

    self._channels = (isArray(self.channel) ? self.channel : [self.channel] ).map(function (channel) {
        if (isNumber(channel)) return channel;
        var idx = space.channel.indexOf(channel);
        if (idx < 0) throw Error('Space ' + self.space + ' has no channel ' + channel);
        return idx;
    });

    //save channel mins/maxes
    self._min = self._channels.map(function (idx) {
        return space.min[idx];
    });
    self._max = self._channels.map(function (idx) {
        return space.max[idx];
    });

    //make self a slidy
    //detect orientation based on a number of values passed
    self.orientation = isArray(self.channel) && self.channel.length > 1 && 'cartesian';

    //detect repeat based on channel
    self.repeat = false;
    if (space.channel[self._channels[0]] === 'hue') self.repeat = 'x';
    else if (space.channel[self._channels[1]] === 'hue') self.repeat = 'y';

    self.slidy = new Slidy(target, {
        pickerClass: 'picky-picker',
        point: true,
        min: self._min,
        max: self._max,
        orientation: self.orientation,
        repeat: self.repeat,
        step: self.step
    });

    //enable events
    self.enable();
}


var proto = Picker.prototype = Object.create(Emitter.prototype);


/** Enable interactions */
proto.enable = function () {
    var self = this;

    //update color on self user input
    on(self.slidy, 'input', function (value) {
        var values = self.color[self.space + 'Array']();

        self._channels.forEach(function (idx, i) {
            values[idx] = value[i];
        });

        //set updated color
        self.color[self.space](values);

        //trigger change for color
        emit(self.color, 'change');
    });

    //listen to bg update events for the specifically this picker
    if (isWorkerAvailable && self.worker) {
        on(worker, 'message', function (e) {
            var imgData = new ImageData(e.data.data, self.canvasWidth, self.canvasHeight);
            if (e.data.id === self.id) self.renderData(imgData);
        });
    }

    //bind options change listener
    if (self.change) {
        on(self, 'change', self.change);
    }

    //rerender on color change - loosely calling
    //50 is subjectively unnoticed interval for bg rendering
    throttle(self.color, 'change', 50, function (e) {
        self.updateBackground();
    });

    //10 is subjectively unnoticed interval for picker movement
    throttle(self.color, 'change', 10, function (e) {
        //ignore active slidy
        if (self.element.contains(doc.activeElement)) return;

        self.updatePosition();
    });

    //for the rest color change events just redirect callback
    on(self.color, 'change', function () {
        self.emit('change', self.color);
    });

    //update position, bg etc
    self.update();

    //emit first change, as if changed from undefined to anything
    self.emit('change', self.color);
};


/** Basic initial color for pickers */
proto.color = null;


/** Use web-worker to render range */
proto.worker = true;


/** A space to pick value in */
proto.space = 'rgb';


/** Default channel */
proto.channel = 'red';

/** 37 is a good balance between performance/quality. You can set 101 or 13 though. */
proto.canvasHeight = 37;
proto.canvasWidth = 37;

/** Slidy default options */
proto.step = 1;


/**
 * Update bg & position
 */
proto.update = function () {
    this.updateBackground()
        .updatePosition();

    return this;
};


/**
 * Update picker position
 * according to the current color value
 */
proto.updatePosition = function () {
    var self = this;

    var cValues = self.color[self.space + 'Array']();

    var value = self._channels.map(function (idx) {
        return cValues[idx];
    });

    //NOTE: beware, iOS crashes on getting value this way:
    // self.slidy.value = value;

    self.slidy.getActivePicker().value = value;

    return self;
};


/**
 * Render picker background
 * according to the current color value
 */
proto.updateBackground = function () {
    var self = this;

    var imgData = self.ctx.getImageData(0, 0, self.canvasWidth, self.canvasHeight);
    var space = spaces[self.space];

    //form options
    var opts = {
        space: this.space,
        channel: self._channels,
        max: self._max,
        min: self._min,
    };

    //render range for a new color value in worker
    if (isWorkerAvailable && this.worker) {
        //response is handled by `message` event
        worker.postMessage(extend(opts, {
            color: this.color.hslArray(),
            sourceSpace: 'hsl',
            data: imgData.data,
            id: this.id
        }));
    }

    //render single-flow
    else {
        renderRange(
            this.color.rgbArray(),
            imgData.data,
            opts
        );
        this.renderData(imgData);
    }

    return self;
};


/** Just show image data passed in self */
proto.renderData = function (imgData) {
    var self = this;

    self.ctx.putImageData(imgData, 0, 0);

    return self;
};