]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comment-reference.ts
48fb8ee0a492a47380ad7b3432ec81eda675dc46
[bookstack] / resources / js / components / page-comment-reference.ts
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
8 /**
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.
11  */
12 let openMarkerClose: Function|null = null;
13
14 export class PageCommentReference extends Component {
15     protected link: HTMLLinkElement;
16     protected reference: string;
17     protected markerWrap: HTMLElement|null = null;
18
19     protected viewCommentText: string;
20     protected jumpToThreadText: string;
21     protected closeText: string;
22
23     setup() {
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;
29
30         // Show within page display area if seen
31         this.showForDisplay();
32
33         // Handle editor view to show on comments toolbox view
34         window.addEventListener('editor-toolbox-change', (event) => {
35              const tabName: string = (event as {detail: {tab: string, open: boolean}}).detail.tab;
36              const isOpen = (event as {detail: {tab: string, open: boolean}}).detail.open;
37              if (tabName === 'comments' && isOpen && this.link.checkVisibility()) {
38                  this.showForEditor();
39              } else {
40                  this.hideMarker();
41              }
42         });
43
44         // Handle visibility changes within editor toolbox archived details dropdown
45         window.addEventListener('toggle', event => {
46             if (event.target instanceof HTMLElement && event.target.contains(this.link)) {
47                 window.requestAnimationFrame(() => {
48                     if (this.link.checkVisibility()) {
49                         this.showForEditor();
50                     } else {
51                         this.hideMarker();
52                     }
53                 });
54             }
55         }, {capture: true});
56
57         // Handle comments tab changes to hide/show markers & indicators
58         window.addEventListener('tabs-change', event => {
59             const sectionId = (event as {detail: {showing: string}}).detail.showing;
60             if (!sectionId.startsWith('comment-tab-panel')) {
61                 return;
62             }
63
64             const panel = document.getElementById(sectionId);
65             if (panel?.contains(this.link)) {
66                 this.showForDisplay();
67             } else {
68                 this.hideMarker();
69             }
70         });
71     }
72
73     public showForDisplay() {
74         const pageContentArea = document.querySelector('.page-content');
75         if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
76             this.updateMarker(pageContentArea);
77         }
78     }
79
80     protected showForEditor() {
81         const contentWrap = document.querySelector('.editor-content-wrap');
82         if (contentWrap instanceof HTMLElement) {
83             this.updateMarker(contentWrap);
84         }
85
86         const onChange = () => {
87             this.hideMarker();
88             setTimeout(() => {
89                 window.$events.remove('editor-html-change', onChange);
90             }, 1);
91         };
92
93         window.$events.listen('editor-html-change', onChange);
94     }
95
96     protected updateMarker(contentContainer: HTMLElement) {
97         // Reset link and existing marker
98         this.link.classList.remove('outdated', 'missing');
99         if (this.markerWrap) {
100             this.markerWrap.remove();
101         }
102
103         const [refId, refHash, refRange] = this.reference.split(':');
104         const refEl = document.getElementById(refId);
105         if (!refEl) {
106             this.link.classList.add('outdated', 'missing');
107             return;
108         }
109
110         const actualHash = hashElement(refEl);
111         if (actualHash !== refHash) {
112             this.link.classList.add('outdated');
113         }
114
115         const marker = el('button', {
116             type: 'button',
117             class: 'content-comment-marker',
118             title: this.viewCommentText,
119         });
120         marker.innerHTML = <string>commentIcon;
121         marker.addEventListener('click', event => {
122             this.showCommentAtMarker(marker);
123         });
124
125         this.markerWrap = el('div', {
126             class: 'content-comment-highlight',
127         }, [marker]);
128
129         contentContainer.append(this.markerWrap);
130         this.positionMarker(refEl, refRange);
131
132         this.link.href = `#${refEl.id}`;
133         this.link.addEventListener('click', (event: MouseEvent) => {
134             event.preventDefault();
135             scrollAndHighlightElement(refEl);
136         });
137
138         const debouncedReposition = debounce(() => {
139             this.positionMarker(refEl, refRange);
140         }, 50, false).bind(this);
141         window.addEventListener('resize', debouncedReposition);
142     }
143
144     protected positionMarker(targetEl: HTMLElement, range: string) {
145         if (!this.markerWrap) {
146             return;
147         }
148
149         const markerParent = this.markerWrap.parentElement as HTMLElement;
150         const parentBounds = markerParent.getBoundingClientRect();
151         let targetBounds = targetEl.getBoundingClientRect();
152         const [rangeStart, rangeEnd] = range.split('-');
153         if (rangeStart && rangeEnd) {
154             const range = new Range();
155             const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
156             const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
157             if (relStart && relEnd) {
158                 range.setStart(relStart.node, relStart.offset);
159                 range.setEnd(relEnd.node, relEnd.offset);
160                 targetBounds = range.getBoundingClientRect();
161             }
162         }
163
164         const relLeft = targetBounds.left - parentBounds.left;
165         const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
166
167         this.markerWrap.style.left = `${relLeft}px`;
168         this.markerWrap.style.top = `${relTop}px`;
169         this.markerWrap.style.width = `${targetBounds.width}px`;
170         this.markerWrap.style.height = `${targetBounds.height}px`;
171     }
172
173     public hideMarker() {
174         // Hide marker and close existing marker windows
175         if (openMarkerClose) {
176             openMarkerClose();
177         }
178         this.markerWrap?.remove();
179         this.markerWrap = null;
180     }
181
182     protected showCommentAtMarker(marker: HTMLElement): void {
183         // Hide marker and close existing marker windows
184         if (openMarkerClose) {
185             openMarkerClose();
186         }
187         marker.hidden = true;
188
189         // Locate relevant comment
190         const commentBox = this.link.closest('.comment-box') as HTMLElement;
191
192         // Build comment window
193         const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
194         const toRemove = readClone.querySelectorAll('.actions, form');
195         for (const el of toRemove) {
196             el.remove();
197         }
198
199         const close = el('button', {type: 'button', title: this.closeText});
200         close.innerHTML = (closeIcon as string);
201         const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
202
203         const commentWindow = el('div', {
204             class: 'content-comment-window'
205         }, [
206             el('div', {
207                 class: 'content-comment-window-actions',
208             }, [jump, close]),
209             el('div', {
210                 class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
211             }, [readClone]),
212         ]);
213
214         marker.parentElement?.append(commentWindow);
215
216         // Handle interaction within window
217         const closeAction = () => {
218             commentWindow.remove();
219             marker.hidden = false;
220             window.removeEventListener('click', windowCloseAction);
221             openMarkerClose = null;
222         };
223
224         const windowCloseAction = (event: MouseEvent) => {
225             if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
226                 closeAction();
227             }
228         };
229         window.addEventListener('click', windowCloseAction);
230
231         openMarkerClose = closeAction;
232         close.addEventListener('click', closeAction.bind(this));
233         jump.addEventListener('click', () => {
234             closeAction();
235             commentBox.scrollIntoView({behavior: 'smooth'});
236             const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
237             highlightTarget.classList.add('anim-highlight');
238             highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
239         });
240
241         // Position window within bounds
242         const commentWindowBounds = commentWindow.getBoundingClientRect();
243         const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
244         if (contentBounds && commentWindowBounds.right > contentBounds.right) {
245             const diff = commentWindowBounds.right - contentBounds.right;
246             commentWindow.style.left = `-${diff}px`;
247         }
248     }
249 }