diff --git a/.eslintrc b/.eslintrc index e81cbc5d..cee33bfa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,17 +15,25 @@ globals: FIREFOX: false OPERA: false URLS: false + BG: false notifyAllTabs: false - refreshAllTabs: false - updateIcon: false getActiveTab: false getActiveTabRealURL: false getTabRealURL: false openURL: false activateTab: false stringAsRegExp: false - wildcardAsRegExp: false ignoreChromeError: false + tryCatch: false + tryRegExp: false + tryJSONparse: false + debounce: false + deepCopy: false + onBackgroundReady: false + deleteStyleSafe: false + getStylesSafe: false + saveStyleSafe: false + sessionStorageHash: false # localization.js template: false t: false @@ -37,31 +45,13 @@ globals: # dom.js onDOMready: false scrollElementIntoView: false + enforceInputRange: false animateElement: false $: false $$: false - # storage.js + # prefs.js prefs: false - cachedStyles: false - sessionStorageHash: false - getStylesSafe: false - invalidateCache: false - saveStyle: false - enableStyle: false - deleteStyle: false - fixBoolean: false - getDomains: false - getType: false - getApplicableSections: false - isCheckbox: false - runTryCatch: false - tryRegExp: false - tryJSONparse: false - debounce: false setupLivePrefs: false - enforceInputRange: false - getCodeMirrorThemes: false - styleSectionsEqual: false rules: accessor-pairs: [2] diff --git a/background.js b/background.js index 415f1b3b..9b24a668 100644 --- a/background.js +++ b/background.js @@ -1,4 +1,4 @@ -/* global getDatabase, getStyles, reportError */ +/* global getDatabase, getStyles, saveStyle, reportError, invalidateCache */ 'use strict'; chrome.webNavigation.onBeforeNavigate.addListener(data => { @@ -39,9 +39,9 @@ function webNavigationListener(method, data) { // messaging -chrome.runtime.onMessage.addListener(onBackgroundMessage); +chrome.runtime.onMessage.addListener(onRuntimeMessage); -function onBackgroundMessage(request, sender, sendResponse) { +function onRuntimeMessage(request, sender, sendResponse) { switch (request.method) { case 'getStyles': @@ -61,9 +61,7 @@ function onBackgroundMessage(request, sender, sendResponse) { return KEEP_CHANNEL_OPEN; case 'invalidateCache': - if (typeof invalidateCache != 'undefined') { - invalidateCache(false, request); - } + invalidateCache(false, request); break; case 'healthCheck': @@ -101,8 +99,8 @@ if ('commands' in chrome) { } // context menus - -const contextMenus = { +// eslint-disable-next-line no-var +var contextMenus = { 'show-badge': { title: 'menuShowBadge', click: info => prefs.set(info.menuItemId, info.checked), @@ -123,7 +121,7 @@ const contextMenus = { // Vivaldi: Vivaldi/# if (/Vivaldi\/[\d.]+$/.test(navigator.userAgent) || /Safari\/[\d.]+$/.test(navigator.userAgent) - && ![...navigator.plugins].some(p => p.name == 'Shockwave Flash')) { + && !Array.from(navigator.plugins).some(p => p.name == 'Shockwave Flash')) { contextMenus.editDeleteText = { title: 'editDeleteText', contexts: ['editable'], @@ -172,8 +170,11 @@ chrome.tabs.onAttached.addListener((tabId, data) => { }); }); -var codeMirrorThemes; // eslint-disable-line no-var -getCodeMirrorThemes(themes => (codeMirrorThemes = themes)); +// eslint-disable-next-line no-var +var codeMirrorThemes; +getCodeMirrorThemes().then(themes => { + codeMirrorThemes = themes; +}); // do not use prefs.get('version', null) as it might not yet be available chrome.storage.local.get('version', prefs => { @@ -198,6 +199,9 @@ chrome.storage.local.get('version', prefs => { injectContentScripts(); function injectContentScripts() { + // expand * as .*? + const wildcardAsRegExp = (s, flags) => + new RegExp(s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&').replace(/\*/g, '.*?'), flags); const contentScripts = chrome.runtime.getManifest().content_scripts; for (const cs of contentScripts) { cs.matches = cs.matches.map(m => ( @@ -227,3 +231,152 @@ function injectContentScripts() { } }); } + + +function refreshAllTabs() { + return new Promise(resolve => { + // list all tabs including chrome-extension:// which can be ours + chrome.tabs.query({}, tabs => { + const lastTab = tabs[tabs.length - 1]; + for (const tab of tabs) { + getStyles({matchUrl: tab.url, enabled: true, asHash: true}, styles => { + const message = {method: 'styleReplaceAll', styles}; + chrome.tabs.sendMessage(tab.id, message); + updateIcon(tab, styles); + if (tab == lastTab) { + resolve(); + } + }); + } + }); + }); +} + + +function updateIcon(tab, styles) { + // while NTP is still loading only process the request for its main frame with a real url + // (but when it's loaded we should process style toggle requests from popups, for example) + const isNTP = tab.url == 'chrome://newtab/'; + if (isNTP && tab.status != 'complete' || tab.id < 0) { + return; + } + if (styles) { + // check for not-yet-existing tabs e.g. omnibox instant search + chrome.tabs.get(tab.id, () => { + if (!chrome.runtime.lastError) { + stylesReceived(styles); + } + }); + return; + } + if (isNTP) { + getTabRealURL(tab).then(url => + getStyles({matchUrl: url, enabled: true, asHash: true}, stylesReceived)); + } else { + getStyles({matchUrl: tab.url, enabled: true, asHash: true}, stylesReceived); + } + + function stylesReceived(styles) { + let numStyles = styles.length; + if (numStyles === undefined) { + // for 'styles' asHash:true fake the length by counting numeric ids manually + numStyles = 0; + for (const id of Object.keys(styles)) { + numStyles += id.match(/^\d+$/) ? 1 : 0; + } + } + const disableAll = 'disableAll' in styles ? styles.disableAll : prefs.get('disableAll'); + const postfix = disableAll ? 'x' : numStyles == 0 ? 'w' : ''; + const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal'); + const text = prefs.get('show-badge') && numStyles ? String(numStyles) : ''; + chrome.browserAction.setIcon({ + tabId: tab.id, + path: { + // Material Design 2016 new size is 16px + 16: `images/icon/16${postfix}.png`, + 32: `images/icon/32${postfix}.png`, + // Chromium forks or non-chromium browsers may still use the traditional 19px + 19: `images/icon/19${postfix}.png`, + 38: `images/icon/38${postfix}.png`, + // TODO: add Edge preferred sizes: 20, 25, 30, 40 + }, + }, () => { + if (!chrome.runtime.lastError) { + // Vivaldi bug workaround: setBadgeText must follow setBadgeBackgroundColor + chrome.browserAction.setBadgeBackgroundColor({color}); + chrome.browserAction.setBadgeText({text, tabId: tab.id}); + } + }); + } +} + + +function getCodeMirrorThemes() { + if (!chrome.runtime.getPackageDirectoryEntry) { + return Promise.resolve([ + '3024-day', + '3024-night', + 'abcdef', + 'ambiance', + 'ambiance-mobile', + 'base16-dark', + 'base16-light', + 'bespin', + 'blackboard', + 'cobalt', + 'colorforth', + 'dracula', + 'duotone-dark', + 'duotone-light', + 'eclipse', + 'elegant', + 'erlang-dark', + 'hopscotch', + 'icecoder', + 'isotope', + 'lesser-dark', + 'liquibyte', + 'material', + 'mbo', + 'mdn-like', + 'midnight', + 'monokai', + 'neat', + 'neo', + 'night', + 'panda-syntax', + 'paraiso-dark', + 'paraiso-light', + 'pastel-on-dark', + 'railscasts', + 'rubyblue', + 'seti', + 'solarized', + 'the-matrix', + 'tomorrow-night-bright', + 'tomorrow-night-eighties', + 'ttcn', + 'twilight', + 'vibrant-ink', + 'xq-dark', + 'xq-light', + 'yeti', + 'zenburn', + ]); + } + return new Promise(resolve => { + chrome.runtime.getPackageDirectoryEntry(rootDir => { + rootDir.getDirectory('codemirror/theme', {create: false}, themeDir => { + themeDir.createReader().readEntries(entries => { + resolve([ + chrome.i18n.getMessage('defaultTheme') + ].concat( + entries.filter(entry => entry.isFile) + .sort((a, b) => (a.name < b.name ? -1 : 1)) + .map(entry => entry.name.replace(/\.css$/, '')) + )); + }); + }); + }); + }); +} diff --git a/backup/fileSaveLoad.js b/backup/fileSaveLoad.js index 6bfdd049..a052516c 100644 --- a/backup/fileSaveLoad.js +++ b/backup/fileSaveLoad.js @@ -1,4 +1,4 @@ -/* global messageBox */ +/* global messageBox, handleUpdate */ 'use strict'; const STYLISH_DUMP_FILE_EXT = '.txt'; @@ -47,8 +47,15 @@ function importFromFile({fileTypeFilter, file} = {}) { function importFromString(jsonString) { - const json = runTryCatch(() => Array.from(JSON.parse(jsonString))) || []; - const oldStyles = json.length && deepCopyStyles(); + if (!BG) { + onBackgroundReady().then(() => importFromString(jsonString)); + return; + } + const json = BG.tryJSONparse(jsonString) || []; // create object in background context + if (typeof json.slice != 'function') { + json.length = 0; + } + const oldStyles = json.length && BG.deepCopy(BG.cachedStyles.list || []); const oldStylesByName = json.length && new Map( oldStyles.map(style => [style.name.trim(), style])); const stats = { @@ -60,18 +67,19 @@ function importFromString(jsonString) { invalid: {names: [], legend: 'invalid skipped'}, }; let index = 0; + let lastRepaint = performance.now(); return new Promise(proceed); function proceed(resolve) { while (index < json.length) { const item = json[index++]; if (!item || !item.name || !item.name.trim() || typeof item != 'object' - || (item.sections && !(item.sections instanceof Array))) { + || (item.sections && typeof item.sections.slice != 'function')) { stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`); continue; } item.name = item.name.trim(); - const byId = cachedStyles.byId.get(item.id); + const byId = BG.cachedStyles.byId.get(item.id); const byName = oldStylesByName.get(item.name); const oldStyle = byId && byId.name.trim() == item.name || !byName ? byId : byName; if (oldStyle == byName && byName) { @@ -81,16 +89,22 @@ function importFromString(jsonString) { const metaEqual = oldStyleKeys && oldStyleKeys.length == Object.keys(item).length && oldStyleKeys.every(k => k == 'sections' || oldStyle[k] === item[k]); - const codeEqual = oldStyle && styleSectionsEqual(oldStyle, item); + const codeEqual = oldStyle && BG.styleSectionsEqual(oldStyle, item); if (metaEqual && codeEqual) { stats.unchanged.names.push(oldStyle.name); stats.unchanged.ids.push(oldStyle.id); continue; } - saveStyle(Object.assign(item, { + // using saveStyle directly since json was parsed in background page context + BG.saveStyle(Object.assign(item, { reason: 'import', notify: false, })).then(style => { + handleUpdate(style, {reason: 'import'}); + if (performance.now() - lastRepaint > 1000) { + scrollElementIntoView($('#style-' + style.id)); + lastRepaint = performance.now(); + } setTimeout(proceed, 0, resolve); if (!oldStyle) { stats.added.names.push(style.name); @@ -120,17 +134,22 @@ function importFromString(jsonString) { stats.metaOnly.names.length + stats.codeOnly.names.length + stats.added.names.length; - Promise.resolve(numChanged && refreshAllTabs()).then(() => { - scrollTo(0, 0); + Promise.resolve(numChanged && BG.refreshAllTabs()).then(() => { + const listNames = kind => { + const {ids, names} = stats[kind]; + return ids + ? names.map((name, i) => `
${name}
`) + : names.map(name => `
${name}
`); + }; const report = Object.keys(stats) .filter(kind => stats[kind].names.length) - .map(kind => `
+ .map(kind => + `
${stats[kind].names.length} ${stats[kind].legend} - ` + stats[kind].names.map((name, i) => - `
${name}
`).join('') + ` -
+ ${listNames(kind).join('')}
`) .join(''); + scrollTo(0, 0); messageBox({ title: 'Finished importing styles', contents: report || 'Nothing was changed.', @@ -155,7 +174,7 @@ function importFromString(jsonString) { ]; index = 0; return new Promise(undoNextId) - .then(refreshAllTabs) + .then(BG.refreshAllTabs) .then(() => messageBox({ title: 'Import has been undone', contents: newIds.length + ' styles were reverted.', @@ -167,14 +186,14 @@ function importFromString(jsonString) { return; } const id = newIds[index++]; - deleteStyle(id, {notify: false}).then(id => { + deleteStyleSafe({id, notify: false}).then(id => { const oldStyle = oldStylesById.get(id); if (oldStyle) { - saveStyle(Object.assign(oldStyle, { - reason: 'undoImport', + saveStyleSafe(Object.assign(oldStyle, { + reason: 'import', notify: false, - })) - .then(() => setTimeout(undoNextId, 0, resolve)); + })).then(() => + setTimeout(undoNextId, 0, resolve)); } else { setTimeout(undoNextId, 0, resolve); } @@ -198,25 +217,6 @@ function importFromString(jsonString) { } } - function deepCopyStyles() { - const clonedStyles = []; - for (let style of cachedStyles.list || []) { - style = Object.assign({}, style); - style.sections = style.sections.slice(); - for (let i = 0, section; (section = style.sections[i]); i++) { - const copy = style.sections[i] = Object.assign({}, section); - for (const propName in copy) { - const prop = copy[propName]; - if (prop instanceof Array) { - copy[propName] = prop.slice(); - } - } - } - clonedStyles.push(style); - } - return clonedStyles; - } - function limitString(s, limit = 100) { return s.length <= limit ? s : s.substr(0, limit) + '...'; } diff --git a/dom.js b/dom.js index 2214fa82..c41f3bcc 100644 --- a/dom.js +++ b/dom.js @@ -50,6 +50,21 @@ function animateElement(element, {className, remove = false}) { } +function enforceInputRange(element) { + const min = Number(element.min); + const max = Number(element.max); + const onChange = () => { + const value = Number(element.value); + if (value < min || value > max) { + element.value = Math.max(min, Math.min(max, value)); + } + }; + onChange(); + element.addEventListener('change', onChange); + element.addEventListener('input', onChange); +} + + function $(selector, base = document) { // we have ids with . like #manage.onlyEdited which look like #id.class // so since getElementById is superfast we'll try it anyway diff --git a/edit.html b/edit.html index 9deb6bf2..6a3597df 100644 --- a/edit.html +++ b/edit.html @@ -645,8 +645,8 @@ - + diff --git a/edit.js b/edit.js index 447fe943..fe3644fd 100644 --- a/edit.js +++ b/edit.js @@ -252,7 +252,8 @@ function initCodeMirror() { } else { // Chrome is starting up and shows our edit.html, but the background page isn't loaded yet themeControl.innerHTML = optionsHtmlFromArray([theme == "default" ? t("defaultTheme") : theme]); - getCodeMirrorThemes(function(themes) { + BG.getCodeMirrorThemes().then(themes => { + BG.codeMirrorThemes = themes; themeControl.innerHTML = optionsHtmlFromArray(themes); themeControl.selectedIndex = Math.max(0, themes.indexOf(theme)); }); @@ -1333,7 +1334,7 @@ function save() { } var name = document.getElementById("name").value; var enabled = document.getElementById("enabled").checked; - saveStyle({ + saveStyleSafe({ id: styleId, name: name, enabled: enabled, @@ -1815,7 +1816,9 @@ function getParams() { return params; } -chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { +chrome.runtime.onMessage.addListener(onRuntimeMessage); + +function onRuntimeMessage(request) { switch (request.method) { case "styleUpdated": if (styleId && styleId == request.style.id && request.reason != 'editSave') { @@ -1838,7 +1841,7 @@ chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { document.execCommand('delete'); break; } -}); +} function getComputedHeight(el) { var compStyle = getComputedStyle(el); diff --git a/manage.html b/manage.html index a887aa9e..32b8b92a 100644 --- a/manage.html +++ b/manage.html @@ -116,9 +116,8 @@ - - + diff --git a/manage.js b/manage.js index fc44e139..657cfa4a 100644 --- a/manage.js +++ b/manage.js @@ -27,7 +27,9 @@ Promise.all([ }); -chrome.runtime.onMessage.addListener(msg => { +chrome.runtime.onMessage.addListener(onRuntimeMessage); + +function onRuntimeMessage(msg) { switch (msg.method) { case 'styleUpdated': case 'styleAdded': @@ -37,7 +39,7 @@ chrome.runtime.onMessage.addListener(msg => { handleDelete(msg.id); break; } -}); +} function initGlobalEvents() { @@ -151,8 +153,6 @@ function createStyleElement({style, name}) { (style.enabled ? 'enabled' : 'disabled') + (style.updateUrl ? ' updatable' : ''), id: 'style-' + style.id, - styleId: style.id, - styleNameLowerCase: name || style.name.toLocaleLowerCase(), }); parts.nameLink.textContent = style.name; @@ -216,6 +216,8 @@ function createStyleElement({style, name}) { } const newEntry = parts.entry.cloneNode(true); + newEntry.styleId = style.id; + newEntry.styleNameLowerCase = name || style.name.toLocaleLowerCase(); const newTargets = $('.targets', newEntry); if (numTargets) { newTargets.parentElement.replaceChild(targets, newTargets); @@ -282,7 +284,10 @@ Object.assign(handleEvent, { }, toggle(event, entry) { - enableStyle(entry.styleId, this.matches('.enable') || this.checked); + saveStyleSafe({ + id: entry.styleId, + enabled: this.matches('.enable') || this.checked, + }); }, check(event, entry) { @@ -291,7 +296,7 @@ Object.assign(handleEvent, { update(event, entry) { // update everything but name - saveStyle(Object.assign(entry.updatedCode, { + saveStyleSafe(Object.assign(entry.updatedCode, { id: entry.styleId, name: null, reason: 'update', @@ -300,7 +305,7 @@ Object.assign(handleEvent, { delete(event, entry) { const id = entry.styleId; - const {name} = cachedStyles.byId.get(id) || {}; + const {name} = BG.cachedStyles.byId.get(id) || {}; animateElement(entry, {className: 'highlight'}); messageBox({ title: t('deleteStyleConfirm'), @@ -310,7 +315,7 @@ Object.assign(handleEvent, { }) .then(({button, enter, esc}) => { if (button == 0 || enter) { - deleteStyle(id); + deleteStyleSafe({id}); } }); }, @@ -335,7 +340,7 @@ Object.assign(handleEvent, { }); -function handleUpdate(style, {reason, quiet} = {}) { +function handleUpdate(style, {reason} = {}) { const element = createStyleElement({style}); const oldElement = $('#style-' + style.id, installed); if (oldElement) { @@ -354,8 +359,8 @@ function handleUpdate(style, {reason, quiet} = {}) { installed.insertBefore(element, findNextElement(style)); if (reason != 'import') { animateElement(element, {className: 'highlight'}); + scrollElementIntoView(element); } - scrollElementIntoView(element); } @@ -465,7 +470,7 @@ function checkUpdate(element) { class Updater { constructor(element) { - const style = cachedStyles.byId.get(element.styleId); + const style = BG.cachedStyles.byId.get(element.styleId); Object.assign(this, { element, id: style.id, @@ -504,7 +509,7 @@ class Updater { handleJson(forceUpdate, json) { return getStylesSafe({id: this.id}).then(([style]) => { - const needsUpdate = forceUpdate || !styleSectionsEqual(style, json); + const needsUpdate = forceUpdate || !BG.styleSectionsEqual(style, json); this.display({json: needsUpdate && json}); return needsUpdate; }); @@ -598,7 +603,7 @@ function searchStyles({immediately, container}) { } for (const element of (container || installed).children) { - const style = cachedStyles.byId.get(element.styleId) || {}; + const style = BG.cachedStyles.byId.get(element.styleId) || {}; if (style) { const isMatching = !query || isMatchingText(style.name) diff --git a/manifest.json b/manifest.json index 929b70dd..0628b51a 100644 --- a/manifest.json +++ b/manifest.json @@ -19,7 +19,7 @@ "*://*/*" ], "background": { - "scripts": ["messaging.js", "storage.js", "background.js", "update.js"] + "scripts": ["messaging.js", "storage.js", "prefs.js", "background.js", "update.js"] }, "commands": { "openManage": { diff --git a/messaging.js b/messaging.js index b62c9673..3975b777 100644 --- a/messaging.js +++ b/messaging.js @@ -1,4 +1,4 @@ -/* global getStyleWithNoCode, applyOnMessage, onBackgroundMessage, getStyles */ +/* global BG: true, onRuntimeMessage, applyOnMessage, handleUpdate, handleDelete */ 'use strict'; // keep message channel open for sendResponse in chrome.runtime.onMessage listener @@ -7,134 +7,59 @@ const FIREFOX = /Firefox/.test(navigator.userAgent); const OPERA = /OPR/.test(navigator.userAgent); const URLS = { ownOrigin: chrome.runtime.getURL(''), - optionsUI: new Set([ + optionsUI: [ chrome.runtime.getURL('options/index.html'), 'chrome://extensions/?options=' + chrome.runtime.id, - ]), - configureCommands: OPERA ? 'opera://settings/configureCommands' - : 'chrome://extensions/configureCommands', + ], + configureCommands: + OPERA ? 'opera://settings/configureCommands' + : 'chrome://extensions/configureCommands', }; const RX_SUPPORTED_URLS = new RegExp(`^(file|https?|ftps?):|^${URLS.ownOrigin}`); -document.documentElement.classList.toggle('firefox', FIREFOX); -document.documentElement.classList.toggle('opera', OPERA); +let BG = chrome.extension.getBackgroundPage(); +if (!BG || BG != window) { + document.documentElement.classList.toggle('firefox', FIREFOX); + document.documentElement.classList.toggle('opera', OPERA); +} -function notifyAllTabs(request) { - // list all tabs including chrome-extension:// which can be ours - if (request.codeIsUpdated === false && request.style) { - request = Object.assign({}, request, { - style: getStyleWithNoCode(request.style) +function notifyAllTabs(msg) { + const originalMessage = msg; + if (msg.codeIsUpdated === false && msg.style) { + msg = Object.assign({}, msg, { + style: getStyleWithNoCode(msg.style) }); } - const affectsAll = !request.affects || request.affects.all; - const affectsOwnOrigin = !affectsAll && (request.affects.editor || request.affects.manager); + const affectsAll = !msg.affects || msg.affects.all; + const affectsOwnOrigin = !affectsAll && (msg.affects.editor || msg.affects.manager); const affectsTabs = affectsAll || affectsOwnOrigin; - const affectsIcon = affectsAll || request.affects.icon; - const affectsPopup = affectsAll || request.affects.popup; + const affectsIcon = affectsAll || msg.affects.icon; + const affectsPopup = affectsAll || msg.affects.popup; if (affectsTabs || affectsIcon) { + // list all tabs including chrome-extension:// which can be ours chrome.tabs.query(affectsOwnOrigin ? {url: URLS.ownOrigin + '*'} : {}, tabs => { for (const tab of tabs) { - if (affectsTabs || URLS.optionsUI.has(tab.url)) { - chrome.tabs.sendMessage(tab.id, request); + if (affectsTabs || URLS.optionsUI.includes(tab.url)) { + chrome.tabs.sendMessage(tab.id, msg); } - if (affectsIcon) { - updateIcon(tab); + if (affectsIcon && BG) { + BG.updateIcon(tab); } } }); } // notify self: the message no longer is sent to the origin in new Chrome - if (window.applyOnMessage) { - applyOnMessage(request); - } else if (window.onBackgroundMessage) { - onBackgroundMessage(request); + if (typeof onRuntimeMessage != 'undefined') { + onRuntimeMessage(originalMessage); + } + // notify apply.js on own pages + if (typeof applyOnMessage != 'undefined') { + applyOnMessage(originalMessage); } // notify background page and all open popups - if (affectsPopup || request.prefs) { - chrome.runtime.sendMessage(request); - } -} - - -function refreshAllTabs() { - return new Promise(resolve => { - // list all tabs including chrome-extension:// which can be ours - chrome.tabs.query({}, tabs => { - const lastTab = tabs[tabs.length - 1]; - for (const tab of tabs) { - getStyles({matchUrl: tab.url, enabled: true, asHash: true}, styles => { - const message = {method: 'styleReplaceAll', styles}; - if (tab.url == location.href && typeof applyOnMessage !== 'undefined') { - applyOnMessage(message); - } else { - chrome.tabs.sendMessage(tab.id, message); - } - updateIcon(tab, styles); - if (tab == lastTab) { - resolve(); - } - }); - } - }); - }); -} - - -function updateIcon(tab, styles) { - // while NTP is still loading only process the request for its main frame with a real url - // (but when it's loaded we should process style toggle requests from popups, for example) - const isNTP = tab.url == 'chrome://newtab/'; - if (isNTP && tab.status != 'complete' || tab.id < 0) { - return; - } - if (styles) { - // check for not-yet-existing tabs e.g. omnibox instant search - chrome.tabs.get(tab.id, () => { - if (!chrome.runtime.lastError) { - stylesReceived(styles); - } - }); - return; - } - if (isNTP) { - getTabRealURL(tab).then(url => - getStyles({matchUrl: url, enabled: true, asHash: true}, stylesReceived)); - } else { - getStyles({matchUrl: tab.url, enabled: true, asHash: true}, stylesReceived); - } - - function stylesReceived(styles) { - let numStyles = styles.length; - if (numStyles === undefined) { - // for 'styles' asHash:true fake the length by counting numeric ids manually - numStyles = 0; - for (const id of Object.keys(styles)) { - numStyles += id.match(/^\d+$/) ? 1 : 0; - } - } - const disableAll = 'disableAll' in styles ? styles.disableAll : prefs.get('disableAll'); - const postfix = disableAll ? 'x' : numStyles == 0 ? 'w' : ''; - const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal'); - const text = prefs.get('show-badge') && numStyles ? String(numStyles) : ''; - chrome.browserAction.setIcon({ - tabId: tab.id, - path: { - // Material Design 2016 new size is 16px - 16: `images/icon/16${postfix}.png`, - 32: `images/icon/32${postfix}.png`, - // Chromium forks or non-chromium browsers may still use the traditional 19px - 19: `images/icon/19${postfix}.png`, - 38: `images/icon/38${postfix}.png`, - // TODO: add Edge preferred sizes: 20, 25, 30, 40 - }, - }, () => { - if (!chrome.runtime.lastError) { - // Vivaldi bug workaround: setBadgeText must follow setBadgeBackgroundColor - chrome.browserAction.setBadgeBackgroundColor({color}); - chrome.browserAction.setBadgeText({text, tabId: tab.id}); - } - }); + if (affectsPopup || msg.prefs) { + chrome.runtime.sendMessage(msg); } } @@ -211,12 +136,153 @@ function stringAsRegExp(s, flags) { } -// expands * as .*? -function wildcardAsRegExp(s, flags) { - return new RegExp(s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&').replace(/\*/g, '.*?'), flags); -} - - function ignoreChromeError() { chrome.runtime.lastError; // eslint-disable-line no-unused-expressions } + + +function getStyleWithNoCode(style) { + const stripped = Object.assign({}, style, {sections: []}); + for (const section of style.sections) { + stripped.sections.push(Object.assign({}, section, {code: null})); + } + return stripped; +} + + +// js engine can't optimize the entire function if it contains try-catch +// so we should keep it isolated from normal code in a minimal wrapper +// Update: might get fixed in V8 TurboFan in the future +function tryCatch(func, ...args) { + try { + return func(...args); + } catch (e) {} +} + + +function tryRegExp(regexp) { + try { + return new RegExp(regexp); + } catch (e) {} +} + + +function tryJSONparse(jsonString) { + try { + return JSON.parse(jsonString); + } catch (e) {} +} + + +function debounce(fn, delay, ...args) { + const timers = debounce.timers = debounce.timers || new Map(); + debounce.run = debounce.run || ((fn, ...args) => { + timers.delete(fn); + fn(...args); + }); + clearTimeout(timers.get(fn)); + timers.set(fn, setTimeout(debounce.run, delay, fn, ...args)); +} + + +function deepCopy(obj) { + if (!obj || typeof obj != 'object') { + return obj; + } else { + const emptyCopy = Object.create(Object.getPrototypeOf(obj)); + return deepMerge(emptyCopy, obj); + } +} + + +function deepMerge(target, ...args) { + for (const obj of args) { + for (const k in obj) { + const value = obj[k]; + if (!value || typeof value != 'object') { + target[k] = value; + } else if (typeof value.slice == 'function') { + const arrayCopy = target[k] = target[k] || []; + for (const element of value) { + arrayCopy.push(deepCopy(element)); + } + } else if (k in target) { + deepMerge(target[k], value); + } else { + target[k] = deepCopy(value); + } + } + } + return target; +} + + +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); + } + }; +} + + +function onBackgroundReady() { + return BG ? Promise.resolve() : new Promise(ping); + function ping(resolve) { + chrome.runtime.sendMessage({method: 'healthCheck'}, health => { + if (health !== undefined) { + BG = chrome.extension.getBackgroundPage(); + resolve(); + } else { + ping(resolve); + } + }); + } +} + + +// in case Chrome haven't yet loaded the bg page and displays our page like edit/manage +function getStylesSafe(options) { + return new Promise(resolve => { + if (BG) { + BG.getStyles(options, resolve); + } else { + onBackgroundReady().then(() => + BG.getStyles(options, resolve)); + } + }); +} + + +function saveStyleSafe(style) { + return onBackgroundReady() + .then(() => BG.saveStyle(BG.deepCopy(style))) + .then(savedStyle => { + if (style.notify === false) { + handleUpdate(savedStyle, style); + } + return savedStyle; + }); +} + + +function deleteStyleSafe({id, notify = true} = {}) { + return onBackgroundReady() + .then(() => BG.deleteStyle({id, notify})) + .then(() => { + if (!notify) { + handleDelete(id); + } + return id; + }); +} diff --git a/options/index.html b/options/index.html index 98978a87..b98c447b 100644 --- a/options/index.html +++ b/options/index.html @@ -5,9 +5,9 @@ - - + + diff --git a/options/index.js b/options/index.js index f3e52cfa..19ecf930 100644 --- a/options/index.js +++ b/options/index.js @@ -1,7 +1,5 @@ -/* global update */ 'use strict'; - setupLivePrefs([ 'show-badge', 'popup.stylesFirst', @@ -33,7 +31,7 @@ document.onclick = e => { } function check() { - chrome.extension.getBackgroundPage().update.perform((cmd, value) => { + BG.update.perform((cmd, value) => { switch (cmd) { case 'count': total = value; diff --git a/popup.html b/popup.html index ecb152f5..1c026e9e 100644 --- a/popup.html +++ b/popup.html @@ -57,9 +57,8 @@ - - + diff --git a/popup.js b/popup.js index bacb1316..aed550b7 100644 --- a/popup.js +++ b/popup.js @@ -1,4 +1,3 @@ -/* global SLOPPY_REGEXP_PREFIX, compileStyleRegExps */ 'use strict'; let installed; @@ -17,8 +16,9 @@ getActiveTabRealURL().then(url => { }); }); +chrome.runtime.onMessage.addListener(onRuntimeMessage); -chrome.runtime.onMessage.addListener(msg => { +function onRuntimeMessage(msg) { switch (msg.method) { case 'styleAdded': case 'styleUpdated': @@ -38,7 +38,7 @@ chrome.runtime.onMessage.addListener(msg => { } break; } -}); +} function setPopupWidth(width = prefs.get('popupWidth')) { @@ -117,7 +117,7 @@ function initPopup(url) { matchTargets.appendChild(urlLink); // For domain - const domains = getDomains(url); + const domains = BG.getDomains(url); for (const domain of domains) { // Don't include TLD if (domains.length > 1 && !domain.includes('.')) { @@ -252,7 +252,7 @@ Object.assign(handleEvent, { }, toggle(event) { - saveStyle({ + saveStyleSafe({ id: handleEvent.getClickedStyleId(event), enabled: this.type == 'checkbox' ? this.checked : this.matches('.enable'), }); @@ -263,7 +263,7 @@ Object.assign(handleEvent, { const box = $('#confirm'); box.dataset.display = true; box.style.cssText = ''; - $('b', box).textContent = (cachedStyles.byId.get(id) || {}).name; + $('b', box).textContent = (BG.cachedStyles.byId.get(id) || {}).name; $('[data-cmd="ok"]', box).onclick = () => confirm(true); $('[data-cmd="cancel"]', box).onclick = () => confirm(false); window.onkeydown = event => { @@ -278,7 +278,7 @@ Object.assign(handleEvent, { animateElement(box, {className: 'lights-on'}) .then(() => (box.dataset.display = false)); if (ok) { - deleteStyle(id).then(() => { + deleteStyleSafe({id}).then(() => { // update view with 'No styles installed for this site' message if (!installed.children.length) { showStyles([]); @@ -297,7 +297,7 @@ Object.assign(handleEvent, { entry.appendChild(info); }, - closeExplanation(event) { + closeExplanation() { $('#regexp-explanation').remove(); }, @@ -347,7 +347,7 @@ function handleUpdate(style) { return; } // Add an entry when a new style for the current url is installed - if (tabURL && getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) { + if (tabURL && BG.getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) { $('#unavailable').style.display = 'none'; createStyleElement({style}); } @@ -368,13 +368,15 @@ function handleDelete(id) { */ function detectSloppyRegexps({entry, style}) { const { - appliedSections = getApplicableSections({style, matchUrl: tabURL}), - wannabeSections = getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}), + appliedSections = + BG.getApplicableSections({style, matchUrl: tabURL}), + wannabeSections = + BG.getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}), } = style; - compileStyleRegExps({style, compileAll: true}); + BG.compileStyleRegExps({style, compileAll: true}); entry.hasInvalidRegexps = wannabeSections.some(section => - section.regexps.some(rx => !cachedStyles.regexps.has(rx))); + section.regexps.some(rx => !BG.cachedStyles.regexps.has(rx))); entry.sectionsSkipped = wannabeSections.length - appliedSections.length; if (!appliedSections.length) { diff --git a/prefs.js b/prefs.js new file mode 100644 index 00000000..a04e1bee --- /dev/null +++ b/prefs.js @@ -0,0 +1,265 @@ +/* global prefs: true, contextMenus */ +'use strict'; + +// eslint-disable-next-line no-var +var prefs = new function Prefs() { + const defaults = { + 'openEditInWindow': false, // new editor opens in a own browser window + 'windowPosition': {}, // detached window position + 'show-badge': true, // display text on popup menu icon + 'disableAll': false, // boss key + + 'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs + 'popup.breadcrumbs.usePath': false, // use URL path for 'this URL' + 'popup.enabledFirst': true, // display enabled styles before disabled styles + 'popup.stylesFirst': true, // display enabled styles before disabled styles + + 'manage.onlyEnabled': false, // display only enabled styles + 'manage.onlyEdited': false, // display only styles created locally + 'manage.newUI': true, // use the new compact layout + 'manage.newUI.favicons': true, // show favicons for the sites in applies-to + 'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none + + 'editor.options': {}, // CodeMirror.defaults.* + 'editor.lineWrapping': true, // word wrap + 'editor.smartIndent': true, // 'smart' indent + 'editor.indentWithTabs': false, // smart indent with tabs + 'editor.tabSize': 4, // tab width, in spaces + 'editor.keyMap': navigator.appVersion.indexOf('Windows') > 0 ? 'sublime' : 'default', + 'editor.theme': 'default', // CSS theme + 'editor.beautify': { // CSS beautifier + selector_separator_newline: true, + newline_before_open_brace: false, + newline_after_open_brace: true, + newline_between_properties: true, + newline_before_close_brace: true, + newline_between_rules: false, + end_with_newline: false, + space_around_selector_separator: true, + }, + 'editor.lintDelay': 500, // lint gutter marker update delay, ms + 'editor.lintReportDelay': 4500, // lint report update delay, ms + 'editor.matchHighlight': 'token', // token = token/word under cursor even if nothing is selected + // selection = only when something is selected + // '' (empty string) = disabled + + 'badgeDisabled': '#8B0000', // badge background color when disabled + 'badgeNormal': '#006666', // badge background color + + 'popupWidth': 246, // popup width in pixels + + 'updateInterval': 0 // user-style automatic update interval, hour + }; + const values = deepCopy(defaults); + + const affectsIcon = [ + 'show-badge', + 'disableAll', + 'badgeDisabled', + 'badgeNormal', + ]; + + // coalesce multiple pref changes in broadcast + let broadcastPrefs = {}; + + Object.defineProperty(this, 'readOnlyValues', {value: {}}); + + Object.assign(Prefs.prototype, { + + get(key, defaultValue) { + if (key in values) { + return values[key]; + } + if (defaultValue !== undefined) { + return defaultValue; + } + if (key in defaults) { + return defaults[key]; + } + console.warn("No default preference for '%s'", key); + }, + + getAll() { + return deepCopy(values); + }, + + set(key, value, {noBroadcast, noSync} = {}) { + const oldValue = deepCopy(values[key]); + values[key] = value; + defineReadonlyProperty(this.readOnlyValues, key, value); + if (!noBroadcast && !equal(value, oldValue)) { + this.broadcast(key, value, {noSync}); + } + localStorage[key] = typeof defaults[key] == 'object' + ? JSON.stringify(value) + : value; + }, + + remove: key => this.set(key, undefined), + + reset: key => this.set(key, deepCopy(defaults[key])), + + broadcast(key, value, {noSync} = {}) { + broadcastPrefs[key] = value; + debounce(doBroadcast); + if (!noSync) { + debounce(doSyncSet); + } + }, + }); + + // Unlike sync, HTML5 localStorage is ready at browser startup + // so we'll mirror the prefs to avoid using the wrong defaults + // during the startup phase + for (const key in defaults) { + const defaultValue = defaults[key]; + let value = localStorage[key]; + if (typeof value == 'string') { + switch (typeof defaultValue) { + case 'boolean': + value = value.toLowerCase() === 'true'; + break; + case 'number': + value |= 0; + break; + case 'object': + value = tryJSONparse(value) || defaultValue; + break; + } + } else { + value = defaultValue; + } + this.set(key, value, {noBroadcast: true}); + } + + getSync().get('settings', ({settings: synced} = {}) => { + if (synced) { + for (const key in defaults) { + if (key == 'popupWidth' && synced[key] != values.popupWidth) { + // this is a fix for the period when popupWidth wasn't synced + // TODO: remove it in a couple of months + continue; + } + if (key in synced) { + this.set(key, synced[key], {noSync: true}); + } + } + } + if (typeof contextMenus !== 'undefined') { + for (const id in contextMenus) { + if (typeof values[id] == 'boolean') { + this.broadcast(id, values[id], {noSync: true}); + } + } + } + }); + + chrome.storage.onChanged.addListener((changes, area) => { + if (area == 'sync' && 'settings' in changes) { + const synced = changes.settings.newValue; + if (synced) { + for (const key in defaults) { + if (key in synced) { + this.set(key, synced[key], {noSync: true}); + } + } + } else { + // user manually deleted our settings, we'll recreate them + getSync().set({'settings': values}); + } + } + }); + + function doBroadcast() { + const affects = {all: 'disableAll' in broadcastPrefs}; + if (!affects.all) { + for (const key in broadcastPrefs) { + affects.icon = affects.icon || affectsIcon.includes(key); + affects.popup = affects.popup || key.startsWith('popup'); + affects.editor = affects.editor || key.startsWith('editor'); + affects.manager = affects.manager || key.startsWith('manage'); + } + } + notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects}); + broadcastPrefs = {}; + } + + function doSyncSet() { + getSync().set({'settings': values}); + } + + // Polyfill for Firefox < 53 https://bugzilla.mozilla.org/show_bug.cgi?id=1220494 + function getSync() { + if ('sync' in chrome.storage) { + return chrome.storage.sync; + } + const crappyStorage = {}; + return { + get(key, callback) { + callback(crappyStorage[key] || {}); + }, + set(source, callback) { + for (const property in source) { + if (source.hasOwnProperty(property)) { + crappyStorage[property] = source[property]; + } + } + callback(); + } + }; + } + + function defineReadonlyProperty(obj, key, value) { + const copy = deepCopy(value); + if (typeof copy == 'object') { + Object.freeze(copy); + } + Object.defineProperty(obj, key, {value: copy, configurable: true}); + } + + function equal(a, b) { + if (!a || !b || typeof a != 'object' || typeof b != 'object') { + return a === b; + } + if (Object.keys(a).length != Object.keys(b).length) { + return false; + } + for (const k in a) { + if (a[k] !== b[k]) { + return false; + } + } + return true; + } +}(); + + +// Accepts an array of pref names (values are fetched via prefs.get) +// and establishes a two-way connection between the document elements and the actual prefs +function setupLivePrefs(IDs) { + const localIDs = {}; + IDs.forEach(function(id) { + localIDs[id] = true; + updateElement(id).addEventListener('change', function() { + prefs.set(this.id, isCheckbox(this) ? this.checked : this.value); + }); + }); + chrome.runtime.onMessage.addListener(msg => { + if (msg.prefs) { + for (const prefName in msg.prefs) { + if (prefName in localIDs) { + updateElement(prefName, msg.prefs[prefName]); + } + } + } + }); + function updateElement(id, value) { + const el = document.getElementById(id); + el[isCheckbox(el) ? 'checked' : 'value'] = value || prefs.get(id); + el.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); + return el; + } + function isCheckbox(el) { + return el.localName == 'input' && el.type == 'checkbox'; + } +} diff --git a/storage.js b/storage.js index f1846fa0..b4bc03bb 100644 --- a/storage.js +++ b/storage.js @@ -1,7 +1,28 @@ -/* global cachedStyles: true, prefs: true, contextMenus: false */ -/* global handleUpdate, handleDelete */ +/* global cachedStyles: true */ 'use strict'; +const RX_NAMESPACE = new RegExp([/[\s\r\n]*/, + /(@namespace[\s\r\n]+(?:[^\s\r\n]+[\s\r\n]+)?url\(http:\/\/.*?\);)/, + /[\s\r\n]*/].map(rx => rx.source).join(''), 'g'); +const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g; +const SLOPPY_REGEXP_PREFIX = '\0'; + +// Note, only 'var'-declared variables are visible from another extension page +// eslint-disable-next-line no-var +var cachedStyles = { + list: null, + byId: new Map(), + filters: new Map(), + regexps: new Map(), + urlDomains: new Map(), + emptyCode: new Map(), // entire code is comments/whitespace/@namespace + mutex: { + inProgress: false, + onDone: [], + }, +}; + + function getDatabase(ready, error) { const dbOpenRequest = window.indexedDB.open('stylish', 2); dbOpenRequest.onsuccess = event => { @@ -24,54 +45,6 @@ function getDatabase(ready, error) { } -const RX_NAMESPACE = new RegExp([/[\s\r\n]*/, - /(@namespace[\s\r\n]+(?:[^\s\r\n]+[\s\r\n]+)?url\(http:\/\/.*?\);)/, - /[\s\r\n]*/].map(rx => rx.source).join(''), 'g'); -const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g; -const SLOPPY_REGEXP_PREFIX = '\0'; - -// Let manage/popup/edit reuse background page variables -// Note, only 'var'-declared variables are visible from another extension page -// eslint-disable-next-line no-var -var cachedStyles, prefs; -(() => { - const bg = chrome.extension.getBackgroundPage(); - cachedStyles = bg && bg.cachedStyles || { - bg, - list: null, - byId: new Map(), - filters: new Map(), - regexps: new Map(), - urlDomains: new Map(), - emptyCode: new Map(), // entire code is comments/whitespace/@namespace - mutex: { - inProgress: false, - onDone: [], - }, - }; - prefs = bg && bg.prefs; -})(); - - -// in case Chrome haven't yet loaded the bg page and displays our page like edit/manage -function getStylesSafe(options) { - return new Promise(resolve => { - if (cachedStyles.bg) { - getStyles(options, resolve); - return; - } - chrome.runtime.sendMessage(Object.assign({method: 'getStyles'}, options), styles => { - if (!styles) { - resolve(getStylesSafe(options)); - } else { - cachedStyles = chrome.extension.getBackgroundPage().cachedStyles; - resolve(styles); - } - }); - }); -} - - function getStyles(options, callback) { if (cachedStyles.list) { callback(filterStyles(options)); @@ -107,60 +80,6 @@ function getStyles(options, callback) { } -function getStyleWithNoCode(style) { - const stripped = Object.assign({}, style, {sections: []}); - for (const section of style.sections) { - stripped.sections.push(Object.assign({}, section, {code: null})); - } - return stripped; -} - - -function invalidateCache(andNotify, {added, updated, deletedId} = {}) { - // prevent double-add on echoed invalidation - const cached = added && cachedStyles.byId.get(added.id); - if (cached) { - return; - } - if (andNotify) { - chrome.runtime.sendMessage({method: 'invalidateCache', added, updated, deletedId}); - } - if (!cachedStyles.list) { - return; - } - if (updated) { - const cached = cachedStyles.byId.get(updated.id); - if (cached) { - Object.assign(cached, updated); - //console.debug('cache: updated', updated); - } - cachedStyles.filters.clear(); - return; - } - if (added) { - cachedStyles.list.push(added); - cachedStyles.byId.set(added.id, added); - //console.debug('cache: added', added); - cachedStyles.filters.clear(); - return; - } - if (deletedId != undefined) { - const deletedStyle = (cachedStyles.byId.get(deletedId) || {}).style; - if (deletedStyle) { - const cachedIndex = cachedStyles.list.indexOf(deletedStyle); - cachedStyles.list.splice(cachedIndex, 1); - cachedStyles.byId.delete(deletedId); - //console.debug('cache: deleted', deletedStyle); - cachedStyles.filters.clear(); - return; - } - } - cachedStyles.list = null; - //console.debug('cache cleared'); - cachedStyles.filters.clear(); -} - - function filterStyles({ enabled, url = null, @@ -174,10 +93,10 @@ function filterStyles({ id = id === null ? null : Number(id); if (enabled === null - && url === null - && id === null - && matchUrl === null - && asHash != true) { + && url === null + && id === null + && matchUrl === null + && asHash != true) { //console.debug('%c%s filterStyles SKIPPED LOOP %s', 'color:gray', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len return cachedStyles.list; } @@ -247,36 +166,6 @@ function filterStyles({ } -function cleanupCachedFilters({force = false} = {}) { - if (!force) { - // sliding timer for 1 second - clearTimeout(cleanupCachedFilters.timeout); - cleanupCachedFilters.timeout = setTimeout(cleanupCachedFilters, 1000, {force: true}); - return; - } - const size = cachedStyles.filters.size; - const oldestHit = cachedStyles.filters.values().next().value.lastHit; - const now = Date.now(); - const timeSpan = now - oldestHit; - const recencyWeight = 5 / size; - const hitWeight = 1 / 4; // we make ~4 hits per URL - const lastHitWeight = 10; - // delete the oldest 10% - [...cachedStyles.filters.entries()] - .map(([id, v], index) => ({ - id, - weight: - index * recencyWeight + - v.hits * hitWeight + - (v.lastHit - oldestHit) / timeSpan * lastHitWeight, - })) - .sort((a, b) => a.weight - b.weight) - .slice(0, size / 10 + 1) - .forEach(({id}) => cachedStyles.filters.delete(id)); - cleanupCachedFilters.timeout = 0; -} - - function saveStyle(style) { return new Promise(resolve => { getDatabase(db => { @@ -312,9 +201,6 @@ function saveStyle(style) { style, codeIsUpdated, reason, }); } - if (typeof handleUpdate != 'undefined') { - handleUpdate(style, {reason}); - } resolve(style); }; }; @@ -340,9 +226,6 @@ function saveStyle(style) { if (notify) { notifyAllTabs({method: 'styleAdded', style, reason}); } - if (typeof handleUpdate != 'undefined') { - handleUpdate(style, {reason}); - } resolve(style); }; }); @@ -350,24 +233,7 @@ function saveStyle(style) { } -function addMissingStyleTargets(style) { - style.sections = (style.sections || []).map(section => - Object.assign({ - urls: [], - urlPrefixes: [], - domains: [], - regexps: [], - }, section) - ); -} - - -function enableStyle(id, enabled) { - return saveStyle({id, enabled}); -} - - -function deleteStyle(id, {notify = true} = {}) { +function deleteStyle({id, notify = true}) { return new Promise(resolve => getDatabase(db => { const tx = db.transaction(['styles'], 'readwrite'); @@ -377,61 +243,12 @@ function deleteStyle(id, {notify = true} = {}) { if (notify) { notifyAllTabs({method: 'styleDeleted', id}); } - if (typeof handleDelete != 'undefined') { - handleDelete(id); - } resolve(id); }; })); } -function reportError(...args) { - for (const arg of args) { - if ('message' in arg) { - console.log(arg.message); - } - } -} - - -function fixBoolean(b) { - if (typeof b != 'undefined') { - return b != 'false'; - } - return null; -} - - -function getDomains(url) { - if (url.indexOf('file:') == 0) { - return []; - } - let d = /.*?:\/*([^/:]+)/.exec(url)[1]; - const domains = [d]; - while (d.indexOf('.') != -1) { - d = d.substring(d.indexOf('.') + 1); - domains.push(d); - } - return domains; -} - - -function getType(o) { - if (typeof o == 'undefined' || typeof o == 'string') { - return typeof o; - } - // with the persistent cachedStyles the Array reference is usually different - // so let's check for e.g. type of 'every' which is only present on arrays - // (in the context of our extension) - if (o instanceof Array || typeof o.every == 'function') { - return 'array'; - } - console.warn('Unsupported type:', o); - return 'undefined'; -} - - function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirst}) { //let t0 = 0; const sections = []; @@ -518,392 +335,6 @@ function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirs } -function isCheckbox(el) { - return el.localName == 'input' && el.type == 'checkbox'; -} - - -// js engine can't optimize the entire function if it contains try-catch -// so we should keep it isolated from normal code in a minimal wrapper -// Update: might get fixed in V8 TurboFan in the future -function runTryCatch(func, ...args) { - try { - return func(...args); - } catch (e) {} -} - - -function tryRegExp(regexp) { - try { - return new RegExp(regexp); - } catch (e) {} -} - - -function tryJSONparse(jsonString) { - try { - return JSON.parse(jsonString); - } catch (e) {} -} - - -function debounce(fn, delay, ...args) { - const timers = debounce.timers = debounce.timers || new Map(); - debounce.run = debounce.run || ((fn, ...args) => { - timers.delete(fn); - fn(...args); - }); - clearTimeout(timers.get(fn)); - timers.set(fn, setTimeout(debounce.run, delay, fn, ...args)); -} - - -prefs = prefs || new function Prefs() { - const defaults = { - 'openEditInWindow': false, // new editor opens in a own browser window - 'windowPosition': {}, // detached window position - 'show-badge': true, // display text on popup menu icon - 'disableAll': false, // boss key - - 'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs - 'popup.breadcrumbs.usePath': false, // use URL path for 'this URL' - 'popup.enabledFirst': true, // display enabled styles before disabled styles - 'popup.stylesFirst': true, // display enabled styles before disabled styles - - 'manage.onlyEnabled': false, // display only enabled styles - 'manage.onlyEdited': false, // display only styles created locally - 'manage.newUI': true, // use the new compact layout - 'manage.newUI.favicons': true, // show favicons for the sites in applies-to - 'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none - - 'editor.options': {}, // CodeMirror.defaults.* - 'editor.lineWrapping': true, // word wrap - 'editor.smartIndent': true, // 'smart' indent - 'editor.indentWithTabs': false, // smart indent with tabs - 'editor.tabSize': 4, // tab width, in spaces - 'editor.keyMap': navigator.appVersion.indexOf('Windows') > 0 ? 'sublime' : 'default', - 'editor.theme': 'default', // CSS theme - 'editor.beautify': { // CSS beautifier - selector_separator_newline: true, - newline_before_open_brace: false, - newline_after_open_brace: true, - newline_between_properties: true, - newline_before_close_brace: true, - newline_between_rules: false, - end_with_newline: false, - space_around_selector_separator: true, - }, - 'editor.lintDelay': 500, // lint gutter marker update delay, ms - 'editor.lintReportDelay': 4500, // lint report update delay, ms - 'editor.matchHighlight': 'token', // token = token/word under cursor even if nothing is selected - // selection = only when something is selected - // '' (empty string) = disabled - - 'badgeDisabled': '#8B0000', // badge background color when disabled - 'badgeNormal': '#006666', // badge background color - - 'popupWidth': 246, // popup width in pixels - - 'updateInterval': 0 // user-style automatic update interval, hour - }; - const values = deepCopy(defaults); - - const affectsIcon = [ - 'show-badge', - 'disableAll', - 'badgeDisabled', - 'badgeNormal', - ]; - - // coalesce multiple pref changes in broadcast - let broadcastPrefs = {}; - - function doBroadcast() { - const affects = {all: 'disableAll' in broadcastPrefs}; - if (!affects.all) { - for (const key in broadcastPrefs) { - affects.icon = affects.icon || affectsIcon.includes(key); - affects.popup = affects.popup || key.startsWith('popup'); - affects.editor = affects.editor || key.startsWith('editor'); - affects.manager = affects.manager || key.startsWith('manage'); - } - } - notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects}); - broadcastPrefs = {}; - } - - function doSyncSet() { - getSync().set({'settings': values}); - } - - Object.defineProperty(this, 'readOnlyValues', {value: {}}); - - Object.assign(Prefs.prototype, { - - get(key, defaultValue) { - if (key in values) { - return values[key]; - } - if (defaultValue !== undefined) { - return defaultValue; - } - if (key in defaults) { - return defaults[key]; - } - console.warn("No default preference for '%s'", key); - }, - - getAll() { - return deepCopy(values); - }, - - set(key, value, {noBroadcast, noSync} = {}) { - const oldValue = deepCopy(values[key]); - values[key] = value; - defineReadonlyProperty(this.readOnlyValues, key, value); - if (!noBroadcast && !equal(value, oldValue)) { - this.broadcast(key, value, {noSync}); - } - localStorage[key] = typeof defaults[key] == 'object' - ? JSON.stringify(value) - : value; - }, - - remove: key => this.set(key, undefined), - - reset: key => this.set(key, deepCopy(defaults[key])), - - broadcast(key, value, {noSync} = {}) { - broadcastPrefs[key] = value; - debounce(doBroadcast); - if (!noSync) { - debounce(doSyncSet); - } - }, - }); - - // Unlike sync, HTML5 localStorage is ready at browser startup - // so we'll mirror the prefs to avoid using the wrong defaults - // during the startup phase - for (const key in defaults) { - const defaultValue = defaults[key]; - let value = localStorage[key]; - if (typeof value == 'string') { - switch (typeof defaultValue) { - case 'boolean': - value = value.toLowerCase() === 'true'; - break; - case 'number': - value |= 0; - break; - case 'object': - value = tryJSONparse(value) || defaultValue; - break; - } - } else { - value = defaultValue; - } - this.set(key, value, {noBroadcast: true}); - } - - getSync().get('settings', ({settings: synced}) => { - if (synced) { - for (const key in defaults) { - if (key == 'popupWidth' && synced[key] != values.popupWidth) { - // this is a fix for the period when popupWidth wasn't synced - // TODO: remove it in a couple of months before the summer 2017 - continue; - } - if (key in synced) { - this.set(key, synced[key], {noSync: true}); - } - } - } - if (typeof contextMenus !== 'undefined') { - for (const id in contextMenus) { - if (typeof values[id] == 'boolean') { - this.broadcast(id, values[id], {noSync: true}); - } - } - } - }); - - chrome.storage.onChanged.addListener((changes, area) => { - if (area == 'sync' && 'settings' in changes) { - const synced = changes.settings.newValue; - if (synced) { - for (const key in defaults) { - if (key in synced) { - this.set(key, synced[key], {noSync: true}); - } - } - } else { - // user manually deleted our settings, we'll recreate them - getSync().set({'settings': values}); - } - } - }); -}(); - - -// Accepts an array of pref names (values are fetched via prefs.get) -// and establishes a two-way connection between the document elements and the actual prefs -function setupLivePrefs(IDs) { - const localIDs = {}; - IDs.forEach(function(id) { - localIDs[id] = true; - updateElement(id).addEventListener('change', function() { - prefs.set(this.id, isCheckbox(this) ? this.checked : this.value); - }); - }); - chrome.runtime.onMessage.addListener(msg => { - if (msg.prefs) { - for (const prefName in msg.prefs) { - if (prefName in localIDs) { - updateElement(prefName, msg.prefs[prefName]); - } - } - } - }); - function updateElement(id, value) { - const el = document.getElementById(id); - el[isCheckbox(el) ? 'checked' : 'value'] = value || prefs.get(id); - el.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); - return el; - } -} - - -function enforceInputRange(element) { - const min = Number(element.min); - const max = Number(element.max); - const onChange = () => { - const value = Number(element.value); - if (value < min || value > max) { - element.value = Math.max(min, Math.min(max, value)); - } - }; - onChange(); - element.addEventListener('change', onChange); - element.addEventListener('input', onChange); -} - - -function getCodeMirrorThemes(callback) { - chrome.runtime.getPackageDirectoryEntry(function(rootDir) { - rootDir.getDirectory('codemirror/theme', {create: false}, function(themeDir) { - themeDir.createReader().readEntries(function(entries) { - const themes = [chrome.i18n.getMessage('defaultTheme')]; - entries - .filter(entry => entry.isFile) - .sort((a, b) => (a.name < b.name ? -1 : 1)) - .forEach(function(entry) { - themes.push(entry.name.replace(/\.css$/, '')); - }); - if (callback) { - callback(themes); - } - }); - }); - }); -} - - -function sessionStorageHash(name) { - return { - name, - value: runTryCatch(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); - } - }; -} - - -function deepCopy(obj) { - if (!obj || typeof obj != 'object') { - return obj; - } else { - const emptyCopy = Object.create(Object.getPrototypeOf(obj)); - return deepMerge(emptyCopy, obj); - } -} - - -function deepMerge(target, ...args) { - for (const obj of args) { - for (const k in obj) { - const value = obj[k]; - if (!value || typeof value != 'object') { - target[k] = value; - } else if (k in target) { - deepMerge(target[k], value); - } else if (typeof value.slice == 'function') { - target[k] = value.slice(); - } else { - target[k] = deepCopy(value); - } - } - } - return target; -} - - -function equal(a, b) { - if (!a || !b || typeof a != 'object' || typeof b != 'object') { - return a === b; - } - if (Object.keys(a).length != Object.keys(b).length) { - return false; - } - for (const k in a) { - if (a[k] !== b[k]) { - return false; - } - } - return true; -} - - -function defineReadonlyProperty(obj, key, value) { - const copy = deepCopy(value); - if (typeof copy == 'object') { - Object.freeze(copy); - } - Object.defineProperty(obj, key, {value: copy, configurable: true}); -} - - -// Polyfill for Firefox < 53 https://bugzilla.mozilla.org/show_bug.cgi?id=1220494 -function getSync() { - if ('sync' in chrome.storage) { - return chrome.storage.sync; - } - const crappyStorage = {}; - return { - get(key, callback) { - callback(crappyStorage[key] || {}); - }, - set(source, callback) { - for (const property in source) { - if (source.hasOwnProperty(property)) { - crappyStorage[property] = source[property]; - } - } - callback(); - } - }; -} - - function styleSectionsEqual(styleA, styleB) { if (!styleA.sections || !styleB.sections) { return undefined; @@ -990,3 +421,136 @@ function compileStyleRegExps({style, compileAll}) { } } } + + +function invalidateCache(andNotify, {added, updated, deletedId} = {}) { + // prevent double-add on echoed invalidation + const cached = added && cachedStyles.byId.get(added.id); + if (cached) { + return; + } + if (andNotify) { + chrome.runtime.sendMessage({method: 'invalidateCache', added, updated, deletedId}); + } + if (!cachedStyles.list) { + return; + } + if (updated) { + const cached = cachedStyles.byId.get(updated.id); + if (cached) { + Object.assign(cached, updated); + //console.debug('cache: updated', updated); + } + cachedStyles.filters.clear(); + return; + } + if (added) { + cachedStyles.list.push(added); + cachedStyles.byId.set(added.id, added); + //console.debug('cache: added', added); + cachedStyles.filters.clear(); + return; + } + if (deletedId != undefined) { + const deletedStyle = (cachedStyles.byId.get(deletedId) || {}).style; + if (deletedStyle) { + const cachedIndex = cachedStyles.list.indexOf(deletedStyle); + cachedStyles.list.splice(cachedIndex, 1); + cachedStyles.byId.delete(deletedId); + //console.debug('cache: deleted', deletedStyle); + cachedStyles.filters.clear(); + return; + } + } + cachedStyles.list = null; + //console.debug('cache cleared'); + cachedStyles.filters.clear(); +} + + +function cleanupCachedFilters({force = false} = {}) { + if (!force) { + // sliding timer for 1 second + clearTimeout(cleanupCachedFilters.timeout); + cleanupCachedFilters.timeout = setTimeout(cleanupCachedFilters, 1000, {force: true}); + return; + } + const size = cachedStyles.filters.size; + const oldestHit = cachedStyles.filters.values().next().value.lastHit; + const now = Date.now(); + const timeSpan = now - oldestHit; + const recencyWeight = 5 / size; + const hitWeight = 1 / 4; // we make ~4 hits per URL + const lastHitWeight = 10; + // delete the oldest 10% + [...cachedStyles.filters.entries()] + .map(([id, v], index) => ({ + id, + weight: + index * recencyWeight + + v.hits * hitWeight + + (v.lastHit - oldestHit) / timeSpan * lastHitWeight, + })) + .sort((a, b) => a.weight - b.weight) + .slice(0, size / 10 + 1) + .forEach(({id}) => cachedStyles.filters.delete(id)); + cleanupCachedFilters.timeout = 0; +} + + +function addMissingStyleTargets(style) { + style.sections = (style.sections || []).map(section => + Object.assign({ + urls: [], + urlPrefixes: [], + domains: [], + regexps: [], + }, section) + ); +} + + +function reportError(...args) { + for (const arg of args) { + if ('message' in arg) { + console.log(arg.message); + } + } +} + + +function fixBoolean(b) { + if (typeof b != 'undefined') { + return b != 'false'; + } + return null; +} + + +function getDomains(url) { + if (url.indexOf('file:') == 0) { + return []; + } + let d = /.*?:\/*([^/:]+)/.exec(url)[1]; + const domains = [d]; + while (d.indexOf('.') != -1) { + d = d.substring(d.indexOf('.') + 1); + domains.push(d); + } + return domains; +} + + +function getType(o) { + if (typeof o == 'undefined' || typeof o == 'string') { + return typeof o; + } + // with the persistent cachedStyles the Array reference is usually different + // so let's check for e.g. type of 'every' which is only present on arrays + // (in the context of our extension) + if (o instanceof Array || typeof o.every == 'function') { + return 'array'; + } + console.warn('Unsupported type:', o); + return 'undefined'; +} diff --git a/update.js b/update.js index bc85d283..3ff46ebf 100644 --- a/update.js +++ b/update.js @@ -1,4 +1,5 @@ -/* globals getStyles */ +/* eslint brace-style: 1, arrow-parens: 1, space-before-function-paren: 1, arrow-body-style: 1 */ +/* globals getStyles, saveStyle */ 'use strict'; // TODO: refactor to make usable in manage::Updater