stylus/manage/manage.js
2017-09-05 18:48:19 +08:00

694 lines
20 KiB
JavaScript

/* global messageBox, getStyleWithNoCode, retranslateCSS */
/* global filtersSelector, filterAndAppend */
/* global checkUpdate, handleUpdateInstalled */
/* global objectDiff */
'use strict';
let installed;
const ENTRY_ID_PREFIX_RAW = 'style-';
const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
const newUI = {
enabled: prefs.get('manage.newUI'),
favicons: prefs.get('manage.newUI.favicons'),
faviconsGray: prefs.get('manage.newUI.faviconsGray'),
targets: prefs.get('manage.newUI.targets'),
renderClass() {
document.documentElement.classList.toggle('newUI', newUI.enabled);
},
};
newUI.renderClass();
usePrefsDuringPageLoad();
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);
});
dieOnNullBackground();
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;
}
}
function initGlobalEvents() {
installed = $('#installed');
installed.onclick = handleEvent.entryClicked;
$('#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 || event.which) === 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;
$$('[data-toggle-on-click]').forEach(el => {
// dataset on SVG doesn't work in Chrome 49-??, works in 57+
const target = $(el.getAttribute('data-toggle-on-click'));
el.onclick = () => target.classList.toggle('hidden');
});
// triggered automatically by setupLivePrefs() below
enforceInputRange($('#manage.newUI.targets'));
// N.B. triggers existing onchange listeners
setupLivePrefs();
$$('[id^="manage.newUI"]')
.forEach(el => (el.oninput = (el.onchange = switchUI)));
switchUI({styleOnly: true});
// translate CSS manually
document.head.appendChild($element({tag: 'style', textContent: `
.disabled h2::after {
content: "${t('genericDisabledLabel')}";
}
#update-all-no-updates[data-skipped-edited="true"]:after {
content: " ${t('updateAllCheckSucceededSomeEdited')}";
}
`}));
}
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));
let index = 0;
const scrollY = (history.state || {}).scrollY;
const shouldRenderAll = scrollY > window.innerHeight || sessionStorage.justEditedStyleId;
const renderBin = document.createDocumentFragment();
if (scrollY) {
renderStyles();
} else {
requestAnimationFrame(renderStyles);
}
function renderStyles() {
const t0 = performance.now();
let rendered = 0;
while (
index < sorted.length &&
// eslint-disable-next-line no-unmodified-loop-condition
(shouldRenderAll || ++rendered < 10 || performance.now() - t0 < 10)
) {
renderBin.appendChild(createStyleElement(sorted[index++]));
}
filterAndAppend({container: renderBin});
if (index < sorted.length) {
requestAnimationFrame(renderStyles);
return;
}
if ('scrollY' in (history.state || {})) {
setTimeout(window.scrollTo, 0, 0, history.state.scrollY);
}
if (newUI.enabled && newUI.favicons) {
debounce(handleEvent.loadFavicons, 16);
}
if (sessionStorage.justEditedStyleId) {
const entry = $(ENTRY_ID_PREFIX + sessionStorage.justEditedStyleId);
delete sessionStorage.justEditedStyleId;
if (entry) {
animateElement(entry);
scrollElementIntoView(entry);
}
}
}
}
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' : ''}`];
createStyleElement.parts = {
newUI: newUI.enabled,
entry,
entryClassBase: entry.className,
checker: $('.checker', entry) || {},
nameLink: $('.style-name-link', entry),
editLink: $('.style-edit-link', entry) || {},
editHrefBase: 'edit.html?id=',
homepage: $('.homepage', entry),
homepageIcon: template[`homepageIcon${newUI.enabled ? 'Small' : 'Big'}`],
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 = ENTRY_ID_PREFIX_RAW + style.id;
entry.styleId = style.id;
entry.styleNameLowerCase = name || style.name.toLocaleLowerCase();
entry.styleMeta = getStyleWithNoCode(style);
entry.className = parts.entryClassBase + ' ' +
(style.enabled ? 'enabled' : 'disabled') +
(style.updateUrl ? ' updatable' : '');
if (style.url) {
$('.homepage', entry).appendChild(parts.homepageIcon.cloneNode(true));
}
if (style.updateUrl && newUI.enabled) {
$('.actions', entry).appendChild(template.updaterIcons.cloneNode(true));
}
if (style.vars && Object.keys(style.vars).length && newUI.enabled) {
$('.actions', entry).appendChild(template.configureIcon.cloneNode(true));
}
// 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);
}
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',
'.configure-usercss': 'config'
},
config(event, {styleMeta: style}) {
const form = buildConfigForm();
messageBox({
title: `Configure ${style.name}`,
className: 'config-dialog',
contents: form.el,
buttons: [
t('confirmSave'),
{
textContent: t('confirmDefault'),
onclick: form.useDefault
},
t('confirmCancel')
]
}).then(result => {
if (result.button !== 0 && !result.enter) {
return;
}
style.reason = 'config';
const vars = form.getVars();
let dirty = false;
for (const key of Object.keys(vars)) {
if (vars[key].dirty) {
dirty = true;
style.vars[key].value = vars[key].value;
}
}
if (!dirty) {
return;
}
saveStyleSafe(style);
});
function buildConfigForm() {
const labels = [];
const vars = deepCopy(style.vars);
for (const key of Object.keys(vars)) {
const va = vars[key];
let appendChild;
if (va.type === 'color') {
va.inputColor = $element({tag: 'input', type: 'color'});
// FIXME: i18n
va.inputAlpha = $element({tag: 'input', type: 'range', min: 0, max: 255, title: 'Opacity'});
va.inputColor.onchange = va.inputAlpha.oninput = () => {
va.dirty = true;
va.value = va.inputColor.value + Number(va.inputAlpha.value).toString(16);
va.inputColor.style.opacity = va.inputAlpha.value / 255;
};
appendChild = [va.label, va.inputColor, va.inputAlpha];
} else if (va.type === 'checkbox') {
va.input = $element({tag: 'input', type: 'checkbox'});
va.input.onchange = () => {
va.dirty = true;
va.value = String(Number(va.input.checked));
};
appendChild = [va.input, $element({tag: 'span', appendChild: va.label})];
} else if (va.type === 'select') {
va.input = $element({
tag: 'select',
appendChild: Object.keys(va.select).map(key => $element({
tag: 'option', value: key, appendChild: va.select[key]
}))
});
va.input.onchange = () => {
va.dirty = true;
va.value = va.input.value;
};
appendChild = [va.label, va.input];
} else {
va.input = $element({tag: 'input', type: 'text'});
va.input.oninput = () => {
va.dirty = true;
va.value = va.input.value;
};
appendChild = [va.label, va.input];
}
labels.push($element({
tag: 'label',
className: `config-${va.type}`,
appendChild
}));
}
drawValues();
function drawValues() {
for (const key of Object.keys(vars)) {
const va = vars[key];
const value = va.value === null || va.value === undefined ?
va.default : va.value;
if (va.type === 'color') {
va.inputColor.value = value.slice(0, -2);
va.inputAlpha.value = parseInt(value.slice(-2), 16);
va.inputColor.style.opacity = va.inputAlpha.value / 255;
} else if (va.type === 'checkbox') {
va.input.checked = Number(value);
} else {
va.input.value = value;
}
}
}
function useDefault() {
for (const key of Object.keys(vars)) {
const va = vars[key];
va.dirty = va.value !== null && va.value !== undefined &&
va.value !== va.default;
va.value = null;
}
drawValues();
}
function getVars() {
return vars;
}
return {
el: labels,
useDefault,
getVars
};
}
},
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);
messageBox({
title: t('deleteStyleConfirm'),
contents: name,
className: 'danger center',
buttons: [t('confirmDelete'), t('confirmCancel')],
})
.then(({button, enter}) => {
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 = document.body) {
for (const img of $$('img', container)) {
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
}
},
});
function handleUpdate(style, {reason, method} = {}) {
let entry;
let oldEntry = $(ENTRY_ID_PREFIX + style.id);
if (oldEntry && method === 'styleUpdated') {
handleToggledOrCodeOnly();
}
entry = entry || createStyleElement({style});
if (oldEntry) {
if (oldEntry.styleNameLowerCase === entry.styleNameLowerCase) {
installed.replaceChild(entry, oldEntry);
} else {
oldEntry.remove();
}
}
if (reason === 'update' && entry.matches('.updatable')) {
handleUpdateInstalled(entry);
}
filterAndAppend({entry});
if (!entry.matches('.hidden') && reason !== 'import') {
animateElement(entry);
scrollElementIntoView(entry);
}
function handleToggledOrCodeOnly() {
const newStyleMeta = getStyleWithNoCode(style);
const diff = objectDiff(oldEntry.styleMeta, newStyleMeta);
if (diff.length === 0) {
// only code was modified
entry = oldEntry;
oldEntry = null;
}
if (diff.length === 1 && diff[0].key === 'enabled') {
oldEntry.classList.toggle('enabled', style.enabled);
oldEntry.classList.toggle('disabled', !style.enabled);
$$('.checker', oldEntry).forEach(el => (el.checked = style.enabled));
oldEntry.styleMeta = newStyleMeta;
entry = oldEntry;
oldEntry = null;
}
}
}
function handleDelete(id) {
const node = $(ENTRY_ID_PREFIX + id);
if (node) {
node.remove();
if (node.matches('.can-update')) {
const btnApply = $('#apply-all-updates');
btnApply.dataset.value = Number(btnApply.dataset.value) - 1;
}
}
}
function switchUI({styleOnly} = {}) {
const current = {};
const changed = {};
let someChanged = false;
// ensure the global option is processed first
for (const el of [$('#manage.newUI'), ...$$('[id^="manage.newUI."]')]) {
const id = el.id.replace(/^manage\.newUI\.?/, '') || 'enabled';
const value = el.type === 'checkbox' ? el.checked : Number(el.value);
const valueChanged = value !== newUI[id] && (id === 'enabled' || current.enabled);
current[id] = value;
changed[id] = valueChanged;
someChanged |= valueChanged;
}
if (!styleOnly && !someChanged) {
return;
}
Object.assign(newUI, current);
newUI.renderClass();
installed.classList.toggle('has-favicons', newUI.favicons);
$('#style-overrides').textContent = `
.newUI .targets {
max-height: ${newUI.targets * 18}px;
}
` + (newUI.faviconsGray ? `
.newUI .target img {
-webkit-filter: grayscale(1);
filter: grayscale(1);
opacity: .25;
}
` : `
.newUI .target img {
-webkit-filter: none;
filter: none;
opacity: 1;
}
`);
if (styleOnly) {
return;
}
const missingFavicons = newUI.enabled && newUI.favicons && !$('.applies-to img');
if (changed.enabled || (missingFavicons && !createStyleElement.parts)) {
installed.textContent = '';
getStylesSafe().then(showStyles);
return;
}
if (changed.targets) {
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 = $(ENTRY_ID_PREFIX + style.id);
if (entry) {
createStyleTargetsElement({entry, style, postponeFavicons: true});
}
}
debounce(handleEvent.loadFavicons);
});
return;
}
}
function rememberScrollPosition() {
history.replaceState({scrollY: window.scrollY}, document.title);
}
function usePrefsDuringPageLoad() {
const observer = new MutationObserver(mutations => {
const adjustedNodes = [];
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
// [naively] assuming each element of addedNodes is a childless element
const prefValue = node.id ? prefs.readOnlyValues[node.id] : undefined;
if (prefValue !== undefined) {
if (node.type === 'checkbox') {
node.checked = prefValue;
} else {
node.value = prefValue;
}
if (node.adjustWidth) {
adjustedNodes.push(node);
}
}
}
}
if (adjustedNodes.length) {
observer.disconnect();
for (const node of adjustedNodes) {
node.adjustWidth();
}
startObserver();
}
});
function startObserver() {
observer.observe(document, {subtree: true, childList: true});
}
startObserver();
onDOMready().then(() => observer.disconnect());
}
// TODO: remove when these bugs are fixed in FF
function dieOnNullBackground() {
if (!FIREFOX || BG) {
return;
}
chrome.runtime.sendMessage({method: 'healthCheck'}, health => {
if (health && !chrome.extension.getBackgroundPage()) {
onDOMready().then(() => {
chrome.runtime.sendMessage({method: 'getStyles'}, showStyles);
messageBox({
title: 'Stylus',
className: 'danger center',
contents: t('dysfunctionalBackgroundConnection'),
onshow: () => $('#message-box-close-icon').remove(),
});
document.documentElement.style.pointerEvents = 'none';
});
}
});
}