]> BookStack Code Mirror - bookstack/blob - resources/js/code/index.mjs
CM6: Got WYSIWYG code blocks working
[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  * Create a CodeMirror instance for showing inside the WYSIWYG editor.
91  *  Manages a textarea element to hold code content.
92  * @param {HTMLElement} cmContainer
93  * @param {ShadowRoot} shadowRoot
94  * @param {String} content
95  * @param {String} language
96  * @returns {EditorView}
97  */
98 export function wysiwygView(cmContainer, shadowRoot, content, language) {
99     // Monkey-patch so that the container document window "CSSStyleSheet" is used instead of the outer window document.
100     // Needed otherwise codemirror fails to apply styles due to a window mismatch when creating a new "CSSStyleSheet" instance.
101     // Opened: https://github.com/codemirror/dev/issues/1133
102     const originalCSSStyleSheetReference = window.CSSStyleSheet;
103     window.CSSStyleSheet = cmContainer.ownerDocument.defaultView.CSSStyleSheet;
104
105     const ev = createView({
106         parent: cmContainer,
107         doc: content,
108         extensions: viewer(cmContainer),
109         root: shadowRoot,
110     });
111
112     window.CSSStyleSheet = originalCSSStyleSheetReference;
113     setMode(ev, language, content);
114
115     return ev;
116 }
117
118
119 /**
120  * Create a CodeMirror instance to show in the WYSIWYG pop-up editor
121  * @param {HTMLElement} elem
122  * @param {String} modeSuggestion
123  * @returns {*}
124  */
125 export function popupEditor(elem, modeSuggestion) {
126     const content = elem.textContent;
127
128     return CodeMirror(function(elt) {
129         elem.parentNode.insertBefore(elt, elem);
130         elem.style.display = 'none';
131     }, {
132         value: content,
133         mode:  getMode(modeSuggestion, content),
134         lineNumbers: true,
135         lineWrapping: false,
136         theme: getTheme()
137     });
138 }
139
140 /**
141  * Create an inline editor to replace the given textarea.
142  * @param {HTMLTextAreaElement} textArea
143  * @param {String} mode
144  * @returns {EditorView}
145  */
146 export function inlineEditor(textArea, mode) {
147     const content = textArea.value;
148     const config = {
149         parent: textArea.parentNode,
150         doc: content,
151         extensions: [
152             ...editor(textArea.parentElement),
153             EditorView.updateListener.of((v) => {
154                 if (v.docChanged) {
155                     textArea.value = v.state.doc.toString();
156                 }
157             }),
158         ],
159     };
160
161     // Create editor view, hide original input
162     const ev = createView(config);
163     setMode(ev, mode, content);
164     textArea.style.display = 'none';
165
166     return ev;
167 }
168
169 /**
170  * Set the language mode of a codemirror EditorView.
171  *
172  * @param {EditorView} ev
173  * @param {string} modeSuggestion
174  * @param {string} content
175  */
176 export function setMode(ev, modeSuggestion, content) {
177     updateViewLanguage(ev, modeSuggestion, content);
178 }
179
180 /**
181  * Set the content of a cm instance.
182  * @param {EditorView} ev
183  * @param codeContent
184  */
185 export function setContent(ev, codeContent) {
186     const doc = ev.state.doc;
187     doc.replace(0, doc.length, codeContent);
188 }
189
190 /**
191  * Get a CodeMirror instance to use for the markdown editor.
192  * @param {HTMLElement} elem
193  * @param {function} onChange
194  * @param {object} domEventHandlers
195  * @param {Array} keyBindings
196  * @returns {*}
197  */
198 export function markdownEditor(elem, onChange, domEventHandlers, keyBindings) {
199     const content = elem.textContent;
200     const config = {
201         parent: elem.parentNode,
202         doc: content,
203         extensions: [
204             ...editor(elem.parentElement),
205             EditorView.updateListener.of((v) => {
206                 onChange(v);
207             }),
208             EditorView.domEventHandlers(domEventHandlers),
209             keymap.of(keyBindings),
210         ],
211     };
212
213     // Emit a pre-event public event to allow tweaking of the configure before view creation.
214     window.$events.emitPublic(elem, 'editor-markdown-cm::pre-init', {cmEditorViewConfig: config});
215
216     // Create editor view, hide original input
217     const ev = createView(config);
218     setMode(ev, 'markdown', '');
219     elem.style.display = 'none';
220
221     return ev;
222 }