migrate to AMD modules

This commit is contained in:
tophf 2020-11-30 03:29:35 +03:00
parent c2adfbf902
commit d054dcf42e
127 changed files with 12217 additions and 11867 deletions

View File

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

View File

@ -8,6 +8,9 @@ env:
es6: true es6: true
webextensions: true webextensions: true
globals:
define: readonly
rules: rules:
accessor-pairs: [2] accessor-pairs: [2]
array-bracket-spacing: [2, never] array-bracket-spacing: [2, never]
@ -42,7 +45,15 @@ rules:
id-blacklist: [0] id-blacklist: [0]
id-length: [0] id-length: [0]
id-match: [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] jsx-quotes: [0]
key-spacing: [0] key-spacing: [0]
keyword-spacing: [2] keyword-spacing: [2]
@ -86,7 +97,7 @@ rules:
no-empty: [2, {allowEmptyCatch: true}] no-empty: [2, {allowEmptyCatch: true}]
no-eq-null: [0] no-eq-null: [0]
no-eval: [2] no-eval: [2]
no-ex-assign: [2] no-ex-assign: [0]
no-extend-native: [2] no-extend-native: [2]
no-extra-bind: [2] no-extra-bind: [2]
no-extra-boolean-cast: [2] no-extra-boolean-cast: [2]
@ -136,6 +147,9 @@ rules:
no-proto: [2] no-proto: [2]
no-redeclare: [2] no-redeclare: [2]
no-regex-spaces: [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-imports: [0]
no-restricted-modules: [2, domain, freelist, smalloc, sys] no-restricted-modules: [2, domain, freelist, smalloc, sys]
no-restricted-syntax: [2, WithStatement] no-restricted-syntax: [2, WithStatement]
@ -165,7 +179,7 @@ rules:
no-unsafe-negation: [2] no-unsafe-negation: [2]
no-unused-expressions: [1] no-unused-expressions: [1]
no-unused-labels: [0] no-unused-labels: [0]
no-unused-vars: [2, {args: after-used}] no-unused-vars: [2, {args: after-used, argsIgnorePattern: "^require$"}]
no-use-before-define: [2, nofunc] no-use-before-define: [2, nofunc]
no-useless-call: [2] no-useless-call: [2]
no-useless-computed-key: [2] no-useless-computed-key: [2]
@ -220,3 +234,7 @@ overrides:
webextensions: false webextensions: false
parserOptions: parserOptions:
ecmaVersion: 2017 ecmaVersion: 2017
- files: ["**/*worker*.js"]
env:
worker: true

View File

@ -47,15 +47,15 @@ See our [contributing](./.github/CONTRIBUTING.md) page for more details.
## License ## License
Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/): Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/):
Copyright © 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com) Copyright © 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com)
Current Stylus: Current Stylus:
Copyright © 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors) Copyright © 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors)
**[GNU GPLv3](./LICENSE)** **[GNU GPLv3](./LICENSE)**
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by

View File

@ -0,0 +1,154 @@
'use strict';
/* Populates API */
define(require => {
const {
URLS,
activateTab,
findExistingTab,
getActiveTab,
isTabReplaceable,
openURL,
} = require('/js/toolbox');
const {API, msg} = require('/js/msg');
const {createWorker} = require('/js/worker-util');
const prefs = require('/js/prefs');
Object.assign(API, ...[
require('./icon-manager'),
require('./openusercss-api'),
require('./search-db'),
], /** @namespace API */ {
browserCommands: {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
reload: () => chrome.runtime.reload(),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
},
/** @type {StyleManager} */
styles: require('./style-manager'),
/** @type {Sync} */
sync: require('./sync'),
/** @type {StyleUpdater} */
updater: require('./update'),
/** @type {UsercssHelper} */
usercss: Object.assign({},
require('./usercss-api-helper'),
require('./usercss-install-helper')),
/** @type {BackgroundWorker} */
worker: createWorker({
url: '/background/background-worker.js',
}),
/** @returns {string} */
getTabUrlPrefix() {
const {url} = this.sender.tab;
if (url.startsWith(URLS.ownOrigin)) {
return 'stylus';
}
return url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
/** @returns {PrefsValues} */
getPrefs: () => prefs.values,
setPref(key, value) {
prefs.set(key, value);
},
/**
* Opens the editor or activates an existing tab
* @param {{
id?: number
domain?: string
'url-prefix'?: string
}} params
* @returns {Promise<chrome.tabs.Tab>}
*/
openEditor(params) {
const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params);
return openURL({
url: `${u}`,
currentWindow: null,
newWindow: prefs.get('openEditInWindow') && Object.assign({},
prefs.get('openEditInWindow.popup') && {type: 'popup'},
prefs.get('windowPosition')),
});
},
/** @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}`;
}
if (options) {
url += '#stylus-options';
}
let tab = await findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true,
});
if (tab) {
await activateTab(tab);
if (url !== (tab.pendingUrl || tab.url)) {
await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error);
}
return tab;
}
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url}).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'));
}));
}
},
});
msg.on((msg, sender) => {
if (msg.method === 'invokeAPI') {
const fn = msg.path.reduce((res, name) => res && res[name], API);
if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`);
const res = typeof fn === 'function'
? fn.apply({msg, sender}, msg.args)
: fn;
return res === undefined ? null : res;
}
});
});

View File

@ -1,84 +1,44 @@
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
'use strict'; 'use strict';
importScripts('/js/worker-util.js'); define(require => { // define and require use `importScripts` which is synchronous
const {loadScript} = workerUtil; const {createAPI} = require('/js/worker-util');
/** @namespace ApiWorker */ let BUILDERS;
workerUtil.createAPI({ const bgw = /** @namespace BackgroundWorker */ {
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);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.nullifyInvalidVars(vars);
},
});
function compileUsercss(preprocessor, code, vars) { async compileUsercss(preprocessor, code, vars) {
loadScript( if (!BUILDERS) createBuilders();
'/vendor-overwrites/csslint/parserlib.js', const builder = BUILDERS[preprocessor] || BUILDERS.default;
'/vendor-overwrites/colorpicker/colorconverter.js', if (!builder) throw new Error(`Unknown preprocessor "${preprocessor}"`);
'/js/moz-parser.js' vars = simplifyVars(vars);
); const {preprocess, postprocess} = builder;
const builder = getUsercssCompiler(preprocessor); if (preprocess) code = await preprocess(code, vars);
vars = simpleVars(vars); const res = bgw.parseMozFormat({code});
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code) if (postprocess) postprocess(res.sections, vars);
.then(code => parseMozFormat({code})) return res;
.then(({sections, errors}) => { },
if (builder.postprocess) {
builder.postprocess(sections, vars);
}
return {sections, errors};
});
function simpleVars(vars) { parseMozFormat(...args) {
if (!vars) { return require('/js/moz-parser').extractSections(...args);
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) { parseUsercssMeta(text) {
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') { return require('/js/meta-parser').parse(text);
// 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) { nullifyInvalidVars(vars) {
const BUILDER = { return require('/js/meta-parser').nullifyInvalidVars(vars);
default: { },
};
createAPI(bgw);
function createBuilders() {
BUILDERS = Object.assign(Object.create(null));
BUILDERS.default = {
postprocess(sections, vars) { postprocess(sections, vars) {
loadScript('/js/sections-util.js'); const {styleCodeEmpty} = require('/js/sections-util');
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join(''); let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
if (!varDef) return; if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n'; varDef = ':root {\n' + varDef + '}\n';
@ -88,18 +48,20 @@ function getUsercssCompiler(preprocessor) {
} }
} }
}, },
}, };
stylus: {
BUILDERS.stylus = {
preprocess(source, vars) { preprocess(source, vars) {
loadScript('/vendor/stylus-lang-bundle/stylus-renderer.min.js'); require('/vendor/stylus-lang-bundle/stylus-renderer.min');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join(''); const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
new self.StylusRenderer(varDef + source) new self.StylusRenderer(varDef + source)
.render((err, output) => err ? reject(err) : resolve(output)); .render((err, output) => err ? reject(err) : resolve(output));
}); });
}, },
}, };
less: {
BUILDERS.less = {
preprocess(source, vars) { preprocess(source, vars) {
if (!self.less) { if (!self.less) {
self.less = { self.less = {
@ -107,17 +69,18 @@ function getUsercssCompiler(preprocessor) {
useFileCache: false, useFileCache: false,
}; };
} }
loadScript('/vendor/less-bundle/less.min.js'); require('/vendor/less-bundle/less.min');
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join(''); const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
return self.less.render(varDefs + source) return self.less.render(varDefs + source)
.then(({css}) => css); .then(({css}) => css);
}, },
}, };
uso: {
preprocess(source, vars) { BUILDERS.uso = {
loadScript('/vendor-overwrites/colorpicker/colorconverter.js'); async preprocess(source, vars) {
const colorConverter = require('/js/color/color-converter');
const pool = new Map(); const pool = new Map();
return Promise.resolve(doReplace(source)); return doReplace(source);
function getValue(name, rgbName) { function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) { if (!vars.hasOwnProperty(name)) {
@ -164,14 +127,35 @@ function getUsercssCompiler(preprocessor) {
}); });
} }
}, },
}, };
};
if (preprocessor) {
if (!BUILDER[preprocessor]) {
throw new Error('unknwon preprocessor');
}
return BUILDER[preprocessor];
} }
return BUILDER.default;
} 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 simplifyVars(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;
}, {});
}
return bgw;
});

View File

@ -1,178 +1,39 @@
/* global
activateTab
API
chromeLocal
findExistingTab
FIREFOX
getActiveTab
isTabReplaceable
msg
openURL
prefs
semverCompare
URLS
workerUtil
*/
'use strict'; 'use strict';
//#region API define(require => {
const {FIREFOX} = require('/js/toolbox');
const {API, msg} = require('/js/msg');
const styleManager = require('./style-manager');
require('./background-api');
Object.assign(API, { // These are loaded conditionally.
// Each item uses `require` individually so IDE can jump to the source and track usage.
/** @type {ApiWorker} */ Promise.all([
worker: workerUtil.createWorker({ FIREFOX &&
url: '/background/background-worker.js', require(['./style-via-api']),
}), FIREFOX && ((browser.commands || {}).update) &&
require(['./browser-cmd-hotkeys']),
/** @returns {string} */ !FIREFOX &&
getTabUrlPrefix() { require(['./content-scripts']),
const {url} = this.sender.tab; !FIREFOX &&
if (url.startsWith(URLS.ownOrigin)) { require(['./style-via-webrequest']),
return 'stylus'; chrome.contextMenus &&
} require(['./context-menus']),
return url.match(/^([\w-]+:\/+[^/#]+)/)[1]; styleManager.ready,
}, ]).then(() => {
msg.isBgReady = true;
/** @returns {Prefs} */ msg.broadcast({method: 'backgroundReady'});
getPrefs: () => prefs.values,
setPref(key, value) {
prefs.set(key, value);
},
/**
* Opens the editor or activates an existing tab
* @param {{
id?: number
domain?: string
'url-prefix'?: string
}} params
* @returns {Promise<chrome.tabs.Tab>}
*/
openEditor(params) {
const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params);
return openURL({
url: `${u}`,
currentWindow: null,
newWindow: prefs.get('openEditInWindow') && Object.assign({},
prefs.get('openEditInWindow.popup') && {type: 'popup'},
prefs.get('windowPosition')),
});
},
/** @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}`;
}
if (options) {
url += '#stylus-options';
}
let tab = await findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true,
});
if (tab) {
await activateTab(tab);
if (url !== (tab.pendingUrl || tab.url)) {
await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error);
}
return tab;
}
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url}).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'));
}));
}
},
});
//#endregion
//#region browserCommands
const browserCommands = {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
reload: () => chrome.runtime.reload(),
};
if (chrome.commands) {
chrome.commands.onCommand.addListener(command => browserCommands[command]());
}
if (FIREFOX && browser.commands && browser.commands.update) {
// register hotkeys in FF
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) {}
}); });
}
//#endregion if (chrome.commands) {
//#region Init chrome.commands.onCommand.addListener(id => API.browserCommands[id]());
msg.on((msg, sender) => {
if (msg.method === 'invokeAPI') {
const fn = msg.path.reduce((res, name) => res && res[name], API);
if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`);
const res = fn.apply({msg, sender}, msg.args);
return res === undefined ? null : res;
} }
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason !== 'update') return;
const [a, b, c] = (previousVersion || '').split('.');
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
require(['./remove-unused-storage']);
}
});
}); });
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);
}
});
msg.broadcast({method: 'backgroundReady'});
//#endregion

View File

@ -0,0 +1,23 @@
'use strict';
/*
Registers hotkeys in FF
*/
define(require => {
const prefs = require('/js/prefs');
const hotkeyPrefs = Object.keys(prefs.defaults).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) {}
}
});

View File

@ -1,25 +1,24 @@
/* global
FIREFOX
ignoreChromeError
msg
URLS
*/
'use strict'; 'use strict';
/* /*
Reinject content scripts when the extension is reloaded/updated. Reinject content scripts when the extension is reloaded/updated.
Firefox handles this automatically. Not used in Firefox as it reinjects automatically.
*/ */
// eslint-disable-next-line no-unused-expressions define(require => {
!FIREFOX && (() => { const {
URLS,
ignoreChromeError,
} = require('/js/toolbox');
const {msg} = require('/js/msg');
const NTP = 'chrome://newtab/'; const NTP = 'chrome://newtab/';
const ALL_URLS = '<all_urls>'; const ALL_URLS = '<all_urls>';
const SCRIPTS = chrome.runtime.getManifest().content_scripts; const SCRIPTS = chrome.runtime.getManifest().content_scripts;
// expand * as .*? // expand * as .*?
const wildcardAsRegExp = (s, flags) => new RegExp( const wildcardAsRegExp = (s, flags) => new RegExp(
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&') s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
.replace(/\*/g, '.*?'), flags); .replace(/\*/g, '.*?'), flags);
for (const cs of SCRIPTS) { for (const cs of SCRIPTS) {
cs.matches = cs.matches.map(m => ( cs.matches = cs.matches.map(m => (
m === ALL_URLS ? m : wildcardAsRegExp(m) m === ALL_URLS ? m : wildcardAsRegExp(m)
@ -118,4 +117,4 @@
function onBusyTabRemoved(tabId) { function onBusyTabRemoved(tabId) {
trackBusyTab(tabId, false); trackBusyTab(tabId, false);
} }
})(); });

View File

@ -1,16 +1,15 @@
/* global
browserCommands
CHROME
FIREFOX
ignoreChromeError
msg
prefs
URLS
*/
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions define(require => {
chrome.contextMenus && (() => { const {
CHROME,
FIREFOX,
URLS,
ignoreChromeError,
} = require('/js/toolbox');
const {API, msg} = require('/js/msg');
const prefs = require('/js/prefs');
const contextMenus = { const contextMenus = {
'show-badge': { 'show-badge': {
title: 'menuShowBadge', title: 'menuShowBadge',
@ -18,20 +17,20 @@ chrome.contextMenus && (() => {
}, },
'disableAll': { 'disableAll': {
title: 'disableAllStyles', title: 'disableAllStyles',
click: browserCommands.styleDisableAll, click: API.browserCommands.styleDisableAll,
}, },
'open-manager': { 'open-manager': {
title: 'openStylesManager', title: 'openStylesManager',
click: browserCommands.openManage, click: API.browserCommands.openManage,
}, },
'open-options': { 'open-options': {
title: 'openOptions', title: 'openOptions',
click: browserCommands.openOptions, click: API.browserCommands.openOptions,
}, },
'reload': { 'reload': {
presentIf: async () => (await browser.management.getSelf()).installType === 'development', presentIf: async () => (await browser.management.getSelf()).installType === 'development',
title: 'reload', title: 'reload',
click: browserCommands.reload, click: API.browserCommands.reload,
}, },
'editor.contextDelete': { 'editor.contextDelete': {
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'), presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
@ -104,4 +103,4 @@ chrome.contextMenus && (() => {
chrome.contextMenus.remove(id, ignoreChromeError); chrome.contextMenus.remove(id, ignoreChromeError);
} }
} }
})(); });

View File

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

View File

@ -1,25 +1,25 @@
/* global chromeLocal workerUtil createChromeStorageDB */
/* exported db */
/* /*
Initialize a database. There are some problems using IndexedDB in Firefox: 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/ https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
Some of them are fixed in FF59: https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
*/ */
'use strict'; 'use strict';
const db = (() => { define(require => {
const {chromeLocal} = require('/js/storage-util');
const {cloneError} = require('/js/worker-util');
const DATABASE = 'stylish'; const DATABASE = 'stylish';
const STORE = 'styles'; const STORE = 'styles';
const FALLBACK = 'dbInChromeStorage'; const FALLBACK = 'dbInChromeStorage';
const dbApi = { const execFn = tryUsingIndexedDB().catch(useChromeStorage);
const exports = {
async exec(...args) { async exec(...args) {
dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage); return (await execFn)(...args);
return dbApi.exec(...args);
}, },
}; };
return dbApi;
async function tryUsingIndexedDB() { async function tryUsingIndexedDB() {
// we use chrome.storage.local fallback if IndexedDB doesn't save data, // we use chrome.storage.local fallback if IndexedDB doesn't save data,
@ -44,13 +44,13 @@ const db = (() => {
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
} }
function useChromeStorage(err) { async function useChromeStorage(err) {
chromeLocal.setValue(FALLBACK, true); chromeLocal.setValue(FALLBACK, true);
if (err) { 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); console.warn('Failed to access indexedDB. Switched to storage API.', err);
} }
return createChromeStorageDB().exec; return require(['./db-chrome-storage']);
} }
async function dbExecIndexedDB(method, ...args) { async function dbExecIndexedDB(method, ...args) {
@ -90,4 +90,6 @@ const db = (() => {
}); });
} }
} }
})();
return exports;
});

View File

@ -1,11 +1,44 @@
/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API */
/* exported iconManager */
'use strict'; 'use strict';
const iconManager = (() => { define(require => {
const {
FIREFOX,
VIVALDI,
CHROME,
debounce,
} = require('/js/toolbox');
const prefs = require('/js/prefs');
const {
setBadgeBackgroundColor,
setBadgeText,
setIcon,
} = require('./icon-util');
const tabManager = require('./tab-manager');
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38]; const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
const staleBadges = new Set(); const staleBadges = new Set();
let exports;
const {
updateIconBadge,
} = exports = /** @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);
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
staleBadges.add(tabId);
if (!frameId) refreshIcon(tabId, true);
},
};
prefs.subscribe([ prefs.subscribe([
'disableAll', 'disableAll',
'badgeDisabled', 'badgeDisabled',
@ -27,21 +60,7 @@ const iconManager = (() => {
refreshAllIcons(); refreshAllIcons();
}); });
Object.assign(API, { chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
/** @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);
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); if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
}); });
@ -53,13 +72,13 @@ const iconManager = (() => {
function onPortDisconnected({sender}) { function onPortDisconnected({sender}) {
if (tabManager.get(sender.tab.id, 'styleIds')) { if (tabManager.get(sender.tab.id, 'styleIds')) {
API.updateIconBadge.call({sender}, [], {lazyBadge: true}); updateIconBadge.call({sender}, [], {lazyBadge: true});
} }
} }
function refreshIconBadgeText(tabId) { function refreshIconBadgeText(tabId) {
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : ''; const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
iconUtil.setBadgeText({tabId, text}); setBadgeText({tabId, text});
} }
function getIconName(hasStyles = false) { function getIconName(hasStyles = false) {
@ -77,7 +96,7 @@ const iconManager = (() => {
return; return;
} }
tabManager.set(tabId, 'icon', newIcon); tabManager.set(tabId, 'icon', newIcon);
iconUtil.setIcon({ setIcon({
path: getIconPath(newIcon), path: getIconPath(newIcon),
tabId, tabId,
}); });
@ -102,14 +121,14 @@ const iconManager = (() => {
} }
function refreshGlobalIcon() { function refreshGlobalIcon() {
iconUtil.setIcon({ setIcon({
path: getIconPath(getIconName()), path: getIconPath(getIconName()),
}); });
} }
function refreshIconBadgeColor() { function refreshIconBadgeColor() {
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal'); const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
iconUtil.setBadgeBackgroundColor({ setBadgeBackgroundColor({
color, color,
}); });
} }
@ -133,4 +152,6 @@ const iconManager = (() => {
} }
staleBadges.clear(); staleBadges.clear();
} }
})();
return exports;
});

View File

@ -1,91 +1,73 @@
/* global ignoreChromeError */
/* exported iconUtil */
'use strict'; 'use strict';
const iconUtil = (() => { define(require => {
const canvas = document.createElement('canvas'); const {ignoreChromeError} = require('/js/toolbox');
const ctx = canvas.getContext('2d');
// https://github.com/openstyles/stylus/issues/335
let noCanvas;
const imageDataCache = new Map(); const imageDataCache = new Map();
// test if canvas is usable // https://github.com/openstyles/stylus/issues/335
const canvasReady = loadImage('/images/icon/16.png') const hasCanvas = loadImage('/images/icon/16.png')
.then(imageData => { .then(({data}) => data.some(b => b !== 255));
noCanvas = imageData.data.every(b => b === 255);
});
return extendNative({ const exports = {
/*
Cache imageData for paths
*/
setIcon,
setBadgeText,
});
function loadImage(url) { /** @param {chrome.browserAction.TabIconDetails} data */
let result = imageDataCache.get(url); async setIcon(data) {
if (!result) { if (await hasCanvas) {
result = new Promise((resolve, reject) => { data.imageData = {};
const img = new Image(); for (const [key, url] of Object.entries(data.path)) {
img.src = url; data.imageData[key] = imageDataCache.get(url) || await loadImage(url);
img.onload = () => { }
const w = canvas.width = img.width; delete data.path;
const h = canvas.height = img.height; }
ctx.clearRect(0, 0, w, h); safeCall('setIcon', data);
ctx.drawImage(img, 0, 0, w, h); },
resolve(ctx.getImageData(0, 0, w, h));
}; /** @param {chrome.browserAction.BadgeTextDetails} data */
img.onerror = reject; setBadgeText(data) {
}); safeCall('setBadgeText', data);
imageDataCache.set(url, result); },
}
/** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */
setBadgeBackgroundColor(data) {
safeCall('setBadgeBackgroundColor', data);
},
};
// 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; return result;
} }
function setIcon(data) { function safeCall(method, data) {
canvasReady.then(() => { const {browserAction = {}} = chrome;
if (noCanvas) { const fn = browserAction[method];
chrome.browserAction.setIcon(data, ignoreChromeError); if (fn) {
return; 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);
} }
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 exports;
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

@ -1,31 +1,27 @@
/* global
CHROME
FIREFOX
ignoreChromeError
msg
URLS
*/
'use strict'; 'use strict';
(() => { define(require => {
const {
CHROME,
FIREFOX,
URLS,
ignoreChromeError,
} = require('/js/toolbox');
const {msg} = require('/js/msg');
/** @type {Set<function(data: Object, type: string)>} */ /** @type {Set<function(data: Object, type: string)>} */
const listeners = new Set(); const listeners = new Set();
/** @type {NavigatorUtil} */
const navigatorUtil = window.navigatorUtil = new Proxy({ const exports = {
onUrlChange(fn) { onUrlChange(fn) {
listeners.add(fn); listeners.add(fn);
}, },
}, { };
get(target, prop) {
return target[prop] ||
(target = chrome.webNavigation[prop]).addListener.bind(target);
},
});
navigatorUtil.onCommitted(onNavigation.bind('committed')); chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed'));
navigatorUtil.onHistoryStateUpdated(onFakeNavigation.bind('history')); chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history'));
navigatorUtil.onReferenceFragmentUpdated(onFakeNavigation.bind('hash')); chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash'));
navigatorUtil.onCommitted(runGreasyforkContentScript, { chrome.webNavigation.onCommitted.addListener(runGreasyforkContentScript, {
// expose style version on greasyfork/sleazyfork 1) info page and 2) code page // expose style version on greasyfork/sleazyfork 1) info page and 2) code page
url: ['greasyfork', 'sleazyfork'].map(host => ({ url: ['greasyfork', 'sleazyfork'].map(host => ({
hostEquals: host + '.org', hostEquals: host + '.org',
@ -33,7 +29,7 @@
})), })),
}); });
if (FIREFOX) { if (FIREFOX) {
navigatorUtil.onDOMContentLoaded(runMainContentScripts, { chrome.webNavigation.onDOMContentLoaded.addListener(runMainContentScripts, {
url: [{ url: [{
urlEquals: 'about:blank', urlEquals: 'about:blank',
}], }],
@ -84,20 +80,6 @@
runAt: 'document_start', runAt: 'document_start',
}); });
} }
})();
/** return exports;
* @typedef NavigatorUtil });
* @property {NavigatorUtilEvent} onBeforeNavigate
* @property {NavigatorUtilEvent} onCommitted
* @property {NavigatorUtilEvent} onCompleted
* @property {NavigatorUtilEvent} onCreatedNavigationTarget
* @property {NavigatorUtilEvent} onDOMContentLoaded
* @property {NavigatorUtilEvent} onErrorOccurred
* @property {NavigatorUtilEvent} onHistoryStateUpdated
* @property {NavigatorUtilEvent} onReferenceFragmentUpdated
* @property {NavigatorUtilEvent} onTabReplaced
*/
/**
* @typedef {function(cb: function, filters: WebNavigationEventFilter?)} NavigatorUtilEvent
*/

View File

@ -1,7 +1,6 @@
/* global API */
'use strict'; 'use strict';
(() => { define(require => {
// begin:nanographql - Tiny graphQL client library // begin:nanographql - Tiny graphQL client library
// Author: yoshuawuyts (https://github.com/yoshuawuyts) // Author: yoshuawuyts (https://github.com/yoshuawuyts)
// License: MIT // License: MIT
@ -37,11 +36,10 @@
body: query({ body: query({
id, id,
}), }),
}) }).then(res => res.json());
.then(res => res.json());
}; };
API.openusercss = { const exports = /** @namespace API */ {
/** /**
* This function can be used to retrieve a theme object from the * This function can be used to retrieve a theme object from the
* GraphQL API, set above * GraphQL API, set above
@ -100,4 +98,6 @@
} }
`), `),
}; };
})();
return exports;
});

View File

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

View File

@ -1,74 +1,69 @@
/* global
API
debounce
stringAsRegExp
tryRegExp
usercss
*/
'use strict'; 'use strict';
(() => { define(require => {
const {
debounce,
stringAsRegExp,
tryRegExp,
} = require('/js/toolbox');
const {API} = require('/js/msg');
// toLocaleLowerCase cache, autocleared after 1 minute // toLocaleLowerCase cache, autocleared after 1 minute
const cache = new Map(); const cache = new Map();
const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl']; const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl'];
const MODES = createModes();
const extractMeta = style => const exports = /** @namespace API */ {
style.usercssData /**
? (style.sourceCode.match(usercss.RX_META) || [''])[0] * @param params
: null; * @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
async searchDB({query, mode = 'all', ids}) {
let res = [];
if (mode === 'url' && query) {
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) : createTester(query);
res = (await API.styles.getAll())
.filter(style =>
(!ids || ids.includes(style.id)) &&
(!query || modeHandler(style, test)))
.map(style => style.id);
if (cache.size) debounce(clearCache, 60e3);
}
return res;
},
};
const stripMeta = style => function createModes() {
style.usercssData return Object.assign(Object.create(null), {
? style.sourceCode.replace(usercss.RX_META, '') code: (style, test) =>
: null; style.usercssData
? test(stripMeta(style))
: searchSections(style, test, 'code'),
const MODES = Object.assign(Object.create(null), { meta: (style, test, part) =>
code: (style, test) => METAKEYS.some(key => test(style[key])) ||
style.usercssData
? test(stripMeta(style))
: searchSections(style, test, 'code'),
meta: (style, test, part) =>
METAKEYS.some(key => test(style[key])) ||
test(part === 'all' ? style.sourceCode : extractMeta(style)) || test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
searchSections(style, test, 'funcs'), searchSections(style, test, 'funcs'),
name: (style, test) => name: (style, test) =>
test(style.customName) || test(style.customName) ||
test(style.name), test(style.name),
all: (style, test) => all: (style, test) =>
MODES.meta(style, test, 'all') || MODES.meta(style, test, 'all') ||
!style.usercssData && MODES.code(style, test), !style.usercssData && MODES.code(style, test),
}); });
}
/** function createTester(query) {
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API.searchDB = async ({query, mode = 'all', ids}) => {
let res = [];
if (mode === 'url' && query) {
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 API.styles.getAll())
.filter(style =>
(!ids || ids.includes(style.id)) &&
(!query || modeHandler(style, test)))
.map(style => style.id);
if (cache.size) debounce(clearCache, 60e3);
}
return res;
};
function makeTester(query) {
const flags = `u${lower(query) === query ? 'i' : ''}`; const flags = `u${lower(query) === query ? 'i' : ''}`;
const words = query const words = query
.split(/(".*?")|\s+/) .split(/(".*?")|\s+/)
@ -105,4 +100,18 @@
function clearCache() { function clearCache() {
cache.clear(); cache.clear();
} }
})();
function extractMeta(style) {
return style.usercssData
? (style.sourceCode.match(API.usercss.rxMETA) || [''])[0]
: null;
}
function stripMeta(style) {
return style.usercssData
? style.sourceCode.replace(API.usercss.rxMETA, '')
: null;
}
return exports;
});

View File

@ -1,17 +1,3 @@
/* global
API
calcStyleDigest
createCache
db
msg
prefs
stringAsRegExp
styleCodeEmpty
styleSectionGlobal
tabManager
tryRegExp
URLS
*/
'use strict'; 'use strict';
/* /*
@ -23,10 +9,25 @@ The live preview feature relies on `runtime.connect` and `port.onDisconnect`
to cleanup the temporary code. See /edit/live-preview.js. to cleanup the temporary code. See /edit/live-preview.js.
*/ */
/* exported styleManager */ define(require => {
const styleManager = API.styles = (() => { const {
stringAsRegExp,
tryRegExp,
URLS,
} = require('/js/toolbox');
const {API, msg} = require('/js/msg');
const {
calcStyleDigest,
styleCodeEmpty,
styleSectionGlobal,
} = require('/js/sections-util');
const createCache = require('/js/cache');
const prefs = require('/js/prefs');
const db = require('./db');
const tabManager = require('./tab-manager');
//#region Declarations //#region Declarations
const ready = init(); const ready = init();
/** /**
* @typedef StyleMapData * @typedef StyleMapData
@ -40,7 +41,7 @@ const styleManager = API.styles = (() => {
/** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */ /** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */
/** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */ /** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */
const cachedStyleForUrl = createCache({ const cachedStyleForUrl = createCache({
onDeleted: (url, cache) => { onDeleted(url, cache) {
for (const section of Object.values(cache.sections)) { for (const section of Object.values(cache.sections)) {
const data = id2data(section.id); const data = id2data(section.id);
if (data) data.appliesTo.delete(url); if (data) data.appliesTo.delete(url);
@ -51,36 +52,30 @@ const styleManager = API.styles = (() => {
const compileRe = createCompiler(text => `^(${text})$`); const compileRe = createCompiler(text => `^(${text})$`);
const compileSloppyRe = createCompiler(text => `^${text}$`); const compileSloppyRe = createCompiler(text => `^${text}$`);
const compileExclusion = createCompiler(buildExclusion); const compileExclusion = createCompiler(buildExclusion);
const DUMMY_URL = {
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
password: '',
pathname: '',
port: '',
protocol: '',
search: '',
searchParams: new URLSearchParams(),
username: '',
};
const MISSING_PROPS = { const MISSING_PROPS = {
name: style => `ID: ${style.id}`, name: style => `ID: ${style.id}`,
_id: () => uuidv4(), _id: () => uuidv4(),
_rev: () => Date.now(), _rev: () => Date.now(),
}; };
const DELETE_IF_NULL = ['id', 'customName']; const DELETE_IF_NULL = ['id', 'customName'];
//#endregion
chrome.runtime.onConnect.addListener(handleLivePreview); chrome.runtime.onConnect.addListener(handleLivePreview);
//#region Public surface //#endregion
//#region Exports
// Sorted alphabetically /** @type {StyleManager} */
return { const styleManager = /** @namespace StyleManager */ {
compareRevision, /* props first,
then method shorthands if any,
then inlined methods sorted alphabetically */
ready,
compareRevision(rev1, rev2) { // TODO: move somewhere else so it doesn't pollute API
return rev1 - rev2;
},
/** @returns {Promise<number>} style id */ /** @returns {Promise<number>} style id */
async delete(id, reason) { async delete(id, reason) {
@ -108,9 +103,9 @@ const styleManager = API.styles = (() => {
await ready; await ready;
const id = uuidIndex.get(_id); const id = uuidIndex.get(_id);
const oldDoc = id && id2style(id); const oldDoc = id && id2style(id);
if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) { if (oldDoc && styleManager.compareRevision(oldDoc._rev, rev) <= 0) {
// FIXME: does it make sense to set reason to 'sync' in deleteByUUID? // FIXME: does it make sense to set reason to 'sync' in deleteByUUID?
return API.styles.delete(id, 'sync'); return styleManager.delete(id, 'sync');
} }
}, },
@ -151,7 +146,7 @@ const styleManager = API.styles = (() => {
await ready; await ready;
/* Chrome hides text frament from location.href of the page e.g. #:~:text=foo /* Chrome hides text frament from location.href of the page e.g. #:~:text=foo
so we'll use the real URL reported by webNavigation API */ so we'll use the real URL reported by webNavigation API */
const {tab, frameId} = this.sender; const {tab, frameId} = this && this.sender || {};
url = tab && tabManager.get(tab.id, 'url', frameId) || url; url = tab && tabManager.get(tab.id, 'url', frameId) || url;
let cache = cachedStyleForUrl.get(url); let cache = cachedStyleForUrl.get(url);
if (!cache) { if (!cache) {
@ -215,7 +210,7 @@ const styleManager = API.styles = (() => {
} }
} }
if (sectionMatched) { if (sectionMatched) {
result.push(/** @namespace StylesByUrlResult */{style, excluded, sloppy}); result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy});
} }
} }
return result; return result;
@ -265,7 +260,7 @@ const styleManager = API.styles = (() => {
const oldDoc = id && id2style(id); const oldDoc = id && id2style(id);
let diff = -1; let diff = -1;
if (oldDoc) { if (oldDoc) {
diff = compareRevision(oldDoc._rev, doc._rev); diff = styleManager.compareRevision(oldDoc._rev, doc._rev);
if (diff > 0) { if (diff > 0) {
API.sync.put(oldDoc._id, oldDoc._rev); API.sync.put(oldDoc._id, oldDoc._rev);
return; return;
@ -297,8 +292,8 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<?StyleObj>} */ /** @returns {Promise<?StyleObj>} */
removeInclusion: removeIncludeExclude.bind(null, 'inclusions'), removeInclusion: removeIncludeExclude.bind(null, 'inclusions'),
}; };
//#endregion
//#endregion
//#region Implementation //#region Implementation
/** @returns {StyleMapData} */ /** @returns {StyleMapData} */
@ -318,7 +313,7 @@ const styleManager = API.styles = (() => {
/** @returns {StyleObj} */ /** @returns {StyleObj} */
function createNewStyle() { function createNewStyle() {
return /** @namespace StyleObj */{ return /** @namespace StyleObj */ {
enabled: true, enabled: true,
updateUrl: null, updateUrl: null,
md5Url: null, md5Url: null,
@ -366,10 +361,6 @@ const styleManager = API.styles = (() => {
}); });
} }
function compareRevision(rev1, rev2) {
return rev1 - rev2;
}
async function addIncludeExclude(type, id, rule) { async function addIncludeExclude(type, id, rule) {
await ready; await ready;
const style = Object.assign({}, id2style(id)); const style = Object.assign({}, id2style(id));
@ -661,7 +652,20 @@ const styleManager = API.styles = (() => {
try { try {
return new URL(url); return new URL(url);
} catch (err) { } catch (err) {
return DUMMY_URL; return {
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
password: '',
pathname: '',
port: '',
protocol: '',
search: '',
searchParams: new URLSearchParams(),
username: '',
};
} }
} }
@ -677,5 +681,8 @@ const styleManager = API.styles = (() => {
function hex4dashed(num, i) { function hex4dashed(num, i) {
return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : '');
} }
//#endregion //#endregion
})();
return styleManager;
});

View File

@ -1,7 +1,10 @@
/* global API CHROME prefs */
'use strict'; 'use strict';
API.styleViaAPI = !CHROME && (() => { define(require => {
const {isEmptyObj} = require('/js/polyfill');
const {API} = require('/js/msg');
const prefs = require('/js/prefs');
const ACTIONS = { const ACTIONS = {
styleApply, styleApply,
styleDeleted, styleDeleted,
@ -11,24 +14,27 @@ API.styleViaAPI = !CHROME && (() => {
prefChanged, prefChanged,
updateCount, updateCount,
}; };
const NOP = Promise.resolve(new Error('NOP')); const NOP = new Error('NOP');
const onError = () => {}; const onError = () => {};
/* <tabId>: Object /* <tabId>: Object
<frameId>: Object <frameId>: Object
url: String, non-enumerable url: String, non-enumerable
<styleId>: Array of strings <styleId>: Array of strings
section code */ section code */
const cache = new Map(); const cache = new Map();
let observingTabs = false; let observingTabs = false;
return function (request) { const exports = /** @namespace API */ {
const action = ACTIONS[request.method]; /**
return !action ? NOP : * Uses chrome.tabs.insertCSS
action(request, this.sender) */
.catch(onError) async styleViaAPI(request) {
.then(maybeToggleObserver); try {
const fn = ACTIONS[request.method];
return fn ? fn(request, this.sender) : NOP;
} catch (e) {}
maybeToggleObserver();
},
}; };
function updateCount(request, sender) { function updateCount(request, sender) {
@ -125,7 +131,7 @@ API.styleViaAPI = !CHROME && (() => {
} }
const {tab, frameId} = sender; const {tab, frameId} = sender;
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId); const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
if (isEmpty(frameStyles)) { if (isEmptyObj(frameStyles)) {
return NOP; return NOP;
} }
removeFrameIfEmpty(tab.id, frameId, tabFrames, {}); removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
@ -162,7 +168,7 @@ API.styleViaAPI = !CHROME && (() => {
const tabFrames = cache.get(tabId); const tabFrames = cache.get(tabId);
if (tabFrames && frameId in tabFrames) { if (tabFrames && frameId in tabFrames) {
delete tabFrames[frameId]; delete tabFrames[frameId];
if (isEmpty(tabFrames)) { if (isEmptyObj(tabFrames)) {
onTabRemoved(tabId); onTabRemoved(tabId);
} }
} }
@ -178,9 +184,9 @@ API.styleViaAPI = !CHROME && (() => {
} }
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) { function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
if (isEmpty(frameStyles)) { if (isEmptyObj(frameStyles)) {
delete tabFrames[frameId]; delete tabFrames[frameId];
if (isEmpty(tabFrames)) { if (isEmptyObj(tabFrames)) {
cache.delete(tabId); cache.delete(tabId);
} }
return true; return true;
@ -224,10 +230,5 @@ API.styleViaAPI = !CHROME && (() => {
.catch(onError); .catch(onError);
} }
function isEmpty(obj) { return exports;
for (const k in obj) { });
return false;
}
return true;
}
})();

View File

@ -1,12 +1,10 @@
/* global
API
CHROME
prefs
*/
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions define(async require => {
CHROME && (async () => { const {API} = require('/js/msg');
const {isEmptyObj} = require('/js/polyfill');
const prefs = require('/js/prefs');
const idCSP = 'patchCsp'; const idCSP = 'patchCsp';
const idOFF = 'disableAll'; const idOFF = 'disableAll';
const idXHR = 'styleViaXhr'; const idXHR = 'styleViaXhr';
@ -16,7 +14,7 @@ CHROME && (async () => {
const enabled = {}; const enabled = {};
await prefs.initializing; await prefs.initializing;
prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true}); prefs.subscribe([idXHR, idOFF, idCSP], toggle, {runNow: true});
function toggle() { function toggle() {
const csp = prefs.get(idCSP) && !prefs.get(idOFF); const csp = prefs.get(idCSP) && !prefs.get(idOFF);
@ -73,14 +71,17 @@ CHROME && (async () => {
/** @param {chrome.webRequest.WebRequestBodyDetails} req */ /** @param {chrome.webRequest.WebRequestBodyDetails} req */
async function prepareStyles(req) { async function prepareStyles(req) {
const sections = await API.styles.getSectionsByUrl(req.url); const sections = await API.styles.getSectionsByUrl(req.url);
if (Object.keys(sections).length) { if (!isEmptyObj(sections)) {
stylesToPass[req.requestId] = !enabled.xhr ? true : stylesToPass[req.requestId] = !enabled.xhr || makeObjectUrl(sections);
URL.createObjectURL(new Blob([JSON.stringify(sections)]))
.slice(blobUrlPrefix.length);
setTimeout(cleanUp, 600e3, req.requestId); setTimeout(cleanUp, 600e3, req.requestId);
} }
} }
function makeObjectUrl(sections) {
const blob = new Blob([JSON.stringify(sections)]);
return URL.createObjectURL(blob).slice(blobUrlPrefix.length);
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */ /** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function modifyHeaders(req) { function modifyHeaders(req) {
const {responseHeaders} = req; const {responseHeaders} = req;
@ -115,7 +116,7 @@ CHROME && (async () => {
patchCspSrc(src, 'img-src', 'data:', '*'); patchCspSrc(src, 'img-src', 'data:', '*');
patchCspSrc(src, 'font-src', 'data:', '*'); patchCspSrc(src, 'font-src', 'data:', '*');
// Allow our DOM styles // 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) // Allow our XHR cookies in CSP sandbox (known case: raw github urls)
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) { if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
src.sandbox.push('allow-same-origin'); src.sandbox.push('allow-same-origin');
@ -141,4 +142,4 @@ CHROME && (async () => {
delete stylesToPass[key]; delete stylesToPass[key];
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId); if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
} }
})(); });

View File

@ -1,24 +1,29 @@
/* global
API
chromeLocal
dbToCloud
msg
prefs
styleManager
tokenManager
*/
/* exported sync */
'use strict'; 'use strict';
const sync = API.sync = (() => { define(require => {
const {API, msg} = require('/js/msg');
const {chromeLocal} = require('/js/storage-util');
const prefs = require('/js/prefs');
const {compareRevision} = require('./style-manager');
const tokenManager = require('./token-manager');
/** @type Sync */
let sync;
//#region Init
const SYNC_DELAY = 1; // minutes const SYNC_DELAY = 1; // minutes
const SYNC_INTERVAL = 30; // minutes const SYNC_INTERVAL = 30; // minutes
const STATES = Object.freeze({
/** @typedef API.sync.Status */ connected: 'connected',
const status = { connecting: 'connecting',
/** @type {'connected'|'connecting'|'disconnected'|'disconnecting'} */ disconnected: 'disconnected',
state: 'disconnected', disconnecting: 'disconnecting',
});
const STORAGE_KEY = 'sync/state/';
const status = /** @namespace Sync.Status */ {
STATES,
state: STATES.disconnected,
syncing: false, syncing: false,
progress: null, progress: null,
currentDriveName: null, currentDriveName: null,
@ -26,51 +31,14 @@ const sync = API.sync = (() => {
login: false, login: false,
}; };
let currentDrive; let currentDrive;
const ctrl = dbToCloud.dbToCloud({ let ctrl;
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(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 ready = prefs.initializing.then(() => { const ready = prefs.initializing.then(() => {
prefs.subscribe('sync.enabled', prefs.subscribe('sync.enabled',
(_, val) => val === 'none' (_, val) => val === 'none'
? sync.stop() ? sync.stop()
: sync.start(val, true), : sync.start(val, true),
{now: true}); {runNow: true});
}); });
chrome.alarms.onAlarm.addListener(info => { chrome.alarms.onAlarm.addListener(info => {
@ -79,8 +47,12 @@ const sync = API.sync = (() => {
} }
}); });
// Sorted alphabetically //#endregion
return { //#region Exports
sync = /** @namespace Sync */ {
// sorted alphabetically
async delete(...args) { async delete(...args) {
await ready; await ready;
@ -89,9 +61,7 @@ const sync = API.sync = (() => {
return ctrl.delete(...args); return ctrl.delete(...args);
}, },
/** /** @returns {Promise<Sync.Status>} */
* @returns {Promise<API.sync.Status>}
*/
async getStatus() { async getStatus() {
return status; return status;
}, },
@ -124,8 +94,9 @@ const sync = API.sync = (() => {
return; return;
} }
currentDrive = getDrive(name); currentDrive = getDrive(name);
if (!ctrl) await initController();
ctrl.use(currentDrive); ctrl.use(currentDrive);
status.state = 'connecting'; status.state = STATES.connecting;
status.currentDriveName = currentDrive.name; status.currentDriveName = currentDrive.name;
status.login = true; status.login = true;
emitStatusChange(); emitStatusChange();
@ -144,7 +115,7 @@ const sync = API.sync = (() => {
} }
} }
prefs.set('sync.enabled', name); prefs.set('sync.enabled', name);
status.state = 'connected'; status.state = STATES.connected;
schedule(SYNC_INTERVAL); schedule(SYNC_INTERVAL);
emitStatusChange(); emitStatusChange();
}, },
@ -155,17 +126,16 @@ const sync = API.sync = (() => {
return; return;
} }
chrome.alarms.clear('syncNow'); chrome.alarms.clear('syncNow');
status.state = 'disconnecting'; status.state = STATES.disconnecting;
emitStatusChange(); emitStatusChange();
try { try {
await ctrl.stop(); await ctrl.stop();
await tokenManager.revokeToken(currentDrive.name); await tokenManager.revokeToken(currentDrive.name);
await chromeLocal.remove(`sync/state/${currentDrive.name}`); await chromeLocal.remove(STORAGE_KEY + currentDrive.name);
} catch (e) { } catch (e) {}
}
currentDrive = null; currentDrive = null;
prefs.set('sync.enabled', 'none'); prefs.set('sync.enabled', 'none');
status.state = 'disconnected'; status.state = STATES.disconnected;
status.currentDriveName = null; status.currentDriveName = null;
status.login = false; status.login = false;
emitStatusChange(); emitStatusChange();
@ -186,6 +156,47 @@ const sync = API.sync = (() => {
}, },
}; };
//#endregion
//#region Utils
async function initController() {
await require(['js!/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);
},
});
}
function schedule(delay = SYNC_DELAY) { function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', { chrome.alarms.create('syncNow', {
delayInMinutes: delay, delayInMinutes: delay,
@ -220,4 +231,8 @@ const sync = API.sync = (() => {
} }
throw new Error(`unknown cloud name: ${name}`); throw new Error(`unknown cloud name: ${name}`);
} }
})();
//#endregion
return sync;
});

View File

@ -1,32 +1,23 @@
/* global navigatorUtil */
/* exported tabManager */
'use strict'; 'use strict';
const tabManager = (() => { define(require => {
const listeners = []; const navigatorUtil = require('./navigator-util');
const listeners = new Set();
const cache = new Map(); const cache = new Map();
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId)); chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed)); chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
navigatorUtil.onUrlChange(({tabId, frameId, url}) => { navigatorUtil.onUrlChange(notify);
const oldUrl = !frameId && tabManager.get(tabId, 'url', frameId);
tabManager.set(tabId, 'url', frameId, url);
if (frameId) return;
for (const fn of listeners) {
try {
fn({tabId, url, oldUrl});
} catch (err) {
console.error(err);
}
}
});
return { const tabManager = {
onUpdate(fn) { onUpdate(fn) {
listeners.push(fn); listeners.add(fn);
}, },
get(tabId, ...keys) { get(tabId, ...keys) {
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId)); 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 * 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}, * (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
@ -47,8 +38,24 @@ const tabManager = (() => {
meta[lastKey] = value; meta[lastKey] = value;
} }
}, },
list() { list() {
return cache.keys(); return cache.keys();
}, },
}; };
})();
function notify({tabId, frameId, url}) {
const oldUrl = !frameId && tabManager.get(tabId, 'url', frameId);
tabManager.set(tabId, 'url', frameId, url);
if (frameId) return;
for (const fn of listeners) {
try {
fn({tabId, url, oldUrl});
} catch (err) {
console.error(err);
}
}
}
return tabManager;
});

View File

@ -1,113 +1,125 @@
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
/* exported tokenManager */
'use strict'; 'use strict';
const tokenManager = (() => { define(require => {
const AUTH = { const {FIREFOX} = require('/js/toolbox');
dropbox: { const {chromeLocal} = require('/js/storage-util');
flow: 'token',
clientId: 'zg52vphuapvpng9', const AUTH = createAuth();
authURL: 'https://www.dropbox.com/oauth2/authorize',
tokenURL: 'https://api.dropboxapi.com/oauth2/token',
revoke: token =>
fetch('https://api.dropboxapi.com/2/auth/token/revoke', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
}),
},
google: {
flow: 'code',
clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com',
clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf',
authURL: 'https://accounts.google.com/o/oauth2/v2/auth',
authQuery: {
// NOTE: Google needs 'prompt' parameter to deliver multiple refresh
// tokens for multiple machines.
// https://stackoverflow.com/q/18519185
access_type: 'offline',
prompt: 'consent',
},
tokenURL: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
revoke: token => {
const params = {token};
return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
},
},
onedrive: {
flow: 'code',
clientId: '3864ce03-867c-4ad8-9856-371a097d47b1',
clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w',
authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
redirect_uri: FIREFOX ?
'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' :
'https://' + location.hostname + '.chromiumapp.org/',
scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
},
};
const NETWORK_LATENCY = 30; // seconds const NETWORK_LATENCY = 30; // seconds
return {getToken, revokeToken, getClientId, buildKeys}; let exports;
const {
function getClientId(name) { buildKeys,
return AUTH[name].clientId;
}
function buildKeys(name) { } = exports = {
const k = {
TOKEN: `secure/token/${name}/token`,
EXPIRE: `secure/token/${name}/expire`,
REFRESH: `secure/token/${name}/refresh`,
};
k.LIST = Object.values(k);
return k;
}
function getToken(name, interactive) { buildKeys(name) {
const k = buildKeys(name); const k = {
return chromeLocal.get(k.LIST) TOKEN: `secure/token/${name}/token`,
.then(obj => { EXPIRE: `secure/token/${name}/expire`,
if (!obj[k.TOKEN]) { REFRESH: `secure/token/${name}/refresh`,
};
k.LIST = Object.values(k);
return k;
},
getClientId(name) {
return AUTH[name].clientId;
},
getToken(name, interactive) {
const k = buildKeys(name);
return chromeLocal.get(k.LIST)
.then(obj => {
if (!obj[k.TOKEN]) {
return authUser(name, k, interactive);
}
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);
}
throw err;
});
}
return authUser(name, k, interactive); return authUser(name, k, interactive);
} });
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);
}
throw err;
});
}
return authUser(name, k, interactive);
});
}
async function revokeToken(name) { async revokeToken(name) {
const provider = AUTH[name]; const provider = AUTH[name];
const k = buildKeys(name); const k = buildKeys(name);
if (provider.revoke) { if (provider.revoke) {
try { try {
const token = await chromeLocal.getValue(k.TOKEN); const token = await chromeLocal.getValue(k.TOKEN);
if (token) { if (token) {
await provider.revoke(token); await provider.revoke(token);
}
} catch (e) {
console.error(e);
} }
} catch (e) {
console.error(e);
} }
} await chromeLocal.remove(k.LIST);
await chromeLocal.remove(k.LIST); },
};
function createAuth() {
return {
dropbox: {
flow: 'token',
clientId: 'zg52vphuapvpng9',
authURL: 'https://www.dropbox.com/oauth2/authorize',
tokenURL: 'https://api.dropboxapi.com/oauth2/token',
revoke: token =>
fetch('https://api.dropboxapi.com/2/auth/token/revoke', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
}),
},
google: {
flow: 'code',
clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com',
clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf',
authURL: 'https://accounts.google.com/o/oauth2/v2/auth',
authQuery: {
// NOTE: Google needs 'prompt' parameter to deliver multiple refresh
// tokens for multiple machines.
// https://stackoverflow.com/q/18519185
access_type: 'offline',
prompt: 'consent',
},
tokenURL: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
revoke: token => {
const params = {token};
return postQuery(
`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
},
},
onedrive: {
flow: 'code',
clientId: '3864ce03-867c-4ad8-9856-371a097d47b1',
clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w',
authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
redirect_uri: FIREFOX ?
'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' :
'https://' + location.hostname + '.chromiumapp.org/',
scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
},
};
} }
function refreshToken(name, k, obj) { async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) { if (!obj[k.REFRESH]) {
return Promise.reject(new Error('no refresh token')); throw new Error('No refresh token');
} }
const provider = AUTH[name]; const provider = AUTH[name];
const body = { const body = {
@ -119,17 +131,17 @@ const tokenManager = (() => {
if (provider.clientSecret) { if (provider.clientSecret) {
body.client_secret = provider.clientSecret; body.client_secret = provider.clientSecret;
} }
return postQuery(provider.tokenURL, body) const result = await postQuery(provider.tokenURL, body);
.then(result => { if (!result.refresh_token) {
if (!result.refresh_token) { // reuse old refresh token
// reuse old refresh token result.refresh_token = obj[k.REFRESH];
result.refresh_token = obj[k.REFRESH]; }
} return handleTokenResult(result, k);
return handleTokenResult(result, k);
});
} }
function authUser(name, k, interactive = false) { async function authUser(name, k, interactive = false) {
await require(['js!/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
/* global webextLaunchWebAuthFlow */
const provider = AUTH[name]; const provider = AUTH[name];
const state = Math.random().toFixed(8).slice(2); const state = Math.random().toFixed(8).slice(2);
const query = { const query = {
@ -145,52 +157,54 @@ const tokenManager = (() => {
Object.assign(query, provider.authQuery); Object.assign(query, provider.authQuery);
} }
const url = `${provider.authURL}?${new URLSearchParams(query)}`; const url = `${provider.authURL}?${new URLSearchParams(query)}`;
return webextLaunchWebAuthFlow({ const finalUrl = await webextLaunchWebAuthFlow({
url, url,
interactive, interactive,
redirect_uri: query.redirect_uri, redirect_uri: query.redirect_uri,
}) });
.then(url => { const params = new URLSearchParams(
const params = new URLSearchParams( provider.flow === 'token' ?
provider.flow === 'token' ? new URL(finalUrl).hash.slice(1) :
new URL(url).hash.slice(1) : new URL(finalUrl).search.slice(1)
new URL(url).search.slice(1) );
); if (params.get('state') !== state) {
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') { if (provider.flow === 'token') {
const obj = {}; const obj = {};
for (const [key, value] of params.entries()) { for (const [key, value] of params) {
obj[key] = value; obj[key] = value;
} }
return obj; result = obj;
} } else {
const code = params.get('code'); const code = params.get('code');
const body = { const body = {
code, code,
grant_type: 'authorization_code', grant_type: 'authorization_code',
client_id: provider.clientId, client_id: provider.clientId,
redirect_uri: query.redirect_uri, redirect_uri: query.redirect_uri,
}; };
if (provider.clientSecret) { if (provider.clientSecret) {
body.client_secret = provider.clientSecret; body.client_secret = provider.clientSecret;
} }
return postQuery(provider.tokenURL, body); result = await postQuery(provider.tokenURL, body);
}) }
.then(result => handleTokenResult(result, k)); return handleTokenResult(result, k);
} }
function handleTokenResult(result, k) { async function handleTokenResult(result, k) {
return chromeLocal.set({ await chromeLocal.set({
[k.TOKEN]: result.access_token, [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, [k.REFRESH]: result.refresh_token,
}) });
.then(() => result.access_token); return result.access_token;
} }
function postQuery(url, body) { async function postQuery(url, body) {
const options = { const options = {
method: 'POST', method: 'POST',
headers: { headers: {
@ -198,17 +212,15 @@ const tokenManager = (() => {
}, },
body: body ? new URLSearchParams(body) : null, body: body ? new URLSearchParams(body) : null,
}; };
return fetch(url, options) const r = await fetch(url, options);
.then(r => { if (r.ok) {
if (r.ok) { return r.json();
return r.json(); }
} const text = await r.text();
return r.text() const err = new Error(`Failed to fetch (${r.status}): ${text}`);
.then(body => { err.code = r.status;
const err = new Error(`failed to fetch (${r.status}): ${body}`); throw err;
err.code = r.status;
throw err;
});
});
} }
})();
return exports;
});

View File

@ -1,20 +1,21 @@
/* global
API
calcStyleDigest
chromeLocal
debounce
download
ignoreChromeError
prefs
semverCompare
styleJSONseemsValid
styleSectionsEqual
usercss
*/
'use strict'; 'use strict';
(() => { define(require => {
const STATES = /** @namespace UpdaterStates */{ const {API} = require('/js/msg');
const {
debounce,
download,
ignoreChromeError,
} = require('/js/toolbox');
const {
calcStyleDigest,
styleJSONseemsValid,
styleSectionsEqual,
} = require('/js/sections-util');
const {chromeLocal} = require('/js/storage-util');
const prefs = require('/js/prefs');
const STATES = /** @namespace UpdaterStates */ {
UPDATED: 'updated', UPDATED: 'updated',
SKIPPED: 'skipped', SKIPPED: 'skipped',
UNREACHABLE: 'server unreachable', UNREACHABLE: 'server unreachable',
@ -28,6 +29,7 @@
ERROR_JSON: 'error: JSON is invalid', ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style', ERROR_VERSION: 'error: version is older than installed style',
}; };
const ALARM_NAME = 'scheduledUpdate'; const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3; const MIN_INTERVAL_MS = 60e3;
const RETRY_ERRORS = [ const RETRY_ERRORS = [
@ -39,173 +41,174 @@
let logQueue = []; let logQueue = [];
let logLastWriteTime = 0; let logLastWriteTime = 0;
API.updater = {
checkAllStyles,
checkStyle,
getStates: () => STATES,
};
chromeLocal.getValue('lastUpdateTime').then(val => { chromeLocal.getValue('lastUpdateTime').then(val => {
lastUpdateTime = val || Date.now(); lastUpdateTime = val || Date.now();
prefs.subscribe('updateInterval', schedule, {now: true}); prefs.subscribe('updateInterval', schedule, {runNow: true});
chrome.alarms.onAlarm.addListener(onAlarm); chrome.alarms.onAlarm.addListener(onAlarm);
}); });
async function checkAllStyles({ /** @type {StyleUpdater} */
save = true, const updater = /** @namespace StyleUpdater */ {
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;
}
/** async checkAllStyles({
* @param {{ save = true,
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, ignoreDigest,
port, observe,
save, } = {}) {
} = opts; resetInterval();
const ucd = style.usercssData; checkingAll = true;
let res, state; const port = observe && chrome.runtime.connect({name: 'updater'});
try { const styles = (await API.styles.getAll())
await checkIfEdited(); .filter(style => style.updateUrl);
res = { if (port) port.postMessage({count: styles.length});
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave), log('');
updated: true, log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
}; await Promise.all(
state = STATES.UPDATED; styles.map(style =>
} catch (err) { updater.checkStyle({style, port, save, ignoreDigest})));
const error = err === 0 && STATES.UNREACHABLE || if (port) port.postMessage({done: true});
err && err.message || if (port) port.disconnect();
err; log('');
res = {error, style, STATES}; checkingAll = false;
state = `${STATES.SKIPPED} (${error})`; },
}
log(`${state} #${style.id} ${style.customName || style.name}`);
if (port) port.postMessage(res);
return res;
async function checkIfEdited() { /**
if (!ignoreDigest && * @param {{
style.originalDigest && id?: number
style.originalDigest !== await calcStyleDigest(style)) { style?: StyleObj
return Promise.reject(STATES.EDITED); port?: chrome.runtime.Port
} save?: boolean = true
} ignoreDigest?: boolean
}} opts
* @returns {{
style: StyleObj
updated?: boolean
error?: any
STATES: UpdaterStates
}}
async function updateUSO() { Original style digests are calculated in these cases:
const md5 = await tryDownload(style.md5Url); * style is installed or updated from server
if (!md5 || md5.length !== 32) { * non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
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() { Update check proceeds in these cases:
// TODO: when sourceCode is > 100kB use http range request(s) for version check * style has the original digest and it's equal to the current digest
const text = await tryDownload(style.updateUrl); * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
const json = await usercss.buildMeta(text); * [ignoreDigest: none/false] style doesn't yet have the original digest
const delta = semverCompare(json.usercssData.version, ucd.version); so we compare the code to the server code and if it's the same we save the digest,
if (!delta && !ignoreDigest) { otherwise we skip the style and report MAYBE_EDITED status
// 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 usercss.buildCode(json);
}
async function maybeSave(json) { 'ignoreDigest' option is set on the second manual individual update check on the manage page.
json.id = style.id; */
json.updateDate = Date.now(); async checkStyle(opts) {
// keep current state const {
delete json.customName; id,
delete json.enabled; style = await API.styles.get(id),
const newStyle = Object.assign({}, style, json); ignoreDigest,
// update digest even if save === false as there might be just a space added etc. port,
if (!ucd && styleSectionsEqual(json, style)) { save,
style.originalDigest = (await API.styles.install(newStyle)).originalDigest; } = opts;
return Promise.reject(STATES.SAME_CODE); 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})`;
} }
if (!style.originalDigest && !ignoreDigest) { log(`${state} #${style.id} ${style.customName || style.name}`);
return Promise.reject(STATES.MAYBE_EDITED); if (port) port.postMessage(res);
} return res;
return !save ? newStyle :
(ucd ? API.usercss : API.styles).install(newStyle);
}
async function tryDownload(url, params) { async function checkIfEdited() {
let {retryDelay = 1000} = opts; if (!ignoreDigest &&
while (true) { style.originalDigest &&
try { style.originalDigest !== await calcStyleDigest(style)) {
return await download(url, params); return Promise.reject(STATES.EDITED);
} 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));
} }
}
} 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(['js!/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));
}
}
},
getStates: () => STATES,
};
function schedule() { function schedule() {
const interval = prefs.get('updateInterval') * 60 * 60 * 1000; const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
@ -220,7 +223,7 @@
} }
function onAlarm({name}) { function onAlarm({name}) {
if (name === ALARM_NAME) checkAllStyles(); if (name === ALARM_NAME) updater.checkAllStyles();
} }
function resetInterval() { function resetInterval() {
@ -253,4 +256,6 @@
logLastWriteTime = Date.now(); logLastWriteTime = Date.now();
logQueue = []; logQueue = [];
} }
})();
return updater;
});

View File

@ -1,81 +1,161 @@
/* global
API
deepCopy
usercss
*/
'use strict'; 'use strict';
API.usercss = { define(require => {
const {API} = require('/js/msg');
const {deepCopy, download} = require('/js/toolbox');
async build({ const GLOBAL_METAS = {
styleId, author: undefined,
sourceCode, description: undefined,
vars, homepageURL: 'url',
checkDup, updateURL: 'updateUrl',
metaOnly, name: undefined,
assignVars, };
}) { const ERR_ARGS_IS_LIST = [
let style = await usercss.buildMeta(sourceCode); 'missingMandatory',
const dup = (checkDup || assignVars) && 'missingChar',
await API.usercss.find(styleId ? {id: styleId} : style); ];
if (!metaOnly) {
if (vars || assignVars) { const usercss = /** @namespace UsercssHelper */ {
await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup);
rxMETA: /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i,
async assignVars(style, oldStyle) {
const vars = style.usercssData.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;
}
style.usercssData.vars = await API.worker.nullifyInvalidVars(vars);
} }
style = await usercss.buildCode(style); },
}
return {style, dup};
},
async buildMeta(style) { async build({
if (style.usercssData) { 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 usercss.buildMeta({sourceCode});
const dup = (checkDup || assignVars) &&
await usercss.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
await usercss.buildCode(style);
}
return {style, dup};
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(usercss.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; return style;
} },
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return Object.assign(await usercss.buildMeta(sourceCode), style);
},
async configVars(id, vars) { async buildMeta(style) {
let style = deepCopy(await API.styles.get(id)); if (style.usercssData) {
style.usercssData.vars = vars; return style;
style = await usercss.buildCode(style);
style = await API.styles.install(style, 'config');
return style.usercssData.vars;
},
async editSave(style) {
return API.styles.editSave(await API.usercss.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;
} }
} // 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(usercss.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, value] of Object.entries(GLOBAL_METAS)) {
if (metadata[key] !== undefined) {
style[value || key] = metadata[key];
}
}
return style;
} catch (err) {
if (err.code) {
const args = ERR_ARGS_IS_LIST.includes(err.code)
? 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 install(style) { async configVars(id, vars) {
return API.styles.install(await API.usercss.parse(style)); let style = deepCopy(await API.styles.get(id));
}, style.usercssData.vars = vars;
await usercss.buildCode(style);
style = await API.styles.install(style, 'config');
return style.usercssData.vars;
},
async parse(style) { async editSave(style) {
style = await API.usercss.buildMeta(style); return API.styles.editSave(await usercss.parse(style));
// preserve style.vars during update },
const dup = await API.usercss.find(style);
if (dup) { async find(styleOrData) {
style.id = dup.id; if (styleOrData.id) {
await usercss.assignVars(style, dup); return API.styles.get(styleOrData.id);
} }
return usercss.buildCode(style); 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 usercss.parse(style));
},
async parse(style) {
style = await usercss.buildMeta(style);
// preserve style.vars during update
const dup = await usercss.find(style);
if (dup) {
style.id = dup.id;
await usercss.assignVars(style, dup);
}
return usercss.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, ' ');
}
return usercss;
});

View File

@ -1,36 +1,24 @@
/* global
API
download
openURL
tabManager
URLS
*/
'use strict'; 'use strict';
(() => { define(require => {
const {
URLS,
download,
openURL,
} = require('/js/toolbox');
const tabManager = require('./tab-manager');
const installCodeCache = {}; 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 exports = /** @namespace UsercssHelper */ {
const fileLoader = !chrome.app && (
async tabId =>
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
const urlLoader = getInstallCode(url) {
async (tabId, url) => ( // when the installer tab is reloaded after the cache is expired, this will throw intentionally
url.startsWith('file:') || const {code, timer} = installCodeCache[url];
tabManager.get(tabId, isContentTypeText.name) || clearInstallCode(url);
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type')) clearTimeout(timer);
) && download(url); return code;
},
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 // `glob`: pathname match pattern for webRequest
@ -48,17 +36,7 @@
}, },
}; };
// Faster installation on known distribution sites to avoid flicker of css text chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
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
}
}, {
urls: [ urls: [
URLS.usoArchiveRaw + 'usercss/*.user.css', URLS.usoArchiveRaw + 'usercss/*.user.css',
'*://greasyfork.org/scripts/*/code/*.user.css', '*://greasyfork.org/scripts/*/code/*.user.css',
@ -70,27 +48,63 @@
types: ['main_frame'], types: ['main_frame'],
}, ['blocking']); }, ['blocking']);
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
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);
}, {
urls: makeUsercssGlobs('*', '/*'), urls: makeUsercssGlobs('*', '/*'),
types: ['main_frame'], types: ['main_frame'],
}, ['responseHeaders']); }, ['responseHeaders']);
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => { tabManager.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:') ||
tabManager.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.') && if (url.includes('.user.') &&
/^(https?|file|ftps?):/.test(url) && /^(https?|file|ftps?):/.test(url) &&
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) && /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
!oldUrl.startsWith(URLS.installUsercss)) { !oldUrl.startsWith(URLS.installUsercss)) {
const inTab = url.startsWith('file:') && Boolean(fileLoader); const inTab = url.startsWith('file:') && !chrome.app;
const code = await (inTab ? fileLoader : urlLoader)(tabId, url); const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) { if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) {
openInstallerPage(tabId, url, {code, inTab}); 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} = {}) { function openInstallerPage(tabId, url, {code, inTab} = {}) {
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`; const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
@ -110,7 +124,11 @@
} }
} }
function makeUsercssGlobs(host, path) { /** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(','); function rememberContentType({tabId, responseHeaders}) {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
} }
})();
return exports;
});

View File

@ -1,18 +1,14 @@
/* global msg API prefs createStyleInjector */
'use strict'; 'use strict';
// Chrome reruns content script when documentElement is replaced. define(require => {
// Note, we're checking against a literal `1`, not just `if (truthy)`, const {API, msg} = require('/js/msg');
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`. const prefs = require('/js/prefs');
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
self.INJECTED = 1;
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html'; let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
const IS_FRAME = window !== parent; const IS_FRAME = window !== parent;
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
const styleInjector = createStyleInjector({ /** @type {StyleInjector} */
const styleInjector = require('/content/style-injector')({
compare: (a, b) => a.id - b.id, compare: (a, b) => a.id - b.id,
onUpdate: onInjectorUpdate, onUpdate: onInjectorUpdate,
}); });
@ -210,4 +206,4 @@ self.INJECTED !== 1 && (() => {
msg.off(applyOnMessage); msg.off(applyOnMessage);
} catch (e) {} } catch (e) {}
} }
})(); });

View File

@ -1,21 +1,14 @@
/* global API */
'use strict'; 'use strict';
// onCommitted may fire twice addEventListener('message', async function onMessage(e) {
// Note, we're checking against a literal `1`, not just `if (truthy)`, if (e.origin === location.origin &&
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`. e.data &&
e.data.name &&
if (window.INJECTED_GREASYFORK !== 1) { e.data.type === 'style-version-query') {
window.INJECTED_GREASYFORK = 1; removeEventListener('message', onMessage);
addEventListener('message', async function onMessage(e) { const {API} = self.require('/js/msg');
if (e.origin === location.origin && const style = await API.usercss.find(e.data) || {};
e.data && const {version} = style.usercssData || {};
e.data.name && postMessage({type: 'style-version', version}, '*');
e.data.type === 'style-version-query') { }
removeEventListener('message', onMessage); });
const style = await API.usercss.find(e.data) || {};
const {version} = style.usercssData || {};
postMessage({type: 'style-version', version}, '*');
}
});
}

View File

@ -1,7 +1,8 @@
/* global API */
'use strict'; 'use strict';
(() => { define(require => {
const {API} = require('/js/msg');
const manifest = chrome.runtime.getManifest(); const manifest = chrome.runtime.getManifest();
const allowedOrigins = [ const allowedOrigins = [
'https://openusercss.org', 'https://openusercss.org',
@ -55,7 +56,7 @@
window.addEventListener('message', installedHandler); window.addEventListener('message', installedHandler);
}; };
const doHandshake = () => { const doHandshake = event => {
// This is a representation of features that Stylus is capable of // This is a representation of features that Stylus is capable of
const implementedFeatures = [ const implementedFeatures = [
'install-usercss', 'install-usercss',
@ -106,7 +107,7 @@
&& event.data.type === 'ouc-handshake-question' && event.data.type === 'ouc-handshake-question'
&& allowedOrigins.includes(event.origin) && allowedOrigins.includes(event.origin)
) { ) {
doHandshake(); doHandshake(event);
} }
}; };
@ -171,4 +172,4 @@
attachInstallListeners(); attachInstallListeners();
attachInstalledListeners(); attachInstalledListeners();
askHandshake(); askHandshake();
})(); });

View File

@ -1,8 +1,10 @@
/* global cloneInto msg API */
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions // eslint-disable-next-line no-unused-expressions
/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (() => { /^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) &&
define(require => {
const {API, msg} = require('/js/msg');
const styleId = RegExp.$1; const styleId = RegExp.$1;
const pageEventId = `${performance.now()}${Math.random()}`; const pageEventId = `${performance.now()}${Math.random()}`;
@ -119,7 +121,7 @@
if (typeof cloneInto !== 'undefined') { if (typeof cloneInto !== 'undefined') {
// Firefox requires explicit cloning, however USO can't process our messages anyway // Firefox requires explicit cloning, however USO can't process our messages anyway
// because USO tries to use a global "event" variable deprecated in Firefox // because USO tries to use a global "event" variable deprecated in Firefox
detail = cloneInto({detail}, document); detail = cloneInto({detail}, document); /* global cloneInto */
} else { } else {
detail = {detail}; detail = {detail};
} }
@ -325,7 +327,7 @@
msg.off(onMessage); msg.off(onMessage);
} catch (e) {} } catch (e) {}
} }
})(); });
function inPageContext(eventId) { function inPageContext(eventId) {
document.currentScript.remove(); document.currentScript.remove();

View File

@ -1,6 +1,10 @@
'use strict'; 'use strict';
self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ /** The name is needed when running in content scripts but specifying it in define()
breaks IDE detection of exports so here's a workaround */
define.currentModule = '/content/style-injector';
define(require => ({
compare, compare,
onUpdate = () => {}, onUpdate = () => {},
}) => { }) => {
@ -17,22 +21,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
// will store the original method refs because the page can override them // will store the original method refs because the page can override them
let creationDoc, createElement, createElementNS; let creationDoc, createElement, createElementNS;
return { return /** @namespace StyleInjector */ {
list, list,
apply(styleMap) { async apply(styleMap) {
const styles = _styleMapToArray(styleMap); const styles = _styleMapToArray(styleMap);
return ( const value = !styles.length
!styles.length ? ? []
Promise.resolve([]) : : await docRootObserver.evade(() => {
docRootObserver.evade(() => { if (!isTransitionPatched && isEnabled) {
if (!isTransitionPatched && isEnabled) { _applyTransitionPatch(styles);
_applyTransitionPatch(styles); }
} return styles.map(_addUpdate);
return styles.map(_addUpdate); });
}) _emitUpdate();
).then(_emitUpdate); return value;
}, },
clear() { clear() {
@ -155,10 +159,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
docRootObserver[onOff](); docRootObserver[onOff]();
} }
function _emitUpdate(value) { function _emitUpdate() {
_toggleObservers(list.length); _toggleObservers(list.length);
onUpdate(); onUpdate();
return value;
} }
/* /*
@ -321,4 +324,4 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
.observe(document, {childList: true}); .observe(document, {childList: true});
} }
} }
}; });

240
edit.html
View File

@ -4,8 +4,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet"> <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"> <style id="firefox-transitions-bug-suppressor">
/* restrict to FF */ /* restrict to FF */
@ -20,96 +18,6 @@
<link id="cm-theme" rel="stylesheet"> <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/msg.js"></script>
<script src="js/prefs.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 -->
<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/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="msgbox/msgbox.js" async></script>
<link href="edit/codemirror-default.css" rel="stylesheet">
<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/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>
<template data-id="appliesTo"> <template data-id="appliesTo">
<li class="applies-to-item"> <li class="applies-to-item">
<div class="select-resizer"> <div class="select-resizer">
@ -276,9 +184,117 @@
</tbody> </tbody>
</table> </table>
</template> </template>
<script src="js/polyfill.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="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="edit/util.js"></script>
<script src="edit/editor.js"></script>
<script src="edit/preinit.js"></script>
<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>
<script src="vendor/codemirror/addon/dialog/dialog.js"></script>
<script src="vendor/codemirror/addon/edit/closebrackets.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>
<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>
<script src="vendor/codemirror/addon/lint/lint.js"></script>
<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-overwrites/codemirror-addon/match-highlighter.js"></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>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/codemirror-default.js"></script>
<script src="edit/codemirror-factory.js"></script>
<script src="edit/moz-section-finder.js"></script>
<script src="edit/moz-section-widget.js"></script>
<script src="edit/linter-manager.js"></script>
<script src="edit/live-preview.js"></script>
<script src="edit/colorpicker-helper.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet">
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet">
<script src="edit/source-editor.js"></script>
<script src="edit/sections-editor-section.js"></script>
<script src="edit/sections-editor.js"></script>
<script src="edit/edit.js"></script>
<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> </head>
<body id="stylus-edit"> <body id="stylus-edit">
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
<symbol id="svg-icon-external-link" viewBox="0 0 8 8">
<path d="M0 0v8h8v-2h-1v1h-6v-6h1v-1h-2zm4 0l1.5 1.5-2.5 2.5 1 1 2.5-2.5 1.5 1.5v-4h-4z"></path>
</symbol>
<symbol id="svg-icon-help" viewBox="0 0 14 16" i18n-alt="helpAlt">
<path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path>
</symbol>
<symbol id="svg-icon-close" viewBox="0 0 12 16">
<path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48z"></path>
</symbol>
<symbol id="svg-icon-v" viewBox="0 0 16 16">
<path d="M8,11.5L2.8,6.3l1.5-1.5L8,8.6l3.7-3.7l1.5,1.5L8,11.5z"/>
</symbol>
<symbol id="svg-icon-settings" viewBox="0 0 16 16">
<path d="M8,0C7.6,0,7.3,0,6.9,0.1v2.2C6.1,2.5,5.4,2.8,4.8,3.2L3.2,1.6c-0.6,0.4-1.1,1-1.6,1.6l1.6,1.6C2.8,5.4,2.5,6.1,2.3,6.9H0.1C0,7.3,0,7.6,0,8c0,0.4,0,0.7,0.1,1.1h2.2c0.1,0.8,0.4,1.5,0.9,2.1l-1.6,1.6c0.4,0.6,1,1.1,1.6,1.6l1.6-1.6c0.6,0.4,1.4,0.7,2.1,0.9v2.2C7.3,16,7.6,16,8,16c0.4,0,0.7,0,1.1-0.1v-2.2c0.8-0.1,1.5-0.4,2.1-0.9l1.6,1.6c0.6-0.4,1.1-1,1.6-1.6l-1.6-1.6c0.4-0.6,0.7-1.4,0.9-2.1h2.2C16,8.7,16,8.4,16,8c0-0.4,0-0.7-0.1-1.1h-2.2c-0.1-0.8-0.4-1.5-0.9-2.1l1.6-1.6c-0.4-0.6-1-1.1-1.6-1.6l-1.6,1.6c-0.6-0.4-1.4-0.7-2.1-0.9V0.1C8.7,0,8.4,0,8,0z M8,4.3c2.1,0,3.7,1.7,3.7,3.7c0,0,0,0,0,0c0,2.1-1.7,3.7-3.7,3.7c0,0,0,0,0,0c-2.1,0-3.7-1.7-3.7-3.7c0,0,0,0,0,0C4.3,5.9,5.9,4.3,8,4.3C8,4.3,8,4.3,8,4.3z"/>
</symbol>
<symbol id="svg-icon-select-arrow" viewBox="0 0 1792 1792">
<path fill-rule="evenodd" d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"/>
</symbol>
<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>
<symbol id="svg-icon-plus" viewBox="0 0 8 8">
<path fill-rule="evenodd" d="M3 0v3h-3v2h3v3h2v-3h3v-2h-3v-3h-2z"/>
</symbol>
<symbol id="svg-icon-minus" viewBox="0 0 8 8">
<path fill-rule="evenodd" d="M0 3v2h8v-2h-8z"/>
</symbol>
</svg>
<div id="header"> <div id="header">
<h1 id="heading">&nbsp;</h1> <!-- nbsp allocates the actual height which prevents page shift --> <h1 id="heading">&nbsp;</h1> <!-- nbsp allocates the actual height which prevents page shift -->
<section id="basic-info"> <section id="basic-info">
@ -459,45 +475,5 @@
<div class="contents"></div> <div class="contents"></div>
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
<symbol id="svg-icon-external-link" viewBox="0 0 8 8">
<path d="M0 0v8h8v-2h-1v1h-6v-6h1v-1h-2zm4 0l1.5 1.5-2.5 2.5 1 1 2.5-2.5 1.5 1.5v-4h-4z"></path>
</symbol>
<symbol id="svg-icon-help" viewBox="0 0 14 16" i18n-alt="helpAlt">
<path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path>
</symbol>
<symbol id="svg-icon-close" viewBox="0 0 12 16">
<path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48z"></path>
</symbol>
<symbol id="svg-icon-v" viewBox="0 0 16 16">
<path d="M8,11.5L2.8,6.3l1.5-1.5L8,8.6l3.7-3.7l1.5,1.5L8,11.5z"/>
</symbol>
<symbol id="svg-icon-settings" viewBox="0 0 16 16">
<path d="M8,0C7.6,0,7.3,0,6.9,0.1v2.2C6.1,2.5,5.4,2.8,4.8,3.2L3.2,1.6c-0.6,0.4-1.1,1-1.6,1.6l1.6,1.6C2.8,5.4,2.5,6.1,2.3,6.9H0.1C0,7.3,0,7.6,0,8c0,0.4,0,0.7,0.1,1.1h2.2c0.1,0.8,0.4,1.5,0.9,2.1l-1.6,1.6c0.4,0.6,1,1.1,1.6,1.6l1.6-1.6c0.6,0.4,1.4,0.7,2.1,0.9v2.2C7.3,16,7.6,16,8,16c0.4,0,0.7,0,1.1-0.1v-2.2c0.8-0.1,1.5-0.4,2.1-0.9l1.6,1.6c0.6-0.4,1.1-1,1.6-1.6l-1.6-1.6c0.4-0.6,0.7-1.4,0.9-2.1h2.2C16,8.7,16,8.4,16,8c0-0.4,0-0.7-0.1-1.1h-2.2c-0.1-0.8-0.4-1.5-0.9-2.1l1.6-1.6c-0.4-0.6-1-1.1-1.6-1.6l-1.6,1.6c-0.6-0.4-1.4-0.7-2.1-0.9V0.1C8.7,0,8.4,0,8,0z M8,4.3c2.1,0,3.7,1.7,3.7,3.7c0,0,0,0,0,0c0,2.1-1.7,3.7-3.7,3.7c0,0,0,0,0,0c-2.1,0-3.7-1.7-3.7-3.7c0,0,0,0,0,0C4.3,5.9,5.9,4.3,8,4.3C8,4.3,8,4.3,8,4.3z"/>
</symbol>
<symbol id="svg-icon-select-arrow" viewBox="0 0 1792 1792">
<path fill-rule="evenodd" d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"/>
</symbol>
<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>
<symbol id="svg-icon-plus" viewBox="0 0 8 8">
<path fill-rule="evenodd" d="M3 0v3h-3v2h3v3h2v-3h3v-2h-3v-3h-2z"/>
</symbol>
<symbol id="svg-icon-minus" viewBox="0 0 8 8">
<path fill-rule="evenodd" d="M0 3v2h8v-2h-8z"/>
</symbol>
</svg>
</body> </body>
</html> </html>

198
edit/autocomplete.js Normal file
View File

@ -0,0 +1,198 @@
'use strict';
/* Registers 'hint' helper in CodeMirror */
define(require => {
const editor = require('./editor');
const {CodeMirror} = require('./codemirror-factory');
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 = [
'@-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);
}
});

View File

@ -1,58 +1,41 @@
/* global loadScript css_beautify showHelp prefs t $ $create */
/* global editor createHotkeyInput moveFocus CodeMirror */
/* exported initBeautifyButton */
'use strict'; 'use strict';
const HOTKEY_ID = 'editor.beautify.hotkey'; define(require => {
const {$, $create, moveFocus} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const editor = require('./editor');
const {CodeMirror} = require('./codemirror-factory');
const {createHotkeyInput, helpPopup} = require('./util');
const beautifier = require('/vendor-overwrites/beautify/beautify-css-mod').css_beautify;
const HOTKEY_ID = 'editor.beautify.hotkey';
prefs.initializing.then(() => {
CodeMirror.defaults.extraKeys[prefs.get(HOTKEY_ID) || ''] = 'beautify';
CodeMirror.commands.beautify = cm => { CodeMirror.commands.beautify = cm => {
// using per-section mode when code editor or applies-to block is focused // using per-section mode when code editor or applies-to block is focused
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement); const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
beautify(isPerSection ? [cm] : editor.getEditors(), false); beautify(isPerSection ? [cm] : editor.getEditors(), false);
}; };
});
prefs.subscribe([HOTKEY_ID], (key, value) => { prefs.subscribe([HOTKEY_ID], (key, value) => {
const {extraKeys} = CodeMirror.defaults; const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) { for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') { if (cmd === 'beautify') {
delete extraKeys[key]; delete extraKeys[key];
break; break;
}
}
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);
});
}
/**
* @param {CodeMirror[]} scope
* @param {?boolean} ui
*/
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); if (value) {
extraKeys[value] = 'beautify';
}
}, {runNow: true});
function doBeautify() { /**
* @name beautify
* @param {CodeMirror[]} scope
* @param {boolean} [ui=true]
*/
async function beautify(scope, ui = true) {
const tabs = prefs.get('editor.indentWithTabs'); const tabs = prefs.get('editor.indentWithTabs');
const options = Object.assign({}, prefs.get('editor.beautify')); const options = Object.assign({}, prefs.get('editor.beautify'));
for (const k of Object.keys(prefs.defaults['editor.beautify'])) { for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
@ -64,16 +47,16 @@ function beautify(scope, ui = true) {
createBeautifyUI(scope, options); createBeautifyUI(scope, options);
} }
for (const cm of scope) { for (const cm of scope) {
setTimeout(doBeautifyEditor, 0, cm, options); setTimeout(doBeautifyEditor, 0, cm, options, ui);
} }
} }
function doBeautifyEditor(cm, options) { function doBeautifyEditor(cm, options, ui) {
const pos = options.translate_positions = const pos = options.translate_positions =
[].concat.apply([], cm.doc.sel.ranges.map(r => [].concat.apply([], cm.doc.sel.ranges.map(r =>
[Object.assign({}, r.anchor), Object.assign({}, r.head)])); [Object.assign({}, r.anchor), Object.assign({}, r.head)]));
const text = cm.getValue(); const text = cm.getValue();
const newText = css_beautify(text, options); const newText = beautifier(text, options);
if (newText !== text) { if (newText !== text) {
if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) { if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) {
// clear the list if last change wasn't a css-beautify // clear the list if last change wasn't a css-beautify
@ -95,7 +78,7 @@ function beautify(scope, ui = true) {
} }
function createBeautifyUI(scope, options) { function createBeautifyUI(scope, options) {
showHelp(t('styleBeautify'), helpPopup.show(t('styleBeautify'),
$create([ $create([
$create('.beautify-options', [ $create('.beautify-options', [
$createOption('.selector1,', 'selector_separator_newline'), $createOption('.selector1,', 'selector_separator_newline'),
@ -114,8 +97,7 @@ function beautify(scope, ui = true) {
$create('.buttons', [ $create('.buttons', [
$create('button', { $create('button', {
attributes: {role: 'close'}, attributes: {role: 'close'},
// showHelp.close will be defined after showHelp() is invoked onclick: helpPopup.close,
onclick: () => showHelp.close(),
}, t('confirmClose')), }, t('confirmClose')),
$create('button', { $create('button', {
attributes: {role: 'undo'}, attributes: {role: 'undo'},
@ -145,7 +127,7 @@ function beautify(scope, ui = true) {
if (target.parentNode.hasAttribute('newline')) { if (target.parentNode.hasAttribute('newline')) {
target.parentNode.setAttribute('newline', value.toString()); target.parentNode.setAttribute('newline', value.toString());
} }
doBeautify(); beautify(scope, false);
}; };
function $createOption(label, optionName, indent) { function $createOption(label, optionName, indent) {
@ -185,4 +167,14 @@ function beautify(scope, ui = true) {
); );
} }
} }
}
return {
beautify,
beautifyOnClick(event, ui, scope) {
event.preventDefault();
beautify(scope || editor.getEditors(), ui);
},
};
});

View File

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

View File

@ -1,13 +1,13 @@
/* global
$
CodeMirror
prefs
t
*/
'use strict'; 'use strict';
(function () { define(require => {
const {$} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const CodeMirror = require('/vendor/codemirror/lib/codemirror');
const editor = require('./editor');
require(['./codemirror-default.css']);
// CodeMirror miserably fails on keyMap='' so let's ensure it's not // CodeMirror miserably fails on keyMap='' so let's ensure it's not
if (!prefs.get('editor.keyMap')) { if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap'); prefs.reset('editor.keyMap');
@ -43,53 +43,55 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options')); Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// Adding hotkeys to some keymaps except 'basic' which is primitive by design // Adding hotkeys to some keymaps except 'basic' which is primitive by design
const KM = CodeMirror.keyMap; require(Object.values(editor.lazyKeymaps || {}), () => {
const extras = Object.values(CodeMirror.defaults.extraKeys); const KM = CodeMirror.keyMap;
if (!extras.includes('jumpToLine')) { const extras = Object.values(CodeMirror.defaults.extraKeys);
KM.sublime['Ctrl-G'] = 'jumpToLine'; if (!extras.includes('jumpToLine')) {
KM.emacsy['Ctrl-G'] = 'jumpToLine'; KM.sublime['Ctrl-G'] = 'jumpToLine';
KM.pcDefault['Ctrl-J'] = 'jumpToLine'; KM.emacsy['Ctrl-G'] = 'jumpToLine';
KM.macDefault['Cmd-J'] = 'jumpToLine'; KM.pcDefault['Ctrl-J'] = 'jumpToLine';
} KM.macDefault['Cmd-J'] = 'jumpToLine';
if (!extras.includes('autocomplete')) { }
// will be used by 'sublime' on PC via fallthrough if (!extras.includes('autocomplete')) {
KM.pcDefault['Ctrl-Space'] = 'autocomplete'; // will be used by 'sublime' on PC via fallthrough
// OSX uses Ctrl-Space and Cmd-Space for something else KM.pcDefault['Ctrl-Space'] = 'autocomplete';
KM.macDefault['Alt-Space'] = 'autocomplete'; // OSX uses Ctrl-Space and Cmd-Space for something else
// copied from 'emacs' keymap KM.macDefault['Alt-Space'] = 'autocomplete';
KM.emacsy['Alt-/'] = 'autocomplete'; // copied from 'emacs' keymap
// 'vim' and 'emacs' define their own autocomplete hotkeys KM.emacsy['Alt-/'] = 'autocomplete';
} // 'vim' and 'emacs' define their own autocomplete hotkeys
if (!extras.includes('blockComment')) { }
KM.sublime['Shift-Ctrl-/'] = 'commentSelection'; if (!extras.includes('blockComment')) {
} KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
if (navigator.appVersion.includes('Windows')) { }
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R if (navigator.appVersion.includes('Windows')) {
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext'; // 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev'; if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace'; if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
// Note: modifier order in CodeMirror is S-C-A // try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
for (const char of ['N', 'T', 'W']) { // Note: modifier order in CodeMirror is S-C-A
for (const remap of [ for (const char of ['N', 'T', 'W']) {
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']}, for (const remap of [
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}, {from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
]) { {from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
const oldKey = remap.from + char; ]) {
for (const km of Object.values(KM)) { const oldKey = remap.from + char;
const command = km[oldKey]; for (const km of Object.values(KM)) {
if (!command) continue; const command = km[oldKey];
for (const newMod of remap.to) { if (!command) continue;
const newKey = newMod + char; for (const newMod of remap.to) {
if (newKey in km) continue; const newKey = newMod + char;
km[newKey] = command; if (newKey in km) continue;
delete km[oldKey]; km[newKey] = command;
break; delete km[oldKey];
break;
}
} }
} }
} }
} }
} });
const cssMime = CodeMirror.mimeModes['text/css']; const cssMime = CodeMirror.mimeModes['text/css'];
Object.assign(cssMime.propertyKeywords, { Object.assign(cssMime.propertyKeywords, {
@ -164,4 +166,4 @@
}, {value: cur.line + 1}); }, {value: cur.line + 1});
}, },
}); });
})(); });

View File

@ -1,25 +1,30 @@
/* global
$
CodeMirror
debounce
editor
loadScript
prefs
rerouteHotkeys
*/
'use strict'; 'use strict';
//#region cmFactory /*
(() => {
/*
All cm instances created by this module are collected so we can broadcast prefs 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 settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
when the instance is not used anymore. when the instance is not used anymore.
*/ */
define(require => {
const {debounce} = require('/js/toolbox');
const {$} = require('/js/dom');
const prefs = require('/js/prefs');
const editor = require('./editor');
const {rerouteHotkeys} = require('./util');
const CodeMirror = require('/vendor/codemirror/lib/codemirror');
require('./util').CodeMirror = CodeMirror;
//#region Factory
const cms = new Set(); const cms = new Set();
let lazyOpt; let lazyOpt;
const cmFactory = window.cmFactory = { const cmFactory = {
CodeMirror,
create(place, options) { create(place, options) {
const cm = CodeMirror(place, options); const cm = CodeMirror(place, options);
const {wrapper} = cm.display; const {wrapper} = cm.display;
@ -38,9 +43,11 @@
cms.add(cm); cms.add(cm);
return cm; return cm;
}, },
destroy(cm) { destroy(cm) {
cms.delete(cm); cms.delete(cm);
}, },
globalSetOption(key, value) { globalSetOption(key, value) {
CodeMirror.defaults[key] = value; CodeMirror.defaults[key] = value;
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) { if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
@ -49,27 +56,32 @@
cms.forEach(cm => cm.setOption(key, value)); cms.forEach(cm => cm.setOption(key, value));
} }
}, },
initBeautifyButton(el, scope) {
el.on('click', e => require(['./beautify'], _ => _.beautifyOnClick(e, false, scope)));
el.on('contextmenu', e => require(['./beautify'], _ => _.beautifyOnClick(e, true, scope)));
},
}; };
const handledPrefs = { const handledPrefs = {
// handled in colorpicker-helper.js 'editor.colorpicker'() {}, // handled in colorpicker-helper.js
'editor.colorpicker'() {}, async 'editor.theme'(key, value) {
/** @returns {?Promise<void>} */ let el2;
'editor.theme'(key, value) { const el = $('#cm-theme');
const elt = $('#cm-theme');
if (value === 'default') { if (value === 'default') {
elt.href = ''; el.href = '';
} else { } else {
const url = chrome.runtime.getURL(`vendor/codemirror/theme/${value}.css`); const path = `/vendor/codemirror/theme/${value}.css`;
if (url !== elt.href) { if (el.href !== location.origin + path) {
// avoid flicker: wait for the second stylesheet to load, then apply the theme // avoid flicker: wait for the second stylesheet to load, then apply the theme
return loadScript(url, true).then(([newElt]) => { el2 = await require([path]);
cmFactory.globalSetOption('theme', value);
elt.remove();
newElt.id = elt.id;
});
} }
} }
cmFactory.globalSetOption('theme', value);
if (el2) {
el.remove();
el2.id = el.id;
}
}, },
}; };
const pref2opt = k => k.slice('editor.'.length); const pref2opt = k => k.slice('editor.'.length);
@ -125,11 +137,10 @@
return lazyOpt._observer; return lazyOpt._observer;
}, },
}; };
})();
//#endregion
//#region Commands //#endregion
(() => { //#region Commands
Object.assign(CodeMirror.commands, { Object.assign(CodeMirror.commands, {
toggleEditorFocus(cm) { toggleEditorFocus(cm) {
if (!cm) return; if (!cm) return;
@ -151,11 +162,10 @@
]) { ]) {
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args); CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
} }
})();
//#endregion
//#region CM option handlers //#endregion
(() => { //#region CM option handlers
const {insertTab, insertSoftTab} = CodeMirror.commands; const {insertTab, insertSoftTab} = CodeMirror.commands;
Object.entries({ Object.entries({
tabSize(cm, value) { tabSize(cm, value) {
@ -276,207 +286,13 @@
function autocompletePicked(cm) { function autocompletePicked(cm) {
cm.state.autocompletePicked = true; cm.state.autocompletePicked = true;
} }
})();
//#endregion
//#region Autocomplete //#endregion
(() => { //#region Bookmarks
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 BM_CLS = 'gutter-bookmark';
const pos = cm.getCursor(); const BM_BRAND = 'sublimeBookmark';
const {line, ch} = pos; const BM_CLICKER = 'CodeMirror-linenumbers';
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 {markText} = CodeMirror.prototype; const {markText} = CodeMirror.prototype;
for (const name of ['prevBookmark', 'nextBookmark']) { for (const name of ['prevBookmark', 'nextBookmark']) {
const cmdFn = CodeMirror.commands[name]; const cmdFn = CodeMirror.commands[name];
@ -494,27 +310,29 @@
Object.assign(CodeMirror.prototype, { Object.assign(CodeMirror.prototype, {
markText() { markText() {
const marker = markText.apply(this, arguments); const marker = markText.apply(this, arguments);
if (marker[BRAND]) { if (marker[BM_BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', CLS); this.doc.addLineClass(marker.lines[0], 'gutter', BM_CLS);
marker.clear = clearMarker; marker.clear = clearMarker;
} }
return marker; return marker;
}, },
}); });
function clearMarker() { function clearMarker() {
const line = this.lines[0]; const line = this.lines[0];
const spans = line.markedSpans; const spans = line.markedSpans;
delete this.clear; // removing our patch from the instance... delete this.clear; // removing our patch from the instance...
this.clear(); // ...and using the original prototype this.clear(); // ...and using the original prototype
if (!spans || spans.some(span => span.marker[BRAND])) { if (!spans || spans.some(span => span.marker[BM_BRAND])) {
this.doc.removeLineClass(line, 'gutter', CLS); this.doc.removeLineClass(line, 'gutter', BM_CLS);
} }
} }
function onGutterClick(cm, line, name, e) { function onGutterClick(cm, line, name, e) {
switch (name === CLICK_AREA && e.button) { switch (name === BM_CLICKER && e.button) {
case 0: { case 0: {
// main button: toggle // 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.setCursor(mark ? mark.find(-1) : {line, ch: 0});
cm.execCommand('toggleBookmark'); cm.execCommand('toggleBookmark');
break; break;
@ -525,11 +343,15 @@
break; break;
} }
} }
function onGutterContextMenu(cm, line, name, e) { function onGutterContextMenu(cm, line, name, e) {
if (name === CLICK_AREA) { if (name === BM_CLICKER) {
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark'); cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
e.preventDefault(); e.preventDefault();
} }
} }
})();
//#endregion //#endregion
return cmFactory;
});

View File

@ -1,8 +1,7 @@
/* Do not edit. This file is auto-generated by build-vendor.js */ /* Do not edit. This file is auto-generated by build-vendor.js */
'use strict'; 'use strict';
/* exported CODEMIRROR_THEMES */ define([], [
const CODEMIRROR_THEMES = [
'3024-day', '3024-day',
'3024-night', '3024-night',
'abcdef', 'abcdef',
@ -66,4 +65,4 @@ const CODEMIRROR_THEMES = [
'yeti', 'yeti',
'yonce', 'yonce',
'zenburn', 'zenburn',
]; ]);

View File

@ -1,12 +1,13 @@
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
'use strict'; 'use strict';
(() => { define(require => {
onDOMready().then(() => { const prefs = require('/js/prefs');
$('#colorpicker-settings').onclick = configureColorpicker; const t = require('/js/localization');
}); const {createHotkeyInput, helpPopup} = require('./util');
const {CodeMirror, globalSetOption} = require('./codemirror-factory');
prefs.subscribe('editor.colorpicker.hotkey', registerHotkey); prefs.subscribe('editor.colorpicker.hotkey', registerHotkey);
prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true}); prefs.subscribe('editor.colorpicker', setColorpickerOption, {runNow: true});
function setColorpickerOption(id, enabled) { function setColorpickerOption(id, enabled) {
const defaults = CodeMirror.defaults; const defaults = CodeMirror.defaults;
@ -43,7 +44,7 @@
delete defaults.extraKeys[keyName]; delete defaults.extraKeys[keyName];
} }
} }
cmFactory.globalSetOption('colorpicker', defaults.colorpicker); globalSetOption('colorpicker', defaults.colorpicker);
} }
function registerHotkey(id, hotkey) { function registerHotkey(id, hotkey) {
@ -66,16 +67,14 @@
function configureColorpicker(event) { function configureColorpicker(event) {
event.preventDefault(); event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', () => { const input = createHotkeyInput('editor.colorpicker.hotkey', () => helpPopup.close());
$('#help-popup .dismiss').onclick(); const popup = helpPopup.show(t('helpKeyMapHotkey'), input);
}); const bounds = this.getBoundingClientRect();
const popup = showHelp(t('helpKeyMapHotkey'), input); popup.style.left = bounds.right + 10 + 'px';
if (this instanceof Element) { popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
const bounds = this.getBoundingClientRect(); popup.style.right = 'auto';
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
}
input.focus(); input.focus();
} }
})();
return configureColorpicker;
});

View File

@ -1,149 +1,101 @@
/* global
$
$$
$create
API
clipString
closeCurrentTab
CodeMirror
CODEMIRROR_THEMES
debounce
deepEqual
DirtyReporter
DocFuncMapper
FIREFOX
getEventKeyName
getOwnTab
initBeautifyButton
linter
messageBox
moveFocus
msg
onDOMready
prefs
rerouteHotkeys
SectionsEditor
sessionStore
setupLivePrefs
SourceEditor
t
tryCatch
tryJSONparse
*/
'use strict'; 'use strict';
/** @type {EditorBase|SourceEditor|SectionsEditor} */ define(require => {
const editor = { const {API, msg} = require('/js/msg');
isUsercss: false, const {
previewDelay: 200, // Chrome devtools uses 200 FIREFOX,
}; closeCurrentTab,
let isSimpleWindow; debounce,
let isWindowed; getOwnTab,
let headerHeight; sessionStore,
} = require('/js/toolbox');
const {
$,
$$,
$create,
$remove,
getEventKeyName,
onDOMready,
setupLivePrefs,
} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const editor = require('./editor');
const preinit = require('./preinit');
const linterMan = require('./linter-manager');
const {CodeMirror, initBeautifyButton} = require('./codemirror-factory');
window.on('beforeunload', beforeUnload); let headerHeight;
msg.onExtension(onRuntimeMessage); let isSimpleWindow;
let isWindowed;
lazyInit(); window.on('beforeunload', beforeUnload);
msg.onExtension(onRuntimeMessage);
(async function init() { lazyInit();
let style;
let nameTarget;
let wasDirty = false;
const dirty = new DirtyReporter();
await Promise.all([
initStyle(),
prefs.initializing
.then(initTheme),
onDOMready(),
]);
const scrollInfo = style.id && tryJSONparse(sessionStore['editorScrollInfo' + style.id]);
/** @namespace EditorBase */
Object.assign(editor, {
style,
dirty,
scrollInfo,
updateName,
updateToc,
toggleStyle,
applyScrollInfo(cm, si = ((scrollInfo || {}).cms || [])[0]) {
if (si && si.sel) {
cm.operation(() => {
cm.setSelections(...si.sel, {scroll: false});
cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2);
});
}
},
});
prefs.subscribe('editor.linter', updateLinter);
prefs.subscribe('editor.keyMap', showHotkeyInTooltip);
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
showHotkeyInTooltip();
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
initNameArea();
initBeautifyButton($('#beautify'), () => editor.getEditors());
initResizeListener();
detectLayout();
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle'); (async function init() {
$('#preview-label').classList.toggle('hidden', !style.id); await preinit;
const toc = []; buildThemeElement();
const elToc = $('#toc'); buildKeymapElement();
elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target)); setupLivePrefs();
if (editor.isUsercss) { initNameArea();
SourceEditor(); initBeautifyButton($('#beautify'));
} else { initResizeListener();
SectionsEditor(); detectLayout(true);
}
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true});
dirty.onChange(updateDirty);
await editor.ready; $('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
editor.ready = true; $('#preview-label').classList.toggle('hidden', !editor.style.id);
$('#toc').onclick = e => editor.jumpToEditor([...$('#toc').children].indexOf(e.target));
// enabling after init to prevent flash of validation failure on an empty name (editor.isUsercss ? require('./source-editor') : require('./sections-editor'))();
$('#name').required = !editor.isUsercss; await editor.ready;
$('#save-button').onclick = editor.save; editor.ready = true;
editor.dirty.onChange(editor.updateDirty);
async function initStyle() { // enabling after init to prevent flash of validation failure on an empty name
const params = new URLSearchParams(location.search); $('#name').required = !editor.isUsercss;
const id = Number(params.get('id')); $('#save-button').onclick = editor.save;
style = id ? await API.styles.get(id) : initEmptyStyle(params);
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
document.documentElement.classList.toggle('usercss', editor.isUsercss);
sessionStore.justEditedStyleId = style.id || '';
// no such style so let's clear the invalid URL parameters
if (!style.id) history.replaceState({}, '', location.pathname);
updateTitle(false);
}
function initEmptyStyle(params) { prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {runNow: true});
return { prefs.subscribe('editor.linter', (key, value) => {
name: params.get('domain') || $('body').classList.toggle('linter-disabled', value === '');
tryCatch(() => new URL(params.get('url-prefix')).hostname) || linterMan.run();
'', });
enabled: true,
sections: [ require(['./colorpicker-helper'], res => {
DocFuncMapper.toSection([...params], {code: ''}), $('#colorpicker-settings').on('click', res);
], });
}; require(['./keymap-help'], res => {
} $('#keyMap-help').on('click', res);
});
require(['./linter-dialogs'], res => {
$('#linter-settings').on('click', res.showLintConfig);
$('#lint-help').on('click', res.showLintHelp);
});
require(Object.values(editor.lazyKeymaps), () => {
buildKeymapElement();
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
});
require([
'./autocomplete',
'./global-search',
]);
})();
function initNameArea() { function initNameArea() {
const nameEl = $('#name'); const nameEl = $('#name');
const resetEl = $('#reset-name'); const resetEl = $('#reset-name');
const isCustomName = style.updateUrl || editor.isUsercss; const isCustomName = editor.style.updateUrl || editor.isUsercss;
nameTarget = isCustomName ? 'customName' : 'name'; editor.nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName'); nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : ''; nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => { nameEl.on('input', () => {
updateName(true); editor.updateName(true);
resetEl.hidden = false; resetEl.hidden = false;
}); });
resetEl.hidden = !style.customName; resetEl.hidden = !editor.style.customName;
resetEl.onclick = () => { resetEl.onclick = () => {
const style = editor.style; const style = editor.style;
nameEl.focus(); nameEl.focus();
@ -151,13 +103,13 @@ lazyInit();
// trying to make it undoable via Ctrl-Z // trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) { if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name; nameEl.value = style.name;
updateName(true); editor.updateName(true);
} }
style.customName = null; // to delete it from db style.customName = null; // to delete it from db
resetEl.hidden = true; resetEl.hidden = true;
}; };
const enabledEl = $('#enabled'); const enabledEl = $('#enabled');
enabledEl.onchange = () => updateEnabledness(enabledEl.checked); enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
} }
function initResizeListener() { function initResizeListener() {
@ -177,24 +129,6 @@ lazyInit();
}); });
} }
function initTheme() {
return new Promise(resolve => {
const theme = prefs.get('editor.theme');
const el = $('#cm-theme');
if (theme === 'default') {
resolve();
} else {
// preload the theme so CodeMirror can use the correct metrics
el.href = `vendor/codemirror/theme/${theme}.css`;
el.on('load', resolve, {once: true});
el.on('error', () => {
prefs.set('editor.theme', 'default');
resolve();
}, {once: true});
}
});
}
function findKeyForCommand(command, map) { function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map]; if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command); let key = Object.keys(map).find(k => map[k] === command);
@ -211,10 +145,10 @@ lazyInit();
} }
function buildThemeElement() { function buildThemeElement() {
const elOptions = [chrome.i18n.getMessage('defaultTheme'), ...CODEMIRROR_THEMES] $('#editor.theme').append(...[
.map(s => $create('option', s)); $create('option', {value: 'default'}, t('defaultTheme')),
elOptions[0].value = 'default'; ...require('./codemirror-themes').map(s => $create('option', s)),
$('#editor.theme').append(...elOptions); ]);
// move the theme after built-in CSS so that its same-specificity selectors win // move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme')); document.head.appendChild($('#cm-theme'));
} }
@ -248,7 +182,10 @@ lazyInit();
} }
if (!groupWithNext) bin = fragment; if (!groupWithNext) bin = fragment;
}); });
$('#editor.keyMap').appendChild(fragment); const selector = $('#editor.keyMap');
selector.textContent = '';
selector.appendChild(fragment);
selector.value = prefs.get('editor.keyMap');
} }
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) { function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
@ -267,424 +204,241 @@ lazyInit();
} }
} }
function toggleStyle() { /* Stuff not needed for the main init so we can let it run at its own tempo */
$('#enabled').checked = !style.enabled; function lazyInit() {
updateEnabledness(!style.enabled); let ownTabId;
} // not using `await` so we don't block the subsequent code
getOwnTab().then(patchHistoryBack);
function updateDirty() { // no windows on android
const isDirty = dirty.isDirty(); if (chrome.windows) {
if (wasDirty !== isDirty) { detectWindowedState();
wasDirty = isDirty; chrome.tabs.onAttached.addListener(onAttached);
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
} }
updateTitle(); async function patchHistoryBack(tab) {
} ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
function updateEnabledness(enabled) { if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
dirty.modify('enabled', style.enabled, enabled); await onDOMready();
style.enabled = enabled; $('#cancel-button').onclick = event => {
editor.updateLivePreview(); event.stopPropagation();
} event.preventDefault();
history.back();
function updateName(isUserInput) { };
if (!editor) return;
if (isUserInput) {
const {value} = $('#name');
dirty.modify('name', style[nameTarget] || style.name, value);
style[nameTarget] = value;
}
updateTitle();
}
function updateTitle(isDirty = dirty.isDirty()) {
document.title = `${
isDirty ? '* ' : ''
}${
style.customName || style.name || t('styleMissingName')
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
}
function updateLinter(key, value) {
$('body').classList.toggle('linter-disabled', value === '');
linter.run();
}
function updateToc(added = editor.sections) {
const {sections} = editor;
const first = sections.indexOf(added[0]);
const elFirst = elToc.children[first];
if (first >= 0 && (!added.focus || !elFirst)) {
for (let el = elFirst, i = first; i < sections.length; i++) {
const entry = sections[i].tocEntry;
if (!deepEqual(entry, toc[i])) {
if (!el) el = elToc.appendChild($create('li', {tabIndex: 0}));
el.tabIndex = entry.removed ? -1 : 0;
toc[i] = Object.assign({}, entry);
const s = el.textContent = clipString(entry.label) || (
entry.target == null
? t('appliesToEverything')
: clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : ''));
if (s.length > 30) el.title = s;
}
el = el.nextElementSibling;
} }
} }
while (toc.length > sections.length) { async function detectWindowedState() {
elToc.lastElementChild.remove(); isSimpleWindow =
toc.length--; (await browser.windows.getCurrent()).type === 'popup';
isWindowed = isSimpleWindow || (
prefs.get('openEditInWindow') &&
history.length === 1 &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
if (isSimpleWindow) {
await onDOMready();
initPopupButton();
}
} }
if (added.focus) { function initPopupButton() {
const cls = 'current'; const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
const old = $('.' + cls, elToc); const btn = $create('img', {
const el = elFirst || elToc.children[first]; id: 'popup-button',
if (old && old !== el) old.classList.remove(cls); title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
el.classList.add(cls); onclick: embedPopup,
});
const onIconsetChanged = (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
};
prefs.subscribe('iconset', onIconsetChanged, {runNow: true});
document.body.appendChild(btn);
window.on('keydown', e => getEventKeyName(e) === POPUP_HOTKEY && embedPopup());
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup'; // adds to keymap help
}
async function onAttached(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);
} }
} }
})();
/* Stuff not needed for the main init so we can let it run at its own tempo */ function onRuntimeMessage(request) {
function lazyInit() { const {style} = request;
let ownTabId; switch (request.method) {
// not using `await` so we don't block the subsequent code case 'styleUpdated':
getOwnTab().then(patchHistoryBack); if (editor.style.id === style.id &&
// no windows on android !['editPreview', 'editPreviewEnd', 'editSave', 'config'].includes(request.reason)) {
if (chrome.windows) { Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id))
restoreWindowSize(); .then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated));
detectWindowedState(); }
chrome.tabs.onAttached.addListener(onAttached); break;
case 'styleDeleted':
if (editor.style.id === style.id) {
closeCurrentTab();
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
}
} }
async function patchHistoryBack(tab) {
ownTabId = tab.id; function beforeUnload(e) {
// use browser history back when 'back to manage' is clicked sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition'));
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) { sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
await onDOMready(); scrollY: window.scrollY,
$('#cancel-button').onclick = event => { cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
event.stopPropagation(); focus: cm.hasFocus(),
event.preventDefault(); height: cm.display.wrapper.style.height.replace('100vh', ''),
history.back(); parentHeight: cm.display.wrapper.parentElement.offsetHeight,
sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex],
})),
});
const activeElement = document.activeElement;
if (activeElement) {
// blurring triggers 'change' or 'input' event if needed
activeElement.blur();
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
if (editor && editor.dirty.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
e.returnValue = t('styleChangesNotSaved');
}
}
function canSaveWindowPos() {
return isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized();
}
function saveWindowPos() {
if (canSaveWindowPos()) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
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');
}
}
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 isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
function embedPopup() {
const ID = 'popup-iframe';
const SEL = '#' + ID;
if ($(SEL)) return;
const frame = $create('iframe', {
id: ID,
src: chrome.runtime.getManifest().browser_action.default_popup,
height: 600,
width: prefs.get('popupWidth'),
onload() {
frame.onload = null;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', e => getEventKeyName(e) === 'Escape' && embedPopup._close());
pw.close = embedPopup._close;
if (pw.IntersectionObserver) {
let loaded;
new pw.IntersectionObserver(([e]) => {
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 !== !frame._scrollbarWidth || frame.width - width) {
frame._scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + frame._scrollbarWidth;
}
if (!loaded) {
loaded = true;
frame.dataset.loaded = '';
}
}).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(() => {
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (frame._scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}).observe(body, {attributes: true, attributeFilter: ['style']});
},
});
// saving the listener here so it's the same function reference for window.off
if (!embedPopup._close) {
embedPopup._close = () => {
$remove(SEL);
window.off('mousedown', embedPopup._close);
}; };
} }
window.on('mousedown', embedPopup._close);
document.body.appendChild(frame);
} }
/** resize on 'undo close' */ });
function restoreWindowSize() {
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
if (pos && pos.left != null && chrome.windows) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
async function detectWindowedState() {
isSimpleWindow =
(await browser.windows.getCurrent()).type === 'popup';
isWindowed = isSimpleWindow || (
prefs.get('openEditInWindow') &&
history.length === 1 &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
if (isSimpleWindow) {
await onDOMready();
initPopupButton();
}
}
function initPopupButton() {
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
const onIconsetChanged = (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
};
prefs.subscribe('iconset', onIconsetChanged, {now: true});
document.body.appendChild(btn);
window.on('keydown', e => getEventKeyName(e) === POPUP_HOTKEY && embedPopup());
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup'; // adds to keymap help
}
async function onAttached(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);
}
}
function onRuntimeMessage(request) {
const {style} = request;
switch (request.method) {
case 'styleUpdated':
if (editor.style.id === style.id &&
!['editPreview', 'editPreviewEnd', 'editSave', 'config'].includes(request.reason)) {
Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id))
.then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated));
}
break;
case 'styleDeleted':
if (editor.style.id === style.id) {
closeCurrentTab();
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
}
}
function beforeUnload(e) {
sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition'));
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
scrollY: window.scrollY,
cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
focus: cm.hasFocus(),
height: cm.display.wrapper.style.height.replace('100vh', ''),
parentHeight: cm.display.wrapper.parentElement.offsetHeight,
sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex],
})),
});
const activeElement = document.activeElement;
if (activeElement) {
// blurring triggers 'change' or 'input' event if needed
activeElement.blur();
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
if (editor && editor.dirty.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
e.returnValue = t('styleChangesNotSaved');
}
}
function showHelp(title = '', body) {
const div = $('#help-popup');
div.className = '';
const contents = $('.contents', div);
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
}
$('.title', div).textContent = title;
showHelp.close = showHelp.close || (event => {
const canClose =
!event ||
event.type === 'click' ||
(
event.key === 'Escape' &&
!event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey &&
!$('.CodeMirror-hints, #message-box') &&
(
!document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
if (!canClose) {
return;
}
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(() => {
messageBox.confirm(t('confirmDiscardChanges'))
.then(ok => ok && showHelp.close());
});
return;
}
if (div.contains(document.activeElement) && showHelp.originalFocus) {
showHelp.originalFocus.focus();
}
div.style.display = '';
contents.textContent = '';
clearTimeout(contents.timer);
window.off('keydown', showHelp.close, true);
window.dispatchEvent(new Event('closeHelp'));
});
window.on('keydown', showHelp.close, true);
$('.dismiss', div).onclick = showHelp.close;
// reset any inline styles
div.style = 'display: block';
showHelp.originalFocus = document.activeElement;
return div;
}
/* exported showCodeMirrorPopup */
function showCodeMirrorPopup(title, html, options) {
const popup = showHelp(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;
}
function canSaveWindowPos() {
return isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized();
}
function saveWindowPos() {
if (canSaveWindowPos()) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
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');
}
}
function detectLayout() {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
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 isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
function embedPopup() {
const ID = 'popup-iframe';
const SEL = '#' + ID;
if ($(SEL)) return;
const frame = $create('iframe', {
id: ID,
src: chrome.runtime.getManifest().browser_action.default_popup,
height: 600,
width: prefs.get('popupWidth'),
onload() {
frame.onload = null;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', e => getEventKeyName(e) === 'Escape' && embedPopup._close());
pw.close = embedPopup._close;
if (pw.IntersectionObserver) {
let loaded;
new pw.IntersectionObserver(([e]) => {
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 !== !frame._scrollbarWidth || frame.width - width) {
frame._scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + frame._scrollbarWidth;
}
if (!loaded) {
loaded = true;
frame.dataset.loaded = '';
}
}).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(() => {
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (frame._scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}).observe(body, {attributes: true, attributeFilter: ['style']});
},
});
// saving the listener here so it's the same function reference for window.off
if (!embedPopup._close) {
embedPopup._close = () => {
$.remove(SEL);
window.off('mousedown', embedPopup._close);
};
}
window.on('mousedown', embedPopup._close);
document.body.appendChild(frame);
}

View File

@ -1,91 +1,90 @@
/* global importScripts workerUtil CSSLint require metaParser */
'use strict'; 'use strict';
importScripts('/js/worker-util.js'); define(require => { // define and require use `importScripts` which is synchronous
const {loadScript} = workerUtil;
/** @namespace EditorWorker */ const ruleRetriever = {
workerUtil.createAPI({
csslint: (code, config) => { csslint() {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/vendor-overwrites/csslint/csslint.js'); return require('/js/csslint/csslint').getRules().map(rule => {
return CSSLint.verify(code, config).messages const output = {};
.map(m => Object.assign(m, {rule: {id: m.rule.id}})); for (const [key, value] of Object.entries(rule)) {
}, if (typeof value !== 'function') {
stylelint: async (code, config) => { output[key] = value;
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js'); }
const {results: [res]} = await require('stylelint').lint({code, config}); }
delete res._postcssResult; // huge and unused return output;
return res; });
}, },
metalint: code => {
loadScript( stylelint() {
'/vendor/usercss-meta/usercss-meta.min.js', require('/vendor/stylelint-bundle/stylelint-bundle.min');
'/vendor-overwrites/colorpicker/colorconverter.js', const stylelint = self.require('stylelint');
'/js/meta-parser.js' const options = {};
); const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
const result = metaParser.lint(code); const rxString = /"([-\w\s]{3,}?)"/g;
// extract needed info for (const id of Object.keys(stylelint.rules)) {
result.errors = result.errors.map(err => const ruleCode = String(stylelint.rules[id]);
({ const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
const possible = m[1];
const set = [];
while ((mStr = rxString.exec(possible))) {
const s = mStr[1];
if (s.includes(' ')) {
set.push(...s.split(/\s+/));
} else {
set.push(s);
}
}
if (possible.includes('ignoreAtRules')) {
set.push('ignoreAtRules');
}
if (possible.includes('ignoreShorthands')) {
set.push('ignoreShorthands');
}
if (set.length) {
sets.push(set);
}
}
if (sets.length) {
options[id] = sets;
}
}
return options;
},
};
/** @namespace EditorWorker */
require('/js/worker-util').createAPI({
async csslint(code, config) {
return require('/js/csslint/csslint')
.verify(code, config).messages
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
},
getRules(linter) {
return ruleRetriever[linter]();
},
metalint(code) {
const result = require('/js/meta-parser').lint(code);
// extract needed info
result.errors = result.errors.map(err => ({
code: err.code, code: err.code,
args: err.args, args: err.args,
message: err.message, message: err.message,
index: err.index, index: err.index,
}) }));
); return result;
return result; },
},
getStylelintRules,
getCsslintRules,
});
function getCsslintRules() { async stylelint(code, config) {
loadScript('/vendor-overwrites/csslint/csslint.js'); require('/vendor/stylelint-bundle/stylelint-bundle.min');
return CSSLint.getRules().map(rule => { const {results: [res]} = await self.require('stylelint').lint({code, config});
const output = {}; delete res._postcssResult; // huge and unused
for (const [key, value] of Object.entries(rule)) { return res;
if (typeof value !== 'function') { },
output[key] = value;
}
}
return output;
}); });
} });
function getStylelintRules() {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
const stylelint = require('stylelint');
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]);
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
const possible = m[1];
const set = [];
while ((mStr = rxString.exec(possible))) {
const s = mStr[1];
if (s.includes(' ')) {
set.push(...s.split(/\s+/));
} else {
set.push(s);
}
}
if (possible.includes('ignoreAtRules')) {
set.push('ignoreAtRules');
}
if (possible.includes('ignoreShorthands')) {
set.push('ignoreShorthands');
}
if (set.length) {
sets.push(set);
}
}
if (sets.length) {
options[id] = sets;
}
}
return options;
}

199
edit/editor.js Normal file
View File

@ -0,0 +1,199 @@
'use strict';
define(require => {
const {
deepEqual,
sessionStore,
tryJSONparse,
} = require('/js/toolbox');
const {$, $create} = require('/js/dom');
const t = require('/js/localization');
const {clipString} = require('./util');
const dirty = DirtyReporter();
let style;
let wasDirty = false;
const toc = [];
/**
* @mixes SectionsEditor
* @mixes SourceEditor
*/
const editor = {
dirty,
isUsercss: false,
/** @type {'customName'|'name'} */
nameTarget: 'name',
previewDelay: 200, // Chrome devtools uses 200
scrollInfo: null,
get style() {
return style;
},
set style(val) {
style = val;
editor.scrollInfo = style.id && tryJSONparse(sessionStore['editorScrollInfo' + style.id]);
},
applyScrollInfo(cm, si = ((editor.scrollInfo || {}).cms || [])[0]) {
if (si && si.sel) {
cm.operation(() => {
cm.setSelections(...si.sel, {scroll: false});
cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2);
});
}
},
toggleStyle() {
$('#enabled').checked = !style.enabled;
editor.updateEnabledness(!style.enabled);
},
updateDirty() {
const isDirty = dirty.isDirty();
if (wasDirty !== isDirty) {
wasDirty = isDirty;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
}
editor.updateTitle();
},
updateEnabledness(enabled) {
dirty.modify('enabled', style.enabled, enabled);
style.enabled = enabled;
editor.updateLivePreview();
},
updateName(isUserInput) {
if (!editor) return;
if (isUserInput) {
const {value} = $('#name');
dirty.modify('name', style[editor.nameTarget] || style.name, value);
style[editor.nameTarget] = value;
}
editor.updateTitle();
},
updateTitle(isDirty = dirty.isDirty()) {
document.title = `${
isDirty ? '* ' : ''
}${
style.customName || style.name || t('styleMissingName')
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
},
updateToc(added = editor.sections) {
const elToc = $('#toc');
const {sections} = editor;
const first = sections.indexOf(added[0]);
const elFirst = elToc.children[first];
if (first >= 0 && (!added.focus || !elFirst)) {
for (let el = elFirst, i = first; i < sections.length; i++) {
const entry = sections[i].tocEntry;
if (!deepEqual(entry, toc[i])) {
if (!el) el = elToc.appendChild($create('li', {tabIndex: 0}));
el.tabIndex = entry.removed ? -1 : 0;
toc[i] = Object.assign({}, entry);
const s = el.textContent = clipString(entry.label) || (
entry.target == null
? t('appliesToEverything')
: clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : ''));
if (s.length > 30) el.title = s;
}
el = el.nextElementSibling;
}
}
while (toc.length > sections.length) {
elToc.lastElementChild.remove();
toc.length--;
}
if (added.focus) {
const cls = 'current';
const old = $('.' + cls, elToc);
const el = elFirst || elToc.children[first];
if (old && old !== el) old.classList.remove(cls);
el.classList.add(cls);
}
},
};
/** @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);
},
};
}
return editor;
});

View File

@ -1,21 +1,24 @@
/* global
$
$$
$create
chromeLocal
CodeMirror
colorMimicry
debounce
editor
focusAccessibility
onDOMready
stringAsRegExp
t
tryRegExp
*/
'use strict'; 'use strict';
onDOMready().then(() => { define(require => {
const {
debounce,
stringAsRegExp,
tryRegExp,
} = require('/js/toolbox');
const {
$,
$$,
$create,
$remove,
focusAccessibility,
} = require('/js/dom');
const t = require('/js/localization');
const colorMimicry = require('/js/color/color-mimicry');
const {chromeLocal} = require('/js/storage-util');
const {CodeMirror} = require('./codemirror-factory');
const editor = require('./editor');
require(['./global-search.css']);
//region Constants and state //region Constants and state
@ -138,13 +141,13 @@ onDOMready().then(() => {
}, },
onfocusout() { onfocusout() {
if (!state.dialog.contains(document.activeElement)) { if (!state.dialog.contains(document.activeElement)) {
state.dialog.addEventListener('focusin', EVENTS.onfocusin); state.dialog.on('focusin', EVENTS.onfocusin);
state.dialog.removeEventListener('focusout', EVENTS.onfocusout); state.dialog.off('focusout', EVENTS.onfocusout);
} }
}, },
onfocusin() { onfocusin() {
state.dialog.addEventListener('focusout', EVENTS.onfocusout); state.dialog.on('focusout', EVENTS.onfocusout);
state.dialog.removeEventListener('focusin', EVENTS.onfocusin); state.dialog.off('focusin', EVENTS.onfocusin);
trimUndoHistory(); trimUndoHistory();
enableUndoButton(state.undoHistory.length); enableUndoButton(state.undoHistory.length);
if (state.find) doSearch({canAdvance: false}); if (state.find) doSearch({canAdvance: false});
@ -189,7 +192,6 @@ onDOMready().then(() => {
Object.assign(CodeMirror.commands, COMMANDS); Object.assign(CodeMirror.commands, COMMANDS);
readStorage(); readStorage();
return;
//region Find //region Find
@ -577,7 +579,7 @@ onDOMready().then(() => {
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true); const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
Object.assign(dialog, DIALOG_PROPS.dialog); Object.assign(dialog, DIALOG_PROPS.dialog);
dialog.addEventListener('focusout', EVENTS.onfocusout); dialog.on('focusout', EVENTS.onfocusout);
dialog.dataset.type = type; dialog.dataset.type = type;
dialog.style.pointerEvents = 'auto'; dialog.style.pointerEvents = 'auto';
@ -590,9 +592,9 @@ onDOMready().then(() => {
state.tally = $('[data-type="tally"]', dialog); state.tally = $('[data-type="tally"]', dialog);
const colors = { const colors = {
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}), body: colorMimicry(document.body, {bg: 'backgroundColor'}),
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}), input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}), icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
}; };
document.documentElement.appendChild( document.documentElement.appendChild(
$(DIALOG_STYLE_SELECTOR) || $(DIALOG_STYLE_SELECTOR) ||
@ -652,7 +654,7 @@ onDOMready().then(() => {
function destroyDialog({restoreFocus = false} = {}) { function destroyDialog({restoreFocus = false} = {}) {
state.input = null; state.input = null;
$.remove(DIALOG_SELECTOR); $remove(DIALOG_SELECTOR);
debounce.unregister(doSearch); debounce.unregister(doSearch);
makeTargetVisible(null); makeTargetVisible(null);
if (restoreFocus) { if (restoreFocus) {

View File

@ -1,48 +1,45 @@
/* global
$
$$
$create
CodeMirror
onDOMready
prefs
showHelp
stringAsRegExp
t
*/
'use strict'; 'use strict';
onDOMready().then(() => { define(require => {
$('#keyMap-help').addEventListener('click', showKeyMapHelp); const {stringAsRegExp} = require('/js/toolbox');
}); const {$$, $create} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const {CodeMirror} = require('./codemirror-factory');
const {helpPopup} = require('./util');
function showKeyMapHelp() { let tBody, inputs;
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
const keyMapSorted = Object.keys(keyMap)
.map(key => ({key, cmd: keyMap[key]}))
.sort((a, b) => (a.cmd < b.cmd || (a.cmd === b.cmd && a.key < b.key) ? -1 : 1));
const table = t.template.keymapHelp.cloneNode(true);
const tBody = table.tBodies[0];
const row = tBody.rows[0];
const cellA = row.children[0];
const cellB = row.children[1];
tBody.textContent = '';
for (const {key, cmd} of keyMapSorted) {
cellA.textContent = key;
cellB.textContent = cmd;
tBody.appendChild(row.cloneNode(true));
}
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table); return function showKeymapHelp() {
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
const keyMapSorted = Object.keys(keyMap)
.map(key => ({key, cmd: keyMap[key]}))
.sort((a, b) => (a.cmd < b.cmd || (a.cmd === b.cmd && a.key < b.key) ? -1 : 1));
const table = t.template.keymapHelp.cloneNode(true);
table.oninput = filterTable;
tBody = table.tBodies[0];
const row = tBody.rows[0];
const cellA = row.children[0];
const cellB = row.children[1];
tBody.textContent = '';
for (const {key, cmd} of keyMapSorted) {
cellA.textContent = key;
cellB.textContent = cmd;
tBody.appendChild(row.cloneNode(true));
}
const inputs = $$('input', table); helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
inputs[0].addEventListener('keydown', hotkeyHandler);
inputs[1].focus();
table.oninput = filterTable; inputs = $$('input', table);
inputs[0].on('keydown', hotkeyHandler);
inputs[1].focus();
};
function hotkeyHandler(event) { function hotkeyHandler(event) {
const keyName = CodeMirror.keyName(event); const keyName = CodeMirror.keyName(event);
if (keyName === 'Esc' || keyName === 'Tab' || keyName === 'Shift-Tab') { if (keyName === 'Esc' ||
keyName === 'Tab' ||
keyName === 'Shift-Tab') {
return; return;
} }
event.preventDefault(); event.preventDefault();
@ -90,6 +87,7 @@ function showKeyMapHelp() {
} }
} }
} }
function mergeKeyMaps(merged, ...more) { function mergeKeyMaps(merged, ...more) {
more.forEach(keyMap => { more.forEach(keyMap => {
if (typeof keyMap === 'string') { if (typeof keyMap === 'string') {
@ -102,7 +100,7 @@ function showKeyMapHelp() {
if (typeof cmd === 'function') { if (typeof cmd === 'function') {
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body) // 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 // 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) + '...'; merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
} else { } else {
merged[key] = cmd; merged[key] = cmd;
@ -115,4 +113,4 @@ function showKeyMapHelp() {
}); });
return merged; return merged;
} }
} });

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};
})();

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

@ -0,0 +1,238 @@
'use strict';
define(require => {
const {tryJSONparse} = require('/js/toolbox');
const {
$,
$create,
$createLink,
messageBoxProxy,
} = require('/js/dom');
const t = require('/js/localization');
const {chromeSync} = require('/js/storage-util');
const {DEFAULTS, worker} = require('./linter-manager');
const {getIssues} = require('./linter-report');
const {
helpPopup,
rerouteHotkeys,
showCodeMirrorPopup,
} = require('./util');
/** @type {{csslint:{}, stylelint:{}}} */
const RULES = {};
let cm;
let defaultConfig;
let isStylelint;
let linter;
let popup;
return {
async showLintConfig() {
linter = $('#editor.linter').value;
if (!linter) {
return;
}
if (!RULES[linter]) {
worker.getRules(linter).then(res => (RULES[linter] = res));
}
await require([
'/vendor/codemirror/mode/javascript/javascript',
'/vendor/codemirror/addon/lint/json-lint',
'js!/vendor/jsonlint/jsonlint',
]);
const config = await chromeSync.getLZValue(chromeSync.LZ_KEY[linter]);
const title = t('linterConfigPopupTitle', isStylelint ? 'Stylelint' : 'CSSLint');
isStylelint = linter === 'stylelint';
defaultConfig = stringifyConfig(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});
},
async showLintHelp() {
// 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([...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(event) {
event.preventDefault();
$('.dismiss').click();
}
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)),
])
)
);
}
}

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

@ -0,0 +1,252 @@
'use strict';
define(require => {
const prefs = require('/js/prefs');
const {chromeSync} = require('/js/storage-util');
const {createWorker} = require('/js/worker-util');
const cms = new Map();
const configs = new Map();
const linters = [];
const lintingUpdatedListeners = [];
const unhookListeners = [];
const linterMan = {
/** @type {EditorWorker} */
worker: createWorker({
url: '/edit/editor-worker.js',
}),
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();
}
},
};
const 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,
'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,
'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,
},
};
const ENGINES = {
csslint: {
validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
async lint(text, config) {
const results = await linterMan.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 linterMan.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;
},
},
};
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;
}
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;
}
function onUpdateLinting(...args) {
for (const fn of lintingUpdatedListeners) {
fn(...args);
}
}
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);
}
}
});
return linterMan;
});

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,15 +1,14 @@
/* global linter editor clipString createLinterHelpDialog $ $create */
'use strict'; 'use strict';
Object.assign(linter, (() => { define(require => {
const {$, $create} = require('/js/dom');
const editor = require('./editor');
const linterMan = require('./linter-manager');
const {clipString} = require('./util');
const tables = new Map(); const tables = new Map();
const helpDialog = createLinterHelpDialog(getIssues);
document.addEventListener('DOMContentLoaded', () => { linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
$('#lint-help').addEventListener('click', helpDialog.show);
}, {once: true});
linter.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
let table = tables.get(cm); let table = tables.get(cm);
if (!table) { if (!table) {
table = createTable(cm); table = createTable(cm);
@ -23,7 +22,7 @@ Object.assign(linter, (() => {
updateCount(); updateCount();
}); });
linter.onUnhook(cm => { linterMan.onUnhook(cm => {
const table = tables.get(cm); const table = tables.get(cm);
if (table) { if (table) {
table.element.remove(); table.element.remove();
@ -32,7 +31,24 @@ Object.assign(linter, (() => {
updateCount(); updateCount();
}); });
return {refreshReport}; return {
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() { function updateCount() {
const issueCount = Array.from(tables.values()) const issueCount = Array.from(tables.values())
@ -41,16 +57,6 @@ Object.assign(linter, (() => {
$('#issue-count').textContent = issueCount; $('#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) { function findNextSibling(tables, cm) {
const editors = editor.getEditors(); const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1; let i = editors.indexOf(cm) + 1;
@ -62,12 +68,6 @@ Object.assign(linter, (() => {
} }
} }
function refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
}
function createTable(cm) { function createTable(cm) {
const caption = $create('caption'); const caption = $create('caption');
const tbody = $create('tbody'); const tbody = $create('tbody');
@ -158,4 +158,4 @@ Object.assign(linter, (() => {
cm.focus(); cm.focus();
cm.jumpToPos(anno.from); 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 +1,89 @@
/* global messageBox editor $ prefs */
/* exported createLivePreview */
'use strict'; 'use strict';
function createLivePreview(preprocess, shouldShow) { define(require => {
let data; const {$, messageBoxProxy} = require('/js/dom');
let previewer; const prefs = require('/js/prefs');
let enabled = prefs.get('editor.livePreview'); const editor = require('./editor');
const label = $('#preview-label');
const errorContainer = $('#preview-errors');
prefs.subscribe(['editor.livePreview'], (key, value) => { let data;
if (value && data && data.id && (data.enabled || editor.dirty.has('enabled'))) { let port;
previewer = createPreviewer(); let preprocess;
previewer.update(data); let enabled = prefs.get('editor.livePreview');
}
if (!value && previewer) { prefs.subscribe('editor.livePreview', (key, value) => {
previewer.disconnect(); if (!value) {
previewer = null; disconnectPreviewer();
} else if (data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
createPreviewer();
updatePreviewer(data);
} }
enabled = value; enabled = value;
}); });
if (shouldShow != null) show(shouldShow);
return {update, show};
function show(state) { const livePreview = {
label.classList.toggle('hidden', !state);
}
function update(_data) { /**
data = _data; * @param {Function} [fn] - preprocessor
if (!previewer) { * @param {boolean} [show]
if (!data.id || !data.enabled || !enabled) { */
return; init(fn, show) {
preprocess = fn;
if (show != null) {
livePreview.show(show);
} }
previewer = createPreviewer(); },
}
previewer.update(data); show(state) {
} $('#preview-label').classList.toggle('hidden', !state);
},
update(newData) {
data = newData;
if (!port) {
if (!data.id || !data.enabled || !enabled) {
return;
}
createPreviewer();
}
updatePreviewer(data);
},
};
function createPreviewer() { function createPreviewer() {
const port = chrome.runtime.connect({ port = chrome.runtime.connect({name: 'livePreview'});
name: 'livePreview', port.onDisconnect.addListener(throwError);
}); }
port.onDisconnect.addListener(err => {
throw err;
});
return {update, disconnect};
function update(data) { function disconnectPreviewer() {
Promise.resolve() if (port) {
.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(); port.disconnect();
port = null;
} }
} }
}
function throwError(err) {
throw err;
}
async function updatePreviewer(data) {
const errorContainer = $('#preview-errors');
try {
port.postMessage(preprocess ? await preprocess(data) : data);
errorContainer.classList.add('hidden');
} catch (err) {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index != null) {
// 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 || err}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => {
messageBoxProxy.alert(err.message || `${err}`, 'pre');
};
}
}
return livePreview;
});

View File

@ -1,13 +1,10 @@
/* global
CodeMirror
debounce
deepEqual
trimCommentLabel
*/
'use strict'; 'use strict';
/* exported MozSectionFinder */ define(require => {
function MozSectionFinder(cm) { const {debounce, deepEqual} = require('/js/toolbox');
const {CodeMirror} = require('./codemirror-factory');
const {trimCommentLabel} = require('./util');
const KEY = 'MozSectionFinder'; const KEY = 'MozSectionFinder';
const MOZ_DOC_LEN = '@-moz-document'.length; const MOZ_DOC_LEN = '@-moz-document'.length;
const rxDOC = /@-moz-document(\s+|$)/ig; const rxDOC = /@-moz-document(\s+|$)/ig;
@ -25,6 +22,7 @@ function MozSectionFinder(cm) {
let updFrom; let updFrom;
/** @type {CodeMirror.Pos} */ /** @type {CodeMirror.Pos} */
let updTo; let updTo;
let cm;
const MozSectionFinder = { const MozSectionFinder = {
IGNORE_ORIGIN: KEY, IGNORE_ORIGIN: KEY,
@ -37,6 +35,11 @@ function MozSectionFinder(cm) {
get sections() { get sections() {
return getState().sections; return getState().sections;
}, },
init(newCM) {
cm = newCM;
},
keepAliveFor(id, ms) { keepAliveFor(id, ms) {
let data = keptAlive.get(id); let data = keptAlive.get(id);
if (data) { if (data) {
@ -49,6 +52,7 @@ function MozSectionFinder(cm) {
} }
data.timer = setTimeout(id => keptAlive.delete(id), ms, id); data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
}, },
on(fn) { on(fn) {
const {listeners} = getState(); const {listeners} = getState();
const needsInit = !listeners.size; const needsInit = !listeners.size;
@ -58,6 +62,7 @@ function MozSectionFinder(cm) {
update(); update();
} }
}, },
off(fn) { off(fn) {
const {listeners, sections} = getState(); const {listeners, sections} = getState();
if (listeners.size) { if (listeners.size) {
@ -69,15 +74,16 @@ function MozSectionFinder(cm) {
} }
} }
}, },
onOff(fn, enable) { onOff(fn, enable) {
MozSectionFinder[enable ? 'on' : 'off'](fn); MozSectionFinder[enable ? 'on' : 'off'](fn);
}, },
/** @param {MozSection} [section] */ /** @param {MozSection} [section] */
updatePositions(section) { updatePositions(section) {
(section ? [section] : getState().sections).forEach(setPositionFromMark); (section ? [section] : getState().sections).forEach(setPositionFromMark);
}, },
}; };
return MozSectionFinder;
/** @returns {MozSectionCmState} */ /** @returns {MozSectionCmState} */
function getState() { function getState() {
@ -389,9 +395,11 @@ function MozSectionFinder(cm) {
function isSameFunc(func, i) { function isSameFunc(func, i) {
return deepEqual(func, this[i], MozSectionFinder.EQ_SKIP_KEYS); return deepEqual(func, this[i], MozSectionFinder.EQ_SKIP_KEYS);
} }
}
/** @typedef CodeMirror.Pos /** @typedef CodeMirror.Pos
* @property {number} line * @property {number} line
* @property {number} ch * @property {number} ch
*/ */
return MozSectionFinder;
});

View File

@ -1,24 +1,16 @@
/* global
$
$create
CodeMirror
colorMimicry
messageBox
MozSectionFinder
msg
prefs
regExpTester
t
tryCatch
*/
'use strict'; 'use strict';
/* exported MozSectionWidget */ define(require => {
function MozSectionWidget( const {msg} = require('/js/msg');
cm, const {$, $create, messageBoxProxy} = require('/js/dom');
finder = MozSectionFinder(cm), const {tryCatch} = require('/js/toolbox');
onDirectChange = () => 0 const t = require('/js/localization');
) { const prefs = require('/js/prefs');
const colorMimicry = require('/js/color/color-mimicry');
const {CodeMirror} = require('./codemirror-factory');
const {updateToc} = require('./editor');
const finder = require('./moz-section-finder');
let TPL, EVENTS, CLICK_ROUTE; let TPL, EVENTS, CLICK_ROUTE;
const KEY = 'MozSectionWidget'; const KEY = 'MozSectionWidget';
const C_CONTAINER = '.applies-to'; const C_CONTAINER = '.applies-to';
@ -36,8 +28,16 @@ function MozSectionWidget(
const {cmpPos} = CodeMirror; const {cmpPos} = CodeMirror;
let enabled = false; let enabled = false;
let funcHeight = 0; let funcHeight = 0;
/** @type {HTMLStyleElement} */
let actualStyle; let actualStyle;
let cm;
return { return {
init(newCM) {
cm = newCM;
},
toggle(enable) { toggle(enable) {
if (Boolean(enable) !== enabled) { if (Boolean(enable) !== enabled) {
(enable ? init : destroy)(); (enable ? init : destroy)();
@ -71,7 +71,7 @@ function MozSectionWidget(
'.remove-applies-to'(elItem, func) { '.remove-applies-to'(elItem, func) {
const funcs = getFuncsFor(elItem); const funcs = getFuncsFor(elItem);
if (funcs.length < 2) { if (funcs.length < 2) {
messageBox({ messageBoxProxy.show({
contents: t('appliesRemoveError'), contents: t('appliesRemoveError'),
buttons: [t('confirmClose')], buttons: [t('confirmClose')],
}); });
@ -110,7 +110,7 @@ function MozSectionWidget(
if (part === 'value' && func === getFuncsFor(el)[0]) { if (part === 'value' && func === getFuncsFor(el)[0]) {
const sec = getSectionFor(el); const sec = getSectionFor(el);
sec.tocEntry.target = el.value; sec.tocEntry.target = el.value;
if (!sec.tocEntry.label) onDirectChange([sec]); if (!sec.tocEntry.label) updateToc([sec]);
} }
cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN); cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
}, },
@ -176,13 +176,13 @@ function MozSectionWidget(
const MIN_LUMA = .05; const MIN_LUMA = .05;
const MIN_LUMA_DIFF = .4; const MIN_LUMA_DIFF = .4;
const color = { const color = {
wrapper: colorMimicry.get(cm.display.wrapper), wrapper: colorMimicry(cm.display.wrapper),
gutter: colorMimicry.get(cm.display.gutters, { gutter: colorMimicry(cm.display.gutters, {
bg: 'backgroundColor', bg: 'backgroundColor',
border: 'borderRightColor', border: 'borderRightColor',
}), }),
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv), line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv), comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
}; };
const hasBorder = const hasBorder =
color.gutter.style.borderRightWidth !== '0px' && color.gutter.style.borderRightWidth !== '0px' &&
@ -422,9 +422,11 @@ function MozSectionWidget(
} }
function showRegExpTester(el) { function showRegExpTester(el) {
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp'); require(['./regexp-tester'], regExpTester => {
regExpTester.toggle(true); const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
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) { function fromDoubleslash(s) {
@ -443,4 +445,4 @@ function MozSectionWidget(
function setProp(obj, name, value) { function setProp(obj, name, value) {
return Object.defineProperty(obj, name, {value, configurable: true}); return Object.defineProperty(obj, name, {value, configurable: true});
} }
} });

82
edit/preinit.js Normal file
View File

@ -0,0 +1,82 @@
'use strict';
define(require => {
const {API} = require('/js/msg');
const {sessionStore, tryCatch, tryJSONparse} = require('/js/toolbox');
const {waitForSelector} = require('/js/dom');
const prefs = require('/js/prefs');
const editor = require('./editor');
const util = require('./util');
const lazyKeymaps = {
emacs: '/vendor/codemirror/keymap/emacs',
vim: '/vendor/codemirror/keymap/vim',
};
// resize the window on 'undo close'
if (chrome.windows) {
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
if (pos && pos.left != null && chrome.windows) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
async function preinit() {
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: [
util.DocFuncMapper.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 */
function loadTheme() {
return new Promise(resolve => {
const theme = prefs.get('editor.theme');
if (theme === 'default') {
resolve();
} else {
const el = document.querySelector('#cm-theme');
el.href = `vendor/codemirror/theme/${theme}.css`;
el.on('load', resolve, {once: true});
el.on('error', () => {
prefs.set('editor.theme', 'default');
resolve();
}, {once: true});
}
});
}
/** 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]);
}
return Promise.all([
preinit(),
prefs.initializing.then(() =>
Promise.all([
loadTheme(),
loadKeymaps(),
])),
waitForSelector('#sections'),
]);
});

View File

@ -1,80 +1,57 @@
/* global
$
$create
openURL
showHelp
t
tryRegExp
URLS
*/
/* exported regExpTester */
'use strict'; 'use strict';
const regExpTester = (() => { /** @module RegexpTester*/
define(require => {
const {URLS, openURL, tryRegExp} = require('/js/toolbox');
const {$, $create} = require('/js/dom');
const t = require('/js/localization');
const {helpPopup} = require('./util');
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain='; const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16']; const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = new Map(); const cachedRegexps = new Map();
let currentRegexps = []; let currentRegexps = [];
let isInit = false; let isWatching;
function init() { const isShown = () => Boolean($('.regexp-report'));
isInit = true;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
function uninit() { const regexpTester = /** @namespace RegExpTester */{
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isInit = false;
}
function onTabUpdate(tabId, info) { toggle(state = !isShown()) {
if (info.url) { if (state && !isShown()) {
update(); if (!isWatching) {
} isWatching = true;
} chrome.tabs.onUpdated.addListener(onTabUpdate);
}
function isShown() { helpPopup.show('', $create('.regexp-report'));
return Boolean($('.regexp-report')); } else if (!state && isShown()) {
} unwatch();
helpPopup.close();
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) { async update(newRegexps) {
if (!isShown()) { if (!isShown()) {
if (isInit) { unwatch();
uninit(); return;
} }
return; if (newRegexps) {
} currentRegexps = newRegexps;
if (newRegexps) {
currentRegexps = newRegexps;
}
const regexps = currentRegexps.map(text => {
const rxData = Object.assign({text}, cachedRegexps.get(text));
if (!rxData.urls) {
cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));
} }
return rxData; const regexps = currentRegexps.map(text => {
}); const rxData = Object.assign({text}, cachedRegexps.get(text));
const getMatchInfo = m => m && {text: m[0], pos: m.index}; if (!rxData.urls) {
browser.tabs.query({}).then(tabs => { cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));
}
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
const tabs = await browser.tabs.query({});
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported); const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
const unique = [...new Set(supported).values()]; const unique = [...new Set(supported).values()];
for (const rxData of regexps) { for (const rxData of regexps) {
@ -92,10 +69,12 @@ const regExpTester = (() => {
} }
const stats = { const stats = {
full: {data: [], label: t('styleRegexpTestFull')}, full: {data: [], label: t('styleRegexpTestFull')},
partial: {data: [], label: [ partial: {
t('styleRegexpTestPartial'), data: [], label: [
t.template.regexpTestPartial.cloneNode(true), t('styleRegexpTestPartial'),
]}, t.template.regexpTestPartial.cloneNode(true),
],
},
none: {data: [], label: t('styleRegexpTestNone')}, none: {data: [], label: t('styleRegexpTestNone')},
invalid: {data: [], label: t('styleRegexpTestInvalid')}, invalid: {data: [], label: t('styleRegexpTestInvalid')},
}; };
@ -167,7 +146,7 @@ const regExpTester = (() => {
} }
} }
} }
showHelp(t('styleRegexpTestTitle'), report); helpPopup.show(t('styleRegexpTestTitle'), report);
report.onclick = onClick; report.onclick = onClick;
const note = $create('p.regexp-report-note', const note = $create('p.regexp-report-note',
@ -176,25 +155,38 @@ const regExpTester = (() => {
.map(s => (s.startsWith('\\') ? $create('code', s) : s))); .map(s => (s.startsWith('\\') ? $create('code', s) : s)));
report.appendChild(note); report.appendChild(note);
adjustNote(report, note); adjustNote(report, note);
}); },
};
function onClick(event) { function adjustNote(report, note) {
const a = event.target.closest('a'); report.style.paddingBottom = note.offsetHeight + 'px';
if (a) { }
event.preventDefault();
openURL({
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
currentWindow: null,
});
} else if (event.target.closest('details')) {
setTimeout(adjustNote);
}
}
function adjustNote(report, note) { function onClick(event) {
report.style.paddingBottom = note.offsetHeight + 'px'; const a = event.target.closest('a');
if (a) {
event.preventDefault();
openURL({
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
currentWindow: null,
});
} else if (event.target.closest('details')) {
setTimeout(adjustNote);
} }
} }
return {toggle, update}; function onTabUpdate(tabId, info) {
})(); if (info.url) {
regexpTester.update();
}
}
function unwatch() {
if (isWatching) {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isWatching = false;
}
}
return regexpTester;
});

View File

@ -1,33 +1,34 @@
/* global CodeMirror editor debounce */
/* exported rerouteHotkeys */
'use strict'; 'use strict';
const rerouteHotkeys = (() => { define(require => {
const {debounce} = require('/js/toolbox');
const {CodeMirror} = require('./codemirror-factory');
const editor = require('./editor');
// reroute handling to nearest editor when keypress resolves to one of these commands // reroute handling to nearest editor when keypress resolves to one of these commands
const REROUTED = new Set([ const REROUTED = new Set([
'save',
'toggleStyle',
'jumpToLine',
'nextEditor', 'prevEditor',
'toggleEditorFocus',
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
'colorpicker',
'beautify', 'beautify',
'colorpicker',
'find',
'findNext',
'findPrev',
'jumpToLine',
'nextEditor',
'prevEditor',
'replace',
'replaceAll',
'save',
'toggleEditorFocus',
'toggleStyle',
]); ]);
return rerouteHotkeys; return function rerouteHotkeys(enable, immediately) {
// 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) { if (!immediately) {
debounce(rerouteHotkeys, 0, enable, true); debounce(rerouteHotkeys, 0, enable, true);
} else if (enable) {
document.addEventListener('keydown', rerouteHandler);
} else { } else {
document.removeEventListener('keydown', rerouteHandler); document[enable ? 'on' : 'off']('keydown', rerouteHandler);
} }
} };
function rerouteHandler(event) { function rerouteHandler(event) {
const keyName = CodeMirror.keyName(event); const keyName = CodeMirror.keyName(event);
@ -46,4 +47,4 @@ const rerouteHotkeys = (() => {
event.stopPropagation(); event.stopPropagation();
} }
} }
})(); });

View File

@ -1,427 +1,424 @@
/* global
$
cmFactory
debounce
DocFuncMapper
editor
initBeautifyButton
linter
prefs
regExpTester
t
trimCommentLabel
tryRegExp
*/
'use strict'; 'use strict';
/* exported createSection */ define(require => {
const {debounce, tryRegExp} = require('/js/toolbox');
const {$} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const cmFactory = require('./codemirror-factory');
const editor = require('./editor');
const linterMan = require('./linter-manager');
const {DocFuncMapper, trimCommentLabel} = require('./util');
/** @type {RegExpTester} */
let regExpTester;
/**
* @param {StyleSection} originalSection
* @param {function():number} genId
* @param {EditorScrollInfo} [si]
* @returns {EditorSection}
*/
function createSection(originalSection, genId, si) {
const {dirty} = editor; const {dirty} = editor;
const sectionId = genId();
const el = t.template.section.cloneNode(true);
const elLabel = $('.code-label', el);
const cm = cmFactory.create(wrapper => {
// making it tall during initial load so IntersectionObserver sees only one adjacent CM
if (editor.ready !== true) {
wrapper.style.height = si ? si.height : '100vh';
}
elLabel.after(wrapper);
}, {
value: originalSection.code,
});
el.CodeMirror = cm; // used by getAssociatedEditor
editor.applyScrollInfo(cm, si);
const changeListeners = new Set(); /**
* @param {StyleSection} originalSection
const appliesToContainer = $('.applies-to-list', el); * @param {function():number} genId
const appliesTo = []; * @param {EditorScrollInfo} [si]
DocFuncMapper.forEachProp(originalSection, (type, value) => * @returns {EditorSection}
insertApplyAfter({type, value})); */
if (!appliesTo.length) { return function createSection(originalSection, genId, si) {
insertApplyAfter({all: true}); const sectionId = genId();
} const el = t.template.section.cloneNode(true);
const elLabel = $('.code-label', el);
let changeGeneration = cm.changeGeneration(); const cm = cmFactory.create(wrapper => {
let removed = false; // making it tall during initial load so IntersectionObserver sees only one adjacent CM
if (editor.ready !== true) {
registerEvents(); wrapper.style.height = si ? si.height : '100vh';
updateRegexpTester(); }
createResizeGrip(cm); elLabel.after(wrapper);
}, {
/** @namespace EditorSection */ value: originalSection.code,
const section = {
id: sectionId,
el,
cm,
appliesTo,
getModel() {
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
return DocFuncMapper.toSection(items, {code: cm.getValue()});
},
remove() {
linter.disableForEditor(cm);
el.classList.add('removed');
removed = true;
appliesTo.forEach(a => a.remove());
},
render() {
cm.refresh();
},
destroy() {
cmFactory.destroy(cm);
},
restore() {
linter.enableForEditor(cm);
el.classList.remove('removed');
removed = false;
appliesTo.forEach(a => a.restore());
cm.refresh();
},
onChange(fn) {
changeListeners.add(fn);
},
off(fn) {
changeListeners.delete(fn);
},
get removed() {
return removed;
},
tocEntry: {
label: '',
get removed() {
return removed;
},
},
};
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true});
return section;
function emitSectionChange(origin) {
for (const fn of changeListeners) {
fn(origin);
}
}
function registerEvents() {
cm.on('changes', () => {
const newGeneration = cm.changeGeneration();
dirty.modify(`section.${sectionId}.code`, changeGeneration, newGeneration);
changeGeneration = newGeneration;
emitSectionChange('code');
}); });
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true); el.CodeMirror = cm; // used by getAssociatedEditor
$('.test-regexp', el).onclick = () => { editor.applyScrollInfo(cm, si);
regExpTester.toggle();
updateRegexpTester();
};
initBeautifyButton($('.beautify-section', el), () => [cm]);
}
function handleKeydown(cm, event) { const changeListeners = new Set();
if (event.shiftKey || event.altKey || event.metaKey) {
return;
}
const {key} = event;
const {line, ch} = cm.getCursor();
switch (key) {
case 'ArrowLeft':
if (line || ch) {
return;
}
// fallthrough
case 'ArrowUp':
cm = line === 0 && editor.prevEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(cm.doc.size - 1, key === 'ArrowLeft' ? 1e20 : ch);
break;
case 'ArrowRight':
if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
return;
}
// fallthrough
case 'ArrowDown':
cm = line === cm.doc.size - 1 && editor.nextEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(0, 0);
break;
}
}
function updateRegexpTester() { const appliesToContainer = $('.applies-to-list', el);
const regexps = appliesTo.filter(a => a.type === 'regexp') const appliesTo = [];
.map(a => a.value); DocFuncMapper.forEachProp(originalSection, (type, value) =>
if (regexps.length) { insertApplyAfter({type, value}));
el.classList.add('has-regexp');
regExpTester.update(regexps);
} else {
el.classList.remove('has-regexp');
regExpTester.toggle(false);
}
}
function updateTocEntry(origin) {
const te = section.tocEntry;
let changed;
if (origin === 'code' || !origin) {
const label = getLabelFromComment();
if (te.label !== label) {
te.label = elLabel.dataset.text = label;
changed = true;
}
}
if (!te.label) {
const target = appliesTo[0].all ? null : appliesTo[0].value;
if (te.target !== target) {
te.target = target;
changed = true;
}
if (te.numTargets !== appliesTo.length) {
te.numTargets = appliesTo.length;
changed = true;
}
}
if (changed) editor.updateToc([section]);
}
function updateTocEntryLazy(...args) {
debounce(updateTocEntry, 0, ...args);
}
function updateTocFocus() {
editor.updateToc({focus: true, 0: section});
}
function updateTocPrefToggled(key, val) {
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
el.onOff(val, 'focusin', updateTocFocus);
if (val) {
updateTocEntry();
if (el.contains(document.activeElement)) {
updateTocFocus();
}
}
}
function getLabelFromComment() {
let cmt = '';
let inCmt;
cm.eachLine(({text}) => {
let i = 0;
if (!inCmt) {
i = text.search(/\S/);
if (i < 0) return;
inCmt = text[i] === '/' && text[i + 1] === '*';
if (!inCmt) return true;
i += 2;
}
const j = text.indexOf('*/', i);
cmt = trimCommentLabel(text.slice(i, j >= 0 ? j : text.length));
return j >= 0 || cmt;
});
return cmt;
}
function insertApplyAfter(init, base) {
const apply = createApply(init);
appliesTo.splice(base ? appliesTo.indexOf(base) + 1 : appliesTo.length, 0, apply);
appliesToContainer.insertBefore(apply.el, base ? base.el.nextSibling : null);
dirty.add(apply, apply);
if (appliesTo.length > 1 && appliesTo[0].all) {
removeApply(appliesTo[0]);
}
emitSectionChange('apply');
return apply;
}
function removeApply(apply) {
const index = appliesTo.indexOf(apply);
appliesTo.splice(index, 1);
apply.remove();
apply.el.remove();
dirty.remove(apply, apply);
if (!appliesTo.length) { if (!appliesTo.length) {
insertApplyAfter({all: true}); insertApplyAfter({all: true});
} }
emitSectionChange('apply');
}
function createApply({type = 'url', value, all = false}) { let changeGeneration = cm.changeGeneration();
const applyId = genId(); let removed = false;
const dirtyPrefix = `section.${sectionId}.apply.${applyId}`;
const el = all ? t.template.appliesToEverything.cloneNode(true) :
t.template.appliesTo.cloneNode(true);
const selectEl = !all && $('.applies-type', el); registerEvents();
if (selectEl) { updateRegexpTester();
selectEl.value = type; createResizeGrip(cm);
selectEl.on('change', () => {
const oldType = type;
dirty.modify(`${dirtyPrefix}.type`, type, selectEl.value);
type = selectEl.value;
if (oldType === 'regexp' || type === 'regexp') {
updateRegexpTester();
}
emitSectionChange('apply');
validate();
});
}
const valueEl = !all && $('.applies-value', el); /** @namespace EditorSection */
if (valueEl) { const section = {
valueEl.value = value; id: sectionId,
valueEl.on('input', () => {
dirty.modify(`${dirtyPrefix}.value`, value, valueEl.value);
value = valueEl.value;
if (type === 'regexp') {
updateRegexpTester();
}
emitSectionChange('apply');
});
valueEl.on('change', validate);
}
restore();
const apply = {
id: applyId,
all,
remove,
restore,
el, el,
valueEl, // used by validator cm,
get type() { appliesTo,
return type; getModel() {
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
return DocFuncMapper.toSection(items, {code: cm.getValue()});
}, },
get value() { remove() {
return value; linterMan.disableForEditor(cm);
el.classList.add('removed');
removed = true;
appliesTo.forEach(a => a.remove());
},
render() {
cm.refresh();
},
destroy() {
cmFactory.destroy(cm);
},
restore() {
linterMan.enableForEditor(cm);
el.classList.remove('removed');
removed = false;
appliesTo.forEach(a => a.restore());
cm.refresh();
},
onChange(fn) {
changeListeners.add(fn);
},
off(fn) {
changeListeners.delete(fn);
},
get removed() {
return removed;
},
tocEntry: {
label: '',
get removed() {
return removed;
},
}, },
}; };
const removeButton = $('.remove-applies-to', el); prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {runNow: true});
if (removeButton) {
removeButton.on('click', e => { return section;
e.preventDefault();
removeApply(apply); function emitSectionChange(origin) {
for (const fn of changeListeners) {
fn(origin);
}
}
function registerEvents() {
cm.on('changes', () => {
const newGeneration = cm.changeGeneration();
dirty.modify(`section.${sectionId}.code`, changeGeneration, newGeneration);
changeGeneration = newGeneration;
emitSectionChange('code');
}); });
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
$('.test-regexp', el).onclick = () => updateRegexpTester(true);
cmFactory.initBeautifyButton($('.beautify-section', el), [cm]);
} }
$('.add-applies-to', el).on('click', e => {
e.preventDefault();
const newApply = insertApplyAfter({type, value: ''}, apply);
$('input', newApply.el).focus();
});
return apply; function handleKeydown(cm, event) {
if (event.shiftKey || event.altKey || event.metaKey) {
return;
}
const {key} = event;
const {line, ch} = cm.getCursor();
switch (key) {
case 'ArrowLeft':
if (line || ch) {
return;
}
// fallthrough
case 'ArrowUp':
cm = line === 0 && editor.prevEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(cm.doc.size - 1, key === 'ArrowLeft' ? 1e20 : ch);
break;
case 'ArrowRight':
if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
return;
}
// fallthrough
case 'ArrowDown':
cm = line === cm.doc.size - 1 && editor.nextEditor(cm, false);
if (!cm) {
return;
}
event.preventDefault();
event.stopPropagation();
cm.setCursor(0, 0);
break;
}
}
function validate() { async function updateRegexpTester(toggle) {
if (type !== 'regexp' || tryRegExp(value)) { if (!regExpTester) regExpTester = await require(['./regexp-tester']);
valueEl.setCustomValidity(''); if (toggle != null) 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);
} else { } else {
valueEl.setCustomValidity(t('styleBadRegexp')); el.classList.remove('has-regexp');
setTimeout(() => valueEl.reportValidity()); regExpTester.toggle(false);
} }
} }
function remove() { function updateTocEntry(origin) {
if (all) { const te = section.tocEntry;
return; let changed;
if (origin === 'code' || !origin) {
const label = getLabelFromComment();
if (te.label !== label) {
te.label = elLabel.dataset.text = label;
changed = true;
}
} }
dirty.remove(`${dirtyPrefix}.type`, type); if (!te.label) {
dirty.remove(`${dirtyPrefix}.value`, value); const target = appliesTo[0].all ? null : appliesTo[0].value;
} if (te.target !== target) {
te.target = target;
function restore() { changed = true;
if (all) { }
return; if (te.numTargets !== appliesTo.length) {
te.numTargets = appliesTo.length;
changed = true;
}
} }
dirty.add(`${dirtyPrefix}.type`, type); if (changed) editor.updateToc([section]);
dirty.add(`${dirtyPrefix}.value`, value);
} }
}
}
function createResizeGrip(cm) { function updateTocEntryLazy(...args) {
const wrapper = cm.display.wrapper; debounce(updateTocEntry, 0, ...args);
wrapper.classList.add('resize-grip-enabled');
const resizeGrip = t.template.resizeGrip.cloneNode(true);
wrapper.appendChild(resizeGrip);
let lastClickTime = 0;
let initHeight;
let initY;
resizeGrip.onmousedown = event => {
initHeight = wrapper.offsetHeight;
initY = event.pageY;
if (event.button !== 0) {
return;
} }
event.preventDefault();
if (Date.now() - lastClickTime < 500) {
lastClickTime = 0;
toggleSectionHeight(cm);
return;
}
lastClickTime = Date.now();
const minHeight = cm.defaultTextHeight() +
/* .CodeMirror-lines padding */
cm.display.lineDiv.offsetParent.offsetTop +
/* borders */
wrapper.offsetHeight - wrapper.clientHeight;
wrapper.style.pointerEvents = 'none';
document.body.style.cursor = 's-resize';
document.on('mousemove', resize);
document.on('mouseup', resizeStop);
function resize(e) { function updateTocFocus() {
const height = Math.max(minHeight, initHeight + e.pageY - initY); editor.updateToc({focus: true, 0: section});
if (height !== wrapper.offsetHeight) { }
cm.setSize(null, height);
function updateTocPrefToggled(key, val) {
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
(val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
if (val) {
updateTocEntry();
if (el.contains(document.activeElement)) {
updateTocFocus();
}
} }
} }
function resizeStop() { function getLabelFromComment() {
document.off('mouseup', resizeStop); let cmt = '';
document.off('mousemove', resize); let inCmt;
wrapper.style.pointerEvents = ''; cm.eachLine(({text}) => {
document.body.style.cursor = ''; let i = 0;
if (!inCmt) {
i = text.search(/\S/);
if (i < 0) return;
inCmt = text[i] === '/' && text[i + 1] === '*';
if (!inCmt) return true;
i += 2;
}
const j = text.indexOf('*/', i);
cmt = trimCommentLabel(text.slice(i, j >= 0 ? j : text.length));
return j >= 0 || cmt;
});
return cmt;
}
function insertApplyAfter(init, base) {
const apply = createApply(init);
appliesTo.splice(base ? appliesTo.indexOf(base) + 1 : appliesTo.length, 0, apply);
appliesToContainer.insertBefore(apply.el, base ? base.el.nextSibling : null);
dirty.add(apply, apply);
if (appliesTo.length > 1 && appliesTo[0].all) {
removeApply(appliesTo[0]);
}
emitSectionChange('apply');
return apply;
}
function removeApply(apply) {
const index = appliesTo.indexOf(apply);
appliesTo.splice(index, 1);
apply.remove();
apply.el.remove();
dirty.remove(apply, apply);
if (!appliesTo.length) {
insertApplyAfter({all: true});
}
emitSectionChange('apply');
}
function createApply({type = 'url', value, all = false}) {
const applyId = genId();
const dirtyPrefix = `section.${sectionId}.apply.${applyId}`;
const el = all ? t.template.appliesToEverything.cloneNode(true) :
t.template.appliesTo.cloneNode(true);
const selectEl = !all && $('.applies-type', el);
if (selectEl) {
selectEl.value = type;
selectEl.on('change', () => {
const oldType = type;
dirty.modify(`${dirtyPrefix}.type`, type, selectEl.value);
type = selectEl.value;
if (oldType === 'regexp' || type === 'regexp') {
updateRegexpTester();
}
emitSectionChange('apply');
validate();
});
}
const valueEl = !all && $('.applies-value', el);
if (valueEl) {
valueEl.value = value;
valueEl.on('input', () => {
dirty.modify(`${dirtyPrefix}.value`, value, valueEl.value);
value = valueEl.value;
if (type === 'regexp') {
updateRegexpTester();
}
emitSectionChange('apply');
});
valueEl.on('change', validate);
}
restore();
const apply = {
id: applyId,
all,
remove,
restore,
el,
valueEl, // used by validator
get type() {
return type;
},
get value() {
return value;
},
};
const removeButton = $('.remove-applies-to', el);
if (removeButton) {
removeButton.on('click', e => {
e.preventDefault();
removeApply(apply);
});
}
$('.add-applies-to', el).on('click', e => {
e.preventDefault();
const newApply = insertApplyAfter({type, value: ''}, apply);
$('input', newApply.el).focus();
});
return apply;
function validate() {
if (type !== 'regexp' || tryRegExp(value)) {
valueEl.setCustomValidity('');
} else {
valueEl.setCustomValidity(t('styleBadRegexp'));
setTimeout(() => valueEl.reportValidity());
}
}
function remove() {
if (all) {
return;
}
dirty.remove(`${dirtyPrefix}.type`, type);
dirty.remove(`${dirtyPrefix}.value`, value);
}
function restore() {
if (all) {
return;
}
dirty.add(`${dirtyPrefix}.type`, type);
dirty.add(`${dirtyPrefix}.value`, value);
}
} }
}; };
function toggleSectionHeight(cm) { function createResizeGrip(cm) {
if (cm.state.toggleHeightSaved) { const wrapper = cm.display.wrapper;
// restore previous size wrapper.classList.add('resize-grip-enabled');
cm.setSize(null, cm.state.toggleHeightSaved); const resizeGrip = t.template.resizeGrip.cloneNode(true);
cm.state.toggleHeightSaved = 0; wrapper.appendChild(resizeGrip);
} else { let lastClickTime = 0;
// maximize let initHeight;
const wrapper = cm.display.wrapper; let initY;
const allBounds = $('#sections').getBoundingClientRect(); resizeGrip.onmousedown = event => {
const pageExtrasHeight = allBounds.top + window.scrollY + initHeight = wrapper.offsetHeight;
parseFloat(getComputedStyle($('#sections')).paddingBottom); initY = event.pageY;
const sectionEl = wrapper.parentNode; if (event.button !== 0) {
const sectionExtrasHeight = sectionEl.clientHeight - wrapper.offsetHeight; return;
cm.state.toggleHeightSaved = wrapper.clientHeight; }
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight); event.preventDefault();
const bounds = sectionEl.getBoundingClientRect(); if (Date.now() - lastClickTime < 500) {
if (bounds.top < 0 || bounds.bottom > window.innerHeight) { lastClickTime = 0;
window.scrollBy(0, bounds.top); toggleSectionHeight(cm);
return;
}
lastClickTime = Date.now();
const minHeight = cm.defaultTextHeight() +
/* .CodeMirror-lines padding */
cm.display.lineDiv.offsetParent.offsetTop +
/* borders */
wrapper.offsetHeight - wrapper.clientHeight;
wrapper.style.pointerEvents = 'none';
document.body.style.cursor = 's-resize';
document.on('mousemove', resize);
document.on('mouseup', resizeStop);
function resize(e) {
const height = Math.max(minHeight, initHeight + e.pageY - initY);
if (height !== wrapper.offsetHeight) {
cm.setSize(null, height);
}
}
function resizeStop() {
document.off('mouseup', resizeStop);
document.off('mousemove', resize);
wrapper.style.pointerEvents = '';
document.body.style.cursor = '';
}
};
function toggleSectionHeight(cm) {
if (cm.state.toggleHeightSaved) {
// restore previous size
cm.setSize(null, cm.state.toggleHeightSaved);
cm.state.toggleHeightSaved = 0;
} else {
// maximize
const wrapper = cm.display.wrapper;
const allBounds = $('#sections').getBoundingClientRect();
const pageExtrasHeight = allBounds.top + window.scrollY +
parseFloat(getComputedStyle($('#sections')).paddingBottom);
const sectionEl = wrapper.parentNode;
const sectionExtrasHeight = sectionEl.clientHeight - wrapper.offsetHeight;
cm.state.toggleHeightSaved = wrapper.clientHeight;
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
const bounds = sectionEl.getBoundingClientRect();
if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
window.scrollBy(0, bounds.top);
}
} }
} }
} }
} });

View File

@ -1,45 +1,48 @@
/* global
$
$$
$create
API
clipString
CodeMirror
createLivePreview
createSection
debounce
editor
FIREFOX
ignoreChromeError
linter
messageBox
prefs
rerouteHotkeys
sectionsToMozFormat
sessionStore
showCodeMirrorPopup
showHelp
t
*/
'use strict'; 'use strict';
/* exported SectionsEditor */ define(require => function SectionsEditor() {
const {API} = require('/js/msg');
const {
FIREFOX,
debounce,
ignoreChromeError,
sessionStore,
} = require('/js/toolbox');
const {
$,
$$,
$create,
$remove,
messageBoxProxy,
} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const {CodeMirror} = require('./codemirror-factory');
const editor = require('./editor');
const livePreview = require('./live-preview');
const linterMan = require('./linter-manager');
const createSection = require('./sections-editor-section');
const {
clipString,
helpPopup,
rerouteHotkeys,
sectionsToMozFormat,
showCodeMirrorPopup,
} = require('./util');
function SectionsEditor() { const {style, /** @type DirtyReporter */dirty} = editor;
const {style, dirty} = editor;
const container = $('#sections'); const container = $('#sections');
/** @type {EditorSection[]} */ /** @type {EditorSection[]} */
const sections = []; const sections = [];
const xo = window.IntersectionObserver && const xo = window.IntersectionObserver &&
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'}); 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 INC_ID = 0; // an increment id that is used by various object to track the order
let sectionOrder = ''; let sectionOrder = '';
let headerOffset; // in compact mode the header is at the top so it reduces the available height let headerOffset; // in compact mode the header is at the top so it reduces the available height
container.classList.add('section-editor');
updateHeader(); updateHeader();
livePreview.init(null, style.id);
container.classList.add('section-editor');
$('#to-mozilla').on('click', showMozillaFormat); $('#to-mozilla').on('click', showMozillaFormat);
$('#to-mozilla-help').on('click', showToMozillaHelp); $('#to-mozilla-help').on('click', showToMozillaHelp);
$('#from-mozilla').on('click', () => showMozillaFormatImport()); $('#from-mozilla').on('click', () => showMozillaFormatImport());
@ -50,8 +53,7 @@ function SectionsEditor() {
.forEach(e => e.on('mousedown', toggleContextMenuDelete)); .forEach(e => e.on('mousedown', toggleContextMenuDelete));
} }
/** @namespace SectionsEditor */ Object.assign(editor, /** @mixin SectionsEditor */ {
Object.assign(editor, {
sections, sections,
@ -194,13 +196,13 @@ function SectionsEditor() {
progressElement.title = progress + '%'; progressElement.title = progress + '%';
}); });
} else { } else {
$.remove(progressElement); $remove(progressElement);
} }
} }
function showToMozillaHelp(event) { function showToMozillaHelp(event) {
event.preventDefault(); event.preventDefault();
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp')); helpPopup.show(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
} }
/** /**
@ -380,7 +382,7 @@ function SectionsEditor() {
const code = popup.codebox.getValue().trim(); const code = popup.codebox.getValue().trim();
if (!/==userstyle==/i.test(code) || if (!/==userstyle==/i.test(code) ||
!await getPreprocessor(code) || !await getPreprocessor(code) ||
await messageBox.confirm( await messageBoxProxy.confirm(
t('importPreprocessor'), 'pre-line', t('importPreprocessor'), 'pre-line',
t('importPreprocessorTitle')) t('importPreprocessorTitle'))
) { ) {
@ -416,7 +418,7 @@ function SectionsEditor() {
} }
function showError(errors) { function showError(errors) {
messageBox({ messageBoxProxy.show({
className: 'center danger', className: 'center danger',
title: t('styleFromMozillaFormatError'), title: t('styleFromMozillaFormatError'),
contents: $create('pre', contents: $create('pre',
@ -433,7 +435,7 @@ function SectionsEditor() {
sectionOrder = validSections.map(s => s.id).join(','); sectionOrder = validSections.map(s => s.id).join(',');
dirty.modify('sectionOrder', oldOrder, sectionOrder); dirty.modify('sectionOrder', oldOrder, sectionOrder);
container.dataset.sectionCount = validSections.length; container.dataset.sectionCount = validSections.length;
linter.refreshReport(); require(['./linter-report'], rep => rep.refreshReport());
editor.updateToc(); editor.updateToc();
} }
@ -446,7 +448,7 @@ function SectionsEditor() {
function validate() { function validate() {
if (!$('#name').reportValidity()) { if (!$('#name').reportValidity()) {
messageBox.alert(t('styleMissingName')); messageBoxProxy.alert(t('styleMissingName'));
return false; return false;
} }
for (const section of sections) { for (const section of sections) {
@ -455,7 +457,7 @@ function SectionsEditor() {
continue; continue;
} }
if (!apply.valueEl.reportValidity()) { if (!apply.valueEl.reportValidity()) {
messageBox.alert(t('styleBadRegexp')); messageBoxProxy.alert(t('styleBadRegexp'));
return false; return false;
} }
} }
@ -627,7 +629,7 @@ function SectionsEditor() {
/** @param {EditorSection} section */ /** @param {EditorSection} section */
function registerEvents(section) { function registerEvents(section) {
const {el, cm} = 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); $('.remove-section', el).onclick = () => removeSection(section);
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section); $('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section); $('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
@ -643,8 +645,8 @@ function SectionsEditor() {
function maybeImportOnPaste(cm, event) { function maybeImportOnPaste(cm, event) {
const text = event.clipboardData.getData('text') || ''; const text = event.clipboardData.getData('text') || '';
if (/@-moz-document/i.test(text) && if (/@-moz-document/i.test(text) &&
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i /@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, '')) .test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
) { ) {
event.preventDefault(); event.preventDefault();
showMozillaFormatImport(text); showMozillaFormatImport(text);
@ -653,7 +655,7 @@ function SectionsEditor() {
function refreshOnView(cm, {code, force} = {}) { function refreshOnView(cm, {code, force} = {}) {
if (code) { if (code) {
linter.enableForEditor(cm, code); linterMan.enableForEditor(cm, code);
} }
if (force || !xo) { if (force || !xo) {
refreshOnViewNow(cm); refreshOnViewNow(cm);
@ -679,7 +681,7 @@ function SectionsEditor() {
} }
async function refreshOnViewNow(cm) { async function refreshOnViewNow(cm) {
linter.enableForEditor(cm); linterMan.enableForEditor(cm);
cm.refresh(); cm.refresh();
} }
@ -693,4 +695,4 @@ function SectionsEditor() {
}, ignoreChromeError); }, ignoreChromeError);
} }
} }
} });

View File

@ -1,36 +1,33 @@
/* global
$
$$
$create
API
chromeSync
cmFactory
CodeMirror
createLivePreview
createMetaCompiler
debounce
editor
linter
messageBox
MozSectionFinder
MozSectionWidget
prefs
sectionsToMozFormat
sessionStore
t
*/
'use strict'; 'use strict';
/* exported SourceEditor */ define(require => function SourceEditor() {
const {API} = require('/js/msg');
const {debounce, sessionStore} = require('/js/toolbox');
const {
$,
$create,
$isTextInput,
$$remove,
messageBoxProxy,
} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const {chromeSync} = require('/js/storage-util');
const cmFactory = require('./codemirror-factory');
const editor = require('./editor');
const livePreview = require('./live-preview');
const linterMan = require('./linter-manager');
const sectionFinder = require('./moz-section-finder');
const sectionWidget = require('./moz-section-widget');
const {sectionsToMozFormat} = require('./util');
function SourceEditor() { const {CodeMirror} = cmFactory;
const {style, dirty} = editor; const {style, /** @type DirtyReporter */dirty} = editor;
let savedGeneration; let savedGeneration;
let placeholderName = ''; let placeholderName = '';
let prevMode = NaN; let prevMode = NaN;
$$.remove('.sectioned-only'); $$remove('.sectioned-only');
$('#header').on('wheel', headerOnScroll); $('#header').on('wheel', headerOnScroll);
$('#sections').textContent = ''; $('#sections').textContent = '';
$('#sections').appendChild($create('.single-editor')); $('#sections').appendChild($create('.single-editor'));
@ -38,11 +35,19 @@ function SourceEditor() {
if (!style.id) setupNewStyle(style); if (!style.id) setupNewStyle(style);
const cm = cmFactory.create($('.single-editor')); const cm = cmFactory.create($('.single-editor'));
const sectionFinder = MozSectionFinder(cm); createMetaCompiler(cm, meta => {
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc); style.usercssData = meta;
const livePreview = createLivePreview(preprocess, style.id); style.name = meta.name;
/** @namespace SourceEditor */ style.url = meta.homepageURL || style.installationUrl;
Object.assign(editor, { updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
sectionFinder.init(cm);
sectionWidget.init(cm);
livePreview.init(preprocess, Boolean(style.id));
Object.assign(editor, /** @mixin SourceEditor */ {
sections: sectionFinder.sections, sections: sectionFinder.sections,
replaceStyle, replaceStyle,
getEditors: () => [cm], getEditors: () => [cm],
@ -62,19 +67,12 @@ function SourceEditor() {
getSearchableInputs: () => [], getSearchableInputs: () => [],
updateLivePreview, updateLivePreview,
}); });
createMetaCompiler(cm, meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
prefs.subscribeMany({ prefs.subscribeMany({
'editor.linter': updateLinterSwitch, 'editor.linter': updateLinterSwitch,
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val), 'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val), 'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
}, {now: true}); }, {runNow: true});
editor.applyScrollInfo(cm); editor.applyScrollInfo(cm);
cm.clearHistory(); cm.clearHistory();
cm.markClean(); cm.markClean();
@ -88,11 +86,11 @@ function SourceEditor() {
const mode = getModeName(); const mode = getModeName();
if (mode === prevMode) return; if (mode === prevMode) return;
prevMode = mode; prevMode = mode;
linter.run(); linterMan.run();
updateLinterSwitch(); updateLinterSwitch();
}); });
setTimeout(linter.enableForEditor, 0, cm); setTimeout(linterMan.enableForEditor, 0, cm);
if (!$.isTextInput(document.activeElement)) { if (!$isTextInput(document.activeElement)) {
cm.focus(); cm.focus();
} }
@ -199,7 +197,7 @@ function SourceEditor() {
return; return;
} }
Promise.resolve(messageBox.confirm(t('styleUpdateDiscardChanges'))).then(ok => { Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
if (!ok) return; if (!ok) return;
updateEnvironment(); updateEnvironment();
if (!sameCode) { if (!sameCode) {
@ -250,16 +248,16 @@ function SourceEditor() {
// save template // save template
if (err.code === 'missingValue' && meta.includes('@name')) { if (err.code === 'missingValue' && meta.includes('@name')) {
const key = chromeSync.LZ_KEY.usercssTemplate; const key = chromeSync.LZ_KEY.usercssTemplate;
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok && messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
chromeSync.setLZValue(key, code) chromeSync.setLZValue(key, code)
.then(() => chromeSync.getLZValue(key)) .then(() => chromeSync.getLZValue(key))
.then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving')))); .then(saved => saved !== code && messageBoxProxy.alert(t('syncStorageErrorSaving'))));
return; return;
} }
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`; contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
contents.push($create('pre', meta)); contents.push($create('pre', meta));
} }
messageBox.alert(contents, 'pre'); messageBoxProxy.alert(contents, 'pre');
}); });
} }
@ -271,7 +269,7 @@ function SourceEditor() {
metaOnly: true, metaOnly: true,
}).then(({dup}) => { }).then(({dup}) => {
if (dup) { if (dup) {
messageBox.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError')); messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
return Promise.reject({handled: true}); return Promise.reject({handled: true});
} }
}); });
@ -334,4 +332,37 @@ function SourceEditor() {
return (mode.name || mode || '') + return (mode.name || mode || '') +
(mode.helperType || ''); (mode.helperType || '');
} }
}
function createMetaCompiler(cm, onUpdated) {
let meta = null;
let metaIndex = null;
let cache = [];
linterMan.register(async (text, options, _cm) => {
if (_cm !== cm) {
return;
}
// TODO: reuse this regexp everywhere for ==userstyle== check
const match = text.match(/\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i);
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,219 +1,231 @@
/* global
$create
CodeMirror
prefs
*/
'use strict'; 'use strict';
/* exported DirtyReporter */ define(require => {
class DirtyReporter { const {
constructor() { $,
this._dirty = new Map(); $create,
this._onchange = new Set(); getEventKeyName,
} messageBoxProxy,
moveFocus,
} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
add(obj, value) { let CodeMirror;
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj); // TODO: maybe move to sections-util.js
if (!saved) { const DocFuncMapper = {
this._dirty.set(obj, {type: 'add', newValue: value}); TO_CSS: {
} else if (saved.type === 'remove') { urls: 'url',
if (saved.savedValue === value) { urlPrefixes: 'url-prefix',
this._dirty.delete(obj); domains: 'domain',
} else { regexps: 'regexp',
saved.newValue = value; },
saved.type = 'modify'; 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));
} }
} },
this.notifyChange(wasDirty); /**
} * @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
remove(obj, value) { * @returns {Object} section
const wasDirty = this.isDirty(); */
const saved = this._dirty.get(obj); toSection(funcItems, section = {}) {
if (!saved) { for (const item of funcItems) {
this._dirty.set(obj, {type: 'remove', savedValue: value}); const [func, value] = item || [];
} else if (saved.type === 'add') { const propName = DocFuncMapper.FROM_CSS[func];
this._dirty.delete(obj); if (propName) {
} else if (saved.type === 'modify') { const props = section[propName] || (section[propName] = []);
saved.type = 'remove'; if (Array.isArray(value)) props.push(...value);
} else props.push(value);
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') { return section;
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',
},
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));
}
},
/**
* @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);
}
}
return section;
},
};
/* 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');
}
/* exported trimCommentLabel */
function trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
}
/* 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 */ const util = {
/**
* @param {!string} prefId get CodeMirror() {
* @param {?function(isEnter:boolean)} onDone return CodeMirror;
*/ },
function createHotkeyInput(prefId, onDone = () => {}) { set CodeMirror(val) {
return $create('input', { CodeMirror = val;
type: 'search', },
spellcheck: false, DocFuncMapper,
value: prefs.get(prefId),
onkeydown(event) { helpPopup: {
const key = CodeMirror.keyName(event); show(title = '', body) {
if (key === 'Tab' || key === 'Shift-Tab') { const div = $('#help-popup');
return; const contents = $('.contents', div);
} div.className = '';
event.preventDefault(); contents.textContent = '';
event.stopPropagation(); if (body) {
switch (key) { contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
case 'Enter': }
if (this.checkValidity()) onDone(true); $('.title', div).textContent = title;
$('.dismiss', div).onclick = util.helpPopup.close;
window.on('keydown', util.helpPopup.close, true);
// reset any inline styles
div.style = 'display: block';
util.helpPopup.originalFocus = document.activeElement;
return div;
},
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')
)
);
if (!canClose) {
return; return;
case 'Esc': }
onDone(false); const div = $('#help-popup');
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(async () => {
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
return ok && util.helpPopup.close();
});
return; return;
default: }
// disallow: [Shift?] characters, modifiers-only, [modifiers?] + Esc, Tab, nav keys if (div.contains(document.activeElement) && util.helpPopup.originalFocus) {
if (!key || new RegExp('^(' + [ util.helpPopup.originalFocus.focus();
'(Back)?Space', }
'(Shift-)?.', // a single character const contents = $('.contents', div);
'(Shift-?|Ctrl-?|Alt-?|Cmd-?){0,2}(|Esc|Tab|(Page)?(Up|Down)|Left|Right|Home|End|Insert|Delete)', div.style.display = '';
].join('|') + ')$', 'i').test(key)) { contents.textContent = '';
this.value = key || this.value; window.off('keydown', util.helpPopup.close, true);
this.setCustomValidity('Not allowed'); window.dispatchEvent(new Event('closeHelp'));
},
},
clipString(str, limit = 100) {
return str.length <= limit ? str : str.substr(0, limit) + '...';
},
createHotkeyInput(prefId, onDone = () => {}) {
return $create('input', {
type: 'search',
spellcheck: false,
value: prefs.get(prefId),
onkeydown(event) {
const key = CodeMirror.keyName(event);
if (key === 'Tab' || key === 'Shift-Tab') {
return; return;
} }
} event.preventDefault();
this.value = key; event.stopPropagation();
this.setCustomValidity(''); switch (key) {
prefs.set(prefId, key); case 'Enter':
if (this.checkValidity()) onDone(true);
return;
case 'Esc':
onDone(false);
return;
default:
// disallow: [Shift?] characters, modifiers-only, [modifiers?] + Esc, Tab, nav keys
if (!key || new RegExp('^(' + [
'(Back)?Space',
'(Shift-)?.', // a single character
'(Shift-?|Ctrl-?|Alt-?|Cmd-?){0,2}(|Esc|Tab|(Page)?(Up|Down)|Left|Right|Home|End|Insert|Delete)',
].join('|') + ')$', 'i').test(key)) {
this.value = key || this.value;
this.setCustomValidity('Not allowed');
return;
}
}
this.value = key;
this.setCustomValidity('');
prefs.set(prefId, key);
},
oninput() {
// fired on pressing "x" to clear the field
prefs.set(prefId, '');
},
onpaste(event) {
event.preventDefault();
},
});
}, },
oninput() {
// fired on pressing "x" to clear the field async rerouteHotkeys(...args) {
prefs.set(prefId, ''); require(['./reroute-hotkeys'], res => res(...args));
}, },
onpaste(event) {
event.preventDefault(); 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');
}, },
});
} showCodeMirrorPopup(title, html, options) {
const popup = util.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();
util.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');
util.rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
},
trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return util.clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
},
};
return util;
});

View File

@ -7,44 +7,21 @@
<title>Loading...</title> <title>Loading...</title>
<link href="global.css" rel="stylesheet"> <link href="global.css" rel="stylesheet">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
<script src="js/polyfill.js"></script> <script src="js/polyfill.js"></script>
<script src="js/msg.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/prefs.js"></script>
<script src="js/dom.js"></script> <script src="js/dom.js"></script>
<script src="js/localization.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/style-injector.js"></script>
<script src="content/apply.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="install-usercss/install-usercss.js"></script>
<script src="msgbox/msgbox.js"></script> <link href="install-usercss/install-usercss.css" rel="stylesheet">
<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">
</head> </head>
<body id="stylus-install-usercss"> <body id="stylus-install-usercss">
<div class="container"> <div class="container">
@ -92,8 +69,6 @@
</div> </div>
</div> </div>
<script src="install-usercss/install-usercss.js"></script>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;"> <svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000"> <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"/> <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"/>

View File

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

View File

@ -1,34 +1,61 @@
/* global CodeMirror semverCompare closeCurrentTab messageBox download
$ $$ $create $createLink t prefs API */
'use strict'; 'use strict';
(() => { define(require => {
const params = new URLSearchParams(location.search); const {API} = require('/js/msg');
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1; const {closeCurrentTab} = require('/js/toolbox');
const initialUrl = params.get('updateUrl'); const {
$,
let installed = null; $create,
let installedDup = null; $createLink,
$remove,
const liveReload = initLiveReload(); $$remove,
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre')); } = require('/js/dom');
const t = require('/js/localization');
const theme = prefs.get('editor.theme'); const prefs = require('/js/prefs');
const cm = CodeMirror($('.main'), { const preinit = require('./preinit');
readOnly: true, const messageBox = require('/js/dlg/message-box');
colorpicker: true, const {styleCodeEmpty} = require('/js/sections-util');
theme, require('js!/vendor/semver-bundle/semver'); /* global semverCompare */
/*
* 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.
*/
const cmReady = require(['/vendor/codemirror/lib/codemirror'], async CM => {
await 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',
'/js/color/color-view',
]);
await require(['/edit/codemirror-default']);
return CM;
}); });
let cm, installed, installedDup;
const {tabId, initialUrl} = preinit;
const liveReload = initLiveReload();
const theme = prefs.get('editor.theme');
if (theme !== 'default') { if (theme !== 'default') {
document.head.appendChild($create('link', { require([`/vendor/codemirror/theme/${theme}.css`]); // not awaiting as it may be absent
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, // "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. // 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 (messageBox.element) messageBox.element.remove();
if (installed) liveReload.onToggled(); if (installed) liveReload.onToggled();
}); });
@ -40,192 +67,27 @@
} }
}, 200); }, 200);
init();
function updateMeta(style, dup = installedDup) { async function init() {
installedDup = dup; const {dup, style, error, sourceCode} = await preinit.ready;
const data = style.usercssData; if (!style && sourceCode == null) {
const dupData = dup && dup.usercssData; messageBox.alert(isNaN(error) ? error : 'HTTP Error ' + error, 'pre');
const versionTest = dup && semverCompare(data.version, dupData.version); return;
cm.setPreprocessor(data.preprocessor);
const installButtonLabel = t(
installed ? 'installButtonInstalled' :
!dup ? 'installButton' :
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
);
document.title = `${installButtonLabel} ${data.name}`;
$('.install').textContent = installButtonLabel;
$('.install').classList.add(
installed ? 'installed' :
!dup ? 'install' :
versionTest > 0 ? 'update' :
'reinstall');
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
$('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description;
if (data.author) {
$('.meta-author').parentNode.style.display = '';
$('.meta-author').textContent = '';
$('.meta-author').appendChild(makeAuthor(data.author));
} else {
$('.meta-author').parentNode.style.display = 'none';
} }
const CodeMirror = await cmReady;
$('.meta-license').parentNode.style.display = data.license ? '' : 'none'; cm = CodeMirror($('.main'), {
$('.meta-license').textContent = data.license; value: sourceCode || style.sourceCode,
readOnly: true,
$('.applies-to').textContent = ''; colorpicker: true,
getAppliesTo(style).forEach(pattern => theme,
$('.applies-to').appendChild($create('li', pattern))); });
if (error) {
$('.external-link').textContent = ''; showBuildError(error);
const externalLink = makeExternalLink();
if (externalLink) {
$('.external-link').appendChild(externalLink);
} }
if (!style) {
$('#header').classList.add('meta-init'); return;
$('#header').classList.remove('meta-init-error');
setTimeout(() => $.remove('.lds-spinner'), 1000);
showError('');
requestAnimationFrame(adjustCodeHeight);
function makeAuthor(text) {
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
if (!match) {
return document.createTextNode(text);
}
const [, name, email, url] = match;
const frag = document.createDocumentFragment();
if (email) {
frag.appendChild($createLink(`mailto:${email}`, name));
} else {
frag.appendChild($create('span', name));
}
if (url) {
frag.appendChild($createLink(url,
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
$create('SVG:path', {
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
}))
));
}
return frag;
} }
function makeExternalLink() {
const urls = [
data.homepageURL && [data.homepageURL, t('externalHomepage')],
data.supportURL && [data.supportURL, t('externalSupport')],
];
return (data.homepageURL || data.supportURL) && (
$create('div', [
$create('h3', t('externalLink')),
$create('ul', urls.map(args => args &&
$create('li',
$createLink(...args)
)
)),
]));
}
}
function showError(err) {
$('.warnings').textContent = '';
if (err) {
$('.warnings').appendChild(buildWarning(err));
}
$('.warnings').classList.toggle('visible', Boolean(err));
$('.container').classList.toggle('has-warnings', Boolean(err));
adjustCodeHeight();
}
function install(style) {
installed = style;
$$.remove('.warning');
$('button.install').disabled = true;
$('button.install').classList.add('installed');
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
$('h2.installed').classList.add('active');
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : '';
updateMeta(style);
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
location.href = '/edit.html?id=' + style.id;
} else {
API.openEditor({id: style.id});
if (!liveReload.enabled) {
if (tabId < 0 && history.length > 1) {
history.back();
} else {
closeCurrentTab();
}
}
}
}
function initSourceCode(sourceCode) {
cm.setValue(sourceCode);
cm.refresh();
API.usercss.build({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 data = style.usercssData;
const dupData = dup && dup.usercssData; const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version); const versionTest = dup && semverCompare(data.version, dupData.version);
@ -279,21 +141,186 @@
} }
} }
function getAppliesTo(style) { function updateMeta(style, dup = installedDup) {
function *_gen() { installedDup = dup;
for (const section of style.sections) { const data = style.usercssData;
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) { const dupData = dup && dup.usercssData;
if (section[type]) { const versionTest = dup && semverCompare(data.version, dupData.version);
yield *section[type];
} cm.setPreprocessor(data.preprocessor);
const installButtonLabel = t(
installed ? 'installButtonInstalled' :
!dup ? 'installButton' :
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
);
document.title = `${installButtonLabel} ${data.name}`;
$('.install').textContent = installButtonLabel;
$('.install').classList.add(
installed ? 'installed' :
!dup ? 'install' :
versionTest > 0 ? 'update' :
'reinstall');
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
$('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description;
if (data.author) {
$('.meta-author').parentNode.style.display = '';
$('.meta-author').textContent = '';
$('.meta-author').appendChild(makeAuthor(data.author));
} else {
$('.meta-author').parentNode.style.display = 'none';
}
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
$('.meta-license').textContent = data.license;
$('.applies-to').textContent = '';
getAppliesTo(style).then(list =>
$('.applies-to').append(...list.map(s => $create('li', s))));
$('.external-link').textContent = '';
const externalLink = makeExternalLink();
if (externalLink) {
$('.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);
showError('');
requestAnimationFrame(adjustCodeHeight);
function makeAuthor(text) {
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
if (!match) {
return document.createTextNode(text);
}
const [, name, email, url] = match;
const frag = document.createDocumentFragment();
if (email) {
frag.appendChild($createLink(`mailto:${email}`, name));
} else {
frag.appendChild($create('span', name));
}
if (url) {
frag.appendChild($createLink(url,
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
$create('SVG:path', {
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
}))
));
}
return frag;
}
function makeExternalLink() {
const urls = [
data.homepageURL && [data.homepageURL, t('externalHomepage')],
data.supportURL && [data.supportURL, t('externalSupport')],
];
return (data.homepageURL || data.supportURL) && (
$create('div', [
$create('h3', t('externalLink')),
$create('ul', urls.map(args => args &&
$create('li',
$createLink(...args)
)
)),
]));
}
}
function showError(err) {
$('.warnings').textContent = '';
$('.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');
$('button.install').disabled = true;
$('button.install').classList.add('installed');
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
$('h2.installed').classList.add('active');
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : '';
updateMeta(style);
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
location.href = '/edit.html?id=' + style.id;
} else {
API.openEditor({id: style.id});
if (!liveReload.enabled) {
if (tabId < 0 && history.length > 1) {
history.back();
} else {
closeCurrentTab();
} }
} }
} }
const result = [..._gen()]; }
if (!result.length) {
result.push(chrome.i18n.getMessage('appliesToEverything')); async function getAppliesTo(style) {
if (style.sectionsPromise) {
try {
style.sections = await style.sectionsPromise;
} catch (error) {
showBuildError(error);
return [];
} finally {
delete style.sectionsPromise;
}
} }
return result; let numGlobals = 0;
const res = [];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
for (const section of style.sections) {
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'));
}
return [...new Set(res)];
} }
function adjustCodeHeight() { function adjustCodeHeight() {
@ -311,24 +338,12 @@
const DELAY = 500; const DELAY = 500;
let isEnabled = false; let isEnabled = false;
let timer = 0; let timer = 0;
/** @type function(?options):Promise<string|null> */ const getData = preinit.getData;
let getData = null; let sequence = preinit.ready;
/** @type Promise */
let sequence = null;
if (tabId < 0) {
getData = DirectDownloader();
sequence = API.usercss.getInstallCode(initialUrl)
.then(code => code || getData())
.catch(getData);
} else {
getData = PortDownloader();
sequence = getData({timer: false});
}
return { return {
get enabled() { get enabled() {
return isEnabled; return isEnabled;
}, },
ready: sequence,
onToggled(e) { onToggled(e) {
if (e) isEnabled = e.target.checked; if (e) isEnabled = e.target.checked;
if (installed || installedDup) { if (installed || installedDup) {
@ -377,42 +392,5 @@
.catch(showError); .catch(showError);
}); });
} }
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);
});
}
} }
})(); });

View File

@ -0,0 +1,90 @@
'use strict';
define(require => {
const {API} = require('/js/msg');
const {closeCurrentTab, download} = require('/js/toolbox');
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 +1,66 @@
/* exported createCache */
'use strict'; 'use strict';
// create a FIFO limit-size map. /**
function createCache({size = 1000, onDeleted} = {}) { * Creates a FIFO limit-size map.
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) { define(require =>
const item = map.get(id); function createCache({size = 1000, onDeleted} = {}) {
return item && item.data; const map = new Map();
} const buffer = Array(size);
let index = 0;
function set(id, data) { let lastIndex = 0;
if (map.size === size) { return {
// full get(id) {
map.delete(buffer[lastIndex].id); const item = map.get(id);
if (onDeleted) { return item && item.data;
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data); },
} set(id, data) {
lastIndex = (lastIndex + 1) % size; if (map.size === size) {
} // full
const item = {id, data, index}; map.delete(buffer[lastIndex].id);
map.set(id, item); if (onDeleted) {
buffer[index] = item; onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
index = (index + 1) % size; }
} lastIndex = (lastIndex + 1) % size;
}
function delete_(id) { const item = {id, data, index};
const item = map.get(id); map.set(id, item);
if (!item) { buffer[index] = item;
return false; index = (index + 1) % size;
} },
map.delete(item.id); delete(id) {
const lastItem = buffer[lastIndex]; const item = map.get(id);
lastItem.index = item.index; if (!item) {
buffer[item.index] = lastItem; return false;
lastIndex = (lastIndex + 1) % size; }
if (onDeleted) { map.delete(item.id);
onDeleted(item.id, item.data); const lastItem = buffer[lastIndex];
} lastItem.index = item.index;
return true; buffer[item.index] = lastItem;
} lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
function clear() { onDeleted(item.id, item.data);
map.clear(); }
index = lastIndex = 0; return true;
} },
} clear() {
map.clear();
index = lastIndex = 0;
},
has: id => map.has(id),
*entries() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
*values() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
});

378
js/color/color-converter.js Normal file
View File

@ -0,0 +1,378 @@
'use strict';
define(require => {
let colorConverter;
const {
NAMED_COLORS,
HSLtoHSV,
HSVtoRGB,
constrainHue,
formatAlpha,
snapToInt,
} = colorConverter = {
ALPHA_DIGITS: 3,
/** @type {Map<string,string>} */
NAMED_COLORS: makeNamedColors(),
HSLtoHSV({h, s, l, a}) {
const t = s * (l < 50 ? l : 100 - l) / 100;
return {
h: constrainHue(h),
s: t + l ? 200 * t / (t + l) / 100 : 0,
v: (t + l) / 100,
a,
};
},
HSVtoHSL({h, s, v}) {
const l = (2 - s) * v / 2;
const t = l < .5 ? l * 2 : 2 - l * 2;
return {
h: Math.round(constrainHue(h)),
s: Math.round(t ? s * v / t * 100 : 0),
l: Math.round(l * 100),
};
},
HSVtoRGB({h, s, v}) {
h = constrainHue(h) % 360;
const C = s * v;
const X = C * (1 - Math.abs((h / 60) % 2 - 1));
const m = v - C;
const [r, g, b] =
h >= 0 && h < 60 ? [C, X, 0] :
h >= 60 && h < 120 ? [X, C, 0] :
h >= 120 && h < 180 ? [0, C, X] :
h >= 180 && h < 240 ? [0, X, C] :
h >= 240 && h < 300 ? [X, 0, C] :
h >= 300 && h < 360 ? [C, 0, X] : [];
return {
r: snapToInt(Math.round((r + m) * 255)),
g: snapToInt(Math.round((g + m) * 255)),
b: snapToInt(Math.round((b + m) * 255)),
};
},
RGBtoHSV({r, g, b, a}) {
r /= 255;
g /= 255;
b /= 255;
const MaxC = Math.max(r, g, b);
const MinC = Math.min(r, g, b);
const DeltaC = MaxC - MinC;
let h =
DeltaC === 0 ? 0 :
MaxC === r ? 60 * (((g - b) / DeltaC) % 6) :
MaxC === g ? 60 * (((b - r) / DeltaC) + 2) :
MaxC === b ? 60 * (((r - g) / DeltaC) + 4) :
0;
h = constrainHue(h);
return {
h,
s: MaxC === 0 ? 0 : DeltaC / MaxC,
v: MaxC,
a,
};
},
constrainHue(h) {
return h < 0 ? h % 360 + 360 :
h > 360 ? h % 360 :
h;
},
format(color = '', type = color.type, hexUppercase) {
if (!color || !type) return typeof color === 'string' ? color : '';
const a = formatAlpha(color.a);
const hasA = Boolean(a);
if (type === 'rgb' && color.type === 'hsl') {
color = HSVtoRGB(HSLtoHSV(color));
}
const {r, g, b, h, s, l} = color;
switch (type) {
case 'hex': {
const rgbStr = (0x1000000 + (r << 16) + (g << 8) + (b | 0)).toString(16).slice(1);
const aStr = hasA ? (0x100 + Math.round(a * 255)).toString(16).slice(1) : '';
const hexStr = `#${rgbStr + aStr}`.replace(/^#(.)\1(.)\2(.)\3(?:(.)\4)?$/, '#$1$2$3$4');
return hexUppercase ? hexStr.toUpperCase() : hexStr.toLowerCase();
}
case 'rgb':
return hasA ?
`rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})` :
`rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
case 'hsl':
return hasA ?
`hsla(${h}, ${s}%, ${l}%, ${a})` :
`hsl(${h}, ${s}%, ${l}%)`;
}
},
formatAlpha(a) {
return isNaN(a) ? '' :
(a + .5 * Math.pow(10, -colorConverter.ALPHA_DIGITS))
.toFixed(colorConverter.ALPHA_DIGITS + 1)
.slice(0, -1)
.replace(/^0(?=\.[1-9])|^1\.0+?$|\.?0+$/g, '');
},
parse(str) {
if (typeof str !== 'string') return;
str = str.trim();
if (!str) return;
if (str[0] !== '#' && !str.includes('(')) {
// eslint-disable-next-line no-use-before-define
str = NAMED_COLORS.get(str);
if (!str) return;
}
if (str[0] === '#') {
if (!validateHex(str)) {
return null;
}
str = str.slice(1);
const [r, g, b, a = 255] = str.length <= 4 ?
str.match(/(.)/g).map(c => parseInt(c + c, 16)) :
str.match(/(..)/g).map(c => parseInt(c, 16));
return {type: 'hex', r, g, b, a: a === 255 ? undefined : a / 255};
}
const [, type, value] = str.match(/^(rgb|hsl)a?\((.*?)\)|$/i);
if (!type) return;
const comma = value.includes(',') && !value.includes('/');
const num = value.trim().split(comma ? /\s*,\s*/ : /\s+(?!\/)|\s*\/\s*/);
if (num.length < 3 || num.length > 4) return;
if (num[3] && !validateAlpha(num[3])) return null;
let a = !num[3] ? 1 : parseFloat(num[3]) / (num[3].endsWith('%') ? 100 : 1);
if (isNaN(a)) a = 1;
const first = num[0];
if (/rgb/i.test(type)) {
if (!validateRGB(num)) {
return null;
}
const k = first.endsWith('%') ? 2.55 : 1;
const [r, g, b] = num.map(s => Math.round(parseFloat(s) * k));
return {type: 'rgb', r, g, b, a};
} else {
if (!validateHSL(num)) {
return null;
}
let h = parseFloat(first);
if (first.endsWith('grad')) h *= 360 / 400;
else if (first.endsWith('rad')) h *= 180 / Math.PI;
else if (first.endsWith('turn')) h *= 360;
const s = parseFloat(num[1]);
const l = parseFloat(num[2]);
return {type: 'hsl', h, s, l, a};
}
},
snapToInt(num) {
const int = Math.round(num);
return Math.abs(int - num) < 1e-3 ? int : num;
},
};
// Copied from _hexcolor() in parserlib.js
function validateHex(color) {
return /^#[a-f\d]+$/i.test(color) && [4, 5, 7, 9].some(n => color.length === n);
}
function validateRGB(nums) {
const isPercentage = nums[0].endsWith('%');
const valid = isPercentage ? validatePercentage : validateNum;
return nums.slice(0, 3).every(valid);
}
function validatePercentage(s) {
if (!s.endsWith('%')) return false;
const n = Number(s.slice(0, -1));
return n >= 0 && n <= 100;
}
function validateNum(s) {
const n = Number(s);
return n >= 0 && n <= 255;
}
function validateHSL(nums) {
return validateAngle(nums[0]) && nums.slice(1, 3).every(validatePercentage);
}
function validateAngle(s) {
return /^-?(\d+|\d*\.\d+)(deg|grad|rad|turn)?$/i.test(s);
}
function validateAlpha(alpha) {
if (alpha.endsWith('%')) {
return validatePercentage(alpha);
}
const n = Number(alpha);
return n >= 0 && n <= 1;
}
function makeNamedColors() {
return new Map([
['transparent', 'rgba(0, 0, 0, 0)'],
// CSS4 named colors
['aliceblue', '#f0f8ff'],
['antiquewhite', '#faebd7'],
['aqua', '#00ffff'],
['aquamarine', '#7fffd4'],
['azure', '#f0ffff'],
['beige', '#f5f5dc'],
['bisque', '#ffe4c4'],
['black', '#000000'],
['blanchedalmond', '#ffebcd'],
['blue', '#0000ff'],
['blueviolet', '#8a2be2'],
['brown', '#a52a2a'],
['burlywood', '#deb887'],
['cadetblue', '#5f9ea0'],
['chartreuse', '#7fff00'],
['chocolate', '#d2691e'],
['coral', '#ff7f50'],
['cornflowerblue', '#6495ed'],
['cornsilk', '#fff8dc'],
['crimson', '#dc143c'],
['cyan', '#00ffff'],
['darkblue', '#00008b'],
['darkcyan', '#008b8b'],
['darkgoldenrod', '#b8860b'],
['darkgray', '#a9a9a9'],
['darkgrey', '#a9a9a9'],
['darkgreen', '#006400'],
['darkkhaki', '#bdb76b'],
['darkmagenta', '#8b008b'],
['darkolivegreen', '#556b2f'],
['darkorange', '#ff8c00'],
['darkorchid', '#9932cc'],
['darkred', '#8b0000'],
['darksalmon', '#e9967a'],
['darkseagreen', '#8fbc8f'],
['darkslateblue', '#483d8b'],
['darkslategray', '#2f4f4f'],
['darkslategrey', '#2f4f4f'],
['darkturquoise', '#00ced1'],
['darkviolet', '#9400d3'],
['deeppink', '#ff1493'],
['deepskyblue', '#00bfff'],
['dimgray', '#696969'],
['dimgrey', '#696969'],
['dodgerblue', '#1e90ff'],
['firebrick', '#b22222'],
['floralwhite', '#fffaf0'],
['forestgreen', '#228b22'],
['fuchsia', '#ff00ff'],
['gainsboro', '#dcdcdc'],
['ghostwhite', '#f8f8ff'],
['gold', '#ffd700'],
['goldenrod', '#daa520'],
['gray', '#808080'],
['grey', '#808080'],
['green', '#008000'],
['greenyellow', '#adff2f'],
['honeydew', '#f0fff0'],
['hotpink', '#ff69b4'],
['indianred', '#cd5c5c'],
['indigo', '#4b0082'],
['ivory', '#fffff0'],
['khaki', '#f0e68c'],
['lavender', '#e6e6fa'],
['lavenderblush', '#fff0f5'],
['lawngreen', '#7cfc00'],
['lemonchiffon', '#fffacd'],
['lightblue', '#add8e6'],
['lightcoral', '#f08080'],
['lightcyan', '#e0ffff'],
['lightgoldenrodyellow', '#fafad2'],
['lightgray', '#d3d3d3'],
['lightgrey', '#d3d3d3'],
['lightgreen', '#90ee90'],
['lightpink', '#ffb6c1'],
['lightsalmon', '#ffa07a'],
['lightseagreen', '#20b2aa'],
['lightskyblue', '#87cefa'],
['lightslategray', '#778899'],
['lightslategrey', '#778899'],
['lightsteelblue', '#b0c4de'],
['lightyellow', '#ffffe0'],
['lime', '#00ff00'],
['limegreen', '#32cd32'],
['linen', '#faf0e6'],
['magenta', '#ff00ff'],
['maroon', '#800000'],
['mediumaquamarine', '#66cdaa'],
['mediumblue', '#0000cd'],
['mediumorchid', '#ba55d3'],
['mediumpurple', '#9370db'],
['mediumseagreen', '#3cb371'],
['mediumslateblue', '#7b68ee'],
['mediumspringgreen', '#00fa9a'],
['mediumturquoise', '#48d1cc'],
['mediumvioletred', '#c71585'],
['midnightblue', '#191970'],
['mintcream', '#f5fffa'],
['mistyrose', '#ffe4e1'],
['moccasin', '#ffe4b5'],
['navajowhite', '#ffdead'],
['navy', '#000080'],
['oldlace', '#fdf5e6'],
['olive', '#808000'],
['olivedrab', '#6b8e23'],
['orange', '#ffa500'],
['orangered', '#ff4500'],
['orchid', '#da70d6'],
['palegoldenrod', '#eee8aa'],
['palegreen', '#98fb98'],
['paleturquoise', '#afeeee'],
['palevioletred', '#db7093'],
['papayawhip', '#ffefd5'],
['peachpuff', '#ffdab9'],
['peru', '#cd853f'],
['pink', '#ffc0cb'],
['plum', '#dda0dd'],
['powderblue', '#b0e0e6'],
['purple', '#800080'],
['rebeccapurple', '#663399'],
['red', '#ff0000'],
['rosybrown', '#bc8f8f'],
['royalblue', '#4169e1'],
['saddlebrown', '#8b4513'],
['salmon', '#fa8072'],
['sandybrown', '#f4a460'],
['seagreen', '#2e8b57'],
['seashell', '#fff5ee'],
['sienna', '#a0522d'],
['silver', '#c0c0c0'],
['skyblue', '#87ceeb'],
['slateblue', '#6a5acd'],
['slategray', '#708090'],
['slategrey', '#708090'],
['snow', '#fffafa'],
['springgreen', '#00ff7f'],
['steelblue', '#4682b4'],
['tan', '#d2b48c'],
['teal', '#008080'],
['thistle', '#d8bfd8'],
['tomato', '#ff6347'],
['turquoise', '#40e0d0'],
['violet', '#ee82ee'],
['wheat', '#f5deb3'],
['white', '#ffffff'],
['whitesmoke', '#f5f5f5'],
['yellow', '#ffff00'],
['yellowgreen', '#9acd32'],
]);
}
return colorConverter;
});

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

@ -0,0 +1,90 @@
'use strict';
define(require => {
const {debounce} = require('/js/toolbox');
const {$create} = require('/js/dom');
const styleCache = new Map();
// Calculates real color of an element:
// colorMimicry(cm.display.gutters, {bg: 'backgroundColor'})
// colorMimicry('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
return function colorMimicry(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,9 +1,10 @@
/* global colorConverter $create debounce */
/* exported colorMimicry */
'use strict'; 'use strict';
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () { define(require => {
const cm = this; const colorMimicry = require('./color-mimicry');
const colorConverter = require('./color-converter');
require(['./color-picker.css']);
const CSS_PREFIX = 'colorpicker-'; const CSS_PREFIX = 'colorpicker-';
const HUE_COLORS = [ const HUE_COLORS = [
{hex: '#ff0000', start: .0}, {hex: '#ff0000', start: .0},
@ -59,7 +60,7 @@
let lastOutputColor; let lastOutputColor;
let userActivity; let userActivity;
const PUBLIC_API = { const colorPicker = {
$root, $root,
show, show,
hide, hide,
@ -67,7 +68,7 @@
getColor, getColor,
options, options,
}; };
return PUBLIC_API; return colorPicker;
//region DOM //region DOM
@ -203,7 +204,7 @@
} }
HSV = {}; HSV = {};
currentFormat = ''; currentFormat = '';
options = PUBLIC_API.options = opt; options = colorPicker.options = opt;
prevFocusedElement = document.activeElement; prevFocusedElement = document.activeElement;
userActivity = 0; userActivity = 0;
lastOutputColor = opt.color || ''; lastOutputColor = opt.color || '';
@ -586,7 +587,7 @@
document.removeEventListener('mouseup', onPopupResizeEnd); document.removeEventListener('mouseup', onPopupResizeEnd);
if (maxHeight !== $root.style.height) { if (maxHeight !== $root.style.height) {
maxHeight = $root.style.height; maxHeight = $root.style.height;
PUBLIC_API.options.maxHeight = parseFloat(maxHeight); colorPicker.options.maxHeight = parseFloat(maxHeight);
fitPaletteHeight(); fitPaletteHeight();
} }
} }
@ -677,12 +678,12 @@
} }
function onCloseRequest(event) { function onCloseRequest(event) {
if (event.detail !== PUBLIC_API) { if (event.detail !== colorPicker) {
hide(); hide();
} else if (!prevFocusedElement) { } else if (!prevFocusedElement && options.cm) {
// we're between mousedown and mouseup and colorview wants to re-open us in this 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 // so we'll prevent onMouseUp from hiding us to avoid flicker
prevFocusedElement = cm.display.input; prevFocusedElement = options.cm.display.input;
} }
} }
@ -866,10 +867,11 @@
} }
function guessTheme() { function guessTheme() {
const {cm} = options;
const el = options.guessBrightness || const el = options.guessBrightness ||
((cm.display.renderedView || [])[0] || {}).text || cm && ((cm.display.renderedView || [])[0] || {}).text ||
cm.display.lineDiv; cm && cm.display.lineDiv;
const bgLuma = window.colorMimicry.get(el, {bg: 'backgroundColor'}).bgLuma; const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma;
return bgLuma < .5 ? 'dark' : 'light'; return bgLuma < .5 ? 'dark' : 'light';
} }
@ -892,93 +894,4 @@
} }
//endregion //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,7 +1,9 @@
/* global CodeMirror colorConverter */
'use strict'; 'use strict';
(() => { define(require => {
const colorConverter = require('./color-converter');
const CodeMirror = require('/vendor/codemirror/lib/codemirror');
//region Constants //region Constants
const COLORVIEW_CLASS = 'colorview'; const COLORVIEW_CLASS = 'colorview';
@ -99,13 +101,12 @@
const cache = new Set(); const cache = new Set();
class ColorSwatch { class ColorSwatch {
constructor(cm, options) { constructor(cm, options = {}) {
this.cm = cm; this.cm = cm;
this.options = options; this.options = options;
this.markersToRemove = []; this.markersToRemove = [];
this.markersToRepaint = []; this.markersToRepaint = [];
this.popup = cm.colorpicker && cm.colorpicker(); if (!options.popup) {
if (!this.popup) {
delete CM_EVENTS.mousedown; delete CM_EVENTS.mousedown;
document.head.appendChild(document.createElement('style')).textContent = ` document.head.appendChild(document.createElement('style')).textContent = `
.colorview-swatch::before { .colorview-swatch::before {
@ -122,7 +123,9 @@
} }
openPopup(color) { openPopup(color) {
if (this.popup) openPopupForCursor(this, color); if (this.options.popup) {
openPopupForCursor(this, color);
}
} }
registerEvents() { registerEvents() {
@ -534,7 +537,10 @@
} }
function doOpenPopup(state, data) { async function doOpenPopup(state, data) {
if (!state.popup) {
state.popup = await require(['/js/color/color-picker']);
}
const {left, bottom: top} = state.cm.charCoords(data, 'window'); const {left, bottom: top} = state.cm.charCoords(data, 'window');
state.popup.show(Object.assign(state.options.popup, data, { state.popup.show(Object.assign(state.options.popup, data, {
top, top,
@ -774,4 +780,4 @@
} }
//endregion //endregion
})(); });

1766
js/csslint/csslint.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ THE SOFTWARE.
'use strict'; 'use strict';
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
self.parserlib = (() => { define(require => {
//#region Properties //#region Properties
@ -3191,9 +3191,9 @@ self.parserlib = (() => {
for (const msg of messages) { for (const msg of messages) {
const {line, col} = msg; const {line, col} = msg;
if (L1 === L2 && line === L1 && C1 <= col && col <= C2 || if (L1 === L2 && line === L1 && C1 <= col && col <= C2 ||
line === L1 && col >= C1 || line === L1 && col >= C1 ||
line === L2 && col <= C2 || line === L2 && col <= C2 ||
line > L1 && line < L2) { line > L1 && line < L2) {
messages.delete(msg); messages.delete(msg);
isClean = false; isClean = false;
} }
@ -4685,7 +4685,8 @@ self.parserlib = (() => {
//#endregion //#endregion
//#region PUBLIC API //#region PUBLIC API
return { /** @type {parserlib} */
return /** @namespace parserlib */ {
css: { css: {
Colors, Colors,
Combinator, Combinator,
@ -4715,4 +4716,4 @@ self.parserlib = (() => {
}; };
//#endregion //#endregion
})(); });

457
js/dlg/config-dialog.js Normal file
View File

@ -0,0 +1,457 @@
'use strict';
define(require => {
const {API} = require('/js/msg');
const {debounce, deepCopy} = require('/js/toolbox');
const t = require('/js/localization');
const {
$,
$create,
$createLink,
$remove,
setupLivePrefs,
} = require('/js/dom');
const prefs = require('/js/prefs');
const colorPicker = require('/js/color/color-picker');
const messageBox = require('./message-box');
require('./config-dialog.css');
return function configDialog(style) {
const AUTOSAVE_DELAY = 500;
let saving = false;
const data = style.usercssData;
const varsHash = deepCopy(data.vars) || {};
const varNames = Object.keys(varsHash);
const vars = varNames.map(name => varsHash[name]);
let varsInitial = getInitialValues(varsHash);
const elements = [];
const isPopup = location.href.includes('popup.html');
const buttons = {};
buildConfigForm();
renderValues();
vars.forEach(renderValueState);
return messageBox.show({
title: `${style.customName || style.name} v${data.version}`,
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
contents: [
$create('.config-heading', data.supportURL &&
$createLink({className: '.external-support', href: data.supportURL},
t('externalFeedback'))),
$create('.config-body', elements),
],
buttons: [
{
textContent: t('confirmSave'),
dataset: {cmd: 'save'},
disabled: true,
onclick: save,
}, {
textContent: t('genericResetLabel'),
title: t('optionsReset'),
dataset: {cmd: 'default'},
onclick: useDefault,
}, {
textContent: t('confirmClose'),
dataset: {cmd: 'close'},
},
],
onshow,
}).then(onhide);
function getInitialValues(source) {
const data = {};
for (const name of varNames) {
const va = source[name];
data[name] = isDefault(va) ? va.default : va.value;
}
return data;
}
function onshow(box) {
$('button', box).insertAdjacentElement('afterend',
$create('label#config-autosave-wrapper', {
title: t('configOnChangeTooltip'),
}, [
$create('input', {id: 'config.autosave', type: 'checkbox'}),
$create('SVG:svg.svg-icon.checked',
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
t('configOnChange'),
]));
setupLivePrefs(['config.autosave']);
if (isPopup) {
adjustSizeForPopup(box);
}
box.on('change', onchange);
buttons.save = $('[data-cmd="save"]', box);
buttons.default = $('[data-cmd="default"]', box);
buttons.close = $('[data-cmd="close"]', box);
updateButtons();
}
function onhide() {
document.body.style.minWidth = '';
document.body.style.minHeight = '';
colorPicker.hide();
}
function onchange({target, justSaved = false}) {
// invoked after element's own onchange so 'va' contains the updated value
const va = target.va;
if (va) {
va.dirty = varsInitial[va.name] !== (isDefault(va) ? va.default : va.value);
if (prefs.get('config.autosave') && !justSaved) {
debounce(save, 100, {anyChangeIsDirty: true});
return;
}
renderValueState(va);
if (!justSaved) {
updateButtons();
}
}
}
function updateButtons() {
const someDirty = vars.some(va => va.dirty);
buttons.save.disabled = !someDirty;
buttons.default.disabled = vars.every(isDefault);
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
}
async function save({anyChangeIsDirty = false} = {}, bgStyle) {
for (let delay = 1; saving && delay < 1000; delay *= 2) {
await new Promise(resolve => setTimeout(resolve, delay));
}
if (saving) {
throw 'Could not save: still saving previous results...';
}
if (!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
return;
}
if (!bgStyle) {
bgStyle = await API.styles.get(style.id).catch(() => ({}));
}
style = style.sections ? Object.assign({}, style) : style;
style.enabled = true;
style.sourceCode = null;
style.sections = null;
const styleVars = style.usercssData.vars;
const bgVars = (bgStyle.usercssData || {}).vars || {};
const invalid = [];
let numValid = 0;
for (const va of vars) {
const bgva = bgVars[va.name];
let error;
if (!bgva) {
error = 'deleted';
delete styleVars[va.name];
} 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)
) {
error = `'${va.value}' not in the updated '${va.type}' list`;
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
continue;
} else {
styleVars[va.name].value = va.value;
va.savedValue = va.value;
numValid++;
continue;
}
invalid.push([
'*' + va.name, ': ',
...error].map(e => e[0] === '*' && $create('b', e.slice(1)) || e));
if (bgva) {
styleVars[va.name].value = deepCopy(bgva);
}
}
if (invalid.length) {
onhide();
messageBox.alert([
$create('div', {style: 'max-width: 34em'}, t('usercssConfigIncomplete')),
$create('ol', {style: 'text-align: left'},
invalid.map(msg =>
$create({tag: 'li', appendChild: msg}))),
], 'pre');
}
if (!numValid) {
return;
}
saving = true;
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) {
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 || `${errors}`;
}
saving = false;
}
function useDefault() {
for (const va of vars) {
va.value = null;
onchange({target: va.input});
}
renderValues();
}
function isDefault(va) {
return va.value === null || va.value === undefined || va.value === va.default;
}
function buildConfigForm() {
let resetter =
$create('a.config-reset-icon', {href: '#'}, [
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'}, [
$create('SVG:title', t('genericResetLabel')),
$create('SVG:polygon', {
points: '16.2,5.5 14.5,3.8 10,8.3 5.5,3.8 3.8,5.5 8.3,10 3.8,14.5 ' +
'5.5,16.2 10,11.7 14.5,16.2 16.2,14.5 11.7,10',
}),
]),
]);
for (const va of vars) {
let children;
switch (va.type) {
case 'color':
children = [
$create('.colorview-swatch.config-value', [
va.input = $create('a.color-swatch', {
va,
href: '#',
onclick: showColorpicker,
}),
]),
];
break;
case 'checkbox':
children = [
$create('span.onoffswitch.config-value', [
va.input = $create('input.slider', {
va,
type: 'checkbox',
onchange: updateVarOnChange,
}),
$create('span'),
]),
];
break;
case 'select':
case 'dropdown':
case 'image':
// TODO: a image picker input?
children = [
$create('.select-resizer.config-value', [
va.input = $create('select', {
va,
onchange: updateVarOnChange,
},
va.options.map(o =>
$create('option', {value: o.name}, o.label))),
$create('SVG:svg.svg-icon.select-arrow',
$create('SVG:use', {'xlink:href': '#svg-icon-select-arrow'})),
]),
];
break;
case 'range':
case 'number': {
const options = {
va,
type: va.type,
onfocus: va.type === 'number' ? selectAllOnFocus : null,
onblur: va.type === 'number' ? updateVarOnBlur : null,
onchange: updateVarOnChange,
oninput: updateVarOnInput,
required: true,
};
if (typeof va.min === 'number') {
options.min = va.min;
}
if (typeof va.max === 'number') {
options.max = va.max;
}
if (typeof va.step === 'number' && isFinite(va.step)) {
options.step = va.step;
}
children = [
va.type === 'range' && $create('span.current-value'),
va.input = $create('input.config-value', options),
];
break;
}
default:
children = [
va.input = $create('input.config-value', {
va,
type: va.type,
onchange: updateVarOnChange,
oninput: updateVarOnInput,
onfocus: selectAllOnFocus,
}),
];
}
resetter = resetter.cloneNode(true);
resetter.va = va;
resetter.onclick = resetOnClick;
elements.push(
$create(`label.config-${va.type}`, [
$create('span.config-name', t.breakWord(va.label)),
...children,
resetter,
]));
va.savedValue = va.value;
}
}
function updateVarOnBlur() {
this.value = isDefault(this.va) ? this.va.default : this.va.value;
}
function updateVarOnChange() {
if (this.type === 'range') {
this.va.value = Number(this.value);
updateRangeCurrentValue(this.va, this.va.value);
} else if (this.type === 'number') {
if (this.reportValidity()) {
this.va.value = Number(this.value);
}
} else {
this.va.value = this.type !== 'checkbox' ? this.value : this.checked ? '1' : '0';
}
}
function updateRangeCurrentValue(va, value) {
const span = $('.current-value', va.input.closest('.config-range'));
if (span) {
span.textContent = value + (va.units || '');
}
}
function updateVarOnInput(event, debounced = false) {
if (debounced) {
event.target.dispatchEvent(new Event('change', {bubbles: true}));
} else {
debounce(updateVarOnInput, AUTOSAVE_DELAY, event, true);
}
}
function selectAllOnFocus(event) {
event.target.select();
}
function renderValues(varsToRender = vars) {
for (const va of varsToRender) {
if (va.input === document.activeElement) {
continue;
}
const value = isDefault(va) ? va.default : va.value;
if (va.type === 'color') {
va.input.style.backgroundColor = value;
if (colorPicker.options.va === va) {
colorPicker.setColor(value);
}
} else if (va.type === 'checkbox') {
va.input.checked = Number(value);
} else if (va.type === 'range') {
va.input.value = value;
updateRangeCurrentValue(va, va.input.value);
} else {
va.input.value = value;
}
if (!prefs.get('config.autosave')) {
renderValueState(va);
}
}
}
function renderValueState(va) {
const el = va.input.closest('label');
el.classList.toggle('dirty', Boolean(va.dirty));
el.classList.toggle('nondefault', !isDefault(va));
$('.config-reset-icon', el).disabled = isDefault(va);
}
function resetOnClick(event) {
event.preventDefault();
this.va.value = null;
renderValues([this.va]);
onchange({target: this.va.input});
}
function showColorpicker(event) {
event.preventDefault();
window.off('keydown', messageBox.listeners.key, true);
const box = $('#message-box-contents');
colorPicker.show({
va: this.va,
color: this.va.value || this.va.default,
top: this.getBoundingClientRect().bottom - 5,
left: box.getBoundingClientRect().left - 360,
guessBrightness: box,
callback: onColorChanged,
});
}
function onColorChanged(newColor) {
if (newColor) {
this.va.value = newColor;
this.va.input.style.backgroundColor = newColor;
this.va.input.dispatchEvent(new Event('change', {bubbles: true}));
}
debounce(restoreEscInDialog);
}
function restoreEscInDialog() {
if (!$('.colorpicker-popup') && messageBox.element) {
window.on('keydown', messageBox.listeners.key, true);
}
}
function adjustSizeForPopup(box) {
const contents = box.firstElementChild;
contents.style = 'max-width: none; max-height: none;'.replace(/;/g, '!important;');
let {offsetWidth: width, offsetHeight: height} = contents;
contents.style = '';
const colorpicker = document.body.appendChild(
$create('.colorpicker-popup', {style: 'display: none!important'}));
const PADDING = 50;
const MIN_WIDTH = parseFloat(getComputedStyle(colorpicker).width) || 350;
const MIN_HEIGHT = 250 + PADDING;
colorpicker.remove();
width = constrain(MIN_WIDTH, 798, width + PADDING);
height = constrain(MIN_HEIGHT, 598, height + PADDING);
document.body.style.setProperty('min-width', width + 'px', 'important');
document.body.style.setProperty('min-height', height + 'px', 'important');
}
function constrain(min, max, value) {
return value < min ? min : value > max ? max : value;
}
};
});

207
js/dlg/message-box.js Normal file
View File

@ -0,0 +1,207 @@
'use strict';
define(require => {
const t = require('/js/localization');
const {
$,
$create,
animateElement,
focusAccessibility,
moveFocus,
} = require('/js/dom');
// TODO: convert this singleton mess so we can show many boxes at once
/** @type {MessageBox} */
const mess = /** @namespace MessageBox */ {
blockScroll: null,
element: null,
listeners: null,
originalFocus: null,
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 show
*/
alert(contents, className, title) {
return mess.show({
title,
contents,
className: `center ${className || ''}`,
buttons: [t('confirmClose')],
});
},
/**
* @param {String|Node|Array<String|Node>} contents
* @param {String} [className] like 'pre' for monospace font
* @param {String} [title]
* @returns {Promise<Boolean>} resolves to true when confirmed
*/
async confirm(contents, className, title) {
const res = await mess.show({
title,
contents,
className: `center ${className || ''}`,
buttons: [t('confirmYes'), t('confirmNo')],
});
return res.button === 0 || res.enter;
},
/**
* @exports MessageBox
* @param {Object} params
* @param {String} params.title
* @param {String|Node|Object|Array<String|Node|Object>} params.contents
* a string gets parsed via t.HTML,
* a non-string is passed as is to $create()
* @param {String} [params.className]
* CSS class name of the message box element
* @param {Array<String|{textContent: String, onclick: Function, ...etc}>} [params.buttons]
* ...etc means anything $create() can handle
* @param {Function(messageboxElement)} [params.onshow]
* invoked after the messagebox is shown
* @param {Boolean} [params.blockScroll]
* blocks the page scroll
* @returns {Promise}
* resolves to an object with optionally present properties depending on the interaction:
* {button: Number, enter: Boolean, esc: Boolean}
*/
async show({
title,
contents,
className = '',
buttons = [],
onshow,
blockScroll,
}) {
await require(['./message-box.css']);
if (!mess.listeners) initOwnListeners();
bindGlobalListeners(blockScroll);
createElement({title, contents, className, buttons});
document.body.appendChild(mess.element);
mess.originalFocus = document.activeElement;
// skip external links like feedback
while ((moveFocus(mess.element, 1) || {}).target === '_blank') {/*NOP*/}
// suppress focus outline when invoked via click
if (focusAccessibility.lastFocusedViaClick && document.activeElement) {
document.activeElement.dataset.focusedViaClick = '';
}
if (typeof onshow === 'function') {
onshow(mess.element);
}
if (!$('#message-box-title').textContent) {
$('#message-box-title').hidden = true;
$('#message-box-close-icon').hidden = true;
}
return new Promise(resolve => {
mess.resolve = resolve;
});
},
};
function bindGlobalListeners(blockScroll) {
mess.blockScroll = blockScroll && {x: scrollX, y: scrollY};
if (blockScroll) {
window.on('scroll', mess.listeners.scroll, {passive: false});
}
window.on('keydown', mess.listeners.key, true);
}
function createElement({title, contents, className, buttons}) {
if (mess.element) {
unbindGlobalListeners();
removeSelf();
}
const id = 'message-box';
mess.element =
$create({id, className}, [
$create([
$create(`#${id}-title`, title),
$create(`#${id}-close-icon`, {onclick: mess.listeners.closeIcon},
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
$create('SVG:path', {d: 'M11.69,10l4.55,4.55-1.69,1.69L10,11.69,' +
'5.45,16.23,3.77,14.55,8.31,10,3.77,5.45,5.45,3.77,10,8.31l4.55-4.55,1.69,1.69Z',
}))),
$create(`#${id}-contents`, t.HTML(contents)),
$create(`#${id}-buttons`,
buttons.map((content, buttonIndex) => content &&
$create('button', Object.assign({
buttonIndex,
onclick: mess.listeners.button,
}, typeof content === 'object' ? content : {
textContent: content,
})))),
]),
]);
}
function initOwnListeners() {
mess.listeners = {
closeIcon() {
resolveWith({button: -1});
},
button() {
resolveWith({button: this.buttonIndex});
},
key(event) {
const {key, shiftKey, ctrlKey, altKey, metaKey, target} = event;
if (shiftKey && key !== 'Tab' || ctrlKey || altKey || metaKey) {
return;
}
switch (key) {
case 'Enter':
if (focusAccessibility.closest(target)) {
return;
}
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
break;
case 'Tab':
moveFocus(mess.element, shiftKey ? -1 : 1);
event.preventDefault();
return;
default:
return;
}
resolveWith(key === 'Enter' ? {enter: true} : {esc: true});
},
scroll() {
scrollTo(mess.blockScroll.x, mess.blockScroll.y);
},
};
}
function removeSelf() {
mess.element.remove();
mess.element = null;
mess.resolve = null;
}
function resolveWith(value) {
setTimeout(mess.resolve, 0, value);
unbindGlobalListeners();
animateElement(mess.element, 'fadeout')
.then(removeSelf);
if (mess.element.contains(document.activeElement)) {
mess.originalFocus.focus();
}
}
function unbindGlobalListeners() {
window.off('keydown', mess.listeners.key, true);
window.off('scroll', mess.listeners.scroll);
}
return mess;
});

866
js/dom.js
View File

@ -1,41 +1,394 @@
/* global prefs */
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
setupLivePrefs moveFocus */
'use strict'; 'use strict';
if (!/^Win\d+/.test(navigator.platform)) { define(require => {
document.documentElement.classList.add('non-windows');
}
Object.assign(EventTarget.prototype, { Object.assign(EventTarget.prototype, {
on: addEventListener, on: addEventListener,
off: removeEventListener, off: removeEventListener,
/** args: [el:EventTarget, type:string, fn:function, ?opts] */ });
onOff(enable, ...args) {
(enable ? addEventListener : removeEventListener).apply(this, args);
},
});
$.isTextInput = (el = {}) => /** @type {Prefs} */
el.localName === 'textarea' || let prefs;
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
$.remove = (selector, base = document) => { //#region Exports
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
if (el) { /** @type {DOM} */
el.remove(); let dom;
const {
$,
$$,
$create,
} = dom = /** @namespace DOM */ {
$(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);
},
$$(selector, base = document) {
return [...base.querySelectorAll(selector)];
},
$isTextInput(el = {}) {
return el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
},
$remove(selector, base = document) {
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
if (el) {
el.remove();
}
},
$$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'
*/
$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;
},
$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 dom.$create(opt);
},
/**
* @param {HTMLElement} el
* @param {string} [cls] - class name that defines or starts an animation
* @param [removeExtraClasses] - class names to remove at animation end in the *same* paint frame,
* which is needed in e.g. Firefox as it may call resolve() in the next frame
* @returns {Promise<void>}
*/
animateElement(el, cls = 'highlight', ...removeExtraClasses) {
return !el ? Promise.resolve(el) : new Promise(resolve => {
let onDone = () => {
el.classList.remove(cls, ...removeExtraClasses);
onDone = null;
resolve();
};
requestAnimationFrame(() => {
if (onDone) {
const style = getComputedStyle(el);
if (style.animationName === 'none' || !parseFloat(style.animationDuration)) {
el.off('animationend', onDone);
onDone();
}
}
});
el.on('animationend', onDone, {once: true});
el.classList.add(cls);
});
},
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
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;
}
},
},
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
: 'LMR'[e.button]
}`;
},
/** @type {MessageBox} however properties are resolved asynchronously! */
messageBoxProxy: new Proxy({}, {
get(_, name) {
return async (...args) => (await require(['/js/dlg/message-box']))[name](...args);
},
}),
/**
* Switches to the next/previous keyboard-focusable element.
* Doesn't check `visibility` or `display` via getComputedStyle for simplicity.
* @param {HTMLElement} rootElement
* @param {Number} step - for exmaple 1 or -1 (or 0 to focus the first focusable el in the box)
* @returns {HTMLElement|false|undefined} -
* HTMLElement: focus changed,
* false: focus unchanged,
* undefined: nothing to focus
*/
moveFocus(rootElement, step) {
const elements = [...rootElement.getElementsByTagName('*')];
const activeEl = document.activeElement;
const activeIndex = step ? Math.max(step < 0 ? 0 : -1, elements.indexOf(activeEl)) : -1;
const num = elements.length;
if (!step) step = 1;
for (let i = 1; i < num; i++) {
const el = elements[(activeIndex + i * step + num) % num];
if (!el.disabled && el.tabIndex >= 0) {
el.focus();
return activeEl !== el && el;
}
}
},
onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
},
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
*/
setupLivePrefs(ids = Object.keys(prefs.defaults).filter(id => $('#' + id))) {
let forceUpdate = true;
prefs.subscribe(ids, updateElement, {runNow: true});
forceUpdate = false;
ids.forEach(id => $('#' + id).on('change', onChange));
function onChange() {
prefs.set(this.id, this[getPropName(this)]);
}
function getPropName(el) {
return el.type === 'checkbox' ? 'checked'
: el.type === 'number' ? 'valueAsNumber' :
'value';
}
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}));
}
} else {
prefs.unsubscribe(ids, updateElement);
}
}
},
// 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
waitForSelector(selector, {stopOnDomReady = true} = {}) {
// TODO: if used concurrently see if it's worth reworking to use just one observer internally
return Promise.resolve($(selector) || new Promise(resolve => {
const mo = new MutationObserver(() => {
const el = $(selector);
if (el) {
mo.disconnect();
resolve(el);
} else if (stopOnDomReady && document.readyState === 'complete') {
mo.disconnect();
}
});
mo.observe(document, {childList: true, subtree: true});
}));
},
};
//#endregion
//#region Init
require(['/js/prefs'], p => {
prefs = p;
dom.waitForSelector('details[data-pref]')
.then(() => requestAnimationFrame(initCollapsibles));
if (!chrome.app) {
// add favicon in Firefox
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,
}));
}
}
});
require(['/js/toolbox'], m => {
m.debounce(addTooltipsToEllipsized, 500);
window.on('resize', () => m.debounce(addTooltipsToEllipsized, 100));
});
window.on('mousedown', suppressFocusRingOnClick, {passive: true});
window.on('keydown', keepFocusRingOnTabbing, {passive: true});
dom.onDOMready().then(() => {
dom.$remove('#firefox-transitions-bug-suppressor');
});
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});
$$.remove = (selector, base = document) => { //#endregion
for (const el of base.querySelectorAll(selector)) { //#region Internals
el.remove();
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 */
// display a full text tooltip on buttons with ellipsis overflow and no inherent title function addTooltipsToEllipsized() {
const addTooltipsToEllipsized = () => {
for (const btn of document.getElementsByTagName('button')) { for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) { if (btn.title && !btn.titleIsForEllipsis) {
continue; continue;
@ -53,334 +406,44 @@ $$.remove = (selector, base = document) => {
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 // makes <details> with [data-pref] save/restore their state
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage()); function initCollapsibles() {
// avoid adding # to the page URL when clicking dummy links const onClick = async event => {
document.on('click', e => { if (event.target.closest('.intercepts-click')) {
if (e.target.closest('a[href="#"]')) { event.preventDefault();
e.preventDefault(); } else {
} const el = event.target.closest('details');
}); await new Promise(setTimeout);
// update inputs on mousewheel when focused if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
document.on('wheel', event => { prefs.set(el.dataset.pref, el.open);
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
* @param {string} [cls] - class name that defines or starts an animation
* @param [removeExtraClasses] - class names to remove at animation end in the *same* paint frame,
* which is needed in e.g. Firefox as it may call resolve() in the next frame
* @returns {Promise<void>}
*/
function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
return !el ? Promise.resolve(el) : new Promise(resolve => {
let onDone = () => {
el.classList.remove(cls, ...removeExtraClasses);
onDone = null;
resolve();
};
requestAnimationFrame(() => {
if (onDone) {
const style = getComputedStyle(el);
if (style.animationName === 'none' || !parseFloat(style.animationDuration)) {
el.off('animationend', onDone);
onDone();
} }
} }
}); };
el.on('animationend', onDone, {once: true}); const prefMap = {};
el.classList.add(cls); for (const el of $$('details[data-pref]')) {
}); prefMap[el.dataset.pref] = el;
} ($('h2', el) || el).on('click', onClick);
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();
} }
}; prefs.subscribe(Object.keys(prefMap), (key, value) => {
element.on('change', onChange); const el = prefMap[key];
element.on('input', onChange); if (el.open !== value && !el.matches('.compact-layout .ignore-pref-if-compact')) {
} el.open = value;
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; }, {runNow: true});
}
function keepAddressOnDummyClick(e) {
// avoid adding # to the page URL when clicking dummy links
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
} }
}; }
// suppress outline on click
window.on('mousedown', ({target}) => { function keepFocusRingOnTabbing(event) {
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) { if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false; dom.focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => { setTimeout(() => {
let el = document.activeElement; let el = document.activeElement;
if (el) { if (el) {
@ -389,102 +452,19 @@ function focusAccessibility() {
} }
}); });
} }
}, {passive: true}); }
}
/** function suppressFocusRingOnClick({target}) {
* Switches to the next/previous keyboard-focusable element. const el = dom.focusAccessibility.closest(target);
* Doesn't check `visibility` or `display` via getComputedStyle for simplicity. if (el) {
* @param {HTMLElement} rootElement dom.focusAccessibility.lastFocusedViaClick = true;
* @param {Number} step - for exmaple 1 or -1 (or 0 to focus the first focusable el in the box) if (el.dataset.focusedViaClick === undefined) {
* @returns {HTMLElement|false|undefined} - el.dataset.focusedViaClick = '';
* HTMLElement: focus changed,
* false: focus unchanged,
* undefined: nothing to focus
*/
function moveFocus(rootElement, step) {
const elements = [...rootElement.getElementsByTagName('*')];
const activeEl = document.activeElement;
const activeIndex = step ? Math.max(step < 0 ? 0 : -1, elements.indexOf(activeEl)) : -1;
const num = elements.length;
if (!step) step = 1;
for (let i = 1; i < num; i++) {
const el = elements[(activeIndex + i * step + num) % num];
if (!el.disabled && el.tabIndex >= 0) {
el.focus();
return activeEl !== el && el;
}
}
}
// 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);
}
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
function onChange() {
const value = getInputValue(this);
if (prefs.get(this.id) !== value) {
prefs.set(this.id, value);
}
}
function updateElement({
id,
value = prefs.get(id),
element = $('#' + id),
force,
}) {
if (!element) {
prefs.unsubscribe(IDs, updateElement);
return;
}
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}));
} }
} }
}
/* exported getEventKeyName */ //#endregion
/**
* @param {KeyboardEvent|MouseEvent} e return dom;
* @param {boolean} [letterAsCode] - use locale-independent KeyA..KeyZ for single-letter chars });
*/
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
: 'LMR'[e.button]
}`;
}

View File

@ -1,16 +1,10 @@
'use strict'; 'use strict';
function t(key, params) { define(require => {
const s = chrome.i18n.getMessage(key, params);
if (!s) throw `Missing string "${key}"`;
return s;
}
Object.assign(t, { const parser = new DOMParser();
template: {}, const ALLOWED_TAGS = ['a', 'b', 'code', 'i', 'sub', 'sup', 'wbr'];
DOMParser: new DOMParser(), const RX_WORD_BREAK = new RegExp([
ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','),
RX_WORD_BREAK: new RegExp([
'(', '(',
/[\d\w\u007B-\uFFFF]{10}/, /[\d\w\u007B-\uFFFF]{10}/,
'|', '|',
@ -19,144 +13,152 @@ Object.assign(t, {
/((?!\s)\W){10}/, /((?!\s)\W){10}/,
')', ')',
/(?!\b|\s|$)/, /(?!\b|\s|$)/,
].map(rx => rx.source || rx).join(''), 'gu'), ].map(rx => rx.source || rx).join(''), 'gu');
HTML(html) { function t(key, params, strict = true) {
return typeof html !== 'string' const s = chrome.i18n.getMessage(key, params);
? html if (!s && strict) throw `Missing string "${key}"`;
: /<\w+/.test(html) // check for html tags return s;
? t.createHtml(html.replace(/>\n\s*</g, '><').trim()) }
: document.createTextNode(html);
},
NodeList(nodes) { Object.assign(t, {
const PREFIX = 'i18n-'; template: {},
for (let n = nodes.length; --n >= 0;) {
const node = nodes[n]; HTML(html) {
if (node.nodeType !== Node.ELEMENT_NODE) { return typeof html !== 'string'
continue; ? html
} : /<\w+/.test(html) // check for html tags
if (node.localName === 'template') { ? t.createHtml(html.replace(/>\n\s*</g, '><').trim())
t.createTemplate(node); : document.createTextNode(html);
continue; },
}
for (let a = node.attributes.length; --a >= 0;) { NodeList(nodes) {
const attr = node.attributes[a]; const PREFIX = 'i18n-';
const name = attr.nodeName; for (let n = nodes.length; --n >= 0;) {
if (!name.startsWith(PREFIX)) { const node = nodes[n];
if (node.nodeType !== Node.ELEMENT_NODE) {
continue; continue;
} }
const type = name.substr(PREFIX.length); if (node.localName === 'template') {
const value = t(attr.value); t.createTemplate(node);
let toInsert, before; continue;
switch (type) { }
case 'word-break': for (let a = node.attributes.length; --a >= 0;) {
// we already know that: hasWordBreak const attr = node.attributes[a];
break; const name = attr.nodeName;
case 'text': if (!name.startsWith(PREFIX)) {
before = node.firstChild; continue;
// fallthrough to text-append
case 'text-append':
toInsert = t.createText(value);
break;
case 'html': {
toInsert = t.createHtml(value);
break;
} }
default: const type = name.substr(PREFIX.length);
node.setAttribute(type, value); const value = t(attr.value);
} let toInsert, before;
t.stopObserver(); switch (type) {
if (toInsert) { case 'word-break':
node.insertBefore(toInsert, before || null); // we already know that: hasWordBreak
} break;
node.removeAttribute(name); case 'text':
} before = node.firstChild;
} // fallthrough to text-append
}, case 'text-append':
toInsert = t.createText(value);
/** Adds soft hyphens every 10 characters to ensure the long words break before breaking the layout */ break;
breakWord(text) { case 'html': {
return text.length <= 10 ? text : toInsert = t.createHtml(value);
text.replace(t.RX_WORD_BREAK, '$&\u00AD'); break;
}, }
default:
createTemplate(node) { node.setAttribute(type, value);
const elements = node.content.querySelectorAll('*');
t.NodeList(elements);
t.template[node.dataset.id] = elements[0];
// compress inter-tag whitespace to reduce number of DOM nodes by 25%
const walker = document.createTreeWalker(elements[0], NodeFilter.SHOW_TEXT);
const toRemove = [];
while (walker.nextNode()) {
const textNode = walker.currentNode;
if (!/[\xA0\S]/.test(textNode.nodeValue)) { // allow \xA0 to keep &nbsp;
toRemove.push(textNode);
}
}
t.stopObserver();
toRemove.forEach(el => el.remove());
},
createText(str) {
return document.createTextNode(t.breakWord(str));
},
createHtml(str, trusted) {
const root = t.DOMParser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
t.NodeList(root.getElementsByTagName('*'));
}
const bin = document.createDocumentFragment();
while (root.firstChild) {
bin.appendChild(root.firstChild);
}
return bin;
},
sanitizeHtml(root) {
const toRemove = [];
const walker = document.createTreeWalker(root);
for (let n; (n = walker.nextNode());) {
if (n.nodeType === Node.TEXT_NODE) {
n.nodeValue = t.breakWord(n.nodeValue);
} else if (t.ALLOWED_TAGS.includes(n.localName)) {
for (const attr of n.attributes) {
if (n.localName !== 'a' || attr.localName !== 'href' || !/^https?:/.test(n.href)) {
n.removeAttribute(attr.name);
} }
t.stopObserver();
if (toInsert) {
node.insertBefore(toInsert, before || null);
}
node.removeAttribute(name);
} }
} else {
toRemove.push(n);
} }
} },
for (const n of toRemove) {
const parent = n.parentNode;
if (parent) parent.removeChild(n); // not using .remove() as there may be a non-element
}
},
formatDate(date) { /** Adds soft hyphens every 10 characters to ensure the long words break before breaking the layout */
if (!date) { breakWord(text) {
return ''; return text.length <= 10 ? text :
} text.replace(RX_WORD_BREAK, '$&\u00AD');
try { },
const newDate = new Date(Number(date) || date);
const string = newDate.toLocaleDateString([chrome.i18n.getUILanguage(), 'en'], { createTemplate(node) {
day: '2-digit', const elements = node.content.querySelectorAll('*');
month: 'short', t.NodeList(elements);
year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit', t.template[node.dataset.id] = elements[0];
}); // compress inter-tag whitespace to reduce number of DOM nodes by 25%
return string === 'Invalid Date' ? '' : string; const walker = document.createTreeWalker(elements[0], NodeFilter.SHOW_TEXT);
} catch (e) { const toRemove = [];
return ''; while (walker.nextNode()) {
} const textNode = walker.currentNode;
}, if (!/[\xA0\S]/.test(textNode.nodeValue)) { // allow \xA0 to keep &nbsp;
}); toRemove.push(textNode);
}
}
t.stopObserver();
toRemove.forEach(el => el.remove());
},
createText(str) {
return document.createTextNode(t.breakWord(str));
},
createHtml(str, trusted) {
const root = parser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
t.NodeList(root.getElementsByTagName('*'));
}
const bin = document.createDocumentFragment();
while (root.firstChild) {
bin.appendChild(root.firstChild);
}
return bin;
},
sanitizeHtml(root) {
const toRemove = [];
const walker = document.createTreeWalker(root);
for (let n; (n = walker.nextNode());) {
if (n.nodeType === Node.TEXT_NODE) {
n.nodeValue = t.breakWord(n.nodeValue);
} else if (ALLOWED_TAGS.includes(n.localName)) {
for (const attr of n.attributes) {
if (n.localName !== 'a' || attr.localName !== 'href' || !/^https?:/.test(n.href)) {
n.removeAttribute(attr.name);
}
}
} else {
toRemove.push(n);
}
}
for (const n of toRemove) {
const parent = n.parentNode;
if (parent) parent.removeChild(n); // not using .remove() as there may be a non-element
}
},
formatDate(date) {
if (!date) {
return '';
}
try {
const newDate = new Date(Number(date) || date);
const string = newDate.toLocaleDateString([chrome.i18n.getUILanguage(), 'en'], {
day: '2-digit',
month: 'short',
year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit',
});
return string === 'Invalid Date' ? '' : string;
} catch (e) {
return '';
}
},
});
(() => {
const observer = new MutationObserver(process); const observer = new MutationObserver(process);
let observing = false; let observing = false;
Object.assign(t, { Object.assign(t, {
@ -186,4 +188,6 @@ Object.assign(t, {
observer.observe(document, {subtree: true, childList: true}); observer.observe(document, {subtree: true, childList: true});
} }
} }
})();
return t;
});

View File

@ -1,465 +0,0 @@
/* exported
capitalize
CHROME_HAS_BORDER_BUG
closeCurrentTab
deepEqual
download
getActiveTab
getStyleWithNoCode
getTab
ignoreChromeError
onTabReady
openURL
sessionStore
stringAsRegExp
tryCatch
tryRegExp
*/
'use strict';
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]);
const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
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;
if (!CHROME && !chrome.browserAction.openPopup) {
// in FF pre-57 legacy addons can override useragent so we assume the worst
// until we know for sure in the async getBrowserInfo()
// (browserAction.openPopup was added in 57)
FIREFOX = browser.runtime.getBrowserInfo ? 51 : 50;
// getBrowserInfo was added in FF 51
Promise.resolve(FIREFOX >= 51 ? browser.runtime.getBrowserInfo() : {version: 50}).then(info => {
FIREFOX = parseFloat(info.version);
document.documentElement.classList.add('moz-appearance-bug', FIREFOX && FIREFOX < 54);
});
}
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',
installUsercss: chrome.runtime.getURL('install-usercss.html'),
// CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
// https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
browserWebStore:
FIREFOX ? 'https://addons.mozilla.org/' :
OPERA ? 'https://addons.opera.com/' :
'https://chrome.google.com/webstore/',
emptyTab: [
// Chrome and simple forks
'chrome://newtab/',
// Opera
'chrome://startpage/',
// Vivaldi
'chrome-extension://mpognobbkildjkofajifpdfhcoklimli/components/startpage/startpage.html',
// Firefox
'about:home',
'about:newtab',
],
// Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/
// TODO: remove when "minimum_chrome_version": "61" or higher
chromeProtectsNTP: CHROME >= 61,
uso: 'https://userstyles.org/',
usoJson: 'https://userstyles.org/styles/chrome/',
usoArchive: 'https://33kk.github.io/uso-archive/',
usoArchiveRaw: 'https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/',
extractUsoArchiveId: url =>
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}` : '';
},
extractGreasyForkInstallUrl: url =>
/^(https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/\d+)[^/]*\/code\/[^/]*\.user\.css$|$/.exec(url)[1],
supported: url => (
url.startsWith('http') ||
url.startsWith('ftp') ||
url.startsWith('file') ||
url.startsWith(URLS.ownOrigin) ||
!URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/')
),
};
if (!chrome.extension.getBackgroundPage || chrome.extension.getBackgroundPage() !== window) {
const cls = FIREFOX ? 'firefox' : OPERA ? 'opera' : VIVALDI ? 'vivaldi' : '';
if (cls) document.documentElement.classList.add(cls);
}
// FF57+ supports openerTabId, but not in Android
// (detecting FF57 by the feature it added, not navigator.ua which may be spoofed in about:config)
const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null;
function getOwnTab() {
return browser.tabs.getCurrent();
}
function getActiveTab() {
return browser.tabs.query({currentWindow: true, active: true})
.then(tabs => tabs[0]);
}
function urlToMatchPattern(url, ignoreSearch) {
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
if (!/^(http|https|ws|wss|ftp|data|file)$/.test(url.protocol)) {
return undefined;
}
if (ignoreSearch) {
return [
`${url.protocol}//${url.hostname}/${url.pathname}`,
`${url.protocol}//${url.hostname}/${url.pathname}?*`,
];
}
// FIXME: is %2f allowed in pathname and search?
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
}
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 tabUrl = new URL(tab.pendingUrl || tab.url);
return tabUrl.protocol === url.protocol &&
tabUrl.username === url.username &&
tabUrl.password === url.password &&
tabUrl.hostname === url.hostname &&
tabUrl.port === url.port &&
tabUrl.pathname === url.pathname &&
(ignoreSearch || tabUrl.search === url.search) &&
(ignoreHash || tabUrl.hash === url.hash);
}
}
/**
* Opens a tab or activates an existing one,
* reuses the New Tab page or about:blank if it's focused now
* @param {Object} _
* @param {string} _.url - if relative, it's auto-expanded to the full extension URL
* @param {number} [_.index] move the tab to this index in the tab strip, -1 = last
* @param {number} [_.openerTabId] defaults to the active tab
* @param {Boolean} [_.active=true] `true` to activate the tab
* @param {Boolean|null} [_.currentWindow=true] `null` to check all windows
* @param {chrome.windows.CreateData} [_.newWindow] creates a new window with these params if specified
* @returns {Promise<chrome.tabs.Tab>} Promise -> opened/activated tab
*/
async function openURL({
url,
index,
openerTabId,
active = true,
currentWindow = true,
newWindow,
}) {
if (!url.includes('://')) {
url = chrome.runtime.getURL(url);
}
let tab = await findExistingTab({url, currentWindow});
if (tab) {
return activateTab(tab, {
index,
openerTabId,
// when hash is different we can only set `url` if it has # otherwise the tab would reload
url: url !== (tab.pendingUrl || tab.url) && url.includes('#') ? url : undefined,
});
}
if (newWindow && browser.windows) {
return (await browser.windows.create(Object.assign({url}, newWindow)).tabs)[0];
}
tab = await getActiveTab() || {url: ''};
if (isTabReplaceable(tab, url)) {
return activateTab(tab, {url, openerTabId});
}
const id = openerTabId == null ? tab.id : openerTabId;
const opener = id != null && !tab.incognito && openerTabIdSupported && {openerTabId: id};
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
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;
}
function activateTab(tab, {url, index, openerTabId} = {}) {
const options = {active: true};
if (url) {
options.url = url;
}
if (openerTabId != null && openerTabIdSupported) {
options.openerTabId = openerTabId;
}
return Promise.all([
browser.tabs.update(tab.id, options),
browser.windows && browser.windows.update(tab.windowId, {focused: true}),
index != null && browser.tabs.move(tab.id, {index}),
])
.then(() => tab);
}
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;
}
// js engine can't optimize the entire function if it contains try-catch
// so we should keep it isolated from normal code in a minimal wrapper
// Update: might get fixed in V8 TurboFan in the future
function tryCatch(func, ...args) {
try {
return func(...args);
} catch (e) {}
}
function tryRegExp(regexp, 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) => {
clearTimeout(debounce.timers.get(fn));
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
}, {
timers: new Map(),
run(fn, ...args) {
debounce.timers.delete(fn);
fn(...args);
},
unregister(fn) {
clearTimeout(debounce.timers.get(fn));
debounce.timers.delete(fn);
},
});
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));
}
return copy;
}
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;
}
function deepEqual(a, b, ignoredKeys) {
if (!a || !b) return a === b;
const type = typeof a;
if (type !== typeof b) return false;
if (type !== 'object') return a === b;
if (Array.isArray(a)) {
return Array.isArray(b) &&
a.length === b.length &&
a.every((v, i) => deepEqual(v, b[i], ignoredKeys));
}
for (const key in a) {
if (!Object.hasOwnProperty.call(a, key) ||
ignoredKeys && ignoredKeys.includes(key)) continue;
if (!Object.hasOwnProperty.call(b, key)) return false;
if (!deepEqual(a[key], b[key], ignoredKeys)) return false;
}
for (const key in b) {
if (!Object.hasOwnProperty.call(b, key) ||
ignoredKeys && ignoredKeys.includes(key)) continue;
if (!Object.hasOwnProperty.call(a, key)) return false;
}
return true;
}
/* A simple polyfill in case DOM storage is disabled in the browser */
const sessionStore = new Proxy({}, {
get(target, name) {
try {
return sessionStorage[name];
} catch (e) {
Object.defineProperty(window, 'sessionStorage', {value: target});
}
},
set(target, name, value, proxy) {
try {
sessionStorage[name] = `${value}`;
} catch (e) {
proxy[name]; // eslint-disable-line no-unused-expressions
target[name] = `${value}`;
}
return true;
},
deleteProperty(target, name) {
return delete target[name];
},
});
/**
* @param {String} url
* @param {Object} params
* @param {String} [params.method]
* @param {String|Object} [params.body]
* @param {String} [params.responseType] arraybuffer, blob, document, json, text
* @param {Number} [params.requiredStatusCode] resolved when matches, otherwise rejected
* @param {Number} [params.timeout] ms
* @param {Object} [params.headers] {name: value}
* @returns {Promise}
*/
function download(url, {
method = 'GET',
body,
responseType = 'text',
requiredStatusCode = 200,
timeout = 60e3, // connection timeout, USO is that bad
loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
headers,
} = {}) {
/* 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);
}
if (headers === undefined) {
headers = {
'Content-type': 'application/x-www-form-urlencoded',
};
}
}
const usoVars = [];
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const u = new URL(collapseUsoVars(url));
const onTimeout = () => {
xhr.abort();
reject(new Error('Timeout fetching ' + u.href));
};
let timer = setTimeout(onTimeout, timeout);
xhr.onreadystatechange = () => {
if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
xhr.onreadystatechange = null;
clearTimeout(timer);
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
}
};
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);
for (const [name, value] of Object.entries(headers || {})) {
xhr.setRequestHeader(name, value);
}
xhr.send(body);
});
function collapseUsoVars(url) {
if (queryPos < 0 ||
url.length < 2000 ||
!url.startsWith(URLS.usoJson) ||
!/^get$/i.test(method)) {
return url;
}
const params = new URLSearchParams(url.slice(queryPos + 1));
for (const [k, v] of params.entries()) {
if (v.length < 10 || v.startsWith('ik-')) continue;
usoVars.push(v);
params.set(k, `\x01${usoVars.length}\x02`);
}
return url.slice(0, queryPos + 1) + params.toString();
}
function expandUsoVars(response) {
if (!usoVars.length || !response) return response;
const isText = typeof response === 'string';
const json = isText && tryJSONparse(response) || response;
json.updateUrl = url;
for (const section of json.sections || []) {
const {code} = section;
if (code.includes('\x01')) {
section.code = code.replace(/\x01(\d+)\x02/g, (_, num) => usoVars[num - 1] || '');
}
}
return isText ? JSON.stringify(json) : json;
}
}
function closeCurrentTab() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
getOwnTab().then(tab => {
if (tab) {
chrome.tabs.remove(tab.id);
}
});
}
function capitalize(s) {
return s[0].toUpperCase() + s.slice(1);
}

View File

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

View File

@ -1,141 +1,147 @@
/* global parserlib */
/* exported parseMozFormat */
'use strict'; 'use strict';
/** define([
* Extracts @-moz-document blocks into sections and the code between them into global sections. '/js/csslint/parserlib',
* Puts the global comments into the following section to minimize the amount of global sections. ], parserlib => ({
* 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
* @returns {{sections: Array, errors: Array}}
*/
function parseMozFormat({code, styleId}) {
const CssToProperty = {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
};
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/;
const parser = new parserlib.css.Parser({starHack: true, skipValidation: true});
const sectionStack = [{code: '', start: 0}];
const errors = [];
const sections = [];
const mozStyle = code.replace(/\r\n?/g, '\n'); // same as parserlib.StringReader
parser.addListener('startdocument', e => { /**
const lastSection = sectionStack[sectionStack.length - 1]; * Extracts @-moz-document blocks into sections and the code between them into global sections.
let outerText = mozStyle.slice(lastSection.start, e.offset); * Puts the global comments into the following section to minimize the amount of global sections.
const lastCmt = getLastComment(outerText); * Doesn't move the comment with ==UserStyle== inside.
const section = { * @param {Object} _
code: '', * @param {string} _.code
start: parser._tokenStream._token.offset + 1, * @param {number} [_.styleId] - used to preserve parserCache on subsequent runs over the same style
* @returns {{sections: Array, errors: Array}}
* @property {?number} lastStyleId
*/
extractSections: function fn({code, styleId}) {
const CssToProperty = {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
}; };
// move last comment before @-moz-document inside the section const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/;
if (!lastCmt.includes('AGENT_SHEET') && const parser = new parserlib.css.Parser({starHack: true, skipValidation: true});
!lastCmt.includes('==') && const sectionStack = [{code: '', start: 0}];
!/==userstyle==/i.test(lastCmt)) { const errors = [];
if (lastCmt) { const sections = [];
section.code = lastCmt + '\n'; const mozStyle = code.replace(/\r\n?/g, '\n'); // same as parserlib.StringReader
outerText = outerText.slice(0, -lastCmt.length);
} parser.addListener('startdocument', e => {
outerText = outerText.match(/^\s*/)[0] + outerText.trim(); const lastSection = sectionStack[sectionStack.length - 1];
} let outerText = mozStyle.slice(lastSection.start, e.offset);
if (outerText.trim()) { const lastCmt = getLastComment(outerText);
lastSection.code = outerText; const section = {
doAddSection(lastSection); code: '',
lastSection.code = ''; start: parser._tokenStream._token.offset + 1,
} };
for (const {name, expr, uri} of e.functions) { // move last comment before @-moz-document inside the section
const aType = CssToProperty[name.toLowerCase()]; if (!lastCmt.includes('AGENT_SHEET') &&
const p0 = expr && expr.parts[0]; !lastCmt.includes('==') &&
if (p0 && aType === 'regexps') { !/==userstyle==/i.test(lastCmt)) {
const s = p0.text; if (lastCmt) {
if (hasSingleEscapes.test(p0.text)) { section.code = lastCmt + '\n';
const isQuoted = (s.startsWith('"') || s.startsWith("'")) && s.endsWith(s[0]); outerText = outerText.slice(0, -lastCmt.length);
p0.value = isQuoted ? s.slice(1, -1) : s;
} }
outerText = outerText.match(/^\s*/)[0] + outerText.trim();
} }
(section[aType] = section[aType] || []).push(uri || p0 && p0.value || ''); if (outerText.trim()) {
} lastSection.code = outerText;
sectionStack.push(section); doAddSection(lastSection);
}); lastSection.code = '';
}
parser.addListener('enddocument', e => { for (const {name, expr, uri} of e.functions) {
const section = sectionStack.pop(); const aType = CssToProperty[name.toLowerCase()];
const lastSection = sectionStack[sectionStack.length - 1]; const p0 = expr && expr.parts[0];
section.code += mozStyle.slice(section.start, e.offset); if (p0 && aType === 'regexps') {
lastSection.start = e.offset + 1; const s = p0.text;
doAddSection(section); if (hasSingleEscapes.test(p0.text)) {
}); const isQuoted = /^['"]/.test(s) && s.endsWith(s[0]);
p0.value = isQuoted ? s.slice(1, -1) : s;
parser.addListener('endstylesheet', () => { }
// add nonclosed outer sections (either broken or the last global one) }
const lastSection = sectionStack[sectionStack.length - 1]; (section[aType] = section[aType] || []).push(uri || p0 && p0.value || '');
lastSection.code += mozStyle.slice(lastSection.start); }
sectionStack.forEach(doAddSection); sectionStack.push(section);
});
parser.addListener('error', e => {
errors.push(e);
});
try {
parser.parse(mozStyle, {
reuseCache: !parseMozFormat.styleId || styleId === parseMozFormat.styleId,
}); });
} catch (e) {
errors.push(e);
}
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}`;
}
parseMozFormat.styleId = styleId;
return {sections, errors};
function doAddSection(section) { parser.addListener('enddocument', e => {
section.code = section.code.trim(); const section = sectionStack.pop();
// don't add empty sections const lastSection = sectionStack[sectionStack.length - 1];
if ( section.code += mozStyle.slice(section.start, e.offset);
!section.code && lastSection.start = e.offset + 1;
!section.urls && doAddSection(section);
!section.urlPrefixes && });
!section.domains &&
!section.regexps
) {
return;
}
/* ignore boilerplate NS */
if (section.code === '@namespace url(http://www.w3.org/1999/xhtml);') {
return;
}
sections.push(Object.assign({}, section));
}
function getLastComment(text) { parser.addListener('endstylesheet', () => {
let open = text.length; // add nonclosed outer sections (either broken or the last global one)
let close; const lastSection = sectionStack[sectionStack.length - 1];
while (open) { lastSection.code += mozStyle.slice(lastSection.start);
// at this point we're guaranteed to be outside of a comment sectionStack.forEach(doAddSection);
close = text.lastIndexOf('*/', open - 2); });
if (close < 0) {
break; parser.addListener('error', e => {
} errors.push(e);
// stop if a non-whitespace precedes and return what we currently have });
const tailEmpty = !text.substring(close + 2, open).trim();
if (!tailEmpty) { try {
break; parser.parse(mozStyle, {
} reuseCache: !fn.lastStyleId || styleId === fn.lastStyleId,
// find a closed preceding comment });
const prevClose = text.lastIndexOf('*/', close - 2); } catch (e) {
// then find the real start of current comment errors.push(e);
// e.g. /* preceding */ /* current /* current /* current */
open = text.indexOf('/*', prevClose < 0 ? 0 : prevClose + 2);
} }
return open ? text.slice(open) : text; 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}`;
}
fn.lastStyleId = styleId;
function doAddSection(section) {
section.code = section.code.trim();
// don't add empty sections
if (
!section.code &&
!section.urls &&
!section.urlPrefixes &&
!section.domains &&
!section.regexps
) {
return;
}
/* ignore boilerplate NS */
if (section.code === '@namespace url(http://www.w3.org/1999/xhtml);') {
return;
}
sections.push(Object.assign({}, section));
}
function getLastComment(text) {
let open = text.length;
let close;
while (open) {
// at this point we're guaranteed to be outside of a comment
close = text.lastIndexOf('*/', open - 2);
if (close < 0) {
break;
}
// stop if a non-whitespace precedes and return what we currently have
const tailEmpty = !text.substring(close + 2, open).trim();
if (!tailEmpty) {
break;
}
// find a closed preceding comment
const prevClose = text.lastIndexOf('*/', close - 2);
// then find the real start of current comment
// e.g. /* preceding */ /* current /* current /* current */
open = text.indexOf('/*', prevClose < 0 ? 0 : prevClose + 2);
}
return open ? text.slice(open) : text;
}
return {sections, errors};
},
}));

130
js/msg.js
View File

@ -1,8 +1,16 @@
/* global deepCopy getOwnTab URLS */ // not used in content scripts
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions /** The name is needed when running in content scripts but specifying it in define()
window.INJECTED !== 1 && (() => { breaks IDE detection of exports so here's a workaround */
define.currentModule = '/js/msg';
define(require => {
const {
URLS,
deepCopy,
getOwnTab,
} = require('/js/toolbox'); // `require` does nothing in content scripts
const TARGETS = Object.assign(Object.create(null), { const TARGETS = Object.assign(Object.create(null), {
all: ['both', 'tab', 'extension'], all: ['both', 'tab', 'extension'],
extension: ['both', 'extension'], extension: ['both', 'extension'],
@ -21,38 +29,12 @@ window.INJECTED !== 1 && (() => {
extension: new Set(), extension: new Set(),
}; };
let bg = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage(); // TODO: maybe move into polyfill.js and hook addListener to wrap/unwrap automatically
const isBg = bg === window; chrome.runtime.onMessage.addListener(onRuntimeMessage);
if (!isBg && (!bg || !bg.document || bg.document.readyState === 'loading')) {
bg = null;
}
// TODO: maybe move into polyfill.js and hook addListener + sendMessage so they wrap/unwrap automatically const msg = /** @namespace msg */ {
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 isBg: getExtBg() === window,
const msg = window.msg = {
isBg,
async broadcast(data) { async broadcast(data) {
const requests = [msg.send(data, 'both').catch(msg.ignoreError)]; const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
@ -73,8 +55,8 @@ window.INJECTED !== 1 && (() => {
}, },
isIgnorableError(err) { isIgnorableError(err) {
const msg = `${err && err.message || err}`; const text = `${err && err.message || err}`;
return msg.includes(ERR_NO_RECEIVER) || msg.includes(ERR_PORT_CLOSED); return text.includes(ERR_NO_RECEIVER) || text.includes(ERR_PORT_CLOSED);
}, },
ignoreError(err) { ignoreError(err) {
@ -113,6 +95,11 @@ window.INJECTED !== 1 && (() => {
_execute(types, ...args) { _execute(types, ...args) {
let result; 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 type of types) {
for (const fn of handler[type]) { for (const fn of handler[type]) {
let res; let res;
@ -130,30 +117,71 @@ window.INJECTED !== 1 && (() => {
}, },
}; };
const apiHandler = !isBg && { function getExtBg() {
const fn = chrome.extension.getBackgroundPage;
const bg = fn && fn();
return bg === window || bg && bg.msg && bg.msg.isBgReady ? bg : null;
}
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;
}
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) { get({PATH}, name) {
const fn = () => {}; const fn = () => {};
fn.PATH = [...PATH, name]; fn.PATH = [...PATH, name];
return new Proxy(fn, apiHandler); return new Proxy(fn, apiHandler);
}, },
async apply({PATH: path}, thisObj, args) { async apply({PATH: path}, thisObj, args) {
if (!bg && chrome.tabs) { const bg = getExtBg() ||
bg = await browser.runtime.getBackgroundPage().catch(() => {}); chrome.tabs && await browser.runtime.getBackgroundPage().catch(() => {});
}
const message = {method: 'invokeAPI', path, args}; const message = {method: 'invokeAPI', path, args};
// content scripts and probably private tabs let res;
// content scripts, probably private tabs, and our extension tab during Chrome startup
if (!bg) { if (!bg) {
return msg.send(message); res = msg.send(message);
} else {
res = deepCopy(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,
}));
} }
// in FF, the object would become a dead object when the window return res;
// 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(path.join('.')) && await getOwnTab(),
url: location.href,
});
return deepCopy(await res);
}, },
}; };
window.API = isBg ? {} : new Proxy({PATH: []}, apiHandler); /** @type {API} */
})(); const API = msg.isBg ? {} : new Proxy({PATH: []}, apiHandler);
// easier debugging in devtools console
window.API = API;
// easier debugging + apiHandler calls it directly from bg to get data in the same paint frame
window.msg = msg;
return {API, msg};
});

View File

@ -1,13 +1,18 @@
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions if (self.INJECTED !== 1) {
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` */
Promise.resolve().then(() => (self.INJECTED = 1));
//#region for content scripts and our extension pages const isContentScript = !chrome.tabs;
//#region Stuff for content scripts and our extension pages
if (!((window.browser || {}).runtime || {}).sendMessage) { if (!((window.browser || {}).runtime || {}).sendMessage) {
/* Auto-promisifier with a fallback to direct call on signature error. /* Auto-promisifier with a fallback to direct call on signature error.
The fallback isn't used now since we call all synchronous methods via `chrome` */ The fallback isn't used now since we call all synchronous methods via `chrome` */
const directEvents = ['addListener', 'removeListener', 'hasListener', 'hasListeners']; const directEvents = ['addListener', 'removeListener', 'hasListener', 'hasListeners'];
// generated by tools/chrome-api-no-cb.js // generated by tools/chrome-api-no-cb.js
const directMethods = { const directMethods = {
@ -23,7 +28,7 @@ self.INJECTED !== 1 && (() => {
try { try {
let resolve, reject; let resolve, reject;
/* Some callbacks have 2 parameters so we're resolving as an array in that case. /* Some callbacks have 2 parameters so we're resolving as an array in that case.
For example, chrome.runtime.requestUpdateCheck and chrome.webRequest.onAuthRequired */ For example, chrome.runtime.requestUpdateCheck and chrome.webRequest.onAuthRequired */
args.push((...results) => args.push((...results) =>
chrome.runtime.lastError ? chrome.runtime.lastError ?
reject(new Error(chrome.runtime.lastError.message)) : reject(new Error(chrome.runtime.lastError.message)) :
@ -61,26 +66,219 @@ self.INJECTED !== 1 && (() => {
} }
//#endregion //#endregion
//#region AMD loader for content scripts
if (!chrome.tabs) return; if (isContentScript && typeof define !== 'function') {
/**
//#region for our extension pages * WARNING!
* All deps needed to run the current define() must be already resolved.
if (!(new URLSearchParams({foo: 1})).get('foo')) { */
// TODO: remove when minimum_chrome_version >= 61 const modules = {};
window.URLSearchParams = class extends URLSearchParams { const addJs = name =>
constructor(init) { name.endsWith('.js') ? name : name + '.js';
if (init && typeof init === 'object') { const require = self.require = name =>
super(); (name = addJs(name)) in modules ? modules[name] : {};
for (const [key, val] of Object.entries(init)) { const define = self.define = fn => {
this.set(key, val); const name = addJs(define.currentModule || `${fn}`.slice(0, 1000));
} if (!(name in modules)) modules[name] = fn(require);
} else { define.currentModule = null;
super(...arguments);
}
}
}; };
} }
//#endregion //#endregion
})(); //#region AMD loader for our extension pages
if (!isContentScript && typeof define !== 'function') {
/**
* Notes:
* 'js!path' and 'path.css' resolve on load automatically.
* 'resolved!path' will skip this path during dependency auto-detection so use only if
you need to use a synchronous require() call in the future when this path is
guaranteed to be resolved elsewhere.
* js/css of the critical rendering path should still be specified in html.
*/
const REQ_SYM = Symbol(Math.random());
const modules = {};
const depsQueue = {};
const loadElement = ({mode, url}) => {
const isCss = url.endsWith('.css');
const tag = isCss ? 'link' : 'script';
const el = document.createElement(tag);
if (mode !== 'js' && !isCss) {
el.src = url;
return el;
}
const args = [url, [], () => el];
const attr = isCss ? 'href' : 'src';
const fileName = url.split('/').pop();
for (const el of document.head.querySelectorAll(`${tag}[${attr}$="${fileName}"]`)) {
if (location.origin + url === (el.href || el.src || '')) {
self.define(...args);
return;
}
}
el.addEventListener('load', self.define.bind(null, ...args), {once: true});
el[attr] = url;
if (isCss) el.rel = 'stylesheet';
return el;
};
const isEmpty = obj => {
for (const k in obj) return false;
return true;
};
const parseDepName = (name, base) => {
let mode;
let url = name;
if (name) {
mode = name.startsWith('js!') ? 'js' : '';
url = mode ? name.slice(mode.length + 1) : name;
if (!url.startsWith('/')) {
url = new URL(url, location.origin + base).pathname;
}
if (!url.endsWith('.js') && !url.endsWith('.css')) {
url += '.js';
}
}
return {mode, url};
};
const require = self.require = function (name) {
return Array.isArray(name) ?
self.define.apply([REQ_SYM, this], arguments) :
modules[parseDepName(name, this).url] || {};
};
const define = self.define = async function (...args) {
const isReq = this && this[0] === REQ_SYM;
let base = isReq && this[1];
let i = 0;
let name = typeof args[i] === 'string' && args[i++];
let deps = Array.isArray(args[i]) && args[i++];
const init = args[i];
const hasInit = i < args.length;
if (isReq) {
name = '';
define.currentModule = base;
} else {
define.currentModule = base = name =
typeof name === 'string' && name ||
((document.currentScript || {}).src || '').replace(location.origin, '') ||
parseDepName(define.currentModule, '').url ||
'';
}
if (name in modules) {
return modules[name];
}
const isInitFn = typeof init === 'function';
if (!deps) {
deps = isInitFn ? ['require', 'exports', 'module'] : [];
let code = isInitFn ? `${init}` : '';
if (code.includes('require')) {
code = code.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)|\/\/[^'"`\r\n]*?require\s*\(.*/g, '');
const rx = /[^.\w]require\s*\(\s*(['"])(?!resolved!)([^'"]+)\1\s*\)/g;
for (let m; (m = rx.exec(code));) {
deps.push(m[2]);
}
}
}
const exports = {};
const internals = isInitFn && {
exports,
module: {exports},
require: require.bind(base),
};
const needs = {};
const toLoad = [];
const ctx = {deps, needs, name, urls: []};
deps.forEach((depName, i) => {
let int, mode, url;
if ((int = internals[depName]) ||
({mode, url} = parseDepName(depName, base)).url in modules) {
deps[i] = int || modules[url];
} else if (needs[url] == null) {
needs[url] = i;
ctx.urls.push(url);
const queue = depsQueue[url];
if (!queue) toLoad.push({mode, url});
(queue || (depsQueue[url] = [])).push(ctx);
}
});
let resolvedElsewhere;
if (!isEmpty(needs)) {
if (toLoad.length) document.head.append(...toLoad.map(loadElement).filter(Boolean));
if (name && !depsQueue[name]) depsQueue[name] = [ctx];
if (!isEmpty(needs)) await new Promise(resolve => (ctx.resolve = resolve));
resolvedElsewhere = name in modules;
}
const result =
resolvedElsewhere ? modules[name] :
isInitFn ? init(...deps) :
hasInit ? init :
deps[0];
if (name && !resolvedElsewhere) {
modules[name] = result;
const toClear = [name];
while (toClear.length) {
const tc = toClear.pop();
const contexts = depsQueue[tc] || [];
for (let i = contexts.length, qCtx; i && (qCtx = contexts[--i]);) {
const {needs} = qCtx;
qCtx.deps[needs[tc]] = result;
delete needs[tc];
delete needs[qCtx.name];
if (isEmpty(needs)) {
toClear.push(...qCtx.urls);
contexts.splice(i, 1);
if (qCtx.resolve) qCtx.resolve();
}
}
if (!contexts.length) delete depsQueue[tc];
}
}
define.currentModule = null;
return result;
};
define.amd = {modules, depsQueue};
}
//#endregion
//#region Stuff for our extension pages
if (!isContentScript) {
if (!(new URLSearchParams({foo: 1})).get('foo')) {
// TODO: remove when minimum_chrome_version >= 61
window.URLSearchParams = class extends URLSearchParams {
constructor(init) {
if (init && typeof init === 'object') {
super();
for (const [key, val] of Object.entries(init)) {
this.set(key, val);
}
} else {
super(...arguments);
}
}
};
}
}
//#endregion
define.currentModule = '/js/polyfill';
define(() => ({
isEmptyObj(obj) {
if (obj) {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
return false;
}
}
}
return true;
},
}));
}

View File

@ -1,12 +1,17 @@
/* global msg API */
/* global deepCopy debounce */ // not used in content scripts
'use strict'; 'use strict';
// eslint-disable-next-line no-unused-expressions /** The name is needed when running in content scripts but specifying it in define()
window.INJECTED !== 1 && (() => { breaks IDE detection of exports so here's a workaround */
define.currentModule = '/js/prefs';
define(require => {
const {deepCopy, debounce} = require('/js/toolbox'); // will be empty in content scripts
const {API, msg} = require('/js/msg');
const STORAGE_KEY = 'settings'; const STORAGE_KEY = 'settings';
const clone = msg.isBg ? deepCopy : (val => JSON.parse(JSON.stringify(val))); const clone = deepCopy || (val => JSON.parse(JSON.stringify(val)));
const defaults = /** @namespace Prefs */{ /** @type {PrefsValues} */
const defaults = /** @namespace PrefsValues */ {
'openEditInWindow': false, // new editor opens in a own browser window 'openEditInWindow': false, // new editor opens in a own browser window
'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox 'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
'windowPosition': {}, // detached window position 'windowPosition': {}, // detached window position
@ -117,9 +122,13 @@ window.INJECTED !== 1 && (() => {
any: new Set(), any: new Set(),
specific: {}, specific: {},
}; };
let isReady;
// getPrefs may fail on browser startup in the active tab as it loads before the background script // 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)) const initializing = (msg.isBg ? readStorage() : API.getPrefs().catch(readStorage))
.then(setAll); .then(data => {
setAll(data);
isReady = true;
});
chrome.storage.onChanged.addListener(async (changes, area) => { chrome.storage.onChanged.addListener(async (changes, area) => {
const data = area === 'sync' && changes[STORAGE_KEY]; const data = area === 'sync' && changes[STORAGE_KEY];
@ -129,17 +138,25 @@ window.INJECTED !== 1 && (() => {
} }
}); });
// This direct assignment allows IDEs to provide correct autocomplete for methods /** @namespace Prefs */
const prefs = window.prefs = { const prefs = {
STORAGE_KEY, STORAGE_KEY,
initializing,
defaults, defaults,
initializing,
get isReady() {
return isReady;
},
/** @type {PrefsValues} */
get values() { get values() {
return deepCopy(values); return deepCopy(values);
}, },
get(key) { get(key) {
return isKnown(key) && values[key]; return isKnown(key) && values[key];
}, },
set(key, val, isSynced) { set(key, val, isSynced) {
if (!isKnown(key)) return; if (!isKnown(key)) return;
const oldValue = values[key]; const oldValue = values[key];
@ -155,36 +172,45 @@ window.INJECTED !== 1 && (() => {
emitChange(key, val, isSynced); emitChange(key, val, isSynced);
} }
}, },
reset(key) { reset(key) {
prefs.set(key, clone(defaults[key])); prefs.set(key, clone(defaults[key]));
}, },
/** /**
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything * @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 {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` * 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 * 2) if `keys` is falsy, no key/value will be provided
*/ */
subscribe(keys, fn, {now} = {}) { async subscribe(keys, fn, {runNow} = {}) {
const toRun = [];
if (keys) { if (keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) { for (const key of Array.isArray(keys) ? keys : [keys]) {
if (!isKnown(key)) continue; if (!isKnown(key)) continue;
const listeners = onChange.specific[key] || const listeners = onChange.specific[key] ||
(onChange.specific[key] = new Set()); (onChange.specific[key] = new Set());
listeners.add(fn); listeners.add(fn);
if (now) fn(key, values[key]); if (runNow) toRun.push({fn, args: [key, values[key]]});
} }
} else { } else {
onChange.any.add(fn); onChange.any.add(fn);
if (now) fn(); if (runNow) toRun.push({fn});
}
if (toRun.length) {
if (!isReady) await initializing;
toRun.forEach(({fn, args}) => fn(...args));
} }
}, },
subscribeMany(data, opts) { subscribeMany(data, opts) {
for (const [k, fn] of Object.entries(data)) { for (const [k, fn] of Object.entries(data)) {
prefs.subscribe(k, fn, opts); prefs.subscribe(k, fn, opts);
} }
}, },
unsubscribe(keys, fn) { unsubscribe(keys, fn) {
if (keys) { if (keys) {
for (const key of keys) { for (const key of keys) {
@ -233,9 +259,8 @@ window.INJECTED !== 1 && (() => {
} }
} }
function readStorage() { async function readStorage() {
return browser.storage.sync.get(STORAGE_KEY) return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
.then(data => data[STORAGE_KEY]);
} }
function updateStorage() { function updateStorage() {
@ -247,4 +272,11 @@ window.INJECTED !== 1 && (() => {
Object.keys(a).length === Object.keys(b).length && Object.keys(a).length === Object.keys(b).length &&
Object.keys(a).every(key => b.hasOwnProperty(key) && simpleDeepEqual(a[key], b[key])); Object.keys(a).every(key => b.hasOwnProperty(key) && simpleDeepEqual(a[key], b[key]));
} }
})();
/* Exposing it for easier debugging otherwise it gets really cumbersome to type
(await require(['/js/prefs'])).get('disableAll')
Also we're still using it as a global in content scripts */
window.prefs = prefs;
return prefs;
});

View File

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

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,90 +1,94 @@
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
'use strict'; 'use strict';
function styleCodeEmpty(code) { define(require => {
if (!code) { const exports = {
return true;
}
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
}
}
return false;
}
/** Checks if section is global i.e. has no targets at all */ async calcStyleDigest(style) {
function styleSectionGlobal(section) { const src = style.usercssData
return (!section.regexps || !section.regexps.length) && ? style.sourceCode
(!section.urlPrefixes || !section.urlPrefixes.length) && : JSON.stringify(normalizeStyleSections(style));
(!section.urls || !section.urls.length) && const srcBytes = new TextEncoder().encode(src);
(!section.domains || !section.domains.length); const res = await crypto.subtle.digest('SHA-1', srcBytes);
} return Array.from(new Uint8Array(res), byte2hex).join('');
},
/** styleCodeEmpty(code) {
* The sections are checked in successive order because it matters when many sections if (!code) {
* match the same URL and they have rules with the same CSS specificity return true;
* @param {Object} a - first style object }
* @param {Object} b - second style object const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
* @returns {?boolean} while (rx.exec(code)) {
*/ if (rx.lastIndex === code.length) {
function styleSectionsEqual({sections: a}, {sections: b}) { return true;
const targets = ['urls', 'urlPrefixes', 'domains', 'regexps']; }
return a && b && a.length === b.length && a.every(sameSection); }
function sameSection(secA, i) { return false;
return equalOrEmpty(secA.code, b[i].code, 'string', (a, b) => a === b) && },
targets.every(target => equalOrEmpty(secA[target], b[i][target], 'array', arrayMirrors));
styleJSONseemsValid(json) {
return json
&& typeof json.name == 'string'
&& json.name.trim()
&& Array.isArray(json.sections)
&& typeof (json.sections[0] || {}).code === 'string';
},
/**
* Checks if section is global i.e. has no targets at all
*/
styleSectionGlobal(section) {
return (!section.regexps || !section.regexps.length) &&
(!section.urlPrefixes || !section.urlPrefixes.length) &&
(!section.urls || !section.urls.length) &&
(!section.domains || !section.domains.length);
},
/**
* The sections are checked in successive order because it matters when many sections
* match the same URL and they have rules with the same CSS specificity
* @param {Object} a - first style object
* @param {Object} b - second style object
* @returns {?boolean}
*/
styleSectionsEqual({sections: a}, {sections: b}) {
const targets = ['urls', 'urlPrefixes', 'domains', 'regexps'];
return a && b && a.length === b.length && a.every(sameSection);
function sameSection(secA, i) {
return equalOrEmpty(secA.code, b[i].code, 'string', (a, b) => a === b) &&
targets.every(target => equalOrEmpty(secA[target], b[i][target], 'array', arrayMirrors));
}
function equalOrEmpty(a, b, type, comparator) {
const typeA = type === 'array' ? Array.isArray(a) : typeof a === type;
const typeB = type === 'array' ? Array.isArray(b) : typeof b === type;
return typeA && typeB && comparator(a, b) ||
(a == null || typeA && !a.length) &&
(b == null || typeB && !b.length);
}
function arrayMirrors(a, b) {
return a.length === b.length &&
a.every(el => b.includes(el)) &&
b.every(el => a.includes(el));
}
},
};
function byte2hex(b) {
return (0x100 + b).toString(16).slice(1);
} }
function equalOrEmpty(a, b, type, comparator) {
const typeA = type === 'array' ? Array.isArray(a) : typeof a === type; function normalizeStyleSections({sections}) {
const typeB = type === 'array' ? Array.isArray(b) : typeof b === type; // retain known properties in an arbitrarily predefined order
return typeA && typeB && comparator(a, b) || return (sections || []).map(section => /** @namespace StyleSection */({
(a == null || typeA && !a.length) && code: section.code || '',
(b == null || typeB && !b.length); urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
} }
function arrayMirrors(a, b) {
return a.length === b.length &&
a.every(el => b.includes(el)) &&
b.every(el => a.includes(el));
}
}
function normalizeStyleSections({sections}) { return exports;
// retain known properties in an arbitrarily predefined order });
return (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('');
}
}
function styleJSONseemsValid(json) {
return json
&& json.name
&& json.name.trim()
&& Array.isArray(json.sections)
&& json.sections
&& json.sections.length
&& typeof json.sections.every === 'function'
&& typeof json.sections[0].code === 'string';
}

View File

@ -1,7 +1,10 @@
/* global loadScript tryJSONparse */
'use strict'; 'use strict';
(() => { define(require => {
const {tryJSONparse} = require('/js/toolbox');
let LZString;
/** @namespace StorageExtras */ /** @namespace StorageExtras */
const StorageExtras = { const StorageExtras = {
async getValue(key) { async getValue(key) {
@ -29,11 +32,8 @@
return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value))); return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value)));
}, },
async getLZString() { async getLZString() {
if (!window.LZString) { return LZString ||
await loadScript('/vendor/lz-string-unsafe/lz-string-unsafe.min.js'); (LZString = await require(['/vendor/lz-string-unsafe/lz-string-unsafe.min']));
window.LZString = window.LZString || window.LZStringUnsafe;
}
return window.LZString;
}, },
}; };
/** @namespace StorageExtrasSync */ /** @namespace StorageExtrasSync */
@ -44,8 +44,14 @@
usercssTemplate: 'usercssTemplate', usercssTemplate: 'usercssTemplate',
}, },
}; };
/** @type {chrome.storage.StorageArea|StorageExtras} */
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras); /** @typedef {chrome.storage.StorageArea|StorageExtras} ChromeLocal */
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */ /** @typedef {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} ChromeSync */
window.chromeSync = Object.assign(browser.storage.sync, StorageExtras, StorageExtrasSync);
})(); return {
/** @type {ChromeLocal} */
chromeLocal: Object.assign(browser.storage.local, StorageExtras),
/** @type {ChromeSync} */
chromeSync: Object.assign(browser.storage.sync, StorageExtras, StorageExtrasSync),
};
});

433
js/toolbox.js Normal file
View File

@ -0,0 +1,433 @@
'use strict';
define(require => {
/** @type {Toolbox} */
let toolbox;
const ua = navigator.userAgent;
const chromeApp = Boolean(chrome.app);
const CHROME = chromeApp && parseInt(ua.match(/Chrom\w+\/(\d+)|$/)[1]);
const OPERA = chromeApp && parseFloat(ua.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
const VIVALDI = chromeApp && ua.includes('Vivaldi');
let FIREFOX = !chrome.app && parseFloat(ua.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
// FF57+ supports openerTabId, but not in Android
// (detecting FF57 by the feature it added, not navigator.ua which may be spoofed in about:config)
const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null;
const debounceTimers = new Map();
const {
URLS,
deepCopy,
deepEqual,
deepMerge,
} = toolbox = /** @namespace Toolbox */ {
CHROME,
FIREFOX,
OPERA,
VIVALDI,
CHROME_HAS_BORDER_BUG: CHROME >= 62 && CHROME <= 74,
URLS: {
ownOrigin: chrome.runtime.getURL(''),
configureCommands:
OPERA ? 'opera://settings/configureCommands'
: 'chrome://extensions/configureCommands',
installUsercss: chrome.runtime.getURL('install-usercss.html'),
// CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
// https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
browserWebStore:
FIREFOX ? 'https://addons.mozilla.org/' :
OPERA ? 'https://addons.opera.com/' :
'https://chrome.google.com/webstore/',
emptyTab: [
// Chrome and simple forks
'chrome://newtab/',
// Opera
'chrome://startpage/',
// Vivaldi
'chrome-extension://mpognobbkildjkofajifpdfhcoklimli/components/startpage/startpage.html',
// Firefox
'about:home',
'about:newtab',
],
// Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/
// TODO: remove when "minimum_chrome_version": "61" or higher
chromeProtectsNTP: CHROME >= 61,
uso: 'https://userstyles.org/',
usoJson: 'https://userstyles.org/styles/chrome/',
usoArchive: 'https://33kk.github.io/uso-archive/',
usoArchiveRaw: 'https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/',
extractUsoArchiveId: url =>
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}` : '';
},
extractGreasyForkInstallUrl: url =>
/^(https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/\d+)[^/]*\/code\/[^/]*\.user\.css$|$/.exec(url)[1],
supported: url => (
url.startsWith('http') ||
url.startsWith('ftp') ||
url.startsWith('file') ||
url.startsWith(URLS.ownOrigin) ||
!URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/')
),
},
/* A simple polyfill in case DOM storage is disabled in the browser */
sessionStore: new Proxy({}, {
get(target, name) {
try {
return sessionStorage[name];
} catch (e) {
Object.defineProperty(window, 'sessionStorage', {value: target});
}
},
set(target, name, value, proxy) {
try {
sessionStorage[name] = `${value}`;
} catch (e) {
proxy[name]; // eslint-disable-line no-unused-expressions
target[name] = `${value}`;
}
return true;
},
deleteProperty(target, name) {
return delete target[name];
},
}),
async activateTab(tab, {url, index, openerTabId} = {}) {
const options = {active: true};
if (url) {
options.url = url;
}
if (openerTabId != null && openerTabIdSupported) {
options.openerTabId = openerTabId;
}
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}),
]);
return tab;
},
capitalize(s) {
return s[0].toUpperCase() + s.slice(1);
},
async closeCurrentTab() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
const tab = await browser.tabs.getCurrent();
if (tab) chrome.tabs.remove(tab.id);
},
debounce(fn, delay, ...args) {
const t = debounceTimers.get(fn);
if (t) clearTimeout(t);
debounceTimers.set(fn, setTimeout(debounceRun, delay, fn, ...args));
},
deepMerge(src, dst) {
if (!src || typeof src !== 'object') {
return src;
}
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(deepCopy(v));
} else {
// using an explicit {} that belongs to this `window`
if (!dst) dst = {};
for (const [k, v] of Object.entries(src)) {
dst[k] = deepCopy(v, dst[k]);
}
}
return dst;
},
/** Useful in arr.map(deepCopy) to ignore the extra parameters passed by map() */
deepCopy(src) {
return deepMerge(src);
},
deepEqual(a, b, ignoredKeys) {
if (!a || !b) return a === b;
const type = typeof a;
if (type !== typeof b) return false;
if (type !== 'object') return a === b;
if (Array.isArray(a)) {
return Array.isArray(b) &&
a.length === b.length &&
a.every((v, i) => deepEqual(v, b[i], ignoredKeys));
}
for (const key in a) {
if (!Object.hasOwnProperty.call(a, key) ||
ignoredKeys && ignoredKeys.includes(key)) continue;
if (!Object.hasOwnProperty.call(b, key)) return false;
if (!deepEqual(a[key], b[key], ignoredKeys)) return false;
}
for (const key in b) {
if (!Object.hasOwnProperty.call(b, key) ||
ignoredKeys && ignoredKeys.includes(key)) continue;
if (!Object.hasOwnProperty.call(a, key)) return false;
}
return true;
},
download(url, {
method = 'GET',
body,
responseType = 'text',
requiredStatusCode = 200,
timeout = 60e3, // connection timeout, USO is that bad
loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
headers,
} = {}) {
/* 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);
}
if (headers === undefined) {
headers = {
'Content-type': 'application/x-www-form-urlencoded',
};
}
}
const usoVars = [];
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const u = new URL(collapseUsoVars(url));
const onTimeout = () => {
xhr.abort();
reject(new Error('Timeout fetching ' + u.href));
};
let timer = setTimeout(onTimeout, timeout);
xhr.onreadystatechange = () => {
if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
xhr.onreadystatechange = null;
clearTimeout(timer);
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
}
};
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);
for (const [name, value] of Object.entries(headers || {})) {
xhr.setRequestHeader(name, value);
}
xhr.send(body);
});
function collapseUsoVars(url) {
if (queryPos < 0 ||
url.length < 2000 ||
!url.startsWith(URLS.usoJson) ||
!/^get$/i.test(method)) {
return url;
}
const params = new URLSearchParams(url.slice(queryPos + 1));
for (const [k, v] of params.entries()) {
if (v.length < 10 || v.startsWith('ik-')) continue;
usoVars.push(v);
params.set(k, `\x01${usoVars.length}\x02`);
}
return url.slice(0, queryPos + 1) + params.toString();
}
function expandUsoVars(response) {
if (!usoVars.length || !response) return response;
const isText = typeof response === 'string';
const json = isText && toolbox.tryJSONparse(response) || response;
json.updateUrl = url;
for (const section of json.sections || []) {
const {code} = section;
if (code.includes('\x01')) {
section.code = code.replace(/\x01(\d+)\x02/g, (_, num) => usoVars[num - 1] || '');
}
}
return isText ? JSON.stringify(json) : json;
}
},
async findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
url = new URL(url);
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 &&
tabUrl.password === url.password &&
tabUrl.hostname === url.hostname &&
tabUrl.port === url.port &&
tabUrl.pathname === url.pathname &&
(ignoreSearch || tabUrl.search === url.search) &&
(ignoreHash || tabUrl.hash === url.hash);
});
},
async getActiveTab() {
return (await browser.tabs.query({currentWindow: true, active: true}))[0];
},
getOwnTab() {
return browser.tabs.getCurrent();
},
getStyleWithNoCode(style) {
const stripped = deepCopy(style);
for (const section of stripped.sections) section.code = null;
stripped.sourceCode = null;
return stripped;
},
ignoreChromeError() {
// eslint-disable-next-line no-unused-expressions
chrome.runtime.lastError;
},
/**
* Replaces empty tab (NTP or about:blank)
* except when new URL is chrome:// or chrome-extension:// and the empty tab is in incognito
*/
isTabReplaceable(tab, newUrl) {
return tab &&
URLS.emptyTab.includes(tab.pendingUrl || tab.url) &&
!(tab.incognito && newUrl.startsWith('chrome'));
},
async openURL({
url,
index,
openerTabId,
active = true,
currentWindow = true,
newWindow,
}) {
if (!url.includes('://')) {
url = chrome.runtime.getURL(url);
}
let tab = await toolbox.findExistingTab({url, currentWindow});
if (tab) {
return toolbox.activateTab(tab, {
index,
openerTabId,
// when hash is different we can only set `url` if it has # otherwise the tab would reload
url: url !== (tab.pendingUrl || tab.url) && url.includes('#') ? url : undefined,
});
}
if (newWindow && browser.windows) {
return (await browser.windows.create(Object.assign({url}, newWindow))).tabs[0];
}
tab = await toolbox.getActiveTab() || {url: ''};
if (toolbox.isTabReplaceable(tab, url)) {
return toolbox.activateTab(tab, {url, openerTabId});
}
const id = openerTabId == null ? tab.id : openerTabId;
const opener = id != null && !tab.incognito && openerTabIdSupported && {openerTabId: id};
return browser.tabs.create(Object.assign({url, index, active}, opener));
},
stringAsRegExp(s, flags, asString) {
s = s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&');
return asString ? s : new RegExp(s, flags);
},
/**
* 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
*/
tryCatch(func, ...args) {
try {
return func(...args);
} catch (e) {}
},
tryJSONparse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {}
},
tryRegExp(regexp, flags) {
try {
return new RegExp(regexp, flags);
} catch (e) {}
},
};
// see PR #781
if (!CHROME && !chrome.browserAction.openPopup) {
// in FF pre-57 legacy addons can override useragent so we assume the worst
// until we know for sure in the async getBrowserInfo()
// (browserAction.openPopup was added in 57)
FIREFOX = browser.runtime.getBrowserInfo ? 51 : 50;
// getBrowserInfo was added in FF 51
Promise.resolve(FIREFOX >= 51 ? browser.runtime.getBrowserInfo() : {version: 50}).then(info => {
FIREFOX = parseFloat(info.version);
document.documentElement.classList.add('moz-appearance-bug', FIREFOX && FIREFOX < 54);
});
}
if (!chrome.extension.getBackgroundPage || chrome.extension.getBackgroundPage() !== window) {
const cls = FIREFOX ? 'firefox' : OPERA ? 'opera' : VIVALDI ? 'vivaldi' : '';
if (cls) document.documentElement.classList.add(cls);
}
Object.assign(toolbox.debounce, {
unregister(fn) {
clearTimeout(debounceTimers.get(fn));
debounceTimers.delete(fn);
},
});
function debounceRun(fn, ...args) {
debounceTimers.delete(fn);
fn(...args);
}
function urlToMatchPattern(url, ignoreSearch) {
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
if (!/^(http|https|ws|wss|ftp|data|file)$/.test(url.protocol)) {
return undefined;
}
if (ignoreSearch) {
return [
`${url.protocol}//${url.hostname}/${url.pathname}`,
`${url.protocol}//${url.hostname}/${url.pathname}?*`,
];
}
// FIXME: is %2f allowed in pathname and search?
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
}
return toolbox;
});

View File

@ -1,81 +0,0 @@
/* global API */
/* 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,
// Methods are sorted alphabetically
async assignVars(style, oldStyle) {
const vars = style.usercssData.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;
}
style.usercssData.vars = await API.worker.nullifyInvalidVars(vars);
}
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(RX_META);
const codeNoMeta = code.slice(0, match.index) + code.slice(match.index + match[0].length);
const {sections, errors} = 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(sourceCode) {
sourceCode = sourceCode.replace(/\r\n?/g, '\n');
const style = {
enabled: true,
sections: [],
sourceCode,
};
const match = sourceCode.match(RX_META);
if (!match) {
return Promise.reject(new Error('Could not find metadata.'));
}
try {
const {metadata} = await API.worker.parseUsercssMeta(match[0], match.index);
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;
} catch (err) {
if (err.code) {
const args = ERR_ARGS_IS_LIST.has(err.code)
? 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);
}
},
};
})();

View File

@ -1,84 +1,118 @@
'use strict'; 'use strict';
const workerUtil = { if (typeof define !== 'function') {
const defines = {};
let currentPath = '/js/worker-util.js';
createWorker({url, lifeTime = 300}) { const require = defines.require = (url, fn) => {
let worker; const deps = [];
let id; for (let u of Array.isArray(url) ? url : [url]) {
let timer; if (u !== 'require') {
const pendingResponse = new Map(); if (!u.endsWith('.js')) u += '.js';
return new Proxy({}, { if (!u.startsWith('/')) u = new URL(u, location.origin + currentPath).pathname;
get: (target, prop) => if (u && !defines.hasOwnProperty(u)) {
(...args) => { currentPath = u;
if (!worker) { importScripts(u);
init(); }
}
return invoke(prop, args);
},
});
function init() {
id = 0;
worker = new Worker(url);
worker.onmessage = onMessage;
}
function uninit() {
worker.onmessage = null;
worker.terminate();
worker = null;
}
function onMessage({data: {id, data, error}}) {
pendingResponse.get(id)[error ? 'reject' : 'resolve'](data);
pendingResponse.delete(id);
if (!pendingResponse.size && lifeTime >= 0) {
timer = setTimeout(uninit, lifeTime * 1000);
} }
deps.push(defines[u]);
} }
if (typeof fn === 'function') {
fn(...deps);
}
return deps[0];
};
function invoke(action, args) { self.define = (deps, fn) => {
return new Promise((resolve, reject) => { if (typeof deps === 'function') {
pendingResponse.set(id, {resolve, reject}); defines[currentPath] = deps(require);
clearTimeout(timer); } else if (Array.isArray(deps)) {
worker.postMessage({id, action, args}); const path = currentPath;
id++; require(deps, (...res) => {
defines[path] = fn(...res);
}); });
} }
}, };
}
createAPI(methods) { define(require => {
self.onmessage = async ({data: {id, action, args}}) => { let exports;
let data, error; const GUEST = 'url';
try { const {cloneError} = exports = {
data = await methods[action](...args);
} catch (err) { cloneError(err) {
error = true; return Object.assign({
data = workerUtil.cloneError(err); name: err.name,
stack: err.stack,
message: err.message,
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
fileName: err.fileName,
}, err);
},
createAPI(methods) {
self.onmessage = async ({data: {id, action, args}}) => {
let data, error;
try {
data = await methods[action](...args);
} catch (err) {
error = true;
data = cloneError(err);
}
self.postMessage({id, data, error});
};
},
createWorker({url, lifeTime = 300}) {
let worker;
let id;
let timer;
const pendingResponse = new Map();
return new Proxy({}, {
get(target, prop) {
return (...args) => {
if (!worker) init();
return invoke(prop, args);
};
},
});
function init() {
id = 0;
worker = new Worker('/js/worker-util.js?' + new URLSearchParams({[GUEST]: url}));
worker.onmessage = onMessage;
} }
self.postMessage({id, data, error});
};
},
cloneError(err) { function uninit() {
return Object.assign({ worker.onmessage = null;
name: err.name, worker.terminate();
stack: err.stack, worker = null;
message: err.message, }
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
fileName: err.fileName,
}, err);
},
loadScript(...urls) { function onMessage({data: {id, data, error}}) {
urls = urls.filter(u => !workerUtil._loadedScripts.has(u)); pendingResponse.get(id)[error ? 'reject' : 'resolve'](data);
if (!urls.length) { pendingResponse.delete(id);
return; if (!pendingResponse.size && lifeTime >= 0) {
} timer = setTimeout(uninit, lifeTime * 1000);
self.importScripts(...urls); }
urls.forEach(u => workerUtil._loadedScripts.add(u)); }
},
_loadedScripts: new Set(), function invoke(action, args) {
}; return new Promise((resolve, reject) => {
pendingResponse.set(id, {resolve, reject});
clearTimeout(timer);
worker.postMessage({id, action, args});
id++;
});
}
},
};
if (self.WorkerGlobalScope) {
Promise.resolve().then(() =>
require(new URLSearchParams(location.search).get(GUEST)));
}
return exports;
});

View File

@ -5,11 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title i18n-text="manageTitle"></title> <title i18n-text="manageTitle"></title>
<link rel="stylesheet" href="global.css"> <link rel="stylesheet" href="global.css">
<link rel="stylesheet" href="options/onoffswitch.css">
<link rel="stylesheet" href="manage/manage.css">
<link rel="stylesheet" href="manage/config-dialog.css">
<link rel="stylesheet" href="msgbox/msgbox.css">
<link rel="stylesheet" href="vendor-overwrites/colorpicker/colorpicker.css">
<style id="firefox-transitions-bug-suppressor"> <style id="firefox-transitions-bug-suppressor">
/* restrict to FF */ /* restrict to FF */
@ -162,30 +157,31 @@
</details> </details>
</template> </template>
<!-- IMPORTANT!
All these assets must be specified in html to avoid blank/unstyled frames while loading
as can be verified in devtools `Network` panel with screenshot option enabled.
TODO: use a proper build system instead to produce a single bundle. -->
<script src="js/polyfill.js"></script> <script src="js/polyfill.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/msg.js"></script> <script src="js/msg.js"></script>
<script src="js/toolbox.js"></script>
<script src="js/prefs.js"></script> <script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/router.js"></script> <script src="js/router.js"></script>
<script src="content/style-injector.js"></script> <script src="content/style-injector.js"></script>
<script src="content/apply.js"></script> <script src="content/apply.js"></script>
<script src="js/localization.js"></script>
<script src="manage/events.js"></script>
<script src="manage/filters.js"></script> <script src="manage/filters.js"></script>
<script src="manage/sort.js"></script> <script src="manage/new-ui.js"></script>
<script src="manage/render.js"></script>
<script src="manage/sorter.js"></script>
<script src="manage/manage.js"></script> <script src="manage/manage.js"></script>
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script> <link rel="stylesheet" href="options/onoffswitch.css">
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script> <link rel="stylesheet" href="manage/manage.css">
<script src="manage/config-dialog.js"></script>
<script src="manage/updater-ui.js"></script>
<script src="manage/object-diff.js"></script>
<script src="manage/import-export.js"></script>
<script src="manage/incremental-search.js"></script>
<script src="msgbox/msgbox.js"></script>
<script src="js/sections-util.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/script-loader.js"></script>
</head> </head>
<body id="stylus-manage" i18n-dragndrop-hint="dragDropMessage"> <body id="stylus-manage" i18n-dragndrop-hint="dragDropMessage">

View File

@ -1,453 +0,0 @@
/* global
$
$create
$createLink
API
debounce
deepCopy
messageBox
prefs
setupLivePrefs
t
*/
/* exported configDialog */
'use strict';
function configDialog(style) {
const AUTOSAVE_DELAY = 500;
let saving = false;
const data = style.usercssData;
const varsHash = deepCopy(data.vars) || {};
const varNames = Object.keys(varsHash);
const vars = varNames.map(name => varsHash[name]);
let varsInitial = getInitialValues(varsHash);
const elements = [];
const colorpicker = window.colorpicker();
const isPopup = location.href.includes('popup.html');
const buttons = {};
buildConfigForm();
renderValues();
vars.forEach(renderValueState);
return messageBox({
title: `${style.customName || style.name} v${data.version}`,
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
contents: [
$create('.config-heading', data.supportURL &&
$createLink({className: '.external-support', href: data.supportURL}, t('externalFeedback'))),
$create('.config-body', elements),
],
buttons: [{
textContent: t('confirmSave'),
dataset: {cmd: 'save'},
disabled: true,
onclick: save,
}, {
textContent: t('genericResetLabel'),
title: t('optionsReset'),
dataset: {cmd: 'default'},
onclick: useDefault,
}, {
textContent: t('confirmClose'),
dataset: {cmd: 'close'},
}],
onshow,
}).then(onhide);
function getInitialValues(source) {
const data = {};
for (const name of varNames) {
const va = source[name];
data[name] = isDefault(va) ? va.default : va.value;
}
return data;
}
function onshow(box) {
$('button', box).insertAdjacentElement('afterend',
$create('label#config-autosave-wrapper', {
title: t('configOnChangeTooltip'),
}, [
$create('input', {id: 'config.autosave', type: 'checkbox'}),
$create('SVG:svg.svg-icon.checked',
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
t('configOnChange'),
]));
setupLivePrefs(['config.autosave']);
if (isPopup) {
adjustSizeForPopup(box);
}
box.addEventListener('change', onchange);
buttons.save = $('[data-cmd="save"]', box);
buttons.default = $('[data-cmd="default"]', box);
buttons.close = $('[data-cmd="close"]', box);
updateButtons();
}
function onhide() {
document.body.style.minWidth = '';
document.body.style.minHeight = '';
colorpicker.hide();
}
function onchange({target, justSaved = false}) {
// invoked after element's own onchange so 'va' contains the updated value
const va = target.va;
if (va) {
va.dirty = varsInitial[va.name] !== (isDefault(va) ? va.default : va.value);
if (prefs.get('config.autosave') && !justSaved) {
debounce(save, 100, {anyChangeIsDirty: true});
return;
}
renderValueState(va);
if (!justSaved) {
updateButtons();
}
}
}
function updateButtons() {
const someDirty = vars.some(va => va.dirty);
buttons.save.disabled = !someDirty;
buttons.default.disabled = vars.every(isDefault);
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
}
function save({anyChangeIsDirty = false} = {}, bgStyle) {
if (saving) {
debounce(save, 0, ...arguments);
return;
}
if (!vars.length ||
!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
return;
}
if (!bgStyle) {
API.styles.get(style.id)
.catch(() => ({}))
.then(bgStyle => save({anyChangeIsDirty}, bgStyle));
return;
}
style = style.sections ? Object.assign({}, style) : style;
style.enabled = true;
style.sourceCode = null;
style.sections = null;
const styleVars = style.usercssData.vars;
const bgVars = (bgStyle.usercssData || {}).vars || {};
const invalid = [];
let numValid = 0;
for (const va of vars) {
const bgva = bgVars[va.name];
let error;
if (!bgva) {
error = 'deleted';
delete styleVars[va.name];
} 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)) {
error = `'${va.value}' not in the updated '${va.type}' list`;
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
continue;
} else {
styleVars[va.name].value = va.value;
va.savedValue = va.value;
numValid++;
continue;
}
invalid.push(['*' + va.name, ': ', ...error].map(e =>
e[0] === '*' && $create('b', e.slice(1)) || e));
if (bgva) {
styleVars[va.name].value = deepCopy(bgva);
}
}
if (invalid.length) {
onhide();
messageBox.alert([
$create('div', {style: 'max-width: 34em'}, t('usercssConfigIncomplete')),
$create('ol', {style: 'text-align: left'},
invalid.map(msg =>
$create({tag: 'li', appendChild: msg}))),
], 'pre');
}
if (!numValid) {
return;
}
saving = true;
return API.usercss.configVars(style.id, style.usercssData.vars)
.then(newVars => {
varsInitial = getInitialValues(newVars);
vars.forEach(va => onchange({target: va.input, justSaved: true}));
renderValues();
updateButtons();
$.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(() => {
saving = false;
});
}
function useDefault() {
for (const va of vars) {
va.value = null;
onchange({target: va.input});
}
renderValues();
}
function isDefault(va) {
return va.value === null || va.value === undefined || va.value === va.default;
}
function buildConfigForm() {
let resetter =
$create('a.config-reset-icon', {href: '#'}, [
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'}, [
$create('SVG:title', t('genericResetLabel')),
$create('SVG:polygon', {
points: '16.2,5.5 14.5,3.8 10,8.3 5.5,3.8 3.8,5.5 8.3,10 3.8,14.5 ' +
'5.5,16.2 10,11.7 14.5,16.2 16.2,14.5 11.7,10',
}),
]),
]);
for (const va of vars) {
let children;
switch (va.type) {
case 'color':
children = [
$create('.colorview-swatch.config-value', [
va.input = $create('a.color-swatch', {
va,
href: '#',
onclick: showColorpicker,
}),
]),
];
break;
case 'checkbox':
children = [
$create('span.onoffswitch.config-value', [
va.input = $create('input.slider', {
va,
type: 'checkbox',
onchange: updateVarOnChange,
}),
$create('span'),
]),
];
break;
case 'select':
case 'dropdown':
case 'image':
// TODO: a image picker input?
children = [
$create('.select-resizer.config-value', [
va.input = $create('select', {
va,
onchange: updateVarOnChange,
},
va.options.map(o =>
$create('option', {value: o.name}, o.label))),
$create('SVG:svg.svg-icon.select-arrow',
$create('SVG:use', {'xlink:href': '#svg-icon-select-arrow'})),
]),
];
break;
case 'range':
case 'number': {
const options = {
va,
type: va.type,
onfocus: va.type === 'number' ? selectAllOnFocus : null,
onblur: va.type === 'number' ? updateVarOnBlur : null,
onchange: updateVarOnChange,
oninput: updateVarOnInput,
required: true,
};
if (typeof va.min === 'number') {
options.min = va.min;
}
if (typeof va.max === 'number') {
options.max = va.max;
}
if (typeof va.step === 'number' && isFinite(va.step)) {
options.step = va.step;
}
children = [
va.type === 'range' && $create('span.current-value'),
va.input = $create('input.config-value', options),
];
break;
}
default:
children = [
va.input = $create('input.config-value', {
va,
type: va.type,
onchange: updateVarOnChange,
oninput: updateVarOnInput,
onfocus: selectAllOnFocus,
}),
];
}
resetter = resetter.cloneNode(true);
resetter.va = va;
resetter.onclick = resetOnClick;
elements.push(
$create(`label.config-${va.type}`, [
$create('span.config-name', t.breakWord(va.label)),
...children,
resetter,
]));
va.savedValue = va.value;
}
}
function updateVarOnBlur() {
this.value = isDefault(this.va) ? this.va.default : this.va.value;
}
function updateVarOnChange() {
if (this.type === 'range') {
this.va.value = Number(this.value);
updateRangeCurrentValue(this.va, this.va.value);
} else if (this.type === 'number') {
if (this.reportValidity()) {
this.va.value = Number(this.value);
}
} else {
this.va.value = this.type !== 'checkbox' ? this.value : this.checked ? '1' : '0';
}
}
function updateRangeCurrentValue(va, value) {
const span = $('.current-value', va.input.closest('.config-range'));
if (span) {
span.textContent = value + (va.units || '');
}
}
function updateVarOnInput(event, debounced = false) {
if (debounced) {
event.target.dispatchEvent(new Event('change', {bubbles: true}));
} else {
debounce(updateVarOnInput, AUTOSAVE_DELAY, event, true);
}
}
function selectAllOnFocus(event) {
event.target.select();
}
function renderValues(varsToRender = vars) {
for (const va of varsToRender) {
if (va.input === document.activeElement) {
continue;
}
const value = isDefault(va) ? va.default : va.value;
if (va.type === 'color') {
va.input.style.backgroundColor = value;
if (colorpicker.options.va === va) {
colorpicker.setColor(value);
}
} else if (va.type === 'checkbox') {
va.input.checked = Number(value);
} else if (va.type === 'range') {
va.input.value = value;
updateRangeCurrentValue(va, va.input.value);
} else {
va.input.value = value;
}
if (!prefs.get('config.autosave')) {
renderValueState(va);
}
}
}
function renderValueState(va) {
const el = va.input.closest('label');
el.classList.toggle('dirty', Boolean(va.dirty));
el.classList.toggle('nondefault', !isDefault(va));
$('.config-reset-icon', el).disabled = isDefault(va);
}
function resetOnClick(event) {
event.preventDefault();
this.va.value = null;
renderValues([this.va]);
onchange({target: this.va.input});
}
function showColorpicker(event) {
event.preventDefault();
window.removeEventListener('keydown', messageBox.listeners.key, true);
const box = $('#message-box-contents');
colorpicker.show({
va: this.va,
color: this.va.value || this.va.default,
top: this.getBoundingClientRect().bottom - 5,
left: box.getBoundingClientRect().left - 360,
guessBrightness: box,
callback: onColorChanged,
});
}
function onColorChanged(newColor) {
if (newColor) {
this.va.value = newColor;
this.va.input.style.backgroundColor = newColor;
this.va.input.dispatchEvent(new Event('change', {bubbles: true}));
}
debounce(restoreEscInDialog);
}
function restoreEscInDialog() {
if (!$('.colorpicker-popup') && messageBox.element) {
window.addEventListener('keydown', messageBox.listeners.key, true);
}
}
function adjustSizeForPopup(box) {
const contents = box.firstElementChild;
contents.style = 'max-width: none; max-height: none;'.replace(/;/g, '!important;');
let {offsetWidth: width, offsetHeight: height} = contents;
contents.style = '';
const colorpicker = document.body.appendChild(
$create('.colorpicker-popup', {style: 'display: none!important'}));
const PADDING = 50;
const MIN_WIDTH = parseFloat(getComputedStyle(colorpicker).width) || 350;
const MIN_HEIGHT = 250 + PADDING;
colorpicker.remove();
width = constrain(MIN_WIDTH, 798, width + PADDING);
height = constrain(MIN_HEIGHT, 598, height + PADDING);
document.body.style.setProperty('min-width', width + 'px', 'important');
document.body.style.setProperty('min-height', height + 'px', 'important');
}
function constrain(min, max, value) {
return value < min ? min : value > max ? max : value;
}
}

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