'use strict'; define('navigator', [ 'forum/pagination', 'components', 'hooks', 'alerts', 'translator', 'storage', ], function (pagination, components, hooks, alerts, translator, storage) { const navigator = {}; let index = 0; let count = 0; let remaining = 0; let navigatorUpdateTimeoutId; let renderPostIntervalId; let touchX; let touchY; let renderPostIndex; let isNavigating = false; let firstMove = true; let bsEnv = ''; navigator.scrollActive = false; let paginationBlockEl = $('.pagination-block'); let paginationTextEl = paginationBlockEl.find('.pagination-text'); let paginationBlockMeterEl = paginationBlockEl.find('meter'); let paginationBlockProgressEl = paginationBlockEl.find('.progress-bar'); let paginationBlockUnreadEl = paginationBlockEl.find('.unread'); let thumbs; $(window).on('action:ajaxify.start', function () { $(window).off('keydown', onKeyDown); }); navigator.init = function (selector, count, toTop, toBottom, callback) { index = 0; navigator.selector = selector; navigator.callback = callback; navigator.toTop = toTop || function () {}; navigator.toBottom = toBottom || function () {}; paginationBlockEl = $('.pagination-block'); paginationTextEl = paginationBlockEl.find('.pagination-text'); paginationBlockMeterEl = paginationBlockEl.find('meter'); paginationBlockProgressEl = paginationBlockEl.find('.progress-bar'); paginationBlockUnreadEl = paginationBlockEl.find('.unread'); thumbs = $('.scroller-thumb'); bsEnv = utils.findBootstrapEnvironment(); $(window).off('scroll', navigator.delayedUpdate).on('scroll', navigator.delayedUpdate); paginationBlockEl.find('.dropdown-menu').off('click').on('click', function (e) { e.stopPropagation(); }); paginationBlockEl.off('shown.bs.dropdown', '.wrapper').on('shown.bs.dropdown', '.wrapper', function () { const el = $(this); setTimeout(async function () { if (['lg', 'xl', 'xxl'].includes(utils.findBootstrapEnvironment())) { el.find('input').trigger('focus'); } const postCountInTopic = await socket.emit('topics.getPostCountInTopic', ajaxify.data.tid); if (postCountInTopic > 0) { paginationBlockEl.find('#myNextPostBtn').removeAttr('disabled'); } }, 100); }); paginationBlockEl.find('.pageup').off('click').on('click', navigator.scrollUp); paginationBlockEl.find('.pagedown').off('click').on('click', navigator.scrollDown); paginationBlockEl.find('.pagetop').off('click').on('click', navigator.toTop); paginationBlockEl.find('.pagebottom').off('click').on('click', navigator.toBottom); paginationBlockEl.find('.pageprev').off('click').on('click', pagination.previousPage); paginationBlockEl.find('.pagenext').off('click').on('click', pagination.nextPage); paginationBlockEl.find('#myNextPostBtn').off('click').on('click', gotoMyNextPost); paginationBlockEl.find('input').on('keydown', function (e) { if (e.which === 13) { const input = $(this); if (!utils.isNumber(input.val())) { input.val(''); return; } const index = parseInt(input.val(), 10); const url = generateUrl(index); input.val(''); paginationBlockEl.find('.dopdown-menu.show').removeClass('show'); ajaxify.go(url); } }); if (ajaxify.data.template.topic) { handleScrollNav(); remaining = ajaxify.data.postcount; updateUnreadIndicator(ajaxify.data.postIndex); } handleKeys(); navigator.setCount(count); navigator.update(); }; let lastNextIndex = 0; async function gotoMyNextPost() { async function getNext(startIndex) { return await socket.emit('topics.getMyNextPostIndex', { tid: ajaxify.data.tid, index: Math.max(1, startIndex), sort: config.topicPostSort, }); } if (ajaxify.data.template.topic) { let nextIndex = await getNext(index); if (lastNextIndex === nextIndex) { // handles last post in pagination nextIndex = await getNext(nextIndex); } if (nextIndex && index !== nextIndex + 1) { lastNextIndex = nextIndex; $(window).one('action:ajaxify.end', function () { if (paginationBlockEl.find('.dropdown-menu').is(':hidden')) { paginationBlockEl.find('.dropdown-toggle').dropdown('toggle'); } }); navigator.scrollToIndex(nextIndex, true, 0); } else { alerts.alert({ message: '[[topic:no-more-next-post]]', type: 'info', }); lastNextIndex = 1; } } } function clampTop(thumb, newTop) { const parent = thumb.parent(); const parentOffset = parent.offset(); const thumbIcon = thumb.find('.scroller-thumb-icon'); const thumbIconHeight = thumbIcon.height(); if (newTop < parentOffset.top) { newTop = parentOffset.top; } else if (newTop > parentOffset.top + parent.height() - thumbIconHeight) { newTop = parentOffset.top + parent.height() - thumbIconHeight; } return newTop; } function setThumbToIndex(index) { if (!thumbs || !thumbs.length || !thumbs.is(':visible')) { return; } thumbs.each((i, el) => { const thumb = $(el); if (thumb.is(':hidden')) { return; } const parent = thumb.parent(); const parentOffset = parent.offset(); const thumbIcon = thumb.find('.scroller-thumb-icon'); const thumbIconHeight = thumbIcon.height(); const gap = (parent.height() - thumbIconHeight) / (ajaxify.data.postcount - 1); const newTop = clampTop(thumb, parentOffset.top + ((index - 1) * gap)); const offset = { top: newTop, left: thumb.offset().left }; thumb.offset(offset); updateThumbTextToIndex(thumb, index); updateThumbTimestampToIndex(thumb, index); }); updateUnreadIndicator(index); renderPost(index); } function updateThumbTextToIndex(thumb, index) { if (bsEnv === 'xs' || bsEnv === 'sm' || bsEnv === 'md') { thumb.find('.thumb-text').text(`${index}/${ajaxify.data.postcount}`); } else { thumb.find('.thumb-text').translateText(`[[topic:navigator.index, ${index}, ${ajaxify.data.postcount}]]`); } } async function updateThumbTimestampToIndex(thumb, index) { const el = thumb.find('.thumb-timestamp'); if (el.length) { const timestamp = await getPostTimestampByIndex(index); el.attr('title', utils.toISOString(timestamp)).timeago(); } } async function getPostTimestampByIndex(index) { // load timestamp of post from DOM if it exists // if not load from server const postEl = $(`[component="post"][data-index=${index - 1}]`); if (postEl.length) { return parseInt(postEl.attr('data-timestamp'), 10); } return await socket.emit('posts.getPostTimestampByIndex', { tid: ajaxify.data.tid, index: index - 1, }); } function handleScrollNav() { if (!thumbs.length) { return; } const parents = thumbs.parent(); parents.off('click').on('click', function (ev) { if ($(ev.target).hasClass('scroller-container')) { const thumb = $(ev.target).find('.scroller-thumb'); const index = calculateIndexFromY(thumb, ev.pageY); navigator.scrollToIndex(index - 1, true, 0); return false; } }); function calculateIndexFromY(thumb, y) { const parent = thumb.parent(); const thumbIcon = thumb.find('.scroller-thumb-icon'); const thumbIconHeight = thumbIcon.height(); const newTop = clampTop(thumb, y - (thumbIconHeight / 2)); const parentOffset = parent.offset(); const percent = (newTop - parentOffset.top) / (parent.height() - thumbIconHeight); index = Math.max(1, Math.ceil(ajaxify.data.postcount * percent)); return index > ajaxify.data.postcount ? ajaxify.data.postcount : index; } let mouseDragging = false; hooks.on('action:ajaxify.end', function () { renderPostIndex = null; }); paginationBlockEl.find('.dropdown-menu').parent() .off('shown.bs.dropdown') .on('shown.bs.dropdown', function () { setThumbToIndex(index); }); // the thumb that's being dragged, there can be more than on on the DOM let dragThumb = null; const debounceUpdateThumbTimestamp = utils.debounce(updateThumbTimestampToIndex, 50); function mousemove(ev) { if (!dragThumb || !dragThumb.length) { return; } const thumbIcon = dragThumb.find('.scroller-thumb-icon'); const thumbIconHeight = thumbIcon.height(); const newTop = clampTop(dragThumb, ev.pageY - (thumbIconHeight / 2)); dragThumb.offset({ top: newTop, left: dragThumb.offset().left }); const index = calculateIndexFromY(dragThumb, ev.pageY); navigator.updateTextAndProgressBar(); updateThumbTextToIndex(dragThumb, index); debounceUpdateThumbTimestamp(dragThumb, index); if (firstMove) { delayedRenderPost(); } firstMove = false; ev.stopPropagation(); return false; } thumbs.off('mousedown').on('mousedown', function (e) { if (e.originalEvent.button !== 0) { return; } mouseDragging = true; dragThumb = $(this); dragThumb.addClass('active'); $(window).on('mousemove', mousemove); firstMove = true; }); function mouseup() { $(window).off('mousemove', mousemove); if (mouseDragging) { navigator.scrollToIndex(index - 1, true, 0); paginationBlockEl.find('.dropdown-menu.show').removeClass('show'); } clearRenderInterval(); mouseDragging = false; firstMove = false; if (dragThumb && dragThumb.length) { dragThumb.removeClass('active'); } dragThumb = null; } function delayedRenderPost() { clearRenderInterval(); renderPostIntervalId = setInterval(function () { renderPost(index); }, 250); } $(window).off('mousemove', mousemove); $(window).off('mouseup', mouseup).on('mouseup', mouseup); thumbs.each((i, el) => { const thumb = $(el); thumb.off('touchstart').on('touchstart', function (ev) { isNavigating = true; touchX = Math.min($(window).width(), Math.max(0, ev.touches[0].clientX)); touchY = Math.min($(window).height(), Math.max(0, ev.touches[0].clientY)); firstMove = true; thumb.addClass('active'); }); thumb.off('touchmove').on('touchmove', function (ev) { const windowWidth = $(window).width(); const windowHeight = $(window).height(); const deltaX = Math.abs(touchX - Math.min(windowWidth, Math.max(0, ev.touches[0].clientX))); const deltaY = Math.abs(touchY - Math.min(windowHeight, Math.max(0, ev.touches[0].clientY))); touchX = Math.min(windowWidth, Math.max(0, ev.touches[0].clientX)); touchY = Math.min(windowHeight, Math.max(0, ev.touches[0].clientY)); if (deltaY >= deltaX && firstMove) { isNavigating = true; delayedRenderPost(); } if (isNavigating && ev.cancelable) { ev.preventDefault(); ev.stopPropagation(); const thumbIcon = thumb.find('.scroller-thumb-icon'); const thumbIconHeight = thumbIcon.height(); const newTop = clampTop(thumb, touchY + $(window).scrollTop() - (thumbIconHeight / 2)); thumb.offset({ top: newTop, left: thumb.offset().left }); const index = calculateIndexFromY(thumb, touchY + $(window).scrollTop()); navigator.updateTextAndProgressBar(); updateThumbTextToIndex(thumb, index); debounceUpdateThumbTimestamp(thumb, index); if (firstMove) { renderPost(index); } } firstMove = false; }); thumb.off('touchend').on('touchend', function () { clearRenderInterval(); if (isNavigating) { thumb.removeClass('active'); navigator.scrollToIndex(index - 1, true, 0); isNavigating = false; paginationBlockEl.find('.dropdown-menu.show').removeClass('show'); } }); }); } async function updateUnreadIndicator(index) { if (!paginationBlockUnreadEl.length || ajaxify.data.postcount <= ajaxify.data.bookmarkThreshold) { return; } const currentBookmark = ajaxify.data.bookmark || storage.getItem('topic:' + ajaxify.data.tid + ':bookmark'); index = Math.max(index, Math.min(currentBookmark, ajaxify.data.postcount)); const unreadEl = paginationBlockUnreadEl.get(0); const trackEl = unreadEl.parentNode; const trackHeight = trackEl.getBoundingClientRect().height; const percentage = 1 - (index / ajaxify.data.postcount); unreadEl.style.height = `${trackHeight * percentage}px`; const thumbEl = trackEl.querySelector('.scroller-thumb'); const thumbHeight = parseInt(thumbEl.style.height, 10); const thumbBottom = parseInt(thumbEl.style.top || 0, 10) + thumbHeight; const anchorEl = unreadEl.querySelector('.meta a'); remaining = Math.min(remaining, ajaxify.data.postcount - index); if (remaining > 0 && (trackHeight - thumbBottom) >= thumbHeight) { const text = await translator.translate(`[[topic:navigator.unread, ${remaining}]]`); anchorEl.href = `${config.relative_path}/topic/${ajaxify.data.slug}/${Math.min(index + 1, ajaxify.data.postcount)}`; anchorEl.innerText = text; } else { anchorEl.href = ajaxify.data.url; anchorEl.innerText = ''; } } function clearRenderInterval() { if (renderPostIntervalId) { clearInterval(renderPostIntervalId); renderPostIntervalId = 0; } } async function renderPost(index) { if (!index || renderPostIndex === index || !paginationBlockEl.find('.post-content').is(':visible')) { return; } renderPostIndex = index; const postData = await socket.emit('posts.getPostSummaryByIndex', { tid: ajaxify.data.tid, index: index - 1 }); const html = await app.parseAndTranslate('partials/topic/navigation-post', { post: postData }); paginationBlockEl .find('.post-content') .html(html) .find('.timeago').timeago(); } function handleKeys() { if (!config.usePagination) { $(window).off('keydown', onKeyDown).on('keydown', onKeyDown); } } function onKeyDown(ev) { if (ev.target.nodeName === 'BODY') { if (ev.shiftKey || ev.ctrlKey || ev.altKey) { return; } if (ev.which === 36 && navigator.toTop) { // home key navigator.toTop(); return false; } else if (ev.which === 35 && navigator.toBottom) { // end key navigator.toBottom(); return false; } } } function generateUrl(index) { const pathname = window.location.pathname.replace(config.relative_path, ''); const parts = pathname.split('/'); return parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : ''); } navigator.getCount = () => count; navigator.setCount = function (value) { value = parseInt(value, 10); if (value === count) { return; } count = value; navigator.updateTextAndProgressBar(); setThumbToIndex(index); toggle(count > 0); }; navigator.show = function () { toggle(true); }; navigator.disable = function () { count = 0; index = 1; navigator.callback = null; navigator.selector = null; $(window).off('scroll', navigator.delayedUpdate); toggle(false); }; function toggle(flag) { const path = ajaxify.removeRelativePath(window.location.pathname.slice(1)); if (flag && (!path.startsWith('topic') && !path.startsWith('category'))) { return; } paginationBlockEl.toggleClass('ready', flag); paginationBlockEl.toggleClass('noreplies', count <= 1); } navigator.delayedUpdate = function () { if (!navigatorUpdateTimeoutId) { navigatorUpdateTimeoutId = setTimeout(function () { navigator.update(); navigatorUpdateTimeoutId = undefined; }, 100); } }; navigator.update = function () { let newIndex = index; const els = $(navigator.selector); if (els.length) { newIndex = parseInt(els.first().attr('data-index'), 10) + 1; } const scrollTop = $(window).scrollTop(); const windowHeight = $(window).height(); const documentHeight = $(document).height(); const middleOfViewport = scrollTop + (windowHeight / 2); let previousDistance = Number.MAX_VALUE; els.each(function () { const $this = $(this); const elIndex = parseInt($this.attr('data-index'), 10); if (elIndex >= 0) { const distanceToMiddle = Math.abs(middleOfViewport - ($this.offset().top + ($this.outerHeight(true) / 2))); if (distanceToMiddle > previousDistance) { return false; } if (distanceToMiddle < previousDistance) { newIndex = elIndex + 1; previousDistance = distanceToMiddle; } } }); const atTop = scrollTop === 0 && parseInt(els.first().attr('data-index'), 10) === 0; const nearBottom = scrollTop + windowHeight > documentHeight - 100 && parseInt(els.last().attr('data-index'), 10) === count - 1; if (atTop) { newIndex = 1; } else if (nearBottom) { newIndex = count; } hooks.fire('action:navigator.update', { newIndex, index }); if (newIndex !== index) { if (typeof navigator.callback === 'function') { navigator.callback(newIndex, count); } index = newIndex; navigator.updateTextAndProgressBar(); setThumbToIndex(index); } toggle(count > 0); }; navigator.getIndex = () => index; navigator.setIndex = (newIndex) => { index = newIndex + 1; if (typeof navigator.callback === 'function') { navigator.callback(index, count); } navigator.updateTextAndProgressBar(); setThumbToIndex(index); }; navigator.updateTextAndProgressBar = function () { if (!utils.isNumber(index)) { return; } index = index > count ? count : index; if (config.usePagination) { paginationTextEl.html(` ${ajaxify.data.pagination.currentPage} / ${ajaxify.data.pagination.pageCount}`); } else { paginationTextEl.translateHtml('[[global:pagination.out_of, ' + index + ', ' + count + ']]'); } const fraction = (index - 1) / (count - 1 || 1); paginationBlockMeterEl.val(fraction); paginationBlockProgressEl.width((fraction * 100) + '%'); }; navigator.scrollUp = function () { const $window = $(window); if (config.usePagination) { const atTop = $window.scrollTop() <= 0; if (atTop) { return pagination.previousPage(function () { $('body,html').scrollTop($(document).height() - $window.height()); }); } } $('body,html').animate({ scrollTop: $window.scrollTop() - $window.height(), }); }; navigator.scrollDown = function () { const $window = $(window); if (config.usePagination) { const atBottom = $window.scrollTop() >= $(document).height() - $window.height(); if (atBottom) { return pagination.nextPage(); } } $('body,html').animate({ scrollTop: $window.scrollTop() + $window.height(), }); }; navigator.scrollTop = function (index) { if ($(navigator.selector + '[data-index="' + index + '"]').length) { navigator.scrollToIndex(index, true); } else { ajaxify.go(generateUrl()); } }; navigator.scrollBottom = function (index) { if (parseInt(index, 10) < 0) { return; } if ($(navigator.selector + '[data-index="' + index + '"]').length) { navigator.scrollToIndex(index, true); } else { index = parseInt(index, 10) + 1; ajaxify.go(generateUrl(index)); } }; navigator.scrollToIndex = function (index, highlight, duration) { const inTopic = !!components.get('topic').length; const inCategory = !!components.get('category').length; if (!utils.isNumber(index) || (!inTopic && !inCategory)) { return; } duration = duration !== undefined ? duration : 400; navigator.scrollActive = true; // if in topic and item already on page if (inTopic && components.get('post/anchor', index).length) { return navigator.scrollToPostIndex(index, highlight, duration); } // if in category and item alreay on page if (inCategory && $('[component="category/topic"][data-index="' + index + '"]').length) { return navigator.scrollToTopicIndex(index, highlight, duration); } if (!config.usePagination) { navigator.scrollActive = false; index = parseInt(index, 10) + 1; ajaxify.go(generateUrl(index)); return; } const scrollMethod = inTopic ? navigator.scrollToPostIndex : navigator.scrollToTopicIndex; const page = 1 + Math.floor(index / config.postsPerPage); if (parseInt(page, 10) !== ajaxify.data.pagination.currentPage) { pagination.loadPage(page, function () { scrollMethod(index, highlight, duration); }); } else { scrollMethod(index, highlight, duration); } }; navigator.shouldScrollToPost = function (postIndex) { if (!ajaxify.data.template.topic || postIndex <= 1) { return false; } const firstPostEl = $('[component="topic"] [component="post"]').first(); return parseInt(firstPostEl.attr('data-index'), 10) !== postIndex - 1; }; navigator.scrollToPostIndex = function (postIndex, highlight, duration) { const scrollTo = components.get('post', 'index', postIndex); navigator.scrollToElement(scrollTo, highlight, duration, postIndex); }; navigator.scrollToTopicIndex = function (topicIndex, highlight, duration) { const scrollTo = $('[component="category/topic"][data-index="' + topicIndex + '"]'); navigator.scrollToElement(scrollTo, highlight, duration, topicIndex); }; navigator.scrollToElement = async (scrollTo, highlight, duration, newIndex = null) => { if (!scrollTo.length) { navigator.scrollActive = false; return; } await hooks.fire('filter:navigator.scroll', { scrollTo, highlight, duration, newIndex: newIndex + 1 }); const postHeight = scrollTo.outerHeight(true); const navbarHeight = components.get('navbar').outerHeight(true) || 0; const topicHeaderHeight = $('.topic-main-buttons').outerHeight(true) || 0; const viewportHeight = $(window).height(); // Temporarily disable navigator update on scroll $(window).off('scroll', navigator.delayedUpdate); duration = duration !== undefined ? duration : 400; navigator.scrollActive = true; let done = false; function animateScroll() { function reenableScroll() { // Re-enable onScroll behaviour setTimeout(() => { // fixes race condition from jQuery — onAnimateComplete called too quickly $(window).off('scroll', navigator.delayedUpdate) .on('scroll', navigator.delayedUpdate); hooks.fire('action:navigator.scrolled', { scrollTo, highlight, duration, newIndex: newIndex + 1 }); }, 50); } function onAnimateComplete() { if (done) { reenableScroll(); return; } done = true; navigator.scrollActive = false; highlightPost(); if (!newIndex) { navigator.update(); } else { navigator.setIndex(newIndex); } } let scrollTop = 0; if (postHeight < viewportHeight - navbarHeight - topicHeaderHeight) { scrollTop = scrollTo.offset().top - (viewportHeight / 2) + (postHeight / 2); } else { scrollTop = scrollTo.offset().top - navbarHeight - topicHeaderHeight; } if (duration === 0) { $(window).scrollTop(scrollTop); onAnimateComplete(); reenableScroll(); return; } $('html, body').animate({ scrollTop: scrollTop + 'px', }, duration, onAnimateComplete); } function highlightPost() { if (highlight) { $('[component="post"],[component="category/topic"]').removeClass('highlight'); scrollTo.addClass('highlight'); setTimeout(function () { scrollTo.removeClass('highlight'); }, 10000); } } animateScroll(); }; return navigator; });