]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-editor.js
Extracted keyboard nav. from dropdowns to share w/ search
[bookstack] / resources / js / components / page-editor.js
1 import * as Dates from "../services/dates";
2 import {onSelect} from "../services/dom";
3 import {debounce} from "../services/util";
4
5 /**
6  * Page Editor
7  * @extends {Component}
8  */
9 class PageEditor {
10     setup() {
11         // Options
12         this.draftsEnabled = this.$opts.draftsEnabled === 'true';
13         this.editorType = this.$opts.editorType;
14         this.pageId = Number(this.$opts.pageId);
15         this.isNewDraft = this.$opts.pageNewDraft === 'true';
16         this.hasDefaultTitle = this.$opts.hasDefaultTitle || false;
17
18         // Elements
19         this.container = this.$el;
20         this.titleElem = this.$refs.titleContainer.querySelector('input');
21         this.saveDraftButton = this.$refs.saveDraft;
22         this.discardDraftButton = this.$refs.discardDraft;
23         this.discardDraftWrap = this.$refs.discardDraftWrap;
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
31         // Translations
32         this.draftText = this.$opts.draftText;
33         this.autosaveFailText = this.$opts.autosaveFailText;
34         this.editingPageText = this.$opts.editingPageText;
35         this.draftDiscardedText = this.$opts.draftDiscardedText;
36         this.setChangelogText = this.$opts.setChangelogText;
37
38         // State data
39         this.editorHTML = '';
40         this.editorMarkdown = '';
41         this.autoSave = {
42             interval: null,
43             frequency: 30000,
44             last: 0,
45         };
46         this.shownWarningsCache = new Set();
47
48         if (this.pageId !== 0 && this.draftsEnabled) {
49             window.setTimeout(() => {
50                 this.startAutoSave();
51             }, 1000);
52         }
53         this.draftDisplay.innerHTML = this.draftText;
54
55         this.setupListeners();
56         this.setInitialFocus();
57     }
58
59     setupListeners() {
60         // Listen to save events from editor
61         window.$events.listen('editor-save-draft', this.saveDraft.bind(this));
62         window.$events.listen('editor-save-page', this.savePage.bind(this));
63
64         // Listen to content changes from the editor
65         window.$events.listen('editor-html-change', html => {
66             this.editorHTML = html;
67         });
68         window.$events.listen('editor-markdown-change', markdown => {
69             this.editorMarkdown = markdown;
70         });
71
72         // Changelog controls
73         const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
74         this.changelogInput.addEventListener('input', updateChangelogDebounced);
75
76         // Draft Controls
77         onSelect(this.saveDraftButton, this.saveDraft.bind(this));
78         onSelect(this.discardDraftButton, this.discardDraft.bind(this));
79
80         // Change editor controls
81         onSelect(this.changeEditorButtons, this.changeEditor.bind(this));
82     }
83
84     setInitialFocus() {
85         if (this.hasDefaultTitle) {
86             return this.titleElem.select();
87         }
88
89         window.setTimeout(() => {
90             window.$events.emit('editor::focus', '');
91         }, 500);
92     }
93
94     startAutoSave() {
95         let lastContent = this.titleElem.value.trim() + '::' + this.editorHTML;
96         this.autoSaveInterval = window.setInterval(() => {
97             // Stop if manually saved recently to prevent bombarding the server
98             let savedRecently = (Date.now() - this.autoSave.last < (this.autoSave.frequency)/2);
99             if (savedRecently) return;
100             const newContent = this.titleElem.value.trim() + '::' + this.editorHTML;
101             if (newContent !== lastContent) {
102                 lastContent = newContent;
103                 this.saveDraft();
104             }
105
106         }, this.autoSave.frequency);
107     }
108
109     savePage() {
110         this.container.closest('form').submit();
111     }
112
113     async saveDraft() {
114         const data = {
115             name: this.titleElem.value.trim(),
116             html: this.editorHTML,
117         };
118
119         if (this.editorType === 'markdown') {
120             data.markdown = this.editorMarkdown;
121         }
122
123         let didSave = false;
124         try {
125             const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
126             if (!this.isNewDraft) {
127                 this.toggleDiscardDraftVisibility(true);
128             }
129
130             this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
131             this.autoSave.last = Date.now();
132             if (resp.data.warning && !this.shownWarningsCache.has(resp.data.warning)) {
133                 window.$events.emit('warning', resp.data.warning);
134                 this.shownWarningsCache.add(resp.data.warning);
135             }
136
137             didSave = true;
138         } catch (err) {
139             // Save the editor content in LocalStorage as a last resort, just in case.
140             try {
141                 const saveKey = `draft-save-fail-${(new Date()).toISOString()}`;
142                 window.localStorage.setItem(saveKey, JSON.stringify(data));
143             } catch (err) {}
144
145             window.$events.emit('error', this.autosaveFailText);
146         }
147
148         return didSave;
149     }
150
151     draftNotifyChange(text) {
152         this.draftDisplay.innerText = text;
153         this.draftDisplayIcon.classList.add('visible');
154         window.setTimeout(() => {
155             this.draftDisplayIcon.classList.remove('visible');
156         }, 2000);
157     }
158
159     async discardDraft() {
160         let response;
161         try {
162             response = await window.$http.get(`/ajax/page/${this.pageId}`);
163         } catch (e) {
164             return console.error(e);
165         }
166
167         if (this.autoSave.interval) {
168             window.clearInterval(this.autoSave.interval);
169         }
170
171         this.draftDisplay.innerText = this.editingPageText;
172         this.toggleDiscardDraftVisibility(false);
173         window.$events.emit('editor::replace', {
174             html: response.data.html,
175             markdown: response.data.markdown,
176         });
177
178         this.titleElem.value = response.data.name;
179         window.setTimeout(() => {
180             this.startAutoSave();
181         }, 1000);
182         window.$events.emit('success', this.draftDiscardedText);
183
184     }
185
186     updateChangelogDisplay() {
187         let summary = this.changelogInput.value.trim();
188         if (summary.length === 0) {
189             summary = this.setChangelogText;
190         } else if (summary.length > 16) {
191             summary = summary.slice(0, 16) + '...';
192         }
193         this.changelogDisplay.innerText = summary;
194     }
195
196     toggleDiscardDraftVisibility(show) {
197         this.discardDraftWrap.classList.toggle('hidden', !show);
198     }
199
200     async changeEditor(event) {
201         event.preventDefault();
202
203         const link = event.target.closest('a').href;
204         const dialog = this.switchDialogContainer.components['confirm-dialog'];
205         const [saved, confirmed] = await Promise.all([this.saveDraft(), dialog.show()]);
206
207         if (saved && confirmed) {
208             window.location = link;
209         }
210     }
211
212 }
213
214 export default PageEditor;