]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comments.ts
Tests: Updated comment test to account for new editor usage
[bookstack] / resources / js / components / page-comments.ts
1 import {Component} from './component';
2 import {getLoading, htmlToDom} from '../services/dom';
3 import {Tabs} from "./tabs";
4 import {PageCommentReference} from "./page-comment-reference";
5 import {scrollAndHighlightElement} from "../services/util";
6 import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
7 import {el} from "../wysiwyg/utils/dom";
8 import {SimpleWysiwygEditorInterface} from "../wysiwyg";
9
10 export class PageComments extends Component {
11
12     private elem!: HTMLElement;
13     private pageId!: number;
14     private container!: HTMLElement;
15     private commentCountBar!: HTMLElement;
16     private activeTab!: HTMLElement;
17     private archivedTab!: HTMLElement;
18     private addButtonContainer!: HTMLElement;
19     private archiveContainer!: HTMLElement;
20     private activeContainer!: HTMLElement;
21     private replyToRow!: HTMLElement;
22     private referenceRow!: HTMLElement;
23     private formContainer!: HTMLElement;
24     private form!: HTMLFormElement;
25     private formInput!: HTMLInputElement;
26     private formReplyLink!: HTMLAnchorElement;
27     private formReferenceLink!: HTMLAnchorElement;
28     private addCommentButton!: HTMLElement;
29     private hideFormButton!: HTMLElement;
30     private removeReplyToButton!: HTMLElement;
31     private removeReferenceButton!: HTMLElement;
32     private wysiwygTextDirection!: string;
33     private wysiwygEditor: SimpleWysiwygEditorInterface|null = 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.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
67
68         // Translations
69         this.createdText = this.$opts.createdText;
70         this.countText = this.$opts.countText;
71         this.archivedCountText = this.$opts.archivedCountText;
72
73         this.formReplyText = this.formReplyLink?.textContent || '';
74
75         this.setupListeners();
76     }
77
78     protected setupListeners(): void {
79         this.elem.addEventListener('page-comment-delete', () => {
80             setTimeout(() => {
81                 this.updateCount();
82                 this.hideForm();
83             }, 1);
84         });
85
86         this.elem.addEventListener('page-comment-reply', ((event: CustomEvent<PageCommentReplyEventData>) => {
87             this.setReply(event.detail.id, event.detail.element);
88         }) as EventListener);
89
90         this.elem.addEventListener('page-comment-archive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
91             this.archiveContainer.append(event.detail.new_thread_dom);
92             setTimeout(() => this.updateCount(), 1);
93         }) as EventListener);
94
95         this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
96             this.container.append(event.detail.new_thread_dom);
97             setTimeout(() => this.updateCount(), 1);
98         }) as EventListener);
99
100         if (this.form) {
101             this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
102             this.removeReferenceButton.addEventListener('click', () => this.setContentReference(''));
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 async saveComment(event: SubmitEvent): Promise<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: (await this.wysiwygEditor?.getContentAsHtml()) || '',
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             const refs = window.$components.allWithinElement<PageCommentReference>(newElem, 'page-comment-reference');
134             for (const ref of refs) {
135                 ref.showForDisplay();
136             }
137
138             window.$events.success(this.createdText);
139             this.hideForm();
140             this.updateCount();
141         }).catch(err => {
142             this.form.toggleAttribute('hidden', false);
143             window.$events.showValidationErrors(err);
144         });
145
146         this.form.toggleAttribute('hidden', false);
147         loading.remove();
148     }
149
150     protected updateCount(): void {
151         const activeCount = this.getActiveThreadCount();
152         this.activeTab.textContent = window.$trans.choice(this.countText, activeCount);
153         const archivedCount = this.getArchivedThreadCount();
154         this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount);
155     }
156
157     protected resetForm(): void {
158         this.removeEditor();
159         this.formInput.value = '';
160         this.parentId = null;
161         this.replyToRow.toggleAttribute('hidden', true);
162         this.container.append(this.formContainer);
163         this.setContentReference('');
164     }
165
166     protected showForm(): void {
167         this.removeEditor();
168         this.formContainer.toggleAttribute('hidden', false);
169         this.addButtonContainer.toggleAttribute('hidden', true);
170         this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
171         this.loadEditor();
172
173         // Ensure the active comments tab is displaying if that's where we're showing the form
174         const tabs = window.$components.firstOnElement(this.elem, 'tabs');
175         if (tabs instanceof Tabs && this.formContainer.closest('#comment-tab-panel-active')) {
176             tabs.show('comment-tab-panel-active');
177         }
178     }
179
180     protected hideForm(): void {
181         this.resetForm();
182         this.formContainer.toggleAttribute('hidden', true);
183         if (this.getActiveThreadCount() > 0) {
184             this.activeContainer.append(this.addButtonContainer);
185         } else {
186             this.commentCountBar.append(this.addButtonContainer);
187         }
188         this.addButtonContainer.toggleAttribute('hidden', false);
189     }
190
191     protected async loadEditor(): Promise<void> {
192         if (this.wysiwygEditor) {
193             this.wysiwygEditor.focus();
194             return;
195         }
196
197         type WysiwygModule = typeof import('../wysiwyg');
198         const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
199         const container = el('div', {class: 'comment-editor-container'});
200         this.formInput.parentElement?.appendChild(container);
201         this.formInput.hidden = true;
202
203         this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '<p></p>', {
204             darkMode: document.documentElement.classList.contains('dark-mode'),
205             textDirection: this.wysiwygTextDirection,
206             translations: (window as unknown as Record<string, Object>).editor_translations,
207         });
208
209         this.wysiwygEditor.focus();
210     }
211
212     protected removeEditor(): void {
213         if (this.wysiwygEditor) {
214             this.wysiwygEditor.remove();
215             this.wysiwygEditor = null;
216         }
217     }
218
219     protected getActiveThreadCount(): number {
220         return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length;
221     }
222
223     protected getArchivedThreadCount(): number {
224         return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length;
225     }
226
227     protected setReply(commentLocalId: string, commentElement: HTMLElement): void {
228         const targetFormLocation = (commentElement.closest('.comment-branch') as HTMLElement).querySelector('.comment-branch-children') as HTMLElement;
229         targetFormLocation.append(this.formContainer);
230         this.showForm();
231         this.parentId = Number(commentLocalId);
232         this.replyToRow.toggleAttribute('hidden', false);
233         this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId));
234         this.formReplyLink.href = `#comment${this.parentId}`;
235     }
236
237     protected removeReplyTo(): void {
238         this.parentId = null;
239         this.replyToRow.toggleAttribute('hidden', true);
240         this.container.append(this.formContainer);
241         this.showForm();
242     }
243
244     public startNewComment(contentReference: string): void {
245         this.resetForm();
246         this.showForm();
247         this.setContentReference(contentReference);
248     }
249
250     protected setContentReference(reference: string): void {
251         this.contentReference = reference;
252         this.referenceRow.toggleAttribute('hidden', !Boolean(reference));
253         const [id] = reference.split(':');
254         this.formReferenceLink.href = `#${id}`;
255         this.formReferenceLink.onclick = function(event) {
256             event.preventDefault();
257             const el = document.getElementById(id);
258             if (el) {
259                 scrollAndHighlightElement(el);
260             }
261         };
262     }
263
264 }