lively.ide_studio_tabs.cp.js

import { pt, LinearGradient, rect, Color } from 'lively.graphics';
import { connect, disconnect, signal } from 'lively.bindings';
import { arr } from 'lively.lang';
import { Label, TilingLayout, Icon, component, part, ViewModel } from 'lively.morphic';

class TabCloseButton extends Label {
  get tab () {
    return this.owner.owner;
  }

  onMouseUp () {
    this.owner.owner.close();
  }
}

const DefaultTab = component({
  name: 'tab',
  borderColor: Color.rgb(0, 0, 0),
  borderRadius: { bottomLeft: 0, bottomRight: 0, topLeft: 5, topRight: 5 },
  clipMode: 'hidden',
  extent: pt(300, 32),
  fill: new LinearGradient({
    stops: [
      { offset: 0, color: Color.white },
      { offset: 1, color: Color.rgb(236, 240, 241) }
    ],
    vector: 0
  }),
  layout: new TilingLayout({
    axisAlign: 'center',
    resizePolicies: [
      ['horizontal container', { height: 'fixed', width: 'fill' }]
    ]
  }),
  submorphs: [{
    name: 'horizontal container',
    halosEnabled: false,
    borderStyle: 'none',
    fill: Color.rgba(0, 0, 0, 0),
    reactsToPointer: false,
    layout: new TilingLayout({
      padding: rect(6, 6, 0, 0),
      resizePolicies: [
        ['tab caption', { height: 'fixed', width: 'fill' }]
      ]
    }),
    submorphs: [{
      type: Label,
      halosEnabled: false,
      name: 'tab caption',
      fill: Color.transparent,
      fixedWidth: true,
      fontColor: Color.rgba(0, 0, 0, 0.5),
      reactsToPointer: false,
      textAndAttributes: ['tab caption', null]
    }, {
      type: TabCloseButton,
      name: 'tab close',
      halosEnabled: false,
      fontColor: Color.rgba(0, 0, 0, 0.5),
      nativeCursor: 'pointer',
      textAndAttributes: Icon.textAttribute('times')
    }]
  }]
});

const HoverTab = component(DefaultTab, {
  fill: Color.rgb(245, 245, 245)
});

const ActiveTab = component(DefaultTab, {
  fill: Color.rgb(183, 183, 183)
});

const SelectedTab = component(DefaultTab, {
  borderWidth: { bottom: 3, top: 0, right: 0, left: 0 },
  borderColor: Color.rgb(33, 47, 60),
  submorphs: [{
    name: 'horizontal container',
    submorphs:
    [{
      name: 'tab caption',
      fontColor: Color.rgba(0, 0, 0, 1)
    }, {
      name: 'tab close',
      fontColor: Color.rgba(0, 0, 0, 1)
    }]
  }]
});

class TabModel extends ViewModel {
  static get properties () {
    return {
      defaultTabMaster: {
        initialize () {
          this.defaultTabMaster = DefaultTab;
        }
      },
      expose: {
        get () {
          return ['isTab', 'content', 'hasMorphicContent', 'caption', 'close', 'selected', 'closeable', 'closeSilently', 'config'];
        }
      },
      bindings: {
        get () {
          return [
            { signal: 'onMouseUp', handler: 'onMouseUp' },
            { signal: 'menuItems', handler: 'menuItems', override: true }
          ];
        }
      },
      caption: {
        defaultValue: 'Unnamed Tab',
        set (caption) {
          if (!caption) return;
          this.setProperty('caption', caption);
          const captionLabel = this.ui.tabCaption;
          if (captionLabel) captionLabel.textString = caption.length > 47 ? caption.substring(0, 47) + '...' : caption;
          if (this.view) this.view.tooltip = caption;
          this.name = caption + ' tab';
        }
      },
      content: {
        defaultValue: undefined
      },
      hasMorphicContent: {
        defaultValue: true
      },
      selected: {
        defaultValue: false,
        set (selected) {
          this.setProperty('selected', selected);
          signal(this.view, 'onSelectionChange', selected);
        }
      },
      closeable: {
        defaultValue: true,
        set (closeable) {
          this.setProperty('closeable', closeable);
          if (this.view) {
            {
              this.ui.tabClose.visible = closeable;
              this.ui.tabClose.isLayoutable = closeable;
              this.ui.tabCaption.padding = closeable ? rect(6, 3, -6, -2) : rect(6, 3, 0, -2); }
          }
        }
      },
      renameable: {
        defaultValue: true
      }
    };
  }

  get config () {
    return {
      caption: this.caption,
      content: this.content,
      hasMorphicContent: this.hasMorphicContent,
      selected: this.selected,
      closeable: this.closeable,
      renamable: this.renameable
    };
  }

  menuItems () {
    if (!this.renameable) return;
    return [
      [
        'Rename Tab', async () => {
          const newName = await $world.prompt('Tab name:', { input: this.caption });
          if (newName) {
            this.caption = newName;
          }
        }
      ]
    ];
  }

  setAppearance (isSelected) {
    this.view.master.setState(isSelected ? 'selected' : null);
  }

  get isTab () {
    return true;
  }

  close () {
    if (!this.closeable) {
      $world.setStatusMessage('This tab cannot be closed.');
      return;
    }
    // hook for connection to do cleanup
    signal(this.view, 'onClose');
    if (this.hasMorphicContent && this.content) { this.content.remove(); }
    this.view.remove();
  }

  /**
   * Can be used to "just close" a tab. No further logic is regarded and no connections are triggered upon removal
   */
  closeSilently () {
    this.view.remove();
  }

  onMouseUp (evt) {
    if (arr.first(evt.targetMorphs) === this.ui.tabClose) return;
    if (!this.selected) {
      this.selected = true;
    }
  }

  viewDidLoad () {
    this.caption = this.caption;
    // this.setAppearance(this.selected);
  }

  onRefresh (prop) {
    if (prop === 'selected') { this.setAppearance(this.selected); }
  }
}

/**
 * An actual tab.
 */
const Tab = component(DefaultTab, {
  name: 'tab',
  defaultViewModel: TabModel,
  master: {
    auto: DefaultTab,
    click: ActiveTab,
    hover: HoverTab,
    states: { selected: SelectedTab }
  }
});

class TabContentContainerModel extends ViewModel {
  static get properties () {
    return {
      expose: {
        get () {
          return ['resize'];
        }
      },
      bindings: {
        get () {
          return [
            { signal: 'onDrop', handler: 'onDrop' },
            { signal: 'extent', handler: 'resize' }
          ];
        }
      }
    };
  }

  onDrop (evt) { // eslint-disable-line no-unused-vars
    const content = this.view.submorphs[0];
    if (content) { this.view.owner.addContentToSelectedTab(content); }
  }

  resize (size) {
    const content = this.view.submorphs[0];
    if (!content) return;
    content.position = pt(0, 0);
    content.extent = size;
  }
}

/**
 * A container for the content associated with a tab providing layout functionality.
 */
const TabContentContainer = component({
  name: 'tab content container',
  defaultViewModel: TabContentContainerModel,
  fill: Color.white,
  halosEnabled: false,
  extent: pt(600, 375)
});

class TabContainerModel extends ViewModel {
  static get properties () {
    return {
      expose: {
        get () { return ['add', 'tabs', 'scrollToRightmostTab']; }
      },
      bindings: {
        get () {
          return [
            { signal: 'onMouseWheel', handler: 'onMouseWheel' }
          ];
        }
      }
    };
  }

  get tabs () {
    return this.ui.tabFlapContainer.submorphs.filter(submorph => submorph.isTab);
  }

  scrollToRightmostTab () {
    const node = this.ui.tabFlapScrollContainer.env.renderer.getNodeForMorph(this.ui.tabFlapScrollContainer);
    if (!node) return;
    this.view.whenRendered().then(() => { // before, the maxscroll is too small to make the new tab visible
      node.scrollLeft = 1000000; // just force to the right most position
      this.ui.tabFlapScrollContainer.setProperty('scroll', 1000000);
    });
  }

  add (aTab) {
    this.ui.tabFlapContainer.addMorph(aTab);
  }

  onMouseWheel (event) {
    const node = this.ui.tabFlapScrollContainer.env.renderer.getNodeForMorph(this.ui.tabFlapScrollContainer);
    let offset;
    if (Math.abs(event.domEvt.deltaY) > Math.abs(event.domEvt.deltaX)) {
      offset = event.domEvt.deltaY;
    } else {
      offset = event.domEvt.deltaX;
    }
    node.scrollLeft = node.scrollLeft + offset;
    this.ui.tabFlapScrollContainer.setProperty('scroll', pt(node.scrollLeft, node.scrollTop));
    event.stop();
  }
}

const NewTabButtonDefault = component({
  name: 'new tab button',
  extent: pt(32, 32),
  borderRadius: { topLeft: 5, bottomLeft: 0, bottomRight: 0, topRight: 0 },
  fill: new LinearGradient({
    stops: [
      { offset: 0, color: Color.white },
      { offset: 1, color: Color.rgb(236, 240, 241) }
    ],
    vector: 0
  }),
  halosEnabled: false,
  layout: new TilingLayout({
    justifySubmorphs: 'center',
    align: 'center',
    axisAlign: 'center'
  }),
  submorphs: [{
    type: Label,
    name: 'new tab',
    halosEnabled: false,
    reactsToPointer: false,
    fontColor: Color.rgba(0, 0, 0, 0.5),
    nativeCursor: 'pointer',
    padding: rect(2, 1, 0, 1),
    textAndAttributes: Icon.textAttribute('plus')
  }]
});

const NewTabButtonHover = component(NewTabButtonDefault, {
  fill: Color.rgb(245, 245, 245),
  submorphs: [{
    name: 'new tab',
    fontColor: Color.rgba(0, 0, 0, 0.5)
  }]
});

const NewTabButtonActive = component(NewTabButtonDefault, {
  fill: Color.rgb(183, 183, 183),
  submorphs: [{
    name: 'new tab',
    fontColor: Color.rgba(0, 0, 0, 0.5)
  }]
});

const NewTabButton = component(NewTabButtonDefault, { // eslint-disable-line no-unused-vars
  name: 'new tab button',
  master: {
    hover: NewTabButtonHover,
    click: NewTabButtonActive,
    auto: NewTabButtonDefault
  }
});

/**
 * A container containing the actual tabs, providing layout functionality.
 */
const TabContainer = component({
  name: 'tab container',
  defaultViewModel: TabContainerModel,
  extent: pt(600, 32),
  halosEnabled: false,
  fill: Color.transparent,
  layout: new TilingLayout({
    axisAlign: 'center',
    resizePolicies: [
      ['tab flap scroll container', { height: 'fixed', width: 'fill' }]
    ]
  }),
  submorphs: [
    {
      name: 'tab flap scroll container',
      extent: pt(525, 32),
      fill: Color.transparent,
      borderWidth: 0,
      clipMode: 'hidden',
      submorphs: [
        {
          name: 'tab flap container',
          fill: Color.transparent,
          borderWidth: 0,
          layout: new TilingLayout({
            axis: 'row'
          })
        }
      ]
    }
  ]
});

class TabsModel extends ViewModel {
  static get properties () {
    return {
      expose: {
        get () {
          return ['addContentToSelectedTab', 'addTab', 'selectedTab', 'keybindings', 'commands', 'tabs', 'loadFromConfig', 'becameVisible', 'becameInvisible'];
        }
      },
      bindings: {
        get () {
          return [
            {
              // target: 'new tab button', signal: 'onMouseDown', handler: 'addTab', updater: '($update) => { $update(); }'
            }
          ];
        }
      },
      providesContentContainer: {
        defaultValue: false
      },
      // this only has the expected value when calling onSelectedTabChange
      _previouslySelectedTab: {},
      showsSingleTab: {
        defaultValue: true
      },
      defaultTabMaster: {
        isComponent: true,
        initialize () {
          this.defaultTabMaster = Tab;
        }
      }
    };
  }

  /**
   * Takes an array of tabconfigs and recreates the therein specified tabs.
   * I.e. this method will open new tabs, set their contents, captions,...
   * Previous state of the tab system will be silently discarded, i.e. without triggering connections.
   * @param {Object[]} config - An array of taconfigecs.
   */
  loadFromConfig (configs) {
    for (let tab of this.tabs) {
      tab.closeSilently();
    }

    for (let tabConfig of configs) {
      this.addTab(tabConfig.caption,
        tabConfig.content,
        tabConfig.selected,
        tabConfig.hasMorphicContent,
        tabConfig.selected,
        tabConfig.closeable,
        tabConfig.renameable);
    }
  }

  addTab (caption,
    content = undefined,
    selectAfterCreation = true,
    hasMorphicContent = this.providesContentContainer,
    closeable,
    renameable
  ) {
    const newTab = part(this.defaultTabMaster, {
      viewModel: {
        caption,
        content,
        hasMorphicContent,
        renameable,
        closeable
      }
    });
    this.initializeConnectionsFor(newTab);

    this.ui.tabContainer.add(newTab);

    newTab.selected = selectAfterCreation;

    this.updateVisibility(false);
    this.scrollToRightmostTab();
    return newTab;
  }

  updateVisibility (closing) {
    if (!this.showsSingleTab && this.tabs.length > 1) {
      this.view.visible = true;
      signal(this, 'becameVisible');
    }
    if (!this.showsSingleTab && this.tabs.length === 2 && closing) {
      this.view.visible = false;
      signal(this, 'becameInvisible');
    }
  }

  initializeConnectionsFor (tab) {
    connect(tab, 'onSelectionChange', this, 'showContent', {
      updater: `($update, selected) => {
        if (selected) $update(source.content);
      }`
    });
    connect(tab, 'onSelectionChange', this, 'deselectAllTabsExcept', {
      updater: `($update, selected) => {
        if (selected) $update(source);
      }`
    });
    connect(tab, 'onSelectionChange', this, 'onSelectedTabChange', {
      updater: '($update, selected) => { if (selected) $update({curr: source, prev: target._previouslySelectedTab}) }'
    });
    connect(tab, 'onClose', this, 'onTabClose', {
      converter: '() => source'
    });
  }

  disbandConnectionsFor (closedTab) {
    disconnect(closedTab, 'onSelectionChange', this, 'showContent');
    disconnect(closedTab, 'onSelectionChange', this, 'deselectAllTabsExcept');
    disconnect(closedTab, 'onSelectionChange', this, 'onSelectedTabChange');
    disconnect(closedTab, 'onClose', this, 'onTabClose');
  }

  onSelectedTabChange (currAndPrevTabsObject) {
    // hook for external components to bind to for when another tab is selected
    signal(this.view, 'onSelectedTabChange', currAndPrevTabsObject);
    this._previouslySelectedTab = currAndPrevTabsObject.curr;
    return currAndPrevTabsObject;
  }

  onTabClose (closedTab) {
    if (closedTab.selected) this.selectNearestTab(closedTab);
    if (this.tabs.length === 2) {
      signal(this.view, 'oneTabRemaining');
    }
    if (this.tabs.length === 1) {
      this._previouslySelectedTab = undefined;
    }
    this.disbandConnectionsFor(closedTab);
    this.updateVisibility(true);
    return closedTab;
  }

  get tabs () {
    return this.ui.tabContainer.tabs;
  }

  deselectAllTabsExcept (excludedTab) {
    this.tabs.forEach(tab => {
      if (tab === excludedTab) return;
      tab.selected = false;
    });
  }

  selectNearestTab (otherTab) {
    if (this.tabs.length === 0) return;
    if (this.tabs.length === 1) {
      this.tabs[0].selected = true;
      return;
    }
    let tabIndex = this.tabs.indexOf(otherTab);
    const tab = tabIndex < this.tabs.length - 1 ? this.tabs[++tabIndex] : this.tabs[--tabIndex];
    tab.selected = true;
    return tab;
  }

  get selectedTab () {
    return this.tabs.find(tab => tab.selected);
  }

  addContentToSelectedTab (content) {
    if (!this.providesContentContainer) return;
    const tab = this.selectedTab;
    if (tab) {
      tab.content = content;
      this.showContent(content);
    }
  }

  /**
   * When using a tab system with `providesContentContainer = false` connect to this method to get the associated content of a tab upon its selection.
   * @param {any} content - The content of a tab that has been selected.
   */
  showContent (content) {
    if (!this.providesContentContainer) return content;
    const container = this.ui.tabContentContainer;
    container.submorphs.forEach(submorph => submorph.remove());

    if (content) {
      container.addMorph(content);
      content.position = pt(0, 0);
      content.extent = container.extent;
    }
  }

  viewDidLoad () {
    if (!this.providesContentContainer && this.ui.tabContentContainer) { this.ui.tabContentContainer.remove(); }
    if (!this.showsSingleTab) this.view.visible = false;
  }

  get keybindings () {
    return [
      { keys: 'Alt-W', command: 'close current tab' },
      { keys: 'Alt-Q', command: 'select previous tab' },
      { keys: 'Alt-Y', command: 'select next tab' }
    ];
  }

  get commands () {
    return [
      {
        name: 'close current tab',
        exec: () => {
          this.selectedTab.close();
        }
      },
      {
        name: 'select previous tab',
        exec: () => {
          const i = this.tabs.indexOf(this.selectedTab);
          this.tabs[i === 0 ? this.tabs.length - 1 : i - 1].selected = true;
        }
      },
      {
        name: 'select next tab',
        exec: () => {
          const i = this.tabs.indexOf(this.selectedTab);
          this.tabs[i + 1 === this.tabs.length ? 0 : i + 1].selected = true;
        }
      }];
  }

  scrollToRightmostTab () {
    this.ui.tabContainer.scrollToRightmostTab();
  }
}

/**
 * A tab-system which allows to switch between different contents based on the selected tab.
 */
const Tabs = component({
  name: 'tabs',
  fill: Color.transparent,
  defaultViewModel: TabsModel,
  layout: new TilingLayout({
    axis: 'column',
    resizePolicies: [
      ['tab container', { height: 'fixed', width: 'fill' }]
    ]
  }),
  submorphs: [
    part(TabContainer),
    part(TabContentContainer)
  ]
});

export { TabModel, Tabs, DefaultTab, HoverTab, ActiveTab, SelectedTab, Tab };