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