diff --git a/client/css/post-list-view.styl b/client/css/post-list-view.styl index b266ca1..f8da90a 100644 --- a/client/css/post-list-view.styl +++ b/client/css/post-list-view.styl @@ -127,6 +127,11 @@ .start-tagging, .stop-tagging display: none + .masstag-hint + display: none + &.active + .open-masstag + display: none .safety margin-right: 0.25em diff --git a/client/html/posts_header.tpl b/client/html/posts_header.tpl index 5b4f1d4..e34f061 100644 --- a/client/html/posts_header.tpl +++ b/client/html/posts_header.tpl @@ -12,11 +12,8 @@ %><% if (ctx.canMassTag) { %><% %><% %><% - %><% if (ctx.parameters.tag) { %><% - %>Tagging with:<% - %><% } else { %><% - %>Mass tag<% - %><% } %><% + %>Tagging with:<% + %>Mass tag<% %><% %><%= ctx.makeTextInput({name: 'masstag', value: ctx.parameters.tag}) %><% %><% diff --git a/client/js/controllers/comments_controller.js b/client/js/controllers/comments_controller.js index 1758393..da76009 100644 --- a/client/js/controllers/comments_controller.js +++ b/client/js/controllers/comments_controller.js @@ -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( diff --git a/client/js/controllers/page_controller.js b/client/js/controllers/page_controller.js index 6a490ee..6f86e0c 100644 --- a/client/js/controllers/page_controller.js +++ b/client/js/controllers/page_controller.js @@ -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) { diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index 00da59d..c1dfd06 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -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 => { diff --git a/client/js/controllers/snapshots_controller.js b/client/js/controllers/snapshots_controller.js index 1cc853d..3aa3f89 100644 --- a/client/js/controllers/snapshots_controller.js +++ b/client/js/controllers/snapshots_controller.js @@ -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( diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js index 0cc11a2..0f1f00e 100644 --- a/client/js/controllers/tag_list_controller.js +++ b/client/js/controllers/tag_list_controller.js @@ -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 => { diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js index 9cdd0a1..6e44ce8 100644 --- a/client/js/controllers/user_list_controller.js +++ b/client/js/controllers/user_list_controller.js @@ -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 => { diff --git a/client/js/router.js b/client/js/router.js index 2226059..f757e07 100644 --- a/client/js/router.js +++ b/client/js/router.js @@ -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(); diff --git a/client/js/util/search.js b/client/js/util/search.js new file mode 100644 index 0000000..ba5c2f7 --- /dev/null +++ b/client/js/util/search.js @@ -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); diff --git a/client/js/util/views.js b/client/js/util/views.js index f340a00..c25a505 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -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, diff --git a/client/js/views/endless_page_view.js b/client/js/views/endless_page_view.js index 2728047..6e6787f 100644 --- a/client/js/views/endless_page_view.js +++ b/client/js/views/endless_page_view.js @@ -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; } diff --git a/client/js/views/manual_page_view.js b/client/js/views/manual_page_view.js index c4ce34f..3507fd7 100644 --- a/client/js/views/manual_page_view.js +++ b/client/js/views/manual_page_view.js @@ -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); } diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js index 649ed46..07f3fe0 100644 --- a/client/js/views/posts_header_view.js +++ b/client/js/views/posts_header_view.js @@ -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(); + } } } diff --git a/client/js/views/tags_header_view.js b/client/js/views/tags_header_view.js index a9babc2..a395d4f 100644 --- a/client/js/views/tags_header_view.js +++ b/client/js/views/tags_header_view.js @@ -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, + }}})); } } diff --git a/client/js/views/users_header_view.js b/client/js/views/users_header_view.js index 8678025..21ba1f1 100644 --- a/client/js/views/users_header_view.js +++ b/client/js/views/users_header_view.js @@ -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, + }}})); } }