1 import {Component} from "./component";
2 import {findTargetNodeAndOffset, hashElement} from "../services/dom";
3 import {el} from "../wysiwyg/utils/dom";
4 import commentIcon from "@icons/comment.svg";
5 import closeIcon from "@icons/close.svg";
6 import {debounce, scrollAndHighlightElement} from "../services/util";
7 import {EditorToolboxChangeEventData} from "./editor-toolbox";
8 import {TabsChangeEvent} from "./tabs";
11 * Track the close function for the current open marker so it can be closed
12 * when another is opened so we only show one marker comment thread at one time.
14 let openMarkerClose: Function|null = null;
16 export class PageCommentReference extends Component {
17 protected link!: HTMLLinkElement;
18 protected reference!: string;
19 protected markerWrap: HTMLElement|null = null;
21 protected viewCommentText!: string;
22 protected jumpToThreadText!: string;
23 protected closeText!: string;
26 this.link = this.$el as HTMLLinkElement;
27 this.reference = this.$opts.reference;
28 this.viewCommentText = this.$opts.viewCommentText;
29 this.jumpToThreadText = this.$opts.jumpToThreadText;
30 this.closeText = this.$opts.closeText;
32 // Show within page display area if seen
33 this.showForDisplay();
35 // Handle editor view to show on comments toolbox view
36 window.addEventListener('editor-toolbox-change', ((event: CustomEvent<EditorToolboxChangeEventData>) => {
37 const tabName: string = event.detail.tab;
38 const isOpen = event.detail.open;
39 if (tabName === 'comments' && isOpen && this.link.checkVisibility()) {
46 // Handle visibility changes within editor toolbox archived details dropdown
47 window.addEventListener('toggle', event => {
48 if (event.target instanceof HTMLElement && event.target.contains(this.link)) {
49 window.requestAnimationFrame(() => {
50 if (this.link.checkVisibility()) {
59 // Handle comments tab changes to hide/show markers & indicators
60 window.addEventListener('tabs-change', ((event: CustomEvent<TabsChangeEvent>) => {
61 const sectionId = event.detail.showing;
62 if (!sectionId.startsWith('comment-tab-panel')) {
66 const panel = document.getElementById(sectionId);
67 if (panel?.contains(this.link)) {
68 this.showForDisplay();
75 public showForDisplay() {
76 const pageContentArea = document.querySelector('.page-content');
77 if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
78 this.updateMarker(pageContentArea);
82 protected showForEditor() {
83 const contentWrap = document.querySelector('.editor-content-wrap');
84 if (contentWrap instanceof HTMLElement) {
85 this.updateMarker(contentWrap);
88 const onChange = () => {
91 window.$events.remove('editor-html-change', onChange);
95 window.$events.listen('editor-html-change', onChange);
98 protected updateMarker(contentContainer: HTMLElement) {
99 // Reset link and existing marker
100 this.link.classList.remove('outdated', 'missing');
101 if (this.markerWrap) {
102 this.markerWrap.remove();
105 const [refId, refHash, refRange] = this.reference.split(':');
106 const refEl = document.getElementById(refId);
108 this.link.classList.add('outdated', 'missing');
112 const actualHash = hashElement(refEl);
113 if (actualHash !== refHash) {
114 this.link.classList.add('outdated');
117 const marker = el('button', {
119 class: 'content-comment-marker',
120 title: this.viewCommentText,
122 marker.innerHTML = <string>commentIcon;
123 marker.addEventListener('click', event => {
124 this.showCommentAtMarker(marker);
127 this.markerWrap = el('div', {
128 class: 'content-comment-highlight',
131 contentContainer.append(this.markerWrap);
132 this.positionMarker(refEl, refRange);
134 this.link.href = `#${refEl.id}`;
135 this.link.addEventListener('click', (event: MouseEvent) => {
136 event.preventDefault();
137 scrollAndHighlightElement(refEl);
140 const debouncedReposition = debounce(() => {
141 this.positionMarker(refEl, refRange);
142 }, 50, false).bind(this);
143 window.addEventListener('resize', debouncedReposition);
146 protected positionMarker(targetEl: HTMLElement, range: string) {
147 if (!this.markerWrap) {
151 const markerParent = this.markerWrap.parentElement as HTMLElement;
152 const parentBounds = markerParent.getBoundingClientRect();
153 let targetBounds = targetEl.getBoundingClientRect();
154 const [rangeStart, rangeEnd] = range.split('-');
155 if (rangeStart && rangeEnd) {
156 const range = new Range();
157 const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
158 const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
159 if (relStart && relEnd) {
160 range.setStart(relStart.node, relStart.offset);
161 range.setEnd(relEnd.node, relEnd.offset);
162 targetBounds = range.getBoundingClientRect();
166 const relLeft = targetBounds.left - parentBounds.left;
167 const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
169 this.markerWrap.style.left = `${relLeft}px`;
170 this.markerWrap.style.top = `${relTop}px`;
171 this.markerWrap.style.width = `${targetBounds.width}px`;
172 this.markerWrap.style.height = `${targetBounds.height}px`;
175 public hideMarker() {
176 // Hide marker and close existing marker windows
177 if (openMarkerClose) {
180 this.markerWrap?.remove();
181 this.markerWrap = null;
184 protected showCommentAtMarker(marker: HTMLElement): void {
185 // Hide marker and close existing marker windows
186 if (openMarkerClose) {
189 marker.hidden = true;
191 // Locate relevant comment
192 const commentBox = this.link.closest('.comment-box') as HTMLElement;
194 // Build comment window
195 const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
196 const toRemove = readClone.querySelectorAll('.actions, form');
197 for (const el of toRemove) {
201 const close = el('button', {type: 'button', title: this.closeText});
202 close.innerHTML = (closeIcon as string);
203 const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
205 const commentWindow = el('div', {
206 class: 'content-comment-window'
209 class: 'content-comment-window-actions',
212 class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
216 marker.parentElement?.append(commentWindow);
218 // Handle interaction within window
219 const closeAction = () => {
220 commentWindow.remove();
221 marker.hidden = false;
222 window.removeEventListener('click', windowCloseAction);
223 openMarkerClose = null;
226 const windowCloseAction = (event: MouseEvent) => {
227 if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
231 window.addEventListener('click', windowCloseAction);
233 openMarkerClose = closeAction;
234 close.addEventListener('click', closeAction.bind(this));
235 jump.addEventListener('click', () => {
237 commentBox.scrollIntoView({behavior: 'smooth'});
238 const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
239 highlightTarget.classList.add('anim-highlight');
240 highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
243 // Position window within bounds
244 const commentWindowBounds = commentWindow.getBoundingClientRect();
245 const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
246 if (contentBounds && commentWindowBounds.right > contentBounds.right) {
247 const diff = commentWindowBounds.right - contentBounds.right;
248 commentWindow.style.left = `-${diff}px`;