codemirror-factory.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. /* global $ */// dom.js
  2. /* global CodeMirror */
  3. /* global editor */
  4. /* global prefs */
  5. /* global rerouteHotkeys */// util.js
  6. 'use strict';
  7. /*
  8. All cm instances created by this module are collected so we can broadcast prefs
  9. settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
  10. when the instance is not used anymore.
  11. */
  12. (() => {
  13. //#region Factory
  14. const cms = new Set();
  15. let lazyOpt;
  16. const cmFactory = window.cmFactory = {
  17. create(place, options) {
  18. const cm = CodeMirror(place, options);
  19. cm.lastActive = 0;
  20. cms.add(cm);
  21. return cm;
  22. },
  23. destroy(cm) {
  24. cms.delete(cm);
  25. },
  26. globalSetOption(key, value) {
  27. CodeMirror.defaults[key] = value;
  28. if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
  29. lazyOpt.set(key, value);
  30. } else {
  31. cms.forEach(cm => cm.setOption(key, value));
  32. }
  33. },
  34. };
  35. // focus and blur
  36. const onCmFocus = cm => {
  37. rerouteHotkeys.toggle(false);
  38. cm.display.wrapper.classList.add('CodeMirror-active');
  39. cm.lastActive = Date.now();
  40. };
  41. const onCmBlur = cm => {
  42. rerouteHotkeys.toggle(true);
  43. setTimeout(() => {
  44. const {wrapper} = cm.display;
  45. wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement));
  46. });
  47. };
  48. CodeMirror.defineInitHook(cm => {
  49. cm.on('focus', onCmFocus);
  50. cm.on('blur', onCmBlur);
  51. });
  52. // propagated preferences
  53. const prefToCmOpt = k =>
  54. k.startsWith('editor.') &&
  55. k.slice('editor.'.length);
  56. const prefKeys = prefs.knownKeys.filter(k =>
  57. k !== 'editor.colorpicker' && // handled in colorpicker-helper.js
  58. prefToCmOpt(k) in CodeMirror.defaults);
  59. const {insertTab, insertSoftTab} = CodeMirror.commands;
  60. for (const [key, fn] of Object.entries({
  61. 'editor.tabSize'(cm, value) {
  62. cm.setOption('indentUnit', Number(value));
  63. },
  64. 'editor.indentWithTabs'(cm, value) {
  65. CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
  66. },
  67. 'editor.matchHighlight'(cm, value) {
  68. const showToken = value === 'token' && /[#.\-\w]/;
  69. const opt = (showToken || value === 'selection') && {
  70. showToken,
  71. annotateScrollbar: true,
  72. onUpdate: updateMatchHighlightCount,
  73. };
  74. cm.setOption('highlightSelectionMatches', opt || null);
  75. },
  76. 'editor.selectByTokens'(cm, value) {
  77. cm.setOption('configureMouse', value ? configureMouseFn : null);
  78. },
  79. })) {
  80. CodeMirror.defineOption(prefToCmOpt(key), prefs.get(key), fn);
  81. prefKeys.push(key);
  82. }
  83. prefs.subscribe(prefKeys, (key, val) => {
  84. const name = prefToCmOpt(key);
  85. if (name === 'theme') {
  86. loadCmTheme(val);
  87. } else {
  88. cmFactory.globalSetOption(name, val);
  89. }
  90. });
  91. // lazy propagation
  92. lazyOpt = window.IntersectionObserver && {
  93. names: ['theme', 'lineWrapping'],
  94. set(key, value) {
  95. const {observer, queue} = lazyOpt;
  96. for (const cm of cms) {
  97. let opts = queue.get(cm);
  98. if (!opts) queue.set(cm, opts = {});
  99. opts[key] = value;
  100. observer.observe(cm.display.wrapper);
  101. }
  102. },
  103. setNow({cm, data}) {
  104. cm.operation(() => data.forEach(kv => cm.setOption(...kv)));
  105. },
  106. onView(entries) {
  107. const {queue, observer} = lazyOpt;
  108. const delayed = [];
  109. for (const e of entries) {
  110. const r = e.isIntersecting && e.intersectionRect;
  111. if (!r) continue;
  112. const cm = e.target.CodeMirror;
  113. const data = Object.entries(queue.get(cm) || {});
  114. queue.delete(cm);
  115. observer.unobserve(e.target);
  116. if (!data.every(([key, val]) => cm.getOption(key) === val)) {
  117. if (r.bottom > 0 && r.top < window.innerHeight) {
  118. lazyOpt.setNow({cm, data});
  119. } else {
  120. delayed.push({cm, data});
  121. }
  122. }
  123. }
  124. if (delayed.length) {
  125. setTimeout(() => delayed.forEach(lazyOpt.setNow));
  126. }
  127. },
  128. get observer() {
  129. if (!lazyOpt._observer) {
  130. // must exceed refreshOnView's 100%
  131. lazyOpt._observer = new IntersectionObserver(lazyOpt.onView, {rootMargin: '150%'});
  132. lazyOpt.queue = new WeakMap();
  133. }
  134. return lazyOpt._observer;
  135. },
  136. };
  137. //#endregion
  138. //#region Commands
  139. Object.assign(CodeMirror.commands, {
  140. commentSelection(cm) {
  141. cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
  142. },
  143. toggleEditorFocus(cm) {
  144. if (!cm) return;
  145. if (cm.hasFocus()) {
  146. setTimeout(() => cm.display.input.blur());
  147. } else {
  148. cm.focus();
  149. }
  150. },
  151. });
  152. for (const cmd of [
  153. 'nextEditor',
  154. 'prevEditor',
  155. 'save',
  156. 'toggleStyle',
  157. ]) {
  158. CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
  159. }
  160. //#endregion
  161. //#region CM option handlers
  162. async function loadCmTheme(name) {
  163. let el2;
  164. const el = $('#cm-theme');
  165. if (name === 'default') {
  166. el.href = '';
  167. } else {
  168. const path = `/vendor/codemirror/theme/${name}.css`;
  169. if (el.href !== location.origin + path) {
  170. // avoid flicker: wait for the second stylesheet to load, then apply the theme
  171. el2 = await require([path]);
  172. }
  173. }
  174. cmFactory.globalSetOption('theme', name);
  175. if (el2) {
  176. el.remove();
  177. el2.id = el.id;
  178. }
  179. }
  180. function updateMatchHighlightCount(cm, state) {
  181. cm.display.wrapper.dataset.matchHighlightCount = state.matchesonscroll.matches.length;
  182. }
  183. function configureMouseFn(cm, repeat) {
  184. return repeat === 'double' ?
  185. {unit: selectTokenOnDoubleclick} :
  186. {};
  187. }
  188. function selectTokenOnDoubleclick(cm, pos) {
  189. let {ch} = pos;
  190. const {line, sticky} = pos;
  191. const {text, styles} = cm.getLineHandle(line);
  192. const execAt = (rx, i) => (rx.lastIndex = i) && null || rx.exec(text);
  193. const at = (rx, i) => (rx.lastIndex = i) && null || rx.test(text);
  194. const atWord = ch => at(/\w/y, ch);
  195. const atSpace = ch => at(/\s/y, ch);
  196. const atTokenEnd = styles.indexOf(ch, 1);
  197. ch += atTokenEnd < 0 ? 0 : sticky === 'before' && atWord(ch - 1) ? 0 : atSpace(ch + 1) ? 0 : 1;
  198. ch = Math.min(text.length, ch);
  199. const type = cm.getTokenTypeAt({line, ch: ch + (sticky === 'after' ? 1 : 0)});
  200. if (atTokenEnd > 0) ch--;
  201. const isCss = type && !/^(comment|string)/.test(type);
  202. const isNumber = type === 'number';
  203. const isSpace = atSpace(ch);
  204. let wordChars =
  205. isNumber ? /[-+\w.%]/y :
  206. isCss ? /[-\w@]/y :
  207. isSpace ? /\s/y :
  208. atWord(ch) ? /\w/y : /[^\w\s]/y;
  209. let a = ch;
  210. while (a && at(wordChars, a)) a--;
  211. a += !a && at(wordChars, a) || isCss && at(/[.!#@]/y, a) ? 0 : at(wordChars, a + 1);
  212. let b, found;
  213. if (isNumber) {
  214. b = a + execAt(/[+-]?[\d.]+(e\d+)?|$/yi, a)[0].length;
  215. found = b >= ch;
  216. if (!found) {
  217. a = b;
  218. ch = a;
  219. }
  220. }
  221. if (!found) {
  222. wordChars = isCss ? /[-\w]*/y : new RegExp(wordChars.source + '*', 'uy');
  223. b = ch + execAt(wordChars, ch)[0].length;
  224. }
  225. return {
  226. from: {line, ch: a},
  227. to: {line, ch: b},
  228. };
  229. }
  230. //#endregion
  231. //#region Bookmarks
  232. const BM_CLS = 'gutter-bookmark';
  233. const BM_BRAND = 'sublimeBookmark';
  234. const BM_CLICKER = 'CodeMirror-linenumbers';
  235. const BM_DATA = Symbol('data');
  236. // TODO: revisit when https://github.com/codemirror/CodeMirror/issues/6716 is fixed
  237. const tmProto = CodeMirror.TextMarker.prototype;
  238. const tmProtoOvr = {};
  239. for (const k of ['clear', 'attachLine', 'detachLine']) {
  240. tmProtoOvr[k] = function (line) {
  241. const {cm} = this.doc;
  242. const withOp = !cm.curOp;
  243. if (withOp) cm.startOperation();
  244. tmProto[k].apply(this, arguments);
  245. cm.curOp.ownsGroup.delayedCallbacks.push(toggleMark.bind(this, this.lines[0], line));
  246. if (withOp) cm.endOperation();
  247. };
  248. }
  249. for (const name of ['prevBookmark', 'nextBookmark']) {
  250. const cmdFn = CodeMirror.commands[name];
  251. CodeMirror.commands[name] = cm => {
  252. cm.setSelection = cm.jumpToPos;
  253. cmdFn(cm);
  254. delete cm.setSelection;
  255. };
  256. }
  257. CodeMirror.defineInitHook(cm => {
  258. cm.on('gutterClick', onGutterClick);
  259. cm.on('gutterContextMenu', onGutterContextMenu);
  260. cm.on('markerAdded', onMarkAdded);
  261. });
  262. // TODO: reimplement bookmarking so next/prev order is decided solely by the line numbers
  263. function onGutterClick(cm, line, name, e) {
  264. switch (name === BM_CLICKER && e.button) {
  265. case 0: {
  266. // main button: toggle
  267. const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BM_BRAND]);
  268. cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
  269. cm.execCommand('toggleBookmark');
  270. break;
  271. }
  272. case 1:
  273. // middle button: select all marks
  274. cm.execCommand('selectBookmarks');
  275. break;
  276. }
  277. }
  278. function onGutterContextMenu(cm, line, name, e) {
  279. if (name === BM_CLICKER) {
  280. cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
  281. e.preventDefault();
  282. }
  283. }
  284. function onMarkAdded(cm, mark) {
  285. if (mark[BM_BRAND]) {
  286. // CM bug workaround to keep the mark at line start when the above line is removed
  287. mark.inclusiveRight = true;
  288. Object.assign(mark, tmProtoOvr);
  289. toggleMark.call(mark, true, mark[BM_DATA] = mark.lines[0]);
  290. }
  291. }
  292. function toggleMark(state, line = this[BM_DATA]) {
  293. this.doc[state ? 'addLineClass' : 'removeLineClass'](line, 'gutter', BM_CLS);
  294. if (state) {
  295. const bms = this.doc.cm.state.sublimeBookmarks;
  296. if (!bms.includes(this)) bms.push(this);
  297. }
  298. }
  299. //#endregion
  300. })();