312 lines
8.5 KiB
JavaScript
312 lines
8.5 KiB
JavaScript
/* 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,
|
|
});
|
|
}
|
|
})();
|