source-editor.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. /* global $ $$remove $create $isTextInput messageBoxProxy */// dom.js
  2. /* global API */// msg.js
  3. /* global CodeMirror */
  4. /* global MozDocMapper */// util.js
  5. /* global MozSectionFinder */
  6. /* global MozSectionWidget */
  7. /* global RX_META debounce sessionStore */// toolbox.js
  8. /* global chromeSync */// storage-util.js
  9. /* global cmFactory */
  10. /* global editor */
  11. /* global linterMan */
  12. /* global prefs */
  13. /* global t */// localization.js
  14. 'use strict';
  15. /* exported SourceEditor */
  16. function SourceEditor() {
  17. const {style, /** @type DirtyReporter */dirty} = editor;
  18. let savedGeneration;
  19. let placeholderName = '';
  20. let prevMode = NaN;
  21. $$remove('.sectioned-only');
  22. $('#header').on('wheel', headerOnScroll);
  23. $('#sections').textContent = '';
  24. $('#sections').appendChild($create('.single-editor'));
  25. if (!style.id) setupNewStyle(style);
  26. const cm = cmFactory.create($('.single-editor'));
  27. const sectionFinder = MozSectionFinder(cm);
  28. const sectionWidget = MozSectionWidget(cm, sectionFinder);
  29. editor.livePreview.init(preprocess);
  30. createMetaCompiler(meta => {
  31. style.usercssData = meta;
  32. style.name = meta.name;
  33. style.url = meta.homepageURL || style.installationUrl;
  34. updateMeta();
  35. });
  36. updateMeta();
  37. cm.setValue(style.sourceCode);
  38. /** @namespace Editor */
  39. Object.assign(editor, {
  40. sections: sectionFinder.sections,
  41. replaceStyle,
  42. updateLivePreview,
  43. closestVisible: () => cm,
  44. getEditors: () => [cm],
  45. getEditorTitle: () => '',
  46. getValue: () => cm.getValue(),
  47. getSearchableInputs: () => [],
  48. prevEditor: nextPrevSection.bind(null, -1),
  49. nextEditor: nextPrevSection.bind(null, 1),
  50. jumpToEditor(i) {
  51. const sec = sectionFinder.sections[i];
  52. if (sec) {
  53. sectionFinder.updatePositions(sec);
  54. cm.jumpToPos(sec.start);
  55. cm.focus();
  56. }
  57. },
  58. async save() {
  59. if (!dirty.isDirty()) return;
  60. const sourceCode = cm.getValue();
  61. try {
  62. const {customName, enabled, id} = style;
  63. let res = !id && await API.usercss.build({sourceCode, checkDup: true, metaOnly: true});
  64. if (res && res.dup) {
  65. messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
  66. } else {
  67. res = await API.usercss.editSave({customName, enabled, id, sourceCode});
  68. if (!id) {
  69. editor.emit('styleChange', res.style, 'new');
  70. }
  71. // Awaiting inside `try` so that exceptions go to our `catch`
  72. await replaceStyle(res.style);
  73. }
  74. showLog(res);
  75. } catch (err) {
  76. const i = err.index;
  77. const isNameEmpty = i > 0 &&
  78. err.code === 'missingValue' &&
  79. sourceCode.slice(sourceCode.lastIndexOf('\n', i - 1), i).trim().endsWith('@name');
  80. return isNameEmpty
  81. ? saveTemplate(sourceCode)
  82. : showSaveError(err);
  83. }
  84. },
  85. scrollToEditor: () => {},
  86. });
  87. prefs.subscribeMany({
  88. 'editor.linter': updateLinterSwitch,
  89. 'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
  90. 'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
  91. }, {runNow: true});
  92. editor.applyScrollInfo(cm);
  93. cm.clearHistory();
  94. cm.markClean();
  95. savedGeneration = cm.changeGeneration();
  96. cm.on('changes', () => {
  97. dirty.modify('sourceGeneration', savedGeneration, cm.changeGeneration());
  98. debounce(updateLivePreview, editor.previewDelay);
  99. });
  100. cm.on('optionChange', (cm, option) => {
  101. if (option !== 'mode') return;
  102. const mode = getModeName();
  103. if (mode === prevMode) return;
  104. prevMode = mode;
  105. linterMan.run();
  106. updateLinterSwitch();
  107. });
  108. setTimeout(linterMan.enableForEditor, 0, cm);
  109. if (!$isTextInput(document.activeElement)) {
  110. cm.focus();
  111. }
  112. editor.on('styleToggled', newStyle => {
  113. if (dirty.isDirty()) {
  114. editor.toggleStyle(newStyle.enabled);
  115. } else {
  116. style.enabled = newStyle.enabled;
  117. }
  118. updateMeta();
  119. updateLivePreview();
  120. });
  121. editor.on('styleChange', (newStyle, reason) => {
  122. if (reason === 'new') return;
  123. if (reason === 'config') {
  124. delete newStyle.sourceCode;
  125. delete newStyle.name;
  126. Object.assign(style, newStyle);
  127. updateLivePreview();
  128. return;
  129. }
  130. replaceStyle(newStyle);
  131. });
  132. async function preprocess(style) {
  133. const res = await API.usercss.build({
  134. styleId: style.id,
  135. sourceCode: style.sourceCode,
  136. assignVars: true,
  137. });
  138. showLog(res);
  139. delete res.style.enabled;
  140. return Object.assign(style, res.style);
  141. }
  142. /** Shows the console.log output from the background worker stored in `log` property */
  143. function showLog(data) {
  144. if (data.log) data.log.forEach(args => console.log(...args));
  145. return data;
  146. }
  147. function updateLivePreview() {
  148. if (!style.id) {
  149. return;
  150. }
  151. editor.livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
  152. }
  153. function updateLinterSwitch() {
  154. const el = $('#editor.linter');
  155. el.value = getCurrentLinter();
  156. const cssLintOption = $('[value="csslint"]', el);
  157. const mode = getModeName();
  158. if (mode !== 'css') {
  159. cssLintOption.disabled = true;
  160. cssLintOption.title = t('linterCSSLintIncompatible', mode);
  161. } else {
  162. cssLintOption.disabled = false;
  163. cssLintOption.title = '';
  164. }
  165. }
  166. function getCurrentLinter() {
  167. const name = prefs.get('editor.linter');
  168. if (cm.getOption('mode') !== 'css' && name === 'csslint') {
  169. return 'stylelint';
  170. }
  171. return name;
  172. }
  173. async function setupNewStyle(style) {
  174. style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
  175. `/* ${t('usercssReplaceTemplateSectionBody')} */`;
  176. let section = MozDocMapper.styleToCss(style);
  177. if (!section.includes('@-moz-document')) {
  178. style.sections[0].domains = ['example.com'];
  179. section = MozDocMapper.styleToCss(style);
  180. }
  181. const DEFAULT_CODE = `
  182. /* ==UserStyle==
  183. @name ${''/* a trick to preserve the trailing spaces */}
  184. @namespace github.com/openstyles/stylus
  185. @version 1.0.0
  186. @description A new userstyle
  187. @author Me
  188. ==/UserStyle== */
  189. `.replace(/^\s+/gm, '');
  190. dirty.clear('sourceGeneration');
  191. style.sourceCode = '';
  192. placeholderName = `${style.name || t('usercssReplaceTemplateName')} - ${new Date().toLocaleString()}`;
  193. let code = await chromeSync.getLZValue(chromeSync.LZ_KEY.usercssTemplate);
  194. code = code || DEFAULT_CODE;
  195. code = code.replace(/@name(\s*)(?=[\r\n])/, (str, space) =>
  196. `${str}${space ? '' : ' '}${placeholderName}`);
  197. // strip the last dummy section if any, add an empty line followed by the section
  198. style.sourceCode = code.replace(/\s*@-moz-document[^{]*{[^}]*}\s*$|\s+$/g, '') + '\n\n' + section;
  199. cm.startOperation();
  200. cm.setValue(style.sourceCode);
  201. cm.clearHistory();
  202. cm.markClean();
  203. cm.endOperation();
  204. dirty.clear('sourceGeneration');
  205. savedGeneration = cm.changeGeneration();
  206. }
  207. function updateMeta() {
  208. const name = style.customName || style.name;
  209. if (name !== placeholderName) {
  210. $('#name').value = name;
  211. }
  212. $('#enabled').checked = style.enabled;
  213. $('#url').href = style.url;
  214. editor.updateName();
  215. cm.setPreprocessor((style.usercssData || {}).preprocessor);
  216. }
  217. function replaceStyle(newStyle) {
  218. dirty.clear('name');
  219. const sameCode = newStyle.sourceCode === cm.getValue();
  220. if (sameCode) {
  221. savedGeneration = cm.changeGeneration();
  222. dirty.clear('sourceGeneration');
  223. updateEnvironment();
  224. dirty.clear('enabled');
  225. updateLivePreview();
  226. return;
  227. }
  228. Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
  229. if (!ok) return;
  230. updateEnvironment();
  231. if (!sameCode) {
  232. const cursor = cm.getCursor();
  233. cm.setValue(style.sourceCode);
  234. cm.setCursor(cursor);
  235. savedGeneration = cm.changeGeneration();
  236. }
  237. if (sameCode) {
  238. // the code is same but the environment is changed
  239. updateLivePreview();
  240. }
  241. dirty.clear();
  242. });
  243. function updateEnvironment() {
  244. if (style.id !== newStyle.id) {
  245. history.replaceState({}, '', `?id=${newStyle.id}`);
  246. }
  247. sessionStore.justEditedStyleId = newStyle.id;
  248. Object.assign(style, newStyle);
  249. editor.onStyleUpdated();
  250. updateMeta();
  251. }
  252. }
  253. async function saveTemplate(code) {
  254. if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
  255. const key = chromeSync.LZ_KEY.usercssTemplate;
  256. await chromeSync.setLZValue(key, code);
  257. if (await chromeSync.getLZValue(key) !== code) {
  258. messageBoxProxy.alert(t('syncStorageErrorSaving'));
  259. }
  260. }
  261. }
  262. function showSaveError(err) {
  263. err = Array.isArray(err) ? err : [err];
  264. const text = err.map(e => e.message || e).join('\n');
  265. const points = err.map(e =>
  266. e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
  267. e.offset >= 0 && {line: e.line - 1, ch: e.col - 1} // csslint code parser
  268. ).filter(Boolean);
  269. cm.setSelections(points.map(p => ({anchor: p, head: p})));
  270. messageBoxProxy.alert($create('pre', text), 'pre');
  271. }
  272. function nextPrevSection(dir) {
  273. // ensure the data is ready in case the user wants to jump around a lot in a large style
  274. sectionFinder.keepAliveFor(nextPrevSection, 10e3);
  275. sectionFinder.updatePositions();
  276. const {sections} = sectionFinder;
  277. const num = sections.length;
  278. if (!num) return;
  279. dir = dir < 0 ? -1 : 0;
  280. const pos = cm.getCursor();
  281. let i = sections.findIndex(sec => CodeMirror.cmpPos(sec.start, pos) > Math.min(dir, 0));
  282. if (i < 0 && (!dir || CodeMirror.cmpPos(sections[num - 1].start, pos) < 0)) {
  283. i = 0;
  284. }
  285. cm.jumpToPos(sections[(i + dir + num) % num].start);
  286. }
  287. function headerOnScroll({target, deltaY, deltaMode, shiftKey}) {
  288. while ((target = target.parentElement)) {
  289. if (deltaY < 0 && target.scrollTop ||
  290. deltaY > 0 && target.scrollTop + target.clientHeight < target.scrollHeight) {
  291. return;
  292. }
  293. }
  294. cm.display.scroller.scrollTop +=
  295. // WheelEvent.DOM_DELTA_LINE
  296. deltaMode === 1 ? deltaY * cm.defaultTextHeight() :
  297. // WheelEvent.DOM_DELTA_PAGE
  298. deltaMode === 2 || shiftKey ? Math.sign(deltaY) * cm.display.scroller.clientHeight :
  299. // WheelEvent.DOM_DELTA_PIXEL
  300. deltaY;
  301. }
  302. function getModeName() {
  303. const mode = cm.doc.mode;
  304. if (!mode) return '';
  305. return (mode.name || mode || '') +
  306. (mode.helperType || '');
  307. }
  308. function createMetaCompiler(onUpdated) {
  309. let meta = null;
  310. let metaIndex = null;
  311. let cache = [];
  312. linterMan.register(async (text, options, _cm) => {
  313. if (_cm !== cm) {
  314. return;
  315. }
  316. const match = text.match(RX_META);
  317. if (!match) {
  318. return [];
  319. }
  320. if (match[0] === meta && match.index === metaIndex) {
  321. return cache;
  322. }
  323. const {metadata, errors} = await linterMan.worker.metalint(match[0]);
  324. if (errors.every(err => err.code === 'unknownMeta')) {
  325. onUpdated(metadata);
  326. }
  327. cache = errors.map(({code, index, args, message}) => {
  328. const isUnknownMeta = code === 'unknownMeta';
  329. const typo = isUnknownMeta && args[1] ? 'Typo' : ''; // args[1] may be present but undefined
  330. return ({
  331. from: cm.posFromIndex((index || 0) + match.index),
  332. to: cm.posFromIndex((index || 0) + match.index),
  333. message: code && t(`meta_${code}${typo}`, args, false) || message,
  334. severity: isUnknownMeta ? 'warning' : 'error',
  335. rule: code,
  336. });
  337. });
  338. meta = match[0];
  339. metaIndex = match.index;
  340. return cache;
  341. });
  342. }
  343. }