improve bookmarking + rework codemirror-factory.js

* pull editing-only stuff from codemirror-default.js
* switch throttledSetOption to IntersectionObserver
This commit is contained in:
tophf 2020-11-21 22:16:15 +03:00
parent b4ca17c531
commit 657798d219
7 changed files with 420 additions and 469 deletions

View File

@ -1,3 +1,5 @@
/* Built-in CodeMirror and addon customization */
.CodeMirror-hints { .CodeMirror-hints {
z-index: 999; z-index: 999;
} }
@ -20,12 +22,6 @@
.CodeMirror-dialog { .CodeMirror-dialog {
animation: highlight 3s cubic-bezier(.18, .02, 0, .94); animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
} }
.CodeMirror-bookmark {
background: linear-gradient(to right, currentColor, transparent);
position: absolute;
width: 2em;
opacity: .5;
}
.CodeMirror-search-field { .CodeMirror-search-field {
width: 10em; width: 10em;
} }
@ -35,10 +31,6 @@
.CodeMirror-search-hint { .CodeMirror-search-hint {
color: #888; color: #888;
} }
.cm-uso-variable {
font-weight: bold;
}
.CodeMirror-activeline .applies-to:before { .CodeMirror-activeline .applies-to:before {
background-color: hsla(214, 100%, 90%, 0.15); background-color: hsla(214, 100%, 90%, 0.15);
content: ""; content: "";
@ -49,11 +41,9 @@
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
} }
.CodeMirror-activeline .applies-to ul { .CodeMirror-activeline .applies-to ul {
z-index: 2; z-index: 2;
} }
.CodeMirror-foldgutter-open::after, .CodeMirror-foldgutter-open::after,
.CodeMirror-foldgutter-folded::after { .CodeMirror-foldgutter-folded::after {
top: 5px; top: 5px;
@ -65,15 +55,25 @@
opacity: .5; opacity: .5;
left: 1px; left: 1px;
} }
.CodeMirror-foldgutter-open::after { .CodeMirror-foldgutter-open::after {
border-width: 5px 3px 0 3px; border-width: 5px 3px 0 3px;
border-color: currentColor transparent transparent transparent; border-color: currentColor transparent transparent transparent;
} }
.CodeMirror-foldgutter-folded::after { .CodeMirror-foldgutter-folded::after {
margin-top: -2px; margin-top: -2px;
margin-left: 1px; margin-left: 1px;
border-width: 4px 0 4px 5px; border-width: 4px 0 4px 5px;
border-color: transparent transparent transparent currentColor; border-color: transparent transparent transparent currentColor;
} }
.CodeMirror-linenumber {
cursor: pointer; /* for bookmarking */
}
/* Custom stuff we add to CodeMirror */
.cm-uso-variable {
font-weight: bold;
}
.gutter-bookmark {
background: linear-gradient(0deg, hsla(180, 100%, 30%, .75) 2px, hsla(180, 100%, 30%, .2) 2px);
}

View File

@ -1,7 +1,6 @@
/* global /* global
$ $
CodeMirror CodeMirror
editor
prefs prefs
t t
*/ */
@ -14,8 +13,6 @@
prefs.reset('editor.keyMap'); prefs.reset('editor.keyMap');
} }
const CM_BOOKMARK = 'CodeMirror-bookmark';
const CM_BOOKMARK_GUTTER = CM_BOOKMARK + 'gutter';
const defaults = { const defaults = {
autoCloseBrackets: prefs.get('editor.autoCloseBrackets'), autoCloseBrackets: prefs.get('editor.autoCloseBrackets'),
mode: 'css', mode: 'css',
@ -23,7 +20,6 @@
lineWrapping: prefs.get('editor.lineWrapping'), lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true, foldGutter: true,
gutters: [ gutters: [
CM_BOOKMARK_GUTTER,
'CodeMirror-linenumbers', 'CodeMirror-linenumbers',
'CodeMirror-foldgutter', 'CodeMirror-foldgutter',
...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []), ...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []),
@ -35,7 +31,7 @@
theme: prefs.get('editor.theme'), theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'), keyMap: prefs.get('editor.keyMap'),
extraKeys: Object.assign(CodeMirror.defaults.extraKeys || {}, { extraKeys: Object.assign(CodeMirror.defaults.extraKeys || {}, {
// independent of current keyMap // independent of current keyMap; some are implemented only for the edit page
'Alt-Enter': 'toggleStyle', 'Alt-Enter': 'toggleStyle',
'Alt-PageDown': 'nextEditor', 'Alt-PageDown': 'nextEditor',
'Alt-PageUp': 'prevEditor', 'Alt-PageUp': 'prevEditor',
@ -46,68 +42,53 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options')); Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// 'basic' keymap only has basic keys by design, so we skip it // Adding hotkeys to some keymaps except 'basic' which is primitive by design
const KM = CodeMirror.keyMap;
const extraKeysCommands = {}; const extras = Object.values(CodeMirror.defaults.extraKeys);
Object.keys(CodeMirror.defaults.extraKeys).forEach(key => { if (!extras.includes('jumpToLine')) {
extraKeysCommands[CodeMirror.defaults.extraKeys[key]] = true; KM.sublime['Ctrl-G'] = 'jumpToLine';
}); KM.emacsy['Ctrl-G'] = 'jumpToLine';
if (!extraKeysCommands.jumpToLine) { KM.pcDefault['Ctrl-J'] = 'jumpToLine';
CodeMirror.keyMap.sublime['Ctrl-G'] = 'jumpToLine'; KM.macDefault['Cmd-J'] = 'jumpToLine';
CodeMirror.keyMap.emacsy['Ctrl-G'] = 'jumpToLine';
CodeMirror.keyMap.pcDefault['Ctrl-J'] = 'jumpToLine';
CodeMirror.keyMap.macDefault['Cmd-J'] = 'jumpToLine';
} }
if (!extraKeysCommands.autocomplete) { if (!extras.includes('autocomplete')) {
// will be used by 'sublime' on PC via fallthrough // will be used by 'sublime' on PC via fallthrough
CodeMirror.keyMap.pcDefault['Ctrl-Space'] = 'autocomplete'; KM.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else // OSX uses Ctrl-Space and Cmd-Space for something else
CodeMirror.keyMap.macDefault['Alt-Space'] = 'autocomplete'; KM.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap // copied from 'emacs' keymap
CodeMirror.keyMap.emacsy['Alt-/'] = 'autocomplete'; KM.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys // 'vim' and 'emacs' define their own autocomplete hotkeys
} }
if (!extraKeysCommands.blockComment) { if (!extras.includes('blockComment')) {
CodeMirror.keyMap.sublime['Shift-Ctrl-/'] = 'commentSelection'; KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
} }
if (navigator.appVersion.includes('Windows')) { if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R // 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extraKeysCommands.findNext) { if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
CodeMirror.keyMap.pcDefault['F3'] = 'findNext'; if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
} if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
if (!extraKeysCommands.findPrev) { // try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
CodeMirror.keyMap.pcDefault['Shift-F3'] = 'findPrev';
}
if (!extraKeysCommands.replace) {
CodeMirror.keyMap.pcDefault['Ctrl-R'] = 'replace';
}
// try to remap non-interceptable Ctrl-(Shift-)N/T/W hotkeys
['N', 'T', 'W'].forEach(char => {
[
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
// Note: modifier order in CodeMirror is S-C-A // Note: modifier order in CodeMirror is S-C-A
for (const char of ['N', 'T', 'W']) {
for (const remap of [
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}, {from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
].forEach(remap => { ]) {
const oldKey = remap.from + char; const oldKey = remap.from + char;
Object.keys(CodeMirror.keyMap).forEach(keyMapName => { for (const km of Object.values(KM)) {
const keyMap = CodeMirror.keyMap[keyMapName]; const command = km[oldKey];
const command = keyMap[oldKey]; if (!command) continue;
if (!command) { for (const newMod of remap.to) {
return;
}
remap.to.some(newMod => {
const newKey = newMod + char; const newKey = newMod + char;
if (!(newKey in keyMap)) { if (newKey in km) continue;
delete keyMap[oldKey]; km[newKey] = command;
keyMap[newKey] = command; delete km[oldKey];
return true; break;
}
}
}
} }
});
});
});
});
} }
Object.assign(CodeMirror.mimeModes['text/css'].propertyKeywords, { Object.assign(CodeMirror.mimeModes['text/css'].propertyKeywords, {
@ -123,6 +104,7 @@
'lightslategrey': true, 'lightslategrey': true,
'slategrey': true, 'slategrey': true,
}); });
Object.assign(CodeMirror.prototype, { Object.assign(CodeMirror.prototype, {
/** /**
* @param {'less' | 'stylus' | ?} [pp] - any value besides `less` or `stylus` sets `css` mode * @param {'less' | 'stylus' | ?} [pp] - any value besides `less` or `stylus` sets `css` mode
@ -141,204 +123,30 @@
this.eachLine(({text}) => (filled = text && /\S/.test(text))); this.eachLine(({text}) => (filled = text && /\S/.test(text)));
return !filled; return !filled;
}, },
}); /**
* Sets cursor and centers it in view if `pos` was out of view
// editor commands * @param {CodeMirror.Pos} pos
for (const name of ['save', 'toggleStyle', 'nextEditor', 'prevEditor']) { */
CodeMirror.commands[name] = (...args) => editor[name](...args); jumpToPos(pos) {
const coords = this.cursorCoords(pos, 'page');
const b = this.display.wrapper.getBoundingClientRect();
if (coords.top < b.top + this.defaultTextHeight() * 2 ||
coords.bottom > b.bottom - 100) {
this.scrollIntoView(pos, b.height / 2);
} }
this.setCursor(pos, null, {scroll: false});
const elBookmark = document.createElement('div');
elBookmark.className = CM_BOOKMARK;
elBookmark.textContent = '\u00A0';
const clearMarker = function () {
const line = this.lines[0];
delete this.clear; // removing our patch from the instance...
this.clear(); // ...and using the original prototype
if (!(line.markedSpans || []).some(span => span.marker.sublimeBookmark)) {
this.doc.setGutterMarker(line, CM_BOOKMARK_GUTTER, null);
}
};
const {markText} = CodeMirror.prototype;
Object.assign(CodeMirror.prototype, {
markText() {
const marker = markText.apply(this, arguments);
if (marker.sublimeBookmark) {
this.doc.setGutterMarker(marker.lines[0], CM_BOOKMARK_GUTTER, elBookmark.cloneNode(true));
marker.clear = clearMarker;
}
return marker;
}, },
}); });
// CodeMirror convenience commands
Object.assign(CodeMirror.commands, { Object.assign(CodeMirror.commands, {
toggleEditorFocus, jumpToLine(cm) {
jumpToLine,
commentSelection,
});
function jumpToLine(cm) {
const cur = cm.getCursor(); const cur = cm.getCursor();
const oldDialog = $('.CodeMirror-dialog', cm.display.wrapper); const oldDialog = $('.CodeMirror-dialog', cm.display.wrapper);
if (oldDialog) { if (oldDialog) cm.focus(); // close the currently opened minidialog
// close the currently opened minidialog
cm.focus();
}
// make sure to focus the input in newly opened minidialog
// setTimeout(() => {
// $('.CodeMirror-dialog', section).focus();
// });
cm.openDialog(t.template.jumpToLine.cloneNode(true), str => { cm.openDialog(t.template.jumpToLine.cloneNode(true), str => {
const m = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$/); const [line, ch] = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$|$/);
if (m) { if (line) cm.setCursor(line - 1, ch ? ch - 1 : cur.ch);
cm.setCursor(m[1] - 1, m[2] ? m[2] - 1 : cur.ch);
}
}, {value: cur.line + 1}); }, {value: cur.line + 1});
} },
});
function commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
}
function toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
setTimeout(() => cm.display.input.blur());
} else {
cm.focus();
}
}
})();
// eslint-disable-next-line no-unused-expressions
CodeMirror.hint && (() => {
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const RX_IMPORTANT = /(i(m(p(o(r(t(a(nt?)?)?)?)?)?)?)?)?(?=\b|\W|$)/iy;
const RX_VAR_KEYWORD = /(^|[^-\w\u0080-\uFFFF])var\(/iy;
const RX_END_OF_VAR = /[\s,)]|$/g;
const RX_CONSUME_PROP = /[-\w]*\s*:\s?|$/y;
const originalHelper = CodeMirror.hint.css || (() => {});
const helper = cm => {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
if (!styles) return originalHelper(cm);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
if (style && (style.startsWith('comment') || style.startsWith('string'))) {
return originalHelper(cm);
}
// !important
if (text[ch - 1] === '!' && /i|\W|^$/i.test(text[ch] || '')) {
RX_IMPORTANT.lastIndex = ch;
return {
list: ['important'],
from: pos,
to: {line, ch: ch + RX_IMPORTANT.exec(text)[0].length},
};
}
let prev = index > 2 ? styles[index - 2] : 0;
let end = styles[index];
// #hex colors
if (text[prev] === '#') {
return {list: [], from: pos, to: pos};
}
// adjust cursor position for /*[[ and ]]*/
const adjust = text[prev] === '/' ? 4 : 0;
prev += adjust;
end -= adjust;
const leftPart = text.slice(prev, ch);
// --css-variables
const startsWithDoubleDash = text[prev] === '-' && text[prev + 1] === '-';
if (startsWithDoubleDash ||
leftPart === '(' && testAt(RX_VAR_KEYWORD, Math.max(0, prev - 4), text)) {
// simplified regex without CSS escapes
const RX_CSS_VAR = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'gm');
const cursor = cm.getSearchCursor(RX_CSS_VAR, null, {caseFold: false, multiline: false});
const list = new Set();
while (cursor.findNext()) {
list.add(cursor.pos.match[1]);
}
if (!startsWithDoubleDash) {
prev++;
}
RX_END_OF_VAR.lastIndex = prev;
end = RX_END_OF_VAR.exec(text).index;
return {
list: [...list.keys()].sort(),
from: {line, ch: prev},
to: {line, ch: end},
};
}
if (!editor || !style || !style.includes(USO_VAR)) {
// add ":" after a property name
const res = originalHelper(cm);
const state = res && cm.getTokenAt(pos).state.state;
if (state === 'block' || state === 'maybeprop') {
res.list = res.list.map(str => str + ': ');
RX_CONSUME_PROP.lastIndex = res.to.ch;
res.to.ch += RX_CONSUME_PROP.exec(text)[0].length;
}
return res;
}
// USO vars in usercss mode editor
const vars = editor.style.usercssData.vars;
const list = vars ?
Object.keys(vars).filter(name => name.startsWith(leftPart)) : [];
return {
list,
from: {line, ch: prev},
to: {line, ch: end},
};
};
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
const hooks = CodeMirror.mimeModes['text/css'].tokenHooks;
const originalCommentHook = hooks['/'];
hooks['/'] = tokenizeUsoVariables;
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] !== 'comment') {
return token;
}
const {string, start, pos} = stream;
// /*[[install-key]]*/
// 01234 43210
if (string[start + 2] === '[' &&
string[start + 3] === '[' &&
string[pos - 3] === ']' &&
string[pos - 4] === ']') {
const vars = typeof editor !== 'undefined' && (editor.style.usercssData || {}).vars;
const name = vars && string.slice(start + 4, pos - 4);
if (vars && Object.hasOwnProperty.call(vars, name.endsWith('-rgb') ? name.slice(0, -4) : name)) {
token[0] = USO_VALID_VAR;
} else {
token[0] = USO_INVALID_VAR;
}
}
return token;
}
function testAt(rx, index, text) {
if (!rx) return false;
rx.lastIndex = index;
return rx.test(text);
}
})(); })();

View File

@ -1,87 +1,190 @@
/* global CodeMirror loadScript rerouteHotkeys prefs $ debounce $create */ /* global
/* exported cmFactory */ $
CodeMirror
debounce
editor
loadScript
prefs
rerouteHotkeys
*/
'use strict'; 'use strict';
//#region cmFactory
(() => {
/* /*
All cm instances created by this module are collected so we can broadcast prefs 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 settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
when the instance is not used anymore. when the instance is not used anymore.
*/ */
const cmFactory = (() => { const cms = new Set();
const editors = new Set(); let lazyOpt;
// 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) => { const cmFactory = window.cmFactory = {
create(place, options) {
const cm = CodeMirror(place, options);
const {wrapper} = cm.display;
cm.lastActive = 0;
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();
});
cms.add(cm);
return cm;
},
destroy(cm) {
cms.delete(cm);
},
globalSetOption(key, value) {
CodeMirror.defaults[key] = value;
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
lazyOpt.set(key, value);
} else {
cms.forEach(cm => cm.setOption(key, value));
}
},
};
const handledPrefs = {
// handled in colorpicker-helper.js
'editor.colorpicker'() {},
/** @returns {?Promise<void>} */
'editor.theme'(key, value) {
const elt = $('#cm-theme');
if (value === 'default') {
elt.href = '';
} else {
const url = chrome.runtime.getURL(`vendor/codemirror/theme/${value}.css`);
if (url !== elt.href) {
// avoid flicker: wait for the second stylesheet to load, then apply the theme
return loadScript(url, true).then(([newElt]) => {
cmFactory.globalSetOption('theme', value);
elt.remove();
newElt.id = elt.id;
});
}
}
},
};
const pref2opt = k => k.slice('editor.'.length);
const mirroredPrefs = Object.keys(prefs.defaults).filter(k =>
!handledPrefs[k] &&
k.startsWith('editor.') &&
Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
prefs.subscribe(mirroredPrefs, (k, val) => cmFactory.globalSetOption(pref2opt(k), val));
prefs.subscribeMany(handledPrefs);
lazyOpt = window.IntersectionObserver && {
names: ['theme', 'lineWrapping'],
set(key, value) {
const {observer, queue} = lazyOpt;
for (const cm of cms) {
let opts = queue.get(cm);
if (!opts) queue.set(cm, opts = {});
opts[key] = value;
observer.observe(cm.display.wrapper);
}
},
setNow({cm, data}) {
cm.operation(() => data.forEach(kv => cm.setOption(...kv)));
},
onView(entries) {
const {queue, observer} = lazyOpt;
const delayed = [];
for (const e of entries) {
const r = e.isIntersecting && e.intersectionRect;
if (!r) continue;
const cm = e.target.CodeMirror;
const data = Object.entries(queue.get(cm) || {});
queue.delete(cm);
observer.unobserve(e.target);
if (!data.every(([key, val]) => cm.getOption(key) === val)) {
if (r.bottom > 0 && r.top < window.innerHeight) {
lazyOpt.setNow({cm, data});
} else {
delayed.push({cm, data});
}
}
}
if (delayed.length) {
setTimeout(() => delayed.forEach(lazyOpt.setNow));
}
},
get observer() {
if (!lazyOpt._observer) {
// must exceed refreshOnView's 100%
lazyOpt._observer = new IntersectionObserver(lazyOpt.onView, {rootMargin: '150%'});
lazyOpt.queue = new WeakMap();
}
return lazyOpt._observer;
},
};
})();
//#endregion
//#region Commands
(() => {
Object.assign(CodeMirror.commands, {
toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
setTimeout(() => cm.display.input.blur());
} else {
cm.focus();
}
},
commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
},
});
for (const cmd of [
'nextEditor',
'prevEditor',
'save',
'toggleStyle',
]) {
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
}
})();
//#endregion
//#region CM option handlers
(() => {
const {insertTab, insertSoftTab} = CodeMirror.commands;
Object.entries({
tabSize(cm, value) {
cm.setOption('indentUnit', Number(value)); cm.setOption('indentUnit', Number(value));
}); },
indentWithTabs(cm, value) {
CodeMirror.defineOption('indentWithTabs', prefs.get('editor.indentWithTabs'), (cm, value) => { CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
CodeMirror.commands.insertTab = value ? },
INSERT_TAB_COMMAND : autocompleteOnTyping(cm, value) {
INSERT_SOFT_TAB_COMMAND;
});
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'), (cm, value) => {
const onOff = value ? 'on' : 'off'; const onOff = value ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping); cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked); cm[onOff]('pick', autocompletePicked);
}); },
matchHighlight(cm, value) {
CodeMirror.defineOption('matchHighlight', prefs.get('editor.matchHighlight'), (cm, value) => { const showToken = value === 'token' && /[#.\-\w]/;
if (value === 'token') { const opt = (showToken || value === 'selection') && {
cm.setOption('highlightSelectionMatches', { showToken,
showToken: /[#.\-\w]/,
annotateScrollbar: true, annotateScrollbar: true,
onUpdate: updateMatchHighlightCount, onUpdate: updateMatchHighlightCount,
}); };
} else if (value === 'selection') { cm.setOption('highlightSelectionMatches', opt || null);
cm.setOption('highlightSelectionMatches', { },
showToken: false, selectByTokens(cm, value) {
annotateScrollbar: true,
onUpdate: updateMatchHighlightCount,
});
} else {
cm.setOption('highlightSelectionMatches', null);
}
});
CodeMirror.defineOption('selectByTokens', prefs.get('editor.selectByTokens'), (cm, value) => {
cm.setOption('configureMouse', value ? configureMouseFn : null); cm.setOption('configureMouse', value ? configureMouseFn : null);
},
}).forEach(([name, fn]) => {
CodeMirror.defineOption(name, prefs.get('editor.' + name), fn);
}); });
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 updateMatchHighlightCount(cm, state) { function updateMatchHighlightCount(cm, state) {
cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length; cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length;
} }
@ -173,121 +276,181 @@ const cmFactory = (() => {
function autocompletePicked(cm) { function autocompletePicked(cm) {
cm.state.autocompletePicked = true; cm.state.autocompletePicked = true;
} }
})();
//#endregion
function destroy(cm) { //#region Autocomplete
editors.delete(cm); (() => {
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const RX_IMPORTANT = /(i(m(p(o(r(t(a(nt?)?)?)?)?)?)?)?)?(?=\b|\W|$)/iy;
const RX_VAR_KEYWORD = /(^|[^-\w\u0080-\uFFFF])var\(/iy;
const RX_END_OF_VAR = /[\s,)]|$/g;
const RX_CONSUME_PROP = /[-\w]*\s*:\s?|$/y;
const originalHelper = CodeMirror.hint.css || (() => {});
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
const hooks = CodeMirror.mimeModes['text/css'].tokenHooks;
const originalCommentHook = hooks['/'];
hooks['/'] = tokenizeUsoVariables;
function helper(cm) {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
if (!styles) {
return originalHelper(cm);
}
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
if (/^(comment|string)/.test(style)) {
return originalHelper(cm);
}
// !important
if (text[ch - 1] === '!' && testAt(/i|\W|$/iy, ch, text)) {
return {
list: ['important'],
from: pos,
to: {line, ch: ch + execAt(RX_IMPORTANT, ch, text)[0].length},
};
}
let prev = index > 2 ? styles[index - 2] : 0;
let end = styles[index];
// #hex colors
if (text[prev] === '#') {
return {list: [], from: pos, to: pos};
}
// adjust cursor position for /*[[ and ]]*/
const adjust = text[prev] === '/' ? 4 : 0;
prev += adjust;
end -= adjust;
// --css-variables
const leftPart = text.slice(prev, ch);
const startsWithDoubleDash = testAt(/--/y, prev, text);
if (startsWithDoubleDash ||
leftPart === '(' && testAt(RX_VAR_KEYWORD, Math.max(0, prev - 4), text)) {
return {
list: findAllCssVars(cm, leftPart),
from: {line, ch: prev + !startsWithDoubleDash},
to: {line, ch: execAt(RX_END_OF_VAR, prev, text).index},
};
}
if (!editor || !style || !style.includes(USO_VAR)) {
const res = originalHelper(cm);
// add ":" after a property name
const state = res && cm.getTokenAt(pos).state.state;
if (state === 'block' || state === 'maybeprop') {
res.list = res.list.map(str => str + ': ');
res.to.ch += execAt(RX_CONSUME_PROP, res.to.ch, text)[0].length;
}
return res;
}
// USO vars in usercss mode editor
const vars = editor.style.usercssData.vars;
return {
list: vars ? Object.keys(vars).filter(v => v.startsWith(leftPart)) : [],
from: {line, ch: prev},
to: {line, ch: end},
};
} }
function create(init, options) { function findAllCssVars(cm, leftPart) {
const cm = CodeMirror(init, options); // simplified regex without CSS escapes
cm.lastActive = 0; const RX_CSS_VAR = new RegExp(
const wrapper = cm.display.wrapper; '(?:^|[\\s/;{])(' +
cm.on('blur', () => { (leftPart.startsWith('--') ? leftPart : '--') +
rerouteHotkeys(true); (leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
setTimeout(() => { '[-0-9a-zA-Z_\u0080-\uFFFF]*)',
wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement)); 'g');
const list = new Set();
cm.eachLine(({text}) => {
for (let m; (m = RX_CSS_VAR.exec(text));) {
list.add(m[1]);
}
}); });
return [...list].sort();
}
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] === 'comment') {
const {string, start, pos} = stream;
if (testAt(/\/\*\[\[/y, start, string) &&
testAt(/]]\*\//y, pos - 4, string)) {
const vars = (editor.style.usercssData || {}).vars;
token[0] =
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
? USO_VALID_VAR
: USO_INVALID_VAR;
}
}
return token;
}
function execAt(rx, index, text) {
rx.lastIndex = index;
return rx.exec(text);
}
function testAt(rx, index, text) {
rx.lastIndex = index;
return rx.test(text);
}
})();
//#endregion
//#region Bookmarks
(() => {
const CLS = 'gutter-bookmark';
const BRAND = 'sublimeBookmark';
const CLICK_AREA = 'CodeMirror-linenumbers';
const {markText} = CodeMirror.prototype;
CodeMirror.defineInitHook(cm => {
cm.on('gutterClick', onGutterClick);
cm.on('gutterContextMenu', onGutterContextMenu);
}); });
cm.on('focus', () => { // TODO: reimplement bookmarking so next/prev order is decided solely by the line numbers
rerouteHotkeys(false); Object.assign(CodeMirror.prototype, {
wrapper.classList.add('CodeMirror-active'); markText() {
cm.lastActive = Date.now(); const marker = markText.apply(this, arguments);
if (marker[BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', CLS);
marker.clear = clearMarker;
}
return marker;
},
}); });
editors.add(cm); function clearMarker() {
return cm; const line = this.lines[0];
} const spans = line.markedSpans;
delete this.clear; // removing our patch from the instance...
function getLastActivated() { this.clear(); // ...and using the original prototype
let result; if (!spans || spans.some(span => span.marker[BRAND])) {
for (const cm of editors) { this.doc.removeLineClass(line, 'gutter', CLS);
if (!result || result.lastActive < cm.lastActive) {
result = cm;
} }
} }
return result; function onGutterClick(cm, line, name, e) {
switch (name === CLICK_AREA && e.button) {
case 0: {
// main button: toggle
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BRAND]);
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
cm.execCommand('toggleBookmark');
break;
} }
case 1:
function setOption(key, value) { // middle button: select all marks
CodeMirror.defaults[key] = value; cm.execCommand('selectBookmarks');
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; break;
} }
} }
if (index >= total) { function onGutterContextMenu(cm, line, name, e) {
$.remove(progress); if (name === CLICK_AREA) {
return; cm.setSelection = cm.jumpToPos;
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
delete cm.setSelection;
e.preventDefault();
} }
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,
});
} }
})(); })();
//#endregion

View File

@ -5,9 +5,8 @@
onDOMready().then(() => { onDOMready().then(() => {
$('#colorpicker-settings').onclick = configureColorpicker; $('#colorpicker-settings').onclick = configureColorpicker;
}); });
prefs.subscribe(['editor.colorpicker.hotkey'], registerHotkey); prefs.subscribe('editor.colorpicker.hotkey', registerHotkey);
prefs.subscribe(['editor.colorpicker'], setColorpickerOption); prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true});
setColorpickerOption(null, prefs.get('editor.colorpicker'));
function setColorpickerOption(id, enabled) { function setColorpickerOption(id, enabled) {
const defaults = CodeMirror.defaults; const defaults = CodeMirror.defaults;
@ -44,7 +43,7 @@
delete defaults.extraKeys[keyName]; delete defaults.extraKeys[keyName];
} }
} }
cmFactory.setOption('colorpicker', defaults.colorpicker); cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
} }
function registerHotkey(id, hotkey) { function registerHotkey(id, hotkey) {

View File

@ -290,12 +290,6 @@ input:invalid {
padding: .1rem .25rem 0 0; padding: .1rem .25rem 0 0;
vertical-align: middle; vertical-align: middle;
} }
.set-option-progress {
position: absolute;
background-color: currentColor;
content: "";
opacity: .15;
}
/* footer */ /* footer */
.usercss #footer { .usercss #footer {
display: block; display: block;

View File

@ -55,7 +55,7 @@ function SourceEditor() {
const sec = sectionFinder.sections[i]; const sec = sectionFinder.sections[i];
if (sec) { if (sec) {
sectionFinder.updatePositions(sec); sectionFinder.updatePositions(sec);
jumpToPos(sec.start); cm.jumpToPos(sec.start);
} }
}, },
closestVisible: () => cm, closestVisible: () => cm,
@ -308,17 +308,7 @@ function SourceEditor() {
if (i < 0 && (!dir || CodeMirror.cmpPos(sections[num - 1].start, pos) < 0)) { if (i < 0 && (!dir || CodeMirror.cmpPos(sections[num - 1].start, pos) < 0)) {
i = 0; i = 0;
} }
jumpToPos(sections[(i + dir + num) % num].start); cm.jumpToPos(sections[(i + dir + num) % num].start);
}
function jumpToPos(pos) {
const coords = cm.cursorCoords(pos, 'page');
const b = cm.display.wrapper.getBoundingClientRect();
if (coords.top < b.top + cm.defaultTextHeight() * 2 ||
coords.bottom > b.bottom - 100) {
cm.scrollIntoView(pos, b.height / 2);
}
cm.setCursor(pos, null, {scroll: false});
} }
function headerOnScroll({target, deltaY, deltaMode, shiftKey}) { function headerOnScroll({target, deltaY, deltaMode, shiftKey}) {

View File

@ -610,10 +610,7 @@
const lines = el.title.split('\n')[1].match(/\d+/g).map(Number); const lines = el.title.split('\n')[1].match(/\d+/g).map(Number);
const i = lines.indexOf(cm.getCursor().line + 1) + 1; const i = lines.indexOf(cm.getCursor().line + 1) + 1;
const line = (lines[i] || lines[0]) - 1; const line = (lines[i] || lines[0]) - 1;
const vpm = cm.options.viewportMargin; cm.jumpToPos({line, ch: 0});
const inView = line >= cm.display.viewFrom - vpm && line <= cm.display.viewTo - vpm;
cm.scrollIntoView(line, inView ? cm.defaultTextHeight() : cm.display.wrapper.clientHeight / 2);
cm.setCursor(line);
} }
//endregion //endregion