]> BookStack Code Mirror - bookstack/blob - resources/js/code/index.mjs
CM6: Aligned styling with existing, improved theme handling
[bookstack] / resources / js / code / index.mjs
1 import {EditorView, keymap} from "@codemirror/view";
2
3 import {copyTextToClipboard} from "../services/clipboard.js"
4 import {viewer, editor} from "./setups.js";
5 import {createView, updateViewLanguage} from "./views.js";
6
7 /**
8  * Highlight pre elements on a page
9  */
10 export function highlight() {
11     const codeBlocks = document.querySelectorAll('.page-content pre, .comment-box .content pre');
12     for (const codeBlock of codeBlocks) {
13         highlightElem(codeBlock);
14     }
15 }
16
17 /**
18  * Highlight all code blocks within the given parent element
19  * @param {HTMLElement} parent
20  */
21 export function highlightWithin(parent) {
22     const codeBlocks = parent.querySelectorAll('pre');
23     for (const codeBlock of codeBlocks) {
24         highlightElem(codeBlock);
25     }
26 }
27
28 /**
29  * Add code highlighting to a single element.
30  * @param {HTMLElement} elem
31  */
32 function highlightElem(elem) {
33     const innerCodeElem = elem.querySelector('code[class^=language-]');
34     elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
35     const content = elem.textContent.trimEnd();
36
37     let langName = '';
38     if (innerCodeElem !== null) {
39         langName = innerCodeElem.className.replace('language-', '');
40     }
41
42     const wrapper = document.createElement('div');
43     elem.parentNode.insertBefore(wrapper, elem);
44
45     const ev = createView({
46         parent: wrapper,
47         doc: content,
48         extensions: viewer(wrapper),
49     });
50
51     setMode(ev, langName, content);
52     elem.remove();
53     addCopyIcon(ev);
54 }
55
56 /**
57  * Add a button to a CodeMirror instance which copies the contents to the clipboard upon click.
58  * @param {EditorView} editorView
59  */
60 function addCopyIcon(editorView) {
61     const copyIcon = `<svg viewBox="0 0 24 24" width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
62     const checkIcon = `<svg viewBox="0 0 24 24" width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`;
63     const copyButton = document.createElement('button');
64     copyButton.setAttribute('type', 'button')
65     copyButton.classList.add('cm-copy-button');
66     copyButton.innerHTML = copyIcon;
67     editorView.dom.appendChild(copyButton);
68
69     const notifyTime = 620;
70     const transitionTime = 60;
71     copyButton.addEventListener('click', event => {
72         copyTextToClipboard(editorView.state.doc.toString());
73         copyButton.classList.add('success');
74
75         setTimeout(() => {
76             copyButton.innerHTML = checkIcon;
77         }, transitionTime / 2);
78
79         setTimeout(() => {
80             copyButton.classList.remove('success');
81         }, notifyTime);
82
83         setTimeout(() => {
84             copyButton.innerHTML = copyIcon;
85         }, notifyTime + (transitionTime / 2));
86     });
87 }
88
89 /**
90  * Ge the theme to use for CodeMirror instances.
91  * @returns {*|string}
92  */
93 function getTheme() {
94     // TODO - Remove
95     const darkMode = document.documentElement.classList.contains('dark-mode');
96     return window.codeTheme || (darkMode ? 'darcula' : 'default');
97 }
98
99 /**
100  * Create a CodeMirror instance for showing inside the WYSIWYG editor.
101  *  Manages a textarea element to hold code content.
102  * @param {HTMLElement} cmContainer
103  * @param {String} content
104  * @param {String} language
105  * @returns {EditorView}
106  */
107 export function wysiwygView(cmContainer, content, language) {
108     const ev = createView({
109         parent: cmContainer,
110         doc: content,
111         extensions: viewer(cmContainer),
112     });
113
114     setMode(ev, language, content);
115
116     return ev;
117 }
118
119
120 /**
121  * Create a CodeMirror instance to show in the WYSIWYG pop-up editor
122  * @param {HTMLElement} elem
123  * @param {String} modeSuggestion
124  * @returns {*}
125  */
126 export function popupEditor(elem, modeSuggestion) {
127     const content = elem.textContent;
128
129     return CodeMirror(function(elt) {
130         elem.parentNode.insertBefore(elt, elem);
131         elem.style.display = 'none';
132     }, {
133         value: content,
134         mode:  getMode(modeSuggestion, content),
135         lineNumbers: true,
136         lineWrapping: false,
137         theme: getTheme()
138     });
139 }
140
141 /**
142  * Create an inline editor to replace the given textarea.
143  * @param {HTMLTextAreaElement} textArea
144  * @param {String} mode
145  * @returns {EditorView}
146  */
147 export function inlineEditor(textArea, mode) {
148     const content = textArea.value;
149     const config = {
150         parent: textArea.parentNode,
151         doc: content,
152         extensions: [
153             ...editor(textArea.parentElement),
154             EditorView.updateListener.of((v) => {
155                 if (v.docChanged) {
156                     textArea.value = v.state.doc.toString();
157                 }
158             }),
159         ],
160     };
161
162     // Create editor view, hide original input
163     const ev = createView(config);
164     setMode(ev, mode, content);
165     textArea.style.display = 'none';
166
167     return ev;
168 }
169
170 /**
171  * Set the language mode of a codemirror EditorView.
172  *
173  * @param {EditorView} ev
174  * @param {string} modeSuggestion
175  * @param {string} content
176  */
177 export function setMode(ev, modeSuggestion, content) {
178     updateViewLanguage(ev, modeSuggestion, content);
179 }
180
181 /**
182  * Set the content of a cm instance.
183  * @param cmInstance
184  * @param codeContent
185  */
186 export function setContent(cmInstance, codeContent) {
187     cmInstance.setValue(codeContent);
188     setTimeout(() => {
189         updateLayout(cmInstance);
190     }, 10);
191 }
192
193 /**
194  * Update the layout (codemirror refresh) of a cm instance.
195  * @param cmInstance
196  */
197 export function updateLayout(cmInstance) {
198     cmInstance.refresh();
199 }
200
201 /**
202  * Get a CodeMirror instance to use for the markdown editor.
203  * @param {HTMLElement} elem
204  * @param {function} onChange
205  * @param {object} domEventHandlers
206  * @param {Array} keyBindings
207  * @returns {*}
208  */
209 export function markdownEditor(elem, onChange, domEventHandlers, keyBindings) {
210     const content = elem.textContent;
211     const config = {
212         parent: elem.parentNode,
213         doc: content,
214         extensions: [
215             ...editor(elem.parentElement),
216             EditorView.updateListener.of((v) => {
217                 onChange(v);
218             }),
219             EditorView.domEventHandlers(domEventHandlers),
220             keymap.of(keyBindings),
221         ],
222     };
223
224     // Emit a pre-event public event to allow tweaking of the configure before view creation.
225     window.$events.emitPublic(elem, 'editor-markdown-cm::pre-init', {cmEditorViewConfig: config});
226
227     // Create editor view, hide original input
228     const ev = createView(config);
229     setMode(ev, 'markdown', '');
230     elem.style.display = 'none';
231
232     return ev;
233 }