diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 3336e17e988..7005f8fcf83 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -4,6 +4,8 @@ use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; +use BookStack\Exceptions\NotifyException; +use BookStack\Exceptions\PrettyException; use BookStack\Facades\Activity as ActivityService; use BookStack\Util\HtmlDescriptionFilter; @@ -20,7 +22,7 @@ public function getById(int $id): Comment /** * Create a new comment on an entity. */ - public function create(Entity $entity, string $html, ?int $parent_id): Comment + public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment { $userId = user()->id; $comment = new Comment(); @@ -29,7 +31,8 @@ public function create(Entity $entity, string $html, ?int $parent_id): Comment $comment->created_by = $userId; $comment->updated_by = $userId; $comment->local_id = $this->getNextLocalId($entity); - $comment->parent_id = $parent_id; + $comment->parent_id = $parentId; + $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : ''; $entity->comments()->save($comment); ActivityService::add(ActivityType::COMMENT_CREATE, $comment); @@ -52,6 +55,41 @@ public function update(Comment $comment, string $html): Comment return $comment; } + + /** + * Archive an existing comment. + */ + public function archive(Comment $comment): Comment + { + if ($comment->parent_id) { + throw new NotifyException('Only top-level comments can be archived.', '/', 400); + } + + $comment->archived = true; + $comment->save(); + + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + + return $comment; + } + + /** + * Un-archive an existing comment. + */ + public function unarchive(Comment $comment): Comment + { + if ($comment->parent_id) { + throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); + } + + $comment->archived = false; + $comment->save(); + + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + + return $comment; + } + /** * Delete a comment from the system. */ diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index 52ccc823864..479d57c4db9 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -3,6 +3,8 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\CommentRepo; +use BookStack\Activity\Tools\CommentTree; +use BookStack\Activity\Tools\CommentTreeNode; use BookStack\Entities\Queries\PageQueries; use BookStack\Http\Controller; use Illuminate\Http\Request; @@ -26,6 +28,7 @@ public function savePageComment(Request $request, int $pageId) $input = $this->validate($request, [ 'html' => ['required', 'string'], 'parent_id' => ['nullable', 'integer'], + 'content_ref' => ['string'], ]); $page = $this->pageQueries->findVisibleById($pageId); @@ -40,14 +43,12 @@ public function savePageComment(Request $request, int $pageId) // Create a new comment. $this->checkPermission('comment-create-all'); - $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null); + $contentRef = $input['content_ref'] ?? ''; + $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef); return view('comments.comment-branch', [ 'readOnly' => false, - 'branch' => [ - 'comment' => $comment, - 'children' => [], - ] + 'branch' => new CommentTreeNode($comment, 0, []), ]); } @@ -74,6 +75,46 @@ public function update(Request $request, int $commentId) ]); } + /** + * Mark a comment as archived. + */ + public function archive(int $id) + { + $comment = $this->commentRepo->getById($id); + $this->checkOwnablePermission('page-view', $comment->entity); + if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { + $this->showPermissionError(); + } + + $this->commentRepo->archive($comment); + + $tree = new CommentTree($comment->entity); + return view('comments.comment-branch', [ + 'readOnly' => false, + 'branch' => $tree->getCommentNodeForId($id), + ]); + } + + /** + * Unmark a comment as archived. + */ + public function unarchive(int $id) + { + $comment = $this->commentRepo->getById($id); + $this->checkOwnablePermission('page-view', $comment->entity); + if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { + $this->showPermissionError(); + } + + $this->commentRepo->unarchive($comment); + + $tree = new CommentTree($comment->entity); + return view('comments.comment-branch', [ + 'readOnly' => false, + 'branch' => $tree->getCommentNodeForId($id), + ]); + } + /** * Delete a comment from the system. */ diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index d0385d3962f..91cea4fe0e3 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -19,6 +19,8 @@ * @property int $entity_id * @property int $created_by * @property int $updated_by + * @property string $content_ref + * @property bool $archived */ class Comment extends Model implements Loggable { diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 16f6804ea42..a05a9d24726 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -9,7 +9,7 @@ class CommentTree { /** * The built nested tree structure array. - * @var array{comment: Comment, depth: int, children: array}[] + * @var CommentTreeNode[] */ protected array $tree; protected array $comments; @@ -28,7 +28,7 @@ public function enabled(): bool public function empty(): bool { - return count($this->tree) === 0; + return count($this->getActive()) === 0; } public function count(): int @@ -36,9 +36,35 @@ public function count(): int return count($this->comments); } - public function get(): array + public function getActive(): array { - return $this->tree; + return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived); + } + + public function activeThreadCount(): int + { + return count($this->getActive()); + } + + public function getArchived(): array + { + return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived); + } + + public function archivedThreadCount(): int + { + return count($this->getArchived()); + } + + public function getCommentNodeForId(int $commentId): ?CommentTreeNode + { + foreach ($this->tree as $node) { + if ($node->comment->id === $commentId) { + return $node; + } + } + + return null; } public function canUpdateAny(): bool @@ -54,6 +80,7 @@ public function canUpdateAny(): bool /** * @param Comment[] $comments + * @return CommentTreeNode[] */ protected function createTree(array $comments): array { @@ -77,26 +104,22 @@ protected function createTree(array $comments): array $tree = []; foreach ($childMap[0] ?? [] as $childId) { - $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap); + $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap); } return $tree; } - protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array + protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode { $childIds = $childMap[$id] ?? []; $children = []; foreach ($childIds as $childId) { - $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap); + $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap); } - return [ - 'comment' => $byId[$id], - 'depth' => $depth, - 'children' => $children, - ]; + return new CommentTreeNode($byId[$id], $depth, $children); } protected function loadComments(): array diff --git a/app/Activity/Tools/CommentTreeNode.php b/app/Activity/Tools/CommentTreeNode.php new file mode 100644 index 00000000000..7b280bd2d95 --- /dev/null +++ b/app/Activity/Tools/CommentTreeNode.php @@ -0,0 +1,23 @@ +<?php + +namespace BookStack\Activity\Tools; + +use BookStack\Activity\Models\Comment; + +class CommentTreeNode +{ + public Comment $comment; + public int $depth; + + /** + * @var CommentTreeNode[] + */ + public array $children; + + public function __construct(Comment $comment, int $depth, array $children) + { + $this->comment = $comment; + $this->depth = $depth; + $this->children = $children; + } +} diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index efbd183b31d..844bc399381 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -27,6 +27,8 @@ public function definition() 'html' => $html, 'parent_id' => null, 'local_id' => 1, + 'content_ref' => '', + 'archived' => false, ]; } } diff --git a/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php new file mode 100644 index 00000000000..794201dec8a --- /dev/null +++ b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php @@ -0,0 +1,30 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->string('content_ref'); + $table->boolean('archived')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->dropColumn('content_ref'); + $table->dropColumn('archived'); + }); + } +}; diff --git a/lang/en/common.php b/lang/en/common.php index b05169bb2c4..06a9e855ce3 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -30,6 +30,8 @@ 'create' => 'Create', 'update' => 'Update', 'edit' => 'Edit', + 'archive' => 'Archive', + 'unarchive' => 'Un-Archive', 'sort' => 'Sort', 'move' => 'Move', 'copy' => 'Copy', diff --git a/lang/en/entities.php b/lang/en/entities.php index a74785eaacd..6e616ded452 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -392,8 +392,11 @@ 'comment' => 'Comment', 'comments' => 'Comments', 'comment_add' => 'Add Comment', + 'comment_none' => 'No comments to display', 'comment_placeholder' => 'Leave a comment here', - 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', + 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', + 'comment_archived_count' => ':count Archived', + 'comment_archived_threads' => 'Archived Threads', 'comment_save' => 'Save Comment', 'comment_new' => 'New Comment', 'comment_created' => 'commented :createDiff', @@ -402,8 +405,14 @@ 'comment_deleted_success' => 'Comment deleted', 'comment_created_success' => 'Comment added', 'comment_updated_success' => 'Comment updated', + 'comment_archive_success' => 'Comment archived', + 'comment_unarchive_success' => 'Comment un-archived', + 'comment_view' => 'View comment', + 'comment_jump_to_thread' => 'Jump to thread', 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', 'comment_in_reply_to' => 'In reply to :commentId', + 'comment_reference' => 'Reference', + 'comment_reference_outdated' => '(Outdated)', 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', // Revision diff --git a/resources/icons/archive.svg b/resources/icons/archive.svg new file mode 100644 index 00000000000..90a4f35b7e9 --- /dev/null +++ b/resources/icons/archive.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg> \ No newline at end of file diff --git a/resources/icons/bookmark.svg b/resources/icons/bookmark.svg new file mode 100644 index 00000000000..30e487c5219 --- /dev/null +++ b/resources/icons/bookmark.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z"/></svg> \ No newline at end of file diff --git a/resources/js/components/editor-toolbox.js b/resources/js/components/editor-toolbox.ts similarity index 57% rename from resources/js/components/editor-toolbox.js rename to resources/js/components/editor-toolbox.ts index ddb4ff39c76..60bdde05efb 100644 --- a/resources/js/components/editor-toolbox.js +++ b/resources/js/components/editor-toolbox.ts @@ -1,42 +1,58 @@ import {Component} from './component'; +export interface EditorToolboxChangeEventData { + tab: string; + open: boolean; +} + export class EditorToolbox extends Component { + protected container!: HTMLElement; + protected buttons!: HTMLButtonElement[]; + protected contentElements!: HTMLElement[]; + protected toggleButton!: HTMLElement; + protected editorWrapEl!: HTMLElement; + + protected open: boolean = false; + protected tab: string = ''; + setup() { // Elements this.container = this.$el; - this.buttons = this.$manyRefs.tabButton; + this.buttons = this.$manyRefs.tabButton as HTMLButtonElement[]; this.contentElements = this.$manyRefs.tabContent; this.toggleButton = this.$refs.toggle; - this.editorWrapEl = this.container.closest('.page-editor'); + this.editorWrapEl = this.container.closest('.page-editor') as HTMLElement; this.setupListeners(); // Set the first tab as active on load - this.setActiveTab(this.contentElements[0].dataset.tabContent); + this.setActiveTab(this.contentElements[0].dataset.tabContent || ''); } - setupListeners() { + protected setupListeners(): void { // Toolbox toggle button click this.toggleButton.addEventListener('click', () => this.toggle()); // Tab button click - this.container.addEventListener('click', event => { - const button = event.target.closest('button'); - if (this.buttons.includes(button)) { - const name = button.dataset.tab; + this.container.addEventListener('click', (event: MouseEvent) => { + const button = (event.target as HTMLElement).closest('button'); + if (button instanceof HTMLButtonElement && this.buttons.includes(button)) { + const name = button.dataset.tab || ''; this.setActiveTab(name, true); } }); } - toggle() { + protected toggle(): void { this.container.classList.toggle('open'); const isOpen = this.container.classList.contains('open'); this.toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); this.editorWrapEl.classList.toggle('toolbox-open', isOpen); + this.open = isOpen; + this.emitState(); } - setActiveTab(tabName, openToolbox = false) { + protected setActiveTab(tabName: string, openToolbox: boolean = false): void { // Set button visibility for (const button of this.buttons) { button.classList.remove('active'); @@ -54,6 +70,14 @@ export class EditorToolbox extends Component { if (openToolbox && !this.container.classList.contains('open')) { this.toggle(); } + + this.tab = tabName; + this.emitState(); + } + + protected emitState(): void { + const data: EditorToolboxChangeEventData = {tab: this.tab, open: this.open}; + this.$emit('change', data); } } diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 10b8025db63..63e1ad0dbf7 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -36,6 +36,7 @@ export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; export {OptionalInput} from './optional-input'; export {PageComment} from './page-comment'; +export {PageCommentReference} from './page-comment-reference'; export {PageComments} from './page-comments'; export {PageDisplay} from './page-display'; export {PageEditor} from './page-editor'; diff --git a/resources/js/components/page-comment-reference.ts b/resources/js/components/page-comment-reference.ts new file mode 100644 index 00000000000..009e806c104 --- /dev/null +++ b/resources/js/components/page-comment-reference.ts @@ -0,0 +1,251 @@ +import {Component} from "./component"; +import {findTargetNodeAndOffset, hashElement} from "../services/dom"; +import {el} from "../wysiwyg/utils/dom"; +import commentIcon from "@icons/comment.svg"; +import closeIcon from "@icons/close.svg"; +import {debounce, scrollAndHighlightElement} from "../services/util"; +import {EditorToolboxChangeEventData} from "./editor-toolbox"; +import {TabsChangeEvent} from "./tabs"; + +/** + * Track the close function for the current open marker so it can be closed + * when another is opened so we only show one marker comment thread at one time. + */ +let openMarkerClose: Function|null = null; + +export class PageCommentReference extends Component { + protected link!: HTMLLinkElement; + protected reference!: string; + protected markerWrap: HTMLElement|null = null; + + protected viewCommentText!: string; + protected jumpToThreadText!: string; + protected closeText!: string; + + setup() { + this.link = this.$el as HTMLLinkElement; + this.reference = this.$opts.reference; + this.viewCommentText = this.$opts.viewCommentText; + this.jumpToThreadText = this.$opts.jumpToThreadText; + this.closeText = this.$opts.closeText; + + // Show within page display area if seen + this.showForDisplay(); + + // Handle editor view to show on comments toolbox view + window.addEventListener('editor-toolbox-change', ((event: CustomEvent<EditorToolboxChangeEventData>) => { + const tabName: string = event.detail.tab; + const isOpen = event.detail.open; + if (tabName === 'comments' && isOpen && this.link.checkVisibility()) { + this.showForEditor(); + } else { + this.hideMarker(); + } + }) as EventListener); + + // Handle visibility changes within editor toolbox archived details dropdown + window.addEventListener('toggle', event => { + if (event.target instanceof HTMLElement && event.target.contains(this.link)) { + window.requestAnimationFrame(() => { + if (this.link.checkVisibility()) { + this.showForEditor(); + } else { + this.hideMarker(); + } + }); + } + }, {capture: true}); + + // Handle comments tab changes to hide/show markers & indicators + window.addEventListener('tabs-change', ((event: CustomEvent<TabsChangeEvent>) => { + const sectionId = event.detail.showing; + if (!sectionId.startsWith('comment-tab-panel')) { + return; + } + + const panel = document.getElementById(sectionId); + if (panel?.contains(this.link)) { + this.showForDisplay(); + } else { + this.hideMarker(); + } + }) as EventListener); + } + + public showForDisplay() { + const pageContentArea = document.querySelector('.page-content'); + if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) { + this.updateMarker(pageContentArea); + } + } + + protected showForEditor() { + const contentWrap = document.querySelector('.editor-content-wrap'); + if (contentWrap instanceof HTMLElement) { + this.updateMarker(contentWrap); + } + + const onChange = () => { + this.hideMarker(); + setTimeout(() => { + window.$events.remove('editor-html-change', onChange); + }, 1); + }; + + window.$events.listen('editor-html-change', onChange); + } + + protected updateMarker(contentContainer: HTMLElement) { + // Reset link and existing marker + this.link.classList.remove('outdated', 'missing'); + if (this.markerWrap) { + this.markerWrap.remove(); + } + + const [refId, refHash, refRange] = this.reference.split(':'); + const refEl = document.getElementById(refId); + if (!refEl) { + this.link.classList.add('outdated', 'missing'); + return; + } + + const actualHash = hashElement(refEl); + if (actualHash !== refHash) { + this.link.classList.add('outdated'); + } + + const marker = el('button', { + type: 'button', + class: 'content-comment-marker', + title: this.viewCommentText, + }); + marker.innerHTML = <string>commentIcon; + marker.addEventListener('click', event => { + this.showCommentAtMarker(marker); + }); + + this.markerWrap = el('div', { + class: 'content-comment-highlight', + }, [marker]); + + contentContainer.append(this.markerWrap); + this.positionMarker(refEl, refRange); + + this.link.href = `#${refEl.id}`; + this.link.addEventListener('click', (event: MouseEvent) => { + event.preventDefault(); + scrollAndHighlightElement(refEl); + }); + + const debouncedReposition = debounce(() => { + this.positionMarker(refEl, refRange); + }, 50, false).bind(this); + window.addEventListener('resize', debouncedReposition); + } + + protected positionMarker(targetEl: HTMLElement, range: string) { + if (!this.markerWrap) { + return; + } + + const markerParent = this.markerWrap.parentElement as HTMLElement; + const parentBounds = markerParent.getBoundingClientRect(); + let targetBounds = targetEl.getBoundingClientRect(); + const [rangeStart, rangeEnd] = range.split('-'); + if (rangeStart && rangeEnd) { + const range = new Range(); + const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart)); + const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd)); + if (relStart && relEnd) { + range.setStart(relStart.node, relStart.offset); + range.setEnd(relEnd.node, relEnd.offset); + targetBounds = range.getBoundingClientRect(); + } + } + + const relLeft = targetBounds.left - parentBounds.left; + const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop; + + this.markerWrap.style.left = `${relLeft}px`; + this.markerWrap.style.top = `${relTop}px`; + this.markerWrap.style.width = `${targetBounds.width}px`; + this.markerWrap.style.height = `${targetBounds.height}px`; + } + + public hideMarker() { + // Hide marker and close existing marker windows + if (openMarkerClose) { + openMarkerClose(); + } + this.markerWrap?.remove(); + this.markerWrap = null; + } + + protected showCommentAtMarker(marker: HTMLElement): void { + // Hide marker and close existing marker windows + if (openMarkerClose) { + openMarkerClose(); + } + marker.hidden = true; + + // Locate relevant comment + const commentBox = this.link.closest('.comment-box') as HTMLElement; + + // Build comment window + const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement; + const toRemove = readClone.querySelectorAll('.actions, form'); + for (const el of toRemove) { + el.remove(); + } + + const close = el('button', {type: 'button', title: this.closeText}); + close.innerHTML = (closeIcon as string); + const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]); + + const commentWindow = el('div', { + class: 'content-comment-window' + }, [ + el('div', { + class: 'content-comment-window-actions', + }, [jump, close]), + el('div', { + class: 'content-comment-window-content comment-container-compact comment-container-super-compact', + }, [readClone]), + ]); + + marker.parentElement?.append(commentWindow); + + // Handle interaction within window + const closeAction = () => { + commentWindow.remove(); + marker.hidden = false; + window.removeEventListener('click', windowCloseAction); + openMarkerClose = null; + }; + + const windowCloseAction = (event: MouseEvent) => { + if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) { + closeAction(); + } + }; + window.addEventListener('click', windowCloseAction); + + openMarkerClose = closeAction; + close.addEventListener('click', closeAction.bind(this)); + jump.addEventListener('click', () => { + closeAction(); + commentBox.scrollIntoView({behavior: 'smooth'}); + const highlightTarget = commentBox.querySelector('.header') as HTMLElement; + highlightTarget.classList.add('anim-highlight'); + highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight')) + }); + + // Position window within bounds + const commentWindowBounds = commentWindow.getBoundingClientRect(); + const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect(); + if (contentBounds && commentWindowBounds.right > contentBounds.right) { + const diff = commentWindowBounds.right - contentBounds.right; + commentWindow.style.left = `-${diff}px`; + } + } +} \ No newline at end of file diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js deleted file mode 100644 index 8c0a8b33e54..00000000000 --- a/resources/js/components/page-comment.js +++ /dev/null @@ -1,119 +0,0 @@ -import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom.ts'; -import {buildForInput} from '../wysiwyg-tinymce/config'; - -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; - - // Editor reference and text options - this.wysiwygEditor = null; - this.wysiwygLanguage = this.$opts.wysiwygLanguage; - this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; - - // 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); - - if (this.wysiwygEditor) { - this.wysiwygEditor.focus(); - return; - } - - const config = buildForInput({ - language: this.wysiwygLanguage, - containerElement: this.input, - darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.wysiwygTextDirection, - translations: {}, - translationMap: window.editor_translations, - }); - - window.tinymce.init(config).then(editors => { - this.wysiwygEditor = editors[0]; - setTimeout(() => this.wysiwygEditor.focus(), 50); - }); - } - - async update(event) { - event.preventDefault(); - const loading = this.showLoading(); - this.form.toggleAttribute('hidden', true); - - const reqData = { - html: this.wysiwygEditor.getContent(), - 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.$emit('delete'); - this.container.closest('.comment-branch').remove(); - window.$events.success(this.deletedText); - } - - showLoading() { - const loading = getLoading(); - loading.classList.add('px-l'); - this.container.append(loading); - return loading; - } - -} diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts new file mode 100644 index 00000000000..0c3e19f4b1a --- /dev/null +++ b/resources/js/components/page-comment.ts @@ -0,0 +1,184 @@ +import {Component} from './component'; +import {getLoading, htmlToDom} from '../services/dom'; +import {buildForInput} from '../wysiwyg-tinymce/config'; +import {PageCommentReference} from "./page-comment-reference"; +import {HttpError} from "../services/http"; + +export interface PageCommentReplyEventData { + id: string; // ID of comment being replied to + element: HTMLElement; // Container for comment replied to +} + +export interface PageCommentArchiveEventData { + new_thread_dom: HTMLElement; +} + +export class PageComment extends Component { + + protected commentId!: string; + protected commentLocalId!: string; + protected deletedText!: string; + protected updatedText!: string; + protected archiveText!: string; + + protected wysiwygEditor: any = null; + protected wysiwygLanguage!: string; + protected wysiwygTextDirection!: string; + + protected container!: HTMLElement; + protected contentContainer!: HTMLElement; + protected form!: HTMLFormElement; + protected formCancel!: HTMLElement; + protected editButton!: HTMLElement; + protected deleteButton!: HTMLElement; + protected replyButton!: HTMLElement; + protected archiveButton!: HTMLElement; + protected input!: HTMLInputElement; + + setup() { + // Options + this.commentId = this.$opts.commentId; + this.commentLocalId = this.$opts.commentLocalId; + this.deletedText = this.$opts.deletedText; + this.deletedText = this.$opts.deletedText; + this.archiveText = this.$opts.archiveText; + + // Editor reference and text options + this.wysiwygLanguage = this.$opts.wysiwygLanguage; + this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; + + // Element references + this.container = this.$el; + this.contentContainer = this.$refs.contentContainer; + this.form = this.$refs.form as HTMLFormElement; + this.formCancel = this.$refs.formCancel; + this.editButton = this.$refs.editButton; + this.deleteButton = this.$refs.deleteButton; + this.replyButton = this.$refs.replyButton; + this.archiveButton = this.$refs.archiveButton; + this.input = this.$refs.input as HTMLInputElement; + + this.setupListeners(); + } + + protected setupListeners(): void { + if (this.replyButton) { + const data: PageCommentReplyEventData = { + id: this.commentLocalId, + element: this.container, + }; + this.replyButton.addEventListener('click', () => this.$emit('reply', data)); + } + + 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)); + } + + if (this.archiveButton) { + this.archiveButton.addEventListener('click', this.archive.bind(this)); + } + } + + protected toggleEditMode(show: boolean) : void { + this.contentContainer.toggleAttribute('hidden', show); + this.form.toggleAttribute('hidden', !show); + } + + protected startEdit() : void { + this.toggleEditMode(true); + + if (this.wysiwygEditor) { + this.wysiwygEditor.focus(); + return; + } + + const config = buildForInput({ + language: this.wysiwygLanguage, + containerElement: this.input, + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.wysiwygTextDirection, + drawioUrl: '', + pageId: 0, + translations: {}, + translationMap: (window as unknown as Record<string, Object>).editor_translations, + }); + + (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => { + this.wysiwygEditor = editors[0]; + setTimeout(() => this.wysiwygEditor.focus(), 50); + }); + } + + protected async update(event: Event): Promise<void> { + event.preventDefault(); + const loading = this.showLoading(); + this.form.toggleAttribute('hidden', true); + + const reqData = { + html: this.wysiwygEditor.getContent(), + }; + + try { + const resp = await window.$http.put(`/comment/${this.commentId}`, reqData); + const newComment = htmlToDom(resp.data as string); + this.container.replaceWith(newComment); + window.$events.success(this.updatedText); + } catch (err) { + console.error(err); + if (err instanceof HttpError) { + window.$events.showValidationErrors(err); + } + this.form.toggleAttribute('hidden', false); + loading.remove(); + } + } + + protected async delete(): Promise<void> { + this.showLoading(); + + await window.$http.delete(`/comment/${this.commentId}`); + this.$emit('delete'); + + const branch = this.container.closest('.comment-branch'); + if (branch instanceof HTMLElement) { + const refs = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference'); + for (const ref of refs) { + ref.hideMarker(); + } + branch.remove(); + } + + window.$events.success(this.deletedText); + } + + protected async archive(): Promise<void> { + this.showLoading(); + const isArchived = this.archiveButton.dataset.isArchived === 'true'; + const action = isArchived ? 'unarchive' : 'archive'; + + const response = await window.$http.put(`/comment/${this.commentId}/${action}`); + window.$events.success(this.archiveText); + const eventData: PageCommentArchiveEventData = {new_thread_dom: htmlToDom(response.data as string)}; + this.$emit(action, eventData); + + const branch = this.container.closest('.comment-branch') as HTMLElement; + const references = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference'); + for (const reference of references) { + reference.hideMarker(); + } + branch.remove(); + } + + protected showLoading(): HTMLElement { + 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 deleted file mode 100644 index 8f023836b09..00000000000 --- a/resources/js/components/page-comments.js +++ /dev/null @@ -1,175 +0,0 @@ -import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom.ts'; -import {buildForInput} from '../wysiwyg-tinymce/config'; - -export class PageComments extends Component { - - setup() { - this.elem = this.$el; - this.pageId = Number(this.$opts.pageId); - - // Element references - this.container = this.$refs.commentContainer; - 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; - - // WYSIWYG options - this.wysiwygLanguage = this.$opts.wysiwygLanguage; - this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; - this.wysiwygEditor = null; - - // Translations - this.createdText = this.$opts.createdText; - this.countText = this.$opts.countText; - - // Internal State - this.parentId = null; - this.formReplyText = this.formReplyLink?.textContent || ''; - - this.setupListeners(); - } - - setupListeners() { - this.elem.addEventListener('page-comment-delete', () => { - setTimeout(() => this.updateCount(), 1); - this.hideForm(); - }); - - this.elem.addEventListener('page-comment-reply', event => { - this.setReply(event.detail.id, event.detail.element); - }); - - if (this.form) { - this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this)); - this.hideFormButton.addEventListener('click', this.hideForm.bind(this)); - this.addCommentButton.addEventListener('click', this.showForm.bind(this)); - 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 reqData = { - html: this.wysiwygEditor.getContent(), - parent_id: this.parentId || null, - }; - - window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => { - const newElem = htmlToDom(resp.data); - - if (reqData.parent_id) { - this.formContainer.after(newElem); - } else { - this.container.append(newElem); - } - - window.$events.success(this.createdText); - this.hideForm(); - this.updateCount(); - }).catch(err => { - this.form.toggleAttribute('hidden', false); - window.$events.showValidationErrors(err); - }); - - this.form.toggleAttribute('hidden', false); - loading.remove(); - } - - updateCount() { - const count = this.getCommentCount(); - this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count}); - } - - resetForm() { - this.removeEditor(); - this.formInput.value = ''; - this.parentId = null; - this.replyToRow.toggleAttribute('hidden', true); - this.container.append(this.formContainer); - } - - showForm() { - this.removeEditor(); - this.formContainer.toggleAttribute('hidden', false); - this.addButtonContainer.toggleAttribute('hidden', true); - this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - this.loadEditor(); - } - - hideForm() { - this.resetForm(); - this.formContainer.toggleAttribute('hidden', true); - if (this.getCommentCount() > 0) { - this.elem.append(this.addButtonContainer); - } else { - this.commentCountBar.append(this.addButtonContainer); - } - this.addButtonContainer.toggleAttribute('hidden', false); - } - - loadEditor() { - if (this.wysiwygEditor) { - this.wysiwygEditor.focus(); - return; - } - - const config = buildForInput({ - language: this.wysiwygLanguage, - containerElement: this.formInput, - darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.wysiwygTextDirection, - translations: {}, - translationMap: window.editor_translations, - }); - - window.tinymce.init(config).then(editors => { - this.wysiwygEditor = editors[0]; - setTimeout(() => this.wysiwygEditor.focus(), 50); - }); - } - - removeEditor() { - if (this.wysiwygEditor) { - this.wysiwygEditor.remove(); - this.wysiwygEditor = null; - } - } - - getCommentCount() { - return this.container.querySelectorAll('[component="page-comment"]').length; - } - - setReply(commentLocalId, commentElement) { - const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children'); - targetFormLocation.append(this.formContainer); - this.showForm(); - this.parentId = commentLocalId; - this.replyToRow.toggleAttribute('hidden', false); - this.formReplyLink.textContent = this.formReplyText.replace('1234', this.parentId); - this.formReplyLink.href = `#comment${this.parentId}`; - } - - removeReplyTo() { - this.parentId = null; - this.replyToRow.toggleAttribute('hidden', true); - this.container.append(this.formContainer); - this.showForm(); - } - -} diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts new file mode 100644 index 00000000000..94f5ab3bb8e --- /dev/null +++ b/resources/js/components/page-comments.ts @@ -0,0 +1,260 @@ +import {Component} from './component'; +import {getLoading, htmlToDom} from '../services/dom'; +import {buildForInput} from '../wysiwyg-tinymce/config'; +import {Tabs} from "./tabs"; +import {PageCommentReference} from "./page-comment-reference"; +import {scrollAndHighlightElement} from "../services/util"; +import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment"; + +export class PageComments extends Component { + + private elem!: HTMLElement; + private pageId!: number; + private container!: HTMLElement; + private commentCountBar!: HTMLElement; + private activeTab!: HTMLElement; + private archivedTab!: HTMLElement; + private addButtonContainer!: HTMLElement; + private archiveContainer!: HTMLElement; + private replyToRow!: HTMLElement; + private referenceRow!: HTMLElement; + private formContainer!: HTMLElement; + private form!: HTMLFormElement; + private formInput!: HTMLInputElement; + private formReplyLink!: HTMLAnchorElement; + private formReferenceLink!: HTMLAnchorElement; + private addCommentButton!: HTMLElement; + private hideFormButton!: HTMLElement; + private removeReplyToButton!: HTMLElement; + private removeReferenceButton!: HTMLElement; + private wysiwygLanguage!: string; + private wysiwygTextDirection!: string; + private wysiwygEditor: any = null; + private createdText!: string; + private countText!: string; + private archivedCountText!: string; + private parentId: number | null = null; + private contentReference: string = ''; + private formReplyText: string = ''; + + setup() { + this.elem = this.$el; + this.pageId = Number(this.$opts.pageId); + + // Element references + this.container = this.$refs.commentContainer; + this.commentCountBar = this.$refs.commentCountBar; + this.activeTab = this.$refs.activeTab; + this.archivedTab = this.$refs.archivedTab; + this.addButtonContainer = this.$refs.addButtonContainer; + this.archiveContainer = this.$refs.archiveContainer; + this.replyToRow = this.$refs.replyToRow; + this.referenceRow = this.$refs.referenceRow; + this.formContainer = this.$refs.formContainer; + this.form = this.$refs.form as HTMLFormElement; + this.formInput = this.$refs.formInput as HTMLInputElement; + this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement; + this.formReferenceLink = this.$refs.formReferenceLink as HTMLAnchorElement; + this.addCommentButton = this.$refs.addCommentButton; + this.hideFormButton = this.$refs.hideFormButton; + this.removeReplyToButton = this.$refs.removeReplyToButton; + this.removeReferenceButton = this.$refs.removeReferenceButton; + + // WYSIWYG options + this.wysiwygLanguage = this.$opts.wysiwygLanguage; + this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; + + // Translations + this.createdText = this.$opts.createdText; + this.countText = this.$opts.countText; + this.archivedCountText = this.$opts.archivedCountText; + + this.formReplyText = this.formReplyLink?.textContent || ''; + + this.setupListeners(); + } + + protected setupListeners(): void { + this.elem.addEventListener('page-comment-delete', () => { + setTimeout(() => this.updateCount(), 1); + this.hideForm(); + }); + + this.elem.addEventListener('page-comment-reply', ((event: CustomEvent<PageCommentReplyEventData>) => { + this.setReply(event.detail.id, event.detail.element); + }) as EventListener); + + this.elem.addEventListener('page-comment-archive', ((event: CustomEvent<PageCommentArchiveEventData>) => { + this.archiveContainer.append(event.detail.new_thread_dom); + setTimeout(() => this.updateCount(), 1); + }) as EventListener); + + this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent<PageCommentArchiveEventData>) => { + this.container.append(event.detail.new_thread_dom); + setTimeout(() => this.updateCount(), 1); + }) as EventListener); + + if (this.form) { + this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this)); + this.removeReferenceButton.addEventListener('click', () => this.setContentReference('')); + this.hideFormButton.addEventListener('click', this.hideForm.bind(this)); + this.addCommentButton.addEventListener('click', this.showForm.bind(this)); + this.form.addEventListener('submit', this.saveComment.bind(this)); + } + } + + protected saveComment(event: SubmitEvent): void { + event.preventDefault(); + event.stopPropagation(); + + const loading = getLoading(); + loading.classList.add('px-l'); + this.form.after(loading); + this.form.toggleAttribute('hidden', true); + + const reqData = { + html: this.wysiwygEditor.getContent(), + parent_id: this.parentId || null, + content_ref: this.contentReference, + }; + + window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => { + const newElem = htmlToDom(resp.data as string); + + if (reqData.parent_id) { + this.formContainer.after(newElem); + } else { + this.container.append(newElem); + } + + const refs = window.$components.allWithinElement<PageCommentReference>(newElem, 'page-comment-reference'); + for (const ref of refs) { + ref.showForDisplay(); + } + + window.$events.success(this.createdText); + this.hideForm(); + this.updateCount(); + }).catch(err => { + this.form.toggleAttribute('hidden', false); + window.$events.showValidationErrors(err); + }); + + this.form.toggleAttribute('hidden', false); + loading.remove(); + } + + protected updateCount(): void { + const activeCount = this.getActiveThreadCount(); + this.activeTab.textContent = window.$trans.choice(this.countText, activeCount); + const archivedCount = this.getArchivedThreadCount(); + this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount); + } + + protected resetForm(): void { + this.removeEditor(); + this.formInput.value = ''; + this.setContentReference(''); + this.removeReplyTo(); + } + + protected showForm(): void { + this.removeEditor(); + this.formContainer.toggleAttribute('hidden', false); + this.addButtonContainer.toggleAttribute('hidden', true); + this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + this.loadEditor(); + + // Ensure the active comments tab is displaying + const tabs = window.$components.firstOnElement(this.elem, 'tabs'); + if (tabs instanceof Tabs) { + tabs.show('comment-tab-panel-active'); + } + } + + protected hideForm(): void { + this.resetForm(); + this.formContainer.toggleAttribute('hidden', true); + if (this.getActiveThreadCount() > 0) { + this.elem.append(this.addButtonContainer); + } else { + this.commentCountBar.append(this.addButtonContainer); + } + this.addButtonContainer.toggleAttribute('hidden', false); + } + + protected loadEditor(): void { + if (this.wysiwygEditor) { + this.wysiwygEditor.focus(); + return; + } + + const config = buildForInput({ + language: this.wysiwygLanguage, + containerElement: this.formInput, + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.wysiwygTextDirection, + drawioUrl: '', + pageId: 0, + translations: {}, + translationMap: (window as unknown as Record<string, Object>).editor_translations, + }); + + (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => { + this.wysiwygEditor = editors[0]; + setTimeout(() => this.wysiwygEditor.focus(), 50); + }); + } + + protected removeEditor(): void { + if (this.wysiwygEditor) { + this.wysiwygEditor.remove(); + this.wysiwygEditor = null; + } + } + + protected getActiveThreadCount(): number { + return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length; + } + + protected getArchivedThreadCount(): number { + return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length; + } + + protected setReply(commentLocalId: string, commentElement: HTMLElement): void { + const targetFormLocation = (commentElement.closest('.comment-branch') as HTMLElement).querySelector('.comment-branch-children') as HTMLElement; + targetFormLocation.append(this.formContainer); + this.showForm(); + this.parentId = Number(commentLocalId); + this.replyToRow.toggleAttribute('hidden', false); + this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId)); + this.formReplyLink.href = `#comment${this.parentId}`; + } + + protected removeReplyTo(): void { + this.parentId = null; + this.replyToRow.toggleAttribute('hidden', true); + this.container.append(this.formContainer); + this.showForm(); + } + + public startNewComment(contentReference: string): void { + this.removeReplyTo(); + this.setContentReference(contentReference); + } + + protected setContentReference(reference: string): void { + this.contentReference = reference; + this.referenceRow.toggleAttribute('hidden', !Boolean(reference)); + const [id] = reference.split(':'); + this.formReferenceLink.href = `#${id}`; + this.formReferenceLink.onclick = function(event) { + event.preventDefault(); + const el = document.getElementById(id); + if (el) { + scrollAndHighlightElement(el); + } + }; + } + +} diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.ts similarity index 50% rename from resources/js/components/pointer.js rename to resources/js/components/pointer.ts index 292b923e551..4b927045aae 100644 --- a/resources/js/components/pointer.js +++ b/resources/js/components/pointer.ts @@ -1,25 +1,39 @@ -import * as DOM from '../services/dom.ts'; +import * as DOM from '../services/dom'; import {Component} from './component'; -import {copyTextToClipboard} from '../services/clipboard.ts'; +import {copyTextToClipboard} from '../services/clipboard'; +import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom"; +import {PageComments} from "./page-comments"; export class Pointer extends Component { + protected showing: boolean = false; + protected isMakingSelection: boolean = false; + protected targetElement: HTMLElement|null = null; + protected targetSelectionRange: Range|null = null; + + protected pointer!: HTMLElement; + protected linkInput!: HTMLInputElement; + protected linkButton!: HTMLElement; + protected includeInput!: HTMLInputElement; + protected includeButton!: HTMLElement; + protected sectionModeButton!: HTMLElement; + protected commentButton!: HTMLElement; + protected modeToggles!: HTMLElement[]; + protected modeSections!: HTMLElement[]; + protected pageId!: string; + setup() { - this.container = this.$el; this.pointer = this.$refs.pointer; - this.linkInput = this.$refs.linkInput; + this.linkInput = this.$refs.linkInput as HTMLInputElement; this.linkButton = this.$refs.linkButton; - this.includeInput = this.$refs.includeInput; + this.includeInput = this.$refs.includeInput as HTMLInputElement; this.includeButton = this.$refs.includeButton; this.sectionModeButton = this.$refs.sectionModeButton; + this.commentButton = this.$refs.commentButton; this.modeToggles = this.$manyRefs.modeToggle; this.modeSections = this.$manyRefs.modeSection; this.pageId = this.$opts.pageId; - // Instance variables - this.showing = false; - this.isSelection = false; - this.setupListeners(); } @@ -30,7 +44,7 @@ export class Pointer extends Component { // Select all contents on input click DOM.onSelect([this.includeInput, this.linkInput], event => { - event.target.select(); + (event.target as HTMLInputElement).select(); event.stopPropagation(); }); @@ -41,7 +55,7 @@ export class Pointer extends Component { // Hide pointer when clicking away DOM.onEvents(document.body, ['click', 'focus'], () => { - if (!this.showing || this.isSelection) return; + if (!this.showing || this.isMakingSelection) return; this.hidePointer(); }); @@ -52,9 +66,10 @@ export class Pointer extends Component { const pageContent = document.querySelector('.page-content'); DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => { event.stopPropagation(); - const targetEl = event.target.closest('[id^="bkmrk"]'); - if (targetEl && window.getSelection().toString().length > 0) { - this.showPointerAtTarget(targetEl, event.pageX, false); + const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]'); + if (targetEl instanceof HTMLElement && (window.getSelection() || '').toString().length > 0) { + const xPos = (event instanceof MouseEvent) ? event.pageX : 0; + this.showPointerAtTarget(targetEl, xPos, false); } }); @@ -63,28 +78,35 @@ export class Pointer extends Component { // Toggle between pointer modes DOM.onSelect(this.modeToggles, event => { + const targetToggle = (event.target as HTMLElement); for (const section of this.modeSections) { - const show = !section.contains(event.target); + const show = !section.contains(targetToggle); section.toggleAttribute('hidden', !show); } - this.modeToggles.find(b => b !== event.target).focus(); + const otherToggle = this.modeToggles.find(b => b !== targetToggle); + otherToggle && otherToggle.focus(); }); + + if (this.commentButton) { + DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this)); + } } hidePointer() { - this.pointer.style.display = null; + this.pointer.style.removeProperty('display'); this.showing = false; + this.targetElement = null; + this.targetSelectionRange = null; } /** * Move and display the pointer at the given element, targeting the given screen x-position if possible. - * @param {Element} element - * @param {Number} xPosition - * @param {Boolean} keyboardMode */ - showPointerAtTarget(element, xPosition, keyboardMode) { - this.updateForTarget(element); + showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) { + this.targetElement = element; + this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null; + this.updateDomForTarget(element); this.pointer.style.display = 'block'; const targetBounds = element.getBoundingClientRect(); @@ -98,18 +120,18 @@ export class Pointer extends Component { this.pointer.style.top = `${yOffset}px`; this.showing = true; - this.isSelection = true; + this.isMakingSelection = true; setTimeout(() => { - this.isSelection = false; + this.isMakingSelection = false; }, 100); const scrollListener = () => { this.hidePointer(); - window.removeEventListener('scroll', scrollListener, {passive: true}); + window.removeEventListener('scroll', scrollListener); }; - element.parentElement.insertBefore(this.pointer, element); + element.parentElement?.insertBefore(this.pointer, element); if (!keyboardMode) { window.addEventListener('scroll', scrollListener, {passive: true}); } @@ -117,9 +139,8 @@ export class Pointer extends Component { /** * Update the pointer inputs/content for the given target element. - * @param {?Element} element */ - updateForTarget(element) { + updateDomForTarget(element: HTMLElement) { const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`); const includeTag = `{{@${this.pageId}#${element.id}}}`; @@ -128,18 +149,18 @@ export class Pointer extends Component { // Update anchor if present const editAnchor = this.pointer.querySelector('#pointer-edit'); - if (editAnchor && element) { + if (editAnchor instanceof HTMLAnchorElement && element) { const {editHref} = editAnchor.dataset; const elementId = element.id; // Get the first 50 characters. - const queryContent = element.textContent && element.textContent.substring(0, 50); + const queryContent = (element.textContent || '').substring(0, 50); editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`; } } enterSectionSelectMode() { - const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')); + const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')) as HTMLElement[]; for (const section of sections) { section.setAttribute('tabindex', '0'); } @@ -147,9 +168,39 @@ export class Pointer extends Component { sections[0].focus(); DOM.onEnterPress(sections, event => { - this.showPointerAtTarget(event.target, 0, true); + this.showPointerAtTarget(event.target as HTMLElement, 0, true); this.pointer.focus(); }); } + createCommentAtPointer() { + if (!this.targetElement) { + return; + } + + const refId = this.targetElement.id; + const hash = hashElement(this.targetElement); + let range = ''; + if (this.targetSelectionRange) { + const commonContainer = this.targetSelectionRange.commonAncestorContainer; + if (this.targetElement.contains(commonContainer)) { + const start = normalizeNodeTextOffsetToParent( + this.targetSelectionRange.startContainer, + this.targetSelectionRange.startOffset, + this.targetElement + ); + const end = normalizeNodeTextOffsetToParent( + this.targetSelectionRange.endContainer, + this.targetSelectionRange.endOffset, + this.targetElement + ); + range = `${start}-${end}`; + } + } + + const reference = `${refId}:${hash}:${range}`; + const pageComments = window.$components.first('page-comments') as PageComments; + pageComments.startNewComment(reference); + } + } diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.ts similarity index 74% rename from resources/js/components/tabs.js rename to resources/js/components/tabs.ts index f0fc058ced7..a03d37cd48c 100644 --- a/resources/js/components/tabs.js +++ b/resources/js/components/tabs.ts @@ -1,5 +1,9 @@ import {Component} from './component'; +export interface TabsChangeEvent { + showing: string; +} + /** * Tabs * Uses accessible attributes to drive its functionality. @@ -19,18 +23,25 @@ import {Component} from './component'; */ export class Tabs extends Component { + protected container!: HTMLElement; + protected tabList!: HTMLElement; + protected tabs!: HTMLElement[]; + protected panels!: HTMLElement[]; + + protected activeUnder!: number; + protected active: null|boolean = null; + setup() { this.container = this.$el; - this.tabList = this.container.querySelector('[role="tablist"]'); + this.tabList = this.container.querySelector('[role="tablist"]') as HTMLElement; this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]')); this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]')); this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000; - this.active = null; this.container.addEventListener('click', event => { - const tab = event.target.closest('[role="tab"]'); - if (tab && this.tabs.includes(tab)) { - this.show(tab.getAttribute('aria-controls')); + const tab = (event.target as HTMLElement).closest('[role="tab"]'); + if (tab instanceof HTMLElement && this.tabs.includes(tab)) { + this.show(tab.getAttribute('aria-controls') || ''); } }); @@ -40,7 +51,7 @@ export class Tabs extends Component { this.updateActiveState(); } - show(sectionId) { + public show(sectionId: string): void { for (const panel of this.panels) { panel.toggleAttribute('hidden', panel.id !== sectionId); } @@ -51,10 +62,11 @@ export class Tabs extends Component { tab.setAttribute('aria-selected', selected ? 'true' : 'false'); } - this.$emit('change', {showing: sectionId}); + const data: TabsChangeEvent = {showing: sectionId}; + this.$emit('change', data); } - updateActiveState() { + protected updateActiveState(): void { const active = window.innerWidth < this.activeUnder; if (active === this.active) { return; @@ -69,13 +81,13 @@ export class Tabs extends Component { this.active = active; } - activate() { + protected activate(): void { const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0]; this.show(panelToShow.id); this.tabList.toggleAttribute('hidden', false); } - deactivate() { + protected deactivate(): void { for (const panel of this.panels) { panel.removeAttribute('hidden'); } diff --git a/resources/js/services/__tests__/translations.test.ts b/resources/js/services/__tests__/translations.test.ts index 043f1745ff6..5014051ab04 100644 --- a/resources/js/services/__tests__/translations.test.ts +++ b/resources/js/services/__tests__/translations.test.ts @@ -58,6 +58,11 @@ describe('Translations Service', () => { expect(caseB).toEqual('an orange angry big dinosaur'); }); + test('it provides count as a replacement by default', () => { + const caseA = $trans.choice(`:count cats|:count dogs`, 4); + expect(caseA).toEqual('4 dogs'); + }); + test('not provided replacements are left as-is', () => { const caseA = $trans.choice(`An :a dog`, 5, {}); expect(caseA).toEqual('An :a dog'); diff --git a/resources/js/services/components.ts b/resources/js/services/components.ts index c19939e92a9..0e13cd0a09f 100644 --- a/resources/js/services/components.ts +++ b/resources/js/services/components.ts @@ -139,8 +139,8 @@ export class ComponentStore { /** * Get all the components of the given name. */ - public get(name: string): Component[] { - return this.components[name] || []; + public get<T extends Component>(name: string): T[] { + return (this.components[name] || []) as T[]; } /** @@ -150,4 +150,9 @@ export class ComponentStore { const elComponents = this.elementComponentMap.get(element) || {}; return elComponents[name] || null; } + + public allWithinElement<T extends Component>(element: HTMLElement, name: string): T[] { + const components = this.get<T>(name); + return components.filter(c => element.contains(c.$el)); + } } diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index c88827bac40..c3817536c85 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -1,3 +1,5 @@ +import {cyrb53} from "./util"; + /** * Check if the given param is a HTMLElement */ @@ -44,9 +46,11 @@ export function forEach(selector: string, callback: (el: Element) => any) { /** * Helper to listen to multiple DOM events */ -export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void { - for (const eventName of events) { - listenerElement.addEventListener(eventName, callback); +export function onEvents(listenerElement: Element|null, events: string[], callback: (e: Event) => any): void { + if (listenerElement) { + for (const eventName of events) { + listenerElement.addEventListener(eventName, callback); + } } } @@ -178,3 +182,78 @@ export function htmlToDom(html: string): HTMLElement { return firstChild; } + +/** + * For the given node and offset, return an adjusted offset that's relative to the given parent element. + */ +export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number { + if (!parentElement.contains(node)) { + throw new Error('ParentElement must be a prent of element'); + } + + let normalizedOffset = offset; + let currentNode: Node|null = node.nodeType === Node.TEXT_NODE ? + node : node.childNodes[offset]; + + while (currentNode !== parentElement && currentNode) { + if (currentNode.previousSibling) { + currentNode = currentNode.previousSibling; + normalizedOffset += (currentNode.textContent?.length || 0); + } else { + currentNode = currentNode.parentNode; + } + } + + return normalizedOffset; +} + +/** + * Find the target child node and adjusted offset based on a parent node and text offset. + * Returns null if offset not found within the given parent node. + */ +export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number): ({node: Node, offset: number}|null) { + if (offset === 0) { + return { node: parentNode, offset: 0 }; + } + + let currentOffset = 0; + let currentNode = null; + + for (let i = 0; i < parentNode.childNodes.length; i++) { + currentNode = parentNode.childNodes[i]; + + if (currentNode.nodeType === Node.TEXT_NODE) { + // For text nodes, count the length of their content + // Returns if within range + const textLength = (currentNode.textContent || '').length; + if (currentOffset + textLength >= offset) { + return { + node: currentNode, + offset: offset - currentOffset + }; + } + + currentOffset += textLength; + } else if (currentNode.nodeType === Node.ELEMENT_NODE) { + // Otherwise, if an element, track the text length and search within + // if in range for the target offset + const elementTextLength = (currentNode.textContent || '').length; + if (currentOffset + elementTextLength >= offset) { + return findTargetNodeAndOffset(currentNode as HTMLElement, offset - currentOffset); + } + + currentOffset += elementTextLength; + } + } + + // Return null if not found within range + return null; +} + +/** + * Create a hash for the given HTML element content. + */ +export function hashElement(element: HTMLElement): string { + const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, ''); + return cyrb53(normalisedElemText); +} \ No newline at end of file diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index be9fba7eca5..6045d51f823 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -1,7 +1,9 @@ import {HttpError} from "./http"; +type Listener = (data: any) => void; + export class EventManager { - protected listeners: Record<string, ((data: any) => void)[]> = {}; + protected listeners: Record<string, Listener[]> = {}; protected stack: {name: string, data: {}}[] = []; /** @@ -24,6 +26,17 @@ export class EventManager { this.listeners[eventName].push(callback); } + /** + * Remove an event listener which is using the given callback for the given event name. + */ + remove(eventName: string, callback: Listener): void { + const listeners = this.listeners[eventName] || []; + const index = listeners.indexOf(callback); + if (index !== -1) { + listeners.splice(index, 1); + } + } + /** * Emit an event for public use. * Sends the event via the native DOM event handling system. @@ -53,8 +66,7 @@ export class EventManager { /** * Notify of standard server-provided validation errors. */ - showValidationErrors(responseErr: {status?: number, data?: object}): void { - if (!responseErr.status) return; + showValidationErrors(responseErr: HttpError): void { if (responseErr.status === 422 && responseErr.data) { const message = Object.values(responseErr.data).flat().join('\n'); this.error(message); diff --git a/resources/js/services/translations.ts b/resources/js/services/translations.ts index b37dbdfb074..f548a51d1d1 100644 --- a/resources/js/services/translations.ts +++ b/resources/js/services/translations.ts @@ -10,6 +10,7 @@ export class Translator { * to use. Similar format at Laravel's 'trans_choice' helper. */ choice(translation: string, count: number, replacements: Record<string, string> = {}): string { + replacements = Object.assign({}, {count: String(count)}, replacements); const splitText = translation.split('|'); const exactCountRegex = /^{([0-9]+)}/; const rangeRegex = /^\[([0-9]+),([0-9*]+)]/; diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts index c5a5d2db804..61a02a3d24d 100644 --- a/resources/js/services/util.ts +++ b/resources/js/services/util.ts @@ -144,4 +144,25 @@ function getVersion(): string { export function importVersioned(moduleName: string): Promise<object> { const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`); return import(importPath); +} + +/* + cyrb53 (c) 2018 bryc (github.com/bryc) + License: Public domain (or MIT if needed). Attribution appreciated. + A fast and simple 53-bit string hash function with decent collision resistance. + Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity. + Taken from: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js +*/ +export function cyrb53(str: string, seed: number = 0): string { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for(let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return String((4294967296 * (2097151 & h2) + (h1 >>> 0))); } \ No newline at end of file diff --git a/resources/sass/_animations.scss b/resources/sass/_animations.scss index f1aa3139b8e..ccbe36161b6 100644 --- a/resources/sass/_animations.scss +++ b/resources/sass/_animations.scss @@ -67,4 +67,26 @@ animation-duration: 180ms; animation-delay: 0s; animation-timing-function: cubic-bezier(.62, .28, .23, .99); +} + +@keyframes highlight { + 0% { + background-color: var(--color-primary-light); + } + 33% { + background-color: transparent; + } + 66% { + background-color: var(--color-primary-light); + } + 100% { + background-color: transparent; + } +} + +.anim-highlight { + animation-name: highlight; + animation-duration: 2s; + animation-delay: 0s; + animation-timing-function: linear; } \ No newline at end of file diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 58d39d3ee6e..9e96b39fbb4 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -569,6 +569,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border-bottom: 0; padding: 0 vars.$xs; } +.tab-container [role="tabpanel"].no-outline:focus { + outline: none; +} .image-picker .none { display: none; @@ -746,6 +749,52 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { height: calc(100% - vars.$m); } +.comment-reference-indicator-wrap a { + float: left; + margin-top: vars.$xs; + font-size: 12px; + display: inline-block; + font-weight: bold; + position: relative; + border-radius: 4px; + overflow: hidden; + padding: 2px 6px 2px 0; + margin-inline-end: vars.$xs; + color: var(--color-link); + span { + display: none; + } + &.outdated span { + display: inline; + } + &.outdated.missing { + color: var(--color-warning); + pointer-events: none; + } + svg { + width: 24px; + margin-inline-end: 0; + } + &:after { + background-color: currentColor; + content: ''; + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + opacity: 0.15; + } + &[href="#"] { + color: #444; + pointer-events: none; + } +} + +.comment-branch .comment-box { + margin-bottom: vars.$m; +} + .comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator { display: none; } @@ -760,7 +809,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: block; } +.comment-container .empty-state { + display: none; +} +.comment-container:not(:has([component="page-comment"])) .empty-state { + display: block; +} + .comment-container-compact .comment-box { + margin-bottom: vars.$xs; .meta { font-size: 0.8rem; } @@ -778,6 +835,29 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { width: vars.$m; } +.comment-container-super-compact .comment-box { + .meta { + font-size: 12px; + } + .avatar { + width: 22px; + height: 22px; + margin-inline-end: 2px !important; + } + .content { + padding: vars.$xxs vars.$s; + line-height: 1.2; + } + .content p { + font-size: 12px; + } +} + +.comment-container-super-compact .comment-thread-indicator { + width: (vars.$xs + 3px); + margin-inline-start: 3px; +} + #tag-manager .drag-card { max-width: 500px; } @@ -1127,4 +1207,21 @@ input.scroll-box-search, .scroll-box-header-item { } .scroll-box > li.empty-state:last-child { display: list-item; +} + +details.section-expander summary { + border-top: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #000); + font-weight: bold; + font-size: 12px; + color: #888; + cursor: pointer; + padding-block: vars.$xs; +} +details.section-expander:open summary { + margin-bottom: vars.$s; +} +details.section-expander { + border-bottom: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #000); } \ No newline at end of file diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index b0176d64ef1..aba1556a983 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -11,6 +11,7 @@ max-width: 840px; margin: 0 auto; overflow-wrap: break-word; + position: relative; .align-left { text-align: left; } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 45e58ffc865..83aec46f093 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -158,11 +158,7 @@ body.tox-fullscreen, body.markdown-fullscreen { border-radius: 4px; box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1); @include mixins.lightDark(background-color, #fff, #333); - width: 275px; - - &.is-page-editable { - width: 328px; - } + width: 328px; &:before { position: absolute; @@ -183,7 +179,6 @@ body.tox-fullscreen, body.markdown-fullscreen { } input, button, a { position: relative; - border-radius: 0; height: 28px; font-size: 12px; vertical-align: top; @@ -194,17 +189,21 @@ body.tox-fullscreen, body.markdown-fullscreen { border: 1px solid #DDD; @include mixins.lightDark(border-color, #ddd, #000); color: #666; - width: 160px; - z-index: 40; - padding: 5px 10px; + width: auto; + flex: 1; + z-index: 58; + padding: 5px; + border-radius: 0; } .text-button { @include mixins.lightDark(color, #444, #AAA); } .input-group .button { line-height: 1; - margin: 0 0 0 -4px; + margin-inline-start: -1px; + margin-block: 0; box-shadow: none; + border-radius: 0; } a.button { margin: 0; @@ -218,6 +217,97 @@ body.tox-fullscreen, body.markdown-fullscreen { } } +// Page inline comments +.content-comment-highlight { + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; + user-select: none; + pointer-events: none; + &:after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: var(--color-primary); + opacity: 0.25; + } +} +.content-comment-window { + font-size: vars.$fs-m; + line-height: 1.4; + position: absolute; + top: calc(100% + 3px); + left: 0; + z-index: 92; + pointer-events: all; + min-width: min(340px, 80vw); + @include mixins.lightDark(background-color, #FFF, #222); + box-shadow: vars.$bs-hover; + border-radius: 4px; + overflow: hidden; +} +.content-comment-window-actions { + background-color: var(--color-primary); + color: #FFF; + display: flex; + align-items: center; + justify-content: end; + gap: vars.$xs; + button { + color: #FFF; + font-size: 12px; + padding: vars.$xs; + line-height: 1; + cursor: pointer; + } + button[data-action="jump"] { + text-decoration: underline; + } + svg { + fill: currentColor; + width: 12px; + } +} +.content-comment-window-content { + padding: vars.$xs vars.$s vars.$xs vars.$xs; + max-height: 200px; + overflow-y: scroll; +} +.content-comment-window-content .comment-reference-indicator-wrap { + display: none; +} +.content-comment-marker { + position: absolute; + right: -16px; + top: -16px; + pointer-events: all; + width: min(1.5em, 32px); + height: min(1.5em, 32px); + border-radius: min(calc(1.5em / 2), 32px); + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-primary); + box-shadow: vars.$bs-hover; + color: #FFF; + cursor: pointer; + z-index: 90; + transform: scale(1); + transition: transform ease-in-out 120ms; + svg { + fill: #FFF; + width: 80%; + } +} +.page-content [id^="bkmrk-"]:hover .content-comment-marker { + transform: scale(1.15); +} + // Page editor sidebar toolbox .floating-toolbox { @include mixins.lightDark(background-color, #FFF, #222); diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php index 78d19ac3ea4..658c33219c3 100644 --- a/resources/views/comments/comment-branch.blade.php +++ b/resources/views/comments/comment-branch.blade.php @@ -1,13 +1,16 @@ +{{-- +$branch CommentTreeNode +--}} <div class="comment-branch"> - <div class="mb-m"> - @include('comments.comment', ['comment' => $branch['comment']]) + <div> + @include('comments.comment', ['comment' => $branch->comment]) </div> <div class="flex-container-row"> <div class="comment-thread-indicator-parent"> <div class="comment-thread-indicator"></div> </div> <div class="comment-branch-children flex"> - @foreach($branch['children'] as $childBranch) + @foreach($branch->children as $childBranch) @include('comments.comment-branch', ['branch' => $childBranch]) @endforeach </div> diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 2bf89d6832d..eadf3518722 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -4,9 +4,9 @@ <div component="{{ $readOnly ? '' : 'page-comment' }}" option:page-comment:comment-id="{{ $comment->id }}" option:page-comment:comment-local-id="{{ $comment->local_id }}" - option:page-comment:comment-parent-id="{{ $comment->parent_id }}" option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}" option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}" + option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}" option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}" option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" id="comment{{$comment->local_id}}" @@ -38,6 +38,12 @@ class="comment-box"> @if(userCan('comment-create-all')) <button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button> @endif + @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment))) + <button refs="page-comment@archive-button" + type="button" + data-is-archived="{{ $comment->archived ? 'true' : 'false' }}" + class="text-button text-muted hover-underline text-small p-xs">@icon('archive') {{ trans('common.' . ($comment->archived ? 'unarchive' : 'archive')) }}</button> + @endif @if(userCan('comment-update', $comment)) <button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button> @endif @@ -74,6 +80,16 @@ class="comment-box"> <a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a> </p> @endif + @if($comment->content_ref) + <div class="comment-reference-indicator-wrap"> + <a component="page-comment-reference" + option:page-comment-reference:reference="{{ $comment->content_ref }}" + option:page-comment-reference:view-comment-text="{{ trans('entities.comment_view') }}" + option:page-comment-reference:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}" + option:page-comment-reference:close-text="{{ trans('common.close') }}" + href="#">@icon('bookmark'){{ trans('entities.comment_reference') }} <span>{{ trans('entities.comment_reference_outdated') }}</span></a> + </div> + @endif {!! $commentHtml !!} </div> diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 48bf885fff5..51c08d69a85 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -1,40 +1,75 @@ -<section component="page-comments" +<section components="page-comments tabs" option:page-comments:page-id="{{ $page->id }}" option:page-comments:created-text="{{ trans('entities.comment_created_success') }}" - option:page-comments:count-text="{{ trans('entities.comment_count') }}" + option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}" + option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}" option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}" option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" - class="comments-list" + class="comments-list tab-container" aria-label="{{ trans('entities.comments') }}"> - <div refs="page-comments@comment-count-bar" class="grid half left-focus v-center no-row-gap"> - <h5 refs="page-comments@comments-title">{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}</h5> + <div refs="page-comments@comment-count-bar" class="flex-container-row items-center"> + <div role="tablist" class="flex"> + <button type="button" + role="tab" + id="comment-tab-active" + aria-controls="comment-tab-panel-active" + refs="page-comments@active-tab" + aria-selected="true">{{ trans_choice('entities.comment_thread_count', $commentTree->activeThreadCount()) }}</button> + <button type="button" + role="tab" + id="comment-tab-archived" + aria-controls="comment-tab-panel-archived" + refs="page-comments@archived-tab" + aria-selected="false">{{ trans_choice('entities.comment_archived_count', count($commentTree->getArchived())) }}</button> + </div> @if ($commentTree->empty() && userCan('comment-create-all')) - <div class="text-m-right" refs="page-comments@add-button-container"> + <div class="ml-m" refs="page-comments@add-button-container"> <button type="button" refs="page-comments@add-comment-button" - class="button outline">{{ trans('entities.comment_add') }}</button> + class="button outline mb-m">{{ trans('entities.comment_add') }}</button> </div> @endif </div> - <div refs="page-comments@commentContainer" class="comment-container"> - @foreach($commentTree->get() as $branch) + <div id="comment-tab-panel-active" + tabindex="0" + role="tabpanel" + aria-labelledby="comment-tab-active" + class="comment-container no-outline"> + <div refs="page-comments@comment-container"> + @foreach($commentTree->getActive() as $branch) + @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) + @endforeach + </div> + + <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p> + + @if(userCan('comment-create-all')) + @include('comments.create') + @if (!$commentTree->empty()) + <div refs="page-comments@addButtonContainer" class="flex-container-row"> + <button type="button" + refs="page-comments@add-comment-button" + class="button outline ml-auto">{{ trans('entities.comment_add') }}</button> + </div> + @endif + @endif + </div> + + <div refs="page-comments@archive-container" + id="comment-tab-panel-archived" + tabindex="0" + role="tabpanel" + aria-labelledby="comment-tab-archived" + hidden="hidden" + class="comment-container no-outline"> + @foreach($commentTree->getArchived() as $branch) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) @endforeach + <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p> </div> - @if(userCan('comment-create-all')) - @include('comments.create') - @if (!$commentTree->empty()) - <div refs="page-comments@addButtonContainer" class="text-right"> - <button type="button" - refs="page-comments@add-comment-button" - class="button outline">{{ trans('entities.comment_add') }}</button> - </div> - @endif - @endif - @if(userCan('comment-create-all') || $commentTree->canUpdateAny()) @push('body-end') <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script> diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php index 417f0c60602..134ed516425 100644 --- a/resources/views/comments/create.blade.php +++ b/resources/views/comments/create.blade.php @@ -12,6 +12,16 @@ </div> </div> </div> + <div refs="page-comments@reference-row" hidden class="primary-background-light text-muted px-s py-xs"> + <div class="grid left-focus v-center"> + <div> + <a refs="page-comments@formReferenceLink" href="#">{{ trans('entities.comment_reference') }}</a> + </div> + <div class="text-right"> + <button refs="page-comments@remove-reference-button" class="text-button">{{ trans('common.remove') }}</button> + </div> + </div> + </div> <div class="content px-s pt-s"> <form refs="page-comments@form" novalidate> diff --git a/resources/views/pages/parts/pointer.blade.php b/resources/views/pages/parts/pointer.blade.php index 56f36cb75f3..f6487b66600 100644 --- a/resources/views/pages/parts/pointer.blade.php +++ b/resources/views/pages/parts/pointer.blade.php @@ -6,29 +6,36 @@ tabindex="-1" aria-label="{{ trans('entities.pages_pointer_label') }}" class="pointer-container"> - <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" > - <div refs="pointer@mode-section" class="flex-container-row items-center gap-s"> + <div class="pointer flex-container-row items-center justify-space-between gap-xs p-xs anim" > + <div refs="pointer@mode-section" class="flex flex-container-row items-center gap-xs"> <button refs="pointer@mode-toggle" title="{{ trans('entities.pages_pointer_toggle_link') }}" class="text-button icon px-xs">@icon('link')</button> - <div class="input-group"> + <div class="input-group flex flex-container-row items-center"> <input refs="pointer@link-input" aria-label="{{ trans('entities.pages_pointer_permalink') }}" readonly="readonly" type="text" id="pointer-url" placeholder="url"> - <button refs="pointer@link-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button> + <button refs="pointer@link-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button> </div> </div> - <div refs="pointer@mode-section" hidden class="flex-container-row items-center gap-s"> + <div refs="pointer@mode-section" hidden class="flex flex-container-row items-center gap-xs"> <button refs="pointer@mode-toggle" title="{{ trans('entities.pages_pointer_toggle_include') }}" class="text-button icon px-xs">@icon('include')</button> - <div class="input-group"> + <div class="input-group flex flex-container-row items-center"> <input refs="pointer@include-input" aria-label="{{ trans('entities.pages_pointer_include_tag') }}" readonly="readonly" type="text" id="pointer-include" placeholder="include"> - <button refs="pointer@include-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button> + <button refs="pointer@include-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button> </div> </div> - @if(userCan('page-update', $page)) - <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}" - class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a> - @endif + <div> + @if(userCan('page-update', $page)) + <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}" + class="button primary outline icon heading-edit-icon px-xs" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a> + @endif + @if($commentTree->enabled() && userCan('comment-create-all')) + <button type="button" + refs="pointer@comment-button" + class="button primary outline icon px-xs m-none" title="{{ trans('entities.comment_add')}}">@icon('comment')</button> + @endif + </div> </div> </div> diff --git a/resources/views/pages/parts/toolbox-comments.blade.php b/resources/views/pages/parts/toolbox-comments.blade.php index d632b85c689..72958a2fede 100644 --- a/resources/views/pages/parts/toolbox-comments.blade.php +++ b/resources/views/pages/parts/toolbox-comments.blade.php @@ -1,3 +1,6 @@ +{{-- +$comments - CommentTree +--}} <div refs="editor-toolbox@tab-content" data-tab-content="comments" class="toolbox-tab-content"> <h4>{{ trans('entities.comments') }}</h4> @@ -5,11 +8,19 @@ <p class="text-muted small mb-m"> {{ trans('entities.comment_editor_explain') }} </p> - @foreach($comments->get() as $branch) + @foreach($comments->getActive() as $branch) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true]) @endforeach @if($comments->empty()) - <p class="italic text-muted">{{ trans('common.no_items') }}</p> + <p class="italic text-muted">{{ trans('entities.comment_none') }}</p> + @endif + @if($comments->archivedThreadCount() > 0) + <details class="section-expander mt-s"> + <summary>{{ trans('entities.comment_archived_threads') }}</summary> + @foreach($comments->getArchived() as $branch) + @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true]) + @endforeach + </details> @endif </div> </div> \ No newline at end of file diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index e3a31dd5ebf..137d43bdb1a 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -28,12 +28,6 @@ class="page-content clearfix"> @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous]) @if ($commentTree->enabled()) - @if(($previous || $next)) - <div class="px-xl print-hidden"> - <hr class="darker"> - </div> - @endif - <div class="comments-container mb-l print-hidden"> @include('comments.comments', ['commentTree' => $commentTree, 'page' => $page]) <div class="clearfix"></div> diff --git a/routes/web.php b/routes/web.php index 8184725834c..ea3efe1ac77 100644 --- a/routes/web.php +++ b/routes/web.php @@ -179,6 +179,8 @@ // Comments Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']); + Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']); + Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']); Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']); Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']); diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php new file mode 100644 index 00000000000..4e9640baeed --- /dev/null +++ b/tests/Entity/CommentDisplayTest.php @@ -0,0 +1,134 @@ +<?php + +namespace Entity; + +use BookStack\Activity\ActivityType; +use BookStack\Activity\Models\Comment; +use BookStack\Entities\Models\Page; +use Tests\TestCase; + +class CommentDisplayTest extends TestCase +{ + public function test_reply_comments_are_nested() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']); + $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']); + + $respHtml = $this->withHtml($this->get($page->getUrl())); + $respHtml->assertElementCount('.comment-branch', 3); + $respHtml->assertElementNotExists('.comment-branch .comment-branch'); + + $comment = $page->comments()->first(); + $resp = $this->postJson("/comment/$page->id", [ + 'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id + ]); + $resp->assertStatus(200); + + $respHtml = $this->withHtml($this->get($page->getUrl())); + $respHtml->assertElementCount('.comment-branch', 4); + $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment'); + } + + public function test_comments_are_visible_in_the_page_editor() + { + $page = $this->entities->page(); + + $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']); + + $respHtml = $this->withHtml($this->get($page->getUrl('/edit'))); + $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor'); + } + + public function test_comment_creator_name_truncated() + { + [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes()); + + $pageResp = $this->asAdmin()->get($page->getUrl()); + $pageResp->assertSee('Wolfeschlegels…'); + } + + public function test_comment_editor_js_loaded_with_create_or_edit_permissions() + { + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertSee('tinymce.min.js?', false); + $resp->assertSee('window.editor_translations', false); + $resp->assertSee('component="entity-selector"', false); + + $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']); + $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertDontSee('tinymce.min.js?', false); + $resp->assertDontSee('window.editor_translations', false); + $resp->assertDontSee('component="entity-selector"', false); + + Comment::factory()->create([ + 'created_by' => $editor->id, + 'entity_type' => 'page', + 'entity_id' => $page->id, + ]); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertSee('tinymce.min.js?', false); + $resp->assertSee('window.editor_translations', false); + $resp->assertSee('component="entity-selector"', false); + } + + public function test_comment_displays_relative_times() + { + $page = $this->entities->page(); + $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]); + $comment->created_at = now()->subWeek(); + $comment->updated_at = now()->subDay(); + $comment->save(); + + $pageResp = $this->asAdmin()->get($page->getUrl()); + $html = $this->withHtml($pageResp); + + // Create date shows relative time as text to user + $html->assertElementContains('.comment-box', 'commented 1 week ago'); + // Updated indicator has full time as title + $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated'); + } + + public function test_comment_displays_reference_if_set() + { + $page = $this->entities->page(); + $comment = Comment::factory()->make([ + 'content_ref' => 'bkmrk-a:abc:4-1', + 'local_id' => 10, + ]); + $page->comments()->save($comment); + + $html = $this->withHtml($this->asEditor()->get($page->getUrl())); + $html->assertElementExists('#comment10 .comment-reference-indicator-wrap a'); + } + + public function test_archived_comments_are_shown_in_their_own_container() + { + $page = $this->entities->page(); + $comment = Comment::factory()->make(['local_id' => 44]); + $page->comments()->save($comment); + + $html = $this->withHtml($this->asEditor()->get($page->getUrl())); + $html->assertElementExists('#comment-tab-panel-active #comment44'); + $html->assertElementNotExists('#comment-tab-panel-archived .comment-box'); + + $comment->archived = true; + $comment->save(); + + $html = $this->withHtml($this->asEditor()->get($page->getUrl())); + $html->assertElementExists('#comment-tab-panel-archived #comment44.comment-box'); + $html->assertElementNotExists('#comment-tab-panel-active #comment44'); + } +} diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentStoreTest.php similarity index 56% rename from tests/Entity/CommentTest.php rename to tests/Entity/CommentStoreTest.php index 9e019e3d148..8b8a5d488b8 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentStoreTest.php @@ -7,7 +7,7 @@ use BookStack\Entities\Models\Page; use Tests\TestCase; -class CommentTest extends TestCase +class CommentStoreTest extends TestCase { public function test_add_comment() { @@ -33,6 +33,32 @@ public function test_add_comment() $this->assertActivityExists(ActivityType::COMMENT_CREATE); } + public function test_add_comment_stores_content_reference_only_if_format_valid() + { + $validityByRefs = [ + 'bkmrk-my-title:4589284922:4-3' => true, + 'bkmrk-my-title:4589284922:' => true, + 'bkmrk-my-title:4589284922:abc' => false, + 'my-title:4589284922:' => false, + 'bkmrk-my-title-4589284922:' => false, + ]; + + $page = $this->entities->page(); + + foreach ($validityByRefs as $ref => $valid) { + $this->asAdmin()->postJson("/comment/$page->id", [ + 'html' => '<p>My comment</p>', + 'parent_id' => null, + 'content_ref' => $ref, + ]); + + if ($valid) { + $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + } else { + $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + } + } + } public function test_comment_edit() { @@ -80,6 +106,89 @@ public function test_comment_delete() $this->assertActivityExists(ActivityType::COMMENT_DELETE); } + public function test_comment_archive_and_unarchive() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $comment->refresh(); + + $this->put("/comment/$comment->id/archive"); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => true, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_UPDATE); + + $this->put("/comment/$comment->id/unarchive"); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => false, + ]); + + $this->assertActivityExists(ActivityType::COMMENT_UPDATE); + } + + public function test_archive_endpoints_require_delete_or_edit_permissions() + { + $viewer = $this->users->viewer(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $comment->refresh(); + + $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"]; + + foreach ($endpoints as $endpoint) { + $resp = $this->actingAs($viewer)->put($endpoint); + $this->assertPermissionError($resp); + } + + $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']); + + foreach ($endpoints as $endpoint) { + $resp = $this->actingAs($viewer)->put($endpoint); + $resp->assertOk(); + } + + $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']); + $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']); + + foreach ($endpoints as $endpoint) { + $resp = $this->actingAs($viewer)->put($endpoint); + $resp->assertOk(); + } + } + + public function test_non_top_level_comments_cant_be_archived_or_unarchived() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $subComment = Comment::factory()->make(['parent_id' => $comment->id]); + $page->comments()->save($subComment); + $subComment->refresh(); + + $resp = $this->putJson("/comment/$subComment->id/archive"); + $resp->assertStatus(400); + + $this->assertDatabaseHas('comments', [ + 'id' => $subComment->id, + 'archived' => false, + ]); + + $resp = $this->putJson("/comment/$subComment->id/unarchive"); + $resp->assertStatus(400); + } + public function test_scripts_cannot_be_injected_via_comment_html() { $page = $this->entities->page(); @@ -139,96 +248,4 @@ public function test_comment_html_is_limited() 'html' => $expected, ]); } - - public function test_reply_comments_are_nested() - { - $this->asAdmin(); - $page = $this->entities->page(); - - $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']); - $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']); - - $respHtml = $this->withHtml($this->get($page->getUrl())); - $respHtml->assertElementCount('.comment-branch', 3); - $respHtml->assertElementNotExists('.comment-branch .comment-branch'); - - $comment = $page->comments()->first(); - $resp = $this->postJson("/comment/$page->id", [ - 'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id - ]); - $resp->assertStatus(200); - - $respHtml = $this->withHtml($this->get($page->getUrl())); - $respHtml->assertElementCount('.comment-branch', 4); - $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment'); - } - - public function test_comments_are_visible_in_the_page_editor() - { - $page = $this->entities->page(); - - $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']); - - $respHtml = $this->withHtml($this->get($page->getUrl('/edit'))); - $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor'); - } - - public function test_comment_creator_name_truncated() - { - [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']); - $page = $this->entities->page(); - - $comment = Comment::factory()->make(); - $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes()); - - $pageResp = $this->asAdmin()->get($page->getUrl()); - $pageResp->assertSee('Wolfeschlegels…'); - } - - public function test_comment_editor_js_loaded_with_create_or_edit_permissions() - { - $editor = $this->users->editor(); - $page = $this->entities->page(); - - $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertSee('tinymce.min.js?', false); - $resp->assertSee('window.editor_translations', false); - $resp->assertSee('component="entity-selector"', false); - - $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']); - $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']); - - $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertDontSee('tinymce.min.js?', false); - $resp->assertDontSee('window.editor_translations', false); - $resp->assertDontSee('component="entity-selector"', false); - - Comment::factory()->create([ - 'created_by' => $editor->id, - 'entity_type' => 'page', - 'entity_id' => $page->id, - ]); - - $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertSee('tinymce.min.js?', false); - $resp->assertSee('window.editor_translations', false); - $resp->assertSee('component="entity-selector"', false); - } - - public function test_comment_displays_relative_times() - { - $page = $this->entities->page(); - $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]); - $comment->created_at = now()->subWeek(); - $comment->updated_at = now()->subDay(); - $comment->save(); - - $pageResp = $this->asAdmin()->get($page->getUrl()); - $html = $this->withHtml($pageResp); - - // Create date shows relative time as text to user - $html->assertElementContains('.comment-box', 'commented 1 week ago'); - // Updated indicator has full time as title - $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') . '"]', 'Updated'); - } }