popup.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. /* global $ $$ $create setupLivePrefs */// dom.js
  2. /* global ABOUT_BLANK getStyleDataMerged preinit */// preinit.js
  3. /* global API msg */// msg.js
  4. /* global Events */
  5. /* global prefs */
  6. /* global t */// localization.js
  7. /* global
  8. CHROME
  9. CHROME_POPUP_BORDER_BUG
  10. FIREFOX
  11. URLS
  12. capitalize
  13. getActiveTab
  14. isEmptyObj
  15. */// toolbox.js
  16. 'use strict';
  17. let tabURL;
  18. let isBlocked;
  19. /** @type Element */
  20. const installed = $('#installed');
  21. const ENTRY_ID_PREFIX_RAW = 'style-';
  22. const $entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`);
  23. preinit.then(({frames, styles, url}) => {
  24. tabURL = url;
  25. initPopup(frames);
  26. if (styles[0]) {
  27. showStyles(styles);
  28. } else {
  29. // unsupported URL;
  30. $('#popup-manage-button').removeAttribute('title');
  31. }
  32. });
  33. msg.onExtension(onRuntimeMessage);
  34. prefs.subscribe('popup.stylesFirst', (key, stylesFirst) => {
  35. const actions = $('body > .actions');
  36. const before = stylesFirst ? actions : actions.nextSibling;
  37. document.body.insertBefore(installed, before);
  38. });
  39. if (CHROME_POPUP_BORDER_BUG) {
  40. prefs.subscribe('popup.borders', toggleSideBorders, {runNow: true});
  41. }
  42. if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143
  43. document.head.appendChild($create('style', 'html { overflow: overlay }'));
  44. }
  45. function onRuntimeMessage(msg) {
  46. if (!tabURL) return;
  47. let ready = Promise.resolve();
  48. switch (msg.method) {
  49. case 'styleAdded':
  50. case 'styleUpdated':
  51. if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return;
  52. ready = handleUpdate(msg);
  53. break;
  54. case 'styleDeleted':
  55. handleDelete(msg.style.id);
  56. break;
  57. }
  58. ready.then(() => dispatchEvent(new CustomEvent(msg.method, {detail: msg})));
  59. }
  60. function setPopupWidth(_key, width) {
  61. document.body.style.width =
  62. Math.max(200, Math.min(800, width)) + 'px';
  63. }
  64. function toggleSideBorders(_key, state) {
  65. // runs before <body> is parsed
  66. const style = document.documentElement.style;
  67. if (state) {
  68. style.cssText +=
  69. 'border-left: 2px solid white !important;' +
  70. 'border-right: 2px solid white !important;';
  71. } else if (style.cssText) {
  72. style.borderLeft = style.borderRight = '';
  73. }
  74. }
  75. /** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */
  76. async function initPopup(frames) {
  77. prefs.subscribe('popupWidth', setPopupWidth, {runNow: true});
  78. // action buttons
  79. $('#disableAll').onchange = function () {
  80. installed.classList.toggle('disabled', this.checked);
  81. };
  82. setupLivePrefs();
  83. Object.assign($('#find-styles-link'), {
  84. href: URLS.usoArchive + 'browse/styles',
  85. async onclick(e) {
  86. e.preventDefault();
  87. await require(['/popup/search']);
  88. Events.searchOnClick(this, e);
  89. },
  90. });
  91. Object.assign($('#popup-manage-button'), {
  92. onclick: Events.openManager,
  93. oncontextmenu: Events.openManager,
  94. });
  95. $('#popup-options-button').onclick = () => {
  96. API.openManage({options: true});
  97. window.close();
  98. };
  99. $('#popup-wiki-button').onclick = Events.openURLandHide;
  100. $('#confirm').onclick = function (e) {
  101. const {id} = this.dataset;
  102. switch (e.target.dataset.cmd) {
  103. case 'ok':
  104. Events.hideModal(this, {animate: true});
  105. API.styles.delete(Number(id));
  106. break;
  107. case 'cancel':
  108. Events.showModal($('.menu', $entry(id)), '.menu-close');
  109. break;
  110. }
  111. };
  112. if (!prefs.get('popup.stylesFirst')) {
  113. document.body.insertBefore(
  114. $('body > .actions'),
  115. installed);
  116. }
  117. for (const el of $$('link[media=print]')) {
  118. el.removeAttribute('media');
  119. }
  120. if (!tabURL) {
  121. blockPopup();
  122. return;
  123. }
  124. frames.forEach(createWriterElement);
  125. Object.assign($('#write-for-frames'), {
  126. onclick: e => e.currentTarget.classList.toggle('expanded'),
  127. hidden: frames.length < 2 || !$('.match .match:not(.dupe)'),
  128. });
  129. const isStore = tabURL.startsWith(URLS.browserWebStore);
  130. if (isStore && !FIREFOX) {
  131. blockPopup();
  132. return;
  133. }
  134. for (let retryCountdown = 10; retryCountdown-- > 0;) {
  135. const tab = await getActiveTab();
  136. if (await msg.sendTab(tab.id, {method: 'ping'}, {frameId: 0}).catch(() => {})) {
  137. return;
  138. }
  139. if (tab.status === 'complete' && (!FIREFOX || tab.url !== ABOUT_BLANK)) {
  140. break;
  141. }
  142. // FF and some Chrome forks (e.g. CentBrowser) implement tab-on-demand
  143. // so we'll wait a bit to handle popup being invoked right after switching
  144. await new Promise(resolve => setTimeout(resolve, 100));
  145. }
  146. initUnreachable(isStore);
  147. }
  148. function initUnreachable(isStore) {
  149. const info = t.template.unreachableInfo;
  150. if (!FIREFOX) {
  151. // Chrome "Allow access to file URLs" in chrome://extensions message
  152. info.appendChild($create('p', t('unreachableFileHint')));
  153. } else {
  154. $('label', info).textContent = t('unreachableAMO');
  155. const note = [
  156. isStore && t(FIREFOX >= 59 ? 'unreachableAMOHint' : 'unreachableMozSiteHintOldFF'),
  157. FIREFOX >= 60 && t('unreachableMozSiteHint'),
  158. ].filter(Boolean).join('\n');
  159. const renderToken = s => s[0] === '<'
  160. ? $create('a.copy', {
  161. textContent: s.slice(1, -1),
  162. onclick: Events.copyContent,
  163. tabIndex: 0,
  164. title: t('copy'),
  165. })
  166. : s;
  167. const renderLine = line => $create('p', line.split(/(<.*?>)/).map(renderToken));
  168. const noteNode = $create('fragment', note.split('\n').map(renderLine));
  169. info.appendChild(noteNode);
  170. }
  171. // Inaccessible locally hosted file type, e.g. JSON, PDF, etc.
  172. if (tabURL.length - tabURL.lastIndexOf('.') <= 5) {
  173. info.appendChild($create('p', t('InaccessibleFileHint')));
  174. }
  175. document.body.classList.add('unreachable');
  176. document.body.insertBefore(info, document.body.firstChild);
  177. }
  178. /** @param {chrome.webNavigation.GetAllFrameResultDetails} frame */
  179. function createWriterElement(frame) {
  180. const {url, frameId, parentFrameId, isDupe} = frame;
  181. const targets = $create('span');
  182. // For this URL
  183. const urlLink = t.template.writeStyle.cloneNode(true);
  184. const isAboutBlank = url === ABOUT_BLANK;
  185. Object.assign(urlLink, {
  186. href: 'edit.html?url-prefix=' + encodeURIComponent(url),
  187. title: `url-prefix("${url}")`,
  188. tabIndex: isAboutBlank ? -1 : 0,
  189. textContent: prefs.get('popup.breadcrumbs.usePath')
  190. ? new URL(url).pathname.slice(1)
  191. : frameId
  192. ? isAboutBlank ? url : 'URL'
  193. : t('writeStyleForURL').replace(/ /g, '\u00a0'), // this&nbsp;URL
  194. onclick: e => Events.openEditor(e, {'url-prefix': url}),
  195. });
  196. if (prefs.get('popup.breadcrumbs')) {
  197. urlLink.onmouseenter =
  198. urlLink.onfocus = () => urlLink.parentNode.classList.add('url()');
  199. urlLink.onmouseleave =
  200. urlLink.onblur = () => urlLink.parentNode.classList.remove('url()');
  201. }
  202. targets.appendChild(urlLink);
  203. // For domain
  204. const domains = getDomains(url);
  205. for (const domain of domains) {
  206. const numParts = domain.length - domain.replace(/\./g, '').length + 1;
  207. // Don't include TLD
  208. if (domains.length > 1 && numParts === 1) {
  209. continue;
  210. }
  211. const domainLink = t.template.writeStyle.cloneNode(true);
  212. Object.assign(domainLink, {
  213. href: 'edit.html?domain=' + encodeURIComponent(domain),
  214. textContent: numParts > 2 ? domain.split('.')[0] : domain,
  215. title: `domain("${domain}")`,
  216. onclick: e => Events.openEditor(e, {domain}),
  217. });
  218. domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : '');
  219. targets.appendChild(domainLink);
  220. }
  221. if (prefs.get('popup.breadcrumbs')) {
  222. targets.classList.add('breadcrumbs');
  223. targets.appendChild(urlLink); // making it the last element
  224. }
  225. const root = $('#write-style');
  226. const parent = $(`[data-frame-id="${parentFrameId}"]`, root) || root;
  227. const child = $create({
  228. tag: 'span',
  229. className: `match${isDupe ? ' dupe' : ''}${isAboutBlank ? ' about-blank' : ''}`,
  230. dataset: {frameId},
  231. appendChild: targets,
  232. });
  233. parent.appendChild(child);
  234. parent.dataset.children = (Number(parent.dataset.children) || 0) + 1;
  235. }
  236. function getDomains(url) {
  237. let d = url.split(/[/:]+/, 2)[1];
  238. if (!d || url.startsWith('file:')) {
  239. return [];
  240. }
  241. const domains = [d];
  242. while (d.includes('.')) {
  243. d = d.substring(d.indexOf('.') + 1);
  244. domains.push(d);
  245. }
  246. return domains;
  247. }
  248. function sortStyles(entries) {
  249. const enabledFirst = prefs.get('popup.enabledFirst');
  250. return entries.sort(({styleMeta: a}, {styleMeta: b}) =>
  251. Boolean(a.frameUrl) - Boolean(b.frameUrl) ||
  252. enabledFirst && Boolean(b.enabled) - Boolean(a.enabled) ||
  253. (a.customName || a.name).localeCompare(b.customName || b.name));
  254. }
  255. function showStyles(frameResults) {
  256. const entries = new Map();
  257. frameResults.forEach(({styles = [], url}, index) => {
  258. if (isBlocked && !index) return;
  259. styles.forEach(style => {
  260. const {id} = style;
  261. if (!entries.has(id)) {
  262. style.frameUrl = index === 0 ? '' : url;
  263. entries.set(id, createStyleElement(style));
  264. }
  265. });
  266. });
  267. if (entries.size) {
  268. resortEntries([...entries.values()]);
  269. } else {
  270. installed.appendChild(t.template.noStyles);
  271. }
  272. require(['/popup/hotkeys']);
  273. }
  274. function resortEntries(entries) {
  275. // `entries` is specified only at startup, after that we respect the prefs
  276. if (entries || prefs.get('popup.autoResort')) {
  277. installed.append(...sortStyles(entries || $$('.entry', installed)));
  278. }
  279. }
  280. function createStyleElement(style) {
  281. let entry = $entry(style);
  282. if (!entry) {
  283. entry = t.template.style.cloneNode(true);
  284. Object.assign(entry, {
  285. id: ENTRY_ID_PREFIX_RAW + style.id,
  286. styleId: style.id,
  287. styleIsUsercss: Boolean(style.usercssData),
  288. onmousedown: Events.maybeEdit,
  289. styleMeta: style,
  290. });
  291. Object.assign($('input', entry), {
  292. onclick: Events.toggleState,
  293. });
  294. Object.assign($('.style-edit-link', entry), {
  295. onclick: e => Events.openEditor(e, {id: style.id}),
  296. });
  297. const styleName = $('.style-name', entry);
  298. Object.assign(styleName, {
  299. htmlFor: ENTRY_ID_PREFIX_RAW + style.id,
  300. onclick: Events.name,
  301. });
  302. styleName.appendChild(document.createTextNode(' '));
  303. const config = $('.configure', entry);
  304. config.onclick = Events.configure;
  305. if (!style.usercssData) {
  306. if (style.updateUrl && style.updateUrl.includes('?') && style.url) {
  307. config.href = style.url;
  308. config.target = '_blank';
  309. config.title = t('configureStyleOnHomepage');
  310. config._sendMessage = {method: 'openSettings'};
  311. $('use', config).attributes['xlink:href'].nodeValue = '#svg-icon-config-uso';
  312. } else {
  313. config.classList.add('hidden');
  314. }
  315. } else if (isEmptyObj(style.usercssData.vars)) {
  316. config.classList.add('hidden');
  317. }
  318. $('.delete', entry).onclick = Events.delete;
  319. const indicator = t.template.regexpProblemIndicator.cloneNode(true);
  320. indicator.appendChild(document.createTextNode('!'));
  321. indicator.onclick = Events.indicator;
  322. $('.main-controls', entry).appendChild(indicator);
  323. $('.menu-button', entry).onclick = Events.toggleMenu;
  324. $('.menu-close', entry).onclick = Events.toggleMenu;
  325. $('.exclude-by-domain-checkbox', entry).onchange = e => Events.toggleExclude(e, 'domain');
  326. $('.exclude-by-url-checkbox', entry).onchange = e => Events.toggleExclude(e, 'url');
  327. }
  328. style = Object.assign(entry.styleMeta, style);
  329. entry.classList.toggle('disabled', !style.enabled);
  330. entry.classList.toggle('enabled', style.enabled);
  331. $('input', entry).checked = style.enabled;
  332. const styleName = $('.style-name', entry);
  333. styleName.lastChild.textContent = style.customName || style.name;
  334. setTimeout(() => {
  335. styleName.title =
  336. entry.styleMeta.sloppy ? t('styleNotAppliedRegexpProblemTooltip') :
  337. entry.styleMeta.excludedScheme ? t(`styleNotAppliedScheme${capitalize(entry.styleMeta.preferScheme)}`) :
  338. styleName.scrollWidth > styleName.clientWidth + 1 ? styleName.textContent :
  339. '';
  340. });
  341. entry.classList.toggle('force-applied', style.included);
  342. entry.classList.toggle('not-applied', style.excluded || style.sloppy || style.excludedScheme);
  343. entry.classList.toggle('regexp-partial', style.sloppy);
  344. $('.exclude-by-domain-checkbox', entry).checked = Events.isStyleExcluded(style, 'domain');
  345. $('.exclude-by-url-checkbox', entry).checked = Events.isStyleExcluded(style, 'url');
  346. $('.exclude-by-domain', entry).title = Events.getExcludeRule('domain');
  347. $('.exclude-by-url', entry).title = Events.getExcludeRule('url');
  348. const {frameUrl} = style;
  349. if (frameUrl) {
  350. const sel = 'span.frame-url';
  351. const frameEl = $(sel, entry) || styleName.insertBefore($create(sel), styleName.lastChild);
  352. frameEl.title = frameUrl;
  353. frameEl.onmousedown = Events.maybeEdit;
  354. }
  355. entry.classList.toggle('frame', Boolean(frameUrl));
  356. return entry;
  357. }
  358. async function handleUpdate({style, reason}) {
  359. if (reason !== 'toggle' || !$entry(style)) {
  360. style = await getStyleDataMerged(tabURL, style.id);
  361. if (!style) return;
  362. }
  363. const el = createStyleElement(style);
  364. if (!el.parentNode) {
  365. installed.appendChild(el);
  366. blockPopup(false);
  367. }
  368. resortEntries();
  369. }
  370. function handleDelete(id) {
  371. const el = $entry(id);
  372. if (el) {
  373. el.remove();
  374. if (!$('.entry')) installed.appendChild(t.template.noStyles);
  375. }
  376. }
  377. function blockPopup(val = true) {
  378. isBlocked = val;
  379. document.body.classList.toggle('blocked', isBlocked);
  380. if (isBlocked) {
  381. document.body.prepend(t.template.unavailableInfo);
  382. } else {
  383. t.template.unavailableInfo.remove();
  384. t.template.noStyles.remove();
  385. }
  386. }