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 @@