From 0c09447f2d2cb2cd77bf181ab425a331c76b1619 Mon Sep 17 00:00:00 2001 From: gconsidine Date: Thu, 1 Mar 2018 16:29:32 -0500 Subject: [PATCH] Refactor scroll handling into independent service --- .../features/output/index.controller.js | 553 ++++-------------- awx/ui/client/features/output/index.js | 4 +- awx/ui/client/features/output/page.service.js | 13 +- .../client/features/output/render.service.js | 297 ++++++++++ .../client/features/output/scroll.service.js | 172 ++++++ 5 files changed, 578 insertions(+), 461 deletions(-) create mode 100644 awx/ui/client/features/output/scroll.service.js diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index ce8a047e7f..25ebb9b531 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -1,83 +1,41 @@ -import Ansi from 'ansi-to-html'; -import hasAnsi from 'has-ansi'; - -let vm; -let ansi; -let model; -let resource; -let page; -let container; -let $timeout; -let $sce; -let $compile; -let $scope; -let $q; - -const record = {}; - -let parent = null; - -const SCROLL_THRESHOLD = 0.1; -const SCROLL_DELAY = 1000; -const EVENT_START_TASK = 'playbook_on_task_start'; -const EVENT_START_PLAY = 'playbook_on_play_start'; -const EVENT_STATS_PLAY = 'playbook_on_stats'; -const ELEMENT_TBODY = '#atStdoutResultTable'; -const ELEMENT_CONTAINER = '.at-Stdout-container'; const JOB_START = 'playbook_on_start'; const JOB_END = 'playbook_on_stats'; -const EVENT_GROUPS = [ - EVENT_START_TASK, - EVENT_START_PLAY -]; - -const TIME_EVENTS = [ - EVENT_START_TASK, - EVENT_START_PLAY, - EVENT_STATS_PLAY -]; +let vm; +let $compile; +let $scope; +let $q; +let page; +let render; +let scroll; +let resource; function JobsIndexController ( _resource_, _page_, - _$sce_, - _$timeout_, + _scroll_, + _render_, _$scope_, _$compile_, _$q_ ) { vm = this || {}; - $timeout = _$timeout_; - $sce = _$sce_; $compile = _$compile_; $scope = _$scope_; $q = _$q_; resource = _resource_; + page = _page_; - model = resource.model; - - ansi = new Ansi(); - - const events = model.get(`related.${resource.related}.results`); - const parsed = parseEvents(events); - const html = $sce.trustAsHtml(parsed.html); - - page.init(resource); - - page.add({ number: 1, lines: parsed.lines }); + scroll = _scroll_; + render = _render_; // Development helper(s) vm.clear = devClear; // Stdout Navigation vm.scroll = { - isLocked: false, showBackToTop: false, - isActive: false, - position: 0, - time: 0, home: scrollHome, end: scrollEnd, down: scrollPageDown, @@ -90,87 +48,93 @@ function JobsIndexController ( vm.isExpanded = true; // Real-time (active between JOB_START and JOB_END events only) - $scope.$on(resource.ws.namespace, processWebSocketEvents); vm.stream = { - isActive: false, - isRendering: false, - isPaused: false, - buffered: 0, - count: 0, - page: 1 + active: false, + rendering: false, + paused: false }; - window.requestAnimationFrame(() => { - const table = $(ELEMENT_TBODY); - container = $(ELEMENT_CONTAINER); + const stream = false; // TODO: Set in route - table.html($sce.getTrustedHtml(html)); - $compile(table.contents())($scope); - - container.scroll(onScroll); - }); + render.requestAnimationFrame(() => init()); } -function processWebSocketEvents (scope, data) { - let done; +function init (stream) { + page.init(resource); + render.init({ + get: () => resource.model.get(`related.${resource.related}.results`), + compile: html => $compile(html)($scope) + }); + + scroll.init({ + isAtRest: scrollIsAtRest, + previous, + next + }); + + if (stream) { + $scope.$on(resource.ws.namespace, process); + } else { + next(); + } +} + +function process (scope, data) { if (data.event === JOB_START) { - vm.scroll.isActive = true; - vm.stream.isActive = true; - vm.scroll.isLocked = true; + vm.stream.active = true; + scroll.lock(); } else if (data.event === JOB_END) { - vm.stream.isActive = false; + vm.stream.active = false; } const pageAdded = page.addToBuffer(data); - if (pageAdded && !vm.scroll.isLocked) { - vm.stream.isPaused = true; + if (pageAdded && !scroll.isLocked()) { + vm.stream.paused = true; } - if (vm.stream.isPaused && vm.scroll.isLocked) { - vm.stream.isPaused = false; + if (vm.stream.paused && scroll.isLocked()) { + vm.stream.paused = false; } - if (vm.stream.isRendering || vm.stream.isPaused) { + if (vm.stream.rendering || vm.stream.paused) { return; } const events = page.emptyBuffer(); - return render(events); + return renderStream(events); } -function render (events) { - vm.stream.isRendering = true; +function renderStream (events) { + vm.stream.rendering = true; return shift() .then(() => append(events)) .then(() => { - if (vm.scroll.isLocked) { - const height = container[0].scrollHeight; - container[0].scrollTop = height; + if (scroll.isLocked()) { + scroll.setScrollPosition(scroll.getScrollHeight()); } - if (!vm.stream.isActive) { + if (!vm.stream.active) { const buffer = page.emptyBuffer(); if (buffer.length) { - return render(buffer); + return renderStream(buffer); + } else { + vm.stream.rendering = false; + scroll.unlock(); } - - vm.stream.isRendering = false; - vm.scroll.isLocked = false; - vm.scroll.isActive = false; } else { - vm.stream.isRendering = false; + vm.stream.rendering = false; } }); } function devClear () { - page.init(resource); - clear(); + init(true); + render.clear(); } function next () { @@ -182,13 +146,12 @@ function next () { return shift() .then(() => append(events)); - }) + }); } function previous () { - const container = $(ELEMENT_CONTAINER)[0]; - - let previousHeight; + let initialPosition = scroll.getScrollPosition(); + let postPopHeight; return page.previous() .then(events => { @@ -198,296 +161,56 @@ function previous () { return pop() .then(() => { - previousHeight = container.scrollHeight; + postPopHeight = scroll.getScrollHeight(); return prepend(events); }) .then(() => { - const currentHeight = container.scrollHeight; - container.scrollTop = currentHeight - previousHeight; + const currentHeight = scroll.getScrollHeight(); + + scroll.setScrollPosition(currentHeight - postPopHeight + initialPosition); }); }); } function append (events) { - return $q(resolve => { - window.requestAnimationFrame(() => { - const parsed = parseEvents(events); - const rows = $($sce.getTrustedHtml($sce.trustAsHtml(parsed.html))); - const table = $(ELEMENT_TBODY); - - page.updateLineCount('current', parsed.lines); - - table.append(rows); - $compile(rows.contents())($scope); - - return resolve(); + return render.append(events) + .then(count => { + page.updateLineCount('current', count); }); - }); } function prepend (events) { - return $q(resolve => { - window.requestAnimationFrame(() => { - const parsed = parseEvents(events); - const rows = $($sce.getTrustedHtml($sce.trustAsHtml(parsed.html))); - const table = $(ELEMENT_TBODY); - - page.updateLineCount('current', parsed.lines); - - table.prepend(rows); - $compile(rows.contents())($scope); - - $scope.$apply(() => { - return resolve(parsed.lines); - }); + return render.prepend(events) + .then(count => { + page.updateLineCount('current', count); }); - }); } function pop () { - return $q(resolve => { - if (!page.isOverCapacity()) { - return resolve(); - } + if (!page.isOverCapacity()) { + return $q.resolve(); + } - window.requestAnimationFrame(() => { - const lines = page.trim('right'); - const rows = $(ELEMENT_TBODY).children().slice(-lines); + const lines = page.trim('right'); - rows.empty(); - rows.remove(); - - return resolve(); - }); - }); + return render.pop(lines); } function shift () { - return $q(resolve => { - if (!page.isOverCapacity()) { - return resolve(); - } + if (!page.isOverCapacity()) { + return $q.resolve(); + } - window.requestAnimationFrame(() => { - const lines = page.trim('left'); - const rows = $(ELEMENT_TBODY).children().slice(0, lines); + const lines = page.trim('left'); - rows.empty(); - rows.remove(); - - return resolve(); - }); - }); -} - -function clear () { - return $q(resolve => { - window.requestAnimationFrame(() => { - const rows = $(ELEMENT_TBODY).children(); - - rows.empty(); - rows.remove(); - - return resolve(); - }); - }); + return render.shift(lines); } function expand () { vm.toggle(parent, true); } -function parseEvents (events) { - let lines = 0; - let html = ''; - - events.sort(orderByLineNumber); - - events.forEach(event => { - const line = parseLine(event); - - html += line.html; - lines += line.count; - }); - - return { - html, - lines - }; -} - -function orderByLineNumber (a, b) { - if (a.start_line > b.start_line) { - return 1; - } - - if (a.start_line < b.start_line) { - return -1; - } - - return 0; -} - -function parseLine (event) { - if (!event || !event.stdout) { - return { html: '', count: 0 }; - } - - const { stdout } = event; - const lines = stdout.split('\r\n'); - - let count = lines.length; - let ln = event.start_line; - - const current = createRecord(ln, lines, event); - - const html = lines.reduce((html, line, i) => { - ln++; - - const isLastLine = i === lines.length - 1; - let row = createRow(current, ln, line); - - if (current && current.isTruncated && isLastLine) { - row += createRow(current); - count++; - } - - return `${html}${row}`; - }, ''); - - return { html, count }; -} - -function createRecord (ln, lines, event) { - if (!event.uuid) { - return null; - } - - const info = { - id: event.id, - line: ln + 1, - uuid: event.uuid, - level: event.event_level, - start: event.start_line, - end: event.end_line, - isTruncated: (event.end_line - event.start_line) > lines.length, - isHost: typeof event.host === 'number' - }; - - if (event.parent_uuid) { - info.parents = getParentEvents(event.parent_uuid); - } - - if (info.isTruncated) { - info.truncatedAt = event.start_line + lines.length; - } - - if (EVENT_GROUPS.includes(event.event)) { - info.isParent = true; - - if (event.event_level === 1) { - parent = event.uuid; - } - - if (event.parent_uuid) { - if (record[event.parent_uuid]) { - if (record[event.parent_uuid].children && - !record[event.parent_uuid].children.includes(event.uuid)) { - record[event.parent_uuid].children.push(event.uuid); - } else { - record[event.parent_uuid].children = [event.uuid]; - } - } - } - } - - if (TIME_EVENTS.includes(event.event)) { - info.time = getTime(event.created); - info.line++; - } - - record[event.uuid] = info; - - return info; -} - -function getParentEvents (uuid, list) { - list = list || []; - - if (record[uuid]) { - list.push(uuid); - - if (record[uuid].parents) { - list = list.concat(record[uuid].parents); - } - } - - return list; -} - -function createRow (current, ln, content) { - let id = ''; - let timestamp = ''; - let tdToggle = ''; - let tdEvent = ''; - let classList = ''; - - content = content || ''; - - if (hasAnsi(content)) { - content = ansi.toHtml(content); - } - - if (current) { - if (current.isParent && current.line === ln) { - id = current.uuid; - tdToggle = ``; - } - - if (current.isHost) { - tdEvent = `${content}`; - } - - if (current.time && current.line === ln) { - timestamp = `${current.time}`; - } - - if (current.parents) { - classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, ''); - } - } - - if (!tdEvent) { - tdEvent = `${content}`; - } - - if (!tdToggle) { - tdToggle = ''; - } - - if (!ln) { - ln = '...'; - } - - return ` - - ${tdToggle} - ${ln} - ${tdEvent} - ${timestamp} - `; -} - -function getTime (created) { - const date = new Date(created); - const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours(); - const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes(); - const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds(); - - return `${hour}:${minute}:${second}`; -} - function showHostDetails (id) { jobEvent.request('get', id) .then(() => { @@ -527,100 +250,38 @@ function toggle (uuid, menu) { } } -function onScroll () { - if (vm.scroll.isActive) { - return; - } - - if (vm.scroll.register) { - $timeout.cancel(vm.scroll.register); - } - - vm.scroll.register = $timeout(registerScrollEvent, SCROLL_DELAY); -} - -function registerScrollEvent () { - vm.scroll.isActive = true; - - const position = container[0].scrollTop; - const height = container[0].offsetHeight; - const downward = position > vm.scroll.position; - - let promise; - - if (position !== 0 ) { - vm.scroll.showBackToTop = true; - } else { - vm.scroll.showBackToTop = false; - } - - - console.log('downward', downward); - if (downward) { - if (((height - position) / height) < SCROLL_THRESHOLD) { - promise = next; - } - } else { - if ((position / height) < SCROLL_THRESHOLD) { - console.log('previous'); - promise = previous; - } - } - - vm.scroll.position = position; - - if (!promise) { - vm.scroll.isActive = false; - - return $q.resolve(); - } - - return promise() - .then(() => { - console.log('done'); - vm.scroll.isActive = false; - /* - *$timeout(() => { - * vm.scroll.isActive = false; - *}, SCROLL_DELAY); - */ - }); - -} - function scrollHome () { + scroll.pause(); + return page.first() .then(events => { if (!events) { return; } - return clear() - .then(() => prepend(events)) + return render.clear() + .then(() => render.prepend(events)) .then(() => { - vm.scroll.isActive = false; + scroll.setScrollPosition(0); + scroll.resume(); }); }); } function scrollEnd () { - if (vm.scroll.isLocked) { + if (scroll.isLocked()) { page.bookmark(); - - vm.scroll.isLocked = false; - vm.scroll.isActive = false; + scroll.unlock(); return; - } else if (!vm.scroll.isLocked && vm.stream.isActive) { + } else if (!scroll.isLocked() && vm.stream.active) { page.bookmark(); - - vm.scroll.isActive = true; - vm.scroll.isLocked = true; + scroll.lock(); return; } - vm.scroll.isActive = true; + scroll.pause(); return page.last() .then(events => { @@ -628,36 +289,32 @@ function scrollEnd () { return; } - return clear() - .then(() => append(events)) + return render.clear() + .then(() => render.append(events)) .then(() => { - const container = $(ELEMENT_CONTAINER)[0]; - - container.scrollTop = container.scrollHeight; - vm.scroll.isActive = false; + scroll.setScrollPosition(scroll.getScrollHeight()); + scroll.resume(); }); }); } function scrollPageUp () { - const container = $(ELEMENT_CONTAINER)[0]; - const jump = container.scrollTop - container.offsetHeight; - - container.scrollTop = jump; + scroll.pageUp(); } function scrollPageDown () { - const container = $(ELEMENT_CONTAINER)[0]; - const jump = container.scrollTop + container.offsetHeight; + scroll.pageDown(); +} - container.scrollTop = jump; +function scrollIsAtRest (isAtRest) { + vm.scroll.showBackToTop = !isAtRest; } JobsIndexController.$inject = [ 'resource', 'JobPageService', - '$sce', - '$timeout', + 'JobScrollService', + 'JobRenderService', '$scope', '$compile', '$q' diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 6d35022cf4..a0e0b70123 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -1,11 +1,10 @@ -import JobsStrings from '~features/output/jobs.strings'; -import IndexController from '~features/output/index.controller'; import atLibModels from '~models'; import atLibComponents from '~components'; import Strings from '~features/output/jobs.strings'; import Controller from '~features/output/index.controller'; import PageService from '~features/output/page.service'; +import ScrollService from '~features/output/scroll.service'; const Template = require('~features/output/index.view.html'); @@ -178,6 +177,7 @@ angular ]) .service('JobStrings', Strings) .service('JobPageService', PageService) + .service('JobScrollService', ScrollService) .run(JobsRun); export default MODULE_NAME; diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js index b82eba4705..e91b80191e 100644 --- a/awx/ui/client/features/output/page.service.js +++ b/awx/ui/client/features/output/page.service.js @@ -1,16 +1,10 @@ function JobPageService ($q) { - this.page = null; - this.resource = null; - this.result = null; - this.buffer = null; - this.cache = null; - this.init = resource => { this.resource = resource; this.page = { - limit: resource.page.pageLimit, - size: resource.page.size, + limit: this.resource.page.pageLimit, + size: this.resource.page.size, current: 0, index: -1, count: 0, @@ -153,13 +147,10 @@ function JobPageService ($q) { } this.bookmark = () => { - console.log('b,current', this.page.current); if (!this.page.bookmark.active) { this.page.bookmark.first = this.page.first; this.page.bookmark.last = this.page.last; this.page.bookmark.current = this.page.current; - - console.log('b,bookmark', this.page.bookmark.current); this.page.bookmark.active = true; } else { this.page.bookmark.active = false; diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js index e69de29bb2..235de94198 100644 --- a/awx/ui/client/features/output/render.service.js +++ b/awx/ui/client/features/output/render.service.js @@ -0,0 +1,297 @@ +import Ansi from 'ansi-to-html'; +import hasAnsi from 'has-ansi'; + +const ELEMENT_TBODY = '#atStdoutResultTable'; +const EVENT_START_TASK = 'playbook_on_task_start'; +const EVENT_START_PLAY = 'playbook_on_play_start'; +const EVENT_STATS_PLAY = 'playbook_on_stats'; +const JOB_START = 'playbook_on_start'; +const JOB_END = 'playbook_on_stats'; + +const EVENT_GROUPS = [ + EVENT_START_TASK, + EVENT_START_PLAY +]; + +const TIME_EVENTS = [ + EVENT_START_TASK, + EVENT_START_PLAY, + EVENT_STATS_PLAY +]; + +const ansi = new Ansi(); + +function JobRenderService ($q, $sce, $window) { + this.init = ({ compile, apply, get }) => { + this.parent = null; + this.record = {}; + this.el = $(ELEMENT_TBODY); + this.hooks = { get, compile, apply }; + }; + + this.sortByLineNumber = (a, b) => { + if (a.start_line > b.start_line) { + return 1; + } + + if (a.start_line < b.start_line) { + return -1; + } + + return 0; + }; + + this.transformEventGroup = events => { + let lines = 0; + let html = ''; + + events.sort(this.sortByLineNumber); + + events.forEach(event => { + const line = this.transformEvent(event); + + html += line.html; + lines += line.count; + }); + + return { html, lines }; + }; + + this.transformEvent = event => { + if (!event || !event.stdout) { + return { html: '', count: 0 }; + } + + const { stdout } = event; + const lines = stdout.split('\r\n'); + + let count = lines.length; + let ln = event.start_line; + + const current = this.createRecord(ln, lines, event); + + const html = lines.reduce((html, line, i) => { + ln++; + + const isLastLine = i === lines.length - 1; + let row = this.createRow(current, ln, line); + + if (current && current.isTruncated && isLastLine) { + row += this.createRow(current); + count++; + } + + return `${html}${row}`; + }, ''); + + return { html, count }; + }; + + this.createRecord = event => { + if (!event.uuid) { + return null; + } + + const info = { + id: event.id, + line: ln + 1, + uuid: event.uuid, + level: event.event_level, + start: event.start_line, + end: event.end_line, + isTruncated: (event.end_line - event.start_line) > lines.length, + isHost: typeof event.host === 'number' + }; + + if (event.parent_uuid) { + info.parents = getParentEvents(event.parent_uuid); + } + + if (info.isTruncated) { + info.truncatedAt = event.start_line + lines.length; + } + + if (EVENT_GROUPS.includes(event.event)) { + info.isParent = true; + + if (event.event_level === 1) { + this.parent = event.uuid; + } + + if (event.parent_uuid) { + if (this.record[event.parent_uuid]) { + if (this.record[event.parent_uuid].children && + !this.record[event.parent_uuid].children.includes(event.uuid)) { + this.record[event.parent_uuid].children.push(event.uuid); + } else { + this.record[event.parent_uuid].children = [event.uuid]; + } + } + } + } + + if (TIME_EVENTS.includes(event.event)) { + info.time = this.getTimestamp(event.created); + info.line++; + } + + this.record[event.uuid] = info; + + return info; + }; + + this.createRow = (current, ln, content) => { + let id = ''; + let timestamp = ''; + let tdToggle = ''; + let tdEvent = ''; + let classList = ''; + + content = content || ''; + + if (hasAnsi(content)) { + content = ansi.toHtml(content); + } + + if (current) { + if (current.isParent && current.line === ln) { + id = current.uuid; + tdToggle = ``; + } + + if (current.isHost) { + tdEvent = `${content}`; + } + + if (current.time && current.line === ln) { + timestamp = `${current.time}`; + } + + if (current.parents) { + classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, ''); + } + } + + if (!tdEvent) { + tdEvent = `${content}`; + } + + if (!tdToggle) { + tdToggle = ''; + } + + if (!ln) { + ln = '...'; + } + + return ` + + ${tdToggle} + ${ln} + ${tdEvent} + ${timestamp} + `; + } + + this.getTimestamp = (created) => { + const date = new Date(created); + const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours(); + const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes(); + const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds(); + + return `${hour}:${minute}:${second}`; + } + + this.getParentEvents = (uuid, list) => { + list = list || []; + + if (this.record[uuid]) { + list.push(uuid); + + if (this.record[uuid].parents) { + list = list.concat(record[uuid].parents); + } + } + + return list; + }; + + this.getEvents = () => { + return this.hooks.get(); + }; + + this.insert = (events, insert) => { + const result = this.transformEventGroup(events); + const html = this.sanitize(result.html); + + return this.requestAnimationFrame(() => insert(html)) + .then(() => this.compile(html)) + .then(() => result.lines); + }; + + this.remove = elements => { + return this.requestAnimationFrame(() => { + elements.empty(); + elements.remove(); + }); + }; + + this.requestAnimationFrame = fn => { + return $q(resolve => { + $window.requestAnimationFrame(() => { + if (fn) { + fn(); + } + + return resolve(); + }); + }); + }; + + this.compile = html => { + this.hooks.compile(html); + + return this.requestAnimationFrame(); + }; + + this.build = () => { + + }; + + this.clear = () => { + const elements = this.el.children(); + + return this.remove(elements); + }; + + this.shift = lines => { + const elements = this.el.children().slice(0, lines); + + return this.remove(elements); + }; + + this.pop = lines => { + const elements = this.el.children().slice(-lines); + + return this.remove(elements); + }; + + this.prepend = events => { + return this.insert(events, html => this.el.prepend(html)) + }; + + this.append = events => { + return this.insert(events, html => this.el.append(html)) + }; + + // TODO: stdout from the API should not be trusted. + this.sanitize = html => { + html = $sce.trustAsHtml(html); + + return $sce.getTrustedHtml(html); + }; +} + +JobRenderService.$inject = ['$q', '$sce', '$window']; + +export default JobRenderService; diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js new file mode 100644 index 0000000000..489871e354 --- /dev/null +++ b/awx/ui/client/features/output/scroll.service.js @@ -0,0 +1,172 @@ +const ELEMENT_CONTAINER = '.at-Stdout-container'; +const DELAY = 100; +const THRESHOLD = 0.1; + +function JobScrollService ($q, $timeout) { + this.init = (hooks) => { + this.el = $(ELEMENT_CONTAINER); + this.timer = null; + + this.position = { + previous: 0, + current: 0 + }; + + this.hooks = { + isAtRest: hooks.isAtRest, + next: hooks.next, + previous: hooks.previous + }; + + this.state = { + locked: false, + paused: false, + top: true + }; + + this.el.scroll(this.listen); + }; + + this.listen = () => { + if (this.isPaused()) { + return; + } + + if (this.timer) { + $timeout.cancel(this.timer); + } + + this.timer = $timeout(this.register, DELAY); + }; + + this.register = () => { + this.pause(); + + const height = this.getScrollHeight(); + const current = this.getScrollPosition(); + const downward = current > this.position.previous; + + let promise; + + if (downward && this.isBeyondThreshold(downward, current)) { + promise = this.hooks.next; + } else if (!downward && this.isBeyondThreshold(downward, current)) { + promise = this.hooks.previous; + } + + if (!promise) { + this.setScrollPosition(current); + this.isAtRest(); + this.resume(); + + return $q.resolve(); + } + + return promise() + .then(() => { + this.setScrollPosition(this.getScrollPosition()); + this.isAtRest(); + this.resume(); + }); + }; + + this.isBeyondThreshold = (downward, current) => { + const previous = this.position.previous; + const height = this.getScrollHeight(); + + if (downward) { + current += this.getViewableHeight(); + + if (current >= height || ((height - current) / height) < THRESHOLD) { + return true; + } + } else { + if (current <= 0 || (current / height) < THRESHOLD) { + return true; + } + } + + return false; + }; + + this.pageUp = () => { + if (this.isPaused()) { + return; + } + + const top = this.getScrollPosition(); + const height = this.getViewableHeight(); + + this.setScrollPosition(top - height); + }; + + this.pageDown = () => { + if (this.isPaused()) { + return; + } + + const top = this.getScrollPosition(); + const height = this.getViewableHeight(); + + this.setScrollPosition(top + height); + }; + + this.getScrollHeight = () => { + return this.el[0].scrollHeight; + }; + + this.getViewableHeight = () => { + return this.el[0].offsetHeight; + }; + + this.getScrollPosition = () => { + return this.el[0].scrollTop; + }; + + this.setScrollPosition = position => { + this.position.previous = this.position.current; + this.position.current = position; + this.el[0].scrollTop = position; + this.isAtRest(); + }; + + this.isAtRest = () => { + if (this.position.current === 0 && !this.state.top) { + this.state.top = true; + this.hooks.isAtRest(true); + } else if (this.position.current > 0 && this.state.top) { + this.state.top = false; + this.hooks.isAtRest(false); + } + }; + + this.resume = () => { + this.state.paused = false; + }; + + this.pause = () => { + this.state.paused = true; + }; + + this.isPaused = () => { + return this.state.paused; + }; + + this.lock = () => { + this.state.locked = true; + this.state.paused = true; + }; + + this.unlock = () => { + this.state.locked = false; + this.state.paused = false; + }; + + this.isLocked = () => { + return this.state.locked; + }; +} + +JobScrollService.$inject = ['$q', '$timeout']; + +export default JobScrollService;