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 292b923e5..997df329a 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 c88827bac..779b48547 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 c5a5d2db8..1a6fa55b6 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 45e58ffc8..de7837057 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 56f36cb75..77fc76382 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 8f023836b..a19d2c7d4 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 997df329a..c3883b7b5 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 779b48547..537af816a 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 b37dbdfb0..821c34f18 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 3336e17e9..c488350ca 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -20,7 +20,7 @@ class CommentRepo /** * 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 @@ class CommentRepo $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 52ccc8238..262080067 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -26,6 +26,7 @@ class CommentController extends Controller $input = $this->validate($request, [ 'html' => ['required', 'string'], 'parent_id' => ['nullable', 'integer'], + 'content_ref' => ['string'], ]); $page = $this->pageQueries->findVisibleById($pageId); @@ -40,7 +41,7 @@ class CommentController extends Controller // 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 d0385d396..91cea4fe0 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; * @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 efbd183b3..844bc3993 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -27,6 +27,8 @@ class CommentFactory extends Factory '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 000000000..794201dec --- /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 a19d2c7d4..45f8d6a9f 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 9e019e3d1..973b2b81d 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -33,6 +33,32 @@ class CommentTest extends TestCase $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 8c0a8b33e..b2e2bac27 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 2bf89d683..c3578293a 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 b2e2bac27..f4d295b95 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 c3883b7b5..d84186d87 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 537af816a..661ed7ca3 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 1a6fa55b6..61a02a3d2 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 de7837057..1fe22b9c4 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 a74785eaa..9ce684ac7 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -402,6 +402,7 @@ return [ '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 f4d295b95..5a148c258 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 f1aa3139b..ccbe36161 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 1fe22b9c4..ac2d195b4 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 c3578293a..1886dad51 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 9ce684ac7..f9fab8ebf 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -403,6 +403,7 @@ return [ '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 5a148c258..9192c7c56 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 58d39d3ee..26b051827 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 ac2d195b4..be5a0f7c3 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 78d19ac3e..83fa4b5c5 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 1886dad51..5b79da4ac 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 000000000..30e487c52 --- /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 9192c7c56..24964bf5c 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 d3ac78a4a..13670c4bf 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 26b051827..5486d6112 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 be5a0f7c3..dbdcc0665 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 5b79da4ac..7cc84a54c 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -77,6 +77,11 @@ @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 ddb4ff39c..953393285 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 10b8025db..63e1ad0db 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 000000000..72e3dbe48 --- /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 24964bf5c..11ad769b1 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 13670c4bf..d3ac78a4a 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 be9fba7ec..7dae6dc29 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 b0176d64e..aba1556a9 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 7cc84a54c..5310b2fe4 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -4,12 +4,8 @@
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 c488350ca..866368ee6 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -53,6 +53,33 @@ class CommentRepo 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 262080067..7a290ebab 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -75,6 +75,42 @@ class CommentController extends Controller ]); } + /** + * 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 b05169bb2..06a9e855c 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -30,6 +30,8 @@ return [ '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 f9fab8ebf..141e75b5f 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -402,6 +402,8 @@ return [ '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 000000000..90a4f35b7 --- /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 11ad769b1..d2cbd21d1 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 5310b2fe4..58e057140 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 @@ @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 818472583..ea3efe1ac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -179,6 +179,8 @@ Route::middleware('auth')->group(function () { // 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 973b2b81d..baf0d392b 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -106,6 +106,66 @@ class CommentTest extends TestCase $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 866368ee6..bf162f68a 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -4,6 +4,8 @@ namespace BookStack\Activity; 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 @@ class CommentRepo */ 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 @@ class CommentRepo */ 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 7a290ebab..7f16c17ff 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 @@ class CommentController extends Controller return view('comments.comment-branch', [ 'readOnly' => false, - 'branch' => [ - 'comment' => $comment, - 'children' => [], - ] + 'branch' => new CommentTreeNode($comment, 0, []), ]); } @@ -81,15 +80,17 @@ class CommentController extends Controller 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 @@ class CommentController extends Controller 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 16f6804ea..13afc9252 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 @@ class CommentTree 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 @@ class CommentTree /** * @param Comment[] $comments + * @return CommentTreeNode[] */ protected function createTree(array $comments): array { @@ -77,26 +94,22 @@ class CommentTree $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 000000000..7b280bd2d --- /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 141e75b5f..cda58e65b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -392,6 +392,7 @@ return [ '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 d2cbd21d1..82cb95f13 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 45f8d6a9f..083919b82 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 83fa4b5c5..658c33219 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 58e057140..fe61bf1a4 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -38,7 +38,7 @@ @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))) + + 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 | 80 +++++++++++++------- resources/views/pages/show.blade.php | 6 -- 8 files changed, 117 insertions(+), 55 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 13afc9252..a05a9d247 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -28,7 +28,7 @@ class CommentTree public function empty(): bool { - return count($this->tree) === 0; + return count($this->getActive()) === 0; } public function count(): int @@ -41,11 +41,21 @@ class CommentTree 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 cda58e65b..c70658c01 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -392,9 +392,10 @@ return [ '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 82cb95f13..12485b807 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 083919b82..2482c9dcb 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 f0fc058ce..56405b8c7 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 5486d6112..d25fab299 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 06e96cad6..882cfdf45 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 +
+ +

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

+ + @if(userCan('comment-create-all')) + @include('comments.create') + @if (!$commentTree->empty()) +
+ +
+ @endif + @endif
- @if(userCan('comment-create-all')) - @include('comments.create') - @if (!$commentTree->empty()) -
- - - - -
- @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 e3a31dd5e..137d43bdb 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -28,12 +28,6 @@ @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 882cfdf45..51c08d69a 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -36,7 +36,7 @@ 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 @@ 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 417f0c606..134ed5164 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 e25a83299..6e616ded4 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -396,6 +396,7 @@ return [ '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 5a71f0c51..48fb8ee0a 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 a86d31ce3..faeb2e051 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 d632b85c6..72958a2fe 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 faeb2e051..9e96b39fb 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 dbdcc0665..621d08f45 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 bf162f68a..c194e7216 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -62,7 +62,7 @@ class CommentRepo 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 @@ class CommentRepo 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 000000000..4e9640bae --- /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 baf0d392b..8b8a5d488 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentStoreTest.php @@ -7,7 +7,7 @@ use BookStack\Activity\Models\Comment; 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 @@ class CommentTest extends TestCase } } + 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 @@ class CommentTest extends TestCase '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 | 60 ++++++++----- 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, 166 insertions(+), 148 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 c194e7216..7005f8fcf 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -22,7 +22,7 @@ class CommentRepo /** * 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 @@ class CommentRepo $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 7f16c17ff..479d57c4d 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -43,7 +43,8 @@ class CommentController extends Controller // 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 953393285..60bdde05e 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 48fb8ee0a..009e806c1 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 ce35cdc4a..0c3e19f4b 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 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 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 04c812580..94f5ab3bb 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 d84186d87..4b927045a 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 56405b8c7..a03d37cd4 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 77c19a761..c3817536c 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 7dae6dc29..6045d51f8 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 043f1745f..5014051ab 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 821c34f18..f548a51d1 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 621d08f45..83aec46f0 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 77fc76382..f6487b666 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"> -
-
+
+
-
+
-