stylus/edit/codemirror-factory.js
2018-10-10 14:49:37 +08:00

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,
});
}
})();