From 8d159f77e440a57c8fdba5afb234d378d11856f6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Apr 2025 15:01:57 +0100 Subject: [PATCH 01/19] Comments: Started logic for content references Adds button for comments to pointer. Adds logic to generate a content reference point. --- resources/js/components/pointer.js | 56 +++++++++++++++++-- resources/js/services/dom.ts | 21 +++++++ resources/js/services/util.ts | 21 +++++++ resources/sass/_pages.scss | 11 ++-- resources/views/pages/parts/pointer.blade.php | 23 +++++--- 5 files changed, 113 insertions(+), 19 deletions(-) diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.js index 292b923e551..997df329a84 100644 --- a/resources/js/components/pointer.js +++ b/resources/js/components/pointer.js @@ -1,6 +1,9 @@ import * as DOM from '../services/dom.ts'; import {Component} from './component'; import {copyTextToClipboard} from '../services/clipboard.ts'; +import {el} from "../wysiwyg/utils/dom"; +import {cyrb53} from "../services/util"; +import {normalizeNodeTextOffsetToParent} from "../services/dom.ts"; export class Pointer extends Component { @@ -12,13 +15,16 @@ export class Pointer extends Component { this.includeInput = this.$refs.includeInput; 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.isMakingSelection = false; + this.targetElement = null; + this.targetSelectionRange = null; this.setupListeners(); } @@ -41,7 +47,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(); }); @@ -70,11 +76,17 @@ export class Pointer extends Component { this.modeToggles.find(b => b !== event.target).focus(); }); + + if (this.commentButton) { + DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this)); + } } hidePointer() { this.pointer.style.display = null; this.showing = false; + this.targetElement = null; + this.targetSelectionRange = null; } /** @@ -84,7 +96,9 @@ export class Pointer extends Component { * @param {Boolean} keyboardMode */ showPointerAtTarget(element, xPosition, keyboardMode) { - this.updateForTarget(element); + this.targetElement = element; + this.targetSelectionRange = window.getSelection()?.getRangeAt(0); + this.updateDomForTarget(element); this.pointer.style.display = 'block'; const targetBounds = element.getBoundingClientRect(); @@ -98,10 +112,10 @@ 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 = () => { @@ -119,7 +133,7 @@ export class Pointer extends Component { * Update the pointer inputs/content for the given target element. * @param {?Element} element */ - updateForTarget(element) { + updateDomForTarget(element) { const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`); const includeTag = `{{@${this.pageId}#${element.id}}}`; @@ -152,4 +166,34 @@ export class Pointer extends Component { }); } + createCommentAtPointer(event) { + if (!this.targetElement) { + return; + } + + const normalisedElemHtml = this.targetElement.outerHTML.replace(/\s{2,}/g, ''); + const refId = this.targetElement.id; + const hash = cyrb53(normalisedElemHtml); + 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}`; + console.log(reference); + } + } diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index c88827bac40..779b4854773 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -178,3 +178,24 @@ export function htmlToDom(html: string): HTMLElement { return firstChild; } + +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; +} diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts index c5a5d2db804..1a6fa55b6b0 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 { 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 (4294967296 * (2097151 & h2) + (h1 >>> 0)) as string; } \ No newline at end of file diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 45e58ffc865..de783705763 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -183,7 +183,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 +193,19 @@ 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: 180px; + 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: 0 0 0 -5px; box-shadow: none; + border-radius: 0; } a.button { margin: 0; diff --git a/resources/views/pages/parts/pointer.blade.php b/resources/views/pages/parts/pointer.blade.php index 56f36cb75f3..77fc763827e 100644 --- a/resources/views/pages/parts/pointer.blade.php +++ b/resources/views/pages/parts/pointer.blade.php @@ -6,14 +6,14 @@ tabindex="-1" aria-label="{{ trans('entities.pages_pointer_label') }}" class="pointer-container"> -
-
+
+
- +
- @if(userCan('page-update', $page)) - @icon('edit') - @endif +
+ @if(userCan('page-update', $page)) + @icon('edit') + @endif + @if($commentTree->enabled() && userCan('comment-create-all')) + + @endif +
From add238fe9fb3d3626e8acd323bd32f91edb2797e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Apr 2025 20:42:56 +0100 Subject: [PATCH 02/19] Comments & Pointer: Converted components to typescript Made changes for dom and translation services for easier usage considering types. trans_choice updated to allow default count replacement data as per Laravel's default behaviour. --- .../{page-comments.js => page-comments.ts} | 82 +++++++++++++------ .../js/components/{pointer.js => pointer.ts} | 53 +++++++----- resources/js/services/dom.ts | 8 +- resources/js/services/translations.ts | 1 + 4 files changed, 98 insertions(+), 46 deletions(-) rename resources/js/components/{page-comments.js => page-comments.ts} (68%) rename resources/js/components/{pointer.js => pointer.ts} (79%) diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.ts similarity index 68% rename from resources/js/components/page-comments.js rename to resources/js/components/page-comments.ts index 8f023836b09..a19d2c7d4b1 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.ts @@ -2,8 +2,38 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; +export interface CommentReplyEvent extends Event { + detail: { + id: string; // ID of comment being replied to + element: HTMLElement; // Container for comment replied to + } +} + export class PageComments extends Component { + private elem: HTMLElement; + private pageId: number; + private container: HTMLElement; + private commentCountBar: HTMLElement; + private commentsTitle: HTMLElement; + private addButtonContainer: HTMLElement; + private replyToRow: HTMLElement; + private formContainer: HTMLElement; + private form: HTMLFormElement; + private formInput: HTMLInputElement; + private formReplyLink: HTMLAnchorElement; + private addCommentButton: HTMLElement; + private hideFormButton: HTMLElement; + private removeReplyToButton: HTMLElement; + private wysiwygLanguage: string; + private wysiwygTextDirection: string; + private wysiwygEditor: any = null; + private createdText: string; + private countText: string; + private parentId: number | null = null; + private contentReference: string = ''; + private formReplyText: string = ''; + setup() { this.elem = this.$el; this.pageId = Number(this.$opts.pageId); @@ -15,9 +45,9 @@ export class PageComments extends Component { 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.form = this.$refs.form as HTMLFormElement; + this.formInput = this.$refs.formInput as HTMLInputElement; + this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement; this.addCommentButton = this.$refs.addCommentButton; this.hideFormButton = this.$refs.hideFormButton; this.removeReplyToButton = this.$refs.removeReplyToButton; @@ -25,26 +55,23 @@ export class PageComments extends Component { // 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() { + protected setupListeners(): void { this.elem.addEventListener('page-comment-delete', () => { setTimeout(() => this.updateCount(), 1); this.hideForm(); }); - this.elem.addEventListener('page-comment-reply', event => { + this.elem.addEventListener('page-comment-reply', (event: CommentReplyEvent) => { this.setReply(event.detail.id, event.detail.element); }); @@ -56,7 +83,7 @@ export class PageComments extends Component { } } - saveComment(event) { + protected saveComment(event): void { event.preventDefault(); event.stopPropagation(); @@ -68,10 +95,11 @@ export class PageComments extends Component { const reqData = { html: this.wysiwygEditor.getContent(), parent_id: this.parentId || null, + content_reference: this.contentReference || '', }; window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => { - const newElem = htmlToDom(resp.data); + const newElem = htmlToDom(resp.data as string); if (reqData.parent_id) { this.formContainer.after(newElem); @@ -91,20 +119,21 @@ export class PageComments extends Component { loading.remove(); } - updateCount() { + protected updateCount(): void { const count = this.getCommentCount(); - this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count}); + this.commentsTitle.textContent = window.$trans.choice(this.countText, count); } - resetForm() { + protected resetForm(): void { this.removeEditor(); this.formInput.value = ''; this.parentId = null; + this.contentReference = ''; this.replyToRow.toggleAttribute('hidden', true); this.container.append(this.formContainer); } - showForm() { + protected showForm(): void { this.removeEditor(); this.formContainer.toggleAttribute('hidden', false); this.addButtonContainer.toggleAttribute('hidden', true); @@ -112,7 +141,7 @@ export class PageComments extends Component { this.loadEditor(); } - hideForm() { + protected hideForm(): void { this.resetForm(); this.formContainer.toggleAttribute('hidden', true); if (this.getCommentCount() > 0) { @@ -123,7 +152,7 @@ export class PageComments extends Component { this.addButtonContainer.toggleAttribute('hidden', false); } - loadEditor() { + protected loadEditor(): void { if (this.wysiwygEditor) { this.wysiwygEditor.focus(); return; @@ -134,42 +163,49 @@ export class PageComments extends Component { containerElement: this.formInput, darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.wysiwygTextDirection, + drawioUrl: '', + pageId: 0, translations: {}, - translationMap: window.editor_translations, + translationMap: (window as Record).editor_translations, }); - window.tinymce.init(config).then(editors => { + (window as {tinymce: {init: (Object) => Promise}}).tinymce.init(config).then(editors => { this.wysiwygEditor = editors[0]; setTimeout(() => this.wysiwygEditor.focus(), 50); }); } - removeEditor() { + protected removeEditor(): void { if (this.wysiwygEditor) { this.wysiwygEditor.remove(); this.wysiwygEditor = null; } } - getCommentCount() { + protected getCommentCount(): number { return this.container.querySelectorAll('[component="page-comment"]').length; } - setReply(commentLocalId, commentElement) { + protected setReply(commentLocalId, commentElement): void { 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.textContent = this.formReplyText.replace('1234', String(this.parentId)); this.formReplyLink.href = `#comment${this.parentId}`; } - removeReplyTo() { + 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.contentReference = contentReference; + } + } diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.ts similarity index 79% rename from resources/js/components/pointer.js rename to resources/js/components/pointer.ts index 997df329a84..c3883b7b554 100644 --- a/resources/js/components/pointer.js +++ b/resources/js/components/pointer.ts @@ -1,18 +1,33 @@ import * as DOM from '../services/dom.ts'; import {Component} from './component'; import {copyTextToClipboard} from '../services/clipboard.ts'; -import {el} from "../wysiwyg/utils/dom"; import {cyrb53} from "../services/util"; import {normalizeNodeTextOffsetToParent} from "../services/dom.ts"; +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; @@ -20,12 +35,6 @@ export class Pointer extends Component { this.modeSections = this.$manyRefs.modeSection; this.pageId = this.$opts.pageId; - // Instance variables - this.showing = false; - this.isMakingSelection = false; - this.targetElement = null; - this.targetSelectionRange = null; - this.setupListeners(); } @@ -36,7 +45,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(); }); @@ -58,9 +67,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"]'); + const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]'); if (targetEl && window.getSelection().toString().length > 0) { - this.showPointerAtTarget(targetEl, event.pageX, false); + const xPos = (event instanceof MouseEvent) ? event.pageX : 0; + this.showPointerAtTarget(targetEl, xPos, false); } }); @@ -69,12 +79,14 @@ 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) { @@ -83,7 +95,7 @@ export class Pointer extends Component { } hidePointer() { - this.pointer.style.display = null; + this.pointer.style.removeProperty('display'); this.showing = false; this.targetElement = null; this.targetSelectionRange = null; @@ -97,7 +109,7 @@ export class Pointer extends Component { */ showPointerAtTarget(element, xPosition, keyboardMode) { this.targetElement = element; - this.targetSelectionRange = window.getSelection()?.getRangeAt(0); + this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null; this.updateDomForTarget(element); this.pointer.style.display = 'block'; @@ -120,7 +132,7 @@ export class Pointer extends Component { const scrollListener = () => { this.hidePointer(); - window.removeEventListener('scroll', scrollListener, {passive: true}); + window.removeEventListener('scroll', scrollListener); }; element.parentElement.insertBefore(this.pointer, element); @@ -142,7 +154,7 @@ 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; @@ -193,7 +205,8 @@ export class Pointer extends Component { } const reference = `${refId}:${hash}:${range}`; - console.log(reference); + const pageComments = window.$components.first('page-comments') as PageComments; + pageComments.startNewComment(reference); } } diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index 779b4854773..537af816a90 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -44,9 +44,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); + } } } diff --git a/resources/js/services/translations.ts b/resources/js/services/translations.ts index b37dbdfb074..821c34f18f7 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 { + replacements = Object.assign({}, replacements, {count: String(count)}); const splitText = translation.split('|'); const exactCountRegex = /^{([0-9]+)}/; const rangeRegex = /^\[([0-9]+),([0-9*]+)]/; From 5e3c3ad634cb7de4a13041292611c7aaafaa2f30 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Apr 2025 21:13:49 +0100 Subject: [PATCH 03/19] Comments: Added back-end content reference handling Also added archived property, to be added. --- app/Activity/CommentRepo.php | 3 +- .../Controllers/CommentController.php | 3 +- app/Activity/Models/Comment.php | 2 ++ .../Activity/Models/CommentFactory.php | 2 ++ ..._content_refs_and_archived_to_comments.php | 30 +++++++++++++++++++ resources/js/components/page-comments.ts | 2 +- tests/Entity/CommentTest.php | 26 ++++++++++++++++ 7 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 3336e17e988..c488350ca24 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -20,7 +20,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 $parent_id, string $content_ref): Comment { $userId = user()->id; $comment = new Comment(); @@ -30,6 +30,7 @@ public function create(Entity $entity, string $html, ?int $parent_id): Comment $comment->updated_by = $userId; $comment->local_id = $this->getNextLocalId($entity); $comment->parent_id = $parent_id; + $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $content_ref) === 1 ? $content_ref : ''; $entity->comments()->save($comment); ActivityService::add(ActivityType::COMMENT_CREATE, $comment); diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index 52ccc823864..26208006784 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -26,6 +26,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,7 +41,7 @@ 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); + $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $input['content_ref']); return view('comments.comment-branch', [ 'readOnly' => false, 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/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 @@ +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/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index a19d2c7d4b1..45f8d6a9f6d 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -95,7 +95,7 @@ export class PageComments extends Component { const reqData = { html: this.wysiwygEditor.getContent(), parent_id: this.parentId || null, - content_reference: this.contentReference || '', + content_ref: this.contentReference || '', }; window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => { diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php index 9e019e3d148..973b2b81d8d 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -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' => '

My comment

', + '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() { From 2e7544a865a2b8ca7fdd3e32bdd86746a1a62512 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 19 Apr 2025 12:46:47 +0100 Subject: [PATCH 04/19] Comments: Converted comment component to TS --- .../{page-comment.js => page-comment.ts} | 47 +++++++++++++------ resources/views/comments/comment.blade.php | 2 +- 2 files changed, 34 insertions(+), 15 deletions(-) rename resources/js/components/{page-comment.js => page-comment.ts} (70%) diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.ts similarity index 70% rename from resources/js/components/page-comment.js rename to resources/js/components/page-comment.ts index 8c0a8b33e54..b2e2bac2784 100644 --- a/resources/js/components/page-comment.js +++ b/resources/js/components/page-comment.ts @@ -4,33 +4,51 @@ import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComment extends Component { + protected commentId: string; + protected commentLocalId: string; + protected commentContentRef: string; + protected deletedText: string; + protected updatedText: 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 input: HTMLInputElement; + setup() { // Options this.commentId = this.$opts.commentId; this.commentLocalId = this.$opts.commentLocalId; - this.commentParentId = this.$opts.commentParentId; + this.commentContentRef = this.$opts.commentContentRef; 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.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.input = this.$refs.input; + this.input = this.$refs.input as HTMLInputElement; this.setupListeners(); } - setupListeners() { + protected setupListeners(): void { if (this.replyButton) { this.replyButton.addEventListener('click', () => this.$emit('reply', { id: this.commentLocalId, @@ -49,12 +67,12 @@ export class PageComment extends Component { } } - toggleEditMode(show) { + protected toggleEditMode(show: boolean) : void { this.contentContainer.toggleAttribute('hidden', show); this.form.toggleAttribute('hidden', !show); } - startEdit() { + protected startEdit() : void { this.toggleEditMode(true); if (this.wysiwygEditor) { @@ -67,29 +85,30 @@ export class PageComment extends Component { containerElement: this.input, darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.wysiwygTextDirection, + drawioUrl: '', + pageId: 0, translations: {}, - translationMap: window.editor_translations, + translationMap: (window as Record).editor_translations, }); - window.tinymce.init(config).then(editors => { + (window as {tinymce: {init: (Object) => Promise}}).tinymce.init(config).then(editors => { this.wysiwygEditor = editors[0]; setTimeout(() => this.wysiwygEditor.focus(), 50); }); } - async update(event) { + protected async update(event: Event): Promise { 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); + const newComment = htmlToDom(resp.data as string); this.container.replaceWith(newComment); window.$events.success(this.updatedText); } catch (err) { @@ -100,7 +119,7 @@ export class PageComment extends Component { } } - async delete() { + protected async delete(): Promise { this.showLoading(); await window.$http.delete(`/comment/${this.commentId}`); @@ -109,7 +128,7 @@ export class PageComment extends Component { window.$events.success(this.deletedText); } - showLoading() { + protected showLoading(): HTMLElement { const loading = getLoading(); loading.classList.add('px-l'); this.container.append(loading); diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 2bf89d6832d..c3578293a5a 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -4,7 +4,7 @@
Date: Sat, 19 Apr 2025 14:07:52 +0100 Subject: [PATCH 05/19] Comments: Added inline comment marker/highlight logic --- resources/js/components/page-comment.ts | 47 ++++++++++++++++++++- resources/js/components/pointer.ts | 6 +-- resources/js/services/dom.ts | 56 +++++++++++++++++++++++++ resources/js/services/util.ts | 2 +- resources/sass/_pages.scss | 21 ++++++++++ 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index b2e2bac2784..f4d295b95a3 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -1,6 +1,7 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom.ts'; +import {findTargetNodeAndOffset, getLoading, hashElement, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; +import {el} from "../wysiwyg/utils/dom"; export class PageComment extends Component { @@ -46,6 +47,7 @@ export class PageComment extends Component { this.input = this.$refs.input as HTMLInputElement; this.setupListeners(); + this.positionForReference(); } protected setupListeners(): void { @@ -135,4 +137,47 @@ export class PageComment extends Component { return loading; } + protected positionForReference() { + if (!this.commentContentRef) { + return; + } + + const [refId, refHash, refRange] = this.commentContentRef.split(':'); + const refEl = document.getElementById(refId); + if (!refEl) { + // TODO - Show outdated marker for comment + return; + } + + const actualHash = hashElement(refEl); + if (actualHash !== refHash) { + // TODO - Show outdated marker for comment + return; + } + + const refElBounds = refEl.getBoundingClientRect(); + let bounds = refElBounds; + const [rangeStart, rangeEnd] = refRange.split('-'); + if (rangeStart && rangeEnd) { + const range = new Range(); + const relStart = findTargetNodeAndOffset(refEl, Number(rangeStart)); + const relEnd = findTargetNodeAndOffset(refEl, Number(rangeEnd)); + if (relStart && relEnd) { + range.setStart(relStart.node, relStart.offset); + range.setEnd(relEnd.node, relEnd.offset); + bounds = range.getBoundingClientRect(); + } + } + + const relLeft = bounds.left - refElBounds.left; + const relTop = bounds.top - refElBounds.top; + // TODO - Extract to class, Use theme color + const marker = el('div', { + class: 'content-comment-highlight', + style: `left: ${relLeft}px; top: ${relTop}px; width: ${bounds.width}px; height: ${bounds.height}px;` + }, ['']); + + refEl.style.position = 'relative'; + refEl.append(marker); + } } diff --git a/resources/js/components/pointer.ts b/resources/js/components/pointer.ts index c3883b7b554..d84186d872d 100644 --- a/resources/js/components/pointer.ts +++ b/resources/js/components/pointer.ts @@ -1,8 +1,7 @@ import * as DOM from '../services/dom.ts'; import {Component} from './component'; import {copyTextToClipboard} from '../services/clipboard.ts'; -import {cyrb53} from "../services/util"; -import {normalizeNodeTextOffsetToParent} from "../services/dom.ts"; +import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom.ts"; import {PageComments} from "./page-comments"; export class Pointer extends Component { @@ -183,9 +182,8 @@ export class Pointer extends Component { return; } - const normalisedElemHtml = this.targetElement.outerHTML.replace(/\s{2,}/g, ''); const refId = this.targetElement.id; - const hash = cyrb53(normalisedElemHtml); + const hash = hashElement(this.targetElement); let range = ''; if (this.targetSelectionRange) { const commonContainer = this.targetSelectionRange.commonAncestorContainer; diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index 537af816a90..661ed7ca3e5 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 */ @@ -181,6 +183,9 @@ 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'); @@ -201,3 +206,54 @@ export function normalizeNodeTextOffsetToParent(node: Node, offset: number, pare 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, offset - currentOffset); + } + + currentOffset += elementTextLength; + } + } + + // Return null if not found within range + return null; +} + +/** + * Create a hash for the given HTML element. + */ +export function hashElement(element: HTMLElement): string { + const normalisedElemHtml = element.outerHTML.replace(/\s{2,}/g, ''); + return cyrb53(normalisedElemHtml); +} \ No newline at end of file diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts index 1a6fa55b6b0..61a02a3d24d 100644 --- a/resources/js/services/util.ts +++ b/resources/js/services/util.ts @@ -164,5 +164,5 @@ export function cyrb53(str: string, seed: number = 0): string { h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return (4294967296 * (2097151 & h2) + (h1 >>> 0)) as string; + return String((4294967296 * (2097151 & h2) + (h1 >>> 0))); } \ No newline at end of file diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index de783705763..1fe22b9c4e2 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -219,6 +219,27 @@ 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; + } +} + // Page editor sidebar toolbox .floating-toolbox { @include mixins.lightDark(background-color, #FFF, #222); From 5bfba281fc0c57556d65092013b5663efd350b3e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Apr 2025 14:04:41 +0100 Subject: [PATCH 06/19] Comments: Started inline comment display windows --- lang/en/entities.php | 1 + resources/js/components/page-comment.ts | 66 ++++++++++++++++++++-- resources/sass/_animations.scss | 22 ++++++++ resources/sass/_pages.scss | 46 +++++++++++++++ resources/views/comments/comment.blade.php | 1 + 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/lang/en/entities.php b/lang/en/entities.php index a74785eaacd..9ce684ac71b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -402,6 +402,7 @@ 'comment_deleted_success' => 'Comment deleted', 'comment_created_success' => 'Comment added', 'comment_updated_success' => 'Comment updated', + 'comment_view' => 'View comment', 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', 'comment_in_reply_to' => 'In reply to :commentId', 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index f4d295b95a3..5a148c25885 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -3,6 +3,8 @@ import {findTargetNodeAndOffset, getLoading, hashElement, htmlToDom} from '../se import {buildForInput} from '../wysiwyg-tinymce/config'; import {el} from "../wysiwyg/utils/dom"; +import commentIcon from "@icons/comment.svg" + export class PageComment extends Component { protected commentId: string; @@ -10,6 +12,7 @@ export class PageComment extends Component { protected commentContentRef: string; protected deletedText: string; protected updatedText: string; + protected viewCommentText: string; protected wysiwygEditor: any = null; protected wysiwygLanguage: string; @@ -31,6 +34,7 @@ export class PageComment extends Component { this.commentContentRef = this.$opts.commentContentRef; this.deletedText = this.$opts.deletedText; this.updatedText = this.$opts.updatedText; + this.viewCommentText = this.$opts.viewCommentText; // Editor reference and text options this.wysiwygLanguage = this.$opts.wysiwygLanguage; @@ -171,13 +175,67 @@ export class PageComment extends Component { const relLeft = bounds.left - refElBounds.left; const relTop = bounds.top - refElBounds.top; - // TODO - Extract to class, Use theme color - const marker = el('div', { + + const marker = el('button', { + type: 'button', + class: 'content-comment-marker', + title: this.viewCommentText, + }); + marker.innerHTML = commentIcon; + marker.addEventListener('click', event => { + this.showCommentAtMarker(marker); + }); + + const markerWrap = el('div', { class: 'content-comment-highlight', style: `left: ${relLeft}px; top: ${relTop}px; width: ${bounds.width}px; height: ${bounds.height}px;` - }, ['']); + }, [marker]); refEl.style.position = 'relative'; - refEl.append(marker); + refEl.append(markerWrap); + } + + protected showCommentAtMarker(marker: HTMLElement): void { + + marker.hidden = true; + const readClone = this.container.closest('.comment-branch').cloneNode(true) as HTMLElement; + const toRemove = readClone.querySelectorAll('.actions, form'); + for (const el of toRemove) { + el.remove(); + } + + const close = el('button', {type: 'button'}, ['x']); + const jump = el('button', {type: 'button'}, ['Jump to thread']); + + const commentWindow = el('div', { + class: 'content-comment-window' + }, [ + el('div', { + class: 'content-comment-window-actions', + }, [jump, close]), + el('div', { + class: 'content-comment-window-content', + }, [readClone]), + ]); + + marker.parentElement.append(commentWindow); + + const closeAction = () => { + commentWindow.remove(); + marker.hidden = false; + }; + + close.addEventListener('click', closeAction.bind(this)); + + jump.addEventListener('click', () => { + closeAction(); + this.container.scrollIntoView({behavior: 'smooth'}); + const highlightTarget = this.container.querySelector('.header') as HTMLElement; + highlightTarget.classList.add('anim-highlight'); + highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight')) + }); + + // TODO - Position wrapper sensibly + // TODO - Movement control? } } 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/_pages.scss b/resources/sass/_pages.scss index 1fe22b9c4e2..ac2d195b4e8 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -239,6 +239,52 @@ body.tox-fullscreen, body.markdown-fullscreen { opacity: 0.25; } } +.content-comment-window { + font-size: vars.$fs-m; + line-height: 1.4; + position: relative; + z-index: 90; + pointer-events: all; + min-width: min(340px, 80vw); + background-color: #FFF; + //border: 1px solid var(--color-primary); + 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; +} +.content-comment-window-content { + padding: vars.$xs; + max-height: 200px; + overflow-y: scroll; +} +.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; + svg { + fill: #FFF; + width: 80%; + } +} // Page editor sidebar toolbox .floating-toolbox { diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index c3578293a5a..1886dad51e4 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -7,6 +7,7 @@ option:page-comment:comment-content-ref="{{ $comment->content_ref }}" option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}" option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}" + option:page-comment:view-comment-text="{{ trans('entities.comment_view') }}" option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}" option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" id="comment{{$comment->local_id}}" From f656a82fe7a2be253ee53c3af8d27edd35617bd2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 24 Apr 2025 13:21:23 +0100 Subject: [PATCH 07/19] Comments: Styled content comments & improved interaction --- lang/en/entities.php | 1 + resources/js/components/page-comment.ts | 52 +++++++++++++++---- resources/sass/_components.scss | 27 ++++++++++ resources/sass/_pages.scss | 29 +++++++++-- .../views/comments/comment-branch.blade.php | 2 +- resources/views/comments/comment.blade.php | 2 + 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/lang/en/entities.php b/lang/en/entities.php index 9ce684ac71b..f9fab8ebfd3 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -403,6 +403,7 @@ 'comment_created_success' => 'Comment added', 'comment_updated_success' => 'Comment updated', '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_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index 5a148c25885..9192c7c56af 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -4,6 +4,13 @@ import {buildForInput} from '../wysiwyg-tinymce/config'; import {el} from "../wysiwyg/utils/dom"; import commentIcon from "@icons/comment.svg" +import closeIcon from "@icons/close.svg" + +/** + * 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 PageComment extends Component { @@ -13,6 +20,8 @@ export class PageComment extends Component { protected deletedText: string; protected updatedText: string; protected viewCommentText: string; + protected jumpToThreadText: string; + protected closeText: string; protected wysiwygEditor: any = null; protected wysiwygLanguage: string; @@ -35,6 +44,8 @@ export class PageComment extends Component { this.deletedText = this.$opts.deletedText; this.updatedText = this.$opts.updatedText; this.viewCommentText = this.$opts.viewCommentText; + this.jumpToThreadText = this.$opts.jumpToThreadText; + this.closeText = this.$opts.closeText; // Editor reference and text options this.wysiwygLanguage = this.$opts.wysiwygLanguage; @@ -130,7 +141,7 @@ export class PageComment extends Component { await window.$http.delete(`/comment/${this.commentId}`); this.$emit('delete'); - this.container.closest('.comment-branch').remove(); + this.container.closest('.comment-branch')?.remove(); window.$events.success(this.deletedText); } @@ -196,16 +207,22 @@ export class PageComment extends Component { } protected showCommentAtMarker(marker: HTMLElement): void { - + // Hide marker and close existing marker windows + if (openMarkerClose) { + openMarkerClose(); + } marker.hidden = true; - const readClone = this.container.closest('.comment-branch').cloneNode(true) as HTMLElement; + + // Build comment window + const readClone = (this.container.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'}, ['x']); - const jump = el('button', {type: 'button'}, ['Jump to thread']); + 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' @@ -214,19 +231,29 @@ export class PageComment extends Component { class: 'content-comment-window-actions', }, [jump, close]), el('div', { - class: 'content-comment-window-content', + class: 'content-comment-window-content comment-container-compact comment-container-super-compact', }, [readClone]), ]); - marker.parentElement.append(commentWindow); + marker.parentElement?.append(commentWindow); + // Handle interaction within window const closeAction = () => { commentWindow.remove(); marker.hidden = false; + window.removeEventListener('click', windowCloseAction); + openMarkerClose = null; }; - close.addEventListener('click', closeAction.bind(this)); + 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(); this.container.scrollIntoView({behavior: 'smooth'}); @@ -235,7 +262,12 @@ export class PageComment extends Component { highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight')) }); - // TODO - Position wrapper sensibly - // TODO - Movement control? + // 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`; + } } } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 58d39d3ee6e..26b0518275b 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -746,6 +746,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { height: calc(100% - vars.$m); } +.comment-branch .comment-box { + margin-bottom: vars.$m; +} + .comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator { display: none; } @@ -761,6 +765,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .comment-container-compact .comment-box { + margin-bottom: vars.$xs; .meta { font-size: 0.8rem; } @@ -778,6 +783,28 @@ 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: 18px; + 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; } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index ac2d195b4e8..be5a0f7c360 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -242,12 +242,13 @@ body.tox-fullscreen, body.markdown-fullscreen { .content-comment-window { font-size: vars.$fs-m; line-height: 1.4; - position: relative; - z-index: 90; + position: absolute; + top: calc(100% + 3px); + left: 0; + z-index: 92; pointer-events: all; min-width: min(340px, 80vw); background-color: #FFF; - //border: 1px solid var(--color-primary); box-shadow: vars.$bs-hover; border-radius: 4px; overflow: hidden; @@ -258,9 +259,24 @@ body.tox-fullscreen, body.markdown-fullscreen { 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; + padding: vars.$xs vars.$s vars.$xs vars.$xs; max-height: 200px; overflow-y: scroll; } @@ -280,11 +296,16 @@ body.tox-fullscreen, body.markdown-fullscreen { 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 { diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php index 78d19ac3ea4..83fa4b5c595 100644 --- a/resources/views/comments/comment-branch.blade.php +++ b/resources/views/comments/comment-branch.blade.php @@ -1,5 +1,5 @@
-
+
@include('comments.comment', ['comment' => $branch['comment']])
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 1886dad51e4..5b79da4ac68 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -8,6 +8,8 @@ option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}" option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}" option:page-comment:view-comment-text="{{ trans('entities.comment_view') }}" + option:page-comment:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}" + option:page-comment:close-text="{{ trans('common.close') }}" option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}" option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}" id="comment{{$comment->local_id}}" From ecda4e1d6f42108fef9c62ff4a9a73a056caa089 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 26 Apr 2025 21:05:54 +0100 Subject: [PATCH 08/19] Comments: Added reference marker to comments --- resources/icons/bookmark.svg | 1 + resources/js/components/page-comment.ts | 17 +++++++--- resources/js/components/page-display.js | 3 ++ resources/sass/_components.scss | 38 ++++++++++++++++++++++ resources/sass/_pages.scss | 3 ++ resources/views/comments/comment.blade.php | 5 +++ 6 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 resources/icons/bookmark.svg 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 @@ + \ No newline at end of file diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index 9192c7c56af..24964bf5cba 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -5,6 +5,7 @@ import {el} from "../wysiwyg/utils/dom"; import commentIcon from "@icons/comment.svg" import closeIcon from "@icons/close.svg" +import {PageDisplay} from "./page-display"; /** * Track the close function for the current open marker so it can be closed @@ -35,6 +36,7 @@ export class PageComment extends Component { protected deleteButton: HTMLElement; protected replyButton: HTMLElement; protected input: HTMLInputElement; + protected contentRefLink: HTMLLinkElement|null; setup() { // Options @@ -60,6 +62,7 @@ export class PageComment extends Component { this.deleteButton = this.$refs.deleteButton; this.replyButton = this.$refs.replyButton; this.input = this.$refs.input as HTMLInputElement; + this.contentRefLink = (this.$refs.contentRef || null) as HTMLLinkElement|null; this.setupListeners(); this.positionForReference(); @@ -153,21 +156,20 @@ export class PageComment extends Component { } protected positionForReference() { - if (!this.commentContentRef) { + if (!this.commentContentRef || !this.contentRefLink) { return; } const [refId, refHash, refRange] = this.commentContentRef.split(':'); const refEl = document.getElementById(refId); if (!refEl) { - // TODO - Show outdated marker for comment + this.contentRefLink.classList.add('outdated', 'missing'); return; } const actualHash = hashElement(refEl); if (actualHash !== refHash) { - // TODO - Show outdated marker for comment - return; + this.contentRefLink.classList.add('outdated'); } const refElBounds = refEl.getBoundingClientRect(); @@ -204,6 +206,13 @@ export class PageComment extends Component { refEl.style.position = 'relative'; refEl.append(markerWrap); + + this.contentRefLink.href = `#${refEl.id}`; + this.contentRefLink.addEventListener('click', (event: MouseEvent) => { + const pageDisplayComponent = window.$components.get('page-display')[0] as PageDisplay; + event.preventDefault(); + pageDisplayComponent.goToText(refId); + }); } protected showCommentAtMarker(marker: HTMLElement): void { diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index d3ac78a4ad1..13670c4bf50 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -57,6 +57,9 @@ export class PageDisplay extends Component { } } + /** + * @public + */ goToText(text) { const idElem = document.getElementById(text); diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 26b0518275b..5486d611288 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -746,6 +746,44 @@ 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; + } +} + .comment-branch .comment-box { margin-bottom: vars.$m; } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index be5a0f7c360..dbdcc06656d 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -280,6 +280,9 @@ body.tox-fullscreen, body.markdown-fullscreen { max-height: 200px; overflow-y: scroll; } +.content-comment-window-content .comment-reference-indicator-wrap { + display: none; +} .content-comment-marker { position: absolute; right: -16px; diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 5b79da4ac68..7cc84a54cf1 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -77,6 +77,11 @@ class="comment-box"> @icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}

@endif + @if($comment->content_ref) + + @endif {!! $commentHtml !!}
From e8f44186a8ebfac6789800211cb5a947991bf971 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 27 Apr 2025 16:51:24 +0100 Subject: [PATCH 09/19] Comments: Split out page comment reference logic to own component Started support for editor view. Moved comment elements to be added relative to content area instad of specific target reference element. Added relocating on screen size change. --- resources/js/components/editor-toolbox.js | 13 + resources/js/components/index.ts | 1 + .../js/components/page-comment-reference.ts | 223 ++++++++++++++++++ resources/js/components/page-comment.ts | 149 +----------- resources/js/components/page-display.js | 3 - resources/js/services/events.ts | 11 + resources/sass/_content.scss | 1 + resources/views/comments/comment.blade.php | 11 +- 8 files changed, 256 insertions(+), 156 deletions(-) create mode 100644 resources/js/components/page-comment-reference.ts diff --git a/resources/js/components/editor-toolbox.js b/resources/js/components/editor-toolbox.js index ddb4ff39c76..95339328542 100644 --- a/resources/js/components/editor-toolbox.js +++ b/resources/js/components/editor-toolbox.js @@ -10,6 +10,10 @@ export class EditorToolbox extends Component { this.toggleButton = this.$refs.toggle; this.editorWrapEl = this.container.closest('.page-editor'); + // State + this.open = false; + this.tab = ''; + this.setupListeners(); // Set the first tab as active on load @@ -34,6 +38,8 @@ export class EditorToolbox extends Component { 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) { @@ -54,6 +60,13 @@ export class EditorToolbox extends Component { if (openToolbox && !this.container.classList.contains('open')) { this.toggle(); } + + this.tab = tabName; + this.emitState(); + } + + emitState() { + this.$emit('change', {tab: this.tab, open: this.open}); } } 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..72e3dbe480d --- /dev/null +++ b/resources/js/components/page-comment-reference.ts @@ -0,0 +1,223 @@ +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 {scrollAndHighlightElement} from "../services/util"; + +/** + * 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 + const pageContentArea = document.querySelector('.page-content'); + if (pageContentArea instanceof HTMLElement) { + this.updateMarker(pageContentArea); + } + + // Handle editor view to show on comments toolbox view + window.addEventListener('editor-toolbox-change', (event) => { + const tabName: string = (event as {detail: {tab: string, open: boolean}}).detail.tab; + const isOpen = (event as {detail: {tab: string, open: boolean}}).detail.open; + if (tabName === 'comments' && isOpen) { + this.showForEditor(); + } else { + this.hideMarker(); + } + }); + } + + 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 refCloneToAssess = refEl.cloneNode(true) as HTMLElement; + const toRemove = refCloneToAssess.querySelectorAll('[data-lexical-text]'); + refCloneToAssess.removeAttribute('style'); + for (const el of toRemove) { + el.after(...el.childNodes); + el.remove(); + } + + const actualHash = hashElement(refCloneToAssess); + if (actualHash !== refHash) { + this.link.classList.add('outdated'); + } + + const marker = el('button', { + type: 'button', + class: 'content-comment-marker', + title: this.viewCommentText, + }); + marker.innerHTML = 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); + }); + + window.addEventListener('resize', () => { + this.positionMarker(refEl, refRange); + }); + } + + 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`; + } + + protected hideMarker() { + // Hide marker and close existing marker windows + if (openMarkerClose) { + openMarkerClose(); + } + this.markerWrap?.remove(); + } + + 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.ts b/resources/js/components/page-comment.ts index 24964bf5cba..11ad769b1d0 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -1,28 +1,13 @@ import {Component} from './component'; -import {findTargetNodeAndOffset, getLoading, hashElement, htmlToDom} from '../services/dom.ts'; +import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; -import {el} from "../wysiwyg/utils/dom"; - -import commentIcon from "@icons/comment.svg" -import closeIcon from "@icons/close.svg" -import {PageDisplay} from "./page-display"; - -/** - * 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 PageComment extends Component { protected commentId: string; protected commentLocalId: string; - protected commentContentRef: string; protected deletedText: string; protected updatedText: string; - protected viewCommentText: string; - protected jumpToThreadText: string; - protected closeText: string; protected wysiwygEditor: any = null; protected wysiwygLanguage: string; @@ -36,18 +21,13 @@ export class PageComment extends Component { protected deleteButton: HTMLElement; protected replyButton: HTMLElement; protected input: HTMLInputElement; - protected contentRefLink: HTMLLinkElement|null; setup() { // Options this.commentId = this.$opts.commentId; this.commentLocalId = this.$opts.commentLocalId; - this.commentContentRef = this.$opts.commentContentRef; this.deletedText = this.$opts.deletedText; this.updatedText = this.$opts.updatedText; - this.viewCommentText = this.$opts.viewCommentText; - this.jumpToThreadText = this.$opts.jumpToThreadText; - this.closeText = this.$opts.closeText; // Editor reference and text options this.wysiwygLanguage = this.$opts.wysiwygLanguage; @@ -62,10 +42,8 @@ export class PageComment extends Component { this.deleteButton = this.$refs.deleteButton; this.replyButton = this.$refs.replyButton; this.input = this.$refs.input as HTMLInputElement; - this.contentRefLink = (this.$refs.contentRef || null) as HTMLLinkElement|null; this.setupListeners(); - this.positionForReference(); } protected setupListeners(): void { @@ -154,129 +132,4 @@ export class PageComment extends Component { this.container.append(loading); return loading; } - - protected positionForReference() { - if (!this.commentContentRef || !this.contentRefLink) { - return; - } - - const [refId, refHash, refRange] = this.commentContentRef.split(':'); - const refEl = document.getElementById(refId); - if (!refEl) { - this.contentRefLink.classList.add('outdated', 'missing'); - return; - } - - const actualHash = hashElement(refEl); - if (actualHash !== refHash) { - this.contentRefLink.classList.add('outdated'); - } - - const refElBounds = refEl.getBoundingClientRect(); - let bounds = refElBounds; - const [rangeStart, rangeEnd] = refRange.split('-'); - if (rangeStart && rangeEnd) { - const range = new Range(); - const relStart = findTargetNodeAndOffset(refEl, Number(rangeStart)); - const relEnd = findTargetNodeAndOffset(refEl, Number(rangeEnd)); - if (relStart && relEnd) { - range.setStart(relStart.node, relStart.offset); - range.setEnd(relEnd.node, relEnd.offset); - bounds = range.getBoundingClientRect(); - } - } - - const relLeft = bounds.left - refElBounds.left; - const relTop = bounds.top - refElBounds.top; - - const marker = el('button', { - type: 'button', - class: 'content-comment-marker', - title: this.viewCommentText, - }); - marker.innerHTML = commentIcon; - marker.addEventListener('click', event => { - this.showCommentAtMarker(marker); - }); - - const markerWrap = el('div', { - class: 'content-comment-highlight', - style: `left: ${relLeft}px; top: ${relTop}px; width: ${bounds.width}px; height: ${bounds.height}px;` - }, [marker]); - - refEl.style.position = 'relative'; - refEl.append(markerWrap); - - this.contentRefLink.href = `#${refEl.id}`; - this.contentRefLink.addEventListener('click', (event: MouseEvent) => { - const pageDisplayComponent = window.$components.get('page-display')[0] as PageDisplay; - event.preventDefault(); - pageDisplayComponent.goToText(refId); - }); - } - - protected showCommentAtMarker(marker: HTMLElement): void { - // Hide marker and close existing marker windows - if (openMarkerClose) { - openMarkerClose(); - } - marker.hidden = true; - - // Build comment window - const readClone = (this.container.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(); - this.container.scrollIntoView({behavior: 'smooth'}); - const highlightTarget = this.container.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`; - } - } } diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index 13670c4bf50..d3ac78a4ad1 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -57,9 +57,6 @@ export class PageDisplay extends Component { } } - /** - * @public - */ goToText(text) { const idElem = document.getElementById(text); diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index be9fba7eca5..7dae6dc29d9 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -24,6 +24,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: Function): 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. 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/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 7cc84a54cf1..5310b2fe4c5 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -4,12 +4,8 @@
@endif @if($comment->content_ref) @endif {!! $commentHtml !!} From 8bdf948743016f0461e589759130cbb50e46ab20 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 28 Apr 2025 15:37:09 +0100 Subject: [PATCH 10/19] Comments: Added archive endpoints, messages, Js actions and tests --- app/Activity/CommentRepo.php | 27 +++++++++ .../Controllers/CommentController.php | 36 +++++++++++ lang/en/common.php | 2 + lang/en/entities.php | 2 + resources/icons/archive.svg | 1 + resources/js/components/page-comment.ts | 19 +++++- resources/views/comments/comment.blade.php | 7 +++ routes/web.php | 2 + tests/Entity/CommentTest.php | 60 +++++++++++++++++++ 9 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 resources/icons/archive.svg diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index c488350ca24..866368ee649 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -53,6 +53,33 @@ public function update(Comment $comment, string $html): Comment return $comment; } + + /** + * Archive an existing comment. + */ + public function archive(Comment $comment): Comment + { + $comment->archived = true; + $comment->save(); + + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + + return $comment; + } + + /** + * Un-archive an existing comment. + */ + public function unarchive(Comment $comment): Comment + { + $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 26208006784..7a290ebabc5 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -75,6 +75,42 @@ public function update(Request $request, int $commentId) ]); } + /** + * Mark a comment as archived. + */ + public function archive(int $id) + { + $comment = $this->commentRepo->getById($id); + if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { + $this->showPermissionError(); + } + + $this->commentRepo->archive($comment); + + return view('comments.comment', [ + 'comment' => $comment, + 'readOnly' => false, + ]); + } + + /** + * Unmark a comment as archived. + */ + public function unarchive(int $id) + { + $comment = $this->commentRepo->getById($id); + if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { + $this->showPermissionError(); + } + + $this->commentRepo->unarchive($comment); + + return view('comments.comment', [ + 'comment' => $comment, + 'readOnly' => false, + ]); + } + /** * Delete a comment from the system. */ 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 f9fab8ebfd3..141e75b5f35 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -402,6 +402,8 @@ '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?', 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 @@ + \ No newline at end of file diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index 11ad769b1d0..d2cbd21d1db 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -8,6 +8,7 @@ export class PageComment extends Component { protected commentLocalId: string; protected deletedText: string; protected updatedText: string; + protected archiveText: string; protected wysiwygEditor: any = null; protected wysiwygLanguage: string; @@ -20,6 +21,7 @@ export class PageComment extends Component { protected editButton: HTMLElement; protected deleteButton: HTMLElement; protected replyButton: HTMLElement; + protected archiveButton: HTMLElement; protected input: HTMLInputElement; setup() { @@ -27,7 +29,8 @@ export class PageComment extends Component { this.commentId = this.$opts.commentId; this.commentLocalId = this.$opts.commentLocalId; this.deletedText = this.$opts.deletedText; - this.updatedText = this.$opts.updatedText; + this.deletedText = this.$opts.deletedText; + this.archiveText = this.$opts.archiveText; // Editor reference and text options this.wysiwygLanguage = this.$opts.wysiwygLanguage; @@ -41,6 +44,7 @@ export class PageComment extends Component { 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(); @@ -63,6 +67,10 @@ export class PageComment extends Component { 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 { @@ -126,6 +134,15 @@ export class PageComment extends Component { window.$events.success(this.deletedText); } + protected async archive(): Promise { + this.showLoading(); + const isArchived = this.archiveButton.dataset.isArchived === 'true'; + + await window.$http.put(`/comment/${this.commentId}/${isArchived ? 'unarchive' : 'archive'}`); + this.$emit('archive'); + window.$events.success(this.archiveText); + } + protected showLoading(): HTMLElement { const loading = getLoading(); loading.classList.add('px-l'); diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 5310b2fe4c5..58e057140a0 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -6,6 +6,7 @@ option:page-comment:comment-local-id="{{ $comment->local_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}}" @@ -37,6 +38,12 @@ class="comment-box"> @if(userCan('comment-create-all')) @endif + @if(userCan('comment-update', $comment) || userCan('comment-delete', $comment)) + + @endif @if(userCan('comment-update', $comment)) @endif 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/CommentTest.php b/tests/Entity/CommentTest.php index 973b2b81d8d..baf0d392beb 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -106,6 +106,66 @@ 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_scripts_cannot_be_injected_via_comment_html() { $page = $this->entities->page(); From 099f6104d07faffdcb2bd9793b249055eb4795b2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 28 Apr 2025 20:09:18 +0100 Subject: [PATCH 11/19] Comments: Started archive display, created mode for tree node --- app/Activity/CommentRepo.php | 10 ++++++ .../Controllers/CommentController.php | 19 +++++----- app/Activity/Tools/CommentTree.php | 35 +++++++++++++------ app/Activity/Tools/CommentTreeNode.php | 23 ++++++++++++ lang/en/entities.php | 1 + resources/js/components/page-comment.ts | 6 ++-- resources/js/components/page-comments.ts | 16 +++++++++ .../views/comments/comment-branch.blade.php | 7 ++-- resources/views/comments/comment.blade.php | 2 +- resources/views/comments/comments.blade.php | 19 +++++++--- 10 files changed, 110 insertions(+), 28 deletions(-) create mode 100644 app/Activity/Tools/CommentTreeNode.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 866368ee649..bf162f68ae6 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; @@ -59,6 +61,10 @@ public function update(Comment $comment, string $html): Comment */ public function archive(Comment $comment): Comment { + if ($comment->parent_id) { + throw new NotifyException('Only top-level comments can be archived.'); + } + $comment->archived = true; $comment->save(); @@ -72,6 +78,10 @@ public function archive(Comment $comment): Comment */ public function unarchive(Comment $comment): Comment { + if ($comment->parent_id) { + throw new NotifyException('Only top-level comments can be un-archived.'); + } + $comment->archived = false; $comment->save(); diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index 7a290ebabc5..7f16c17ffce 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; @@ -45,10 +47,7 @@ public function savePageComment(Request $request, int $pageId) return view('comments.comment-branch', [ 'readOnly' => false, - 'branch' => [ - 'comment' => $comment, - 'children' => [], - ] + 'branch' => new CommentTreeNode($comment, 0, []), ]); } @@ -81,15 +80,17 @@ public function update(Request $request, int $commentId) 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); - return view('comments.comment', [ - 'comment' => $comment, + $tree = new CommentTree($comment->entity); + return view('comments.comment-branch', [ 'readOnly' => false, + 'branch' => $tree->getCommentNodeForId($id), ]); } @@ -99,15 +100,17 @@ public function archive(int $id) 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); - return view('comments.comment', [ - 'comment' => $comment, + $tree = new CommentTree($comment->entity); + return view('comments.comment-branch', [ 'readOnly' => false, + 'branch' => $tree->getCommentNodeForId($id), ]); } diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 16f6804ea42..13afc92521d 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; @@ -36,9 +36,25 @@ 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 getArchived(): array + { + return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived); + } + + 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 +70,7 @@ public function canUpdateAny(): bool /** * @param Comment[] $comments + * @return CommentTreeNode[] */ protected function createTree(array $comments): array { @@ -77,26 +94,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 @@ +comment = $comment; + $this->depth = $depth; + $this->children = $children; + } +} diff --git a/lang/en/entities.php b/lang/en/entities.php index 141e75b5f35..cda58e65bdf 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -392,6 +392,7 @@ 'comment' => 'Comment', 'comments' => 'Comments', 'comment_add' => 'Add Comment', + 'comment_archived' => ':count Archived Comment|:count Archived Comments', 'comment_placeholder' => 'Leave a comment here', 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', 'comment_save' => 'Save Comment', diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index d2cbd21d1db..82cb95f13de 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -137,10 +137,12 @@ export class PageComment extends Component { protected async archive(): Promise { this.showLoading(); const isArchived = this.archiveButton.dataset.isArchived === 'true'; + const action = isArchived ? 'unarchive' : 'archive'; - await window.$http.put(`/comment/${this.commentId}/${isArchived ? 'unarchive' : 'archive'}`); - this.$emit('archive'); + const response = await window.$http.put(`/comment/${this.commentId}/${action}`); + this.$emit(action, {new_thread_dom: htmlToDom(response.data as string)}); window.$events.success(this.archiveText); + this.container.closest('.comment-branch')?.remove(); } protected showLoading(): HTMLElement { diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index 45f8d6a9f6d..083919b826f 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -9,6 +9,12 @@ export interface CommentReplyEvent extends Event { } } +export interface ArchiveEvent extends Event { + detail: { + new_thread_dom: HTMLElement; + } +} + export class PageComments extends Component { private elem: HTMLElement; @@ -17,6 +23,7 @@ export class PageComments extends Component { private commentCountBar: HTMLElement; private commentsTitle: HTMLElement; private addButtonContainer: HTMLElement; + private archiveContainer: HTMLElement; private replyToRow: HTMLElement; private formContainer: HTMLElement; private form: HTMLFormElement; @@ -43,6 +50,7 @@ export class PageComments extends Component { this.commentCountBar = this.$refs.commentCountBar; this.commentsTitle = this.$refs.commentsTitle; this.addButtonContainer = this.$refs.addButtonContainer; + this.archiveContainer = this.$refs.archiveContainer; this.replyToRow = this.$refs.replyToRow; this.formContainer = this.$refs.formContainer; this.form = this.$refs.form as HTMLFormElement; @@ -75,6 +83,14 @@ export class PageComments extends Component { this.setReply(event.detail.id, event.detail.element); }); + this.elem.addEventListener('page-comment-archive', (event: ArchiveEvent) => { + this.archiveContainer.append(event.detail.new_thread_dom); + }); + + this.elem.addEventListener('page-comment-unarchive', (event: ArchiveEvent) => { + this.container.append(event.detail.new_thread_dom) + }); + if (this.form) { this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this)); this.hideFormButton.addEventListener('click', this.hideForm.bind(this)); diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php index 83fa4b5c595..658c33219c3 100644 --- a/resources/views/comments/comment-branch.blade.php +++ b/resources/views/comments/comment-branch.blade.php @@ -1,13 +1,16 @@ +{{-- +$branch CommentTreeNode +--}}
- @include('comments.comment', ['comment' => $branch['comment']]) + @include('comments.comment', ['comment' => $branch->comment])
- @foreach($branch['children'] as $childBranch) + @foreach($branch->children as $childBranch) @include('comments.comment-branch', ['branch' => $childBranch]) @endforeach
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 58e057140a0..fe61bf1a4f6 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -38,7 +38,7 @@ class="comment-box"> @if(userCan('comment-create-all')) @endif - @if(userCan('comment-update', $comment) || userCan('comment-delete', $comment)) + @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment))) @endif
-
- @foreach($commentTree->get() as $branch) +
+ @foreach($commentTree->getActive() as $branch) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) @endforeach
@@ -27,14 +27,25 @@ class="button outline">{{ trans('entities.comment_add') }} @if(userCan('comment-create-all')) @include('comments.create') @if (!$commentTree->empty()) -
+
+ + + + class="button outline ml-auto">{{ trans('entities.comment_add') }}
@endif @endif +
+ @foreach($commentTree->getArchived() as $branch) + @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) + @endforeach +
+ @if(userCan('comment-create-all') || $commentTree->canUpdateAny()) @push('body-end') From e7dcc2dcdf9e709523705a2b9ea9ffb7dedda59b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 30 Apr 2025 17:42:09 +0100 Subject: [PATCH 12/19] Comments: Moved to tab UI, Converted tabs component to ts --- app/Activity/Tools/CommentTree.php | 12 +++- lang/en/entities.php | 5 +- resources/js/components/page-comment.ts | 2 +- resources/js/components/page-comments.ts | 35 ++++++--- resources/js/components/{tabs.js => tabs.ts} | 25 ++++--- resources/sass/_components.scss | 7 ++ resources/views/comments/comments.blade.php | 76 +++++++++++++------- resources/views/pages/show.blade.php | 6 -- 8 files changed, 115 insertions(+), 53 deletions(-) rename resources/js/components/{tabs.js => tabs.ts} (78%) diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 13afc92521d..a05a9d24726 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -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 @@ -41,11 +41,21 @@ public function getActive(): array 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) { diff --git a/lang/en/entities.php b/lang/en/entities.php index cda58e65bdf..c70658c0176 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -392,9 +392,10 @@ 'comment' => 'Comment', 'comments' => 'Comments', 'comment_add' => 'Add Comment', - 'comment_archived' => ':count Archived Comment|:count Archived Comments', + '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_save' => 'Save Comment', 'comment_new' => 'New Comment', 'comment_created' => 'commented :createDiff', diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index 82cb95f13de..12485b80728 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -140,8 +140,8 @@ export class PageComment extends Component { const action = isArchived ? 'unarchive' : 'archive'; const response = await window.$http.put(`/comment/${this.commentId}/${action}`); - this.$emit(action, {new_thread_dom: htmlToDom(response.data as string)}); window.$events.success(this.archiveText); + this.$emit(action, {new_thread_dom: htmlToDom(response.data as string)}); this.container.closest('.comment-branch')?.remove(); } diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index 083919b826f..2482c9dcb7d 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -1,6 +1,7 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; +import {Tabs} from "./tabs"; export interface CommentReplyEvent extends Event { detail: { @@ -21,7 +22,8 @@ export class PageComments extends Component { private pageId: number; private container: HTMLElement; private commentCountBar: HTMLElement; - private commentsTitle: HTMLElement; + private activeTab: HTMLElement; + private archivedTab: HTMLElement; private addButtonContainer: HTMLElement; private archiveContainer: HTMLElement; private replyToRow: HTMLElement; @@ -37,6 +39,7 @@ export class PageComments extends Component { private wysiwygEditor: any = null; private createdText: string; private countText: string; + private archivedCountText: string; private parentId: number | null = null; private contentReference: string = ''; private formReplyText: string = ''; @@ -48,7 +51,8 @@ export class PageComments extends Component { // Element references this.container = this.$refs.commentContainer; this.commentCountBar = this.$refs.commentCountBar; - this.commentsTitle = this.$refs.commentsTitle; + 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; @@ -67,6 +71,7 @@ export class PageComments extends Component { // Translations this.createdText = this.$opts.createdText; this.countText = this.$opts.countText; + this.archivedCountText = this.$opts.archivedCountText; this.formReplyText = this.formReplyLink?.textContent || ''; @@ -85,10 +90,12 @@ export class PageComments extends Component { this.elem.addEventListener('page-comment-archive', (event: ArchiveEvent) => { this.archiveContainer.append(event.detail.new_thread_dom); + setTimeout(() => this.updateCount(), 1); }); this.elem.addEventListener('page-comment-unarchive', (event: ArchiveEvent) => { - this.container.append(event.detail.new_thread_dom) + this.container.append(event.detail.new_thread_dom); + setTimeout(() => this.updateCount(), 1); }); if (this.form) { @@ -136,8 +143,10 @@ export class PageComments extends Component { } protected updateCount(): void { - const count = this.getCommentCount(); - this.commentsTitle.textContent = window.$trans.choice(this.countText, count); + 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 { @@ -155,12 +164,18 @@ export class PageComments extends Component { 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.getCommentCount() > 0) { + if (this.getActiveThreadCount() > 0) { this.elem.append(this.addButtonContainer); } else { this.commentCountBar.append(this.addButtonContainer); @@ -198,8 +213,12 @@ export class PageComments extends Component { } } - protected getCommentCount(): number { - return this.container.querySelectorAll('[component="page-comment"]').length; + 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, commentElement): void { diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.ts similarity index 78% rename from resources/js/components/tabs.js rename to resources/js/components/tabs.ts index f0fc058ced7..56405b8c78e 100644 --- a/resources/js/components/tabs.js +++ b/resources/js/components/tabs.ts @@ -19,18 +19,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 +47,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); } @@ -54,7 +61,7 @@ export class Tabs extends Component { this.$emit('change', {showing: sectionId}); } - updateActiveState() { + protected updateActiveState(): void { const active = window.innerWidth < this.activeUnder; if (active === this.active) { return; @@ -69,13 +76,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/sass/_components.scss b/resources/sass/_components.scss index 5486d611288..d25fab299d9 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -802,6 +802,13 @@ 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 { diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 06e96cad689..882cfdf45ee 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -1,49 +1,73 @@ -
-
-
{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}
+
+
+ + +
@if ($commentTree->empty() && userCan('comment-create-all')) -
+
+ class="button outline mb-m">{{ trans('entities.comment_add') }}
@endif
-
- @foreach($commentTree->getActive() as $branch) - @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) - @endforeach -
+
+
+ @foreach($commentTree->getActive() as $branch) + @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) + @endforeach +
- @if(userCan('comment-create-all')) - @include('comments.create') - @if (!$commentTree->empty()) -
- - +

{{ trans('entities.comment_none') }}

- -
+ @if(userCan('comment-create-all')) + @include('comments.create') + @if (!$commentTree->empty()) +
+ +
+ @endif @endif - @endif +
-
+ @if(userCan('comment-create-all') || $commentTree->canUpdateAny()) 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)) - - @endif - @endif {!! $commentHtml !!} diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 882cfdf45ee..51c08d69a85 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -36,7 +36,7 @@ class="button outline mb-m">{{ trans('entities.comment_add') }} tabindex="0" role="tabpanel" aria-labelledby="comment-tab-active" - class="comment-container"> + class="comment-container no-outline">
@foreach($commentTree->getActive() as $branch) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) @@ -63,7 +63,7 @@ class="button outline ml-auto">{{ trans('entities.comment_add') }} role="tabpanel" aria-labelledby="comment-tab-archived" hidden="hidden" - class="comment-container"> + class="comment-container no-outline"> @foreach($commentTree->getArchived() as $branch) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) @endforeach 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 @@
+
From a27df485bb19a2fbcddf8cac8ae2b367d23022e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 9 May 2025 12:14:28 +0100 Subject: [PATCH 15/19] Comments: Fixed display, added archive list support for editor toolbox --- lang/en/entities.php | 1 + resources/js/components/page-comment-reference.ts | 15 ++++++++++++++- resources/sass/_components.scss | 15 +++++++++++++++ .../views/pages/parts/toolbox-comments.blade.php | 15 +++++++++++++-- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lang/en/entities.php b/lang/en/entities.php index e25a83299f3..6e616ded452 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -396,6 +396,7 @@ 'comment_placeholder' => 'Leave a comment here', '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', diff --git a/resources/js/components/page-comment-reference.ts b/resources/js/components/page-comment-reference.ts index 5a71f0c513b..48fb8ee0a49 100644 --- a/resources/js/components/page-comment-reference.ts +++ b/resources/js/components/page-comment-reference.ts @@ -34,13 +34,26 @@ export class PageCommentReference extends Component { window.addEventListener('editor-toolbox-change', (event) => { const tabName: string = (event as {detail: {tab: string, open: boolean}}).detail.tab; const isOpen = (event as {detail: {tab: string, open: boolean}}).detail.open; - if (tabName === 'comments' && isOpen) { + if (tabName === 'comments' && isOpen && this.link.checkVisibility()) { this.showForEditor(); } else { this.hideMarker(); } }); + // 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 => { const sectionId = (event as {detail: {showing: string}}).detail.showing; diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index a86d31ce373..faeb2e05141 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1202,4 +1202,19 @@ 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; + 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; } \ No newline at end of file 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 +--}}

{{ trans('entities.comments') }}

@@ -5,11 +8,19 @@

{{ trans('entities.comment_editor_explain') }}

- @foreach($comments->get() as $branch) + @foreach($comments->getActive() as $branch) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true]) @endforeach @if($comments->empty()) -

{{ trans('common.no_items') }}

+

{{ trans('entities.comment_none') }}

+ @endif + @if($comments->archivedThreadCount() > 0) +
+ {{ trans('entities.comment_archived_threads') }} + @foreach($comments->getArchived() as $branch) + @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true]) + @endforeach +
@endif
\ No newline at end of file From f8c0aaff0326ac77195d854effd9315d80aa2bc4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 9 May 2025 14:17:04 +0100 Subject: [PATCH 16/19] Comments: Checked content/arhived comment styles in dark mode Also added default non-clickable styles for scenarios for references which don't have an active content link. --- resources/sass/_components.scss | 9 ++++++++- resources/sass/_pages.scss | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index faeb2e05141..9e96b39fbb4 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -785,6 +785,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { top: 0; opacity: 0.15; } + &[href="#"] { + color: #444; + pointer-events: none; + } } .comment-branch .comment-box { @@ -836,7 +840,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { font-size: 12px; } .avatar { - width: 18px; + width: 22px; + height: 22px; margin-inline-end: 2px !important; } .content { @@ -1206,6 +1211,7 @@ input.scroll-box-search, .scroll-box-header-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; @@ -1217,4 +1223,5 @@ details.section-expander:open summary { } 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/_pages.scss b/resources/sass/_pages.scss index dbdcc06656d..621d08f45f2 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -248,7 +248,7 @@ body.tox-fullscreen, body.markdown-fullscreen { z-index: 92; pointer-events: all; min-width: min(340px, 80vw); - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); box-shadow: vars.$bs-hover; border-radius: 4px; overflow: hidden; From 62f78f1c6d1f901cd986495a7271b983b60ec74d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 12 May 2025 14:26:09 +0100 Subject: [PATCH 17/19] Comments: Split tests, added extra archive/reference tests --- app/Activity/CommentRepo.php | 4 +- tests/Entity/CommentDisplayTest.php | 134 ++++++++++++++++++ .../{CommentTest.php => CommentStoreTest.php} | 117 ++++----------- 3 files changed, 160 insertions(+), 95 deletions(-) create mode 100644 tests/Entity/CommentDisplayTest.php rename tests/Entity/{CommentTest.php => CommentStoreTest.php} (66%) diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index bf162f68ae6..c194e72168c 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -62,7 +62,7 @@ public function update(Comment $comment, string $html): Comment public function archive(Comment $comment): Comment { if ($comment->parent_id) { - throw new NotifyException('Only top-level comments can be archived.'); + throw new NotifyException('Only top-level comments can be archived.', '/', 400); } $comment->archived = true; @@ -79,7 +79,7 @@ public function archive(Comment $comment): Comment public function unarchive(Comment $comment): Comment { if ($comment->parent_id) { - throw new NotifyException('Only top-level comments can be un-archived.'); + throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); } $comment->archived = false; 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 @@ +asAdmin(); + $page = $this->entities->page(); + + $this->postJson("/comment/$page->id", ['html' => '

My new comment

']); + $this->postJson("/comment/$page->id", ['html' => '

My new comment

']); + + $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' => '

My nested comment

', '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' => '

My great comment to see in the editor

']); + + $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 66% rename from tests/Entity/CommentTest.php rename to tests/Entity/CommentStoreTest.php index baf0d392beb..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() { @@ -166,6 +166,29 @@ public function test_archive_endpoints_require_delete_or_edit_permissions() } } + 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(); @@ -225,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' => '

My new comment

']); - $this->postJson("/comment/$page->id", ['html' => '

My new comment

']); - - $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' => '

My nested comment

', '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' => '

My great comment to see in the editor

']); - - $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'); - } } From 8f92b6f21b2412005e138c1482f32c158ff69204 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 12 May 2025 15:31:55 +0100 Subject: [PATCH 18/19] Comments: Fixed a range of TS errors + other - Migrated toolbox component to TS - Aligned how custom event types are managed - Fixed PHP use of content_ref where not provided --- app/Activity/CommentRepo.php | 6 +- .../Controllers/CommentController.php | 3 +- .../{editor-toolbox.js => editor-toolbox.ts} | 43 +++++---- .../js/components/page-comment-reference.ts | 36 ++++---- resources/js/components/page-comment.ts | 62 ++++++++----- resources/js/components/page-comments.ts | 88 ++++++++----------- resources/js/components/pointer.ts | 46 +++++----- resources/js/components/tabs.ts | 17 ++-- resources/js/services/dom.ts | 6 +- resources/js/services/events.ts | 9 +- 10 files changed, 167 insertions(+), 149 deletions(-) rename resources/js/components/{editor-toolbox.js => editor-toolbox.ts} (61%) diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index c194e72168c..7005f8fcf83 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -22,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, string $content_ref): Comment + public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment { $userId = user()->id; $comment = new Comment(); @@ -31,8 +31,8 @@ public function create(Entity $entity, string $html, ?int $parent_id, string $co $comment->created_by = $userId; $comment->updated_by = $userId; $comment->local_id = $this->getNextLocalId($entity); - $comment->parent_id = $parent_id; - $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $content_ref) === 1 ? $content_ref : ''; + $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); diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index 7f16c17ffce..479d57c4db9 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -43,7 +43,8 @@ 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, $input['content_ref']); + $contentRef = $input['content_ref'] ?? ''; + $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef); return view('comments.comment-branch', [ 'readOnly' => false, diff --git a/resources/js/components/editor-toolbox.js b/resources/js/components/editor-toolbox.ts similarity index 61% rename from resources/js/components/editor-toolbox.js rename to resources/js/components/editor-toolbox.ts index 95339328542..60bdde05efb 100644 --- a/resources/js/components/editor-toolbox.js +++ b/resources/js/components/editor-toolbox.ts @@ -1,39 +1,49 @@ 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'); - - // State - this.open = false; - this.tab = ''; + 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'); @@ -42,7 +52,7 @@ export class EditorToolbox extends Component { 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'); @@ -65,8 +75,9 @@ export class EditorToolbox extends Component { this.emitState(); } - emitState() { - this.$emit('change', {tab: this.tab, open: this.open}); + protected emitState(): void { + const data: EditorToolboxChangeEventData = {tab: this.tab, open: this.open}; + this.$emit('change', data); } } diff --git a/resources/js/components/page-comment-reference.ts b/resources/js/components/page-comment-reference.ts index 48fb8ee0a49..009e806c104 100644 --- a/resources/js/components/page-comment-reference.ts +++ b/resources/js/components/page-comment-reference.ts @@ -4,6 +4,8 @@ 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 @@ -12,13 +14,13 @@ import {debounce, scrollAndHighlightElement} from "../services/util"; let openMarkerClose: Function|null = null; export class PageCommentReference extends Component { - protected link: HTMLLinkElement; - protected reference: string; + protected link!: HTMLLinkElement; + protected reference!: string; protected markerWrap: HTMLElement|null = null; - protected viewCommentText: string; - protected jumpToThreadText: string; - protected closeText: string; + protected viewCommentText!: string; + protected jumpToThreadText!: string; + protected closeText!: string; setup() { this.link = this.$el as HTMLLinkElement; @@ -31,15 +33,15 @@ export class PageCommentReference extends Component { this.showForDisplay(); // Handle editor view to show on comments toolbox view - window.addEventListener('editor-toolbox-change', (event) => { - const tabName: string = (event as {detail: {tab: string, open: boolean}}).detail.tab; - const isOpen = (event as {detail: {tab: string, open: boolean}}).detail.open; - if (tabName === 'comments' && isOpen && this.link.checkVisibility()) { - this.showForEditor(); - } else { - this.hideMarker(); - } - }); + window.addEventListener('editor-toolbox-change', ((event: CustomEvent) => { + 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 => { @@ -55,8 +57,8 @@ export class PageCommentReference extends Component { }, {capture: true}); // Handle comments tab changes to hide/show markers & indicators - window.addEventListener('tabs-change', event => { - const sectionId = (event as {detail: {showing: string}}).detail.showing; + window.addEventListener('tabs-change', ((event: CustomEvent) => { + const sectionId = event.detail.showing; if (!sectionId.startsWith('comment-tab-panel')) { return; } @@ -67,7 +69,7 @@ export class PageCommentReference extends Component { } else { this.hideMarker(); } - }); + }) as EventListener); } public showForDisplay() { diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index ce35cdc4a24..0c3e19f4b1a 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -1,29 +1,39 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom.ts'; +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 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; + 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 @@ -53,10 +63,11 @@ export class PageComment extends Component { protected setupListeners(): void { if (this.replyButton) { - this.replyButton.addEventListener('click', () => this.$emit('reply', { + const data: PageCommentReplyEventData = { id: this.commentLocalId, element: this.container, - })); + }; + this.replyButton.addEventListener('click', () => this.$emit('reply', data)); } if (this.editButton) { @@ -95,10 +106,10 @@ export class PageComment extends Component { drawioUrl: '', pageId: 0, translations: {}, - translationMap: (window as Record).editor_translations, + translationMap: (window as unknown as Record).editor_translations, }); - (window as {tinymce: {init: (Object) => Promise}}).tinymce.init(config).then(editors => { + (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => { this.wysiwygEditor = editors[0]; setTimeout(() => this.wysiwygEditor.focus(), 50); }); @@ -120,7 +131,9 @@ export class PageComment extends Component { window.$events.success(this.updatedText); } catch (err) { console.error(err); - window.$events.showValidationErrors(err); + if (err instanceof HttpError) { + window.$events.showValidationErrors(err); + } this.form.toggleAttribute('hidden', false); loading.remove(); } @@ -151,7 +164,8 @@ export class PageComment extends Component { const response = await window.$http.put(`/comment/${this.commentId}/${action}`); window.$events.success(this.archiveText); - this.$emit(action, {new_thread_dom: htmlToDom(response.data as string)}); + 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(branch, 'page-comment-reference'); diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index 04c8125808c..94f5ab3bb8e 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -1,50 +1,38 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom.ts'; +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"; - -export interface CommentReplyEvent extends Event { - detail: { - id: string; // ID of comment being replied to - element: HTMLElement; // Container for comment replied to - } -} - -export interface ArchiveEvent extends Event { - detail: { - new_thread_dom: HTMLElement; - } -} +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 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 createdText!: string; + private countText!: string; + private archivedCountText!: string; private parentId: number | null = null; private contentReference: string = ''; private formReplyText: string = ''; @@ -92,19 +80,19 @@ export class PageComments extends Component { this.hideForm(); }); - this.elem.addEventListener('page-comment-reply', (event: CommentReplyEvent) => { + this.elem.addEventListener('page-comment-reply', ((event: CustomEvent) => { this.setReply(event.detail.id, event.detail.element); - }); + }) as EventListener); - this.elem.addEventListener('page-comment-archive', (event: ArchiveEvent) => { + this.elem.addEventListener('page-comment-archive', ((event: CustomEvent) => { this.archiveContainer.append(event.detail.new_thread_dom); setTimeout(() => this.updateCount(), 1); - }); + }) as EventListener); - this.elem.addEventListener('page-comment-unarchive', (event: ArchiveEvent) => { + this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent) => { 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)); @@ -115,7 +103,7 @@ export class PageComments extends Component { } } - protected saveComment(event): void { + protected saveComment(event: SubmitEvent): void { event.preventDefault(); event.stopPropagation(); @@ -209,10 +197,10 @@ export class PageComments extends Component { drawioUrl: '', pageId: 0, translations: {}, - translationMap: (window as Record).editor_translations, + translationMap: (window as unknown as Record).editor_translations, }); - (window as {tinymce: {init: (Object) => Promise}}).tinymce.init(config).then(editors => { + (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => { this.wysiwygEditor = editors[0]; setTimeout(() => this.wysiwygEditor.focus(), 50); }); @@ -233,11 +221,11 @@ export class PageComments extends Component { return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length; } - protected setReply(commentLocalId, commentElement): void { - const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children'); + 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 = commentLocalId; + 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}`; diff --git a/resources/js/components/pointer.ts b/resources/js/components/pointer.ts index d84186d872d..4b927045aae 100644 --- a/resources/js/components/pointer.ts +++ b/resources/js/components/pointer.ts @@ -1,7 +1,7 @@ -import * as DOM from '../services/dom.ts'; +import * as DOM from '../services/dom'; import {Component} from './component'; -import {copyTextToClipboard} from '../services/clipboard.ts'; -import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom.ts"; +import {copyTextToClipboard} from '../services/clipboard'; +import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom"; import {PageComments} from "./page-comments"; export class Pointer extends Component { @@ -11,16 +11,16 @@ export class Pointer extends Component { 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; + 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.pointer = this.$refs.pointer; @@ -67,7 +67,7 @@ export class Pointer extends Component { DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => { event.stopPropagation(); const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]'); - if (targetEl && window.getSelection().toString().length > 0) { + if (targetEl instanceof HTMLElement && (window.getSelection() || '').toString().length > 0) { const xPos = (event instanceof MouseEvent) ? event.pageX : 0; this.showPointerAtTarget(targetEl, xPos, false); } @@ -102,11 +102,8 @@ export class Pointer extends Component { /** * 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) { + showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) { this.targetElement = element; this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null; this.updateDomForTarget(element); @@ -134,7 +131,7 @@ export class Pointer extends Component { window.removeEventListener('scroll', scrollListener); }; - element.parentElement.insertBefore(this.pointer, element); + element.parentElement?.insertBefore(this.pointer, element); if (!keyboardMode) { window.addEventListener('scroll', scrollListener, {passive: true}); } @@ -142,9 +139,8 @@ export class Pointer extends Component { /** * Update the pointer inputs/content for the given target element. - * @param {?Element} element */ - updateDomForTarget(element) { + updateDomForTarget(element: HTMLElement) { const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`); const includeTag = `{{@${this.pageId}#${element.id}}}`; @@ -158,13 +154,13 @@ export class Pointer extends Component { 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'); } @@ -172,12 +168,12 @@ 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(event) { + createCommentAtPointer() { if (!this.targetElement) { return; } diff --git a/resources/js/components/tabs.ts b/resources/js/components/tabs.ts index 56405b8c78e..a03d37cd48c 100644 --- a/resources/js/components/tabs.ts +++ 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,12 +23,12 @@ import {Component} from './component'; */ export class Tabs extends Component { - protected container: HTMLElement; - protected tabList: HTMLElement; - protected tabs: HTMLElement[]; - protected panels: HTMLElement[]; + protected container!: HTMLElement; + protected tabList!: HTMLElement; + protected tabs!: HTMLElement[]; + protected panels!: HTMLElement[]; - protected activeUnder: number; + protected activeUnder!: number; protected active: null|boolean = null; setup() { @@ -58,7 +62,8 @@ 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); } protected updateActiveState(): void { diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts index 77c19a76105..c3817536c85 100644 --- a/resources/js/services/dom.ts +++ b/resources/js/services/dom.ts @@ -225,7 +225,7 @@ export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number) if (currentNode.nodeType === Node.TEXT_NODE) { // For text nodes, count the length of their content // Returns if within range - const textLength = currentNode.textContent.length; + const textLength = (currentNode.textContent || '').length; if (currentOffset + textLength >= offset) { return { node: currentNode, @@ -237,9 +237,9 @@ export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number) } 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; + const elementTextLength = (currentNode.textContent || '').length; if (currentOffset + elementTextLength >= offset) { - return findTargetNodeAndOffset(currentNode, offset - currentOffset); + return findTargetNodeAndOffset(currentNode as HTMLElement, offset - currentOffset); } currentOffset += elementTextLength; diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index 7dae6dc29d9..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 void)[]> = {}; + protected listeners: Record = {}; protected stack: {name: string, data: {}}[] = []; /** @@ -27,7 +29,7 @@ export class EventManager { /** * Remove an event listener which is using the given callback for the given event name. */ - remove(eventName: string, callback: Function): void { + remove(eventName: string, callback: Listener): void { const listeners = this.listeners[eventName] || []; const index = listeners.indexOf(callback); if (index !== -1) { @@ -64,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); From 32b29fcdfc9e54416d5c01cf54b8a9df6753d326 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 13 May 2025 12:03:15 +0100 Subject: [PATCH 19/19] Comments: Fixed pointer display, Fixed translation test --- resources/js/services/__tests__/translations.test.ts | 5 +++++ resources/js/services/translations.ts | 2 +- resources/sass/_pages.scss | 12 +++++------- resources/views/pages/parts/pointer.blade.php | 10 +++++----- 4 files changed, 16 insertions(+), 13 deletions(-) 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/translations.ts b/resources/js/services/translations.ts index 821c34f18f7..f548a51d1d1 100644 --- a/resources/js/services/translations.ts +++ b/resources/js/services/translations.ts @@ -10,7 +10,7 @@ export class Translator { * to use. Similar format at Laravel's 'trans_choice' helper. */ choice(translation: string, count: number, replacements: Record = {}): string { - replacements = Object.assign({}, replacements, {count: String(count)}); + 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/sass/_pages.scss b/resources/sass/_pages.scss index 621d08f45f2..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; @@ -193,7 +189,8 @@ body.tox-fullscreen, body.markdown-fullscreen { border: 1px solid #DDD; @include mixins.lightDark(border-color, #ddd, #000); color: #666; - width: 180px; + width: auto; + flex: 1; z-index: 58; padding: 5px; border-radius: 0; @@ -203,7 +200,8 @@ body.tox-fullscreen, body.markdown-fullscreen { } .input-group .button { line-height: 1; - margin: 0 0 0 -5px; + margin-inline-start: -1px; + margin-block: 0; box-shadow: none; border-radius: 0; } diff --git a/resources/views/pages/parts/pointer.blade.php b/resources/views/pages/parts/pointer.blade.php index 77fc763827e..f6487b66600 100644 --- a/resources/views/pages/parts/pointer.blade.php +++ b/resources/views/pages/parts/pointer.blade.php @@ -6,21 +6,21 @@ tabindex="-1" aria-label="{{ trans('entities.pages_pointer_label') }}" class="pointer-container"> -
-
+
+
-
+
-