stylus/manage.js

725 lines
20 KiB
JavaScript
Raw Normal View History

/* global messageBox */
'use strict';
2015-01-30 17:31:20 +00:00
let installed;
const newUI = {
enabled: prefs.get('manage.newUI'),
favicons: prefs.get('manage.newUI.favicons'),
targets: prefs.get('manage.newUI.targets'),
renderClass() {
document.documentElement.classList.toggle('newUI', newUI.enabled);
},
};
newUI.renderClass();
const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps'];
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const handleEvent = {};
Promise.all([
getStylesSafe(),
onDOMready().then(initGlobalEvents),
]).then(([styles]) => {
showStyles(styles);
});
chrome.runtime.onMessage.addListener(onRuntimeMessage);
function onRuntimeMessage(msg) {
switch (msg.method) {
case 'styleUpdated':
case 'styleAdded':
handleUpdate(msg.style, msg);
break;
case 'styleDeleted':
handleDelete(msg.id);
break;
}
}
2015-01-30 17:31:20 +00:00
function initGlobalEvents() {
installed = $('#installed');
installed.onclick = handleEvent.entryClicked;
$('#check-all-updates').onclick = checkUpdateAll;
$('#apply-all-updates').onclick = applyUpdateAll;
$('#search').oninput = searchStyles;
$('#manage-options-button').onclick = () => chrome.runtime.openOptionsPage();
$('#manage-shortcuts-button').onclick = () => openURL({url: URLS.configureCommands});
$$('#header a[href^="http"]').forEach(a => (a.onclick = handleEvent.external));
// focus search field on / key
document.onkeypress = event => {
if (event.keyCode == 47
&& !event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey
&& !event.target.matches('[type="text"], [type="search"]')) {
event.preventDefault();
$('#search').focus();
}
};
// remember scroll position on normal history navigation
window.onbeforeunload = rememberScrollPosition;
for (const [className, checkbox] of [
['enabled-only', $('#manage.onlyEnabled')],
['edited-only', $('#manage.onlyEdited')],
['updates-only', $('#onlyUpdates input')],
]) {
// will be triggered by setupLivePrefs immediately
checkbox.onchange = () => installed.classList.toggle(className, checkbox.checked);
}
$$('[data-toggle-on-click]').forEach(el => {
el.onclick = () => $(el.dataset.toggleOnClick).classList.toggle('hidden');
});
2017-04-13 07:54:56 +00:00
enforceInputRange($('#manage.newUI.targets'));
setupLivePrefs([
'manage.onlyEnabled',
'manage.onlyEdited',
'manage.newUI',
'manage.newUI.favicons',
'manage.newUI.targets',
]);
$$('[id^="manage.newUI"]')
.forEach(el => (el.oninput = (el.onchange = switchUI)));
switchUI({styleOnly: true});
2015-01-30 17:31:20 +00:00
}
function showStyles(styles = []) {
const sorted = styles
.map(style => ({name: style.name.toLocaleLowerCase(), style}))
.sort((a, b) => (a.name < b.name ? -1 : a.name == b.name ? 0 : 1));
const shouldRenderAll = (history.state || {}).scrollY > window.innerHeight;
const renderBin = document.createDocumentFragment();
renderStyles(0);
function renderStyles(index) {
const t0 = performance.now();
while (index < sorted.length) {
renderBin.appendChild(createStyleElement(sorted[index++]));
if (!shouldRenderAll && performance.now() - t0 > 10) {
break;
}
}
if ($('#search').value.trim()) {
// re-apply filtering on history Back
searchStyles({immediately: true, container: renderBin});
}
installed.appendChild(renderBin);
if (index < sorted.length) {
setTimeout(renderStyles, 0, index);
} else if (shouldRenderAll && 'scrollY' in (history.state || {})) {
setTimeout(() => scrollTo(0, history.state.scrollY));
}
if (newUI.enabled && newUI.favicons) {
debounce(handleEvent.loadFavicons, 16);
}
}
2015-01-30 17:31:20 +00:00
}
function createStyleElement({style, name}) {
// query the sub-elements just once, then reuse the references
if ((createStyleElement.parts || {}).newUI !== newUI.enabled) {
const entry = template[`style${newUI.enabled ? 'Compact' : ''}`].cloneNode(true);
createStyleElement.parts = {
newUI: newUI.enabled,
entry,
entryClassBase: entry.className,
checker: $('.checker', entry) || {},
nameLink: $('.style-name-link', entry),
editLink: $('.style-edit-link', entry) || {},
editHrefBase: $('.style-name-link, .style-edit-link', entry).getAttribute('href'),
homepage: $('.homepage', entry),
appliesTo: $('.applies-to', entry),
targets: $('.targets', entry),
expander: $('.expander', entry),
decorations: {
urlPrefixesAfter: '*',
regexpsBefore: '/',
regexpsAfter: '/',
},
};
}
const parts = createStyleElement.parts;
parts.checker.checked = style.enabled;
parts.nameLink.textContent = style.name;
parts.nameLink.href = parts.editLink.href = parts.editHrefBase + style.id;
parts.homepage.href = parts.homepage.title = style.url || '';
const entry = parts.entry.cloneNode(true);
entry.id = 'style-' + style.id;
entry.styleId = style.id;
entry.styleNameLowerCase = name || style.name.toLocaleLowerCase();
entry.className = parts.entryClassBase + ' ' +
(style.enabled ? 'enabled' : 'disabled') +
(style.updateUrl ? ' updatable' : '');
// name being supplied signifies we're invoked by showStyles()
// which debounces its main loop thus loading the postponed favicons
createStyleTargetsElement({entry, style, postponeFavicons: name});
return entry;
}
function createStyleTargetsElement({entry, style, postponeFavicons}) {
const parts = createStyleElement.parts;
const targets = parts.targets.cloneNode(true);
let container = targets;
let numTargets = 0;
let numIcons = 0;
const displayed = new Set();
for (const type of TARGET_TYPES) {
for (const section of style.sections) {
for (const targetValue of section[type] || []) {
if (displayed.has(targetValue)) {
continue;
}
displayed.add(targetValue);
const element = template.appliesToTarget.cloneNode(true);
if (!newUI.enabled) {
if (numTargets == 10) {
container = container.appendChild(template.extraAppliesTo.cloneNode(true));
} else if (numTargets > 1) {
container.appendChild(template.appliesToSeparator.cloneNode(true));
}
} else if (newUI.favicons) {
let favicon = '';
if (type == 'domains') {
favicon = GET_FAVICON_URL + targetValue;
} else if (targetValue.startsWith('chrome-extension:')) {
favicon = OWN_ICON;
} else if (type != 'regexps') {
favicon = targetValue.includes('://') && targetValue.match(/^.*?:\/\/([^/]+)/);
favicon = favicon ? GET_FAVICON_URL + favicon[1] : '';
}
if (favicon) {
element.appendChild(document.createElement('img')).dataset.src = favicon;
numIcons++;
}
}
element.appendChild(
document.createTextNode(
(parts.decorations[type + 'Before'] || '') +
targetValue +
(parts.decorations[type + 'After'] || '')));
container.appendChild(element);
numTargets++;
}
}
}
if (newUI.enabled) {
if (numTargets > newUI.targets) {
$('.applies-to', entry).classList.add('has-more');
}
if (numIcons && !postponeFavicons) {
debounce(handleEvent.loadFavicons);
}
}
const entryTargets = $('.targets', entry);
if (numTargets) {
entryTargets.parentElement.replaceChild(targets, entryTargets);
} else {
entryTargets.appendChild(template.appliesToEverything.cloneNode(true));
}
entry.classList.toggle('global', !numTargets);
2015-01-30 17:31:20 +00:00
}
Object.assign(handleEvent, {
ENTRY_ROUTES: {
'.checker, .enable, .disable': 'toggle',
'.style-name-link': 'edit',
'.homepage': 'external',
'.check-update': 'check',
'.update': 'update',
'.delete': 'delete',
'.applies-to .expander': 'expandTargets',
},
entryClicked(event) {
const target = event.target;
const entry = target.closest('.entry');
for (const selector in handleEvent.ENTRY_ROUTES) {
for (let el = target; el && el != entry; el = el.parentElement) {
if (el.matches(selector)) {
const handler = handleEvent.ENTRY_ROUTES[selector];
return handleEvent[handler].call(el, event, entry);
}
}
}
},
edit(event) {
if (event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const left = event.button == 0;
const middle = event.button == 1;
const shift = event.shiftKey;
const ctrl = event.ctrlKey;
const openWindow = left && shift && !ctrl;
const openBackgroundTab = (middle && !shift) || (left && ctrl && !shift);
const openForegroundTab = (middle && shift) || (left && ctrl && shift);
const url = event.target.closest('[href]').href;
if (openWindow || openBackgroundTab || openForegroundTab) {
if (openWindow) {
chrome.windows.create(Object.assign(prefs.get('windowPosition'), {url}));
} else {
openURL({url, active: openForegroundTab});
}
} else {
rememberScrollPosition();
getActiveTab().then(tab => {
sessionStorageHash('manageStylesHistory').set(tab.id, url);
location.href = url;
});
}
},
toggle(event, entry) {
saveStyleSafe({
id: entry.styleId,
enabled: this.matches('.enable') || this.checked,
});
},
check(event, entry) {
checkUpdate(entry);
},
update(event, entry) {
// update everything but name
saveStyleSafe(Object.assign(entry.updatedCode, {
id: entry.styleId,
name: null,
reason: 'update',
}));
},
delete(event, entry) {
const id = entry.styleId;
const {name} = BG.cachedStyles.byId.get(id) || {};
animateElement(entry, {className: 'highlight'});
messageBox({
title: t('deleteStyleConfirm'),
contents: name,
className: 'danger center',
buttons: [t('confirmDelete'), t('confirmCancel')],
})
.then(({button, enter, esc}) => {
if (button == 0 || enter) {
deleteStyleSafe({id});
}
});
},
external(event) {
openURL({url: event.target.closest('a').href});
event.preventDefault();
},
expandTargets() {
this.closest('.applies-to').classList.toggle('expanded');
},
loadFavicons(container = installed) {
for (const img of container.getElementsByTagName('img')) {
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
}
}
});
2015-01-30 17:31:20 +00:00
function handleUpdate(style, {reason} = {}) {
const element = createStyleElement({style});
if ($('#search').value.trim()) {
const renderBin = document.createDocumentFragment();
renderBin.appendChild(element);
searchStyles({immediately: true, container: renderBin});
}
2017-04-01 08:41:33 +00:00
const oldElement = $('#style-' + style.id, installed);
if (oldElement) {
if (oldElement.styleNameLowerCase == element.styleNameLowerCase) {
installed.replaceChild(element, oldElement);
} else {
oldElement.remove();
}
if (reason == 'update') {
element.classList.add('update-done');
element.classList.remove('can-update', 'updatable');
$('.update-note', element).textContent = t('updateCompleted');
renderUpdatesOnlyFilter();
}
}
installed.insertBefore(element, findNextElement(style));
if (reason != 'import') {
animateElement(element, {className: 'highlight'});
scrollElementIntoView(element);
}
2015-01-30 17:31:20 +00:00
}
2015-01-30 17:31:20 +00:00
function handleDelete(id) {
2017-04-01 08:41:33 +00:00
const node = $('#style-' + id, installed);
if (node) {
node.remove();
}
2015-01-30 17:31:20 +00:00
}
function switchUI({styleOnly} = {}) {
const enabled = $('#manage.newUI').checked;
const favicons = $('#manage.newUI.favicons').checked;
const targets = Number($('#manage.newUI.targets').value);
const stateToggled = newUI.enabled != enabled;
const targetsChanged = enabled && targets != newUI.targets;
const faviconsChanged = enabled && favicons != newUI.favicons;
const missingFavicons = enabled && favicons && !$('.applies-to img');
if (!styleOnly && !stateToggled && !targetsChanged && !faviconsChanged) {
return;
}
Object.assign(newUI, {enabled, favicons, targets});
newUI.renderClass();
installed.classList.toggle('has-favicons', favicons);
$('#style-overrides').textContent = `
.newUI .targets {
max-height: ${newUI.targets * 18}px;
}
`;
if (styleOnly) {
return;
}
if (stateToggled || missingFavicons && !createStyleElement.parts) {
installed.innerHTML = '';
getStylesSafe().then(showStyles);
return;
}
if (targetsChanged) {
for (const targets of $$('.entry .targets')) {
const hasMore = targets.children.length > newUI.targets;
targets.parentElement.classList.toggle('has-more', hasMore);
}
return;
}
if (missingFavicons) {
getStylesSafe().then(styles => {
for (const style of styles) {
const entry = $('#style-' + style.id);
if (entry) {
createStyleTargetsElement({entry, style, postponeFavicons: true});
}
}
debounce(handleEvent.loadFavicons);
});
return;
}
}
2015-03-15 04:21:08 +00:00
function applyUpdateAll() {
const btnApply = $('#apply-all-updates');
btnApply.disabled = true;
setTimeout(() => {
btnApply.classList.add('hidden');
btnApply.disabled = false;
}, 1000);
2017-03-25 05:54:58 +00:00
$$('.can-update .update').forEach(button => {
scrollElementIntoView(button);
2017-03-25 05:54:58 +00:00
button.click();
});
renderUpdatesOnlyFilter({show: false});
2015-03-15 04:21:08 +00:00
}
2015-01-30 17:31:20 +00:00
function checkUpdateAll() {
const btnCheck = $('#check-all-updates');
const btnApply = $('#apply-all-updates');
const noUpdates = $('#update-all-no-updates');
const progress = $('#update-progress');
btnCheck.disabled = true;
btnApply.classList.add('hidden');
noUpdates.classList.add('hidden');
const maxWidth = progress.parentElement.clientWidth;
const queue = $$('.updatable:not(.can-update)').map(checkUpdate);
const total = queue.length;
let updatesFound = false;
let checked = 0;
processQueue();
// notify the automatic updater to reset the next automatic update accordingly
chrome.runtime.sendMessage({
method: 'resetInterval'
});
function processQueue(status) {
if (status === true) {
updatesFound = true;
btnApply.disabled = true;
btnApply.classList.remove('hidden');
renderUpdatesOnlyFilter({check: true});
}
if (checked < total) {
queue[checked++].then(status => {
progress.style.width = Math.round(checked / total * maxWidth) + 'px';
setTimeout(processQueue, 0, status);
});
return;
}
btnCheck.disabled = false;
btnApply.disabled = false;
if (!updatesFound) {
noUpdates.classList.remove('hidden');
setTimeout(() => {
noUpdates.classList.add('hidden');
}, 10e3);
}
}
2015-01-30 17:31:20 +00:00
}
function checkUpdate(element) {
$('.update-note', element).textContent = t('checkingForUpdate');
$('.check-update', element).title = '';
element.classList.remove('checking-update', 'no-update', 'update-problem');
element.classList.add('checking-update');
return new Updater(element).run(); // eslint-disable-line no-use-before-define
2015-01-30 17:31:20 +00:00
}
class Updater {
constructor(element) {
const style = BG.cachedStyles.byId.get(element.styleId);
Object.assign(this, {
element,
id: style.id,
url: style.updateUrl,
md5Url: style.md5Url,
md5: style.originalMd5,
});
}
run() {
return this.md5Url && this.md5
? this.checkMd5()
: this.checkFullCode();
}
checkMd5() {
return Updater.download(this.md5Url).then(
md5 => (md5.length == 32
? this.decideOnMd5(md5 != this.md5)
: this.onFailure(-1)),
status => this.onFailure(status));
}
decideOnMd5(md5changed) {
if (md5changed) {
return this.checkFullCode({forceUpdate: true});
}
this.display();
}
checkFullCode({forceUpdate = false} = {}) {
return Updater.download(this.url).then(
text => this.handleJson(forceUpdate, JSON.parse(text)),
status => this.onFailure(status));
}
handleJson(forceUpdate, json) {
return getStylesSafe({id: this.id}).then(([style]) => {
const needsUpdate = forceUpdate || !BG.styleSectionsEqual(style, json);
this.display({json: needsUpdate && json});
return needsUpdate;
});
}
onFailure(status) {
this.display({
message: status == 0
? t('updateCheckFailServerUnreachable')
: t('updateCheckFailBadResponseCode', [status]),
});
}
display({json, message} = {}) {
// json on success
// message on failure
// none on update not needed
this.element.classList.remove('checking-update');
if (json) {
this.element.classList.add('can-update');
this.element.updatedCode = json;
$('.update-note', this.element).textContent = '';
$('#onlyUpdates').classList.remove('hidden');
} else {
this.element.classList.add('no-update');
this.element.classList.toggle('update-problem', Boolean(message));
$('.update-note', this.element).textContent = message || t('updateCheckSucceededNoUpdate');
if (newUI.enabled) {
$('.check-update', this.element).title = message;
}
// don't hide if check-all is running
if (!$('#check-all-updates').disabled) {
$('#onlyUpdates').classList.toggle('hidden', !$('.can-update'));
}
}
}
static download(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onloadend = () => (xhr.status == 200
? resolve(xhr.responseText)
: reject(xhr.status));
if (url.length > 2000) {
const [mainUrl, query] = url.split('?');
xhr.open('POST', mainUrl, true);
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send(query);
} else {
xhr.open('GET', url);
xhr.send();
}
});
}
2015-01-30 17:31:20 +00:00
}
function renderUpdatesOnlyFilter({show, check} = {}) {
const numUpdatable = $$('.can-update').length;
const canUpdate = numUpdatable > 0;
const checkbox = $('#onlyUpdates input');
show = show !== undefined ? show : canUpdate;
check = check !== undefined ? show && check : checkbox.checked && canUpdate;
$('#onlyUpdates').classList.toggle('hidden', !show);
checkbox.checked = check;
checkbox.dispatchEvent(new Event('change'));
const btnApply = $('#apply-all-updates');
if (!btnApply.matches('.hidden')) {
if (canUpdate) {
btnApply.dataset.value = numUpdatable;
} else {
btnApply.classList.add('hidden');
}
}
}
function searchStyles({immediately, container}) {
const query = $('#search').value.toLocaleLowerCase();
2017-03-26 08:57:44 +00:00
if (query == (searchStyles.lastQuery || '') && !immediately && !container) {
return;
}
searchStyles.lastQuery = query;
if (!immediately) {
clearTimeout(searchStyles.timeout);
2017-03-26 08:57:44 +00:00
searchStyles.timeout = setTimeout(searchStyles, 150, {immediately: true});
return;
}
for (const element of (container || installed).children) {
const style = BG.cachedStyles.byId.get(element.styleId) || {};
if (style) {
2017-03-26 08:57:44 +00:00
const isMatching = !query
|| isMatchingText(style.name)
|| style.url && isMatchingText(style.url)
|| isMatchingStyle(style);
element.style.display = isMatching ? '' : 'none';
}
}
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) {
return text.toLocaleLowerCase().indexOf(query) >= 0;
}
2015-01-30 17:31:20 +00:00
}
function rememberScrollPosition() {
history.replaceState({scrollY: window.scrollY}, document.title);
}
function findNextElement(style) {
const nameLLC = style.name.toLocaleLowerCase();
const elements = installed.children;
let a = 0;
let b = elements.length - 1;
if (b < 0) {
return undefined;
}
if (elements[0].styleNameLowerCase > nameLLC) {
return elements[0];
}
if (elements[b].styleNameLowerCase <= nameLLC) {
return undefined;
}
// bisect
while (a < b - 1) {
const c = (a + b) / 2 | 0;
if (nameLLC < elements[c].styleNameLowerCase) {
b = c;
} else {
a = c;
}
}
if (elements[a].styleNameLowerCase > nameLLC) {
return elements[a];
}
while (a <= b && elements[a].name < nameLLC) {
a++;
}
return elements[elements[a].styleNameLowerCase <= nameLLC ? a + 1 : a];
}