diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 44823873..96d693f1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -329,6 +329,10 @@ "message": "Find more styles for this site", "description": "Text for a link that gets a list of styles for the current site" }, + "findStylesSiteSelectorTooltip": { + "message": "Choose where to search for the styles.\n'Find more styles' will also use the choice.", + "description": "Short text for a link that gets a list of styles for the current site" + }, "helpAlt": { "message": "Help", "description": "Alternate text for help buttons" diff --git a/background/background.js b/background/background.js index 60942b61..ae7eba3b 100644 --- a/background/background.js +++ b/background/background.js @@ -1,4 +1,4 @@ -/* global dbExec, getStyles, saveStyle */ +/* global dbExec, getStyles, saveStyle, deleteStyle, calcStyleDigest */ /* global handleCssTransitionBug */ /* global usercssHelper openEditor */ /* global styleViaAPI */ @@ -339,6 +339,29 @@ function onRuntimeMessage(request, sender, sendResponse) { case 'openEditor': openEditor(request.id); return; + + case 'openManager': + openURL({url: 'manage.html'}).then(function onReady(tab) { + if (tab && tab.status === 'complete') { + chrome.tabs.sendMessage(tab.id, { + method: 'highlightStyle', + id: request.styleId, + }); + } else if (tab) { + setTimeout(() => chrome.tabs.get(tab.id, onReady), 100); + } + }); + return; + + case 'deleteStyle': + deleteStyle(request); + return; + + case 'calcStyleDigest': + getStyles({id: request.id}) + .then(([style]) => style && calcStyleDigest(style)) + .then(sendResponse); + return KEEP_CHANNEL_OPEN; } } diff --git a/background/update.js b/background/update.js index 36adcde7..bea6f693 100644 --- a/background/update.js +++ b/background/update.js @@ -56,7 +56,9 @@ var updater = { 'ignoreDigest' option is set on the second manual individual update check on the manage page. */ - const maybeUpdate = style.usercssData ? maybeUpdateUsercss : maybeUpdateUSO; + const maybeUpdate = style.usercssData ? maybeUpdateUsercss : + style.freestylerData ? maybeUpdateFWS : + maybeUpdateUSO; return (ignoreDigest ? Promise.resolve() : calcStyleDigest(style)) .then(checkIfEdited) .then(maybeUpdate) @@ -114,6 +116,29 @@ var updater = { }); } + function maybeUpdateFWS() { + return updater.invokeFreestylerAPI('check_updates', { + json: [style.freestylerData] + }).then(data => ( + !data || !data[0] ? Promise.reject(updater.ERROR_JSON) : + !data[0].isUpdated ? Promise.reject(updater.SAME_MD5) : + true + )).then(() => updater.invokeFreestylerAPI('get_updates', { + json: [style.freestylerData] + })).then(data => { + data = data && data[0] || {}; + const newStyle = tryJSONparse(data.newJson); + if (newStyle) { + newStyle.freestylerData = { + id: data.id, + hash: data.newHash, + params: data.newParams, + }; + } + return newStyle; + }); + } + function maybeValidate(json) { if (json.usercssData) { // usercss is already validated while building @@ -196,6 +221,18 @@ var updater = { }); } })(), + + invokeFreestylerAPI(method, params) { + return new Promise(resolve => { + const encodeParam = k => + encodeURIComponent(k === 'json' ? JSON.stringify(params[k]) : params[k]); + const query = Object.keys(params) + .map(k => k + '=' + encodeParam(k)) + .join('&'); + download(`https://freestyler.ws/api/v2/${method}.php?${query}`) + .then(text => resolve(params.json ? tryJSONparse(text) : text)); + }); + } }; updater.schedule(); diff --git a/content/freestyler.js b/content/freestyler.js new file mode 100644 index 00000000..c118c38b --- /dev/null +++ b/content/freestyler.js @@ -0,0 +1,212 @@ +'use strict'; + +// IIFE simplifies garbage-collection on orphaning or non-style pages +(() => { + if (!getPageData().id) { + return; + } + getInstalledStyle().then(setPageDataAndNotify); + notifyPage(chrome.runtime.id); + + const pageListeners = { + install: onUpdate, + update: onUpdate, + applyParams: onUpdate, + uninstall: onUninstall, + stylesManager: onStylesManager, + [chrome.runtime.id]: orphanCheck, + }; + + for (const name of Object.keys(pageListeners)) { + window.addEventListener(name, pageListeners[name]); + } + + + function onUpdate(event) { + const pageData = getPageData(); + let installedStyle; + getInstalledStyle() + .then(checkIfEdited) + .then(makeSiteRequest) + .then(maybeSaveStyle) + .then(setPageDataAndNotify) + .catch(() => notifyPage( + event.type === 'install' ? 'installFailed' : + event.type === 'update' ? 'updateFailed' : + event.type === 'applyParams' ? 'applyFailed' : '' + )); + + function checkIfEdited(style) { + return style && invokeBG('calcStyleDigest', {id: style.id}) + .then(digest => { + if (digest === style.originalDigest || + confirm(chrome.i18n.getMessage('updateCheckManualUpdateForce'))) { + return style; + } else { + setPageDataAndNotify(style); + return Promise.reject(); + } + }); + } + + function makeSiteRequest(style) { + installedStyle = style; + return invokeFreestylerAPI('get_styles_json', { + json: [Object.assign( + pickProps(pageData, [ + 'id', + 'params' + ]), installedStyle && { + 'installed_params': pickProps(installedStyle.freestylerData, [ + 'params', + 'hash', + ]), + 'installed_hash': installedStyle.freestylerData.hash, + } + )] + }); + } + + function maybeSaveStyle(data) { + data = data && data[0] || {}; + const style = tryJSONparse(data.json); + if (!styleJSONseemsValid(style)) { + return Promise.reject(); + } + return invokeBG('saveStyle', { + reason: 'update', + url: getStyleUrl(), + id: installedStyle && installedStyle.id, + name: !installedStyle && style.name, + sections: style.sections, + freestylerData: { + id: data.id, + hash: data.jsonHash, + params: pageData.params, + }, + // use a dummmy URL to make this style updatable + updateUrl: location.origin, + }); + } + } + + + function onUninstall() { + getInstalledStyle().then(style => { + if (style && confirm(chrome.i18n.getMessage('deleteStyleConfirm'))) { + invokeBG('deleteStyle', style); + style = null; + } + setPageDataAndNotify(style); + }); + } + + + function onStylesManager() { + getInstalledStyle().then(style => { + invokeBG('openManager', { + styleId: (style || {}).id, + }); + }); + } + + + function getInstalledStyle() { + return invokeBG('getStyles', { + url: getStyleUrl(), + }).then(styles => styles[0]); + } + + + function styleJSONseemsValid(style) { + return ( + style && + style.name && typeof style.name === 'string' && + style.sections && typeof style.sections.splice === 'function' && + typeof (style.sections[0] || {}).code === 'string' + ); + } + + + function setPageDataAndNotify(style) { + $id('plugin-data-container').value = JSON.stringify(style ? [style.freestylerData] : []); + $id('plugin-info-container').value = JSON.stringify({version: '2.4.1.3'}); + notifyPage('pluginReady'); + } + + + function invokeFreestylerAPI(method, params) { + return new Promise(resolve => { + const encodeParam = k => + encodeURIComponent(k === 'json' ? JSON.stringify(params[k]) : params[k]); + const query = Object.keys(params) + .map(k => k + '=' + encodeParam(k)) + .join('&'); + invokeBG('download', { + url: `https://${location.hostname}/api/v2/${method}.php?${query}`, + }).then(text => { + resolve(params.json ? tryJSONparse(text) : text); + }); + }); + } + + + function notifyPage(message) { + if (message) { + window.dispatchEvent(new CustomEvent(message)); + } + } + + + function getPageData() { + // the data may change during page lifetime + return tryJSONparse($id('site-data-container').value) || ''; + } + + + function getStyleUrl() { + return location.href.replace(/#.*/, ''); + } + + + function $id(id) { + return document.getElementById(id) || ''; + } + + + function tryJSONparse(jsonString) { + try { + return JSON.parse(jsonString); + } catch (e) {} + } + + + function pickProps(obj, names) { + const result = {}; + for (const name of names) { + result[name] = obj[name]; + } + return result; + } + + + function invokeBG(method, params) { + return new Promise(resolve => { + params.method = method; + chrome.runtime.sendMessage(params, resolve); + }); + } + + + function orphanCheck() { + const port = chrome.runtime.connect(); + if (port) { + port.disconnect(); + } else { + // we're orphaned due to an extension update + for (const name of Object.keys(pageListeners)) { + window.removeEventListener(name, pageListeners[name]); + } + } + } +})(); diff --git a/content/install.js b/content/userstyles.js similarity index 100% rename from content/install.js rename to content/userstyles.js diff --git a/images/world-freestyler.png b/images/world-freestyler.png new file mode 100644 index 00000000..ba7d777f Binary files /dev/null and b/images/world-freestyler.png differ diff --git a/images/world-userstyles.png b/images/world-userstyles.png new file mode 100644 index 00000000..ee123eef Binary files /dev/null and b/images/world-userstyles.png differ diff --git a/js/prefs.js b/js/prefs.js index d90cea8e..a1f1bb2f 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -16,6 +16,7 @@ var prefs = new function Prefs() { 'popup.enabledFirst': true, // display enabled styles before disabled styles 'popup.stylesFirst': true, // display enabled styles before disabled styles 'popup.borders': false, // add white borders on the sides + 'popup.findStylesSource': 'userstyles', 'manage.onlyEnabled': false, // display only enabled styles 'manage.onlyLocal': false, // display only styles created locally @@ -104,8 +105,12 @@ var prefs = new function Prefs() { return deepCopy(values); }, - set(key, value, {broadcast = true, sync = true, fromBroadcast} = {}) { - const oldValue = values[key]; + set(key, value, { + broadcast = true, + sync = true, + onlyIfChanged = false, + fromBroadcast, + } = {}) { switch (typeof defaults[key]) { case typeof value: break; @@ -119,9 +124,13 @@ var prefs = new function Prefs() { value = value === true || value === 'true'; break; } + const oldValue = values[key]; + const hasChanged = !equal(value, oldValue); + if (!hasChanged && onlyIfChanged) { + return; + } values[key] = value; defineReadonlyProperty(this.readOnlyValues, key, value); - const hasChanged = !equal(value, oldValue); if (!fromBroadcast) { if (BG && BG !== window) { BG.prefs.set(key, BG.deepCopy(value), {broadcast, sync}); diff --git a/manage/manage.js b/manage/manage.js index 0b1f41ae..34717854 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -48,6 +48,11 @@ function onRuntimeMessage(msg) { case 'styleDeleted': handleDelete(msg.id); break; + case 'highlightStyle': + if (!highlightEntry(msg) && showStyles.inProgress) { + once(window, 'showStyles:done', () => highlightEntry(msg)); + } + break; } } @@ -102,6 +107,7 @@ function initGlobalEvents() { function showStyles(styles = []) { + showStyles.inProgress = true; const sorted = styles .map(style => ({name: style.name.toLocaleLowerCase(), style})) .sort((a, b) => (a.name < b.name ? -1 : a.name === b.name ? 0 : 1)); @@ -137,13 +143,11 @@ function showStyles(styles = []) { debounce(handleEvent.loadFavicons, 16); } if (sessionStorage.justEditedStyleId) { - const entry = $(ENTRY_ID_PREFIX + sessionStorage.justEditedStyleId); + highlightEntry({id: sessionStorage.justEditedStyleId}); delete sessionStorage.justEditedStyleId; - if (entry) { - animateElement(entry); - scrollElementIntoView(entry); - } } + window.dispatchEvent(new CustomEvent('showStyles:done')); + showStyles.inProgress = false; } } @@ -428,8 +432,7 @@ function handleUpdate(style, {reason, method} = {}) { } filterAndAppend({entry}); if (!entry.matches('.hidden') && reason !== 'import') { - animateElement(entry); - scrollElementIntoView(entry); + highlightEntry({entry}); } function handleToggledOrCodeOnly() { @@ -575,6 +578,28 @@ function usePrefsDuringPageLoad() { } +// TODO: move to dom.js and use where applicable +function once(element, event, callback, options) { + element.addEventListener(event, onEvent, options); + function onEvent(...args) { + element.removeEventListener(event, onEvent); + callback.call(element, ...args); + } +} + + +function highlightEntry({ + id, + entry = $(ENTRY_ID_PREFIX + id), +}) { + if (entry) { + animateElement(entry); + scrollElementIntoView(entry); + return true; + } +} + + // TODO: remove when these bugs are fixed in FF function dieOnNullBackground() { if (!FIREFOX || BG) { diff --git a/manifest.json b/manifest.json index cfd6d428..c1646981 100644 --- a/manifest.json +++ b/manifest.json @@ -54,7 +54,13 @@ "matches": ["http://userstyles.org/*", "https://userstyles.org/*"], "run_at": "document_start", "all_frames": false, - "js": ["content/install.js"] + "js": ["content/userstyles.js"] + }, + { + "matches": ["http://freestyler.ws/*", "https://freestyler.ws/*"], + "run_at": "document_end", + "all_frames": false, + "js": ["content/freestyler.js"] }, { "matches": [""], diff --git a/popup.html b/popup.html index fb0899eb..906ddec3 100644 --- a/popup.html +++ b/popup.html @@ -116,6 +116,18 @@
+ + + + + +
diff --git a/popup/popup.css b/popup/popup.css index 21482997..4e00d99a 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -239,6 +239,48 @@ body.blocked .actions > .left-gutter { display: none; } +#find-styles svg { + vertical-align: sub; + pointer-events: auto; + cursor: pointer; +} + +#find-styles-sources { + display: flex; + transition: opacity .5s .1s cubic-bezier(.25,.02,1,.21); + flex-direction: column; + position: absolute; + background-color: white; + box-shadow: 3px 3px 10px rgba(0, 0, 0, .5); + border: 1px solid darkgray; + bottom: .75em; + right: .75em; + padding: .5em 0; +} + +#find-styles-sources > * { + padding: .5em 1em; +} + +#find-styles-sources > *:hover { + background-color: lightgray; +} + +#find-styles img { + max-height: 18px; + -webkit-filter: grayscale(1) brightness(1.15); + filter: grayscale(1) brightness(1.15); + transition: -webkit-filter .5s; + transition: filter .5s; + margin-right: 4px; + vertical-align: middle; +} + +#find-styles a:hover img { + -webkit-filter: none; + filter: none; +} + /* Never shown, but can be enabled with a style */ .enable, @@ -429,6 +471,10 @@ body.blocked .actions > .left-gutter { margin: 0; } +.hidden { + display: none !important; +} + @keyframes lights-off { from { background-color: transparent; diff --git a/popup/popup.js b/popup/popup.js index eff7f6db..f9dfff38 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -18,9 +18,7 @@ getActiveTab().then(tab => tabURL = URLS.supported(url) ? url : ''; Promise.all([ tabURL && getStylesSafe({matchUrl: tabURL}), - onDOMready().then(() => { - initPopup(tabURL); - }), + onDOMready().then(initPopup), ]).then(([styles]) => { showStyles(styles); }); @@ -74,7 +72,7 @@ function toggleSideBorders(state = prefs.get('popup.borders')) { } -function initPopup(url) { +function initPopup() { installed = $('#installed'); setPopupWidth(); @@ -106,18 +104,55 @@ function initPopup(url) { installed); } - $('#find-styles-link').onclick = handleEvent.openURLandHide; - $('#find-styles-link').href += - url.startsWith(location.protocol) ? - '?search_terms=Stylus' : - 'all/' + encodeURIComponent(url.startsWith('file:') ? 'file:' : url); + $$('[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'); + }); - if (!url) { + if (!tabURL) { document.body.classList.add('blocked'); document.body.insertBefore(template.unavailableInfo, document.body.firstChild); return; } + const findStylesElement = $('#find-styles-link'); + findStylesElement.onclick = handleEvent.openURLandHide; + function openAndRememberSource(event) { + prefs.set('popup.findStylesSource', this.dataset.prefValue, {onlyIfChanged: true}); + handleEvent.openURLandHide.call(this, event); + } + $$('#find-styles-sources a').forEach(a => (a.onclick = openAndRememberSource)); + // touch devices don't have onHover events so the element we'll be toggled via clicking (touching) + if ('ontouchstart' in document.body) { + const menu = $('#find-styles-sources'); + const menuData = menu.dataset; + const closeOnOutsideTouch = event => { + if (!menu.contains(event.target)) { + delete menuData.show; + window.removeEventListener('touchstart', closeOnOutsideTouch); + } + }; + findStylesElement.onclick = event => { + if (menuData.show) { + closeOnOutsideTouch(event); + } else { + menuData.show = true; + window.addEventListener('touchstart', closeOnOutsideTouch); + event.preventDefault(); + } + }; + } + // freestyler: strip 'www.' when hostname has 3+ parts + $('#find-styles a[href*="freestyler"]').href += + encodeURIComponent(new URL(tabURL).hostname.replace(/^www\.(?=.+?\.)/, '')); + // userstyles: send just 'file:' for file:// links + $('#find-styles a[href*="userstyles"]').href += + encodeURIComponent(tabURL.startsWith('file:') ? 'file:' : tabURL); + // set the default link to the last used one + $$(`#find-styles a[data-pref-value="${(prefs.get('popup.findStylesSource') || 'userstyles')}"]`) + .forEach(a => (findStylesElement.href = a.href)); + getActiveTab().then(function ping(tab, retryCountdown = 10) { chrome.tabs.sendMessage(tab.id, {method: 'ping'}, {frameId: 0}, pong => { if (pong) { @@ -150,10 +185,10 @@ function initPopup(url) { // For this URL const urlLink = template.writeStyle.cloneNode(true); Object.assign(urlLink, { - href: 'edit.html?url-prefix=' + encodeURIComponent(url), - title: `url-prefix("${url}")`, + href: 'edit.html?url-prefix=' + encodeURIComponent(tabURL), + title: `url-prefix("${tabURL}")`, textContent: prefs.get('popup.breadcrumbs.usePath') - ? new URL(url).pathname.slice(1) + ? new URL(tabURL).pathname.slice(1) // this URL : t('writeStyleForURL').replace(/ /g, '\u00a0'), onclick: handleEvent.openLink, @@ -167,7 +202,7 @@ function initPopup(url) { matchTargets.appendChild(urlLink); // For domain - const domains = BG.getDomains(url); + const domains = BG.getDomains(tabURL); for (const domain of domains) { const numParts = domain.length - domain.replace(/\./g, '').length + 1; // Don't include TLD