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