]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comments.ts
04c8125808cb8ffe00186073eb8176e0beea86d5
[bookstack] / resources / js / components / page-comments.ts
1 import {Component} from './component';
2 import {getLoading, htmlToDom} from '../services/dom.ts';
3 import {buildForInput} from '../wysiwyg-tinymce/config';
4 import {Tabs} from "./tabs";
5 import {PageCommentReference} from "./page-comment-reference";
6 import {scrollAndHighlightElement} from "../services/util";
7
8 export interface CommentReplyEvent extends Event {
9     detail: {
10         id: string; // ID of comment being replied to
11         element: HTMLElement; // Container for comment replied to
12     }
13 }
14
15 export interface ArchiveEvent extends Event {
16     detail: {
17         new_thread_dom: HTMLElement;
18     }
19 }
20
21 export class PageComments extends Component {
22
23     private elem: HTMLElement;
24     private pageId: number;
25     private container: HTMLElement;
26     private commentCountBar: HTMLElement;
27     private activeTab: HTMLElement;
28     private archivedTab: HTMLElement;
29     private addButtonContainer: HTMLElement;
30     private archiveContainer: HTMLElement;
31     private replyToRow: HTMLElement;
32     private referenceRow: HTMLElement;
33     private formContainer: HTMLElement;
34     private form: HTMLFormElement;
35     private formInput: HTMLInputElement;
36     private formReplyLink: HTMLAnchorElement;
37     private formReferenceLink: HTMLAnchorElement;
38     private addCommentButton: HTMLElement;
39     private hideFormButton: HTMLElement;
40     private removeReplyToButton: HTMLElement;
41     private removeReferenceButton: HTMLElement;
42     private wysiwygLanguage: string;
43     private wysiwygTextDirection: string;
44     private wysiwygEditor: any = null;
45     private createdText: string;
46     private countText: string;
47     private archivedCountText: string;
48     private parentId: number | null = null;
49     private contentReference: string = '';
50     private formReplyText: string = '';
51
52     setup() {
53         this.elem = this.$el;
54         this.pageId = Number(this.$opts.pageId);
55
56         // Element references
57         this.container = this.$refs.commentContainer;
58         this.commentCountBar = this.$refs.commentCountBar;
59         this.activeTab = this.$refs.activeTab;
60         this.archivedTab = this.$refs.archivedTab;
61         this.addButtonContainer = this.$refs.addButtonContainer;
62         this.archiveContainer = this.$refs.archiveContainer;
63         this.replyToRow = this.$refs.replyToRow;
64         this.referenceRow = this.$refs.referenceRow;
65         this.formContainer = this.$refs.formContainer;
66         this.form = this.$refs.form as HTMLFormElement;
67         this.formInput = this.$refs.formInput as HTMLInputElement;
68         this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement;
69         this.formReferenceLink = this.$refs.formReferenceLink as HTMLAnchorElement;
70         this.addCommentButton = this.$refs.addCommentButton;
71         this.hideFormButton = this.$refs.hideFormButton;
72         this.removeReplyToButton = this.$refs.removeReplyToButton;
73         this.removeReferenceButton = this.$refs.removeReferenceButton;
74
75         // WYSIWYG options
76         this.wysiwygLanguage = this.$opts.wysiwygLanguage;
77         this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
78
79         // Translations
80         this.createdText = this.$opts.createdText;
81         this.countText = this.$opts.countText;
82         this.archivedCountText = this.$opts.archivedCountText;
83
84         this.formReplyText = this.formReplyLink?.textContent || '';
85
86         this.setupListeners();
87     }
88
89     protected setupListeners(): void {
90         this.elem.addEventListener('page-comment-delete', () => {
91             setTimeout(() => this.updateCount(), 1);
92             this.hideForm();
93         });
94
95         this.elem.addEventListener('page-comment-reply', (event: CommentReplyEvent) => {
96             this.setReply(event.detail.id, event.detail.element);
97         });
98
99         this.elem.addEventListener('page-comment-archive', (event: ArchiveEvent) => {
100             this.archiveContainer.append(event.detail.new_thread_dom);
101             setTimeout(() => this.updateCount(), 1);
102         });
103
104         this.elem.addEventListener('page-comment-unarchive', (event: ArchiveEvent) => {
105             this.container.append(event.detail.new_thread_dom);
106             setTimeout(() => this.updateCount(), 1);
107         });
108
109         if (this.form) {
110             this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
111             this.removeReferenceButton.addEventListener('click', () => this.setContentReference(''));
112             this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
113             this.addCommentButton.addEventListener('click', this.showForm.bind(this));
114             this.form.addEventListener('submit', this.saveComment.bind(this));
115         }
116     }
117
118     protected saveComment(event): void {
119         event.preventDefault();
120         event.stopPropagation();
121
122         const loading = getLoading();
123         loading.classList.add('px-l');
124         this.form.after(loading);
125         this.form.toggleAttribute('hidden', true);
126
127         const reqData = {
128             html: this.wysiwygEditor.getContent(),
129             parent_id: this.parentId || null,
130             content_ref: this.contentReference,
131         };
132
133         window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
134             const newElem = htmlToDom(resp.data as string);
135
136             if (reqData.parent_id) {
137                 this.formContainer.after(newElem);
138             } else {
139                 this.container.append(newElem);
140             }
141
142             const refs = window.$components.allWithinElement<PageCommentReference>(newElem, 'page-comment-reference');
143             for (const ref of refs) {
144                 ref.showForDisplay();
145             }
146
147             window.$events.success(this.createdText);
148             this.hideForm();
149             this.updateCount();
150         }).catch(err => {
151             this.form.toggleAttribute('hidden', false);
152             window.$events.showValidationErrors(err);
153         });
154
155         this.form.toggleAttribute('hidden', false);
156         loading.remove();
157     }
158
159     protected updateCount(): void {
160         const activeCount = this.getActiveThreadCount();
161         this.activeTab.textContent = window.$trans.choice(this.countText, activeCount);
162         const archivedCount = this.getArchivedThreadCount();
163         this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount);
164     }
165
166     protected resetForm(): void {
167         this.removeEditor();
168         this.formInput.value = '';
169         this.setContentReference('');
170         this.removeReplyTo();
171     }
172
173     protected showForm(): void {
174         this.removeEditor();
175         this.formContainer.toggleAttribute('hidden', false);
176         this.addButtonContainer.toggleAttribute('hidden', true);
177         this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
178         this.loadEditor();
179
180         // Ensure the active comments tab is displaying
181         const tabs = window.$components.firstOnElement(this.elem, 'tabs');
182         if (tabs instanceof Tabs) {
183             tabs.show('comment-tab-panel-active');
184         }
185     }
186
187     protected hideForm(): void {
188         this.resetForm();
189         this.formContainer.toggleAttribute('hidden', true);
190         if (this.getActiveThreadCount() > 0) {
191             this.elem.append(this.addButtonContainer);
192         } else {
193             this.commentCountBar.append(this.addButtonContainer);
194         }
195         this.addButtonContainer.toggleAttribute('hidden', false);
196     }
197
198     protected loadEditor(): void {
199         if (this.wysiwygEditor) {
200             this.wysiwygEditor.focus();
201             return;
202         }
203
204         const config = buildForInput({
205             language: this.wysiwygLanguage,
206             containerElement: this.formInput,
207             darkMode: document.documentElement.classList.contains('dark-mode'),
208             textDirection: this.wysiwygTextDirection,
209             drawioUrl: '',
210             pageId: 0,
211             translations: {},
212             translationMap: (window as Record<string, Object>).editor_translations,
213         });
214
215         (window as {tinymce: {init: (Object) => Promise<any>}}).tinymce.init(config).then(editors => {
216             this.wysiwygEditor = editors[0];
217             setTimeout(() => this.wysiwygEditor.focus(), 50);
218         });
219     }
220
221     protected removeEditor(): void {
222         if (this.wysiwygEditor) {
223             this.wysiwygEditor.remove();
224             this.wysiwygEditor = null;
225         }
226     }
227
228     protected getActiveThreadCount(): number {
229         return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length;
230     }
231
232     protected getArchivedThreadCount(): number {
233         return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length;
234     }
235
236     protected setReply(commentLocalId, commentElement): void {
237         const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children');
238         targetFormLocation.append(this.formContainer);
239         this.showForm();
240         this.parentId = commentLocalId;
241         this.replyToRow.toggleAttribute('hidden', false);
242         this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId));
243         this.formReplyLink.href = `#comment${this.parentId}`;
244     }
245
246     protected removeReplyTo(): void {
247         this.parentId = null;
248         this.replyToRow.toggleAttribute('hidden', true);
249         this.container.append(this.formContainer);
250         this.showForm();
251     }
252
253     public startNewComment(contentReference: string): void {
254         this.removeReplyTo();
255         this.setContentReference(contentReference);
256     }
257
258     protected setContentReference(reference: string): void {
259         this.contentReference = reference;
260         this.referenceRow.toggleAttribute('hidden', !Boolean(reference));
261         const [id] = reference.split(':');
262         this.formReferenceLink.href = `#${id}`;
263         this.formReferenceLink.onclick = function(event) {
264             event.preventDefault();
265             const el = document.getElementById(id);
266             if (el) {
267                 scrollAndHighlightElement(el);
268             }
269         };
270     }
271
272 }