diff --git a/lib/components/Redoc/redoc.js b/lib/components/Redoc/redoc.js index 6989a871..8c625620 100644 --- a/lib/components/Redoc/redoc.js +++ b/lib/components/Redoc/redoc.js @@ -2,8 +2,7 @@ import { provide, enableProdMode, ElementRef} from '@angular/core'; import { bootstrap } from '@angular/platform-browser-dynamic'; -import {BrowserDomAdapter} from '@angular/platform-browser/src/browser/browser_adapter'; - +import { BrowserDomAdapter } from '@angular/platform-browser/src/browser/browser_adapter'; import { RedocComponent, BaseComponent } from '../base'; import { ApiInfo, diff --git a/lib/components/SideMenu/side-menu.js b/lib/components/SideMenu/side-menu.js index c79e4b83..098c2cf4 100644 --- a/lib/components/SideMenu/side-menu.js +++ b/lib/components/SideMenu/side-menu.js @@ -5,231 +5,61 @@ import { BrowserDomAdapter } from '@angular/platform-browser/src/browser/browser import { global } from '@angular/core/src/facade/lang'; import { RedocComponent, BaseComponent, SchemaManager } from '../base'; -import { RedocEventsService } from '../../services/index'; -import { OptionsService } from '../../services/index'; - -const CHANGE = { - NEXT : 1, - BACK : -1, - INITIAL : 0 -}; - -const INVIEW_POSITION = { - ABOVE : 1, - BELLOW: -1, - INVIEW: 0 -}; +import { ScrollService, Hash, MenuService, OptionsService } from '../../services/index'; @RedocComponent({ selector: 'side-menu', templateUrl: './lib/components/SideMenu/side-menu.html', + providers: [ScrollService, MenuService, Hash], styleUrls: ['./lib/components/SideMenu/side-menu.css'] }) @Reflect.metadata('parameters', [[SchemaManager], [ElementRef], - [BrowserDomAdapter], [OptionsService], [RedocEventsService]]) + [BrowserDomAdapter], [ScrollService], [MenuService], [Hash], [OptionsService]]) export class SideMenu extends BaseComponent { - constructor(schemaMgr, elementRef, dom, optionsMgr, events) { + constructor(schemaMgr, elementRef, dom, scrollService, menuService, hash, optionsService) { super(schemaMgr); this.$element = elementRef.nativeElement; this.dom = dom; - this.options = optionsMgr.options; - this.$scrollParent = this.options.$scrollParent; - - this.activeCatIdx = 0; - this.activeMethodIdx = -1; - this.prevOffsetY = null; - - this.events = events; + this.scrollService = scrollService; + this.menuService = menuService; + this.hash = hash; this.activeCatCaption = ''; this.activeItemCaption = ''; + + this.options = optionsService.options; + + this.menuService.changed.subscribe(this.changed.bind(this)); } - init() { - this.events.bootstrapped.subscribe(() => this.hashScroll()); - this.bindEvents(); - this.$mobileNav = this.dom.querySelector(this.$element, '.mobile-nav'); - this.$resourcesNav = this.dom.querySelector(this.$element, '#resources-nav'); - this.changeActive(CHANGE.INITIAL); - } - - scrollY() { - return (this.$scrollParent.pageYOffset != null) ? this.$scrollParent.pageYOffset : this.$scrollParent.scrollTop; - } - - hashScroll(evt) { - let hash = this.dom.getLocation().hash; - if (!hash) return; - - let $el; - hash = hash.substr(1); - let namespace = hash.split('/')[0]; - let ptr = decodeURIComponent(hash.substr(namespace.length + 1)); - if (namespace === 'operation') { - $el = this.getMethodElByOperId(ptr); - } else if (namespace === 'tag') { - let tag = ptr.split('/')[0]; - ptr = ptr.substr(tag.length); - $el = this.getMethodElByPtr(ptr, tag); - } - - if ($el) this.scrollTo($el); - if (evt) evt.preventDefault(); - } - - bindEvents() { - this.prevOffsetY = this.scrollY(); - - //decorate option.scrollYOffset to account mobile nav - this.scrollYOffset = () => { - let mobileNavOffset = this.$mobileNav.clientHeight; - return this.options.scrollYOffset() + mobileNavOffset; - }; - this._cancel = {}; - this._cancel.scroll = this.dom.onAndCancel(this.$scrollParent, 'scroll', () => { this.scrollHandler(); }); - this._cancel.hash = this.dom.onAndCancel(global, 'hashchange', evt => this.hashScroll(evt)); - } - - destroy() { - this._cancel.scroll(); - this._cancel.hash(); + changed(cat, item) { + this.activeCatCaption = cat.name || ''; + this.activeItemCaption = item && item.summary || ''; } activateAndScroll(idx, methodIdx) { if (this.mobileMode()) { this.toggleMobileNav(); } - this.activate(idx, methodIdx); - this.scrollToActive(); + this.menuService.activate(idx, methodIdx); + this.menuService.scrollToActive(); } - scrollTo($el) { - // TODO: rewrite this to use offsetTop as more reliable solution - let subjRect = $el.getBoundingClientRect(); - let offset = this.scrollY() + subjRect.top - this.scrollYOffset() + 1; - if (this.$scrollParent.scrollTo) { - this.$scrollParent.scrollTo(0, offset); - } else { - this.$scrollParent.scrollTop = offset; - } - } + init() { + this.$mobileNav = this.dom.querySelector(this.$element, '.mobile-nav'); + this.$resourcesNav = this.dom.querySelector(this.$element, '#resources-nav'); - scrollToActive() { - this.scrollTo(this.getCurrentMethodEl()); - } - - activate(catIdx, methodIdx) { - let menu = this.data.menu; - - this.activeCatCaption = ''; - this.activeItemCaption = ''; - - menu[this.activeCatIdx].active = false; - if (menu[this.activeCatIdx].methods.length) { - if (this.activeMethodIdx >= 0) { - menu[this.activeCatIdx].methods[this.activeMethodIdx].active = false; - } - } - - this.activeCatIdx = catIdx; - this.activeMethodIdx = methodIdx; - menu[catIdx].active = true; - this.activeCatCaption = menu[catIdx].name; - this.activeMethodPtr = null; - if (menu[catIdx].methods.length && (methodIdx > -1)) { - let currentItem = menu[catIdx].methods[methodIdx]; - currentItem.active = true; - this.activeMethodPtr = currentItem.pointer; - this.activeItemCaption = currentItem.summary; - } - } - - _calcActiveIndexes(offset) { - let menu = this.data.menu; - let catCount = menu.length; - let catLength = menu[this.activeCatIdx].methods.length; - - let resMethodIdx = this.activeMethodIdx + offset; - let resCatIdx = this.activeCatIdx; - - if (resMethodIdx > catLength - 1) { - resCatIdx++; - resMethodIdx = -1; - } - if (resMethodIdx < -1) { - let prevCatIdx = --resCatIdx; - catLength = menu[Math.max(prevCatIdx, 0)].methods.length; - resMethodIdx = catLength - 1; - } - if (resCatIdx > catCount - 1) { - resCatIdx = catCount - 1; - resMethodIdx = catLength - 1; - } - if (resCatIdx < 0) { - resCatIdx = 0; - resMethodIdx = 0; - } - - return [resCatIdx, resMethodIdx]; - } - - changeActive(offset = 1) { - let [catIdx, methodIdx] = this._calcActiveIndexes(offset); - this.activate(catIdx, methodIdx); - return (methodIdx === 0 && catIdx === 0); - } - - getMethodElByPtr(ptr, tag) { - let selector = ptr ? `[pointer="${ptr}"][tag="${tag}"]` : `[tag="${tag}"]`; - return document.querySelector(selector); - } - - getMethodElByOperId(operationId) { - let selector =`[operation-id="${operationId}"]`; - return document.querySelector(selector); - } - - getCurrentMethodEl() { - return this.getMethodElByPtr(this.activeMethodPtr, this.data.menu[this.activeCatIdx].name); - } - - /* returns 1 if element if above the view, 0 if in view and -1 below the view */ - getElementInViewPos($el) { - if (Math.floor($el.getBoundingClientRect().top) > this.scrollYOffset()) { - return INVIEW_POSITION.ABOVE; - } - - if ($el.getBoundingClientRect().bottom <= this.scrollYOffset()) { - return INVIEW_POSITION.BELLOW; - } - return INVIEW_POSITION.INVIEW; - } - - scrollHandler() { - let isScrolledDown = (this.scrollY() - this.prevOffsetY > 0); - this.prevOffsetY = this.scrollY(); - let stable = false; - while(!stable) { - let $activeMethodHost = this.getCurrentMethodEl(); - if (!$activeMethodHost) return; - var elementInViewPos = this.getElementInViewPos($activeMethodHost); - if(isScrolledDown && elementInViewPos === INVIEW_POSITION.BELLOW) { - stable = this.changeActive(CHANGE.NEXT); - continue; - } - if(!isScrolledDown && elementInViewPos === INVIEW_POSITION.ABOVE ) { - stable = this.changeActive(CHANGE.BACK); - continue; - } - stable = true; - } + //decorate option.scrollYOffset to account mobile nav + var origOffset = this.options.scrollYOffset; + this.options.scrollYOffset = () => { + let mobileNavOffset = this.$mobileNav.clientHeight; + return origOffset() + mobileNavOffset; + }; } prepareModel() { this.data = {}; - this.data.menu = Array.from(this.schemaMgr.buildMenuTree().entries()).map( - el => ({name: el[0], description: el[1].description, methods: el[1].methods}) - ); + this.data.menu = this.menuService.categories; } mobileMode() { @@ -249,4 +79,9 @@ export class SideMenu extends BaseComponent { dom.setStyle(this.$resourcesNav, 'height', height + 'px'); } } + + destroy() { + this.scrollService.unbind(); + this.hash.unbind(); + } } diff --git a/lib/services/events.service.js b/lib/services/events.service.js index 4886b703..57cf41f9 100644 --- a/lib/services/events.service.js +++ b/lib/services/events.service.js @@ -1,6 +1,6 @@ 'use strict'; -import {EventEmitter} from '@angular/core'; +import { EventEmitter } from '@angular/core'; export class RedocEventsService { constructor() { diff --git a/lib/services/hash.service.js b/lib/services/hash.service.js new file mode 100644 index 00000000..60529be8 --- /dev/null +++ b/lib/services/hash.service.js @@ -0,0 +1,33 @@ +'use strict'; +import { Injectable, EventEmitter } from '@angular/core'; +import { BrowserDomAdapter } from '@angular/platform-browser/src/browser/browser_adapter'; +import { global } from '@angular/core/src/facade/lang'; + +import { RedocEventsService } from './events.service.js'; + +@Reflect.metadata('parameters', [[BrowserDomAdapter], [RedocEventsService]]) +@Injectable() +export class Hash { + constructor(dom, events) { + this.changed = new EventEmitter(); + this.dom = dom; + this.bind(); + + events.bootstrapped.subscribe(() => this.changed.next(this.hash)); + } + + get hash() { + return this.dom.getLocation().hash; + } + + bind() { + this._cancel = this.dom.onAndCancel(global, 'hashchange', (evt) => { + this.changed.next(this.hash); + evt.preventDefault(); + }); + } + + unbind() { + this._cancel(); + } +} diff --git a/lib/services/index.js b/lib/services/index.js index c82c081e..c9962f48 100644 --- a/lib/services/index.js +++ b/lib/services/index.js @@ -2,3 +2,6 @@ export * from './events.service.js'; export * from './options.service.js'; +export * from './menu.service.js'; +export * from './scroll.service.js'; +export * from './hash.service.js'; diff --git a/lib/services/menu.service.js b/lib/services/menu.service.js new file mode 100644 index 00000000..d3f1cce5 --- /dev/null +++ b/lib/services/menu.service.js @@ -0,0 +1,150 @@ +'use strict'; +import { Injectable, EventEmitter } from '@angular/core'; +import { ScrollService, INVIEW_POSITION } from './scroll.service.js'; +import { Hash } from './hash.service.js'; +import SchemaManager from '../utils/SchemaManager'; + +const CHANGE = { + NEXT : 1, + BACK : -1, + INITIAL : 0 +}; + +@Reflect.metadata('parameters', [[Hash], [ScrollService], [SchemaManager]]) +@Injectable() +export class MenuService { + constructor(hash, scrollService, schemaMgr) { + this.hash = hash; + this.scrollService = scrollService; + + this.activeCatIdx = 0; + this.activeMethodIdx = -1; + this.changed = new EventEmitter(); + + this.categories = Array.from(schemaMgr.buildMenuTree().entries()).map( + el => ({name: el[0], description: el[1].description, methods: el[1].methods}) + ); + + scrollService.scroll.subscribe((evt) => { + this.scrollUpdate(evt.isScrolledDown); + }); + + this.changeActive(CHANGE.INITIAL); + + this.hash.changed.subscribe(this.hashScroll.bind(this)); + } + + scrollUpdate(isScrolledDown) { + let stable = false; + while(!stable) { + let $activeMethodHost = this.getCurrentMethodEl(); + if (!$activeMethodHost) return; + var elementInViewPos = this.scrollService.getElementPos($activeMethodHost); + if(isScrolledDown && elementInViewPos === INVIEW_POSITION.BELLOW) { + stable = this.changeActive(CHANGE.NEXT); + continue; + } + if(!isScrolledDown && elementInViewPos === INVIEW_POSITION.ABOVE ) { + stable = this.changeActive(CHANGE.BACK); + continue; + } + stable = true; + } + } + + getCurrentMethodEl() { + return this.getMethodElByPtr(this.activeMethodPtr, + this.categories[this.activeCatIdx].name); + } + + getMethodElByPtr(ptr, tag) { + let selector = ptr ? `[pointer="${ptr}"][tag="${tag}"]` : `[tag="${tag}"]`; + return document.querySelector(selector); + } + + getMethodElByOperId(operationId) { + let selector =`[operation-id="${operationId}"]`; + return document.querySelector(selector); + } + + activate(catIdx, methodIdx) { + let menu = this.categories; + + menu[this.activeCatIdx].active = false; + if (menu[this.activeCatIdx].methods.length) { + if (this.activeMethodIdx >= 0) { + menu[this.activeCatIdx].methods[this.activeMethodIdx].active = false; + } + } + + this.activeCatIdx = catIdx; + this.activeMethodIdx = methodIdx; + menu[catIdx].active = true; + this.activeMethodPtr = null; + let currentItem; + if (menu[catIdx].methods.length && (methodIdx > -1)) { + currentItem = menu[catIdx].methods[methodIdx]; + currentItem.active = true; + this.activeMethodPtr = currentItem.pointer; + } + + this.changed.next(menu[catIdx], currentItem); + } + + _calcActiveIndexes(offset) { + let menu = this.categories; + let catCount = menu.length; + let catLength = menu[this.activeCatIdx].methods.length; + + let resMethodIdx = this.activeMethodIdx + offset; + let resCatIdx = this.activeCatIdx; + + if (resMethodIdx > catLength - 1) { + resCatIdx++; + resMethodIdx = -1; + } + if (resMethodIdx < -1) { + let prevCatIdx = --resCatIdx; + catLength = menu[Math.max(prevCatIdx, 0)].methods.length; + resMethodIdx = catLength - 1; + } + if (resCatIdx > catCount - 1) { + resCatIdx = catCount - 1; + resMethodIdx = catLength - 1; + } + if (resCatIdx < 0) { + resCatIdx = 0; + resMethodIdx = 0; + } + + return [resCatIdx, resMethodIdx]; + } + + changeActive(offset = 1) { + let [catIdx, methodIdx] = this._calcActiveIndexes(offset); + this.activate(catIdx, methodIdx); + return (methodIdx === 0 && catIdx === 0); + } + + scrollToActive() { + this.scrollService.scrollTo(this.getCurrentMethodEl()); + } + + hashScroll(hash) { + if (!hash) return; + + let $el; + hash = hash.substr(1); + let namespace = hash.split('/')[0]; + let ptr = decodeURIComponent(hash.substr(namespace.length + 1)); + if (namespace === 'operation') { + $el = this.getMethodElByOperId(ptr); + } else if (namespace === 'tag') { + let tag = ptr.split('/')[0]; + ptr = ptr.substr(tag.length); + $el = this.getMethodElByPtr(ptr, tag); + } + + if ($el) this.scrollService.scrollTo($el); + } +} diff --git a/lib/services/scroll.service.js b/lib/services/scroll.service.js new file mode 100644 index 00000000..6daa83fb --- /dev/null +++ b/lib/services/scroll.service.js @@ -0,0 +1,66 @@ +'use strict'; +import { Injectable, EventEmitter } from '@angular/core'; +import { BrowserDomAdapter } from '@angular/platform-browser/src/browser/browser_adapter'; +import { OptionsService } from './options.service.js'; + +export const INVIEW_POSITION = { + ABOVE : 1, + BELLOW: -1, + INVIEW: 0 +}; + +@Reflect.metadata('parameters', [ + [BrowserDomAdapter], [OptionsService]]) +@Injectable() +export class ScrollService { + constructor(dom, optionsService) { + //events.bootstrapped.subscribe(() => this.hashScroll()); + this.scrollYOffset = () => optionsService.options.scrollYOffset(); + this.$scrollParent = optionsService.options.$scrollParent; + this.scroll = new EventEmitter(); + this.dom = dom; + this.bind(); + } + + scrollY() { + return (this.$scrollParent.pageYOffset != null) ? this.$scrollParent.pageYOffset : this.$scrollParent.scrollTop; + } + + /* returns 1 if element if above the view, 0 if in view and -1 below the view */ + getElementPos($el) { + if (Math.floor($el.getBoundingClientRect().top) > this.scrollYOffset()) { + return INVIEW_POSITION.ABOVE; + } + + if ($el.getBoundingClientRect().bottom <= this.scrollYOffset()) { + return INVIEW_POSITION.BELLOW; + } + return INVIEW_POSITION.INVIEW; + } + + scrollTo($el) { + // TODO: rewrite this to use offsetTop as more reliable solution + let subjRect = $el.getBoundingClientRect(); + let offset = this.scrollY() + subjRect.top - this.scrollYOffset() + 1; + if (this.$scrollParent.scrollTo) { + this.$scrollParent.scrollTo(0, offset); + } else { + this.$scrollParent.scrollTop = offset; + } + } + + scrollHandler(evt) { + let isScrolledDown = (this.scrollY() - this.prevOffsetY > 0); + this.prevOffsetY = this.scrollY(); + this.scroll.next({isScrolledDown, evt}); + } + + bind() { + this.prevOffsetY = this.scrollY(); + this._cancel = this.dom.onAndCancel(this.$scrollParent, 'scroll', (evt) => { this.scrollHandler(evt); }); + } + + unbind() { + this._cancel(); + } +} diff --git a/protractor.conf.js b/protractor.conf.js index f50b1ec2..e4fdcfd8 100644 --- a/protractor.conf.js +++ b/protractor.conf.js @@ -14,7 +14,7 @@ let config = { return loadJson('https://apis-guru.github.io/api-models/api/v1/list.json').then((list) => { global.apisGuruList = list; return browser.getCapabilities().then(function (cap) { - browser.isIE = cap.caps_.browserName === 'internet explorer'; + browser.isIE = cap.browserName === 'internet explorer'; }); }); }, diff --git a/tests/e2e/redoc.spec.js b/tests/e2e/redoc.spec.js index 7db651ce..bc1fd6be 100644 --- a/tests/e2e/redoc.spec.js +++ b/tests/e2e/redoc.spec.js @@ -1,4 +1,5 @@ 'use strict'; +console.log('here'); const verifyNoBrowserErrors = require('./helpers').verifyNoBrowserErrors; const scrollToEl = require('./helpers').scrollToEl; const fixFFTest = require('./helpers').fixFFTest;