/* global CodeMirror loadScript rerouteHotkeys prefs $ debounce $create */ /* exported cmFactory */ 'use strict'; /* All cm instances created by this module are collected so we can broadcast prefs settings to them. You should `cmFactory.destroy(cm)` to unregister the listener when the instance is not used anymore. */ const cmFactory = (() => { const editors = new Set(); // used by `indentWithTabs` option const INSERT_TAB_COMMAND = CodeMirror.commands.insertTab; const INSERT_SOFT_TAB_COMMAND = CodeMirror.commands.insertSoftTab; CodeMirror.defineOption('tabSize', prefs.get('editor.tabSize'), (cm, value) => { cm.setOption('indentUnit', Number(value)); }); CodeMirror.defineOption('indentWithTabs', prefs.get('editor.indentWithTabs'), (cm, value) => { CodeMirror.commands.insertTab = value ? INSERT_TAB_COMMAND : INSERT_SOFT_TAB_COMMAND; }); CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'), (cm, value) => { const onOff = value ? 'on' : 'off'; cm[onOff]('changes', autocompleteOnTyping); cm[onOff]('pick', autocompletePicked); }); CodeMirror.defineOption('matchHighlight', prefs.get('editor.matchHighlight'), (cm, value) => { if (value === 'token') { cm.setOption('highlightSelectionMatches', { showToken: /[#.\-\w]/, annotateScrollbar: true }); } else if (value === 'selection') { cm.setOption('highlightSelectionMatches', { showToken: false, annotateScrollbar: true }); } else { cm.setOption('highlightSelectionMatches', null); } }); CodeMirror.defineOption('selectByTokens', prefs.get('editor.selectByTokens'), (cm, value) => { cm.setOption('configureMouse', value ? configureMouseFn : null); }); prefs.subscribe(null, (key, value) => { const option = key.replace(/^editor\./, ''); if (!option) { console.error('no "cm_option"', key); return; } // FIXME: this is implemented in `colorpicker-helper.js`. if (option === 'colorpicker') { return; } if (option === 'theme') { const themeLink = $('#cm-theme'); // use non-localized 'default' internally if (value === 'default') { themeLink.href = ''; } else { const url = chrome.runtime.getURL('vendor/codemirror/theme/' + value + '.css'); if (themeLink.href !== url) { // avoid flicker: wait for the second stylesheet to load, then apply the theme return loadScript(url, true).then(([newThemeLink]) => { setOption(option, value); themeLink.remove(); newThemeLink.id = 'cm-theme'; }); } } } // broadcast option setOption(option, value); }); return {create, destroy, setOption}; function configureMouseFn(cm, repeat) { return repeat === 'double' ? {unit: selectTokenOnDoubleclick} : {}; } function selectTokenOnDoubleclick(cm, pos) { let {ch} = pos; const {line, sticky} = pos; const {text, styles} = cm.getLineHandle(line); const execAt = (rx, i) => (rx.lastIndex = i) && null || rx.exec(text); const at = (rx, i) => (rx.lastIndex = i) && null || rx.test(text); const atWord = ch => at(/\w/y, ch); const atSpace = ch => at(/\s/y, ch); const atTokenEnd = styles.indexOf(ch, 1); ch += atTokenEnd < 0 ? 0 : sticky === 'before' && atWord(ch - 1) ? 0 : atSpace(ch + 1) ? 0 : 1; ch = Math.min(text.length, ch); const type = cm.getTokenTypeAt({line, ch: ch + (sticky === 'after' ? 1 : 0)}); if (atTokenEnd > 0) ch--; const isCss = type && !/^(comment|string)/.test(type); const isNumber = type === 'number'; const isSpace = atSpace(ch); let wordChars = isNumber ? /[-+\w.%]/y : isCss ? /[-\w@]/y : isSpace ? /\s/y : atWord(ch) ? /\w/y : /[^\w\s]/y; let a = ch; while (a && at(wordChars, a)) a--; a += !a && at(wordChars, a) || isCss && at(/[.!#@]/y, a) ? 0 : at(wordChars, a + 1); let b, found; if (isNumber) { b = a + execAt(/[+-]?[\d.]+(e\d+)?|$/yi, a)[0].length; found = b >= ch; if (!found) { a = b; ch = a; } } if (!found) { wordChars = isCss ? /[-\w]*/y : new RegExp(wordChars.source + '*', 'uy'); b = ch + execAt(wordChars, ch)[0].length; } return { from: {line, ch: a}, to: {line, ch: b}, }; } function autocompleteOnTyping(cm, [info], debounced) { const lastLine = info.text[info.text.length - 1]; if ( cm.state.completionActive || info.origin && !info.origin.includes('input') || !lastLine ) { return; } if (cm.state.autocompletePicked) { cm.state.autocompletePicked = false; return; } if (!debounced) { debounce(autocompleteOnTyping, 100, cm, [info], true); return; } if (lastLine.match(/[-a-z!]+$/i)) { cm.state.autocompletePicked = false; cm.options.hintOptions.completeSingle = false; cm.execCommand('autocomplete'); setTimeout(() => { cm.options.hintOptions.completeSingle = true; }); } } function autocompletePicked(cm) { cm.state.autocompletePicked = true; } function destroy(cm) { editors.delete(cm); } function create(init, options) { const cm = CodeMirror(init, options); cm.lastActive = 0; const wrapper = cm.display.wrapper; cm.on('blur', () => { rerouteHotkeys(true); setTimeout(() => { wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement)); }); }); cm.on('focus', () => { rerouteHotkeys(false); wrapper.classList.add('CodeMirror-active'); cm.lastActive = Date.now(); }); editors.add(cm); cm.distroy = () => editors.delete(cm); return cm; } function getLastActivated() { let result; for (const cm of editors) { if (!result || result.lastActive < cm.lastActive) { result = cm; } } return result; } function setOption(key, value) { CodeMirror.defaults[key] = value; if (editors.size > 4 && (key === 'theme' || key === 'lineWrapping')) { throttleSetOption({key, value, index: 0}); return; } for (const cm of editors) { cm.setOption(key, value); } } function throttleSetOption({ key, value, index, timeStart = performance.now(), editorsCopy = [...editors], cmStart = getLastActivated(), progress, }) { if (index === 0) { if (!cmStart) { return; } cmStart.setOption(key, value); } const THROTTLE_AFTER_MS = 100; const THROTTLE_SHOW_PROGRESS_AFTER_MS = 100; const t0 = performance.now(); const total = editorsCopy.length; while (index < total) { const cm = editorsCopy[index++]; if (cm === cmStart || !editors.has(cm)) { continue; } cm.setOption(key, value); if (performance.now() - t0 > THROTTLE_AFTER_MS) { break; } } if (index >= total) { $.remove(progress); return; } if (!progress && index < total / 2 && t0 - timeStart > THROTTLE_SHOW_PROGRESS_AFTER_MS) { let option = $('#editor.' + key); if (option) { if (option.type === 'checkbox') { option = (option.labels || [])[0] || option.nextElementSibling || option; } progress = document.body.appendChild( $create('.set-option-progress', {targetElement: option})); } } if (progress) { const optionBounds = progress.targetElement.getBoundingClientRect(); const bounds = { top: optionBounds.top + window.scrollY + 1, left: optionBounds.left + window.scrollX + 1, width: (optionBounds.width - 2) * index / total | 0, height: optionBounds.height - 2, }; const style = progress.style; for (const prop in bounds) { if (bounds[prop] !== parseFloat(style[prop])) { style[prop] = bounds[prop] + 'px'; } } } setTimeout(throttleSetOption, 0, { key, value, index, timeStart, cmStart, editorsCopy, progress, }); } })();