mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-03-12 15:41:01 +01:00
feat: at-a-glance navigator
This commit is contained in:
@@ -8,6 +8,11 @@ define('forum/infinitescroll', ['hooks', 'alerts'], function (hooks, alerts) {
|
||||
let loadingMore = false;
|
||||
let container;
|
||||
let scrollTimeout = 0;
|
||||
let glance;
|
||||
|
||||
require(['forum/topic/glance'], (_glance) => {
|
||||
glance = _glance;
|
||||
});
|
||||
|
||||
scroll.init = function (el, cb) {
|
||||
const $body = $('body');
|
||||
@@ -39,7 +44,8 @@ define('forum/infinitescroll', ['hooks', 'alerts'], function (hooks, alerts) {
|
||||
function onScroll() {
|
||||
const bsEnv = utils.findBootstrapEnvironment();
|
||||
const mobileComposerOpen = (bsEnv === 'xs' || bsEnv === 'sm') && $('html').hasClass('composing');
|
||||
if (loadingMore || mobileComposerOpen) {
|
||||
const glanceActive = glance.isActive();
|
||||
if (loadingMore || mobileComposerOpen || glanceActive) {
|
||||
return;
|
||||
}
|
||||
const currentScrollTop = $(window).scrollTop();
|
||||
|
||||
@@ -7,6 +7,7 @@ define('forum/topic', [
|
||||
'forum/topic/postTools',
|
||||
'forum/topic/events',
|
||||
'forum/topic/posts',
|
||||
'forum/topic/glance',
|
||||
'navigator',
|
||||
'sort',
|
||||
'quickreply',
|
||||
@@ -17,7 +18,7 @@ define('forum/topic', [
|
||||
'alerts',
|
||||
], function (
|
||||
infinitescroll, threadTools, postTools,
|
||||
events, posts, navigator, sort, quickreply,
|
||||
events, posts, glance, navigator, sort, quickreply,
|
||||
components, storage, hooks, api, alerts
|
||||
) {
|
||||
const Topic = {};
|
||||
@@ -65,6 +66,7 @@ define('forum/topic', [
|
||||
addPostsPreviewHandler();
|
||||
setupQuickReply();
|
||||
handleBookmark(tid);
|
||||
glance.init();
|
||||
|
||||
$(window).on('scroll', utils.debounce(updateTopicTitle, 250));
|
||||
|
||||
|
||||
229
public/src/client/topic/glance.js
Normal file
229
public/src/client/topic/glance.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/* eslint-disable import/no-unresolved */
|
||||
|
||||
import { render } from 'benchpress';
|
||||
import { loadMore } from 'forum/infinitescroll';
|
||||
import * as navigator from 'navigator';
|
||||
import { onPage } from 'hooks';
|
||||
|
||||
let trackTop;
|
||||
let trackBottom;
|
||||
let trackHeight;
|
||||
let knobEl;
|
||||
|
||||
export function init() {
|
||||
const topicEl = document.querySelector('[component="topic"]');
|
||||
const navigatorEl = document.querySelector('[component="topic/navigator"]');
|
||||
|
||||
if (!ajaxify.data.template.topic || !topicEl || !navigatorEl) {
|
||||
console.warn('[glance] Cannot init — not in topic or can\'t find topic element');
|
||||
return;
|
||||
}
|
||||
|
||||
enableButtons();
|
||||
({ knobEl } = enableKnob());
|
||||
|
||||
console.debug('[glance] init');
|
||||
}
|
||||
|
||||
export function isActive() {
|
||||
const topicEl = document.querySelector('[component="topic"]');
|
||||
return ajaxify.data.template.topic && topicEl && topicEl.classList.contains('minimal');
|
||||
}
|
||||
|
||||
function enableButtons() {
|
||||
const navigatorEl = document.querySelector('[component="topic/navigator"]');
|
||||
navigatorEl.addEventListener('click', (e) => {
|
||||
const subselector = e.target.closest('[data-action]');
|
||||
if (!subselector) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = subselector.getAttribute('data-action');
|
||||
navigator[action]();
|
||||
});
|
||||
}
|
||||
|
||||
function enableKnob() {
|
||||
const trackEl = document.querySelector('[component="topic/navigator"] .track');
|
||||
const knobEl = document.querySelector('[component="topic/navigator"] .knob');
|
||||
let active = false;
|
||||
|
||||
({ top: trackTop, bottom: trackBottom, height: trackHeight } = trackEl.getBoundingClientRect());
|
||||
|
||||
onPage('action:navigator.update', ({ newIndex }) => {
|
||||
if (!active) {
|
||||
repositionKnob(newIndex);
|
||||
}
|
||||
});
|
||||
|
||||
onPage('action:navigator.scrolled', ({ newIndex }) => {
|
||||
if (!active) {
|
||||
repositionKnob(newIndex);
|
||||
}
|
||||
});
|
||||
|
||||
knobEl.addEventListener('mousedown', (e) => {
|
||||
// Only respond to left click
|
||||
if (e.buttons !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggle(true);
|
||||
active = true;
|
||||
document.addEventListener('mousemove', onKnobMove);
|
||||
document.addEventListener('mouseup', () => {
|
||||
toggle(false);
|
||||
document.removeEventListener('mousemove', onKnobMove);
|
||||
active = false;
|
||||
}, {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
return { knobEl };
|
||||
}
|
||||
|
||||
function repositionKnob(index) {
|
||||
// Updates the position of the knob on the track based on viewport
|
||||
if (!index) {
|
||||
index = navigator.getIndex();
|
||||
}
|
||||
|
||||
const percentage = index / ajaxify.data.postcount;
|
||||
knobEl.style.top = `${trackHeight * percentage}px`;
|
||||
}
|
||||
|
||||
function onKnobMove(e) {
|
||||
const top = Math.min(trackBottom, Math.max(trackTop, e.clientY)) - trackTop;
|
||||
const percentage = top / trackHeight;
|
||||
|
||||
const documentHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
|
||||
knobEl.style.top = `${top}px`;
|
||||
window.scrollTo(0, documentHeight * percentage);
|
||||
}
|
||||
|
||||
function toggle(state) {
|
||||
const topicEl = document.querySelector('[component="topic"]');
|
||||
|
||||
if (!state) {
|
||||
state = !isActive();
|
||||
}
|
||||
|
||||
topicEl.classList[state ? 'add' : 'remove']('minimal');
|
||||
|
||||
if (state) {
|
||||
generatePlaceholders();
|
||||
registerScrollEvent();
|
||||
} else {
|
||||
removePlaceholders();
|
||||
deregisterScrollEvent();
|
||||
|
||||
navigator.scrollToIndex(navigator.getIndex() - 1, true, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let ticking = false;
|
||||
let scrollTimeout;
|
||||
function onScrollTick() {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
scrollTimeout = setTimeout(onScrollEnd, 500);
|
||||
ticking = false;
|
||||
});
|
||||
|
||||
ticking = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrollEnd() {
|
||||
const placeholders = Array.from(document.querySelectorAll('[component="post/placeholder"]')).filter((el) => {
|
||||
const { top, bottom } = el.getBoundingClientRect();
|
||||
return bottom > 0 && top < window.innerHeight;
|
||||
});
|
||||
|
||||
if (!placeholders.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstIndex = placeholders[0].getAttribute('data-index');
|
||||
|
||||
const { data, done } = await new Promise((resolve) => {
|
||||
loadMore('topics.loadMore', {
|
||||
tid: ajaxify.data.tid,
|
||||
after: firstIndex, // + (direction > 0 ? 1 : 0),
|
||||
count: placeholders.length,
|
||||
direction: 1,
|
||||
topicPostSort: config.topicPostSort,
|
||||
}, function (data, done) {
|
||||
resolve({ data, done });
|
||||
});
|
||||
});
|
||||
|
||||
let elements = await app.parseAndTranslate('topic', 'posts', data);
|
||||
elements = Array.from(elements); // frickin' jquery
|
||||
elements = elements.filter(el => el.nodeType === 1);
|
||||
|
||||
elements.forEach((el) => {
|
||||
const index = el.getAttribute('data-index');
|
||||
const placeholderEl = document.querySelector(`[component="post/placeholder"][data-index="${index}"]`);
|
||||
if (!placeholderEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
placeholderEl.replaceWith(el);
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
function registerScrollEvent() {
|
||||
document.addEventListener('scroll', onScrollTick);
|
||||
}
|
||||
|
||||
function deregisterScrollEvent() {
|
||||
document.removeEventListener('scroll', onScrollTick);
|
||||
}
|
||||
|
||||
async function generatePlaceholders() {
|
||||
const { postcount } = ajaxify.data;
|
||||
const posts = document.querySelectorAll('[component="post"]');
|
||||
if (!posts.length) {
|
||||
throw new Error('[[error:no-post]]');
|
||||
}
|
||||
|
||||
const firstPost = posts[0];
|
||||
const lastPost = posts[posts.length - 1];
|
||||
const firstIndex = parseInt(firstPost.getAttribute('data-index'), 10);
|
||||
const lastIndex = parseInt(lastPost.getAttribute('data-index'), 10);
|
||||
|
||||
const numAbove = firstIndex;
|
||||
const numBelow = postcount - lastIndex - 1;
|
||||
|
||||
const placeholderEl = document.createElement('li');
|
||||
const html = await render('partials/topic/post-placeholder', {});
|
||||
placeholderEl.classList.add('pt-4'); // harmony-specific
|
||||
placeholderEl.setAttribute('component', 'post/placeholder');
|
||||
|
||||
const postsEl = document.querySelector('[component="topic"]');
|
||||
for (let x = 0, index = firstIndex; x < numAbove; x++, index--) {
|
||||
const node = placeholderEl.cloneNode();
|
||||
node.setAttribute('data-index', index - 1);
|
||||
node.innerHTML = html;
|
||||
postsEl.prepend(node);
|
||||
}
|
||||
for (let x = 0, index = lastIndex; x < numBelow; x++, index++) {
|
||||
const node = placeholderEl.cloneNode();
|
||||
node.setAttribute('data-index', index + 1);
|
||||
node.innerHTML = html;
|
||||
postsEl.append(node);
|
||||
}
|
||||
}
|
||||
|
||||
function removePlaceholders() {
|
||||
// todo: directionality
|
||||
document.querySelectorAll('[component="post/placeholder"]').forEach(el => el.remove());
|
||||
}
|
||||
@@ -414,6 +414,8 @@ define('navigator', ['forum/pagination', 'components', 'hooks', 'alerts'], funct
|
||||
navigator.callback(newIndex, count);
|
||||
}
|
||||
|
||||
hooks.fire('action:navigator.update', { newIndex });
|
||||
|
||||
if (newIndex !== index) {
|
||||
index = newIndex;
|
||||
navigator.updateTextAndProgressBar();
|
||||
|
||||
Reference in New Issue
Block a user