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";
9 * Track the close function for the current open marker so it can be closed
10 * when another is opened so we only show one marker comment thread at one time.
12 let openMarkerClose: Function|null = null;
14 export class PageCommentReference extends Component {
15 protected link: HTMLLinkElement;
16 protected reference: string;
17 protected markerWrap: HTMLElement|null = null;
19 protected viewCommentText: string;
20 protected jumpToThreadText: string;
21 protected closeText: string;
24 this.link = this.$el as HTMLLinkElement;
25 this.reference = this.$opts.reference;
26 this.viewCommentText = this.$opts.viewCommentText;
27 this.jumpToThreadText = this.$opts.jumpToThreadText;
28 this.closeText = this.$opts.closeText;
30 // Show within page display area if seen
31 const pageContentArea = document.querySelector('.page-content');
32 if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
33 this.updateMarker(pageContentArea);
36 // Handle editor view to show on comments toolbox view
37 window.addEventListener('editor-toolbox-change', (event) => {
38 const tabName: string = (event as {detail: {tab: string, open: boolean}}).detail.tab;
39 const isOpen = (event as {detail: {tab: string, open: boolean}}).detail.open;
40 if (tabName === 'comments' && isOpen) {
47 // Handle comments tab changes to hide/show markers & indicators
48 window.addEventListener('tabs-change', event => {
49 const sectionId = (event as {detail: {showing: string}}).detail.showing;
50 if (!sectionId.startsWith('comment-tab-panel') || !(pageContentArea instanceof HTMLElement)) {
54 const panel = document.getElementById(sectionId);
55 if (panel?.contains(this.link)) {
56 this.updateMarker(pageContentArea);
63 protected showForEditor() {
64 const contentWrap = document.querySelector('.editor-content-wrap');
65 if (contentWrap instanceof HTMLElement) {
66 this.updateMarker(contentWrap);
69 const onChange = () => {
72 window.$events.remove('editor-html-change', onChange);
76 window.$events.listen('editor-html-change', onChange);
79 protected updateMarker(contentContainer: HTMLElement) {
80 // Reset link and existing marker
81 this.link.classList.remove('outdated', 'missing');
82 if (this.markerWrap) {
83 this.markerWrap.remove();
86 const [refId, refHash, refRange] = this.reference.split(':');
87 const refEl = document.getElementById(refId);
89 this.link.classList.add('outdated', 'missing');
93 const refCloneToAssess = refEl.cloneNode(true) as HTMLElement;
94 const toRemove = refCloneToAssess.querySelectorAll('[data-lexical-text]');
95 refCloneToAssess.removeAttribute('style');
96 for (const el of toRemove) {
97 el.after(...el.childNodes);
101 const actualHash = hashElement(refCloneToAssess);
102 if (actualHash !== refHash) {
103 this.link.classList.add('outdated');
106 const marker = el('button', {
108 class: 'content-comment-marker',
109 title: this.viewCommentText,
111 marker.innerHTML = <string>commentIcon;
112 marker.addEventListener('click', event => {
113 this.showCommentAtMarker(marker);
116 this.markerWrap = el('div', {
117 class: 'content-comment-highlight',
120 contentContainer.append(this.markerWrap);
121 this.positionMarker(refEl, refRange);
123 this.link.href = `#${refEl.id}`;
124 this.link.addEventListener('click', (event: MouseEvent) => {
125 event.preventDefault();
126 scrollAndHighlightElement(refEl);
129 const debouncedReposition = debounce(() => {
130 this.positionMarker(refEl, refRange);
131 }, 50, false).bind(this);
132 window.addEventListener('resize', debouncedReposition);
135 protected positionMarker(targetEl: HTMLElement, range: string) {
136 if (!this.markerWrap) {
140 const markerParent = this.markerWrap.parentElement as HTMLElement;
141 const parentBounds = markerParent.getBoundingClientRect();
142 let targetBounds = targetEl.getBoundingClientRect();
143 const [rangeStart, rangeEnd] = range.split('-');
144 if (rangeStart && rangeEnd) {
145 const range = new Range();
146 const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
147 const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
148 if (relStart && relEnd) {
149 range.setStart(relStart.node, relStart.offset);
150 range.setEnd(relEnd.node, relEnd.offset);
151 targetBounds = range.getBoundingClientRect();
155 const relLeft = targetBounds.left - parentBounds.left;
156 const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
158 this.markerWrap.style.left = `${relLeft}px`;
159 this.markerWrap.style.top = `${relTop}px`;
160 this.markerWrap.style.width = `${targetBounds.width}px`;
161 this.markerWrap.style.height = `${targetBounds.height}px`;
164 public hideMarker() {
165 // Hide marker and close existing marker windows
166 if (openMarkerClose) {
169 this.markerWrap?.remove();
170 this.markerWrap = null;
173 protected showCommentAtMarker(marker: HTMLElement): void {
174 // Hide marker and close existing marker windows
175 if (openMarkerClose) {
178 marker.hidden = true;
180 // Locate relevant comment
181 const commentBox = this.link.closest('.comment-box') as HTMLElement;
183 // Build comment window
184 const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
185 const toRemove = readClone.querySelectorAll('.actions, form');
186 for (const el of toRemove) {
190 const close = el('button', {type: 'button', title: this.closeText});
191 close.innerHTML = (closeIcon as string);
192 const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
194 const commentWindow = el('div', {
195 class: 'content-comment-window'
198 class: 'content-comment-window-actions',
201 class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
205 marker.parentElement?.append(commentWindow);
207 // Handle interaction within window
208 const closeAction = () => {
209 commentWindow.remove();
210 marker.hidden = false;
211 window.removeEventListener('click', windowCloseAction);
212 openMarkerClose = null;
215 const windowCloseAction = (event: MouseEvent) => {
216 if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
220 window.addEventListener('click', windowCloseAction);
222 openMarkerClose = closeAction;
223 close.addEventListener('click', closeAction.bind(this));
224 jump.addEventListener('click', () => {
226 commentBox.scrollIntoView({behavior: 'smooth'});
227 const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
228 highlightTarget.classList.add('anim-highlight');
229 highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
232 // Position window within bounds
233 const commentWindowBounds = commentWindow.getBoundingClientRect();
234 const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
235 if (contentBounds && commentWindowBounds.right > contentBounds.right) {
236 const diff = commentWindowBounds.right - contentBounds.right;
237 commentWindow.style.left = `-${diff}px`;