client/comments: rework comments appearance and UX

This commit is contained in:
rr- 2016-12-24 21:49:39 +01:00
parent f0573be715
commit fe0ba63f19
11 changed files with 311 additions and 308 deletions

View file

@ -1,51 +1,8 @@
@import colors
$comment-header-background-color = $top-navigation-color
$comment-border-color = #DDD
.comment-form-container
&:not(.editing)
.tabs nav
display: none
.tabs .edit.tab
display: none
.comment-content
margin-left: 0.5em
&.editing
.tab:not(.active)
display: none
.tabs-wrapper
background: $active-tab-background-color
padding: 0.3em
.tab-wrapper[data-tab='preview']
background: $window-color
.tab.preview
padding: 1em
.tab.edit
textarea
resize: vertical
width: 100%
max-height: 80vh
box-sizing: padding-box
vertical-align: top /* ghost margin on chrome */
form
width: auto
margin: 0
&:after
display: block
height: 1px
content: ' '
clear: both
nav
vertical-align: middle !important
&.buttons
margin: 0 0.3em 0.5em 0 !important
float: left
&.actions
float: left
margin: 0.3em 0 0.5em 0 !important
.comment
.comment-container
margin: 0 0 1em 0
padding: 0
display: -webkit-flex
@ -63,25 +20,67 @@
a
display: inline-block
.body
nav:not(.active), .tab:not(.active)
display: none
.comment
border: 1px solid $comment-border-color
flex-grow: 1
header
white-space: nowrap
line-height: 16pt
font-size: 95%
vertical-align: middle
margin-bottom: 0.5em
background: $top-navigation-color
padding: 0.2em 0.5em
position: relative
background: $comment-header-background-color
border-bottom: 1px solid $comment-border-color
.nickname, .date, .score-container, .edit
nav.edit
padding: 0.33em 1em 0 1em
ul
list-style-type: none
margin: 0
padding: 0
li
display: inline-block
margin: 0 0 -1px 0
a
padding: 0.25em 1em
&.active
background: $window-color
border: 1px solid $comment-border-color
border-bottom: none
nav.readonly
padding: 0.33em 1em
&:before
position: absolute
display: block
content: ' '
width: 0
height: 0
left: -1.5em
top: calc(50% - 0.75em)
border: 0.75em solid transparent
border-right: 0.75em solid darken($comment-border-color, 10%)
&:after
position: absolute
display: block
content: ' '
width: 0
height: 0
left: calc(-1.5em + 1px)
top: calc(50% - 0.75em)
border: 0.75em solid transparent
border-right: 0.75em solid $comment-header-background-color
.date, .score-container, .edit
margin-right: 2em
.date, .score-container, .edit, .delete
font-size: 95%
.edit, .delete, .score-container a, .nickname a
&:not(.inactive)
color: mix($main-color, $inactive-tab-text-color)
.edit, .delete
font-size: 80%
i
margin-right: 0.3em
@ -96,6 +95,19 @@
display: inline-block
width: 2em
.body
width: auto
margin: 1em
.keep-height
position: relative
textarea
position: absolute
width: 100%
height: 100%
.tab.edit
min-height: 150px
.messages
margin: 1em 0
@ -118,9 +130,6 @@
white-space: pre
word-wrap: normal
p:first-child
margin-top: 0
.spoiler
background: #eee
color: #eee
@ -140,5 +149,5 @@
background: #fafafa
color: #444
blockquote :last-child
margin-bottom: 0
:last-child
margin-bottom: 0

View file

@ -1,4 +1,4 @@
.comments>ul
list-style-type: none
margin: 0 0 2em 0
margin: 0
padding: 0

View file

@ -1,57 +1,85 @@
<div class='comment'>
<div class='comment-container'>
<div class='avatar'>
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'>
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>
<% } %>
<%= ctx.makeThumbnail(ctx.comment.user ? ctx.comment.user.avatarUrl : null) %>
<%= ctx.makeThumbnail(ctx.user ? ctx.user.avatarUrl : null) %>
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
</a>
<% } %>
</div>
<div class='body'>
<header><%
%><span class='nickname'><%
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'><%
%><% } %><%
<div class='comment'>
<header>
<nav class='edit tabs'>
<ul>
<li class='edit'><a href>Write</a></li>
<li class='preview'><a href>Preview</a></li>
</ul>
</nav>
%><%- ctx.comment.user ? ctx.comment.user.name : 'Deleted user' %><%
<nav class='readonly'><%
%><strong><span class='nickname'><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
%><% } %><%
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%></a><%
%><% } %><%
%></span></strong>
<span class='date'><%
%>commented <%= ctx.makeRelativeTime(ctx.comment ? ctx.comment.creationTime : null) %><%
%></span><%
%><wbr><%
%><span class='score-container'></span><%
%><wbr><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
%><i class='fa fa-pencil'></i> edit<%
%></a><%
%><% } %><%
%></span><%
%><wbr><%
%><wbr><%
%><span class='date'><%
%><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><%
%></span><%
%><wbr><%
%><span class='score-container'></span><%
%><wbr><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
%><i class='fa fa-pencil'></i> edit<%
%></a><%
%><% } %><%
%><wbr><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
%><i class='fa fa-remove'></i> delete<%
%></a><%
%><% } %><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
%><i class='fa fa-remove'></i> delete<%
%></a><%
%><% } %><%
%></nav><%
%></header>
<div class='comment-form-container'></div>
<form class='body'>
<div class='keep-height'>
<div class='tab preview'>
<div class='comment-content'>
<%= ctx.makeMarkdown(ctx.comment ? ctx.comment.text : '') %>
</div>
</div>
<div class='tab edit'>
<textarea required minlength=1><%- ctx.comment ? ctx.comment.text : '' %></textarea>
</div>
</div>
<nav class='edit'>
<div class='messages'></div>
<input type='submit' class='save-changes' value='Save'/>
<% if (!ctx.onlyEditing) { %>
<input type='button' class='cancel-editing discourage' value='Cancel'/>
<% } %>
</div>
</form>
</div>
</div>

View file

@ -1,31 +0,0 @@
<div class='tabs'>
<form>
<div class='tabs-wrapper'><%
%><div class='tab-wrapper'><%
%><div class='preview tab'><%
%><div class='comment-content'><%
%><%= ctx.makeMarkdown(ctx.comment.text) %><%
%></div><%
%></div><%
%><div class='edit tab'><%
%><textarea required minlength=1><%- ctx.comment.text %></textarea><%
%></div><%
%></div><%
%></div>
<nav class='buttons'>
<ul>
<li class='preview'><a href>Preview</a></li>
<li class='edit'><a href>Edit</a></li>
</ul>
</nav>
<nav class='actions'>
<input type='submit' class='save' value='Save'/>
<input type='button' class='cancel discourage' value='Cancel'/>
</nav>
</form>
<div class='messages'></div>
</div>

View file

@ -4,7 +4,6 @@ const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const PostDetailView = require('../views/post_detail_view.js');

View file

@ -69,10 +69,10 @@ class PostMainController extends BasePostController {
'merge', e => this._evtMergePost(e));
}
if (this._view.commentFormControl) {
this._view.commentFormControl.addEventListener(
if (this._view.commentControl) {
this._view.commentControl.addEventListener(
'change', e => this._evtCommentChange(e));
this._view.commentFormControl.addEventListener(
this._view.commentControl.addEventListener(
'submit', e => this._evtCreateComment(e));
}
@ -183,18 +183,18 @@ class PostMainController extends BasePostController {
}
_evtCreateComment(e) {
// TODO: disable form
this._view.commentControl.disableForm();
const comment = Comment.create(this._post.id);
comment.text = e.detail.text;
comment.save()
.then(() => {
this._post.comments.add(comment);
this._view.commentFormControl.setText('');
// TODO: enable form
this._view.commentControl.exitEditMode();
this._view.commentControl.enableForm();
misc.disableExitConfirmation();
}, errorMessage => {
this._view.commentFormControl.showError(errorMessage);
// TODO: enable form
this._view.commentControl.showError(errorMessage);
this._view.commentControl.enableForm();
});
}

View file

@ -1,55 +1,87 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const events = require('../events.js');
const views = require('../util/views.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('comment');
const scoreTemplate = views.getTemplate('score');
class CommentControl extends events.EventTarget {
constructor(hostNode, comment) {
constructor(hostNode, comment, onlyEditing) {
super();
this._hostNode = hostNode;
this._comment = comment;
this._onlyEditing = onlyEditing;
comment.addEventListener('change', e => this._evtChange(e));
comment.addEventListener('changeScore', e => this._evtChangeScore(e));
if (comment) {
comment.addEventListener(
'change', e => this._evtChange(e));
comment.addEventListener(
'changeScore', e => this._evtChangeScore(e));
}
const isLoggedIn = api.isLoggedIn(this._comment.user);
const isLoggedIn = comment && api.isLoggedIn(comment.user);
const infix = isLoggedIn ? 'own' : 'any';
views.replaceContent(this._hostNode, template({
comment: this._comment,
comment: comment,
user: comment ? comment.user : api.user,
canViewUsers: api.hasPrivilege('users:view'),
canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
onlyEditing: onlyEditing,
}));
if (this._editButtonNode) {
this._editButtonNode.addEventListener(
'click', e => this._evtEditClick(e));
if (this._editButtonNodes) {
for (let node of this._editButtonNodes) {
node.addEventListener('click', e => this._evtEditClick(e));
}
}
if (this._deleteButtonNode) {
this._deleteButtonNode.addEventListener(
'click', e => this._evtDeleteClick(e));
}
this._formControl = new CommentFormControl(
this._hostNode.querySelector('.comment-form-container'),
this._comment,
true);
events.proxyEvent(this._formControl, this, 'submit');
if (this._previewEditingButtonNode) {
this._previewEditingButtonNode.addEventListener(
'click', e => this._evtPreviewEditingClick(e));
}
if (this._saveChangesButtonNode) {
this._saveChangesButtonNode.addEventListener(
'click', e => this._evtSaveChangesClick(e));
}
if (this._cancelEditingButtonNode) {
this._cancelEditingButtonNode.addEventListener(
'click', e => this._evtCancelEditingClick(e));
}
this._installScore();
if (onlyEditing) {
this._selectNav('edit');
this._selectTab('edit');
} else {
this._selectNav('readonly');
this._selectTab('preview');
}
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _scoreContainerNode() {
return this._hostNode.querySelector('.score-container');
}
get _editButtonNode() {
return this._hostNode.querySelector('.edit');
get _editButtonNodes() {
return this._hostNode.querySelectorAll('li.edit>a, a.edit');
}
get _previewEditingButtonNode() {
return this._hostNode.querySelector('li.preview>a');
}
get _deleteButtonNode() {
@ -64,12 +96,32 @@ class CommentControl extends events.EventTarget {
return this._hostNode.querySelector('.downvote');
}
get _saveChangesButtonNode() {
return this._hostNode.querySelector('.save-changes');
}
get _cancelEditingButtonNode() {
return this._hostNode.querySelector('.cancel-editing');
}
get _textareaNode() {
return this._hostNode.querySelector('.tab.edit textarea');
}
get _contentNode() {
return this._hostNode.querySelector('.tab.preview .comment-content');
}
get _heightKeeperNode() {
return this._hostNode.querySelector('.keep-height');
}
_installScore() {
views.replaceContent(
this._scoreContainerNode,
scoreTemplate({
score: this._comment.score,
ownScore: this._comment.ownScore,
score: this._comment ? this._comment.score : 0,
ownScore: this._comment ? this._comment.ownScore : 0,
canScore: api.hasPrivilege('comments:score'),
}));
@ -83,9 +135,40 @@ class CommentControl extends events.EventTarget {
}
}
enterEditMode() {
this._selectNav('edit');
this._selectTab('edit');
}
exitEditMode() {
if (this._onlyEditing) {
this._selectNav('edit');
this._selectTab('edit');
this._setText('');
} else {
this._selectNav('readonly');
this._selectTab('preview');
this._setText(this._comment.text);
}
this._forgetHeight();
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtEditClick(e) {
e.preventDefault();
this._formControl.enterEditMode();
this.enterEditMode();
}
_evtScoreClick(e, score) {
@ -114,12 +197,69 @@ class CommentControl extends events.EventTarget {
}
_evtChange(e) {
this._formControl.exitEditMode();
this.exitEditMode();
}
_evtChangeScore(e) {
this._installScore();
}
_evtPreviewEditingClick(e) {
e.preventDefault();
this._contentNode.innerHTML =
misc.formatMarkdown(this._textareaNode.value);
this._selectTab('edit');
this._selectTab('preview');
}
_evtEditClick(e) {
e.preventDefault();
this.enterEditMode();
}
_evtSaveChangesClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
target: this,
comment: this._comment,
text: this._textareaNode.value,
},
}));
}
_evtCancelEditingClick(e) {
e.preventDefault();
this.exitEditMode();
}
_setText(text) {
this._textareaNode.value = text;
this._contentNode.innerHTML = misc.formatMarkdown(text);
}
_selectNav(modeName) {
for (let node of this._hostNode.querySelectorAll('nav')) {
node.classList.toggle('active', node.classList.contains(modeName));
}
}
_selectTab(tabName) {
this._ensureHeight();
for (let node of this._hostNode.querySelectorAll('.tab, .tabs li')) {
node.classList.toggle('active', node.classList.contains(tabName));
}
}
_ensureHeight() {
this._heightKeeperNode.style.minHeight =
this._heightKeeperNode.getBoundingClientRect().height + 'px';
}
_forgetHeight() {
this._heightKeeperNode.style.minHeight = null;
}
};
module.exports = CommentControl;

View file

@ -1,141 +0,0 @@
'use strict';
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const template = views.getTemplate('comment-form');
class CommentFormControl extends events.EventTarget {
constructor(hostNode, comment, canCancel, minHeight) {
super();
this._hostNode = hostNode;
this._comment = comment || {text: ''};
this._canCancel = canCancel;
this._minHeight = minHeight || 150;
const sourceNode = template({
comment: this._comment,
});
const previewTabButton = sourceNode.querySelector('.buttons .preview');
const editTabButton = sourceNode.querySelector('.buttons .edit');
const formNode = sourceNode.querySelector('form');
const cancelButton = sourceNode.querySelector('.cancel');
const textareaNode = sourceNode.querySelector('form textarea');
previewTabButton.addEventListener(
'click', e => this._evtPreviewClick(e));
editTabButton.addEventListener(
'click', e => this._evtEditClick(e));
formNode.addEventListener('submit', e => this._evtSaveClick(e));
if (this._canCancel) {
cancelButton
.addEventListener('click', e => this._evtCancelClick(e));
} else {
cancelButton.style.display = 'none';
}
for (let event of ['cut', 'paste', 'drop', 'keydown']) {
textareaNode.addEventListener(event, e => {
window.setTimeout(() => this._growTextArea(), 0);
});
}
textareaNode.addEventListener('change', e => {
this.dispatchEvent(new CustomEvent('change', {
detail: {
target: this,
},
}));
this._growTextArea();
});
views.replaceContent(this._hostNode, sourceNode);
}
enterEditMode() {
this._freezeTabHeights();
this._hostNode.classList.add('editing');
this._selectTab('edit');
this._growTextArea();
}
exitEditMode() {
this._hostNode.classList.remove('editing');
this._hostNode.querySelector('.tab-wrapper').style.minHeight = null;
views.clearMessages(this._hostNode);
this.setText(this._comment.text);
}
get _textareaNode() {
return this._hostNode.querySelector('.edit.tab textarea');
}
get _contentNode() {
return this._hostNode.querySelector('.preview.tab .comment-content');
}
setText(text) {
this._textareaNode.value = text;
this._contentNode.innerHTML = misc.formatMarkdown(text);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtPreviewClick(e) {
e.preventDefault();
this._contentNode.innerHTML =
misc.formatMarkdown(this._textareaNode.value);
this._freezeTabHeights();
this._selectTab('preview');
}
_evtEditClick(e) {
e.preventDefault();
this.enterEditMode();
}
_evtSaveClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
target: this,
comment: this._comment,
text: this._textareaNode.value,
},
}));
}
_evtCancelClick(e) {
e.preventDefault();
this.exitEditMode();
}
_selectTab(tabName) {
this._freezeTabHeights();
const tabWrapperNode = this._hostNode.querySelector('.tab-wrapper');
tabWrapperNode.setAttribute('data-tab', tabName);
for (let tab of this._hostNode.querySelectorAll('.tab, .buttons li')) {
tab.classList.toggle('active', tab.classList.contains(tabName));
}
}
_freezeTabHeights() {
const tabsNode = this._hostNode.querySelector('.tab-wrapper');
const tabsHeight = tabsNode.getBoundingClientRect().height;
tabsNode.style.minHeight = tabsHeight + 'px';
}
_growTextArea() {
this._textareaNode.style.height =
Math.max(
this._minHeight || 0,
this._textareaNode.scrollHeight) + 'px';
}
};
module.exports = CommentFormControl;

View file

@ -34,7 +34,7 @@ class CommentListControl extends events.EventTarget {
_installCommentNode(comment) {
const commentListItemNode = document.createElement('li');
const commentControl = new CommentControl(
commentListItemNode, comment);
commentListItemNode, comment, false);
events.proxyEvent(commentControl, this, 'submit');
events.proxyEvent(commentControl, this, 'score');
events.proxyEvent(commentControl, this, 'delete');

View file

@ -23,7 +23,7 @@ class Comment extends events.EventTarget {
get id() { return this._id; }
get postId() { return this._postId; }
get text() { return this._text; }
get text() { return this._text || ''; }
get user() { return this._user; }
get creationTime() { return this._creationTime; }
get lastEditTime() { return this._lastEditTime; }

View file

@ -10,8 +10,8 @@ const PostReadonlySidebarControl =
require('../controls/post_readonly_sidebar_control.js');
const PostEditSidebarControl =
require('../controls/post_edit_sidebar_control.js');
const CommentControl = require('../controls/comment_control.js');
const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('post-main');
@ -101,9 +101,8 @@ class PostMainView {
return;
}
this.commentFormControl = new CommentFormControl(
commentFormContainer, null, false, 150);
this.commentFormControl.enterEditMode();
this.commentControl = new CommentControl(
commentFormContainer, null, true);
}
_installComments(comments) {