diff --git a/.eslintrc.yml b/.eslintrc.yml index 0871bcad..c99504b4 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -19,7 +19,7 @@ rules: brace-style: [2, 1tbs, {allowSingleLine: false}] camelcase: [2, {properties: never}] class-methods-use-this: [2] - comma-dangle: [0] + comma-dangle: [2, {arrays: always-multiline, objects: always-multiline}] comma-spacing: [2, {before: false, after: true}] comma-style: [2, last] complexity: [0] diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d99e03e5..3b59a07a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -260,6 +260,42 @@ "message": "Stop using customized name, switch to the style's own name", "description": "Tooltip of 'x' button shown in editor when changing the name input of a) styles updated from a URL i.e. not locally created, b) UserCSS styles" }, + "dateAbbrDay": { + "message": "$value$d", + "description": "Day suffix in a short relative date, for example: 8d", + "placeholders": { + "value": { + "content": "$1" + } + } + }, + "dateAbbrHour": { + "message": "$value$h", + "description": "Hour suffix in a short relative date, for example: 8h", + "placeholders": { + "value": { + "content": "$1" + } + } + }, + "dateAbbrMonth": { + "message": "$value$m", + "description": "Month suffix in a short relative date, for example: 8m", + "placeholders": { + "value": { + "content": "$1" + } + } + }, + "dateAbbrYear": { + "message": "$value$y", + "description": "Year suffix in a short relative date, for example: 8y", + "placeholders": { + "value": { + "content": "$1" + } + } + }, "dateInstalled": { "message": "Date installed", "description": "Option text for the user to sort the style by install date" @@ -976,6 +1012,12 @@ "optionsAdvancedNewStyleAsUsercss": { "message": "Write new style as usercss" }, + "optionsAdvancedPatchCsp": { + "message": "Patch CSP to allow style assets" + }, + "optionsAdvancedPatchCspNote": { + "message": "Enable this if styles contain images or fonts which fail to load on sites with a strict CSP (Content-Security-Policy).\n\nEnabling this setting will relax CSP restrictions, allowing essential style content to load. This option is only intended for advanced users who understand the potential security implications, and accept responsibility for monitoring the content which they're allowing. Read about CSS-based attacks for more information.\n\nAlso be aware, this particular setting is not guaranteed to take effect if another installed extension modifies the network response first." + }, "optionsAdvancedStyleViaXhr": { "message": "Instant inject mode" }, @@ -1243,14 +1285,30 @@ "message": "Weekly installs", "description": "Text for label that shows the number of times a search result was installed during last week" }, - "searchStyles": { - "message": "Search contents", - "description": "Label for the search filter textbox on the Manage styles page" + "searchStylesAll": { + "message": "All", + "description": "Option for `find styles` scope selector in the manager." + }, + "searchStylesCode": { + "message": "CSS code", + "description": "Option for `find styles` scope selector in the manager." }, "searchStylesHelp": { - "message": " key focuses the search field.\nPlain text: search within the name, code, homepage URL and sites it is applied to. Words with less than 3 letters are ignored.\nStyles matching a full URL: prefix the search with , e.g. \nRegular expressions: include slashes and flags, e.g. \nExact words: wrap the query in double quotes, e.g. <\".header ~ div\">", + "message": " or key focuses the search field.\nDefault mode is plain text search for all space-separated terms in any order.\nExact words: wrap the query in double quotes, e.g. <\".header ~ div\">\nRegular expressions: include slashes and flags, e.g. \n\"By URL\" in scope selector: finds styles that apply to a fully specified URL e.g. https://www.example.org/\n\"Metadata\" in scope selector: searches in names, \"applies to\" specifiers, installation URL, update URL, and the entire metadata block for usercss styles.", "description": "Text in the minihelp displayed when clicking (i) icon to the right of the search input field on the Manage styles page" }, + "searchStylesMatchUrl": { + "message": "By URL", + "description": "Option for `find styles` scope selector in the manager. See searchMatchUrlHint for more info." + }, + "searchStylesMeta": { + "message": "Metadata", + "description": "Option for `find styles` scope selector in the manager." + }, + "searchStylesName": { + "message": "Name", + "description": "Option for `find styles` scope selector in the manager." + }, "sectionAdd": { "message": "Add another section", "description": "Label for the button to add a section" diff --git a/background/background-worker.js b/background/background-worker.js index 2b49eebd..7b30969c 100644 --- a/background/background-worker.js +++ b/background/background-worker.js @@ -25,7 +25,7 @@ workerUtil.createAPI({ '/js/meta-parser.js' ); return metaParser.nullifyInvalidVars(vars); - } + }, }); function compileUsercss(preprocessor, code, vars) { @@ -55,7 +55,7 @@ function compileUsercss(preprocessor, code, vars) { const va = vars[key]; output[key] = Object.assign({}, va, { value: va.value === null || va.value === undefined ? - getVarValue(va, 'default') : getVarValue(va, 'value') + getVarValue(va, 'default') : getVarValue(va, 'value'), }); return output; }, {}); @@ -86,7 +86,7 @@ function getUsercssCompiler(preprocessor) { section.code = varDef + section.code; } } - } + }, }, stylus: { preprocess(source, vars) { @@ -96,7 +96,7 @@ function getUsercssCompiler(preprocessor) { new self.StylusRenderer(varDef + source) .render((err, output) => err ? reject(err) : resolve(output)); }); - } + }, }, less: { preprocess(source, vars) { @@ -110,7 +110,7 @@ function getUsercssCompiler(preprocessor) { const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join(''); return self.less.render(varDefs + source) .then(({css}) => css); - } + }, }, uso: { preprocess(source, vars) { @@ -162,8 +162,8 @@ function getUsercssCompiler(preprocessor) { return pool.get(name); }); } - } - } + }, + }, }; if (preprocessor) { diff --git a/background/background.js b/background/background.js index d90a63be..dfcad0bf 100644 --- a/background/background.js +++ b/background/background.js @@ -8,7 +8,7 @@ // eslint-disable-next-line no-var var backgroundWorker = workerUtil.createWorker({ - url: '/background/background-worker.js' + url: '/background/background-worker.js', }); // eslint-disable-next-line no-var @@ -99,7 +99,7 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, { getSyncStatus: sync.getStatus, syncLogin: sync.login, - openManage + openManage, }); // ************************************************************************* @@ -119,7 +119,7 @@ if (FIREFOX) { navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, { url: [ {urlEquals: 'about:blank'}, - ] + ], }); } @@ -135,24 +135,13 @@ if (chrome.commands) { // ************************************************************************* chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { - // save install type: "admin", "development", "normal", "sideload" or "other" - // "normal" = addon installed from webstore - chrome.management.getSelf(info => { - localStorage.installType = info.installType; - if (reason === 'install' && info.installType === 'development' && chrome.contextMenus) { - createContextMenus(['reload']); - } - }); - if (reason !== 'update') return; - // translations may change - localStorage.L10N = JSON.stringify({ - browserUIlanguage: chrome.i18n.getUILanguage(), - }); - // themes may change - delete localStorage.codeMirrorThemes; - // inline search cache for USO is not needed anymore, TODO: remove this by the middle of 2021 if (semverCompare(previousVersion, '1.5.13') <= 0) { + // Removing unused stuff + // TODO: delete this entire block by the middle of 2021 + try { + localStorage.clear(); + } catch (e) {} setTimeout(async () => { const del = Object.keys(await chromeLocal.get()) .filter(key => key.startsWith('usoSearchCache')); @@ -181,7 +170,7 @@ contextMenus = { click: browserCommands.openOptions, }, 'reload': { - presentIf: () => localStorage.installType === 'development', + presentIf: async () => (await browser.management.getSelf()).installType === 'development', title: 'reload', click: browserCommands.reload, }, @@ -195,13 +184,13 @@ contextMenus = { msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension') .catch(msg.ignoreError); }, - } + }, }; -function createContextMenus(ids) { +async function createContextMenus(ids) { for (const id of ids) { let item = contextMenus[id]; - if (item.presentIf && !item.presentIf()) { + if (item.presentIf && !await item.presentIf()) { continue; } item = Object.assign({id}, item); @@ -320,33 +309,29 @@ function openEditor(params) { }); } -function openManage({options = false, search} = {}) { +async function openManage({options = false, search, searchMode} = {}) { let url = chrome.runtime.getURL('manage.html'); if (search) { - url += `?search=${encodeURIComponent(search)}`; + url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`; } if (options) { url += '#stylus-options'; } - return findExistingTab({ + let tab = await findExistingTab({ url, currentWindow: null, ignoreHash: true, - ignoreSearch: true - }) - .then(tab => { - if (tab) { - return Promise.all([ - activateTab(tab), - (tab.pendingUrl || tab.url) !== url && msg.sendTab(tab.id, {method: 'pushState', url}) - .catch(console.error) - ]); - } - return getActiveTab().then(tab => { - if (isTabReplaceable(tab, url)) { - return activateTab(tab, {url}); - } - return browser.tabs.create({url}); - }); - }); + ignoreSearch: true, + }); + if (tab) { + await activateTab(tab); + if (url !== (tab.pendingUrl || tab.url)) { + await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error); + } + return tab; + } + tab = await getActiveTab(); + return isTabReplaceable(tab, url) + ? activateTab(tab, {url}) + : browser.tabs.create({url}); } diff --git a/background/content-scripts.js b/background/content-scripts.js index 3aeecd16..23293097 100644 --- a/background/content-scripts.js +++ b/background/content-scripts.js @@ -29,7 +29,7 @@ const contentScripts = (() => { url: [ {hostEquals: 'greasyfork.org', urlMatches}, {hostEquals: 'sleazyfork.org', urlMatches}, - ] + ], }); return {injectToTab, injectToAllTabs}; @@ -57,7 +57,7 @@ const contentScripts = (() => { const options = { runAt: script.run_at, allFrames: script.all_frames, - matchAboutBlank: script.match_about_blank + matchAboutBlank: script.match_about_blank, }; if (frameId !== null) { options.allFrames = false; @@ -80,7 +80,7 @@ const contentScripts = (() => { } else { injectToTab({ url: tab.pendingUrl || tab.url, - tabId: tab.id + tabId: tab.id, }); } } diff --git a/background/db-chrome-storage.js b/background/db-chrome-storage.js index 01e38262..f3a67796 100644 --- a/background/db-chrome-storage.js +++ b/background/db-chrome-storage.js @@ -32,7 +32,7 @@ function createChromeStorageDB() { } } return output; - }) + }), }; return {exec}; diff --git a/background/db.js b/background/db.js index 223d3870..84075e18 100644 --- a/background/db.js +++ b/background/db.js @@ -24,14 +24,11 @@ const db = (() => { async function tryUsingIndexedDB() { // we use chrome.storage.local fallback if IndexedDB doesn't save data, // which, once detected on the first run, is remembered in chrome.storage.local - // for reliablility and in localStorage for fast synchronous access - // (FF may block localStorage depending on its privacy options) - // note that it may throw when accessing the variable - // https://github.com/openstyles/stylus/issues/615 + // note that accessing indexedDB may throw, https://github.com/openstyles/stylus/issues/615 if (typeof indexedDB === 'undefined') { throw new Error('indexedDB is undefined'); } - switch (await getFallback()) { + switch (await chromeLocal.getValue(FALLBACK)) { case true: throw null; case false: break; default: await testDB(); @@ -39,12 +36,6 @@ const db = (() => { return useIndexedDB(); } - async function getFallback() { - return localStorage[FALLBACK] === 'true' ? true : - localStorage[FALLBACK] === 'false' ? false : - chromeLocal.getValue(FALLBACK); - } - async function testDB() { let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1); // throws if result is null @@ -62,13 +53,11 @@ const db = (() => { chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err)); console.warn('Failed to access indexedDB. Switched to storage API.', err); } - localStorage[FALLBACK] = 'true'; return createChromeStorageDB().exec; } function useIndexedDB() { chromeLocal.setValue(FALLBACK, false); - localStorage[FALLBACK] = 'false'; return dbExecIndexedDB; } diff --git a/background/icon-manager.js b/background/icon-manager.js index 0ea14d03..c69faa1b 100644 --- a/background/icon-manager.js +++ b/background/icon-manager.js @@ -13,7 +13,7 @@ const iconManager = (() => { ], () => debounce(refreshIconBadgeColor)); prefs.subscribe([ - 'show-badge' + 'show-badge', ], () => debounce(refreshAllIconsBadgeText)); prefs.subscribe([ @@ -79,7 +79,7 @@ const iconManager = (() => { tabManager.set(tabId, 'icon', newIcon); iconUtil.setIcon({ path: getIconPath(newIcon), - tabId + tabId, }); } @@ -103,14 +103,14 @@ const iconManager = (() => { function refreshGlobalIcon() { iconUtil.setIcon({ - path: getIconPath(getIconName()) + path: getIconPath(getIconName()), }); } function refreshIconBadgeColor() { const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal'); iconUtil.setBadgeBackgroundColor({ - color + color, }); } diff --git a/background/icon-util.js b/background/icon-util.js index ef7b2822..4bfffe31 100644 --- a/background/icon-util.js +++ b/background/icon-util.js @@ -19,7 +19,7 @@ const iconUtil = (() => { Cache imageData for paths */ setIcon, - setBadgeText + setBadgeText, }); function loadImage(url) { @@ -85,7 +85,7 @@ const iconUtil = (() => { return target[prop]; } return chrome.browserAction[prop].bind(chrome.browserAction); - } + }, }); } })(); diff --git a/background/navigator-util.js b/background/navigator-util.js index c1b702c6..ad73bf16 100644 --- a/background/navigator-util.js +++ b/background/navigator-util.js @@ -4,7 +4,7 @@ const navigatorUtil = (() => { const handler = { - urlChange: null + urlChange: null, }; return extendNative({onUrlChange}); @@ -69,7 +69,7 @@ const navigatorUtil = (() => { return target[prop]; } return chrome.webNavigation[prop].addListener.bind(chrome.webNavigation[prop]); - } + }, }); } })(); diff --git a/background/openusercss-api.js b/background/openusercss-api.js index 0ef98140..dfd890ff 100644 --- a/background/openusercss-api.js +++ b/background/openusercss-api.js @@ -31,11 +31,11 @@ return fetch(api, { method: 'POST', headers: new Headers({ - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }), body: query({ - id - }) + id, + }), }) .then(res => res.json()); }; diff --git a/background/search-db.js b/background/search-db.js index 21ef0572..fcea0a15 100644 --- a/background/search-db.js +++ b/background/search-db.js @@ -1,90 +1,97 @@ -/* global API_METHODS styleManager tryRegExp debounce */ +/* global + API_METHODS + debounce + stringAsRegExp + styleManager + tryRegExp + usercss +*/ 'use strict'; (() => { // toLocaleLowerCase cache, autocleared after 1 minute const cache = new Map(); - // top-level style properties to be searched - const PARTS = { - name: searchText, - url: searchText, - sourceCode: searchText, - sections: searchSections, - }; + const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl']; + + const extractMeta = style => + style.usercssData + ? (style.sourceCode.match(usercss.RX_META) || [''])[0] + : null; + + const stripMeta = style => + style.usercssData + ? style.sourceCode.replace(usercss.RX_META, '') + : null; + + const MODES = Object.assign(Object.create(null), { + code: (style, test) => + style.usercssData + ? test(stripMeta(style)) + : searchSections(style, test, 'code'), + + meta: (style, test, part) => + METAKEYS.some(key => test(style[key])) || + test(part === 'all' ? style.sourceCode : extractMeta(style)) || + searchSections(style, test, 'funcs'), + + name: (style, test) => + test(style.customName) || + test(style.name), + + all: (style, test) => + MODES.meta(style, test, 'all') || + !style.usercssData && MODES.code(style, test), + }); /** * @param params * @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed") + * @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all] * @param {number[]} [params.ids] - if not specified, all styles are searched * @returns {number[]} - array of matched styles ids */ - API_METHODS.searchDB = ({query, ids}) => { - let rx, words, icase, matchUrl; - query = query.trim(); - - if (/^url:/i.test(query)) { - matchUrl = query.slice(query.indexOf(':') + 1).trim(); - if (matchUrl) { - return styleManager.getStylesByUrl(matchUrl) - .then(results => results.map(r => r.data.id)); - } - } - if (query.startsWith('/') && /^\/(.+?)\/([gimsuy]*)$/.test(query)) { - rx = tryRegExp(RegExp.$1, RegExp.$2); - } - if (!rx) { - words = query - .split(/(".*?")|\s+/) - .filter(Boolean) - .map(w => w.startsWith('"') && w.endsWith('"') - ? w.slice(1, -1) - : w) - .filter(w => w.length > 1); - words = words.length ? words : [query]; - icase = words.some(w => w === lower(w)); - } - - return styleManager.getAllStyles().then(styles => { - if (ids) { - const idSet = new Set(ids); - styles = styles.filter(s => idSet.has(s.id)); - } - const results = []; - for (const style of styles) { - const id = style.id; - if (!query || words && !words.length) { - results.push(id); - continue; - } - for (const part in PARTS) { - const text = part === 'name' ? style.customName || style.name : style[part]; - if (text && PARTS[part](text, rx, words, icase)) { - results.push(id); - break; - } - } - } + API_METHODS.searchDB = async ({query, mode = 'all', ids}) => { + let res = []; + if (mode === 'url' && query) { + res = (await styleManager.getStylesByUrl(query)).map(r => r.data.id); + } else if (mode in MODES) { + const modeHandler = MODES[mode]; + const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query); + const rx = m && tryRegExp(m[1], m[2]); + const test = rx ? rx.test.bind(rx) : makeTester(query); + res = (await styleManager.getAllStyles()) + .filter(style => + (!ids || ids.includes(style.id)) && + (!query || modeHandler(style, test))) + .map(style => style.id); if (cache.size) debounce(clearCache, 60e3); - return results; - }); + } + return res; }; - function searchText(text, rx, words, icase) { - if (rx) return rx.test(text); - for (let pass = 1; pass <= (icase ? 2 : 1); pass++) { - if (words.every(w => text.includes(w))) return true; - text = lower(text); - } + function makeTester(query) { + const flags = `u${lower(query) === query ? 'i' : ''}`; + const words = query + .split(/(".*?")|\s+/) + .filter(Boolean) + .map(w => w.startsWith('"') && w.endsWith('"') + ? w.slice(1, -1) + : w) + .filter(w => w.length > 1); + const rxs = (words.length ? words : [query]) + .map(w => stringAsRegExp(w, flags)); + return text => rxs.every(rx => rx.test(text)); } - function searchSections(sections, rx, words, icase) { + function searchSections({sections}, test, part) { + const inCode = part === 'code' || part === 'all'; + const inFuncs = part === 'funcs' || part === 'all'; for (const section of sections) { for (const prop in section) { const value = section[prop]; - if (typeof value === 'string') { - if (searchText(value, rx, words, icase)) return true; - } else if (Array.isArray(value)) { - if (value.some(str => searchText(str, rx, words, icase))) return true; + if (inCode && prop === 'code' && test(value) || + inFuncs && Array.isArray(value) && value.some(str => test(str))) { + return true; } } } @@ -92,9 +99,7 @@ function lower(text) { let result = cache.get(text); - if (result) return result; - result = text.toLocaleLowerCase(); - cache.set(text, result); + if (!result) cache.set(text, result = text.toLocaleLowerCase()); return result; } diff --git a/background/style-manager.js b/background/style-manager.js index 1af60bef..20713d15 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -12,6 +12,8 @@ script would try to fetch the new code. The live preview feature relies on `runtime.connect` and `port.onDisconnect` to cleanup the temporary code. See /edit/live-preview.js. */ + +/** @type {styleManager} */ const styleManager = (() => { const preparing = prepare(); @@ -38,7 +40,7 @@ const styleManager = (() => { style.appliesTo.delete(url); } } - } + }, }); const BAD_MATCHER = {test: () => false}; @@ -58,16 +60,16 @@ const styleManager = (() => { protocol: '', search: '', searchParams: new URLSearchParams(), - username: '' + username: '', }; const DELETE_IF_NULL = ['id', 'customName']; handleLivePreviewConnections(); - return Object.assign({ - compareRevision - }, ensurePrepared({ + return Object.assign(/** @namespace styleManager */{ + compareRevision, + }, ensurePrepared(/** @namespace styleManager */{ get, getByUUID, getSectionsByUrl, @@ -86,7 +88,7 @@ const styleManager = (() => { addExclusion, removeExclusion, addInclusion, - removeInclusion + removeInclusion, })); function handleLivePreviewConnections() { @@ -135,9 +137,8 @@ const styleManager = (() => { } } - function getAllStyles(noCode = false) { - const datas = [...styles.values()].map(s => s.data); - return noCode ? datas.map(getStyleWithNoCode) : datas; + function getAllStyles() { + return [...styles.values()].map(s => s.data); } function compareRevision(rev1, rev2) { @@ -316,7 +317,7 @@ const styleManager = (() => { uuidIndex.delete(style.data._id); return msg.broadcast({ method: 'styleDeleted', - style: {id} + style: {id}, }); }) .then(() => id); @@ -347,7 +348,7 @@ const styleManager = (() => { md5Url: null, url: null, originalMd5: null, - installDate: Date.now() + installDate: Date.now(), }; } @@ -368,7 +369,7 @@ const styleManager = (() => { updated.add(url); cache.sections[data.id] = { id: data.id, - code + code, }; } } @@ -378,10 +379,10 @@ const styleManager = (() => { style: { id: data.id, md5Url: data.md5Url, - enabled: data.enabled + enabled: data.enabled, }, reason, - codeIsUpdated + codeIsUpdated, }); } @@ -424,7 +425,7 @@ const styleManager = (() => { if (!style) { styles.set(data.id, { appliesTo: new Set(), - data + data, }); method = 'styleAdded'; } else { @@ -469,11 +470,7 @@ const styleManager = (() => { } } if (sectionMatched) { - result.push({ - data: getStyleWithNoCode(data), - excluded, - sloppy - }); + result.push({data, excluded, sloppy}); } } return result; @@ -484,7 +481,7 @@ const styleManager = (() => { if (!cache) { cache = { sections: {}, - maybeMatch: new Set() + maybeMatch: new Set(), }; buildCache(styles.values()); cachedStyleForUrl.set(url, cache); @@ -510,7 +507,7 @@ const styleManager = (() => { if (code) { cache.sections[data.id] = { id: data.id, - code + code, }; appliesTo.add(url); } @@ -535,7 +532,7 @@ const styleManager = (() => { const ADD_MISSING_PROPS = { name: style => `ID: ${style.id}`, _id: () => uuidv4(), - _rev: () => Date.now() + _rev: () => Date.now(), }; return db.exec('getAll') @@ -559,7 +556,7 @@ const styleManager = (() => { fixUsoMd5Issue(style); styles.set(style.id, { appliesTo: new Set(), - data: style + data: style, }); uuidIndex.set(style._id, style.id); } @@ -705,7 +702,7 @@ const styleManager = (() => { domain = u.hostname; } return domain; - } + }, }; } diff --git a/background/style-via-webrequest.js b/background/style-via-webrequest.js new file mode 100644 index 00000000..86ead33c --- /dev/null +++ b/background/style-via-webrequest.js @@ -0,0 +1,140 @@ +/* global API CHROME prefs */ +'use strict'; + +// eslint-disable-next-line no-unused-expressions +CHROME && (async () => { + const idCSP = 'patchCsp'; + const idOFF = 'disableAll'; + const idXHR = 'styleViaXhr'; + const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by * + const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/'); + const stylesToPass = {}; + const enabled = {}; + + await prefs.initializing; + prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true}); + + function toggle() { + const csp = prefs.get(idCSP) && !prefs.get(idOFF); + const xhr = prefs.get(idXHR) && !prefs.get(idOFF) && Boolean(chrome.declarativeContent); + if (xhr === enabled.xhr && csp === enabled.csp) { + return; + } + // Need to unregister first so that the optional EXTRA_HEADERS is properly registered + chrome.webRequest.onBeforeRequest.removeListener(prepareStyles); + chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders); + if (xhr || csp) { + const reqFilter = { + urls: [''], + types: ['main_frame', 'sub_frame'], + }; + chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter); + chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [ + 'blocking', + 'responseHeaders', + xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS, + ].filter(Boolean)); + } + if (enabled.xhr !== xhr) { + enabled.xhr = xhr; + toggleEarlyInjection(); + } + enabled.csp = csp; + } + + /** Runs content scripts earlier than document_start */ + function toggleEarlyInjection() { + const api = chrome.declarativeContent; + if (!api) return; + api.onPageChanged.removeRules([idXHR], async () => { + if (enabled.xhr) { + api.onPageChanged.addRules([{ + id: idXHR, + conditions: [ + new api.PageStateMatcher({ + pageUrl: {urlContains: '://'}, + }), + ], + actions: [ + new api.RequestContentScript({ + js: chrome.runtime.getManifest().content_scripts[0].js, + allFrames: true, + }), + ], + }]); + } + }); + } + + /** @param {chrome.webRequest.WebRequestBodyDetails} req */ + function prepareStyles(req) { + API.getSectionsByUrl(req.url).then(sections => { + if (Object.keys(sections).length) { + stylesToPass[req.requestId] = !enabled.xhr ? true : + URL.createObjectURL(new Blob([JSON.stringify(sections)])).slice(blobUrlPrefix.length); + setTimeout(cleanUp, 600e3, req.requestId); + } + }); + } + + /** @param {chrome.webRequest.WebResponseHeadersDetails} req */ + function modifyHeaders(req) { + const {responseHeaders} = req; + const id = stylesToPass[req.requestId]; + if (!id) { + return; + } + if (enabled.xhr) { + responseHeaders.push({ + name: 'Set-Cookie', + value: `${chrome.runtime.id}=${id}`, + }); + } + const csp = enabled.csp && + responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy'); + if (csp) { + patchCsp(csp); + } + if (enabled.xhr || csp) { + return {responseHeaders}; + } + } + + /** @param {chrome.webRequest.HttpHeader} csp */ + function patchCsp(csp) { + const src = {}; + for (let p of csp.value.split(';')) { + p = p.trim().split(/\s+/); + src[p[0]] = p.slice(1); + } + // Allow style assets + patchCspSrc(src, 'img-src', 'data:', '*'); + patchCspSrc(src, 'font-src', 'data:', '*'); + // Allow our DOM styles + patchCspSrc(src, 'style-src', '\'unsafe-inline\''); + // Allow our XHR cookies in CSP sandbox (known case: raw github urls) + if (src.sandbox && !src.sandbox.includes('allow-same-origin')) { + src.sandbox.push('allow-same-origin'); + } + csp.value = Object.entries(src).map(([k, v]) => + `${k}${v.length ? ' ' : ''}${v.join(' ')}`).join('; '); + } + + function patchCspSrc(src, name, ...values) { + let def = src['default-src']; + let list = src[name]; + if (def || list) { + if (!def) def = []; + if (!list) list = [...def]; + if (values.includes('*')) list = src[name] = list.filter(v => !rxHOST.test(v)); + list.push(...values.filter(v => !list.includes(v) && !def.includes(v))); + if (!list.length) delete src[name]; + } + } + + function cleanUp(key) { + const blobId = stylesToPass[key]; + delete stylesToPass[key]; + if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId); + } +})(); diff --git a/background/style-via-xhr.js b/background/style-via-xhr.js deleted file mode 100644 index dc7dc1bc..00000000 --- a/background/style-via-xhr.js +++ /dev/null @@ -1,85 +0,0 @@ -/* global API CHROME prefs */ -'use strict'; - -// eslint-disable-next-line no-unused-expressions -CHROME && (async () => { - const prefId = 'styleViaXhr'; - const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/'); - const stylesToPass = {}; - - await prefs.initializing; - toggle(prefId, prefs.get(prefId)); - prefs.subscribe([prefId], toggle); - - function toggle(key, value) { - if (!chrome.declarativeContent) { // not yet granted in options page - value = false; - } - if (value) { - const reqFilter = { - urls: [''], - types: ['main_frame', 'sub_frame'], - }; - chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter); - chrome.webRequest.onHeadersReceived.addListener(passStyles, reqFilter, [ - 'blocking', - 'responseHeaders', - chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS, - ].filter(Boolean)); - } else { - chrome.webRequest.onBeforeRequest.removeListener(prepareStyles); - chrome.webRequest.onHeadersReceived.removeListener(passStyles); - } - if (!chrome.declarativeContent) { - return; - } - chrome.declarativeContent.onPageChanged.removeRules([prefId], async () => { - if (!value) return; - chrome.declarativeContent.onPageChanged.addRules([{ - id: prefId, - conditions: [ - new chrome.declarativeContent.PageStateMatcher({ - pageUrl: {urlContains: ':'}, - }), - ], - actions: [ - new chrome.declarativeContent.RequestContentScript({ - allFrames: true, - // This runs earlier than document_start - js: chrome.runtime.getManifest().content_scripts[0].js, - }), - ], - }]); - }); - } - - /** @param {chrome.webRequest.WebRequestBodyDetails} req */ - function prepareStyles(req) { - API.getSectionsByUrl(req.url).then(sections => { - const str = JSON.stringify(sections); - if (str !== '{}') { - stylesToPass[req.requestId] = URL.createObjectURL(new Blob([str])).slice(blobUrlPrefix.length); - setTimeout(cleanUp, 600e3, req.requestId); - } - }); - } - - /** @param {chrome.webRequest.WebResponseHeadersDetails} req */ - function passStyles(req) { - const blobId = stylesToPass[req.requestId]; - if (blobId) { - const {responseHeaders} = req; - responseHeaders.push({ - name: 'Set-Cookie', - value: `${chrome.runtime.id}=${prefs.get('disableAll') ? 1 : 0}${blobId}`, - }); - return {responseHeaders}; - } - } - - function cleanUp(key) { - const blobId = stylesToPass[key]; - delete stylesToPass[key]; - if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId); - } -})(); diff --git a/background/sync.js b/background/sync.js index 052784d8..6581e732 100644 --- a/background/sync.js +++ b/background/sync.js @@ -13,7 +13,7 @@ const sync = (() => { progress: null, currentDriveName: null, errorMessage: null, - login: false + login: false, }; let currentDrive; const ctrl = dbToCloud.dbToCloud({ @@ -43,7 +43,7 @@ const sync = (() => { setState(drive, state) { const key = `sync/state/${drive.name}`; return chromeLocal.setValue(key, state); - } + }, }); const initializing = prefs.initializing.then(() => { @@ -58,7 +58,7 @@ const sync = (() => { }); return Object.assign({ - getStatus: () => status + getStatus: () => status, }, ensurePrepared({ start, stop, @@ -73,7 +73,7 @@ const sync = (() => { return ctrl.delete(...args); }, syncNow, - login + login, })); function ensurePrepared(obj) { @@ -99,7 +99,7 @@ const sync = (() => { function schedule(delay = SYNC_DELAY) { chrome.alarms.create('syncNow', { delayInMinutes: delay, - periodInMinutes: SYNC_INTERVAL + periodInMinutes: SYNC_INTERVAL, }); } @@ -206,7 +206,7 @@ const sync = (() => { function getDrive(name) { if (name === 'dropbox' || name === 'google' || name === 'onedrive') { return dbToCloud.drive[name]({ - getAccessToken: () => tokenManager.getToken(name) + getAccessToken: () => tokenManager.getToken(name), }); } throw new Error(`unknown cloud name: ${name}`); diff --git a/background/token-manager.js b/background/token-manager.js index b99b38b5..a5738e0f 100644 --- a/background/token-manager.js +++ b/background/token-manager.js @@ -13,9 +13,9 @@ const tokenManager = (() => { fetch('https://api.dropboxapi.com/2/auth/token/revoke', { method: 'POST', headers: { - 'Authorization': `Bearer ${token}` - } - }) + 'Authorization': `Bearer ${token}`, + }, + }), }, google: { flow: 'code', @@ -27,14 +27,14 @@ const tokenManager = (() => { // tokens for multiple machines. // https://stackoverflow.com/q/18519185 access_type: 'offline', - prompt: 'consent' + prompt: 'consent', }, tokenURL: 'https://oauth2.googleapis.com/token', scopes: ['https://www.googleapis.com/auth/drive.appdata'], revoke: token => { const params = {token}; return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`); - } + }, }, onedrive: { flow: 'code', @@ -45,8 +45,8 @@ const tokenManager = (() => { redirect_uri: FIREFOX ? 'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' : 'https://' + location.hostname + '.chromiumapp.org/', - scopes: ['Files.ReadWrite.AppFolder', 'offline_access'] - } + scopes: ['Files.ReadWrite.AppFolder', 'offline_access'], + }, }; const NETWORK_LATENCY = 30; // seconds @@ -114,7 +114,7 @@ const tokenManager = (() => { client_id: provider.clientId, refresh_token: obj[k.REFRESH], grant_type: 'refresh_token', - scope: provider.scopes.join(' ') + scope: provider.scopes.join(' '), }; if (provider.clientSecret) { body.client_secret = provider.clientSecret; @@ -136,7 +136,7 @@ const tokenManager = (() => { response_type: provider.flow, client_id: provider.clientId, redirect_uri: provider.redirect_uri || chrome.identity.getRedirectURL(), - state + state, }; if (provider.scopes) { query.scope = provider.scopes.join(' '); @@ -148,7 +148,7 @@ const tokenManager = (() => { return webextLaunchWebAuthFlow({ url, interactive, - redirect_uri: query.redirect_uri + redirect_uri: query.redirect_uri, }) .then(url => { const params = new URLSearchParams( @@ -171,7 +171,7 @@ const tokenManager = (() => { code, grant_type: 'authorization_code', client_id: provider.clientId, - redirect_uri: query.redirect_uri + redirect_uri: query.redirect_uri, }; if (provider.clientSecret) { body.client_secret = provider.clientSecret; @@ -185,7 +185,7 @@ const tokenManager = (() => { return chromeLocal.set({ [k.TOKEN]: result.access_token, [k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined, - [k.REFRESH]: result.refresh_token + [k.REFRESH]: result.refresh_token, }) .then(() => result.access_token); } @@ -194,7 +194,7 @@ const tokenManager = (() => { const options = { method: 'POST', headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', }, body: body ? new URLSearchParams(body) : null, }; diff --git a/background/update.js b/background/update.js index ec55a9e5..9b877cbd 100644 --- a/background/update.js +++ b/background/update.js @@ -35,7 +35,7 @@ const ALARM_NAME = 'scheduledUpdate'; const MIN_INTERVAL_MS = 60e3; - let lastUpdateTime = parseInt(localStorage.lastUpdateTime) || Date.now(); + let lastUpdateTime; let checkingAll = false; let logQueue = []; let logLastWriteTime = 0; @@ -46,9 +46,11 @@ API_METHODS.updateCheck = checkStyle; API_METHODS.getUpdaterStates = () => STATES; - prefs.subscribe(['updateInterval'], schedule); - schedule(); - chrome.alarms.onAlarm.addListener(onAlarm); + chromeLocal.getValue('lastUpdateTime').then(val => { + lastUpdateTime = val || Date.now(); + prefs.subscribe('updateInterval', schedule, {now: true}); + chrome.alarms.onAlarm.addListener(onAlarm); + }); return {checkAllStyles, checkStyle, STATES}; @@ -255,7 +257,7 @@ } function resetInterval() { - localStorage.lastUpdateTime = lastUpdateTime = Date.now(); + chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now()); schedule(); } diff --git a/background/usercss-helper.js b/background/usercss-helper.js index 58dc2233..acb1e1af 100644 --- a/background/usercss-helper.js +++ b/background/usercss-helper.js @@ -61,7 +61,7 @@ const usercssHelper = (() => { find(styleId ? {id: styleId} : style) : Promise.resolve(); return Promise.all([ metaOnly ? style : doBuild(style, findDup), - findDup + findDup, ]); }) .then(([style, dup]) => ({style, dup})); diff --git a/background/usercss-install-helper.js b/background/usercss-install-helper.js index b0fbb593..5bf61fdb 100644 --- a/background/usercss-install-helper.js +++ b/background/usercss-install-helper.js @@ -1,4 +1,10 @@ -/* global API_METHODS openURL download URLS tabManager */ +/* global + API_METHODS + download + openURL + tabManager + URLS +*/ 'use strict'; (() => { @@ -27,16 +33,39 @@ return code; }; + // `glob`: pathname match pattern for webRequest + // `rx`: pathname regex to verify the URL really looks like a raw usercss + const maybeDistro = { + // https://github.com/StylishThemes/GitHub-Dark/raw/master/github-dark.user.css + 'github.com': { + glob: '/*/raw/*', + rx: /^\/[^/]+\/[^/]+\/raw\/[^/]+\/[^/]+?\.user\.(css|styl)$/, + }, + // https://raw.githubusercontent.com/StylishThemes/GitHub-Dark/master/github-dark.user.css + 'raw.githubusercontent.com': { + glob: '/*', + rx: /^(\/[^/]+?){4}\.user\.(css|styl)$/, + }, + }; + // Faster installation on known distribution sites to avoid flicker of css text chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => { - openInstallerPage(tabId, url, {}); - // Silently suppressing navigation like it never happened - return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url + const u = new URL(url); + const m = maybeDistro[u.hostname]; + if (!m || m.rx.test(u.pathname)) { + openInstallerPage(tabId, url, {}); + // Silently suppress navigation. + // Don't redirect to the install URL as it'll flash the text! + return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url + } }, { urls: [ URLS.usoArchiveRaw + 'usercss/*.user.css', '*://greasyfork.org/scripts/*/code/*.user.css', '*://sleazyfork.org/scripts/*/code/*.user.css', + ...[].concat( + ...Object.entries(maybeDistro) + .map(([host, {glob}]) => makeUsercssGlobs(host, glob))), ], types: ['main_frame'], }, ['blocking']); @@ -46,7 +75,7 @@ const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type'); tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined); }, { - urls: '%css,%css?*,%styl,%styl?*'.replace(/%/g, '*://*/*.user.').split(','), + urls: makeUsercssGlobs('*', '/*'), types: ['main_frame'], }, ['responseHeaders']); @@ -57,7 +86,7 @@ !oldUrl.startsWith(URLS.installUsercss)) { const inTab = url.startsWith('file:') && Boolean(fileLoader); const code = await (inTab ? fileLoader : urlLoader)(tabId, url); - if (/==userstyle==/i.test(code)) { + if (/==userstyle==/i.test(code) && !/^\s* { if (STYLE_VIA_API) { await API.styleViaAPI({method: 'styleApply'}); } else { - const blobId = chrome.app && getXhrBlobId(); - const styles = blobId && getStylesViaXhr(blobId) || + const styles = chrome.app && !chrome.tabs && getStylesViaXhr() || await API.getSectionsByUrl(getMatchUrl(), null, true); if (styles.disableAll) { delete styles.disableAll; @@ -70,27 +69,16 @@ self.INJECTED !== 1 && (() => { } } - function getXhrBlobId() { + function getStylesViaXhr() { try { - const {cookie} = document; // may throw in sandboxed frames - return new RegExp(`(^|\\s|;)${chrome.runtime.id}=\\s*([-\\w]+)\\s*(;|$)`).exec(cookie)[2]; - } catch (e) {} - } - - function getStylesViaXhr(data) { - try { - const disableAll = data[0] === '1'; - const url = 'blob:' + chrome.runtime.getURL(data.slice(1)); + const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0]; + const url = 'blob:' + chrome.runtime.getURL(blobId); document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie - let res; - if (!disableAll) { // when disabled, will get the styles asynchronously, no rush - const xhr = new XMLHttpRequest(); - xhr.open('GET', url, false); // synchronous - xhr.send(); - res = JSON.parse(xhr.response); - } + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); // synchronous + xhr.send(); URL.revokeObjectURL(url); - return res; + return JSON.parse(xhr.response); } catch (e) {} } diff --git a/content/install-hook-openusercss.js b/content/install-hook-openusercss.js index 31defada..f85fb4da 100644 --- a/content/install-hook-openusercss.js +++ b/content/install-hook-openusercss.js @@ -5,7 +5,7 @@ const manifest = chrome.runtime.getManifest(); const allowedOrigins = [ 'https://openusercss.org', - 'https://openusercss.com' + 'https://openusercss.com', ]; const sendPostMessage = message => { @@ -17,7 +17,7 @@ const askHandshake = () => { // Tell the page that we exist and that it should send the handshake sendPostMessage({ - type: 'ouc-begin-handshake' + type: 'ouc-begin-handshake', }); }; @@ -25,7 +25,7 @@ const sendInstalledCallback = styleData => { sendPostMessage({ type: 'ouc-is-installed-response', - style: styleData + style: styleData, }); }; @@ -36,14 +36,14 @@ ) { API.findUsercss({ name: event.data.name, - namespace: event.data.namespace + namespace: event.data.namespace, }).then(style => { const data = {event}; const callbackObject = { installed: Boolean(style), enabled: style.enabled, name: data.name, - namespace: data.namespace + namespace: data.namespace, }; sendInstalledCallback(callbackObject); @@ -71,7 +71,7 @@ 'update-auto', 'export-json-backups', 'import-json-backups', - 'manage-local' + 'manage-local', ]; const reportedFeatures = []; @@ -96,8 +96,8 @@ key: event.data.key, extension: { name: manifest.name, - capabilities: reportedFeatures - } + capabilities: reportedFeatures, + }, }); }; @@ -120,7 +120,7 @@ // we were able to install the theme and it may display a success message sendPostMessage({ type: 'ouc-install-callback', - key: data.key + key: data.key, }); }; @@ -135,7 +135,7 @@ }).then(style => { sendInstallCallback({ enabled: style.enabled, - key: event.data.key + key: event.data.key, }); }); } diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index f01f1a8f..1a5f4842 100644 --- a/content/install-hook-userstyles.js +++ b/content/install-hook-userstyles.js @@ -85,7 +85,7 @@ const observer = new MutationObserver(check); observer.observe(document.documentElement, { childList: true, - subtree: true + subtree: true, }); check(); @@ -105,7 +105,7 @@ ? 'styleCanBeUpdatedChrome' : 'styleAlreadyInstalledChrome', detail: { - updateUrl: installedStyle.updateUrl + updateUrl: installedStyle.updateUrl, }, }); }); @@ -155,7 +155,7 @@ function doInstall() { let oldStyle; return API.findStyle({ - md5Url: getMeta('stylish-md5-url') || location.href + md5Url: getMeta('stylish-md5-url') || location.href, }, true) .then(_oldStyle => { oldStyle = _oldStyle; diff --git a/edit/beautify.js b/edit/beautify.js index 0f097fce..af4d5cf2 100644 --- a/edit/beautify.js +++ b/edit/beautify.js @@ -162,7 +162,7 @@ function beautify(scope, ui = true) { $create('SVG:path', { 'fill-rule': 'evenodd', 'd': 'M1408 704q0 26-19 45l-448 448q-19 19-45 ' + - '19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z' + '19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z', }), ]), ]), @@ -176,7 +176,7 @@ function beautify(scope, ui = true) { $create('input', { type: 'checkbox', dataset: {option: optionName}, - checked: options[optionName] !== false + checked: options[optionName] !== false, }), $create('SVG:svg.svg-icon.checked', $create('SVG:use', {'xlink:href': '#svg-icon-checked'})), diff --git a/edit/codemirror-default.js b/edit/codemirror-default.js index ff2f51a8..a25efa73 100644 --- a/edit/codemirror-default.js +++ b/edit/codemirror-default.js @@ -1,4 +1,10 @@ -/* global CodeMirror prefs editor $ template */ +/* global + $ + CodeMirror + editor + prefs + t +*/ 'use strict'; @@ -82,7 +88,7 @@ [ {from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']}, // Note: modifier order in CodeMirror is S-C-A - {from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']} + {from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}, ].forEach(remap => { const oldKey = remap.from + char; Object.keys(CodeMirror.keyMap).forEach(keyMapName => { @@ -134,7 +140,7 @@ let filled; this.eachLine(({text}) => (filled = text && /\S/.test(text))); return !filled; - } + }, }); // editor commands @@ -183,7 +189,7 @@ // setTimeout(() => { // $('.CodeMirror-dialog', section).focus(); // }); - cm.openDialog(template.jumpToLine.cloneNode(true), str => { + cm.openDialog(t.template.jumpToLine.cloneNode(true), str => { const m = str.match(/^\s*(\d+)(?:\s*:\s*(\d+))?\s*$/); if (m) { cm.setCursor(m[1] - 1, m[2] ? m[2] - 1 : cur.ch); diff --git a/edit/codemirror-factory.js b/edit/codemirror-factory.js index 887e6c08..4d30ddf4 100644 --- a/edit/codemirror-factory.js +++ b/edit/codemirror-factory.js @@ -33,13 +33,13 @@ const cmFactory = (() => { cm.setOption('highlightSelectionMatches', { showToken: /[#.\-\w]/, annotateScrollbar: true, - onUpdate: updateMatchHighlightCount + onUpdate: updateMatchHighlightCount, }); } else if (value === 'selection') { cm.setOption('highlightSelectionMatches', { showToken: false, annotateScrollbar: true, - onUpdate: updateMatchHighlightCount + onUpdate: updateMatchHighlightCount, }); } else { cm.setOption('highlightSelectionMatches', null); diff --git a/edit/codemirror-themes.js b/edit/codemirror-themes.js index d9e25d19..aa3628c1 100644 --- a/edit/codemirror-themes.js +++ b/edit/codemirror-themes.js @@ -1,7 +1,7 @@ -/* exported CODEMIRROR_THEMES */ -// this file is generated by update-codemirror-themes.js +/* Do not edit. This file is auto-generated by build-vendor.js */ 'use strict'; +/* exported CODEMIRROR_THEMES */ const CODEMIRROR_THEMES = [ '3024-day', '3024-night', @@ -65,5 +65,5 @@ const CODEMIRROR_THEMES = [ 'xq-light', 'yeti', 'yonce', - 'zenburn' + 'zenburn', ]; diff --git a/edit/edit.js b/edit/edit.js index c1de3dd0..7468c47d 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -22,11 +22,10 @@ prefs rerouteHotkeys SectionsEditor - sessionStorageHash + sessionStore setupLivePrefs SourceEditor t - tHTML tryCatch tryJSONparse */ @@ -56,13 +55,23 @@ lazyInit(); .then(initTheme), onDOMready(), ]); + const scrollInfo = style.id && tryJSONparse(sessionStore['editorScrollInfo' + style.id]); /** @namespace EditorBase */ Object.assign(editor, { style, dirty, + scrollInfo, updateName, updateToc, toggleStyle, + applyScrollInfo(cm, si = ((scrollInfo || {}).cms || [])[0]) { + if (si && si.sel) { + cm.operation(() => { + cm.setSelections(...si.sel, {scroll: false}); + cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2); + }); + } + }, }); prefs.subscribe('editor.linter', updateLinter); prefs.subscribe('editor.keyMap', showHotkeyInTooltip); @@ -78,17 +87,21 @@ lazyInit(); $('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle'); $('#preview-label').classList.toggle('hidden', !style.id); - const toc = []; const elToc = $('#toc'); elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target)); - - (editor.isUsercss ? SourceEditor : SectionsEditor)(); - + if (editor.isUsercss) { + SourceEditor(); + } else { + SectionsEditor(); + } prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true}); dirty.onChange(updateDirty); - await editor.ready; + await editor.ready; + editor.ready = true; + + setTimeout(() => editor.getEditors().forEach(linter.enableForEditor)); // enabling after init to prevent flash of validation failure on an empty name $('#name').required = !editor.isUsercss; $('#save-button').onclick = editor.save; @@ -100,7 +113,7 @@ lazyInit(); // switching the mode here to show the correct page ASAP, usually before DOMContentLoaded editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss')); document.documentElement.classList.toggle('usercss', editor.isUsercss); - sessionStorage.justEditedStyleId = style.id || ''; + sessionStore.justEditedStyleId = style.id || ''; // no such style so let's clear the invalid URL parameters if (!style.id) history.replaceState({}, '', location.pathname); updateTitle(false); @@ -290,16 +303,9 @@ lazyInit(); function updateToc(added = editor.sections) { const {sections} = editor; const first = sections.indexOf(added[0]); - let el = elToc.children[first]; - if (added.focus) { - const cls = 'current'; - const old = $('.' + cls, elToc); - if (old && old !== el) old.classList.remove(cls); - el.classList.add(cls); - return; - } - if (first >= 0) { - for (let i = first; i < sections.length; i++) { + const elFirst = elToc.children[first]; + if (first >= 0 && (!added.focus || !elFirst)) { + for (let el = elFirst, i = first; i < sections.length; i++) { const entry = sections[i].tocEntry; if (!deepEqual(entry, toc[i])) { if (!el) el = elToc.appendChild($create('li', {tabIndex: 0})); @@ -318,6 +324,13 @@ lazyInit(); elToc.lastElementChild.remove(); toc.length--; } + if (added.focus) { + const cls = 'current'; + const old = $('.' + cls, elToc); + const el = elFirst || elToc.children[first]; + if (old && old !== el) old.classList.remove(cls); + el.classList.add(cls); + } } })(); @@ -335,7 +348,7 @@ function lazyInit() { async function patchHistoryBack(tab) { ownTabId = tab.id; // use browser history back when 'back to manage' is clicked - if (sessionStorageHash('manageStylesHistory').value[ownTabId] === location.href) { + if (sessionStore['manageStylesHistory' + ownTabId] === location.href) { await onDOMready(); $('#cancel-button').onclick = event => { event.stopPropagation(); @@ -346,8 +359,8 @@ function lazyInit() { } /** resize on 'undo close' */ function restoreWindowSize() { - const pos = tryJSONparse(sessionStorage.windowPos); - delete sessionStorage.windowPos; + const pos = tryJSONparse(sessionStore.windowPos); + delete sessionStore.windowPos; if (pos && pos.left != null && chrome.windows) { chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos); } @@ -408,7 +421,16 @@ function onRuntimeMessage(request) { } function beforeUnload(e) { - sessionStorage.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition')); + sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition')); + sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({ + scrollY: window.scrollY, + cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({ + focus: cm.hasFocus(), + height: cm.display.wrapper.style.height.replace('100vh', ''), + parentHeight: cm.display.wrapper.parentElement.offsetHeight, + sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex], + })), + }); const activeElement = document.activeElement; if (activeElement) { // blurring triggers 'change' or 'input' event if needed @@ -429,7 +451,7 @@ function showHelp(title = '', body) { const contents = $('.contents', div); contents.textContent = ''; if (body) { - contents.appendChild(typeof body === 'string' ? tHTML(body) : body); + contents.appendChild(typeof body === 'string' ? t.HTML(body) : body); } $('.title', div).textContent = title; @@ -492,7 +514,7 @@ function showCodeMirrorPopup(title, html, options) { matchBrackets: true, styleActiveLine: true, theme: prefs.get('editor.theme'), - keyMap: prefs.get('editor.keyMap') + keyMap: prefs.get('editor.keyMap'), }, options)); cm.focus(); rerouteHotkeys(false); diff --git a/edit/editor-worker.js b/edit/editor-worker.js index 9dd1b368..9a0ec6fd 100644 --- a/edit/editor-worker.js +++ b/edit/editor-worker.js @@ -29,13 +29,13 @@ workerUtil.createAPI({ code: err.code, args: err.args, message: err.message, - index: err.index + index: err.index, }) ); return result; }, getStylelintRules, - getCsslintRules + getCsslintRules, }); function getCsslintRules() { diff --git a/edit/global-search.js b/edit/global-search.js index 546805c5..ff18616f 100644 --- a/edit/global-search.js +++ b/edit/global-search.js @@ -1,5 +1,18 @@ -/* global CodeMirror focusAccessibility colorMimicry editor chromeLocal - onDOMready $ $$ $create t debounce tryRegExp stringAsRegExp template */ +/* global + $ + $$ + $create + chromeLocal + CodeMirror + colorMimicry + debounce + editor + focusAccessibility + onDOMready + stringAsRegExp + t + tryRegExp +*/ 'use strict'; onDOMready().then(() => { @@ -100,7 +113,7 @@ onDOMready().then(() => { state.lastFind = ''; toggleDataset(this, 'enabled', !state.icase); doSearch({canAdvance: false}); - } + }, }, }; @@ -136,7 +149,7 @@ onDOMready().then(() => { trimUndoHistory(); enableUndoButton(state.undoHistory.length); if (state.find) doSearch({canAdvance: false}); - } + }, }; const DIALOG_PROPS = { @@ -152,7 +165,7 @@ onDOMready().then(() => { state.replace = this.value; adjustTextareaSize(this); debounce(writeStorage, STORAGE_UPDATE_DELAY); - } + }, }, }; @@ -169,7 +182,7 @@ onDOMready().then(() => { replace(cm) { state.reverse = false; focusDialog('replace', cm); - } + }, }; COMMANDS.replaceAll = COMMANDS.replace; @@ -563,14 +576,14 @@ onDOMready().then(() => { state.originalFocus = document.activeElement; state.firstRun = true; - const dialog = state.dialog = template.searchReplaceDialog.cloneNode(true); + const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true); Object.assign(dialog, DIALOG_PROPS.dialog); dialog.addEventListener('focusout', EVENTS.onfocusout); dialog.dataset.type = type; dialog.style.pointerEvents = 'auto'; const content = $('[data-type="content"]', dialog); - content.parentNode.replaceChild(template[type].cloneNode(true), content); + content.parentNode.replaceChild(t.template[type].cloneNode(true), content); createInput(0, 'input', state.find); createInput(1, 'input2', state.replace); @@ -633,7 +646,7 @@ onDOMready().then(() => { input.value = value; Object.assign(input, DIALOG_PROPS[name]); - input.parentElement.appendChild(template.clearSearch.cloneNode(true)); + input.parentElement.appendChild(t.template.clearSearch.cloneNode(true)); $('[data-action]', input.parentElement)._input = input; } diff --git a/edit/linter-config-dialog.js b/edit/linter-config-dialog.js index d357cb79..5168e86b 100644 --- a/edit/linter-config-dialog.js +++ b/edit/linter-config-dialog.js @@ -61,7 +61,7 @@ loadScript([ '/vendor/codemirror/mode/javascript/javascript.js', '/vendor/codemirror/addon/lint/json-lint.js', - '/vendor/jsonlint/jsonlint.js' + '/vendor/jsonlint/jsonlint.js', ]).then(() => { cm.setOption('mode', 'application/json'); cm.setOption('lint', true); diff --git a/edit/linter-defaults.js b/edit/linter-defaults.js index 4d1d6aee..6c2dd09b 100644 --- a/edit/linter-defaults.js +++ b/edit/linter-defaults.js @@ -12,13 +12,13 @@ const LINTER_DEFAULTS = (() => { rules: { 'at-rule-no-unknown': [true, { 'ignoreAtRules': ['extend', 'extends', 'css', 'block'], - 'severity': 'warning' + 'severity': 'warning', }], 'block-no-empty': [true, SEVERITY], 'color-no-invalid-hex': [true, SEVERITY], 'declaration-block-no-duplicate-properties': [true, { 'ignore': ['consecutive-duplicates-with-different-values'], - 'severity': 'warning' + 'severity': 'warning', }], 'declaration-block-no-shorthand-property-overrides': [true, SEVERITY], 'font-family-no-duplicate-names': [true, SEVERITY], @@ -172,7 +172,7 @@ const LINTER_DEFAULTS = (() => { 'value-list-comma-space-before': 'never', 'value-list-max-empty-lines': 0 */ - } + }, }; const CSSLINT = { // Default warnings @@ -216,7 +216,7 @@ const LINTER_DEFAULTS = (() => { 'universal-selector': 0, 'unqualified-attributes': 0, 'vendor-prefix': 0, - 'zero-units': 0 + 'zero-units': 0, }; return {STYLELINT, CSSLINT, SEVERITY}; })(); diff --git a/edit/linter-engines.js b/edit/linter-engines.js index 8ec09144..295e5d32 100644 --- a/edit/linter-engines.js +++ b/edit/linter-engines.js @@ -7,7 +7,7 @@ storageName: chromeSync.LZ_KEY.csslint, lint: csslint, validMode: mode => mode === 'css', - getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config) + getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config), }, stylelint: { storageName: chromeSync.LZ_KEY.stylelint, @@ -15,9 +15,9 @@ validMode: () => true, getConfig: config => ({ syntax: 'sugarss', - rules: Object.assign({}, LINTER_DEFAULTS.STYLELINT.rules, config && config.rules) - }) - } + rules: Object.assign({}, LINTER_DEFAULTS.STYLELINT.rules, config && config.rules), + }), + }, }); async function stylelint(text, config, mode) { diff --git a/edit/linter-meta.js b/edit/linter-meta.js index 0068771a..bee0b5e5 100644 --- a/edit/linter-meta.js +++ b/edit/linter-meta.js @@ -33,7 +33,7 @@ function createMetaCompiler(cm, onUpdated) { to: cm.posFromIndex((err.index || 0) + match.index), message: err.code && chrome.i18n.getMessage(`meta_${err.code}`, err.args) || err.message, severity: err.code === 'unknownMeta' ? 'warning' : 'error', - rule: err.code + rule: err.code, }) ); meta = match[0]; diff --git a/edit/linter-report.js b/edit/linter-report.js index 4387cb76..b3aea52b 100644 --- a/edit/linter-report.js +++ b/edit/linter-report.js @@ -77,7 +77,7 @@ Object.assign(linter, (() => { element: table, trs, updateAnnotations, - updateCaption + updateCaption, }; function updateCaption() { @@ -124,18 +124,18 @@ Object.assign(linter, (() => { const message = $create('td', {attributes: {role: 'message'}}); const trElement = $create('tr', { - onclick: () => gotoLintIssue(cm, anno) + onclick: () => gotoLintIssue(cm, anno), }, [ severity, line, $create('td', {attributes: {role: 'sep'}}, ':'), col, - message + message, ]); return { element: trElement, update, - getAnnotation: () => anno + getAnnotation: () => anno, }; function update(_anno) { diff --git a/edit/linter.js b/edit/linter.js index 607eb057..52a525b8 100644 --- a/edit/linter.js +++ b/edit/linter.js @@ -3,7 +3,7 @@ /* exported editorWorker */ const editorWorker = workerUtil.createWorker({ - url: '/edit/editor-worker.js' + url: '/edit/editor-worker.js', }); /* exported linter */ @@ -19,7 +19,7 @@ const linter = (() => { enableForEditor, disableForEditor, onLintingUpdated, - onUnhook + onUnhook, }; function onUnhook(cb) { diff --git a/edit/live-preview.js b/edit/live-preview.js index 34bf13be..fafaa50f 100644 --- a/edit/live-preview.js +++ b/edit/live-preview.js @@ -40,7 +40,7 @@ function createLivePreview(preprocess, shouldShow) { function createPreviewer() { const port = chrome.runtime.connect({ - name: 'livePreview' + name: 'livePreview', }); port.onDisconnect.addListener(err => { throw err; diff --git a/edit/moz-section-finder.js b/edit/moz-section-finder.js index e80fbb12..9d143993 100644 --- a/edit/moz-section-finder.js +++ b/edit/moz-section-finder.js @@ -75,7 +75,7 @@ function MozSectionFinder(cm) { /** @param {MozSection} [section] */ updatePositions(section) { (section ? [section] : getState().sections).forEach(setPositionFromMark); - } + }, }; return MozSectionFinder; diff --git a/edit/moz-section-widget.js b/edit/moz-section-widget.js index e8f4cfe3..fcb6fce5 100644 --- a/edit/moz-section-widget.js +++ b/edit/moz-section-widget.js @@ -9,7 +9,6 @@ prefs regExpTester t - template tryCatch */ 'use strict'; @@ -55,7 +54,7 @@ function MozSectionWidget( $create('ul' + C_LIST), ]), listItem: - template.appliesTo.cloneNode(true), + t.template.appliesTo.cloneNode(true), appliesToEverything: $create('li.applies-to-everything', t('appliesToEverything')), }; @@ -74,7 +73,7 @@ function MozSectionWidget( if (funcs.length < 2) { messageBox({ contents: t('appliesRemoveError'), - buttons: [t('confirmClose')] + buttons: [t('confirmClose')], }); return; } @@ -125,7 +124,7 @@ function MozSectionWidget( return; } } - } + }, }; actualStyle = $create('style'); diff --git a/edit/regexp-tester.js b/edit/regexp-tester.js index 5b7bf13e..82fab8ab 100644 --- a/edit/regexp-tester.js +++ b/edit/regexp-tester.js @@ -1,4 +1,12 @@ -/* global showHelp $ $create tryRegExp URLS t template openURL */ +/* global + $ + $create + openURL + showHelp + t + tryRegExp + URLS +*/ /* exported regExpTester */ 'use strict'; @@ -86,7 +94,7 @@ const regExpTester = (() => { full: {data: [], label: t('styleRegexpTestFull')}, partial: {data: [], label: [ t('styleRegexpTestPartial'), - template.regexpTestPartial.cloneNode(true), + t.template.regexpTestPartial.cloneNode(true), ]}, none: {data: [], label: t('styleRegexpTestNone')}, invalid: {data: [], label: t('styleRegexpTestInvalid')}, diff --git a/edit/sections-editor-section.js b/edit/sections-editor-section.js index d1446881..02fdf787 100644 --- a/edit/sections-editor-section.js +++ b/edit/sections-editor-section.js @@ -9,7 +9,6 @@ prefs regExpTester t - template trimCommentLabel tryRegExp */ @@ -17,20 +16,28 @@ /* exported createSection */ -/** @returns {EditorSection} */ -function createSection(originalSection, genId) { +/** + * @param {StyleSection} originalSection + * @param {function():number} genId + * @param {EditorScrollInfo} [si] + * @returns {EditorSection} + */ +function createSection(originalSection, genId, si) { const {dirty} = editor; const sectionId = genId(); - const el = template.section.cloneNode(true); + const el = t.template.section.cloneNode(true); const elLabel = $('.code-label', el); const cm = cmFactory.create(wrapper => { // making it tall during initial load so IntersectionObserver sees only one adjacent CM - wrapper.style.height = '100vh'; + if (editor.ready !== true) { + wrapper.style.height = si ? si.height : '100vh'; + } elLabel.after(wrapper); }, { value: originalSection.code, }); el.CodeMirror = cm; // used by getAssociatedEditor + editor.applyScrollInfo(cm, si); const changeListeners = new Set(); @@ -259,8 +266,8 @@ function createSection(originalSection, genId) { function createApply({type = 'url', value, all = false}) { const applyId = genId(); const dirtyPrefix = `section.${sectionId}.apply.${applyId}`; - const el = all ? template.appliesToEverything.cloneNode(true) : - template.appliesTo.cloneNode(true); + const el = all ? t.template.appliesToEverything.cloneNode(true) : + t.template.appliesTo.cloneNode(true); const selectEl = !all && $('.applies-type', el); if (selectEl) { @@ -353,7 +360,7 @@ function createSection(originalSection, genId) { function createResizeGrip(cm) { const wrapper = cm.display.wrapper; wrapper.classList.add('resize-grip-enabled'); - const resizeGrip = template.resizeGrip.cloneNode(true); + const resizeGrip = t.template.resizeGrip.cloneNode(true); wrapper.appendChild(resizeGrip); let lastClickTime = 0; let initHeight; diff --git a/edit/sections-editor.js b/edit/sections-editor.js index a2c07773..1c9a649f 100644 --- a/edit/sections-editor.js +++ b/edit/sections-editor.js @@ -15,6 +15,7 @@ messageBox prefs sectionsToMozFormat + sessionStore showCodeMirrorPopup showHelp t @@ -117,7 +118,7 @@ function SectionsEditor() { } newStyle = await API.editSave(newStyle); destroyRemovedSections(); - sessionStorage.justEditedStyleId = newStyle.id; + sessionStore.justEditedStyleId = newStyle.id; editor.replaceStyle(newStyle, false); }, @@ -141,7 +142,7 @@ function SectionsEditor() { /** @param {EditorSection} section */ function fitToContent(section) { - const {el, cm, cm: {display: {wrapper, sizer}}} = section; + const {cm, cm: {display: {wrapper, sizer}}} = section; if (cm.display.renderedView) { resize(); } else { @@ -154,12 +155,13 @@ function SectionsEditor() { return; } if (headerOffset == null) { - headerOffset = el.getBoundingClientRect().top; + headerOffset = container.getBoundingClientRect().top; } contentHeight += 9; // border & resize grip cm.off('update', resize); const cmHeight = wrapper.offsetHeight; - const maxHeight = (window.innerHeight - headerOffset) - (section.el.offsetHeight - cmHeight); + const appliesToHeight = Math.min(section.el.offsetHeight - cmHeight, window.innerHeight / 2); + const maxHeight = (window.innerHeight - headerOffset) - appliesToHeight; const fit = Math.min(contentHeight, maxHeight); if (Math.abs(fit - cmHeight) > 1) { cm.setSize(null, fit); @@ -434,7 +436,7 @@ function SectionsEditor() { /** @returns {Style} */ function getModel() { return Object.assign({}, style, { - sections: sections.filter(s => !s.removed).map(s => s.getModel()) + sections: sections.filter(s => !s.removed).map(s => s.getModel()), }); } @@ -484,7 +486,7 @@ function SectionsEditor() { livePreview.update(getModel()); } - function initSections(originalSections, { + function initSections(src, { focusOn = 0, replace = false, pristine = false, @@ -495,27 +497,35 @@ function SectionsEditor() { container.textContent = ''; } let done; - const total = originalSections.length; - originalSections = originalSections.slice(); + let index = 0; + let y = 0; + const total = src.length; + let si = editor.scrollInfo; + if (si && si.cms && si.cms.length === src.length) { + si.scrollY2 = si.scrollY + window.innerHeight; + container.style.height = si.scrollY2 + 'px'; + scrollTo(0, si.scrollY); + } else { + si = null; + } return new Promise(resolve => { done = resolve; - chunk(true); + chunk(!si); }); function chunk(forceRefresh) { const t0 = performance.now(); - while (originalSections.length && performance.now() - t0 < 100) { - insertSectionAfter(originalSections.shift(), undefined, forceRefresh); + while (index < total && performance.now() - t0 < 100) { + if (si) forceRefresh = y < si.scrollY2 && (y += si.cms[index].parentHeight) > si.scrollY; + insertSectionAfter(src[index], undefined, forceRefresh, si && si.cms[index]); if (pristine) dirty.clear(); - if (focusOn !== false && sections[focusOn]) { - sections[focusOn].cm.focus(); - focusOn = false; - } + if (index === focusOn && !si) sections[index].cm.focus(); + index++; } - setGlobalProgress(total - originalSections.length, total); - if (!originalSections.length) { + setGlobalProgress(index, total); + if (index === total) { setGlobalProgress(); - requestAnimationFrame(fitToAvailableSpace); - sections.forEach(({cm}) => setTimeout(linter.enableForEditor, 0, cm)); + if (!si) requestAnimationFrame(fitToAvailableSpace); + container.style.removeProperty('height'); done(); } else { setTimeout(chunk); @@ -564,24 +574,26 @@ function SectionsEditor() { * @param {StyleSection} [init] * @param {EditorSection} [base] * @param {boolean} [forceRefresh] + * @param {EditorScrollInfo} [si] */ - function insertSectionAfter(init, base, forceRefresh) { + function insertSectionAfter(init, base, forceRefresh, si) { if (!init) { init = {code: '', urlPrefixes: ['http://example.com']}; } - const section = createSection(init, genId); + const section = createSection(init, genId, si); const {cm} = section; - sections.splice(base ? sections.indexOf(base) + 1 : sections.length, 0, section); + const index = base ? sections.indexOf(base) + 1 : sections.length; + sections.splice(index, 0, section); container.insertBefore(section.el, base ? base.el.nextSibling : null); - refreshOnView(cm, forceRefresh); + refreshOnView(cm, base || forceRefresh); registerEvents(section); - if (!base || init.code) { + if ((!si || !si.height) && (!base || init.code)) { // Fit a) during startup or b) when the clone button is clicked on a section with some code fitToContent(section); } if (base) { cm.focus(); - setTimeout(editor.scrollToEditor, 0, cm); + editor.scrollToEditor(cm); linter.enableForEditor(cm); } updateSectionOrder(); @@ -646,11 +658,18 @@ function SectionsEditor() { xo.observe(cm.display.wrapper); } + /** @param {IntersectionObserverEntry[]} entries */ function refreshOnViewListener(entries) { - for (const {isIntersecting, target} of entries) { - if (isIntersecting) { - target.CodeMirror.refresh(); - xo.unobserve(target); + for (const e of entries) { + const r = e.isIntersecting && e.intersectionRect; + if (r) { + xo.unobserve(e.target); + const cm = e.target.CodeMirror; + if (r.bottom > 0 && r.top < window.innerHeight) { + cm.refresh(); + } else { + setTimeout(() => cm.refresh()); + } } } } diff --git a/edit/show-keymap-help.js b/edit/show-keymap-help.js index 66cf08ea..b17971b7 100644 --- a/edit/show-keymap-help.js +++ b/edit/show-keymap-help.js @@ -1,5 +1,14 @@ -/* global CodeMirror showHelp onDOMready $ $$ $create template t - prefs stringAsRegExp */ +/* global + $ + $$ + $create + CodeMirror + onDOMready + prefs + showHelp + stringAsRegExp + t +*/ 'use strict'; onDOMready().then(() => { @@ -11,7 +20,7 @@ function showKeyMapHelp() { const keyMapSorted = Object.keys(keyMap) .map(key => ({key, cmd: keyMap[key]})) .sort((a, b) => (a.cmd < b.cmd || (a.cmd === b.cmd && a.key < b.key) ? -1 : 1)); - const table = template.keymapHelp.cloneNode(true); + const table = t.template.keymapHelp.cloneNode(true); const tBody = table.tBodies[0]; const row = tBody.rows[0]; const cellA = row.children[0]; diff --git a/edit/source-editor.js b/edit/source-editor.js index ca8cd183..48272e17 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -16,6 +16,7 @@ MozSectionWidget prefs sectionsToMozFormat + sessionStore t */ @@ -74,6 +75,7 @@ function SourceEditor() { 'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val), 'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val), }, {now: true}); + editor.applyScrollInfo(cm); cm.clearHistory(); cm.markClean(); savedGeneration = cm.changeGeneration(); @@ -89,7 +91,6 @@ function SourceEditor() { linter.run(); updateLinterSwitch(); }); - debounce(linter.enableForEditor, 0, cm); if (!$.isTextInput(document.activeElement)) { cm.focus(); } @@ -98,7 +99,7 @@ function SourceEditor() { return API.buildUsercss({ styleId: style.id, sourceCode: style.sourceCode, - assignVars: true + assignVars: true, }) .then(({style: newStyle}) => { delete newStyle.enabled; @@ -217,7 +218,7 @@ function SourceEditor() { if (style.id !== newStyle.id) { history.replaceState({}, '', `?id=${newStyle.id}`); } - sessionStorage.justEditedStyleId = newStyle.id; + sessionStore.justEditedStyleId = newStyle.id; Object.assign(style, newStyle); $('#preview-label').classList.remove('hidden'); updateMeta(); diff --git a/edit/util.js b/edit/util.js index 8af544de..f987e601 100644 --- a/edit/util.js +++ b/edit/util.js @@ -214,6 +214,6 @@ function createHotkeyInput(prefId, onDone = () => {}) { }, onpaste(event) { event.preventDefault(); - } + }, }); } diff --git a/global.css b/global.css index ad92d486..77489a74 100644 --- a/global.css +++ b/global.css @@ -142,6 +142,7 @@ select { transition: color .5s; } +.select-wrapper, .select-resizer { display: inline-flex!important; cursor: default; diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js index fa6c6aa3..56afae69 100644 --- a/install-usercss/install-usercss.js +++ b/install-usercss/install-usercss.js @@ -22,7 +22,7 @@ if (theme !== 'default') { document.head.appendChild($create('link', { rel: 'stylesheet', - href: `vendor/codemirror/theme/${theme}.css` + href: `vendor/codemirror/theme/${theme}.css`, })); } window.addEventListener('resize', adjustCodeHeight); @@ -111,7 +111,7 @@ frag.appendChild($createLink(url, $create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'}, $create('SVG:path', { - d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z' + d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z', })) )); } @@ -130,7 +130,7 @@ $create('li', $createLink(...args) ) - )) + )), ])); } } diff --git a/js/cache.js b/js/cache.js index 07acfd06..3b6abd73 100644 --- a/js/cache.js +++ b/js/cache.js @@ -25,7 +25,7 @@ function createCache({size = 1000, onDeleted} = {}) { }, get size() { return map.size; - } + }, }; function get(id) { diff --git a/js/dom.js b/js/dom.js index d8bafda8..74649ab4 100644 --- a/js/dom.js +++ b/js/dom.js @@ -296,7 +296,7 @@ function $createLink(href = '', content) { const opt = { tag: 'a', target: '_blank', - rel: 'noopener' + rel: 'noopener', }; if (typeof href === 'object') { Object.assign(opt, href); diff --git a/js/localization.js b/js/localization.js index e4a8de10..2effaf17 100644 --- a/js/localization.js +++ b/js/localization.js @@ -1,149 +1,16 @@ -/* global tryCatch */ -/* exported tHTML formatDate */ 'use strict'; -const template = {}; -tDocLoader(); - - function t(key, params) { - const cache = !params && t.cache[key]; - const s = cache || chrome.i18n.getMessage(key, params); - if (s === '') { - throw `Missing string "${key}"`; - } - if (!params && !cache) { - t.cache[key] = s; - } + const s = chrome.i18n.getMessage(key, params); + if (!s) throw `Missing string "${key}"`; return s; } - -function tHTML(html, tag) { - // body is a text node without HTML tags - if (typeof html === 'string' && !tag && /<\w+/.test(html) === false) { - return document.createTextNode(html); - } - if (typeof html === 'string') { - // spaces are removed; use   for an explicit space - html = html.replace(/>\s+<').trim(); - if (tag) { - html = `<${tag}>${html}`; - } - const body = t.DOMParser.parseFromString(html, 'text/html').body; - if (html.includes('i18n-')) { - tNodeList(body.getElementsByTagName('*')); - } - // the html string may contain more than one top-level node - if (!body.childNodes[1]) { - return body.firstChild; - } - const fragment = document.createDocumentFragment(); - while (body.firstChild) { - fragment.appendChild(body.firstChild); - } - return fragment; - } - return html; -} - - -function tNodeList(nodes) { - const PREFIX = 'i18n-'; - - for (let n = nodes.length; --n >= 0;) { - const node = nodes[n]; - if (node.nodeType !== Node.ELEMENT_NODE) { - continue; - } - if (node.localName === 'template') { - createTemplate(node); - continue; - } - for (let a = node.attributes.length; --a >= 0;) { - const attr = node.attributes[a]; - const name = attr.nodeName; - if (!name.startsWith(PREFIX)) { - continue; - } - const type = name.substr(PREFIX.length); - const value = t(attr.value); - let toInsert, before; - switch (type) { - case 'word-break': - // we already know that: hasWordBreak - break; - case 'text': - before = node.firstChild; - // fallthrough to text-append - case 'text-append': - toInsert = createText(value); - break; - case 'html': { - toInsert = createHtml(value); - break; - } - default: - node.setAttribute(type, value); - } - tDocLoader.pause(); - if (toInsert) { - node.insertBefore(toInsert, before || null); - } - node.removeAttribute(name); - } - } - - function createTemplate(node) { - const elements = node.content.querySelectorAll('*'); - tNodeList(elements); - template[node.dataset.id] = elements[0]; - // compress inter-tag whitespace to reduce number of DOM nodes by 25% - const walker = document.createTreeWalker(elements[0], NodeFilter.SHOW_TEXT); - const toRemove = []; - while (walker.nextNode()) { - const textNode = walker.currentNode; - if (!textNode.nodeValue.trim()) { - toRemove.push(textNode); - } - } - tDocLoader.pause(); - toRemove.forEach(el => el.remove()); - } - - function createText(str) { - return document.createTextNode(tWordBreak(str)); - } - - function createHtml(value) { - // bar are the only recognizable HTML elements - const rx = /(?:]*)>([^<]*)<\/a>)?([^<]*)/gi; - const bin = document.createDocumentFragment(); - for (let m; (m = rx.exec(value)) && m[0];) { - const [, linkParams, linkText, nextText] = m; - if (linkText) { - const href = /\bhref\s*=\s*(\S+)/.exec(linkParams); - const a = bin.appendChild(document.createElement('a')); - a.href = href && href[1].replace(/^(["'])(.*)\1$/, '$2') || ''; - a.appendChild(createText(linkText)); - } - if (nextText) { - bin.appendChild(createText(nextText)); - } - } - return bin; - } -} - - -function tDocLoader() { - t.DOMParser = new DOMParser(); - t.cache = (() => { - try { - return JSON.parse(localStorage.L10N); - } catch (e) {} - })() || {}; - t.RX_WORD_BREAK = new RegExp([ +Object.assign(t, { + template: {}, + DOMParser: new DOMParser(), + ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','), + RX_WORD_BREAK: new RegExp([ '(', /[\d\w\u007B-\uFFFF]{10}/, '|', @@ -152,73 +19,171 @@ function tDocLoader() { /((?!\s)\W){10}/, ')', /(?!\b|\s|$)/, - ].map(rx => rx.source || rx).join(''), 'g'); + ].map(rx => rx.source || rx).join(''), 'g'), - // reset L10N cache on UI language change - const UIlang = chrome.i18n.getUILanguage(); - if (t.cache.browserUIlanguage !== UIlang) { - t.cache = {browserUIlanguage: UIlang}; - localStorage.L10N = JSON.stringify(t.cache); - } - const cacheLength = Object.keys(t.cache).length; + HTML(html) { + return typeof html !== 'string' + ? html + : /<\w+/.test(html) // check for html tags + ? t.createHtml(html.replace(/>\n\s*<').trim()) + : document.createTextNode(html); + }, - Object.assign(tDocLoader, { - observer: new MutationObserver(process), - start() { - if (!tDocLoader.observing) { - tDocLoader.observing = true; - tDocLoader.observer.observe(document, {subtree: true, childList: true}); + NodeList(nodes) { + const PREFIX = 'i18n-'; + for (let n = nodes.length; --n >= 0;) { + const node = nodes[n]; + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; } - }, - stop() { - tDocLoader.pause(); - document.removeEventListener('DOMContentLoaded', onLoad); - }, - pause() { - if (tDocLoader.observing) { - tDocLoader.observing = false; - tDocLoader.observer.disconnect(); + if (node.localName === 'template') { + t.createTemplate(node); + continue; + } + for (let a = node.attributes.length; --a >= 0;) { + const attr = node.attributes[a]; + const name = attr.nodeName; + if (!name.startsWith(PREFIX)) { + continue; + } + const type = name.substr(PREFIX.length); + const value = t(attr.value); + let toInsert, before; + switch (type) { + case 'word-break': + // we already know that: hasWordBreak + break; + case 'text': + before = node.firstChild; + // fallthrough to text-append + case 'text-append': + toInsert = t.createText(value); + break; + case 'html': { + toInsert = t.createHtml(value); + break; + } + default: + node.setAttribute(type, value); + } + t.stopObserver(); + if (toInsert) { + node.insertBefore(toInsert, before || null); + } + node.removeAttribute(name); + } + } + }, + + /** Adds soft hyphens every 10 characters to ensure the long words break before breaking the layout */ + breakWord(text) { + return text.length <= 10 ? text : + text.replace(t.RX_WORD_BREAK, '$&\u00AD'); + }, + + createTemplate(node) { + const elements = node.content.querySelectorAll('*'); + t.NodeList(elements); + t.template[node.dataset.id] = elements[0]; + // compress inter-tag whitespace to reduce number of DOM nodes by 25% + const walker = document.createTreeWalker(elements[0], NodeFilter.SHOW_TEXT); + const toRemove = []; + while (walker.nextNode()) { + const textNode = walker.currentNode; + if (!/[\xA0\S]/.test(textNode.nodeValue)) { // allow \xA0 to keep   + toRemove.push(textNode); + } + } + t.stopObserver(); + toRemove.forEach(el => el.remove()); + }, + + createText(str) { + return document.createTextNode(t.breakWord(str)); + }, + + createHtml(str, trusted) { + const root = t.DOMParser.parseFromString(str, 'text/html').body; + if (!trusted) { + t.sanitizeHtml(root); + } else if (str.includes('i18n-')) { + t.NodeList(root.getElementsByTagName('*')); + } + const bin = document.createDocumentFragment(); + while (root.firstChild) { + bin.appendChild(root.firstChild); + } + return bin; + }, + + sanitizeHtml(root) { + const toRemove = []; + const walker = document.createTreeWalker(root); + for (let n; (n = walker.nextNode());) { + if (n.nodeType === Node.TEXT_NODE) { + n.nodeValue = t.breakWord(n.nodeValue); + } else if (t.ALLOWED_TAGS.includes(n.localName)) { + for (const attr of n.attributes) { + if (n.localName !== 'a' || attr.localName !== 'href' || !/^https?:/.test(n.href)) { + n.removeAttribute(attr.name); + } + } + } else { + toRemove.push(n); + } + } + for (const n of toRemove) { + const parent = n.parentNode; + if (parent) parent.removeChild(n); // not using .remove() as there may be a non-element + } + }, + + formatDate(date) { + if (!date) { + return ''; + } + try { + const newDate = new Date(Number(date) || date); + const string = newDate.toLocaleDateString([chrome.i18n.getUILanguage(), 'en'], { + day: '2-digit', + month: 'short', + year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit', + }); + return string === 'Invalid Date' ? '' : string; + } catch (e) { + return ''; + } + }, +}); + +(() => { + const observer = new MutationObserver(process); + let observing = false; + Object.assign(t, { + stopObserver() { + if (observing) { + observing = false; + observer.disconnect(); } }, }); + document.addEventListener('DOMContentLoaded', () => { + process(observer.takeRecords()); + t.stopObserver(); + }, {once: true}); - tNodeList(document.getElementsByTagName('*')); - tDocLoader.start(); - document.addEventListener('DOMContentLoaded', onLoad); + t.NodeList(document.getElementsByTagName('*')); + start(); function process(mutations) { - for (const mutation of mutations) { - tNodeList(mutation.addedNodes); - } - tDocLoader.start(); + mutations.forEach(m => t.NodeList(m.addedNodes)); + start(); } - function onLoad() { - document.removeEventListener('DOMContentLoaded', onLoad); - process(tDocLoader.observer.takeRecords()); - tDocLoader.stop(); - if (cacheLength !== Object.keys(t.cache).length) { - localStorage.L10N = JSON.stringify(t.cache); + function start() { + if (!observing) { + observing = true; + observer.observe(document, {subtree: true, childList: true}); } } -} - - -function tWordBreak(text) { - // adds soft hyphens every 10 characters to ensure the long words break before breaking the layout - return text.length <= 10 ? text : - text.replace(t.RX_WORD_BREAK, '$&\u00AD'); -} - - -function formatDate(date) { - return !date ? '' : tryCatch(() => { - const newDate = new Date(Number(date) || date); - const string = newDate.toLocaleDateString([t.cache.browserUIlanguage, 'en'], { - day: '2-digit', - month: 'short', - year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit', - }); - return string === 'Invalid Date' ? '' : string; - }) || ''; -} +})(); diff --git a/js/messaging.js b/js/messaging.js index ba7ae2ed..c713d3e5 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -1,6 +1,20 @@ -/* exported getTab getActiveTab onTabReady stringAsRegExp openURL ignoreChromeError - getStyleWithNoCode tryRegExp sessionStorageHash download deepEqual - closeCurrentTab capitalize CHROME_HAS_BORDER_BUG */ +/* exported + capitalize + CHROME_HAS_BORDER_BUG + closeCurrentTab + deepEqual + download + getActiveTab + getStyleWithNoCode + getTab + ignoreChromeError + onTabReady + openURL + sessionStore + stringAsRegExp + tryCatch + tryRegExp +*/ 'use strict'; const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]); @@ -112,7 +126,7 @@ function urlToMatchPattern(url, ignoreSearch) { if (ignoreSearch) { return [ `${url.protocol}//${url.hostname}/${url.pathname}`, - `${url.protocol}//${url.hostname}/${url.pathname}?*` + `${url.protocol}//${url.hostname}/${url.pathname}?*`, ]; } // FIXME: is %2f allowed in pathname and search? @@ -206,7 +220,7 @@ function activateTab(tab, {url, index, openerTabId} = {}) { return Promise.all([ browser.tabs.update(tab.id, options), browser.windows && browser.windows.update(tab.windowId, {focused: true}), - index != null && browser.tabs.move(tab.id, {index}) + index != null && browser.tabs.move(tab.id, {index}), ]) .then(() => tab); } @@ -316,24 +330,28 @@ function deepEqual(a, b, ignoredKeys) { return true; } - -function sessionStorageHash(name) { - return { - name, - value: tryCatch(JSON.parse, sessionStorage[name]) || {}, - set(k, v) { - this.value[k] = v; - this.updateStorage(); - }, - unset(k) { - delete this.value[k]; - this.updateStorage(); - }, - updateStorage() { - sessionStorage[this.name] = JSON.stringify(this.value); +/* A simple polyfill in case DOM storage is disabled in the browser */ +const sessionStore = new Proxy({}, { + get(target, name) { + try { + return sessionStorage[name]; + } catch (e) { + Object.defineProperty(window, 'sessionStorage', {value: target}); } - }; -} + }, + set(target, name, value, proxy) { + try { + sessionStorage[name] = `${value}`; + } catch (e) { + proxy[name]; // eslint-disable-line no-unused-expressions + target[name] = `${value}`; + } + return true; + }, + deleteProperty(target, name) { + return delete target[name]; + }, +}); /** * @param {String} url diff --git a/js/meta-parser.js b/js/meta-parser.js index 2f5bec83..246c2996 100644 --- a/js/meta-parser.js +++ b/js/meta-parser.js @@ -12,17 +12,17 @@ const metaParser = (() => { throw new ParseError({ code: 'unknownPreprocessor', args: [state.value], - index: state.valueIndex + index: state.valueIndex, }); } - } + }, }, validateVar: { select: state => { if (state.varResult.options.every(o => o.name !== state.value)) { throw new ParseError({ code: 'invalidSelectValueMismatch', - index: state.valueIndex + index: state.valueIndex, }); } }, @@ -32,19 +32,19 @@ const metaParser = (() => { throw new ParseError({ code: 'invalidColor', args: [state.value], - index: state.valueIndex + index: state.valueIndex, }); } state.value = colorConverter.format(color, 'rgb'); - } - } + }, + }, }; const parser = createParser(options); const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'})); return { parse, lint, - nullifyInvalidVars + nullifyInvalidVars, }; function parse(text, indexOffset) { diff --git a/js/polyfill.js b/js/polyfill.js index 32b19b05..18c0765f 100644 --- a/js/polyfill.js +++ b/js/polyfill.js @@ -66,15 +66,6 @@ self.INJECTED !== 1 && (() => { //#region for our extension pages - for (const storage of ['localStorage', 'sessionStorage']) { - try { - window[storage]._access_check = 1; - delete window[storage]._access_check; - } catch (err) { - Object.defineProperty(window, storage, {value: {}}); - } - } - if (!(new URLSearchParams({foo: 1})).get('foo')) { // TODO: remove when minimum_chrome_version >= 61 window.URLSearchParams = class extends URLSearchParams { diff --git a/js/prefs.js b/js/prefs.js index ea290534..4e3f935e 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -15,6 +15,7 @@ window.INJECTED !== 1 && (() => { 'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes 'newStyleAsUsercss': false, // create new style in usercss format 'styleViaXhr': false, // early style injection to avoid FOUC + 'patchCsp': false, // add data: and popular image hosting sites to strict CSP // checkbox in style config dialog 'config.autosave': true, diff --git a/js/usercss.js b/js/usercss.js index b2afea36..4dd2177a 100644 --- a/js/usercss.js +++ b/js/usercss.js @@ -12,7 +12,12 @@ const usercss = (() => { }; const RX_META = /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i; const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']); - return {buildMeta, buildCode, assignVars}; + return { + RX_META, + buildMeta, + buildCode, + assignVars, + }; function buildMeta(sourceCode) { sourceCode = sourceCode.replace(/\r\n?/g, '\n'); @@ -20,7 +25,7 @@ const usercss = (() => { const style = { enabled: true, sourceCode, - sections: [] + sections: [], }; const match = sourceCode.match(RX_META); diff --git a/js/worker-util.js b/js/worker-util.js index 5ba40232..767502e6 100644 --- a/js/worker-util.js +++ b/js/worker-util.js @@ -67,7 +67,7 @@ const workerUtil = { message: err.message, lineNumber: err.lineNumber, columnNumber: err.columnNumber, - fileName: err.fileName + fileName: err.fileName, }, err); }, diff --git a/manage.html b/manage.html index 2a5aa734..9ee92aba 100644 --- a/manage.html +++ b/manage.html @@ -11,8 +11,6 @@ - -