stylus/popup/popup.js
narcolepticinsomniac bc56a7abd6
menus as dialogs (#727)
* menus as dialogs

I think it's better in general, and also eliminates the glitchiness of expandable menus in FF, not to mention possible scroll issues on long style lists with overflow, plus the fact that we could add many menu items in the future without the menu looking ridiculous.

IDEK how the formatting got screwed up and borked the diffs in the other PR. I updated my editor config plugin, so let's see if that helps. I restarted with a fresh master and copy/pasted the changes, so if it's still screwy, I guess my editor needs more help. If not, I'm not playing around with it anymore right now, and the other PR does at least work.

* menus as dialogs
2019-06-20 15:37:40 -04:00

648 lines
19 KiB
JavaScript

/* global configDialog hotkeys onTabReady msg
getActiveTab FIREFOX getTabRealURL URLS API onDOMready $ $$ prefs CHROME
setupLivePrefs template t $create tWordBreak animateElement
tryJSONparse debounce */
'use strict';
let installed;
let tabURL;
const handleEvent = {};
const ENTRY_ID_PREFIX_RAW = 'style-';
const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
toggleSideBorders();
getActiveTab()
.then(tab =>
FIREFOX && tab.url === 'about:blank' && tab.status === 'loading'
? getTabRealURLFirefox(tab)
: getTabRealURL(tab)
)
.then(url => Promise.all([
(tabURL = URLS.supported(url) ? url : '') &&
API.getStylesByUrl(tabURL),
onDOMready().then(initPopup),
]))
.then(([results]) => {
if (!results) {
// unsupported URL;
return;
}
showStyles(results.map(r => Object.assign(r.data, r)));
})
.catch(console.error);
msg.onExtension(onRuntimeMessage);
prefs.subscribe(['popup.stylesFirst'], (key, stylesFirst) => {
const actions = $('body > .actions');
const before = stylesFirst ? actions : actions.nextSibling;
document.body.insertBefore(installed, before);
});
prefs.subscribe(['popupWidth'], (key, value) => setPopupWidth(value));
prefs.subscribe(['popup.borders'], (key, value) => toggleSideBorders(value));
function onRuntimeMessage(msg) {
switch (msg.method) {
case 'styleAdded':
case 'styleUpdated':
if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return;
handleUpdate(msg);
break;
case 'styleDeleted':
handleDelete(msg.style.id);
break;
}
dispatchEvent(new CustomEvent(msg.method, {detail: msg}));
}
function setPopupWidth(width = prefs.get('popupWidth')) {
document.body.style.width =
Math.max(200, Math.min(800, width)) + 'px';
}
function toggleSideBorders(state = prefs.get('popup.borders')) {
// runs before <body> is parsed
const style = document.documentElement.style;
if (CHROME >= 3167 && state) {
style.cssText +=
'border-left: 2px solid white !important;' +
'border-right: 2px solid white !important;';
} else if (style.cssText) {
style.borderLeft = style.borderRight = '';
}
}
function initPopup() {
installed = $('#installed');
setPopupWidth();
// action buttons
$('#disableAll').onchange = function () {
installed.classList.toggle('disabled', this.checked);
};
setupLivePrefs();
Object.assign($('#popup-manage-button'), {
onclick: handleEvent.openManager,
onmouseup: handleEvent.openManager,
oncontextmenu: handleEvent.openManager,
});
$('#popup-options-button').onclick = () => {
chrome.runtime.openOptionsPage();
window.close();
};
$('#popup-wiki-button').onclick = handleEvent.openURLandHide;
if (!prefs.get('popup.stylesFirst')) {
document.body.insertBefore(
$('body > .actions'),
installed);
}
if (!tabURL) {
document.body.classList.add('blocked');
document.body.insertBefore(template.unavailableInfo, document.body.firstChild);
return;
}
getActiveTab().then(function ping(tab, retryCountdown = 10) {
msg.sendTab(tab.id, {method: 'ping'}, {frameId: 0})
.catch(() => false)
.then(pong => {
if (pong) {
return;
}
// FF and some Chrome forks (e.g. CentBrowser) implement tab-on-demand
// so we'll wait a bit to handle popup being invoked right after switching
if (retryCountdown > 0 && (
tab.status !== 'complete' ||
FIREFOX && tab.url === 'about:blank')) {
setTimeout(ping, 100, tab, --retryCountdown);
return;
}
const info = template.unreachableInfo;
if (!FIREFOX) {
// Chrome "Allow access to file URLs" in chrome://extensions message
info.appendChild($create('p', t('unreachableFileHint')));
}
if (FIREFOX && tabURL.startsWith(URLS.browserWebStore)) {
$('label', info).textContent = t('unreachableAMO');
const note = (FIREFOX < 59 ? t('unreachableAMOHintOldFF') : t('unreachableAMOHint')) +
(FIREFOX < 60 ? '' : '\n' + t('unreachableAMOHintNewFF'));
const renderToken = s => s[0] === '<' ? $create('b', tWordBreak(s.slice(1, -1))) : s;
const renderLine = line => $create('p', line.split(/(<.*?>)/).map(renderToken));
const noteNode = $create('fragment', note.split('\n').map(renderLine));
info.appendChild(noteNode);
}
// Inaccessible locally hosted file type, e.g. JSON, PDF, etc.
if (tabURL.length - tabURL.lastIndexOf('.') <= 5) {
info.appendChild($create('p', t('InaccessibleFileHint')));
}
document.body.classList.add('unreachable');
document.body.insertBefore(info, document.body.firstChild);
});
});
// Write new style links
const writeStyle = $('#write-style');
const matchTargets = document.createElement('span');
const matchWrapper = document.createElement('span');
matchWrapper.id = 'match';
matchWrapper.appendChild(matchTargets);
// For this URL
const urlLink = template.writeStyle.cloneNode(true);
Object.assign(urlLink, {
href: 'edit.html?url-prefix=' + encodeURIComponent(tabURL),
title: `url-prefix("${tabURL}")`,
textContent: prefs.get('popup.breadcrumbs.usePath')
? new URL(tabURL).pathname.slice(1)
// this&nbsp;URL
: t('writeStyleForURL').replace(/ /g, '\u00a0'),
onclick: handleEvent.openLink,
});
if (prefs.get('popup.breadcrumbs')) {
urlLink.onmouseenter =
urlLink.onfocus = () => urlLink.parentNode.classList.add('url()');
urlLink.onmouseleave =
urlLink.onblur = () => urlLink.parentNode.classList.remove('url()');
}
matchTargets.appendChild(urlLink);
// For domain
const domains = getDomains(tabURL);
for (const domain of domains) {
const numParts = domain.length - domain.replace(/\./g, '').length + 1;
// Don't include TLD
if (domains.length > 1 && numParts === 1) {
continue;
}
const domainLink = template.writeStyle.cloneNode(true);
Object.assign(domainLink, {
href: 'edit.html?domain=' + encodeURIComponent(domain),
textContent: numParts > 2 ? domain.split('.')[0] : domain,
title: `domain("${domain}")`,
onclick: handleEvent.openLink,
});
domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : '');
matchTargets.appendChild(domainLink);
}
if (prefs.get('popup.breadcrumbs')) {
matchTargets.classList.add('breadcrumbs');
matchTargets.appendChild(matchTargets.removeChild(matchTargets.firstElementChild));
}
writeStyle.appendChild(matchWrapper);
function getDomains(url) {
let d = /.*?:\/*([^/:]+)|$/.exec(url)[1];
if (!d || url.startsWith('file:')) {
return [];
}
const domains = [d];
while (d.indexOf('.') !== -1) {
d = d.substring(d.indexOf('.') + 1);
domains.push(d);
}
return domains;
}
}
function sortStyles(entries) {
const enabledFirst = prefs.get('popup.enabledFirst');
entries.sort((a, b) =>
enabledFirst && a.styleMeta.enabled !== b.styleMeta.enabled ?
(a.styleMeta.enabled ? -1 : 1) :
a.styleMeta.name.localeCompare(b.styleMeta.name)
);
}
function showStyles(styles) {
if (!styles) {
return;
}
if (!styles.length) {
installed.appendChild(template.noStyles.cloneNode(true));
window.dispatchEvent(new Event('showStyles:done'));
return;
}
const entries = styles.map(createStyleElement);
sortStyles(entries);
entries.forEach(e => installed.appendChild(e));
window.dispatchEvent(new Event('showStyles:done'));
}
function sortStylesInPlace() {
if (!prefs.get('popup.autoResort')) {
return;
}
const entries = $$('.entry', installed);
if (!entries.length) {
return;
}
sortStyles(entries);
entries.forEach(e => installed.appendChild(e));
}
function createStyleElement(style) {
let entry = $(ENTRY_ID_PREFIX + style.id);
if (!entry) {
entry = template.style.cloneNode(true);
entry.setAttribute('style-id', style.id);
Object.assign(entry, {
id: ENTRY_ID_PREFIX_RAW + style.id,
styleId: style.id,
styleIsUsercss: Boolean(style.usercssData),
onmousedown: handleEvent.maybeEdit,
styleMeta: style
});
const checkbox = $('.checker', entry);
Object.assign(checkbox, {
id: ENTRY_ID_PREFIX_RAW + style.id,
// title: t('exclusionsPopupTip'),
onclick: handleEvent.toggle,
// oncontextmenu: handleEvent.openExcludeMenu
});
const editLink = $('.style-edit-link', entry);
Object.assign(editLink, {
href: editLink.getAttribute('href') + style.id,
onclick: handleEvent.openLink,
});
const styleName = $('.style-name', entry);
Object.assign(styleName, {
htmlFor: ENTRY_ID_PREFIX_RAW + style.id,
onclick: handleEvent.name,
});
styleName.checkbox = checkbox;
styleName.appendChild(document.createTextNode(' '));
const config = $('.configure', entry);
config.onclick = handleEvent.configure;
if (!style.usercssData) {
if (style.updateUrl && style.updateUrl.includes('?') && style.url) {
config.href = style.url;
config.target = '_blank';
config.title = t('configureStyleOnHomepage');
config.dataset.sendMessage = JSON.stringify({method: 'openSettings'});
$('use', config).attributes['xlink:href'].nodeValue = '#svg-icon-config-uso';
} else {
config.classList.add('hidden');
}
} else if (Object.keys(style.usercssData.vars || {}).length === 0) {
config.classList.add('hidden');
}
$('.delete', entry).onclick = handleEvent.delete;
const indicator = template.regexpProblemIndicator.cloneNode(true);
indicator.appendChild(document.createTextNode('!'));
indicator.onclick = handleEvent.indicator;
$('.main-controls', entry).appendChild(indicator);
$('.menu-button', entry).onclick = handleEvent.toggleMenu;
$('.menu-close', entry).onclick = handleEvent.toggleMenu;
$('.exclude-by-domain-checkbox', entry).onchange = e => handleEvent.toggleExclude(e, 'domain');
$('.exclude-by-url-checkbox', entry).onchange = e => handleEvent.toggleExclude(e, 'url');
}
style = Object.assign(entry.styleMeta, style);
entry.classList.toggle('disabled', !style.enabled);
entry.classList.toggle('enabled', style.enabled);
$('.checker', entry).checked = style.enabled;
const styleName = $('.style-name', entry);
styleName.lastChild.textContent = style.name;
setTimeout(() => {
styleName.title = entry.styleMeta.sloppy ?
t('styleNotAppliedRegexpProblemTooltip') :
styleName.scrollWidth > styleName.clientWidth + 1 ?
styleName.textContent : '';
});
entry.classList.toggle('not-applied', style.excluded || style.sloppy);
entry.classList.toggle('regexp-partial', style.sloppy);
$('.exclude-by-domain-checkbox', entry).checked = styleExcluded(style, 'domain');
$('.exclude-by-url-checkbox', entry).checked = styleExcluded(style, 'url');
$('.exclude-by-domain', entry).title = getExcludeRule('domain');
$('.exclude-by-url', entry).title = getExcludeRule('url');
return entry;
}
function styleExcluded({exclusions}, type) {
if (!exclusions) {
return false;
}
const rule = getExcludeRule(type);
return exclusions.includes(rule);
}
function getExcludeRule(type) {
const u = new URL(tabURL);
if (type === 'domain') {
return u.origin + '/*';
}
// current page
return escapeGlob(u.origin + u.pathname);
}
function escapeGlob(text) {
return text.replace(/\*/g, '\\*');
}
Object.assign(handleEvent, {
getClickedStyleId(event) {
return (handleEvent.getClickedStyleElement(event) || {}).styleId;
},
getClickedStyleElement(event) {
return event.target.closest('.entry');
},
name(event) {
this.checkbox.dispatchEvent(new MouseEvent('click'));
event.preventDefault();
},
toggle(event) {
// when fired on checkbox, prevent the parent label from seeing the event, see #501
event.stopPropagation();
API
.toggleStyle(handleEvent.getClickedStyleId(event), this.checked)
.then(sortStylesInPlace);
},
toggleExclude(event, type) {
const entry = handleEvent.getClickedStyleElement(event);
if (event.target.checked) {
API.addExclusion(entry.styleMeta.id, getExcludeRule(type));
} else {
API.removeExclusion(entry.styleMeta.id, getExcludeRule(type));
}
},
toggleMenu(event) {
const entry = handleEvent.getClickedStyleElement(event);
const menu = $('.menu', entry);
const menuActive = $('.menu[data-display=true]');
if (menuActive) {
// fade-out style menu
animateElement(menu, {
className: 'lights-on',
onComplete: () => (menu.dataset.display = false),
});
window.onkeydown = null;
} else {
$('.menu-title', entry).textContent = $('.style-name', entry).textContent;
menu.dataset.display = true;
menu.style.cssText = '';
window.onkeydown = event => {
const close = $('.menu-close', entry);
const checkbox = $('.exclude-by-domain-checkbox', entry);
const keyCode = event.keyCode || event.which;
if (document.activeElement === close && (keyCode === 9) && !event.shiftKey) {
event.preventDefault();
checkbox.focus();
}
if (document.activeElement === checkbox && (keyCode === 9) && event.shiftKey) {
event.preventDefault();
close.focus();
}
if (keyCode === 27) {
event.preventDefault();
close.click();
}
};
}
event.preventDefault();
},
delete(event) {
const entry = handleEvent.getClickedStyleElement(event);
const id = entry.styleId;
const box = $('#confirm');
const menu = $('.menu', entry);
const cancel = $('[data-cmd="cancel"]', box);
const affirm = $('[data-cmd="ok"]', box);
box.dataset.display = true;
box.style.cssText = '';
$('b', box).textContent = $('.style-name', entry).textContent;
affirm.focus();
affirm.onclick = () => confirm(true);
cancel.onclick = () => confirm(false);
window.onkeydown = event => {
const close = $('.menu-close', entry);
const checkbox = $('.exclude-by-domain-checkbox', entry);
const confirmActive = $('#confirm[data-display="true"]');
const keyCode = event.keyCode || event.which;
if (document.activeElement === cancel && (keyCode === 9)) {
event.preventDefault();
affirm.focus();
}
if (document.activeElement === close && (keyCode === 9) && !event.shiftKey) {
event.preventDefault();
checkbox.focus();
}
if (document.activeElement === checkbox && (keyCode === 9) && event.shiftKey) {
event.preventDefault();
close.focus();
}
if (keyCode === 27) {
event.preventDefault();
if (confirmActive) {
box.dataset.display = false;
menu.focus();
} else {
close.click();
}
}
};
function confirm(ok) {
if (ok) {
// fade-out deletion confirmation dialog
animateElement(box, {
className: 'lights-on',
onComplete: () => (box.dataset.display = false),
});
window.onkeydown = null;
API.deleteStyle(id);
} else {
box.dataset.display = false;
menu.focus();
}
}
},
configure(event) {
const {styleId, styleIsUsercss} = handleEvent.getClickedStyleElement(event);
if (styleIsUsercss) {
API.getStyle(styleId, true).then(style => {
hotkeys.setState(false);
configDialog(style).then(() => {
hotkeys.setState(true);
});
});
} else {
handleEvent.openURLandHide.call(this, event);
}
},
indicator(event) {
const entry = handleEvent.getClickedStyleElement(event);
const info = template.regexpProblemExplanation.cloneNode(true);
$.remove('#' + info.id);
$$('a', info).forEach(el => (el.onclick = handleEvent.openURLandHide));
$$('button', info).forEach(el => (el.onclick = handleEvent.closeExplanation));
entry.appendChild(info);
},
closeExplanation() {
$('#regexp-explanation').remove();
},
openLink(event) {
if (!chrome.windows || !prefs.get('openEditInWindow', false)) {
handleEvent.openURLandHide.call(this, event);
return;
}
event.preventDefault();
chrome.windows.create(
Object.assign({
url: this.href
}, prefs.get('windowPosition', {}))
);
close();
},
maybeEdit(event) {
if (!(
event.button === 0 && (event.ctrlKey || event.metaKey) ||
event.button === 1 ||
event.button === 2)) {
return;
}
// open an editor on middleclick
if (event.target.matches('.entry, .style-name, .style-edit-link')) {
this.onmouseup = () => $('.style-edit-link', this).click();
this.oncontextmenu = event => event.preventDefault();
event.preventDefault();
return;
}
// prevent the popup being opened in a background tab
// when an irrelevant link was accidentally clicked
if (event.target.closest('a')) {
event.preventDefault();
return;
}
},
openURLandHide(event) {
event.preventDefault();
const message = tryJSONparse(this.dataset.sendMessage);
getActiveTab()
.then(activeTab => API.openURL({
url: this.href || this.dataset.href,
index: activeTab.index + 1
}))
.then(tab => {
if (message) {
return onTabReady(tab)
.then(() => msg.sendTab(tab.id, message));
}
})
.then(window.close);
},
openManager(event) {
event.preventDefault();
if (!this.eventHandled) {
this.eventHandled = true;
this.dataset.href += event.shiftKey || event.button === 2 ?
'?url=' + encodeURIComponent(tabURL) : '';
handleEvent.openURLandHide.call(this, event);
}
},
});
function handleUpdate({style, reason}) {
if (!tabURL) return;
fetchStyle()
.then(style => {
if (!style) {
return;
}
if ($(ENTRY_ID_PREFIX + style.id)) {
createStyleElement(style);
return;
}
document.body.classList.remove('blocked');
$$.remove('.blocked-info, #no-styles');
createStyleElement(style);
})
.catch(console.error);
function fetchStyle() {
if (reason === 'toggle' && $(ENTRY_ID_PREFIX + style.id)) {
return Promise.resolve(style);
}
return API.getStylesByUrl(tabURL, style.id)
.then(([result]) => result && Object.assign(result.data, result));
}
}
function handleDelete(id) {
$.remove(ENTRY_ID_PREFIX + id);
if (!$('.entry')) {
installed.appendChild(template.noStyles.cloneNode(true));
}
}
function getTabRealURLFirefox(tab) {
// wait for FF tab-on-demand to get a real URL (initially about:blank), 5 sec max
return new Promise(resolve => {
function onNavigation({tabId, url, frameId}) {
if (tabId === tab.id && frameId === 0) {
detach();
resolve(url);
}
}
function detach(timedOut) {
if (timedOut) {
resolve(tab.url);
} else {
debounce.unregister(detach);
}
chrome.webNavigation.onBeforeNavigate.removeListener(onNavigation);
chrome.webNavigation.onCommitted.removeListener(onNavigation);
chrome.tabs.onRemoved.removeListener(detach);
chrome.tabs.onReplaced.removeListener(detach);
}
chrome.webNavigation.onBeforeNavigate.addListener(onNavigation);
chrome.webNavigation.onCommitted.addListener(onNavigation);
chrome.tabs.onRemoved.addListener(detach);
chrome.tabs.onReplaced.addListener(detach);
debounce(detach, 5000, {timedOut: true});
});
}