diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c00f63e5..d464ac9b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -452,6 +452,9 @@ "message": "Clone", "description": "Used in various places for an action that clones something" }, + "genericDescription": { + "message": "Description" + }, "genericDisabledLabel": { "message": "Disabled", "description": "Used in various lists/options to indicate that something is disabled" @@ -1242,6 +1245,28 @@ "message": "Temporarily applies the changes without saving.\nSave the style to make the changes permanent.", "description": "Tooltip for the checkbox in style editor to enable live preview while editing." }, + "publish": { + "message": "Publish", + "description": "Header for the section to link the style with userStyles.world" + }, + "publishPush": { + "message": "Push update", + "description": "The 'Publish style' button's new name when a connection is established" + }, + "publishReconnect": { + "message": "Try disconnecting then publish again" + }, + "publishRetry": { + "message": "Stylus is still trying to publish this style, but you can retry if you see no authentication activity or popups. Retry now?" + }, + "publishStyle": { + "message": "Publish style", + "description": "Publish the current style to userstyles.world" + }, + "publishUsw": { + "message": "Using ", + "description": "Name of the link to https://userstyles.world in the editor" + }, "readingStyles": { "message": "Reading styles..." }, @@ -1365,18 +1390,6 @@ "message": "Sections", "description": "Header for the table of contents block listing style section names in the left panel of the classic editor" }, - "integration": { - "message": "UserStyles.world integration", - "description": "Header for the section to link the style with userStyles.world" - }, - "uploadStyle": { - "message": "Publish style", - "description": "Publish the current style to userstyles.world" - }, - "revokeLink": { - "message": "Revoke link", - "description": "Revoke current link of style with userstyles.world" - }, "shortcuts": { "message": "Shortcuts", "description": "Go to shortcut configuration" @@ -1493,6 +1506,9 @@ "message": "Mozilla Format", "description": "Heading for the section with buttons to import/export Mozilla format of the style" }, + "styleName": { + "message": "Style name" + }, "styleNotAppliedRegexpProblemTooltip": { "message": "Style was not applied due to its incorrect usage of 'regexp()'", "description": "Tooltip in the popup for styles that were not applied at all" diff --git a/background/background.js b/background/background.js index 9ffaf1b2..2cdbc06c 100644 --- a/background/background.js +++ b/background/background.js @@ -6,6 +6,7 @@ /* global syncMan */ /* global updateMan */ /* global usercssMan */ +/* global uswApi */ /* global FIREFOX URLS @@ -20,10 +21,26 @@ addAPI(/** @namespace API */ { + /** Temporary storage for data needed elsewhere e.g. in a content script */ + data: ((data = {}) => ({ + del: key => delete data[key], + get: key => data[key], + has: key => key in data, + pop: key => { + const val = data[key]; + delete data[key]; + return val; + }, + set: (key, val) => { + data[key] = val; + }, + }))(), + styles: styleMan, sync: syncMan, updater: updateMan, usercss: usercssMan, + usw: uswApi, /** @type {BackgroundWorker} */ worker: createWorker({url: '/background/background-worker'}), diff --git a/background/style-manager.js b/background/style-manager.js index 31eba73b..c62f601d 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -6,8 +6,6 @@ /* global prefs */ /* global tabMan */ /* global usercssMan */ -/* global tokenMan */ -/* global retrieveStyleInformation uploadStyle */// usw-api.js 'use strict'; /* @@ -63,7 +61,6 @@ const styleMan = (() => { let ready = init(); chrome.runtime.onConnect.addListener(handleLivePreview); - chrome.runtime.onConnect.addListener(handlePublishingUSW); //#endregion //#region Exports @@ -74,16 +71,21 @@ const styleMan = (() => { async delete(id, reason) { if (ready.then) await ready; const data = id2data(id); + const {style, appliesTo} = data; await db.exec('delete', id); if (reason !== 'sync') { - API.sync.delete(data.style._id, Date.now()); + API.sync.delete(style._id, Date.now()); } - for (const url of data.appliesTo) { + for (const url of appliesTo) { const cache = cachedStyleForUrl.get(url); if (cache) delete cache.sections[id]; } dataMap.delete(id); - uuidIndex.delete(data.style._id); + uuidIndex.delete(style._id); + if (style._usw && style._usw.token) { + // Must be called after the style is deleted from dataMap + API.usw.revoke(id); + } await msg.broadcast({ method: 'styleDeleted', style: {id}, @@ -107,7 +109,7 @@ const styleMan = (() => { if (ready.then) await ready; style = mergeWithMapped(style); style.updateDate = Date.now(); - return handleSave(await saveStyle(style), {reason: 'editSave'}); + return saveStyle(style, {reason: 'editSave'}); }, /** @returns {Promise} */ @@ -240,7 +242,7 @@ const styleMan = (() => { if (url) style.url = style.installationUrl = url; style.originalDigest = await calcStyleDigest(style); // FIXME: update updateDate? what about usercss config? - return handleSave(await saveStyle(style), {reason}); + return saveStyle(style, {reason}); }, /** @returns {Promise} */ @@ -268,11 +270,13 @@ const styleMan = (() => { } }, + save: saveStyle, + /** @returns {Promise} style id */ async toggle(id, enabled) { if (ready.then) await ready; const style = Object.assign({}, id2style(id), {enabled}); - handleSave(await saveStyle(style), {reason: 'toggle', codeIsUpdated: false}); + await saveStyle(style, {reason: 'toggle', codeIsUpdated: false}); return id; }, @@ -356,65 +360,6 @@ const styleMan = (() => { }); } - function handlePublishingUSW(port) { - if (port.name !== 'link-style-usw') { - return; - } - port.onMessage.addListener(async incData => { - const {data: style, reason} = incData; - if (!style.id) { - return; - } - switch (reason) { - case 'revoke': - await tokenMan.revokeToken('userstylesworld', style.id); - style._usw = {}; - handleSave(await saveStyle(style), {reason: 'success-revoke', codeIsUpdated: true}); - break; - - case 'publish': { - if (!style._usw || !style._usw.token) { - for (const {style: someStyle} of dataMap.values()) { - if (someStyle._id === style._id) { - someStyle.tmpSourceCode = style.sourceCode; - let metadata = {}; - try { - const {metadata: tmpMetadata} = await API.worker.parseUsercssMeta(style.sourceCode); - metadata = tmpMetadata; - } catch (err) { - console.log(err); - } - someStyle.metadata = metadata; - } else { - delete someStyle.tmpSourceCode; - delete someStyle.metadata; - } - handleSave(await saveStyle(someStyle), {broadcast: false}); - } - style._usw = { - token: await tokenMan.getToken('userstylesworld', true, style.id), - }; - - delete style.tmpSourceCode; - delete style.metadata; - for (const [k, v] of Object.entries(await retrieveStyleInformation(style._usw.token))) { - style._usw[k] = v; - } - handleSave(await saveStyle(style), {reason: 'success-publishing', codeIsUpdated: true}); - } - - const returnResult = await uploadStyle(style); - // USw prefix errors with `Error:`. - if (returnResult.startsWith('Error:')) { - style._usw.publishingError = returnResult; - handleSave(await saveStyle(style), {reason: 'publishing-failed', codeIsUpdated: true}); - } - break; - } - } - }); - } - async function addIncludeExclude(type, id, rule) { if (ready.then) await ready; const style = Object.assign({}, id2style(id)); @@ -423,7 +368,7 @@ const styleMan = (() => { throw new Error('The rule already exists'); } style[type] = list.concat([rule]); - return handleSave(await saveStyle(style), {reason: 'styleSettings'}); + return saveStyle(style, {reason: 'styleSettings'}); } async function removeIncludeExclude(type, id, rule) { @@ -434,7 +379,7 @@ const styleMan = (() => { return; } style[type] = list.filter(r => r !== rule); - return handleSave(await saveStyle(style), {reason: 'styleSettings'}); + return saveStyle(style, {reason: 'styleSettings'}); } function broadcastStyleUpdated(style, reason, method = 'styleUpdated', codeIsUpdated = true) { @@ -490,14 +435,14 @@ const styleMan = (() => { style.id = newId; } uuidIndex.set(style._id, style.id); - API.sync.put(style._id, style._rev, style._usw); + API.sync.put(style._id, style._rev); } - async function saveStyle(style) { + async function saveStyle(style, handlingOptions) { beforeSave(style); const newId = await db.exec('put', style); afterSave(style, newId); - return style; + return handleSave(style, handlingOptions); } function handleSave(style, {reason, codeIsUpdated, broadcast = true}) { @@ -528,9 +473,7 @@ const styleMan = (() => { async function init() { const styles = await db.exec('getAll') || []; - const updated = styles.filter(style => - addMissingProps(style) + - addCustomName(style)); + const updated = styles.filter(fixOldStyleProps); if (updated.length) { await db.exec('putMany', updated); } @@ -543,7 +486,7 @@ const styleMan = (() => { bgReady._resolveStyles(); } - function addMissingProps(style) { + function fixOldStyleProps(style) { let res = 0; for (const key in MISSING_PROPS) { if (!style[key]) { @@ -551,20 +494,15 @@ const styleMan = (() => { res = 1; } } - return res; - } - - /** Upgrades the old way of customizing local names */ - function addCustomName(style) { - let res = 0; + /* Upgrade the old way of customizing local names */ const {originalName} = style; if (originalName) { - res = 1; if (originalName !== style.name) { style.customName = style.name; style.name = originalName; } delete style.originalName; + res = 1; } return res; } diff --git a/background/token-manager.js b/background/token-manager.js index 2508f2b6..605da94d 100644 --- a/background/token-manager.js +++ b/background/token-manager.js @@ -64,11 +64,12 @@ const tokenMan = (() => { return { - buildKeys(name, styleId) { + buildKeys(name, hooks) { + const prefix = `secure/token/${hooks ? hooks.keyName(name) : name}/`; const k = { - TOKEN: `secure/token/${name}/${styleId ? `${styleId}/` : ''}token`, - EXPIRE: `secure/token/${name}/${styleId ? `${styleId}/` : ''}expire`, - REFRESH: `secure/token/${name}/${styleId ? `${styleId}/` : ''}refresh`, + TOKEN: `${prefix}token`, + EXPIRE: `${prefix}expire`, + REFRESH: `${prefix}refresh`, }; k.LIST = Object.values(k); return k; @@ -78,8 +79,8 @@ const tokenMan = (() => { return AUTH[name].clientId; }, - async getToken(name, interactive, styleId) { - const k = tokenMan.buildKeys(name, styleId); + async getToken(name, interactive, hooks) { + const k = tokenMan.buildKeys(name, hooks); const obj = await chromeLocal.get(k.LIST); if (obj[k.TOKEN]) { if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) { @@ -92,13 +93,12 @@ const tokenMan = (() => { if (!interactive) { throw new Error(`Invalid token: ${name}`); } - const accessToken = authUser(name, k, interactive, styleId ? {vendor_data: styleId} : {}); - return accessToken; + return authUser(k, name, interactive, hooks); }, - async revokeToken(name, styleId) { + async revokeToken(name, hooks) { const provider = AUTH[name]; - const k = tokenMan.buildKeys(name, styleId); + const k = tokenMan.buildKeys(name, hooks); if (provider.revoke) { try { const token = await chromeLocal.getValue(k.TOKEN); @@ -133,17 +133,17 @@ const tokenMan = (() => { return handleTokenResult(result, k); } - async function authUser(name, k, interactive = false, extraQuery = {}) { + async function authUser(keys, name, interactive = false, hooks = null) { await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']); /* global webextLaunchWebAuthFlow */ const provider = AUTH[name]; const state = Math.random().toFixed(8).slice(2); - const query = Object.assign(extraQuery, { + const query = { response_type: provider.flow, client_id: provider.clientId, redirect_uri: provider.redirect_uri || chrome.identity.getRedirectURL(), state, - }); + }; if (provider.scopes) { query.scope = provider.scopes.join(' '); } @@ -153,17 +153,25 @@ const tokenMan = (() => { if (alwaysUseTab == null) { alwaysUseTab = await detectVivaldiWebRequestBug(); } + if (hooks) hooks.query(query); const url = `${provider.authURL}?${new URLSearchParams(query)}`; + const width = Math.min(screen.availWidth - 100, 800); + const height = Math.min(screen.availHeight - 100, 800); + const wnd = await browser.windows.getLastFocused(); const finalUrl = await webextLaunchWebAuthFlow({ url, alwaysUseTab, interactive, redirect_uri: query.redirect_uri, - windowOptions: { + windowOptions: Object.assign({ state: 'normal', - width: Math.min(screen.width - 100, 800), - height: Math.min(screen.height - 100, 800), - }, + width, + height, + }, wnd.state !== 'minimized' && { + // Center the popup to the current window + top: Math.ceil(wnd.top + (wnd.height - width) / 2), + left: Math.ceil(wnd.left + (wnd.width - width) / 2), + }), }); const params = new URLSearchParams( provider.flow === 'token' ? @@ -194,7 +202,7 @@ const tokenMan = (() => { } result = await postQuery(provider.tokenURL, body); } - return handleTokenResult(result, k); + return handleTokenResult(result, keys); } async function handleTokenResult(result, k) { diff --git a/background/usw-api.js b/background/usw-api.js index 96e37aa7..b26cb6c1 100644 --- a/background/usw-api.js +++ b/background/usw-api.js @@ -1,29 +1,119 @@ +/* global API msg */// msg.js /* global URLS */ // toolbox.js - +/* global tokenMan */ 'use strict'; -/* exported retrieveStyleInformation */ -async function retrieveStyleInformation(token) { - return (await (await fetch(`${URLS.usw}api/style`, { - method: 'GET', - headers: new Headers({ - 'Authorization': `Bearer ${token}`, - }), - credentials: 'omit', - })).json()).data; -} +const uswApi = (() => { -/* exported uploadStyle */ -async function uploadStyle(style) { - return (await (await fetch(`${URLS.usw}api/style/${style._usw.id}`, { - method: 'POST', - headers: new Headers({ - 'Authorization': `Bearer ${style._usw.token}`, - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ - code: style.sourceCode, - }), - credentials: 'omit', - })).json()).data; + //#region Internals + + class TokenHooks { + constructor(id) { + this.id = id; + } + keyName(name) { + return `${name}/${this.id}`; + } + query(query) { + return Object.assign(query, {vendor_data: this.id}); + } + } + + function fakeUsercssHeader(style) { + const {name, _usw: u = {}} = style; + const meta = Object.entries({ + '@name': u.name || name || '?', + '@version': // Same as USO-archive version: YYYYMMDD.hh.mm + new Date().toISOString().replace(/^(\d+)-(\d+)-(\d+)T(\d+):(\d+).+/, '$1$2$3.$4.$5'), + '@namespace': u.namespace !== '?' && u.namespace || + u.username && `userstyles.world/user/${u.username}` || + '?', + '@description': u.description, + '@author': u.username, + '@license': u.license, + }); + const maxKeyLen = meta.reduce((res, [k]) => Math.max(res, k.length), 0); + return [ + '/* ==UserStyle==', + ...meta.map(([k, v]) => `${k}${' '.repeat(maxKeyLen - k.length + 2)}${v || ''}`), + '==/UserStyle== */', + ].join('\n') + '\n\n'; + } + + async function linkStyle(style, sourceCode) { + const {id} = style; + const metadata = await API.worker.parseUsercssMeta(sourceCode).catch(console.warn) || {}; + const uswData = Object.assign({}, style, {metadata, sourceCode}); + API.data.set('usw' + id, uswData); + const token = await tokenMan.getToken('userstylesworld', true, new TokenHooks(id)); + const info = await uswFetch('style', token); + const data = style._usw = Object.assign({token}, info); + style.url = style.url || data.homepage || `${URLS.usw}style/${data.id}`; + await uswSave(style); + return data; + } + + async function uswFetch(path, token, opts) { + opts = Object.assign({credentials: 'omit'}, opts); + opts.headers = Object.assign({Authorization: `Bearer ${token}`}, opts.headers); + return (await (await fetch(`${URLS.usw}api/${path}`, opts)).json()).data; + } + + /** Uses a custom method when broadcasting and avoids needlessly sending the entire style */ + async function uswSave(style) { + const {id, _usw} = style; + await API.styles.save(style, {broadcast: false}); + msg.broadcastExtension({method: 'uswData', style: {id, _usw}}); + } + + //#endregion + //#region Exports + + return { + /** + * @param {number} id + * @param {string} sourceCode + * @return {Promise} + */ + async publish(id, sourceCode) { + const style = await API.styles.get(id); + const data = (style._usw || {}).token + ? style._usw + : await linkStyle(style, sourceCode); + const header = style.usercssData ? '' : fakeUsercssHeader(style); + return uswFetch(`style/${data.id}`, data.token, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({code: header + sourceCode}), + }); + }, + + /** + * @param {number} id + * @return {Promise} + */ + async revoke(id) { + await tokenMan.revokeToken('userstylesworld', new TokenHooks(id)); + const style = await API.styles.get(id); + if (style) { + style._usw = {}; + await uswSave(style); + } + }, + }; + + //#endregion +})(); + +/* Doing this outside so we don't break IDE's recognition of the exported methods in IIFE */ +for (const [k, fn] of Object.entries(uswApi)) { + uswApi[k] = async (id, ...args) => { + API.data.set('usw' + id, true); + try { + /* Awaiting inside `try` so that `finally` runs when done */ + return await fn(id, ...args); + } finally { + API.data.del('usw' + id); + } + }; } diff --git a/content/install-hook-userstylesworld.js b/content/install-hook-userstylesworld.js index 6644eb37..9aa3430c 100644 --- a/content/install-hook-userstylesworld.js +++ b/content/install-hook-userstylesworld.js @@ -19,9 +19,8 @@ if (location.pathname === '/api/oauth/style/new') { const styleId = Number(new URLSearchParams(location.search).get('vendor_data')); - API.styles.get(styleId).then(style => { - style.sourceCode = style.tmpSourceCode; - sendPostMessage({type: 'usw-fill-new-style', data: style}); + API.data.pop('usw' + styleId).then(data => { + sendPostMessage({type: 'usw-fill-new-style', data}); }); } } diff --git a/edit.html b/edit.html index f37e083f..6e789819 100644 --- a/edit.html +++ b/edit.html @@ -242,7 +242,7 @@ -
-

+
+

- - +   + +
+ + + +
diff --git a/edit/base.js b/edit/base.js index 84c52d9a..bcdf536c 100644 --- a/edit/base.js +++ b/edit/base.js @@ -21,6 +21,7 @@ * @namespace Editor */ const editor = { + style: null, dirty: DirtyReporter(), isUsercss: false, isWindowed: false, @@ -34,6 +35,10 @@ const editor = { previewDelay: 200, // Chrome devtools uses 200 scrollInfo: null, + onStyleUpdated() { + document.documentElement.classList.toggle('is-new-style', !editor.style.id); + }, + updateTitle(isDirty = editor.dirty.isDirty()) { const {customName, name} = editor.style; document.title = `${ @@ -84,6 +89,7 @@ const baseInit = (() => { // switching the mode here to show the correct page ASAP, usually before DOMContentLoaded editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss')); editor.style = style; + editor.onStyleUpdated(); editor.updateTitle(false); document.documentElement.classList.toggle('usercss', editor.isUsercss); sessionStore.justEditedStyleId = style.id || ''; @@ -132,8 +138,7 @@ baseInit.domReady.then(() => { document.body.classList.remove('compact-layout', 'fixed-header'); window.off('scroll', fixedHeader); } - for (const type of ['options', 'toc', 'lint']) { - const el = $(`details[data-pref="editor.${type}.expanded"]`); + for (const el of $$('details[data-pref]')) { el.open = compact ? false : prefs.get(el.dataset.pref); } } @@ -161,9 +166,6 @@ baseInit.ready.then(() => { initThemeElement(); setupLivePrefs(); - $('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle'); - $('#preview-label').classList.toggle('hidden', !editor.style.id); - require(Object.values(editor.lazyKeymaps), () => { initKeymapElement(); prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true}); diff --git a/edit/edit.css b/edit/edit.css index 2ceddfe5..52e880a3 100644 --- a/edit/edit.css +++ b/edit/edit.css @@ -7,6 +7,14 @@ body { font: 12px arial,sans-serif; } +a { + color: #000; + transition: color .5s; +} +a:hover { + color: #666; +} + #global-progress { position: fixed; height: 4px; @@ -24,10 +32,17 @@ body { opacity: 1; } +html.is-new-style #preview-label, +html.is-new-style #publish, .hidden { display: none !important; } - +html.is-new-style #heading::after { + content: attr(data-add); +} +html:not(.is-new-style) #heading::after { + content: attr(data-edit); +} /************ embedded popup for simple-window editor ************/ #popup-iframe { @@ -215,7 +230,9 @@ input:invalid { margin-left: -13px; cursor: pointer; } - +#header summary + * { + padding: .5rem 0; +} #header summary h2 { display: inline-block; border-bottom: 1px dotted transparent; @@ -225,9 +242,6 @@ input:invalid { padding-left: 13px; /* clicking directly on details-marker doesn't set pref so we cover it with h2 */ } -#options-wrapper { - padding: .5rem 0; -} #header summary:hover h2 { border-color: #bbb; } @@ -244,6 +258,7 @@ input:invalid { #header details { margin-top: .5rem; + max-width: 100%; } #actions > * { @@ -276,6 +291,81 @@ input:invalid { #lint:not([open]) h2 { margin-bottom: 0; } + +#publish > div > * { + margin-top: .75em; +} +#publish a:visited { + margin-top: .75em; +} +#publish[data-connected] summary::marker, +#publish[data-connected] h2 { + color: hsl(180, 100%, 20%); +} +#publish:not([data-connected]) #usw-link-info, +#publish:not([data-connected]) #usw-disconnect { + display: none; +} +#publish[data-connected] #usw-publish-style::after { + content: attr(data-push); +} +#publish:not([data-connected]) #usw-publish-style::after { + content: attr(data-publish); +} +#usw-link-info dl { + margin: 0; + display: flex; +} +#usw-link-info dt { + flex-shrink: 0; +} +#usw-link-info dt::after { + content: ":" +} +#usw-link-info dt, +#usw-link-info dd { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#usw-link-info dd { + margin-left: .5em; +} +#usw-link-info dd[data-usw="name"] { + font-weight: bold; +} +#usw-progress { + position: relative; + vertical-align: top; +} +#usw-progress .success, +#usw-progress .unchanged { + font-size: 150%; + font-weight: bold; + position: absolute; + margin-left: .25em; +} +#usw-progress .success { + margin-top: -.25em; +} +#usw-progress .success::after { + content: '\2713'; /* checkmark */ +} +#usw-progress .unchanged::after { + content: '='; +} +#usw-progress .error { + display: block; + margin-top: .5em; + color: red; +} +#usw-progress .error + div { + font-size: smaller; +} +#usw-progress .lds-spinner { + transform: scale(0.125); + transform-origin: 0 10px; +} /* options */ #options [type="number"] { width: 3.5em; @@ -739,7 +829,6 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high #lint { overflow: hidden; margin: .5rem -1rem 0; - min-height: 30px; padding: 0; box-sizing: border-box; display: flex; @@ -758,7 +847,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high text-indent: -2px; } #lint > .lint-scroll-container { - margin: 34px 10px 0; + margin: 1rem 10px 0; position: absolute; top: 0; bottom: 0; @@ -954,7 +1043,7 @@ body.linter-disabled .hidden-unless-compact { position: inherit; border-right: none; border-bottom: 1px dashed #AAA; - padding: 0; + padding: .5rem 1rem .5rem .5rem; } .fixed-header { padding-top: var(--fixed-padding); @@ -972,24 +1061,30 @@ body.linter-disabled .hidden-unless-compact { .fixed-header #options { display: none !important; } + #header summary + *, + #lint > .lint-scroll-container { + margin-left: 1rem; + padding: .25rem 0 .5rem; + } #actions { display: flex; flex-wrap: wrap; white-space: nowrap; - padding: 0 1rem; margin: 0; box-sizing: border-box; } #header input[type="checkbox"] { vertical-align: middle; } + #header details { + margin: 0; + } #heading, h2 { display: none; } #basic-info { - padding: .5rem 1rem; - margin: 0; + margin-bottom: .5rem; box-sizing: border-box; display: flex; flex-wrap: wrap; @@ -1006,22 +1101,17 @@ body.linter-disabled .hidden-unless-compact { #options-wrapper { display: flex; flex-wrap: wrap; - padding: .5rem 1rem 0; box-sizing: border-box; } - #toc { - padding: .5rem 1rem; - } #details-wrapper { flex-direction: row; flex-wrap: wrap; - padding-bottom: .25rem; } - #options { + #options[open] { width: 100%; } #sections-list[open] { - height: 102px; + max-height: 102px; } #sections-list[open] #toc { max-height: 60px; @@ -1029,13 +1119,16 @@ body.linter-disabled .hidden-unless-compact { } #sections-list, #lint { - width: 50%; + max-width: 50%; } .options-column { flex-grow: 1; padding-right: .5rem; box-sizing: border-box; } + .options-column > .usercss-only { + margin-bottom: 0; + } #options-wrapper .options-column:nth-child(2) { margin-top: 0; } @@ -1054,8 +1147,9 @@ body.linter-disabled .hidden-unless-compact { margin-left: 0; padding-left: 4px; } - #options h2 { - margin: 0 0 .5em; + #header summary h2 { + margin: 0; + padding: 0; } .option label { margin: 0; @@ -1069,7 +1163,8 @@ body.linter-disabled .hidden-unless-compact { top: 0.2rem; } #lint > .lint-scroll-container { - margin: 26px 1rem 0; + padding-top: 0; + margin-right: 0; } #lint { padding: 0; diff --git a/edit/edit.js b/edit/edit.js index 1c22dfbf..2b9e44f7 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -11,7 +11,6 @@ /* global linterMan */ /* global prefs */ /* global t */// localization.js -/* global updateUI revokeLinking publishStyle */// usw-integration.js 'use strict'; //#region init @@ -19,7 +18,6 @@ baseInit.ready.then(async () => { await waitForSheet(); (editor.isUsercss ? SourceEditor : SectionsEditor)(); - updateUI(); await editor.ready; editor.ready = true; editor.dirty.onChange(editor.updateDirty); @@ -48,33 +46,29 @@ baseInit.ready.then(async () => { require(['/edit/linter-dialogs'], () => linterMan.showLintConfig()); $('#lint-help').onclick = () => require(['/edit/linter-dialogs'], () => linterMan.showLintHelp()); - $('#revoke-link').onclick = () => revokeLinking(); - $('#publish-style').onclick = () => publishStyle(); require([ '/edit/autocomplete', '/edit/global-search', ]); }); +//#endregion +//#region events + +const IGNORE_UPDATE_REASONS = [ + 'editPreview', + 'editPreviewEnd', + 'editSave', + 'config', +]; + msg.onExtension(request => { const {style} = request; switch (request.method) { case 'styleUpdated': - if (editor.style.id === style.id) { - if (!['editPreview', 'editPreviewEnd', 'editSave', 'config'].includes(request.reason)) { - Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id)) - .then(newStyle => { - editor.replaceStyle(newStyle, request.codeIsUpdated); - - if (['success-publishing', 'success-revoke'].includes(request.reason)) { - updateUI(newStyle); - } - if (request.reason === 'publishing-failed') { - messageBoxProxy.alert(newStyle._usw.publishingError, 'pre', - 'UserStyles.world: ' + t('genericError')); - } - }); - } + if (editor.style.id === style.id && !IGNORE_UPDATE_REASONS.includes(request.reason)) { + Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id)) + .then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated)); } break; case 'styleDeleted': @@ -262,15 +256,11 @@ editor.livePreview = (() => { /** * @param {Function} [fn] - preprocessor - * @param {boolean} [show] */ - init(fn, show) { + init(fn) { preprocess = fn; - if (show != null) toggle(show); }, - toggle, - update(newData) { data = newData; if (!port) { @@ -290,10 +280,6 @@ editor.livePreview = (() => { }); } - function toggle(state) { - $('#preview-label').classList.toggle('hidden', !state); - } - async function updatePreviewer(data) { const errorContainer = $('#preview-errors'); try { diff --git a/edit/global-search.js b/edit/global-search.js index 0faf0734..664409fd 100644 --- a/edit/global-search.js +++ b/edit/global-search.js @@ -1,4 +1,4 @@ -/* global $ $$ $create $remove focusAccessibility */// dom.js +/* global $ $$ $create $remove focusAccessibility toggleDataset */// dom.js /* global CodeMirror */ /* global chromeLocal */// storage-util.js /* global colorMimicry */ @@ -876,15 +876,6 @@ } - function toggleDataset(el, prop, state) { - if (state) { - el.dataset[prop] = ''; - } else { - delete el.dataset[prop]; - } - } - - function saveWindowScrollPos() { state.scrollX = window.scrollX; state.scrollY = window.scrollY; diff --git a/edit/sections-editor.js b/edit/sections-editor.js index 0f2f7db4..e83fec3d 100644 --- a/edit/sections-editor.js +++ b/edit/sections-editor.js @@ -25,7 +25,7 @@ function SectionsEditor() { updateHeader(); rerouteHotkeys.toggle(true); // enabled initially because we don't always focus a CodeMirror - editor.livePreview.init(null, style.id); + editor.livePreview.init(); container.classList.add('section-editor'); $('#to-mozilla').on('click', showMozillaFormat); $('#to-mozilla-help').on('click', showToMozillaHelp); @@ -54,6 +54,11 @@ function SectionsEditor() { return `${t('sectionCode')} ${index + 1}`; }, + getValue(asObject) { + const st = getModel(); + return asObject ? st : MozDocMapper.styleToCss(st); + }, + getSearchableInputs(cm) { const sec = sections.find(s => s.cm === cm); return sec ? sec.appliesTo.map(a => a.valueEl).filter(Boolean) : []; @@ -86,14 +91,13 @@ function SectionsEditor() { await initSections(newStyle.sections, {replace: true}); } Object.assign(style, newStyle); + editor.onStyleUpdated(); updateHeader(); dirty.clear(); // Go from new style URL to edit style URL - if (location.href.indexOf('id=') === -1 && style.id) { - history.replaceState({}, document.title, 'edit.html?id=' + style.id); - $('#heading').textContent = t('editStyleHeading'); + if (style.id && !/[&?]id=/.test(location.search)) { + history.replaceState({}, document.title, `${location.pathname}?id=${style.id}`); } - editor.livePreview.toggle(Boolean(style.id)); updateLivePreview(); }, @@ -323,7 +327,7 @@ function SectionsEditor() { function showMozillaFormat() { const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true}); - popup.codebox.setValue(MozDocMapper.styleToCss(getModel())); + popup.codebox.setValue(editor.getValue()); popup.codebox.execCommand('selectAll'); } @@ -425,7 +429,7 @@ function SectionsEditor() { editor.updateToc(); } - /** @returns {Style} */ + /** @returns {StyleObj} */ function getModel() { return Object.assign({}, style, { sections: sections.filter(s => !s.removed).map(s => s.getModel()), diff --git a/edit/source-editor.js b/edit/source-editor.js index 68cb7d95..1fb348dd 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -30,7 +30,7 @@ function SourceEditor() { const cm = cmFactory.create($('.single-editor')); const sectionFinder = MozSectionFinder(cm); const sectionWidget = MozSectionWidget(cm, sectionFinder); - editor.livePreview.init(preprocess, style.id); + editor.livePreview.init(preprocess); createMetaCompiler(meta => { style.usercssData = meta; style.name = meta.name; @@ -48,6 +48,7 @@ function SourceEditor() { closestVisible: () => cm, getEditors: () => [cm], getEditorTitle: () => '', + getValue: () => cm.getValue(), getSearchableInputs: () => [], prevEditor: nextPrevSection.bind(null, -1), nextEditor: nextPrevSection.bind(null, 1), @@ -241,9 +242,8 @@ function SourceEditor() { } sessionStore.justEditedStyleId = newStyle.id; Object.assign(style, newStyle); - $('#preview-label').classList.remove('hidden'); + editor.onStyleUpdated(); updateMeta(); - editor.livePreview.toggle(Boolean(style.id)); } } diff --git a/edit/usw-integration.js b/edit/usw-integration.js index 6e2dd072..dd394460 100644 --- a/edit/usw-integration.js +++ b/edit/usw-integration.js @@ -1,49 +1,97 @@ -/* global $ $create $remove */// dom.js +/* global $ $create $remove messageBoxProxy showSpinner toggleDataset */// dom.js +/* global API msg */// msg.js +/* global URLS */// toolbox.js +/* global baseInit */ /* global editor */ - +/* global t */// localization.js 'use strict'; -let uswPort; +(() => { + //#region Main -function connectToPort() { - if (!uswPort) { - uswPort = chrome.runtime.connect({name: 'link-style-usw'}); - uswPort.onDisconnect.addListener(err => { - throw err; - }); - } -} + const ERROR_TITLE = 'UserStyles.world ' + t('genericError'); + const PROGRESS = '#usw-progress'; + let spinnerTimer = 0; + let prevCode = ''; + msg.onExtension(request => { + if (request.method === 'uswData' && + request.style.id === editor.style.id) { + Object.assign(editor.style, request.style); + updateUI(); + } + }); -/* exported revokeLinking */ -function revokeLinking() { - connectToPort(); + baseInit.ready.then(() => { + updateUI(); + $('#usw-publish-style').onclick = disableWhileActive(publishStyle); + $('#usw-disconnect').onclick = disableWhileActive(disconnect); + }); - uswPort.postMessage({reason: 'revoke', data: editor.style}); -} - -/* exported publishStyle */ -function publishStyle() { - connectToPort(); - const data = Object.assign(editor.style, {sourceCode: editor.getEditors()[0].getValue()}); - uswPort.postMessage({reason: 'publish', data}); -} - - -/* exported updateUI */ -function updateUI(useStyle) { - const style = useStyle || editor.style; - if (style._usw && style._usw.token) { - $('#revoke-link').style = ''; - - const linkInformation = $create('div', {id: 'link-info'}, [ - $create('p', `Style name: ${style._usw.name}`), - $create('p', `Description: ${style._usw.description}`), + async function publishStyle() { + const {id} = editor.style; + if (await API.data.has('usw' + id) && + !await messageBoxProxy.confirm(t('publishRetry'), 'danger', ERROR_TITLE)) { + return; + } + const code = editor.getValue(); + const isDiff = code !== prevCode; + const res = isDiff ? await API.usw.publish(id, code) : t('importReportUnchanged'); + const title = `${new Date().toLocaleString()}\n${res}`; + const failed = /^Error:/.test(res); + $(PROGRESS).append(...failed && [ + $create('div.error', {title}, res), + $create('div', t('publishReconnect')), + ] || [ + $create(`span.${isDiff ? 'success' : 'unchanged'}`, {title}), ]); - $remove('#link-info'); - $('#integration').insertBefore(linkInformation, $('#integration').firstChild); - } else { - $('#revoke-link').style = 'display: none;'; - $remove('#link-info'); + if (!failed) prevCode = code; } -} + + async function disconnect() { + await API.usw.revoke(editor.style.id); + prevCode = null; // to allow the next publishStyle to upload style + } + + function updateUI(style = editor.style) { + const usw = style._usw || {}; + const section = $('#publish'); + toggleDataset(section, 'connected', usw.token); + for (const type of ['name', 'description']) { + const el = $(`dd[data-usw="${type}"]`, section); + el.textContent = el.title = usw[type] || ''; + } + const elUrl = $('#usw-url'); + elUrl.href = `${URLS.usw}${usw.id ? `style/${usw.id}` : ''}`; + elUrl.textContent = t('publishUsw').replace(/<(.+)>/, `$1${usw.id ? `#${usw.id}` : ''}`); + } + + //#endregion + //#region Utility + + function disableWhileActive(fn) { + /** @this {Element} */ + return async function () { + this.disabled = true; + timerOn(); + await fn().catch(console.error); + timerOff(); + this.disabled = false; + }; + } + + function timerOn() { + if (!spinnerTimer) { + $(PROGRESS).textContent = ''; + spinnerTimer = setTimeout(showSpinner, 250, PROGRESS); + } + } + + function timerOff() { + $remove(`${PROGRESS} .lds-spinner`); + clearTimeout(spinnerTimer); + spinnerTimer = 0; + } + + //#endregion +})(); diff --git a/global.css b/global.css index f267be01..2defbb98 100644 --- a/global.css +++ b/global.css @@ -236,6 +236,11 @@ select[disabled] + .select-arrow { fill: hsl(0, 0%, 50%); } +summary { + -moz-user-select: none; + user-select: none; +} + /* global stuff we use everywhere */ .hidden { display: none !important; diff --git a/install-usercss.html b/install-usercss.html index 041687bd..9ed75523 100644 --- a/install-usercss.html +++ b/install-usercss.html @@ -21,6 +21,7 @@ + diff --git a/install-usercss/install-usercss.css b/install-usercss/install-usercss.css index 55332c74..55c2ad90 100644 --- a/install-usercss/install-usercss.css +++ b/install-usercss/install-usercss.css @@ -297,93 +297,12 @@ label { padding-left: 16px; position: relative; } -/* spinner: https://github.com/loadingio/css-spinner */ -@keyframes lds-spinner { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} .lds-spinner { - position: absolute; - width: 200px; - height: 200px; top: 50px; - left: 0; - right: 0; - margin: auto; opacity: .2; transition: opacity .5s; -} -.lds-spinner div { - left: 94px; - top: 23px; - position: absolute; - animation: lds-spinner linear 1s infinite; - background: currentColor; - width: 12px; - height: 34px; - border-radius: 20%; - transform-origin: 6px 77px; -} -.lds-spinner div:nth-child(1) { - transform: rotate(0deg); - animation-delay: -0.916666666666667s; -} -.lds-spinner div:nth-child(2) { - transform: rotate(30deg); - animation-delay: -0.833333333333333s; -} -.lds-spinner div:nth-child(3) { - transform: rotate(60deg); - animation-delay: -0.75s; -} -.lds-spinner div:nth-child(4) { - transform: rotate(90deg); - animation-delay: -0.666666666666667s; -} -.lds-spinner div:nth-child(5) { - transform: rotate(120deg); - animation-delay: -0.583333333333333s; -} -.lds-spinner div:nth-child(6) { - transform: rotate(150deg); - animation-delay: -0.5s; -} -.lds-spinner div:nth-child(7) { - transform: rotate(180deg); - animation-delay: -0.416666666666667s; -} -.lds-spinner div:nth-child(8) { - transform: rotate(210deg); - animation-delay: -0.333333333333333s; -} -.lds-spinner div:nth-child(9) { - transform: rotate(240deg); - animation-delay: -0.25s; -} -.lds-spinner div:nth-child(10) { - transform: rotate(270deg); - animation-delay: -0.166666666666667s; -} -.lds-spinner div:nth-child(11) { - transform: rotate(300deg); - animation-delay: -0.083333333333333s; -} -.lds-spinner div:nth-child(12) { - transform: rotate(330deg); - animation-delay: 0s; -} -@keyframes load3 { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } + animation: none; } /************ reponsive layouts ************/ diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js index 1befaff4..74ad9388 100644 --- a/install-usercss/install-usercss.js +++ b/install-usercss/install-usercss.js @@ -22,7 +22,7 @@ document.on('visibilitychange', () => { }); setTimeout(() => { - if (!installed) { + if (!cm) { $('#header').appendChild($create('.lds-spinner', new Array(12).fill($create('div')).map(e => e.cloneNode()))); } diff --git a/js/dom.js b/js/dom.js index ff2390c9..a2893146 100644 --- a/js/dom.js +++ b/js/dom.js @@ -13,6 +13,8 @@ moveFocus scrollElementIntoView setupLivePrefs + showSpinner + toggleDataset waitForSheet */ @@ -325,6 +327,22 @@ function setupLivePrefs(ids = prefs.knownKeys.filter(id => $('#' + id))) { } } +/** @param {string|Node} parent - selector or DOM node */ +async function showSpinner(parent) { + await require(['/spinner.css']); + parent = parent instanceof Node ? parent : $(parent); + parent.appendChild($create('.lds-spinner', + new Array(12).fill($create('div')).map(e => e.cloneNode()))); +} + +function toggleDataset(el, prop, state) { + if (state) { + el.dataset[prop] = ''; + } else { + delete el.dataset[prop]; + } +} + /** * @param {string} selector - beware of $ quirks with `#dotted.id` that won't work with $$ * @param {Object} [opt] diff --git a/js/prefs.js b/js/prefs.js index 5a5011d3..e11f5c0b 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -61,7 +61,7 @@ 'editor.toc.expanded': true, // UI element state: expanded/collapsed 'editor.options.expanded': true, // UI element state: expanded/collapsed 'editor.lint.expanded': true, // UI element state: expanded/collapsed - 'editor.integration.expanded': true, // UI element state expanded/collapsed + 'editor.publish.expanded': true, // UI element state expanded/collapsed 'editor.lineWrapping': true, // word wrap 'editor.smartIndent': true, // 'smart' indent 'editor.indentWithTabs': false, // smart indent with tabs diff --git a/manifest.json b/manifest.json index dd8e6bce..80ba9366 100644 --- a/manifest.json +++ b/manifest.json @@ -44,10 +44,10 @@ "background/sync-manager.js", "background/tab-manager.js", "background/token-manager.js", - "background/usw-api.js", "background/update-manager.js", "background/usercss-install-helper.js", "background/usercss-manager.js", + "background/usw-api.js", "background/style-manager.js", "background/background.js" diff --git a/popup/search.css b/popup/search.css index 4887fadd..bd67d940 100644 --- a/popup/search.css +++ b/popup/search.css @@ -282,102 +282,3 @@ body.search-results-shown { margin-right: .5em; flex: 1 1 0; } - -/* spinner: https://github.com/loadingio/css-spinner */ -.lds-spinner { - -moz-user-select: none; - user-select: none; - pointer-events: none; - position: absolute; - top: 0; - left: 0; - right: 0; - width: 200px; /* don't change! use "transform: scale(.75)" */ - height: 200px; /* don't change! use "transform: scale(.75)" */ - margin: auto; - animation: lds-spinner 1s reverse; - animation-fill-mode: both; -} - -@keyframes lds-spinner { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -.lds-spinner div { - left: 94px; - top: 23px; - position: absolute; - animation: lds-spinner linear 1s infinite; - animation-direction: reverse; - background: currentColor; - width: 12px; - height: 34px; - border-radius: 20%; - transform-origin: 6px 77px; -} - -.lds-spinner div:nth-child(1) { - transform: rotate(0deg); - animation-delay: -0.916666666666667s; -} - -.lds-spinner div:nth-child(2) { - transform: rotate(30deg); - animation-delay: -0.833333333333333s; -} - -.lds-spinner div:nth-child(3) { - transform: rotate(60deg); - animation-delay: -0.75s; -} - -.lds-spinner div:nth-child(4) { - transform: rotate(90deg); - animation-delay: -0.666666666666667s; -} - -.lds-spinner div:nth-child(5) { - transform: rotate(120deg); - animation-delay: -0.583333333333333s; -} - -.lds-spinner div:nth-child(6) { - transform: rotate(150deg); - animation-delay: -0.5s; -} - -.lds-spinner div:nth-child(7) { - transform: rotate(180deg); - animation-delay: -0.416666666666667s; -} - -.lds-spinner div:nth-child(8) { - transform: rotate(210deg); - animation-delay: -0.333333333333333s; -} - -.lds-spinner div:nth-child(9) { - transform: rotate(240deg); - animation-delay: -0.25s; -} - -.lds-spinner div:nth-child(10) { - transform: rotate(270deg); - animation-delay: -0.166666666666667s; -} - -.lds-spinner div:nth-child(11) { - transform: rotate(300deg); - animation-delay: -0.083333333333333s; -} - -.lds-spinner div:nth-child(12) { - transform: rotate(330deg); - animation-delay: 0s; -} diff --git a/popup/search.js b/popup/search.js index 513bda0c..a474e344 100644 --- a/popup/search.js +++ b/popup/search.js @@ -1,4 +1,4 @@ -/* global $ $$ $create $remove */// dom.js +/* global $ $$ $create $remove showSpinner */// dom.js /* global $entry tabURL */// popup.js /* global API */// msg.js /* global Events */ @@ -153,13 +153,6 @@ }); } - - function showSpinner(parent) { - parent = parent instanceof Node ? parent : $(parent); - parent.appendChild($create('.lds-spinner', - new Array(12).fill($create('div')).map(e => e.cloneNode()))); - } - function next() { displayedPage = Math.min(totalPages, displayedPage + 1); scrollToFirstResult = true; diff --git a/spinner.css b/spinner.css new file mode 100644 index 00000000..7d89cebd --- /dev/null +++ b/spinner.css @@ -0,0 +1,98 @@ +/* spinner: https://github.com/loadingio/css-spinner */ +.lds-spinner { + -moz-user-select: none; + user-select: none; + pointer-events: none; + position: absolute; + top: 0; + left: 0; + right: 0; + width: 200px; /* don't change! use "transform: scale(.75)" */ + height: 200px; /* don't change! use "transform: scale(.75)" */ + margin: auto; + animation: lds-spinner 1s reverse; + animation-fill-mode: both; +} + +@keyframes lds-spinner { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.lds-spinner div { + left: 94px; + top: 23px; + position: absolute; + animation: lds-spinner linear 1s infinite; + animation-direction: reverse; + background: currentColor; + width: 12px; + height: 34px; + border-radius: 20%; + transform-origin: 6px 77px; +} + +.lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -0.916666666666667s; +} + +.lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -0.833333333333333s; +} + +.lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.75s; +} + +.lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.666666666666667s; +} + +.lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.583333333333333s; +} + +.lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.5s; +} + +.lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.416666666666667s; +} + +.lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.333333333333333s; +} + +.lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.25s; +} + +.lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.166666666666667s; +} + +.lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.083333333333333s; +} + +.lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; +}