]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comment-reference.ts
Comments: Fixed a range of TS errors + other
[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 import {EditorToolboxChangeEventData} from "./editor-toolbox";
8 import {TabsChangeEvent} from "./tabs";
9
10 /**
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.
13  */
14 let openMarkerClose: Function|null = null;
15
16 export class PageCommentReference extends Component {
17     protected link!: HTMLLinkElement;
18     protected reference!: string;
19     protected markerWrap: HTMLElement|null = null;
20
21     protected viewCommentText!: string;
22     protected jumpToThreadText!: string;
23     protected closeText!: string;
24
25     setup() {
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;
31
32         // Show within page display area if seen
33         this.showForDisplay();
34
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()) {
40                 this.showForEditor();
41             } else {
42                 this.hideMarker();
43             }
44         }) as EventListener);
45
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()) {
51                         this.showForEditor();
52                     } else {
53                         this.hideMarker();
54                     }
55                 });
56             }
57         }, {capture: true});
58
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')) {
63                 return;
64             }
65
66             const panel = document.getElementById(sectionId);
67             if (panel?.contains(this.link)) {
68                 this.showForDisplay();
69             } else {
70                 this.hideMarker();
71             }
72         }) as EventListener);
73     }
74
75     public showForDisplay() {
76         const pageContentArea = document.querySelector('.page-content');
77         if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
78             this.updateMarker(pageContentArea);
79         }
80     }
81
82     protected showForEditor() {
83         const contentWrap = document.querySelector('.editor-content-wrap');
84         if (contentWrap instanceof HTMLElement) {
85             this.updateMarker(contentWrap);
86         }
87
88         const onChange = () => {
89             this.hideMarker();
90             setTimeout(() => {
91                 window.$events.remove('editor-html-change', onChange);
92             }, 1);
93         };
94
95         window.$events.listen('editor-html-change', onChange);
96     }
97
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();
103         }
104
105         const [refId, refHash, refRange] = this.reference.split(':');
106         const refEl = document.getElementById(refId);
107         if (!refEl) {
108             this.link.classList.add('outdated', 'missing');
109             return;
110         }
111
112         const actualHash = hashElement(refEl);
113         if (actualHash !== refHash) {
114             this.link.classList.add('outdated');
115         }
116
117         const marker = el('button', {
118             type: 'button',
119             class: 'content-comment-marker',
120             title: this.viewCommentText,
121         });
122         marker.innerHTML = <string>commentIcon;
123         marker.addEventListener('click', event => {
124             this.showCommentAtMarker(marker);
125         });
126
127         this.markerWrap = el('div', {
128             class: 'content-comment-highlight',
129         }, [marker]);
130
131         contentContainer.append(this.markerWrap);
132         this.positionMarker(refEl, refRange);
133
134         this.link.href = `#${refEl.id}`;
135         this.link.addEventListener('click', (event: MouseEvent) => {
136             event.preventDefault();
137             scrollAndHighlightElement(refEl);
138         });
139
140         const debouncedReposition = debounce(() => {
141             this.positionMarker(refEl, refRange);
142         }, 50, false).bind(this);
143         window.addEventListener('resize', debouncedReposition);
144     }
145
146     protected positionMarker(targetEl: HTMLElement, range: string) {
147         if (!this.markerWrap) {
148             return;
149         }
150
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();
163             }
164         }
165
166         const relLeft = targetBounds.left - parentBounds.left;
167         const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
168
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`;
173     }
174
175     public hideMarker() {
176         // Hide marker and close existing marker windows
177         if (openMarkerClose) {
178             openMarkerClose();
179         }
180         this.markerWrap?.remove();
181         this.markerWrap = null;
182     }
183
184     protected showCommentAtMarker(marker: HTMLElement): void {
185         // Hide marker and close existing marker windows
186         if (openMarkerClose) {
187             openMarkerClose();
188         }
189         marker.hidden = true;
190
191         // Locate relevant comment
192         const commentBox = this.link.closest('.comment-box') as HTMLElement;
193
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) {
198             el.remove();
199         }
200
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]);
204
205         const commentWindow = el('div', {
206             class: 'content-comment-window'
207         }, [
208             el('div', {
209                 class: 'content-comment-window-actions',
210             }, [jump, close]),
211             el('div', {
212                 class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
213             }, [readClone]),
214         ]);
215
216         marker.parentElement?.append(commentWindow);
217
218         // Handle interaction within window
219         const closeAction = () => {
220             commentWindow.remove();
221             marker.hidden = false;
222             window.removeEventListener('click', windowCloseAction);
223             openMarkerClose = null;
224         };
225
226         const windowCloseAction = (event: MouseEvent) => {
227             if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
228                 closeAction();
229             }
230         };
231         window.addEventListener('click', windowCloseAction);
232
233         openMarkerClose = closeAction;
234         close.addEventListener('click', closeAction.bind(this));
235         jump.addEventListener('click', () => {
236             closeAction();
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'))
241         });
242
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`;
249         }
250     }
251 }