2018-01-30 16:35:18 +03:00
|
|
|
import { action, computed, observable } from 'mobx';
|
2018-01-09 20:00:17 +03:00
|
|
|
import { querySelector } from '../utils/dom';
|
2018-01-22 21:30:53 +03:00
|
|
|
import { GroupModel, OperationModel, SpecStore } from './models';
|
2017-10-12 00:01:37 +03:00
|
|
|
|
|
|
|
import { HistoryService } from './HistoryService';
|
2018-01-22 21:30:53 +03:00
|
|
|
import { ScrollService } from './ScrollService';
|
2017-10-12 00:01:37 +03:00
|
|
|
|
|
|
|
import { flattenByProp } from '../utils';
|
2018-01-22 21:30:53 +03:00
|
|
|
import { GROUP_DEPTH } from './MenuBuilder';
|
2017-10-12 00:01:37 +03:00
|
|
|
|
|
|
|
export type MenuItemGroupType = 'group' | 'tag' | 'section';
|
|
|
|
export type MenuItemType = MenuItemGroupType | 'operation';
|
|
|
|
|
|
|
|
/** Generic interface for MenuItems */
|
|
|
|
export interface IMenuItem {
|
|
|
|
id: string;
|
|
|
|
absoluteIdx?: number;
|
|
|
|
name: string;
|
|
|
|
depth: number;
|
|
|
|
active: boolean;
|
2018-01-22 21:30:53 +03:00
|
|
|
items: IMenuItem[];
|
2017-10-12 00:01:37 +03:00
|
|
|
parent?: IMenuItem;
|
|
|
|
deprecated?: boolean;
|
|
|
|
type: MenuItemType;
|
|
|
|
|
|
|
|
getHash(): string;
|
|
|
|
deactivate(): void;
|
|
|
|
activate(): void;
|
|
|
|
}
|
|
|
|
|
|
|
|
export const SECTION_ATTR = 'data-section-id';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Stores all side-menu related information
|
|
|
|
*/
|
|
|
|
export class MenuStore {
|
|
|
|
/**
|
2018-01-22 21:30:53 +03:00
|
|
|
* active item absolute index (when flattened). -1 means nothing is selected
|
2017-10-12 00:01:37 +03:00
|
|
|
*/
|
2018-02-22 12:26:53 +03:00
|
|
|
@observable activeItemIdx: number = -1;
|
2017-10-12 00:01:37 +03:00
|
|
|
|
2018-01-30 16:36:21 +03:00
|
|
|
/**
|
|
|
|
* whether sidebar with menu is opened or not
|
|
|
|
*/
|
|
|
|
@observable sideBarOpened: boolean = false;
|
|
|
|
|
2017-10-12 00:01:37 +03:00
|
|
|
/**
|
2018-01-22 21:30:53 +03:00
|
|
|
* cached flattened menu items to support absolute indexing
|
2017-10-12 00:01:37 +03:00
|
|
|
*/
|
2018-01-22 21:30:53 +03:00
|
|
|
private _unsubscribe: () => void;
|
|
|
|
private _hashUnsubscribe: () => void;
|
|
|
|
private _items?: Array<GroupModel | OperationModel>;
|
2017-10-12 00:01:37 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param spec [SpecStore](#SpecStore) which contains page content structure
|
|
|
|
* @param _scrollService scroll service instance used by this menu
|
|
|
|
*/
|
|
|
|
constructor(private spec: SpecStore, private _scrollService: ScrollService) {
|
|
|
|
this._unsubscribe = _scrollService.subscribe(this.updateOnScroll);
|
|
|
|
this._hashUnsubscribe = HistoryService.subscribe(this.updateOnHash);
|
|
|
|
}
|
|
|
|
|
2018-01-30 16:36:21 +03:00
|
|
|
@action
|
|
|
|
toggleSidebar() {
|
|
|
|
this.sideBarOpened = this.sideBarOpened ? false : true;
|
|
|
|
}
|
|
|
|
|
|
|
|
@action
|
|
|
|
closeSidebar() {
|
|
|
|
this.sideBarOpened = false;
|
|
|
|
}
|
|
|
|
|
2017-10-12 00:01:37 +03:00
|
|
|
/**
|
|
|
|
* top level menu items (not flattened)
|
|
|
|
*/
|
|
|
|
@computed
|
|
|
|
get items(): IMenuItem[] {
|
2018-01-09 20:00:17 +03:00
|
|
|
if (!this._items) {
|
|
|
|
this._items = this.spec.operationGroups;
|
|
|
|
}
|
|
|
|
return this._items;
|
2017-10-12 00:01:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* update active items on scroll
|
|
|
|
* @param isScrolledDown whether last scroll was downside
|
|
|
|
*/
|
|
|
|
@action.bound
|
|
|
|
updateOnScroll(isScrolledDown: boolean): void {
|
|
|
|
const step = isScrolledDown ? 1 : -1;
|
|
|
|
let itemIdx = this.activeItemIdx;
|
|
|
|
while (true) {
|
|
|
|
if (itemIdx === -1 && !isScrolledDown) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (itemIdx >= this.flatItems.length - 1 && isScrolledDown) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isScrolledDown) {
|
|
|
|
const el = this.getElementAt(itemIdx + 1);
|
|
|
|
if (this._scrollService.isElementBellow(el)) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const el = this.getElementAt(itemIdx);
|
|
|
|
if (this._scrollService.isElementAbove(el)) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
itemIdx += step;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.activate(this.flatItems[itemIdx], true, true);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* update active items on hash change
|
|
|
|
* @param hash current hash
|
|
|
|
*/
|
|
|
|
@action.bound
|
2017-11-14 18:46:50 +03:00
|
|
|
updateOnHash(hash: string = HistoryService.hash): boolean {
|
2018-01-22 21:30:53 +03:00
|
|
|
if (!hash) {
|
|
|
|
return false;
|
|
|
|
}
|
2017-10-12 00:01:37 +03:00
|
|
|
let item: IMenuItem | undefined;
|
|
|
|
hash = hash.substr(1);
|
2018-01-22 21:30:53 +03:00
|
|
|
const namespace = hash.split('/')[0];
|
2017-10-12 00:01:37 +03:00
|
|
|
let ptr = decodeURIComponent(hash.substr(namespace.length + 1));
|
|
|
|
if (namespace === 'section' || namespace === 'tag') {
|
2018-01-22 21:30:53 +03:00
|
|
|
const sectionId = ptr.split('/')[0];
|
2017-10-12 00:01:37 +03:00
|
|
|
ptr = ptr.substr(sectionId.length);
|
|
|
|
|
|
|
|
let searchId;
|
|
|
|
if (namespace === 'section') {
|
|
|
|
searchId = hash;
|
|
|
|
} else {
|
|
|
|
searchId = ptr || namespace + '/' + sectionId;
|
|
|
|
}
|
|
|
|
|
2018-01-22 21:30:53 +03:00
|
|
|
item = this.flatItems.find(i => i.id === searchId);
|
2017-10-12 00:01:37 +03:00
|
|
|
if (item === undefined) {
|
|
|
|
this._scrollService.scrollIntoViewBySelector(`[${SECTION_ATTR}="${searchId}"]`);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
} else if (namespace === 'operation') {
|
2018-01-22 21:30:53 +03:00
|
|
|
item = this.flatItems.find(i => {
|
|
|
|
return (i as OperationModel).operationId === ptr;
|
2017-10-12 00:01:37 +03:00
|
|
|
});
|
|
|
|
}
|
2018-01-10 20:20:35 +03:00
|
|
|
if (item) {
|
|
|
|
this.activateAndScroll(item, false);
|
|
|
|
}
|
2017-10-12 00:01:37 +03:00
|
|
|
return item !== undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* get section/operation DOM Node related to the item or null if it doesn't exist
|
|
|
|
* @param idx item absolute index
|
|
|
|
*/
|
|
|
|
getElementAt(idx: number): Element | null {
|
|
|
|
const item = this.flatItems[idx];
|
2018-01-09 20:00:17 +03:00
|
|
|
return (item && querySelector(`[${SECTION_ATTR}="${item.id}"]`)) || null;
|
2017-10-12 00:01:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* current active item
|
|
|
|
*/
|
|
|
|
get activeItem(): IMenuItem {
|
|
|
|
return this.flatItems[this.activeItemIdx] || undefined;
|
|
|
|
}
|
|
|
|
|
2018-02-08 19:41:02 +03:00
|
|
|
getItemById = (id: string) => {
|
|
|
|
return this.flatItems.find(item => item.id === id);
|
|
|
|
};
|
|
|
|
|
2017-10-12 00:01:37 +03:00
|
|
|
/**
|
|
|
|
* flattened items as they appear in the tree depth-first (top to bottom in the view)
|
|
|
|
*/
|
2018-01-09 20:00:17 +03:00
|
|
|
@computed
|
2017-10-12 00:01:37 +03:00
|
|
|
get flatItems(): IMenuItem[] {
|
2018-01-09 20:00:17 +03:00
|
|
|
const flatItems = flattenByProp(this._items || [], 'items');
|
|
|
|
flatItems.forEach((item, idx) => (item.absoluteIdx = idx));
|
|
|
|
return flatItems;
|
2017-10-12 00:01:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* activate menu item
|
|
|
|
* @param item item to activate
|
|
|
|
* @param updateHash [true] whether to update location hash
|
|
|
|
* @param rewriteHistory [false] whether to rewrite browser history (do not create new enrty)
|
|
|
|
*/
|
|
|
|
@action
|
|
|
|
activate(
|
|
|
|
item: IMenuItem | undefined,
|
|
|
|
updateHash: boolean = true,
|
|
|
|
rewriteHistory: boolean = false,
|
|
|
|
) {
|
|
|
|
if ((this.activeItem && this.activeItem.id) === (item && item.id)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.deactivate(this.activeItem);
|
|
|
|
if (!item) {
|
|
|
|
HistoryService.update('', rewriteHistory);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// do not allow activating group items
|
|
|
|
// TODO: control over options
|
|
|
|
if (item.depth <= GROUP_DEPTH) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-01-16 17:33:27 +03:00
|
|
|
this.activeItemIdx = item.absoluteIdx!;
|
2017-10-12 00:01:37 +03:00
|
|
|
if (updateHash) {
|
|
|
|
HistoryService.update(item.getHash(), rewriteHistory);
|
|
|
|
}
|
|
|
|
|
|
|
|
while (item !== undefined) {
|
|
|
|
item.activate();
|
|
|
|
item = item.parent;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* makes item and all the parents not active
|
|
|
|
* @param item item to deactivate
|
|
|
|
*/
|
|
|
|
deactivate(item: IMenuItem | undefined) {
|
|
|
|
while (item !== undefined) {
|
|
|
|
item.deactivate();
|
|
|
|
item = item.parent;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* activate menu item and scroll to it
|
|
|
|
* @see MenuStore.activate
|
|
|
|
*/
|
2018-02-08 19:41:02 +03:00
|
|
|
@action.bound
|
|
|
|
activateAndScroll(item: IMenuItem | undefined, updateHash?: boolean, rewriteHistory?: boolean) {
|
2017-10-12 00:01:37 +03:00
|
|
|
this.activate(item, updateHash, rewriteHistory);
|
|
|
|
this.scrollToActive();
|
2018-01-30 16:36:21 +03:00
|
|
|
if (!item || !item.items.length) {
|
|
|
|
this.closeSidebar();
|
|
|
|
}
|
2017-10-12 00:01:37 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* scrolls to active section
|
|
|
|
*/
|
|
|
|
scrollToActive(): void {
|
|
|
|
this._scrollService.scrollIntoView(this.getElementAt(this.activeItemIdx));
|
|
|
|
}
|
|
|
|
|
|
|
|
dispose() {
|
|
|
|
this._unsubscribe();
|
|
|
|
this._hashUnsubscribe();
|
|
|
|
}
|
|
|
|
}
|