API groups + use executeScript for early injection (#1149)

* parserlib: fast section extraction, tweaks and speedups
* csslint: "simple-not" rule
* csslint: enable and fix "selector-newline" rule
* simplify db: resolve with result
* simplify download()
* remove noCode param as it wastes more time/memory on copying
* styleManager: switch style<->data names to reflect their actual contents
* inline method bodies to avoid indirection and enable better autocomplete/hint/jump support in IDE
* upgrade getEventKeyName to handle mouse clicks
* don't trust location.href as it hides text fragment
* getAllKeys is implemented since Chrome48, FF44
* allow recoverable css errors + async'ify usercss.js
* openManage: unminimize windows
* remove the obsolete Chrome pre-65 workaround
* fix temporal dead zone in apply.js
* ff bug workaround for simple editor window
* consistent window scrolling in scrollToEditor and jumpToPos
* rework waitForSelector and collapsible <details>
* blank paint frame workaround for new Chrome
* extract stuff from edit.js and load on demand
* simplify regexpTester::isShown
* move MozDocMapper to sections-util.js
* extract fitSelectBox()
* initialize router earlier
* use helpPopup.close()
* fix autofocus in popups, follow-up to 5bb1b5ef
* clone objects in prefs.get() + cosmetics
* reuse getAll result for INC
This commit is contained in:
tophf 2021-01-01 17:27:58 +03:00 committed by GitHub
parent 06823bd5b4
commit fdbfb23547
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 8336 additions and 8877 deletions

View File

@ -1,4 +1,2 @@
vendor/
vendor-overwrites/*
!vendor-overwrites/colorpicker
!vendor-overwrites/csslint
vendor-overwrites/

View File

@ -8,6 +8,9 @@ env:
es6: true
webextensions: true
globals:
require: readonly # in polyfill.js
rules:
accessor-pairs: [2]
array-bracket-spacing: [2, never]
@ -42,7 +45,15 @@ rules:
id-blacklist: [0]
id-length: [0]
id-match: [0]
indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}]
indent: [2, 2, {
SwitchCase: 1,
ignoreComments: true,
ignoredNodes: [
"TemplateLiteral > *",
"ConditionalExpression",
"ForStatement"
]
}]
jsx-quotes: [0]
key-spacing: [0]
keyword-spacing: [2]
@ -86,7 +97,7 @@ rules:
no-empty: [2, {allowEmptyCatch: true}]
no-eq-null: [0]
no-eval: [2]
no-ex-assign: [2]
no-ex-assign: [0]
no-extend-native: [2]
no-extra-bind: [2]
no-extra-boolean-cast: [2]
@ -136,6 +147,9 @@ rules:
no-proto: [2]
no-redeclare: [2]
no-regex-spaces: [2]
no-restricted-globals: [2, name, event]
# `name` and `event` (in Chrome) are built-in globals
# but we don't use these globals so it's most likely a mistake/typo
no-restricted-imports: [0]
no-restricted-modules: [2, domain, freelist, smalloc, sys]
no-restricted-syntax: [2, WithStatement]
@ -163,7 +177,7 @@ rules:
no-unreachable: [2]
no-unsafe-finally: [2]
no-unsafe-negation: [2]
no-unused-expressions: [1]
no-unused-expressions: [2]
no-unused-labels: [0]
no-unused-vars: [2, {args: after-used}]
no-use-before-define: [2, nofunc]
@ -220,3 +234,7 @@ overrides:
webextensions: false
parserOptions:
ecmaVersion: 2017
- files: ["**/*worker*.js"]
env:
worker: true

View File

@ -1,176 +1,26 @@
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
/* global createWorkerApi */// worker-util.js
'use strict';
importScripts('/js/worker-util.js');
const {loadScript} = workerUtil;
/** @namespace BackgroundWorker */
createWorkerApi({
workerUtil.createAPI({
parseMozFormat(arg) {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
return parseMozFormat(arg);
},
compileUsercss,
parseUsercssMeta(text, indexOffset = 0) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.parse(text, indexOffset);
async compileUsercss(...args) {
require(['/js/usercss-compiler']); /* global compileUsercss */
return compileUsercss(...args);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
require(['/js/meta-parser']); /* global metaParser */
return metaParser.nullifyInvalidVars(vars);
},
parseMozFormat(...args) {
require(['/js/moz-parser']); /* global extractSections */
return extractSections(...args);
},
parseUsercssMeta(text) {
require(['/js/meta-parser']);
return metaParser.parse(text);
},
});
function compileUsercss(preprocessor, code, vars) {
loadScript(
'/vendor-overwrites/csslint/parserlib.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/moz-parser.js'
);
const builder = getUsercssCompiler(preprocessor);
vars = simpleVars(vars);
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code)
.then(code => parseMozFormat({code}))
.then(({sections, errors}) => {
if (builder.postprocess) {
builder.postprocess(sections, vars);
}
return {sections, errors};
});
function simpleVars(vars) {
if (!vars) {
return {};
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
return Object.keys(vars).reduce((output, key) => {
const va = vars[key];
output[key] = Object.assign({}, va, {
value: va.value === null || va.value === undefined ?
getVarValue(va, 'default') : getVarValue(va, 'value'),
});
return output;
}, {});
}
function getVarValue(va, prop) {
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
// TODO: handle customized image
return va.options.find(o => o.name === va[prop]).value;
}
if ((va.type === 'number' || va.type === 'range') && va.units) {
return va[prop] + va.units;
}
return va[prop];
}
}
function getUsercssCompiler(preprocessor) {
const BUILDER = {
default: {
postprocess(sections, vars) {
loadScript('/js/sections-util.js');
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n';
for (const section of sections) {
if (!styleCodeEmpty(section.code)) {
section.code = varDef + section.code;
}
}
},
},
stylus: {
preprocess(source, vars) {
loadScript('/vendor/stylus-lang-bundle/stylus-renderer.min.js');
return new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
new self.StylusRenderer(varDef + source)
.render((err, output) => err ? reject(err) : resolve(output));
});
},
},
less: {
preprocess(source, vars) {
if (!self.less) {
self.less = {
logLevel: 0,
useFileCache: false,
};
}
loadScript('/vendor/less-bundle/less.min.js');
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) {
loadScript('/vendor-overwrites/colorpicker/colorconverter.js');
const pool = new Map();
return Promise.resolve(doReplace(source));
function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), name);
}
return null;
}
const {type, value} = vars[name];
switch (type) {
case 'color': {
let color = pool.get(rgbName || name);
if (color == null) {
color = colorConverter.parse(value);
if (color) {
if (color.type === 'hsl') {
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
}
const {r, g, b} = color;
color = rgbName
? `${r}, ${g}, ${b}`
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
// the pool stores `false` for bad colors to differentiate from a yet unknown color
pool.set(rgbName || name, color || false);
}
return color || null;
}
case 'dropdown':
case 'select': // prevent infinite recursion
pool.set(name, '');
return doReplace(value);
}
return value;
}
function doReplace(text) {
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
if (!pool.has(name)) {
const value = getValue(name);
pool.set(name, value === null ? match : value);
}
return pool.get(name);
});
}
},
},
};
if (preprocessor) {
if (!BUILDER[preprocessor]) {
throw new Error('unknwon preprocessor');
}
return BUILDER[preprocessor];
}
return BUILDER.default;
}

View File

@ -1,315 +1,72 @@
/* global download prefs openURL FIREFOX CHROME
URLS ignoreChromeError chromeLocal semverCompare
styleManager msg navigatorUtil workerUtil contentScripts sync
findExistingTab activateTab isTabReplaceable getActiveTab
*/
/* global API msg */// msg.js
/* global addAPI bgReady */// common.js
/* global createWorker */// worker-util.js
/* global prefs */
/* global styleMan */
/* global syncMan */
/* global updateMan */
/* global usercssMan */
/* global
FIREFOX
URLS
activateTab
download
findExistingTab
getActiveTab
isTabReplaceable
openURL
*/ // toolbox.js
'use strict';
// eslint-disable-next-line no-var
var backgroundWorker = workerUtil.createWorker({
url: '/background/background-worker.js',
});
//#region API
// eslint-disable-next-line no-var
var browserCommands, contextMenus;
addAPI(/** @namespace API */ {
// *************************************************************************
// browser commands
browserCommands = {
openManage,
openOptions: () => openManage({options: true}),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
styles: styleMan,
sync: syncMan,
updater: updateMan,
usercss: usercssMan,
/** @type {BackgroundWorker} */
worker: createWorker({url: '/background/background-worker'}),
download(url, opts) {
return typeof url === 'string' && url.startsWith(URLS.uso) &&
this.sender.url.startsWith(URLS.uso) &&
download(url, opts || {});
},
reload: () => chrome.runtime.reload(),
};
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
deleteStyle: styleManager.deleteStyle,
editSave: styleManager.editSave,
findStyle: styleManager.findStyle,
getAllStyles: styleManager.getAllStyles, // used by importer
getSectionsByUrl: styleManager.getSectionsByUrl,
getStyle: styleManager.get,
getStylesByUrl: styleManager.getStylesByUrl,
importStyle: styleManager.importStyle,
importManyStyles: styleManager.importMany,
installStyle: styleManager.installStyle,
styleExists: styleManager.styleExists,
toggleStyle: styleManager.toggleStyle,
addInclusion: styleManager.addInclusion,
removeInclusion: styleManager.removeInclusion,
addExclusion: styleManager.addExclusion,
removeExclusion: styleManager.removeExclusion,
/** @returns {string} */
getTabUrlPrefix() {
const {url} = this.sender.tab;
if (url.startsWith(URLS.ownOrigin)) {
return 'stylus';
}
return url.match(/^([\w-]+:\/+[^/#]+)/)[1];
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
download(msg) {
delete msg.method;
return download(msg.url, msg);
},
parseCss({code}) {
return backgroundWorker.parseMozFormat({code});
},
getPrefs: () => prefs.values,
setPref: (key, value) => prefs.set(key, value),
openEditor,
/* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent when the tab is ready,
which is needed in the popup, otherwise another extension could force the tab to open in foreground
thus auto-closing the popup (in Chrome at least) and preventing the sendMessage code from running */
async openURL(opts) {
const tab = await openURL(opts);
if (opts.message) {
await onTabReady(tab);
await msg.sendTab(tab.id, opts.message);
}
return tab;
function onTabReady(tab) {
return new Promise((resolve, reject) =>
setTimeout(function ping(numTries = 10, delay = 100) {
msg.sendTab(tab.id, {method: 'ping'})
.catch(() => false)
.then(pong => pong
? resolve(tab)
: numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) ||
reject('timeout'));
}));
}
},
optionsCustomizeHotkeys() {
return browserCommands.openOptions()
.then(() => new Promise(resolve => setTimeout(resolve, 500)))
.then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'}));
},
syncStart: sync.start,
syncStop: sync.stop,
syncNow: sync.syncNow,
getSyncStatus: sync.getStatus,
syncLogin: sync.login,
openManage,
});
// *************************************************************************
// register all listeners
msg.on(onRuntimeMessage);
// tell apply.js to refresh styles for non-committed navigation
navigatorUtil.onUrlChange(({tabId, frameId}, type) => {
if (type !== 'committed') {
msg.sendTab(tabId, {method: 'urlChanged'}, {frameId})
.catch(msg.ignoreError);
}
});
if (FIREFOX) {
// FF misses some about:blank iframes so we inject our content script explicitly
navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, {
url: [
{urlEquals: 'about:blank'},
],
});
}
if (chrome.contextMenus) {
chrome.contextMenus.onClicked.addListener((info, tab) =>
contextMenus[info.menuItemId].click(info, tab));
}
if (chrome.commands) {
// Not available in Firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1240350
chrome.commands.onCommand.addListener(command => browserCommands[command]());
}
// *************************************************************************
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason !== 'update') return;
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'));
if (del.length) chromeLocal.remove(del);
}, 15e3);
}
});
// *************************************************************************
// context menus
contextMenus = {
'show-badge': {
title: 'menuShowBadge',
click: info => prefs.set(info.menuItemId, info.checked),
},
'disableAll': {
title: 'disableAllStyles',
click: browserCommands.styleDisableAll,
},
'open-manager': {
title: 'openStylesManager',
click: browserCommands.openManage,
},
'open-options': {
title: 'openOptions',
click: browserCommands.openOptions,
},
'reload': {
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
title: 'reload',
click: browserCommands.reload,
},
'editor.contextDelete': {
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
title: 'editDeleteText',
type: 'normal',
contexts: ['editable'],
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
click: (info, tab) => {
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
.catch(msg.ignoreError);
},
},
};
async function createContextMenus(ids) {
for (const id of ids) {
let item = contextMenus[id];
if (item.presentIf && !await item.presentIf()) {
continue;
}
item = Object.assign({id}, item);
delete item.presentIf;
item.title = chrome.i18n.getMessage(item.title);
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
item.type = 'checkbox';
item.checked = prefs.get(id);
}
if (!item.contexts) {
item.contexts = ['browser_action'];
}
delete item.click;
chrome.contextMenus.create(item, ignoreChromeError);
}
}
if (chrome.contextMenus) {
// "Delete" item in context menu for browsers that don't have it
if (CHROME &&
// looking at the end of UA string
/(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) &&
// skip forks with Flash as those are likely to have the menu e.g. CentBrowser
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) {
prefs.defaults['editor.contextDelete'] = true;
}
// circumvent the bug with disabling check marks in Chrome 62-64
const toggleCheckmark = CHROME >= 62 && CHROME <= 64 ?
(id => chrome.contextMenus.remove(id, () => createContextMenus([id]) + ignoreChromeError())) :
((id, checked) => chrome.contextMenus.update(id, {checked}, ignoreChromeError));
const togglePresence = (id, checked) => {
if (checked) {
createContextMenus([id]);
} else {
chrome.contextMenus.remove(id, ignoreChromeError);
}
};
const keys = Object.keys(contextMenus);
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark);
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && id in prefs.defaults), togglePresence);
createContextMenus(keys);
}
// reinject content scripts when the extension is reloaded/updated. Firefox
// would handle this automatically.
if (!FIREFOX) {
setTimeout(contentScripts.injectToAllTabs, 0);
}
// register hotkeys
if (FIREFOX && browser.commands && browser.commands.update) {
const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, (name, value) => {
try {
name = name.split('.')[1];
if (value.trim()) {
browser.commands.update({name, shortcut: value});
} else {
browser.commands.reset(name);
}
} catch (e) {}
});
}
msg.broadcast({method: 'backgroundReady'});
function webNavIframeHelperFF({tabId, frameId}) {
if (!frameId) return;
msg.sendTab(tabId, {method: 'ping'}, {frameId})
.catch(() => false)
.then(pong => {
if (pong) return;
// insert apply.js to iframe
const files = chrome.runtime.getManifest().content_scripts[0].js;
for (const file of files) {
chrome.tabs.executeScript(tabId, {
frameId,
file,
matchAboutBlank: true,
}, ignoreChromeError);
}
});
}
function onRuntimeMessage(msg, sender) {
if (msg.method !== 'invokeAPI') {
return;
}
const fn = window.API_METHODS[msg.name];
if (!fn) {
throw new Error(`unknown API: ${msg.name}`);
}
const res = fn.apply({msg, sender}, msg.args);
return res === undefined ? null : res;
}
function openEditor(params) {
/* Open the editor. Activate if it is already opened
params: {
id?: Number,
domain?: String,
'url-prefix'?: String
}
/**
* Opens the editor or activates an existing tab
* @param {{
id?: number
domain?: string
'url-prefix'?: string
}} params
* @returns {Promise<chrome.tabs.Tab>}
*/
async openEditor(params) {
const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params);
return openURL({
const wnd = prefs.get('openEditInWindow');
const wndPos = wnd && prefs.get('windowPosition');
const wndBase = wnd && prefs.get('openEditInWindow.popup') ? {type: 'popup'} : {};
const ffBug = wnd && FIREFOX; // https://bugzil.la/1271047
const tab = await openURL({
url: `${u}`,
currentWindow: null,
newWindow: prefs.get('openEditInWindow') && Object.assign({},
prefs.get('openEditInWindow.popup') && {type: 'popup'},
prefs.get('windowPosition')),
newWindow: Object.assign(wndBase, !ffBug && wndPos),
});
}
if (ffBug) await browser.windows.update(tab.windowId, wndPos);
return tab;
},
async function openManage({options = false, search, searchMode} = {}) {
/** @returns {Promise<chrome.tabs.Tab>} */
async openManage({options = false, search, searchMode} = {}) {
let url = chrome.runtime.getURL('manage.html');
if (search) {
url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`;
@ -333,5 +90,92 @@ async function openManage({options = false, search, searchMode} = {}) {
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url});
: browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window
},
/**
* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent
* when the tab is ready, which is needed in the popup, otherwise another
* extension could force the tab to open in foreground thus auto-closing the
* popup (in Chrome at least) and preventing the sendMessage code from running
* @returns {Promise<chrome.tabs.Tab>}
*/
async openURL(opts) {
const tab = await openURL(opts);
if (opts.message) {
await onTabReady(tab);
await msg.sendTab(tab.id, opts.message);
}
return tab;
function onTabReady(tab) {
return new Promise((resolve, reject) =>
setTimeout(function ping(numTries = 10, delay = 100) {
msg.sendTab(tab.id, {method: 'ping'})
.catch(() => false)
.then(pong => pong
? resolve(tab)
: numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) ||
reject('timeout'));
}));
}
},
prefs: {
getValues: () => prefs.__values, // will be deepCopy'd by apiHandler
set: prefs.set,
},
});
//#endregion
//#region Events
const browserCommands = {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
reload: () => chrome.runtime.reload(),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
};
if (chrome.commands) {
chrome.commands.onCommand.addListener(id => browserCommands[id]());
}
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason === 'update') {
const [a, b, c] = (previousVersion || '').split('.');
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
require(['/background/remove-unused-storage']);
}
}
});
msg.on((msg, sender) => {
if (msg.method === 'invokeAPI') {
let res = msg.path.reduce((res, name) => res && res[name], API);
if (!res) throw new Error(`Unknown API.${msg.path.join('.')}`);
res = res.apply({msg, sender}, msg.args);
return res === undefined ? null : res;
}
});
//#endregion
Promise.all([
bgReady.styles,
/* These are loaded conditionally.
Each item uses `require` individually so IDE can jump to the source and track usage. */
FIREFOX &&
require(['/background/style-via-api']),
FIREFOX && ((browser.commands || {}).update) &&
require(['/background/browser-cmd-hotkeys']),
!FIREFOX &&
require(['/background/content-scripts']),
chrome.contextMenus &&
require(['/background/context-menus']),
]).then(() => {
bgReady._resolveAll();
msg.isBgReady = true;
msg.broadcast({method: 'backgroundReady'});
});

View File

@ -0,0 +1,22 @@
/* global prefs */
'use strict';
/*
Registers hotkeys in FF
*/
(() => {
const hotkeyPrefs = prefs.knownKeys.filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true});
async function updateHotkey(name, value) {
try {
name = name.split('.')[1];
if (value.trim()) {
await browser.commands.update({name, shortcut: value});
} else {
await browser.commands.reset(name);
}
} catch (e) {}
}
})();

31
background/common.js Normal file
View File

@ -0,0 +1,31 @@
/* global API */// msg.js
'use strict';
/**
* Common stuff that's loaded first so it's immediately available to all background scripts
*/
/* exported
addAPI
bgReady
compareRevision
*/
const bgReady = {};
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
bgReady.all = new Promise(r => (bgReady._resolveAll = r));
function addAPI(methods) {
for (const [key, val] of Object.entries(methods)) {
const old = API[key];
if (old && Object.prototype.toString.call(old) === '[object Object]') {
Object.assign(old, val);
} else {
API[key] = val;
}
}
}
function compareRevision(rev1, rev2) {
return rev1 - rev2;
}

View File

@ -1,8 +1,14 @@
/* global msg ignoreChromeError URLS */
/* exported contentScripts */
/* global bgReady */// common.js
/* global msg */
/* global URLS ignoreChromeError */// toolbox.js
'use strict';
const contentScripts = (() => {
/*
Reinject content scripts when the extension is reloaded/updated.
Not used in Firefox as it reinjects automatically.
*/
bgReady.all.then(() => {
const NTP = 'chrome://newtab/';
const ALL_URLS = '<all_urls>';
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
@ -18,21 +24,7 @@ const contentScripts = (() => {
const busyTabs = new Set();
let busyTabsTimer;
// expose version on greasyfork/sleazyfork 1) info page and 2) code page
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-greasyfork.js',
runAt: 'document_start',
});
}, {
url: [
{hostEquals: 'greasyfork.org', urlMatches},
{hostEquals: 'sleazyfork.org', urlMatches},
],
});
return {injectToTab, injectToAllTabs};
setTimeout(injectToAllTabs);
function injectToTab({url, tabId, frameId = null}) {
for (const script of SCRIPTS) {
@ -122,4 +114,4 @@ const contentScripts = (() => {
function onBusyTabRemoved(tabId) {
trackBusyTab(tabId, false);
}
})();
});

101
background/context-menus.js Normal file
View File

@ -0,0 +1,101 @@
/* global browserCommands */// background.js
/* global msg */
/* global prefs */
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
'use strict';
(() => {
const contextMenus = {
'show-badge': {
title: 'menuShowBadge',
click: info => prefs.set(info.menuItemId, info.checked),
},
'disableAll': {
title: 'disableAllStyles',
click: browserCommands.styleDisableAll,
},
'open-manager': {
title: 'openStylesManager',
click: browserCommands.openManage,
},
'open-options': {
title: 'openOptions',
click: browserCommands.openOptions,
},
'reload': {
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
title: 'reload',
click: browserCommands.reload,
},
'editor.contextDelete': {
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
title: 'editDeleteText',
type: 'normal',
contexts: ['editable'],
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
click: (info, tab) => {
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
.catch(msg.ignoreError);
},
},
};
// "Delete" item in context menu for browsers that don't have it
if (CHROME &&
// looking at the end of UA string
/(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) &&
// skip forks with Flash as those are likely to have the menu e.g. CentBrowser
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) {
prefs.__defaults['editor.contextDelete'] = true;
}
const keys = Object.keys(contextMenus);
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'),
CHROME >= 62 && CHROME <= 64 ? toggleCheckmarkBugged : toggleCheckmark);
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)),
togglePresence);
createContextMenus(keys);
chrome.contextMenus.onClicked.addListener((info, tab) =>
contextMenus[info.menuItemId].click(info, tab));
async function createContextMenus(ids) {
for (const id of ids) {
let item = contextMenus[id];
if (item.presentIf && !await item.presentIf()) {
continue;
}
item = Object.assign({id}, item);
delete item.presentIf;
item.title = chrome.i18n.getMessage(item.title);
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
item.type = 'checkbox';
item.checked = prefs.get(id);
}
if (!item.contexts) {
item.contexts = ['browser_action'];
}
delete item.click;
chrome.contextMenus.create(item, ignoreChromeError);
}
}
function toggleCheckmark(id, checked) {
chrome.contextMenus.update(id, {checked}, ignoreChromeError);
}
/** Circumvents the bug with disabling check marks in Chrome 62-64 */
async function toggleCheckmarkBugged(id) {
await browser.contextMenus.remove(id).catch(ignoreChromeError);
createContextMenus([id]);
}
function togglePresence(id, checked) {
if (checked) {
createContextMenus([id]);
} else {
chrome.contextMenus.remove(id, ignoreChromeError);
}
}
})();

View File

@ -1,60 +1,56 @@
/* global chromeLocal */
/* exported createChromeStorageDB */
/* global chromeLocal */// storage-util.js
'use strict';
/* exported createChromeStorageDB */
function createChromeStorageDB() {
let INC;
const PREFIX = 'style-';
const METHODS = {
delete(id) {
return chromeLocal.remove(PREFIX + id);
},
// FIXME: we don't use this method at all. Should we remove this?
get: id => chromeLocal.getValue(PREFIX + id),
put: obj =>
// FIXME: should we clone the object?
Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++})))
.then(() => chromeLocal.setValue(PREFIX + obj.id, obj))
.then(() => obj.id),
putMany: items => prepareInc()
.then(() =>
chromeLocal.set(items.reduce((data, item) => {
if (!item.id) item.id = INC++;
get(id) {
return chromeLocal.getValue(PREFIX + id);
},
async getAll() {
const all = await chromeLocal.get();
if (!INC) prepareInc(all);
return Object.entries(all)
.map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
.filter(Boolean);
},
async put(item) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
await chromeLocal.setValue(PREFIX + item.id, item);
return item.id;
},
async putMany(items) {
const data = {};
for (const item of items) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
data[PREFIX + item.id] = item;
return data;
}, {})))
.then(() => items.map(i => i.id)),
delete: id => chromeLocal.remove(PREFIX + id),
getAll: () => chromeLocal.get()
.then(result => {
const output = [];
for (const key in result) {
if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) {
output.push(result[key]);
}
}
return output;
}),
await chromeLocal.set(data);
return items.map(_ => _.id);
},
};
return {exec};
function exec(method, ...args) {
if (METHODS[method]) {
return METHODS[method](...args)
.then(result => {
if (method === 'putMany' && result.map) {
return result.map(r => ({target: {result: r}}));
}
return {target: {result}};
});
}
return Promise.reject(new Error(`unknown DB method ${method}`));
}
function prepareInc() {
if (INC) return Promise.resolve();
return chromeLocal.get().then(result => {
async function prepareInc(data) {
INC = 1;
for (const key in result) {
for (const key in data || await chromeLocal.get()) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
@ -62,6 +58,9 @@ function createChromeStorageDB() {
}
}
}
});
}
return function dbExecChromeStorage(method, ...args) {
return METHODS[method](...args);
};
}

View File

@ -1,14 +1,15 @@
/* global chromeLocal workerUtil createChromeStorageDB */
/* exported db */
/* global chromeLocal */// storage-util.js
/* global cloneError */// worker-util.js
'use strict';
/*
Initialize a database. There are some problems using IndexedDB in Firefox:
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
*/
'use strict';
/* exported db */
const db = (() => {
const DATABASE = 'stylish';
const STORE = 'styles';
@ -33,32 +34,25 @@ const db = (() => {
case false: break;
default: await testDB();
}
return useIndexedDB();
chromeLocal.setValue(FALLBACK, false);
return dbExecIndexedDB;
}
async function testDB() {
let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1);
// throws if result is null
e = e.target.result[0];
const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
await dbExecIndexedDB('put', {id});
e = await dbExecIndexedDB('get', id);
// throws if result or id is null
await dbExecIndexedDB('delete', e.target.result.id);
const e = await dbExecIndexedDB('get', id);
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
}
function useChromeStorage(err) {
async function useChromeStorage(err) {
chromeLocal.setValue(FALLBACK, true);
if (err) {
chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err));
console.warn('Failed to access indexedDB. Switched to storage API.', err);
}
return createChromeStorageDB().exec;
}
function useIndexedDB() {
chromeLocal.setValue(FALLBACK, false);
return dbExecIndexedDB;
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
return createChromeStorageDB();
}
async function dbExecIndexedDB(method, ...args) {
@ -70,8 +64,9 @@ const db = (() => {
function storeRequest(store, method, ...args) {
return new Promise((resolve, reject) => {
/** @type {IDBRequest} */
const request = store[method](...args);
request.onsuccess = resolve;
request.onsuccess = () => resolve(request.result);
request.onerror = reject;
});
}

View File

@ -1,48 +1,36 @@
/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API_METHODS */
/* exported iconManager */
/* global API */// msg.js
/* global addAPI bgReady */// common.js
/* global prefs */
/* global tabMan */
/* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js
'use strict';
const iconManager = (() => {
(() => {
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
const staleBadges = new Set();
const imageDataCache = new Map();
// https://github.com/openstyles/stylus/issues/335
let hasCanvas = loadImage(`/images/icon/${ICON_SIZES[0]}.png`)
.then(({data}) => (hasCanvas = data.some(b => b !== 255)));
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor));
prefs.subscribe([
'show-badge',
], () => debounce(refreshAllIconsBadgeText));
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons));
prefs.initializing.then(() => {
refreshIconBadgeColor();
refreshAllIconsBadgeText();
refreshAllIcons();
});
Object.assign(API_METHODS, {
/** @param {(number|string)[]} styleIds
* @param {boolean} [lazyBadge=false] preventing flicker during page load */
addAPI(/** @namespace API */ {
/**
* @param {(number|string)[]} styleIds
* @param {boolean} [lazyBadge=false] preventing flicker during page load
*/
updateIconBadge(styleIds, {lazyBadge} = {}) {
// FIXME: in some cases, we only have to redraw the badge. is it worth a optimization?
const {frameId, tab: {id: tabId}} = this.sender;
const value = styleIds.length ? styleIds.map(Number) : undefined;
tabManager.set(tabId, 'styleIds', frameId, value);
tabMan.set(tabId, 'styleIds', frameId, value);
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
staleBadges.add(tabId);
if (!frameId) refreshIcon(tabId, true);
},
});
navigatorUtil.onCommitted(({tabId, frameId}) => {
if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
if (!frameId) tabMan.set(tabId, 'styleIds', undefined);
});
chrome.runtime.onConnect.addListener(port => {
@ -51,15 +39,30 @@ const iconManager = (() => {
}
});
bgReady.all.then(() => {
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor), {runNow: true});
prefs.subscribe([
'show-badge',
], () => debounce(refreshAllIconsBadgeText), {runNow: true});
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons), {runNow: true});
});
function onPortDisconnected({sender}) {
if (tabManager.get(sender.tab.id, 'styleIds')) {
API_METHODS.updateIconBadge.call({sender}, [], {lazyBadge: true});
if (tabMan.get(sender.tab.id, 'styleIds')) {
API.updateIconBadge.call({sender}, [], {lazyBadge: true});
}
}
function refreshIconBadgeText(tabId) {
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
iconUtil.setBadgeText({tabId, text});
setBadgeText({tabId, text});
}
function getIconName(hasStyles = false) {
@ -69,15 +72,15 @@ const iconManager = (() => {
}
function refreshIcon(tabId, force = false) {
const oldIcon = tabManager.get(tabId, 'icon');
const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0));
const oldIcon = tabMan.get(tabId, 'icon');
const newIcon = getIconName(tabMan.get(tabId, 'styleIds', 0));
// (changing the icon only for the main page, frameId = 0)
if (!force && oldIcon === newIcon) {
return;
}
tabManager.set(tabId, 'icon', newIcon);
iconUtil.setIcon({
tabMan.set(tabId, 'icon', newIcon);
setIcon({
path: getIconPath(newIcon),
tabId,
});
@ -96,33 +99,55 @@ const iconManager = (() => {
/** @return {number | ''} */
function getStyleCount(tabId) {
const allIds = new Set();
const data = tabManager.get(tabId, 'styleIds') || {};
const data = tabMan.get(tabId, 'styleIds') || {};
Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id)));
return allIds.size || '';
}
// Caches imageData for icon paths
async function loadImage(url) {
const {OffscreenCanvas} = self.createImageBitmap && self || {};
const img = OffscreenCanvas
? await createImageBitmap(await (await fetch(url)).blob())
: await new Promise((resolve, reject) =>
Object.assign(new Image(), {
src: url,
onload: e => resolve(e.target),
onerror: reject,
}));
const {width: w, height: h} = img;
const canvas = OffscreenCanvas
? new OffscreenCanvas(w, h)
: Object.assign(document.createElement('canvas'), {width: w, height: h});
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
const result = ctx.getImageData(0, 0, w, h);
imageDataCache.set(url, result);
return result;
}
function refreshGlobalIcon() {
iconUtil.setIcon({
setIcon({
path: getIconPath(getIconName()),
});
}
function refreshIconBadgeColor() {
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
iconUtil.setBadgeBackgroundColor({
setBadgeBackgroundColor({
color,
});
}
function refreshAllIcons() {
for (const tabId of tabManager.list()) {
for (const tabId of tabMan.list()) {
refreshIcon(tabId);
}
refreshGlobalIcon();
}
function refreshAllIconsBadgeText() {
for (const tabId of tabManager.list()) {
for (const tabId of tabMan.list()) {
refreshIconBadgeText(tabId);
}
}
@ -133,4 +158,40 @@ const iconManager = (() => {
}
staleBadges.clear();
}
function safeCall(method, data) {
const {browserAction = {}} = chrome;
const fn = browserAction[method];
if (fn) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
fn.call(browserAction, data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
fn.call(browserAction, data);
}
}
}
/** @param {chrome.browserAction.TabIconDetails} data */
async function setIcon(data) {
if (hasCanvas === true || await hasCanvas) {
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
data.imageData[key] = imageDataCache.get(url) || await loadImage(url);
}
delete data.path;
}
safeCall('setIcon', data);
}
/** @param {chrome.browserAction.BadgeTextDetails} data */
function setBadgeText(data) {
safeCall('setBadgeText', data);
}
/** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */
function setBadgeBackgroundColor(data) {
safeCall('setBadgeBackgroundColor', data);
}
})();

View File

@ -1,91 +0,0 @@
/* global ignoreChromeError */
/* exported iconUtil */
'use strict';
const iconUtil = (() => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// https://github.com/openstyles/stylus/issues/335
let noCanvas;
const imageDataCache = new Map();
// test if canvas is usable
const canvasReady = loadImage('/images/icon/16.png')
.then(imageData => {
noCanvas = imageData.data.every(b => b === 255);
});
return extendNative({
/*
Cache imageData for paths
*/
setIcon,
setBadgeText,
});
function loadImage(url) {
let result = imageDataCache.get(url);
if (!result) {
result = new Promise((resolve, reject) => {
const img = new Image();
img.src = url;
img.onload = () => {
const w = canvas.width = img.width;
const h = canvas.height = img.height;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
resolve(ctx.getImageData(0, 0, w, h));
};
img.onerror = reject;
});
imageDataCache.set(url, result);
}
return result;
}
function setIcon(data) {
canvasReady.then(() => {
if (noCanvas) {
chrome.browserAction.setIcon(data, ignoreChromeError);
return;
}
const pending = [];
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
pending.push(loadImage(url)
.then(imageData => {
data.imageData[key] = imageData;
}));
}
Promise.all(pending).then(() => {
delete data.path;
chrome.browserAction.setIcon(data, ignoreChromeError);
});
});
}
function setBadgeText(data) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
chrome.browserAction.setBadgeText(data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
chrome.browserAction.setBadgeText(data);
}
}
function extendNative(target) {
return new Proxy(target, {
get: (target, prop) => {
// FIXME: do we really need this?
if (!chrome.browserAction ||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
return () => {};
}
if (target[prop]) {
return target[prop];
}
return chrome.browserAction[prop].bind(chrome.browserAction);
},
});
}
})();

View File

@ -0,0 +1,82 @@
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
/* global bgReady */// common.js
/* global msg */
'use strict';
/* exported navMan */
const navMan = (() => {
const listeners = new Set();
chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed'));
chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history'));
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash'));
return {
/** @param {function(data: Object, type: ('committed'|'history'|'hash'))} fn */
onUrlChange(fn) {
listeners.add(fn);
},
};
/** @this {string} type */
async function onNavigation(data) {
if (CHROME &&
URLS.chromeProtectsNTP &&
data.url.startsWith('https://www.google.') &&
data.url.includes('/_/chrome/newtab?')) {
// Modern Chrome switched to WebUI NTP so this is obsolete, but there may be exceptions
// TODO: investigate, and maybe use a separate listener for CHROME <= ver
const tab = await browser.tabs.get(data.tabId);
const url = tab.pendingUrl || tab.url;
if (url === 'chrome://newtab/') {
data.url = url;
}
}
listeners.forEach(fn => fn(data, this));
}
/** @this {string} type */
function onFakeNavigation(data) {
onNavigation.call(this, data);
msg.sendTab(data.tabId, {method: 'urlChanged'}, {frameId: data.frameId})
.catch(msg.ignoreError);
}
})();
bgReady.all.then(() => {
/*
* Expose style version on greasyfork/sleazyfork 1) info page and 2) code page
* Not using manifest.json as adding a content script disables the extension on update.
*/
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-greasyfork.js',
runAt: 'document_start',
});
}, {
url: [
{hostEquals: 'greasyfork.org', urlMatches},
{hostEquals: 'sleazyfork.org', urlMatches},
],
});
/*
* FF misses some about:blank iframes so we inject our content script explicitly
*/
if (FIREFOX) {
chrome.webNavigation.onDOMContentLoaded.addListener(async ({tabId, frameId}) => {
if (frameId &&
!await msg.sendTab(tabId, {method: 'ping'}, {frameId}).catch(ignoreChromeError)) {
for (const file of chrome.runtime.getManifest().content_scripts[0].js) {
chrome.tabs.executeScript(tabId, {
frameId,
file,
matchAboutBlank: true,
}, ignoreChromeError);
}
}
}, {
url: [{urlEquals: 'about:blank'}],
});
}
});

View File

@ -1,75 +0,0 @@
/* global CHROME URLS */
/* exported navigatorUtil */
'use strict';
const navigatorUtil = (() => {
const handler = {
urlChange: null,
};
return extendNative({onUrlChange});
function onUrlChange(fn) {
initUrlChange();
handler.urlChange.push(fn);
}
function initUrlChange() {
if (handler.urlChange) {
return;
}
handler.urlChange = [];
chrome.webNavigation.onCommitted.addListener(data =>
fixNTPUrl(data)
.then(() => executeCallbacks(handler.urlChange, data, 'committed'))
.catch(console.error)
);
chrome.webNavigation.onHistoryStateUpdated.addListener(data =>
fixNTPUrl(data)
.then(() => executeCallbacks(handler.urlChange, data, 'historyStateUpdated'))
.catch(console.error)
);
chrome.webNavigation.onReferenceFragmentUpdated.addListener(data =>
fixNTPUrl(data)
.then(() => executeCallbacks(handler.urlChange, data, 'referenceFragmentUpdated'))
.catch(console.error)
);
}
function fixNTPUrl(data) {
if (
!CHROME ||
!URLS.chromeProtectsNTP ||
!data.url.startsWith('https://www.google.') ||
!data.url.includes('/_/chrome/newtab?')
) {
return Promise.resolve();
}
return browser.tabs.get(data.tabId)
.then(tab => {
const url = tab.pendingUrl || tab.url;
if (url === 'chrome://newtab/') {
data.url = url;
}
});
}
function executeCallbacks(callbacks, data, type) {
for (const cb of callbacks) {
cb(data, type);
}
}
function extendNative(target) {
return new Proxy(target, {
get: (target, prop) => {
if (target[prop]) {
return target[prop];
}
return chrome.webNavigation[prop].addListener.bind(chrome.webNavigation[prop]);
},
});
}
})();

View File

@ -1,5 +1,8 @@
/* global addAPI */// common.js
'use strict';
/* CURRENTLY UNUSED */
(() => {
// begin:nanographql - Tiny graphQL client library
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
@ -25,10 +28,9 @@
// end:nanographql
const api = 'https://api.openusercss.org';
const doQuery = ({id}, queryString) => {
const doQuery = async ({id}, queryString) => {
const query = gql(queryString);
return fetch(api, {
return (await fetch(api, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
@ -36,11 +38,10 @@
body: query({
id,
}),
})
.then(res => res.json());
})).json();
};
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
addAPI(/** @namespace- API */ { // TODO: remove "-" when this is implemented
/**
* This function can be used to retrieve a theme object from the
* GraphQL API, set above

View File

@ -0,0 +1,15 @@
/* global chromeLocal */// storage-util.js
'use strict';
// Removing unused stuff from storage on extension update
// TODO: delete this by the middle of 2021
try {
localStorage.clear();
} catch (e) {}
setTimeout(async () => {
const del = Object.keys(await chromeLocal.get())
.filter(key => key.startsWith('usoSearchCache'));
if (del.length) chromeLocal.remove(del);
}, 15e3);

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,6 @@
/* global
API_METHODS
debounce
stringAsRegExp
styleManager
tryRegExp
usercss
*/
/* global API */// msg.js
/* global URLS debounce stringAsRegExp tryRegExp */// toolbox.js
/* global addAPI */// common.js
'use strict';
(() => {
@ -15,12 +10,12 @@
const extractMeta = style =>
style.usercssData
? (style.sourceCode.match(usercss.RX_META) || [''])[0]
? (style.sourceCode.match(URLS.rxMETA) || [''])[0]
: null;
const stripMeta = style =>
style.usercssData
? style.sourceCode.replace(usercss.RX_META, '')
? style.sourceCode.replace(URLS.rxMETA, '')
: null;
const MODES = Object.assign(Object.create(null), {
@ -43,6 +38,8 @@
!style.usercssData && MODES.code(style, test),
});
addAPI(/** @namespace API */ {
styles: {
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
@ -50,16 +47,16 @@
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API_METHODS.searchDB = async ({query, mode = 'all', ids}) => {
async searchDB({query, mode = 'all', ids}) {
let res = [];
if (mode === 'url' && query) {
res = (await styleManager.getStylesByUrl(query)).map(r => r.data.id);
res = (await API.styles.getByUrl(query)).map(r => r.style.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())
const test = rx ? rx.test.bind(rx) : createTester(query);
res = (await API.styles.getAll())
.filter(style =>
(!ids || ids.includes(style.id)) &&
(!query || modeHandler(style, test)))
@ -67,9 +64,11 @@
if (cache.size) debounce(clearCache, 60e3);
}
return res;
};
},
},
});
function makeTester(query) {
function createTester(query) {
const flags = `u${lower(query) === query ? 'i' : ''}`;
const words = query
.split(/(".*?")|\s+/)

View File

@ -1,7 +1,14 @@
/* global API_METHODS styleManager CHROME prefs */
/* global API */// msg.js
/* global addAPI */// common.js
/* global isEmptyObj */// toolbox.js
/* global prefs */
'use strict';
API_METHODS.styleViaAPI = !CHROME && (() => {
/**
* Uses chrome.tabs.insertCSS
*/
(() => {
const ACTIONS = {
styleApply,
styleDeleted,
@ -11,25 +18,25 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
prefChanged,
updateCount,
};
const NOP = Promise.resolve(new Error('NOP'));
const NOP = new Error('NOP');
const onError = () => {};
/* <tabId>: Object
<frameId>: Object
url: String, non-enumerable
<styleId>: Array of strings
section code */
const cache = new Map();
let observingTabs = false;
return function (request) {
const action = ACTIONS[request.method];
return !action ? NOP :
action(request, this.sender)
.catch(onError)
.then(maybeToggleObserver);
};
addAPI(/** @namespace API */ {
async styleViaAPI(request) {
try {
const fn = ACTIONS[request.method];
return fn ? fn(request, this.sender) : NOP;
} catch (e) {}
maybeToggleObserver();
},
});
function updateCount(request, sender) {
const {tab, frameId} = sender;
@ -37,7 +44,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
throw new Error('we do not count styles for frames');
}
const {frameStyles} = getCachedData(tab.id, frameId);
API_METHODS.updateIconBadge.call({sender}, Object.keys(frameStyles));
API.updateIconBadge.call({sender}, Object.keys(frameStyles));
}
function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) {
@ -48,7 +55,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
if (id === null && !ignoreUrlCheck && frameStyles.url === url) {
return NOP;
}
return styleManager.getSectionsByUrl(url, id).then(sections => {
return API.styles.getSectionsByUrl(url, id).then(sections => {
const tasks = [];
for (const section of Object.values(sections)) {
const styleId = section.id;
@ -125,7 +132,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
}
const {tab, frameId} = sender;
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
if (isEmpty(frameStyles)) {
if (isEmptyObj(frameStyles)) {
return NOP;
}
removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
@ -162,7 +169,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
const tabFrames = cache.get(tabId);
if (tabFrames && frameId in tabFrames) {
delete tabFrames[frameId];
if (isEmpty(tabFrames)) {
if (isEmptyObj(tabFrames)) {
onTabRemoved(tabId);
}
}
@ -178,9 +185,9 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
}
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
if (isEmpty(frameStyles)) {
if (isEmptyObj(frameStyles)) {
delete tabFrames[frameId];
if (isEmpty(tabFrames)) {
if (isEmptyObj(tabFrames)) {
cache.delete(tabId);
}
return true;
@ -223,11 +230,4 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true})
.catch(onError);
}
function isEmpty(obj) {
for (const k in obj) {
return false;
}
return true;
}
})();

View File

@ -1,101 +1,103 @@
/* global API CHROME prefs */
/* global API */// msg.js
/* global CHROME */// toolbox.js
/* global 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('/');
/** @type {Object<string,StylesToPass>} */
const stylesToPass = {};
const enabled = {};
const state = {};
const injectedCode = CHROME && `${data => {
if (self.INJECTED !== 1) { // storing data only if apply.js hasn't run yet
window[Symbol.for('styles')] = data;
}
}}`;
await prefs.initializing;
prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true});
toggle();
prefs.subscribe([idXHR, idOFF, idCSP], toggle);
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) {
const off = prefs.get(idOFF);
const csp = prefs.get(idCSP) && !off;
const xhr = prefs.get(idXHR) && !off;
if (xhr === state.xhr && csp === state.csp && off === state.off) {
return;
}
// Need to unregister first so that the optional EXTRA_HEADERS is properly registered
const reqFilter = {
urls: ['*://*/*'],
types: ['main_frame', 'sub_frame'],
};
chrome.webNavigation.onCommitted.removeListener(injectData);
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders);
if (xhr || csp) {
const reqFilter = {
urls: ['<all_urls>'],
types: ['main_frame', 'sub_frame'],
};
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
// We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
'blocking',
'responseHeaders',
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
].filter(Boolean));
}
if (enabled.xhr !== xhr) {
enabled.xhr = xhr;
toggleEarlyInjection();
if (CHROME ? !off : xhr || csp) {
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
}
enabled.csp = csp;
if (CHROME && !off) {
chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]});
}
/** 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,
}),
],
}]);
}
});
state.csp = csp;
state.off = off;
state.xhr = xhr;
}
/** @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);
async function prepareStyles(req) {
const sections = await API.styles.getSectionsByUrl(req.url);
stylesToPass[req2key(req)] = /** @namespace StylesToPass */ {
blobId: '',
str: JSON.stringify(sections),
timer: setTimeout(cleanUp, 600e3, req),
};
}
function injectData(req) {
const data = stylesToPass[req2key(req)];
if (data && !data.injected) {
data.injected = true;
chrome.tabs.executeScript(req.tabId, {
frameId: req.frameId,
runAt: 'document_start',
code: `(${injectedCode})(${data.str})`,
});
if (!state.xhr) cleanUp(req);
}
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function modifyHeaders(req) {
const {responseHeaders} = req;
const id = stylesToPass[req.requestId];
if (!id) {
const data = stylesToPass[req2key(req)];
if (!data || data.str === '{}') {
cleanUp(req);
return;
}
if (enabled.xhr) {
if (state.xhr) {
data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length);
responseHeaders.push({
name: 'Set-Cookie',
value: `${chrome.runtime.id}=${id}`,
value: `${chrome.runtime.id}=${data.blobId}`,
});
}
const csp = enabled.csp &&
const csp = state.csp &&
responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy');
if (csp) {
patchCsp(csp);
}
if (enabled.xhr || csp) {
if (state.xhr || csp) {
return {responseHeaders};
}
}
@ -111,7 +113,7 @@ CHROME && (async () => {
patchCspSrc(src, 'img-src', 'data:', '*');
patchCspSrc(src, 'font-src', 'data:', '*');
// Allow our DOM styles
patchCspSrc(src, 'style-src', '\'unsafe-inline\'');
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');
@ -132,9 +134,19 @@ CHROME && (async () => {
}
}
function cleanUp(key) {
const blobId = stylesToPass[key];
function cleanUp(req) {
const key = req2key(req);
const data = stylesToPass[key];
if (data) {
delete stylesToPass[key];
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
clearTimeout(data.timer);
if (data.blobId) {
URL.revokeObjectURL(blobUrlPrefix + data.blobId);
}
}
}
function req2key(req) {
return req.tabId + ':' + req.frameId;
}
})();

225
background/sync-manager.js Normal file
View File

@ -0,0 +1,225 @@
/* global API msg */// msg.js
/* global chromeLocal */// storage-util.js
/* global compareRevision */// common.js
/* global prefs */
/* global tokenMan */
'use strict';
const syncMan = (() => {
//#region Init
const SYNC_DELAY = 1; // minutes
const SYNC_INTERVAL = 30; // minutes
const STATES = Object.freeze({
connected: 'connected',
connecting: 'connecting',
disconnected: 'disconnected',
disconnecting: 'disconnecting',
});
const STORAGE_KEY = 'sync/state/';
const status = /** @namespace SyncManager.Status */ {
STATES,
state: STATES.disconnected,
syncing: false,
progress: null,
currentDriveName: null,
errorMessage: null,
login: false,
};
let ctrl;
let currentDrive;
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = prefs.ready.then(() => {
ready = true;
prefs.subscribe('sync.enabled',
(_, val) => val === 'none'
? syncMan.stop()
: syncMan.start(val, true),
{runNow: true});
});
chrome.alarms.onAlarm.addListener(info => {
if (info.name === 'syncNow') {
syncMan.syncNow();
}
});
//#endregion
//#region Exports
return {
async delete(...args) {
if (ready.then) await ready;
if (!currentDrive) return;
schedule();
return ctrl.delete(...args);
},
/** @returns {Promise<SyncManager.Status>} */
async getStatus() {
return status;
},
async login(name = prefs.get('sync.enabled')) {
if (ready.then) await ready;
try {
await tokenMan.getToken(name, true);
} catch (err) {
if (/Authorization page could not be loaded/i.test(err.message)) {
// FIXME: Chrome always fails at the first login so we try again
await tokenMan.getToken(name);
}
throw err;
}
status.login = true;
emitStatusChange();
},
async put(...args) {
if (ready.then) await ready;
if (!currentDrive) return;
schedule();
return ctrl.put(...args);
},
async start(name, fromPref = false) {
if (ready.then) await ready;
if (!ctrl) await initController();
if (currentDrive) return;
currentDrive = getDrive(name);
ctrl.use(currentDrive);
status.state = STATES.connecting;
status.currentDriveName = currentDrive.name;
status.login = true;
emitStatusChange();
try {
if (!fromPref) {
await syncMan.login(name).catch(handle401Error);
}
await syncMan.syncNow();
status.errorMessage = null;
} catch (err) {
status.errorMessage = err.message;
// FIXME: should we move this logic to options.js?
if (!fromPref) {
console.error(err);
return syncMan.stop();
}
}
prefs.set('sync.enabled', name);
status.state = STATES.connected;
schedule(SYNC_INTERVAL);
emitStatusChange();
},
async stop() {
if (ready.then) await ready;
if (!currentDrive) return;
chrome.alarms.clear('syncNow');
status.state = STATES.disconnecting;
emitStatusChange();
try {
await ctrl.stop();
await tokenMan.revokeToken(currentDrive.name);
await chromeLocal.remove(STORAGE_KEY + currentDrive.name);
} catch (e) {}
currentDrive = null;
prefs.set('sync.enabled', 'none');
status.state = STATES.disconnected;
status.currentDriveName = null;
status.login = false;
emitStatusChange();
},
async syncNow() {
if (ready.then) await ready;
if (!currentDrive) throw new Error('cannot sync when disconnected');
try {
await (ctrl.isInit() ? ctrl.syncNow() : ctrl.start()).catch(handle401Error);
status.errorMessage = null;
} catch (err) {
status.errorMessage = err.message;
}
emitStatusChange();
},
};
//#endregion
//#region Utils
async function initController() {
await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
ctrl = dbToCloud.dbToCloud({
onGet(id) {
return API.styles.getByUUID(id);
},
onPut(doc) {
return API.styles.putByUUID(doc);
},
onDelete(id, rev) {
return API.styles.deleteByUUID(id, rev);
},
async onFirstSync() {
for (const i of await API.styles.getAll()) {
ctrl.put(i._id, i._rev);
}
},
onProgress(e) {
if (e.phase === 'start') {
status.syncing = true;
} else if (e.phase === 'end') {
status.syncing = false;
status.progress = null;
} else {
status.progress = e;
}
emitStatusChange();
},
compareRevision,
getState(drive) {
return chromeLocal.getValue(STORAGE_KEY + drive.name);
},
setState(drive, state) {
return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
},
});
}
async function handle401Error(err) {
let emit;
if (err.code === 401) {
await tokenMan.revokeToken(currentDrive.name).catch(console.error);
emit = true;
} else if (/User interaction required|Requires user interaction/i.test(err.message)) {
emit = true;
}
if (emit) {
status.login = false;
emitStatusChange();
}
return Promise.reject(err);
}
function emitStatusChange() {
msg.broadcastExtension({method: 'syncStatusUpdate', status});
}
function getDrive(name) {
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
return dbToCloud.drive[name]({
getAccessToken: () => tokenMan.getToken(name),
});
}
throw new Error(`unknown cloud name: ${name}`);
}
function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', {
delayInMinutes: delay,
periodInMinutes: SYNC_INTERVAL,
});
}
//#endregion
})();

View File

@ -1,236 +0,0 @@
/* global dbToCloud styleManager chromeLocal prefs tokenManager msg */
/* exported sync */
'use strict';
const sync = (() => {
const SYNC_DELAY = 1; // minutes
const SYNC_INTERVAL = 30; // minutes
const status = {
state: 'disconnected',
syncing: false,
progress: null,
currentDriveName: null,
errorMessage: null,
login: false,
};
let currentDrive;
const ctrl = dbToCloud.dbToCloud({
onGet(id) {
return styleManager.getByUUID(id);
},
onPut(doc) {
return styleManager.putByUUID(doc);
},
onDelete(id, rev) {
return styleManager.deleteByUUID(id, rev);
},
onFirstSync() {
return styleManager.getAllStyles()
.then(styles => {
styles.forEach(i => ctrl.put(i._id, i._rev));
});
},
onProgress,
compareRevision(a, b) {
return styleManager.compareRevision(a, b);
},
getState(drive) {
const key = `sync/state/${drive.name}`;
return chromeLocal.getValue(key);
},
setState(drive, state) {
const key = `sync/state/${drive.name}`;
return chromeLocal.setValue(key, state);
},
});
const initializing = prefs.initializing.then(() => {
prefs.subscribe(['sync.enabled'], onPrefChange);
onPrefChange(null, prefs.get('sync.enabled'));
});
chrome.alarms.onAlarm.addListener(info => {
if (info.name === 'syncNow') {
syncNow().catch(console.error);
}
});
return Object.assign({
getStatus: () => status,
}, ensurePrepared({
start,
stop,
put: (...args) => {
if (!currentDrive) return;
schedule();
return ctrl.put(...args);
},
delete: (...args) => {
if (!currentDrive) return;
schedule();
return ctrl.delete(...args);
},
syncNow,
login,
}));
function ensurePrepared(obj) {
return Object.entries(obj).reduce((o, [key, fn]) => {
o[key] = (...args) =>
initializing.then(() => fn(...args));
return o;
}, {});
}
function onProgress(e) {
if (e.phase === 'start') {
status.syncing = true;
} else if (e.phase === 'end') {
status.syncing = false;
status.progress = null;
} else {
status.progress = e;
}
emitStatusChange();
}
function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', {
delayInMinutes: delay,
periodInMinutes: SYNC_INTERVAL,
});
}
function onPrefChange(key, value) {
if (value === 'none') {
stop().catch(console.error);
} else {
start(value, true).catch(console.error);
}
}
function withFinally(p, cleanup) {
return p.then(
result => {
cleanup(undefined, result);
return result;
},
err => {
cleanup(err);
throw err;
}
);
}
function syncNow() {
if (!currentDrive) {
return Promise.reject(new Error('cannot sync when disconnected'));
}
return withFinally(
(ctrl.isInit() ? ctrl.syncNow() : ctrl.start())
.catch(handle401Error),
err => {
status.errorMessage = err ? err.message : null;
emitStatusChange();
}
);
}
function handle401Error(err) {
if (err.code === 401) {
return tokenManager.revokeToken(currentDrive.name)
.catch(console.error)
.then(() => {
status.login = false;
emitStatusChange();
throw err;
});
}
if (/User interaction required|Requires user interaction/i.test(err.message)) {
status.login = false;
emitStatusChange();
}
throw err;
}
function emitStatusChange() {
msg.broadcastExtension({method: 'syncStatusUpdate', status});
}
function login(name = prefs.get('sync.enabled')) {
return tokenManager.getToken(name, true)
.catch(err => {
if (/Authorization page could not be loaded/i.test(err.message)) {
// FIXME: Chrome always fails at the first login so we try again
return tokenManager.getToken(name);
}
throw err;
})
.then(() => {
status.login = true;
emitStatusChange();
});
}
function start(name, fromPref = false) {
if (currentDrive) {
return Promise.resolve();
}
currentDrive = getDrive(name);
ctrl.use(currentDrive);
status.state = 'connecting';
status.currentDriveName = currentDrive.name;
status.login = true;
emitStatusChange();
return withFinally(
(fromPref ? Promise.resolve() : login(name))
.catch(handle401Error)
.then(() => syncNow()),
err => {
status.errorMessage = err ? err.message : null;
// FIXME: should we move this logic to options.js?
if (err && !fromPref) {
console.error(err);
return stop();
}
prefs.set('sync.enabled', name);
schedule(SYNC_INTERVAL);
status.state = 'connected';
emitStatusChange();
}
);
}
function getDrive(name) {
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
return dbToCloud.drive[name]({
getAccessToken: () => tokenManager.getToken(name),
});
}
throw new Error(`unknown cloud name: ${name}`);
}
function stop() {
if (!currentDrive) {
return Promise.resolve();
}
chrome.alarms.clear('syncNow');
status.state = 'disconnecting';
emitStatusChange();
return withFinally(
ctrl.stop()
.then(() => tokenManager.revokeToken(currentDrive.name))
.then(() => chromeLocal.remove(`sync/state/${currentDrive.name}`)),
() => {
currentDrive = null;
prefs.set('sync.enabled', 'none');
status.state = 'disconnected';
status.currentDriveName = null;
status.login = false;
emitStatusChange();
}
);
}
})();

View File

@ -1,16 +1,18 @@
/* global navigatorUtil */
/* exported tabManager */
/* global bgReady */// common.js
/* global navMan */
'use strict';
const tabManager = (() => {
const listeners = [];
const tabMan = (() => {
const listeners = new Set();
const cache = new Map();
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
navigatorUtil.onUrlChange(({tabId, frameId, url}) => {
bgReady.all.then(() => {
navMan.onUrlChange(({tabId, frameId, url}) => {
const oldUrl = !frameId && tabMan.get(tabId, 'url', frameId);
tabMan.set(tabId, 'url', frameId, url);
if (frameId) return;
const oldUrl = tabManager.get(tabId, 'url');
tabManager.set(tabId, 'url', url);
for (const fn of listeners) {
try {
fn({tabId, url, oldUrl});
@ -19,14 +21,17 @@ const tabManager = (() => {
}
}
});
});
return {
onUpdate(fn) {
listeners.push(fn);
listeners.add(fn);
},
get(tabId, ...keys) {
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
},
/**
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
@ -47,8 +52,10 @@ const tabManager = (() => {
meta[lastKey] = value;
}
},
list() {
return cache.keys();
},
};
})();

View File

@ -1,8 +1,9 @@
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
/* exported tokenManager */
/* global FIREFOX */// toolbox.js
/* global chromeLocal */// storage-util.js
'use strict';
const tokenManager = (() => {
/* exported tokenMan */
const tokenMan = (() => {
const AUTH = {
dropbox: {
flow: 'token',
@ -50,13 +51,9 @@ const tokenManager = (() => {
};
const NETWORK_LATENCY = 30; // seconds
return {getToken, revokeToken, getClientId, buildKeys};
return {
function getClientId(name) {
return AUTH[name].clientId;
}
function buildKeys(name) {
buildKeys(name) {
const k = {
TOKEN: `secure/token/${name}/token`,
EXPIRE: `secure/token/${name}/expire`,
@ -64,50 +61,48 @@ const tokenManager = (() => {
};
k.LIST = Object.values(k);
return k;
}
},
function getToken(name, interactive) {
const k = buildKeys(name);
return chromeLocal.get(k.LIST)
.then(obj => {
if (!obj[k.TOKEN]) {
return authUser(name, k, interactive);
}
getClientId(name) {
return AUTH[name].clientId;
},
async getToken(name, interactive) {
const k = tokenMan.buildKeys(name);
const obj = await chromeLocal.get(k.LIST);
if (obj[k.TOKEN]) {
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
return obj[k.TOKEN];
}
if (obj[k.REFRESH]) {
return refreshToken(name, k, obj)
.catch(err => {
if (err.code === 401) {
return authUser(name, k, interactive);
try {
return await refreshToken(name, k, obj);
} catch (err) {
if (err.code !== 401) throw err;
}
}
throw err;
});
}
return authUser(name, k, interactive);
});
}
},
async function revokeToken(name) {
async revokeToken(name) {
const provider = AUTH[name];
const k = buildKeys(name);
const k = tokenMan.buildKeys(name);
if (provider.revoke) {
try {
const token = await chromeLocal.getValue(k.TOKEN);
if (token) {
await provider.revoke(token);
}
if (token) await provider.revoke(token);
} catch (e) {
console.error(e);
}
}
await chromeLocal.remove(k.LIST);
}
},
};
function refreshToken(name, k, obj) {
async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) {
return Promise.reject(new Error('no refresh token'));
throw new Error('No refresh token');
}
const provider = AUTH[name];
const body = {
@ -119,17 +114,17 @@ const tokenManager = (() => {
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body)
.then(result => {
const result = await postQuery(provider.tokenURL, body);
if (!result.refresh_token) {
// reuse old refresh token
result.refresh_token = obj[k.REFRESH];
}
return handleTokenResult(result, k);
});
}
function authUser(name, k, interactive = false) {
async function authUser(name, k, interactive = false) {
await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
/* global webextLaunchWebAuthFlow */
const provider = AUTH[name];
const state = Math.random().toFixed(8).slice(2);
const query = {
@ -145,27 +140,27 @@ const tokenManager = (() => {
Object.assign(query, provider.authQuery);
}
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
return webextLaunchWebAuthFlow({
const finalUrl = await webextLaunchWebAuthFlow({
url,
interactive,
redirect_uri: query.redirect_uri,
})
.then(url => {
});
const params = new URLSearchParams(
provider.flow === 'token' ?
new URL(url).hash.slice(1) :
new URL(url).search.slice(1)
new URL(finalUrl).hash.slice(1) :
new URL(finalUrl).search.slice(1)
);
if (params.get('state') !== state) {
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
}
let result;
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params.entries()) {
for (const [key, value] of params) {
obj[key] = value;
}
return obj;
}
result = obj;
} else {
const code = params.get('code');
const body = {
code,
@ -176,21 +171,23 @@ const tokenManager = (() => {
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body);
})
.then(result => handleTokenResult(result, k));
result = await postQuery(provider.tokenURL, body);
}
return handleTokenResult(result, k);
}
function handleTokenResult(result, k) {
return chromeLocal.set({
async function handleTokenResult(result, k) {
await chromeLocal.set({
[k.TOKEN]: result.access_token,
[k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined,
[k.EXPIRE]: result.expires_in
? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
: undefined,
[k.REFRESH]: result.refresh_token,
})
.then(() => result.access_token);
});
return result.access_token;
}
function postQuery(url, body) {
async function postQuery(url, body) {
const options = {
method: 'POST',
headers: {
@ -198,17 +195,13 @@ const tokenManager = (() => {
},
body: body ? new URLSearchParams(body) : null,
};
return fetch(url, options)
.then(r => {
const r = await fetch(url, options);
if (r.ok) {
return r.json();
}
return r.text()
.then(body => {
const err = new Error(`failed to fetch (${r.status}): ${body}`);
const text = await r.text();
const err = new Error(`Failed to fetch (${r.status}): ${text}`);
err.code = r.status;
throw err;
});
});
}
})();

View File

@ -0,0 +1,251 @@
/* global API */// msg.js
/* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
/* global chromeLocal */// storage-util.js
/* global debounce download ignoreChromeError */// toolbox.js
/* global prefs */
'use strict';
/* exported updateMan */
const updateMan = (() => {
const STATES = /** @namespace UpdaterStates */ {
UPDATED: 'updated',
SKIPPED: 'skipped',
UNREACHABLE: 'server unreachable',
// details for SKIPPED status
EDITED: 'locally edited',
MAYBE_EDITED: 'may be locally edited',
SAME_MD5: 'up-to-date: MD5 is unchanged',
SAME_CODE: 'up-to-date: code sections are unchanged',
SAME_VERSION: 'up-to-date: version is unchanged',
ERROR_MD5: 'error: MD5 is invalid',
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
};
const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3;
const RETRY_ERRORS = [
503, // service unavailable
429, // too many requests
];
let lastUpdateTime;
let checkingAll = false;
let logQueue = [];
let logLastWriteTime = 0;
chromeLocal.getValue('lastUpdateTime').then(val => {
lastUpdateTime = val || Date.now();
prefs.subscribe('updateInterval', schedule, {runNow: true});
chrome.alarms.onAlarm.addListener(onAlarm);
});
return {
checkAllStyles,
checkStyle,
getStates: () => STATES,
};
async function checkAllStyles({
save = true,
ignoreDigest,
observe,
} = {}) {
resetInterval();
checkingAll = true;
const port = observe && chrome.runtime.connect({name: 'updater'});
const styles = (await API.styles.getAll())
.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
await Promise.all(
styles.map(style =>
checkStyle({style, port, save, ignoreDigest})));
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
}
/**
* @param {{
id?: number
style?: StyleObj
port?: chrome.runtime.Port
save?: boolean = true
ignoreDigest?: boolean
}} opts
* @returns {{
style: StyleObj
updated?: boolean
error?: any
STATES: UpdaterStates
}}
Original style digests are calculated in these cases:
* style is installed or updated from server
* non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
Update check proceeds in these cases:
* style has the original digest and it's equal to the current digest
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
* [ignoreDigest: none/false] style doesn't yet have the original digest
so we compare the code to the server code and if it's the same we save the digest,
otherwise we skip the style and report MAYBE_EDITED status
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
async function checkStyle(opts) {
const {
id,
style = await API.styles.get(id),
ignoreDigest,
port,
save,
} = opts;
const ucd = style.usercssData;
let res, state;
try {
await checkIfEdited();
res = {
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
updated: true,
};
state = STATES.UPDATED;
} catch (err) {
const error = err === 0 && STATES.UNREACHABLE ||
err && err.message ||
err;
res = {error, style, STATES};
state = `${STATES.SKIPPED} (${error})`;
}
log(`${state} #${style.id} ${style.customName || style.name}`);
if (port) port.postMessage(res);
return res;
async function checkIfEdited() {
if (!ignoreDigest &&
style.originalDigest &&
style.originalDigest !== await calcStyleDigest(style)) {
return Promise.reject(STATES.EDITED);
}
}
async function updateUSO() {
const md5 = await tryDownload(style.md5Url);
if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5);
}
const json = await tryDownload(style.updateUrl, {responseType: 'json'});
if (!styleJSONseemsValid(json)) {
return Promise.reject(STATES.ERROR_JSON);
}
// USO may not provide a correctly updated originalMd5 (#555)
json.originalMd5 = md5;
return json;
}
async function updateUsercss() {
// TODO: when sourceCode is > 100kB use http range request(s) for version check
const text = await tryDownload(style.updateUrl);
const json = await API.usercss.buildMeta({sourceCode: text});
await require(['/vendor/semver-bundle/semver']); /* global semverCompare */
const delta = semverCompare(json.usercssData.version, ucd.version);
if (!delta && !ignoreDigest) {
// re-install is invalid in a soft upgrade
const sameCode = text === style.sourceCode;
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
}
if (delta < 0) {
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return API.usercss.buildCode(json);
}
async function maybeSave(json) {
json.id = style.id;
json.updateDate = Date.now();
// keep current state
delete json.customName;
delete json.enabled;
const newStyle = Object.assign({}, style, json);
// update digest even if save === false as there might be just a space added etc.
if (!ucd && styleSectionsEqual(json, style)) {
style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
return Promise.reject(STATES.SAME_CODE);
}
if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.MAYBE_EDITED);
}
return !save ? newStyle :
(ucd ? API.usercss.install : API.styles.install)(newStyle);
}
async function tryDownload(url, params) {
let {retryDelay = 1000} = opts;
while (true) {
try {
return await download(url, params);
} catch (code) {
if (!RETRY_ERRORS.includes(code) ||
retryDelay > MIN_INTERVAL_MS) {
return Promise.reject(code);
}
}
retryDelay *= 1.25;
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
function schedule() {
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
if (interval > 0) {
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
chrome.alarms.create(ALARM_NAME, {
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
});
} else {
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
}
}
function onAlarm({name}) {
if (name === ALARM_NAME) checkAllStyles();
}
function resetInterval() {
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
schedule();
}
function log(text) {
logQueue.push({text, time: new Date().toLocaleString()});
debounce(flushQueue, text && checkingAll ? 1000 : 0);
}
async function flushQueue(lines) {
if (!lines) {
flushQueue(await chromeLocal.getValue('updateLog') || []);
return;
}
const time = Date.now() - logLastWriteTime > 11e3 ?
logQueue[0].time + ' ' :
'';
if (logQueue[0] && !logQueue[0].text) {
logQueue.shift();
if (lines[lines.length - 1]) lines.push('');
}
lines.splice(0, lines.length - 1000);
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
lines.push(...logQueue.slice(1).map(item => item.text));
chromeLocal.setValue('updateLog', lines);
logLastWriteTime = Date.now();
logQueue = [];
}
})();

View File

@ -1,290 +0,0 @@
/* global
API_METHODS
calcStyleDigest
chromeLocal
debounce
download
getStyleWithNoCode
ignoreChromeError
prefs
semverCompare
styleJSONseemsValid
styleManager
styleSectionsEqual
tryJSONparse
usercss
*/
'use strict';
(() => {
const STATES = {
UPDATED: 'updated',
SKIPPED: 'skipped',
// details for SKIPPED status
EDITED: 'locally edited',
MAYBE_EDITED: 'may be locally edited',
SAME_MD5: 'up-to-date: MD5 is unchanged',
SAME_CODE: 'up-to-date: code sections are unchanged',
SAME_VERSION: 'up-to-date: version is unchanged',
ERROR_MD5: 'error: MD5 is invalid',
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
};
const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3;
let lastUpdateTime;
let checkingAll = false;
let logQueue = [];
let logLastWriteTime = 0;
const retrying = new Set();
API_METHODS.updateCheckAll = checkAllStyles;
API_METHODS.updateCheck = checkStyle;
API_METHODS.getUpdaterStates = () => STATES;
chromeLocal.getValue('lastUpdateTime').then(val => {
lastUpdateTime = val || Date.now();
prefs.subscribe('updateInterval', schedule, {now: true});
chrome.alarms.onAlarm.addListener(onAlarm);
});
return {checkAllStyles, checkStyle, STATES};
function checkAllStyles({
save = true,
ignoreDigest,
observe,
} = {}) {
resetInterval();
checkingAll = true;
retrying.clear();
const port = observe && chrome.runtime.connect({name: 'updater'});
return styleManager.getAllStyles().then(styles => {
styles = styles.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
return Promise.all(
styles.map(style =>
checkStyle({style, port, save, ignoreDigest})));
}).then(() => {
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
retrying.clear();
});
}
function checkStyle({
id,
style,
port,
save = true,
ignoreDigest,
}) {
/*
Original style digests are calculated in these cases:
* style is installed or updated from server
* style is checked for an update and its code is equal to the server code
Update check proceeds in these cases:
* style has the original digest and it's equal to the current digest
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
* [ignoreDigest: none/false] style doesn't yet have the original digest
so we compare the code to the server code and if it's the same we save the digest,
otherwise we skip the style and report MAYBE_EDITED status
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
return fetchStyle()
.then(() => {
if (!ignoreDigest) {
return calcStyleDigest(style)
.then(checkIfEdited);
}
})
.then(() => {
if (style.usercssData) {
return maybeUpdateUsercss();
}
return maybeUpdateUSO();
})
.then(maybeSave)
.then(reportSuccess)
.catch(reportFailure);
function fetchStyle() {
if (style) {
return Promise.resolve();
}
return styleManager.get(id)
.then(style_ => {
style = style_;
});
}
function reportSuccess(saved) {
log(STATES.UPDATED + ` #${style.id} ${style.customName || style.name}`);
const info = {updated: true, style: saved};
if (port) port.postMessage(info);
return info;
}
function reportFailure(error) {
if ((
error === 503 || // Service Unavailable
error === 429 // Too Many Requests
) && !retrying.has(id)) {
retrying.add(id);
return new Promise(resolve => {
setTimeout(() => {
resolve(checkStyle({id, style, port, save, ignoreDigest}));
}, 1000);
});
}
error = error === 0 ? 'server unreachable' : error;
// UserCSS metadata error returns an object; e.g. "Invalid @var color..."
if (typeof error === 'object' && error.message) {
error = error.message;
}
log(STATES.SKIPPED + ` (${error}) #${style.id} ${style.customName || style.name}`);
const info = {error, STATES, style: getStyleWithNoCode(style)};
if (port) port.postMessage(info);
return info;
}
function checkIfEdited(digest) {
if (style.originalDigest && style.originalDigest !== digest) {
return Promise.reject(STATES.EDITED);
}
}
function maybeUpdateUSO() {
return download(style.md5Url).then(md5 => {
if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5);
}
// USO can't handle POST requests for style json
return download(style.updateUrl, {body: null})
.then(text => {
const style = tryJSONparse(text);
if (style) {
// USO may not provide a correctly updated originalMd5 (#555)
style.originalMd5 = md5;
}
return style;
});
});
}
function maybeUpdateUsercss() {
// TODO: when sourceCode is > 100kB use http range request(s) for version check
return download(style.updateUrl).then(text =>
usercss.buildMeta(text).then(json => {
const {usercssData: {version}} = style;
const {usercssData: {version: newVersion}} = json;
switch (Math.sign(semverCompare(version, newVersion))) {
case 0:
// re-install is invalid in a soft upgrade
if (!ignoreDigest) {
const sameCode = text === style.sourceCode;
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
}
break;
case 1:
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return usercss.buildCode(json);
})
);
}
function maybeSave(json = {}) {
// usercss is already validated while building
if (!json.usercssData && !styleJSONseemsValid(json)) {
return Promise.reject(STATES.ERROR_JSON);
}
json.id = style.id;
json.updateDate = Date.now();
// keep current state
delete json.enabled;
const newStyle = Object.assign({}, style, json);
if (!style.usercssData && styleSectionsEqual(json, style)) {
// update digest even if save === false as there might be just a space added etc.
return styleManager.installStyle(newStyle)
.then(saved => {
style.originalDigest = saved.originalDigest;
return Promise.reject(STATES.SAME_CODE);
});
}
if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.MAYBE_EDITED);
}
return save ?
API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) :
newStyle;
}
}
function schedule() {
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
if (interval > 0) {
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
chrome.alarms.create(ALARM_NAME, {
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
});
} else {
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
}
}
function onAlarm({name}) {
if (name === ALARM_NAME) checkAllStyles();
}
function resetInterval() {
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
schedule();
}
function log(text) {
logQueue.push({text, time: new Date().toLocaleString()});
debounce(flushQueue, text && checkingAll ? 1000 : 0);
}
async function flushQueue(lines) {
if (!lines) {
flushQueue(await chromeLocal.getValue('updateLog') || []);
return;
}
const time = Date.now() - logLastWriteTime > 11e3 ?
logQueue[0].time + ' ' :
'';
if (logQueue[0] && !logQueue[0].text) {
logQueue.shift();
if (lines[lines.length - 1]) lines.push('');
}
lines.splice(0, lines.length - 1000);
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
lines.push(...logQueue.slice(1).map(item => item.text));
chromeLocal.setValue('updateLog', lines);
logLastWriteTime = Date.now();
logQueue = [];
}
})();

View File

@ -1,132 +0,0 @@
/* global API_METHODS usercss styleManager deepCopy */
/* exported usercssHelper */
'use strict';
const usercssHelper = (() => {
API_METHODS.installUsercss = installUsercss;
API_METHODS.editSaveUsercss = editSaveUsercss;
API_METHODS.configUsercssVars = configUsercssVars;
API_METHODS.buildUsercss = build;
API_METHODS.buildUsercssMeta = buildMeta;
API_METHODS.findUsercss = find;
function buildMeta(style) {
if (style.usercssData) {
return Promise.resolve(style);
}
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return usercss.buildMeta(sourceCode)
.then(newStyle => Object.assign(newStyle, style));
}
function assignVars(style) {
return find(style)
.then(dup => {
if (dup) {
style.id = dup.id;
// preserve style.vars during update
return usercss.assignVars(style, dup)
.then(() => style);
}
return style;
});
}
/**
* Parse the source, find the duplication, and build sections with variables
* @param _
* @param {String} _.sourceCode
* @param {Boolean=} _.checkDup
* @param {Boolean=} _.metaOnly
* @param {Object} _.vars
* @param {Boolean=} _.assignVars
* @returns {Promise<{style, dup:Boolean?}>}
*/
function build({
styleId,
sourceCode,
checkDup,
metaOnly,
vars,
assignVars = false,
}) {
return usercss.buildMeta(sourceCode)
.then(style => {
const findDup = checkDup || assignVars ?
find(styleId ? {id: styleId} : style) : Promise.resolve();
return Promise.all([
metaOnly ? style : doBuild(style, findDup),
findDup,
]);
})
.then(([style, dup]) => ({style, dup}));
function doBuild(style, findDup) {
if (vars || assignVars) {
const getOld = vars ? Promise.resolve({usercssData: {vars}}) : findDup;
return getOld
.then(oldStyle => usercss.assignVars(style, oldStyle))
.then(() => usercss.buildCode(style));
}
return usercss.buildCode(style);
}
}
// Build the style within aditional properties then inherit variable values
// from the old style.
function parse(style) {
return buildMeta(style)
.then(buildMeta)
.then(assignVars)
.then(usercss.buildCode);
}
// FIXME: simplify this to `installUsercss(sourceCode)`?
function installUsercss(style) {
return parse(style)
.then(styleManager.installStyle);
}
// FIXME: simplify this to `editSaveUsercss({sourceCode, exclusions})`?
function editSaveUsercss(style) {
return parse(style)
.then(styleManager.editSave);
}
function configUsercssVars(id, vars) {
return styleManager.get(id)
.then(style => {
const newStyle = deepCopy(style);
newStyle.usercssData.vars = vars;
return usercss.buildCode(newStyle);
})
.then(style => styleManager.installStyle(style, 'config'))
.then(style => style.usercssData.vars);
}
/**
* @param {Style|{name:string, namespace:string}} styleOrData
* @returns {Style}
*/
function find(styleOrData) {
if (styleOrData.id) {
return styleManager.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
return styleManager.getAllStyles().then(styleList => {
for (const dup of styleList) {
const data = dup.usercssData;
if (!data) continue;
if (data.name === name &&
data.namespace === namespace) {
return dup;
}
}
});
}
})();

View File

@ -1,37 +1,22 @@
/* global
API_METHODS
download
openURL
tabManager
URLS
*/
/* global URLS download openURL */// toolbox.js
/* global addAPI bgReady */// common.js
/* global tabMan */// msg.js
'use strict';
(() => {
bgReady.all.then(() => {
const installCodeCache = {};
const clearInstallCode = url => delete installCodeCache[url];
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
const isContentTypeText = type => /^text\/(?!html)/i.test(type);
// in Firefox we have to use a content script to read file://
const fileLoader = !chrome.app && (
async tabId =>
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
const urlLoader =
async (tabId, url) => (
url.startsWith('file:') ||
tabManager.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
API_METHODS.getUsercssInstallCode = url => {
addAPI(/** @namespace API */ {
usercss: {
getInstallCode(url) {
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
const {code, timer} = installCodeCache[url];
clearInstallCode(url);
clearTimeout(timer);
return code;
};
},
},
});
// `glob`: pathname match pattern for webRequest
// `rx`: pathname regex to verify the URL really looks like a raw usercss
@ -48,17 +33,7 @@
},
};
// Faster installation on known distribution sites to avoid flicker of css text
chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, 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
}
}, {
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
urls: [
URLS.usoArchiveRaw + 'usercss/*.user.css',
'*://greasyfork.org/scripts/*/code/*.user.css',
@ -70,27 +45,63 @@
types: ['main_frame'],
}, ['blocking']);
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}, {
chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
urls: makeUsercssGlobs('*', '/*'),
types: ['main_frame'],
}, ['responseHeaders']);
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
tabMan.onUpdate(maybeInstall);
function clearInstallCode(url) {
return delete installCodeCache[url];
}
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
function isContentTypeText(type) {
return /^text\/(?!html)/i.test(type);
}
// in Firefox we have to use a content script to read file://
async function loadFromFile(tabId) {
return (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0];
}
async function loadFromUrl(tabId, url) {
return (
url.startsWith('file:') ||
tabMan.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
}
function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
}
async function maybeInstall({tabId, url, oldUrl = ''}) {
if (url.includes('.user.') &&
/^(https?|file|ftps?):/.test(url) &&
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
!oldUrl.startsWith(URLS.installUsercss)) {
const inTab = url.startsWith('file:') && Boolean(fileLoader);
const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) {
const inTab = url.startsWith('file:') && !chrome.app;
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
if (!/^\s*</.test(code) && URLS.rxMETA.test(code)) {
openInstallerPage(tabId, url, {code, inTab});
}
}
});
}
/** Faster installation on known distribution sites to avoid flicker of css text */
function maybeInstallFromDistro({tabId, 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
}
}
function openInstallerPage(tabId, url, {code, inTab} = {}) {
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
@ -110,7 +121,9 @@
}
}
function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
/** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
function rememberContentType({tabId, responseHeaders}) {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabMan.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}
})();
});

View File

@ -0,0 +1,152 @@
/* global API */// msg.js
/* global URLS deepCopy download */// toolbox.js
'use strict';
const usercssMan = {
GLOBAL_META: Object.entries({
author: null,
description: null,
homepageURL: 'url',
updateURL: 'updateUrl',
name: null,
}),
async assignVars(style, oldStyle) {
const meta = style.usercssData;
const vars = meta.vars;
const oldVars = oldStyle.usercssData.vars;
if (vars && oldVars) {
// The type of var might be changed during the update. Set value to null if the value is invalid.
for (const [key, v] of Object.entries(vars)) {
const old = oldVars[key] && oldVars[key].value;
if (old) v.value = old;
}
meta.vars = await API.worker.nullifyInvalidVars(vars);
}
},
async build({
styleId,
sourceCode,
vars,
checkDup,
metaOnly,
assignVars,
initialUrl,
}) {
// downloading here while install-usercss page is loading to avoid the wait
if (initialUrl) sourceCode = await download(initialUrl);
const style = await usercssMan.buildMeta({sourceCode});
const dup = (checkDup || assignVars) &&
await usercssMan.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
await usercssMan.buildCode(style);
}
return {style, dup};
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(URLS.rxMETA);
const i = match.index;
const j = i + match[0].length;
const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j);
const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
const recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
}
style.sections = sections;
return style;
},
async buildMeta(style) {
if (style.usercssData) {
return style;
}
// remember normalized sourceCode
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
style = Object.assign({
enabled: true,
sections: [],
}, style);
const match = code.match(URLS.rxMETA);
if (!match) {
return Promise.reject(new Error('Could not find metadata.'));
}
try {
code = blankOut(code, 0, match.index) + match[0];
const {metadata} = await API.worker.parseUsercssMeta(code);
style.usercssData = metadata;
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
for (const [key, globalKey] of usercssMan.GLOBAL_META) {
const val = metadata[key];
if (val !== undefined) {
style[globalKey || key] = val;
}
}
return style;
} catch (err) {
if (err.code) {
const args = err.code === 'missingMandatory' || err.code === 'missingChar'
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
: err.args;
const msg = chrome.i18n.getMessage(`meta_${(err.code)}`, args);
if (msg) err.message = msg;
}
return Promise.reject(err);
}
},
async configVars(id, vars) {
const style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
await usercssMan.buildCode(style);
return (await API.styles.install(style, 'config'))
.usercssData.vars;
},
async editSave(style) {
return API.styles.editSave(await usercssMan.parse(style));
},
async find(styleOrData) {
if (styleOrData.id) {
return API.styles.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of await API.styles.getAll()) {
const data = dup.usercssData;
if (data &&
data.name === name &&
data.namespace === namespace) {
return dup;
}
}
},
async install(style) {
return API.styles.install(await usercssMan.parse(style));
},
async parse(style) {
style = await usercssMan.buildMeta(style);
// preserve style.vars during update
const dup = await usercssMan.find(style);
if (dup) {
style.id = dup.id;
await usercssMan.assignVars(style, dup);
}
return usercssMan.buildCode(style);
},
};
/** Replaces everything with spaces to keep the original length,
* but preserves the line breaks to keep the original line/col relation */
function blankOut(str, start = 0, end = str.length) {
return str.slice(start, end).replace(/[^\r\n]/g, ' ');
}

View File

@ -1,34 +1,22 @@
/* global msg API prefs createStyleInjector */
/* global API msg */// msg.js
/* global StyleInjector */
/* global prefs */
'use strict';
// Chrome reruns content script when documentElement is replaced.
// Note, we're checking against a literal `1`, not just `if (truthy)`,
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
(() => {
if (window.INJECTED === 1) return;
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
self.INJECTED = 1;
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
const IS_FRAME = window !== parent;
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
const styleInjector = createStyleInjector({
let hasStyles = false;
let isTab = !chrome.tabs || location.pathname !== '/popup.html';
const isFrame = window !== parent;
const isFrameAboutBlank = isFrame && location.href === 'about:blank';
const isUnstylable = !chrome.app && document instanceof XMLDocument;
const styleInjector = StyleInjector({
compare: (a, b) => a.id - b.id,
onUpdate: onInjectorUpdate,
});
const initializing = init();
/** @type chrome.runtime.Port */
let port;
let lazyBadge = IS_FRAME;
let parentDomain;
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
if (!IS_TAB) {
chrome.tabs.getCurrent(tab => {
IS_TAB = Boolean(tab);
if (tab && styleInjector.list.length) updateCount();
});
}
// dynamic about: and javascript: iframes don't have a URL yet so we'll use their parent
const matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href) || location.href;
// save it now because chrome.runtime will be unavailable in the orphaned script
const orphanEventId = chrome.runtime.id;
@ -36,6 +24,22 @@ self.INJECTED !== 1 && (() => {
// firefox doesn't orphanize content scripts so the old elements stay
if (!chrome.app) styleInjector.clearOrphans();
/** @type chrome.runtime.Port */
let port;
let lazyBadge = isFrame;
let parentDomain;
// Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
const ready = init();
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
if (!isTab) {
chrome.tabs.getCurrent(tab => {
isTab = Boolean(tab);
if (tab && styleInjector.list.length) updateCount();
});
}
msg.onTab(applyOnMessage);
if (!chrome.tabs) {
@ -47,30 +51,39 @@ self.INJECTED !== 1 && (() => {
if (!isOrphaned) {
updateCount();
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
onOff(['disableAll'], updateDisableAll);
if (IS_FRAME) {
onOff('disableAll', updateDisableAll);
if (isFrame) {
updateExposeIframes();
onOff(['exposeIframes'], updateExposeIframes);
onOff('exposeIframes', updateExposeIframes);
}
}
}
async function init() {
if (STYLE_VIA_API) {
if (isUnstylable) {
await API.styleViaAPI({method: 'styleApply'});
} else {
const styles = chrome.app && !chrome.tabs && getStylesViaXhr() ||
await API.getSectionsByUrl(getMatchUrl(), null, true);
if (styles.disableAll) {
delete styles.disableAll;
styleInjector.toggle(false);
}
const SYM_ID = 'styles';
const SYM = Symbol.for(SYM_ID);
const styles =
window[SYM] ||
(isFrameAboutBlank
? tryCatch(() => parent[parent.Symbol.for(SYM_ID)])
: chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr)) ||
await API.styles.getSectionsByUrl(matchUrl, null, true);
hasStyles = !styles.disableAll;
if (hasStyles) {
window[SYM] = styles;
await styleInjector.apply(styles);
} else {
delete window[SYM];
prefs.subscribe('disableAll', updateDisableAll);
}
}
}
/** Must be executed inside try/catch */
function getStylesViaXhr() {
try {
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
@ -79,71 +92,56 @@ self.INJECTED !== 1 && (() => {
xhr.send();
URL.revokeObjectURL(url);
return JSON.parse(xhr.response);
} catch (e) {}
}
function getMatchUrl() {
let matchUrl = location.href;
if (!chrome.tabs && !matchUrl.match(/^(http|file|chrome|ftp)/)) {
// dynamic about: and javascript: iframes don't have an URL yet
// so we'll try the parent frame which is guaranteed to have a real URL
try {
if (IS_FRAME) {
matchUrl = parent.location.href;
}
} catch (e) {}
}
return matchUrl;
}
function applyOnMessage(request) {
if (STYLE_VIA_API) {
if (request.method === 'urlChanged') {
const {method} = request;
if (isUnstylable) {
if (method === 'urlChanged') {
request.method = 'styleReplaceAll';
}
if (/^(style|updateCount)/.test(request.method)) {
if (/^(style|updateCount)/.test(method)) {
API.styleViaAPI(request);
return;
}
}
switch (request.method) {
const {style} = request;
switch (method) {
case 'ping':
return true;
case 'styleDeleted':
styleInjector.remove(request.style.id);
styleInjector.remove(style.id);
break;
case 'styleUpdated':
if (request.style.enabled) {
API.getSectionsByUrl(getMatchUrl(), request.style.id)
.then(sections => {
if (!sections[request.style.id]) {
styleInjector.remove(request.style.id);
if (style.enabled) {
API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
sections[style.id]
? styleInjector.apply(sections)
: styleInjector.remove(style.id));
} else {
styleInjector.apply(sections);
}
});
} else {
styleInjector.remove(request.style.id);
styleInjector.remove(style.id);
}
break;
case 'styleAdded':
if (request.style.enabled) {
API.getSectionsByUrl(getMatchUrl(), request.style.id)
if (style.enabled) {
API.styles.getSectionsByUrl(matchUrl, style.id)
.then(styleInjector.apply);
}
break;
case 'urlChanged':
API.getSectionsByUrl(getMatchUrl())
.then(styleInjector.replace);
API.styles.getSectionsByUrl(matchUrl).then(sections => {
hasStyles = true;
styleInjector.replace(sections);
});
break;
case 'backgroundReady':
initializing.catch(err =>
ready.catch(err =>
msg.isIgnorableError(err)
? init()
: console.error(err));
@ -156,8 +154,10 @@ self.INJECTED !== 1 && (() => {
}
function updateDisableAll(key, disableAll) {
if (STYLE_VIA_API) {
if (isUnstylable) {
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
} else if (!hasStyles && !disableAll) {
init();
} else {
styleInjector.toggle(!disableAll);
}
@ -179,8 +179,8 @@ self.INJECTED !== 1 && (() => {
}
function updateCount() {
if (!IS_TAB) return;
if (IS_FRAME) {
if (!isTab) return;
if (isFrame) {
if (!port && styleInjector.list.length) {
port = chrome.runtime.connect({name: 'iframe'});
} else if (port && !styleInjector.list.length) {
@ -188,23 +188,25 @@ self.INJECTED !== 1 && (() => {
}
if (lazyBadge && performance.now() > 1000) lazyBadge = false;
}
(STYLE_VIA_API ?
(isUnstylable ?
API.styleViaAPI({method: 'updateCount'}) :
API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
).catch(msg.ignoreError);
}
function orphanCheck() {
function tryCatch(func, ...args) {
try {
if (chrome.i18n.getUILanguage()) return;
return func(...args);
} catch (e) {}
}
function orphanCheck() {
if (tryCatch(() => chrome.i18n.getUILanguage())) return;
// In Chrome content script is orphaned on an extension update/reload
// so we need to detach event listeners
window.removeEventListener(orphanEventId, orphanCheck, true);
isOrphaned = true;
styleInjector.clear();
try {
msg.off(applyOnMessage);
} catch (e) {}
tryCatch(msg.off, applyOnMessage);
}
})();

View File

@ -1,4 +1,4 @@
/* global API */
/* global API */// msg.js
'use strict';
// onCommitted may fire twice
@ -13,7 +13,7 @@ if (window.INJECTED_GREASYFORK !== 1) {
e.data.name &&
e.data.type === 'style-version-query') {
removeEventListener('message', onMessage);
const style = await API.findUsercss(e.data) || {};
const style = await API.usercss.find(e.data) || {};
const {version} = style.usercssData || {};
postMessage({type: 'style-version', version}, '*');
}

View File

@ -1,4 +1,4 @@
/* global API */
/* global API */// msg.js
'use strict';
(() => {
@ -34,7 +34,7 @@
&& event.data.type === 'ouc-is-installed'
&& allowedOrigins.includes(event.origin)
) {
API.findUsercss({
API.usercss.find({
name: event.data.name,
namespace: event.data.namespace,
}).then(style => {
@ -55,7 +55,7 @@
window.addEventListener('message', installedHandler);
};
const doHandshake = () => {
const doHandshake = event => {
// This is a representation of features that Stylus is capable of
const implementedFeatures = [
'install-usercss',
@ -106,7 +106,7 @@
&& event.data.type === 'ouc-handshake-question'
&& allowedOrigins.includes(event.origin)
) {
doHandshake();
doHandshake(event);
}
};
@ -129,7 +129,7 @@
&& event.data.type === 'ouc-install-usercss'
&& allowedOrigins.includes(event.origin)
) {
API.installUsercss({
API.usercss.install({
name: event.data.title,
sourceCode: event.data.code,
}).then(style => {

View File

@ -1,19 +1,21 @@
'use strict';
// preventing reregistration if reinjected by tabs.executeScript for whatever reason, just in case
if (typeof self.oldCode !== 'string') {
self.oldCode = (document.querySelector('body > pre') || document.body).textContent;
if (typeof window.oldCode !== 'string') {
window.oldCode = (document.querySelector('body > pre') || document.body).textContent;
chrome.runtime.onConnect.addListener(port => {
if (port.name !== 'downloadSelf') return;
port.onMessage.addListener(({id, force}) => {
fetch(location.href, {mode: 'same-origin'})
.then(r => r.text())
.then(code => ({id, code: force || code !== self.oldCode ? code : null}))
.catch(error => ({id, error: error.message || `${error}`}))
.then(msg => {
port.onMessage.addListener(async ({id, force}) => {
const msg = {id};
try {
const code = await (await fetch(location.href, {mode: 'same-origin'})).text();
if (code !== window.oldCode || force) {
msg.code = window.oldCode = code;
}
} catch (error) {
msg.error = error.message || `${error}`;
}
port.postMessage(msg);
if (msg.code != null) self.oldCode = msg.code;
});
});
// FF keeps content scripts connected on navigation https://github.com/openstyles/stylus/issues/864
addEventListener('pagehide', () => port.disconnect(), {once: true});
@ -21,4 +23,4 @@ if (typeof self.oldCode !== 'string') {
}
// passing the result to tabs.executeScript
self.oldCode; // eslint-disable-line no-unused-expressions
window.oldCode; // eslint-disable-line no-unused-expressions

View File

@ -1,4 +1,4 @@
/* global cloneInto msg API */
/* global API msg */// msg.js
'use strict';
// eslint-disable-next-line no-unused-expressions
@ -14,17 +14,10 @@
msg.on(onMessage);
onDOMready().then(() => {
window.postMessage({
direction: 'from-content-script',
message: 'StylishInstalled',
}, '*');
});
let currentMd5;
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
Promise.all([
API.findStyle({md5Url}),
API.styles.find({md5Url}),
getResource(md5Url),
onDOMready(),
]).then(checkUpdatability);
@ -119,7 +112,7 @@
if (typeof cloneInto !== 'undefined') {
// Firefox requires explicit cloning, however USO can't process our messages anyway
// because USO tries to use a global "event" variable deprecated in Firefox
detail = cloneInto({detail}, document);
detail = cloneInto({detail}, document); /* global cloneInto */
} else {
detail = {detail};
}
@ -154,9 +147,9 @@
function doInstall() {
let oldStyle;
return API.findStyle({
return API.styles.find({
md5Url: getMeta('stylish-md5-url') || location.href,
}, true)
})
.then(_oldStyle => {
oldStyle = _oldStyle;
return oldStyle ?
@ -172,7 +165,7 @@
});
}
function saveStyleCode(message, name, addProps = {}) {
async function saveStyleCode(message, name, addProps = {}) {
const isNew = message === 'styleInstall';
const needsConfirmation = isNew || !saveStyleCode.confirmed;
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
@ -180,22 +173,19 @@
}
saveStyleCode.confirmed = true;
enableUpdateButton(false);
return getStyleJson().then(json => {
const json = await getStyleJson();
if (!json) {
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
'https://github.com/openstyles/stylus/issues/195');
return;
}
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
return API.installStyle(Object.assign(json, addProps, {originalMd5: currentMd5}))
.then(style => {
const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}));
if (!isNew && style.updateUrl.includes('?')) {
enableUpdateButton(true);
} else {
sendEvent({type: 'styleInstalledChrome'});
}
});
});
function enableUpdateButton(state) {
const important = s => s.replace(/;/g, '!important;');
@ -218,50 +208,32 @@
return e ? e.getAttribute('href') : null;
}
function getResource(url, options) {
if (url.startsWith('#')) {
return Promise.resolve(document.getElementById(url.slice(1)).textContent);
async function getResource(url, opts) {
try {
return url.startsWith('#')
? document.getElementById(url.slice(1)).textContent
: await API.download(url, opts);
} catch (error) {
alert('Error\n' + error.message);
return Promise.reject(error);
}
return API.download(Object.assign({
url,
timeout: 60e3,
// USO can't handle POST requests for style json
body: null,
}, options))
.catch(error => {
alert('Error' + (error ? '\n' + error : ''));
throw error;
});
}
// USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
// instead of "https://update.userstyles.org/#####.md5"
function tryFixMd5(style) {
if (style && style.md5Url && style.md5Url.includes('update.update')) {
style.md5Url = style.md5Url.replace('update.update', 'update');
}
return style;
}
function getStyleJson() {
return getResource(getStyleURL(), {responseType: 'json'})
.then(style => {
if (!style || !Array.isArray(style.sections) || style.sections.length) {
return style;
}
async function getStyleJson() {
try {
const style = await getResource(getStyleURL(), {responseType: 'json'});
const codeElement = document.getElementById('stylish-code');
if (codeElement && !codeElement.textContent.trim()) {
if (!style || !Array.isArray(style.sections) || style.sections.length ||
codeElement && !codeElement.textContent.trim()) {
return style;
}
return getResource(getMeta('stylish-update-url'))
.then(code => API.parseCss({code}))
.then(result => {
style.sections = result.sections;
const code = await getResource(getMeta('stylish-update-url'));
style.sections = (await API.worker.parseMozFormat({code})).sections;
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
return style;
});
})
.then(tryFixMd5)
.catch(() => null);
} catch (e) {}
}
/**
@ -295,7 +267,7 @@
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
: new Promise(resolve => window.addEventListener('load', resolve, {once: true}));
}
function openSettings(countdown = 10e3) {
@ -334,6 +306,7 @@
function inPageContext(eventId) {
document.currentScript.remove();
window.isInstalled = true;
const origMethods = {
json: Response.prototype.json,
byId: document.getElementById,

View File

@ -1,6 +1,7 @@
'use strict';
self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
/** @type {function(opts):StyleInjector} */
window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
compare,
onUpdate = () => {},
}) => {
@ -8,8 +9,6 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
const PATCH_ID = 'transition-patch';
// styles are out of order if any of these elements is injected between them
const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']);
// detect Chrome 65 via a feature it added since browser version can be spoofed
const isChromePre65 = chrome.app && typeof Worklet !== 'function';
const docRewriteObserver = RewriteObserver(_sort);
const docRootObserver = RootObserver(_sortIfNeeded);
const list = [];
@ -19,22 +18,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
// will store the original method refs because the page can override them
let creationDoc, createElement, createElementNS;
return {
return /** @namespace StyleInjector */ {
list,
apply(styleMap) {
async apply(styleMap) {
const styles = _styleMapToArray(styleMap);
return (
!styles.length ?
Promise.resolve([]) :
docRootObserver.evade(() => {
const value = !styles.length
? []
: await docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
})
).then(_emitUpdate);
});
_emitUpdate();
return value;
},
clear() {
@ -157,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
docRootObserver[onOff]();
}
function _emitUpdate(value) {
function _emitUpdate() {
_toggleObservers(list.length);
onUpdate();
return value;
}
/*
@ -232,17 +230,8 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
function _update({id, code}) {
const style = table.get(id);
if (style.code === code) return;
if (style.code !== code) {
style.code = code;
// workaround for Chrome devtools bug fixed in v65
if (isChromePre65) {
const oldEl = style.el;
style.el = _createStyle(id, code);
if (isEnabled) {
oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling);
oldEl.remove();
}
} else {
style.el.textContent = code;
}
}

View File

@ -4,8 +4,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
<link rel="stylesheet" href="msgbox/msgbox.css">
<style id="firefox-transitions-bug-suppressor">
/* restrict to FF */
@ -21,94 +19,59 @@
<link id="cm-theme" rel="stylesheet">
<script src="js/polyfill.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/toolbox.js"></script>
<script src="js/msg.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="edit/util.js"></script>
<script src="edit/edit.js"></script> <!-- run it ASAP to send a request for the style -->
<script src="js/sections-util.js"></script>
<script src="edit/base.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
<script src="vendor/codemirror/mode/css/css.js"></script>
<script src="vendor/codemirror/mode/stylus/stylus.js"></script>
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<script src="vendor/codemirror/addon/dialog/dialog.js"></script>
<script src="vendor/codemirror/addon/edit/closebrackets.js"></script>
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="vendor/codemirror/addon/scroll/annotatescrollbar.js"></script>
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="vendor/codemirror/addon/comment/comment.js"></script>
<script src="vendor/codemirror/addon/selection/active-line.js"></script>
<script src="vendor/codemirror/addon/edit/matchbrackets.js"></script>
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
<script src="vendor/codemirror/addon/fold/comment-fold.js"></script>
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/lint/lint.js"></script>
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/hint/show-hint.js"></script>
<script src="vendor/codemirror/addon/hint/css-hint.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
<script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script>
<script src="vendor/lz-string-unsafe/lz-string-unsafe.min.js"></script>
<script src="msgbox/msgbox.js" async></script>
<script src="js/color/color-converter.js"></script>
<script src="js/color/color-mimicry.js"></script>
<script src="js/color/color-picker.js"></script>
<script src="js/color/color-view.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/worker-util.js"></script>
<link href="edit/codemirror-default.css" rel="stylesheet">
<script src="edit/util.js"></script>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/codemirror-default.js"></script>
<script src="edit/codemirror-factory.js"></script>
<script src="edit/regexp-tester.js"></script>
<script src="edit/live-preview.js"></script>
<script src="edit/moz-section-finder.js"></script>
<script src="edit/moz-section-widget.js"></script>
<script src="edit/reroute-hotkeys.js"></script>
<link href="edit/global-search.css" rel="stylesheet">
<script src="edit/global-search.js"></script>
<script src="edit/colorpicker-helper.js"></script>
<script src="edit/linter-manager.js"></script>
<script src="edit/beautify.js"></script>
<script src="edit/show-keymap-help.js"></script>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/source-editor.js"></script>
<script src="edit/sections-editor-section.js"></script>
<script src="edit/sections-editor.js"></script>
<script src="js/worker-util.js"></script>
<script src="edit/linter.js"></script>
<script src="edit/linter-defaults.js"></script>
<script src="edit/linter-engines.js"></script>
<script src="edit/linter-meta.js"></script>
<script src="edit/linter-help-dialog.js"></script>
<script src="edit/linter-report.js"></script>
<script src="edit/linter-config-dialog.js"></script>
<script src="edit/edit.js"></script>
<template data-id="appliesTo">
<li class="applies-to-item">
@ -276,6 +239,16 @@
</tbody>
</table>
</template>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet">
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<link href="js/color/color-picker.css" rel="stylesheet">
<link href="edit/codemirror-default.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
</head>
<body id="stylus-edit">
@ -498,6 +471,5 @@
</symbol>
</svg>
</body>
</html>

234
edit/autocomplete.js Normal file
View File

@ -0,0 +1,234 @@
/* global CodeMirror */
/* global debounce */// toolbox.js
/* global editor */
/* global prefs */
'use strict';
/* Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */
(() => {
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
const cssMime = CodeMirror.mimeModes['text/css'];
const docFuncs = addSuffix(cssMime.documentTypes, '(');
const {tokenHooks} = cssMime;
const originalCommentHook = tokenHooks['/'];
const originalHelper = CodeMirror.hint.css || (() => {});
let cssProps, cssMedia;
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'),
(cm, value) => {
cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
cm[value ? 'on' : 'off']('pick', autocompletePicked);
});
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
tokenHooks['/'] = tokenizeUsoVariables;
function helper(cm) {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
const isStylusLang = cm.doc.mode.name === 'stylus';
const type = style && style.split(' ', 1)[0] || 'prop?';
if (!type || type === 'comment' || type === 'string') {
return originalHelper(cm);
}
// not using getTokenAt until the need is unavoidable because it reparses text
// and runs a whole lot of complex calc inside which is slow on long lines
// especially if autocomplete is auto-shown on each keystroke
let prev, end, state;
let i = index;
while (
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
(prev = i > 2 ? styles[i - 2] : 0) &&
isSameToken(text, style, prev)
) i -= 2;
i = index;
while (
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
(end = styles[i]) &&
isSameToken(text, style, end)
) i += 2;
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
const str = text.slice(prev, end);
const left = text.slice(prev, ch).trim();
let leftLC = left.toLowerCase();
let list = [];
switch (leftLC[0]) {
case '!':
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
break;
case '@':
list = [
'@-moz-document',
'@charset',
'@font-face',
'@import',
'@keyframes',
'@media',
'@namespace',
'@page',
'@supports',
'@viewport',
];
break;
case '#': // prevents autocomplete for #hex colors
break;
case '-': // --variable
case '(': // var(
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
? findAllCssVars(cm, left)
: [];
prev += str.startsWith('(');
leftLC = left;
break;
case '/': // USO vars
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
prev += 4;
end -= 4;
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
leftLC = left.slice(4);
}
break;
case 'u': // url(), url-prefix()
case 'd': // domain()
case 'r': // regexp()
if (/^(variable|tag|error)/.test(type) &&
docFuncs.some(s => s.startsWith(leftLC)) &&
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
end++;
list = docFuncs;
}
break;
default:
// properties and media features
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
if (!cssProps) initCssProps();
if (type === 'prop?') {
prev += leftLC.length;
leftLC = '';
}
list = state === 'atBlock_parens' ? cssMedia : cssProps;
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
end += execAt(rxCONSUME, end, text)[0].length;
} else {
return isStylusLang
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
: originalHelper(cm);
}
}
return {
list: (list || []).filter(s => s.startsWith(leftLC)),
from: {line, ch: prev + str.match(/^\s*/)[0].length},
to: {line, ch: end},
};
}
function initCssProps() {
cssProps = addSuffix(cssMime.propertyKeywords).sort();
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
}
function addSuffix(obj, suffix = ': ') {
return Object.keys(obj).map(k => k + suffix);
}
function getMediaKeys([k, v]) {
return k === 'mediaFeatures' && addSuffix(v) ||
k.startsWith('media') && Object.keys(v);
}
/** makes sure we don't process a different adjacent comment */
function isSameToken(text, style, i) {
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
}
function findAllCssVars(cm, leftPart) {
// simplified regex without CSS escapes
const rx = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'g');
const list = new Set();
cm.eachLine(({text}) => {
for (let m; (m = rx.exec(text));) {
list.add(m[1]);
}
});
return [...list].sort();
}
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] === 'comment') {
const {string, start, pos} = stream;
if (testAt(/\/\*\[\[/y, start, string) &&
testAt(/]]\*\//y, pos - 4, string)) {
const vars = (editor.style.usercssData || {}).vars;
token[0] =
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
? USO_VALID_VAR
: USO_INVALID_VAR;
}
}
return token;
}
function execAt(rx, index, text) {
rx.lastIndex = index;
return rx.exec(text);
}
function testAt(rx, index, text) {
rx.lastIndex = Math.max(0, index);
return rx.test(text);
}
function autocompleteOnTyping(cm, [info], debounced) {
const lastLine = info.text[info.text.length - 1];
if (cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!lastLine) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (lastLine.match(/[-a-z!]+$/i)) {
cm.state.autocompletePicked = false;
cm.options.hintOptions.completeSingle = false;
cm.execCommand('autocomplete');
setTimeout(() => {
cm.options.hintOptions.completeSingle = true;
});
}
}
function autocompletePicked(cm) {
cm.state.autocompletePicked = true;
}
})();

412
edit/base.js Normal file
View File

@ -0,0 +1,412 @@
/* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
/* global API */// msg.js
/* global CODEMIRROR_THEMES */
/* global CodeMirror */
/* global MozDocMapper */// sections-util.js
/* global initBeautifyButton */// beautify.js
/* global prefs */
/* global t */// localization.js
/* global
FIREFOX
debounce
getOwnTab
sessionStore
tryCatch
tryJSONparse
*/// toolbox.js
'use strict';
/**
* @type Editor
* @namespace Editor
*/
const editor = {
dirty: DirtyReporter(),
isUsercss: false,
isWindowed: false,
livePreview: null,
/** @type {'customName'|'name'} */
nameTarget: 'name',
previewDelay: 200, // Chrome devtools uses 200
scrollInfo: null,
updateTitle(isDirty = editor.dirty.isDirty()) {
const {customName, name} = editor.style;
document.title = `${
isDirty ? '* ' : ''
}${
customName || name || t('styleMissingName')
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
},
};
//#region pre-init
const baseInit = (() => {
const lazyKeymaps = {
emacs: '/vendor/codemirror/keymap/emacs',
vim: '/vendor/codemirror/keymap/vim',
};
const domReady = waitForSelector('#sections');
return {
domReady,
ready: Promise.all([
domReady,
loadStyle(),
prefs.ready.then(() =>
Promise.all([
loadTheme(),
loadKeymaps(),
])),
]),
};
/** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
function loadKeymaps() {
const km = prefs.get('editor.keyMap');
return /emacs/i.test(km) && require([lazyKeymaps.emacs]) ||
/vim/i.test(km) && require([lazyKeymaps.vim]);
}
async function loadStyle() {
const params = new URLSearchParams(location.search);
const id = Number(params.get('id'));
const style = id && await API.styles.get(id) || {
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
enabled: true,
sections: [
MozDocMapper.toSection([...params], {code: ''}),
],
};
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
editor.lazyKeymaps = lazyKeymaps;
editor.style = style;
editor.updateTitle(false);
document.documentElement.classList.toggle('usercss', editor.isUsercss);
sessionStore.justEditedStyleId = style.id || '';
// no such style so let's clear the invalid URL parameters
if (!style.id) history.replaceState({}, '', location.pathname);
}
/** Preloads the theme so CodeMirror can use the correct metrics in its first render */
async function loadTheme() {
const theme = prefs.get('editor.theme');
if (theme !== 'default') {
const el = $('#cm-theme');
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
el2.id = el.id;
el.remove();
if (!el2.sheet) {
prefs.set('editor.theme', 'default');
}
}
}
})();
//#endregion
//#region init layout/resize
baseInit.domReady.then(() => {
let headerHeight;
detectLayout(true);
window.on('resize', () => detectLayout());
function detectLayout(now) {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
if (now) fixedHeader();
else debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
document.body.classList.remove('compact-layout', 'fixed-header');
window.off('scroll', fixedHeader);
}
for (const type of ['options', 'toc', 'lint']) {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
if (window.scrollY >= scrollPoint && !headerFixed) {
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
$('body').classList.add('fixed-header');
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
});
//#endregion
//#region init header
baseInit.ready.then(() => {
initBeautifyButton($('#beautify'));
initKeymapElement();
initNameArea();
initThemeElement();
setupLivePrefs();
$('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !editor.style.id);
require(Object.values(editor.lazyKeymaps), () => {
initKeymapElement();
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
});
function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command);
if (key) {
return key;
}
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
key = ft && findKeyForCommand(command, ft);
if (key) {
return key;
}
}
return '';
}
function initNameArea() {
const nameEl = $('#name');
const resetEl = $('#reset-name');
const isCustomName = editor.style.updateUrl || editor.isUsercss;
editor.nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => {
editor.updateName(true);
resetEl.hidden = false;
});
resetEl.hidden = !editor.style.customName;
resetEl.onclick = () => {
const {style} = editor;
nameEl.focus();
nameEl.select();
// trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name;
editor.updateName(true);
}
style.customName = null; // to delete it from db
resetEl.hidden = true;
};
const enabledEl = $('#enabled');
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
}
function initThemeElement() {
$('#editor.theme').append(...[
$create('option', {value: 'default'}, t('defaultTheme')),
...CODEMIRROR_THEMES.map(s => $create('option', s)),
]);
// move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme'));
}
function initKeymapElement() {
// move 'pc' or 'mac' prefix to the end of the displayed label
const maps = Object.keys(CodeMirror.keyMap)
.map(name => ({
value: name,
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
}))
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
const fragment = document.createDocumentFragment();
let bin = fragment;
let groupName;
// group suffixed maps in <optgroup>
maps.forEach(({value, name}, i) => {
groupName = !name.includes('-') ? name : groupName;
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
if (groupWithNext) {
if (bin === fragment) {
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
}
}
const el = bin.appendChild($create('option', {value}, name));
if (value === prefs.defaults['editor.keyMap']) {
el.dataset.default = '';
el.title = t('defaultTheme');
}
if (!groupWithNext) bin = fragment;
});
const selector = $('#editor.keyMap');
selector.textContent = '';
selector.appendChild(fragment);
selector.value = prefs.get('editor.keyMap');
}
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
const extraKeys = CodeMirror.defaults.extraKeys;
for (const el of $$('[data-hotkey-tooltip]')) {
if (el._hotkeyTooltipKeyMap !== mapName) {
el._hotkeyTooltipKeyMap = mapName;
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
const cmd = el.dataset.hotkeyTooltip;
const key = cmd[0] === '=' ? cmd.slice(1) :
findKeyForCommand(cmd, mapName) ||
extraKeys && findKeyForCommand(cmd, extraKeys);
const newTitle = title + (title && key ? '\n' : '') + (key || '');
if (el.title !== newTitle) el.title = newTitle;
}
}
}
});
//#endregion
//#region init windowed mode
(() => {
let ownTabId;
if (chrome.windows) {
initWindowedMode();
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
// resize the window on 'undo close'
if (pos && pos.left != null) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
getOwnTab().then(async tab => {
ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
await baseInit.domReady;
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
}
});
async function initWindowedMode() {
chrome.tabs.onAttached.addListener(onTabAttached);
const isSimple = (await browser.windows.getCurrent()).type === 'popup';
if (isSimple) require(['/edit/embedded-popup']);
editor.isWindowed = isSimple || (
history.length === 1 &&
await prefs.ready && prefs.get('openEditInWindow') &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
}
async function onTabAttached(tabId, info) {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
const win = await browser.windows.get(info.newWindowId, {populate: true});
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
// FF-only because Chrome retardedly resets the size during dragging
if (openEditInWindow && FIREFOX) {
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
}
})();
//#endregion
//#region internals
/** @returns DirtyReporter */
function DirtyReporter() {
const data = new Map();
const listeners = new Set();
const notifyChange = wasDirty => {
if (wasDirty !== (data.size > 0)) {
listeners.forEach(cb => cb());
}
};
/** @namespace DirtyReporter */
return {
add(obj, value) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
data.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
data.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
}
}
notifyChange(wasDirty);
},
clear(obj) {
const wasDirty = data.size > 0;
if (obj === undefined) {
data.clear();
} else {
data.delete(obj);
}
notifyChange(wasDirty);
},
has(key) {
return data.has(key);
},
isDirty() {
return data.size > 0;
},
modify(obj, oldValue, newValue) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
if (oldValue !== newValue) {
data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
data.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
notifyChange(wasDirty);
},
onChange(cb, add = true) {
listeners[add ? 'add' : 'delete'](cb);
},
remove(obj, value) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
data.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
data.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
notifyChange(wasDirty);
},
};
}
//#endregion

View File

@ -1,20 +1,18 @@
/* global loadScript css_beautify showHelp prefs t $ $create */
/* global editor createHotkeyInput moveFocus CodeMirror */
/* exported initBeautifyButton */
/* global $ $create moveFocus */// dom.js
/* global CodeMirror */
/* global createHotkeyInput helpPopup */// util.js
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
const HOTKEY_ID = 'editor.beautify.hotkey';
prefs.initializing.then(() => {
CodeMirror.defaults.extraKeys[prefs.get(HOTKEY_ID) || ''] = 'beautify';
CodeMirror.commands.beautify = cm => {
// using per-section mode when code editor or applies-to block is focused
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
beautify(isPerSection ? [cm] : editor.getEditors(), false);
};
});
prefs.subscribe([HOTKEY_ID], (key, value) => {
prefs.subscribe('editor.beautify.hotkey', (key, value) => {
const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') {
@ -25,50 +23,28 @@ prefs.subscribe([HOTKEY_ID], (key, value) => {
if (value) {
extraKeys[value] = 'beautify';
}
});
/**
* @param {HTMLElement} btn - the button element shown in the UI
* @param {function():CodeMirror[]} getScope
*/
function initBeautifyButton(btn, getScope) {
btn.addEventListener('click', () => beautify(getScope()));
btn.addEventListener('contextmenu', e => {
e.preventDefault();
beautify(getScope(), false);
});
}
}, {runNow: true});
/**
* @name beautify
* @param {CodeMirror[]} scope
* @param {?boolean} ui
* @param {boolean} [ui=true]
*/
function beautify(scope, ui = true) {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
window.css_beautify = window.exports.css_beautify;
}
})
.then(doBeautify);
function doBeautify() {
async function beautify(scope, ui = true) {
await require(['/vendor-overwrites/beautify/beautify-css-mod']); /* global css_beautify */
const tabs = prefs.get('editor.indentWithTabs');
const options = Object.assign({}, prefs.get('editor.beautify'));
for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
if (!(k in options)) options[k] = prefs.defaults['editor.beautify'][k];
}
const options = Object.assign(prefs.defaults['editor.beautify'], prefs.get('editor.beautify'));
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
options.indent_char = tabs ? '\t' : ' ';
if (ui) {
createBeautifyUI(scope, options);
}
for (const cm of scope) {
setTimeout(doBeautifyEditor, 0, cm, options);
setTimeout(beautifyEditor, 0, cm, options, ui);
}
}
function doBeautifyEditor(cm, options) {
function beautifyEditor(cm, options, ui) {
const pos = options.translate_positions =
[].concat.apply([], cm.doc.sel.ranges.map(r =>
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
@ -95,7 +71,7 @@ function beautify(scope, ui = true) {
}
function createBeautifyUI(scope, options) {
showHelp(t('styleBeautify'),
helpPopup.show(t('styleBeautify'),
$create([
$create('.beautify-options', [
$createOption('.selector1,', 'selector_separator_newline'),
@ -109,13 +85,12 @@ function beautify(scope, ui = true) {
]),
$create('p.beautify-hint', [
$create('span', t('styleBeautifyHint') + '\u00A0'),
createHotkeyInput(HOTKEY_ID, () => moveFocus($('#help-popup'), 1)),
createHotkeyInput('editor.beautify.hotkey', () => moveFocus($('#help-popup'), 0)),
]),
$create('.buttons', [
$create('button', {
attributes: {role: 'close'},
// showHelp.close will be defined after showHelp() is invoked
onclick: () => showHelp.close(),
onclick: helpPopup.close,
}, t('confirmClose')),
$create('button', {
attributes: {role: 'undo'},
@ -145,7 +120,7 @@ function beautify(scope, ui = true) {
if (target.parentNode.hasAttribute('newline')) {
target.parentNode.setAttribute('newline', value.toString());
}
doBeautify();
beautify(scope, false);
};
function $createOption(label, optionName, indent) {
@ -185,4 +160,11 @@ function beautify(scope, ui = true) {
);
}
}
/* exported initBeautifyButton */
function initBeautifyButton(btn, scope) {
btn.onclick = btn.oncontextmenu = e => {
e.preventDefault();
beautify(scope || editor.getEditors(), e.type === 'click');
};
}

View File

@ -16,9 +16,6 @@
/* Not using the ring-color hack as it became ugly in new Chrome */
outline: none !important;
}
.CodeMirror-lint-mark-warning {
background: none;
}
.CodeMirror-dialog {
animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
}

View File

@ -1,13 +1,11 @@
/* global
$
CodeMirror
prefs
t
*/
/* global $ */// dom.js
/* global CodeMirror */
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
(function () {
(() => {
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap');
@ -43,6 +41,7 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
require(Object.values(typeof editor === 'object' && editor.lazyKeymaps || {}), () => {
const KM = CodeMirror.keyMap;
const extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) {
@ -90,6 +89,7 @@
}
}
}
});
const cssMime = CodeMirror.mimeModes['text/css'];
Object.assign(cssMime.propertyKeywords, {
@ -142,12 +142,16 @@
jumpToPos(pos, end = pos) {
const {curOp} = this;
if (!curOp) this.startOperation();
const coords = this.cursorCoords(pos, 'window');
const b = this.display.wrapper.getBoundingClientRect();
if (coords.top < Math.max(0, b.top + this.defaultTextHeight() * 2) ||
coords.bottom > Math.min(window.innerHeight, b.bottom - 100)) {
this.scrollIntoView(pos, b.height / 2);
const y = this.cursorCoords(pos, 'window').top;
const rect = this.display.wrapper.getBoundingClientRect();
// case 1) outside of CM viewport or too close to edge so tell CM to render a new viewport
if (y < rect.top + 50 || y > rect.bottom - 100) {
this.scrollIntoView(pos, rect.height / 2);
// case 2) inside CM viewport but outside of window viewport so just scroll the window
} else if (y < 0 || y > innerHeight) {
editor.scrollToEditor(this);
}
// Using prototype since our bookmark patch sets cm.setSelection to jumpToPos
CodeMirror.prototype.setSelection.call(this, pos, end);
if (!curOp) this.endOperation();
},

View File

@ -1,25 +1,24 @@
/* global
$
CodeMirror
debounce
editor
loadScript
prefs
rerouteHotkeys
*/
/* global $ */// dom.js
/* global CodeMirror */
/* global editor */
/* global prefs */
/* global rerouteHotkeys */// util.js
'use strict';
//#region cmFactory
(() => {
/*
All cm instances created by this module are collected so we can broadcast prefs
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
when the instance is not used anymore.
*/
(() => {
//#region Factory
const cms = new Set();
let lazyOpt;
const cmFactory = window.cmFactory = {
create(place, options) {
const cm = CodeMirror(place, options);
const {wrapper} = cm.display;
@ -38,9 +37,11 @@
cms.add(cm);
return cm;
},
destroy(cm) {
cms.delete(cm);
},
globalSetOption(key, value) {
CodeMirror.defaults[key] = value;
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
@ -52,28 +53,28 @@
};
const handledPrefs = {
// handled in colorpicker-helper.js
'editor.colorpicker'() {},
/** @returns {?Promise<void>} */
'editor.theme'(key, value) {
const elt = $('#cm-theme');
'editor.colorpicker'() {}, // handled in colorpicker-helper.js
async 'editor.theme'(key, value) {
let el2;
const el = $('#cm-theme');
if (value === 'default') {
elt.href = '';
el.href = '';
} else {
const url = chrome.runtime.getURL(`vendor/codemirror/theme/${value}.css`);
if (url !== elt.href) {
const path = `/vendor/codemirror/theme/${value}.css`;
if (el.href !== location.origin + path) {
// avoid flicker: wait for the second stylesheet to load, then apply the theme
return loadScript(url, true).then(([newElt]) => {
cmFactory.globalSetOption('theme', value);
elt.remove();
newElt.id = elt.id;
});
el2 = await require([path]);
}
}
cmFactory.globalSetOption('theme', value);
if (el2) {
el.remove();
el2.id = el.id;
}
},
};
const pref2opt = k => k.slice('editor.'.length);
const mirroredPrefs = Object.keys(prefs.defaults).filter(k =>
const mirroredPrefs = prefs.knownKeys.filter(k =>
!handledPrefs[k] &&
k.startsWith('editor.') &&
Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
@ -125,12 +126,14 @@
return lazyOpt._observer;
},
};
})();
//#endregion
//#endregion
//#region Commands
(() => {
Object.assign(CodeMirror.commands, {
commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
},
toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
@ -139,9 +142,6 @@
cm.focus();
}
},
commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
},
});
for (const cmd of [
'nextEditor',
@ -151,11 +151,10 @@
]) {
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
}
})();
//#endregion
//#endregion
//#region CM option handlers
(() => {
const {insertTab, insertSoftTab} = CodeMirror.commands;
Object.entries({
tabSize(cm, value) {
@ -164,11 +163,6 @@
indentWithTabs(cm, value) {
CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
},
autocompleteOnTyping(cm, value) {
const onOff = value ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
},
matchHighlight(cm, value) {
const showToken = value === 'token' && /[#.\-\w]/;
const opt = (showToken || value === 'selection') && {
@ -246,237 +240,12 @@
};
}
function autocompleteOnTyping(cm, [info], debounced) {
const lastLine = info.text[info.text.length - 1];
if (
cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!lastLine
) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (lastLine.match(/[-a-z!]+$/i)) {
cm.state.autocompletePicked = false;
cm.options.hintOptions.completeSingle = false;
cm.execCommand('autocomplete');
setTimeout(() => {
cm.options.hintOptions.completeSingle = true;
});
}
}
function autocompletePicked(cm) {
cm.state.autocompletePicked = true;
}
})();
//#endregion
//#region Autocomplete
(() => {
const AT_RULES = [
'@-moz-document',
'@charset',
'@font-face',
'@import',
'@keyframes',
'@media',
'@namespace',
'@page',
'@supports',
'@viewport',
];
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
const cssMime = CodeMirror.mimeModes['text/css'];
const docFuncs = addSuffix(cssMime.documentTypes, '(');
const {tokenHooks} = cssMime;
const originalCommentHook = tokenHooks['/'];
const originalHelper = CodeMirror.hint.css || (() => {});
let cssProps, cssMedia;
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
tokenHooks['/'] = tokenizeUsoVariables;
function helper(cm) {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
const isStylusLang = cm.doc.mode.name === 'stylus';
const type = style && style.split(' ', 1)[0] || 'prop?';
if (!type || type === 'comment' || type === 'string') {
return originalHelper(cm);
}
// not using getTokenAt until the need is unavoidable because it reparses text
// and runs a whole lot of complex calc inside which is slow on long lines
// especially if autocomplete is auto-shown on each keystroke
let prev, end, state;
let i = index;
while (
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
(prev = i > 2 ? styles[i - 2] : 0) &&
isSameToken(text, style, prev)
) i -= 2;
i = index;
while (
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
(end = styles[i]) &&
isSameToken(text, style, end)
) i += 2;
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
const str = text.slice(prev, end);
const left = text.slice(prev, ch).trim();
let leftLC = left.toLowerCase();
let list = [];
switch (leftLC[0]) {
case '!':
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
break;
case '@':
list = AT_RULES;
break;
case '#': // prevents autocomplete for #hex colors
break;
case '-': // --variable
case '(': // var(
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
? findAllCssVars(cm, left)
: [];
prev += str.startsWith('(');
leftLC = left;
break;
case '/': // USO vars
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
prev += 4;
end -= 4;
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
leftLC = left.slice(4);
}
break;
case 'u': // url(), url-prefix()
case 'd': // domain()
case 'r': // regexp()
if (/^(variable|tag|error)/.test(type) &&
docFuncs.some(s => s.startsWith(leftLC)) &&
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
end++;
list = docFuncs;
}
break;
default:
// properties and media features
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
if (!cssProps) initCssProps();
if (type === 'prop?') {
prev += leftLC.length;
leftLC = '';
}
list = state === 'atBlock_parens' ? cssMedia : cssProps;
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
end += execAt(rxCONSUME, end, text)[0].length;
} else {
return isStylusLang
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
: originalHelper(cm);
}
}
return {
list: (list || []).filter(s => s.startsWith(leftLC)),
from: {line, ch: prev + str.match(/^\s*/)[0].length},
to: {line, ch: end},
};
}
function initCssProps() {
cssProps = addSuffix(cssMime.propertyKeywords).sort();
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
}
function addSuffix(obj, suffix = ': ') {
return Object.keys(obj).map(k => k + suffix);
}
function getMediaKeys([k, v]) {
return k === 'mediaFeatures' && addSuffix(v) ||
k.startsWith('media') && Object.keys(v);
}
/** makes sure we don't process a different adjacent comment */
function isSameToken(text, style, i) {
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
}
function findAllCssVars(cm, leftPart) {
// simplified regex without CSS escapes
const rx = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'g');
const list = new Set();
cm.eachLine(({text}) => {
for (let m; (m = rx.exec(text));) {
list.add(m[1]);
}
});
return [...list].sort();
}
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] === 'comment') {
const {string, start, pos} = stream;
if (testAt(/\/\*\[\[/y, start, string) &&
testAt(/]]\*\//y, pos - 4, string)) {
const vars = (editor.style.usercssData || {}).vars;
token[0] =
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
? USO_VALID_VAR
: USO_INVALID_VAR;
}
}
return token;
}
function execAt(rx, index, text) {
rx.lastIndex = index;
return rx.exec(text);
}
function testAt(rx, index, text) {
rx.lastIndex = Math.max(0, index);
return rx.test(text);
}
})();
//#endregion
//#region Bookmarks
(() => {
const CLS = 'gutter-bookmark';
const BRAND = 'sublimeBookmark';
const CLICK_AREA = 'CodeMirror-linenumbers';
const BM_CLS = 'gutter-bookmark';
const BM_BRAND = 'sublimeBookmark';
const BM_CLICKER = 'CodeMirror-linenumbers';
const {markText} = CodeMirror.prototype;
for (const name of ['prevBookmark', 'nextBookmark']) {
const cmdFn = CodeMirror.commands[name];
@ -494,27 +263,29 @@
Object.assign(CodeMirror.prototype, {
markText() {
const marker = markText.apply(this, arguments);
if (marker[BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', CLS);
if (marker[BM_BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', BM_CLS);
marker.clear = clearMarker;
}
return marker;
},
});
function clearMarker() {
const line = this.lines[0];
const spans = line.markedSpans;
delete this.clear; // removing our patch from the instance...
this.clear(); // ...and using the original prototype
if (!spans || spans.some(span => span.marker[BRAND])) {
this.doc.removeLineClass(line, 'gutter', CLS);
if (!spans || spans.some(span => span.marker[BM_BRAND])) {
this.doc.removeLineClass(line, 'gutter', BM_CLS);
}
}
function onGutterClick(cm, line, name, e) {
switch (name === CLICK_AREA && e.button) {
switch (name === BM_CLICKER && e.button) {
case 0: {
// main button: toggle
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BRAND]);
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BM_BRAND]);
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
cm.execCommand('toggleBookmark');
break;
@ -525,11 +296,13 @@
break;
}
}
function onGutterContextMenu(cm, line, name, e) {
if (name === CLICK_AREA) {
if (name === BM_CLICKER) {
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
e.preventDefault();
}
}
})();
//#endregion
})();

View File

@ -1,81 +0,0 @@
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
'use strict';
(() => {
onDOMready().then(() => {
$('#colorpicker-settings').onclick = configureColorpicker;
});
prefs.subscribe('editor.colorpicker.hotkey', registerHotkey);
prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true});
function setColorpickerOption(id, enabled) {
const defaults = CodeMirror.defaults;
const keyName = prefs.get('editor.colorpicker.hotkey');
defaults.colorpicker = enabled;
if (enabled) {
if (keyName) {
CodeMirror.commands.colorpicker = invokeColorpicker;
defaults.extraKeys = defaults.extraKeys || {};
defaults.extraKeys[keyName] = 'colorpicker';
}
defaults.colorpicker = {
tooltip: t('colorpickerTooltip'),
popup: {
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
paletteLine: t('numberedLine'),
paletteHint: t('colorpickerPaletteHint'),
hexUppercase: prefs.get('editor.colorpicker.hexUppercase'),
embedderCallback: state => {
['hexUppercase', 'color']
.filter(name => state[name] !== prefs.get('editor.colorpicker.' + name))
.forEach(name => prefs.set('editor.colorpicker.' + name, state[name]));
},
get maxHeight() {
return prefs.get('editor.colorpicker.maxHeight');
},
set maxHeight(h) {
prefs.set('editor.colorpicker.maxHeight', h);
},
},
};
} else {
if (defaults.extraKeys) {
delete defaults.extraKeys[keyName];
}
}
cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
}
function registerHotkey(id, hotkey) {
CodeMirror.commands.colorpicker = invokeColorpicker;
const extraKeys = CodeMirror.defaults.extraKeys;
for (const key in extraKeys) {
if (extraKeys[key] === 'colorpicker') {
delete extraKeys[key];
break;
}
}
if (hotkey) {
extraKeys[hotkey] = 'colorpicker';
}
}
function invokeColorpicker(cm) {
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
}
function configureColorpicker(event) {
event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', () => {
$('#help-popup .dismiss').onclick();
});
const popup = showHelp(t('helpKeyMapHotkey'), input);
if (this instanceof Element) {
const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
}
input.focus();
}
})();

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +1,48 @@
/* global importScripts workerUtil CSSLint require metaParser */
/* global createWorkerApi */// worker-util.js
'use strict';
importScripts('/js/worker-util.js');
const {loadScript} = workerUtil;
(() => {
const {require} = self; // self.require will be overwritten by StyleLint
/** @namespace EditorWorker */
workerUtil.createAPI({
csslint: (code, config) => {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/vendor-overwrites/csslint/csslint.js');
return CSSLint.verify(code, config).messages
createWorkerApi({
async csslint(code, config) {
require(['/js/csslint/parserlib', '/js/csslint/csslint']); /* global CSSLint */
return CSSLint
.verify(code, config).messages
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
},
stylelint: async (code, config) => {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
const {results: [res]} = await require('stylelint').lint({code, config});
delete res._postcssResult; // huge and unused
return res;
getRules(linter) {
return ruleRetriever[linter](); // eslint-disable-line no-use-before-define
},
metalint: code => {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
metalint(code) {
require(['/js/meta-parser']); /* global metaParser */
const result = metaParser.lint(code);
// extract needed info
result.errors = result.errors.map(err =>
({
result.errors = result.errors.map(err => ({
code: err.code,
args: err.args,
message: err.message,
index: err.index,
})
);
}));
return result;
},
getStylelintRules,
getCsslintRules,
async stylelint(code, config) {
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
const {results: [res]} = await self.require('stylelint').lint({code, config});
delete res._postcssResult; // huge and unused
return res;
},
});
function getCsslintRules() {
loadScript('/vendor-overwrites/csslint/csslint.js');
const ruleRetriever = {
csslint() {
require(['/js/csslint/csslint']);
return CSSLint.getRules().map(rule => {
const output = {};
for (const [key, value] of Object.entries(rule)) {
@ -50,16 +52,15 @@ function getCsslintRules() {
}
return output;
});
}
},
function getStylelintRules() {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
const stylelint = require('stylelint');
stylelint() {
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
const options = {};
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
const rxString = /"([-\w\s]{3,}?)"/g;
for (const id of Object.keys(stylelint.rules)) {
const ruleCode = String(stylelint.rules[id]);
for (const [id, rule] of Object.entries(self.require('stylelint').rules)) {
const ruleCode = `${rule}`;
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
@ -88,4 +89,6 @@ function getStylelintRules() {
}
}
return options;
}
},
};
})();

114
edit/embedded-popup.js Normal file
View File

@ -0,0 +1,114 @@
/* global $ $create $remove getEventKeyName */// dom.js
/* global CodeMirror */
/* global baseInit */// base.js
/* global prefs */
/* global t */// localization.js
'use strict';
(() => {
const ID = 'popup-iframe';
const SEL = '#' + ID;
const URL = chrome.runtime.getManifest().browser_action.default_popup;
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
/** @type {HTMLIFrameElement} */
let frame;
let isLoaded;
let scrollbarWidth;
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
document.documentElement.appendChild(btn);
baseInit.domReady.then(() => {
document.body.appendChild(btn);
// Adding a dummy command to show in keymap help popup
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup';
});
prefs.subscribe('iconset', (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
}, {runNow: true});
window.on('keydown', e => {
if (getEventKeyName(e) === POPUP_HOTKEY) {
embedPopup();
}
});
function embedPopup() {
if ($(SEL)) return;
isLoaded = false;
scrollbarWidth = 0;
frame = $create('iframe', {
id: ID,
src: URL,
height: 600,
width: prefs.get('popupWidth'),
onload: initFrame,
});
window.on('mousedown', removePopup);
document.body.appendChild(frame);
}
function initFrame() {
frame = this;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', removePopupOnEsc);
pw.close = removePopup;
if (pw.IntersectionObserver) {
new pw.IntersectionObserver(onIntersect).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(onMutation).observe(body, {
attributes: true,
attributeFilter: ['style'],
});
}
function onMutation() {
const body = frame.contentDocument.body;
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}
function onIntersect([e]) {
const pw = frame.contentWindow;
const el = pw.document.scrollingElement;
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
const hasSB = h > el.offsetHeight;
const {width} = e.boundingClientRect;
frame.height = h;
if (!hasSB !== !scrollbarWidth || frame.width - width) {
scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + scrollbarWidth;
}
if (!isLoaded) {
isLoaded = true;
frame.dataset.loaded = '';
}
}
function removePopup() {
frame = null;
$remove(SEL);
window.off('mousedown', removePopup);
}
function removePopupOnEsc(e) {
if (getEventKeyName(e) === 'Escape') {
removePopup();
}
}
})();

View File

@ -1,21 +1,14 @@
/* global
$
$$
$create
chromeLocal
CodeMirror
colorMimicry
debounce
editor
focusAccessibility
onDOMready
stringAsRegExp
t
tryRegExp
*/
/* global $ $$ $create $remove focusAccessibility */// dom.js
/* global CodeMirror */
/* global chromeLocal */// storage-util.js
/* global colorMimicry */
/* global debounce stringAsRegExp tryRegExp */// toolbox.js
/* global editor */
/* global t */// localization.js
'use strict';
onDOMready().then(() => {
(() => {
require(['/edit/global-search.css']);
//region Constants and state
@ -138,13 +131,13 @@ onDOMready().then(() => {
},
onfocusout() {
if (!state.dialog.contains(document.activeElement)) {
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
state.dialog.on('focusin', EVENTS.onfocusin);
state.dialog.off('focusout', EVENTS.onfocusout);
}
},
onfocusin() {
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
state.dialog.on('focusout', EVENTS.onfocusout);
state.dialog.off('focusin', EVENTS.onfocusin);
trimUndoHistory();
enableUndoButton(state.undoHistory.length);
if (state.find) doSearch({canAdvance: false});
@ -189,7 +182,6 @@ onDOMready().then(() => {
Object.assign(CodeMirror.commands, COMMANDS);
readStorage();
return;
//region Find
@ -577,7 +569,7 @@ onDOMready().then(() => {
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
Object.assign(dialog, DIALOG_PROPS.dialog);
dialog.addEventListener('focusout', EVENTS.onfocusout);
dialog.on('focusout', EVENTS.onfocusout);
dialog.dataset.type = type;
dialog.style.pointerEvents = 'auto';
@ -590,9 +582,9 @@ onDOMready().then(() => {
state.tally = $('[data-type="tally"]', dialog);
const colors = {
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
body: colorMimicry(document.body, {bg: 'backgroundColor'}),
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
};
document.documentElement.appendChild(
$(DIALOG_STYLE_SELECTOR) ||
@ -652,7 +644,7 @@ onDOMready().then(() => {
function destroyDialog({restoreFocus = false} = {}) {
state.input = null;
$.remove(DIALOG_SELECTOR);
$remove(DIALOG_SELECTOR);
debounce.unregister(doSearch);
makeTargetVisible(null);
if (restoreFocus) {
@ -795,7 +787,6 @@ onDOMready().then(() => {
});
if (!cm.curOp) cm.startOperation();
if (!state.firstRun) {
editor.scrollToEditor(cm);
cm.jumpToPos(pos.from, pos.to);
}
// focus or expose as the current search target
@ -960,4 +951,4 @@ onDOMready().then(() => {
}
//endregion
});
})();

View File

@ -1,197 +0,0 @@
/* global memoize editorWorker showCodeMirrorPopup loadScript messageBox
LINTER_DEFAULTS rerouteHotkeys $ $create $createLink tryJSONparse t
chromeSync */
'use strict';
(() => {
document.addEventListener('DOMContentLoaded', () => {
$('#linter-settings').addEventListener('click', showLintConfig);
}, {once: true});
function stringifyConfig(config) {
return JSON.stringify(config, null, 2)
.replace(/,\n\s+\{\n\s+("severity":\s"\w+")\n\s+\}/g, ', {$1}');
}
function showLinterErrorMessage(title, contents, popup) {
messageBox({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
}).then(() => popup && popup.codebox && popup.codebox.focus());
}
function showLintConfig() {
const linter = $('#editor.linter').value;
if (!linter) {
return;
}
const storageName = chromeSync.LZ_KEY[linter];
const getRules = memoize(linter === 'stylelint' ?
editorWorker.getStylelintRules : editorWorker.getCsslintRules);
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
const defaultConfig = stringifyConfig(
linter === 'stylelint' ? LINTER_DEFAULTS.STYLELINT : LINTER_DEFAULTS.CSSLINT
);
const title = t('linterConfigPopupTitle', linterTitle);
const popup = showCodeMirrorPopup(title, null, {
lint: false,
extraKeys: {'Ctrl-Enter': save},
hintOptions: {hint},
});
$('.contents', popup).appendChild(makeFooter());
let cm = popup.codebox;
cm.focus();
chromeSync.getLZValue(storageName).then(config => {
cm.setValue(config ? stringifyConfig(config) : defaultConfig);
cm.clearHistory();
cm.markClean();
updateButtonState();
});
cm.on('changes', updateButtonState);
rerouteHotkeys(false);
window.addEventListener('closeHelp', () => {
rerouteHotkeys(true);
cm = null;
}, {once: true});
loadScript([
'/vendor/codemirror/mode/javascript/javascript.js',
'/vendor/codemirror/addon/lint/json-lint.js',
'/vendor/jsonlint/jsonlint.js',
]).then(() => {
cm.setOption('mode', 'application/json');
cm.setOption('lint', true);
});
function findInvalidRules(config, linter) {
return getRules()
.then(rules => {
if (linter === 'stylelint') {
return Object.keys(config.rules).filter(k => !config.rules.hasOwnProperty(k));
}
const ruleSet = new Set(rules.map(r => r.id));
return Object.keys(config).filter(k => !ruleSet.has(k));
});
}
function makeFooter() {
return $create('div', [
$create('p', [
$createLink(
linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
t('linterRulesLink')),
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
]),
$create('.buttons', [
$create('button.save', {onclick: save, title: 'Ctrl-Enter'}, t('styleSaveLabel')),
$create('button.cancel', {onclick: cancel}, t('confirmClose')),
$create('button.reset', {onclick: reset, title: t('linterResetMessage')}, t('genericResetLabel')),
]),
]);
}
function save(event) {
if (event instanceof Event) {
event.preventDefault();
}
const json = tryJSONparse(cm.getValue());
if (!json) {
showLinterErrorMessage(linter, t('linterJSONError'), popup);
cm.focus();
return;
}
findInvalidRules(json, linter).then(invalid => {
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$create('ul', invalid.map(name => $create('li', name))),
], popup);
return;
}
chromeSync.setLZValue(storageName, json);
cm.markClean();
cm.focus();
updateButtonState();
});
}
function reset(event) {
event.preventDefault();
cm.setValue(defaultConfig);
cm.focus();
updateButtonState();
}
function cancel(event) {
event.preventDefault();
$('.dismiss').dispatchEvent(new Event('click'));
}
function updateButtonState() {
$('.save', popup).disabled = cm.isClean();
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
}
function hint(cm) {
return getRules().then(rules => {
let ruleIds, options;
if (linter === 'stylelint') {
ruleIds = Object.keys(rules);
options = rules;
} else {
ruleIds = rules.map(r => r.id);
options = {};
}
const cursor = cm.getCursor();
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
const {line, ch} = cursor;
const quoted = string.startsWith('"');
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
const depth = getLexicalDepth(lexical);
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
let [, prevWord] = search.find(true) || [];
let words = [];
if (depth === 1 && linter === 'stylelint') {
words = quoted ? ['rules'] : [];
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
words = ruleIds;
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
words = !quoted ? ['true', 'false', 'null'] :
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
} else if (depth === 4 && prevWord === 'severity') {
words = ['error', 'warning'];
} else if (depth === 4) {
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
} else if (depth === 5 && lexical.type === ']' && quoted) {
while (prevWord && !ruleIds.includes(prevWord)) {
prevWord = (search.find(true) || [])[1];
}
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
}
return {
list: words.filter(word => word.startsWith(leftPart)),
from: {line, ch: start + (quoted ? 1 : 0)},
to: {line, ch: string.endsWith('"') ? end - 1 : end},
};
});
}
function getLexicalDepth(lexicalState) {
let depth = 0;
while ((lexicalState = lexicalState.prev)) {
depth++;
}
return depth;
}
}
})();

View File

@ -1,222 +0,0 @@
/* exported LINTER_DEFAULTS */
'use strict';
const LINTER_DEFAULTS = (() => {
const SEVERITY = {severity: 'warning'};
const STYLELINT = {
// 'sugarss' is a indent-based syntax like Sass or Stylus
// ref: https://github.com/postcss/postcss#syntaxes
// syntax: 'sugarss',
// ** recommended rules **
// ref: https://github.com/stylelint/stylelint-config-recommended/blob/master/index.js
rules: {
'at-rule-no-unknown': [true, {
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
'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',
}],
'declaration-block-no-shorthand-property-overrides': [true, SEVERITY],
'font-family-no-duplicate-names': [true, SEVERITY],
'function-calc-no-unspaced-operator': [true, SEVERITY],
'function-linear-gradient-no-nonstandard-direction': [true, SEVERITY],
'keyframe-declaration-no-important': [true, SEVERITY],
'media-feature-name-no-unknown': [true, SEVERITY],
/* recommended true */
'no-empty-source': false,
'no-extra-semicolons': [true, SEVERITY],
'no-invalid-double-slash-comments': [true, SEVERITY],
'property-no-unknown': [true, SEVERITY],
'selector-pseudo-class-no-unknown': [true, SEVERITY],
'selector-pseudo-element-no-unknown': [true, SEVERITY],
'selector-type-no-unknown': false, // for scss/less/stylus-lang
'string-no-newline': [true, SEVERITY],
'unit-no-unknown': [true, SEVERITY],
// ** non-essential rules
'comment-no-empty': false,
'declaration-block-no-redundant-longhand-properties': false,
'shorthand-property-no-redundant-values': false,
// ** stylistic rules **
/*
'at-rule-empty-line-before': [
'always',
{
'except': [
'blockless-after-same-name-blockless',
'first-nested'
],
'ignore': [
'after-comment'
]
}
],
'at-rule-name-case': 'lower',
'at-rule-name-space-after': 'always-single-line',
'at-rule-semicolon-newline-after': 'always',
'block-closing-brace-empty-line-before': 'never',
'block-closing-brace-newline-after': 'always',
'block-closing-brace-newline-before': 'always-multi-line',
'block-closing-brace-space-before': 'always-single-line',
'block-opening-brace-newline-after': 'always-multi-line',
'block-opening-brace-space-after': 'always-single-line',
'block-opening-brace-space-before': 'always',
'color-hex-case': 'lower',
'color-hex-length': 'short',
'comment-empty-line-before': [
'always',
{
'except': [
'first-nested'
],
'ignore': [
'stylelint-commands'
]
}
],
'comment-whitespace-inside': 'always',
'custom-property-empty-line-before': [
'always',
{
'except': [
'after-custom-property',
'first-nested'
],
'ignore': [
'after-comment',
'inside-single-line-block'
]
}
],
'declaration-bang-space-after': 'never',
'declaration-bang-space-before': 'always',
'declaration-block-semicolon-newline-after': 'always-multi-line',
'declaration-block-semicolon-space-after': 'always-single-line',
'declaration-block-semicolon-space-before': 'never',
'declaration-block-single-line-max-declarations': 1,
'declaration-block-trailing-semicolon': 'always',
'declaration-colon-newline-after': 'always-multi-line',
'declaration-colon-space-after': 'always-single-line',
'declaration-colon-space-before': 'never',
'declaration-empty-line-before': [
'always',
{
'except': [
'after-declaration',
'first-nested'
],
'ignore': [
'after-comment',
'inside-single-line-block'
]
}
],
'function-comma-newline-after': 'always-multi-line',
'function-comma-space-after': 'always-single-line',
'function-comma-space-before': 'never',
'function-max-empty-lines': 0,
'function-name-case': 'lower',
'function-parentheses-newline-inside': 'always-multi-line',
'function-parentheses-space-inside': 'never-single-line',
'function-whitespace-after': 'always',
'indentation': 2,
'length-zero-no-unit': true,
'max-empty-lines': 1,
'media-feature-colon-space-after': 'always',
'media-feature-colon-space-before': 'never',
'media-feature-name-case': 'lower',
'media-feature-parentheses-space-inside': 'never',
'media-feature-range-operator-space-after': 'always',
'media-feature-range-operator-space-before': 'always',
'media-query-list-comma-newline-after': 'always-multi-line',
'media-query-list-comma-space-after': 'always-single-line',
'media-query-list-comma-space-before': 'never',
'no-eol-whitespace': true,
'no-missing-end-of-source-newline': true,
'number-leading-zero': 'always',
'number-no-trailing-zeros': true,
'property-case': 'lower',
'rule-empty-line-before': [
'always-multi-line',
{
'except': [
'first-nested'
],
'ignore': [
'after-comment'
]
}
],
'selector-attribute-brackets-space-inside': 'never',
'selector-attribute-operator-space-after': 'never',
'selector-attribute-operator-space-before': 'never',
'selector-combinator-space-after': 'always',
'selector-combinator-space-before': 'always',
'selector-descendant-combinator-no-non-space': true,
'selector-list-comma-newline-after': 'always',
'selector-list-comma-space-before': 'never',
'selector-max-empty-lines': 0,
'selector-pseudo-class-case': 'lower',
'selector-pseudo-class-parentheses-space-inside': 'never',
'selector-pseudo-element-case': 'lower',
'selector-pseudo-element-colon-notation': 'double',
'selector-type-case': 'lower',
'unit-case': 'lower',
'value-list-comma-newline-after': 'always-multi-line',
'value-list-comma-space-after': 'always-single-line',
'value-list-comma-space-before': 'never',
'value-list-max-empty-lines': 0
*/
},
};
const CSSLINT = {
// Default warnings
'display-property-grouping': 1,
'duplicate-properties': 1,
'empty-rules': 1,
'errors': 1,
'warnings': 1,
'known-properties': 1,
// Default disabled
'adjoining-classes': 0,
'box-model': 0,
'box-sizing': 0,
'bulletproof-font-face': 0,
'compatible-vendor-prefixes': 0,
'duplicate-background-images': 0,
'fallback-colors': 0,
'floats': 0,
'font-faces': 0,
'font-sizes': 0,
'gradients': 0,
'ids': 0,
'import': 0,
'import-ie-limit': 0,
'important': 0,
'order-alphabetical': 0,
'outline-none': 0,
'overqualified-elements': 0,
'qualified-headings': 0,
'regex-selectors': 0,
'rules-count': 0,
'selector-max': 0,
'selector-max-approaching': 0,
'selector-newline': 0,
'shorthand': 0,
'star-property-hack': 0,
'text-indent': 0,
'underscore-property-hack': 0,
'unique-headings': 0,
'universal-selector': 0,
'unqualified-attributes': 0,
'vendor-prefix': 0,
'zero-units': 0,
};
return {STYLELINT, CSSLINT, SEVERITY};
})();

226
edit/linter-dialogs.js Normal file
View File

@ -0,0 +1,226 @@
/* global $ $create $createLink messageBoxProxy */// dom.js
/* global chromeSync */// storage-util.js
/* global editor */
/* global helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
/* global linterMan */
/* global t */// localization.js
/* global tryJSONparse */// toolbox.js
'use strict';
(() => {
/** @type {{csslint:{}, stylelint:{}}} */
const RULES = {};
let cm;
let defaultConfig;
let isStylelint;
let linter;
let popup;
linterMan.showLintConfig = async () => {
linter = $('#editor.linter').value;
if (!linter) {
return;
}
if (!RULES[linter]) {
linterMan.worker.getRules(linter).then(res => (RULES[linter] = res));
}
await require([
'/vendor/codemirror/mode/javascript/javascript',
'/vendor/codemirror/addon/lint/json-lint',
'/vendor/jsonlint/jsonlint',
]);
const config = await chromeSync.getLZValue(chromeSync.LZ_KEY[linter]);
const title = t('linterConfigPopupTitle', isStylelint ? 'Stylelint' : 'CSSLint');
isStylelint = linter === 'stylelint';
defaultConfig = stringifyConfig(linterMan.DEFAULTS[linter]);
popup = showCodeMirrorPopup(title, null, {
extraKeys: {'Ctrl-Enter': onConfigSave},
hintOptions: {hint},
lint: true,
mode: 'application/json',
value: config ? stringifyConfig(config) : defaultConfig,
});
$('.contents', popup).appendChild(
$create('div', [
$create('p', [
$createLink(
isStylelint
? 'https://stylelint.io/user-guide/rules/'
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
t('linterRulesLink')),
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
]),
$create('.buttons', [
$create('button.save', {onclick: onConfigSave, title: 'Ctrl-Enter'},
t('styleSaveLabel')),
$create('button.cancel', {onclick: onConfigCancel}, t('confirmClose')),
$create('button.reset', {onclick: onConfigReset, title: t('linterResetMessage')},
t('genericResetLabel')),
]),
]));
cm = popup.codebox;
cm.focus();
cm.on('changes', updateConfigButtons);
updateConfigButtons();
rerouteHotkeys(false);
window.on('closeHelp', onConfigClose, {once: true});
};
linterMan.showLintHelp = async () => {
// FIXME: implement a linterChooser?
const linter = $('#editor.linter').value;
const baseUrl = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
// some CSSLint rules do not have a url
: 'https://github.com/CSSLint/csslint/issues/535';
let headerLink, template;
if (linter === 'csslint') {
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
template = ({rule: ruleID}) => {
const rule = RULES.csslint.find(rule => rule.id === ruleID);
return rule &&
$create('li', [
$create('b', $createLink(rule.url || baseUrl, rule.name)),
$create('br'),
rule.desc,
]);
};
} else {
headerLink = $createLink(baseUrl, 'stylelint');
template = rule =>
$create('li',
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
}
const header = t('linterIssuesHelp', '\x01').split('\x01');
const activeRules = new Set([...linterMan.getIssues()].map(issue => issue.rule));
helpPopup.show(t('linterIssues'),
$create([
header[0], headerLink, header[1],
$create('ul.rules', [...activeRules].map(template)),
]));
};
function getLexicalDepth(lexicalState) {
let depth = 0;
while ((lexicalState = lexicalState.prev)) {
depth++;
}
return depth;
}
function hint(cm) {
const rules = RULES[linter];
let ruleIds, options;
if (isStylelint) {
ruleIds = Object.keys(rules);
options = rules;
} else {
ruleIds = rules.map(r => r.id);
options = {};
}
const cursor = cm.getCursor();
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
const {line, ch} = cursor;
const quoted = string.startsWith('"');
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
const depth = getLexicalDepth(lexical);
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
let [, prevWord] = search.find(true) || [];
let words = [];
if (depth === 1 && isStylelint) {
words = quoted ? ['rules'] : [];
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
words = ruleIds;
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
words = !quoted ? ['true', 'false', 'null'] :
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
} else if (depth === 4 && prevWord === 'severity') {
words = ['error', 'warning'];
} else if (depth === 4) {
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
} else if (depth === 5 && lexical.type === ']' && quoted) {
while (prevWord && !ruleIds.includes(prevWord)) {
prevWord = (search.find(true) || [])[1];
}
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
}
return {
list: words.filter(word => word.startsWith(leftPart)),
from: {line, ch: start + (quoted ? 1 : 0)},
to: {line, ch: string.endsWith('"') ? end - 1 : end},
};
}
function onConfigCancel() {
helpPopup.close();
editor.closestVisible().focus();
}
function onConfigClose() {
rerouteHotkeys(true);
cm = null;
}
function onConfigReset(event) {
event.preventDefault();
cm.setValue(defaultConfig);
cm.focus();
updateConfigButtons();
}
async function onConfigSave(event) {
if (event instanceof Event) {
event.preventDefault();
}
const json = tryJSONparse(cm.getValue());
if (!json) {
showLinterErrorMessage(linter, t('linterJSONError'), popup);
cm.focus();
return;
}
let invalid;
if (isStylelint) {
invalid = Object.keys(json.rules).filter(k => !RULES.stylelint.hasOwnProperty(k));
} else {
const ids = RULES.csslint.map(r => r.id);
invalid = Object.keys(json).filter(k => !ids.includes(k));
}
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$create('ul', invalid.map(name => $create('li', name))),
], popup);
return;
}
chromeSync.setLZValue(chromeSync.LZ_KEY[linter], json);
cm.markClean();
cm.focus();
updateConfigButtons();
}
function stringifyConfig(config) {
return JSON.stringify(config, null, 2)
.replace(/,\n\s+{\n\s+("severity":\s"\w+")\n\s+}/g, ', {$1}');
}
async function showLinterErrorMessage(title, contents, popup) {
await messageBoxProxy.show({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
});
if (popup && popup.codebox) {
popup.codebox.focus();
}
}
function updateConfigButtons() {
$('.save', popup).disabled = cm.isClean();
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
}
})();

View File

@ -1,115 +0,0 @@
/* global LINTER_DEFAULTS linter editorWorker prefs chromeSync */
'use strict';
(() => {
registerLinters({
csslint: {
storageName: chromeSync.LZ_KEY.csslint,
lint: csslint,
validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config),
},
stylelint: {
storageName: chromeSync.LZ_KEY.stylelint,
lint: stylelint,
validMode: () => true,
getConfig: config => ({
syntax: 'sugarss',
rules: Object.assign({}, LINTER_DEFAULTS.STYLELINT.rules, config && config.rules),
}),
},
});
async function stylelint(text, config, mode) {
const raw = await editorWorker.stylelint(text, config);
if (!raw) {
return [];
}
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
// and we can't just pre-remove the comments since "//" may be inside a string token or whatever
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
const res = [];
for (const w of raw.warnings) {
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
if (!slashCommentAllowed || !(
w.rule === 'no-invalid-double-slash-comments' ||
w.rule === 'property-no-unknown' && msg.includes('"//"')
)) {
res.push({
from: {line: w.line - 1, ch: w.column - 1},
to: {line: w.line - 1, ch: w.column},
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
severity: w.severity,
rule: w.rule,
});
}
}
return res;
}
function csslint(text, config) {
return editorWorker.csslint(text, config)
.then(results =>
results
.map(({line, col: ch, message, rule, type: severity}) => line && {
message,
from: {line: line - 1, ch: ch - 1},
to: {line: line - 1, ch},
rule: rule.id,
severity,
})
.filter(Boolean)
);
}
function registerLinters(engines) {
const configs = new Map();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') {
return;
}
for (const [name, engine] of Object.entries(engines)) {
if (changes.hasOwnProperty(engine.storageName)) {
chromeSync.getLZValue(engine.storageName)
.then(config => {
configs.set(name, engine.getConfig(config));
linter.run();
});
}
}
});
linter.register((text, options, cm) => {
const selectedLinter = prefs.get('editor.linter');
if (!selectedLinter) {
return;
}
const mode = cm.getOption('mode');
if (engines[selectedLinter].validMode(mode)) {
return runLint(selectedLinter);
}
for (const [name, engine] of Object.entries(engines)) {
if (engine.validMode(mode)) {
return runLint(name);
}
}
function runLint(name) {
return getConfig(name)
.then(config => engines[name].lint(text, config, mode));
}
});
function getConfig(name) {
if (configs.has(name)) {
return Promise.resolve(configs.get(name));
}
return chromeSync.getLZValue(engines[name].storageName)
.then(config => {
configs.set(name, engines[name].getConfig(config));
return configs.get(name);
});
}
}
})();

View File

@ -1,52 +0,0 @@
/* global showHelp editorWorker memoize $ $create $createLink t */
/* exported createLinterHelpDialog */
'use strict';
function createLinterHelpDialog(getIssues) {
let csslintRules;
const prepareCsslintRules = memoize(() =>
editorWorker.getCsslintRules()
.then(rules => {
csslintRules = rules;
})
);
return {show};
function show() {
// FIXME: implement a linterChooser?
const linter = $('#editor.linter').value;
const baseUrl = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
// some CSSLint rules do not have a url
: 'https://github.com/CSSLint/csslint/issues/535';
let headerLink, template;
if (linter === 'csslint') {
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
template = ({rule: ruleID}) => {
const rule = csslintRules.find(rule => rule.id === ruleID);
return rule &&
$create('li', [
$create('b', $createLink(rule.url || baseUrl, rule.name)),
$create('br'),
rule.desc,
]);
};
} else {
headerLink = $createLink(baseUrl, 'stylelint');
template = rule =>
$create('li',
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
}
const header = t('linterIssuesHelp', '\x01').split('\x01');
const activeRules = new Set([...getIssues()].map(issue => issue.rule));
Promise.resolve(linter === 'csslint' && prepareCsslintRules())
.then(() =>
showHelp(t('linterIssues'),
$create([
header[0], headerLink, header[1],
$create('ul.rules', [...activeRules].map(template)),
])
)
);
}
}

420
edit/linter-manager.js Normal file
View File

@ -0,0 +1,420 @@
/* global $ $create */// dom.js
/* global chromeSync */// storage-util.js
/* global clipString */// util.js
/* global createWorker */// worker-util.js
/* global editor */
/* global prefs */
'use strict';
//#region linterMan
const linterMan = (() => {
const cms = new Map();
const linters = [];
const lintingUpdatedListeners = [];
const unhookListeners = [];
return {
/** @type {EditorWorker} */
worker: createWorker({url: '/edit/editor-worker'}),
disableForEditor(cm) {
cm.setOption('lint', false);
cms.delete(cm);
for (const cb of unhookListeners) {
cb(cm);
}
},
/**
* @param {Object} cm
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
* update when lint gutter is added to a lot of editors simultaneously.
*/
enableForEditor(cm, code) {
if (cms.has(cm)) return;
cms.set(cm, null);
if (code) {
enableOnProblems(cm, code);
} else {
cm.setOption('lint', {getAnnotations, onUpdateLinting});
}
},
onLintingUpdated(fn) {
lintingUpdatedListeners.push(fn);
},
onUnhook(fn) {
unhookListeners.push(fn);
},
register(fn) {
linters.push(fn);
},
run() {
for (const cm of cms.keys()) {
cm.performLint();
}
},
};
async function enableOnProblems(cm, code) {
const results = await getAnnotations(code, {}, cm);
if (results.length || cm.display.renderedView) {
cms.set(cm, results);
cm.setOption('lint', {getAnnotations: getCachedAnnotations, onUpdateLinting});
} else {
cms.delete(cm);
}
}
async function getAnnotations(...args) {
const results = await Promise.all(linters.map(fn => fn(...args)));
return [].concat(...results.filter(Boolean));
}
function getCachedAnnotations(code, opt, cm) {
const results = cms.get(cm);
cms.set(cm, null);
cm.options.lint.getAnnotations = getAnnotations;
return results;
}
function onUpdateLinting(...args) {
for (const fn of lintingUpdatedListeners) {
fn(...args);
}
}
})();
//#endregion
//#region DEFAULTS
linterMan.DEFAULTS = {
stylelint: {
rules: {
'at-rule-no-unknown': [true, {
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
'severity': 'warning',
}],
'block-no-empty': [true, {severity: 'warning'}],
'color-no-invalid-hex': [true, {severity: 'warning'}],
'declaration-block-no-duplicate-properties': [true, {
'ignore': ['consecutive-duplicates-with-different-values'],
'severity': 'warning',
}],
'declaration-block-no-shorthand-property-overrides': [true, {severity: 'warning'}],
'font-family-no-duplicate-names': [true, {severity: 'warning'}],
'function-calc-no-unspaced-operator': [true, {severity: 'warning'}],
'function-linear-gradient-no-nonstandard-direction': [true, {severity: 'warning'}],
'keyframe-declaration-no-important': [true, {severity: 'warning'}],
'media-feature-name-no-unknown': [true, {severity: 'warning'}],
'no-empty-source': false,
'no-extra-semicolons': [true, {severity: 'warning'}],
'no-invalid-double-slash-comments': [true, {severity: 'warning'}],
'property-no-unknown': [true, {severity: 'warning'}],
'selector-pseudo-class-no-unknown': [true, {severity: 'warning'}],
'selector-pseudo-element-no-unknown': [true, {severity: 'warning'}],
'selector-type-no-unknown': false, // for scss/less/stylus-lang
'string-no-newline': [true, {severity: 'warning'}],
'unit-no-unknown': [true, {severity: 'warning'}],
'comment-no-empty': false,
'declaration-block-no-redundant-longhand-properties': false,
'shorthand-property-no-redundant-values': false,
},
},
csslint: {
'display-property-grouping': 1,
'duplicate-properties': 1,
'empty-rules': 1,
'errors': 1,
'known-properties': 1,
'selector-newline': 1,
'simple-not': 1,
'warnings': 1,
// disabled
'adjoining-classes': 0,
'box-model': 0,
'box-sizing': 0,
'bulletproof-font-face': 0,
'compatible-vendor-prefixes': 0,
'duplicate-background-images': 0,
'fallback-colors': 0,
'floats': 0,
'font-faces': 0,
'font-sizes': 0,
'gradients': 0,
'ids': 0,
'import': 0,
'import-ie-limit': 0,
'important': 0,
'order-alphabetical': 0,
'outline-none': 0,
'overqualified-elements': 0,
'qualified-headings': 0,
'regex-selectors': 0,
'rules-count': 0,
'selector-max': 0,
'selector-max-approaching': 0,
'shorthand': 0,
'star-property-hack': 0,
'text-indent': 0,
'underscore-property-hack': 0,
'unique-headings': 0,
'universal-selector': 0,
'unqualified-attributes': 0,
'vendor-prefix': 0,
'zero-units': 0,
},
};
//#endregion
//#region ENGINES
(() => {
const configs = new Map();
const {DEFAULTS, worker} = linterMan;
const ENGINES = {
csslint: {
validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
async lint(text, config) {
const results = await worker.csslint(text, config);
return results
.map(({line, col: ch, message, rule, type: severity}) => line && {
message,
from: {line: line - 1, ch: ch - 1},
to: {line: line - 1, ch},
rule: rule.id,
severity,
})
.filter(Boolean);
},
},
stylelint: {
validMode: () => true,
getConfig: config => ({
syntax: 'sugarss',
rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
}),
async lint(text, config, mode) {
const raw = await worker.stylelint(text, config);
if (!raw) {
return [];
}
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
// and we can't just pre-remove the comments since "//" may be inside a string token
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
const res = [];
for (const w of raw.warnings) {
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
if (!slashCommentAllowed || !(
w.rule === 'no-invalid-double-slash-comments' ||
w.rule === 'property-no-unknown' && msg.includes('"//"')
)) {
res.push({
from: {line: w.line - 1, ch: w.column - 1},
to: {line: w.line - 1, ch: w.column},
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
severity: w.severity,
rule: w.rule,
});
}
}
return res;
},
},
};
linterMan.register(async (text, _options, cm) => {
const linter = prefs.get('editor.linter');
if (linter) {
const {mode} = cm.options;
const currentFirst = Object.entries(ENGINES).sort(([a]) => a === linter ? -1 : 1);
for (const [name, engine] of currentFirst) {
if (engine.validMode(mode)) {
const cfg = configs.get(name) || await getConfig(name);
return ENGINES[name].lint(text, cfg, mode);
}
}
}
});
chrome.storage.onChanged.addListener(changes => {
for (const name of Object.keys(ENGINES)) {
if (chromeSync.LZ_KEY[name] in changes) {
getConfig(name).then(linterMan.run);
}
}
});
async function getConfig(name) {
const rawCfg = await chromeSync.getLZValue(chromeSync.LZ_KEY[name]);
const cfg = ENGINES[name].getConfig(rawCfg);
configs.set(name, cfg);
return cfg;
}
})();
//#endregion
//#region Reports
(() => {
const tables = new Map();
linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
let table = tables.get(cm);
if (!table) {
table = createTable(cm);
tables.set(cm, table);
const container = $('.lint-report-container');
const nextSibling = findNextSibling(tables, cm);
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
}
table.updateCaption();
table.updateAnnotations(annotations);
updateCount();
});
linterMan.onUnhook(cm => {
const table = tables.get(cm);
if (table) {
table.element.remove();
tables.delete(cm);
}
updateCount();
});
Object.assign(linterMan, {
getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
issues.add(tr.getAnnotation());
}
}
return issues;
},
refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
},
});
function updateCount() {
const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount;
}
function findNextSibling(tables, cm) {
const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1;
while (i < editors.length) {
if (tables.has(editors[i])) {
return editors[i];
}
i++;
}
}
function createTable(cm) {
const caption = $create('caption');
const tbody = $create('tbody');
const table = $create('table', [caption, tbody]);
const trs = [];
return {
element: table,
trs,
updateAnnotations,
updateCaption,
};
function updateCaption() {
caption.textContent = editor.getEditorTitle(cm);
}
function updateAnnotations(lines) {
let i = 0;
for (const anno of getAnnotations()) {
let tr;
if (i < trs.length) {
tr = trs[i];
} else {
tr = createTr();
trs.push(tr);
tbody.append(tr.element);
}
tr.update(anno);
i++;
}
if (i === 0) {
trs.length = 0;
tbody.textContent = '';
} else {
while (trs.length > i) {
trs.pop().element.remove();
}
}
table.classList.toggle('empty', trs.length === 0);
function *getAnnotations() {
for (const line of lines.filter(Boolean)) {
yield *line;
}
}
}
function createTr() {
let anno;
const severityIcon = $create('div');
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
const line = $create('td', {attributes: {role: 'line'}});
const col = $create('td', {attributes: {role: 'col'}});
const message = $create('td', {attributes: {role: 'message'}});
const trElement = $create('tr', {
onclick: () => gotoLintIssue(cm, anno),
}, [
severity,
line,
$create('td', {attributes: {role: 'sep'}}, ':'),
col,
message,
]);
return {
element: trElement,
update,
getAnnotation: () => anno,
};
function update(_anno) {
anno = _anno;
trElement.className = anno.severity;
severity.dataset.rule = anno.rule;
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
severityIcon.textContent = anno.severity;
line.textContent = anno.from.line + 1;
col.textContent = anno.from.ch + 1;
message.title = clipString(anno.message, 1000) +
(anno.rule ? `\n(${anno.rule})` : '');
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
}
}
}
function gotoLintIssue(cm, anno) {
editor.scrollToEditor(cm);
cm.focus();
cm.jumpToPos(anno.from);
}
})();
//#endregion

View File

@ -1,44 +0,0 @@
/* global linter editorWorker */
/* exported createMetaCompiler */
'use strict';
/**
* @param {CodeMirror} cm
* @param {function(meta:Object)} onUpdated
*/
function createMetaCompiler(cm, onUpdated) {
let meta = null;
let metaIndex = null;
let cache = [];
linter.register((text, options, _cm) => {
if (_cm !== cm) {
return;
}
const match = text.match(/\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i);
if (!match) {
return [];
}
if (match[0] === meta && match.index === metaIndex) {
return cache;
}
return editorWorker.metalint(match[0])
.then(({metadata, errors}) => {
if (errors.every(err => err.code === 'unknownMeta')) {
onUpdated(metadata);
}
cache = errors.map(err =>
({
from: cm.posFromIndex((err.index || 0) + match.index),
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,
})
);
meta = match[0];
metaIndex = match.index;
return cache;
});
});
}

View File

@ -1,161 +0,0 @@
/* global linter editor clipString createLinterHelpDialog $ $create */
'use strict';
Object.assign(linter, (() => {
const tables = new Map();
const helpDialog = createLinterHelpDialog(getIssues);
document.addEventListener('DOMContentLoaded', () => {
$('#lint-help').addEventListener('click', helpDialog.show);
}, {once: true});
linter.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
let table = tables.get(cm);
if (!table) {
table = createTable(cm);
tables.set(cm, table);
const container = $('.lint-report-container');
const nextSibling = findNextSibling(tables, cm);
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
}
table.updateCaption();
table.updateAnnotations(annotations);
updateCount();
});
linter.onUnhook(cm => {
const table = tables.get(cm);
if (table) {
table.element.remove();
tables.delete(cm);
}
updateCount();
});
return {refreshReport};
function updateCount() {
const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount;
}
function getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
issues.add(tr.getAnnotation());
}
}
return issues;
}
function findNextSibling(tables, cm) {
const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1;
while (i < editors.length) {
if (tables.has(editors[i])) {
return editors[i];
}
i++;
}
}
function refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
}
function createTable(cm) {
const caption = $create('caption');
const tbody = $create('tbody');
const table = $create('table', [caption, tbody]);
const trs = [];
return {
element: table,
trs,
updateAnnotations,
updateCaption,
};
function updateCaption() {
caption.textContent = editor.getEditorTitle(cm);
}
function updateAnnotations(lines) {
let i = 0;
for (const anno of getAnnotations()) {
let tr;
if (i < trs.length) {
tr = trs[i];
} else {
tr = createTr();
trs.push(tr);
tbody.append(tr.element);
}
tr.update(anno);
i++;
}
if (i === 0) {
trs.length = 0;
tbody.textContent = '';
} else {
while (trs.length > i) {
trs.pop().element.remove();
}
}
table.classList.toggle('empty', trs.length === 0);
function *getAnnotations() {
for (const line of lines.filter(Boolean)) {
yield *line;
}
}
}
function createTr() {
let anno;
const severityIcon = $create('div');
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
const line = $create('td', {attributes: {role: 'line'}});
const col = $create('td', {attributes: {role: 'col'}});
const message = $create('td', {attributes: {role: 'message'}});
const trElement = $create('tr', {
onclick: () => gotoLintIssue(cm, anno),
}, [
severity,
line,
$create('td', {attributes: {role: 'sep'}}, ':'),
col,
message,
]);
return {
element: trElement,
update,
getAnnotation: () => anno,
};
function update(_anno) {
anno = _anno;
trElement.className = anno.severity;
severity.dataset.rule = anno.rule;
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
severityIcon.textContent = anno.severity;
line.textContent = anno.from.line + 1;
col.textContent = anno.from.ch + 1;
message.title = clipString(anno.message, 1000) +
(anno.rule ? `\n(${anno.rule})` : '');
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
}
}
}
function gotoLintIssue(cm, anno) {
editor.scrollToEditor(cm);
cm.focus();
cm.jumpToPos(anno.from);
}
})());

View File

@ -1,77 +0,0 @@
/* global workerUtil */
'use strict';
/* exported editorWorker */
/** @type {EditorWorker} */
const editorWorker = workerUtil.createWorker({
url: '/edit/editor-worker.js',
});
/* exported linter */
const linter = (() => {
const lintingUpdatedListeners = [];
const unhookListeners = [];
const linters = [];
const cms = new Set();
return {
disableForEditor(cm) {
cm.setOption('lint', false);
cms.delete(cm);
for (const cb of unhookListeners) {
cb(cm);
}
},
/**
* @param {Object} cm
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
* update when lint gutter is added to a lot of editors simultaneously.
*/
enableForEditor(cm, code) {
if (cms.has(cm)) return;
if (code) return enableOnProblems(cm, code);
cm.setOption('lint', {getAnnotations, onUpdateLinting});
cms.add(cm);
},
onLintingUpdated(cb) {
lintingUpdatedListeners.push(cb);
},
onUnhook(cb) {
unhookListeners.push(cb);
},
register(linterFn) {
linters.push(linterFn);
},
run() {
for (const cm of cms) {
cm.performLint();
}
},
};
async function enableOnProblems(cm, code) {
const results = await getAnnotations(code, {}, cm);
if (results.length) {
cms.add(cm);
cm.setOption('lint', {
getAnnotations() {
cm.options.lint.getAnnotations = getAnnotations;
return results;
},
onUpdateLinting,
});
}
}
async function getAnnotations(...args) {
const results = await Promise.all(linters.map(fn => fn(...args)));
return [].concat(...results.filter(Boolean));
}
function onUpdateLinting(...args) {
for (const cb of lintingUpdatedListeners) {
cb(...args);
}
}
})();

View File

@ -1,74 +0,0 @@
/* global messageBox editor $ prefs */
/* exported createLivePreview */
'use strict';
function createLivePreview(preprocess, shouldShow) {
let data;
let previewer;
let enabled = prefs.get('editor.livePreview');
const label = $('#preview-label');
const errorContainer = $('#preview-errors');
prefs.subscribe(['editor.livePreview'], (key, value) => {
if (value && data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
previewer = createPreviewer();
previewer.update(data);
}
if (!value && previewer) {
previewer.disconnect();
previewer = null;
}
enabled = value;
});
if (shouldShow != null) show(shouldShow);
return {update, show};
function show(state) {
label.classList.toggle('hidden', !state);
}
function update(_data) {
data = _data;
if (!previewer) {
if (!data.id || !data.enabled || !enabled) {
return;
}
previewer = createPreviewer();
}
previewer.update(data);
}
function createPreviewer() {
const port = chrome.runtime.connect({
name: 'livePreview',
});
port.onDisconnect.addListener(err => {
throw err;
});
return {update, disconnect};
function update(data) {
Promise.resolve()
.then(() => preprocess ? preprocess(data) : data)
.then(data => port.postMessage(data))
.then(
() => errorContainer.classList.add('hidden'),
err => {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index !== undefined) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || String(err)}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
}
);
}
function disconnect() {
port.disconnect();
}
}
}

View File

@ -1,9 +1,6 @@
/* global
CodeMirror
debounce
deepEqual
trimCommentLabel
*/
/* global CodeMirror */
/* global debounce deepEqual */// toolbox.js
/* global trimCommentLabel */// util.js
'use strict';
/* exported MozSectionFinder */
@ -26,7 +23,7 @@ function MozSectionFinder(cm) {
/** @type {CodeMirror.Pos} */
let updTo;
const MozSectionFinder = {
const finder = {
IGNORE_ORIGIN: KEY,
EQ_SKIP_KEYS: [
'mark',
@ -45,10 +42,11 @@ function MozSectionFinder(cm) {
const NOP = () => 0;
data = {fn: NOP};
keptAlive.set(id, data);
MozSectionFinder.on(NOP);
finder.on(NOP);
}
data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
},
on(fn) {
const {listeners} = getState();
const needsInit = !listeners.size;
@ -58,6 +56,7 @@ function MozSectionFinder(cm) {
update();
}
},
off(fn) {
const {listeners, sections} = getState();
if (listeners.size) {
@ -69,15 +68,16 @@ function MozSectionFinder(cm) {
}
}
},
onOff(fn, enable) {
MozSectionFinder[enable ? 'on' : 'off'](fn);
finder[enable ? 'on' : 'off'](fn);
},
/** @param {MozSection} [section] */
updatePositions(section) {
(section ? [section] : getState().sections).forEach(setPositionFromMark);
},
};
return MozSectionFinder;
/** @returns {MozSectionCmState} */
function getState() {
@ -97,7 +97,7 @@ function MozSectionFinder(cm) {
if (!updFrom) updFrom = {line: Infinity, ch: 0};
if (!updTo) updTo = {line: -1, ch: 0};
for (const c of changes) {
if (c.origin !== MozSectionFinder.IGNORE_ORIGIN) {
if (c.origin !== finder.IGNORE_ORIGIN) {
updFrom = minPos(c.from, updFrom);
updTo = maxPos(CodeMirror.changeEnd(c), updTo);
}
@ -387,11 +387,13 @@ function MozSectionFinder(cm) {
/** @this {MozSectionFunc[]} new functions */
function isSameFunc(func, i) {
return deepEqual(func, this[i], MozSectionFinder.EQ_SKIP_KEYS);
}
return deepEqual(func, this[i], finder.EQ_SKIP_KEYS);
}
/** @typedef CodeMirror.Pos
* @property {number} line
* @property {number} ch
*/
return finder;
}

View File

@ -1,24 +1,16 @@
/* global
$
$create
CodeMirror
colorMimicry
messageBox
MozSectionFinder
msg
prefs
regExpTester
t
tryCatch
*/
/* global $ $create messageBoxProxy */// dom.js
/* global CodeMirror */
/* global MozSectionFinder */
/* global colorMimicry */
/* global editor */
/* global msg */
/* global prefs */
/* global t */// localization.js
/* global tryCatch */// toolbox.js
'use strict';
/* exported MozSectionWidget */
function MozSectionWidget(
cm,
finder = MozSectionFinder(cm),
onDirectChange = () => 0
) {
function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
let TPL, EVENTS, CLICK_ROUTE;
const KEY = 'MozSectionWidget';
const C_CONTAINER = '.applies-to';
@ -36,7 +28,9 @@ function MozSectionWidget(
const {cmpPos} = CodeMirror;
let enabled = false;
let funcHeight = 0;
/** @type {HTMLStyleElement} */
let actualStyle;
return {
toggle(enable) {
if (Boolean(enable) !== enabled) {
@ -71,7 +65,7 @@ function MozSectionWidget(
'.remove-applies-to'(elItem, func) {
const funcs = getFuncsFor(elItem);
if (funcs.length < 2) {
messageBox({
messageBoxProxy.show({
contents: t('appliesRemoveError'),
buttons: [t('confirmClose')],
});
@ -110,7 +104,7 @@ function MozSectionWidget(
if (part === 'value' && func === getFuncsFor(el)[0]) {
const sec = getSectionFor(el);
sec.tocEntry.target = el.value;
if (!sec.tocEntry.label) onDirectChange([sec]);
if (!sec.tocEntry.label) editor.updateToc([sec]);
}
cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
},
@ -176,13 +170,13 @@ function MozSectionWidget(
const MIN_LUMA = .05;
const MIN_LUMA_DIFF = .4;
const color = {
wrapper: colorMimicry.get(cm.display.wrapper),
gutter: colorMimicry.get(cm.display.gutters, {
wrapper: colorMimicry(cm.display.wrapper),
gutter: colorMimicry(cm.display.gutters, {
bg: 'backgroundColor',
border: 'borderRightColor',
}),
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
};
const hasBorder =
color.gutter.style.borderRightWidth !== '0px' &&
@ -421,10 +415,12 @@ function MozSectionWidget(
f.value.clear();
}
function showRegExpTester(el) {
async function showRegExpTester(el) {
/* global regexpTester */
await require(['/edit/regexp-tester']);
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
regExpTester.toggle(true);
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
regexpTester.toggle(true);
regexpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
}
function fromDoubleslash(s) {

View File

@ -1,62 +1,39 @@
/* global
$
$create
openURL
showHelp
t
tryRegExp
URLS
*/
/* exported regExpTester */
/* global $create */// dom.js
/* global URLS openURL tryRegExp */// toolbox.js
/* global helpPopup */// util.js
/* global t */// localization.js
'use strict';
const regExpTester = (() => {
const regexpTester = (() => {
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = new Map();
let currentRegexps = [];
let isInit = false;
let isWatching = false;
let isShown = false;
function init() {
isInit = true;
window.on('closeHelp', () => regexpTester.toggle(false));
return {
toggle(state = !isShown) {
if (state && !isShown) {
if (!isWatching) {
isWatching = true;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
helpPopup.show('', $create('.regexp-report'));
isShown = true;
} else if (!state && isShown) {
unwatch();
helpPopup.close();
isShown = false;
}
},
function uninit() {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isInit = false;
}
function onTabUpdate(tabId, info) {
if (info.url) {
update();
}
}
function isShown() {
return Boolean($('.regexp-report'));
}
function toggle(state = !isShown()) {
if (state && !isShown()) {
if (!isInit) {
init();
}
showHelp('', $create('.regexp-report'));
} else if (!state && isShown()) {
if (isInit) {
uninit();
}
// TODO: need a closeHelp function
$('#help-popup .dismiss').onclick();
}
}
function update(newRegexps) {
if (!isShown()) {
if (isInit) {
uninit();
}
async update(newRegexps) {
if (!isShown) {
unwatch();
return;
}
if (newRegexps) {
@ -74,7 +51,7 @@ const regExpTester = (() => {
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
browser.tabs.query({}).then(tabs => {
const tabs = await browser.tabs.query({});
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
const unique = [...new Set(supported).values()];
for (const rxData of regexps) {
@ -92,10 +69,12 @@ const regExpTester = (() => {
}
const stats = {
full: {data: [], label: t('styleRegexpTestFull')},
partial: {data: [], label: [
partial: {
data: [], label: [
t('styleRegexpTestPartial'),
t.template.regexpTestPartial.cloneNode(true),
]},
],
},
none: {data: [], label: t('styleRegexpTestNone')},
invalid: {data: [], label: t('styleRegexpTestInvalid')},
};
@ -167,7 +146,7 @@ const regExpTester = (() => {
}
}
}
showHelp(t('styleRegexpTestTitle'), report);
helpPopup.show(t('styleRegexpTestTitle'), report);
report.onclick = onClick;
const note = $create('p.regexp-report-note',
@ -176,7 +155,12 @@ const regExpTester = (() => {
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
report.appendChild(note);
adjustNote(report, note);
});
},
};
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
}
function onClick(event) {
const a = event.target.closest('a');
@ -191,10 +175,16 @@ const regExpTester = (() => {
}
}
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
function onTabUpdate(tabId, info) {
if (info.url) {
regexpTester.update();
}
}
return {toggle, update};
function unwatch() {
if (isWatching) {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isWatching = false;
}
}
})();

View File

@ -1,49 +0,0 @@
/* global CodeMirror editor debounce */
/* exported rerouteHotkeys */
'use strict';
const rerouteHotkeys = (() => {
// reroute handling to nearest editor when keypress resolves to one of these commands
const REROUTED = new Set([
'save',
'toggleStyle',
'jumpToLine',
'nextEditor', 'prevEditor',
'toggleEditorFocus',
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
'colorpicker',
'beautify',
]);
return rerouteHotkeys;
// note that this function relies on `editor`. Calling this function before
// the editor is initialized may throw an error.
function rerouteHotkeys(enable, immediately) {
if (!immediately) {
debounce(rerouteHotkeys, 0, enable, true);
} else if (enable) {
document.addEventListener('keydown', rerouteHandler);
} else {
document.removeEventListener('keydown', rerouteHandler);
}
}
function rerouteHandler(event) {
const keyName = CodeMirror.keyName(event);
if (!keyName) {
return;
}
const rerouteCommand = name => {
if (REROUTED.has(name)) {
CodeMirror.commands[name](editor.closestVisible(event.target));
return true;
}
};
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
event.preventDefault();
event.stopPropagation();
}
}
})();

View File

@ -1,21 +1,15 @@
/* global
$
cmFactory
debounce
DocFuncMapper
editor
initBeautifyButton
linter
prefs
regExpTester
t
trimCommentLabel
tryRegExp
*/
/* global $ */// dom.js
/* global MozDocMapper trimCommentLabel */// util.js
/* global cmFactory */
/* global debounce tryRegExp */// toolbox.js
/* global editor */
/* global initBeautifyButton */// beautify.js
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported createSection */
/**
* @param {StyleSection} originalSection
* @param {function():number} genId
@ -43,7 +37,7 @@ function createSection(originalSection, genId, si) {
const appliesToContainer = $('.applies-to-list', el);
const appliesTo = [];
DocFuncMapper.forEachProp(originalSection, (type, value) =>
MozDocMapper.forEachProp(originalSection, (type, value) =>
insertApplyAfter({type, value}));
if (!appliesTo.length) {
insertApplyAfter({all: true});
@ -64,10 +58,10 @@ function createSection(originalSection, genId, si) {
appliesTo,
getModel() {
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
return DocFuncMapper.toSection(items, {code: cm.getValue()});
return MozDocMapper.toSection(items, {code: cm.getValue()});
},
remove() {
linter.disableForEditor(cm);
linterMan.disableForEditor(cm);
el.classList.add('removed');
removed = true;
appliesTo.forEach(a => a.remove());
@ -79,7 +73,7 @@ function createSection(originalSection, genId, si) {
cmFactory.destroy(cm);
},
restore() {
linter.enableForEditor(cm);
linterMan.enableForEditor(cm);
el.classList.remove('removed');
removed = false;
appliesTo.forEach(a => a.restore());
@ -102,7 +96,7 @@ function createSection(originalSection, genId, si) {
},
};
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true});
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {runNow: true});
return section;
@ -120,11 +114,8 @@ function createSection(originalSection, genId, si) {
emitSectionChange('code');
});
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
$('.test-regexp', el).onclick = () => {
regExpTester.toggle();
updateRegexpTester();
};
initBeautifyButton($('.beautify-section', el), () => [cm]);
$('.test-regexp', el).onclick = () => updateRegexpTester(true);
initBeautifyButton($('.beautify-section', el), [cm]);
}
function handleKeydown(cm, event) {
@ -165,15 +156,22 @@ function createSection(originalSection, genId, si) {
}
}
function updateRegexpTester() {
async function updateRegexpTester(toggle) {
const isLoaded = typeof regexpTester === 'object';
if (toggle && !isLoaded) {
await require(['/edit/regexp-tester']); /* global regexpTester */
}
if (toggle != null && isLoaded) {
regexpTester.toggle(toggle);
}
const regexps = appliesTo.filter(a => a.type === 'regexp')
.map(a => a.value);
if (regexps.length) {
el.classList.add('has-regexp');
regExpTester.update(regexps);
if (isLoaded) regexpTester.update(regexps);
} else {
el.classList.remove('has-regexp');
regExpTester.toggle(false);
if (isLoaded) regexpTester.toggle(false);
}
}
@ -211,7 +209,7 @@ function createSection(originalSection, genId, si) {
function updateTocPrefToggled(key, val) {
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
el.onOff(val, 'focusin', updateTocFocus);
(val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
if (val) {
updateTocEntry();
if (el.contains(document.activeElement)) {

View File

@ -1,45 +1,30 @@
/* global
$
$$
$create
API
clipString
CodeMirror
createLivePreview
createSection
debounce
editor
FIREFOX
ignoreChromeError
linter
messageBox
prefs
rerouteHotkeys
sectionsToMozFormat
sessionStore
showCodeMirrorPopup
showHelp
t
*/
/* global $ $$ $create $remove messageBoxProxy */// dom.js
/* global API */// msg.js
/* global CodeMirror */
/* global FIREFOX URLS debounce ignoreChromeError sessionStore */// toolbox.js
/* global MozDocMapper clipString helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
/* global createSection */// sections-editor-section.js
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported SectionsEditor */
function SectionsEditor() {
const {style, dirty} = editor;
const {style, /** @type DirtyReporter */dirty} = editor;
const container = $('#sections');
/** @type {EditorSection[]} */
const sections = [];
const xo = window.IntersectionObserver &&
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
const livePreview = createLivePreview(null, style.id);
let INC_ID = 0; // an increment id that is used by various object to track the order
let sectionOrder = '';
let headerOffset; // in compact mode the header is at the top so it reduces the available height
container.classList.add('section-editor');
updateHeader();
editor.livePreview.init(null, style.id);
container.classList.add('section-editor');
$('#to-mozilla').on('click', showMozillaFormat);
$('#to-mozilla-help').on('click', showToMozillaHelp);
$('#from-mozilla').on('click', () => showMozillaFormatImport());
@ -50,7 +35,7 @@ function SectionsEditor() {
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
}
/** @namespace SectionsEditor */
/** @namespace Editor */
Object.assign(editor, {
sections,
@ -95,7 +80,7 @@ function SectionsEditor() {
dirty.clear('name');
// FIXME: avoid recreating all editors?
if (codeIsUpdated !== false) {
await initSections(newStyle.sections, {replace: true, pristine: true});
await initSections(newStyle.sections, {replace: true});
}
Object.assign(style, newStyle);
updateHeader();
@ -105,7 +90,7 @@ function SectionsEditor() {
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
$('#heading').textContent = t('editStyleHeading');
}
livePreview.show(Boolean(style.id));
editor.livePreview.toggle(Boolean(style.id));
updateLivePreview();
},
@ -117,29 +102,24 @@ function SectionsEditor() {
if (!validate(newStyle)) {
return;
}
newStyle = await API.editSave(newStyle);
newStyle = await API.styles.editSave(newStyle);
destroyRemovedSections();
sessionStore.justEditedStyleId = newStyle.id;
editor.replaceStyle(newStyle, false);
},
scrollToEditor(cm) {
const section = sections.find(s => s.cm === cm).el;
const bounds = section.getBoundingClientRect();
if (
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
(bounds.top < 0 && bounds.bottom < window.innerHeight)
) {
if (bounds.top < 0) {
window.scrollBy(0, bounds.top - 1);
} else {
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
}
const {el} = sections.find(s => s.cm === cm);
const r = el.getBoundingClientRect();
const h = window.innerHeight;
if (r.bottom > h && r.top > 0 ||
r.bottom < h && r.top < 0) {
window.scrollBy(0, (r.top + r.bottom - h) / 2 | 0);
}
},
});
editor.ready = initSections(style.sections, {pristine: true});
editor.ready = initSections(style.sections);
/** @param {EditorSection} section */
function fitToContent(section) {
@ -156,7 +136,7 @@ function SectionsEditor() {
return;
}
if (headerOffset == null) {
headerOffset = container.getBoundingClientRect().top;
headerOffset = container.getBoundingClientRect().top + scrollY | 0;
}
contentHeight += 9; // border & resize grip
cm.off('update', resize);
@ -194,13 +174,13 @@ function SectionsEditor() {
progressElement.title = progress + '%';
});
} else {
$.remove(progressElement);
$remove(progressElement);
}
}
function showToMozillaHelp(event) {
event.preventDefault();
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
helpPopup.show(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
}
/**
@ -336,7 +316,7 @@ function SectionsEditor() {
function showMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
popup.codebox.setValue(sectionsToMozFormat(getModel()));
popup.codebox.setValue(MozDocMapper.styleToCss(getModel()));
popup.codebox.execCommand('selectAll');
}
@ -378,22 +358,21 @@ function SectionsEditor() {
lockPageUI(true);
try {
const code = popup.codebox.getValue().trim();
if (!/==userstyle==/i.test(code) ||
if (!URLS.rxMETA.test(code) ||
!await getPreprocessor(code) ||
await messageBox.confirm(
await messageBoxProxy.confirm(
t('importPreprocessor'), 'pre-line',
t('importPreprocessorTitle'))
) {
const {sections, errors} = await API.parseCss({code});
// shouldn't happen but just in case
if (!sections.length || errors.length) {
throw errors;
const {sections, errors} = await API.worker.parseMozFormat({code});
if (!sections.length || errors.some(e => !e.recoverable)) {
await Promise.reject(errors);
}
await initSections(sections, {
replace: replaceOldStyle,
focusOn: replaceOldStyle ? 0 : false,
});
$('.dismiss').dispatchEvent(new Event('click'));
helpPopup.close();
}
} catch (err) {
showError(err);
@ -403,7 +382,7 @@ function SectionsEditor() {
async function getPreprocessor(code) {
try {
return (await API.buildUsercssMeta({sourceCode: code})).usercssData.preprocessor;
return (await API.usercss.buildMeta({sourceCode: code})).usercssData.preprocessor;
} catch (e) {}
}
@ -417,10 +396,12 @@ function SectionsEditor() {
}
function showError(errors) {
messageBox({
messageBoxProxy.show({
className: 'center danger',
title: t('styleFromMozillaFormatError'),
contents: $create('pre', Array.isArray(errors) ? errors.join('\n') : errors),
contents: $create('pre',
(Array.isArray(errors) ? errors : [errors])
.map(e => e.message || e).join('\n')),
buttons: [t('confirmClose')],
});
}
@ -432,7 +413,7 @@ function SectionsEditor() {
sectionOrder = validSections.map(s => s.id).join(',');
dirty.modify('sectionOrder', oldOrder, sectionOrder);
container.dataset.sectionCount = validSections.length;
linter.refreshReport();
linterMan.refreshReport();
editor.updateToc();
}
@ -445,7 +426,7 @@ function SectionsEditor() {
function validate() {
if (!$('#name').reportValidity()) {
messageBox.alert(t('styleMissingName'));
messageBoxProxy.alert(t('styleMissingName'));
return false;
}
for (const section of sections) {
@ -454,7 +435,7 @@ function SectionsEditor() {
continue;
}
if (!apply.valueEl.reportValidity()) {
messageBox.alert(t('styleBadRegexp'));
messageBoxProxy.alert(t('styleBadRegexp'));
return false;
}
}
@ -486,13 +467,12 @@ function SectionsEditor() {
}
function updateLivePreviewNow() {
livePreview.update(getModel());
editor.livePreview.update(getModel());
}
async function initSections(src, {
focusOn = 0,
replace = false,
pristine = false,
} = {}) {
if (replace) {
sections.forEach(s => s.remove(true));
@ -504,6 +484,8 @@ function SectionsEditor() {
si.scrollY2 = si.scrollY + window.innerHeight;
container.style.height = si.scrollY2 + 'px';
scrollTo(0, si.scrollY);
// only restore focus if it's the first CM to avoid derpy quirks
focusOn = si.cms[0].focus && 0;
rerouteHotkeys(true);
} else {
si = null;
@ -523,8 +505,8 @@ function SectionsEditor() {
if (si) forceRefresh = y < si.scrollY2 && (y += si.cms[i].parentHeight) > si.scrollY;
insertSectionAfter(src[i], null, forceRefresh, si && si.cms[i]);
setGlobalProgress(i, src.length);
if (pristine) dirty.clear();
if (i === focusOn && !si) sections[i].cm.focus();
dirty.clear();
if (i === focusOn) sections[i].cm.focus();
}
if (!si) requestAnimationFrame(fitToAvailableSpace);
container.style.removeProperty('height');
@ -626,7 +608,7 @@ function SectionsEditor() {
/** @param {EditorSection} section */
function registerEvents(section) {
const {el, cm} = section;
$('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp'));
$('.applies-to-help', el).onclick = () => helpPopup.show(t('appliesLabel'), t('appliesHelp'));
$('.remove-section', el).onclick = () => removeSection(section);
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
@ -652,7 +634,7 @@ function SectionsEditor() {
function refreshOnView(cm, {code, force} = {}) {
if (code) {
linter.enableForEditor(cm, code);
linterMan.enableForEditor(cm, code);
}
if (force || !xo) {
refreshOnViewNow(cm);
@ -678,7 +660,7 @@ function SectionsEditor() {
}
async function refreshOnViewNow(cm) {
linter.enableForEditor(cm);
linterMan.enableForEditor(cm);
cm.refresh();
}

View File

@ -1,21 +1,13 @@
/* global
$
$$
$create
CodeMirror
onDOMready
prefs
showHelp
stringAsRegExp
t
*/
/* global $$ $create */// dom.js
/* global CodeMirror */
/* global helpPopup */// util.js
/* global prefs */
/* global stringAsRegExp */// toolbox.js
/* global t */// localization.js
'use strict';
onDOMready().then(() => {
$('#keyMap-help').addEventListener('click', showKeyMapHelp);
});
function showKeyMapHelp() {
/* exported showKeymapHelp */
function showKeymapHelp() {
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
const keyMapSorted = Object.keys(keyMap)
.map(key => ({key, cmd: keyMap[key]}))
@ -32,17 +24,19 @@ function showKeyMapHelp() {
tBody.appendChild(row.cloneNode(true));
}
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
const inputs = $$('input', table);
inputs[0].addEventListener('keydown', hotkeyHandler);
inputs[0].on('keydown', hotkeyHandler);
inputs[1].focus();
table.oninput = filterTable;
function hotkeyHandler(event) {
const keyName = CodeMirror.keyName(event);
if (keyName === 'Esc' || keyName === 'Tab' || keyName === 'Shift-Tab') {
if (keyName === 'Esc' ||
keyName === 'Tab' ||
keyName === 'Shift-Tab') {
return;
}
event.preventDefault();
@ -90,6 +84,7 @@ function showKeyMapHelp() {
}
}
}
function mergeKeyMaps(merged, ...more) {
more.forEach(keyMap => {
if (typeof keyMap === 'string') {
@ -102,7 +97,7 @@ function showKeyMapHelp() {
if (typeof cmd === 'function') {
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body)
// for 'vim*' keymaps: almost nothing as it doesn't rely on CM keymap mechanism
cmd = cmd.toString().replace(/^function.*?\{[\s\r\n]*([\s\S]+?)[\s\r\n]*\}$/, '$1');
cmd = cmd.toString().replace(/^function.*?{[\s\r\n]*([\s\S]+?)[\s\r\n]*}$/, '$1');
merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
} else {
merged[key] = cmd;

View File

@ -1,36 +1,26 @@
/* global
$
$$
$create
API
chromeSync
cmFactory
CodeMirror
createLivePreview
createMetaCompiler
debounce
editor
linter
messageBox
MozSectionFinder
MozSectionWidget
prefs
sectionsToMozFormat
sessionStore
t
*/
/* global $ $$remove $create $isTextInput messageBoxProxy */// dom.js
/* global API */// msg.js
/* global CodeMirror */
/* global MozDocMapper */// util.js
/* global MozSectionFinder */
/* global MozSectionWidget */
/* global URLS debounce sessionStore */// toolbox.js
/* global chromeSync */// storage-util.js
/* global cmFactory */
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported SourceEditor */
function SourceEditor() {
const {style, dirty} = editor;
const {style, /** @type DirtyReporter */dirty} = editor;
let savedGeneration;
let placeholderName = '';
let prevMode = NaN;
$$.remove('.sectioned-only');
$$remove('.sectioned-only');
$('#header').on('wheel', headerOnScroll);
$('#sections').textContent = '';
$('#sections').appendChild($create('.single-editor'));
@ -39,16 +29,26 @@ function SourceEditor() {
const cm = cmFactory.create($('.single-editor'));
const sectionFinder = MozSectionFinder(cm);
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc);
const livePreview = createLivePreview(preprocess, style.id);
/** @namespace SourceEditor */
const sectionWidget = MozSectionWidget(cm, sectionFinder);
editor.livePreview.init(preprocess, style.id);
createMetaCompiler(meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
/** @namespace Editor */
Object.assign(editor, {
sections: sectionFinder.sections,
replaceStyle,
updateLivePreview,
closestVisible: () => cm,
getEditors: () => [cm],
scrollToEditor: () => {},
getEditorTitle: () => '',
save,
getSearchableInputs: () => [],
prevEditor: nextPrevSection.bind(null, -1),
nextEditor: nextPrevSection.bind(null, 1),
jumpToEditor(i) {
@ -58,23 +58,37 @@ function SourceEditor() {
cm.jumpToPos(sec.start);
}
},
closestVisible: () => cm,
getSearchableInputs: () => [],
updateLivePreview,
async save() {
if (!dirty.isDirty()) return;
const sourceCode = cm.getValue();
try {
const {customName, enabled, id} = style;
if (!id &&
(await API.usercss.build({sourceCode, checkDup: true, metaOnly: true})).dup) {
messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
} else {
await replaceStyle(
await API.usercss.editSave({customName, enabled, id, sourceCode}));
}
} catch (err) {
const i = err.index;
const isNameEmpty = i > 0 &&
err.code === 'missingValue' &&
sourceCode.slice(sourceCode.lastIndexOf('\n', i - 1), i).trim().endsWith('@name');
return isNameEmpty
? saveTemplate(sourceCode)
: showSaveError(err);
}
},
scrollToEditor: () => {},
});
createMetaCompiler(cm, meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
prefs.subscribeMany({
'editor.linter': updateLinterSwitch,
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
}, {now: true});
}, {runNow: true});
editor.applyScrollInfo(cm);
cm.clearHistory();
cm.markClean();
@ -88,31 +102,29 @@ function SourceEditor() {
const mode = getModeName();
if (mode === prevMode) return;
prevMode = mode;
linter.run();
linterMan.run();
updateLinterSwitch();
});
setTimeout(linter.enableForEditor, 0, cm);
if (!$.isTextInput(document.activeElement)) {
setTimeout(linterMan.enableForEditor, 0, cm);
if (!$isTextInput(document.activeElement)) {
cm.focus();
}
function preprocess(style) {
return API.buildUsercss({
async function preprocess(style) {
const {style: newStyle} = await API.usercss.build({
styleId: style.id,
sourceCode: style.sourceCode,
assignVars: true,
})
.then(({style: newStyle}) => {
});
delete newStyle.enabled;
return Object.assign(style, newStyle);
});
}
function updateLivePreview() {
if (!style.id) {
return;
}
livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
editor.livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
}
function updateLinterSwitch() {
@ -140,10 +152,10 @@ function SourceEditor() {
async function setupNewStyle(style) {
style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
`/* ${t('usercssReplaceTemplateSectionBody')} */`;
let section = sectionsToMozFormat(style);
let section = MozDocMapper.styleToCss(style);
if (!section.includes('@-moz-document')) {
style.sections[0].domains = ['example.com'];
section = sectionsToMozFormat(style);
section = MozDocMapper.styleToCss(style);
}
const DEFAULT_CODE = `
/* ==UserStyle==
@ -199,7 +211,7 @@ function SourceEditor() {
return;
}
Promise.resolve(messageBox.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
if (!ok) return;
updateEnvironment();
if (!sameCode) {
@ -223,77 +235,29 @@ function SourceEditor() {
Object.assign(style, newStyle);
$('#preview-label').classList.remove('hidden');
updateMeta();
livePreview.show(Boolean(style.id));
editor.livePreview.toggle(Boolean(style.id));
}
}
function save() {
if (!dirty.isDirty()) return;
const code = cm.getValue();
return ensureUniqueStyle(code)
.then(() => API.editSaveUsercss({
id: style.id,
enabled: style.enabled,
sourceCode: code,
customName: style.customName,
}))
.then(replaceStyle)
.catch(err => {
if (err.handled) return;
const contents = Array.isArray(err) ?
$create('pre', err.join('\n')) :
[err.message || String(err)];
if (Number.isInteger(err.index)) {
const pos = cm.posFromIndex(err.index);
const meta = drawLinePointer(pos);
// save template
if (err.code === 'missingValue' && meta.includes('@name')) {
async function saveTemplate(code) {
if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
const key = chromeSync.LZ_KEY.usercssTemplate;
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
chromeSync.setLZValue(key, code)
.then(() => chromeSync.getLZValue(key))
.then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving'))));
return;
await chromeSync.setLZValue(key, code);
if (await chromeSync.getLZValue(key) !== code) {
messageBoxProxy.alert(t('syncStorageErrorSaving'));
}
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
contents.push($create('pre', meta));
}
messageBox.alert(contents, 'pre');
});
}
function ensureUniqueStyle(code) {
return style.id ? Promise.resolve() :
API.buildUsercss({
sourceCode: code,
checkDup: true,
metaOnly: true,
}).then(({dup}) => {
if (dup) {
messageBox.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
return Promise.reject({handled: true});
}
});
}
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
const pointer = ' '.repeat(pos.ch) + '^';
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
const leftPad = start !== 0 ? '...' : '';
const rightPad = end !== line.length ? '...' : '';
return (
leftPad +
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
rightPad +
'\n' +
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
pointer.slice(start, end)
);
function showSaveError(err) {
err = Array.isArray(err) ? err : [err];
const text = err.map(e => e.message || e).join('\n');
const points = err.map(e =>
e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1} // csslint code parser
).filter(Boolean);
cm.setSelections(points.map(p => ({anchor: p, head: p})));
messageBoxProxy.alert($create('pre', text), 'pre');
}
function nextPrevSection(dir) {
@ -334,4 +298,36 @@ function SourceEditor() {
return (mode.name || mode || '') +
(mode.helperType || '');
}
function createMetaCompiler(onUpdated) {
let meta = null;
let metaIndex = null;
let cache = [];
linterMan.register(async (text, options, _cm) => {
if (_cm !== cm) {
return;
}
const match = text.match(URLS.rxMETA);
if (!match) {
return [];
}
if (match[0] === meta && match.index === metaIndex) {
return cache;
}
const {metadata, errors} = await linterMan.worker.metalint(match[0]);
if (errors.every(err => err.code === 'unknownMeta')) {
onUpdated(metadata);
}
cache = errors.map(err => ({
from: cm.posFromIndex((err.index || 0) + match.index),
to: cm.posFromIndex((err.index || 0) + match.index),
message: err.code && t(`meta_${err.code}`, err.args, false) || err.message,
severity: err.code === 'unknownMeta' ? 'warning' : 'error',
rule: err.code,
}));
meta = match[0];
metaIndex = match.index;
return cache;
});
}
}

View File

@ -1,178 +1,109 @@
/* global
$create
CodeMirror
prefs
*/
/* global $ $create getEventKeyName messageBoxProxy moveFocus */// dom.js
/* global CodeMirror */
/* global debounce */// toolbox.js
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported DirtyReporter */
class DirtyReporter {
constructor() {
this._dirty = new Map();
this._onchange = new Set();
}
const helpPopup = {
add(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
this._dirty.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
show(title = '', body) {
const div = $('#help-popup');
const contents = $('.contents', div);
div.className = '';
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
}
}
this.notifyChange(wasDirty);
}
remove(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
this._dirty.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
this.notifyChange(wasDirty);
}
modify(obj, oldValue, newValue) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
if (oldValue !== newValue) {
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
this._dirty.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
this.notifyChange(wasDirty);
}
clear(obj) {
const wasDirty = this.isDirty();
if (obj === undefined) {
this._dirty.clear();
} else {
this._dirty.delete(obj);
}
this.notifyChange(wasDirty);
}
isDirty() {
return this._dirty.size > 0;
}
onChange(cb, add = true) {
this._onchange[add ? 'add' : 'delete'](cb);
}
notifyChange(wasDirty) {
if (wasDirty !== this.isDirty()) {
this._onchange.forEach(cb => cb());
}
}
has(key) {
return this._dirty.has(key);
}
}
/* exported DocFuncMapper */
const DocFuncMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
domains: 'domain',
regexps: 'regexp',
$('.title', div).textContent = title;
$('.dismiss', div).onclick = helpPopup.close;
window.on('keydown', helpPopup.close, true);
// reset any inline styles
div.style = 'display: block';
helpPopup.originalFocus = document.activeElement;
return div;
},
FROM_CSS: {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
},
/**
* @param {Object} section
* @param {function(func:string, value:string)} fn
*/
forEachProp(section, fn) {
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
const props = section[propName];
if (props) props.forEach(value => fn(func, value));
close(event) {
const canClose =
!event ||
event.type === 'click' || (
getEventKeyName(event) === 'Escape' &&
!$('.CodeMirror-hints, #message-box') && (
!document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
const div = $('#help-popup');
if (!canClose || !div) {
return;
}
},
/**
* @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
* @returns {Object} section
*/
toSection(funcItems, section = {}) {
for (const item of funcItems) {
const [func, value] = item || [];
const propName = DocFuncMapper.FROM_CSS[func];
if (propName) {
const props = section[propName] || (section[propName] = []);
if (Array.isArray(value)) props.push(...value);
else props.push(value);
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(async () => {
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
return ok && helpPopup.close();
});
return;
}
if (div.contains(document.activeElement) && helpPopup.originalFocus) {
helpPopup.originalFocus.focus();
}
return section;
const contents = $('.contents', div);
div.style.display = '';
contents.textContent = '';
window.off('keydown', helpPopup.close, true);
window.dispatchEvent(new Event('closeHelp'));
},
};
/* exported sectionsToMozFormat */
function sectionsToMozFormat(style) {
return style.sections.map(section => {
const cssFuncs = [];
DocFuncMapper.forEachProp(section, (type, value) =>
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
return cssFuncs.length ?
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
section.code;
}).join('\n\n');
}
// reroute handling to nearest editor when keypress resolves to one of these commands
Object.assign(rerouteHotkeys, {
commands: [
'beautify',
'colorpicker',
'find',
'findNext',
'findPrev',
'jumpToLine',
'nextEditor',
'prevEditor',
'replace',
'replaceAll',
'save',
'toggleEditorFocus',
'toggleStyle',
],
/* exported trimCommentLabel */
function trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
}
toggle(enable) {
document[enable ? 'on' : 'off']('keydown', rerouteHotkeys.handler);
},
handler(event) {
const keyName = CodeMirror.keyName(event);
if (!keyName) {
return;
}
const rerouteCommand = name => {
if (rerouteHotkeys.commands.includes(name)) {
CodeMirror.commands[name](editor.closestVisible(event.target));
return true;
}
};
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
event.preventDefault();
event.stopPropagation();
}
},
});
/* exported clipString */
function clipString(str, limit = 100) {
return str.length <= limit ? str : str.substr(0, limit) + '...';
}
/* exported memoize */
function memoize(fn) {
let cached = false;
let result;
return (...args) => {
if (!cached) {
result = fn(...args);
cached = true;
}
return result;
};
}
/* exported createHotkeyInput */
/**
* @param {!string} prefId
* @param {?function(isEnter:boolean)} onDone
*/
function createHotkeyInput(prefId, onDone = () => {}) {
return $create('input', {
type: 'search',
@ -217,3 +148,59 @@ function createHotkeyInput(prefId, onDone = () => {}) {
},
});
}
function rerouteHotkeys(enable, immediately) {
if (immediately) {
rerouteHotkeys.toggle(enable);
} else {
debounce(rerouteHotkeys.toggle, 0, enable);
}
}
/* exported showCodeMirrorPopup */
function showCodeMirrorPopup(title, html, options) {
const popup = helpPopup.show(title, html);
popup.classList.add('big');
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
matchBrackets: true,
styleActiveLine: true,
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
}, options));
cm.focus();
rerouteHotkeys(false);
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
const onKeyDown = event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
const search = $('#search-replace-dialog');
const area = search && search.contains(document.activeElement) ? search : popup;
moveFocus(area, event.shiftKey ? -1 : 1);
event.preventDefault();
}
};
window.on('keydown', onKeyDown, true);
window.on('closeHelp', () => {
window.off('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
}
/* exported trimCommentLabel */
function trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
}

View File

@ -7,44 +7,21 @@
<title>Loading...</title>
<link href="global.css" rel="stylesheet">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
<script src="js/polyfill.js"></script>
<script src="js/msg.js"></script>
<script src="js/messaging.js"></script>
<script src="js/toolbox.js"></script>
<script src="install-usercss/preinit.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="vendor/semver-bundle/semver.js"></script>
<link href="msgbox/msgbox.css" rel="stylesheet">
<script src="msgbox/msgbox.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<script src="vendor/codemirror/mode/css/css.js"></script>
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
<script src="edit/codemirror-default.js"></script>
<link rel="stylesheet" href="edit/codemirror-default.css">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
</head>
<body id="stylus-install-usercss">
<div class="container">
@ -92,13 +69,13 @@
</div>
</div>
<script src="install-usercss/install-usercss.js"></script>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000">
<path fill-rule="evenodd" d="M983.2,184.3L853,69.8c-4-3.5-9.3-5.3-14.5-5c-5.3,0.4-10.3,2.8-13.8,6.8L352.3,609.2L184.4,386.9c-3.2-4.2-8-7-13.2-7.8c-5.3-0.8-10.6,0.6-14.9,3.9L18,487.5c-8.8,6.7-10.6,19.3-3.9,28.1L325,927.2c3.6,4.8,9.3,7.7,15.3,8c0.2,0,0.5,0,0.7,0c5.8,0,11.3-2.5,15.1-6.8L985,212.6C992.3,204.3,991.5,191.6,983.2,184.3z"/>
</symbol>
</svg>
<script src="js/dlg/message-box.js"></script>
<script src="install-usercss/install-usercss.js"></script>
</body>
</html>

View File

@ -301,6 +301,10 @@ li {
user-select: auto;
}
#header.meta-init[data-arrived-fast="true"] > * {
transition-duration: .1s;
}
label {
padding-left: 16px;
position: relative;

View File

@ -1,34 +1,23 @@
/* global CodeMirror semverCompare closeCurrentTab messageBox download
$ $$ $create $createLink t prefs API */
/* global $ $create $createLink $$remove */
/* global API */// msg.js
/* global closeCurrentTab */// toolbox.js
/* global messageBox */
/* global prefs */
/* global preinit */
/* global t */// localization.js
'use strict';
(() => {
const params = new URLSearchParams(location.search);
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
const initialUrl = params.get('updateUrl');
let cm;
let initialUrl;
let installed;
let installedDup;
let liveReload;
let tabId;
let installed = null;
let installedDup = null;
const liveReload = initLiveReload();
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre'));
const theme = prefs.get('editor.theme');
const cm = CodeMirror($('.main'), {
readOnly: true,
colorpicker: true,
theme,
});
if (theme !== 'default') {
document.head.appendChild($create('link', {
rel: 'stylesheet',
href: `vendor/codemirror/theme/${theme}.css`,
}));
}
window.addEventListener('resize', adjustCodeHeight);
window.on('resize', adjustCodeHeight);
// "History back" in Firefox (for now) restores the old DOM including the messagebox,
// which stays after installing since we don't want to wait for the fadeout animation before resolving.
document.addEventListener('visibilitychange', () => {
document.on('visibilitychange', () => {
if (messageBox.element) messageBox.element.remove();
if (installed) liveReload.onToggled();
});
@ -40,6 +29,116 @@
}
}, 200);
/*
* Preinit starts to download as early as possible,
* then the critical rendering path scripts are loaded in html,
* then the meta of the downloaded code is parsed in the background worker,
* then CodeMirror scripts/css are added so they can load while the worker runs in parallel,
* then the meta response arrives from API and is immediately displayed in CodeMirror,
* then the sections of code are parsed in the background worker and displayed.
*/
(async function init() {
const theme = prefs.get('editor.theme');
if (theme !== 'default') {
require([`/vendor/codemirror/theme/${theme}.css`]); // not awaiting as it may be absent
}
const scriptsReady = require([
'/vendor/codemirror/lib/codemirror', /* global CodeMirror */
]).then(() => require([
'/vendor/codemirror/keymap/sublime',
'/vendor/codemirror/keymap/emacs',
'/vendor/codemirror/keymap/vim', // TODO: load conditionally
'/vendor/codemirror/mode/css/css',
'/vendor/codemirror/addon/search/searchcursor',
'/vendor/codemirror/addon/fold/foldcode',
'/vendor/codemirror/addon/fold/foldgutter',
'/vendor/codemirror/addon/fold/brace-fold',
'/vendor/codemirror/addon/fold/indent-fold',
'/vendor/codemirror/addon/selection/active-line',
'/vendor/codemirror/lib/codemirror.css',
'/vendor/codemirror/addon/fold/foldgutter.css',
'/vendor/semver-bundle/semver', /* global semverCompare */
'/js/sections-util', /* global styleCodeEmpty */
'/js/color/color-converter',
'/edit/codemirror-default.css',
])).then(() => require([
'/edit/codemirror-default',
'/js/color/color-view',
]));
({tabId, initialUrl} = await preinit);
liveReload = initLiveReload();
const {dup, style, error, sourceCode} = await preinit.ready;
if (!style && sourceCode == null) {
messageBox.alert(isNaN(error) ? error : 'HTTP Error ' + error, 'pre');
return;
}
await scriptsReady;
cm = CodeMirror($('.main'), {
value: sourceCode || style.sourceCode,
readOnly: true,
colorpicker: true,
theme,
});
if (error) {
showBuildError(error);
}
if (!style) {
return;
}
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
updateMeta(style, dup);
// update UI
if (versionTest < 0) {
$('.actions').parentNode.insertBefore(
$create('.warning', t('versionInvalidOlder')),
$('.actions')
);
}
$('button.install').onclick = () => {
(!dup ?
Promise.resolve(true) :
messageBox.confirm(t('styleInstallOverwrite', [
data.name + (dup.customName ? ` (${dup.customName})` : ''),
dupData.version,
data.version,
]))
).then(ok => ok &&
API.usercss.install(style)
.then(install)
.catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
);
};
// set updateUrl
const checker = $('.set-update-url input[type=checkbox]');
const updateUrl = new URL(style.updateUrl || initialUrl);
if (dup && dup.updateUrl === updateUrl.href) {
checker.checked = true;
// there is no way to "unset" updateUrl, you can only overwrite it.
checker.disabled = true;
} else if (updateUrl.protocol !== 'file:') {
checker.checked = true;
style.updateUrl = updateUrl.href;
}
checker.onchange = () => {
style.updateUrl = checker.checked ? updateUrl.href : null;
};
checker.onchange();
$('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
updateUrl.href.slice(0, 300) + '...';
if (initialUrl.startsWith('file:')) {
$('.live-reload input').onchange = liveReload.onToggled;
} else {
$('.live-reload').remove();
}
})();
function updateMeta(style, dup = installedDup) {
installedDup = dup;
@ -79,8 +178,8 @@
$('.meta-license').textContent = data.license;
$('.applies-to').textContent = '';
getAppliesTo(style).forEach(pattern =>
$('.applies-to').appendChild($create('li', pattern)));
getAppliesTo(style).then(list =>
$('.applies-to').append(...list.map(s => $create('li', s))));
$('.external-link').textContent = '';
const externalLink = makeExternalLink();
@ -88,9 +187,10 @@
$('.external-link').appendChild(externalLink);
}
$('#header').dataset.arrivedFast = performance.now() < 500;
$('#header').classList.add('meta-init');
$('#header').classList.remove('meta-init-error');
setTimeout(() => $.remove('.lds-spinner'), 1000);
setTimeout(() => $$remove('.lds-spinner'), 1000);
showError('');
requestAnimationFrame(adjustCodeHeight);
@ -137,18 +237,41 @@
function showError(err) {
$('.warnings').textContent = '';
if (err) {
$('.warnings').appendChild(buildWarning(err));
}
$('.warnings').classList.toggle('visible', Boolean(err));
$('.container').classList.toggle('has-warnings', Boolean(err));
err = Array.isArray(err) ? err : [err];
if (err[0]) {
let i;
if ((i = err[0].index) >= 0 ||
(i = err[0].offset) >= 0) {
cm.jumpToPos(cm.posFromIndex(i));
cm.setSelections(err.map(e => {
const pos = e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1}; // csslint code parser
return pos && {anchor: pos, head: pos};
}).filter(Boolean));
cm.focus();
}
$('.warnings').appendChild(
$create('.warning', [
t('parseUsercssError'),
'\n',
...err.map(e => e.message ? $create('pre', e.message) : e || 'Unknown error'),
]));
}
adjustCodeHeight();
}
function showBuildError(error) {
$('#header').classList.add('meta-init-error');
console.error(error);
showError(error);
}
function install(style) {
installed = style;
$$.remove('.warning');
$$remove('.warning');
$('button.install').disabled = true;
$('button.install').classList.add('installed');
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
@ -173,127 +296,30 @@
}
}
function initSourceCode(sourceCode) {
cm.setValue(sourceCode);
cm.refresh();
API.buildUsercss({sourceCode, checkDup: true})
.then(init)
.catch(err => {
$('#header').classList.add('meta-init-error');
console.error(err);
showError(err);
});
}
function buildWarning(err) {
const contents = Array.isArray(err) ?
[$create('pre', err.join('\n'))] :
[err && err.message && $create('pre', err.message) || err || 'Unknown error'];
if (Number.isInteger(err.index) && typeof contents[0] === 'string') {
const pos = cm.posFromIndex(err.index);
contents[0] = `${pos.line + 1}:${pos.ch + 1} ` + contents[0];
contents.push($create('pre', drawLinePointer(pos)));
setTimeout(() => {
cm.scrollIntoView({line: pos.line + 1, ch: pos.ch}, window.innerHeight / 4);
cm.setCursor(pos.line, pos.ch + 1);
cm.focus();
});
}
return $create('.warning', [
t('parseUsercssError'),
'\n',
...contents,
]);
}
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
const pointer = ' '.repeat(pos.ch) + '^';
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
const leftPad = start !== 0 ? '...' : '';
const rightPad = end !== line.length ? '...' : '';
return (
leftPad +
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
rightPad +
'\n' +
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
pointer.slice(start, end)
);
}
function init({style, dup}) {
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
updateMeta(style, dup);
// update UI
if (versionTest < 0) {
$('.actions').parentNode.insertBefore(
$create('.warning', t('versionInvalidOlder')),
$('.actions')
);
}
$('button.install').onclick = () => {
(!dup ?
Promise.resolve(true) :
messageBox.confirm(t('styleInstallOverwrite', [
data.name + (dup.customName ? ` (${dup.customName})` : ''),
dupData.version,
data.version,
]))
).then(ok => ok &&
API.installUsercss(style)
.then(install)
.catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
);
};
// set updateUrl
const checker = $('.set-update-url input[type=checkbox]');
const updateUrl = new URL(style.updateUrl || initialUrl);
if (dup && dup.updateUrl === updateUrl.href) {
checker.checked = true;
// there is no way to "unset" updateUrl, you can only overwrite it.
checker.disabled = true;
} else if (updateUrl.protocol !== 'file:') {
checker.checked = true;
style.updateUrl = updateUrl.href;
}
checker.onchange = () => {
style.updateUrl = checker.checked ? updateUrl.href : null;
};
checker.onchange();
$('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
updateUrl.href.slice(0, 300) + '...';
if (initialUrl.startsWith('file:')) {
$('.live-reload input').onchange = liveReload.onToggled;
} else {
$('.live-reload').remove();
async function getAppliesTo(style) {
if (style.sectionsPromise) {
try {
style.sections = await style.sectionsPromise;
} catch (error) {
showBuildError(error);
return [];
} finally {
delete style.sectionsPromise;
}
}
function getAppliesTo(style) {
function *_gen() {
let numGlobals = 0;
const res = [];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
for (const section of style.sections) {
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
if (section[type]) {
yield *section[type];
const targets = [].concat(...TARGETS.map(t => section[t]).filter(Boolean));
res.push(...targets);
numGlobals += !targets.length && !styleCodeEmpty(section.code);
}
res.sort();
if (!res.length || numGlobals) {
res.push(t('appliesToEverything'));
}
}
}
const result = [..._gen()];
if (!result.length) {
result.push(chrome.i18n.getMessage('appliesToEverything'));
}
return result;
return [...new Set(res)];
}
function adjustCodeHeight() {
@ -311,24 +337,12 @@
const DELAY = 500;
let isEnabled = false;
let timer = 0;
/** @type function(?options):Promise<string|null> */
let getData = null;
/** @type Promise */
let sequence = null;
if (tabId < 0) {
getData = DirectDownloader();
sequence = API.getUsercssInstallCode(initialUrl)
.then(code => code || getData())
.catch(getData);
} else {
getData = PortDownloader();
sequence = getData({timer: false});
}
const getData = preinit.getData;
let sequence = preinit.ready;
return {
get enabled() {
return isEnabled;
},
ready: sequence,
onToggled(e) {
if (e) isEnabled = e.target.checked;
if (installed || installedDup) {
@ -372,46 +386,9 @@
cm.setValue(code);
cm.setCursor(cursor);
cm.scrollTo(scrollInfo.left, scrollInfo.top);
return API.installUsercss({id, sourceCode: code})
return API.usercss.install({id, sourceCode: code})
.then(updateMeta)
.catch(showError);
});
}
function DirectDownloader() {
let oldCode = null;
const passChangedCode = code => {
const isSame = code === oldCode;
oldCode = code;
return isSame ? null : code;
};
return () => download(initialUrl).then(passChangedCode);
}
function PortDownloader() {
const resolvers = new Map();
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
port.onMessage.addListener(({id, code, error}) => {
const r = resolvers.get(id);
resolvers.delete(id);
if (error) {
r.reject(error);
} else {
r.resolve(code);
}
});
port.onDisconnect.addListener(async () => {
const tab = await browser.tabs.get(tabId).catch(() => ({}));
if (tab.url === initialUrl) {
location.reload();
} else {
closeCurrentTab();
}
});
return (opts = {}) => new Promise((resolve, reject) => {
const id = performance.now();
resolvers.set(id, {resolve, reject});
opts.id = id;
port.postMessage(opts);
});
}
}
})();

View File

@ -0,0 +1,90 @@
/* global API */// msg.js
/* global closeCurrentTab download */// toolbox.js
'use strict';
/* exported preinit */
const preinit = (() => {
const params = new URLSearchParams(location.search);
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
const initialUrl = params.get('updateUrl');
/** @type function(?options):Promise<?string> */
let getData;
/** @type {Promise<?string>} */
let firstGet;
if (tabId < 0) {
getData = DirectDownloader();
firstGet = API.usercss.getInstallCode(initialUrl)
.then(code => code || getData())
.catch(getData);
} else {
getData = PortDownloader();
firstGet = getData({timer: false});
}
function DirectDownloader() {
let oldCode = null;
return async () => {
const code = await download(initialUrl);
if (oldCode !== code) {
oldCode = code;
return code;
}
};
}
function PortDownloader() {
const resolvers = new Map();
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
port.onMessage.addListener(({id, code, error}) => {
const r = resolvers.get(id);
resolvers.delete(id);
if (error) {
r.reject(error);
} else {
r.resolve(code);
}
});
port.onDisconnect.addListener(async () => {
const tab = await browser.tabs.get(tabId).catch(() => ({}));
if (tab.url === initialUrl) {
location.reload();
} else {
closeCurrentTab();
}
});
return (opts = {}) => new Promise((resolve, reject) => {
const id = performance.now();
resolvers.set(id, {resolve, reject});
opts.id = id;
port.postMessage(opts);
});
}
return {
getData,
initialUrl,
tabId,
/** @type {Promise<{style, dup} | {error}>} */
ready: (async () => {
let sourceCode;
try {
sourceCode = await firstGet;
} catch (error) {
return {error};
}
try {
const data = await API.usercss.build({sourceCode, checkDup: true, metaOnly: true});
Object.defineProperty(data.style, 'sectionsPromise', {
value: API.usercss.buildCode(data.style).then(style => style.sections),
configurable: true,
});
return data;
} catch (error) {
return {error, sourceCode};
}
})(),
};
})();

View File

@ -1,71 +0,0 @@
/* exported createCache */
'use strict';
// create a FIFO limit-size map.
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get,
set,
delete: delete_,
clear,
has: id => map.has(id),
entries: function *() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
values: function *() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
function get(id) {
const item = map.get(id);
return item && item.data;
}
function set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
}
function delete_(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
}
function clear() {
map.clear();
index = lastIndex = 0;
}
}

89
js/color/color-mimicry.js Normal file
View File

@ -0,0 +1,89 @@
/* global $create */// dom.js
/* global debounce */// toolbox.js
'use strict';
/* exported colorMimicry */
/**
* Calculates real color of an element:
* colorMimicry(cm.display.gutters, {bg: 'backgroundColor'})
* colorMimicry('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
*/
function colorMimicry(el, targets, dummyContainer = document.body) {
const styleCache = colorMimicry.styleCache || (colorMimicry.styleCache = new Map());
targets = targets || {};
targets.fore = 'color';
const colors = {};
const done = {};
let numDone = 0;
let numTotal = 0;
const rootStyle = getStyle(document.documentElement);
for (const k in targets) {
const base = {r: 255, g: 255, b: 255, a: 1};
blend(base, rootStyle[targets[k]]);
colors[k] = base;
numTotal++;
}
const isDummy = typeof el === 'string';
if (isDummy) {
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
}
for (let current = el; current; current = current && current.parentElement) {
const style = getStyle(current);
for (const k in targets) {
if (!done[k]) {
done[k] = blend(colors[k], style[targets[k]]);
numDone += done[k] ? 1 : 0;
if (numDone === numTotal) {
current = null;
break;
}
}
}
colors.style = colors.style || style;
}
if (isDummy) {
el.remove();
}
for (const k in targets) {
const {r, g, b, a} = colors[k];
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
// https://www.w3.org/TR/AERT#color-contrast
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
}
debounce(clearCache);
return colors;
function blend(base, color) {
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
if (a === 255) {
base.r = r;
base.g = g;
base.b = b;
base.a = 1;
} else if (a) {
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
const q1 = a / 255 / mixedA;
const q2 = base.a * (1 - mixedA) / mixedA;
base.r = Math.round(r * q1 + base.r * q2);
base.g = Math.round(g * q1 + base.g * q2);
base.b = Math.round(b * q1 + base.b * q2);
base.a = mixedA;
}
return Math.abs(base.a - 1) < 1e-3;
}
// speed-up for sequential invocations within the same event loop cycle
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
function getStyle(el) {
let style = styleCache.get(el);
if (!style) {
style = getComputedStyle(el);
styleCache.set(el, style);
}
return style;
}
function clearCache() {
styleCache.clear();
}
}

View File

@ -1,9 +1,9 @@
/* global colorConverter $create debounce */
/* exported colorMimicry */
/* global colorConverter */
/* global colorMimicry */
'use strict';
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
const cm = this;
const cm = window.CodeMirror && this;
const CSS_PREFIX = 'colorpicker-';
const HUE_COLORS = [
{hex: '#ff0000', start: .0},
@ -679,7 +679,7 @@
function onCloseRequest(event) {
if (event.detail !== PUBLIC_API) {
hide();
} else if (!prevFocusedElement) {
} else if (!prevFocusedElement && cm) {
// we're between mousedown and mouseup and colorview wants to re-open us in this cm
// so we'll prevent onMouseUp from hiding us to avoid flicker
prevFocusedElement = cm.display.input;
@ -867,9 +867,9 @@
function guessTheme() {
const el = options.guessBrightness ||
((cm.display.renderedView || [])[0] || {}).text ||
cm.display.lineDiv;
const bgLuma = window.colorMimicry.get(el, {bg: 'backgroundColor'}).bgLuma;
cm && ((cm.display.renderedView || [])[0] || {}).text ||
cm && cm.display.lineDiv;
const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma;
return bgLuma < .5 ? 'dark' : 'light';
}
@ -893,92 +893,3 @@
//endregion
};
//////////////////////////////////////////////////////////////////
// eslint-disable-next-line no-var
var colorMimicry = (() => {
const styleCache = new Map();
return {get};
// Calculates real color of an element:
// colorMimicry.get(cm.display.gutters, {bg: 'backgroundColor'})
// colorMimicry.get('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
function get(el, targets, dummyContainer = document.body) {
targets = targets || {};
targets.fore = 'color';
const colors = {};
const done = {};
let numDone = 0;
let numTotal = 0;
const rootStyle = getStyle(document.documentElement);
for (const k in targets) {
const base = {r: 255, g: 255, b: 255, a: 1};
blend(base, rootStyle[targets[k]]);
colors[k] = base;
numTotal++;
}
const isDummy = typeof el === 'string';
if (isDummy) {
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
}
for (let current = el; current; current = current && current.parentElement) {
const style = getStyle(current);
for (const k in targets) {
if (!done[k]) {
done[k] = blend(colors[k], style[targets[k]]);
numDone += done[k] ? 1 : 0;
if (numDone === numTotal) {
current = null;
break;
}
}
}
colors.style = colors.style || style;
}
if (isDummy) {
el.remove();
}
for (const k in targets) {
const {r, g, b, a} = colors[k];
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
// https://www.w3.org/TR/AERT#color-contrast
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
}
debounce(clearCache);
return colors;
}
function blend(base, color) {
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
if (a === 255) {
base.r = r;
base.g = g;
base.b = b;
base.a = 1;
} else if (a) {
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
const q1 = a / 255 / mixedA;
const q2 = base.a * (1 - mixedA) / mixedA;
base.r = Math.round(r * q1 + base.r * q2);
base.g = Math.round(g * q1 + base.g * q2);
base.b = Math.round(b * q1 + base.b * q2);
base.a = mixedA;
}
return Math.abs(base.a - 1) < 1e-3;
}
// speed-up for sequential invocations within the same event loop cycle
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
function getStyle(el) {
let style = styleCache.get(el);
if (!style) {
style = getComputedStyle(el);
styleCache.set(el, style);
}
return style;
}
function clearCache() {
styleCache.clear();
}
})();

View File

@ -1,4 +1,5 @@
/* global CodeMirror colorConverter */
/* global CodeMirror */
/* global colorConverter */
'use strict';
(() => {
@ -99,7 +100,7 @@
const cache = new Set();
class ColorSwatch {
constructor(cm, options) {
constructor(cm, options = {}) {
this.cm = cm;
this.options = options;
this.markersToRemove = [];

View File

@ -38,7 +38,7 @@ class Reporter {
* @param {Object} ruleset - The set of rules to work with, including if
* they are errors or warnings.
* @param {Object} allow - explicitly allowed lines
* @param {[][]} ingore - list of line ranges to be ignored
* @param {[][]} ignore - list of line ranges to be ignored
*/
constructor(lines, ruleset, allow, ignore) {
this.messages = [];
@ -204,7 +204,8 @@ var CSSLint = (() => {
try {
parser.parse(text, {reuseCache});
} catch (ex) {
reporter.error('Fatal error, cannot continue: ' + ex.message, ex.line, ex.col, {});
reporter.error('Fatal error, cannot continue!\n' + ex.stack,
ex.line || 1, ex.col || 1, {});
}
const report = {
@ -324,23 +325,8 @@ var CSSLint = (() => {
//endregion
//region Util
// expose for testing purposes
CSSLint._Reporter = Reporter;
CSSLint.Util = {
indexOf(values, value) {
if (typeof values.indexOf === 'function') {
return values.indexOf(value);
}
for (let i = 0, len = values.length; i < len; i++) {
if (values[i] === value) {
return i;
}
}
return -1;
},
registerBlockEvents(parser, start, end, property) {
for (const e of [
'document',
@ -643,7 +629,7 @@ CSSLint.addRule({
if (inKeyFrame &&
typeof inKeyFrame === 'string' &&
name.startsWith(inKeyFrame) ||
CSSLint.Util.indexOf(applyTo, name) < 0) {
applyTo.indexOf(name) < 0) {
return;
}
properties.push(event.property);
@ -657,7 +643,7 @@ CSSLint.addRule({
for (const name of properties) {
for (const prop in compatiblePrefixes) {
const variations = compatiblePrefixes[prop];
if (CSSLint.Util.indexOf(variations, name.text) <= -1) continue;
if (variations.indexOf(name.text) <= -1) continue;
if (!propertyGroups[prop]) {
propertyGroups[prop] = {
@ -667,7 +653,7 @@ CSSLint.addRule({
};
}
if (CSSLint.Util.indexOf(propertyGroups[prop].actual, name.text) === -1) {
if (propertyGroups[prop].actual.indexOf(name.text) === -1) {
propertyGroups[prop].actual.push(name.text);
propertyGroups[prop].actualNodes.push(name);
}
@ -680,7 +666,7 @@ CSSLint.addRule({
if (value.full.length <= actual.length) continue;
for (const item of value.full) {
if (CSSLint.Util.indexOf(actual, item) !== -1) continue;
if (actual.indexOf(item) !== -1) continue;
const propertiesSpecified =
actual.length === 1 ?
@ -1122,7 +1108,9 @@ CSSLint.addRule({
parser.addListener('import', () => count++);
parser.addListener('endstylesheet', () => {
if (count > MAX_IMPORT_COUNT) {
reporter.rollupError(`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`, this);
reporter.rollupError(
`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`,
this);
}
});
},
@ -1179,9 +1167,8 @@ CSSLint.addRule({
init(parser, reporter) {
parser.addListener('property', event => {
if (event.invalid) {
reporter.report(event.invalid.message, event.line, event.col, this);
}
const inv = event.invalid;
if (inv) reporter.report(inv.message, inv.line, inv.col, this);
});
},
});
@ -1422,13 +1409,10 @@ CSSLint.addRule({
init(parser, reporter) {
parser.addListener('startrule', event => {
for (const {parts} of event.selectors) {
for (let p = 0, pLen = parts.length; p < pLen; p++) {
for (let n = p + 1; n < pLen; n++) {
if (parts[p].type === 'descendant' &&
parts[n].line > parts[p].line) {
for (let i = 0, p, pn; i < parts.length - 1 && (p = parts[i]); i++) {
if (p.type === 'descendant' && (pn = parts[i + 1]).line > p.line) {
reporter.report('newline character found in selector (forgot a comma?)',
parts[p].line, parts[0].col, this);
}
pn.line, pn.col, this);
}
}
}
@ -1491,6 +1475,37 @@ CSSLint.addRule({
},
});
CSSLint.addRule({
id: 'simple-not',
name: 'Require use of simple selectors inside :not()',
desc: 'A complex selector inside :not() is only supported by CSS4-compliant browsers.',
browsers: 'All',
init(parser, reporter) {
parser.addListener('startrule', e => {
for (const sel of e.selectors) {
if (!/:not\(/i.test(sel.text)) continue;
for (const part of sel.parts) {
if (!part.modifiers) continue;
for (const mod of part.modifiers) {
if (mod.type !== 'not') continue;
const {args} = mod;
const {parts} = args[0];
if (args.length > 1 ||
parts.length !== 1 ||
parts[0].modifiers.length + (parts[0].elementName ? 1 : 0) > 1 ||
/^:not\(/i.test(parts[0])) {
reporter.report(
`Simple selector expected, but found '${args.join(', ')}'`,
args[0].line, args[0].col, this);
}
}
}
}
});
},
});
CSSLint.addRule({
id: 'star-property-hack',
name: 'Disallow properties with a star prefix',

View File

@ -676,8 +676,9 @@ self.parserlib = (() => {
x: 'resolution',
ar: 'dimension',
};
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]+/yu;
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]+/yu;
// Sticky `y` flag must be used in expressions used with peekTest and readMatch
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]/u;
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]/u;
const rxNameCharNoEsc = /[-_\da-zA-Z\u00A0-\uFFFF]+/yu; // must not match \\
const rxUnquotedUrlCharNoEsc = /[-!#$%&*-[\]-~\u00A0-\uFFFF]+/yu; // must not match \\
const rxVendorPrefix = /^-(webkit|moz|ms|o)-(.+)/i;
@ -1174,6 +1175,7 @@ self.parserlib = (() => {
//#region Tokens
/* https://www.w3.org/TR/css3-syntax/#lexical */
/** @type {Object<string,number|Object>} */
const Tokens = Object.assign([], {
EOF: {}, // must be the first token
}, {
@ -1530,8 +1532,10 @@ self.parserlib = (() => {
constructor(matchFunc, toString, options) {
this.matchFunc = matchFunc;
/** @type {function(?number):string} */
this.toString = typeof toString === 'function' ? toString : () => toString;
if (options) this.options = options;
/** @type {?Matcher[]} */
this.options = options;
}
/**
@ -2013,11 +2017,6 @@ self.parserlib = (() => {
// individual media query
class MediaQuery extends SyntaxUnit {
/**
* @param {String} modifier The modifier "not" or "only" (or null).
* @param {String} mediaType The type of media (i.e., "print").
* @param {Array} features Array of selectors parts making up this selector.
*/
constructor(modifier, mediaType, features, pos) {
const text = (modifier ? modifier + ' ' : '') +
(mediaType ? mediaType : '') +
@ -2045,9 +2044,6 @@ self.parserlib = (() => {
* including multiple selectors (those separated by commas).
*/
class Selector extends SyntaxUnit {
/**
* @param {SelectorPart[]} parts
*/
constructor(parts, pos) {
super(parts.join(' '), pos, TYPES.SELECTOR_TYPE);
this.parts = parts;
@ -2061,10 +2057,6 @@ self.parserlib = (() => {
* Does not include combinators such as spaces, +, >, etc.
*/
class SelectorPart extends SyntaxUnit {
/**
* @param {String} elementName or null if there's none
* @param {SelectorSubPart[]} modifiers - may be empty
*/
constructor(elementName, modifiers, text, pos) {
super(text, pos, TYPES.SELECTOR_PART_TYPE);
this.elementName = elementName;
@ -2076,9 +2068,6 @@ self.parserlib = (() => {
* Selector modifier string
*/
class SelectorSubPart extends SyntaxUnit {
/**
* @param {string} type - elementName id class attribute pseudo any not
*/
constructor(text, type, pos) {
super(text, pos, TYPES.SELECTOR_SUB_PART_TYPE);
this.type = type;
@ -2136,21 +2125,15 @@ self.parserlib = (() => {
}
return 0;
}
/**
* @return {int} The numeric value for the specificity.
*/
valueOf() {
return (this.a * 1000) + (this.b * 100) + (this.c * 10) + this.d;
return this.a * 1000 + this.b * 100 + this.c * 10 + this.d;
}
/**
* @return {String} The string representation of specificity.
*/
toString() {
return this.a + ',' + this.b + ',' + this.c + ',' + this.d;
return `${this.a},${this.b},${this.c},${this.d}`;
}
/**
* Calculates the specificity of the given selector.
* @param {Selector} The selector to calculate specificity for.
* @param {Selector} selector The selector to calculate specificity for.
* @return {Specificity} The specificity of the selector.
*/
static calculate(selector) {
@ -2192,7 +2175,6 @@ self.parserlib = (() => {
class PropertyName extends SyntaxUnit {
constructor(text, hack, pos) {
super(text, pos, TYPES.PROPERTY_NAME_TYPE);
// type of IE hack applied ("*", "_", or null).
this.hack = hack;
}
toString() {
@ -2205,9 +2187,6 @@ self.parserlib = (() => {
* separated by commas, this type represents just one of the values.
*/
class PropertyValue extends SyntaxUnit {
/**
* @param {PropertyValuePart[]} parts An array of value parts making up this value.
*/
constructor(parts, pos) {
super(parts.join(' '), pos, TYPES.PROPERTY_VALUE_TYPE);
this.parts = parts;
@ -2225,12 +2204,7 @@ self.parserlib = (() => {
const {value, type} = token;
super(value, token, TYPES.PROPERTY_VALUE_PART_TYPE);
this.tokenType = type;
if (token.expr) this.expr = token.expr;
// There can be ambiguity with escape sequences in identifiers, as
// well as with "color" parts which are also "identifiers", so record
// an explicit hint when the token generating this PropertyValuePart
// was an identifier.
this.wasIdent = type === Tokens.IDENT;
this.expr = token.expr || null;
switch (type) {
case Tokens.ANGLE:
case Tokens.DIMENSION:
@ -2286,7 +2260,6 @@ self.parserlib = (() => {
}
}
// A utility class that allows for easy iteration over the various parts of a property value.
class PropertyValueIterator {
/**
* @param {PropertyValue} value
@ -2368,7 +2341,7 @@ self.parserlib = (() => {
/** @param {PropertyValuePart} p */
function vtIsIdent(p) {
return p.type === 'identifier' || p.wasIdent;
return p.tokenType === Tokens.IDENT;
}
/** @param {PropertyValuePart} p */
@ -2684,13 +2657,19 @@ self.parserlib = (() => {
const reader = this._reader;
/** @namespace parserlib.Token */
const tok = {
value: '',
type: Tokens.CHAR,
col: reader._col,
line: reader._line,
offset: reader._cursor,
};
const a = tok.value = reader.read();
const b = reader.peek();
let a = tok.value = reader.read();
let b = reader.peek();
if (a === '\\') {
if (b === '\n' || b === '\f') return tok;
a = this.readEscape();
b = reader.peek();
}
switch (a) {
case ' ':
case '\n':
@ -2744,7 +2723,7 @@ self.parserlib = (() => {
case "'":
return this.stringToken(a, tok);
case '#':
if ((rxNameChar.lastIndex = 0, rxNameChar.test(b))) {
if (rxNameChar.test(b)) {
tok.type = Tokens.HASH;
tok.value = this.readName(a);
}
@ -2767,7 +2746,7 @@ self.parserlib = (() => {
}
} else if (b >= '0' && b <= '9' || b === '.' && reader.peekTest(/\.\d/y)) {
this.numberToken(a, tok);
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(b))) {
} else if (rxIdentStart.test(b)) {
this.identOrFunctionToken(a, tok);
} else {
tok.type = Tokens.MINUS;
@ -2805,10 +2784,6 @@ self.parserlib = (() => {
tok.value = '<!--';
}
return tok;
case '\\':
return b !== '\r' && b !== '\n' && b !== '\f' ?
this.identOrFunctionToken(this.readEscape(), tok) :
tok;
// EOF
case null:
tok.type = Tokens.EOF;
@ -2821,7 +2796,7 @@ self.parserlib = (() => {
}
if (a >= '0' && a <= '9') {
this.numberToken(a, tok);
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(a))) {
} else if (rxIdentStart.test(a)) {
this.identOrFunctionToken(a, tok);
} else {
tok.type = typeMap.get(a) || Tokens.CHAR;
@ -2911,7 +2886,7 @@ self.parserlib = (() => {
let tt = Tokens.NUMBER;
let units, type;
const c = reader.peek();
if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(c))) {
if (rxIdentStart.test(c)) {
units = this.readName(reader.read());
type = UNITS[units] || UNITS[lower(units)];
tt = type && Tokens[type.toUpperCase()] ||
@ -3063,6 +3038,76 @@ self.parserlib = (() => {
this._reader.readCount(2 - first.length) +
this._reader.readMatch(/([^*]|\*(?!\/))*(\*\/|$)/y);
}
/**
* @param {boolean} [omitComments]
* @param {string} [stopOn] - goes to the parent if used at the top nesting level of the value,
specifying an empty string will stop after consuming the first encountered top block.
* @returns {?string}
*/
readDeclValue({omitComments, stopOn = ';!})'} = {}) {
const reader = this._reader;
const value = [];
const endings = [];
let end = stopOn;
const rx = stopOn.includes(';')
? /([^;!'"{}()[\]/\\]|\/(?!\*))+/y
: /([^'"{}()[\]/\\]|\/(?!\*))+/y;
while (!reader.eof()) {
const chunk = reader.readMatch(rx);
if (chunk) {
value.push(chunk);
}
reader.mark();
const c = reader.read();
if (!endings.length && stopOn.includes(c)) {
reader.reset();
break;
}
value.push(c);
if (c === '\\') {
value[value.length - 1] = this.readEscape();
} else if (c === '/') {
value[value.length - 1] = this.readComment(c);
if (omitComments) value.pop();
} else if (c === '"' || c === "'") {
value[value.length - 1] = this.readString(c);
} else if (c === '{' || c === '(' || c === '[') {
endings.push(end);
end = c === '{' ? '}' : c === '(' ? ')' : ']';
} else if (c === '}' || c === ')' || c === ']') {
if (!end.includes(c)) {
reader.reset();
return null;
}
end = endings.pop();
if (!end && !stopOn) {
break;
}
}
}
return fastJoin(value);
}
readUnknownSym() {
const reader = this._reader;
const prelude = [];
let block;
while (true) {
if (reader.eof()) this.throwUnexpected();
const c = reader.peek();
if (c === '{') {
block = this.readDeclValue({stopOn: ''});
break;
} else if (c === ';') {
reader.read();
break;
} else {
prelude.push(this.readDeclValue({omitComments: true, stopOn: ';{'}));
}
}
return {prelude, block};
}
}
//#endregion
@ -3347,11 +3392,13 @@ self.parserlib = (() => {
class Parser extends EventTarget {
/**
* @param {Object} [options]
* @param {Boolean} [options.starHack] - allows IE6 star hack
* @param {Boolean} [options.underscoreHack] - interprets leading underscores as IE6-7 for known properties
* @param {Boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing syntax errors
* @param {Boolean} [options.strict] - stop on errors instead of reporting them and continuing
* @param {Boolean} [options.skipValidation] - skip syntax validation
* @param {boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing
* @param {boolean} [options.skipValidation] - skip syntax validation
* @param {boolean} [options.starHack] - allows IE6 star hack
* @param {boolean} [options.strict] - stop on errors instead of reporting them and continuing
* @param {boolean} [options.topDocOnly] - quickly extract all top-level @-moz-document,
their {}-block contents is retrieved as text using _simpleBlock()
* @param {boolean} [options.underscoreHack] - interprets leading _ as IE6-7 for known props
*/
constructor(options) {
super();
@ -3361,14 +3408,12 @@ self.parserlib = (() => {
}
/**
* @param {String|{type: string, ...}} event
* @param {Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
* @param {string|Object} event
* @param {parserlib.Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
*/
fire(event, token = this._tokenStream._token) {
if (typeof event === 'string') {
event = {type: event};
} else if (event.message && event.message.includes('/*[[')) {
return;
}
if (event.offset === undefined && token) {
event.offset = token.offset;
@ -3393,18 +3438,28 @@ self.parserlib = (() => {
this._skipCruft();
}
}
for (let tt, token; (tt = (token = stream.LT(1)).type) > Tokens.EOF; this._skipCruft()) {
const {topDocOnly} = this.options;
const allowedActions = topDocOnly ? Parser.ACTIONS.topDoc : Parser.ACTIONS.stylesheet;
for (let tt, token; (tt = (token = stream.get(true)).type); this._skipCruft()) {
try {
let action = Parser.ACTIONS.stylesheet.get(tt);
let action = allowedActions.get(tt);
if (action) {
action.call(this, stream.get(true));
action.call(this, token);
continue;
}
action = Parser.ACTIONS.stylesheetMisplaced.get(tt);
if (action) {
action.call(this, stream.get(true), true);
action.call(this, token, true);
throw new SyntaxError(Tokens[tt].text + ' not allowed here.', token);
}
if (topDocOnly) {
stream.readDeclValue({stopOn: '{}'});
if (stream._reader.peek() === '{') {
stream.readDeclValue({stopOn: ''});
}
continue;
}
stream.unget();
if (!this._ruleset() && stream.peek() !== Tokens.EOF) {
stream.throwUnexpected(stream.get(true));
}
@ -3677,10 +3732,14 @@ self.parserlib = (() => {
}
stream.mustMatch(Tokens.LBRACE);
this.fire({type: 'startdocument', functions, prefix}, start);
if (this.options.topDocOnly) {
stream.readDeclValue({stopOn: '}'});
} else {
this._ws();
let action;
do action = Parser.ACTIONS.document.get(stream.peek());
while (action ? action.call(this, stream.get(true)) || true : this._ruleset());
}
stream.mustMatch(Tokens.RBRACE);
this.fire({type: 'enddocument', functions, prefix});
this._ws();
@ -3960,22 +4019,11 @@ self.parserlib = (() => {
_negation(start) {
const stream = this._tokenStream;
let value = start.value + this._ws();
const value = [start.value, this._ws()];
const args = this._selectorsGroup();
if (!args) stream.throwUnexpected(stream.LT(1));
const arg = args[0];
const parts = arg.parts;
if (args.length > 1 ||
parts.length !== 1 ||
parts[0].modifiers.length + (parts[0].elementName ? 1 : 0) > 1 ||
/^:not\b/i.test(parts[0])) {
this.fire({
type: 'warning',
message: `Simple selector expected, but found '${args.join(', ')}'`,
}, arg);
}
value += arg + this._ws() + stream.mustMatch(Tokens.RPAREN).value;
return Object.assign(new SelectorSubPart(value, 'not', start), {args: [arg]});
value.push(...args, this._ws(), stream.mustMatch(Tokens.RPAREN).value);
return Object.assign(new SelectorSubPart(fastJoin(value), 'not', start), {args});
}
_declaration(consumeSemicolon) {
@ -4061,67 +4109,14 @@ self.parserlib = (() => {
}
_customProperty() {
const stream = this._tokenStream;
const reader = stream._reader;
const value = [];
// These chars belong to the parent if used at the top nesting level of the property's value
const UNGET = ';!})';
let end = UNGET;
const endings = [];
readValue:
while (!reader.eof()) {
const chunk = reader.readMatch(/([^;!'"{}()[\]/]|\/(?!\*))+/y);
if (chunk) {
value.push(chunk);
}
reader.mark();
const c = reader.read();
value.push(c);
switch (c) {
case '/':
value[value.length - 1] = stream.readComment(c);
continue;
case '"':
case "'":
value[value.length - 1] = stream.readString(c);
continue;
case '{':
case '(':
case '[':
endings.push(end);
end = c === '{' ? '}' : c === '(' ? ')' : ']';
continue;
case ';':
case '!':
if (endings.length) {
continue;
}
reader.reset();
// fallthrough
case '}':
case ')':
case ']':
if (!end.includes(c)) {
reader.reset();
return null;
}
end = endings.pop();
if (end) {
continue;
}
if (UNGET.includes(c)) {
reader.reset();
value.pop();
}
break readValue;
}
}
if (!value[0]) return null;
const token = stream._token;
token.value = fastJoin(value);
const value = this._tokenStream.readDeclValue();
if (value) {
const token = this._tokenStream._token;
token.value = value;
token.type = Tokens.IDENT;
return new PropertyValue([new PropertyValuePart(token)], token);
}
}
_term(inFunction) {
const stream = this._tokenStream;
@ -4400,64 +4395,12 @@ self.parserlib = (() => {
}
_unknownSym(start) {
const stream = this._tokenStream;
if (this.options.strict) {
throw new SyntaxError('Unknown @ rule.', start);
}
const {prelude, block} = this._tokenStream.readUnknownSym();
this.fire({type: 'unknown-at-rule', name: start.value, prelude, block}, start);
this._ws();
const simpleValue =
stream.match(Tokens.IDENT) && SyntaxUnit.fromToken(stream._token) ||
stream.peek() === Tokens.FUNCTION && this._function({asText: true}) ||
this._unknownBlock(TT.LParenBracket);
this._ws();
const blockValue = this._unknownBlock();
if (!blockValue) {
stream.match(Tokens.SEMICOLON);
}
this.fire({
type: 'unknown-at-rule',
name: start.value,
simpleValue,
blockValue,
}, start);
this._ws();
}
_unknownBlock(canStartWith = [Tokens.LBRACE]) {
const stream = this._tokenStream;
if (!canStartWith.includes(stream.peek())) {
return null;
}
stream.get();
const start = stream._token;
const reader = stream._reader;
reader.mark();
reader._cursor = start.offset;
reader._line = start.line;
reader._col = start.col;
const value = [];
const endings = [];
let blockEnd;
while (!reader.eof()) {
const chunk = reader.readMatch(/[^{}()[\]]*[{}()[\]]?/y);
const c = chunk.slice(-1);
value.push(chunk);
if (c === '{' || c === '(' || c === '[') {
endings.push(blockEnd);
blockEnd = c === '{' ? '}' : c === '(' ? ')' : ']';
} else if (c === '}' || c === ')' || c === ']') {
if (c !== blockEnd) {
break;
}
blockEnd = endings.pop();
if (!blockEnd) {
stream.resetLT();
return new SyntaxUnit(fastJoin(value), start);
}
}
}
reader.reset();
return null;
}
_verifyEnd() {
@ -4577,6 +4520,12 @@ self.parserlib = (() => {
[Tokens.NAMESPACE_SYM, Parser.prototype._namespace],
]),
topDoc: new Map([
symDocument,
symUnknown,
[Tokens.S, Parser.prototype._ws],
]),
document: new Map([
symMedia,
symDocMisplaced,

View File

@ -1,19 +1,22 @@
/* global
$
$create
$createLink
API
debounce
deepCopy
messageBox
prefs
setupLivePrefs
t
*/
/* exported configDialog */
/* global $ $create $createLink $remove messageBoxProxy setupLivePrefs */// dom.js
/* global API */// msg.js
/* global debounce deepCopy */// toolbox.js
/* global messageBox */
/* global prefs */
/* global t */// localization.js
'use strict';
function configDialog(style) {
/* exported configDialog */
async function configDialog(style) {
await require([
'/js/color/color-converter',
'/js/color/color-mimicry',
'/js/color/color-picker',
'/js/color/color-picker.css',
'/js/dlg/config-dialog.css',
]);
const AUTOSAVE_DELAY = 500;
let saving = false;
@ -32,7 +35,7 @@ function configDialog(style) {
renderValues();
vars.forEach(renderValueState);
return messageBox({
return messageBoxProxy.show({
title: `${style.customName || style.name} v${data.version}`,
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
contents: [
@ -82,7 +85,7 @@ function configDialog(style) {
adjustSizeForPopup(box);
}
box.addEventListener('change', onchange);
box.on('change', onchange);
buttons.save = $('[data-cmd="save"]', box);
buttons.default = $('[data-cmd="default"]', box);
buttons.close = $('[data-cmd="close"]', box);
@ -118,20 +121,18 @@ function configDialog(style) {
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
}
function save({anyChangeIsDirty = false} = {}, bgStyle) {
if (saving) {
debounce(save, 0, ...arguments);
return;
async function save({anyChangeIsDirty = false} = {}, bgStyle) {
for (let delay = 1; saving && delay < 1000; delay *= 2) {
await new Promise(resolve => setTimeout(resolve, delay));
}
if (!vars.length ||
!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
if (saving) {
throw 'Could not save: still saving previous results...';
}
if (!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
return;
}
if (!bgStyle) {
API.getStyle(style.id, true)
.catch(() => ({}))
.then(bgStyle => save({anyChangeIsDirty}, bgStyle));
return;
bgStyle = await API.styles.get(style.id).catch(() => ({}));
}
style = style.sections ? Object.assign({}, style) : style;
style.enabled = true;
@ -147,13 +148,12 @@ function configDialog(style) {
if (!bgva) {
error = 'deleted';
delete styleVars[va.name];
} else
if (bgva.type !== va.type) {
} else if (bgva.type !== va.type) {
error = ['type ', '*' + va.type, ' != ', '*' + bgva.type];
} else
if ((va.type === 'select' || va.type === 'dropdown') &&
!isDefault(va) &&
bgva.options.every(o => o.name !== va.value)) {
} else if (
(va.type === 'select' || va.type === 'dropdown') &&
!isDefault(va) && bgva.options.every(o => o.name !== va.value)
) {
error = `'${va.value}' not in the updated '${va.type}' list`;
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
continue;
@ -182,22 +182,22 @@ function configDialog(style) {
return;
}
saving = true;
return API.configUsercssVars(style.id, style.usercssData.vars)
.then(newVars => {
try {
const newVars = await API.usercss.configVars(style.id, style.usercssData.vars);
varsInitial = getInitialValues(newVars);
vars.forEach(va => onchange({target: va.input, justSaved: true}));
renderValues();
updateButtons();
$.remove('.config-error');
})
.catch(errors => {
$remove('.config-error');
} catch (errors) {
const el = $('.config-error', messageBox.element) ||
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors.message || String(errors);
})
.then(() => {
el.textContent =
el.title = (Array.isArray(errors) ? errors : [errors])
.map(e => e.message || `${e}`)
.join('\n');
}
saving = false;
});
}
function useDefault() {
@ -401,7 +401,7 @@ function configDialog(style) {
function showColorpicker(event) {
event.preventDefault();
window.removeEventListener('keydown', messageBox.listeners.key, true);
window.off('keydown', messageBox.listeners.key, true);
const box = $('#message-box-contents');
colorpicker.show({
va: this.va,
@ -424,7 +424,7 @@ function configDialog(style) {
function restoreEscInDialog() {
if (!$('.colorpicker-popup') && messageBox.element) {
window.addEventListener('keydown', messageBox.listeners.key, true);
window.on('keydown', messageBox.listeners.key, true);
}
}
@ -434,12 +434,12 @@ function configDialog(style) {
let {offsetWidth: width, offsetHeight: height} = contents;
contents.style = '';
const colorpicker = document.body.appendChild(
const elPicker = document.body.appendChild(
$create('.colorpicker-popup', {style: 'display: none!important'}));
const PADDING = 50;
const MIN_WIDTH = parseFloat(getComputedStyle(colorpicker).width) || 350;
const MIN_WIDTH = parseFloat(getComputedStyle(elPicker).width) || 350;
const MIN_HEIGHT = 250 + PADDING;
colorpicker.remove();
elPicker.remove();
width = constrain(MIN_WIDTH, 798, width + PADDING);
height = constrain(MIN_HEIGHT, 598, height + PADDING);

View File

@ -1,13 +1,17 @@
/* global
$
$create
animateElement
focusAccessibility
moveFocus
t
*/
/* global $ $create animateElement focusAccessibility moveFocus */// dom.js
/* global t */// localization.js
'use strict';
// TODO: convert this singleton mess so we can show many boxes at once
/* global messageBox */
window.messageBox = {
element: null,
listeners: null,
_blockScroll: null,
_originalFocus: null,
_resolve: null,
};
/**
* @param {Object} params
* @param {String} params.title
@ -26,22 +30,25 @@
* resolves to an object with optionally present properties depending on the interaction:
* {button: Number, enter: Boolean, esc: Boolean}
*/
function messageBox({
messageBox.show = async ({
title,
contents,
className = '',
buttons = [],
onshow,
blockScroll,
}) {
initOwnListeners();
}) => {
await require(['/js/dlg/message-box.css']);
if (!messageBox.listeners) initOwnListeners();
bindGlobalListeners();
createElement();
document.body.appendChild(messageBox.element);
messageBox.originalFocus = document.activeElement;
// skip external links like feedback
while ((moveFocus(messageBox.element, 1) || {}).target === '_blank') {/*NOP*/}
messageBox._originalFocus = document.activeElement;
// focus the first focusable child but skip the first external link which is usually `feedback`
if ((moveFocus(messageBox.element, 0) || {}).target === '_blank') {
moveFocus(messageBox.element, 1);
}
// suppress focus outline when invoked via click
if (focusAccessibility.lastFocusedViaClick && document.activeElement) {
document.activeElement.dataset.focusedViaClick = '';
@ -56,12 +63,12 @@ function messageBox({
$('#message-box-close-icon').hidden = true;
}
return new Promise(_resolve => {
messageBox.resolve = _resolve;
return new Promise(resolve => {
messageBox._resolve = resolve;
});
function initOwnListeners() {
messageBox.listeners = messageBox.listeners || {
messageBox.listeners = {
closeIcon() {
resolveWith({button: -1});
},
@ -93,18 +100,18 @@ function messageBox({
resolveWith(key === 'Enter' ? {enter: true} : {esc: true});
},
scroll() {
scrollTo(blockScroll.x, blockScroll.y);
scrollTo(messageBox._blockScroll.x, messageBox._blockScroll.y);
},
};
}
function resolveWith(value) {
setTimeout(messageBox._resolve, 0, value);
unbindGlobalListeners();
setTimeout(messageBox.resolve, 0, value);
animateElement(messageBox.element, 'fadeout')
.then(removeSelf);
if (messageBox.element.contains(document.activeElement)) {
messageBox.originalFocus.focus();
messageBox._originalFocus.focus();
}
}
@ -137,33 +144,33 @@ function messageBox({
}
function bindGlobalListeners() {
blockScroll = blockScroll && {x: scrollX, y: scrollY};
messageBox._blockScroll = blockScroll && {x: scrollX, y: scrollY};
if (blockScroll) {
window.addEventListener('scroll', messageBox.listeners.scroll);
window.on('scroll', messageBox.listeners.scroll, {passive: false});
}
window.addEventListener('keydown', messageBox.listeners.key, true);
window.on('keydown', messageBox.listeners.key, true);
}
function unbindGlobalListeners() {
window.removeEventListener('keydown', messageBox.listeners.key, true);
window.removeEventListener('scroll', messageBox.listeners.scroll);
window.off('keydown', messageBox.listeners.key, true);
window.off('scroll', messageBox.listeners.scroll);
}
function removeSelf() {
messageBox.element.remove();
messageBox.element = null;
messageBox.resolve = null;
}
messageBox._resolve = null;
}
};
/**
* @param {String|Node|Array<String|Node>} contents
* @param {String} [className] like 'pre' for monospace font
* @param {String} [title]
* @returns {Promise<Boolean>} same as messageBox
* @returns {Promise<Boolean>} same as show()
*/
messageBox.alert = (contents, className, title) =>
messageBox({
messageBox.show({
title,
contents,
className: `center ${className || ''}`,
@ -176,10 +183,12 @@ messageBox.alert = (contents, className, title) =>
* @param {String} [title]
* @returns {Promise<Boolean>} resolves to true when confirmed
*/
messageBox.confirm = (contents, className, title) =>
messageBox({
messageBox.confirm = async (contents, className, title) => {
const res = await messageBox.show({
title,
contents,
className: `center ${className || ''}`,
buttons: [t('confirmYes'), t('confirmNo')],
}).then(result => result.button === 0 || result.enter);
});
return res.button === 0 || res.enter;
};

801
js/dom.js
View File

@ -1,138 +1,200 @@
/* global debounce */// toolbox.js
/* global prefs */
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
setupLivePrefs moveFocus */
'use strict';
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
/* exported
$$remove
$createLink
$isTextInput
animateElement
getEventKeyName
messageBoxProxy
moveFocus
scrollElementIntoView
setupLivePrefs
*/
Object.assign(EventTarget.prototype, {
on: addEventListener,
off: removeEventListener,
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
onOff(enable, ...args) {
(enable ? addEventListener : removeEventListener).apply(this, args);
});
//#region Exports
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
const focusAccessibility = {
// last event's focusedViaClick
lastFocusedViaClick: false,
// to avoid a full layout recalc due to changes on body/root
// we modify the closest focusable element (like input or button or anything with tabindex=0)
closest(el) {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
el = el.control;
labelSeen = true;
}
if (el.tabIndex >= 0) return el;
}
},
};
/**
* Autoloads message-box.js
* @alias messageBox
*/
window.messageBoxProxy = new Proxy({}, {
get(_, name) {
return async (...args) => {
await require([
'/js/dlg/message-box', /* global messageBox */
'/js/dlg/message-box.css',
]);
window.messageBoxProxy = messageBox;
return messageBox[name](...args);
};
},
});
$.isTextInput = (el = {}) =>
el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
function $(selector, base = document) {
// we have ids with . like #manage.onlyEnabled which looks like #id.class
// so since getElementById is superfast we'll try it anyway
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
return byId || base.querySelector(selector);
}
$.remove = (selector, base = document) => {
function $$(selector, base = document) {
return [...base.querySelectorAll(selector)];
}
function $isTextInput(el = {}) {
return el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
}
function $remove(selector, base = document) {
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
if (el) {
el.remove();
}
};
}
$$.remove = (selector, base = document) => {
function $$remove(selector, base = document) {
for (const el of base.querySelectorAll(selector)) {
el.remove();
}
}
/*
$create('tag#id.class.class', ?[children])
$create('tag#id.class.class', ?textContentOrChildNode)
$create('tag#id.class.class', {properties}, ?[children])
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
tag is 'div' by default, #id and .class are optional
$create([children])
$create({propertiesAndOptions})
$create({propertiesAndOptions}, ?[children])
tag: string, default 'div'
appendChild: element/string or an array of elements/strings
dataset: object
any DOM property: assigned as is
tag may include namespace like 'ns:tag'
*/
function $create(selector = 'div', properties, children) {
let ns, tag, opt;
if (typeof selector === 'string') {
if (Array.isArray(properties) ||
properties instanceof Node ||
typeof properties !== 'object') {
opt = {};
children = properties;
} else {
opt = properties || {};
children = children || opt.appendChild;
}
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
const id = selector.slice(idStart + 1, classStart);
if (id) {
opt.id = id;
}
const cls = selector.slice(classStart + 1);
if (cls) {
opt[selector.includes(':') ? 'class' : 'className'] =
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
}
tag = selector.slice(0, Math.min(idStart, classStart));
} else if (Array.isArray(selector)) {
tag = 'div';
opt = {};
children = selector;
} else {
opt = selector;
tag = opt.tag;
children = opt.appendChild || properties;
}
if (tag && tag.includes(':')) {
[ns, tag] = tag.split(':');
if (ns === 'SVG' || ns === 'svg') {
ns = 'http://www.w3.org/2000/svg';
}
}
const element = ns ? document.createElementNS(ns, tag) :
tag === 'fragment' ? document.createDocumentFragment() :
document.createElement(tag || 'div');
for (const child of Array.isArray(children) ? children : [children]) {
if (child) {
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
}
}
for (const [key, val] of Object.entries(opt)) {
switch (key) {
case 'dataset':
Object.assign(element.dataset, val);
break;
case 'attributes':
Object.entries(val).forEach(attr => element.setAttribute(...attr));
break;
case 'style': {
const t = typeof val;
if (t === 'string') element.style.cssText = val;
if (t === 'object') Object.assign(element.style, val);
break;
}
case 'tag':
case 'appendChild':
break;
default: {
if (ns) {
const i = key.indexOf(':') + 1;
const attrNS = i && `http://www.w3.org/1999/${key.slice(0, i - 1)}`;
element.setAttributeNS(attrNS || null, key, val);
} else {
element[key] = val;
}
}
}
}
return element;
}
function $createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener',
};
{
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
const addTooltipsToEllipsized = () => {
for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) {
continue;
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
}
const width = btn.offsetWidth;
if (!width || btn.preresizeClientWidth === width) {
continue;
opt.appendChild = opt.appendChild || content;
return $create(opt);
}
btn.preresizeClientWidth = width;
if (btn.scrollWidth > width) {
const text = btn.textContent;
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
btn.titleIsForEllipsis = true;
} else if (btn.title) {
btn.title = '';
}
}
};
// enqueue after DOMContentLoaded/load events
setTimeout(addTooltipsToEllipsized, 500);
// throttle on continuous resizing
let timer;
window.on('resize', () => {
clearTimeout(timer);
timer = setTimeout(addTooltipsToEllipsized, 100);
});
}
onDOMready().then(() => {
$.remove('#firefox-transitions-bug-suppressor');
initCollapsibles();
focusAccessibility();
if (!chrome.app && chrome.windows && typeof prefs !== 'undefined') {
// add favicon in Firefox
prefs.initializing.then(() => {
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
for (const size of [38, 32, 19, 16]) {
document.head.appendChild($create('link', {
rel: 'icon',
href: `/images/icon/${iconset}${size}.png`,
sizes: size + 'x' + size,
}));
}
});
}
});
// set language for CSS :lang and [FF-only] hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
// avoid adding # to the page URL when clicking dummy links
document.on('click', e => {
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
});
// update inputs on mousewheel when focused
document.on('wheel', event => {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}, {
capture: true,
passive: false,
});
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
}
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return;
const {top, height} = element.getBoundingClientRect();
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
window.scrollBy(0, top - windowHeight / 2 + height);
}
}
/**
* @param {HTMLElement} el
@ -162,234 +224,19 @@ function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
});
}
function enforceInputRange(element) {
const min = Number(element.min);
const max = Number(element.max);
const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true}));
const onChange = ({type}) => {
if (type === 'input' && element.checkValidity()) {
doNotify();
} else if (type === 'change' && !element.checkValidity()) {
element.value = Math.max(min, Math.min(max, Number(element.value)));
doNotify();
}
};
element.on('change', onChange);
element.on('input', onChange);
}
function $(selector, base = document) {
// we have ids with . like #manage.onlyEnabled which looks like #id.class
// so since getElementById is superfast we'll try it anyway
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
return byId || base.querySelector(selector);
}
function $$(selector, base = document) {
return [...base.querySelectorAll(selector)];
}
function $create(selector = 'div', properties, children) {
/*
$create('tag#id.class.class', ?[children])
$create('tag#id.class.class', ?textContentOrChildNode)
$create('tag#id.class.class', {properties}, ?[children])
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
tag is 'div' by default, #id and .class are optional
$create([children])
$create({propertiesAndOptions})
$create({propertiesAndOptions}, ?[children])
tag: string, default 'div'
appendChild: element/string or an array of elements/strings
dataset: object
any DOM property: assigned as is
tag may include namespace like 'ns:tag'
*/
let ns, tag, opt;
if (typeof selector === 'string') {
if (Array.isArray(properties) ||
properties instanceof Node ||
typeof properties !== 'object') {
opt = {};
children = properties;
} else {
opt = properties || {};
children = children || opt.appendChild;
}
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
const id = selector.slice(idStart + 1, classStart);
if (id) {
opt.id = id;
}
const cls = selector.slice(classStart + 1);
if (cls) {
opt[selector.includes(':') ? 'class' : 'className'] =
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
}
tag = selector.slice(0, Math.min(idStart, classStart));
} else if (Array.isArray(selector)) {
tag = 'div';
opt = {};
children = selector;
} else {
opt = selector;
tag = opt.tag;
delete opt.tag;
children = opt.appendChild || properties;
delete opt.appendChild;
}
if (tag && tag.includes(':')) {
([ns, tag] = tag.split(':'));
}
const element = ns
? document.createElementNS(ns === 'SVG' || ns === 'svg' ? 'http://www.w3.org/2000/svg' : ns, tag)
: tag === 'fragment'
? document.createDocumentFragment()
: document.createElement(tag || 'div');
for (const child of Array.isArray(children) ? children : [children]) {
if (child) {
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
}
}
if (opt.dataset) {
Object.assign(element.dataset, opt.dataset);
delete opt.dataset;
}
if (opt.attributes) {
for (const attr in opt.attributes) {
element.setAttribute(attr, opt.attributes[attr]);
}
delete opt.attributes;
}
if (opt.style) {
if (typeof opt.style === 'string') element.style.cssText = opt.style;
if (typeof opt.style === 'object') Object.assign(element.style, opt.style);
delete opt.style;
}
if (ns) {
for (const attr in opt) {
const i = attr.indexOf(':') + 1;
const attrNS = i && `http://www.w3.org/1999/${attr.slice(0, i - 1)}`;
element.setAttributeNS(attrNS || null, attr, opt[attr]);
}
} else {
Object.assign(element, opt);
}
return element;
}
function $createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener',
};
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
}
opt.appendChild = opt.appendChild || content;
return $create(opt);
}
// makes <details> with [data-pref] save/restore their state
function initCollapsibles({bindClickOn = 'h2'} = {}) {
const prefMap = {};
const elements = $$('details[data-pref]');
if (!elements.length) {
return;
}
for (const el of elements) {
const key = el.dataset.pref;
prefMap[key] = el;
el.open = prefs.get(key);
(bindClickOn && $(bindClickOn, el) || el).on('click', onClick);
}
prefs.subscribe(Object.keys(prefMap), (key, value) => {
const el = prefMap[key];
if (el.open !== value) {
el.open = value;
}
});
function onClick(event) {
if (event.target.closest('.intercepts-click')) {
event.preventDefault();
} else {
setTimeout(saveState, 0, event.target.closest('details'));
}
}
function saveState(el) {
if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
prefs.set(el.dataset.pref, el.open);
}
}
}
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
function focusAccessibility() {
// last event's focusedViaClick
focusAccessibility.lastFocusedViaClick = false;
// to avoid a full layout recalc due to changes on body/root
// we modify the closest focusable element (like input or button or anything with tabindex=0)
focusAccessibility.closest = el => {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
el = el.control;
labelSeen = true;
}
if (el.tabIndex >= 0) return el;
}
};
// suppress outline on click
window.on('mousedown', ({target}) => {
const el = focusAccessibility.closest(target);
if (el) {
focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
}
}, {passive: true});
// keep outline on Tab or Shift-Tab key
window.on('keydown', event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
el = el.closest('[data-focused-via-click]');
if (el) delete el.dataset.focusedViaClick;
}
});
}
}, {passive: true});
function getEventKeyName(e, letterAsCode) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
(e.altKey ? 'Alt-' : '') +
(e.metaKey ? 'Meta-' : '');
return `${
mods === e.key + '-' ? '' : mods
}${
e.key
? e.key.length === 1 && letterAsCode ? e.code : e.key
: 'Mouse' + ('LMR'[e.button] || e.button)
}`;
}
/**
@ -417,69 +264,237 @@ function moveFocus(rootElement, step) {
}
}
// 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 = Object.getOwnPropertyNames(prefs.defaults)
.filter(id => $('#' + id))
) {
for (const id of IDs) {
const element = $('#' + id);
updateElement({id, element, force: true});
element.on('change', onChange);
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
}
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return;
const {top, height} = element.getBoundingClientRect();
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
window.scrollBy(0, top - windowHeight / 2 + height);
}
}
/**
* 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 = prefs.knownKeys.filter(id => $('#' + id))) {
let forceUpdate = true;
prefs.subscribe(ids, updateElement, {runNow: true});
forceUpdate = false;
ids.forEach(id => $('#' + id).on('change', onChange));
function onChange() {
const value = getInputValue(this);
if (prefs.get(this.id) !== value) {
prefs.set(this.id, value);
prefs.set(this.id, this[getPropName(this)]);
}
function getPropName(el) {
return el.type === 'checkbox' ? 'checked'
: el.type === 'number' ? 'valueAsNumber' :
'value';
}
function updateElement({
id,
value = prefs.get(id),
element = $('#' + id),
force,
}) {
if (!element) {
prefs.unsubscribe(IDs, updateElement);
return;
function updateElement(id, value) {
const el = $('#' + id);
if (el) {
const prop = getPropName(el);
if (el[prop] !== value || forceUpdate) {
el[prop] = value;
el.dispatchEvent(new Event('change', {bubbles: true}));
}
setInputValue(element, value, force);
}
function getInputValue(input) {
if (input.type === 'checkbox') {
return input.checked;
}
if (input.type === 'number') {
return Number(input.value);
}
return input.value;
}
function setInputValue(input, value, force = false) {
if (force || getInputValue(input) !== value) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
prefs.unsubscribe(ids, updateElement);
}
}
}
/* exported getEventKeyName */
/**
* @param {KeyboardEvent} e
* @param {boolean} [letterAsCode] - use locale-independent KeyA..KeyZ for single-letter chars
* @param {string} selector - beware of $ quirks with `#dotted.id` that won't work with $$
* @param {Object} [opt]
* @param {function(HTMLElement, HTMLElement[]):boolean} [opt.recur] - called on each match
with (firstMatchingElement, allMatchingElements) parameters until stopOnDomReady,
you can also return `false` to disconnect the observer
* @param {boolean} [opt.stopOnDomReady] - stop observing on DOM ready
* @returns {Promise<HTMLElement>} - resolves on first match
*/
function getEventKeyName(e, letterAsCode) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
(e.altKey ? 'Alt-' : '') +
(e.metaKey ? 'Meta-' : '');
return (mods === e.key + '-' ? '' : mods) +
(e.key.length === 1 && letterAsCode ? e.code : e.key);
function waitForSelector(selector, {recur, stopOnDomReady = true} = {}) {
let el = $(selector);
let elems, isResolved;
return el && (!recur || recur(el, (elems = $$(selector))) === false)
? Promise.resolve(el)
: new Promise(resolve => {
const mo = new MutationObserver(() => {
if (!el) el = $(selector);
if (!el) return;
if (!recur ||
callRecur() === false ||
stopOnDomReady && document.readyState === 'complete') {
mo.disconnect();
}
if (!isResolved) {
isResolved = true;
resolve(el);
}
});
mo.observe(document, {childList: true, subtree: true});
});
function callRecur() {
const all = $$(selector); // simpler and faster than analyzing each node in `mutations`
const added = !elems ? all : all.filter(el => !elems.includes(el));
if (added.length) {
elems = all;
return recur(added[0], added);
}
}
}
//#endregion
//#region Internals
(() => {
const Collapsible = {
bindEvents(_, elems) {
const prefKeys = [];
for (const el of elems) {
prefKeys.push(el.dataset.pref);
($('h2', el) || el).on('click', Collapsible.saveOnClick);
}
prefs.subscribe(prefKeys, Collapsible.updateOnPrefChange, {runNow: true});
},
canSave(el) {
return !el.matches('.compact-layout .ignore-pref-if-compact');
},
async saveOnClick(event) {
if (event.target.closest('.intercepts-click')) {
event.preventDefault();
} else {
const el = event.target.closest('details');
await new Promise(setTimeout);
if (Collapsible.canSave(el)) {
prefs.set(el.dataset.pref, el.open);
}
}
},
updateOnPrefChange(key, value) {
const el = $(`details[data-pref="${key}"]`);
if (el.open !== value && Collapsible.canSave(el)) {
el.open = value;
}
},
};
window.on('mousedown', suppressFocusRingOnClick, {passive: true});
window.on('keydown', keepFocusRingOnTabbing, {passive: true});
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
// set language for a) CSS :lang pseudo and b) hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
document.on('click', keepAddressOnDummyClick);
document.on('wheel', changeFocusedInputOnWheel, {capture: true, passive: false});
Promise.resolve().then(async () => {
if (!chrome.app) addFaviconFF();
await prefs.ready;
waitForSelector('details[data-pref]', {recur: Collapsible.bindEvents});
});
onDOMready().then(() => {
$remove('#firefox-transitions-bug-suppressor');
debounce(addTooltipsToEllipsized, 500);
window.on('resize', () => debounce(addTooltipsToEllipsized, 100));
});
function addFaviconFF() {
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
for (const size of [38, 32, 19, 16]) {
document.head.appendChild($create('link', {
rel: 'icon',
href: `/images/icon/${iconset}${size}.png`,
sizes: size + 'x' + size,
}));
}
}
function changeFocusedInputOnWheel(event) {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}
/** Displays a full text tooltip on buttons with ellipsis overflow and no inherent title */
function addTooltipsToEllipsized() {
for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) {
continue;
}
const width = btn.offsetWidth;
if (!width || btn.preresizeClientWidth === width) {
continue;
}
btn.preresizeClientWidth = width;
if (btn.scrollWidth > width) {
const text = btn.textContent;
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
btn.titleIsForEllipsis = true;
} else if (btn.title) {
btn.title = '';
}
}
}
function keepAddressOnDummyClick(e) {
// avoid adding # to the page URL when clicking dummy links
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
}
function keepFocusRingOnTabbing(event) {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
el = el.closest('[data-focused-via-click]');
if (el) delete el.dataset.focusedViaClick;
}
});
}
}
function suppressFocusRingOnClick({target}) {
const el = focusAccessibility.closest(target);
if (el) {
focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
}
}
})();
//#endregion

View File

@ -1,15 +1,17 @@
'use strict';
function t(key, params) {
//#region Exports
function t(key, params, strict = true) {
const s = chrome.i18n.getMessage(key, params);
if (!s) throw `Missing string "${key}"`;
if (!s && strict) throw `Missing string "${key}"`;
return s;
}
Object.assign(t, {
template: {},
DOMParser: new DOMParser(),
ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','),
parser: new DOMParser(),
ALLOWED_TAGS: ['a', 'b', 'code', 'i', 'sub', 'sup', 'wbr'],
RX_WORD_BREAK: new RegExp([
'(',
/[\d\w\u007B-\uFFFF]{10}/,
@ -103,7 +105,7 @@ Object.assign(t, {
},
createHtml(str, trusted) {
const root = t.DOMParser.parseFromString(str, 'text/html').body;
const root = t.parser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
@ -156,6 +158,9 @@ Object.assign(t, {
},
});
//#endregion
//#region Internals
(() => {
const observer = new MutationObserver(process);
let observing = false;
@ -187,3 +192,5 @@ Object.assign(t, {
}
}
})();
//#endregion

View File

@ -1,8 +1,8 @@
/* global usercssMeta colorConverter */
/* exported metaParser */
'use strict';
/* exported metaParser */
const metaParser = (() => {
require(['/vendor/usercss-meta/usercss-meta.min']); /* global usercssMeta */
const {createParser, ParseError} = usercssMeta;
const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
const options = {
@ -27,6 +27,7 @@ const metaParser = (() => {
}
},
color: state => {
require(['/js/color/color-converter']); /* global colorConverter */
const color = colorConverter.parse(state.value);
if (!color) {
throw new ParseError({
@ -40,39 +41,27 @@ const metaParser = (() => {
},
};
const parser = createParser(options);
const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'}));
const looseParser = createParser(Object.assign({}, options, {
allowErrors: true,
unknownKey: 'throw',
}));
return {
parse,
lint,
nullifyInvalidVars,
};
function parse(text, indexOffset) {
try {
return parser.parse(text);
} catch (err) {
if (typeof err.index === 'number') {
err.index += indexOffset;
}
throw err;
}
}
lint: looseParser.parse,
parse: parser.parse,
function lint(text) {
return looseParser.parse(text);
}
function nullifyInvalidVars(vars) {
nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
if (va.value === null) {
continue;
}
if (va.value !== null) {
try {
parser.validateVar(va);
} catch (err) {
va.value = null;
}
}
return vars;
}
return vars;
},
};
})();

View File

@ -1,24 +1,29 @@
/* global parserlib */
/* exported parseMozFormat */
'use strict';
require([
'/js/csslint/parserlib', /* global parserlib */
'/js/sections-util', /* global MozDocMapper */
]);
/* exported extractSections */
/**
* Extracts @-moz-document blocks into sections and the code between them into global sections.
* Puts the global comments into the following section to minimize the amount of global sections.
* Doesn't move the comment with ==UserStyle== inside.
* @param {string} code
* @param {number} styleId - used to preserve parserCache on subsequent runs over the same style
* @param {Object} _
* @param {string} _.code
* @param {boolean} [_.fast] - uses topDocOnly option to extract sections as text
* @param {number} [_.styleId] - used to preserve parserCache on subsequent runs over the same style
* @returns {{sections: Array, errors: Array}}
* @property {?number} lastStyleId
*/
function parseMozFormat({code, styleId}) {
const CssToProperty = {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
};
function extractSections({code, styleId, fast = true}) {
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/;
const parser = new parserlib.css.Parser({starHack: true, skipValidation: true});
const parser = new parserlib.css.Parser({
starHack: true,
skipValidation: true,
topDocOnly: fast,
});
const sectionStack = [{code: '', start: 0}];
const errors = [];
const sections = [];
@ -34,7 +39,6 @@ function parseMozFormat({code, styleId}) {
};
// move last comment before @-moz-document inside the section
if (!lastCmt.includes('AGENT_SHEET') &&
!lastCmt.includes('==') &&
!/==userstyle==/i.test(lastCmt)) {
if (lastCmt) {
section.code = lastCmt + '\n';
@ -48,12 +52,12 @@ function parseMozFormat({code, styleId}) {
lastSection.code = '';
}
for (const {name, expr, uri} of e.functions) {
const aType = CssToProperty[name.toLowerCase()];
const aType = MozDocMapper.FROM_CSS[name.toLowerCase()];
const p0 = expr && expr.parts[0];
if (p0 && aType === 'regexps') {
const s = p0.text;
if (hasSingleEscapes.test(p0.text)) {
const isQuoted = (s.startsWith('"') || s.startsWith("'")) && s.endsWith(s[0]);
const isQuoted = /^['"]/.test(s) && s.endsWith(s[0]);
p0.value = isQuoted ? s.slice(1, -1) : s;
}
}
@ -78,17 +82,24 @@ function parseMozFormat({code, styleId}) {
});
parser.addListener('error', e => {
errors.push(`${e.line}:${e.col} ${e.message.replace(/ at line \d.+$/, '')}`);
errors.push(e);
});
try {
parser.parse(mozStyle, {
reuseCache: !parseMozFormat.styleId || styleId === parseMozFormat.styleId,
reuseCache: !extractSections.lastStyleId || styleId === extractSections.lastStyleId,
});
} catch (e) {
errors.push(e.message);
errors.push(e);
}
parseMozFormat.styleId = styleId;
for (const err of errors) {
for (const [k, v] of Object.entries(err)) {
if (typeof v === 'object') delete err[k];
}
err.message = `${err.line}:${err.col} ${err.message}`;
}
extractSections.lastStyleId = styleId;
return {sections, errors};
function doAddSection(section) {

125
js/msg.js
View File

@ -1,8 +1,9 @@
/* global deepCopy getOwnTab URLS */ // not used in content scripts
/* global URLS deepCopy deepMerge getOwnTab */// toolbox.js - not used in content scripts
'use strict';
// eslint-disable-next-line no-unused-expressions
window.INJECTED !== 1 && (() => {
(() => {
if (window.INJECTED === 1) return;
const TARGETS = Object.assign(Object.create(null), {
all: ['both', 'tab', 'extension'],
extension: ['both', 'extension'],
@ -21,38 +22,12 @@ window.INJECTED !== 1 && (() => {
extension: new Set(),
};
let bg = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage();
const isBg = bg === window;
if (!isBg && (!bg || !bg.document || bg.document.readyState === 'loading')) {
bg = null;
}
// TODO: maybe move into polyfill.js and hook addListener to wrap/unwrap automatically
chrome.runtime.onMessage.addListener(onRuntimeMessage);
// TODO: maybe move into polyfill.js and hook addListener + sendMessage so they wrap/unwrap automatically
const wrapData = data => ({
data,
});
const wrapError = error => ({
error: Object.assign({
message: error.message || `${error}`,
stack: error.stack,
}, error), // passing custom properties e.g. `error.index`
});
const unwrapResponse = ({data, error} = {error: {message: ERR_NO_RECEIVER}}) =>
error
? Promise.reject(Object.assign(new Error(error.message), error))
: data;
chrome.runtime.onMessage.addListener(({data, target}, sender, sendResponse) => {
const res = window.msg._execute(TARGETS[target] || TARGETS.all, data, sender);
if (res instanceof Promise) {
res.then(wrapData, wrapError).then(sendResponse);
return true;
}
if (res !== undefined) sendResponse(wrapData(res));
});
// This direct assignment allows IDEs to provide autocomplete for msg methods automatically
const msg = window.msg = {
isBg,
isBg: getExtBg() === window,
async broadcast(data) {
const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
@ -73,8 +48,8 @@ window.INJECTED !== 1 && (() => {
},
isIgnorableError(err) {
const msg = `${err && err.message || err}`;
return msg.includes(ERR_NO_RECEIVER) || msg.includes(ERR_PORT_CLOSED);
const text = `${err && err.message || err}`;
return text.includes(ERR_NO_RECEIVER) || text.includes(ERR_PORT_CLOSED);
},
ignoreError(err) {
@ -113,6 +88,11 @@ window.INJECTED !== 1 && (() => {
_execute(types, ...args) {
let result;
if (!(args[0] instanceof Object)) {
/* Data from other windows must be deep-copied to allow for GC in Chrome and
merely survive in FF as it kills cross-window objects when their tab is closed. */
args = args.map(deepCopy);
}
for (const type of types) {
for (const fn of handler[type]) {
let res;
@ -130,27 +110,64 @@ window.INJECTED !== 1 && (() => {
},
};
window.API = new Proxy({}, {
get(target, name) {
// using a named function for convenience when debugging
return async function invokeAPI(...args) {
if (!bg && chrome.tabs) {
bg = await browser.runtime.getBackgroundPage().catch(() => {});
function getExtBg() {
const fn = chrome.extension.getBackgroundPage;
const bg = fn && fn();
return bg === window || bg && bg.msg && bg.msg.isBgReady ? bg : null;
}
const message = {method: 'invokeAPI', name, args};
// content scripts and probably private tabs
if (!bg) {
return msg.send(message);
function onRuntimeMessage({data, target}, sender, sendResponse) {
const res = msg._execute(TARGETS[target] || TARGETS.all, data, sender);
if (res instanceof Promise) {
res.then(wrapData, wrapError).then(sendResponse);
return true;
}
// in FF, the object would become a dead object when the window
// is closed, so we have to clone the object into background.
const res = bg.msg._execute(TARGETS.extension, bg.deepCopy(message), {
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
tab: NEEDS_TAB_IN_SENDER.includes(name) && await getOwnTab(),
url: location.href,
});
return deepCopy(await res);
if (res !== undefined) sendResponse(wrapData(res));
}
function wrapData(data) {
return {data};
}
function wrapError(error) {
return {
error: Object.assign({
message: error.message || `${error}`,
stack: error.stack,
}, error), // passing custom properties e.g. `error.index`
};
}
function unwrapResponse({data, error} = {error: {message: ERR_NO_RECEIVER}}) {
return error
? Promise.reject(Object.assign(new Error(error.message), error))
: data;
}
const apiHandler = !msg.isBg && {
get({path}, name) {
const fn = () => {};
fn.path = [...path, name];
return new Proxy(fn, apiHandler);
},
});
async apply({path}, thisObj, args) {
const bg = getExtBg() ||
chrome.tabs && await browser.runtime.getBackgroundPage().catch(() => {});
const message = {method: 'invokeAPI', path, args};
let res;
// content scripts, probably private tabs, and our extension tab during Chrome startup
if (!bg) {
res = msg.send(message);
} else {
res = deepMerge(await bg.msg._execute(TARGETS.extension, message, {
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
tab: NEEDS_TAB_IN_SENDER.includes(path.join('.')) && await getOwnTab(),
url: location.href,
}));
}
return res;
},
};
/** @type {API} */
window.API = msg.isBg ? {} : new Proxy({path: []}, apiHandler);
})();

View File

@ -1,7 +1,10 @@
'use strict';
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
(() => {
/* Chrome reinjects content script when documentElement is replaced so we ignore it
by checking against a literal `1`, not just `if (truthy)`, because <html id="INJECTED">
is exposed per HTML spec as a global `window.INJECTED` */
if (window.INJECTED === 1) return;
//#region for content scripts and our extension pages
@ -66,6 +69,35 @@ self.INJECTED !== 1 && (() => {
//#region for our extension pages
window.require = async function require(urls, cb) {
const promises = [];
const all = [];
const toLoad = [];
for (let url of Array.isArray(urls) ? urls : [urls]) {
const isCss = url.endsWith('.css');
const tag = isCss ? 'link' : 'script';
const attr = isCss ? 'href' : 'src';
if (!isCss && !url.endsWith('.js')) url += '.js';
if (url.startsWith('/')) url = url.slice(1);
let el = document.head.querySelector(`${tag}[${attr}$="${url}"]`);
if (!el) {
el = document.createElement(tag);
toLoad.push(el);
promises.push(new Promise((resolve, reject) => {
el.onload = resolve;
el.onerror = reject;
el[attr] = url;
if (isCss) el.rel = 'stylesheet';
}).catch(console.warn));
}
all.push(el);
}
if (toLoad.length) document.head.append(...toLoad);
if (promises.length) await Promise.all(promises);
if (cb) cb(...all);
return all[0];
};
if (!(new URLSearchParams({foo: 1})).get('foo')) {
// TODO: remove when minimum_chrome_version >= 61
window.URLSearchParams = class extends URLSearchParams {

View File

@ -1,11 +1,21 @@
/* global msg API */
/* global deepCopy debounce */ // not used in content scripts
/* global API msg */// msg.js
/* global debounce deepMerge */// toolbox.js - not used in content scripts
'use strict';
// eslint-disable-next-line no-unused-expressions
window.INJECTED !== 1 && (() => {
(() => {
if (window.INJECTED === 1) return;
const STORAGE_KEY = 'settings';
const clone = msg.isBg ? deepCopy : (val => JSON.parse(JSON.stringify(val)));
const clone = typeof deepMerge === 'function'
? deepMerge
: val =>
typeof val === 'object' && val
? JSON.parse(JSON.stringify(val))
: val;
/**
* @type PrefsValues
* @namespace PrefsValues
*/
const defaults = {
'openEditInWindow': false, // new editor opens in a own browser window
'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
@ -112,34 +122,53 @@ window.INJECTED !== 1 && (() => {
'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
};
const knownKeys = Object.keys(defaults);
/** @type {PrefsValues} */
const values = clone(defaults);
const onChange = {
any: new Set(),
specific: {},
};
// getPrefs may fail on browser startup in the active tab as it loads before the background script
const initializing = (msg.isBg ? readStorage() : API.getPrefs().catch(readStorage))
.then(setAll);
// API fails in the active tab during Chrome startup as it loads the tab before bg
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = (msg.isBg ? readStorage() : API.prefs.getValues().catch(readStorage))
.then(data => {
setAll(data);
ready = true;
});
chrome.storage.onChanged.addListener(async (changes, area) => {
const data = area === 'sync' && changes[STORAGE_KEY];
if (data) {
await initializing;
if (ready.then) await ready;
setAll(data.newValue);
}
});
// This direct assignment allows IDEs to provide correct autocomplete for methods
const prefs = window.prefs = {
STORAGE_KEY,
initializing,
defaults,
knownKeys,
ready,
/** @type {PrefsValues} */
defaults: new Proxy({}, {
get: (_, key) => clone(defaults[key]),
}),
/** @type {PrefsValues} */
get values() {
return deepCopy(values);
return clone(values);
},
__defaults: defaults, // direct reference, be careful!
__values: values, // direct reference, be careful!
get(key) {
return isKnown(key) && values[key];
const res = values[key];
if (res !== undefined || isKnown(key)) {
return clone(res);
}
},
set(key, val, isSynced) {
if (!isKnown(key)) return;
const oldValue = values[key];
@ -155,36 +184,45 @@ window.INJECTED !== 1 && (() => {
emitChange(key, val, isSynced);
}
},
reset(key) {
prefs.set(key, clone(defaults[key]));
},
/**
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
* @param {function(key:string, value:any)} fn
* @param {function(key:string?, value:any?)} fn
* @param {Object} [opts]
* @param {boolean} [opts.now] - when truthy, the listener is called immediately:
* @param {boolean} [opts.runNow] - when truthy, the listener is called immediately:
* 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
* 2) if `keys` is falsy, no key/value will be provided
*/
subscribe(keys, fn, {now} = {}) {
async subscribe(keys, fn, {runNow} = {}) {
const toRun = [];
if (keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) {
if (!isKnown(key)) continue;
const listeners = onChange.specific[key] ||
(onChange.specific[key] = new Set());
listeners.add(fn);
if (now) fn(key, values[key]);
if (runNow) toRun.push({fn, key});
}
} else {
onChange.any.add(fn);
if (now) fn();
if (runNow) toRun.push({fn});
}
if (toRun.length) {
if (ready.then) await ready;
toRun.forEach(({fn, key}) => fn(key, values[key]));
}
},
subscribeMany(data, opts) {
for (const [k, fn] of Object.entries(data)) {
prefs.subscribe(k, fn, opts);
}
},
unsubscribe(keys, fn) {
if (keys) {
for (const key of keys) {
@ -203,7 +241,7 @@ window.INJECTED !== 1 && (() => {
};
function isKnown(key) {
const res = defaults.hasOwnProperty(key);
const res = knownKeys.includes(key);
if (!res) console.warn('Unknown preference "%s"', key);
return res;
}
@ -228,14 +266,13 @@ window.INJECTED !== 1 && (() => {
if (msg.isBg) {
debounce(updateStorage);
} else {
API.setPref(key, value);
API.prefs.set(key, value);
}
}
}
function readStorage() {
return browser.storage.sync.get(STORAGE_KEY)
.then(data => data[STORAGE_KEY]);
async function readStorage() {
return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
}
function updateStorage() {

View File

@ -1,66 +1,17 @@
/* global deepEqual msg */
/* exported router */
/* global deepEqual */// toolbox.js
/* global msg */
'use strict';
const router = (() => {
const buffer = [];
const watchers = [];
document.addEventListener('DOMContentLoaded', () => update());
window.addEventListener('popstate', () => update());
window.addEventListener('hashchange', () => update());
msg.on(e => {
if (e.method === 'pushState' && e.url !== location.href) {
history.pushState(history.state, null, e.url);
update();
return true;
}
});
return {watch, updateSearch, getSearch, updateHash};
const router = {
buffer: [],
watchers: [],
function watch(options, callback) {
/* Watch search params or hash and get notified on change.
options: {search?: Array<key: String>, hash?: String}
callback: (Array<value: String | null> | Boolean) => void
`hash` should always start with '#'.
When watching search params, callback receives a list of values.
When watching hash, callback receives a boolean.
*/
watchers.push({options, callback});
}
function updateSearch(key, value) {
const u = new URL(location);
u.searchParams[value ? 'set' : 'delete'](key, value);
history.replaceState(history.state, null, `${u}`);
update(true);
}
function updateHash(hash) {
/* hash: String
Send an empty string to remove the hash.
*/
if (buffer.length > 1) {
if (!hash && !buffer[buffer.length - 2].includes('#') ||
hash && buffer[buffer.length - 2].endsWith(hash)) {
history.back();
return;
}
}
if (!hash) {
hash = ' ';
}
history.pushState(history.state, null, hash);
update();
}
function getSearch(key) {
getSearch(key) {
return new URLSearchParams(location.search).get(key);
}
},
function update(replace) {
update(replace) {
const {buffer} = router;
if (!buffer.length) {
buffer.push(location.href);
} else if (buffer[buffer.length - 1] === location.href) {
@ -72,7 +23,7 @@ const router = (() => {
} else {
buffer.push(location.href);
}
for (const {options, callback} of watchers) {
for (const {options, callback} of router.watchers) {
let state;
if (options.hash) {
state = options.hash === location.hash;
@ -85,5 +36,55 @@ const router = (() => {
callback(state);
}
}
},
/**
* @param {string} hash - empty string removes the hash
*/
updateHash(hash) {
const {buffer} = router;
if (buffer.length > 1) {
if (!hash && !buffer[buffer.length - 2].includes('#') ||
hash && buffer[buffer.length - 2].endsWith(hash)) {
history.back();
return;
}
})();
}
if (!hash) {
hash = ' ';
}
history.pushState(history.state, null, hash);
router.update();
},
updateSearch(key, value) {
const u = new URL(location);
u.searchParams[value ? 'set' : 'delete'](key, value);
history.replaceState(history.state, null, `${u}`);
router.update(true);
},
watch(options, callback) {
/* Watch search params or hash and get notified on change.
options: {search?: Array<key: String>, hash?: String}
callback: (Array<value: String | null> | Boolean) => void
`hash` should always start with '#'.
When watching search params, callback receives a list of values.
When watching hash, callback receives a boolean.
*/
router.watchers.push({options, callback});
},
};
document.on('DOMContentLoaded', () => router.update());
window.on('popstate', () => router.update());
window.on('hashchange', () => router.update());
msg.on(e => {
if (e.method === 'pushState' && e.url !== location.href) {
history.pushState(history.state, null, e.url);
router.update();
return true;
}
});

View File

@ -1,50 +0,0 @@
/* exported loadScript */
'use strict';
// loadScript(script: Array<Promise|string>|string): Promise
const loadScript = (() => {
const cache = new Map();
function inject(file) {
if (!cache.has(file)) {
cache.set(file, doInject(file));
}
return cache.get(file);
}
function doInject(file) {
return new Promise((resolve, reject) => {
let el;
if (file.endsWith('.js')) {
el = document.createElement('script');
el.src = file;
} else {
el = document.createElement('link');
el.rel = 'stylesheet';
el.href = file;
}
el.onload = () => {
el.onload = null;
el.onerror = null;
resolve(el);
};
el.onerror = () => {
el.onload = null;
el.onerror = null;
reject(new Error(`Failed to load script: ${file}`));
};
document.head.appendChild(el);
});
}
return (files, noCache = false) => {
if (!Array.isArray(files)) {
files = [files];
}
return Promise.all(files.map(f =>
typeof f !== 'string' ? f :
noCache ? doInject(f) :
inject(f)
));
};
})();

View File

@ -1,11 +1,77 @@
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
'use strict';
/* exported
calcStyleDigest
MozDocMapper
styleCodeEmpty
styleJSONseemsValid
styleSectionGlobal
styleSectionsEqual
*/
const MozDocMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
domains: 'domain',
regexps: 'regexp',
},
FROM_CSS: {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
},
/**
* @param {Object} section
* @param {function(func:string, value:string)} fn
*/
forEachProp(section, fn) {
for (const [propName, func] of Object.entries(MozDocMapper.TO_CSS)) {
const props = section[propName];
if (props) props.forEach(value => fn(func, value));
}
},
/**
* @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
* @returns {Object} section
*/
toSection(funcItems, section = {}) {
for (const item of funcItems) {
const [func, value] = item || [];
const propName = MozDocMapper.FROM_CSS[func];
if (propName) {
const props = section[propName] || (section[propName] = []);
if (Array.isArray(value)) props.push(...value);
else props.push(value);
}
}
return section;
},
/**
* @param {StyleObj} style
* @returns {string}
*/
styleToCss(style) {
const res = [];
for (const section of style.sections) {
const funcs = [];
MozDocMapper.forEachProp(section, (type, value) =>
funcs.push(`${type}("${value.replace(/[\\"]/g, '\\$&')}")`));
res.push(funcs.length
? `@-moz-document ${funcs.join(', ')} {\n${section.code}\n}`
: section.code);
}
return res.join('\n\n');
},
};
function styleCodeEmpty(code) {
if (!code) {
return true;
}
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
@ -50,41 +116,27 @@ function styleSectionsEqual({sections: a}, {sections: b}) {
}
}
function normalizeStyleSections({sections}) {
async function calcStyleDigest(style) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => /** @namespace StyleSection */({
const src = style.usercssData
? style.sourceCode
// retain known properties in an arbitrarily predefined order
: JSON.stringify((style.sections || []).map(section => /** @namespace StyleSection */({
code: section.code || '',
urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
}
function calcStyleDigest(style) {
const jsonString = style.usercssData ?
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
const text = new TextEncoder('utf-8').encode(jsonString);
return crypto.subtle.digest('SHA-1', text).then(hex);
function hex(buffer) {
const parts = [];
const PAD8 = '00000000';
const view = new DataView(buffer);
for (let i = 0; i < view.byteLength; i += 4) {
parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8));
}
return parts.join('');
}
})));
const srcBytes = new TextEncoder().encode(src);
const res = await crypto.subtle.digest('SHA-1', srcBytes);
return Array.from(new Uint8Array(res), b => (0x100 + b).toString(16).slice(1)).join('');
}
function styleJSONseemsValid(json) {
return json
&& json.name
&& typeof json.name == 'string'
&& json.name.trim()
&& Array.isArray(json.sections)
&& json.sections
&& json.sections.length
&& typeof json.sections.every === 'function'
&& typeof json.sections[0].code === 'string';
&& typeof (json.sections[0] || {}).code === 'string';
}

View File

@ -1,7 +1,9 @@
/* global loadScript tryJSONparse */
/* global tryJSONparse */// toolbox.js
'use strict';
(() => {
let LZString;
/** @namespace StorageExtras */
const StorageExtras = {
async getValue(key) {
@ -14,9 +16,9 @@
return (await this.getLZValues([key]))[key];
},
async getLZValues(keys = Object.values(this.LZ_KEY)) {
const [data, LZString] = await Promise.all([
const [data] = await Promise.all([
this.get(keys),
this.getLZString(),
LZString || loadLZString(),
]);
for (const key of keys) {
const value = data[key];
@ -25,16 +27,9 @@
return data;
},
async setLZValue(key, value) {
const LZString = await this.getLZString();
if (!LZString) await loadLZString();
return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value)));
},
async getLZString() {
if (!window.LZString) {
await loadScript('/vendor/lz-string-unsafe/lz-string-unsafe.min.js');
window.LZString = window.LZString || window.LZStringUnsafe;
}
return window.LZString;
},
};
/** @namespace StorageExtrasSync */
const StorageExtrasSync = {
@ -44,6 +39,12 @@
usercssTemplate: 'usercssTemplate',
},
};
async function loadLZString() {
await require(['/vendor/lz-string-unsafe/lz-string-unsafe.min']);
LZString = window.LZString || window.LZStringUnsafe;
}
/** @type {chrome.storage.StorageArea|StorageExtras} */
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras);
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */

View File

@ -1,13 +1,16 @@
'use strict';
/* exported
CHROME_POPUP_BORDER_BUG
capitalize
CHROME_HAS_BORDER_BUG
closeCurrentTab
deepEqual
download
getActiveTab
getStyleWithNoCode
getOwnTab
getTab
ignoreChromeError
isEmptyObj
onTabReady
openURL
sessionStore
@ -15,7 +18,6 @@
tryCatch
tryRegExp
*/
'use strict';
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]);
const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
@ -23,7 +25,7 @@ const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi');
let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
// see PR #781
const CHROME_HAS_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
const CHROME_POPUP_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
if (!CHROME && !chrome.browserAction.openPopup) {
// in FF pre-57 legacy addons can override useragent so we assume the worst
@ -40,12 +42,6 @@ if (!CHROME && !chrome.browserAction.openPopup) {
const URLS = {
ownOrigin: chrome.runtime.getURL(''),
// FIXME delete?
optionsUI: [
chrome.runtime.getURL('options.html'),
'chrome://extensions/?options=' + chrome.runtime.id,
],
configureCommands:
OPERA ? 'opera://settings/configureCommands'
: 'chrome://extensions/configureCommands',
@ -75,6 +71,8 @@ const URLS = {
// TODO: remove when "minimum_chrome_version": "61" or higher
chromeProtectsNTP: CHROME >= 61,
rxMETA: /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i,
uso: 'https://userstyles.org/',
usoJson: 'https://userstyles.org/styles/chrome/',
@ -84,10 +82,13 @@ const URLS = {
url &&
url.startsWith(URLS.usoArchiveRaw) &&
parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]),
extractUsoArchiveInstallUrl: url => {
const id = URLS.extractUsoArchiveId(url);
return id ? `${URLS.usoArchive}?style=${id}` : '';
},
extractGreasyForkId: url =>
/^https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/(\d+)[^/]*\/code\/[^/]*\.user\.css$/.test(url) &&
RegExp.$1,
extractGreasyForkInstallUrl: url =>
/^(https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/\d+)[^/]*\/code\/[^/]*\.user\.css$|$/.exec(url)[1],
supported: url => (
url.startsWith('http') ||
@ -98,11 +99,11 @@ const URLS = {
),
};
if (chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window) {
window.API_METHODS = {};
} else {
const cls = FIREFOX ? 'firefox' : OPERA ? 'opera' : VIVALDI ? 'vivaldi' : '';
if (cls) document.documentElement.classList.add(cls);
if (FIREFOX || OPERA || VIVALDI) {
document.documentElement.classList.add(
FIREFOX && 'firefox' ||
OPERA && 'opera' ||
VIVALDI && 'vivaldi');
}
// FF57+ supports openerTabId, but not in Android
@ -113,9 +114,8 @@ function getOwnTab() {
return browser.tabs.getCurrent();
}
function getActiveTab() {
return browser.tabs.query({currentWindow: true, active: true})
.then(tabs => tabs[0]);
async function getActiveTab() {
return (await browser.tabs.query({currentWindow: true, active: true}))[0];
}
function urlToMatchPattern(url, ignoreSearch) {
@ -133,13 +133,13 @@ function urlToMatchPattern(url, ignoreSearch) {
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
}
function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
async function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
url = new URL(url);
return browser.tabs.query({url: urlToMatchPattern(url, ignoreSearch), currentWindow})
// FIXME: is tab.url always normalized?
.then(tabs => tabs.find(matchTab));
function matchTab(tab) {
const tabs = await browser.tabs.query({
url: urlToMatchPattern(url, ignoreSearch),
currentWindow,
});
return tabs.find(tab => {
const tabUrl = new URL(tab.pendingUrl || tab.url);
return tabUrl.protocol === url.protocol &&
tabUrl.username === url.username &&
@ -149,7 +149,7 @@ function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch =
tabUrl.pathname === url.pathname &&
(ignoreSearch || tabUrl.search === url.search) &&
(ignoreHash || tabUrl.hash === url.hash);
}
});
}
/**
@ -185,7 +185,7 @@ async function openURL({
});
}
if (newWindow && browser.windows) {
return (await browser.windows.create(Object.assign({url}, newWindow)).tabs)[0];
return (await browser.windows.create(Object.assign({url}, newWindow))).tabs[0];
}
tab = await getActiveTab() || {url: ''};
if (isTabReplaceable(tab, url)) {
@ -196,20 +196,17 @@ async function openURL({
return browser.tabs.create(Object.assign({url, index, active}, opener));
}
// replace empty tab (NTP or about:blank)
// except when new URL is chrome:// or chrome-extension:// and the empty tab is
// in incognito
/**
* Replaces empty tab (NTP or about:blank)
* except when new URL is chrome:// or chrome-extension:// and the empty tab is in incognito
*/
function isTabReplaceable(tab, newUrl) {
if (!tab || !URLS.emptyTab.includes(tab.pendingUrl || tab.url)) {
return false;
}
if (tab.incognito && newUrl.startsWith('chrome')) {
return false;
}
return true;
return tab &&
URLS.emptyTab.includes(tab.pendingUrl || tab.url) &&
!(tab.incognito && newUrl.startsWith('chrome'));
}
function activateTab(tab, {url, index, openerTabId} = {}) {
async function activateTab(tab, {url, index, openerTabId} = {}) {
const options = {active: true};
if (url) {
options.url = url;
@ -217,62 +214,64 @@ function activateTab(tab, {url, index, openerTabId} = {}) {
if (openerTabId != null && openerTabIdSupported) {
options.openerTabId = openerTabId;
}
return Promise.all([
await 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}),
])
.then(() => tab);
]);
return tab;
}
function stringAsRegExp(s, flags) {
return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags);
function stringAsRegExp(s, flags, asString) {
s = s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&');
return asString ? s : new RegExp(s, flags);
}
function ignoreChromeError() {
// eslint-disable-next-line no-unused-expressions
chrome.runtime.lastError;
}
function getStyleWithNoCode(style) {
const stripped = deepCopy(style);
for (const section of stripped.sections) section.code = null;
stripped.sourceCode = null;
return stripped;
function isEmptyObj(obj) {
if (obj) {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
return false;
}
}
}
return true;
}
// 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
/**
* 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
* 2020 update: probably fixed at least in V8
*/
function tryCatch(func, ...args) {
try {
return func(...args);
} catch (e) {}
}
function tryRegExp(regexp, flags) {
try {
return new RegExp(regexp, flags);
} catch (e) {}
}
function tryJSONparse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {}
}
const debounce = Object.assign((fn, delay, ...args) => {
function debounce(fn, delay, ...args) {
clearTimeout(debounce.timers.get(fn));
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
}, {
}
Object.assign(debounce, {
timers: new Map(),
run(fn, ...args) {
debounce.timers.delete(fn);
@ -284,27 +283,28 @@ const debounce = Object.assign((fn, delay, ...args) => {
},
});
function deepCopy(obj) {
if (!obj || typeof obj !== 'object') return obj;
// N.B. the copy should be an explicit literal
if (Array.isArray(obj)) {
const copy = [];
for (const v of obj) {
copy.push(!v || typeof v !== 'object' ? v : deepCopy(v));
function deepMerge(src, dst) {
if (!src || typeof src !== 'object') {
return src;
}
return copy;
if (Array.isArray(src)) {
// using `Array` that belongs to this `window`; not using Array.from as it's slower
if (!dst) dst = Array.prototype.map.call(src, deepCopy);
else for (const v of src) dst.push(deepMerge(v));
} else {
// using an explicit {} that belongs to this `window`
if (!dst) dst = {};
for (const [k, v] of Object.entries(src)) {
dst[k] = deepMerge(v, dst[k]);
}
const copy = {};
const hasOwnProperty = Object.prototype.hasOwnProperty;
for (const k in obj) {
if (!hasOwnProperty.call(obj, k)) continue;
const v = obj[k];
copy[k] = !v || typeof v !== 'object' ? v : deepCopy(v);
}
return copy;
return dst;
}
/** Useful in arr.map(deepCopy) to ignore the extra parameters passed by map() */
function deepCopy(src) {
return deepMerge(src);
}
function deepEqual(a, b, ignoredKeys) {
if (!a || !b) return a === b;
@ -371,70 +371,49 @@ function download(url, {
requiredStatusCode = 200,
timeout = 60e3, // connection timeout, USO is that bad
loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
headers = {
'Content-type': 'application/x-www-form-urlencoded',
},
headers,
} = {}) {
const queryPos = url.indexOf('?');
if (queryPos > 0 && body === undefined) {
/* USO can't handle POST requests for style json and XHR/fetch can't handle super long URL
* so we need to collapse all long variables and expand them in the response */
const queryPos = url.startsWith(URLS.uso) ? url.indexOf('?') : -1;
if (queryPos >= 0) {
if (body === undefined) {
method = 'POST';
body = url.slice(queryPos);
url = url.slice(0, queryPos);
}
// * USO can't handle POST requests for style json
// * XHR/fetch can't handle long URL
// So we need to collapse all long variables and expand them in the response
if (headers === undefined) {
headers = {
'Content-type': 'application/x-www-form-urlencoded',
};
}
}
const usoVars = [];
return new Promise((resolve, reject) => {
let xhr;
const xhr = new XMLHttpRequest();
const u = new URL(collapseUsoVars(url));
const onTimeout = () => {
if (xhr) xhr.abort();
xhr.abort();
reject(new Error('Timeout fetching ' + u.href));
};
let timer = setTimeout(onTimeout, timeout);
const switchTimer = () => {
clearTimeout(timer);
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
};
if (u.protocol === 'file:' && FIREFOX) { // TODO: maybe remove this since FF68+ can't do it anymore
// https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path
// FIXME: add FetchController when it is available.
fetch(u.href, {mode: 'same-origin'})
.then(r => {
switchTimer();
return r.status === 200 ? r.text() : Promise.reject(r.status);
})
.catch(reject)
.then(text => {
clearTimeout(timer);
resolve(text);
});
return;
}
xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
xhr.onreadystatechange = null;
switchTimer();
}
};
xhr.onloadend = event => {
clearTimeout(timer);
if (event.type !== 'error' && (
xhr.status === requiredStatusCode || !requiredStatusCode ||
u.protocol === 'file:')) {
resolve(expandUsoVars(xhr.response));
} else {
reject(xhr.status);
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
}
};
xhr.onerror = xhr.onloadend;
xhr.onload = () =>
xhr.status === requiredStatusCode || !requiredStatusCode || u.protocol === 'file:'
? resolve(expandUsoVars(xhr.response))
: reject(xhr.status);
xhr.onerror = () => reject(xhr.status);
xhr.onloadend = () => clearTimeout(timer);
xhr.responseType = responseType;
xhr.open(method, u.href, true);
for (const key in headers) {
xhr.setRequestHeader(key, headers[key]);
xhr.open(method, u.href);
for (const [name, value] of Object.entries(headers || {})) {
xhr.setRequestHeader(name, value);
}
xhr.send(body);
});
@ -470,13 +449,10 @@ function download(url, {
}
}
function closeCurrentTab() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
getOwnTab().then(tab => {
if (tab) {
chrome.tabs.remove(tab.id);
}
});
async function closeCurrentTab() {
// https://bugzil.la/1409375
const tab = await getOwnTab();
if (tab) chrome.tabs.remove(tab.id);
}
function capitalize(s) {

128
js/usercss-compiler.js Normal file
View File

@ -0,0 +1,128 @@
'use strict';
const BUILDERS = Object.assign(Object.create(null), {
default: {
post(sections, vars) {
require(['/js/sections-util']); /* global styleCodeEmpty */
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n';
for (const section of sections) {
if (!styleCodeEmpty(section.code)) {
section.code = varDef + section.code;
}
}
},
},
stylus: {
pre(source, vars) {
require(['/vendor/stylus-lang-bundle/stylus-renderer.min']); /* global StylusRenderer */
return new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
new StylusRenderer(varDef + source)
.render((err, output) => err ? reject(err) : resolve(output));
});
},
},
less: {
async pre(source, vars) {
if (!self.less) {
self.less = {
logLevel: 0,
useFileCache: false,
};
}
require(['/vendor/less-bundle/less.min']); /* global less */
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
return (await less.render(varDefs + source)).css;
},
},
uso: {
async pre(source, vars) {
require(['/js/color/color-converter']); /* global colorConverter */
const pool = new Map();
return doReplace(source);
function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), name);
}
return null;
}
const {type, value} = vars[name];
switch (type) {
case 'color': {
let color = pool.get(rgbName || name);
if (color == null) {
color = colorConverter.parse(value);
if (color) {
if (color.type === 'hsl') {
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
}
const {r, g, b} = color;
color = rgbName
? `${r}, ${g}, ${b}`
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
// the pool stores `false` for bad colors to differentiate from a yet unknown color
pool.set(rgbName || name, color || false);
}
return color || null;
}
case 'dropdown':
case 'select': // prevent infinite recursion
pool.set(name, '');
return doReplace(value);
}
return value;
}
function doReplace(text) {
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
if (!pool.has(name)) {
const value = getValue(name);
pool.set(name, value === null ? match : value);
}
return pool.get(name);
});
}
},
},
});
/* exported compileUsercss */
async function compileUsercss(preprocessor, code, vars) {
let builder = BUILDERS[preprocessor];
if (!builder) {
builder = BUILDERS.default;
if (preprocessor != null) console.warn(`Unknown preprocessor "${preprocessor}"`);
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
vars = Object.entries(vars || {}).reduce((output, [key, va]) => {
// TODO: handle customized image
const prop = va.value == null ? 'default' : 'value';
const value =
/^(select|dropdown|image)$/.test(va.type) ?
va.options.find(o => o.name === va[prop]).value :
/^(number|range)$/.test(va.type) && va.units ?
va[prop] + va.units :
va[prop];
output[key] = Object.assign({}, va, {value});
return output;
}, {});
if (builder.pre) {
code = await builder.pre(code, vars);
}
require(['/js/moz-parser']); /* global extractSections */
const res = extractSections({code});
if (builder.post) {
builder.post(res.sections, vars);
}
return res;
}

View File

@ -1,103 +0,0 @@
/* global backgroundWorker */
/* exported usercss */
'use strict';
const usercss = (() => {
const GLOBAL_METAS = {
author: undefined,
description: undefined,
homepageURL: 'url',
updateURL: 'updateUrl',
name: undefined,
};
const RX_META = /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i;
const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']);
return {
RX_META,
buildMeta,
buildCode,
assignVars,
};
function buildMeta(sourceCode) {
sourceCode = sourceCode.replace(/\r\n?/g, '\n');
const style = {
enabled: true,
sourceCode,
sections: [],
};
const match = sourceCode.match(RX_META);
if (!match) {
throw new Error('can not find metadata');
}
return backgroundWorker.parseUsercssMeta(match[0], match.index)
.catch(err => {
if (err.code) {
const args = ERR_ARGS_IS_LIST.has(err.code) ? drawList(err.args) : err.args;
const message = chrome.i18n.getMessage(`meta_${err.code}`, args);
if (message) {
err.message = message;
}
}
throw err;
})
.then(({metadata}) => {
style.usercssData = metadata;
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
for (const [key, value] of Object.entries(GLOBAL_METAS)) {
if (metadata[key] !== undefined) {
style[value || key] = metadata[key];
}
}
return style;
});
}
function drawList(items) {
return items.map(i => i.length === 1 ? JSON.stringify(i) : i).join(', ');
}
/**
* @param {Object} style
* @param {Boolean} [allowErrors=false]
* @returns {(Style | {style: Style, errors: (false|String[])})} - style object
* when allowErrors is falsy or {style, errors} object when allowErrors is truthy
*/
function buildCode(style, allowErrors) {
const match = style.sourceCode.match(RX_META);
return backgroundWorker.compileUsercss(
style.usercssData.preprocessor,
style.sourceCode.slice(0, match.index) + style.sourceCode.slice(match.index + match[0].length),
style.usercssData.vars
)
.then(({sections, errors}) => {
if (!errors.length) errors = false;
if (!sections.length || errors && !allowErrors) {
throw errors || 'Style does not contain any actual CSS to apply.';
}
style.sections = sections;
return allowErrors ? {style, errors} : style;
});
}
function assignVars(style, oldStyle) {
const {usercssData: {vars}} = style;
const {usercssData: {vars: oldVars}} = oldStyle;
if (!vars || !oldVars) {
return Promise.resolve();
}
// The type of var might be changed during the update. Set value to null if the value is invalid.
for (const key of Object.keys(vars)) {
if (oldVars[key] && oldVars[key].value) {
vars[key].value = oldVars[key].value;
}
}
return backgroundWorker.nullifyInvalidVars(vars)
.then(vars => {
style.usercssData.vars = vars;
});
}
})();

Some files were not shown because too many files have changed in this diff Show More