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