]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comment-reference.ts
72e3dbe480d815ea801758b22edbfc33070afb1e
[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 {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         const pageContentArea = document.querySelector('.page-content');
32         if (pageContentArea instanceof HTMLElement) {
33             this.updateMarker(pageContentArea);
34         }
35
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) {
41                  this.showForEditor();
42              } else {
43                  this.hideMarker();
44              }
45         });
46     }
47
48     protected showForEditor() {
49         const contentWrap = document.querySelector('.editor-content-wrap');
50         if (contentWrap instanceof HTMLElement) {
51             this.updateMarker(contentWrap);
52         }
53
54         const onChange = () => {
55             this.hideMarker();
56             setTimeout(() => {
57                 window.$events.remove('editor-html-change', onChange);
58             }, 1);
59         };
60
61         window.$events.listen('editor-html-change', onChange);
62     }
63
64     protected updateMarker(contentContainer: HTMLElement) {
65         // Reset link and existing marker
66         this.link.classList.remove('outdated', 'missing');
67         if (this.markerWrap) {
68             this.markerWrap.remove();
69         }
70
71         const [refId, refHash, refRange] = this.reference.split(':');
72         const refEl = document.getElementById(refId);
73         if (!refEl) {
74             this.link.classList.add('outdated', 'missing');
75             return;
76         }
77
78         const refCloneToAssess = refEl.cloneNode(true) as HTMLElement;
79         const toRemove = refCloneToAssess.querySelectorAll('[data-lexical-text]');
80         refCloneToAssess.removeAttribute('style');
81         for (const el of toRemove) {
82             el.after(...el.childNodes);
83             el.remove();
84         }
85
86         const actualHash = hashElement(refCloneToAssess);
87         if (actualHash !== refHash) {
88             this.link.classList.add('outdated');
89         }
90
91         const marker = el('button', {
92             type: 'button',
93             class: 'content-comment-marker',
94             title: this.viewCommentText,
95         });
96         marker.innerHTML = <string>commentIcon;
97         marker.addEventListener('click', event => {
98             this.showCommentAtMarker(marker);
99         });
100
101         this.markerWrap = el('div', {
102             class: 'content-comment-highlight',
103         }, [marker]);
104
105         contentContainer.append(this.markerWrap);
106         this.positionMarker(refEl, refRange);
107
108         this.link.href = `#${refEl.id}`;
109         this.link.addEventListener('click', (event: MouseEvent) => {
110             event.preventDefault();
111             scrollAndHighlightElement(refEl);
112         });
113
114         window.addEventListener('resize', () => {
115             this.positionMarker(refEl, refRange);
116         });
117     }
118
119     protected positionMarker(targetEl: HTMLElement, range: string) {
120         if (!this.markerWrap) {
121             return;
122         }
123
124         const markerParent = this.markerWrap.parentElement as HTMLElement;
125         const parentBounds = markerParent.getBoundingClientRect();
126         let targetBounds = targetEl.getBoundingClientRect();
127         const [rangeStart, rangeEnd] = range.split('-');
128         if (rangeStart && rangeEnd) {
129             const range = new Range();
130             const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
131             const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
132             if (relStart && relEnd) {
133                 range.setStart(relStart.node, relStart.offset);
134                 range.setEnd(relEnd.node, relEnd.offset);
135                 targetBounds = range.getBoundingClientRect();
136             }
137         }
138
139         const relLeft = targetBounds.left - parentBounds.left;
140         const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
141
142         this.markerWrap.style.left = `${relLeft}px`;
143         this.markerWrap.style.top = `${relTop}px`;
144         this.markerWrap.style.width = `${targetBounds.width}px`;
145         this.markerWrap.style.height = `${targetBounds.height}px`;
146     }
147
148     protected hideMarker() {
149         // Hide marker and close existing marker windows
150         if (openMarkerClose) {
151             openMarkerClose();
152         }
153         this.markerWrap?.remove();
154     }
155
156     protected showCommentAtMarker(marker: HTMLElement): void {
157         // Hide marker and close existing marker windows
158         if (openMarkerClose) {
159             openMarkerClose();
160         }
161         marker.hidden = true;
162
163         // Locate relevant comment
164         const commentBox = this.link.closest('.comment-box') as HTMLElement;
165
166         // Build comment window
167         const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
168         const toRemove = readClone.querySelectorAll('.actions, form');
169         for (const el of toRemove) {
170             el.remove();
171         }
172
173         const close = el('button', {type: 'button', title: this.closeText});
174         close.innerHTML = (closeIcon as string);
175         const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
176
177         const commentWindow = el('div', {
178             class: 'content-comment-window'
179         }, [
180             el('div', {
181                 class: 'content-comment-window-actions',
182             }, [jump, close]),
183             el('div', {
184                 class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
185             }, [readClone]),
186         ]);
187
188         marker.parentElement?.append(commentWindow);
189
190         // Handle interaction within window
191         const closeAction = () => {
192             commentWindow.remove();
193             marker.hidden = false;
194             window.removeEventListener('click', windowCloseAction);
195             openMarkerClose = null;
196         };
197
198         const windowCloseAction = (event: MouseEvent) => {
199             if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
200                 closeAction();
201             }
202         };
203         window.addEventListener('click', windowCloseAction);
204
205         openMarkerClose = closeAction;
206         close.addEventListener('click', closeAction.bind(this));
207         jump.addEventListener('click', () => {
208             closeAction();
209             commentBox.scrollIntoView({behavior: 'smooth'});
210             const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
211             highlightTarget.classList.add('anim-highlight');
212             highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
213         });
214
215         // Position window within bounds
216         const commentWindowBounds = commentWindow.getBoundingClientRect();
217         const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
218         if (contentBounds && commentWindowBounds.right > contentBounds.right) {
219             const diff = commentWindowBounds.right - contentBounds.right;
220             commentWindow.style.left = `-${diff}px`;
221         }
222     }
223 }