install-usercss.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. /* global $ $create $createLink $$remove showSpinner */// dom.js
  2. /* global API */// msg.js
  3. /* global URLS closeCurrentTab deepEqual */// toolbox.js
  4. /* global messageBox */
  5. /* global prefs */
  6. /* global preinit */
  7. /* global t */// localization.js
  8. 'use strict';
  9. let cm;
  10. let initialUrl;
  11. let installed;
  12. let installedDup;
  13. let liveReload;
  14. let tabId;
  15. let vars;
  16. // "History back" in Firefox (for now) restores the old DOM including the messagebox,
  17. // which stays after installing since we don't want to wait for the fadeout animation before resolving.
  18. document.on('visibilitychange', () => {
  19. $$remove('#message-box:not(.config-dialog)');
  20. if (installed) liveReload.onToggled();
  21. });
  22. setTimeout(() => !cm && showSpinner($('#header')), 200);
  23. /*
  24. * Preinit starts to download as early as possible,
  25. * then the critical rendering path scripts are loaded in html,
  26. * then the meta of the downloaded code is parsed in the background worker,
  27. * then CodeMirror scripts/css are added so they can load while the worker runs in parallel,
  28. * then the meta response arrives from API and is immediately displayed in CodeMirror,
  29. * then the sections of code are parsed in the background worker and displayed.
  30. */
  31. (async function init() {
  32. const theme = prefs.get('editor.theme');
  33. if (theme !== 'default') {
  34. require([`/vendor/codemirror/theme/${theme}.css`]); // not awaiting as it may be absent
  35. }
  36. const scriptsReady = require([
  37. '/vendor/codemirror/lib/codemirror', /* global CodeMirror */
  38. ]).then(() => require([
  39. '/vendor/codemirror/keymap/sublime',
  40. '/vendor/codemirror/keymap/emacs',
  41. '/vendor/codemirror/keymap/vim', // TODO: load conditionally
  42. '/vendor/codemirror/mode/css/css',
  43. '/vendor/codemirror/addon/search/searchcursor',
  44. '/vendor/codemirror/addon/fold/foldcode',
  45. '/vendor/codemirror/addon/fold/foldgutter',
  46. '/vendor/codemirror/addon/fold/brace-fold',
  47. '/vendor/codemirror/addon/fold/indent-fold',
  48. '/vendor/codemirror/addon/selection/active-line',
  49. '/vendor/codemirror/lib/codemirror.css',
  50. '/vendor/codemirror/addon/fold/foldgutter.css',
  51. '/js/cmpver', /* global compareVersion */
  52. '/js/sections-util', /* global styleCodeEmpty */
  53. '/js/color/color-converter',
  54. '/edit/codemirror-default.css',
  55. ])).then(() => require([
  56. '/edit/codemirror-default',
  57. '/js/color/color-view',
  58. ]));
  59. ({tabId, initialUrl} = preinit);
  60. liveReload = initLiveReload();
  61. const [
  62. {dup, style, error, sourceCode},
  63. hasFileAccess,
  64. ] = await Promise.all([
  65. preinit.ready,
  66. API.data.get('hasFileAccess'),
  67. ]);
  68. if (!style && sourceCode == null) {
  69. messageBox.alert(isNaN(error) ? `${error}` : 'HTTP Error ' + error, 'pre');
  70. return;
  71. }
  72. await scriptsReady;
  73. cm = CodeMirror($('.main'), {
  74. value: sourceCode || style.sourceCode,
  75. readOnly: true,
  76. colorpicker: true,
  77. theme,
  78. });
  79. window.on('resize', adjustCodeHeight);
  80. if (error) {
  81. showBuildError(error);
  82. }
  83. if (!style) {
  84. return;
  85. }
  86. const data = style.usercssData;
  87. const dupData = dup && dup.usercssData;
  88. const versionTest = dup && compareVersion(data.version, dupData.version);
  89. updateMeta(style, dup);
  90. // update UI
  91. if (versionTest < 0) {
  92. $('.actions').parentNode.insertBefore(
  93. $create('.warning', t('versionInvalidOlder')),
  94. $('.actions')
  95. );
  96. }
  97. $('button.install').onclick = () => {
  98. (!dup ?
  99. Promise.resolve(true) :
  100. messageBox.confirm($create('span', t('styleInstallOverwrite', [
  101. data.name + (dup.customName ? ` (${dup.customName})` : ''),
  102. dupData.version,
  103. data.version,
  104. ])))
  105. ).then(ok => ok &&
  106. API.usercss.install(style)
  107. .then(install)
  108. .catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
  109. );
  110. };
  111. // set updateUrl
  112. const checker = $('.set-update-url input[type=checkbox]');
  113. const updateUrl = new URL(style.updateUrl || initialUrl);
  114. if (dup && dup.updateUrl === updateUrl.href) {
  115. checker.checked = true;
  116. // there is no way to "unset" updateUrl, you can only overwrite it.
  117. checker.disabled = true;
  118. } else if (updateUrl.protocol !== 'file:' || hasFileAccess) {
  119. checker.checked = true;
  120. style.updateUrl = updateUrl.href;
  121. }
  122. checker.onchange = () => {
  123. style.updateUrl = checker.checked ? updateUrl.href : null;
  124. };
  125. checker.onchange();
  126. $('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
  127. updateUrl.href.slice(0, 300) + '...';
  128. // set prefer scheme
  129. const preferScheme = $('.set-prefer-scheme select');
  130. preferScheme.onchange = () => {
  131. style.preferScheme = preferScheme.value;
  132. };
  133. preferScheme.onchange();
  134. if (URLS.isLocalhost(initialUrl)) {
  135. $('.live-reload input').onchange = liveReload.onToggled;
  136. } else {
  137. $('.live-reload').remove();
  138. }
  139. })();
  140. function updateMeta(style, dup = installedDup) {
  141. installedDup = dup;
  142. const data = style.usercssData;
  143. const dupData = dup && dup.usercssData;
  144. const versionTest = dup && compareVersion(data.version, dupData.version);
  145. cm.setPreprocessor(data.preprocessor);
  146. const installButtonLabel = t(
  147. installed ? 'installButtonInstalled' :
  148. !dup ? 'installButton' :
  149. versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
  150. );
  151. document.title = `${installButtonLabel} ${data.name}`;
  152. $('.install').textContent = installButtonLabel;
  153. $('.install').classList.add(
  154. installed ? 'installed' :
  155. !dup ? 'install' :
  156. versionTest > 0 ? 'update' :
  157. 'reinstall');
  158. $('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
  159. $('.meta-name').textContent = data.name;
  160. $('.meta-version').textContent = data.version;
  161. $('.meta-description').textContent = data.description;
  162. $('.set-prefer-scheme select').value =
  163. style.preferScheme === 'dark' ? 'dark' :
  164. style.preferScheme === 'light' ? 'light' : 'none';
  165. if (data.author) {
  166. $('.meta-author').parentNode.style.display = '';
  167. $('.meta-author').textContent = '';
  168. $('.meta-author').appendChild(makeAuthor(data.author));
  169. } else {
  170. $('.meta-author').parentNode.style.display = 'none';
  171. }
  172. $('.meta-license').parentNode.style.display = data.license ? '' : 'none';
  173. $('.meta-license').textContent = data.license;
  174. $('.applies-to').textContent = '';
  175. getAppliesTo(style).then(list =>
  176. $('.applies-to').append(...list.map(s => $create('li', s))));
  177. $('.external-link').textContent = '';
  178. const externalLink = makeExternalLink();
  179. if (externalLink) {
  180. $('.external-link').appendChild(externalLink);
  181. }
  182. Object.assign($('.configure-usercss'), {
  183. hidden: !data.vars,
  184. onclick: openConfigDialog,
  185. });
  186. if (!data.vars) {
  187. $$remove('#message-box.config-dialog');
  188. } else if (!deepEqual(data.vars, vars)) {
  189. vars = data.vars;
  190. // Use the user-customized vars from the installed style
  191. for (const [dk, dv] of Object.entries(dup && dupData.vars || {})) {
  192. const v = vars[dk];
  193. if (v && v.type === dv.type) {
  194. v.value = dv.value;
  195. }
  196. }
  197. openConfigDialog();
  198. }
  199. $('#header').dataset.arrivedFast = performance.now() < 500;
  200. $('#header').classList.add('meta-init');
  201. $('#header').classList.remove('meta-init-error');
  202. setTimeout(() => $$remove('.lds-spinner'), 1000);
  203. showError('');
  204. requestAnimationFrame(adjustCodeHeight);
  205. if (dup) enablePostActions();
  206. function makeAuthor(text) {
  207. const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
  208. if (!match) {
  209. return document.createTextNode(text);
  210. }
  211. const [, name, email, url] = match;
  212. const frag = document.createDocumentFragment();
  213. if (email) {
  214. frag.appendChild($createLink(`mailto:${email}`, name));
  215. } else {
  216. frag.appendChild($create('span', name));
  217. }
  218. if (url) {
  219. frag.appendChild($createLink(url,
  220. $create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
  221. $create('SVG:path', {
  222. d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
  223. }))
  224. ));
  225. }
  226. return frag;
  227. }
  228. function makeExternalLink() {
  229. const urls = [
  230. data.homepageURL && [data.homepageURL, t('externalHomepage')],
  231. data.supportURL && [data.supportURL, t('externalSupport')],
  232. ];
  233. return (data.homepageURL || data.supportURL) && (
  234. $create('div', [
  235. $create('h3', t('externalLink')),
  236. $create('ul', urls.map(args => args &&
  237. $create('li',
  238. $createLink(...args)
  239. )
  240. )),
  241. ]));
  242. }
  243. async function openConfigDialog() {
  244. await require(['/js/dlg/config-dialog']); /* global configDialog */
  245. configDialog(style);
  246. }
  247. }
  248. function showError(err) {
  249. $('.warnings').textContent = '';
  250. $('.warnings').classList.toggle('visible', Boolean(err));
  251. $('.container').classList.toggle('has-warnings', Boolean(err));
  252. err = Array.isArray(err) ? err : [err];
  253. if (err[0]) {
  254. let i;
  255. if ((i = err[0].index) >= 0 ||
  256. (i = err[0].offset) >= 0) {
  257. cm.jumpToPos(cm.posFromIndex(i));
  258. cm.setSelections(err.map(e => {
  259. const pos = e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
  260. e.offset >= 0 && {line: e.line - 1, ch: e.col - 1}; // csslint code parser
  261. return pos && {anchor: pos, head: pos};
  262. }).filter(Boolean));
  263. cm.focus();
  264. }
  265. $('.warnings').appendChild(
  266. $create('.warning', [
  267. t('parseUsercssError'),
  268. '\n',
  269. ...err.map(e => e.message ? $create('pre', e.message) : e || 'Unknown error'),
  270. ]));
  271. }
  272. adjustCodeHeight();
  273. }
  274. function showBuildError(error) {
  275. $('#header').classList.add('meta-init-error');
  276. console.error(error);
  277. showError(error);
  278. }
  279. function install(style) {
  280. installed = style;
  281. $$remove('.warning');
  282. $('button.install').disabled = true;
  283. $('button.install').classList.add('installed');
  284. $('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
  285. $('h2.installed').classList.add('active');
  286. $('.set-update-url input[type=checkbox]').disabled = true;
  287. $('.set-update-url').title = style.updateUrl ?
  288. t('installUpdateFrom', style.updateUrl) : '';
  289. $('.set-prefer-scheme select').disabled = true;
  290. enablePostActions();
  291. updateMeta(style);
  292. }
  293. function enablePostActions() {
  294. const {id} = installed || installedDup;
  295. sessionStorage.justEditedStyleId = id;
  296. $('h2.installed').hidden = !installed;
  297. $('.installed-actions').hidden = false;
  298. $('.installed-actions a[href*="edit.html"]').search = `?id=${id}`;
  299. $('#delete').onclick = async () => {
  300. if (await messageBox.confirm(t('deleteStyleConfirm'), 'danger center', t('confirmDelete'))) {
  301. await API.styles.delete(id);
  302. if (tabId < 0 && history.length > 1) {
  303. history.back();
  304. } else {
  305. closeCurrentTab();
  306. }
  307. }
  308. };
  309. }
  310. async function getAppliesTo(style) {
  311. if (style.sectionsPromise) {
  312. try {
  313. style.sections = await style.sectionsPromise;
  314. } catch (error) {
  315. showBuildError(error);
  316. return [];
  317. } finally {
  318. delete style.sectionsPromise;
  319. }
  320. }
  321. let numGlobals = 0;
  322. const res = [];
  323. const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
  324. for (const section of style.sections) {
  325. const targets = [].concat(...TARGETS.map(t => section[t]).filter(Boolean));
  326. res.push(...targets);
  327. numGlobals += !targets.length && !styleCodeEmpty(section.code);
  328. }
  329. res.sort();
  330. if (!res.length || numGlobals) {
  331. res.push(t('appliesToEverything'));
  332. }
  333. return [...new Set(res)];
  334. }
  335. function adjustCodeHeight() {
  336. // Chrome-only bug (apparently): it doesn't limit the scroller element height
  337. const scroller = cm.display.scroller;
  338. const prevWindowHeight = adjustCodeHeight.prevWindowHeight;
  339. if (scroller.scrollHeight === scroller.clientHeight ||
  340. prevWindowHeight && window.innerHeight !== prevWindowHeight) {
  341. adjustCodeHeight.prevWindowHeight = window.innerHeight;
  342. cm.setSize(null, $('.main').offsetHeight - $('.warnings').offsetHeight);
  343. }
  344. }
  345. function initLiveReload() {
  346. const DELAY = 500;
  347. let isEnabled = false;
  348. let timer = 0;
  349. const getData = preinit.getData;
  350. let sequence = preinit.ready;
  351. return {
  352. get enabled() {
  353. return isEnabled;
  354. },
  355. onToggled(e) {
  356. if (e) isEnabled = e.target.checked;
  357. if (installed || installedDup) {
  358. if (isEnabled) {
  359. check({force: true});
  360. } else {
  361. stop();
  362. }
  363. $('.install').disabled = isEnabled;
  364. Object.assign($('#live-reload-install-hint'), {
  365. hidden: !isEnabled,
  366. textContent: t(`liveReloadInstallHint${tabId >= 0 ? 'FF' : ''}`),
  367. });
  368. }
  369. },
  370. };
  371. function check(opts) {
  372. getData(opts)
  373. .then(update, logError)
  374. .then(() => {
  375. timer = 0;
  376. start();
  377. });
  378. }
  379. function logError(error) {
  380. console.warn(t('liveReloadError', error));
  381. }
  382. function start() {
  383. timer = timer || setTimeout(check, DELAY);
  384. }
  385. function stop() {
  386. clearTimeout(timer);
  387. timer = 0;
  388. }
  389. function update(code) {
  390. if (code == null) return;
  391. sequence = sequence.catch(console.error).then(() => {
  392. const {id} = installed || installedDup;
  393. const scrollInfo = cm.getScrollInfo();
  394. const cursor = cm.getCursor();
  395. cm.setValue(code);
  396. cm.setCursor(cursor);
  397. cm.scrollTo(scrollInfo.left, scrollInfo.top);
  398. return API.usercss.install({id, sourceCode: code})
  399. .then(updateMeta)
  400. .catch(showError);
  401. });
  402. }
  403. }