360 lines
10 KiB
JavaScript
360 lines
10 KiB
JavaScript
/* global installed messageBox */
|
|
/* global sorter */
|
|
'use strict';
|
|
|
|
const filtersSelector = {
|
|
hide: '',
|
|
unhide: '',
|
|
numShown: 0,
|
|
numTotal: 0,
|
|
};
|
|
|
|
// TODO: remove .replace(/^\?/, '') when minimum_chrome_version >= 52 (https://crbug.com/601425)
|
|
const urlFilterParam = new URLSearchParams(location.search.replace(/^\?/, '')).get('url');
|
|
if (location.search) {
|
|
history.replaceState(0, document.title, location.origin + location.pathname);
|
|
}
|
|
|
|
HTMLSelectElement.prototype.adjustWidth = function () {
|
|
const option0 = this.selectedOptions[0];
|
|
if (!option0) return;
|
|
const parent = this.parentNode;
|
|
const singleSelect = this.cloneNode(false);
|
|
singleSelect.style.width = '';
|
|
singleSelect.appendChild(option0.cloneNode(true));
|
|
parent.replaceChild(singleSelect, this);
|
|
if (this.style.width !== singleSelect.offsetWidth + 'px') {
|
|
this.style.width = singleSelect.offsetWidth + 'px';
|
|
}
|
|
parent.replaceChild(this, singleSelect);
|
|
};
|
|
|
|
onDOMready().then(onBackgroundReady).then(() => {
|
|
$('#search').oninput = searchStyles;
|
|
if (urlFilterParam) {
|
|
$('#search').value = 'url:' + urlFilterParam;
|
|
}
|
|
$('#search-help').onclick = event => {
|
|
event.preventDefault();
|
|
messageBox({
|
|
className: 'help-text',
|
|
title: t('searchStyles'),
|
|
contents:
|
|
$create('ul',
|
|
t('searchStylesHelp').split('\n').map(line =>
|
|
$create('li', line.split(/(<.*?>)/).map((s, i, words) => {
|
|
if (s.startsWith('<')) {
|
|
const num = words.length;
|
|
const className = i === num - 2 && !words[num - 1] ? '.last' : '';
|
|
return $create('mark' + className, s.slice(1, -1));
|
|
} else {
|
|
return s;
|
|
}
|
|
})))),
|
|
buttons: [t('confirmOK')],
|
|
});
|
|
};
|
|
|
|
$$('select[id$=".invert"]').forEach(el => {
|
|
const slave = $('#' + el.id.replace('.invert', ''));
|
|
const slaveData = slave.dataset;
|
|
const valueMap = new Map([
|
|
[false, slaveData.filter],
|
|
[true, slaveData.filterHide],
|
|
]);
|
|
// enable slave control when user switches the value
|
|
el.oninput = () => {
|
|
if (!slave.checked) {
|
|
// oninput occurs before onchange
|
|
setTimeout(() => {
|
|
if (!slave.checked) {
|
|
slave.checked = true;
|
|
slave.dispatchEvent(new Event('change', {bubbles: true}));
|
|
}
|
|
});
|
|
}
|
|
};
|
|
// swap slave control's filtering rules
|
|
el.onchange = event => {
|
|
const value = el.value === 'true';
|
|
const filter = valueMap.get(value);
|
|
if (slaveData.filter === filter) {
|
|
return;
|
|
}
|
|
slaveData.filter = filter;
|
|
slaveData.filterHide = valueMap.get(!value);
|
|
debounce(filterOnChange, 0, event);
|
|
// avoid triggering MutationObserver during page load
|
|
if (document.readyState === 'complete') {
|
|
el.adjustWidth();
|
|
}
|
|
};
|
|
el.onchange({target: el});
|
|
});
|
|
|
|
$$('[data-filter]').forEach(el => {
|
|
el.onchange = filterOnChange;
|
|
if (el.closest('.hidden')) {
|
|
el.checked = false;
|
|
}
|
|
});
|
|
|
|
$('#reset-filters').onclick = event => {
|
|
event.preventDefault();
|
|
if (!filtersSelector.hide) {
|
|
return;
|
|
}
|
|
for (const el of $$('#filters [data-filter]')) {
|
|
let value;
|
|
if (el.type === 'checkbox' && el.checked) {
|
|
value = el.checked = false;
|
|
} else if (el.value) {
|
|
value = el.value = '';
|
|
}
|
|
if (value !== undefined) {
|
|
el.lastValue = value;
|
|
if (el.id in prefs.readOnlyValues) {
|
|
prefs.set(el.id, false);
|
|
}
|
|
}
|
|
}
|
|
filterOnChange({forceRefilter: true});
|
|
};
|
|
|
|
// Adjust width after selects are visible
|
|
prefs.subscribe(['manage.filters.expanded'], () => {
|
|
const el = $('#filters');
|
|
if (el.open) {
|
|
$$('select', el).forEach(select => select.adjustWidth());
|
|
}
|
|
});
|
|
|
|
filterOnChange({forceRefilter: true});
|
|
});
|
|
|
|
|
|
function filterOnChange({target: el, forceRefilter}) {
|
|
const getValue = el => (el.type === 'checkbox' ? el.checked : el.value.trim());
|
|
if (!forceRefilter) {
|
|
const value = getValue(el);
|
|
if (value === el.lastValue) {
|
|
return;
|
|
}
|
|
el.lastValue = value;
|
|
}
|
|
const enabledFilters = $$('#header [data-filter]').filter(el => getValue(el));
|
|
const buildFilter = hide =>
|
|
(hide ? '' : '.entry.hidden') +
|
|
[...enabledFilters.map(el =>
|
|
el.dataset[hide ? 'filterHide' : 'filter']
|
|
.split(/,\s*/)
|
|
.map(s => (hide ? '.entry:not(.hidden)' : '') + s)
|
|
.join(','))
|
|
].join(hide ? ',' : '');
|
|
Object.assign(filtersSelector, {
|
|
hide: buildFilter(true),
|
|
unhide: buildFilter(false),
|
|
});
|
|
if (installed) {
|
|
reapplyFilter();
|
|
sorter.update();
|
|
}
|
|
}
|
|
|
|
|
|
function filterAndAppend({entry, container}) {
|
|
if (!container) {
|
|
container = [entry];
|
|
// reverse the visibility, otherwise reapplyFilter will see no need to work
|
|
if (!filtersSelector.hide || !entry.matches(filtersSelector.hide)) {
|
|
entry.classList.add('hidden');
|
|
}
|
|
} else if ($('#search').value.trim()) {
|
|
searchStyles({immediately: true, container});
|
|
}
|
|
reapplyFilter(container);
|
|
}
|
|
|
|
|
|
function reapplyFilter(container = installed) {
|
|
// A: show
|
|
let toHide = [];
|
|
let toUnhide = [];
|
|
if (filtersSelector.hide) {
|
|
filterContainer({hide: false});
|
|
} else {
|
|
toUnhide = container;
|
|
}
|
|
// showStyles() is building the page and no filters are active
|
|
if (toUnhide instanceof DocumentFragment) {
|
|
installed.appendChild(toUnhide);
|
|
return;
|
|
} else if (toUnhide.length && $('#search').value.trim()) {
|
|
searchStyles({immediately: true, container: toUnhide});
|
|
filterContainer({hide: false});
|
|
}
|
|
// filtering needed or a single-element job from handleUpdate()
|
|
for (const entry of toUnhide.children || toUnhide) {
|
|
if (!entry.parentNode) {
|
|
installed.appendChild(entry);
|
|
}
|
|
if (entry.classList.contains('hidden')) {
|
|
entry.classList.remove('hidden');
|
|
}
|
|
}
|
|
// B: hide
|
|
if (filtersSelector.hide) {
|
|
filterContainer({hide: true});
|
|
}
|
|
if (!toHide.length) {
|
|
showFiltersStats();
|
|
return;
|
|
}
|
|
for (const entry of toHide) {
|
|
entry.classList.add('hidden');
|
|
}
|
|
// showStyles() is building the page with filters active so we need to:
|
|
// 1. add all hidden entries to the end
|
|
// 2. add the visible entries before the first hidden entry
|
|
if (container instanceof DocumentFragment) {
|
|
installed.appendChild(container);
|
|
showFiltersStats();
|
|
return;
|
|
}
|
|
// single-element job from handleEvent(): add the last wraith
|
|
if (toHide.length === 1 && toHide[0].parentElement !== installed) {
|
|
installed.appendChild(toHide[0]);
|
|
}
|
|
showFiltersStats();
|
|
return;
|
|
|
|
/***************************************/
|
|
|
|
function filterContainer({hide}) {
|
|
const selector = filtersSelector[hide ? 'hide' : 'unhide'];
|
|
if (container.filter) {
|
|
if (hide) {
|
|
// already filtered in previous invocation
|
|
return;
|
|
}
|
|
for (const el of container) {
|
|
(el.matches(selector) ? toUnhide : toHide).push(el);
|
|
}
|
|
return;
|
|
} else if (hide) {
|
|
toHide = $$(selector, container);
|
|
} else {
|
|
toUnhide = $$(selector, container);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function showFiltersStats() {
|
|
if (!BG.cachedStyles.list) {
|
|
debounce(showFiltersStats, 100);
|
|
return;
|
|
}
|
|
const active = filtersSelector.hide !== '';
|
|
$('#filters summary').classList.toggle('active', active);
|
|
$('#reset-filters').disabled = !active;
|
|
const numTotal = BG.cachedStyles.list.length;
|
|
const numHidden = installed.getElementsByClassName('entry hidden').length;
|
|
const numShown = Math.min(numTotal - numHidden, installed.children.length);
|
|
if (filtersSelector.numShown !== numShown ||
|
|
filtersSelector.numTotal !== numTotal) {
|
|
filtersSelector.numShown = numShown;
|
|
filtersSelector.numTotal = numTotal;
|
|
$('#filters-stats').textContent = t('filteredStyles', [numShown, numTotal]);
|
|
document.body.classList.toggle('all-styles-hidden-by-filters',
|
|
!numShown && numTotal && filtersSelector.hide);
|
|
}
|
|
}
|
|
|
|
|
|
function searchStyles({immediately, container}) {
|
|
const searchElement = $('#search');
|
|
const value = searchElement.value.trim();
|
|
const urlMode = /^\s*url:/i.test(value);
|
|
const query = urlMode
|
|
? value.replace(/^\s*url:/i, '')
|
|
: value.toLocaleLowerCase();
|
|
if (query === searchElement.lastValue && !immediately && !container) {
|
|
return;
|
|
}
|
|
if (!immediately) {
|
|
debounce(searchStyles, 150, {immediately: true});
|
|
return;
|
|
}
|
|
searchElement.lastValue = query;
|
|
|
|
const rx = query.startsWith('/') && query.indexOf('/', 1) > 0 &&
|
|
tryRegExp(...(value.match(/^\s*\/(.*?)\/([gimsuy]*)\s*$/) || []).slice(1));
|
|
const words = rx ? null :
|
|
query.startsWith('"') && query.endsWith('"') ? [value.trim().slice(1, -1)] :
|
|
query.split(/\s+/).filter(s => s.length > 1);
|
|
if (words && !words.length) {
|
|
words.push(query);
|
|
}
|
|
const entries = container && container.children || container || installed.children;
|
|
const siteStyleIds = urlMode &&
|
|
new Set(BG.filterStyles({matchUrl: query}).map(style => style.id));
|
|
let needsRefilter = false;
|
|
for (const entry of entries) {
|
|
let isMatching = !query || words && !words.length;
|
|
if (!isMatching) {
|
|
const style = urlMode ? siteStyleIds.has(entry.styleId) :
|
|
BG.cachedStyles.byId.get(entry.styleId) || {};
|
|
isMatching = Boolean(style && (
|
|
urlMode ||
|
|
isMatchingText(style.name) ||
|
|
style.url && isMatchingText(style.url) ||
|
|
style.sourceCode && isMatchingText(style.sourceCode) ||
|
|
isMatchingStyle(style)));
|
|
}
|
|
if (entry.classList.contains('not-matching') !== !isMatching) {
|
|
entry.classList.toggle('not-matching', !isMatching);
|
|
needsRefilter = true;
|
|
}
|
|
}
|
|
if (needsRefilter && !container) {
|
|
filterOnChange({forceRefilter: true});
|
|
}
|
|
return;
|
|
|
|
function isMatchingStyle(style) {
|
|
for (const section of style.sections) {
|
|
for (const prop in section) {
|
|
const value = section[prop];
|
|
switch (typeof value) {
|
|
case 'string':
|
|
if (isMatchingText(value)) {
|
|
return true;
|
|
}
|
|
break;
|
|
case 'object':
|
|
for (const str of value) {
|
|
if (isMatchingText(str)) {
|
|
return true;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isMatchingText(text) {
|
|
if (rx) {
|
|
return rx.test(text);
|
|
}
|
|
for (let pass = 1; pass <= 2; pass++) {
|
|
if (words.every(word => text.includes(word))) {
|
|
return true;
|
|
}
|
|
text = text.toLocaleLowerCase();
|
|
}
|
|
return false;
|
|
}
|
|
}
|