]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-editor.js
Enhance changelog input to textarea with character counter
[bookstack] / resources / js / components / page-editor.js
1 import {onSelect} from '../services/dom.ts';
2 import {debounce} from '../services/util.ts';
3 import {Component} from './component';
4 import {utcTimeStampToLocalTime} from '../services/dates.ts';
5
6 export class PageEditor extends Component {
7
8     setup() {
9         // Options
10         this.draftsEnabled = this.$opts.draftsEnabled === 'true';
11         this.editorType = this.$opts.editorType;
12         this.pageId = Number(this.$opts.pageId);
13         this.isNewDraft = this.$opts.pageNewDraft === 'true';
14         this.hasDefaultTitle = this.$opts.hasDefaultTitle || false;
15
16         // Elements
17         this.container = this.$el;
18         this.titleElem = this.$refs.titleContainer.querySelector('input');
19         this.saveDraftButton = this.$refs.saveDraft;
20         this.discardDraftButton = this.$refs.discardDraft;
21         this.discardDraftWrap = this.$refs.discardDraftWrap;
22         this.deleteDraftButton = this.$refs.deleteDraft;
23         this.deleteDraftWrap = this.$refs.deleteDraftWrap;
24         this.draftDisplay = this.$refs.draftDisplay;
25         this.draftDisplayIcon = this.$refs.draftDisplayIcon;
26         this.changelogInput = this.$refs.changelogInput;
27         this.changelogDisplay = this.$refs.changelogDisplay;
28         this.changeEditorButtons = this.$manyRefs.changeEditor || [];
29         this.switchDialogContainer = this.$refs.switchDialog;
30         this.deleteDraftDialogContainer = this.$refs.deleteDraftDialog;
31
32         // Translations
33         this.draftText = this.$opts.draftText;
34         this.autosaveFailText = this.$opts.autosaveFailText;
35         this.editingPageText = this.$opts.editingPageText;
36         this.draftDiscardedText = this.$opts.draftDiscardedText;
37         this.draftDeleteText = this.$opts.draftDeleteText;
38         this.draftDeleteFailText = this.$opts.draftDeleteFailText;
39         this.setChangelogText = this.$opts.setChangelogText;
40
41         // State data
42         this.autoSave = {
43             interval: null,
44             frequency: 30000,
45             last: 0,
46             pendingChange: false,
47         };
48         this.shownWarningsCache = new Set();
49
50         if (this.pageId !== 0 && this.draftsEnabled) {
51             window.setTimeout(() => {
52                 this.startAutoSave();
53             }, 1000);
54         }
55         this.draftDisplay.innerHTML = this.draftText;
56
57         this.setupListeners();
58         this.setInitialFocus();
59     }
60
61     setupListeners() {
62         // Listen to save events from editor
63         window.$events.listen('editor-save-draft', this.saveDraft.bind(this));
64         window.$events.listen('editor-save-page', this.savePage.bind(this));
65
66         // Listen to content changes from the editor
67         const onContentChange = () => {
68             this.autoSave.pendingChange = true;
69         };
70         window.$events.listen('editor-html-change', onContentChange);
71         window.$events.listen('editor-markdown-change', onContentChange);
72
73         // Listen to changes on the title input
74         this.titleElem.addEventListener('input', onContentChange);
75
76         // Changelog controls
77         const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
78         this.changelogInput.addEventListener('input', () => {
79             const count = this.changelogInput.value.length;
80             const counterEl = document.getElementById('changelog-count');
81             if (counterEl) counterEl.innerText = `${count} / 250`;
82             updateChangelogDebounced();
83         });
84
85         // Draft Controls
86         onSelect(this.saveDraftButton, this.saveDraft.bind(this));
87         onSelect(this.discardDraftButton, this.discardDraft.bind(this));
88         onSelect(this.deleteDraftButton, this.deleteDraft.bind(this));
89
90         // Change editor controls
91         onSelect(this.changeEditorButtons, this.changeEditor.bind(this));
92     }
93
94     setInitialFocus() {
95         if (this.hasDefaultTitle) {
96             this.titleElem.select();
97             return;
98         }
99
100         window.setTimeout(() => {
101             window.$events.emit('editor::focus', '');
102         }, 500);
103     }
104
105     startAutoSave() {
106         this.autoSave.interval = window.setInterval(this.runAutoSave.bind(this), this.autoSave.frequency);
107     }
108
109     runAutoSave() {
110         // Stop if manually saved recently to prevent bombarding the server
111         const savedRecently = (Date.now() - this.autoSave.last < (this.autoSave.frequency) / 2);
112         if (savedRecently || !this.autoSave.pendingChange) {
113             return;
114         }
115
116         this.saveDraft();
117     }
118
119     savePage() {
120         this.container.closest('form').requestSubmit();
121     }
122
123     async saveDraft() {
124         const data = {name: this.titleElem.value.trim()};
125
126         const editorContent = await this.getEditorComponent().getContent();
127         Object.assign(data, editorContent);
128
129         let didSave = false;
130         try {
131             const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
132             if (!this.isNewDraft) {
133                 this.discardDraftWrap.toggleAttribute('hidden', false);
134                 this.deleteDraftWrap.toggleAttribute('hidden', false);
135             }
136
137             this.draftNotifyChange(`${resp.data.message} ${utcTimeStampToLocalTime(resp.data.timestamp)}`);
138             this.autoSave.last = Date.now();
139             if (resp.data.warning && !this.shownWarningsCache.has(resp.data.warning)) {
140                 window.$events.emit('warning', resp.data.warning);
141                 this.shownWarningsCache.add(resp.data.warning);
142             }
143
144             didSave = true;
145             this.autoSave.pendingChange = false;
146         } catch {
147             // Save the editor content in LocalStorage as a last resort, just in case.
148             try {
149                 const saveKey = `draft-save-fail-${(new Date()).toISOString()}`;
150                 window.localStorage.setItem(saveKey, JSON.stringify(data));
151             } catch (lsErr) {
152                 console.error(lsErr);
153             }
154
155             window.$events.emit('error', this.autosaveFailText);
156         }
157
158         return didSave;
159     }
160
161     draftNotifyChange(text) {
162         this.draftDisplay.innerText = text;
163         this.draftDisplayIcon.classList.add('visible');
164         window.setTimeout(() => {
165             this.draftDisplayIcon.classList.remove('visible');
166         }, 2000);
167     }
168
169     async discardDraft(notify = true) {
170         let response;
171         try {
172             response = await window.$http.get(`/ajax/page/${this.pageId}`);
173         } catch (e) {
174             console.error(e);
175             return;
176         }
177
178         if (this.autoSave.interval) {
179             window.clearInterval(this.autoSave.interval);
180         }
181
182         this.draftDisplay.innerText = this.editingPageText;
183         this.discardDraftWrap.toggleAttribute('hidden', true);
184         window.$events.emit('editor::replace', {
185             html: response.data.html,
186             markdown: response.data.markdown,
187         });
188
189         this.titleElem.value = response.data.name;
190         window.setTimeout(() => {
191             this.startAutoSave();
192         }, 1000);
193
194         if (notify) {
195             window.$events.success(this.draftDiscardedText);
196         }
197     }
198
199     async deleteDraft() {
200         /** @var {ConfirmDialog} * */
201         const dialog = window.$components.firstOnElement(this.deleteDraftDialogContainer, 'confirm-dialog');
202         const confirmed = await dialog.show();
203         if (!confirmed) {
204             return;
205         }
206
207         try {
208             const discard = this.discardDraft(false);
209             const draftDelete = window.$http.delete(`/page-revisions/user-drafts/${this.pageId}`);
210             await Promise.all([discard, draftDelete]);
211             window.$events.success(this.draftDeleteText);
212             this.deleteDraftWrap.toggleAttribute('hidden', true);
213         } catch (err) {
214             console.error(err);
215             window.$events.error(this.draftDeleteFailText);
216         }
217     }
218
219     updateChangelogDisplay() {
220         let summary = this.changelogInput.value.trim();
221         if (summary.length === 0) {
222             summary = this.setChangelogText;
223         } else if (summary.length > 16) {
224             summary = `${summary.slice(0, 16)}...`;
225         }
226         this.changelogDisplay.innerText = summary;
227     }
228
229     async changeEditor(event) {
230         event.preventDefault();
231
232         const link = event.target.closest('a').href;
233         /** @var {ConfirmDialog} * */
234         const dialog = window.$components.firstOnElement(this.switchDialogContainer, 'confirm-dialog');
235         const [saved, confirmed] = await Promise.all([this.saveDraft(), dialog.show()]);
236
237         if (saved && confirmed) {
238             window.location = link;
239         }
240     }
241
242     /**
243      * @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce}
244      */
245     getEditorComponent() {
246         return window.$components.first('markdown-editor')
247             || window.$components.first('wysiwyg-editor')
248             || window.$components.first('wysiwyg-editor-tinymce');
249     }
250
251 }