<NME> tab_view.ts
<BEF> /**
 * @license
 * Copyright 2018 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * @file Tabbed view widget.
 */

import 'neuroglancer/widget/tab_view.css';

import {WatchableValueInterface} from 'neuroglancer/trackable_value';
import {animationFrameDebounce} from 'neuroglancer/util/animation_frame_debounce';
import {Owned, RefCounted} from 'neuroglancer/util/disposable';
import {removeChildren, removeFromParent} from 'neuroglancer/util/dom';
import {NullarySignal, Signal} from 'neuroglancer/util/signal';
import {Trackable} from 'neuroglancer/util/trackable';
import {WatchableVisibilityPriority} from 'neuroglancer/visibility_priority/frontend';

export class Tab extends RefCounted {
  element = document.createElement('div');

  get visible() {
    return this.visibility.visible;
  }

  constructor(
      public visibility = new WatchableVisibilityPriority(WatchableVisibilityPriority.VISIBLE)) {
    super();
    const {element} = this;
    element.classList.add('neuroglancer-tab-content');
  }

  disposed() {
    removeFromParent(this.element);
    super.disposed();
  }
}

export class OptionSpecification<T> extends RefCounted implements Trackable {
  changed = new NullarySignal();
  options = new Map<string, T>();
  optionsChanged = new NullarySignal();

  private selectedValue: string|undefined = undefined;
  private defaultValue: string|undefined = undefined;

  get value() {
    const {selectedValue} = this;
    if (selectedValue !== undefined) {
      return selectedValue;
    }
    return this.defaultValue;
  }

  set default(value: string|undefined) {
    if (this.defaultValue !== value) {
      this.defaultValue = value;
      this.changed.dispatch();
    }
  }

  get default() {
    return this.defaultValue;
  }

  set value(value: string|undefined) {
    if (value !== undefined && this.ready_ && !this.options.has(value)) {
      value = undefined;
    }
    const {selectedValue} = this;
    if (selectedValue !== value) {
      this.selectedValue = value;
      this.changed.dispatch();
    }
  }

  get validValue() {
    const value = this.selectedValue;
    if (value === undefined || !this.options.has(value)) {
      return this.defaultValue;
    }
    return value;
  }

  add(id: string, value: T) {
    const {options} = this;
    if (options.has(id)) {
      throw new Error(`Option already defined: ${JSON.stringify(id)}.`);
    }
    options.set(id, value);
    this.optionsChanged.dispatch();
    if (this.defaultValue === undefined) {
      this.default = id;
    }
  }

  remove(id: string) {
    const {options} = this;
    if (!options.has(id)) {
      throw new Error(`Option is not defined: ${JSON.stringify(id)}.`);
    }
    options.delete(id);
    this.optionsChanged.dispatch();
  }

  toJSON() {
    const {value, defaultValue} = this;
    if (value === defaultValue) {
      return undefined;
    }
    return value;
  }

  reset() {
    this.value = undefined;
  }

  ready_ = true;

  /**
   * When `ready` is `false`, the selected `value` may be set to an unknown option.
   */
  get ready() {
    return this.ready_;
  }

  set ready(value: boolean) {
    if (value !== this.ready_) {
      this.ready_ = value;
      if (value) {
        this.value = this.value;
      }
      this.changed.dispatch();
    }
  }

  restoreState(obj: any) {
    if (typeof obj !== 'string') {
      obj = undefined;
    }
    this.value = obj;
  }
}

export class StackView<TabId, TabType extends Tab = Tab> extends RefCounted {
  element = document.createElement('div');
  tabs = new Map<TabId, Owned<TabType>>();
  tabVisibilityChanged = new Signal<(id: TabId, visible: boolean) => void>();

  private displayedTab: TabId|undefined;

  get visible() {
    return this.visibility.visible;
  }

  private debouncedUpdateSelectedTab =
      this.registerCancellable(animationFrameDebounce(() => this.updateSelectedTab()));

  flush() {
    this.debouncedUpdateSelectedTab.flush();
  }

  constructor(
      public getter: (id: TabId) => Owned<TabType>,
      public selected: WatchableValueInterface<TabId|undefined>,
      public visibility = new WatchableVisibilityPriority(WatchableVisibilityPriority.VISIBLE),
      public invalidateByDefault = false) {
    super();

    const {element} = this;
    element.className = 'neuroglancer-stack-view';
    this.registerDisposer(visibility.changed.add(this.debouncedUpdateSelectedTab));
    this.registerDisposer(selected.changed.add(this.debouncedUpdateSelectedTab));
    this.updateSelectedTab();
  }

  invalidate(id: TabId) {
    const {tabs} = this;
    const tab = tabs.get(id);
    if (tab === undefined) {
      return;
    }
    tab.dispose();
    tabs.delete(id);
    if (id === this.displayedTab) {
      this.displayedTab = undefined;
      this.debouncedUpdateSelectedTab();
    }
  }

  private hideTab(id: TabId) {
    const tab = this.tabs.get(id);
    if (tab !== undefined) {
      tab.visibility.value = WatchableVisibilityPriority.IGNORED;
      tab.element.style.display = 'none';
    }
    this.tabVisibilityChanged.dispatch(id, false);
  }

  private showTab(id: TabId) {
    const {tabs} = this;
    let tab = tabs.get(id);
    if (tab === undefined) {
  private updateSelectedTab() {
    const {displayedTab} = this;
    const newTab = this.visible ? this.selected.value : undefined;
    if (newTab === displayedTab) {
      return;
    }
    if (displayedTab !== undefined) {
  }

  private updateSelectedTab() {
    const {displayedTab} = this;
    const newTab = this.visible ? this.selected.value : undefined;
    if (newTab === displayedTab && (newTab === undefined || this.tabs.has(newTab))) {
      return;
    }
    if (displayedTab !== undefined) {
    this.showTab(newTab);
  }

  invalidateAll() {
    const {tabs} = this;
    for (const tab of tabs.values()) {
      tab.dispose();
    }
    tabs.clear();
    this.debouncedUpdateSelectedTab();
  }

    const {tabs} = this;
    for (const [id, tab] of tabs) {
      if (predicate !== undefined && predicate(id)) continue;
      tabs.delete(id);
      tab.dispose();
    }
    this.debouncedUpdateSelectedTab();
  }

  disposed() {
    this.invalidateAll();
    removeFromParent(this.element);
    super.disposed();
  }
}

  }
}

export class TabView extends RefCounted {
  element = document.createElement('div');
  tabBar = document.createElement('div');

  private stack: StackView<string>;
  private tabLabels = new Map<string, HTMLElement>();
  private tabsGeneration = -1;
  tabBar = document.createElement('div');

  tabs: WatchableValueChangeInterface<{id: string, label: string, hidden: boolean}[]>;
  selectedTab: WatchableValueInterface<string|undefined>;
  private handleTabElement: ((id: string, element: HTMLElement) => void)|undefined;

      this.registerCancellable(animationFrameDebounce(() => this.updateTabs()));

  constructor(
      public state: TabSpecification,
      public visibility = new WatchableVisibilityPriority(WatchableVisibilityPriority.VISIBLE)) {
    super();

    const {element, tabBar} = this;
    element.className = 'neuroglancer-tab-view';
    tabBar.className = 'neuroglancer-tab-view-bar';
    element.appendChild(tabBar);
    this.registerDisposer(visibility.changed.add(this.debouncedUpdateView));
    const stack = this.stack = this.registerDisposer(new StackView<string>(
        id => this.state.options.get(id)!.getter(), this.state, this.visibility));
    element.appendChild(stack.element);
    this.registerDisposer(this.state.optionsChanged.add(this.debouncedUpdateView));
    this.registerDisposer(this.state.changed.add(() => this.updateTabLabelStyles()));
    this.updateTabs();
  }

  private updateTabLabelStyles() {
    const selectedId = this.state.value;
    for (const [id, element] of this.tabLabels) {
      updateTabLabelVisibilityStyle(element, id === selectedId);
    }
  }

  private updateTabs() {
    if (this.tabsGeneration !== this.state.optionsChanged.count) {
      this.destroyTabs();
      if (this.visible) {
        this.makeTabs();
        this.debouncedUpdateView();
      } else {
        this.updateTabLabelStyles();
      }
      prevSelectedId = this.selectedTab.value;
    }));
      return;
    }
    this.tabLabels.clear();
    removeChildren(this.tabBar);
    this.tabsGeneration = -1;
    this.stack.invalidateAll();
  }

  private makeTabs() {
    const {tabBar, tabLabels} = this;
    const optionsArray = Array.from(this.state.options);
    optionsArray.sort(([, {order: aOrder = 0}], [, {order: bOrder = 0}]) => {
      return aOrder - bOrder;
    });
    const selectedId = this.state.value;
    for (const [id, {label}] of optionsArray) {
      const labelElement = document.createElement('div');
      labelElement.classList.add('neuroglancer-tab-label');
      labelElement.textContent = label;
      updateTabLabelVisibilityStyle(labelElement, id === selectedId);
      labelElement.addEventListener('click', () => {
        this.state.value = id;
      });
      tabLabels.set(id, labelElement);
      tabBar.appendChild(labelElement);
    }
    this.tabsGeneration = this.state.optionsChanged.count;
  }

  disposed() {

  private makeTabs() {
    const {tabBar, tabLabels, handleTabElement} = this;
    for (const {id, label, hidden} of this.tabs.value) {
      if (hidden && id !== this.selectedTab.value) continue;
      const labelElement = document.createElement('div');
      labelElement.classList.add('neuroglancer-tab-label');
      labelElement.textContent = label;
      labelElement.addEventListener('click', () => {
        this.selectedTab.value = id;
      });
      if (handleTabElement !== undefined) {
        handleTabElement(id, labelElement);
      }
      tabLabels.set(id, labelElement);
      tabBar.appendChild(labelElement);
    }
    this.updateTabLabelStyles();
    this.tabsGeneration = this.tabs.changed.count;
  }

  disposed() {
    removeChildren(this.tabBar);
    this.tabLabels.clear();
    removeFromParent(this.element);
    super.disposed();
  }
}

<MSG> feat(ui): add support for configurable side panels

Previously, only two side panels were supported, the layer side panel
and the selection details side panel.  Only a static configuration was
supported, a single column on the right, with the layer side panel on
top and the selection details below, and the vertical height divided
equally.

This commit adds support for an arbitrary number of side panels, to
the left/right/top/bottom of the main data panels.  Panels can be
moved via drag and drop, and the size can also be adjusted.  The side
panels for multiple layers can now be shown at once, and individual
tabs of side panels can also be dragged to separate panels.

<DFF> @@ -20,7 +20,7 @@
 
 import 'neuroglancer/widget/tab_view.css';
 
-import {WatchableValueInterface} from 'neuroglancer/trackable_value';
+import {WatchableValueChangeInterface, WatchableValueInterface} from 'neuroglancer/trackable_value';
 import {animationFrameDebounce} from 'neuroglancer/util/animation_frame_debounce';
 import {Owned, RefCounted} from 'neuroglancer/util/disposable';
 import {removeChildren, removeFromParent} from 'neuroglancer/util/dom';
@@ -216,7 +216,7 @@ export class StackView<TabId, TabType extends Tab = Tab> extends RefCounted {
   private updateSelectedTab() {
     const {displayedTab} = this;
     const newTab = this.visible ? this.selected.value : undefined;
-    if (newTab === displayedTab) {
+    if (newTab === displayedTab && (newTab === undefined || this.tabs.has(newTab))) {
       return;
     }
     if (displayedTab !== undefined) {
@@ -232,12 +232,13 @@ export class StackView<TabId, TabType extends Tab = Tab> extends RefCounted {
     this.showTab(newTab);
   }
 
-  invalidateAll() {
+  invalidateAll(predicate: ((id: TabId) => boolean) | undefined = undefined) {
     const {tabs} = this;
-    for (const tab of tabs.values()) {
+    for (const [id, tab] of tabs) {
+      if (predicate !== undefined && predicate(id)) continue;
+      tabs.delete(id);
       tab.dispose();
     }
-    tabs.clear();
     this.debouncedUpdateSelectedTab();
   }
 
@@ -260,10 +261,21 @@ function updateTabLabelVisibilityStyle(labelElement: HTMLElement, visible: boole
   }
 }
 
+export interface TabViewOptions {
+  makeTab: (id: string) => Tab;
+  selectedTab: WatchableValueInterface<string|undefined>;
+  tabs: WatchableValueChangeInterface<{id: string, label: string}[]>;
+  handleTabElement?: (id: string, element: HTMLElement) => void;
+}
+
 export class TabView extends RefCounted {
   element = document.createElement('div');
   tabBar = document.createElement('div');
 
+  tabs: WatchableValueChangeInterface<{id: string, label: string}[]>;
+  selectedTab: WatchableValueInterface<string|undefined>;
+  private handleTabElement: ((id: string, element: HTMLElement) => void)|undefined;
+
   private stack: StackView<string>;
   private tabLabels = new Map<string, HTMLElement>();
   private tabsGeneration = -1;
@@ -276,32 +288,34 @@ export class TabView extends RefCounted {
       this.registerCancellable(animationFrameDebounce(() => this.updateTabs()));
 
   constructor(
-      public state: TabSpecification,
+      options: TabViewOptions,
       public visibility = new WatchableVisibilityPriority(WatchableVisibilityPriority.VISIBLE)) {
     super();
-
+    this.tabs = options.tabs;
+    this.selectedTab = options.selectedTab;
+    this.handleTabElement = options.handleTabElement;
     const {element, tabBar} = this;
     element.className = 'neuroglancer-tab-view';
     tabBar.className = 'neuroglancer-tab-view-bar';
     element.appendChild(tabBar);
     this.registerDisposer(visibility.changed.add(this.debouncedUpdateView));
     const stack = this.stack = this.registerDisposer(new StackView<string>(
-        id => this.state.options.get(id)!.getter(), this.state, this.visibility));
+        options.makeTab, options.selectedTab, this.visibility));
     element.appendChild(stack.element);
-    this.registerDisposer(this.state.optionsChanged.add(this.debouncedUpdateView));
-    this.registerDisposer(this.state.changed.add(() => this.updateTabLabelStyles()));
+    this.registerDisposer(options.tabs.changed.add(this.debouncedUpdateView));
+    this.registerDisposer(options.selectedTab.changed.add(() => this.updateTabLabelStyles()));
     this.updateTabs();
   }
 
   private updateTabLabelStyles() {
-    const selectedId = this.state.value;
+    const selectedId = this.selectedTab.value;
     for (const [id, element] of this.tabLabels) {
       updateTabLabelVisibilityStyle(element, id === selectedId);
     }
   }
 
   private updateTabs() {
-    if (this.tabsGeneration !== this.state.optionsChanged.count) {
+    if (this.tabsGeneration !== this.tabs.changed.count) {
       this.destroyTabs();
       if (this.visible) {
         this.makeTabs();
@@ -314,30 +328,33 @@ export class TabView extends RefCounted {
       return;
     }
     this.tabLabels.clear();
+    if (!this.visible) {
+      this.stack.invalidateAll();
+    } else {
+      const tabs = this.tabs.value;
+      this.stack.invalidateAll(existingId => tabs.find(({id}) => id === existingId) !== undefined);
+    }
     removeChildren(this.tabBar);
     this.tabsGeneration = -1;
-    this.stack.invalidateAll();
   }
 
   private makeTabs() {
-    const {tabBar, tabLabels} = this;
-    const optionsArray = Array.from(this.state.options);
-    optionsArray.sort(([, {order: aOrder = 0}], [, {order: bOrder = 0}]) => {
-      return aOrder - bOrder;
-    });
-    const selectedId = this.state.value;
-    for (const [id, {label}] of optionsArray) {
+    const {tabBar, tabLabels, handleTabElement} = this;
+    for (const {id, label} of this.tabs.value) {
       const labelElement = document.createElement('div');
       labelElement.classList.add('neuroglancer-tab-label');
       labelElement.textContent = label;
-      updateTabLabelVisibilityStyle(labelElement, id === selectedId);
       labelElement.addEventListener('click', () => {
-        this.state.value = id;
+        this.selectedTab.value = id;
       });
+      if (handleTabElement !== undefined) {
+        handleTabElement(id, labelElement);
+      }
       tabLabels.set(id, labelElement);
       tabBar.appendChild(labelElement);
     }
-    this.tabsGeneration = this.state.optionsChanged.count;
+    this.updateTabLabelStyles();
+    this.tabsGeneration = this.tabs.changed.count;
   }
 
   disposed() {

feat(ui): add support for configurable side panels