spritejs/sprite-core

View on GitHub
src/core/basesprite.js

Summary

Maintainability
F
3 days
Test Coverage
import {Matrix, Vector} from 'sprite-math';
import {Timeline} from 'sprite-animator';
import {flow, absolute, rectVertices, deprecate, drawRadiusBox, findColor, cacheContextPool} from '../utils';
import BaseAttr from './baseattr';
import BaseNode from './basenode';

import filters from './filters';

const _animations = Symbol('animations'),
  _cachePriority = Symbol('cachePriority'),
  _effects = Symbol('effects'),
  _flow = Symbol('flow'),
  _releaseKeys = Symbol('releaseKeys');

const CACHE_PRIORITY_THRESHOLDS = 6;
// const CACHE_PRIORITY_THRESHOLDS = 0; // disable cache_priority, for canvas drawing bug...

export default class BaseSprite extends BaseNode {
  static Attr = BaseAttr;

  /**
    new Sprite({
      attr: {
        ...
      }
    })
   */
  constructor(attrs) {
    super(attrs);
    this[_animations] = new Set();
    this[_cachePriority] = 0;
    this[_flow] = {};
    this[_releaseKeys] = new Set();
  }

  static setAttributeEffects(effects = {}) {
    if(this.prototype[_effects] == null) {
      this.prototype[_effects] = effects;
    }
    Object.assign(this.prototype[_effects], effects);
  }

  /*
    Sprite.addAttributes({
      pop1(attr, val) {
        ...
      },
    })
  */
  static addAttributes(attrs = {}) {
    return this.Attr.addAttributes(attrs);
  }

  static defineAttributes(attrs, effects) {
    this.Attr = class extends this.Attr {
      constructor(subject) {
        super(subject);
        if(attrs.init) {
          attrs.init.call(this, this, subject);
        }
      }
    };
    if(attrs) this.addAttributes(attrs);
    if(effects) this.setAttributeEffects(effects);
    return this.Attr;
  }

  get effects() {
    return this[_effects];
  }

  setReleaseKey(key) {
    this[_releaseKeys].add(key);
  }

  reflow() {
    this[_flow] = {};
  }

  flow(prop, value) {
    if(value === undefined) {
      return this[_flow][prop];
    }
    this[_flow][prop] = value;
  }

  get hasLayout() {
    if(this.attr('position') === 'absolute') return false;
    if(this.parent && this.parent.relayout) {
      const display = this.parent.attr('display');
      return display !== '' && display !== 'static';
    }
    return false;
  }

  set zIndex(val) {
    this.attr('zIndex', val);
  }

  get zIndex() {
    return this.attr('zIndex');
  }

  get isVirtual() {
    return false;
  }

  isVisible() {
    if(!this.parent) return false;

    const display = this.attr('display');
    if(display === 'none') {
      return false;
    }

    const opacity = this.attr('opacity');
    if(opacity <= 0) {
      return false;
    }

    if(this.isVirtual) return true;

    const [width, height] = this.offsetSize;
    if(width <= 0 || height <= 0) {
      return false;
    }

    if(this.parent.isVisible) {
      return this.parent.isVisible();
    }
    return true;
  }

  get transform() {
    const transform = new Matrix(this.attr('transformMatrix'));
    const transformOrigin = this.attr('transformOrigin');
    if(transformOrigin) {
      const t = new Matrix();
      t.translate(...transformOrigin);
      t.multiply(transform);
      t.translate(...transformOrigin.map(v => -v));
      return t;
    }
    return transform;
  }

  connect(parent, zOrder = 0) {
    if(parent && typeof parent.stroke === 'function') {
      // directly connect to canvas2d context
      const node = new BaseNode();
      node.context = parent;
      node.timeline = new Timeline();
      node.update = function () {
        const currentTime = this.timeline.currentTime;
        node.dispatchEvent('update', {target: this, timeline: this.timeline, renderTime: currentTime}, true, true);
      };
      parent = node;
    }
    const ret = super.connect(parent, zOrder);
    Object.defineProperty(this, 'context', {
      get: () => parent.cache || parent.context,
      configurable: true,
    });
    this[_animations].forEach((animation) => {
      if(parent.layer) {
        animation.baseTimeline = parent.layer.timeline;
      }
      animation.play();
      animation.finished.then(() => {
        this[_animations].delete(animation);
      });
    });
    if(this.hasLayout) this.clearLayout();
    this.reflow();
    return ret;
  }

  disconnect(parent) {
    this[_animations].forEach(animation => animation.cancel());
    if(this.cache) {
      this.cache = null;
    }
    if(this.hasLayout) this.clearLayout();
    this.reflow();
    const ret = super.disconnect(parent);
    delete this.context;
    [...this[_releaseKeys]].forEach(key => delete this[key]);
    return ret;
  }

  @absolute
  get xy() {
    let x,
      y;
    if(this.hasLayout) {
      x = this.attr('layoutX');
      y = this.attr('layoutY');
    } else {
      [x, y] = this.attr('pos');
    }
    return [x, y];
  }

  get animations() {
    return this[_animations];
  }

  @absolute
  @flow
  get attrSize() {
    let [width, height] = this.attr('size');
    const isBorderBox = this.attr('boxSizing') === 'border-box';

    if(this.hasLayout) {
      const layoutWidth = this.attr('layoutWidth'),
        layoutHeight = this.attr('layoutHeight')
      ;[width, height] = [layoutWidth !== '' ? layoutWidth : width, layoutHeight !== '' ? layoutHeight : height];
    }
    if(isBorderBox) {
      const borderWidth = this.attr('border').width,
        [paddingTop, paddingRight, paddingBottom, paddingLeft] = this.attr('padding');

      if(width !== '') {
        width = Math.max(0, width - 2 * borderWidth - paddingLeft - paddingRight);
      }
      if(height !== '') {
        height = Math.max(0, height - 2 * borderWidth - paddingTop - paddingBottom);
      }
    }
    return [width, height];
  }

  @absolute
  @flow
  get boxOffsetSize() { // get original boxSize, without layout
    if(this.isVirtual) return [0, 0];
    const [width, height] = this.attr('size');
    const [top, right, bottom, left] = this.attr('padding');
    const {width: borderWidth} = this.attr('border'),
      lw = borderWidth * 2;

    return [left + (width | 0) + right + lw, top + (height | 0) + bottom + lw];
  }

  // content width / height
  @flow
  get contentSize() {
    if(this.isVirtual) return [0, 0];
    const [width, height] = this.attrSize;
    return [width | 0, height | 0];
  }

  // content + padding
  @flow
  get clientSize() {
    const [top, right, bottom, left] = this.attr('padding'),
      [width, height] = this.contentSize;

    return [left + width + right, top + height + bottom];
  }

  // content + padding + border
  @flow
  get offsetSize() {
    const {width: borderWidth} = this.attr('border'),
      [width, height] = this.clientSize;

    return [width + 2 * borderWidth,
      height + 2 * borderWidth];
  }

  get layoutSize() {
    const size = this.offsetSize;
    const [top, right, bottom, left] = this.attr('margin');
    return [left + size[0] + right, top + size[1] + bottom];
  }

  get innerSize() {
    return this.contentSize;
  }

  get outerSize() {
    return this.offsetSize;
  }

  getParentXY(lx = 0, ly = 0) {
    const layer = this.layer;
    if(!layer) return [0, 0];
    const parents = [];
    let target = this.parent;
    while(target && target !== layer) {
      parents.push(target);
      target = target.parent;
    }
    parents.reverse();

    let parentX = lx,
      parentY = ly;

    parents.forEach((node) => {
      const scrollLeft = node.attr('scrollLeft'),
        scrollTop = node.attr('scrollTop'),
        borderWidth = node.attr('border').width,
        padding = node.attr('padding');

      [parentX, parentY] = node.pointToOffset(parentX, parentY);
      parentX = parentX - node.originalRect[0] - borderWidth - padding[3] + scrollLeft;
      parentY = parentY - node.originalRect[1] - borderWidth - padding[0] + scrollTop;
    });
    return [parentX, parentY];
  }

  getLayerXY(dx = 0, dy = 0) {
    const layer = this.layer;
    if(!layer) return [0, 0];
    let target = this;
    let [x, y] = [dx, dy];
    while(target && target !== layer) {
      [x, y] = target.offsetToPoint(x, y);
      const parent = target.parent;

      if(parent !== layer) {
        const borderWidth = parent.attr('border').width;
        const padding = parent.attr('padding'),
          scrollLeft = parent.attr('scrollLeft') || 0,
          scrollTop = parent.attr('scrollTop') || 0;

        // const parentX = evt.offsetX - this.originalRect[0] - borderWidth - padding[3] + scrollLeft
        // const parentY = evt.offsetY - this.originalRect[1] - borderWidth - padding[0] + scrollTop

        x = x + parent.originalRect[0] + borderWidth + padding[3] - scrollLeft;
        y = y + parent.originalRect[1] + borderWidth + padding[0] - scrollTop;
      }
      target = parent;
    }
    return [x, y];
  }

  get boundingRect() {
    const transform = this.transform;
    let [ox, oy, width, height] = this.originalRect;

    if(this.hasLayout) {
      const margin = this.attr('margin');
      width += margin[1];
      height += margin[2];
    }

    const vertexs = [[ox, oy],
      [width + ox, oy],
      [ox, height + oy],
      [width + ox, height + oy]];

    const transformed = vertexs.map((v) => {
      return transform.transformPoint(v[0], v[1]);
    });

    const vx = transformed.map(v => v[0]),
      vy = transformed.map(v => v[1]);

    const minX = Math.min(...vx),
      minY = Math.min(...vy),
      maxX = Math.max(...vx),
      maxY = Math.max(...vy);

    return [...[minX, minY], ...[maxX - minX, maxY - minY]];
  }

  // rect before transform
  @flow
  get originalRect() {
    const [width, height] = this.offsetSize,
      [anchorX, anchorY] = this.attr('anchor');

    const rect = [-anchorX * width,
      -anchorY * height,
      width, height];

    if(this.hasLayout) {
      const margin = this.attr('margin');
      rect[0] += margin[3];
      rect[1] += margin[0];
    }
    return rect;
  }

  get originalRenderRect() {
    const bound = this.originalRect,
      pos = this.xy;

    return [pos[0] + bound[0],
      pos[1] + bound[1],
      bound[2],
      bound[3]];
  }

  get renderBox() {
    const bound = this.boundingRect,
      pos = this.xy;

    return [pos[0] + bound[0],
      pos[1] + bound[1],
      pos[0] + bound[0] + bound[2],
      pos[1] + bound[1] + bound[3]];
  }

  get renderRect() {
    const [x0, y0, x1, y1] = this.renderBox;
    return [x0, y0, x1 - x0, y1 - y0];
  }

  get vertices() {
    const vertices = rectVertices(this.originalRect),
      transform = this.transform,
      [x0, y0] = this.xy;

    return vertices.map((v) => {
      const [x, y] = transform.transformPoint(v[0], v[1]);
      return [x0 + x, y0 + y];
    });
  }

  set cache(context) {
    if(context == null) {
      this[_cachePriority] = 0;
      if(this.parent && this.parent.cache) {
        this.parent.cache = null;
      }
    }
    if(this.cacheContext && context !== this.cacheContext) {
      cacheContextPool.put(this.cacheContext);
    }
    this.cacheContext = context;
  }

  get cache() {
    if(this[_cachePriority] >= CACHE_PRIORITY_THRESHOLDS) {
      return this.cacheContext;
    }
    if(this.cacheContext) {
      this.cache = null;
    }
    return false;
  }

  @deprecate('Instead use sprite.cache = null')
  clearCache() {
    this.cache = null;
  }

  appendTo(parent) {
    parent.appendChild(this);
  }

  forceUpdate(clearCache = false) {
    if(clearCache) {
      this.cache = null;
    }
    super.forceUpdate();
  }

  // layer position to sprite offset
  pointToOffset(x, y) {
    const [x0, y0] = this.xy;
    const [dx, dy] = [x - x0, y - y0];
    const transform = this.transform;
    return transform.inverse().transformPoint(dx, dy);
  }

  offsetToPoint(dx, dy) {
    const transform = this.transform;
    const [x0, y0] = this.xy;
    const [x, y] = transform.transformPoint(dx, dy);
    return [x + x0, y + y0];
  }

  getOffsetXY(evt) {
    let parentX,
      parentY;

    if(evt.parentX != null) {
      // group
      parentX = evt.parentX;
      parentY = evt.parentY;
    } else {
      parentX = evt.layerX;
      parentY = evt.layerY;
    }
    if(parentX != null && parentY != null) {
      return this.pointToOffset(parentX, parentY);
    }
  }

  dispatchEvent(type, evt, collisionState = false, swallow = false, useCapturePhase = null) {
    if(collisionState) {
      const offsetXY = this.getOffsetXY(evt);
      if(offsetXY) {
        evt.offsetX = offsetXY[0];
        evt.offsetY = offsetXY[1];
      }
    }

    return super.dispatchEvent(type, evt, collisionState, swallow, useCapturePhase);
  }

  pointCollision(evt) {
    /* istanbul ignore if */
    if(!this.isVisible()) {
      return false;
    }
    const offsetXY = this.getOffsetXY(evt);
    if(!offsetXY) return true;

    let [nx, ny] = offsetXY;
    evt.offsetX = nx;
    evt.offsetY = ny;

    const [ox, oy, ow, oh] = this.originalRect;

    if(nx >= ox && nx - ox < ow
      && ny >= oy && ny - oy < oh) {
      if(this.context && this.context.isPointInPath) {
        const borderWidth = this.attr('border').width,
          borderRadius = this.attr('borderRadius');
        if(borderWidth || borderRadius) {
          const [width, height] = this.outerSize;
          const [x, y, w, h, r] = [0, 0,
            width, height,
            borderRadius];
          drawRadiusBox(this.context, [x, y, w, h], r);
          if(this.layer && this.layer.offset) {
            nx += this.layer.offset[0];
            ny += this.layer.offset[1];
          }
          return this.context.isPointInPath(nx - ox, ny - oy);
        }
      }
      return true;
    }
  }

  // OBB: http://blog.csdn.net/silangquan/article/details/50812425
  OBBCollision(sprite) {
    // vertices: [p1, p2, p3, p4]
    const [p11, p12, p13] = this.vertices,
      [p21, p22, p23] = sprite.vertices;

    const a1 = (new Vector(p12, p11)).unit(),
      a2 = (new Vector(p13, p12)).unit(),
      a3 = (new Vector(p22, p21)).unit(),
      a4 = (new Vector(p23, p22)).unit();

    // The projection of the axis of a vertex in a certain direction
    function verticesProjection(vertices, axis) {
      const [p1, p2, p3, p4] = vertices.map(v => axis.dot(new Vector(v)));

      return [Math.min(p1, p2, p3, p4), Math.max(p1, p2, p3, p4)];
    }

    function projectionIntersect(p1, p2) {
      const m1 = (p1[0] + p1[1]) / 2,
        l1 = Math.abs(p1[1] - p1[0]),
        m2 = (p2[0] + p2[1]) / 2,
        l2 = Math.abs(p2[1] - p2[0]);

      return Math.abs(m2 - m1) <= (l1 + l2) / 2;
    }

    return projectionIntersect(
      verticesProjection(this.vertices, a1),
      verticesProjection(sprite.vertices, a1)
    ) && projectionIntersect(
      verticesProjection(this.vertices, a2),
      verticesProjection(sprite.vertices, a2)
    ) && projectionIntersect(
      verticesProjection(this.vertices, a3),
      verticesProjection(sprite.vertices, a3)
    ) && projectionIntersect(
      verticesProjection(this.vertices, a4),
      verticesProjection(sprite.vertices, a4)
    );
  }

  relayout() {

  }

  draw(t, drawingContext = this.context) { // eslint-disable-line complexity
    super.draw(t, drawingContext);

    if(!this.isVisible()) {
      return;
    }

    const bound = this.originalRect;
    let cachableContext = !this.isVirtual && this.cache;

    const filter = this.attr('filter'),
      shadow = this.attr('shadow'),
      clipOverflow = this.attr('clipOverflow'),
      enableCache = this.attr('enableCache') === true
        || (this.attr('enableCache') === 'auto' && !this.__labelCount && clipOverflow)
        || shadow || filter;

    const ratio = this.layer ? (this.layer.displayRatio || 1.0) : 1.0;

    if(enableCache && (shadow || filter || cachableContext !== false) && !cachableContext) {
      cachableContext = cacheContextPool.get(drawingContext);
      if(cachableContext) {
        // +2 to solve 1px problem
        cachableContext.canvas.width = Math.ceil(bound[2] * ratio) + 2;
        cachableContext.canvas.height = Math.ceil(bound[3] * ratio) + 2;
      }
    }

    const evtArgs = {context: drawingContext, cacheContext: cachableContext, target: this, renderTime: t, fromCache: !!this.cache};

    drawingContext.save();
    drawingContext.translate(...this.xy);
    drawingContext.transform(...this.transform.m);

    // fix for wxapp
    const alpha = drawingContext.globalAlpha != null ? drawingContext.globalAlpha : 1;
    drawingContext.globalAlpha = alpha * this.attr('opacity');

    if(!cachableContext) {
      drawingContext.translate(bound[0], bound[1]);
    } else {
      cachableContext.save();
      // solve 1px problem
      cachableContext.translate(bound[0] - Math.floor(bound[0]) + 1, bound[1] - Math.floor(bound[1]) + 1);
      if(ratio !== 1.0) {
        cachableContext.scale(ratio, ratio);
      }
    }

    this.dispatchEvent('beforedraw', evtArgs, true, true);

    if(cachableContext) {
      // set cache before render for group
      if(!this.cache) {
        this.cache = cachableContext;
        this.render(t, cachableContext);
      }
    } else {
      this.render(t, drawingContext);
    }

    if((shadow || filter) && !cachableContext) {
      console.warn('No cachable context. Shadows and filters have been ignored.');
    }
    if(!clipOverflow && cachableContext) {
      console.warn('Clip overflow is ignored because of cache enabled.');
    }

    if(cachableContext && cachableContext.canvas.width > 0 && cachableContext.canvas.height > 0) {
      if(filter) {
        drawingContext.filter = filters.compile(filter);
      }
      if(shadow) {
        let {blur, color, offset} = shadow;
        blur = blur || 1;
        color = color || 'rgba(0,0,0,1)';
        drawingContext.shadowBlur = blur;
        drawingContext.shadowColor = color;
        if(offset) {
          drawingContext.shadowOffsetX = offset[0];
          drawingContext.shadowOffsetY = offset[1];
        }
      }
      drawingContext.drawImage(cachableContext.canvas, Math.floor(bound[0]) - 1, Math.floor(bound[1]) - 1,
        bound[2] + 2, bound[3] + 2);
    }

    this.dispatchEvent('afterdraw', evtArgs, true, true);

    if(cachableContext) {
      cachableContext.restore();
    }
    drawingContext.restore();

    this[_cachePriority]++;

    return drawingContext;
  }

  get needRender() {
    if(this.isVirtual) return false;

    const [offsetWidth, offsetHeight] = this.offsetSize;
    if(offsetWidth <= 0 || offsetHeight <= 0) return false;

    const border = this.attr('border');

    if(border.width <= 0
      && this.attr('borderRadius') <= 0
      && !this.attr('bgcolor')
      && !this.attr('gradients').bgcolor
      && !this.attr('bgimage')) {
      return false; // don't need to render
    }

    return true;
  }

  show() {
    this.attr('display', this.__originalDisplay || '');
    return this;
  }

  hide() {
    const display = this.attr('display');
    if(display !== 'none') {
      this.__originalDisplay = display;
      this.attr('display', 'none');
    }
    return this;
  }

  render(t, drawingContext) {
    const border = this.attr('border'),
      borderRadius = this.attr('borderRadius'),
      padding = this.attr('padding'),
      [offsetWidth, offsetHeight] = this.offsetSize,
      [clientWidth, clientHeight] = this.clientSize;

    if(!this.needRender) {
      drawingContext.translate(padding[3], padding[0]);
      return false;
    }

    const borderWidth = border.width;
    let borderStyle = border.style;

    // draw border
    if(borderWidth) {
      drawingContext.lineWidth = borderWidth;

      const [x, y, w, h, r] = [borderWidth / 2, borderWidth / 2,
        offsetWidth - borderWidth, offsetHeight - borderWidth,
        borderRadius];

      drawRadiusBox(drawingContext, [x, y, w, h], r);

      drawingContext.save();
      if(borderStyle && borderStyle !== 'solid') {
        const dashOffset = this.attr('dashOffset');
        drawingContext.lineDashOffset = dashOffset;
        if(borderStyle === 'dashed') {
          borderStyle = [borderWidth * 3, borderWidth * 3];
        }
        drawingContext.setLineDash(borderStyle);
      }
      drawingContext.strokeStyle = findColor(drawingContext, this, 'border');
      drawingContext.stroke();
      drawingContext.restore();
    }

    // draw bgcolor
    const bgcolor = findColor(drawingContext, this, 'bgcolor');
    const bgimage = this.attr('bgimage');

    if(!this.cacheContext || borderWidth || borderRadius || bgcolor || bgimage && bgimage.display !== 'none') {
      let [x, y, w, h, r] = [borderWidth, borderWidth,
        clientWidth, clientHeight,
        borderRadius];

      if(Array.isArray(r)) {
        r = r.map(r => r - borderWidth / 2);
      }

      drawRadiusBox(drawingContext, [x, y, w, h], r);

      if(bgcolor) {
        drawingContext.fillStyle = bgcolor;
        drawingContext.fill();
      }

      // clip is expensive, we should only perform clip when it has to.
      if(bgimage && bgimage.display !== 'none' || borderRadius && (this.nodeType !== 'sprite' || this.textures && this.textures.length)) {
        drawingContext.clip();
      }

      if(bgimage && bgimage.image && bgimage.display !== 'none') {
        drawBgImage(drawingContext, bgimage, borderWidth, offsetWidth, offsetHeight, clientWidth, clientHeight);
      }
    }

    drawingContext.translate(borderWidth + padding[3], borderWidth + padding[0]);

    return true;
  }
}

function drawDot9Image(drawingContext, image, clip9, borderWidth, offsetWidth, offsetHeight, clientWidth, clientHeight) {
  const w = image.width,
    h = image.height;

  const [top, right, bottom, left] = clip9 || [16, 16, 16, 16];
  const leftTop = [0, 0, left, top],
    rightTop = [w - right, 0, right, top],
    rightBottom = [w - right, h - bottom, right, bottom],
    leftBottom = [0, h - bottom, left, bottom];

  const boxRight = offsetWidth - right - borderWidth,
    boxBottom = offsetHeight - borderWidth - bottom;

  // draw .9 cross
  const midWidth = w - left - right,
    midHeight = h - top - bottom;

  if(midWidth > 0) {
    let midBoxWidth = clientWidth - left - right + 2;
    let leftOffset = borderWidth + left - 1;
    while(midBoxWidth > 0) {
      const ww = Math.min(midBoxWidth, midWidth) + 1;
      const topPiece = [left - 1, 0, ww, top],
        bottomPiece = [left - 1, h - bottom, ww, bottom];

      drawingContext.drawImage(image, ...topPiece, leftOffset, borderWidth, ww, top);
      drawingContext.drawImage(image, ...bottomPiece, leftOffset, boxBottom, ww, bottom);
      midBoxWidth -= midWidth;
      if(midBoxWidth > 0) {
        leftOffset += midWidth;
      }
    }
  }

  if(midHeight > 0) {
    let midBoxHeight = clientHeight - top - bottom + 2;
    let topOffset = borderWidth + top - 1;
    while(midBoxHeight > 0) {
      const hh = Math.min(midBoxHeight, midHeight) + 1;
      const leftPiece = [0, top - 1, left, hh],
        rightPiece = [w - right, top - 1, right, hh];

      drawingContext.drawImage(image, ...leftPiece, borderWidth, topOffset, left, hh);
      drawingContext.drawImage(image, ...rightPiece, boxRight, topOffset, right, hh);
      midBoxHeight -= midHeight;
      if(midBoxHeight > 0) {
        topOffset += midHeight;
      }
    }
  }

  if(midHeight && midWidth > 0) {
    let midBoxWidth = clientWidth - left - right + 2;
    let leftOffset = borderWidth + left - 1;

    while(midBoxWidth > 0) {
      let midBoxHeight = clientHeight - top - bottom + 2;
      let topOffset = borderWidth + top - 1;
      while(midBoxHeight > 0) {
        const ww = Math.min(midBoxWidth, midWidth) + 1,
          hh = Math.min(midBoxHeight, midHeight) + 1;
        const midPiece = [left - 1, top - 1, ww, hh];
        drawingContext.drawImage(image, ...midPiece, leftOffset, topOffset, ww, hh);
        midBoxHeight -= midWidth;
        if(midBoxHeight > 0) {
          topOffset += midHeight;
        }
      }
      midBoxWidth -= midWidth;
      if(midBoxWidth > 0) {
        leftOffset += midWidth;
      }
    }
  }

  // draw four corners
  drawingContext.drawImage(image, ...leftTop, borderWidth, borderWidth, left, top);
  drawingContext.drawImage(image, ...rightTop, boxRight, borderWidth, right, top);
  drawingContext.drawImage(image, ...rightBottom, boxRight, boxBottom, left, bottom);
  drawingContext.drawImage(image, ...leftBottom, borderWidth, boxBottom, left, bottom);
}

function drawBgImage(drawingContext, bgimage, borderWidth, offsetWidth, offsetHeight, clientWidth, clientHeight) {
  const {image, display, clip9} = bgimage;

  if(display === '.9') {
    drawDot9Image(drawingContext, image, clip9, borderWidth, offsetWidth, offsetHeight, clientWidth, clientHeight);
  } else {
    let offset = bgimage.offset || [0, 0],
      w = image.width,
      h = image.height;

    if(display === 'center') {
      offset = [(clientWidth - w) * 0.5, (clientHeight - h) * 0.5];
    } else if(display === 'stretch') {
      w = clientWidth - offset[0];
      h = clientHeight - offset[1];
    }
    drawingContext.drawImage(image, borderWidth + offset[0], borderWidth + offset[1], w, h);

    if(w > 0 && (display === 'repeat' || display === 'repeatX')) {
      let cw = clientWidth - borderWidth - offset[0] - w;
      while(cw > borderWidth) {
        drawingContext.drawImage(image, clientWidth - cw, borderWidth + offset[1], w, h);
        if(h > 0 && display === 'repeat') {
          let ch = clientHeight - borderWidth - offset[1] - h;
          while(ch > borderWidth) {
            drawingContext.drawImage(image, clientWidth - cw, clientHeight - ch, w, h);
            ch -= h;
          }
        }
        cw -= w;
      }
    }

    if(h > 0 && (display === 'repeat' || display === 'repeatY')) {
      let ch = clientHeight - borderWidth - offset[1] - h;
      while(ch > borderWidth) {
        drawingContext.drawImage(image, borderWidth + offset[0], clientHeight - ch, w, h);
        ch -= h;
      }
    }
  }
}