add scope selector to style search in manager

This commit is contained in:
tophf 2020-11-17 21:55:38 +03:00
parent a1acf53539
commit 8b98baba6a
11 changed files with 166 additions and 150 deletions

View File

@ -1285,14 +1285,30 @@
"message": "Weekly installs",
"description": "Text for label that shows the number of times a search result was installed during last week"
},
"searchStyles": {
"message": "Search contents",
"description": "Label for the search filter textbox on the Manage styles page"
"searchStylesAll": {
"message": "All",
"description": "Option for `find styles` scope selector in the manager."
},
"searchStylesCode": {
"message": "CSS code",
"description": "Option for `find styles` scope selector in the manager."
},
"searchStylesHelp": {
"message": "</> key focuses the search field.\nPlain text: search within the name, code, homepage URL and sites it is applied to. Words with less than 3 letters are ignored.\nStyles matching a full URL: prefix the search with <url:>, e.g. <url:https://github.com/openstyles/stylus>\nRegular expressions: include slashes and flags, e.g. </body.*?\\ba\\b/simguy>\nExact words: wrap the query in double quotes, e.g. <\".header ~ div\">",
"message": "</> or <Ctrl-F> key focuses the search field.\nDefault mode is plain text search for all space-separated terms in any order.\nExact words: wrap the query in double quotes, e.g. <\".header ~ div\">\nRegular expressions: include slashes and flags, e.g. </body.*?\\ba\\b/i>\n\"For URL\" in scope selector: finds styles that apply to a fully specified URL e.g. https://www.example.org/\n\"Metadata\" in scope selector: searches in names, \"applies to\" specifiers, installation URL, update URL, and the entire metadata block for usercss styles.",
"description": "Text in the minihelp displayed when clicking (i) icon to the right of the search input field on the Manage styles page"
},
"searchStylesMatchUrl": {
"message": "For URL",
"description": "Option for `find styles` scope selector in the manager. See searchMatchUrlHint for more info."
},
"searchStylesMeta": {
"message": "Metadata",
"description": "Option for `find styles` scope selector in the manager."
},
"searchStylesName": {
"message": "Name",
"description": "Option for `find styles` scope selector in the manager."
},
"sectionAdd": {
"message": "Add another section",
"description": "Label for the button to add a section"

View File

@ -309,33 +309,29 @@ function openEditor(params) {
});
}
function openManage({options = false, search} = {}) {
async function openManage({options = false, search, searchMode} = {}) {
let url = chrome.runtime.getURL('manage.html');
if (search) {
url += `?search=${encodeURIComponent(search)}`;
url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`;
}
if (options) {
url += '#stylus-options';
}
return findExistingTab({
let tab = await findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true,
})
.then(tab => {
if (tab) {
return Promise.all([
activateTab(tab),
(tab.pendingUrl || tab.url) !== url && msg.sendTab(tab.id, {method: 'pushState', url})
.catch(console.error),
]);
}
return getActiveTab().then(tab => {
if (isTabReplaceable(tab, url)) {
return activateTab(tab, {url});
}
return browser.tabs.create({url});
});
});
});
if (tab) {
await activateTab(tab);
if (url !== (tab.pendingUrl || tab.url)) {
await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error);
}
return tab;
}
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url});
}

View File

@ -1,90 +1,97 @@
/* global API_METHODS styleManager tryRegExp debounce */
/* global
API_METHODS
debounce
stringAsRegExp
styleManager
tryRegExp
usercss
*/
'use strict';
(() => {
// toLocaleLowerCase cache, autocleared after 1 minute
const cache = new Map();
// top-level style properties to be searched
const PARTS = {
name: searchText,
url: searchText,
sourceCode: searchText,
sections: searchSections,
};
const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl'];
const extractMeta = style =>
style.usercssData
? (style.sourceCode.match(usercss.RX_META) || [''])[0]
: null;
const stripMeta = style =>
style.usercssData
? style.sourceCode.replace(usercss.RX_META, '')
: null;
const MODES = Object.assign(Object.create(null), {
code: (style, test) =>
style.usercssData
? test(stripMeta(style))
: searchSections(style, test, 'code'),
meta: (style, test, part) =>
METAKEYS.some(key => test(style[key])) ||
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
searchSections(style, test, 'funcs'),
name: (style, test) =>
test(style.customName) ||
test(style.name),
all: (style, test) =>
MODES.meta(style, test, 'all') ||
!style.usercssData && MODES.code(style, test),
});
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API_METHODS.searchDB = ({query, ids}) => {
let rx, words, icase, matchUrl;
query = query.trim();
if (/^url:/i.test(query)) {
matchUrl = query.slice(query.indexOf(':') + 1).trim();
if (matchUrl) {
return styleManager.getStylesByUrl(matchUrl)
.then(results => results.map(r => r.data.id));
}
}
if (query.startsWith('/') && /^\/(.+?)\/([gimsuy]*)$/.test(query)) {
rx = tryRegExp(RegExp.$1, RegExp.$2);
}
if (!rx) {
words = query
.split(/(".*?")|\s+/)
.filter(Boolean)
.map(w => w.startsWith('"') && w.endsWith('"')
? w.slice(1, -1)
: w)
.filter(w => w.length > 1);
words = words.length ? words : [query];
icase = words.some(w => w === lower(w));
}
return styleManager.getAllStyles().then(styles => {
if (ids) {
const idSet = new Set(ids);
styles = styles.filter(s => idSet.has(s.id));
}
const results = [];
for (const style of styles) {
const id = style.id;
if (!query || words && !words.length) {
results.push(id);
continue;
}
for (const part in PARTS) {
const text = part === 'name' ? style.customName || style.name : style[part];
if (text && PARTS[part](text, rx, words, icase)) {
results.push(id);
break;
}
}
}
API_METHODS.searchDB = async ({query, mode = 'all', ids}) => {
let res = [];
if (mode === 'url' && query) {
res = (await styleManager.getStylesByUrl(query)).map(r => r.data.id);
} else if (mode in MODES) {
const modeHandler = MODES[mode];
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
const rx = m && tryRegExp(m[1], m[2]);
const test = rx ? rx.test.bind(rx) : makeTester(query);
res = (await styleManager.getAllStyles())
.filter(style =>
(!ids || ids.includes(style.id)) &&
(!query || modeHandler(style, test)))
.map(style => style.id);
if (cache.size) debounce(clearCache, 60e3);
return results;
});
}
return res;
};
function searchText(text, rx, words, icase) {
if (rx) return rx.test(text);
for (let pass = 1; pass <= (icase ? 2 : 1); pass++) {
if (words.every(w => text.includes(w))) return true;
text = lower(text);
}
function makeTester(query) {
const flags = `u${lower(query) === query ? 'i' : ''}`;
const words = query
.split(/(".*?")|\s+/)
.filter(Boolean)
.map(w => w.startsWith('"') && w.endsWith('"')
? w.slice(1, -1)
: w)
.filter(w => w.length > 1);
const rxs = (words.length ? words : [query])
.map(w => stringAsRegExp(w, flags));
return text => rxs.every(rx => rx.test(text));
}
function searchSections(sections, rx, words, icase) {
function searchSections({sections}, test, part) {
const inCode = part === 'code' || part === 'all';
const inFuncs = part === 'funcs' || part === 'all';
for (const section of sections) {
for (const prop in section) {
const value = section[prop];
if (typeof value === 'string') {
if (searchText(value, rx, words, icase)) return true;
} else if (Array.isArray(value)) {
if (value.some(str => searchText(str, rx, words, icase))) return true;
if (inCode && prop === 'code' && test(value) ||
inFuncs && Array.isArray(value) && value.some(str => test(str))) {
return true;
}
}
}
@ -92,9 +99,7 @@
function lower(text) {
let result = cache.get(text);
if (result) return result;
result = text.toLocaleLowerCase();
cache.set(text, result);
if (!result) cache.set(text, result = text.toLocaleLowerCase());
return result;
}

View File

@ -470,11 +470,7 @@ const styleManager = (() => {
}
}
if (sectionMatched) {
result.push({
data: getStyleWithNoCode(data),
excluded,
sloppy,
});
result.push({data, excluded, sloppy});
}
}
return result;

View File

@ -142,6 +142,7 @@ select {
transition: color .5s;
}
.select-wrapper,
.select-resizer {
display: inline-flex!important;
cursor: default;

View File

@ -12,7 +12,12 @@ const usercss = (() => {
};
const RX_META = /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i;
const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']);
return {buildMeta, buildCode, assignVars};
return {
RX_META,
buildMeta,
buildCode,
assignVars,
};
function buildMeta(sourceCode) {
sourceCode = sourceCode.replace(/\r\n?/g, '\n');

View File

@ -266,9 +266,19 @@
</label>
<div id="search-wrapper">
<input id="search" type="search" i18n-placeholder="searchStyles" spellcheck="false"
<input id="search" type="search" i18n-placeholder="search" spellcheck="false"
data-filter=":not(.not-matching)"
data-filter-hide=".not-matching">
<div class="select-wrapper">
<select id="searchMode">
<option i18n-text="searchStylesName" value="name"></option>
<option i18n-text="searchStylesMeta" value="meta" selected></option>
<option i18n-text="searchStylesCode" value="code"></option>
<option i18n-text="searchStylesMatchUrl" value="url"></option>
<option i18n-text="searchStylesAll" value="all"></option>
</select>
<svg class="svg-icon select-arrow"><use xlink:href="#svg-icon-select-arrow"/></svg>
</div>
<a href="#" id="search-help" tabindex="0">
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
</a>

View File

@ -11,8 +11,9 @@ const filtersSelector = {
let initialized = false;
router.watch({search: ['search']}, ([search]) => {
router.watch({search: ['search', 'searchMode']}, ([search, mode]) => {
$('#search').value = search || '';
if (mode) $('#searchMode').value = mode;
if (!initialized) {
initFilters();
initialized = true;
@ -37,15 +38,15 @@ HTMLSelectElement.prototype.adjustWidth = function () {
};
function initFilters() {
$('#search').oninput = e => {
router.updateSearch('search', e.target.value);
$('#search').oninput = $('#searchMode').oninput = function (e) {
router.updateSearch(this.id, e.target.value);
};
$('#search-help').onclick = event => {
event.preventDefault();
messageBox({
className: 'help-text',
title: t('searchStyles'),
title: t('search'),
contents:
$create('ul',
t('searchStylesHelp').split('\n').map(line =>
@ -141,7 +142,7 @@ function initFilters() {
}
function filterOnChange({target: el, forceRefilter}) {
function filterOnChange({target: el, forceRefilter, alreadySearched}) {
const getValue = el => (el.type === 'checkbox' ? el.checked : el.value.trim());
if (!forceRefilter) {
const value = getValue(el);
@ -164,7 +165,7 @@ function filterOnChange({target: el, forceRefilter}) {
unhide: buildFilter(false),
});
if (installed) {
reapplyFilter().then(sorter.updateStripes);
reapplyFilter(installed, alreadySearched).then(sorter.updateStripes);
}
}
@ -278,10 +279,12 @@ function showFiltersStats() {
}
function searchStyles({immediately, container} = {}) {
async function searchStyles({immediately, container} = {}) {
const el = $('#search');
const elMode = $('#searchMode');
const query = el.value.trim();
if (query === el.lastValue && !immediately && !container) {
const mode = elMode.value;
if (query === el.lastValue && mode === elMode.lastValue && !immediately && !container) {
return;
}
if (!immediately) {
@ -289,24 +292,24 @@ function searchStyles({immediately, container} = {}) {
return;
}
el.lastValue = query;
elMode.lastValue = mode;
const entries = container && container.children || container || installed.children;
return API.searchDB({
query,
ids: [...entries].map(el => el.styleId),
}).then(ids => {
ids = new Set(ids);
let needsRefilter = false;
for (const entry of entries) {
const isMatching = ids.has(entry.styleId);
if (entry.classList.contains('not-matching') !== !isMatching) {
entry.classList.toggle('not-matching', !isMatching);
needsRefilter = true;
}
const all = installed.children;
const entries = container && container.children || container || all;
const idsToSearch = entries !== all && [...entries].map(el => el.styleId);
const ids = entries[0]
? await API.searchDB({query, mode, ids: idsToSearch})
: [];
let needsRefilter = false;
for (const entry of entries) {
const isMatching = ids.includes(entry.styleId);
if (entry.classList.contains('not-matching') !== !isMatching) {
entry.classList.toggle('not-matching', !isMatching);
needsRefilter = true;
}
if (needsRefilter && !container) {
filterOnChange({forceRefilter: true});
}
return container;
});
}
if (needsRefilter && !container) {
filterOnChange({forceRefilter: true, alreadySearched: true});
}
return container;
}

View File

@ -825,10 +825,6 @@ a:hover {
background-color: hsla(0, 0%, 50%, .4);
}
#filters {
border: 1px solid transparent;
}
.active #filters-stats {
background-color: darkcyan;
border-color: darkcyan;
@ -874,10 +870,11 @@ a:hover {
#search-wrapper, #sort-wrapper {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: .5rem;
}
#searchMode {
margin-left: -1px;
}
#search-wrapper {
margin-top: .35rem;
}
@ -886,17 +883,12 @@ a:hover {
display: inline-flex;
flex-grow: 1;
position: relative;
max-width: calc(100% - 30px);
}
#manage\.newUI\.sort {
max-width: 100%;
}
#search {
max-width: calc(100% - 30px);
}
#search, #manage\.newUI\.sort {
flex-grow: 1;
background: #fff;

View File

@ -75,7 +75,7 @@ const handleEvent = {};
const query = router.getSearch('search');
const [styles, ids, el] = await Promise.all([
API.getAllStyles(),
query && API.searchDB({query}), // FIXME: integrate this into filter.js
query && API.searchDB({query, mode: router.getSearch('searchMode')}),
waitForSelector('#installed'), // needed to avoid flicker due to an extra frame and layout shift
prefs.initializing,
]);
@ -102,7 +102,7 @@ const handleEvent = {};
].map(id => `--${id}:"${CSS.escape(t(id))}";`).join('')
}}`);
if (!VIVALDI) {
$$('#header select').forEach(el => el.adjustWidth());
$$('#filters select').forEach(el => el.adjustWidth());
}
if (CHROME >= 80 && CHROME <= 88) {
// Wrong checkboxes are randomly checked after going back in history, https://crbug.com/1138598

View File

@ -137,7 +137,6 @@ function initPopup(frames) {
Object.assign($('#popup-manage-button'), {
onclick: handleEvent.openManager,
onmouseup: handleEvent.openManager,
oncontextmenu: handleEvent.openManager,
});
@ -654,17 +653,10 @@ Object.assign(handleEvent, {
},
openManager(event) {
if (event.button === 2 && !tabURL) return;
event.preventDefault();
if (!this.eventHandled) {
// FIXME: this only works if popup is closed
this.eventHandled = true;
API.openManage({
search: tabURL && (event.shiftKey || event.button === 2) ?
`url:${tabURL}` : null,
});
window.close();
}
const isSearch = tabURL && (event.shiftKey || event.button === 2);
API.openManage(isSearch ? {search: tabURL, searchMode: 'url'} : {});
window.close();
},
copyContent(event) {