]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comment-reference.ts
Comments: Addressed a range of edge cases and ux issues for references
[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         const pageContentArea = document.querySelector('.page-content');
32         if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
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         // 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)) {
51                 return;
52             }
53
54             const panel = document.getElementById(sectionId);
55             if (panel?.contains(this.link)) {
56                 this.updateMarker(pageContentArea);
57             } else {
58                 this.hideMarker();
59             }
60         });
61     }
62
63     protected showForEditor() {
64         const contentWrap = document.querySelector('.editor-content-wrap');
65         if (contentWrap instanceof HTMLElement) {
66             this.updateMarker(contentWrap);
67         }
68
69         const onChange = () => {
70             this.hideMarker();
71             setTimeout(() => {
72                 window.$events.remove('editor-html-change', onChange);
73             }, 1);
74         };
75
76         window.$events.listen('editor-html-change', onChange);
77     }
78
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();
84         }
85
86         const [refId, refHash, refRange] = this.reference.split(':');
87         const refEl = document.getElementById(refId);
88         if (!refEl) {
89             this.link.classList.add('outdated', 'missing');
90             return;
91         }
92
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);
98             el.remove();
99         }
100
101         const actualHash = hashElement(refCloneToAssess);
102         if (actualHash !== refHash) {
103             this.link.classList.add('outdated');
104         }
105
106         const marker = el('button', {
107             type: 'button',
108             class: 'content-comment-marker',
109             title: this.viewCommentText,
110         });
111         marker.innerHTML = <string>commentIcon;
112         marker.addEventListener('click', event => {
113             this.showCommentAtMarker(marker);
114         });
115
116         this.markerWrap = el('div', {
117             class: 'content-comment-highlight',
118         }, [marker]);
119
120         contentContainer.append(this.markerWrap);
121         this.positionMarker(refEl, refRange);
122
123         this.link.href = `#${refEl.id}`;
124         this.link.addEventListener('click', (event: MouseEvent) => {
125             event.preventDefault();
126             scrollAndHighlightElement(refEl);
127         });
128
129         const debouncedReposition = debounce(() => {
130             this.positionMarker(refEl, refRange);
131         }, 50, false).bind(this);
132         window.addEventListener('resize', debouncedReposition);
133     }
134
135     protected positionMarker(targetEl: HTMLElement, range: string) {
136         if (!this.markerWrap) {
137             return;
138         }
139
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();
152             }
153         }
154
155         const relLeft = targetBounds.left - parentBounds.left;
156         const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
157
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`;
162     }
163
164     public hideMarker() {
165         // Hide marker and close existing marker windows
166         if (openMarkerClose) {
167             openMarkerClose();
168         }
169         this.markerWrap?.remove();
170         this.markerWrap = null;
171     }
172
173     protected showCommentAtMarker(marker: HTMLElement): void {
174         // Hide marker and close existing marker windows
175         if (openMarkerClose) {
176             openMarkerClose();
177         }
178         marker.hidden = true;
179
180         // Locate relevant comment
181         const commentBox = this.link.closest('.comment-box') as HTMLElement;
182
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) {
187             el.remove();
188         }
189
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]);
193
194         const commentWindow = el('div', {
195             class: 'content-comment-window'
196         }, [
197             el('div', {
198                 class: 'content-comment-window-actions',
199             }, [jump, close]),
200             el('div', {
201                 class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
202             }, [readClone]),
203         ]);
204
205         marker.parentElement?.append(commentWindow);
206
207         // Handle interaction within window
208         const closeAction = () => {
209             commentWindow.remove();
210             marker.hidden = false;
211             window.removeEventListener('click', windowCloseAction);
212             openMarkerClose = null;
213         };
214
215         const windowCloseAction = (event: MouseEvent) => {
216             if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
217                 closeAction();
218             }
219         };
220         window.addEventListener('click', windowCloseAction);
221
222         openMarkerClose = closeAction;
223         close.addEventListener('click', closeAction.bind(this));
224         jump.addEventListener('click', () => {
225             closeAction();
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'))
230         });
231
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`;
238         }
239     }
240 }