diff --git a/.eslintrc b/.eslintrc index d02a8b8c..1d17b783 100644 --- a/.eslintrc +++ b/.eslintrc @@ -57,7 +57,10 @@ globals: isCheckbox: false runTryCatch: false tryRegExp: false + tryJSONparse: false + debounce: false setupLivePrefs: false + enforceInputRange: false getCodeMirrorThemes: false styleSectionsEqual: false diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e13a9670..c2ec7d38 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -282,6 +282,18 @@ "message": "Only edited styles", "description": "Checkbox to show only locally edited styles" }, + "manageNewUI": { + "message": "New manage UI layout", + "description": "Label for the checkbox that toggles the new UI on manage page" + }, + "manageFavicons": { + "message": "Favicons in applies-to column", + "description": "Label for the checkbox that toggles applies-to favicons in the new UI on manage page" + }, + "manageMaxTargets": { + "message": "Number of applies-to items", + "description": "Label for the numeric input box to limit max number of applies-to targets in the new UI on manage page" + }, "manageText": { "message": "Get styles on userstyles.org | Get help", "description": "Help text on the manage page" @@ -482,6 +494,10 @@ "message": "(Stylus can't affect this page.)", "description": "Note in the toolbar pop-up when on a URL Stylus can't affect" }, + "toggleStyle": { + "message": "Toggle style", + "description": "Label for the checkbox to enable/disable a style" + }, "undo": { "message": "Undo", "description": "Button label" diff --git a/manage.css b/manage.css index f660abe9..1a9bbe36 100644 --- a/manage.css +++ b/manage.css @@ -47,10 +47,9 @@ a:hover { .svg-icon { cursor: pointer; vertical-align: middle; - margin-top: -4px; transition: fill .5s; - width: 16px; - height: 16px; + width: 20px; + height: 20px; fill: #000; } @@ -58,11 +57,21 @@ a:hover { fill: #666; } +.svg-icon.delete { + width: 16px; + height: 16px; +} + .homepage { margin-left: 0.1em; margin-right: 0.1em; } +.homepage .svg-icon { + margin-top: -4px; + margin-left: .5ex; +} + .style-name { margin-top: .25em; word-break: break-word; @@ -101,7 +110,7 @@ a:hover { margin-right: .25rem; } -.applies-to > :first-child { +.applies-to label { margin-right: .5ex; } @@ -109,8 +118,9 @@ a:hover { background-color: rgba(128, 128, 128, .15); } -.applies-to-extra { +.applies-to-extra:not([open]) { display: inline; + margin-left: 1ex; } summary { @@ -142,6 +152,175 @@ summary { display: none; } +/* compact layout */ + +.newUI { + display: table; + margin: .75rem 0 .75rem 0; +} + +.newUI .disabled { + opacity: 1; +} + +.newUI .disabled .style-name, +.newUI .disabled .applies-to { + opacity: .5; +} + +.newUI .entry { + display: table-row; +} + +.newUI .entry:nth-child(2n) { + background-color: rgba(128, 128, 128, 0.05); +} + +.newUI .entry > * { + padding: .9rem 0 1rem; + margin: 0; + display: table-cell; + vertical-align: middle; +} + +.newUI .checker { + position: relative; + top: 1px; + margin-right: 1ex; +} + +.newUI .style-name { + font-size: 14px; + font-family: sans-serif; + text-indent: -2em; + padding-left: 3em; + padding-right: 30px; +} + +.newUI .homepage .svg-icon { + position: absolute; + margin-top: 0; + margin-left: -28px; +} + +.newUI .actions { + width: 60px; + height: 20px; + white-space: nowrap; + font-size: 0; +} + +.newUI .actions > * { + margin: 0; +} + +.newUI .actions .svg-icon { + margin-right: 8px; +} + +.newUI .updater-icons > * { + transition: opacity 1s; + display: none; +} + +.newUI .checking-update .check-update { + opacity: 0; + display: inline; + pointer-events: none; +} + +.newUI .can-update .update, +.newUI .no-update:not(.update-problem) .up-to-date, +.newUI .no-update.update-problem .check-update, +.newUI .update-done .updated { + display: inline; +} + +.update-problem .check-update svg { + fill: darkred; +} + +.newUI .applies-to { + padding-top: .25rem; + padding-bottom: .25rem; +} + +.newUI .targets { + overflow: hidden; +} + +.newUI .applies-to.expanded .targets { + max-height: 100vh; +} + +.newUI .target { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: calc(100vw - 280px - 60px - 25vw - 3rem); + box-sizing: border-box; + padding-right: 1rem; + line-height: 18px; +} + +.newUI .applies-to .expander { + margin: 0; + cursor: pointer; + font-size: 3ex; + line-height: .5ex; + vertical-align: super; + letter-spacing: .1ex; +} + +.newUI.has-favicons .target { + padding-left: 20px; +} + +.newUI .target:hover { + background-color: inherit; +} + +.newUI .target img { + width: 16px; + height: 16px; + vertical-align: sub; + margin-left: -20px; + margin-right: 4px; + transition: opacity .5s, filter .5s; + /* unprefixed since Chrome 53 */ + -webkit-filter: grayscale(1); + filter: grayscale(1); + opacity: .25; + display: none; +} + +.newUI.has-favicons .target img[src] { + display: inline; +} + +.newUI .entry:hover .target img { + opacity: 1; + /* unprefixed since Chrome 53 */ + -webkit-filter: grayscale(0); + filter: grayscale(0); +} + +#newUIoptions label { + display: flex; + align-items: center; + margin-bottom: auto; +} + +#newUIoptions input[type="number"] { + width: 3em; + margin-right: .5em; +} + +input[id^="manage.newUI"] { + margin-left: 0; +} + /* Default, no update buttons */ .update, .check-update { @@ -285,6 +464,15 @@ fieldset { } } +@keyframes fadein-25pct { + from { + opacity: 0; + } + to { + opacity: .25; + } +} + @media (max-width: 675px) { #header { height: auto; diff --git a/manage.html b/manage.html index a5c055a3..36ebeda0 100644 --- a/manage.html +++ b/manage.html @@ -6,10 +6,12 @@ + + + + @@ -46,6 +68,10 @@ + + @@ -85,6 +111,11 @@

+ +

@@ -106,13 +137,39 @@

- - - + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manage.js b/manage.js index c4a3309a..b6cfa652 100644 --- a/manage.js +++ b/manage.js @@ -2,11 +2,19 @@ 'use strict'; const installed = $('#installed'); -const TARGET_LABEL = t('appliesDisplay', '').trim(); +const newUI = { + enabled: prefs.get('manage.newUI'), + favicons: prefs.get('manage.newUI.favicons'), + targets: prefs.get('manage.newUI.targets'), +}; + const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps']; -const TARGET_LIMIT = 10; +const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain='; +const OWN_ICON = chrome.app.getDetails().icons['16']; + const handleEvent = {}; + getStylesSafe() .then(showStyles) .then(initGlobalEvents); @@ -26,6 +34,7 @@ chrome.runtime.onMessage.addListener(msg => { function initGlobalEvents() { + installed.onclick = handleEvent.entryClicked; $('#check-all-updates').onclick = checkUpdateAll; $('#apply-all-updates').onclick = applyUpdateAll; $('#search').oninput = searchStyles; @@ -50,19 +59,28 @@ function initGlobalEvents() { } }); + for (const [className, checkbox] of [ + ['enabled-only', $('#manage.onlyEnabled')], + ['edited-only', $('#manage.onlyEdited')], + ]) { + // will be triggered by setupLivePrefs immediately + checkbox.onchange = () => installed.classList.toggle(className, checkbox.checked); + } + + enforceInputRange($('#manage.newUI.favicons')); + setupLivePrefs([ 'manage.onlyEnabled', 'manage.onlyEdited', + 'manage.newUI', + 'manage.newUI.favicons', + 'manage.newUI.targets', ]); - [ - ['enabled-only', $('#manage.onlyEnabled')], - ['edited-only', $('#manage.onlyEdited')], - ] - .forEach(([className, checkbox]) => { - checkbox.onchange = () => installed.classList.toggle(className, checkbox.checked); - checkbox.onchange(); - }); + $$('[id^="manage.newUI"]') + .forEach(el => (el.oninput = (el.onchange = switchUI))); + + switchUI({styleOnly: true}); } @@ -98,7 +116,7 @@ function showStyles(styles = []) { function createStyleElement({style, name}) { - const entry = template.style.cloneNode(true); + const entry = template[`style${newUI.enabled ? 'Compact' : ''}`].cloneNode(true); entry.classList.add(style.enabled ? 'enabled' : 'disabled'); entry.setAttribute('style-id', style.id); entry.id = 'style-' + style.id; @@ -118,69 +136,111 @@ function createStyleElement({style, name}) { const styleNameEditLink = $('a', styleName); styleNameEditLink.appendChild(document.createTextNode(style.name)); styleNameEditLink.href = styleNameEditLink.getAttribute('href') + style.id; - styleNameEditLink.onclick = handleEvent.edit; if (style.url) { - const homepage = template.styleHomepage.cloneNode(true); - homepage.href = style.url; - homepage.onclick = handleEvent.external; - styleName.appendChild(document.createTextNode(' ')); - styleName.appendChild(homepage); + const homepage = Object.assign(template.styleHomepage.cloneNode(true), { + href: style.url, + title: style.url, + }); + if (newUI.enabled) { + const actions = $('.actions', entry); + actions.insertBefore(homepage, actions.firstChild); + } else { + styleName.appendChild(homepage); + } } - const targets = new Map(TARGET_TYPES.map(t => [t, new Set()])); + const appliesTo = $('.applies-to', entry); const decorations = { urlPrefixesAfter: '*', regexpsBefore: '/', regexpsAfter: '/', }; - for (const [name, target] of targets.entries()) { + const showFavicons = newUI.enabled && newUI.favicons; + const maxTargets = newUI.enabled ? Number.MAX_VALUE : 10; + const displayed = new Set(); + let container = newUI.enabled ? $('.targets', appliesTo) : appliesTo; + let numTargets = 0; + for (const type of TARGET_TYPES) { for (const section of style.sections) { - for (const targetValue of section[name] || []) { - target.add( - (decorations[name + 'Before'] || '') + - targetValue.trim() + - (decorations[name + 'After'] || '')); + for (const targetValue of section[type] || []) { + if (displayed.has(targetValue)) { + continue; + } + displayed.add(targetValue); + if (numTargets++ == maxTargets) { + container = appliesTo.appendChild(template.extraAppliesTo.cloneNode(true)); + } else if (numTargets > 1 && !newUI.enabled) { + container.appendChild(template.appliesToSeparator.cloneNode(true)); + } + const element = template.appliesToTarget.cloneNode(true); + if (showFavicons) { + 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.match(/^.*?:\/\/([^/]+)/); + favicon = favicon ? GET_FAVICON_URL + favicon[1] : ''; + } + if (favicon) { + element.appendChild(document.createElement('img')).dataset.src = favicon; + debounce(handleEvent.loadFavicons); + } + } + element.appendChild( + document.createTextNode( + (decorations[type + 'Before'] || '') + + targetValue + + (decorations[type + 'After'] || ''))); + container.appendChild(element); } } } - const appliesTo = $('.applies-to', entry); - appliesTo.firstElementChild.textContent = TARGET_LABEL; - const targetsList = Array.prototype.concat.apply([], - [...targets.values()].map(set => [...set.values()])); - if (!targetsList.length) { + if (!numTargets) { appliesTo.appendChild(template.appliesToEverything.cloneNode(true)); entry.classList.add('global'); - } else { - let index = 0; - let container = appliesTo; - for (const target of targetsList) { - if (index > 0) { - container.appendChild(template.appliesToSeparator.cloneNode(true)); - } - if (++index == TARGET_LIMIT) { - container = appliesTo.appendChild(template.extraAppliesTo.cloneNode(true)); - } - const item = template.appliesToTarget.cloneNode(true); - item.textContent = target; - container.appendChild(item); - } } - const editLink = $('.style-edit-link', entry); - editLink.href = editLink.getAttribute('href') + style.id; - editLink.onclick = handleEvent.edit; + if (newUI.enabled) { + $('.checker', entry).checked = style.enabled; + if (numTargets > newUI.targets) { + appliesTo.appendChild(template.expandAppliesTo.cloneNode(true)); + } + } else { + const editLink = $('.style-edit-link', entry); + editLink.href = editLink.getAttribute('href') + style.id; + } - $('.enable', entry).onclick = handleEvent.toggle; - $('.disable', entry).onclick = handleEvent.toggle; - $('.check-update', entry).onclick = handleEvent.check; - $('.update', entry).onclick = handleEvent.update; - $('.delete', entry).onclick = handleEvent.delete; return entry; } 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; @@ -210,30 +270,28 @@ Object.assign(handleEvent, { } }, - toggle(event) { - enableStyle(getClickedStyleId(event), this.matches('.enable')) + toggle(event, entry) { + enableStyle(entry.styleId, this.matches('.enable') || this.checked) .then(handleUpdate); }, - check(event) { - checkUpdate(getClickedStyleElement(event)); + check(event, entry) { + checkUpdate(entry); }, - update(event) { - const styleElement = getClickedStyleElement(event); + update(event, entry) { // update everything but name - saveStyle(Object.assign(styleElement.updatedCode, { - id: styleElement.styleId, + saveStyle(Object.assign(entry.updatedCode, { + id: entry.styleId, name: null, reason: 'update', })); }, - delete(event) { - const styleElement = getClickedStyleElement(event); - const id = styleElement.styleId; + delete(event, entry) { + const id = entry.styleId; const {name} = cachedStyles.byId.get(id) || {}; - animateElement(styleElement, {className: 'highlight'}); + animateElement(entry, {className: 'highlight'}); messageBox({ title: t('deleteStyleConfirm'), contents: name, @@ -251,10 +309,23 @@ Object.assign(handleEvent, { 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; + } + } + } }); -function handleUpdate(style, {reason} = {}) { +function handleUpdate(style, {reason, quiet} = {}) { const element = createStyleElement({style}); const oldElement = $('#style-' + style.id, installed); if (oldElement) { @@ -269,8 +340,10 @@ function handleUpdate(style, {reason} = {}) { } } installed.insertBefore(element, findNextElement(style)); - animateElement(element, {className: 'highlight'}); - scrollElementIntoView(element); + if (!quiet) { + animateElement(element, {className: 'highlight'}); + scrollElementIntoView(element); + } } @@ -282,6 +355,38 @@ function handleDelete(id) { } +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}); + + installed.classList.toggle('newUI', enabled); + installed.classList.toggle('has-favicons', favicons); + $('#newUIoptions').classList.toggle('hidden', !enabled); + $('#style-overrides').textContent = ` + .newUI .targets { + max-height: ${newUI.targets * 18}px; + } + `; + + if (!styleOnly && (stateToggled || missingFavicons)) { + installed.innerHTML = ''; + getStylesSafe().then(showStyles); + } +} + + function applyUpdateAll() { const btnApply = $('#apply-all-updates'); btnApply.disabled = true; @@ -310,8 +415,11 @@ function checkUpdateAll() { Promise.all($$('[style-update-url]').map(checkUpdate)) .then(updatables => { btnCheck.disabled = false; - if (updatables.includes(true)) { + const numUpdatable = updatables.filter(u => u).length; + if (numUpdatable) { btnApply.classList.remove('hidden'); + btnApply.originalLabel = btnApply.originalLabel || btnApply.textContent; + btnApply.textContent = btnApply.originalLabel + ` (${numUpdatable})`; } else { noUpdates.classList.remove('hidden'); setTimeout(() => { @@ -329,7 +437,8 @@ function checkUpdateAll() { function checkUpdate(element) { $('.update-note', element).innerHTML = t('checkingForUpdate'); - element.classList.remove('checking-update', 'no-update', 'can-update'); + $('.check-update', element).title = ''; + element.classList.remove('checking-update', 'no-update', 'can-update', 'update-problem'); element.classList.add('checking-update'); return new Updater(element).run(); // eslint-disable-line no-use-before-define } @@ -337,12 +446,13 @@ function checkUpdate(element) { class Updater { constructor(element) { + const style = cachedStyles.byId.get(element.styleId); Object.assign(this, { element, - id: element.styleId, - url: element.getAttribute('style-update-url'), - md5Url: element.getAttribute('style-md5-url'), - md5: element.getAttribute('style-original-md5'), + id: style.id, + url: style.updateUrl, + md5Url: style.md5Url, + md5: style.originalMd5, }); } @@ -357,7 +467,7 @@ class Updater { md5 => (md5.length == 32 ? this.decideOnMd5(md5 != this.md5) : this.onFailure(-1)), - this.onFailure); + status => this.onFailure(status)); } decideOnMd5(md5changed) { @@ -370,7 +480,7 @@ class Updater { checkFullCode({forceUpdate = false} = {}) { return Updater.download(this.url).then( text => this.handleJson(forceUpdate, JSON.parse(text)), - this.onFailure); + status => this.onFailure(status)); } handleJson(forceUpdate, json) { @@ -400,7 +510,11 @@ class Updater { $('.update-note', this.element).innerHTML = ''; } else { this.element.classList.add('no-update'); + this.element.classList.toggle('update-problem', Boolean(message)); $('.update-note', this.element).innerHTML = message || t('updateCheckSucceededNoUpdate'); + if (newUI.enabled) { + $('.check-update', this.element).title = message; + } } } diff --git a/msgbox/msgbox.css b/msgbox/msgbox.css index d632efc7..dc83e1e0 100644 --- a/msgbox/msgbox.css +++ b/msgbox/msgbox.css @@ -86,7 +86,12 @@ cursor: pointer; position: absolute; right: 3px; - top: 7px; + top: 4px; +} + +#message-box-close-icon svg { + width: 16px; + height: 16px; } #message-box-contents { diff --git a/options/index.js b/options/index.js index 22147898..8fbd404f 100644 --- a/options/index.js +++ b/options/index.js @@ -10,20 +10,7 @@ setupLivePrefs([ 'popupWidth', 'updateInterval', ]); -enforceValueRange('popupWidth'); - -function enforceValueRange(id) { - const element = document.getElementById(id); - const min = Number(element.min); - const max = Number(element.max); - let value = Number(element.value); - if (value < min || value > max) { - value = Math.max(min, Math.min(max, value)); - element.value = value; - } - element.onchange = element.onchange || (() => enforceValueRange(id)); - return value | 0; -} +enforceInputRange($('#popupWidth')); // overwrite the default URL if browser is Opera $('[data-cmd="open-keyboard"]').href = configureCommands.url; diff --git a/storage.js b/storage.js index dbb435bc..0c24d6f3 100644 --- a/storage.js +++ b/storage.js @@ -547,14 +547,14 @@ function tryJSONparse(jsonString) { } -function debounce(fn, ...args) { +function debounce(fn, delay, ...args) { const timers = debounce.timers = debounce.timers || new Map(); debounce.run = debounce.run || ((fn, ...args) => { timers.delete(fn); fn(...args); }); clearTimeout(timers.get(fn)); - timers.set(fn, setTimeout(debounce.run, 0, fn, ...args)); + timers.set(fn, setTimeout(debounce.run, delay, fn, ...args)); } @@ -572,6 +572,9 @@ prefs = prefs || new function Prefs() { 'manage.onlyEnabled': false, // display only enabled styles 'manage.onlyEdited': false, // display only styles created locally + 'manage.newUI': true, // use the new compact layout + 'manage.newUI.favicons': true, // show favicons for the sites in applies-to + 'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none 'editor.options': {}, // CodeMirror.defaults.* 'editor.lineWrapping': true, // word wrap @@ -755,6 +758,21 @@ function setupLivePrefs(IDs) { } +function enforceInputRange(element) { + const min = Number(element.min); + const max = Number(element.max); + const onChange = () => { + const value = Number(element.value); + if (value < min || value > max) { + element.value = Math.max(min, Math.min(max, value)); + } + }; + onChange(); + element.addEventListener('change', onChange); + element.addEventListener('input', onChange); +} + + function getCodeMirrorThemes(callback) { chrome.runtime.getPackageDirectoryEntry(function(rootDir) { rootDir.getDirectory('codemirror/theme', {create: false}, function(themeDir) {