spritejs/sprite-core

View on GitHub
src/core/group.js

Summary

Maintainability
C
1 day
Test Coverage
import {parseValue, attr, flow, relative, createSvgPath} from '../utils';
import BaseSprite from './basesprite';

import groupApi from '../helpers/group';

const _zOrder = Symbol('zOrder'),
  _layoutTag = Symbol('layoutTag');

const reflow = true,
  relayout = true;

class GroupAttr extends BaseSprite.Attr {
  static inits = [];

  constructor(subject) {
    super(subject);
    GroupAttr.inits.forEach((init) => {
      init(this, subject);
    });
  }

  @attr
  enableCache = 'auto';

  @attr({reflow, value: null})
  set clip(val) {
    if(val) {
      val = typeof val === 'string' ? {d: val} : val;
      this.subject.svg = createSvgPath(val);
      this.set('clip', val);
    } else {
      this.subject.svg = null;
      this.set('clip', null);
    }
  }

  @attr({reflow, relayout})
  @relative('width')
  layoutWidth = '';

  @attr({reflow, relayout})
  @relative('height')
  layoutHeight = '';

  @attr({reflow, relayout})
  @relative('width')
  width = '';

  @attr({reflow, relayout})
  @relative('height')
  height = '';

  @attr({relayout})
  display = '';

  @parseValue(parseFloat)
  @attr
  scrollLeft = 0;

  @parseValue(parseFloat)
  @attr
  scrollTop = 0;
}

const _layout = Symbol('layout');

export default class Group extends BaseSprite {
  static Attr = GroupAttr;

  static applyLayout(name, layout) {
    this[_layout] = this[_layout] || {};
    const {attrs, relayout} = layout;
    if(attrs.init) {
      GroupAttr.inits.push(attrs.init);
    }
    Group.addAttributes(attrs);
    this[_layout][name] = relayout;
  }

  constructor(attr = {}) {
    super(attr);
    this.childNodes = [];
    this.sortedChildNodes = [];
    this[_zOrder] = 0;
    this[_layoutTag] = false;
    this.__labelCount = 0;
  }

  get isVirtual() {
    const display = this.attr('display');
    if(display !== '' && display !== 'none') return false;

    const parent = this.parent;
    if(parent && parent instanceof Group && !parent.isVirtual) return false;

    const {width: borderWidth} = this.attr('border'),
      borderRadius = this.attr('borderRadius'),
      bgcolor = this.attr('bgcolor'),
      {bgcolor: bgGradient} = this.attr('gradients'),
      [width, height] = this.attrSize,
      [anchorX, anchorY] = this.attr('anchor'),
      bgimage = this.attr('bgimage'),
      [paddingTop, paddingRight, paddingBottom, paddingLeft] = this.attr('padding');

    return !anchorX && !anchorY && !width && !height && !borderRadius
      && !borderWidth && !bgcolor && !bgGradient && !bgimage
      && !paddingTop && !paddingRight && !paddingBottom && !paddingLeft;
  }

  connect(parent, zOrder = 0) {
    const ret = super.connect(parent, zOrder);
    const labelCount = this.__labelCount;
    let _p = parent;
    while(_p && _p.__labelCount != null) {
      _p.__labelCount += labelCount;
      _p = _p.parent;
    }
    return ret;
  }

  disconnect(parent) {
    const ret = super.disconnect(parent);
    const labelCount = this.__labelCount;
    let _p = parent;
    while(_p && _p.__labelCount != null) {
      _p.__labelCount -= labelCount;
      _p = _p.parent;
    }
    return ret;
  }

  scrollTo(x, y) {
    this.attr('scrollLeft', x);
    this.attr('scrollTop', y);
  }

  scrollBy(dx, dy) {
    const x = this.attr('scrollLeft'),
      y = this.attr('scrollTop');

    this.scrollTo(x + dx, y + dy);
  }

  cloneNode(deepCopy) {
    const node = super.cloneNode();
    if(deepCopy) {
      const children = this.childNodes;
      children.forEach((child) => {
        const subNode = child.cloneNode(deepCopy);
        node.append(subNode);
      });
    }
    return node;
  }

  get children() {
    const children = this.childNodes || [];
    return children.filter(child => child instanceof BaseSprite);
  }

  update(child) {
    child.isDirty = true;
    const attrSize = this.attrSize;
    if(attrSize[0] === '' || attrSize[1] === '') {
      this.reflow();
    }
    this.forceUpdate(true);
  }

  pointCollision(evt) {
    if(super.pointCollision(evt) || this.isVirtual) {
      if(this.svg) {
        const {offsetX, offsetY} = evt;
        if(offsetX == null && offsetY == null) return true;
        const rect = this.originalRect;
        evt.isInClip = this.svg.isPointInPath(offsetX - rect[0], offsetY - rect[1]);
      }
      return true;
    }
    return false;
  }

  @flow
  get contentSize() {
    if(this.isVirtual) return [0, 0];
    let [width, height] = this.attrSize;

    if(width === '' || height === '') {
      if(this.attr('clip')) {
        const svg = this.svg;
        const bounds = svg.bounds;
        width = width || bounds[2];
        height = height || bounds[3];
      } else {
        let right,
          bottom;

        right = 0;
        bottom = 0;
        this.childNodes.forEach((sprite) => {
          if(sprite.attr('display') !== 'none') {
            const renderBox = sprite.renderBox;
            if(renderBox) {
              right = Math.max(right, renderBox[2]);
              bottom = Math.max(bottom, renderBox[3]);
            }
          }
        });
        width = width || right;
        height = height || bottom;
      }
    }
    return [width, height];
  }

  dispatchEvent(type, evt, collisionState = false, swallow = false, useCapturePhase = null) {
    const handlers = this.getEventHandlers(type);
    if(swallow && handlers.length === 0) {
      return;
    }
    let hasCapturePhase = false;
    if(!swallow && !evt.terminated && type !== 'mouseenter') {
      const isCollision = collisionState || this.pointCollision(evt);
      if(isCollision || type === 'mouseleave' || !this.attr('clipOverflow')) {
        const scrollLeft = this.attr('scrollLeft'),
          scrollTop = this.attr('scrollTop'),
          borderWidth = this.attr('border').width,
          padding = this.attr('padding');

        let parentX,
          parentY;

        if('offsetX' in evt) parentX = evt.offsetX - this.originalRect[0] - borderWidth - padding[3] + scrollLeft;
        if('offsetY' in evt) parentY = evt.offsetY - this.originalRect[1] - borderWidth - padding[0] + scrollTop;

        const _parentX = evt.parentX,
          _parentY = evt.parentY;

        evt.parentX = parentX;
        evt.parentY = parentY;

        if(isCollision && handlers.length && handlers.some(handler => handler.useCapture)) {
          hasCapturePhase = true;
          if(!evt.target) evt.target = this.getTargetFromXY(parentX, parentY);
          super.dispatchEvent(type, evt, isCollision, swallow, true);
        }

        const targetSprites = [];
        if(!hasCapturePhase || !evt.cancelBubble) {
          const sprites = this.sortedChildNodes.slice(0).reverse();

          for(let i = 0; i < sprites.length && evt.isInClip !== false; i++) {
            const sprite = sprites[i];
            const hit = sprite.dispatchEvent(type, evt, collisionState, swallow, useCapturePhase);
            if(hit) {
              if(evt.targetSprites) {
                targetSprites.push(...evt.targetSprites);
                delete evt.targetSprites;
              }
              targetSprites.push(sprite);
            }
            if(evt.terminated && type !== 'mousemove') {
              break;
            }
          }
        }

        evt.targetSprites = targetSprites;
        // stopDispatch can only terminate event in the same level
        evt.terminated = false;
        evt.parentX = _parentX;
        evt.parentY = _parentY;
        collisionState = isCollision;
      }
    }
    evt.targetSprites = evt.targetSprites || [];
    if(evt.cancelBubble) {
      // stop bubbling
      return false;
    }
    if(hasCapturePhase) {
      return super.dispatchEvent(type, evt, collisionState, swallow, false);
    }
    if(evt.targetSprites.length > 0) {
      // bubbling
      collisionState = true;
    }
    return super.dispatchEvent(type, evt, collisionState, swallow, useCapturePhase);
  }

  relayout() {
    const items = this.childNodes.filter((child) => {
      if(child.hasLayout) {
        child.attr('layoutWidth', null);
        child.attr('layoutHeight', null);
        child.attr('layoutX', null);
        child.attr('layoutY', null);
      }
      if(child.relayout) {
        const display = child.attr('display');
        if(display !== '' && display !== 'static') {
          child.relayout();
        }
      }
      return child.hasLayout && child.attr('display') !== 'none';
    });

    const display = this.attr('display');
    const doLayout = Group[_layout][display];
    if(doLayout) {
      doLayout(this, items);
    }
  }

  clearLayout() {
    this[_layoutTag] = false;
    if(this.hasLayout) this.parent.clearLayout();
  }

  draw(t, drawingContext = this.context) {
    // must relayout before draw
    // prevent originalRect changing when rendering.
    const display = this.attr('display');
    if(display !== '' && display !== 'static' && !this[_layoutTag]) {
      this.relayout();
      this[_layoutTag] = true;
    }
    return super.draw(t, drawingContext);
  }

  render(t, drawingContext) {
    const clipPath = this.attr('clip');
    if(clipPath) {
      this.svg.beginPath().to(drawingContext);
      drawingContext.clip();
    }

    if(!this.isVirtual) {
      super.render(t, drawingContext);
      if(this.attr('clipOverflow')) {
        drawingContext.beginPath();
        drawingContext.rect(0, 0, this.contentSize[0], this.contentSize[1]);
        drawingContext.clip();
      }
    }

    drawingContext.save();
    const scrollLeft = this.attr('scrollLeft'),
      scrollTop = this.attr('scrollTop');

    drawingContext.translate(-scrollLeft, -scrollTop);
    const sprites = this.sortedChildNodes;

    for(let i = 0; i < sprites.length; i++) {
      const child = sprites[i],
        isDirty = child.isDirty;
      child.isDirty = false;

      child.draw(t, drawingContext);
      if(isDirty) {
        child.dispatchEvent('update', {target: child, renderTime: t}, true, true);
      }
    }
    drawingContext.restore();
  }
}
Object.assign(Group.prototype, groupApi);