/* global CodeMirror loadScript rerouteHotkeys */ '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; prefs.subscribe(null, onPrefChanged); return {create, destroy, setOption}; function onPrefChanged(key, value) { let option = key.replace(/^editor\./, ''); if (!option) { console.error('no "cm_option"', key); return; } switch (option) { case 'tabSize': value = Number(value); setOption('indentUnit', value); break; case 'indentWithTabs': CodeMirror.commands.insertTab = value ? INSERT_TAB_COMMAND : INSERT_SOFT_TAB_COMMAND; break; case 'theme': { const themeLink = $('#cm-theme'); // use non-localized 'default' internally if (!value || value === 'default' || value === t('defaultTheme')) { value = 'default'; if (prefs.get(key) !== value) { prefs.set(key, value); } themeLink.href = ''; $('#editor.theme').value = value; break; } const url = chrome.runtime.getURL('vendor/codemirror/theme/' + value + '.css'); if (themeLink.href === url) { // preloaded in initCodeMirror() break; } // avoid flicker: wait for the second stylesheet to load, then apply the theme document.head.appendChild($create('link#cm-theme2', {rel: 'stylesheet', href: url})); setTimeout(() => { setOption(option, value); themeLink.remove(); $('#cm-theme2').id = 'cm-theme'; }, 100); return; } case 'autocompleteOnTyping': for (const cm of editors) { setupAutocomplete(cm, value); } return; case 'autoCloseBrackets': Promise.resolve(value && loadScript('/vendor/codemirror/addon/edit/closebrackets.js')).then(() => { setOption(option, value); }); return; case 'matchHighlight': switch (value) { case 'token': case 'selection': document.body.dataset[option] = value; value = {showToken: value === 'token' && /[#.\-\w]/, annotateScrollbar: true}; break; default: value = null; } option = 'highlightSelectionMatches'; break; case 'colorpicker': // FIXME: this is implemented in `colorpicker-helper.js`. return; case 'selectByTokens': option = 'configureMouse'; value = value ? configureMouseFn : null; break; } setOption(option, value); } 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 setupAutocomplete(cm, enable = true) { const onOff = enable ? 'on' : 'off'; cm[onOff]('changes', autocompleteOnTyping); cm[onOff]('pick', autocompletePicked); } function autocompleteOnTyping(cm, [info], debounced) { if ( cm.state.completionActive || info.origin && !info.origin.includes('input') || !info.text.last ) { return; } if (cm.state.autocompletePicked) { cm.state.autocompletePicked = false; return; } if (!debounced) { debounce(autocompleteOnTyping, 100, cm, [info], true); return; } if (info.text.last.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); if (prefs.get('editor.autocompleteOnTyping')) { setupAutocomplete(cm); } 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 editor of editors) { editor.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, }); } })();