diff --git a/edit/codemirror-default.css b/edit/codemirror-default.css index dbd9a72c..a1365d17 100644 --- a/edit/codemirror-default.css +++ b/edit/codemirror-default.css @@ -1,3 +1,5 @@ +/* Built-in CodeMirror and addon customization */ + .CodeMirror-hints { z-index: 999; } @@ -20,12 +22,6 @@ .CodeMirror-dialog { 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 { width: 10em; } @@ -35,10 +31,6 @@ .CodeMirror-search-hint { color: #888; } -.cm-uso-variable { - font-weight: bold; -} - .CodeMirror-activeline .applies-to:before { background-color: hsla(214, 100%, 90%, 0.15); content: ""; @@ -49,11 +41,9 @@ position: absolute; pointer-events: none; } - .CodeMirror-activeline .applies-to ul { z-index: 2; } - .CodeMirror-foldgutter-open::after, .CodeMirror-foldgutter-folded::after { top: 5px; @@ -65,15 +55,25 @@ opacity: .5; left: 1px; } - .CodeMirror-foldgutter-open::after { border-width: 5px 3px 0 3px; border-color: currentColor transparent transparent transparent; } - .CodeMirror-foldgutter-folded::after { margin-top: -2px; margin-left: 1px; border-width: 4px 0 4px 5px; 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); +} diff --git a/edit/codemirror-default.js b/edit/codemirror-default.js index a25efa73..846a772c 100644 --- a/edit/codemirror-default.js +++ b/edit/codemirror-default.js @@ -1,7 +1,6 @@ /* global $ CodeMirror - editor prefs t */ @@ -14,8 +13,6 @@ prefs.reset('editor.keyMap'); } - const CM_BOOKMARK = 'CodeMirror-bookmark'; - const CM_BOOKMARK_GUTTER = CM_BOOKMARK + 'gutter'; const defaults = { autoCloseBrackets: prefs.get('editor.autoCloseBrackets'), mode: 'css', @@ -23,7 +20,6 @@ lineWrapping: prefs.get('editor.lineWrapping'), foldGutter: true, gutters: [ - CM_BOOKMARK_GUTTER, 'CodeMirror-linenumbers', 'CodeMirror-foldgutter', ...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []), @@ -35,7 +31,7 @@ theme: prefs.get('editor.theme'), keyMap: prefs.get('editor.keyMap'), 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-PageDown': 'nextEditor', 'Alt-PageUp': 'prevEditor', @@ -46,68 +42,53 @@ Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options')); - // 'basic' keymap only has basic keys by design, so we skip it - - const extraKeysCommands = {}; - Object.keys(CodeMirror.defaults.extraKeys).forEach(key => { - extraKeysCommands[CodeMirror.defaults.extraKeys[key]] = true; - }); - if (!extraKeysCommands.jumpToLine) { - CodeMirror.keyMap.sublime['Ctrl-G'] = 'jumpToLine'; - CodeMirror.keyMap.emacsy['Ctrl-G'] = 'jumpToLine'; - CodeMirror.keyMap.pcDefault['Ctrl-J'] = 'jumpToLine'; - CodeMirror.keyMap.macDefault['Cmd-J'] = 'jumpToLine'; + // Adding hotkeys to some keymaps except 'basic' which is primitive by design + const KM = CodeMirror.keyMap; + const extras = Object.values(CodeMirror.defaults.extraKeys); + if (!extras.includes('jumpToLine')) { + KM.sublime['Ctrl-G'] = 'jumpToLine'; + KM.emacsy['Ctrl-G'] = 'jumpToLine'; + KM.pcDefault['Ctrl-J'] = 'jumpToLine'; + KM.macDefault['Cmd-J'] = 'jumpToLine'; } - if (!extraKeysCommands.autocomplete) { + if (!extras.includes('autocomplete')) { // 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 - CodeMirror.keyMap.macDefault['Alt-Space'] = 'autocomplete'; + KM.macDefault['Alt-Space'] = 'autocomplete'; // copied from 'emacs' keymap - CodeMirror.keyMap.emacsy['Alt-/'] = 'autocomplete'; + KM.emacsy['Alt-/'] = 'autocomplete'; // 'vim' and 'emacs' define their own autocomplete hotkeys } - if (!extraKeysCommands.blockComment) { - CodeMirror.keyMap.sublime['Shift-Ctrl-/'] = 'commentSelection'; + if (!extras.includes('blockComment')) { + KM.sublime['Shift-Ctrl-/'] = 'commentSelection'; } - if (navigator.appVersion.includes('Windows')) { // 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R - if (!extraKeysCommands.findNext) { - CodeMirror.keyMap.pcDefault['F3'] = 'findNext'; - } - if (!extraKeysCommands.findPrev) { - 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 => { - [ + if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext'; + if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev'; + if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace'; + // try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys + // 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-']}, - // Note: modifier order in CodeMirror is S-C-A {from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}, - ].forEach(remap => { + ]) { const oldKey = remap.from + char; - Object.keys(CodeMirror.keyMap).forEach(keyMapName => { - const keyMap = CodeMirror.keyMap[keyMapName]; - const command = keyMap[oldKey]; - if (!command) { - return; - } - remap.to.some(newMod => { + for (const km of Object.values(KM)) { + const command = km[oldKey]; + if (!command) continue; + for (const newMod of remap.to) { const newKey = newMod + char; - if (!(newKey in keyMap)) { - delete keyMap[oldKey]; - keyMap[newKey] = command; - return true; - } - }); - }); - }); - }); + if (newKey in km) continue; + km[newKey] = command; + delete km[oldKey]; + break; + } + } + } + } } Object.assign(CodeMirror.mimeModes['text/css'].propertyKeywords, { @@ -123,6 +104,7 @@ 'lightslategrey': true, 'slategrey': true, }); + Object.assign(CodeMirror.prototype, { /** * @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))); return !filled; }, - }); - - // editor commands - for (const name of ['save', 'toggleStyle', 'nextEditor', 'prevEditor']) { - CodeMirror.commands[name] = (...args) => editor[name](...args); - } - - 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; + /** + * Sets cursor and centers it in view if `pos` was out of view + * @param {CodeMirror.Pos} pos + */ + 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); } - return marker; + this.setCursor(pos, null, {scroll: false}); }, }); - // CodeMirror convenience commands Object.assign(CodeMirror.commands, { - toggleEditorFocus, - jumpToLine, - commentSelection, + jumpToLine(cm) { + const cur = cm.getCursor(); + const oldDialog = $('.CodeMirror-dialog', cm.display.wrapper); + if (oldDialog) cm.focus(); // close the currently opened minidialog + cm.openDialog(t.template.jumpToLine.cloneNode(true), str => { + const [line, ch] = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$|$/); + if (line) cm.setCursor(line - 1, ch ? ch - 1 : cur.ch); + }, {value: cur.line + 1}); + }, }); - - function jumpToLine(cm) { - const cur = cm.getCursor(); - const oldDialog = $('.CodeMirror-dialog', cm.display.wrapper); - if (oldDialog) { - // 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 => { - const m = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$/); - if (m) { - cm.setCursor(m[1] - 1, m[2] ? m[2] - 1 : cur.ch); - } - }, {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); - } })(); diff --git a/edit/codemirror-factory.js b/edit/codemirror-factory.js index 4d30ddf4..1639de47 100644 --- a/edit/codemirror-factory.js +++ b/edit/codemirror-factory.js @@ -1,86 +1,189 @@ -/* 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. +/* global + $ + CodeMirror + debounce + editor + loadScript + prefs + rerouteHotkeys */ -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; +'use strict'; - CodeMirror.defineOption('tabSize', prefs.get('editor.tabSize'), (cm, value) => { - cm.setOption('indentUnit', Number(value)); - }); +//#region cmFactory +(() => { + /* + 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 cms = new Set(); + let lazyOpt; - 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, - onUpdate: updateMatchHighlightCount, + 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)); + }); }); - } else if (value === 'selection') { - cm.setOption('highlightSelectionMatches', { - showToken: false, - annotateScrollbar: true, - onUpdate: updateMatchHighlightCount, + cm.on('focus', () => { + rerouteHotkeys(false); + wrapper.classList.add('CodeMirror-active'); + cm.lastActive = Date.now(); }); - } 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 = ''; + 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 { - const url = chrome.runtime.getURL('vendor/codemirror/theme/' + value + '.css'); - if (themeLink.href !== url) { + cms.forEach(cm => cm.setOption(key, value)); + } + }, + }; + + const handledPrefs = { + // handled in colorpicker-helper.js + 'editor.colorpicker'() {}, + /** @returns {?Promise} */ + '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(([newThemeLink]) => { - setOption(option, value); - themeLink.remove(); - newThemeLink.id = 'cm-theme'; + return loadScript(url, true).then(([newElt]) => { + cmFactory.globalSetOption('theme', value); + elt.remove(); + newElt.id = elt.id; }); } } - } - // broadcast option - setOption(option, value); + }, + }; + 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)); + }, + indentWithTabs(cm, value) { + CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab; + }, + autocompleteOnTyping(cm, value) { + const onOff = value ? 'on' : 'off'; + cm[onOff]('changes', autocompleteOnTyping); + cm[onOff]('pick', autocompletePicked); + }, + matchHighlight(cm, value) { + const showToken = value === 'token' && /[#.\-\w]/; + const opt = (showToken || value === 'selection') && { + showToken, + annotateScrollbar: true, + onUpdate: updateMatchHighlightCount, + }; + cm.setOption('highlightSelectionMatches', opt || null); + }, + selectByTokens(cm, value) { + cm.setOption('configureMouse', value ? configureMouseFn : null); + }, + }).forEach(([name, fn]) => { + CodeMirror.defineOption(name, prefs.get('editor.' + name), fn); }); - return {create, destroy, setOption}; function updateMatchHighlightCount(cm, state) { cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length; @@ -173,121 +276,181 @@ const cmFactory = (() => { function autocompletePicked(cm) { cm.state.autocompletePicked = true; } +})(); +//#endregion - function destroy(cm) { - editors.delete(cm); - } +//#region Autocomplete +(() => { + 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 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; - } + function helper(cm) { + const pos = cm.getCursor(); + const {line, ch} = pos; + const {styles, text} = cm.getLineHandle(line); + if (!styles) { + return originalHelper(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; + const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {}; + if (/^(comment|string)/.test(style)) { + return originalHelper(cm); } - 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, + // !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}, }; - const style = progress.style; - for (const prop in bounds) { - if (bounds[prop] !== parseFloat(style[prop])) { - style[prop] = bounds[prop] + 'px'; - } + } + 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 findAllCssVars(cm, leftPart) { + // 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]*)', + '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; } } - setTimeout(throttleSetOption, 0, { - key, - value, - index, - timeStart, - cmStart, - editorsCopy, - progress, - }); + 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); + }); + // TODO: reimplement bookmarking so next/prev order is decided solely by the line numbers + Object.assign(CodeMirror.prototype, { + markText() { + const marker = markText.apply(this, arguments); + if (marker[BRAND]) { + this.doc.addLineClass(marker.lines[0], 'gutter', CLS); + marker.clear = clearMarker; + } + return marker; + }, + }); + function clearMarker() { + const line = this.lines[0]; + const spans = line.markedSpans; + delete this.clear; // removing our patch from the instance... + this.clear(); // ...and using the original prototype + if (!spans || spans.some(span => span.marker[BRAND])) { + this.doc.removeLineClass(line, 'gutter', CLS); + } + } + 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: + // middle button: select all marks + cm.execCommand('selectBookmarks'); + break; + } + } + function onGutterContextMenu(cm, line, name, e) { + if (name === CLICK_AREA) { + cm.setSelection = cm.jumpToPos; + cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark'); + delete cm.setSelection; + e.preventDefault(); + } + } +})(); +//#endregion diff --git a/edit/colorpicker-helper.js b/edit/colorpicker-helper.js index f47a3dfe..48c3be64 100644 --- a/edit/colorpicker-helper.js +++ b/edit/colorpicker-helper.js @@ -5,9 +5,8 @@ onDOMready().then(() => { $('#colorpicker-settings').onclick = configureColorpicker; }); - prefs.subscribe(['editor.colorpicker.hotkey'], registerHotkey); - prefs.subscribe(['editor.colorpicker'], setColorpickerOption); - setColorpickerOption(null, prefs.get('editor.colorpicker')); + prefs.subscribe('editor.colorpicker.hotkey', registerHotkey); + prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true}); function setColorpickerOption(id, enabled) { const defaults = CodeMirror.defaults; @@ -44,7 +43,7 @@ delete defaults.extraKeys[keyName]; } } - cmFactory.setOption('colorpicker', defaults.colorpicker); + cmFactory.globalSetOption('colorpicker', defaults.colorpicker); } function registerHotkey(id, hotkey) { diff --git a/edit/edit.css b/edit/edit.css index c635b524..454b4279 100644 --- a/edit/edit.css +++ b/edit/edit.css @@ -290,12 +290,6 @@ input:invalid { padding: .1rem .25rem 0 0; vertical-align: middle; } -.set-option-progress { - position: absolute; - background-color: currentColor; - content: ""; - opacity: .15; -} /* footer */ .usercss #footer { display: block; diff --git a/edit/source-editor.js b/edit/source-editor.js index 48272e17..c2b1cd93 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -55,7 +55,7 @@ function SourceEditor() { const sec = sectionFinder.sections[i]; if (sec) { sectionFinder.updatePositions(sec); - jumpToPos(sec.start); + cm.jumpToPos(sec.start); } }, closestVisible: () => cm, @@ -308,17 +308,7 @@ function SourceEditor() { if (i < 0 && (!dir || CodeMirror.cmpPos(sections[num - 1].start, pos) < 0)) { i = 0; } - 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}); + cm.jumpToPos(sections[(i + dir + num) % num].start); } function headerOnScroll({target, deltaY, deltaMode, shiftKey}) { diff --git a/vendor-overwrites/colorpicker/colorview.js b/vendor-overwrites/colorpicker/colorview.js index 10ebfc8d..6add3efa 100644 --- a/vendor-overwrites/colorpicker/colorview.js +++ b/vendor-overwrites/colorpicker/colorview.js @@ -610,10 +610,7 @@ const lines = el.title.split('\n')[1].match(/\d+/g).map(Number); const i = lines.indexOf(cm.getCursor().line + 1) + 1; const line = (lines[i] || lines[0]) - 1; - const vpm = cm.options.viewportMargin; - 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); + cm.jumpToPos({line, ch: 0}); } //endregion