From e8f44186a8ebfac6789800211cb5a947991bf971 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 27 Apr 2025 16:51:24 +0100 Subject: [PATCH] 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 @@