filters.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /* global installed */
  2. 'use strict';
  3. const filtersSelector = {
  4. hide: '',
  5. unhide: '',
  6. numShown: 0,
  7. numTotal: 0,
  8. };
  9. const urlFilterParam = new URLSearchParams(location.search).get('url');
  10. if (location.search) {
  11. history.replaceState(0, document.title, location.origin + location.pathname);
  12. }
  13. HTMLSelectElement.prototype.adjustWidth = function () {
  14. const parent = this.parentNode;
  15. const singleSelect = this.cloneNode(false);
  16. singleSelect.style.width = '';
  17. singleSelect.appendChild(this.selectedOptions[0].cloneNode(true));
  18. parent.replaceChild(singleSelect, this);
  19. if (this.style.width !== singleSelect.offsetWidth + 'px') {
  20. this.style.width = singleSelect.offsetWidth + 'px';
  21. }
  22. parent.replaceChild(this, singleSelect);
  23. };
  24. onDOMready().then(onBackgroundReady).then(() => {
  25. $('#search').oninput = searchStyles;
  26. if (urlFilterParam) {
  27. $('#search').value = 'url:' + urlFilterParam;
  28. }
  29. $$('select[id$=".invert"]').forEach(el => {
  30. const slave = $('#' + el.id.replace('.invert', ''));
  31. const slaveData = slave.dataset;
  32. const valueMap = new Map([
  33. [false, slaveData.filter],
  34. [true, slaveData.filterHide],
  35. ]);
  36. // enable slave control when user switches the value
  37. el.oninput = () => {
  38. if (!slave.checked) {
  39. // oninput occurs before onchange
  40. setTimeout(() => {
  41. if (!slave.checked) {
  42. slave.checked = true;
  43. slave.dispatchEvent(new Event('change', {bubbles: true}));
  44. }
  45. });
  46. }
  47. };
  48. // swap slave control's filtering rules
  49. el.onchange = event => {
  50. const value = el.value === 'true';
  51. const filter = valueMap.get(value);
  52. if (slaveData.filter === filter) {
  53. return;
  54. }
  55. slaveData.filter = filter;
  56. slaveData.filterHide = valueMap.get(!value);
  57. debounce(filterOnChange, 0, event);
  58. // avoid triggering MutationObserver during page load
  59. if (document.readyState === 'complete') {
  60. el.adjustWidth();
  61. }
  62. };
  63. el.onchange({target: el});
  64. });
  65. $$('[data-filter]').forEach(el => {
  66. el.onchange = filterOnChange;
  67. if (el.closest('.hidden')) {
  68. el.checked = false;
  69. }
  70. });
  71. filterOnChange({forceRefilter: true});
  72. });
  73. function filterOnChange({target: el, forceRefilter}) {
  74. const getValue = el => (el.type === 'checkbox' ? el.checked : el.value.trim());
  75. if (!forceRefilter) {
  76. const value = getValue(el);
  77. if (value === el.lastValue) {
  78. return;
  79. }
  80. el.lastValue = value;
  81. }
  82. const enabledFilters = $$('#header [data-filter]').filter(el => getValue(el));
  83. const buildFilter = hide =>
  84. (hide ? '' : '.entry.hidden') +
  85. [...enabledFilters.map(el =>
  86. el.dataset[hide ? 'filterHide' : 'filter']
  87. .split(/,\s*/)
  88. .map(s => (hide ? '.entry:not(.hidden)' : '') + s)
  89. .join(','))
  90. ].join(hide ? ',' : '');
  91. Object.assign(filtersSelector, {
  92. hide: buildFilter(true),
  93. unhide: buildFilter(false),
  94. });
  95. if (installed) {
  96. reapplyFilter();
  97. }
  98. }
  99. function filterAndAppend({entry, container}) {
  100. if (!container) {
  101. container = [entry];
  102. // reverse the visibility, otherwise reapplyFilter will see no need to work
  103. if (!filtersSelector.hide || !entry.matches(filtersSelector.hide)) {
  104. entry.classList.add('hidden');
  105. }
  106. } else if ($('#search').value.trim()) {
  107. searchStyles({immediately: true, container});
  108. }
  109. reapplyFilter(container);
  110. }
  111. function reapplyFilter(container = installed) {
  112. // A: show
  113. let toHide = [];
  114. let toUnhide = [];
  115. if (filtersSelector.hide) {
  116. filterContainer({hide: false});
  117. } else {
  118. toUnhide = container;
  119. }
  120. // showStyles() is building the page and no filters are active
  121. if (toUnhide instanceof DocumentFragment) {
  122. installed.appendChild(toUnhide);
  123. return;
  124. } else if (toUnhide.length && $('#search').value.trim()) {
  125. searchStyles({immediately: true, container: toUnhide});
  126. filterContainer({hide: false});
  127. }
  128. // filtering needed or a single-element job from handleUpdate()
  129. const entries = installed.children;
  130. const numEntries = entries.length;
  131. let numVisible = numEntries - $$('.entry.hidden').length;
  132. for (const entry of toUnhide.children || toUnhide) {
  133. const next = findInsertionPoint(entry);
  134. if (entry.nextElementSibling !== next) {
  135. installed.insertBefore(entry, next);
  136. }
  137. if (entry.classList.contains('hidden')) {
  138. entry.classList.remove('hidden');
  139. numVisible++;
  140. }
  141. }
  142. // B: hide
  143. if (filtersSelector.hide) {
  144. filterContainer({hide: true});
  145. }
  146. if (!toHide.length) {
  147. showFiltersStats();
  148. return;
  149. }
  150. for (const entry of toHide) {
  151. entry.classList.add('hidden');
  152. }
  153. // showStyles() is building the page with filters active so we need to:
  154. // 1. add all hidden entries to the end
  155. // 2. add the visible entries before the first hidden entry
  156. if (container instanceof DocumentFragment) {
  157. for (const entry of toHide) {
  158. installed.appendChild(entry);
  159. }
  160. installed.insertBefore(container, $('.entry.hidden'));
  161. showFiltersStats();
  162. return;
  163. }
  164. // normal filtering of the page or a single-element job from handleUpdate()
  165. // we need to keep the visible entries together at the start
  166. // first pass only moves one hidden entry in hidden groups with odd number of items
  167. shuffle(false);
  168. setTimeout(shuffle, 0, true);
  169. // single-element job from handleEvent(): add the last wraith
  170. if (toHide.length === 1 && toHide[0].parentElement !== installed) {
  171. installed.appendChild(toHide[0]);
  172. }
  173. showFiltersStats();
  174. return;
  175. /***************************************/
  176. function filterContainer({hide}) {
  177. const selector = filtersSelector[hide ? 'hide' : 'unhide'];
  178. if (container.filter) {
  179. if (hide) {
  180. // already filtered in previous invocation
  181. return;
  182. }
  183. for (const el of container) {
  184. (el.matches(selector) ? toUnhide : toHide).push(el);
  185. }
  186. return;
  187. } else if (hide) {
  188. toHide = $$(selector, container);
  189. } else {
  190. toUnhide = $$(selector, container);
  191. }
  192. }
  193. function shuffle(fullPass) {
  194. if (fullPass && !document.body.classList.contains('update-in-progress')) {
  195. $('#check-all-updates').disabled = !$('.updatable:not(.can-update)');
  196. }
  197. // 1. skip the visible group on top
  198. let firstHidden = $('#installed > .hidden');
  199. let entry = firstHidden;
  200. let i = [...entries].indexOf(entry);
  201. let horizon = entries[numVisible];
  202. const skipGroup = state => {
  203. const start = i;
  204. const first = entry;
  205. while (entry && entry.classList.contains('hidden') === state) {
  206. entry = entry.nextElementSibling;
  207. i++;
  208. }
  209. return {first, start, len: i - start};
  210. };
  211. let prevGroup = i ? {first: entries[0], start: 0, len: i} : skipGroup(true);
  212. // eslint-disable-next-line no-unmodified-loop-condition
  213. while (entry) {
  214. // 2a. find the next hidden group's start and end
  215. // 2b. find the next visible group's start and end
  216. const isHidden = entry.classList.contains('hidden');
  217. const group = skipGroup(isHidden);
  218. const hidden = isHidden ? group : prevGroup;
  219. const visible = isHidden ? prevGroup : group;
  220. // 3. move the shortest group; repeat 2-3
  221. if (hidden.len < visible.len && (fullPass || hidden.len % 2)) {
  222. // 3a. move hidden under the horizon
  223. for (let j = 0; j < (fullPass ? hidden.len : 1); j++) {
  224. const entry = entries[hidden.start];
  225. installed.insertBefore(entry, horizon);
  226. horizon = entry;
  227. i--;
  228. }
  229. prevGroup = isHidden ? skipGroup(false) : group;
  230. firstHidden = entry;
  231. } else if (isHidden || !fullPass) {
  232. prevGroup = group;
  233. } else {
  234. // 3b. move visible above the horizon
  235. for (let j = 0; j < visible.len; j++) {
  236. const entry = entries[visible.start + j];
  237. installed.insertBefore(entry, firstHidden);
  238. }
  239. prevGroup = {
  240. first: firstHidden,
  241. start: hidden.start + visible.len,
  242. len: hidden.len + skipGroup(true).len,
  243. };
  244. }
  245. }
  246. }
  247. function findInsertionPoint(entry) {
  248. const nameLLC = entry.styleNameLowerCase;
  249. let a = 0;
  250. let b = Math.min(numEntries, numVisible) - 1;
  251. if (b < 0) {
  252. return entries[numVisible];
  253. }
  254. if (entries[0].styleNameLowerCase > nameLLC) {
  255. return entries[0];
  256. }
  257. if (entries[b].styleNameLowerCase <= nameLLC) {
  258. return entries[numVisible];
  259. }
  260. // bisect
  261. while (a < b - 1) {
  262. const c = (a + b) / 2 | 0;
  263. if (nameLLC < entries[c].styleNameLowerCase) {
  264. b = c;
  265. } else {
  266. a = c;
  267. }
  268. }
  269. if (entries[a].styleNameLowerCase > nameLLC) {
  270. return entries[a];
  271. }
  272. while (a <= b && entries[a].styleNameLowerCase < nameLLC) {
  273. a++;
  274. }
  275. return entries[entries[a].styleNameLowerCase <= nameLLC ? a + 1 : a];
  276. }
  277. }
  278. function showFiltersStats({immediately} = {}) {
  279. if (!immediately) {
  280. debounce(showFiltersStats, 100, {immediately: true});
  281. return;
  282. }
  283. $('#filters').classList.toggle('active', filtersSelector.hide !== '');
  284. const numTotal = BG.cachedStyles.list.length;
  285. const numHidden = installed.getElementsByClassName('entry hidden').length;
  286. const numShown = Math.min(numTotal - numHidden, installed.children.length);
  287. if (filtersSelector.numShown !== numShown ||
  288. filtersSelector.numTotal !== numTotal) {
  289. filtersSelector.numShown = numShown;
  290. filtersSelector.numTotal = numTotal;
  291. $('#filters-stats').textContent = t('filteredStyles', [numShown, numTotal]);
  292. }
  293. }
  294. function searchStyles({immediately, container}) {
  295. const searchElement = $('#search');
  296. const urlMode = /^\s*url:/i.test(searchElement.value);
  297. const query = urlMode
  298. ? searchElement.value.replace(/^\s*url:/i, '').trim()
  299. : searchElement.value.toLocaleLowerCase();
  300. const queryPrev = searchElement.lastValue || '';
  301. if (query === queryPrev && !immediately && !container) {
  302. return;
  303. }
  304. if (!immediately) {
  305. debounce(searchStyles, 150, {immediately: true});
  306. return;
  307. }
  308. searchElement.lastValue = query;
  309. const searchInVisible = !urlMode && queryPrev && query.includes(queryPrev);
  310. const entries = container && container.children || container ||
  311. (searchInVisible ? $$('.entry:not(.hidden)') : installed.children);
  312. const siteStyleIds = urlMode &&
  313. new Set(BG.filterStyles({matchUrl: query}).map(style => style.id));
  314. let needsRefilter = false;
  315. for (const entry of entries) {
  316. let isMatching = !query;
  317. if (!isMatching) {
  318. const style = urlMode ? siteStyleIds.has(entry.styleId) :
  319. BG.cachedStyles.byId.get(entry.styleId) || {};
  320. isMatching = Boolean(style && (
  321. urlMode ||
  322. isMatchingText(style.name) ||
  323. style.url && isMatchingText(style.url) ||
  324. isMatchingStyle(style)));
  325. }
  326. if (entry.classList.contains('not-matching') !== !isMatching) {
  327. entry.classList.toggle('not-matching', !isMatching);
  328. needsRefilter = true;
  329. }
  330. }
  331. if (needsRefilter && !container) {
  332. filterOnChange({forceRefilter: true});
  333. }
  334. return;
  335. function isMatchingStyle(style) {
  336. for (const section of style.sections) {
  337. for (const prop in section) {
  338. const value = section[prop];
  339. switch (typeof value) {
  340. case 'string':
  341. if (isMatchingText(value)) {
  342. return true;
  343. }
  344. break;
  345. case 'object':
  346. for (const str of value) {
  347. if (isMatchingText(str)) {
  348. return true;
  349. }
  350. }
  351. break;
  352. }
  353. }
  354. }
  355. }
  356. function isMatchingText(text) {
  357. return text.toLocaleLowerCase().indexOf(query) >= 0;
  358. }
  359. }