popup.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. /* global retranslateCSS */
  2. 'use strict';
  3. let installed;
  4. let tabURL;
  5. const handleEvent = {};
  6. const ENTRY_ID_PREFIX_RAW = 'style-';
  7. const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
  8. toggleSideBorders();
  9. getActiveTab().then(tab =>
  10. FIREFOX && tab.url === 'about:blank' && tab.status === 'loading'
  11. ? getTabRealURLFirefox(tab)
  12. : getTabRealURL(tab)
  13. ).then(url => {
  14. tabURL = URLS.supported(url) ? url : '';
  15. Promise.all([
  16. tabURL && getStylesSafe({matchUrl: tabURL}),
  17. onDOMready().then(() => {
  18. initPopup(tabURL);
  19. }),
  20. ]).then(([styles]) => {
  21. showStyles(styles);
  22. });
  23. });
  24. chrome.runtime.onMessage.addListener(onRuntimeMessage);
  25. function onRuntimeMessage(msg) {
  26. switch (msg.method) {
  27. case 'styleAdded':
  28. case 'styleUpdated':
  29. // notifyAllTabs sets msg.style's code to null so we have to get the actual style
  30. // because we analyze its code in detectSloppyRegexps
  31. handleUpdate(BG.cachedStyles.byId.get(msg.style.id));
  32. break;
  33. case 'styleDeleted':
  34. handleDelete(msg.id);
  35. break;
  36. case 'prefChanged':
  37. if ('popup.stylesFirst' in msg.prefs) {
  38. const stylesFirst = msg.prefs['popup.stylesFirst'];
  39. const actions = $('body > .actions');
  40. const before = stylesFirst ? actions : actions.nextSibling;
  41. document.body.insertBefore(installed, before);
  42. } else if ('popupWidth' in msg.prefs) {
  43. setPopupWidth(msg.prefs.popupWidth);
  44. } else if ('popup.borders' in msg.prefs) {
  45. toggleSideBorders(msg.prefs['popup.borders']);
  46. }
  47. break;
  48. }
  49. }
  50. function setPopupWidth(width = prefs.get('popupWidth')) {
  51. document.body.style.width =
  52. Math.max(200, Math.min(800, width)) + 'px';
  53. }
  54. function toggleSideBorders(state = prefs.get('popup.borders')) {
  55. // runs before <body> is parsed
  56. const style = document.documentElement.style;
  57. if (CHROME >= 3167 && state) {
  58. style.cssText +=
  59. 'border-left: 2px solid white !important;' +
  60. 'border-right: 2px solid white !important;';
  61. } else if (style.cssText) {
  62. style.borderLeft = style.borderRight = '';
  63. }
  64. }
  65. function initPopup(url) {
  66. installed = $('#installed');
  67. setPopupWidth();
  68. // action buttons
  69. $('#disableAll').onchange = function () {
  70. installed.classList.toggle('disabled', this.checked);
  71. };
  72. setupLivePrefs();
  73. Object.assign($('#popup-manage-button'), {
  74. onclick: handleEvent.openManager,
  75. onmouseup: handleEvent.openManager,
  76. oncontextmenu: handleEvent.openManager,
  77. });
  78. $('#popup-options-button').onclick = () => {
  79. chrome.runtime.openOptionsPage();
  80. window.close();
  81. };
  82. const shortcutsButton = $('#popup-shortcuts-button');
  83. shortcutsButton.dataset.href = URLS.configureCommands;
  84. shortcutsButton.onclick = handleEvent.openURLandHide;
  85. if (!prefs.get('popup.stylesFirst')) {
  86. document.body.insertBefore(
  87. $('body > .actions'),
  88. installed);
  89. }
  90. $('#find-styles-link').onclick = handleEvent.openURLandHide;
  91. $('#find-styles-link').href +=
  92. url.startsWith(location.protocol) ?
  93. '?search_terms=Stylus' :
  94. 'all/' + encodeURIComponent(url.startsWith('file:') ? 'file:' : url);
  95. if (!url) {
  96. document.body.classList.add('blocked');
  97. document.body.insertBefore(template.unavailableInfo, document.body.firstChild);
  98. return;
  99. }
  100. getActiveTab().then(function ping(tab, retryCountdown = 10) {
  101. sendMessage({tabId: tab.id, method: 'ping', frameId: 0}, pong => {
  102. if (pong) {
  103. return;
  104. }
  105. ignoreChromeError();
  106. // FF and some Chrome forks (e.g. CentBrowser) implement tab-on-demand
  107. // so we'll wait a bit to handle popup being invoked right after switching
  108. if (
  109. retryCountdown > 0 && (
  110. tab.status !== 'complete' ||
  111. FIREFOX && tab.url === 'about:blank'
  112. )
  113. ) {
  114. setTimeout(ping, 100, tab, --retryCountdown);
  115. } else {
  116. document.body.classList.add('unreachable');
  117. document.body.insertBefore(template.unreachableInfo, document.body.firstChild);
  118. }
  119. });
  120. });
  121. // Write new style links
  122. const writeStyle = $('#write-style');
  123. const matchTargets = document.createElement('span');
  124. const matchWrapper = document.createElement('span');
  125. matchWrapper.id = 'match';
  126. matchWrapper.appendChild(matchTargets);
  127. // For this URL
  128. const urlLink = template.writeStyle.cloneNode(true);
  129. Object.assign(urlLink, {
  130. href: 'edit.html?url-prefix=' + encodeURIComponent(url),
  131. title: `url-prefix("${url}")`,
  132. textContent: prefs.get('popup.breadcrumbs.usePath')
  133. ? new URL(url).pathname.slice(1)
  134. // this&nbsp;URL
  135. : t('writeStyleForURL').replace(/ /g, '\u00a0'),
  136. onclick: handleEvent.openLink,
  137. });
  138. if (prefs.get('popup.breadcrumbs')) {
  139. urlLink.onmouseenter =
  140. urlLink.onfocus = () => urlLink.parentNode.classList.add('url()');
  141. urlLink.onmouseleave =
  142. urlLink.onblur = () => urlLink.parentNode.classList.remove('url()');
  143. }
  144. matchTargets.appendChild(urlLink);
  145. // For domain
  146. const domains = BG.getDomains(url);
  147. for (const domain of domains) {
  148. const numParts = domain.length - domain.replace(/\./g, '').length + 1;
  149. // Don't include TLD
  150. if (domains.length > 1 && numParts === 1) {
  151. continue;
  152. }
  153. const domainLink = template.writeStyle.cloneNode(true);
  154. Object.assign(domainLink, {
  155. href: 'edit.html?domain=' + encodeURIComponent(domain),
  156. textContent: numParts > 2 ? domain.split('.')[0] : domain,
  157. title: `domain("${domain}")`,
  158. onclick: handleEvent.openLink,
  159. });
  160. domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : '');
  161. matchTargets.appendChild(domainLink);
  162. }
  163. if (prefs.get('popup.breadcrumbs')) {
  164. matchTargets.classList.add('breadcrumbs');
  165. matchTargets.appendChild(matchTargets.removeChild(matchTargets.firstElementChild));
  166. }
  167. writeStyle.appendChild(matchWrapper);
  168. }
  169. function showStyles(styles) {
  170. if (!styles) {
  171. return;
  172. }
  173. if (!styles.length) {
  174. installed.textContent = '';
  175. installed.appendChild(template.noStyles.cloneNode(true));
  176. return;
  177. }
  178. const enabledFirst = prefs.get('popup.enabledFirst');
  179. styles.sort((a, b) => (
  180. enabledFirst && a.enabled !== b.enabled
  181. ? !(a.enabled < b.enabled) ? -1 : 1
  182. : a.name.localeCompare(b.name)
  183. ));
  184. let postponeDetect = false;
  185. const t0 = performance.now();
  186. const container = document.createDocumentFragment();
  187. for (const style of styles) {
  188. createStyleElement({style, container, postponeDetect});
  189. postponeDetect = postponeDetect || performance.now() - t0 > 100;
  190. }
  191. installed.appendChild(container);
  192. getStylesSafe({matchUrl: tabURL, strictRegexp: false})
  193. .then(unscreenedStyles => {
  194. for (const unscreened of unscreenedStyles) {
  195. if (!styles.includes(unscreened)) {
  196. postponeDetect = postponeDetect || performance.now() - t0 > 100;
  197. createStyleElement({
  198. style: Object.assign({appliedSections: [], postponeDetect}, unscreened),
  199. });
  200. }
  201. }
  202. });
  203. }
  204. function createStyleElement({
  205. style,
  206. container = installed,
  207. postponeDetect,
  208. }) {
  209. const entry = template.style.cloneNode(true);
  210. entry.setAttribute('style-id', style.id);
  211. Object.assign(entry, {
  212. id: ENTRY_ID_PREFIX_RAW + style.id,
  213. styleId: style.id,
  214. className: entry.className + ' ' + (style.enabled ? 'enabled' : 'disabled'),
  215. onmousedown: handleEvent.maybeEdit,
  216. });
  217. const checkbox = $('.checker', entry);
  218. Object.assign(checkbox, {
  219. id: ENTRY_ID_PREFIX_RAW + style.id,
  220. checked: style.enabled,
  221. onclick: handleEvent.toggle,
  222. });
  223. const editLink = $('.style-edit-link', entry);
  224. Object.assign(editLink, {
  225. href: editLink.getAttribute('href') + style.id,
  226. onclick: handleEvent.openLink,
  227. });
  228. const styleName = $('.style-name', entry);
  229. Object.assign(styleName, {
  230. htmlFor: ENTRY_ID_PREFIX_RAW + style.id,
  231. onclick: handleEvent.name,
  232. });
  233. styleName.checkbox = checkbox;
  234. styleName.appendChild(document.createTextNode(style.name));
  235. $('.enable', entry).onclick = handleEvent.toggle;
  236. $('.disable', entry).onclick = handleEvent.toggle;
  237. $('.delete', entry).onclick = handleEvent.delete;
  238. invokeOrPostpone(!postponeDetect, detectSloppyRegexps, {entry, style});
  239. const oldElement = $(ENTRY_ID_PREFIX + style.id);
  240. if (oldElement) {
  241. oldElement.parentNode.replaceChild(entry, oldElement);
  242. } else {
  243. container.appendChild(entry);
  244. }
  245. }
  246. Object.assign(handleEvent, {
  247. getClickedStyleId(event) {
  248. return (handleEvent.getClickedStyleElement(event) || {}).styleId;
  249. },
  250. getClickedStyleElement(event) {
  251. return event.target.closest('.entry');
  252. },
  253. name(event) {
  254. this.checkbox.click();
  255. event.preventDefault();
  256. },
  257. toggle(event) {
  258. saveStyleSafe({
  259. id: handleEvent.getClickedStyleId(event),
  260. enabled: this.type === 'checkbox' ? this.checked : this.matches('.enable'),
  261. });
  262. },
  263. delete(event) {
  264. const id = handleEvent.getClickedStyleId(event);
  265. const box = $('#confirm');
  266. box.dataset.display = true;
  267. box.style.cssText = '';
  268. $('b', box).textContent = (BG.cachedStyles.byId.get(id) || {}).name;
  269. $('[data-cmd="ok"]', box).onclick = () => confirm(true);
  270. $('[data-cmd="cancel"]', box).onclick = () => confirm(false);
  271. window.onkeydown = event => {
  272. const keyCode = event.keyCode || event.which;
  273. if (!event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey
  274. && (keyCode === 13 || keyCode === 27)) {
  275. event.preventDefault();
  276. confirm(keyCode === 13);
  277. }
  278. };
  279. function confirm(ok) {
  280. window.onkeydown = null;
  281. animateElement(box, {
  282. className: 'lights-on',
  283. onComplete: () => (box.dataset.display = false),
  284. });
  285. if (ok) {
  286. deleteStyleSafe({id}).then(() => {
  287. // don't wait for the async notifyAllTabs as we check the children right away
  288. handleDelete(id);
  289. // update view with 'No styles installed for this site' message
  290. if (!installed.children.length) {
  291. showStyles([]);
  292. }
  293. });
  294. }
  295. }
  296. },
  297. indicator(event) {
  298. const entry = handleEvent.getClickedStyleElement(event);
  299. const info = template.regexpProblemExplanation.cloneNode(true);
  300. $$('#' + info.id).forEach(el => el.remove());
  301. $$('a', info).forEach(el => (el.onclick = handleEvent.openURLandHide));
  302. $$('button', info).forEach(el => (el.onclick = handleEvent.closeExplanation));
  303. entry.appendChild(info);
  304. },
  305. closeExplanation() {
  306. $('#regexp-explanation').remove();
  307. },
  308. openLink(event) {
  309. if (!chrome.windows || !prefs.get('openEditInWindow', false)) {
  310. handleEvent.openURLandHide.call(this, event);
  311. return;
  312. }
  313. event.preventDefault();
  314. chrome.windows.create(
  315. Object.assign({
  316. url: this.href
  317. }, prefs.get('windowPosition', {}))
  318. );
  319. close();
  320. },
  321. maybeEdit(event) {
  322. if (!(
  323. event.button === 0 && (event.ctrlKey || event.metaKey) ||
  324. event.button === 1 ||
  325. event.button === 2)) {
  326. return;
  327. }
  328. // open an editor on middleclick
  329. if (event.target.matches('.entry, .style-name, .style-edit-link')) {
  330. this.onmouseup = () => $('.style-edit-link', this).click();
  331. this.oncontextmenu = event => event.preventDefault();
  332. event.preventDefault();
  333. return;
  334. }
  335. // prevent the popup being opened in a background tab
  336. // when an irrelevant link was accidentally clicked
  337. if (event.target.closest('a')) {
  338. event.preventDefault();
  339. return;
  340. }
  341. },
  342. openURLandHide(event) {
  343. event.preventDefault();
  344. openURL({url: this.href || this.dataset.href})
  345. .then(window.close);
  346. },
  347. openManager(event) {
  348. event.preventDefault();
  349. if (!this.eventHandled) {
  350. this.eventHandled = true;
  351. this.dataset.href += event.shiftKey || event.button === 2 ?
  352. '?url=' + encodeURIComponent(tabURL) : '';
  353. handleEvent.openURLandHide.call(this, event);
  354. }
  355. },
  356. });
  357. function handleUpdate(style) {
  358. if ($(ENTRY_ID_PREFIX + style.id)) {
  359. createStyleElement({style});
  360. return;
  361. }
  362. // Add an entry when a new style for the current url is installed
  363. if (tabURL && BG.getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) {
  364. document.body.classList.remove('blocked');
  365. $$('.blocked-info, #no-styles').forEach(el => el.remove());
  366. createStyleElement({style});
  367. }
  368. }
  369. function handleDelete(id) {
  370. $$(ENTRY_ID_PREFIX + id).forEach(el => el.remove());
  371. }
  372. /*
  373. According to CSS4 @document specification the entire URL must match.
  374. Stylish-for-Chrome implemented it incorrectly since the very beginning.
  375. We'll detect styles that abuse the bug by finding the sections that
  376. would have been applied by Stylish but not by us as we follow the spec.
  377. Additionally we'll check for invalid regexps.
  378. */
  379. function detectSloppyRegexps({entry, style}) {
  380. // make sure all regexps are compiled
  381. const rxCache = BG.cachedStyles.regexps;
  382. let hasRegExp = false;
  383. for (const section of style.sections) {
  384. for (const regexp of section.regexps) {
  385. hasRegExp = true;
  386. for (let pass = 1; pass <= 2; pass++) {
  387. const cacheKey = pass === 1 ? regexp : BG.SLOPPY_REGEXP_PREFIX + regexp;
  388. if (!rxCache.has(cacheKey)) {
  389. // according to CSS4 @document specification the entire URL must match
  390. const anchored = pass === 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$';
  391. // create in the bg context to avoid leaking of "dead objects"
  392. const rx = BG.tryRegExp(anchored);
  393. rxCache.set(cacheKey, rx || false);
  394. }
  395. }
  396. }
  397. }
  398. if (!hasRegExp) {
  399. return;
  400. }
  401. const {
  402. appliedSections =
  403. BG.getApplicableSections({style, matchUrl: tabURL}),
  404. wannabeSections =
  405. BG.getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}),
  406. } = style;
  407. entry.hasInvalidRegexps = wannabeSections.some(section =>
  408. section.regexps.some(rx => !rxCache.has(rx)));
  409. entry.sectionsSkipped = wannabeSections.length - appliedSections.length;
  410. if (!appliedSections.length) {
  411. entry.classList.add('not-applied');
  412. $('.style-name', entry).title = t('styleNotAppliedRegexpProblemTooltip');
  413. }
  414. if (entry.sectionsSkipped || entry.hasInvalidRegexps) {
  415. entry.classList.toggle('regexp-partial', entry.sectionsSkipped);
  416. entry.classList.toggle('regexp-invalid', entry.hasInvalidRegexps);
  417. const indicator = template.regexpProblemIndicator.cloneNode(true);
  418. indicator.appendChild(document.createTextNode(entry.sectionsSkipped || '!'));
  419. indicator.onclick = handleEvent.indicator;
  420. $('.main-controls', entry).appendChild(indicator);
  421. }
  422. }
  423. function getTabRealURLFirefox(tab) {
  424. // wait for FF tab-on-demand to get a real URL (initially about:blank), 5 sec max
  425. return new Promise(resolve => {
  426. function onNavigation({tabId, url, frameId}) {
  427. if (tabId === tab.id && frameId === 0) {
  428. detach();
  429. resolve(url);
  430. }
  431. }
  432. function detach(timedOut) {
  433. if (timedOut) {
  434. resolve(tab.url);
  435. } else {
  436. debounce.unregister(detach);
  437. }
  438. chrome.webNavigation.onBeforeNavigate.removeListener(onNavigation);
  439. chrome.webNavigation.onCommitted.removeListener(onNavigation);
  440. chrome.tabs.onRemoved.removeListener(detach);
  441. chrome.tabs.onReplaced.removeListener(detach);
  442. }
  443. chrome.webNavigation.onBeforeNavigate.addListener(onNavigation);
  444. chrome.webNavigation.onCommitted.addListener(onNavigation);
  445. chrome.tabs.onRemoved.addListener(detach);
  446. chrome.tabs.onReplaced.addListener(detach);
  447. debounce(detach, 5000, {timedOut: true});
  448. });
  449. }