GMartigny/pencilJS

View on GitHub
src/modules/container/container.js

Summary

Maintainability
C
1 day
Test Coverage
import EventEmitter from "@pencil.js/event-emitter";
import BaseEvent from "@pencil.js/base-event";
import Position from "@pencil.js/position";
import { radianCircle } from "@pencil.js/math";

const scenePromiseKey = Symbol("_scenePromise");

/**
 * Container class
 * @class
 * @extends EventEmitter
 */
export default class Container extends EventEmitter {
    /**
     * Container constructor
     * @param {PositionDefinition} [positionDefinition] - Position in its container
     * @param {ContainerOptions} [options] - Specific options
     */
    constructor (positionDefinition = new Position(), options) {
        super();

        /**
         * @type {Position}
         */
        this.position = Position.from(positionDefinition);
        /**
         * @type {ContainerOptions}
         */
        this.options = {};
        this.setOptions(options);
        /**
         * @type {Array<Container>}
         */
        this.children = [];
        /**
         * @type {Container}
         */
        this.parent = null;
        /**
         * @type {Number}
         */
        this.frameCount = 0;

        /**
         * @type {Promise<Scene>}
         * @private
         */
        this[scenePromiseKey] = new Promise((resolve) => {
            this.on(Container.events.attach, () => {
                const root = this.getRoot();
                if (root.isScene) {
                    resolve(root);
                }
                else {
                    root.getScene().then(scene => resolve(scene));
                }
            }, true);
        });
    }

    /**
     * Define options for this container
     * @param {ContainerOptions} options - Options to override
     * @return {Container} Itself
     */
    setOptions (options) {
        this.options = {
            ...this.constructor.defaultOptions,
            ...this.options,
            ...options,
        };
        this.options.rotationCenter = Position.from(this.options.rotationCenter);
        if (typeof this.options.scale !== "number") {
            this.options.scale = Position.from(this.options.scale);
        }

        return this;
    }

    /**
     * Container can't be hovered
     * @return {Boolean}
     */
    isHover () { // eslint-disable-line class-methods-use-this
        return false;
    }

    /**
     * Add another container as a child
     * @param {...Container} child - Another container
     * @return {Container} Itself
     */
    add (...child) {
        child.forEach((one) => {
            if (one === this) {
                throw new RangeError("A container can't contain itself.");
            }

            if (one.isScene) {
                throw new RangeError("A scene can't be contained in another container.");
            }

            if (one.parent) {
                one.parent.remove(one);
            }
            one.parent = this;
            this.children.push(one);
            one.fire(new BaseEvent(Container.events.attach, one));
        });

        return this;
    }

    /**
     * Remove a child from the list
     * @param {...Container} child - Child to remove
     * @return {Container} Itself
     */
    remove (...child) {
        child.forEach((one) => {
            if (this.children.includes(one)) {
                const removed = this.children.splice(this.children.indexOf(one), 1)[0];
                removed.parent = null;
                removed.fire(new BaseEvent(Container.events.detach, removed));
            }
        });

        return this;
    }

    /**
     * Remove all its children
     * @return {Container} Itself
     */
    empty () {
        return this.remove(...this.children);
    }

    /**
     * Remove itself from its parent
     * @return {Container} Itself
     */
    delete () {
        if (this.parent) {
            this.parent.remove(this);
        }

        return this;
    }

    /**
     * Return a promise for the associated scene
     * @return {Promise<Scene>}
     */
    getScene () {
        return this[scenePromiseKey];
    }

    /**
     * Return its highest parent
     * @return {Container} Itself
     */
    getRoot () {
        if (this.parent) {
            return this.parent.getRoot();
        }

        return this;
    }

    /**
     * Get this container's absolute position (up to it's utmost parent)
     * @return {Position}
     */
    getAbsolutePosition () {
        const position = new Position();

        // FIXME: don't work for scale and don't take origin into account
        this.climbAncestry((ancestor) => {
            position.rotate(ancestor.options.rotation, ancestor.options.rotationCenter).add(ancestor.position);
        });

        return position;
    }

    /**
     * Bubble the event to its parent
     * @param {BaseEvent} event - Event to fire
     * @return {Container} Itself
     */
    fire (event) {
        super.fire(event);
        if (this.parent && event.bubble) {
            this.parent.fire(event);
        }
        return this;
    }

    /**
     * Find the target at a position
     * @param {Position} position - Any position
     * @param {CanvasRenderingContext2D} ctx - Drawing context to apply paths
     * @return {Container} Itself
     */
    getTarget (position, ctx) {
        if (!this.options.shown) {
            return null;
        }

        ctx.save();

        ctx.translate(this.position.x, this.position.y);

        this.setContext(ctx);

        let lastHovered = null;
        let lookup = this.children.length - 1;
        while (!lastHovered && lookup >= 0) {
            lastHovered = this.children[lookup].getTarget(position, ctx);
            --lookup;
        }

        let target = lastHovered;
        // No found or behind this
        if (!lastHovered || (lastHovered.options.zIndex < 0 && lastHovered.parent === this)) {
            if (this.isHover(position, ctx)) {
                target = this;
            }
        }

        ctx.restore();
        return target;
    }

    /**
     * Set variables of the context according to specified options
     * @param {CanvasRenderingContext2D} ctx - Drawing context
     * @return {Container} Itself
     */
    setContext (ctx) {
        if (this.options.clip) {
            const clipping = new window.Path2D();
            const { clip } = this.options;
            const { x, y } = clip.position;
            ctx.translate(x, y);
            if (clip.trace) {
                clip.trace(clipping);
            }
            ctx.clip(clipping);
            ctx.translate(-x, -y);
        }

        if (this.options.rotation) {
            const anchor = Position.from(this.options.rotationCenter);
            ctx.translate(anchor.x, anchor.y);
            ctx.rotate(this.options.rotation * radianCircle);
            ctx.translate(-anchor.x, -anchor.y);
        }

        if (typeof this.options.scale === "number") {
            if (this.options.scale !== 1) {
                ctx.scale(this.options.scale, this.options.scale);
            }
        }
        else {
            const scale = Position.from(this.options.scale);
            if (scale.x !== 1 || scale.y !== 1) {
                ctx.scale(scale.x, scale.y);
            }
        }

        return this;
    }

    /**
     * Call the render method of all children
     * @param {CanvasRenderingContext2D} ctx - Drawing context
     * @return {Container} Itself
     */
    render (ctx) {
        if (!this.options.shown) {
            return this;
        }

        this.frameCount++;
        this.fire(new BaseEvent(Container.events.draw, this));

        ctx.save();

        ctx.translate(this.position.x, this.position.y);

        this.setContext(ctx);

        this.children.sort((a, b) => a.options.zIndex - b.options.zIndex);

        Container.setOpacity(ctx, this.options.opacity);

        const firstPositiveIndex = this.children.findIndex(child => child.options.zIndex >= 0);
        const pivotIndex = firstPositiveIndex === -1 ? this.children.length : firstPositiveIndex;
        // Render children behind
        for (let i = 0, l = pivotIndex; i < l; ++i) {
            this.children[i].render(ctx);
        }

        this.makePath(ctx);

        // Render children in front
        for (let i = pivotIndex, l = this.children.length; i < l; ++i) {
            this.children[i].render(ctx);
        }

        ctx.restore();

        return this;
    }

    /**
     * Do nothing on Container, override it to add behavior
     * @return {Container} Itself
     */
    makePath () {
        return this;
    }

    /**
     * Display it
     * @return {Container} Itself
     */
    show () {
        this.options.shown = true;
        this.fire(new BaseEvent(Container.events.show, this));
        return this;
    }

    /**
     * Hide it
     * @return {Container} Itself
     */
    hide () {
        this.options.shown = false;
        this.fire(new BaseEvent(Container.events.hide, this));
        return this;
    }

    /**
     * Define if this is an ancestor of another container
     * @param {Container} container - Any container
     * @return {Boolean}
     */
    isAncestorOf (container) {
        if (container && container.parent) {
            if (container.parent === this) {
                return true;
            }

            return this.isAncestorOf(container.parent);
        }

        return false;
    }

    /**
     * @callback ancestryCallback
     * @param {Container} ancestor
     */
    /**
     * Execute an action on every ancestor of this
     * @param {ancestryCallback} callback - Function to execute on each ancestor
     * @param {Container} [until=null] - Define a ancestor where to stop the climbing
     */
    climbAncestry (callback, until) {
        callback(this);

        if (this.parent && this.parent !== until) {
            this.parent.climbAncestry(callback);
        }
    }

    /**
     * Return a json ready object
     * @return {Object}
     */
    toJSON () {
        const { defaultOptions } = this.constructor;
        const optionsCopy = {};

        Object.keys(this.options).forEach((key) => {
            const value = this.options[key];
            if (!(value && value.equals ? value.equals(defaultOptions[key]) : Object.is(value, defaultOptions[key]))) {
                optionsCopy[key] = value;
            }
        });

        const json = {
            constructor: this.constructor.name,
            position: this.position,
        };
        if (this.children.length) {
            json.children = this.children.map(child => child.toJSON());
        }
        if (Object.keys(optionsCopy).length) {
            json.options = optionsCopy;
        }
        return json;
    }

    /**
     * Create a copy of any descendant of Container
     * @return {Container}
     */
    clone () {
        return this.constructor.from(this.toJSON());
    }

    /**
     * Define context opacity
     * @param {CanvasRenderingContext2D} ctx - Drawing context
     * @param {Number} opacity - Opacity value
     */
    static setOpacity (ctx, opacity) {
        if (opacity !== null && ctx.globalAlpha !== opacity) {
            ctx.globalAlpha = opacity;
        }
    }

    /**
     * Return an instance from a generic object
     * @param {Object} definition - Container definition
     * @return {Container}
     */
    static from (definition) {
        return new Container(definition.position, definition.options);
    }

    /**
     * @typedef {Object} ContainerOptions
     * @prop {Boolean} [shown=true] - Is shown
     * @prop {Number} [opacity=null] - Opacity level from 0 to 1 (null mean inherited from parent)
     * @prop {Number} [rotation=0] - Rotation ratio from 0 to 1 (clockwise)
     * @prop {PositionDefinition} [rotationCenter=new Position()] - Center of rotation relative to this position
     * @prop {Number|PositionDefinition} [scale=1] - Scaling ratio or a pair of value for horizontal and vertical scaling
     * @prop {Number} [zIndex=1] - Depth ordering
     * @prop {Component} [clip=null] - Other component used to clip the rendering
     */
    /**
     * @return {ContainerOptions}
     */
    static get defaultOptions () {
        return {
            shown: true,
            opacity: null,
            rotation: 0,
            rotationCenter: new Position(),
            scale: 1,
            zIndex: 1,
            clip: null,
        };
    }

    /**
     * @typedef {Object} ContainerEvent
     * @extends EventEmitterEvents
     * @prop {String} attach - Container is append to a new parent
     * @prop {String} detach - Container remove from it's parent
     * @prop {String} draw - Container is drawn
     * @prop {String} hide -
     * @prop {String} show -
     */
    /**
     * @return {ContainerEvent}
     */
    static get events () {
        return {
            ...super.events,
            attach: "attach",
            detach: "detach",
            draw: "draw",
            hide: "hide",
            show: "show",
        };
    }
}