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