manage.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. /* global messageBox, getStyleWithNoCode, retranslateCSS */
  2. /* global filtersSelector, filterAndAppend */
  3. /* global checkUpdate, handleUpdateInstalled */
  4. /* global objectDiff */
  5. /* global configDialog */
  6. 'use strict';
  7. let installed;
  8. const ENTRY_ID_PREFIX_RAW = 'style-';
  9. const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
  10. const newUI = {
  11. enabled: prefs.get('manage.newUI'),
  12. favicons: prefs.get('manage.newUI.favicons'),
  13. faviconsGray: prefs.get('manage.newUI.faviconsGray'),
  14. targets: prefs.get('manage.newUI.targets'),
  15. renderClass() {
  16. document.documentElement.classList.toggle('newUI', newUI.enabled);
  17. },
  18. };
  19. newUI.renderClass();
  20. usePrefsDuringPageLoad();
  21. const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps'];
  22. const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
  23. const OWN_ICON = chrome.runtime.getManifest().icons['16'];
  24. const handleEvent = {};
  25. Promise.all([
  26. getStylesSafe(),
  27. onDOMready().then(initGlobalEvents),
  28. ]).then(([styles]) => {
  29. showStyles(styles);
  30. });
  31. dieOnNullBackground();
  32. chrome.runtime.onMessage.addListener(onRuntimeMessage);
  33. function onRuntimeMessage(msg) {
  34. switch (msg.method) {
  35. case 'styleUpdated':
  36. case 'styleAdded':
  37. handleUpdate(msg.style, msg);
  38. break;
  39. case 'styleDeleted':
  40. handleDelete(msg.id);
  41. break;
  42. }
  43. }
  44. function initGlobalEvents() {
  45. installed = $('#installed');
  46. installed.onclick = handleEvent.entryClicked;
  47. $('#manage-options-button').onclick = () => chrome.runtime.openOptionsPage();
  48. $('#manage-shortcuts-button').onclick = () => openURL({url: URLS.configureCommands});
  49. $$('#header a[href^="http"]').forEach(a => (a.onclick = handleEvent.external));
  50. // focus search field on / key
  51. document.onkeypress = event => {
  52. if ((event.keyCode || event.which) === 47
  53. && !event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey
  54. && !event.target.matches('[type="text"], [type="search"]')) {
  55. event.preventDefault();
  56. $('#search').focus();
  57. }
  58. };
  59. // remember scroll position on normal history navigation
  60. window.onbeforeunload = rememberScrollPosition;
  61. $$('[data-toggle-on-click]').forEach(el => {
  62. // dataset on SVG doesn't work in Chrome 49-??, works in 57+
  63. const target = $(el.getAttribute('data-toggle-on-click'));
  64. el.onclick = () => target.classList.toggle('hidden');
  65. });
  66. // triggered automatically by setupLivePrefs() below
  67. enforceInputRange($('#manage.newUI.targets'));
  68. // N.B. triggers existing onchange listeners
  69. setupLivePrefs();
  70. $$('[id^="manage.newUI"]')
  71. .forEach(el => (el.oninput = (el.onchange = switchUI)));
  72. switchUI({styleOnly: true});
  73. // translate CSS manually
  74. document.head.appendChild($element({tag: 'style', textContent: `
  75. .disabled h2::after {
  76. content: "${t('genericDisabledLabel')}";
  77. }
  78. #update-all-no-updates[data-skipped-edited="true"]:after {
  79. content: " ${t('updateAllCheckSucceededSomeEdited')}";
  80. }
  81. `}));
  82. }
  83. function showStyles(styles = []) {
  84. const sorted = styles
  85. .map(style => ({name: style.name.toLocaleLowerCase(), style}))
  86. .sort((a, b) => (a.name < b.name ? -1 : a.name === b.name ? 0 : 1));
  87. let index = 0;
  88. const scrollY = (history.state || {}).scrollY;
  89. const shouldRenderAll = scrollY > window.innerHeight || sessionStorage.justEditedStyleId;
  90. const renderBin = document.createDocumentFragment();
  91. if (scrollY) {
  92. renderStyles();
  93. } else {
  94. requestAnimationFrame(renderStyles);
  95. }
  96. function renderStyles() {
  97. const t0 = performance.now();
  98. let rendered = 0;
  99. while (
  100. index < sorted.length &&
  101. // eslint-disable-next-line no-unmodified-loop-condition
  102. (shouldRenderAll || ++rendered < 10 || performance.now() - t0 < 10)
  103. ) {
  104. renderBin.appendChild(createStyleElement(sorted[index++]));
  105. }
  106. filterAndAppend({container: renderBin});
  107. if (index < sorted.length) {
  108. requestAnimationFrame(renderStyles);
  109. return;
  110. }
  111. if ('scrollY' in (history.state || {})) {
  112. setTimeout(window.scrollTo, 0, 0, history.state.scrollY);
  113. }
  114. if (newUI.enabled && newUI.favicons) {
  115. debounce(handleEvent.loadFavicons, 16);
  116. }
  117. if (sessionStorage.justEditedStyleId) {
  118. const entry = $(ENTRY_ID_PREFIX + sessionStorage.justEditedStyleId);
  119. delete sessionStorage.justEditedStyleId;
  120. if (entry) {
  121. animateElement(entry);
  122. scrollElementIntoView(entry);
  123. }
  124. }
  125. }
  126. }
  127. function createStyleElement({style, name}) {
  128. // query the sub-elements just once, then reuse the references
  129. if ((createStyleElement.parts || {}).newUI !== newUI.enabled) {
  130. const entry = template[`style${newUI.enabled ? 'Compact' : ''}`];
  131. createStyleElement.parts = {
  132. newUI: newUI.enabled,
  133. entry,
  134. entryClassBase: entry.className,
  135. checker: $('.checker', entry) || {},
  136. nameLink: $('.style-name-link', entry),
  137. editLink: $('.style-edit-link', entry) || {},
  138. editHrefBase: 'edit.html?id=',
  139. homepage: $('.homepage', entry),
  140. homepageIcon: template[`homepageIcon${newUI.enabled ? 'Small' : 'Big'}`],
  141. appliesTo: $('.applies-to', entry),
  142. targets: $('.targets', entry),
  143. expander: $('.expander', entry),
  144. decorations: {
  145. urlPrefixesAfter: '*',
  146. regexpsBefore: '/',
  147. regexpsAfter: '/',
  148. },
  149. };
  150. }
  151. const parts = createStyleElement.parts;
  152. parts.checker.checked = style.enabled;
  153. parts.nameLink.textContent = style.name;
  154. parts.nameLink.href = parts.editLink.href = parts.editHrefBase + style.id;
  155. parts.homepage.href = parts.homepage.title = style.url || '';
  156. const entry = parts.entry.cloneNode(true);
  157. entry.id = ENTRY_ID_PREFIX_RAW + style.id;
  158. entry.styleId = style.id;
  159. entry.styleNameLowerCase = name || style.name.toLocaleLowerCase();
  160. entry.styleMeta = getStyleWithNoCode(style);
  161. entry.className = parts.entryClassBase + ' ' +
  162. (style.enabled ? 'enabled' : 'disabled') +
  163. (style.updateUrl ? ' updatable' : '') +
  164. (style.usercssData ? ' usercss' : '');
  165. if (style.url) {
  166. $('.homepage', entry).appendChild(parts.homepageIcon.cloneNode(true));
  167. }
  168. if (style.updateUrl && newUI.enabled) {
  169. $('.actions', entry).appendChild(template.updaterIcons.cloneNode(true));
  170. }
  171. if (shouldShowConfig() && newUI.enabled) {
  172. $('.actions', entry).appendChild(template.configureIcon.cloneNode(true));
  173. }
  174. // name being supplied signifies we're invoked by showStyles()
  175. // which debounces its main loop thus loading the postponed favicons
  176. createStyleTargetsElement({entry, style, postponeFavicons: name});
  177. return entry;
  178. function shouldShowConfig() {
  179. return style.usercssData && Object.keys(style.usercssData.vars).length > 0;
  180. }
  181. }
  182. function createStyleTargetsElement({entry, style, postponeFavicons}) {
  183. const parts = createStyleElement.parts;
  184. const targets = parts.targets.cloneNode(true);
  185. let container = targets;
  186. let numTargets = 0;
  187. let numIcons = 0;
  188. const displayed = new Set();
  189. for (const type of TARGET_TYPES) {
  190. for (const section of style.sections) {
  191. for (const targetValue of section[type] || []) {
  192. if (displayed.has(targetValue)) {
  193. continue;
  194. }
  195. displayed.add(targetValue);
  196. const element = template.appliesToTarget.cloneNode(true);
  197. if (!newUI.enabled) {
  198. if (numTargets === 10) {
  199. container = container.appendChild(template.extraAppliesTo.cloneNode(true));
  200. } else if (numTargets > 1) {
  201. container.appendChild(template.appliesToSeparator.cloneNode(true));
  202. }
  203. } else if (newUI.favicons) {
  204. let favicon = '';
  205. if (type === 'domains') {
  206. favicon = GET_FAVICON_URL + targetValue;
  207. } else if (targetValue.startsWith('chrome-extension:')) {
  208. favicon = OWN_ICON;
  209. } else if (type !== 'regexps') {
  210. favicon = targetValue.includes('://') && targetValue.match(/^.*?:\/\/([^/]+)/);
  211. favicon = favicon ? GET_FAVICON_URL + favicon[1] : '';
  212. }
  213. if (favicon) {
  214. element.appendChild(document.createElement('img')).dataset.src = favicon;
  215. numIcons++;
  216. }
  217. }
  218. element.appendChild(
  219. document.createTextNode(
  220. (parts.decorations[type + 'Before'] || '') +
  221. targetValue +
  222. (parts.decorations[type + 'After'] || '')));
  223. container.appendChild(element);
  224. numTargets++;
  225. }
  226. }
  227. }
  228. if (newUI.enabled) {
  229. if (numTargets > newUI.targets) {
  230. $('.applies-to', entry).classList.add('has-more');
  231. }
  232. if (numIcons && !postponeFavicons) {
  233. debounce(handleEvent.loadFavicons);
  234. }
  235. }
  236. const entryTargets = $('.targets', entry);
  237. if (numTargets) {
  238. entryTargets.parentElement.replaceChild(targets, entryTargets);
  239. } else {
  240. entryTargets.appendChild(template.appliesToEverything.cloneNode(true));
  241. }
  242. entry.classList.toggle('global', !numTargets);
  243. }
  244. Object.assign(handleEvent, {
  245. ENTRY_ROUTES: {
  246. '.checker, .enable, .disable': 'toggle',
  247. '.style-name-link': 'edit',
  248. '.homepage': 'external',
  249. '.check-update': 'check',
  250. '.update': 'update',
  251. '.delete': 'delete',
  252. '.applies-to .expander': 'expandTargets',
  253. '.configure-usercss': 'config'
  254. },
  255. config(event, {styleMeta: style}) {
  256. configDialog(style).then(vars => {
  257. if (!vars) {
  258. return;
  259. }
  260. const keys = Object.keys(vars).filter(k => vars[k].dirty);
  261. if (!keys.length) {
  262. return;
  263. }
  264. style.reason = 'config';
  265. for (const key of keys) {
  266. style.usercssData.vars[key].value = vars[key].value;
  267. }
  268. onBackgroundReady()
  269. .then(() => BG.usercssHelper.save(style));
  270. });
  271. },
  272. entryClicked(event) {
  273. const target = event.target;
  274. const entry = target.closest('.entry');
  275. for (const selector in handleEvent.ENTRY_ROUTES) {
  276. for (let el = target; el && el !== entry; el = el.parentElement) {
  277. if (el.matches(selector)) {
  278. const handler = handleEvent.ENTRY_ROUTES[selector];
  279. return handleEvent[handler].call(el, event, entry);
  280. }
  281. }
  282. }
  283. },
  284. edit(event) {
  285. if (event.altKey) {
  286. return;
  287. }
  288. event.preventDefault();
  289. event.stopPropagation();
  290. const left = event.button === 0;
  291. const middle = event.button === 1;
  292. const shift = event.shiftKey;
  293. const ctrl = event.ctrlKey;
  294. const openWindow = left && shift && !ctrl;
  295. const openBackgroundTab = (middle && !shift) || (left && ctrl && !shift);
  296. const openForegroundTab = (middle && shift) || (left && ctrl && shift);
  297. const url = event.target.closest('[href]').href;
  298. if (openWindow || openBackgroundTab || openForegroundTab) {
  299. if (chrome.windows && openWindow) {
  300. chrome.windows.create(Object.assign(prefs.get('windowPosition'), {url}));
  301. } else {
  302. openURL({url, active: openForegroundTab});
  303. }
  304. } else {
  305. rememberScrollPosition();
  306. getActiveTab().then(tab => {
  307. sessionStorageHash('manageStylesHistory').set(tab.id, url);
  308. location.href = url;
  309. });
  310. }
  311. },
  312. toggle(event, entry) {
  313. saveStyleSafe({
  314. id: entry.styleId,
  315. enabled: this.matches('.enable') || this.checked,
  316. });
  317. },
  318. check(event, entry) {
  319. checkUpdate(entry);
  320. },
  321. update(event, entry) {
  322. const request = Object.assign(entry.updatedCode, {
  323. id: entry.styleId,
  324. reason: 'update',
  325. });
  326. if (entry.updatedCode.usercssData) {
  327. onBackgroundReady()
  328. .then(() => BG.usercssHelper.save(request));
  329. } else {
  330. // update everything but name
  331. request.name = null;
  332. saveStyleSafe(request);
  333. }
  334. },
  335. delete(event, entry) {
  336. const id = entry.styleId;
  337. const {name} = BG.cachedStyles.byId.get(id) || {};
  338. animateElement(entry);
  339. messageBox({
  340. title: t('deleteStyleConfirm'),
  341. contents: name,
  342. className: 'danger center',
  343. buttons: [t('confirmDelete'), t('confirmCancel')],
  344. })
  345. .then(({button, enter}) => {
  346. if (button === 0 || enter) {
  347. deleteStyleSafe({id});
  348. }
  349. });
  350. },
  351. external(event) {
  352. openURL({url: event.target.closest('a').href});
  353. event.preventDefault();
  354. },
  355. expandTargets() {
  356. this.closest('.applies-to').classList.toggle('expanded');
  357. },
  358. loadFavicons(container = document.body) {
  359. for (const img of $$('img', container)) {
  360. if (img.dataset.src) {
  361. img.src = img.dataset.src;
  362. delete img.dataset.src;
  363. }
  364. }
  365. },
  366. });
  367. function handleUpdate(style, {reason, method} = {}) {
  368. let entry;
  369. let oldEntry = $(ENTRY_ID_PREFIX + style.id);
  370. if (oldEntry && method === 'styleUpdated') {
  371. handleToggledOrCodeOnly();
  372. }
  373. entry = entry || createStyleElement({style});
  374. if (oldEntry) {
  375. if (oldEntry.styleNameLowerCase === entry.styleNameLowerCase) {
  376. installed.replaceChild(entry, oldEntry);
  377. } else {
  378. oldEntry.remove();
  379. }
  380. }
  381. if (reason === 'update' && entry.matches('.updatable')) {
  382. handleUpdateInstalled(entry);
  383. }
  384. filterAndAppend({entry});
  385. if (!entry.matches('.hidden') && reason !== 'import') {
  386. animateElement(entry);
  387. scrollElementIntoView(entry);
  388. }
  389. function handleToggledOrCodeOnly() {
  390. const newStyleMeta = getStyleWithNoCode(style);
  391. const diff = objectDiff(oldEntry.styleMeta, newStyleMeta);
  392. if (diff.length === 0) {
  393. // only code was modified
  394. entry = oldEntry;
  395. oldEntry = null;
  396. }
  397. if (diff.length === 1 && diff[0].key === 'enabled') {
  398. oldEntry.classList.toggle('enabled', style.enabled);
  399. oldEntry.classList.toggle('disabled', !style.enabled);
  400. $$('.checker', oldEntry).forEach(el => (el.checked = style.enabled));
  401. oldEntry.styleMeta = newStyleMeta;
  402. entry = oldEntry;
  403. oldEntry = null;
  404. }
  405. }
  406. }
  407. function handleDelete(id) {
  408. const node = $(ENTRY_ID_PREFIX + id);
  409. if (node) {
  410. node.remove();
  411. if (node.matches('.can-update')) {
  412. const btnApply = $('#apply-all-updates');
  413. btnApply.dataset.value = Number(btnApply.dataset.value) - 1;
  414. }
  415. }
  416. }
  417. function switchUI({styleOnly} = {}) {
  418. const current = {};
  419. const changed = {};
  420. let someChanged = false;
  421. // ensure the global option is processed first
  422. for (const el of [$('#manage.newUI'), ...$$('[id^="manage.newUI."]')]) {
  423. const id = el.id.replace(/^manage\.newUI\.?/, '') || 'enabled';
  424. const value = el.type === 'checkbox' ? el.checked : Number(el.value);
  425. const valueChanged = value !== newUI[id] && (id === 'enabled' || current.enabled);
  426. current[id] = value;
  427. changed[id] = valueChanged;
  428. someChanged |= valueChanged;
  429. }
  430. if (!styleOnly && !someChanged) {
  431. return;
  432. }
  433. Object.assign(newUI, current);
  434. newUI.renderClass();
  435. installed.classList.toggle('has-favicons', newUI.favicons);
  436. $('#style-overrides').textContent = `
  437. .newUI .targets {
  438. max-height: ${newUI.targets * 18}px;
  439. }
  440. ` + (newUI.faviconsGray ? `
  441. .newUI .target img {
  442. -webkit-filter: grayscale(1);
  443. filter: grayscale(1);
  444. opacity: .25;
  445. }
  446. ` : `
  447. .newUI .target img {
  448. -webkit-filter: none;
  449. filter: none;
  450. opacity: 1;
  451. }
  452. `);
  453. if (styleOnly) {
  454. return;
  455. }
  456. const missingFavicons = newUI.enabled && newUI.favicons && !$('.applies-to img');
  457. if (changed.enabled || (missingFavicons && !createStyleElement.parts)) {
  458. installed.textContent = '';
  459. getStylesSafe().then(showStyles);
  460. return;
  461. }
  462. if (changed.targets) {
  463. for (const targets of $$('.entry .targets')) {
  464. const hasMore = targets.children.length > newUI.targets;
  465. targets.parentElement.classList.toggle('has-more', hasMore);
  466. }
  467. return;
  468. }
  469. if (missingFavicons) {
  470. getStylesSafe().then(styles => {
  471. for (const style of styles) {
  472. const entry = $(ENTRY_ID_PREFIX + style.id);
  473. if (entry) {
  474. createStyleTargetsElement({entry, style, postponeFavicons: true});
  475. }
  476. }
  477. debounce(handleEvent.loadFavicons);
  478. });
  479. return;
  480. }
  481. }
  482. function rememberScrollPosition() {
  483. history.replaceState({scrollY: window.scrollY}, document.title);
  484. }
  485. function usePrefsDuringPageLoad() {
  486. const observer = new MutationObserver(mutations => {
  487. const adjustedNodes = [];
  488. for (const mutation of mutations) {
  489. for (const node of mutation.addedNodes) {
  490. // [naively] assuming each element of addedNodes is a childless element
  491. const prefValue = node.id ? prefs.readOnlyValues[node.id] : undefined;
  492. if (prefValue !== undefined) {
  493. if (node.type === 'checkbox') {
  494. node.checked = prefValue;
  495. } else {
  496. node.value = prefValue;
  497. }
  498. if (node.adjustWidth) {
  499. adjustedNodes.push(node);
  500. }
  501. }
  502. }
  503. }
  504. if (adjustedNodes.length) {
  505. observer.disconnect();
  506. for (const node of adjustedNodes) {
  507. node.adjustWidth();
  508. }
  509. startObserver();
  510. }
  511. });
  512. function startObserver() {
  513. observer.observe(document, {subtree: true, childList: true});
  514. }
  515. startObserver();
  516. onDOMready().then(() => observer.disconnect());
  517. }
  518. // TODO: remove when these bugs are fixed in FF
  519. function dieOnNullBackground() {
  520. if (!FIREFOX || BG) {
  521. return;
  522. }
  523. sendMessage({method: 'healthCheck'}, health => {
  524. if (health && !chrome.extension.getBackgroundPage()) {
  525. onDOMready().then(() => {
  526. sendMessage({method: 'getStyles'}, showStyles);
  527. messageBox({
  528. title: 'Stylus',
  529. className: 'danger center',
  530. contents: t('dysfunctionalBackgroundConnection'),
  531. onshow: () => {
  532. $('#message-box-close-icon').remove();
  533. window.removeEventListener('keydown', messageBox.listeners.key, true);
  534. }
  535. });
  536. document.documentElement.style.pointerEvents = 'none';
  537. });
  538. }
  539. });
  540. }