autocomplete.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /* global CodeMirror */
  2. /* global cmFactory */
  3. /* global debounce */// toolbox.js
  4. /* global editor */
  5. /* global linterMan */
  6. /* global prefs */
  7. 'use strict';
  8. /* Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */
  9. (() => {
  10. const USO_VAR = 'uso-variable';
  11. const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
  12. const USO_INVALID_VAR = 'error ' + USO_VAR;
  13. const rxPROP = /^(prop(erty)?|variable-2)\b/;
  14. const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
  15. const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
  16. const cssMime = CodeMirror.mimeModes['text/css'];
  17. const docFuncs = addSuffix(cssMime.documentTypes, '(');
  18. const {tokenHooks} = cssMime;
  19. const originalCommentHook = tokenHooks['/'];
  20. const originalHelper = CodeMirror.hint.css || (() => {});
  21. let cssMedia, cssProps, cssValues;
  22. const AOT_ID = 'autocompleteOnTyping';
  23. const AOT_PREF_ID = 'editor.' + AOT_ID;
  24. const aot = prefs.get(AOT_PREF_ID);
  25. CodeMirror.defineOption(AOT_ID, aot, (cm, value) => {
  26. cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
  27. cm[value ? 'on' : 'off']('pick', autocompletePicked);
  28. });
  29. prefs.subscribe(AOT_PREF_ID, (key, val) => cmFactory.globalSetOption(AOT_ID, val), {runNow: aot});
  30. CodeMirror.registerHelper('hint', 'css', helper);
  31. CodeMirror.registerHelper('hint', 'stylus', helper);
  32. tokenHooks['/'] = tokenizeUsoVariables;
  33. async function helper(cm) {
  34. const pos = cm.getCursor();
  35. const {line, ch} = pos;
  36. const {styles, text} = cm.getLineHandle(line);
  37. const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
  38. const isStylusLang = cm.doc.mode.name === 'stylus';
  39. const type = style && style.split(' ', 1)[0] || 'prop?';
  40. if (!type || type === 'comment' || type === 'string') {
  41. return originalHelper(cm);
  42. }
  43. // not using getTokenAt until the need is unavoidable because it reparses text
  44. // and runs a whole lot of complex calc inside which is slow on long lines
  45. // especially if autocomplete is auto-shown on each keystroke
  46. let prev, end, state;
  47. let i = index;
  48. while (
  49. (prev == null || `${styles[i - 1]}`.startsWith(type)) &&
  50. (prev = i > 2 ? styles[i - 2] : 0) &&
  51. isSameToken(text, style, prev)
  52. ) i -= 2;
  53. i = index;
  54. while (
  55. (end == null || `${styles[i + 1]}`.startsWith(type)) &&
  56. (end = styles[i]) &&
  57. isSameToken(text, style, end)
  58. ) i += 2;
  59. const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
  60. const str = text.slice(prev, end);
  61. const left = text.slice(prev, ch).trim();
  62. let leftLC = left.toLowerCase();
  63. let list;
  64. switch (leftLC[0]) {
  65. case '!':
  66. list = '!important'.startsWith(leftLC) ? ['!important'] : [];
  67. break;
  68. case '@':
  69. list = [
  70. '@-moz-document',
  71. '@charset',
  72. '@font-face',
  73. '@import',
  74. '@keyframes',
  75. '@media',
  76. '@namespace',
  77. '@page',
  78. '@supports',
  79. '@viewport',
  80. ];
  81. break;
  82. case '#': // prevents autocomplete for #hex colors
  83. break;
  84. case '-': // --variable
  85. case '(': // var(
  86. list = str.startsWith('--') || testAt(rxVAR, ch - 5, text)
  87. ? findAllCssVars(cm, left)
  88. : [];
  89. if (str.startsWith('(')) {
  90. prev++;
  91. leftLC = left.slice(1);
  92. } else {
  93. leftLC = left;
  94. }
  95. break;
  96. case '/': // USO vars
  97. if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
  98. prev += 4;
  99. end -= 4;
  100. end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
  101. list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
  102. leftLC = left.slice(4);
  103. }
  104. break;
  105. case 'u': // url(), url-prefix()
  106. case 'd': // domain()
  107. case 'r': // regexp()
  108. if (/^(variable|tag|error)/.test(type) &&
  109. docFuncs.some(s => s.startsWith(leftLC)) &&
  110. /^(top|documentTypes|atBlock)/.test(getTokenState())) {
  111. end++;
  112. list = docFuncs;
  113. break;
  114. }
  115. // fallthrough to `default`
  116. default:
  117. // property values
  118. if (isStylusLang || getTokenState() === 'prop') {
  119. while (i > 0 && !rxPROP.test(styles[i + 1])) i -= 2;
  120. const propEnd = styles[i];
  121. let prop;
  122. if (propEnd > text.lastIndexOf(';', ch - 1)) {
  123. while (i > 0 && rxPROP.test(styles[i + 1])) i -= 2;
  124. prop = text.slice(styles[i] || 0, propEnd).match(/([-\w]+)?$/u)[1];
  125. }
  126. if (prop) {
  127. if (/[^-\w]/.test(leftLC)) {
  128. prev += execAt(/[\s:()]*/y, prev, text)[0].length;
  129. leftLC = leftLC.replace(/^[^\w\s]\s*/, '');
  130. }
  131. if (prop.startsWith('--')) prop = 'color'; // assuming 90% of variables are colors
  132. if (!cssValues) cssValues = await linterMan.worker.getCssPropsValues();
  133. list = [...new Set([...cssValues.own[prop] || [], ...cssValues.global])];
  134. end = prev + execAt(/(\s*[-a-z(]+)?/y, prev, text)[0].length;
  135. }
  136. }
  137. // properties and media features
  138. if (!list &&
  139. /^(prop(erty|\?)|atom|error)/.test(type) &&
  140. /^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
  141. if (!cssProps) initCssProps();
  142. if (type === 'prop?') {
  143. prev += leftLC.length;
  144. leftLC = '';
  145. }
  146. list = state === 'atBlock_parens' ? cssMedia : cssProps;
  147. end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
  148. end += execAt(rxCONSUME, end, text)[0].length;
  149. }
  150. if (!list) {
  151. return isStylusLang
  152. ? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
  153. : originalHelper(cm);
  154. }
  155. }
  156. return {
  157. list: (list || []).filter(s => s.startsWith(leftLC)),
  158. from: {line, ch: prev + str.match(/^\s*/)[0].length},
  159. to: {line, ch: end},
  160. };
  161. }
  162. function initCssProps() {
  163. cssProps = addSuffix(cssMime.propertyKeywords);
  164. cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
  165. }
  166. function addSuffix(obj, suffix = ': ') {
  167. // Sorting first, otherwise "foo-bar:" would precede "foo:"
  168. return Object.keys(obj).sort().map(k => k + suffix);
  169. }
  170. function getMediaKeys([k, v]) {
  171. return k === 'mediaFeatures' && addSuffix(v) ||
  172. k.startsWith('media') && Object.keys(v);
  173. }
  174. /** makes sure we don't process a different adjacent comment */
  175. function isSameToken(text, style, i) {
  176. return !style || text[i] !== '/' && text[i + 1] !== '*' ||
  177. !style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
  178. }
  179. function findAllCssVars(cm, leftPart) {
  180. // simplified regex without CSS escapes
  181. const rx = new RegExp(
  182. '(?:^|[\\s/;{])(' +
  183. (leftPart.startsWith('--') ? leftPart : '--') +
  184. (leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
  185. '[-0-9a-zA-Z_\u0080-\uFFFF]*)',
  186. 'g');
  187. const list = new Set();
  188. cm.eachLine(({text}) => {
  189. for (let m; (m = rx.exec(text));) {
  190. list.add(m[1]);
  191. }
  192. });
  193. return [...list].sort();
  194. }
  195. function tokenizeUsoVariables(stream) {
  196. const token = originalCommentHook.apply(this, arguments);
  197. if (token[1] === 'comment') {
  198. const {string, start, pos} = stream;
  199. if (testAt(/\/\*\[\[/y, start, string) &&
  200. testAt(/]]\*\//y, pos - 4, string)) {
  201. const vars = (editor.style.usercssData || {}).vars;
  202. token[0] =
  203. vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
  204. ? USO_VALID_VAR
  205. : USO_INVALID_VAR;
  206. }
  207. }
  208. return token;
  209. }
  210. function execAt(rx, index, text) {
  211. rx.lastIndex = index;
  212. return rx.exec(text);
  213. }
  214. function testAt(rx, index, text) {
  215. rx.lastIndex = Math.max(0, index);
  216. return rx.test(text);
  217. }
  218. function autocompleteOnTyping(cm, [info], debounced) {
  219. const lastLine = info.text[info.text.length - 1];
  220. if (cm.state.completionActive ||
  221. info.origin && !info.origin.includes('input') ||
  222. !lastLine) {
  223. return;
  224. }
  225. if (cm.state.autocompletePicked) {
  226. cm.state.autocompletePicked = false;
  227. return;
  228. }
  229. if (!debounced) {
  230. debounce(autocompleteOnTyping, 100, cm, [info], true);
  231. return;
  232. }
  233. if (lastLine.match(/[-a-z!]+$/i)) {
  234. cm.state.autocompletePicked = false;
  235. cm.options.hintOptions.completeSingle = false;
  236. cm.execCommand('autocomplete');
  237. setTimeout(() => {
  238. cm.options.hintOptions.completeSingle = true;
  239. });
  240. }
  241. }
  242. function autocompletePicked(cm) {
  243. cm.state.autocompletePicked = true;
  244. }
  245. })();