Home Manual Reference Source Repository

docs/XYContainer/XYContainer.js

import { VNode } from 'snabbdom/vnode';
import h from 'snabbdom/h';

import { 
  Renderable, 
  RenderableInjector,
  ConfiguredRenderable,
  Renderer,
  AddChildArgs,
  RenderableConfig
} from '../dom';
import { Inject, Injector, Optional } from '../di';
import { 
  ContainerRef, 
  XYDirection,
  ConfigurationRef,
  RenderableArg,
  RenderableConstructorArg,
  UNALLOCATED,
  DragStatus,
  DragEvent
} from '../common';
import { XYItemContainer, XYItemContainerConfig } from './XYItemContainer';
import { Draggable } from '../Draggable';
import { BeforeDestroyEvent } from '../events';
import { Splitter, SPLITTER_SIZE } from './Splitter';
import { get, isNumber, clamp, round } from '../utils';
import { Stack, StackItemContainer } from '../stack';

export interface XYContainerConfig extends RenderableConfig {
  /**
   * The size in pixels of the splitter.
   * @type {number}
   */
  splitterSize?: number;
  /**
   * Determines whether the Row or Column should persist when only a single item remains.
   * The default behavior is to unwrap and destroy the row or column.
   * @type {boolean}
   */
  static?: boolean;
  children: Array<XYItemContainerConfig|RenderableConstructorArg<XYItemContainer>>;
}

export interface XYSizingOptions {
  distribute?: boolean;
}

export interface ContainerAddChildArgs extends AddChildArgs {
  distribute?: boolean;
}

export type AdjacentResults = { before: XYItemContainer|null, after: XYItemContainer|null };

export const MAX_RATIO_DISTRIBUTION_ITERATIONS = 5;

export class XYContainer extends Renderable {
  protected _height: number = 0;
  protected _width: number = 0;
  protected _direction: XYDirection;
  protected _className: string;
  protected _contentItems: XYItemContainer[] = [];
  protected _splitters: Splitter[] = [];
  protected _dragLimitMin: number = 0;
  protected _dragLimitMax: number = 0;
  protected _container: Renderable|null;

  constructor(
    @Inject(ConfigurationRef) @Optional() protected _config: XYContainerConfig|null
  ) {
    super();
  }

  get height(): number {
    return this._height;
  }

  get width(): number {
    return this._width;
  }

  get direction(): XYDirection {
    return this._direction;
  }

  get isRow(): boolean {
    return this.direction === XYDirection.X;
  }

  get splitters(): Splitter[] {
    return this._splitters;
  }

  get splitterSize(): number {
    return get(this._config, 'splitterSize', SPLITTER_SIZE);
  }

  get isStatic(): boolean {
    return get(this._config, 'static', false);
  }

  protected get _totalSplitterSize(): number {
    return this._splitters.reduce((result, splitter) => result + splitter.size, 0);
  }

  protected get _totalContainerSize(): number {
    if (!this._container) {
      return 0;
    }
    
    return this.isRow 
      ? this._container.width - this._totalSplitterSize
      : this._container.height - this._totalSplitterSize;
  }

  initialize(): void {
    super.initialize();
    
    const children = this._config && this._config.children ? this._config.children : [];
    
    children.forEach(config => {
      const item = ConfiguredRenderable.isRenderableConstructor<XYItemContainer>(config)
        ? this.createChild(config) 
        : this.createChildItem(config as any);

      return this.addChild(item, { render: false, resize: false });
    });
  }

  createChildItem(config: XYItemContainerConfig, options: { index?: number } = {}): XYItemContainer {
    return this.createChild(new ConfiguredRenderable(XYItemContainer, config));
  }

  addChild(item: Renderable, options: ContainerAddChildArgs = {}): void {
    const { distribute = true, resize = true } = options;
    const childArgs = Object.assign({}, options, { render: false, resize: false });
    let container: XYItemContainer;
    
    // If this is an item container just add it.
    if (!(item instanceof XYItemContainer)) {
      if (item instanceof StackItemContainer) {
        item = this._createStackWrapper(item);
      }
      
      container = this.createChildItem({ use: item }, childArgs);

      item.setContainer(container);
    } else {
      container = item;
    }

    if (resize) {
      let newItemRatio;
      
      if (container.initialSize != null) {
        newItemRatio = (container.initialSize / this._totalContainerSize) * 100;
      } else {
        newItemRatio = (1 / (this._contentItems.length + 1)) * 100;
      }

      container.ratio = newItemRatio;
      
      for (const item of this._contentItems) {
        item.ratio = <number>item.ratio * ((100 - newItemRatio) / 100);
      }
    }
    
    while (this._splitters.length < this._contentItems.length) {
      this._splitters.push(this._createSplitter());
    }
      
    super.addChild(container, options);
  }

  removeChild(item: XYItemContainer): void {
    const index = this._contentItems.indexOf(item);

    if (index === -1) {
      return;
    }

    const splitterIndex = clamp(index, 0, this._splitters.length - 1);
    const splitter = this._splitters[splitterIndex];

    if (splitter) {
      this._splitters.splice(splitterIndex, 1);
      splitter.destroy();
    }

    super.removeChild(item, { render: false });

    if (this._contentItems.length === 1 && this.container && get(this._config, 'static') !== true) {
      const container = this._contentItems[0];
      const item = container.item;
      
      this._contentItems = [];
      this.container.replaceChild(this, item, { destroy: true, render: false });
      
      container.setContainer(null);
      container.removeChild(item, { destroy: false });
    }
    
    this.resize();
    this._renderer.render();
  }
  
  render(): VNode {
    const children: VNode[] = [];

    for (const [ index, child ] of this._contentItems.entries()) {
      if (index > 0 && this._splitters[index - 1]) {
        children.push(this._splitters[index - 1].render());
      }
      
      children.push(child.render());
    }
    
    return h(`div.${this._className}`, {
      style: {
        height: `${this._height}px`,
        width: `${this._width}px`
      }
    }, children);
  }

  resize(): void {
    this._height = this._container ? this._container.height : 0 ;
    this._width = this._container ? this._container.width : 0;

    this._calculateRatios();
    this._setDimensions();

    super.resize();
  }

  setSizeOf(item: XYItemContainer, size: number, options: XYSizingOptions = {}): void {
    const { distribute = false } = options;
    const { before, after } = this.getAdjacentItems(item);
    const prevRatio = item.ratio;
    
    item.ratio = (size / this._totalContainerSize) * 100;

    if (after && isNumber(after.ratio)) {
      after.ratio = Math.max(0, after.ratio + (<number>prevRatio - item.ratio));
    } else if (before && isNumber(before.ratio)) {
      before.ratio = Math.max(0, before.ratio + (<number>prevRatio - item.ratio));
    }

    this.resize();
    this._renderer.render();
  }

  getChildren(): XYItemContainer[] {
    return super.getChildren() as XYItemContainer[];
  }

  getAdjacentItems(item: XYItemContainer): AdjacentResults {
    const index = this._contentItems.indexOf(item);

    return {
      before: index > 0 ? this._contentItems[index - 1] : null,
      after: index < this._contentItems.length - 1 ? this._contentItems[index + 1] : null
    };
  }

  getSplitterFromItem(item: XYItemContainer): Splitter|null {
    const index = this._contentItems.indexOf(item);

    if (index === -1) {
      return null;
    }

    if (index < this._contentItems.length - 1) {
      return this._splitters[index] || null;
    }

    return this._splitters[index - 1] || null;
  }

  getTotalSplitterSizes(start: number = 0, end: number = this._splitters.length - 1): number {
    return this._splitters.slice(start, end).reduce((res, sptr) => res + sptr.size, 0);
  }

  private _createSplitter(): Splitter {
    const splitterConfig = {
      size: this._config && this._config.splitterSize ? this._config.splitterSize : SPLITTER_SIZE,
      disabler: this._isSplitterDisabled.bind(this)
    };

    const splitter = this.createChild(new ConfiguredRenderable(Splitter, splitterConfig), [ Draggable ]);

    splitter.dragStatus.subscribe(this._dragStatusChanged.bind(this));

    return splitter;
  }

  private _isSplitterDisabled(splitter: Splitter): boolean {
    const { before, after } = this._getSplitterItems(splitter);

    return before.isMinimized || after.isMinimized;
  }

  private _dragStatusChanged(event: DragEvent<Splitter>): void {
    switch (event.status) {
      case DragStatus.START: return this._dragStart(event);
      case DragStatus.STOP: return this._dragEnd(event);
      case DragStatus.DRAGGING: return this._dragMove(event);
    }
  }

  private _getSplitterItems(splitter: Splitter): { before: XYItemContainer, after: XYItemContainer } {
    const index = this._splitters.indexOf(splitter);

    return {
      before: this._contentItems[index],
      after: this._contentItems[index + 1]
    };
  }

  private _dragStart(event: DragEvent<Splitter>): void {
    const { host } = event;
    const { before, after } = this._getSplitterItems(host);
    
    this._dragLimitMin = Math.max((-before.size + before.minSize), -(after.maxSize - after.size));
    this._dragLimitMax = Math.min(after.size - after.minSize, before.maxSize - before.size);
  }

  private _dragEnd(event: DragEvent<Splitter>): void {
    let { host, x, y } = event;
    
    host.dragTo(0, 0);

    x = clamp(x, this._dragLimitMin, this._dragLimitMax);
    y = clamp(y, this._dragLimitMin, this._dragLimitMax);

    this._updateSplitterItems(host, x, y);
    this._renderer.render();
  }

  private _updateSplitterItems(splitter: Splitter, x: number, y: number): void {
    const { before, after } = this._getSplitterItems(splitter);
    const totalContainerSize = this._totalContainerSize;
    
    if (this.isRow) {
      before.ratio = ((before.width + x) / totalContainerSize) * 100;
      after.ratio = ((after.width - x) / totalContainerSize) * 100;
    } else {
      before.ratio = ((before.height + y) / totalContainerSize) * 100;
      after.ratio = ((after.height - y) / totalContainerSize) * 100;
    }

    this.resize();
  }
  
  private _dragMove(event: DragEvent<Splitter>): void {
    if (this.isRow) {
      event.host.dragTo(clamp(event.x, this._dragLimitMin, this._dragLimitMax), event.host.y);
    } else {
      event.host.dragTo(event.host.x, clamp(event.y, this._dragLimitMin, this._dragLimitMax));
    }
  }

  private _setDimensions(): void {
    const totalSplitterSize = this._totalSplitterSize;
    let total = 0;
    let totalWidth = this._width;
    let totalHeight = this._height;
    let sizes: number[] = [];

    if (this.isRow) {
      totalWidth -= totalSplitterSize;
    } else {
      totalHeight -= totalSplitterSize;
    }

    for (const child of this._contentItems) {
      let size = (this.isRow ? totalWidth : totalHeight) * (<number>child.ratio / 100);

      total += size;
      sizes.push(size);
    }

    const extraPixels = Math.floor((this.isRow ? totalWidth : totalHeight) - total);

    for (const [ index, child ] of this._contentItems.entries()) {
      if (extraPixels - index > 0) {
        sizes[index]++;
      }

      if (this.isRow) {
        child.setSize({ width: sizes[index], height: totalHeight });
      } else {
        child.setSize({ width: totalWidth, height: sizes[index] });
      }
    }
  }

  private _calculateRatios(): void {
    let total = 0;
    const unallocatedChildren: XYItemContainer[] = [];

    if (!this._contentItems.length) {
      return;
    }

    for (const child of this._contentItems) {
      if (child.ratio !== UNALLOCATED) {
        total += child.ratio as number;
      } else {
        unallocatedChildren.push(child);
      }
    }

    if (Math.round(total) === 100) {
      this._distributeRatios();
    
      return;
    }

    if (Math.round(total) < 100 && unallocatedChildren.length) {
      for (const child of unallocatedChildren) {
        child.ratio = (100 - total) / unallocatedChildren.length
      }
    } else {
      if (Math.round(total) > 100) {
        for (const child of unallocatedChildren) {
          child.ratio = 50;
          total += 50;
        }
      } 
      
      for (const child of this._contentItems) {
        child.ratio = (<number>child.ratio / total) * 100;
      }
    }

    this._distributeRatios();
  }
  
  private _distributeRatios(_iterationCount: number = 0): void {
    // Recursion alert. Check for inifinite loop here.
    const growable: XYItemContainer[] = [];
    const shrinkable: XYItemContainer[] = [];
    const containerSize = this._totalContainerSize;
    let totalRatio = 0;

    for (const child of this._contentItems) {
      const minRatio = !child.isMinimized ? (child.minSize / containerSize) * 100 : 0;
      const maxRatio = (child.maxSize / containerSize) * 100;
      
      child.ratio = clamp(child.ratio, minRatio, maxRatio);
      totalRatio += child.ratio;
      
      const size = containerSize * (<number>child.ratio / 100);

      if (size < child.maxSize) {
        growable.push(child);
      }

      if (size > child.minSize) {
        shrinkable.push(child);
      }
    }

    if (round(totalRatio, 2) > 100) {
      for (const child of shrinkable) {
        child.ratio = <number>child.ratio - ((totalRatio - 100) / shrinkable.length);
      }
    } else if (totalRatio < 100) {
      for (const child of growable) {
        child.ratio = <number>child.ratio + ((100 - totalRatio) / growable.length);
      }
    }

    totalRatio = this._contentItems.reduce((r, i) => r + <number>i.ratio, 0);
    
    for (const child of this._contentItems) {
      child.ratio = (<number>child.ratio / totalRatio) * 100;
    }
  }

  private _createStackWrapper(item: Renderable): Stack {
    const stack = this.createChild(new ConfiguredRenderable(Stack, null));

    stack.addChild(item, { render: false });

    return stack;
  }
}