]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-editor.js
Merge branch 'development' of github.com:BookStackApp/BookStack into development
[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', updateChangelogDebounced);
79
80         // Draft Controls
81         onSelect(this.saveDraftButton, this.saveDraft.bind(this));
82         onSelect(this.discardDraftButton, this.discardDraft.bind(this));
83         onSelect(this.deleteDraftButton, this.deleteDraft.bind(this));
84
85         // Change editor controls
86         onSelect(this.changeEditorButtons, this.changeEditor.bind(this));
87     }
88
89     setInitialFocus() {
90         if (this.hasDefaultTitle) {
91             this.titleElem.select();
92             return;
93         }
94
95         window.setTimeout(() => {
96             window.$events.emit('editor::focus', '');
97         }, 500);
98     }
99
100     startAutoSave() {
101         this.autoSave.interval = window.setInterval(this.runAutoSave.bind(this), this.autoSave.frequency);
102     }
103
104     runAutoSave() {
105         // Stop if manually saved recently to prevent bombarding the server
106         const savedRecently = (Date.now() - this.autoSave.last < (this.autoSave.frequency) / 2);
107         if (savedRecently || !this.autoSave.pendingChange) {
108             return;
109         }
110
111         this.saveDraft();
112     }
113
114     savePage() {
115         this.container.closest('form').requestSubmit();
116     }
117
118     async saveDraft() {
119         const data = {name: this.titleElem.value.trim()};
120
121         const editorContent = await this.getEditorComponent().getContent();
122         Object.assign(data, editorContent);
123
124         let didSave = false;
125         try {
126             const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
127             if (!this.isNewDraft) {
128                 this.discardDraftWrap.toggleAttribute('hidden', false);
129                 this.deleteDraftWrap.toggleAttribute('hidden', false);
130             }
131
132             this.draftNotifyChange(`${resp.data.message} ${utcTimeStampToLocalTime(resp.data.timestamp)}`);
133             this.autoSave.last = Date.now();
134             if (resp.data.warning && !this.shownWarningsCache.has(resp.data.warning)) {
135                 window.$events.emit('warning', resp.data.warning);
136                 this.shownWarningsCache.add(resp.data.warning);
137             }
138
139             didSave = true;
140             this.autoSave.pendingChange = false;
141         } catch {
142             // Save the editor content in LocalStorage as a last resort, just in case.
143             try {
144                 const saveKey = `draft-save-fail-${(new Date()).toISOString()}`;
145                 window.localStorage.setItem(saveKey, JSON.stringify(data));
146             } catch (lsErr) {
147                 console.error(lsErr);
148             }
149
150             window.$events.emit('error', this.autosaveFailText);
151         }
152
153         return didSave;
154     }
155
156     draftNotifyChange(text) {
157         this.draftDisplay.innerText = text;
158         this.draftDisplayIcon.classList.add('visible');
159         window.setTimeout(() => {
160             this.draftDisplayIcon.classList.remove('visible');
161         }, 2000);
162     }
163
164     async discardDraft(notify = true) {
165         let response;
166         try {
167             response = await window.$http.get(`/ajax/page/${this.pageId}`);
168         } catch (e) {
169             console.error(e);
170             return;
171         }
172
173         if (this.autoSave.interval) {
174             window.clearInterval(this.autoSave.interval);
175         }
176
177         this.draftDisplay.innerText = this.editingPageText;
178         this.discardDraftWrap.toggleAttribute('hidden', true);
179         window.$events.emit('editor::replace', {
180             html: response.data.html,
181             markdown: response.data.markdown,
182         });
183
184         this.titleElem.value = response.data.name;
185         window.setTimeout(() => {
186             this.startAutoSave();
187         }, 1000);
188
189         if (notify) {
190             window.$events.success(this.draftDiscardedText);
191         }
192     }
193
194     async deleteDraft() {
195         /** @var {ConfirmDialog} * */
196         const dialog = window.$components.firstOnElement(this.deleteDraftDialogContainer, 'confirm-dialog');
197         const confirmed = await dialog.show();
198         if (!confirmed) {
199             return;
200         }
201
202         try {
203             const discard = this.discardDraft(false);
204             const draftDelete = window.$http.delete(`/page-revisions/user-drafts/${this.pageId}`);
205             await Promise.all([discard, draftDelete]);
206             window.$events.success(this.draftDeleteText);
207             this.deleteDraftWrap.toggleAttribute('hidden', true);
208         } catch (err) {
209             console.error(err);
210             window.$events.error(this.draftDeleteFailText);
211         }
212     }
213
214     updateChangelogDisplay() {
215         let summary = this.changelogInput.value.trim();
216         if (summary.length === 0) {
217             summary = this.setChangelogText;
218         } else if (summary.length > 16) {
219             summary = `${summary.slice(0, 16)}...`;
220         }
221         this.changelogDisplay.innerText = summary;
222     }
223
224     async changeEditor(event) {
225         event.preventDefault();
226
227         const link = event.target.closest('a').href;
228         /** @var {ConfirmDialog} * */
229         const dialog = window.$components.firstOnElement(this.switchDialogContainer, 'confirm-dialog');
230         const [saved, confirmed] = await Promise.all([this.saveDraft(), dialog.show()]);
231
232         if (saved && confirmed) {
233             window.location = link;
234         }
235     }
236
237     /**
238      * @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce}
239      */
240     getEditorComponent() {
241         return window.$components.first('markdown-editor')
242             || window.$components.first('wysiwyg-editor')
243             || window.$components.first('wysiwyg-editor-tinymce');
244     }
245
246 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.