diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index f16767fcf..2aabab79d 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -7,27 +7,14 @@ use BookStack\Entities\Models\Entity; use BookStack\Facades\Activity as ActivityService; use League\CommonMark\CommonMarkConverter; -/** - * Class CommentRepo. - */ class CommentRepo { - /** - * @var Comment - */ - protected $comment; - - public function __construct(Comment $comment) - { - $this->comment = $comment; - } - /** * Get a comment by ID. */ public function getById(int $id): Comment { - return $this->comment->newQuery()->findOrFail($id); + return Comment::query()->findOrFail($id); } /** @@ -36,7 +23,7 @@ class CommentRepo public function create(Entity $entity, string $text, ?int $parent_id): Comment { $userId = user()->id; - $comment = $this->comment->newInstance(); + $comment = new Comment(); $comment->text = $text; $comment->html = $this->commentToHtml($text); @@ -83,7 +70,7 @@ class CommentRepo 'allow_unsafe_links' => false, ]); - return $converter->convertToHtml($commentText); + return $converter->convert($commentText); } /** @@ -91,9 +78,8 @@ class CommentRepo */ protected function getNextLocalId(Entity $entity): int { - /** @var Comment $comment */ - $comment = $entity->comments(false)->orderBy('local_id', 'desc')->first(); + $currentMaxId = $entity->comments()->max('local_id'); - return ($comment->local_id ?? 0) + 1; + return $currentMaxId + 1; } } diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index b198d2d56..9e7491fd7 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -10,11 +10,9 @@ use Illuminate\Validation\ValidationException; class CommentController extends Controller { - protected $commentRepo; - - public function __construct(CommentRepo $commentRepo) - { - $this->commentRepo = $commentRepo; + public function __construct( + protected CommentRepo $commentRepo + ) { } /** @@ -43,7 +41,12 @@ class CommentController extends Controller $this->checkPermission('comment-create-all'); $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id')); - return view('comments.comment', ['comment' => $comment]); + return view('comments.comment-branch', [ + 'branch' => [ + 'comment' => $comment, + 'children' => [], + ] + ]); } /** diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php new file mode 100644 index 000000000..559edccf3 --- /dev/null +++ b/app/Activity/Tools/CommentTree.php @@ -0,0 +1,102 @@ +comments = $this->loadComments(); + $this->tree = $this->createTree($this->comments); + } + + public function enabled(): bool + { + return !setting('app-disable-comments'); + } + + public function empty(): bool + { + return count($this->tree) === 0; + } + + public function count(): int + { + return count($this->comments); + } + + public function get(): array + { + return $this->tree; + } + + /** + * @param Comment[] $comments + */ + protected function createTree(array $comments): array + { + $byId = []; + foreach ($comments as $comment) { + $byId[$comment->local_id] = $comment; + } + + $childMap = []; + foreach ($comments as $comment) { + $parent = $comment->parent_id; + if (is_null($parent) || !isset($byId[$parent])) { + $parent = 0; + } + + if (!isset($childMap[$parent])) { + $childMap[$parent] = []; + } + $childMap[$parent][] = $comment->local_id; + } + + $tree = []; + foreach ($childMap[0] as $childId) { + $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap); + } + + return $tree; + } + + protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array + { + $childIds = $childMap[$id] ?? []; + $children = []; + + foreach ($childIds as $childId) { + $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap); + } + + return [ + 'comment' => $byId[$id], + 'depth' => $depth, + 'children' => $children, + ]; + } + + protected function loadComments(): array + { + if (!$this->enabled()) { + return []; + } + + return $this->page->comments() + ->with('createdBy') + ->get() + ->all(); + } +} diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index a6ef68dd7..e0444ecd2 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -3,6 +3,7 @@ namespace BookStack\Entities\Controllers; use BookStack\Activity\Models\View; +use BookStack\Activity\Tools\CommentTree; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\BookContents; @@ -140,15 +141,10 @@ class PageController extends Controller $pageContent = (new PageContent($page)); $page->html = $pageContent->render(); - $sidebarTree = (new BookContents($page->book))->getTree(); $pageNav = $pageContent->getNavigation($page->html); - // Check if page comments are enabled - $commentsEnabled = !setting('app-disable-comments'); - if ($commentsEnabled) { - $page->load(['comments.createdBy']); - } - + $sidebarTree = (new BookContents($page->book))->getTree(); + $commentTree = (new CommentTree($page)); $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree); View::incrementFor($page); @@ -159,7 +155,7 @@ class PageController extends Controller 'book' => $page->book, 'current' => $page, 'sidebarTree' => $sidebarTree, - 'commentsEnabled' => $commentsEnabled, + 'commentTree' => $commentTree, 'pageNav' => $pageNav, 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), diff --git a/lang/en/entities.php b/lang/en/entities.php index 501fc9f2a..caf9e2361 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -362,11 +362,10 @@ return [ 'comment_placeholder' => 'Leave a comment here', 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', 'comment_save' => 'Save Comment', - 'comment_saving' => 'Saving comment...', - 'comment_deleting' => 'Deleting comment...', 'comment_new' => 'New Comment', 'comment_created' => 'commented :createDiff', 'comment_updated' => 'Updated :updateDiff by :username', + 'comment_updated_indicator' => 'Updated', 'comment_deleted_success' => 'Comment deleted', 'comment_created_success' => 'Comment added', 'comment_updated_success' => 'Comment updated', diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 803714e62..a56f18a5a 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -34,6 +34,7 @@ export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; export {OptionalInput} from './optional-input'; +export {PageComment} from './page-comment'; export {PageComments} from './page-comments'; export {PageDisplay} from './page-display'; export {PageEditor} from './page-editor'; diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js new file mode 100644 index 000000000..8284d7f20 --- /dev/null +++ b/resources/js/components/page-comment.js @@ -0,0 +1,96 @@ +import {Component} from './component'; +import {getLoading, htmlToDom} from '../services/dom'; + +export class PageComment extends Component { + + setup() { + // Options + this.commentId = this.$opts.commentId; + this.commentLocalId = this.$opts.commentLocalId; + this.commentParentId = this.$opts.commentParentId; + this.deletedText = this.$opts.deletedText; + this.updatedText = this.$opts.updatedText; + + // Element References + this.container = this.$el; + this.contentContainer = this.$refs.contentContainer; + this.form = this.$refs.form; + this.formCancel = this.$refs.formCancel; + this.editButton = this.$refs.editButton; + this.deleteButton = this.$refs.deleteButton; + this.replyButton = this.$refs.replyButton; + this.input = this.$refs.input; + + this.setupListeners(); + } + + setupListeners() { + if (this.replyButton) { + this.replyButton.addEventListener('click', () => this.$emit('reply', { + id: this.commentLocalId, + element: this.container, + })); + } + + if (this.editButton) { + this.editButton.addEventListener('click', this.startEdit.bind(this)); + this.form.addEventListener('submit', this.update.bind(this)); + this.formCancel.addEventListener('click', () => this.toggleEditMode(false)); + } + + if (this.deleteButton) { + this.deleteButton.addEventListener('click', this.delete.bind(this)); + } + } + + toggleEditMode(show) { + this.contentContainer.toggleAttribute('hidden', show); + this.form.toggleAttribute('hidden', !show); + } + + startEdit() { + this.toggleEditMode(true); + const lineCount = this.$refs.input.value.split('\n').length; + this.$refs.input.style.height = `${(lineCount * 20) + 40}px`; + } + + async update(event) { + event.preventDefault(); + const loading = this.showLoading(); + this.form.toggleAttribute('hidden', true); + + const reqData = { + text: this.input.value, + parent_id: this.parentId || null, + }; + + try { + const resp = await window.$http.put(`/comment/${this.commentId}`, reqData); + const newComment = htmlToDom(resp.data); + this.container.replaceWith(newComment); + window.$events.success(this.updatedText); + } catch (err) { + console.error(err); + window.$events.showValidationErrors(err); + this.form.toggleAttribute('hidden', false); + loading.remove(); + } + } + + async delete() { + this.showLoading(); + + await window.$http.delete(`/comment/${this.commentId}`); + this.container.closest('.comment-branch').remove(); + window.$events.success(this.deletedText); + this.$emit('delete'); + } + + showLoading() { + const loading = getLoading(); + loading.classList.add('px-l'); + this.container.append(loading); + return loading; + } + +} diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 0ac9d0572..a46a5c3b3 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -1,6 +1,5 @@ -import {scrollAndHighlightElement} from '../services/util'; import {Component} from './component'; -import {htmlToDom} from '../services/dom'; +import {getLoading, htmlToDom} from '../services/dom'; export class PageComments extends Component { @@ -10,190 +9,130 @@ export class PageComments extends Component { // Element references this.container = this.$refs.commentContainer; - this.formContainer = this.$refs.formContainer; this.commentCountBar = this.$refs.commentCountBar; + this.commentsTitle = this.$refs.commentsTitle; this.addButtonContainer = this.$refs.addButtonContainer; this.replyToRow = this.$refs.replyToRow; + this.formContainer = this.$refs.formContainer; + this.form = this.$refs.form; + this.formInput = this.$refs.formInput; + this.formReplyLink = this.$refs.formReplyLink; + this.addCommentButton = this.$refs.addCommentButton; + this.hideFormButton = this.$refs.hideFormButton; + this.removeReplyToButton = this.$refs.removeReplyToButton; // Translations - this.updatedText = this.$opts.updatedText; - this.deletedText = this.$opts.deletedText; this.createdText = this.$opts.createdText; this.countText = this.$opts.countText; // Internal State - this.editingComment = null; this.parentId = null; + this.formReplyText = this.formReplyLink.textContent; - if (this.formContainer) { - this.form = this.formContainer.querySelector('form'); - this.formInput = this.form.querySelector('textarea'); - this.form.addEventListener('submit', this.saveComment.bind(this)); - } - - this.elem.addEventListener('click', this.handleAction.bind(this)); - this.elem.addEventListener('submit', this.updateComment.bind(this)); + this.setupListeners(); } - handleAction(event) { - const actionElem = event.target.closest('[action]'); + setupListeners() { + this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this)); + this.hideFormButton.addEventListener('click', this.hideForm.bind(this)); + this.addCommentButton.addEventListener('click', this.showForm.bind(this)); - if (event.target.matches('a[href^="#"]')) { - const id = event.target.href.split('#')[1]; - scrollAndHighlightElement(document.querySelector(`#${id}`)); - } - - if (actionElem === null) return; - event.preventDefault(); - - const action = actionElem.getAttribute('action'); - const comment = actionElem.closest('[comment]'); - if (action === 'edit') this.editComment(comment); - if (action === 'closeUpdateForm') this.closeUpdateForm(); - if (action === 'delete') this.deleteComment(comment); - if (action === 'addComment') this.showForm(); - if (action === 'hideForm') this.hideForm(); - if (action === 'reply') this.setReply(comment); - if (action === 'remove-reply-to') this.removeReplyTo(); - } - - closeUpdateForm() { - if (!this.editingComment) return; - this.editingComment.querySelector('[comment-content]').style.display = 'block'; - this.editingComment.querySelector('[comment-edit-container]').style.display = 'none'; - } - - editComment(commentElem) { - this.hideForm(); - if (this.editingComment) this.closeUpdateForm(); - commentElem.querySelector('[comment-content]').style.display = 'none'; - commentElem.querySelector('[comment-edit-container]').style.display = 'block'; - const textArea = commentElem.querySelector('[comment-edit-container] textarea'); - const lineCount = textArea.value.split('\n').length; - textArea.style.height = `${(lineCount * 20) + 40}px`; - this.editingComment = commentElem; - } - - updateComment(event) { - const form = event.target; - event.preventDefault(); - const text = form.querySelector('textarea').value; - const reqData = { - text, - parent_id: this.parentId || null, - }; - this.showLoading(form); - const commentId = this.editingComment.getAttribute('comment'); - window.$http.put(`/comment/${commentId}`, reqData).then(resp => { - const newComment = document.createElement('div'); - newComment.innerHTML = resp.data; - this.editingComment.innerHTML = newComment.children[0].innerHTML; - window.$events.success(this.updatedText); - window.$components.init(this.editingComment); - this.closeUpdateForm(); - this.editingComment = null; - }).catch(window.$events.showValidationErrors).then(() => { - this.hideLoading(form); - }); - } - - deleteComment(commentElem) { - const id = commentElem.getAttribute('comment'); - this.showLoading(commentElem.querySelector('[comment-content]')); - window.$http.delete(`/comment/${id}`).then(() => { - commentElem.parentNode.removeChild(commentElem); - window.$events.success(this.deletedText); + this.elem.addEventListener('page-comment-delete', () => { this.updateCount(); this.hideForm(); }); + + this.elem.addEventListener('page-comment-reply', event => { + this.setReply(event.detail.id, event.detail.element); + }); + + if (this.form) { + this.form.addEventListener('submit', this.saveComment.bind(this)); + } } saveComment(event) { event.preventDefault(); event.stopPropagation(); + + const loading = getLoading(); + loading.classList.add('px-l'); + this.form.after(loading); + this.form.toggleAttribute('hidden', true); + const text = this.formInput.value; const reqData = { text, parent_id: this.parentId || null, }; - this.showLoading(this.form); + window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => { const newElem = htmlToDom(resp.data); - this.container.appendChild(newElem); - window.$components.init(newElem); + this.formContainer.after(newElem); window.$events.success(this.createdText); - this.resetForm(); + this.hideForm(); this.updateCount(); }).catch(err => { + this.form.toggleAttribute('hidden', false); window.$events.showValidationErrors(err); - this.hideLoading(this.form); }); + + this.form.toggleAttribute('hidden', false); + loading.remove(); } updateCount() { - const count = this.container.children.length; - this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count}); + const count = this.getCommentCount(); + this.commentsTitle.textContent = window.trans_plural(this.countText, count, {count}); } resetForm() { this.formInput.value = ''; - this.formContainer.appendChild(this.form); - this.hideForm(); - this.removeReplyTo(); - this.hideLoading(this.form); + this.parentId = null; + this.replyToRow.toggleAttribute('hidden', true); + this.container.append(this.formContainer); } showForm() { - this.formContainer.style.display = 'block'; - this.formContainer.parentNode.style.display = 'block'; - this.addButtonContainer.style.display = 'none'; - this.formInput.focus(); - this.formInput.scrollIntoView({behavior: 'smooth'}); + this.formContainer.toggleAttribute('hidden', false); + this.addButtonContainer.toggleAttribute('hidden', true); + this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + setTimeout(() => { + this.formInput.focus(); + }, 100); } hideForm() { - this.formContainer.style.display = 'none'; - this.formContainer.parentNode.style.display = 'none'; + this.resetForm(); + this.formContainer.toggleAttribute('hidden', true); if (this.getCommentCount() > 0) { - this.elem.appendChild(this.addButtonContainer); + this.elem.append(this.addButtonContainer); } else { - this.commentCountBar.appendChild(this.addButtonContainer); + this.commentCountBar.append(this.addButtonContainer); } - this.addButtonContainer.style.display = 'block'; + this.addButtonContainer.toggleAttribute('hidden', false); } getCommentCount() { - return this.elem.querySelectorAll('.comment-box[comment]').length; + return this.container.querySelectorAll('[component="page-comment"]').length; } - setReply(commentElem) { + setReply(commentLocalId, commentElement) { + const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children'); + targetFormLocation.append(this.formContainer); this.showForm(); - this.parentId = Number(commentElem.getAttribute('local-id')); - this.replyToRow.style.display = 'block'; + this.parentId = commentLocalId; + this.replyToRow.toggleAttribute('hidden', false); const replyLink = this.replyToRow.querySelector('a'); - replyLink.textContent = `#${this.parentId}`; + replyLink.textContent = this.formReplyText.replace('1234', this.parentId); replyLink.href = `#comment${this.parentId}`; } removeReplyTo() { this.parentId = null; - this.replyToRow.style.display = 'none'; - } - - showLoading(formElem) { - const groups = formElem.querySelectorAll('.form-group'); - for (const group of groups) { - group.style.display = 'none'; - } - formElem.querySelector('.form-group.loading').style.display = 'block'; - } - - hideLoading(formElem) { - const groups = formElem.querySelectorAll('.form-group'); - for (const group of groups) { - group.style.display = 'block'; - } - formElem.querySelector('.form-group.loading').style.display = 'none'; + this.replyToRow.toggleAttribute('hidden', true); + this.container.append(this.formContainer); + this.showForm(); } } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 1521e6eaa..321c26e88 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -663,6 +663,12 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } +.comments-container { + padding-inline: $-xl; + @include smaller-than($m) { + padding-inline: $-xs; + } +} .comment-box { border-radius: 4px; border: 1px solid #DDD; @@ -682,26 +688,51 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { &:hover .actions, &:focus-within .actions { opacity: 1; } + .actions button:focus { + outline: 1px dotted var(--color-primary); + } + @include smaller-than($m) { + .actions { + opacity: 1; + } + } } .comment-box .header { - .meta { - img, a, span { - display: inline-block; - vertical-align: top; - } - a, span { - padding: $-xxs 0 $-xxs 0; - line-height: 1.6; - } - a { color: #666; } - span { - padding-inline-start: $-xxs; - } + border-bottom: 1px solid #DDD; + @include lightDark(border-color, #DDD, #000); + button { + font-size: .8rem; + } + a { + color: inherit; } .text-muted { color: #999; } + .right-meta .text-muted { + opacity: .8; + } +} + +.comment-thread-indicator { + border-inline-start: 3px dotted #DDD; + @include lightDark(border-color, #DDD, #444); + margin-inline-start: $-xs; + width: $-l; + height: calc(100% - $-m); +} + +.comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator { + display: none; +} + +.comment-reply { + display: none; +} + +.comment-branch .comment-branch .comment-branch .comment-branch .comment-reply { + display: block; } #tag-manager .drag-card { diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php new file mode 100644 index 000000000..78d19ac3e --- /dev/null +++ b/resources/views/comments/comment-branch.blade.php @@ -0,0 +1,15 @@ +
+
+ @include('comments.comment', ['comment' => $branch['comment']]) +
+
+
+
+
+
+ @foreach($branch['children'] as $childBranch) + @include('comments.comment-branch', ['branch' => $childBranch]) + @endforeach +
+
+
\ No newline at end of file diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 6189c65d4..04468b83c 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -1,78 +1,82 @@ -
+
-
-
- #{{$comment->local_id}} -    +
+
@if ($comment->createdBy) - {{ $comment->createdBy->name }} + {{ $comment->createdBy->name }}   - {{ $comment->createdBy->name }} + {{ $comment->createdBy->getShortName(16) }} @else - {{ trans('common.deleted_user') }} + {{ trans('common.deleted_user') }} @endif - {{ trans('entities.comment_created', ['createDiff' => $comment->created]) }} +  {{ trans('entities.comment_created', ['createDiff' => $comment->created]) }} @if($comment->isUpdated()) - - •  - {{ trans('entities.comment_updated', ['updateDiff' => $comment->updated, 'username' => $comment->updatedBy? $comment->updatedBy->name : trans('common.deleted_user')]) }} - + + + {{ trans('entities.comment_updated_indicator') }} + @endif
-
- @if(userCan('comment-update', $comment)) - - @endif - @if(userCan('comment-create-all')) - - @endif - @if(userCan('comment-delete', $comment)) - - @endif +
+
+ @if(userCan('comment-create-all')) + + @endif + @if(userCan('comment-update', $comment)) + + @endif + @if(userCan('comment-delete', $comment)) + + @endif + +  •  + +
+
- @if ($comment->parent_id) -
- {!! trans('entities.comment_in_reply_to', ['commentId' => '#'.$comment->parent_id.'']) !!} -
- @endif - -
- +
+ @if ($comment->parent_id) +

+ @icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }} +

+ @endif {!! $comment->html !!}
@if(userCan('comment-update', $comment)) - + @endif
\ No newline at end of file diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 140d0d027..b79f0fd45 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -1,34 +1,34 @@
-
-
{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}
- @if (count($page->comments) === 0 && userCan('comment-create-all')) -
-
@endif
- @foreach($page->comments as $comment) - @include('comments.comment', ['comment' => $comment]) + @foreach($commentTree->get() as $branch) + @include('comments.comment-branch', ['branch' => $branch]) @endforeach
@if(userCan('comment-create-all')) @include('comments.create') - @if (count($page->comments) > 0) + @if (!$commentTree->empty())
-
@endif diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php index a5a84b004..cb7905ddc 100644 --- a/resources/views/comments/create.blade.php +++ b/resources/views/comments/create.blade.php @@ -1,32 +1,32 @@ -