client/paging: avoid redrawing header navigation

This commit is contained in:
rr- 2016-08-28 18:53:06 +02:00
parent e83e1b06a1
commit cf1d15354d
16 changed files with 263 additions and 164 deletions

View file

@ -127,6 +127,11 @@
.start-tagging,
.stop-tagging
display: none
.masstag-hint
display: none
&.active
.open-masstag
display: none
.safety
margin-right: 0.25em

View file

@ -12,11 +12,8 @@
%><% if (ctx.canMassTag) { %><%
%><wbr/><%
%><span class='masstag'><%
%><% if (ctx.parameters.tag) { %><%
%><span class='append masstag-hint'>Tagging with:</span><%
%><% } else { %><%
%><a href class='mousetrap button append open-masstag'>Mass tag</a><%
%><% } %><%
%><span class='append masstag-hint'>Tagging with:</span><%
%><a href class='mousetrap button append open-masstag'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'masstag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start-tagging' type='submit' value='Start tagging'/><%

View file

@ -22,7 +22,8 @@ class CommentsController {
topNavigation.activate('comments');
topNavigation.setTitle('Listing comments');
this._pageController = new PageController({
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(

View file

@ -6,19 +6,25 @@ const ManualPageView = require('../views/manual_page_view.js');
class PageController {
constructor(ctx) {
if (settings.get().endlessScroll) {
this._view = new EndlessPageView();
} else {
this._view = new ManualPageView();
}
}
get view() {
return this._view;
}
run(ctx) {
const extendedContext = {
getClientUrlForPage: ctx.getClientUrlForPage,
parameters: ctx.parameters,
};
ctx.headerContext = Object.assign({}, extendedContext);
ctx.pageContext = Object.assign({}, extendedContext);
if (settings.get().endlessScroll) {
this._view = new EndlessPageView(ctx);
} else {
this._view = new ManualPageView(ctx);
}
this._view.run(ctx);
}
showSuccess(message) {

View file

@ -26,37 +26,18 @@ class PostListController {
topNavigation.setTitle('Listing posts');
this._ctx = ctx;
this._pageController = new PageController({
this._pageController = new PageController();
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/posts/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return PostList.search(
this._decorateSearchQuery(ctx.parameters.query),
page, settings.get().postsPerPage, fields);
},
headerRenderer: headerCtx => {
Object.assign(headerCtx, {
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
return new PostsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
return view;
},
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
@ -67,6 +48,15 @@ class PostListController {
return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/posts/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_evtTag(e) {
for (let tag of this._massTagTags) {
e.detail.post.addTag(tag);
@ -100,6 +90,32 @@ class PostListController {
}
return text.trim();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
return '/posts/' + misc.formatUrlParameters(
Object.assign({}, this._ctx.parameters, {page: page}));
},
requestPage: page => {
return PostList.search(
this._decorateSearchQuery(this._ctx.parameters.query),
page, settings.get().postsPerPage, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
return view;
},
});
}
}
module.exports = router => {

View file

@ -19,7 +19,8 @@ class SnapshotsController {
topNavigation.activate('');
topNavigation.setTitle('History');
this._pageController = new PageController({
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(

View file

@ -23,27 +23,18 @@ class TagListController {
topNavigation.activate('tags');
topNavigation.setTitle('Listing tags');
this._pageController = new PageController({
this._ctx = ctx;
this._pageController = new PageController();
this._headerView = new TagsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/tags/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return TagList.search(ctx.parameters.query, page, 50, fields);
},
headerRenderer: headerCtx => {
Object.assign(headerCtx, {
canEditTagCategories:
api.hasPrivilege('tagCategories:edit'),
});
return new TagsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
},
canEditTagCategories: api.hasPrivilege('tagCategories:edit'),
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
@ -53,6 +44,33 @@ class TagListController {
showError(message) {
this._pageController.showError(message);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/tags/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, this._ctx.parameters, {page: page});
return '/tags/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return TagList.search(
this._ctx.parameters.query, page, 50, fields);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
},
});
}
}
module.exports = router => {

View file

@ -20,18 +20,42 @@ class UserListController {
topNavigation.activate('users');
topNavigation.setTitle('Listing users');
this._pageController = new PageController({
this._ctx = ctx;
this._pageController = new PageController();
this._headerView = new UsersHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/users/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
{}, this._ctx.parameters, {page: page});
return '/users/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return UserList.search(ctx.parameters.query, page);
},
headerRenderer: headerCtx => {
return new UsersHeaderView(headerCtx);
return UserList.search(this._ctx.parameters.query, page);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -41,10 +65,6 @@ class UserListController {
},
});
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
}
module.exports = router => {

View file

@ -137,8 +137,8 @@ class Router {
}
show(path, state, push) {
const oldPath = this.ctx ? this.ctx.path : ctx.path;
const ctx = new Context(path, state);
const oldPath = this.ctx ? this.ctx.path : ctx.path;
this.dispatch(ctx, () => {
if (ctx.path !== oldPath && push !== false) {
ctx.pushState();

17
client/js/util/search.js Normal file
View file

@ -0,0 +1,17 @@
'use strict';
const misc = require('./misc.js');
const keyboard = require('../util/keyboard.js');
const views = require('./views.js');
function searchInputNodeFocusHelper(inputNode) {
keyboard.bind('q', () => {
inputNode.focus();
inputNode.setSelectionRange(
inputNode.value.length, inputNode.value.length);
});
}
module.exports = misc.arrayToObject([
searchInputNodeFocusHelper,
], func => func.name);

View file

@ -248,6 +248,25 @@ function makeVoidElement(name, attributes) {
return `<${_serializeElement(name, attributes)}/>`;
}
function emptyContent(target) {
while (target.lastChild) {
target.removeChild(target.lastChild);
}
}
function replaceContent(target, source) {
emptyContent(target);
if (source instanceof NodeList) {
for (let child of [...source]) {
target.appendChild(child);
}
} else if (source instanceof Node) {
target.appendChild(source);
} else if (source !== null) {
throw `Invalid view source: ${source}`;
}
}
function showMessage(target, message, className) {
if (!message) {
message = 'Unknown message';
@ -283,9 +302,7 @@ function showInfo(target, message) {
function clearMessages(target) {
const messagesHolder = target.querySelector('.messages');
/* TODO: animate that */
while (messagesHolder.lastChild) {
messagesHolder.removeChild(messagesHolder.lastChild);
}
emptyContent(messagesHolder);
}
function htmlToDom(html) {
@ -394,25 +411,10 @@ function enableForm(form) {
}
}
function replaceContent(target, source) {
while (target.lastChild) {
target.removeChild(target.lastChild);
}
if (source instanceof NodeList) {
for (let child of [...source]) {
target.appendChild(child);
}
} else if (source instanceof Node) {
target.appendChild(source);
} else if (source !== null) {
throw `Invalid view source: ${source}`;
}
}
function syncScrollPosition() {
window.requestAnimationFrame(
() => {
if (history.state.hasOwnProperty('scrollX')) {
if (history.state && history.state.hasOwnProperty('scrollX')) {
window.scrollTo(history.state.scrollX, history.state.scrollY);
} else {
window.scrollTo(0, 0);
@ -488,6 +490,7 @@ document.addEventListener('click', e => {
module.exports = misc.arrayToObject([
htmlToDom,
getTemplate,
emptyContent,
replaceContent,
enableForm,
disableForm,

View file

@ -9,27 +9,22 @@ const pageTemplate = views.getTemplate('endless-pager-page');
class EndlessPageView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, holderTemplate());
}
run(ctx) {
this._active = true;
this._working = 0;
this._init = false;
views.emptyContent(this._pagesHolderNode);
this.threshold = window.innerHeight / 3;
this.minPageShown = null;
this.maxPageShown = null;
this.totalPages = null;
this.currentPage = null;
const sourceNode = holderTemplate();
const pageHeaderHolderNode
= sourceNode.querySelector('.page-header-holder');
this._pagesHolderNode = sourceNode.querySelector('.pages-holder');
views.replaceContent(this._hostNode, sourceNode);
ctx.headerContext.hostNode = pageHeaderHolderNode;
if (ctx.headerRenderer) {
ctx.headerRenderer(ctx.headerContext);
}
this._loadPage(ctx, ctx.parameters.page, true).then(pageNode => {
if (ctx.parameters.page !== 1) {
pageNode.scrollIntoView();
@ -40,6 +35,14 @@ class EndlessPageView {
views.monitorNodeRemoval(this._pagesHolderNode, () => this._destroy());
}
get pageHeaderHolderNode() {
return this._hostNode.querySelector('.page-header-holder');
}
get _pagesHolderNode() {
return this._hostNode.querySelector('.pages-holder');
}
_destroy() {
this._active = false;
}

View file

@ -56,25 +56,17 @@ function _getPages(currentPage, pageNumbers, ctx) {
class ManualPageView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, holderTemplate());
}
const sourceNode = holderTemplate();
const pageContentHolderNode
= sourceNode.querySelector('.page-content-holder');
const pageHeaderHolderNode
= sourceNode.querySelector('.page-header-holder');
const pageNavNode = sourceNode.querySelector('.page-nav');
run(ctx) {
const currentPage = ctx.parameters.page;
ctx.headerContext.hostNode = pageHeaderHolderNode;
if (ctx.headerRenderer) {
ctx.headerRenderer(ctx.headerContext);
}
views.replaceContent(this._hostNode, sourceNode);
this.clearMessages();
views.emptyContent(this._pageNavNode);
ctx.requestPage(currentPage).then(response => {
Object.assign(ctx.pageContext, response);
ctx.pageContext.hostNode = pageContentHolderNode;
ctx.pageContext.hostNode = this._pageContentHolderNode;
ctx.pageRenderer(ctx.pageContext);
const totalPages = Math.ceil(response.total / response.pageSize);
@ -94,7 +86,7 @@ class ManualPageView {
if (response.total) {
views.replaceContent(
pageNavNode,
this._pageNavNode,
navTemplate({
prevLink: ctx.getClientUrlForPage(currentPage - 1),
nextLink: ctx.getClientUrlForPage(currentPage + 1),
@ -114,6 +106,22 @@ class ManualPageView {
});
}
get pageHeaderHolderNode() {
return this._hostNode.querySelector('.page-header-holder');
}
get _pageContentHolderNode() {
return this._hostNode.querySelector('.page-content-holder');
}
get _pageNavNode() {
return this._hostNode.querySelector('.page-nav');
}
clearMessages() {
views.clearMessages(this._hostNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}

View file

@ -1,41 +1,34 @@
'use strict';
const router = require('../router.js');
const events = require('../events.js');
const settings = require('../models/settings.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
const search = require('../util/search.js');
const views = require('../util/views.js');
const TagAutoCompleteControl =
require('../controls/tag_auto_complete_control.js');
const template = views.getTemplate('posts-header');
class PostsHeaderView {
class PostsHeaderView extends events.EventTarget {
constructor(ctx) {
super();
ctx.settings = settings.get();
this._ctx = ctx;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
if (this._queryInputNode) {
new TagAutoCompleteControl(this._queryInputNode, {addSpace: true});
}
this._queryAutoCompleteControl = new TagAutoCompleteControl(
this._queryInputNode, {addSpace: true});
if (this._massTagInputNode) {
new TagAutoCompleteControl(
this._masstagAutoCompleteControl = new TagAutoCompleteControl(
this._massTagInputNode, {addSpace: false});
}
keyboard.bind('q', () => {
this._formNode.querySelector('input:first-of-type').focus();
});
keyboard.bind('p', () => {
const firstPostNode =
document.body.querySelector('.post-list li:first-child a');
if (firstPostNode) {
firstPostNode.focus();
}
});
keyboard.bind('p', () => this._focusFirstPostNode());
search.searchInputNodeFocusHelper(this._queryInputNode);
for (let safetyButtonNode of this._safetyButtonNodes) {
safetyButtonNode.addEventListener(
@ -51,8 +44,6 @@ class PostsHeaderView {
}
this._stopMassTagLinkNode.addEventListener(
'click', e => this._evtStopTaggingClick(e));
// this._massTagFormNode.addEventListener(
// 'submit', e => this._evtMassTagFormSubmit(e));
this._toggleMassTagVisibility(!!ctx.parameters.tag);
}
}
@ -93,10 +84,11 @@ class PostsHeaderView {
_evtStopTaggingClick(e) {
e.preventDefault();
router.show('/posts/' + misc.formatUrlParameters({
this._toggleMassTagVisibility(false);
this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: {
query: this._ctx.parameters.query,
page: this._ctx.parameters.page,
}));
}}}));
}
_evtSafetyButtonClick(e, url) {
@ -107,20 +99,35 @@ class PostsHeaderView {
browsingSettings.listPosts[safety] =
!browsingSettings.listPosts[safety];
settings.save(browsingSettings, true);
router.show(router.url);
this.dispatchEvent(
new CustomEvent(
'navigate', {detail: {parameters: this._ctx.parameters}}));
}
_evtFormSubmit(e) {
e.preventDefault();
let params = {
this._queryAutoCompleteControl.hide();
if (this._masstagAutoCompleteControl) {
this._masstagAutoCompleteControl.hide();
}
let parameters = {
query: this._queryInputNode.value,
page: this._ctx.parameters.page,
};
if (this._massTagInputNode) {
params.tag = this._massTagInputNode.value;
parameters.tag = this._massTagInputNode.value;
this._massTagInputNode.blur();
}
router.show('/posts/' + misc.formatUrlParameters(params));
this.dispatchEvent(
new CustomEvent('navigate', {detail: {parameters: parameters}}));
}
_focusFirstPostNode() {
const firstPostNode =
document.body.querySelector('.post-list li:first-child a');
if (firstPostNode) {
firstPostNode.focus();
}
}
}

View file

@ -1,16 +1,18 @@
'use strict';
const router = require('../router.js');
const keyboard = require('../util/keyboard.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const search = require('../util/search.js');
const views = require('../util/views.js');
const TagAutoCompleteControl =
require('../controls/tag_auto_complete_control.js');
const template = views.getTemplate('tags-header');
class TagsHeaderView {
class TagsHeaderView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
@ -18,9 +20,7 @@ class TagsHeaderView {
new TagAutoCompleteControl(this._queryInputNode);
}
keyboard.bind('q', () => {
form.querySelector('input').focus();
});
search.searchInputNodeFocusHelper(this._queryInputNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
@ -36,10 +36,9 @@ class TagsHeaderView {
_evtSubmit(e) {
e.preventDefault();
this._queryInputNode.blur();
router.show(
'/tags/' + misc.formatUrlParameters({
query: this._queryInputNode.value,
}));
this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: {
query: this._queryInputNode.value,
}}}));
}
}

View file

@ -1,20 +1,20 @@
'use strict';
const router = require('../router.js');
const keyboard = require('../util/keyboard.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const search = require('../util/search.js');
const views = require('../util/views.js');
const template = views.getTemplate('users-header');
class UsersHeaderView {
class UsersHeaderView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
keyboard.bind('q', () => {
this._formNode.querySelector('input').focus();
});
search.searchInputNodeFocusHelper(this._queryInputNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
@ -29,11 +29,9 @@ class UsersHeaderView {
_evtSubmit(e) {
e.preventDefault();
this._queryInputNode.blur();
router.show(
'/users/' + misc.formatUrlParameters({
query: this._queryInputNode.value,
}));
this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: {
query: this._queryInputNode.value,
}}}));
}
}