]> BookStack Code Mirror - bookstack/blob - resources/js/components/code-editor.js
091c3483f4d6bd1ecffabb7f52f3f0b17933abfa
[bookstack] / resources / js / components / code-editor.js
1 import {onChildEvent, onEnterPress, onSelect} from '../services/dom';
2 import {Component} from './component';
3
4 export class CodeEditor extends Component {
5
6     /**
7      * @type {null|SimpleEditorInterface}
8      */
9     editor = null;
10
11     /**
12      * @type {?Function}
13      */
14     saveCallback = null;
15
16     /**
17      * @type {?Function}
18      */
19     cancelCallback = null;
20
21     history = {};
22
23     historyKey = 'code_history';
24
25     setup() {
26         this.container = this.$refs.container;
27         this.popup = this.$el;
28         this.editorInput = this.$refs.editor;
29         this.languageButtons = this.$manyRefs.languageButton;
30         this.languageOptionsContainer = this.$refs.languageOptionsContainer;
31         this.saveButton = this.$refs.saveButton;
32         this.languageInput = this.$refs.languageInput;
33         this.historyDropDown = this.$refs.historyDropDown;
34         this.historyList = this.$refs.historyList;
35         this.favourites = new Set(this.$opts.favourites.split(','));
36
37         this.setupListeners();
38         this.setupFavourites();
39     }
40
41     setupListeners() {
42         this.container.addEventListener('keydown', event => {
43             if (event.ctrlKey && event.key === 'Enter') {
44                 this.save();
45             }
46         });
47
48         onSelect(this.languageButtons, event => {
49             const language = event.target.dataset.lang;
50             this.languageInput.value = language;
51             this.languageInputChange(language);
52         });
53
54         onEnterPress(this.languageInput, () => this.save());
55         this.languageInput.addEventListener('input', () => this.languageInputChange(this.languageInput.value));
56         onSelect(this.saveButton, () => this.save());
57
58         onChildEvent(this.historyList, 'button', 'click', (event, elem) => {
59             event.preventDefault();
60             const historyTime = elem.dataset.time;
61             if (this.editor) {
62                 this.editor.setContent(this.history[historyTime]);
63             }
64         });
65     }
66
67     setupFavourites() {
68         for (const button of this.languageButtons) {
69             this.setupFavouritesForButton(button);
70         }
71
72         this.sortLanguageList();
73     }
74
75     /**
76      * @param {HTMLButtonElement} button
77      */
78     setupFavouritesForButton(button) {
79         const language = button.dataset.lang;
80         let isFavorite = this.favourites.has(language);
81         button.setAttribute('data-favourite', isFavorite ? 'true' : 'false');
82
83         onChildEvent(button.parentElement, '.lang-option-favorite-toggle', 'click', () => {
84             isFavorite = !isFavorite;
85
86             if (isFavorite) {
87                 this.favourites.add(language);
88             } else {
89                 this.favourites.delete(language);
90             }
91
92             button.setAttribute('data-favourite', isFavorite ? 'true' : 'false');
93
94             window.$http.patch('/preferences/update-code-language-favourite', {
95                 language,
96                 active: isFavorite,
97             });
98
99             this.sortLanguageList();
100             if (isFavorite) {
101                 button.scrollIntoView({block: 'center', behavior: 'smooth'});
102             }
103         });
104     }
105
106     sortLanguageList() {
107         const sortedParents = this.languageButtons.sort((a, b) => {
108             const aFav = a.dataset.favourite === 'true';
109             const bFav = b.dataset.favourite === 'true';
110
111             if (aFav && !bFav) {
112                 return -1;
113             } if (bFav && !aFav) {
114                 return 1;
115             }
116
117             return a.dataset.lang > b.dataset.lang ? 1 : -1;
118         }).map(button => button.parentElement);
119
120         for (const parent of sortedParents) {
121             this.languageOptionsContainer.append(parent);
122         }
123     }
124
125     save() {
126         if (this.saveCallback) {
127             this.saveCallback(this.editor.getContent(), this.languageInput.value);
128         }
129         this.hide();
130     }
131
132     async open(code, language, direction, saveCallback, cancelCallback) {
133         this.languageInput.value = language;
134         this.saveCallback = saveCallback;
135         this.cancelCallback = cancelCallback;
136
137         await this.show();
138         this.languageInputChange(language);
139         this.editor.setContent(code);
140         this.setDirection(direction);
141     }
142
143     async show() {
144         const Code = await window.importVersioned('code');
145         if (!this.editor) {
146             this.editor = Code.popupEditor(this.editorInput, this.languageInput.value);
147         }
148
149         this.loadHistory();
150         this.getPopup().show(() => {
151             this.editor.focus();
152         }, () => {
153             this.addHistory();
154             if (this.cancelCallback) {
155                 this.cancelCallback();
156             }
157         });
158     }
159
160     setDirection(direction) {
161         const target = this.editorInput.parentElement;
162         if (direction) {
163             target.setAttribute('dir', direction);
164         } else {
165             target.removeAttribute('dir');
166         }
167     }
168
169     hide() {
170         this.getPopup().hide();
171         this.addHistory();
172     }
173
174     /**
175      * @returns {Popup}
176      */
177     getPopup() {
178         return window.$components.firstOnElement(this.popup, 'popup');
179     }
180
181     async updateEditorMode(language) {
182         this.editor.setMode(language, this.editor.getContent());
183     }
184
185     languageInputChange(language) {
186         this.updateEditorMode(language);
187         const inputLang = language.toLowerCase();
188
189         for (const link of this.languageButtons) {
190             const lang = link.dataset.lang.toLowerCase().trim();
191             const isMatch = inputLang === lang;
192             link.classList.toggle('active', isMatch);
193             if (isMatch) {
194                 link.scrollIntoView({block: 'center', behavior: 'smooth'});
195             }
196         }
197     }
198
199     loadHistory() {
200         this.history = JSON.parse(window.sessionStorage.getItem(this.historyKey) || '{}');
201         const historyKeys = Object.keys(this.history).reverse();
202         this.historyDropDown.classList.toggle('hidden', historyKeys.length === 0);
203         this.historyList.innerHTML = historyKeys.map(key => {
204             const localTime = (new Date(parseInt(key, 10))).toLocaleTimeString();
205             return `<li><button type="button" data-time="${key}" class="text-item">${localTime}</button></li>`;
206         }).join('');
207     }
208
209     addHistory() {
210         if (!this.editor) return;
211         const code = this.editor.getContent();
212         if (!code) return;
213
214         // Stop if we'd be storing the same as the last item
215         const lastHistoryKey = Object.keys(this.history).pop();
216         if (this.history[lastHistoryKey] === code) return;
217
218         this.history[String(Date.now())] = code;
219         const historyString = JSON.stringify(this.history);
220         window.sessionStorage.setItem(this.historyKey, historyString);
221     }
222
223 }