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-overwrites/*
!vendor-overwrites/colorpicker
!vendor-overwrites/csslint
vendor-overwrites/

View File

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

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';
importScripts('/js/worker-util.js');
const {loadScript} = workerUtil;
define(require => { // define and require use `importScripts` which is synchronous
const {createAPI} = require('/js/worker-util');
/** @namespace ApiWorker */
workerUtil.createAPI({
parseMozFormat(arg) {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
return parseMozFormat(arg);
let BUILDERS;
const bgw = /** @namespace BackgroundWorker */ {
async compileUsercss(preprocessor, code, vars) {
if (!BUILDERS) createBuilders();
const builder = BUILDERS[preprocessor] || BUILDERS.default;
if (!builder) throw new Error(`Unknown preprocessor "${preprocessor}"`);
vars = simplifyVars(vars);
const {preprocess, postprocess} = builder;
if (preprocess) code = await preprocess(code, vars);
const res = bgw.parseMozFormat({code});
if (postprocess) postprocess(res.sections, vars);
return res;
},
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);
parseMozFormat(...args) {
return require('/js/moz-parser').extractSections(...args);
},
parseUsercssMeta(text) {
return require('/js/meta-parser').parse(text);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.nullifyInvalidVars(vars);
return require('/js/meta-parser').nullifyInvalidVars(vars);
},
});
};
function compileUsercss(preprocessor, code, vars) {
loadScript(
'/vendor-overwrites/csslint/parserlib.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/moz-parser.js'
);
const builder = getUsercssCompiler(preprocessor);
vars = simpleVars(vars);
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code)
.then(code => parseMozFormat({code}))
.then(({sections, errors}) => {
if (builder.postprocess) {
builder.postprocess(sections, vars);
}
return {sections, errors};
});
createAPI(bgw);
function simpleVars(vars) {
if (!vars) {
return {};
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
return Object.keys(vars).reduce((output, key) => {
const va = vars[key];
output[key] = Object.assign({}, va, {
value: va.value === null || va.value === undefined ?
getVarValue(va, 'default') : getVarValue(va, 'value'),
});
return output;
}, {});
}
function createBuilders() {
BUILDERS = Object.assign(Object.create(null));
function getVarValue(va, prop) {
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
// TODO: handle customized image
return va.options.find(o => o.name === va[prop]).value;
}
if ((va.type === 'number' || va.type === 'range') && va.units) {
return va[prop] + va.units;
}
return va[prop];
}
}
function getUsercssCompiler(preprocessor) {
const BUILDER = {
default: {
BUILDERS.default = {
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('');
if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n';
@ -88,18 +48,20 @@ function getUsercssCompiler(preprocessor) {
}
}
},
},
stylus: {
};
BUILDERS.stylus = {
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) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
new self.StylusRenderer(varDef + source)
.render((err, output) => err ? reject(err) : resolve(output));
});
},
},
less: {
};
BUILDERS.less = {
preprocess(source, vars) {
if (!self.less) {
self.less = {
@ -107,17 +69,18 @@ function getUsercssCompiler(preprocessor) {
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('');
return self.less.render(varDefs + source)
.then(({css}) => css);
},
},
uso: {
preprocess(source, vars) {
loadScript('/vendor-overwrites/colorpicker/colorconverter.js');
};
BUILDERS.uso = {
async preprocess(source, vars) {
const colorConverter = require('/js/color/color-converter');
const pool = new Map();
return Promise.resolve(doReplace(source));
return doReplace(source);
function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) {
@ -164,14 +127,35 @@ function getUsercssCompiler(preprocessor) {
});
}
},
},
};
}
if (preprocessor) {
if (!BUILDER[preprocessor]) {
throw new Error('unknwon preprocessor');
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;
}
return BUILDER[preprocessor];
if ((va.type === 'number' || va.type === 'range') && va.units) {
return va[prop] + va.units;
}
return BUILDER.default;
}
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';
//#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, {
/** @type {ApiWorker} */
worker: workerUtil.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 {Prefs} */
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')),
// These are loaded conditionally.
// Each item uses `require` individually so IDE can jump to the source and track usage.
Promise.all([
FIREFOX &&
require(['./style-via-api']),
FIREFOX && ((browser.commands || {}).update) &&
require(['./browser-cmd-hotkeys']),
!FIREFOX &&
require(['./content-scripts']),
!FIREFOX &&
require(['./style-via-webrequest']),
chrome.contextMenus &&
require(['./context-menus']),
styleManager.ready,
]).then(() => {
msg.isBgReady = true;
msg.broadcast({method: 'backgroundReady'});
});
},
/** @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 (chrome.commands) {
chrome.commands.onCommand.addListener(id => API.browserCommands[id]());
}
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
//#region Init
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}) => {
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);
const [a, b, c] = (previousVersion || '').split('.');
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
require(['./remove-unused-storage']);
}
});
});
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,18 +1,17 @@
/* global
FIREFOX
ignoreChromeError
msg
URLS
*/
'use strict';
/*
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
!FIREFOX && (() => {
define(require => {
const {
URLS,
ignoreChromeError,
} = require('/js/toolbox');
const {msg} = require('/js/msg');
const NTP = 'chrome://newtab/';
const ALL_URLS = '<all_urls>';
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
@ -118,4 +117,4 @@
function onBusyTabRemoved(tabId) {
trackBusyTab(tabId, false);
}
})();
});

View File

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

View File

@ -1,49 +1,49 @@
/* global chromeLocal */
/* exported createChromeStorageDB */
'use strict';
function createChromeStorageDB() {
let INC;
define(require => {
const {chromeLocal} = require('/js/storage-util');
let INC;
const PREFIX = 'style-';
const METHODS = {
delete: id => chromeLocal.remove(PREFIX + id),
// FIXME: we don't use this method at all. Should we remove this?
get: id => chromeLocal.getValue(PREFIX + id),
put: obj =>
// FIXME: should we clone the object?
Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++})))
.then(() => chromeLocal.setValue(PREFIX + obj.id, obj))
.then(() => obj.id),
putMany: items => prepareInc()
.then(() =>
chromeLocal.set(items.reduce((data, item) => {
if (!item.id) item.id = INC++;
async getAll() {
return Object.entries(await chromeLocal.get())
.map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
.filter(Boolean);
},
async put(item) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
await chromeLocal.setValue(PREFIX + item.id, item);
return item.id;
},
async putMany(items) {
const data = {};
for (const item of items) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
data[PREFIX + item.id] = item;
return data;
}, {})))
.then(() => items.map(i => i.id)),
delete: id => chromeLocal.remove(PREFIX + id),
getAll: () => chromeLocal.get()
.then(result => {
const output = [];
for (const key in result) {
if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) {
output.push(result[key]);
}
}
return output;
}),
await chromeLocal.set(data);
return items.map(_ => _.id);
},
};
return {
exec: (method, ...args) => METHODS[method](...args),
};
function prepareInc() {
if (INC) return Promise.resolve();
return chromeLocal.get().then(result => {
async function prepareInc() {
INC = 1;
for (const key in result) {
for (const key in await chromeLocal.get()) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
@ -51,6 +51,9 @@ function createChromeStorageDB() {
}
}
}
});
}
}
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:
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
Initialize a database. There are some problems using IndexedDB in Firefox:
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
*/
'use strict';
const db = (() => {
define(require => {
const {chromeLocal} = require('/js/storage-util');
const {cloneError} = require('/js/worker-util');
const DATABASE = 'stylish';
const STORE = 'styles';
const FALLBACK = 'dbInChromeStorage';
const dbApi = {
const execFn = tryUsingIndexedDB().catch(useChromeStorage);
const exports = {
async exec(...args) {
dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage);
return dbApi.exec(...args);
return (await execFn)(...args);
},
};
return dbApi;
async function tryUsingIndexedDB() {
// 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
}
function useChromeStorage(err) {
async function useChromeStorage(err) {
chromeLocal.setValue(FALLBACK, true);
if (err) {
chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err));
console.warn('Failed to access indexedDB. Switched to storage API.', err);
}
return createChromeStorageDB().exec;
return require(['./db-chrome-storage']);
}
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';
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 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([
'disableAll',
'badgeDisabled',
@ -27,21 +60,7 @@ const iconManager = (() => {
refreshAllIcons();
});
Object.assign(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);
},
});
navigatorUtil.onCommitted(({tabId, frameId}) => {
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
});
@ -53,13 +72,13 @@ const iconManager = (() => {
function onPortDisconnected({sender}) {
if (tabManager.get(sender.tab.id, 'styleIds')) {
API.updateIconBadge.call({sender}, [], {lazyBadge: true});
updateIconBadge.call({sender}, [], {lazyBadge: true});
}
}
function refreshIconBadgeText(tabId) {
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
iconUtil.setBadgeText({tabId, text});
setBadgeText({tabId, text});
}
function getIconName(hasStyles = false) {
@ -77,7 +96,7 @@ const iconManager = (() => {
return;
}
tabManager.set(tabId, 'icon', newIcon);
iconUtil.setIcon({
setIcon({
path: getIconPath(newIcon),
tabId,
});
@ -102,14 +121,14 @@ const iconManager = (() => {
}
function refreshGlobalIcon() {
iconUtil.setIcon({
setIcon({
path: getIconPath(getIconName()),
});
}
function refreshIconBadgeColor() {
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
iconUtil.setBadgeBackgroundColor({
setBadgeBackgroundColor({
color,
});
}
@ -133,4 +152,6 @@ const iconManager = (() => {
}
staleBadges.clear();
}
})();
return exports;
});

View File

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

View File

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

View File

@ -1,7 +1,6 @@
/* global API */
'use strict';
(() => {
define(require => {
// begin:nanographql - Tiny graphQL client library
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
// License: MIT
@ -37,11 +36,10 @@
body: query({
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
* 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,28 +1,48 @@
/* global
API
debounce
stringAsRegExp
tryRegExp
usercss
*/
'use strict';
(() => {
define(require => {
const {
debounce,
stringAsRegExp,
tryRegExp,
} = require('/js/toolbox');
const {API} = require('/js/msg');
// toLocaleLowerCase cache, autocleared after 1 minute
const cache = new Map();
const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl'];
const MODES = createModes();
const extractMeta = style =>
style.usercssData
? (style.sourceCode.match(usercss.RX_META) || [''])[0]
: null;
const exports = /** @namespace API */ {
/**
* @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
*/
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 =>
style.usercssData
? style.sourceCode.replace(usercss.RX_META, '')
: null;
const MODES = Object.assign(Object.create(null), {
function createModes() {
return Object.assign(Object.create(null), {
code: (style, test) =>
style.usercssData
? test(stripMeta(style))
@ -41,34 +61,9 @@
MODES.meta(style, test, 'all') ||
!style.usercssData && MODES.code(style, test),
});
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API.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) {
function createTester(query) {
const flags = `u${lower(query) === query ? 'i' : ''}`;
const words = query
.split(/(".*?")|\s+/)
@ -105,4 +100,18 @@
function clearCache() {
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';
/*
@ -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.
*/
/* exported styleManager */
const styleManager = API.styles = (() => {
define(require => {
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
const ready = init();
/**
* @typedef StyleMapData
@ -40,7 +41,7 @@ const styleManager = API.styles = (() => {
/** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */
/** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */
const cachedStyleForUrl = createCache({
onDeleted: (url, cache) => {
onDeleted(url, cache) {
for (const section of Object.values(cache.sections)) {
const data = id2data(section.id);
if (data) data.appliesTo.delete(url);
@ -51,36 +52,30 @@ const styleManager = API.styles = (() => {
const compileRe = createCompiler(text => `^(${text})$`);
const compileSloppyRe = createCompiler(text => `^${text}$`);
const compileExclusion = createCompiler(buildExclusion);
const DUMMY_URL = {
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
password: '',
pathname: '',
port: '',
protocol: '',
search: '',
searchParams: new URLSearchParams(),
username: '',
};
const MISSING_PROPS = {
name: style => `ID: ${style.id}`,
_id: () => uuidv4(),
_rev: () => Date.now(),
};
const DELETE_IF_NULL = ['id', 'customName'];
//#endregion
chrome.runtime.onConnect.addListener(handleLivePreview);
//#region Public surface
//#endregion
//#region Exports
// Sorted alphabetically
return {
/** @type {StyleManager} */
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 */
async delete(id, reason) {
@ -108,9 +103,9 @@ const styleManager = API.styles = (() => {
await ready;
const id = uuidIndex.get(_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?
return API.styles.delete(id, 'sync');
return styleManager.delete(id, 'sync');
}
},
@ -151,7 +146,7 @@ const styleManager = API.styles = (() => {
await ready;
/* 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 */
const {tab, frameId} = this.sender;
const {tab, frameId} = this && this.sender || {};
url = tab && tabManager.get(tab.id, 'url', frameId) || url;
let cache = cachedStyleForUrl.get(url);
if (!cache) {
@ -215,7 +210,7 @@ const styleManager = API.styles = (() => {
}
}
if (sectionMatched) {
result.push(/** @namespace StylesByUrlResult */{style, excluded, sloppy});
result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy});
}
}
return result;
@ -265,7 +260,7 @@ const styleManager = API.styles = (() => {
const oldDoc = id && id2style(id);
let diff = -1;
if (oldDoc) {
diff = compareRevision(oldDoc._rev, doc._rev);
diff = styleManager.compareRevision(oldDoc._rev, doc._rev);
if (diff > 0) {
API.sync.put(oldDoc._id, oldDoc._rev);
return;
@ -297,8 +292,8 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<?StyleObj>} */
removeInclusion: removeIncludeExclude.bind(null, 'inclusions'),
};
//#endregion
//#endregion
//#region Implementation
/** @returns {StyleMapData} */
@ -318,7 +313,7 @@ const styleManager = API.styles = (() => {
/** @returns {StyleObj} */
function createNewStyle() {
return /** @namespace StyleObj */{
return /** @namespace StyleObj */ {
enabled: true,
updateUrl: null,
md5Url: null,
@ -366,10 +361,6 @@ const styleManager = API.styles = (() => {
});
}
function compareRevision(rev1, rev2) {
return rev1 - rev2;
}
async function addIncludeExclude(type, id, rule) {
await ready;
const style = Object.assign({}, id2style(id));
@ -661,7 +652,20 @@ const styleManager = API.styles = (() => {
try {
return new URL(url);
} 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) {
return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : '');
}
//#endregion
})();
return styleManager;
});

View File

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

View File

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

View File

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

View File

@ -1,32 +1,23 @@
/* global navigatorUtil */
/* exported tabManager */
'use strict';
const tabManager = (() => {
const listeners = [];
define(require => {
const navigatorUtil = require('./navigator-util');
const listeners = new Set();
const cache = new Map();
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
navigatorUtil.onUrlChange(({tabId, frameId, url}) => {
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);
}
}
});
navigatorUtil.onUrlChange(notify);
return {
const tabManager = {
onUpdate(fn) {
listeners.push(fn);
listeners.add(fn);
},
get(tabId, ...keys) {
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
},
/**
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
@ -47,8 +38,24 @@ const tabManager = (() => {
meta[lastKey] = value;
}
},
list() {
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,9 +1,75 @@
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
/* exported tokenManager */
'use strict';
const tokenManager = (() => {
const AUTH = {
define(require => {
const {FIREFOX} = require('/js/toolbox');
const {chromeLocal} = require('/js/storage-util');
const AUTH = createAuth();
const NETWORK_LATENCY = 30; // seconds
let exports;
const {
buildKeys,
} = exports = {
buildKeys(name) {
const k = {
TOKEN: `secure/token/${name}/token`,
EXPIRE: `secure/token/${name}/expire`,
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);
});
},
async revokeToken(name) {
const provider = AUTH[name];
const k = buildKeys(name);
if (provider.revoke) {
try {
const token = await chromeLocal.getValue(k.TOKEN);
if (token) {
await provider.revoke(token);
}
} catch (e) {
console.error(e);
}
}
await chromeLocal.remove(k.LIST);
},
};
function createAuth() {
return {
dropbox: {
flow: 'token',
clientId: 'zg52vphuapvpng9',
@ -33,7 +99,8 @@ const tokenManager = (() => {
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)}`);
return postQuery(
`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
},
},
onedrive: {
@ -48,66 +115,11 @@ const tokenManager = (() => {
scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
},
};
const NETWORK_LATENCY = 30; // seconds
return {getToken, revokeToken, getClientId, buildKeys};
function getClientId(name) {
return AUTH[name].clientId;
}
function buildKeys(name) {
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) {
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);
});
}
async function revokeToken(name) {
const provider = AUTH[name];
const k = buildKeys(name);
if (provider.revoke) {
try {
const token = await chromeLocal.getValue(k.TOKEN);
if (token) {
await provider.revoke(token);
}
} catch (e) {
console.error(e);
}
}
await chromeLocal.remove(k.LIST);
}
function refreshToken(name, k, obj) {
async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) {
return Promise.reject(new Error('no refresh token'));
throw new Error('No refresh token');
}
const provider = AUTH[name];
const body = {
@ -119,17 +131,17 @@ const tokenManager = (() => {
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body)
.then(result => {
const result = await postQuery(provider.tokenURL, body);
if (!result.refresh_token) {
// reuse old refresh token
result.refresh_token = obj[k.REFRESH];
}
return handleTokenResult(result, k);
});
}
function authUser(name, k, interactive = false) {
async function authUser(name, k, interactive = false) {
await require(['js!/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
/* global webextLaunchWebAuthFlow */
const provider = AUTH[name];
const state = Math.random().toFixed(8).slice(2);
const query = {
@ -145,27 +157,27 @@ const tokenManager = (() => {
Object.assign(query, provider.authQuery);
}
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
return webextLaunchWebAuthFlow({
const finalUrl = await webextLaunchWebAuthFlow({
url,
interactive,
redirect_uri: query.redirect_uri,
})
.then(url => {
});
const params = new URLSearchParams(
provider.flow === 'token' ?
new URL(url).hash.slice(1) :
new URL(url).search.slice(1)
new URL(finalUrl).hash.slice(1) :
new URL(finalUrl).search.slice(1)
);
if (params.get('state') !== state) {
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
}
let result;
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params.entries()) {
for (const [key, value] of params) {
obj[key] = value;
}
return obj;
}
result = obj;
} else {
const code = params.get('code');
const body = {
code,
@ -176,21 +188,23 @@ const tokenManager = (() => {
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body);
})
.then(result => handleTokenResult(result, k));
result = await postQuery(provider.tokenURL, body);
}
return handleTokenResult(result, k);
}
function handleTokenResult(result, k) {
return chromeLocal.set({
async function handleTokenResult(result, k) {
await chromeLocal.set({
[k.TOKEN]: result.access_token,
[k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined,
[k.EXPIRE]: result.expires_in
? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
: undefined,
[k.REFRESH]: result.refresh_token,
})
.then(() => result.access_token);
});
return result.access_token;
}
function postQuery(url, body) {
async function postQuery(url, body) {
const options = {
method: 'POST',
headers: {
@ -198,17 +212,15 @@ const tokenManager = (() => {
},
body: body ? new URLSearchParams(body) : null,
};
return fetch(url, options)
.then(r => {
const r = await fetch(url, options);
if (r.ok) {
return r.json();
}
return r.text()
.then(body => {
const err = new Error(`failed to fetch (${r.status}): ${body}`);
const text = await r.text();
const err = new Error(`Failed to fetch (${r.status}): ${text}`);
err.code = r.status;
throw err;
});
});
}
})();
return exports;
});

View File

@ -1,20 +1,21 @@
/* global
API
calcStyleDigest
chromeLocal
debounce
download
ignoreChromeError
prefs
semverCompare
styleJSONseemsValid
styleSectionsEqual
usercss
*/
'use strict';
(() => {
const STATES = /** @namespace UpdaterStates */{
define(require => {
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',
SKIPPED: 'skipped',
UNREACHABLE: 'server unreachable',
@ -28,6 +29,7 @@
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
};
const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3;
const RETRY_ERRORS = [
@ -39,19 +41,16 @@
let logQueue = [];
let logLastWriteTime = 0;
API.updater = {
checkAllStyles,
checkStyle,
getStates: () => STATES,
};
chromeLocal.getValue('lastUpdateTime').then(val => {
lastUpdateTime = val || Date.now();
prefs.subscribe('updateInterval', schedule, {now: true});
prefs.subscribe('updateInterval', schedule, {runNow: true});
chrome.alarms.onAlarm.addListener(onAlarm);
});
async function checkAllStyles({
/** @type {StyleUpdater} */
const updater = /** @namespace StyleUpdater */ {
async checkAllStyles({
save = true,
ignoreDigest,
observe,
@ -66,12 +65,12 @@
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
await Promise.all(
styles.map(style =>
checkStyle({style, port, save, ignoreDigest})));
updater.checkStyle({style, port, save, ignoreDigest})));
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
}
},
/**
* @param {{
@ -101,7 +100,7 @@
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
async function checkStyle(opts) {
async checkStyle(opts) {
const {
id,
style = await API.styles.get(id),
@ -157,7 +156,8 @@
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 usercss.buildMeta(text);
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
@ -168,7 +168,7 @@
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return usercss.buildCode(json);
return API.usercss.buildCode(json);
}
async function maybeSave(json) {
@ -187,7 +187,7 @@
return Promise.reject(STATES.MAYBE_EDITED);
}
return !save ? newStyle :
(ucd ? API.usercss : API.styles).install(newStyle);
(ucd ? API.usercss.install : API.styles.install)(newStyle);
}
async function tryDownload(url, params) {
@ -205,7 +205,10 @@
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
},
getStates: () => STATES,
};
function schedule() {
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
@ -220,7 +223,7 @@
}
function onAlarm({name}) {
if (name === ALARM_NAME) checkAllStyles();
if (name === ALARM_NAME) updater.checkAllStyles();
}
function resetInterval() {
@ -253,4 +256,6 @@
logLastWriteTime = Date.now();
logQueue = [];
}
})();
return updater;
});

View File

@ -1,11 +1,37 @@
/* global
API
deepCopy
usercss
*/
'use strict';
API.usercss = {
define(require => {
const {API} = require('/js/msg');
const {deepCopy, download} = require('/js/toolbox');
const GLOBAL_METAS = {
author: undefined,
description: undefined,
homepageURL: 'url',
updateURL: 'updateUrl',
name: undefined,
};
const ERR_ARGS_IS_LIST = [
'missingMandatory',
'missingChar',
];
const usercss = /** @namespace UsercssHelper */ {
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);
}
},
async build({
styleId,
@ -14,39 +40,84 @@ API.usercss = {
checkDup,
metaOnly,
assignVars,
initialUrl,
}) {
let style = await usercss.buildMeta(sourceCode);
// 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 API.usercss.find(styleId ? {id: styleId} : style);
await usercss.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
style = await usercss.buildCode(style);
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;
},
async buildMeta(style) {
if (style.usercssData) {
return style;
}
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return Object.assign(await usercss.buildMeta(sourceCode), style);
// remember normalized sourceCode
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
style = Object.assign({
enabled: true,
sections: [],
}, style);
const match = code.match(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 configVars(id, vars) {
let style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
style = await usercss.buildCode(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));
return API.styles.editSave(await usercss.parse(style));
},
async find(styleOrData) {
@ -65,17 +136,26 @@ API.usercss = {
},
async install(style) {
return API.styles.install(await API.usercss.parse(style));
return API.styles.install(await usercss.parse(style));
},
async parse(style) {
style = await API.usercss.buildMeta(style);
style = await usercss.buildMeta(style);
// preserve style.vars during update
const dup = await API.usercss.find(style);
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';
(() => {
define(require => {
const {
URLS,
download,
openURL,
} = require('/js/toolbox');
const tabManager = require('./tab-manager');
const installCodeCache = {};
const clearInstallCode = url => delete installCodeCache[url];
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
const isContentTypeText = type => /^text\/(?!html)/i.test(type);
// in Firefox we have to use a content script to read file://
const fileLoader = !chrome.app && (
async tabId =>
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
const exports = /** @namespace UsercssHelper */ {
const urlLoader =
async (tabId, url) => (
url.startsWith('file:') ||
tabManager.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
API.usercss.getInstallCode = url => {
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
@ -48,17 +36,7 @@
},
};
// Faster installation on known distribution sites to avoid flicker of css text
chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => {
const u = new URL(url);
const m = maybeDistro[u.hostname];
if (!m || m.rx.test(u.pathname)) {
openInstallerPage(tabId, url, {});
// Silently suppress navigation.
// Don't redirect to the install URL as it'll flash the text!
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
}
}, {
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
urls: [
URLS.usoArchiveRaw + 'usercss/*.user.css',
'*://greasyfork.org/scripts/*/code/*.user.css',
@ -70,27 +48,63 @@
types: ['main_frame'],
}, ['blocking']);
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}, {
chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
urls: makeUsercssGlobs('*', '/*'),
types: ['main_frame'],
}, ['responseHeaders']);
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
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.') &&
/^(https?|file|ftps?):/.test(url) &&
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
!oldUrl.startsWith(URLS.installUsercss)) {
const inTab = url.startsWith('file:') && Boolean(fileLoader);
const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
const inTab = url.startsWith('file:') && !chrome.app;
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) {
openInstallerPage(tabId, url, {code, inTab});
}
}
});
}
/** Faster installation on known distribution sites to avoid flicker of css text */
function maybeInstallFromDistro({tabId, url}) {
const u = new URL(url);
const m = maybeDistro[u.hostname];
if (!m || m.rx.test(u.pathname)) {
openInstallerPage(tabId, url, {});
// Silently suppress navigation.
// Don't redirect to the install URL as it'll flash the text!
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
}
}
function openInstallerPage(tabId, url, {code, inTab} = {}) {
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
@ -110,7 +124,11 @@
}
}
function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
/** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
function rememberContentType({tabId, responseHeaders}) {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
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';
// Chrome reruns content script when documentElement is replaced.
// Note, we're checking against a literal `1`, not just `if (truthy)`,
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
self.INJECTED = 1;
define(require => {
const {API, msg} = require('/js/msg');
const prefs = require('/js/prefs');
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
const IS_FRAME = window !== parent;
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
const styleInjector = createStyleInjector({
/** @type {StyleInjector} */
const styleInjector = require('/content/style-injector')({
compare: (a, b) => a.id - b.id,
onUpdate: onInjectorUpdate,
});
@ -210,4 +206,4 @@ self.INJECTED !== 1 && (() => {
msg.off(applyOnMessage);
} catch (e) {}
}
})();
});

View File

@ -1,21 +1,14 @@
/* global API */
'use strict';
// onCommitted may fire twice
// Note, we're checking against a literal `1`, not just `if (truthy)`,
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
if (window.INJECTED_GREASYFORK !== 1) {
window.INJECTED_GREASYFORK = 1;
addEventListener('message', async function onMessage(e) {
addEventListener('message', async function onMessage(e) {
if (e.origin === location.origin &&
e.data &&
e.data.name &&
e.data.type === 'style-version-query') {
removeEventListener('message', onMessage);
const {API} = self.require('/js/msg');
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';
(() => {
define(require => {
const {API} = require('/js/msg');
const manifest = chrome.runtime.getManifest();
const allowedOrigins = [
'https://openusercss.org',
@ -55,7 +56,7 @@
window.addEventListener('message', installedHandler);
};
const doHandshake = () => {
const doHandshake = event => {
// This is a representation of features that Stylus is capable of
const implementedFeatures = [
'install-usercss',
@ -106,7 +107,7 @@
&& event.data.type === 'ouc-handshake-question'
&& allowedOrigins.includes(event.origin)
) {
doHandshake();
doHandshake(event);
}
};
@ -171,4 +172,4 @@
attachInstallListeners();
attachInstalledListeners();
askHandshake();
})();
});

View File

@ -1,8 +1,10 @@
/* global cloneInto msg API */
'use strict';
// 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 pageEventId = `${performance.now()}${Math.random()}`;
@ -119,7 +121,7 @@
if (typeof cloneInto !== 'undefined') {
// Firefox requires explicit cloning, however USO can't process our messages anyway
// because USO tries to use a global "event" variable deprecated in Firefox
detail = cloneInto({detail}, document);
detail = cloneInto({detail}, document); /* global cloneInto */
} else {
detail = {detail};
}
@ -325,7 +327,7 @@
msg.off(onMessage);
} catch (e) {}
}
})();
});
function inPageContext(eventId) {
document.currentScript.remove();

View File

@ -1,6 +1,10 @@
'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,
onUpdate = () => {},
}) => {
@ -17,22 +21,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
// will store the original method refs because the page can override them
let creationDoc, createElement, createElementNS;
return {
return /** @namespace StyleInjector */ {
list,
apply(styleMap) {
async apply(styleMap) {
const styles = _styleMapToArray(styleMap);
return (
!styles.length ?
Promise.resolve([]) :
docRootObserver.evade(() => {
const value = !styles.length
? []
: await docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
})
).then(_emitUpdate);
});
_emitUpdate();
return value;
},
clear() {
@ -155,10 +159,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
docRootObserver[onOff]();
}
function _emitUpdate(value) {
function _emitUpdate() {
_toggleObservers(list.length);
onUpdate();
return value;
}
/*
@ -321,4 +324,4 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
.observe(document, {childList: true});
}
}
};
});

240
edit.html
View File

@ -4,8 +4,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
<link rel="stylesheet" href="msgbox/msgbox.css">
<style id="firefox-transitions-bug-suppressor">
/* restrict to FF */
@ -20,96 +18,6 @@
<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">
<li class="applies-to-item">
<div class="select-resizer">
@ -276,9 +184,117 @@
</tbody>
</table>
</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>
<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">
<h1 id="heading">&nbsp;</h1> <!-- nbsp allocates the actual height which prevents page shift -->
<section id="basic-info">
@ -459,45 +475,5 @@
<div class="contents"></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>
</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,20 +1,23 @@
/* global loadScript css_beautify showHelp prefs t $ $create */
/* global editor createHotkeyInput moveFocus CodeMirror */
/* exported initBeautifyButton */
'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 => {
// using per-section mode when code editor or applies-to block is focused
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
beautify(isPerSection ? [cm] : editor.getEditors(), false);
};
});
prefs.subscribe([HOTKEY_ID], (key, value) => {
prefs.subscribe([HOTKEY_ID], (key, value) => {
const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') {
@ -25,34 +28,14 @@ prefs.subscribe([HOTKEY_ID], (key, value) => {
if (value) {
extraKeys[value] = 'beautify';
}
});
}, {runNow: true});
/**
* @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);
});
}
/**
/**
* @name beautify
* @param {CodeMirror[]} scope
* @param {?boolean} ui
* @param {boolean} [ui=true]
*/
function beautify(scope, ui = true) {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
window.css_beautify = window.exports.css_beautify;
}
})
.then(doBeautify);
function doBeautify() {
async function beautify(scope, ui = true) {
const tabs = prefs.get('editor.indentWithTabs');
const options = Object.assign({}, prefs.get('editor.beautify'));
for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
@ -64,16 +47,16 @@ function beautify(scope, ui = true) {
createBeautifyUI(scope, options);
}
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 =
[].concat.apply([], cm.doc.sel.ranges.map(r =>
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
const text = cm.getValue();
const newText = css_beautify(text, options);
const newText = beautifier(text, options);
if (newText !== text) {
if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) {
// clear the list if last change wasn't a css-beautify
@ -95,7 +78,7 @@ function beautify(scope, ui = true) {
}
function createBeautifyUI(scope, options) {
showHelp(t('styleBeautify'),
helpPopup.show(t('styleBeautify'),
$create([
$create('.beautify-options', [
$createOption('.selector1,', 'selector_separator_newline'),
@ -114,8 +97,7 @@ function beautify(scope, ui = true) {
$create('.buttons', [
$create('button', {
attributes: {role: 'close'},
// showHelp.close will be defined after showHelp() is invoked
onclick: () => showHelp.close(),
onclick: helpPopup.close,
}, t('confirmClose')),
$create('button', {
attributes: {role: 'undo'},
@ -145,7 +127,7 @@ function beautify(scope, ui = true) {
if (target.parentNode.hasAttribute('newline')) {
target.parentNode.setAttribute('newline', value.toString());
}
doBeautify();
beautify(scope, false);
};
function $createOption(label, optionName, indent) {
@ -185,4 +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 */
outline: none !important;
}
.CodeMirror-lint-mark-warning {
background: none;
}
.CodeMirror-dialog {
animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
}

View File

@ -1,13 +1,13 @@
/* global
$
CodeMirror
prefs
t
*/
'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
if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap');
@ -43,6 +43,7 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
require(Object.values(editor.lazyKeymaps || {}), () => {
const KM = CodeMirror.keyMap;
const extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) {
@ -90,6 +91,7 @@
}
}
}
});
const cssMime = CodeMirror.mimeModes['text/css'];
Object.assign(cssMime.propertyKeywords, {
@ -164,4 +166,4 @@
}, {value: cur.line + 1});
},
});
})();
});

View File

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

View File

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

View File

@ -1,12 +1,13 @@
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
'use strict';
(() => {
onDOMready().then(() => {
$('#colorpicker-settings').onclick = configureColorpicker;
});
define(require => {
const prefs = require('/js/prefs');
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', setColorpickerOption, {now: true});
prefs.subscribe('editor.colorpicker', setColorpickerOption, {runNow: true});
function setColorpickerOption(id, enabled) {
const defaults = CodeMirror.defaults;
@ -43,7 +44,7 @@
delete defaults.extraKeys[keyName];
}
}
cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
globalSetOption('colorpicker', defaults.colorpicker);
}
function registerHotkey(id, hotkey) {
@ -66,16 +67,14 @@
function configureColorpicker(event) {
event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', () => {
$('#help-popup .dismiss').onclick();
});
const popup = showHelp(t('helpKeyMapHotkey'), input);
if (this instanceof Element) {
const input = createHotkeyInput('editor.colorpicker.hotkey', () => helpPopup.close());
const popup = helpPopup.show(t('helpKeyMapHotkey'), input);
const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
}
input.focus();
}
})();
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';
/** @type {EditorBase|SourceEditor|SectionsEditor} */
const editor = {
isUsercss: false,
previewDelay: 200, // Chrome devtools uses 200
};
let isSimpleWindow;
let isWindowed;
let headerHeight;
define(require => {
const {API, msg} = require('/js/msg');
const {
FIREFOX,
closeCurrentTab,
debounce,
getOwnTab,
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);
msg.onExtension(onRuntimeMessage);
let headerHeight;
let isSimpleWindow;
let isWindowed;
lazyInit();
window.on('beforeunload', beforeUnload);
msg.onExtension(onRuntimeMessage);
(async function init() {
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();
lazyInit();
(async function init() {
await preinit;
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
initNameArea();
initBeautifyButton($('#beautify'), () => editor.getEditors());
initBeautifyButton($('#beautify'));
initResizeListener();
detectLayout();
detectLayout(true);
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !style.id);
const toc = [];
const elToc = $('#toc');
elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target));
if (editor.isUsercss) {
SourceEditor();
} else {
SectionsEditor();
}
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true});
dirty.onChange(updateDirty);
$('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !editor.style.id);
$('#toc').onclick = e => editor.jumpToEditor([...$('#toc').children].indexOf(e.target));
(editor.isUsercss ? require('./source-editor') : require('./sections-editor'))();
await editor.ready;
editor.ready = true;
editor.dirty.onChange(editor.updateDirty);
// enabling after init to prevent flash of validation failure on an empty name
$('#name').required = !editor.isUsercss;
$('#save-button').onclick = editor.save;
async function initStyle() {
const params = new URLSearchParams(location.search);
const id = Number(params.get('id'));
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);
}
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {runNow: true});
prefs.subscribe('editor.linter', (key, value) => {
$('body').classList.toggle('linter-disabled', value === '');
linterMan.run();
});
function initEmptyStyle(params) {
return {
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
enabled: true,
sections: [
DocFuncMapper.toSection([...params], {code: ''}),
],
};
}
require(['./colorpicker-helper'], res => {
$('#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() {
const nameEl = $('#name');
const resetEl = $('#reset-name');
const isCustomName = style.updateUrl || editor.isUsercss;
nameTarget = isCustomName ? 'customName' : 'name';
const isCustomName = editor.style.updateUrl || editor.isUsercss;
editor.nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => {
updateName(true);
editor.updateName(true);
resetEl.hidden = false;
});
resetEl.hidden = !style.customName;
resetEl.hidden = !editor.style.customName;
resetEl.onclick = () => {
const style = editor.style;
nameEl.focus();
@ -151,13 +103,13 @@ lazyInit();
// trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name;
updateName(true);
editor.updateName(true);
}
style.customName = null; // to delete it from db
resetEl.hidden = true;
};
const enabledEl = $('#enabled');
enabledEl.onchange = () => updateEnabledness(enabledEl.checked);
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
}
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) {
if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command);
@ -211,10 +145,10 @@ lazyInit();
}
function buildThemeElement() {
const elOptions = [chrome.i18n.getMessage('defaultTheme'), ...CODEMIRROR_THEMES]
.map(s => $create('option', s));
elOptions[0].value = 'default';
$('#editor.theme').append(...elOptions);
$('#editor.theme').append(...[
$create('option', {value: 'default'}, t('defaultTheme')),
...require('./codemirror-themes').map(s => $create('option', s)),
]);
// move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme'));
}
@ -248,7 +182,10 @@ lazyInit();
}
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')) {
@ -267,92 +204,13 @@ lazyInit();
}
}
function toggleStyle() {
$('#enabled').checked = !style.enabled;
updateEnabledness(!style.enabled);
}
function updateDirty() {
const isDirty = dirty.isDirty();
if (wasDirty !== isDirty) {
wasDirty = isDirty;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
}
updateTitle();
}
function updateEnabledness(enabled) {
dirty.modify('enabled', style.enabled, enabled);
style.enabled = enabled;
editor.updateLivePreview();
}
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) {
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);
}
}
})();
/* Stuff not needed for the main init so we can let it run at its own tempo */
function lazyInit() {
/* Stuff not needed for the main init so we can let it run at its own tempo */
function lazyInit() {
let ownTabId;
// not using `await` so we don't block the subsequent code
getOwnTab().then(patchHistoryBack);
// no windows on android
if (chrome.windows) {
restoreWindowSize();
detectWindowedState();
chrome.tabs.onAttached.addListener(onAttached);
}
@ -368,14 +226,6 @@ function lazyInit() {
};
}
}
/** 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';
@ -401,7 +251,7 @@ function lazyInit() {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
};
prefs.subscribe('iconset', onIconsetChanged, {now: true});
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
@ -423,9 +273,9 @@ function lazyInit() {
}
prefs.set('openEditInWindow', openEditInWindow);
}
}
}
function onRuntimeMessage(request) {
function onRuntimeMessage(request) {
const {style} = request;
switch (request.method) {
case 'styleUpdated':
@ -444,9 +294,9 @@ function onRuntimeMessage(request) {
document.execCommand('delete');
break;
}
}
}
function beforeUnload(e) {
function beforeUnload(e) {
sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition'));
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
scrollY: window.scrollY,
@ -468,114 +318,16 @@ function beforeUnload(e) {
// 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() {
function canSaveWindowPos() {
return isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized();
}
}
function saveWindowPos() {
function saveWindowPos() {
if (canSaveWindowPos()) {
prefs.set('windowPosition', {
left: window.screenX,
@ -584,9 +336,9 @@ function saveWindowPos() {
height: window.outerHeight,
});
}
}
}
function fixedHeader() {
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
@ -596,14 +348,15 @@ function fixedHeader() {
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
}
function detectLayout() {
function detectLayout(now) {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
debounce(fixedHeader, 250);
if (now) fixedHeader();
else debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
@ -614,9 +367,9 @@ function detectLayout() {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
}
function isWindowMaximized() {
function isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
@ -628,9 +381,9 @@ function isWindowMaximized() {
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
}
function embedPopup() {
function embedPopup() {
const ID = 'popup-iframe';
const SEL = '#' + ID;
if ($(SEL)) return;
@ -681,10 +434,11 @@ function embedPopup() {
// saving the listener here so it's the same function reference for window.off
if (!embedPopup._close) {
embedPopup._close = () => {
$.remove(SEL);
$remove(SEL);
window.off('mousedown', embedPopup._close);
};
}
window.on('mousedown', embedPopup._close);
document.body.appendChild(frame);
}
}
});

View File

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

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';
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
@ -138,13 +141,13 @@ onDOMready().then(() => {
},
onfocusout() {
if (!state.dialog.contains(document.activeElement)) {
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
state.dialog.on('focusin', EVENTS.onfocusin);
state.dialog.off('focusout', EVENTS.onfocusout);
}
},
onfocusin() {
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
state.dialog.on('focusout', EVENTS.onfocusout);
state.dialog.off('focusin', EVENTS.onfocusin);
trimUndoHistory();
enableUndoButton(state.undoHistory.length);
if (state.find) doSearch({canAdvance: false});
@ -189,7 +192,6 @@ onDOMready().then(() => {
Object.assign(CodeMirror.commands, COMMANDS);
readStorage();
return;
//region Find
@ -577,7 +579,7 @@ onDOMready().then(() => {
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
Object.assign(dialog, DIALOG_PROPS.dialog);
dialog.addEventListener('focusout', EVENTS.onfocusout);
dialog.on('focusout', EVENTS.onfocusout);
dialog.dataset.type = type;
dialog.style.pointerEvents = 'auto';
@ -590,9 +592,9 @@ onDOMready().then(() => {
state.tally = $('[data-type="tally"]', dialog);
const colors = {
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
body: colorMimicry(document.body, {bg: 'backgroundColor'}),
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
};
document.documentElement.appendChild(
$(DIALOG_STYLE_SELECTOR) ||
@ -652,7 +654,7 @@ onDOMready().then(() => {
function destroyDialog({restoreFocus = false} = {}) {
state.input = null;
$.remove(DIALOG_SELECTOR);
$remove(DIALOG_SELECTOR);
debounce.unregister(doSearch);
makeTargetVisible(null);
if (restoreFocus) {

View File

@ -1,27 +1,23 @@
/* global
$
$$
$create
CodeMirror
onDOMready
prefs
showHelp
stringAsRegExp
t
*/
'use strict';
onDOMready().then(() => {
$('#keyMap-help').addEventListener('click', showKeyMapHelp);
});
define(require => {
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;
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);
const tBody = table.tBodies[0];
table.oninput = filterTable;
tBody = table.tBodies[0];
const row = tBody.rows[0];
const cellA = row.children[0];
const cellB = row.children[1];
@ -32,17 +28,18 @@ function showKeyMapHelp() {
tBody.appendChild(row.cloneNode(true));
}
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
const inputs = $$('input', table);
inputs[0].addEventListener('keydown', hotkeyHandler);
inputs = $$('input', table);
inputs[0].on('keydown', hotkeyHandler);
inputs[1].focus();
table.oninput = filterTable;
};
function hotkeyHandler(event) {
const keyName = CodeMirror.keyName(event);
if (keyName === 'Esc' || keyName === 'Tab' || keyName === 'Shift-Tab') {
if (keyName === 'Esc' ||
keyName === 'Tab' ||
keyName === 'Shift-Tab') {
return;
}
event.preventDefault();
@ -90,6 +87,7 @@ function showKeyMapHelp() {
}
}
}
function mergeKeyMaps(merged, ...more) {
more.forEach(keyMap => {
if (typeof keyMap === 'string') {
@ -102,7 +100,7 @@ function showKeyMapHelp() {
if (typeof cmd === 'function') {
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body)
// for 'vim*' keymaps: almost nothing as it doesn't rely on CM keymap mechanism
cmd = cmd.toString().replace(/^function.*?\{[\s\r\n]*([\s\S]+?)[\s\r\n]*\}$/, '$1');
cmd = cmd.toString().replace(/^function.*?{[\s\r\n]*([\s\S]+?)[\s\r\n]*}$/, '$1');
merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
} else {
merged[key] = cmd;
@ -115,4 +113,4 @@ function showKeyMapHelp() {
});
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';
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 helpDialog = createLinterHelpDialog(getIssues);
document.addEventListener('DOMContentLoaded', () => {
$('#lint-help').addEventListener('click', helpDialog.show);
}, {once: true});
linter.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
let table = tables.get(cm);
if (!table) {
table = createTable(cm);
@ -23,7 +22,7 @@ Object.assign(linter, (() => {
updateCount();
});
linter.onUnhook(cm => {
linterMan.onUnhook(cm => {
const table = tables.get(cm);
if (table) {
table.element.remove();
@ -32,16 +31,9 @@ Object.assign(linter, (() => {
updateCount();
});
return {refreshReport};
return {
function updateCount() {
const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount;
}
function getIssues() {
getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
@ -49,6 +41,20 @@ Object.assign(linter, (() => {
}
}
return issues;
},
refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
},
};
function updateCount() {
const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount;
}
function findNextSibling(tables, cm) {
@ -62,12 +68,6 @@ Object.assign(linter, (() => {
}
}
function refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
}
function createTable(cm) {
const caption = $create('caption');
const tbody = $create('tbody');
@ -158,4 +158,4 @@ Object.assign(linter, (() => {
cm.focus();
cm.jumpToPos(anno.from);
}
})());
});

View File

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

View File

@ -1,74 +1,89 @@
/* global messageBox editor $ prefs */
/* exported createLivePreview */
'use strict';
function createLivePreview(preprocess, shouldShow) {
let data;
let previewer;
let enabled = prefs.get('editor.livePreview');
const label = $('#preview-label');
const errorContainer = $('#preview-errors');
define(require => {
const {$, messageBoxProxy} = require('/js/dom');
const prefs = require('/js/prefs');
const editor = require('./editor');
prefs.subscribe(['editor.livePreview'], (key, value) => {
if (value && data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
previewer = createPreviewer();
previewer.update(data);
}
if (!value && previewer) {
previewer.disconnect();
previewer = null;
let data;
let port;
let preprocess;
let enabled = prefs.get('editor.livePreview');
prefs.subscribe('editor.livePreview', (key, value) => {
if (!value) {
disconnectPreviewer();
} else if (data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
createPreviewer();
updatePreviewer(data);
}
enabled = value;
});
if (shouldShow != null) show(shouldShow);
return {update, show};
function show(state) {
label.classList.toggle('hidden', !state);
const livePreview = {
/**
* @param {Function} [fn] - preprocessor
* @param {boolean} [show]
*/
init(fn, show) {
preprocess = fn;
if (show != null) {
livePreview.show(show);
}
},
function update(_data) {
data = _data;
if (!previewer) {
show(state) {
$('#preview-label').classList.toggle('hidden', !state);
},
update(newData) {
data = newData;
if (!port) {
if (!data.id || !data.enabled || !enabled) {
return;
}
previewer = createPreviewer();
}
previewer.update(data);
createPreviewer();
}
updatePreviewer(data);
},
};
function createPreviewer() {
const port = chrome.runtime.connect({
name: 'livePreview',
});
port.onDisconnect.addListener(err => {
throw err;
});
return {update, disconnect};
port = chrome.runtime.connect({name: 'livePreview'});
port.onDisconnect.addListener(throwError);
}
function update(data) {
Promise.resolve()
.then(() => preprocess ? preprocess(data) : data)
.then(data => port.postMessage(data))
.then(
() => errorContainer.classList.add('hidden'),
err => {
function disconnectPreviewer() {
if (port) {
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 !== undefined) {
} 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 || String(err)}`;
err.message = `${pos.line}:${pos.ch} ${err.message || err}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
errorContainer.onclick = () => {
messageBoxProxy.alert(err.message || `${err}`, 'pre');
};
}
);
}
function disconnect() {
port.disconnect();
}
}
}
return livePreview;
});

View File

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

View File

@ -1,24 +1,16 @@
/* global
$
$create
CodeMirror
colorMimicry
messageBox
MozSectionFinder
msg
prefs
regExpTester
t
tryCatch
*/
'use strict';
/* exported MozSectionWidget */
function MozSectionWidget(
cm,
finder = MozSectionFinder(cm),
onDirectChange = () => 0
) {
define(require => {
const {msg} = require('/js/msg');
const {$, $create, messageBoxProxy} = require('/js/dom');
const {tryCatch} = require('/js/toolbox');
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;
const KEY = 'MozSectionWidget';
const C_CONTAINER = '.applies-to';
@ -36,8 +28,16 @@ function MozSectionWidget(
const {cmpPos} = CodeMirror;
let enabled = false;
let funcHeight = 0;
/** @type {HTMLStyleElement} */
let actualStyle;
let cm;
return {
init(newCM) {
cm = newCM;
},
toggle(enable) {
if (Boolean(enable) !== enabled) {
(enable ? init : destroy)();
@ -71,7 +71,7 @@ function MozSectionWidget(
'.remove-applies-to'(elItem, func) {
const funcs = getFuncsFor(elItem);
if (funcs.length < 2) {
messageBox({
messageBoxProxy.show({
contents: t('appliesRemoveError'),
buttons: [t('confirmClose')],
});
@ -110,7 +110,7 @@ function MozSectionWidget(
if (part === 'value' && func === getFuncsFor(el)[0]) {
const sec = getSectionFor(el);
sec.tocEntry.target = el.value;
if (!sec.tocEntry.label) onDirectChange([sec]);
if (!sec.tocEntry.label) updateToc([sec]);
}
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_DIFF = .4;
const color = {
wrapper: colorMimicry.get(cm.display.wrapper),
gutter: colorMimicry.get(cm.display.gutters, {
wrapper: colorMimicry(cm.display.wrapper),
gutter: colorMimicry(cm.display.gutters, {
bg: 'backgroundColor',
border: 'borderRightColor',
}),
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
};
const hasBorder =
color.gutter.style.borderRightWidth !== '0px' &&
@ -422,9 +422,11 @@ function MozSectionWidget(
}
function showRegExpTester(el) {
require(['./regexp-tester'], regExpTester => {
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
regExpTester.toggle(true);
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
});
}
function fromDoubleslash(s) {
@ -443,4 +445,4 @@ function MozSectionWidget(
function setProp(obj, name, value) {
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,62 +1,39 @@
/* global
$
$create
openURL
showHelp
t
tryRegExp
URLS
*/
/* exported regExpTester */
'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 OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = new Map();
let currentRegexps = [];
let isInit = false;
let isWatching;
function init() {
isInit = true;
const isShown = () => Boolean($('.regexp-report'));
const regexpTester = /** @namespace RegExpTester */{
toggle(state = !isShown()) {
if (state && !isShown()) {
if (!isWatching) {
isWatching = true;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
function uninit() {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isInit = false;
}
function onTabUpdate(tabId, info) {
if (info.url) {
update();
}
}
function isShown() {
return Boolean($('.regexp-report'));
}
function toggle(state = !isShown()) {
if (state && !isShown()) {
if (!isInit) {
init();
}
showHelp('', $create('.regexp-report'));
helpPopup.show('', $create('.regexp-report'));
} else if (!state && isShown()) {
if (isInit) {
uninit();
}
// TODO: need a closeHelp function
$('#help-popup .dismiss').onclick();
}
unwatch();
helpPopup.close();
}
},
function update(newRegexps) {
async update(newRegexps) {
if (!isShown()) {
if (isInit) {
uninit();
}
unwatch();
return;
}
if (newRegexps) {
@ -74,7 +51,7 @@ const regExpTester = (() => {
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
browser.tabs.query({}).then(tabs => {
const tabs = await browser.tabs.query({});
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
const unique = [...new Set(supported).values()];
for (const rxData of regexps) {
@ -92,10 +69,12 @@ const regExpTester = (() => {
}
const stats = {
full: {data: [], label: t('styleRegexpTestFull')},
partial: {data: [], label: [
partial: {
data: [], label: [
t('styleRegexpTestPartial'),
t.template.regexpTestPartial.cloneNode(true),
]},
],
},
none: {data: [], label: t('styleRegexpTestNone')},
invalid: {data: [], label: t('styleRegexpTestInvalid')},
};
@ -167,7 +146,7 @@ const regExpTester = (() => {
}
}
}
showHelp(t('styleRegexpTestTitle'), report);
helpPopup.show(t('styleRegexpTestTitle'), report);
report.onclick = onClick;
const note = $create('p.regexp-report-note',
@ -176,7 +155,12 @@ const regExpTester = (() => {
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
report.appendChild(note);
adjustNote(report, note);
});
},
};
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
}
function onClick(event) {
const a = event.target.closest('a');
@ -191,10 +175,18 @@ const regExpTester = (() => {
}
}
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
function onTabUpdate(tabId, info) {
if (info.url) {
regexpTester.update();
}
}
return {toggle, update};
})();
function unwatch() {
if (isWatching) {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isWatching = false;
}
}
return regexpTester;
});

View File

@ -1,33 +1,34 @@
/* global CodeMirror editor debounce */
/* exported rerouteHotkeys */
'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
const REROUTED = new Set([
'save',
'toggleStyle',
'jumpToLine',
'nextEditor', 'prevEditor',
'toggleEditorFocus',
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
'colorpicker',
'beautify',
'colorpicker',
'find',
'findNext',
'findPrev',
'jumpToLine',
'nextEditor',
'prevEditor',
'replace',
'replaceAll',
'save',
'toggleEditorFocus',
'toggleStyle',
]);
return rerouteHotkeys;
// note that this function relies on `editor`. Calling this function before
// the editor is initialized may throw an error.
function rerouteHotkeys(enable, immediately) {
return function rerouteHotkeys(enable, immediately) {
if (!immediately) {
debounce(rerouteHotkeys, 0, enable, true);
} else if (enable) {
document.addEventListener('keydown', rerouteHandler);
} else {
document.removeEventListener('keydown', rerouteHandler);
}
document[enable ? 'on' : 'off']('keydown', rerouteHandler);
}
};
function rerouteHandler(event) {
const keyName = CodeMirror.keyName(event);
@ -46,4 +47,4 @@ const rerouteHotkeys = (() => {
event.stopPropagation();
}
}
})();
});

View File

@ -1,29 +1,26 @@
/* global
$
cmFactory
debounce
DocFuncMapper
editor
initBeautifyButton
linter
prefs
regExpTester
t
trimCommentLabel
tryRegExp
*/
'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;
/**
const {dirty} = editor;
/**
* @param {StyleSection} originalSection
* @param {function():number} genId
* @param {EditorScrollInfo} [si]
* @returns {EditorSection}
*/
function createSection(originalSection, genId, si) {
const {dirty} = editor;
return function createSection(originalSection, genId, si) {
const sectionId = genId();
const el = t.template.section.cloneNode(true);
const elLabel = $('.code-label', el);
@ -67,7 +64,7 @@ function createSection(originalSection, genId, si) {
return DocFuncMapper.toSection(items, {code: cm.getValue()});
},
remove() {
linter.disableForEditor(cm);
linterMan.disableForEditor(cm);
el.classList.add('removed');
removed = true;
appliesTo.forEach(a => a.remove());
@ -79,7 +76,7 @@ function createSection(originalSection, genId, si) {
cmFactory.destroy(cm);
},
restore() {
linter.enableForEditor(cm);
linterMan.enableForEditor(cm);
el.classList.remove('removed');
removed = false;
appliesTo.forEach(a => a.restore());
@ -102,7 +99,7 @@ function createSection(originalSection, genId, si) {
},
};
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true});
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {runNow: true});
return section;
@ -120,11 +117,8 @@ function createSection(originalSection, genId, si) {
emitSectionChange('code');
});
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
$('.test-regexp', el).onclick = () => {
regExpTester.toggle();
updateRegexpTester();
};
initBeautifyButton($('.beautify-section', el), () => [cm]);
$('.test-regexp', el).onclick = () => updateRegexpTester(true);
cmFactory.initBeautifyButton($('.beautify-section', el), [cm]);
}
function handleKeydown(cm, event) {
@ -165,7 +159,9 @@ function createSection(originalSection, genId, si) {
}
}
function updateRegexpTester() {
async function updateRegexpTester(toggle) {
if (!regExpTester) regExpTester = await require(['./regexp-tester']);
if (toggle != null) regExpTester.toggle(toggle);
const regexps = appliesTo.filter(a => a.type === 'regexp')
.map(a => a.value);
if (regexps.length) {
@ -211,7 +207,7 @@ function createSection(originalSection, genId, si) {
function updateTocPrefToggled(key, val) {
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
el.onOff(val, 'focusin', updateTocFocus);
(val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
if (val) {
updateTocEntry();
if (el.contains(document.activeElement)) {
@ -355,9 +351,9 @@ function createSection(originalSection, genId, si) {
dirty.add(`${dirtyPrefix}.value`, value);
}
}
}
};
function createResizeGrip(cm) {
function createResizeGrip(cm) {
const wrapper = cm.display.wrapper;
wrapper.classList.add('resize-grip-enabled');
const resizeGrip = t.template.resizeGrip.cloneNode(true);
@ -424,4 +420,5 @@ function createResizeGrip(cm) {
}
}
}
}
}
});

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';
/* 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, dirty} = editor;
const {style, /** @type DirtyReporter */dirty} = editor;
const container = $('#sections');
/** @type {EditorSection[]} */
const sections = [];
const xo = window.IntersectionObserver &&
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
const livePreview = createLivePreview(null, style.id);
let INC_ID = 0; // an increment id that is used by various object to track the order
let sectionOrder = '';
let headerOffset; // in compact mode the header is at the top so it reduces the available height
container.classList.add('section-editor');
updateHeader();
livePreview.init(null, style.id);
container.classList.add('section-editor');
$('#to-mozilla').on('click', showMozillaFormat);
$('#to-mozilla-help').on('click', showToMozillaHelp);
$('#from-mozilla').on('click', () => showMozillaFormatImport());
@ -50,8 +53,7 @@ function SectionsEditor() {
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
}
/** @namespace SectionsEditor */
Object.assign(editor, {
Object.assign(editor, /** @mixin SectionsEditor */ {
sections,
@ -194,13 +196,13 @@ function SectionsEditor() {
progressElement.title = progress + '%';
});
} else {
$.remove(progressElement);
$remove(progressElement);
}
}
function showToMozillaHelp(event) {
event.preventDefault();
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
helpPopup.show(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
}
/**
@ -380,7 +382,7 @@ function SectionsEditor() {
const code = popup.codebox.getValue().trim();
if (!/==userstyle==/i.test(code) ||
!await getPreprocessor(code) ||
await messageBox.confirm(
await messageBoxProxy.confirm(
t('importPreprocessor'), 'pre-line',
t('importPreprocessorTitle'))
) {
@ -416,7 +418,7 @@ function SectionsEditor() {
}
function showError(errors) {
messageBox({
messageBoxProxy.show({
className: 'center danger',
title: t('styleFromMozillaFormatError'),
contents: $create('pre',
@ -433,7 +435,7 @@ function SectionsEditor() {
sectionOrder = validSections.map(s => s.id).join(',');
dirty.modify('sectionOrder', oldOrder, sectionOrder);
container.dataset.sectionCount = validSections.length;
linter.refreshReport();
require(['./linter-report'], rep => rep.refreshReport());
editor.updateToc();
}
@ -446,7 +448,7 @@ function SectionsEditor() {
function validate() {
if (!$('#name').reportValidity()) {
messageBox.alert(t('styleMissingName'));
messageBoxProxy.alert(t('styleMissingName'));
return false;
}
for (const section of sections) {
@ -455,7 +457,7 @@ function SectionsEditor() {
continue;
}
if (!apply.valueEl.reportValidity()) {
messageBox.alert(t('styleBadRegexp'));
messageBoxProxy.alert(t('styleBadRegexp'));
return false;
}
}
@ -627,7 +629,7 @@ function SectionsEditor() {
/** @param {EditorSection} section */
function registerEvents(section) {
const {el, cm} = section;
$('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp'));
$('.applies-to-help', el).onclick = () => helpPopup.show(t('appliesLabel'), t('appliesHelp'));
$('.remove-section', el).onclick = () => removeSection(section);
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
@ -653,7 +655,7 @@ function SectionsEditor() {
function refreshOnView(cm, {code, force} = {}) {
if (code) {
linter.enableForEditor(cm, code);
linterMan.enableForEditor(cm, code);
}
if (force || !xo) {
refreshOnViewNow(cm);
@ -679,7 +681,7 @@ function SectionsEditor() {
}
async function refreshOnViewNow(cm) {
linter.enableForEditor(cm);
linterMan.enableForEditor(cm);
cm.refresh();
}
@ -693,4 +695,4 @@ function SectionsEditor() {
}, 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';
/* 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 {style, dirty} = editor;
const {CodeMirror} = cmFactory;
const {style, /** @type DirtyReporter */dirty} = editor;
let savedGeneration;
let placeholderName = '';
let prevMode = NaN;
$$.remove('.sectioned-only');
$$remove('.sectioned-only');
$('#header').on('wheel', headerOnScroll);
$('#sections').textContent = '';
$('#sections').appendChild($create('.single-editor'));
@ -38,11 +35,19 @@ function SourceEditor() {
if (!style.id) setupNewStyle(style);
const cm = cmFactory.create($('.single-editor'));
const sectionFinder = MozSectionFinder(cm);
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc);
const livePreview = createLivePreview(preprocess, style.id);
/** @namespace SourceEditor */
Object.assign(editor, {
createMetaCompiler(cm, meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
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,
replaceStyle,
getEditors: () => [cm],
@ -62,19 +67,12 @@ function SourceEditor() {
getSearchableInputs: () => [],
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({
'editor.linter': updateLinterSwitch,
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
}, {now: true});
}, {runNow: true});
editor.applyScrollInfo(cm);
cm.clearHistory();
cm.markClean();
@ -88,11 +86,11 @@ function SourceEditor() {
const mode = getModeName();
if (mode === prevMode) return;
prevMode = mode;
linter.run();
linterMan.run();
updateLinterSwitch();
});
setTimeout(linter.enableForEditor, 0, cm);
if (!$.isTextInput(document.activeElement)) {
setTimeout(linterMan.enableForEditor, 0, cm);
if (!$isTextInput(document.activeElement)) {
cm.focus();
}
@ -199,7 +197,7 @@ function SourceEditor() {
return;
}
Promise.resolve(messageBox.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
if (!ok) return;
updateEnvironment();
if (!sameCode) {
@ -250,16 +248,16 @@ function SourceEditor() {
// save template
if (err.code === 'missingValue' && meta.includes('@name')) {
const key = chromeSync.LZ_KEY.usercssTemplate;
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
chromeSync.setLZValue(key, code)
.then(() => chromeSync.getLZValue(key))
.then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving'))));
.then(saved => saved !== code && messageBoxProxy.alert(t('syncStorageErrorSaving'))));
return;
}
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
contents.push($create('pre', meta));
}
messageBox.alert(contents, 'pre');
messageBoxProxy.alert(contents, 'pre');
});
}
@ -271,7 +269,7 @@ function SourceEditor() {
metaOnly: true,
}).then(({dup}) => {
if (dup) {
messageBox.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
return Promise.reject({handled: true});
}
});
@ -334,4 +332,37 @@ function SourceEditor() {
return (mode.name || mode || '') +
(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,96 +1,20 @@
/* global
$create
CodeMirror
prefs
*/
'use strict';
/* exported DirtyReporter */
class DirtyReporter {
constructor() {
this._dirty = new Map();
this._onchange = new Set();
}
define(require => {
const {
$,
$create,
getEventKeyName,
messageBoxProxy,
moveFocus,
} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
add(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
this._dirty.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
}
}
this.notifyChange(wasDirty);
}
let CodeMirror;
remove(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
this._dirty.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
this.notifyChange(wasDirty);
}
modify(obj, oldValue, newValue) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
if (oldValue !== newValue) {
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
this._dirty.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
this.notifyChange(wasDirty);
}
clear(obj) {
const wasDirty = this.isDirty();
if (obj === undefined) {
this._dirty.clear();
} else {
this._dirty.delete(obj);
}
this.notifyChange(wasDirty);
}
isDirty() {
return this._dirty.size > 0;
}
onChange(cb, add = true) {
this._onchange[add ? 'add' : 'delete'](cb);
}
notifyChange(wasDirty) {
if (wasDirty !== this.isDirty()) {
this._onchange.forEach(cb => cb());
}
}
has(key) {
return this._dirty.has(key);
}
}
/* exported DocFuncMapper */
const DocFuncMapper = {
// TODO: maybe move to sections-util.js
const DocFuncMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
@ -130,50 +54,73 @@ const DocFuncMapper = {
}
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 */
/**
* @param {!string} prefId
* @param {?function(isEnter:boolean)} onDone
*/
function createHotkeyInput(prefId, onDone = () => {}) {
const util = {
get CodeMirror() {
return CodeMirror;
},
set CodeMirror(val) {
CodeMirror = val;
},
DocFuncMapper,
helpPopup: {
show(title = '', body) {
const div = $('#help-popup');
const contents = $('.contents', div);
div.className = '';
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
}
$('.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;
}
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;
}
if (div.contains(document.activeElement) && util.helpPopup.originalFocus) {
util.helpPopup.originalFocus.focus();
}
const contents = $('.contents', div);
div.style.display = '';
contents.textContent = '';
window.off('keydown', util.helpPopup.close, true);
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,
@ -216,4 +163,69 @@ function createHotkeyInput(prefId, onDone = () => {}) {
event.preventDefault();
},
});
}
},
async rerouteHotkeys(...args) {
require(['./reroute-hotkeys'], res => res(...args));
},
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>
<link href="global.css" rel="stylesheet">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
<script src="js/polyfill.js"></script>
<script src="js/msg.js"></script>
<script src="js/messaging.js"></script>
<script src="js/toolbox.js"></script>
<script src="install-usercss/preinit.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="vendor/semver-bundle/semver.js"></script>
<link href="msgbox/msgbox.css" rel="stylesheet">
<script src="msgbox/msgbox.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<script src="vendor/codemirror/mode/css/css.js"></script>
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
<script src="edit/codemirror-default.js"></script>
<link rel="stylesheet" href="edit/codemirror-default.css">
<script src="install-usercss/install-usercss.js"></script>
<link href="install-usercss/install-usercss.css" rel="stylesheet">
</head>
<body id="stylus-install-usercss">
<div class="container">
@ -92,8 +69,6 @@
</div>
</div>
<script src="install-usercss/install-usercss.js"></script>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000">
<path fill-rule="evenodd" d="M983.2,184.3L853,69.8c-4-3.5-9.3-5.3-14.5-5c-5.3,0.4-10.3,2.8-13.8,6.8L352.3,609.2L184.4,386.9c-3.2-4.2-8-7-13.2-7.8c-5.3-0.8-10.6,0.6-14.9,3.9L18,487.5c-8.8,6.7-10.6,19.3-3.9,28.1L325,927.2c3.6,4.8,9.3,7.7,15.3,8c0.2,0,0.5,0,0.7,0c5.8,0,11.3-2.5,15.1-6.8L985,212.6C992.3,204.3,991.5,191.6,983.2,184.3z"/>

View File

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

View File

@ -1,34 +1,61 @@
/* global CodeMirror semverCompare closeCurrentTab messageBox download
$ $$ $create $createLink t prefs API */
'use strict';
(() => {
const params = new URLSearchParams(location.search);
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
const initialUrl = params.get('updateUrl');
let installed = null;
let installedDup = null;
const liveReload = initLiveReload();
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre'));
const theme = prefs.get('editor.theme');
const cm = CodeMirror($('.main'), {
readOnly: true,
colorpicker: true,
theme,
define(require => {
const {API} = require('/js/msg');
const {closeCurrentTab} = require('/js/toolbox');
const {
$,
$create,
$createLink,
$remove,
$$remove,
} = require('/js/dom');
const t = require('/js/localization');
const prefs = require('/js/prefs');
const preinit = require('./preinit');
const messageBox = require('/js/dlg/message-box');
const {styleCodeEmpty} = require('/js/sections-util');
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') {
document.head.appendChild($create('link', {
rel: 'stylesheet',
href: `vendor/codemirror/theme/${theme}.css`,
}));
require([`/vendor/codemirror/theme/${theme}.css`]); // not awaiting as it may be absent
}
window.addEventListener('resize', adjustCodeHeight);
window.on('resize', adjustCodeHeight);
// "History back" in Firefox (for now) restores the old DOM including the messagebox,
// which stays after installing since we don't want to wait for the fadeout animation before resolving.
document.addEventListener('visibilitychange', () => {
document.on('visibilitychange', () => {
if (messageBox.element) messageBox.element.remove();
if (installed) liveReload.onToggled();
});
@ -40,192 +67,27 @@
}
}, 200);
init();
function updateMeta(style, dup = installedDup) {
installedDup = dup;
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
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';
async function init() {
const {dup, style, error, sourceCode} = await preinit.ready;
if (!style && sourceCode == null) {
messageBox.alert(isNaN(error) ? error : 'HTTP Error ' + error, 'pre');
return;
}
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
$('.meta-license').textContent = data.license;
$('.applies-to').textContent = '';
getAppliesTo(style).forEach(pattern =>
$('.applies-to').appendChild($create('li', pattern)));
$('.external-link').textContent = '';
const externalLink = makeExternalLink();
if (externalLink) {
$('.external-link').appendChild(externalLink);
}
$('#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 = '';
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);
const CodeMirror = await cmReady;
cm = CodeMirror($('.main'), {
value: sourceCode || style.sourceCode,
readOnly: true,
colorpicker: true,
theme,
});
if (error) {
showBuildError(error);
}
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();
});
if (!style) {
return;
}
return $create('.warning', [
t('parseUsercssError'),
'\n',
...contents,
]);
}
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
const pointer = ' '.repeat(pos.ch) + '^';
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
const leftPad = start !== 0 ? '...' : '';
const rightPad = end !== line.length ? '...' : '';
return (
leftPad +
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
rightPad +
'\n' +
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
pointer.slice(start, end)
);
}
function init({style, dup}) {
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
@ -279,21 +141,186 @@
}
}
function getAppliesTo(style) {
function *_gen() {
function updateMeta(style, dup = installedDup) {
installedDup = dup;
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
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();
}
}
}
}
async function getAppliesTo(style) {
if (style.sectionsPromise) {
try {
style.sections = await style.sectionsPromise;
} catch (error) {
showBuildError(error);
return [];
} finally {
delete style.sectionsPromise;
}
}
let numGlobals = 0;
const res = [];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
for (const section of style.sections) {
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
if (section[type]) {
yield *section[type];
const targets = [].concat(...TARGETS.map(t => section[t]).filter(Boolean));
res.push(...targets);
numGlobals += !targets.length && !styleCodeEmpty(section.code);
}
res.sort();
if (!res.length || numGlobals) {
res.push(t('appliesToEverything'));
}
}
}
const result = [..._gen()];
if (!result.length) {
result.push(chrome.i18n.getMessage('appliesToEverything'));
}
return result;
return [...new Set(res)];
}
function adjustCodeHeight() {
@ -311,24 +338,12 @@
const DELAY = 500;
let isEnabled = false;
let timer = 0;
/** @type function(?options):Promise<string|null> */
let getData = null;
/** @type Promise */
let sequence = null;
if (tabId < 0) {
getData = DirectDownloader();
sequence = API.usercss.getInstallCode(initialUrl)
.then(code => code || getData())
.catch(getData);
} else {
getData = PortDownloader();
sequence = getData({timer: false});
}
const getData = preinit.getData;
let sequence = preinit.ready;
return {
get enabled() {
return isEnabled;
},
ready: sequence,
onToggled(e) {
if (e) isEnabled = e.target.checked;
if (installed || installedDup) {
@ -377,42 +392,5 @@
.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,39 +1,21 @@
/* exported createCache */
'use strict';
// create a FIFO limit-size map.
function createCache({size = 1000, onDeleted} = {}) {
/**
* Creates a FIFO limit-size map.
*/
define(require =>
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get,
set,
delete: delete_,
clear,
has: id => map.has(id),
entries: function *() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
values: function *() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
function get(id) {
get(id) {
const item = map.get(id);
return item && item.data;
}
function set(id, data) {
},
set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
@ -46,9 +28,8 @@ function createCache({size = 1000, onDeleted} = {}) {
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
}
function delete_(id) {
},
delete(id) {
const item = map.get(id);
if (!item) {
return false;
@ -62,10 +43,24 @@ function createCache({size = 1000, onDeleted} = {}) {
onDeleted(item.id, item.data);
}
return true;
}
function clear() {
},
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';
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
const cm = this;
define(require => {
const colorMimicry = require('./color-mimicry');
const colorConverter = require('./color-converter');
require(['./color-picker.css']);
const CSS_PREFIX = 'colorpicker-';
const HUE_COLORS = [
{hex: '#ff0000', start: .0},
@ -59,7 +60,7 @@
let lastOutputColor;
let userActivity;
const PUBLIC_API = {
const colorPicker = {
$root,
show,
hide,
@ -67,7 +68,7 @@
getColor,
options,
};
return PUBLIC_API;
return colorPicker;
//region DOM
@ -203,7 +204,7 @@
}
HSV = {};
currentFormat = '';
options = PUBLIC_API.options = opt;
options = colorPicker.options = opt;
prevFocusedElement = document.activeElement;
userActivity = 0;
lastOutputColor = opt.color || '';
@ -586,7 +587,7 @@
document.removeEventListener('mouseup', onPopupResizeEnd);
if (maxHeight !== $root.style.height) {
maxHeight = $root.style.height;
PUBLIC_API.options.maxHeight = parseFloat(maxHeight);
colorPicker.options.maxHeight = parseFloat(maxHeight);
fitPaletteHeight();
}
}
@ -677,12 +678,12 @@
}
function onCloseRequest(event) {
if (event.detail !== PUBLIC_API) {
if (event.detail !== colorPicker) {
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
// 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() {
const {cm} = options;
const el = options.guessBrightness ||
((cm.display.renderedView || [])[0] || {}).text ||
cm.display.lineDiv;
const bgLuma = window.colorMimicry.get(el, {bg: 'backgroundColor'}).bgLuma;
cm && ((cm.display.renderedView || [])[0] || {}).text ||
cm && cm.display.lineDiv;
const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma;
return bgLuma < .5 ? 'dark' : 'light';
}
@ -892,93 +894,4 @@
}
//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';
(() => {
define(require => {
const colorConverter = require('./color-converter');
const CodeMirror = require('/vendor/codemirror/lib/codemirror');
//region Constants
const COLORVIEW_CLASS = 'colorview';
@ -99,13 +101,12 @@
const cache = new Set();
class ColorSwatch {
constructor(cm, options) {
constructor(cm, options = {}) {
this.cm = cm;
this.options = options;
this.markersToRemove = [];
this.markersToRepaint = [];
this.popup = cm.colorpicker && cm.colorpicker();
if (!this.popup) {
if (!options.popup) {
delete CM_EVENTS.mousedown;
document.head.appendChild(document.createElement('style')).textContent = `
.colorview-swatch::before {
@ -122,7 +123,9 @@
}
openPopup(color) {
if (this.popup) openPopupForCursor(this, color);
if (this.options.popup) {
openPopupForCursor(this, color);
}
}
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');
state.popup.show(Object.assign(state.options.popup, data, {
top,
@ -774,4 +780,4 @@
}
//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';
/* eslint-disable class-methods-use-this */
self.parserlib = (() => {
define(require => {
//#region Properties
@ -4685,7 +4685,8 @@ self.parserlib = (() => {
//#endregion
//#region PUBLIC API
return {
/** @type {parserlib} */
return /** @namespace parserlib */ {
css: {
Colors,
Combinator,
@ -4715,4 +4716,4 @@ self.parserlib = (() => {
};
//#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;
});

718
js/dom.js
View File

@ -1,200 +1,57 @@
/* global prefs */
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
setupLivePrefs moveFocus */
'use strict';
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
define(require => {
Object.assign(EventTarget.prototype, {
Object.assign(EventTarget.prototype, {
on: addEventListener,
off: removeEventListener,
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
onOff(enable, ...args) {
(enable ? addEventListener : removeEventListener).apply(this, args);
},
});
$.isTextInput = (el = {}) =>
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();
}
};
{
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
const addTooltipsToEllipsized = () => {
for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) {
continue;
}
const width = btn.offsetWidth;
if (!width || btn.preresizeClientWidth === width) {
continue;
}
btn.preresizeClientWidth = width;
if (btn.scrollWidth > width) {
const text = btn.textContent;
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
btn.titleIsForEllipsis = true;
} else if (btn.title) {
btn.title = '';
}
}
};
// 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,
}));
}
});
}
});
/** @type {Prefs} */
let prefs;
// set language for CSS :lang and [FF-only] hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
// avoid adding # to the page URL when clicking dummy links
document.on('click', e => {
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
});
// update inputs on mousewheel when focused
document.on('wheel', event => {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}, {
capture: true,
passive: false,
});
//#region Exports
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
}
/** @type {DOM} */
let dom;
const {
$,
$$,
$create,
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);
}
}
} = dom = /** @namespace DOM */ {
/**
* @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});
el.classList.add(cls);
});
}
function enforceInputRange(element) {
const min = Number(element.min);
const max = Number(element.max);
const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true}));
const onChange = ({type}) => {
if (type === 'input' && element.checkValidity()) {
doNotify();
} else if (type === 'change' && !element.checkValidity()) {
element.value = Math.max(min, Math.min(max, Number(element.value)));
doNotify();
}
};
element.on('change', onChange);
element.on('input', onChange);
}
function $(selector, base = document) {
$(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) {
$$(selector, base = document) {
return [...base.querySelectorAll(selector)];
}
},
$isTextInput(el = {}) {
return el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
},
function $create(selector = 'div', properties, children) {
/*
$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])
@ -211,9 +68,9 @@ function $create(selector = 'div', properties, children) {
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 ||
@ -236,69 +93,61 @@ function $create(selector = 'div', properties, children) {
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(':'));
[ns, tag] = tag.split(':');
if (ns === 'SVG' || ns === 'svg') {
ns = 'http://www.w3.org/2000/svg';
}
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');
}
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));
}
}
if (opt.dataset) {
Object.assign(element.dataset, opt.dataset);
delete opt.dataset;
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;
}
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;
}
case 'tag':
case 'appendChild':
break;
default: {
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]);
}
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 {
Object.assign(element, opt);
element[key] = val;
}
}
}
}
return element;
}
},
function $createLink(href = '', content) {
$createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
@ -310,54 +159,44 @@ function $createLink(href = '', content) {
opt.href = href;
}
opt.appendChild = opt.appendChild || content;
return $create(opt);
}
return dom.$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;
/**
* @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();
}
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;
}
});
el.on('animationend', onDone, {once: true});
el.classList.add(cls);
});
},
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() {
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
focusAccessibility: {
// last event's focusedViaClick
focusAccessibility.lastFocusedViaClick = false;
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 => {
closest(el) {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
@ -366,115 +205,10 @@ function focusAccessibility() {
}
if (el.tabIndex >= 0) return el;
}
};
// suppress outline on click
window.on('mousedown', ({target}) => {
const el = focusAccessibility.closest(target);
if (el) {
focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
}
}, {passive: true});
// keep outline on Tab or Shift-Tab key
window.on('keydown', event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
el = el.closest('[data-focused-via-click]');
if (el) delete el.dataset.focusedViaClick;
}
});
}
}, {passive: true});
}
},
},
/**
* 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
*/
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 */
/**
* @param {KeyboardEvent|MouseEvent} e
* @param {boolean} [letterAsCode] - use locale-independent KeyA..KeyZ for single-letter chars
*/
function getEventKeyName(e, letterAsCode) {
getEventKeyName(e, letterAsCode) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
@ -487,4 +221,250 @@ function getEventKeyName(e, letterAsCode) {
? 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});
//#endregion
//#region Internals
function changeFocusedInputOnWheel(event) {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}
/** Displays a full text tooltip on buttons with ellipsis overflow and no inherent title */
function addTooltipsToEllipsized() {
for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) {
continue;
}
const width = btn.offsetWidth;
if (!width || btn.preresizeClientWidth === width) {
continue;
}
btn.preresizeClientWidth = width;
if (btn.scrollWidth > width) {
const text = btn.textContent;
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
btn.titleIsForEllipsis = true;
} else if (btn.title) {
btn.title = '';
}
}
}
// makes <details> with [data-pref] save/restore their state
function initCollapsibles() {
const onClick = async event => {
if (event.target.closest('.intercepts-click')) {
event.preventDefault();
} else {
const el = event.target.closest('details');
await new Promise(setTimeout);
if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
prefs.set(el.dataset.pref, el.open);
}
}
};
const prefMap = {};
for (const el of $$('details[data-pref]')) {
prefMap[el.dataset.pref] = el;
($('h2', el) || el).on('click', onClick);
}
prefs.subscribe(Object.keys(prefMap), (key, value) => {
const el = prefMap[key];
if (el.open !== value && !el.matches('.compact-layout .ignore-pref-if-compact')) {
el.open = value;
}
}, {runNow: true});
}
function keepAddressOnDummyClick(e) {
// avoid adding # to the page URL when clicking dummy links
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
}
function keepFocusRingOnTabbing(event) {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
dom.focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
el = el.closest('[data-focused-via-click]');
if (el) delete el.dataset.focusedViaClick;
}
});
}
}
function suppressFocusRingOnClick({target}) {
const el = dom.focusAccessibility.closest(target);
if (el) {
dom.focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
}
}
//#endregion
return dom;
});

View File

@ -1,16 +1,10 @@
'use strict';
function t(key, params) {
const s = chrome.i18n.getMessage(key, params);
if (!s) throw `Missing string "${key}"`;
return s;
}
define(require => {
Object.assign(t, {
template: {},
DOMParser: new DOMParser(),
ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','),
RX_WORD_BREAK: new RegExp([
const parser = new DOMParser();
const ALLOWED_TAGS = ['a', 'b', 'code', 'i', 'sub', 'sup', 'wbr'];
const RX_WORD_BREAK = new RegExp([
'(',
/[\d\w\u007B-\uFFFF]{10}/,
'|',
@ -19,7 +13,16 @@ Object.assign(t, {
/((?!\s)\W){10}/,
')',
/(?!\b|\s|$)/,
].map(rx => rx.source || rx).join(''), 'gu'),
].map(rx => rx.source || rx).join(''), 'gu');
function t(key, params, strict = true) {
const s = chrome.i18n.getMessage(key, params);
if (!s && strict) throw `Missing string "${key}"`;
return s;
}
Object.assign(t, {
template: {},
HTML(html) {
return typeof html !== 'string'
@ -78,7 +81,7 @@ Object.assign(t, {
/** Adds soft hyphens every 10 characters to ensure the long words break before breaking the layout */
breakWord(text) {
return text.length <= 10 ? text :
text.replace(t.RX_WORD_BREAK, '$&\u00AD');
text.replace(RX_WORD_BREAK, '$&\u00AD');
},
createTemplate(node) {
@ -103,7 +106,7 @@ Object.assign(t, {
},
createHtml(str, trusted) {
const root = t.DOMParser.parseFromString(str, 'text/html').body;
const root = parser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
@ -122,7 +125,7 @@ Object.assign(t, {
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)) {
} 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);
@ -154,9 +157,8 @@ Object.assign(t, {
return '';
}
},
});
});
(() => {
const observer = new MutationObserver(process);
let observing = false;
Object.assign(t, {
@ -186,4 +188,6 @@ Object.assign(t, {
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';
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 PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
const options = {
@ -40,39 +40,27 @@ const metaParser = (() => {
},
};
const parser = createParser(options);
const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'}));
const looseParser = createParser(Object.assign({}, options, {
allowErrors: true,
unknownKey: 'throw',
}));
return {
parse,
lint,
nullifyInvalidVars,
};
function parse(text, indexOffset) {
try {
return parser.parse(text);
} catch (err) {
if (typeof err.index === 'number') {
err.index += indexOffset;
}
throw err;
}
}
lint: looseParser.parse,
parse: parser.parse,
function lint(text) {
return looseParser.parse(text);
}
function nullifyInvalidVars(vars) {
nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
if (va.value === null) {
continue;
}
if (va.value !== null) {
try {
parser.validateVar(va);
} catch (err) {
va.value = null;
}
}
return vars;
}
})();
return vars;
},
};
});

View File

@ -1,16 +1,20 @@
/* global parserlib */
/* exported parseMozFormat */
'use strict';
/**
define([
'/js/csslint/parserlib',
], parserlib => ({
/**
* Extracts @-moz-document blocks into sections and the code between them into global sections.
* Puts the global comments into the following section to minimize the amount of global sections.
* Doesn't move the comment with ==UserStyle== inside.
* @param {string} code
* @param {number} styleId - used to preserve parserCache on subsequent runs over the same style
* @param {Object} _
* @param {string} _.code
* @param {number} [_.styleId] - used to preserve parserCache on subsequent runs over the same style
* @returns {{sections: Array, errors: Array}}
* @property {?number} lastStyleId
*/
function parseMozFormat({code, styleId}) {
extractSections: function fn({code, styleId}) {
const CssToProperty = {
'url': 'urls',
'url-prefix': 'urlPrefixes',
@ -53,7 +57,7 @@ function parseMozFormat({code, styleId}) {
if (p0 && aType === 'regexps') {
const s = p0.text;
if (hasSingleEscapes.test(p0.text)) {
const isQuoted = (s.startsWith('"') || s.startsWith("'")) && s.endsWith(s[0]);
const isQuoted = /^['"]/.test(s) && s.endsWith(s[0]);
p0.value = isQuoted ? s.slice(1, -1) : s;
}
}
@ -83,7 +87,7 @@ function parseMozFormat({code, styleId}) {
try {
parser.parse(mozStyle, {
reuseCache: !parseMozFormat.styleId || styleId === parseMozFormat.styleId,
reuseCache: !fn.lastStyleId || styleId === fn.lastStyleId,
});
} catch (e) {
errors.push(e);
@ -94,8 +98,7 @@ function parseMozFormat({code, styleId}) {
}
err.message = `${err.line}:${err.col} ${err.message}`;
}
parseMozFormat.styleId = styleId;
return {sections, errors};
fn.lastStyleId = styleId;
function doAddSection(section) {
section.code = section.code.trim();
@ -138,4 +141,7 @@ function parseMozFormat({code, styleId}) {
}
return open ? text.slice(open) : text;
}
}
return {sections, errors};
},
}));

126
js/msg.js
View File

@ -1,8 +1,16 @@
/* global deepCopy getOwnTab URLS */ // not used in content scripts
'use strict';
// eslint-disable-next-line no-unused-expressions
window.INJECTED !== 1 && (() => {
/** 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 = '/js/msg';
define(require => {
const {
URLS,
deepCopy,
getOwnTab,
} = require('/js/toolbox'); // `require` does nothing in content scripts
const TARGETS = Object.assign(Object.create(null), {
all: ['both', 'tab', 'extension'],
extension: ['both', 'extension'],
@ -21,38 +29,12 @@ window.INJECTED !== 1 && (() => {
extension: new Set(),
};
let bg = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage();
const isBg = bg === window;
if (!isBg && (!bg || !bg.document || bg.document.readyState === 'loading')) {
bg = null;
}
// TODO: maybe move into polyfill.js and hook addListener to wrap/unwrap automatically
chrome.runtime.onMessage.addListener(onRuntimeMessage);
// TODO: maybe move into polyfill.js and hook addListener + sendMessage so they wrap/unwrap automatically
const wrapData = data => ({
data,
});
const wrapError = error => ({
error: Object.assign({
message: error.message || `${error}`,
stack: error.stack,
}, error), // passing custom properties e.g. `error.index`
});
const unwrapResponse = ({data, error} = {error: {message: ERR_NO_RECEIVER}}) =>
error
? Promise.reject(Object.assign(new Error(error.message), error))
: data;
chrome.runtime.onMessage.addListener(({data, target}, sender, sendResponse) => {
const res = window.msg._execute(TARGETS[target] || TARGETS.all, data, sender);
if (res instanceof Promise) {
res.then(wrapData, wrapError).then(sendResponse);
return true;
}
if (res !== undefined) sendResponse(wrapData(res));
});
const msg = /** @namespace msg */ {
// This direct assignment allows IDEs to provide autocomplete for msg methods automatically
const msg = window.msg = {
isBg,
isBg: getExtBg() === window,
async broadcast(data) {
const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
@ -73,8 +55,8 @@ window.INJECTED !== 1 && (() => {
},
isIgnorableError(err) {
const msg = `${err && err.message || err}`;
return msg.includes(ERR_NO_RECEIVER) || msg.includes(ERR_PORT_CLOSED);
const text = `${err && err.message || err}`;
return text.includes(ERR_NO_RECEIVER) || text.includes(ERR_PORT_CLOSED);
},
ignoreError(err) {
@ -113,6 +95,11 @@ window.INJECTED !== 1 && (() => {
_execute(types, ...args) {
let result;
if (!(args[0] instanceof Object)) {
/* Data from other windows must be deep-copied to allow for GC in Chrome and
merely survive in FF as it kills cross-window objects when their tab is closed. */
args = args.map(deepCopy);
}
for (const type of types) {
for (const fn of handler[type]) {
let res;
@ -130,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) {
const fn = () => {};
fn.PATH = [...PATH, name];
return new Proxy(fn, apiHandler);
},
async apply({PATH: path}, thisObj, args) {
if (!bg && chrome.tabs) {
bg = await browser.runtime.getBackgroundPage().catch(() => {});
}
const bg = getExtBg() ||
chrome.tabs && await browser.runtime.getBackgroundPage().catch(() => {});
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) {
return msg.send(message);
}
// in FF, the object would become a dead object when the window
// is closed, so we have to clone the object into background.
const res = bg.msg._execute(TARGETS.extension, bg.deepCopy(message), {
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,
});
return deepCopy(await res);
}));
}
return 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,9 +1,14 @@
'use strict';
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
if (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) {
/* Auto-promisifier with a fallback to direct call on signature error.
@ -61,11 +66,189 @@ self.INJECTED !== 1 && (() => {
}
//#endregion
//#region AMD loader for content scripts
if (!chrome.tabs) return;
if (isContentScript && typeof define !== 'function') {
/**
* WARNING!
* All deps needed to run the current define() must be already resolved.
*/
const modules = {};
const addJs = name =>
name.endsWith('.js') ? name : name + '.js';
const require = self.require = name =>
(name = addJs(name)) in modules ? modules[name] : {};
const define = self.define = fn => {
const name = addJs(define.currentModule || `${fn}`.slice(0, 1000));
if (!(name in modules)) modules[name] = fn(require);
define.currentModule = null;
};
}
//#region for our extension pages
//#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 {
@ -81,6 +264,21 @@ self.INJECTED !== 1 && (() => {
}
};
}
}
//#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';
// eslint-disable-next-line no-unused-expressions
window.INJECTED !== 1 && (() => {
/** 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 = '/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 clone = msg.isBg ? deepCopy : (val => JSON.parse(JSON.stringify(val)));
const defaults = /** @namespace Prefs */{
const clone = deepCopy || (val => JSON.parse(JSON.stringify(val)));
/** @type {PrefsValues} */
const defaults = /** @namespace PrefsValues */ {
'openEditInWindow': false, // new editor opens in a own browser window
'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
'windowPosition': {}, // detached window position
@ -117,9 +122,13 @@ window.INJECTED !== 1 && (() => {
any: new Set(),
specific: {},
};
let isReady;
// getPrefs may fail on browser startup in the active tab as it loads before the background script
const initializing = (msg.isBg ? readStorage() : API.getPrefs().catch(readStorage))
.then(setAll);
.then(data => {
setAll(data);
isReady = true;
});
chrome.storage.onChanged.addListener(async (changes, area) => {
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
const prefs = window.prefs = {
/** @namespace Prefs */
const prefs = {
STORAGE_KEY,
initializing,
defaults,
initializing,
get isReady() {
return isReady;
},
/** @type {PrefsValues} */
get values() {
return deepCopy(values);
},
get(key) {
return isKnown(key) && values[key];
},
set(key, val, isSynced) {
if (!isKnown(key)) return;
const oldValue = values[key];
@ -155,36 +172,45 @@ window.INJECTED !== 1 && (() => {
emitChange(key, val, isSynced);
}
},
reset(key) {
prefs.set(key, clone(defaults[key]));
},
/**
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
* @param {function(key:string, value:any)} fn
* @param {function(key:string?, value:any?)} fn
* @param {Object} [opts]
* @param {boolean} [opts.now] - when truthy, the listener is called immediately:
* @param {boolean} [opts.runNow] - when truthy, the listener is called immediately:
* 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
* 2) if `keys` is falsy, no key/value will be provided
*/
subscribe(keys, fn, {now} = {}) {
async subscribe(keys, fn, {runNow} = {}) {
const toRun = [];
if (keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) {
if (!isKnown(key)) continue;
const listeners = onChange.specific[key] ||
(onChange.specific[key] = new Set());
listeners.add(fn);
if (now) fn(key, values[key]);
if (runNow) toRun.push({fn, args: [key, values[key]]});
}
} else {
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) {
for (const [k, fn] of Object.entries(data)) {
prefs.subscribe(k, fn, opts);
}
},
unsubscribe(keys, fn) {
if (keys) {
for (const key of keys) {
@ -233,9 +259,8 @@ window.INJECTED !== 1 && (() => {
}
}
function readStorage() {
return browser.storage.sync.get(STORAGE_KEY)
.then(data => data[STORAGE_KEY]);
async function readStorage() {
return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
}
function updateStorage() {
@ -247,4 +272,11 @@ window.INJECTED !== 1 && (() => {
Object.keys(a).length === Object.keys(b).length &&
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';
const router = (() => {
define(require => {
const {msg} = require('/js/msg');
const {deepEqual} = require('/js/toolbox');
const buffer = [];
const watchers = [];
document.addEventListener('DOMContentLoaded', () => update());
window.addEventListener('popstate', () => update());
window.addEventListener('hashchange', () => update());
document.on('DOMContentLoaded', () => update());
window.on('popstate', () => update());
window.on('hashchange', () => update());
msg.on(e => {
if (e.method === 'pushState' && e.url !== location.href) {
history.pushState(history.state, null, e.url);
@ -15,29 +16,14 @@ const router = (() => {
return true;
}
});
return {watch, updateSearch, getSearch, updateHash};
function watch(options, callback) {
/* Watch search params or hash and get notified on change.
return {
options: {search?: Array<key: String>, hash?: String}
callback: (Array<value: String | null> | Boolean) => void
getSearch(key) {
return new URLSearchParams(location.search).get(key);
},
`hash` should always start with '#'.
When watching search params, callback receives a list of values.
When watching hash, callback receives a boolean.
*/
watchers.push({options, callback});
}
function updateSearch(key, value) {
const u = new URL(location);
u.searchParams[value ? 'set' : 'delete'](key, value);
history.replaceState(history.state, null, `${u}`);
update(true);
}
function updateHash(hash) {
updateHash(hash) {
/* hash: String
Send an empty string to remove the hash.
@ -54,11 +40,28 @@ const router = (() => {
}
history.pushState(history.state, null, hash);
update();
}
},
function getSearch(key) {
return new URLSearchParams(location.search).get(key);
}
updateSearch(key, value) {
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) {
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,41 +1,64 @@
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
'use strict';
function styleCodeEmpty(code) {
define(require => {
const exports = {
async calcStyleDigest(style) {
const src = style.usercssData
? style.sourceCode
: JSON.stringify(normalizeStyleSections(style));
const srcBytes = new TextEncoder().encode(src);
const res = await crypto.subtle.digest('SHA-1', srcBytes);
return Array.from(new Uint8Array(res), byte2hex).join('');
},
styleCodeEmpty(code) {
if (!code) {
return true;
}
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
}
}
return false;
}
},
/** Checks if section is global i.e. has no targets at all */
function styleSectionGlobal(section) {
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}
*/
function styleSectionsEqual({sections: a}, {sections: b}) {
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;
@ -43,14 +66,20 @@ function styleSectionsEqual({sections: a}, {sections: 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 normalizeStyleSections({sections}) {
function byte2hex(b) {
return (0x100 + b).toString(16).slice(1);
}
function normalizeStyleSections({sections}) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => /** @namespace StyleSection */({
code: section.code || '',
@ -59,32 +88,7 @@ function normalizeStyleSections({sections}) {
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';
}
return exports;
});

View File

@ -1,7 +1,10 @@
/* global loadScript tryJSONparse */
'use strict';
(() => {
define(require => {
const {tryJSONparse} = require('/js/toolbox');
let LZString;
/** @namespace StorageExtras */
const StorageExtras = {
async getValue(key) {
@ -29,11 +32,8 @@
return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value)));
},
async getLZString() {
if (!window.LZString) {
await loadScript('/vendor/lz-string-unsafe/lz-string-unsafe.min.js');
window.LZString = window.LZString || window.LZStringUnsafe;
}
return window.LZString;
return LZString ||
(LZString = await require(['/vendor/lz-string-unsafe/lz-string-unsafe.min']));
},
};
/** @namespace StorageExtrasSync */
@ -44,8 +44,14 @@
usercssTemplate: 'usercssTemplate',
},
};
/** @type {chrome.storage.StorageArea|StorageExtras} */
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras);
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */
window.chromeSync = Object.assign(browser.storage.sync, StorageExtras, StorageExtrasSync);
})();
/** @typedef {chrome.storage.StorageArea|StorageExtras} ChromeLocal */
/** @typedef {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} ChromeSync */
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,6 +1,68 @@
'use strict';
const workerUtil = {
if (typeof define !== 'function') {
const defines = {};
let currentPath = '/js/worker-util.js';
const require = defines.require = (url, fn) => {
const deps = [];
for (let u of Array.isArray(url) ? url : [url]) {
if (u !== 'require') {
if (!u.endsWith('.js')) u += '.js';
if (!u.startsWith('/')) u = new URL(u, location.origin + currentPath).pathname;
if (u && !defines.hasOwnProperty(u)) {
currentPath = u;
importScripts(u);
}
}
deps.push(defines[u]);
}
if (typeof fn === 'function') {
fn(...deps);
}
return deps[0];
};
self.define = (deps, fn) => {
if (typeof deps === 'function') {
defines[currentPath] = deps(require);
} else if (Array.isArray(deps)) {
const path = currentPath;
require(deps, (...res) => {
defines[path] = fn(...res);
});
}
};
}
define(require => {
let exports;
const GUEST = 'url';
const {cloneError} = exports = {
cloneError(err) {
return Object.assign({
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;
@ -8,18 +70,17 @@ const workerUtil = {
let timer;
const pendingResponse = new Map();
return new Proxy({}, {
get: (target, prop) =>
(...args) => {
if (!worker) {
init();
}
get(target, prop) {
return (...args) => {
if (!worker) init();
return invoke(prop, args);
};
},
});
function init() {
id = 0;
worker = new Worker(url);
worker = new Worker('/js/worker-util.js?' + new URLSearchParams({[GUEST]: url}));
worker.onmessage = onMessage;
}
@ -46,39 +107,12 @@ const workerUtil = {
});
}
},
createAPI(methods) {
self.onmessage = async ({data: {id, action, args}}) => {
let data, error;
try {
data = await methods[action](...args);
} catch (err) {
error = true;
data = workerUtil.cloneError(err);
}
self.postMessage({id, data, error});
};
},
cloneError(err) {
return Object.assign({
name: err.name,
stack: err.stack,
message: err.message,
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
fileName: err.fileName,
}, err);
},
loadScript(...urls) {
urls = urls.filter(u => !workerUtil._loadedScripts.has(u));
if (!urls.length) {
return;
if (self.WorkerGlobalScope) {
Promise.resolve().then(() =>
require(new URLSearchParams(location.search).get(GUEST)));
}
self.importScripts(...urls);
urls.forEach(u => workerUtil._loadedScripts.add(u));
},
_loadedScripts: new Set(),
};
return exports;
});

View File

@ -5,11 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title i18n-text="manageTitle"></title>
<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">
/* restrict to FF */
@ -162,30 +157,31 @@
</details>
</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/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/msg.js"></script>
<script src="js/toolbox.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="content/style-injector.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/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="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<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>
<link rel="stylesheet" href="options/onoffswitch.css">
<link rel="stylesheet" href="manage/manage.css">
</head>
<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;
}
}

304
manage/events.js Normal file
View File

@ -0,0 +1,304 @@
'use strict';
define(require => {
const {API} = require('/js/msg');
const {
debounce,
getOwnTab,
getStyleWithNoCode,
openURL,
sessionStore,
} = require('/js/toolbox');
const t = require('/js/localization');
const {
$,
$$,
animateElement,
messageBoxProxy,
scrollElementIntoView,
} = require('/js/dom');
const newUI = require('./new-ui');
const {
bulkChangeQueue,
$entry,
createStyleElement,
createStyleTargetsElement,
getFaviconImgSrc,
} = require('./render');
const sorter = require('./sorter');
const filters = require('./filters');
let updaterUI;
require(['./updater-ui'], res => (updaterUI = res));
const REVEAL_DATES_FOR = 'h2.style-name, [data-type=age]';
const Events = {
ENTRY_ROUTES: {
'input, .enable, .disable': 'toggle',
'.style-name': 'name',
'.homepage': 'external',
'.check-update': 'check',
'.update': 'update',
'.delete': 'delete',
'.applies-to .expander': 'expandTargets',
'.configure-usercss': 'config',
},
addEntryTitle(link) {
const style = link.closest('.entry').styleMeta;
const ucd = style.usercssData;
link.title =
`${t('dateInstalled')}: ${t.formatDate(style.installDate) || '—'}\n` +
`${t('dateUpdated')}: ${t.formatDate(style.updateDate) || '—'}\n` +
(ucd ? `UserCSS, v.${ucd.version}` : '');
},
check(event, entry) {
updaterUI.checkUpdate(entry, {single: true});
},
async config(event, {styleMeta}) {
(await require(['/js/dlg/config-dialog']))(styleMeta);
},
async delete(event, entry) {
const id = entry.styleId;
animateElement(entry);
const {button} = await messageBoxProxy.show({
title: t('deleteStyleConfirm'),
contents: entry.styleMeta.customName || entry.styleMeta.name,
className: 'danger center',
buttons: [t('confirmDelete'), t('confirmCancel')],
});
if (button === 0) {
API.styles.delete(id);
}
const deleteButton = $('#message-box-buttons > button');
if (deleteButton) deleteButton.removeAttribute('data-focused-via-click');
},
async edit(event, entry) {
if (event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const key = `${event.shiftKey ? 's' : ''}${event.ctrlKey ? 'c' : ''}${'LMR'[event.button]}`;
const url = $('[href]', entry).href;
const ownTab = await getOwnTab();
if (key === 'L') {
sessionStore['manageStylesHistory' + ownTab.id] = url;
location.href = url;
} else if (chrome.windows && key === 'sL') {
API.openEditor({id: entry.styleId});
} else {
openURL({
url,
index: ownTab.index + 1,
active: key === 'sM' || key === 'scL',
});
}
},
expandTargets(event, entry) {
if (!entry._allTargetsRendered) {
createStyleTargetsElement({entry, expanded: true});
setTimeout(getFaviconImgSrc, 0, entry);
}
this.closest('.applies-to').classList.toggle('expanded');
},
external(event) {
if (event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
// Shift-click = the built-in 'open in a new window' action
return;
}
getOwnTab().then(({index}) => {
openURL({
url: event.target.closest('a').href,
index: index + 1,
active: !event.ctrlKey || event.shiftKey,
});
});
},
entryClicked(event) {
const target = event.target;
const entry = target.closest('.entry');
for (const selector in Events.ENTRY_ROUTES) {
for (let el = target; el && el !== entry; el = el.parentElement) {
if (el.matches(selector)) {
const handler = Events.ENTRY_ROUTES[selector];
return Events[handler].call(el, event, entry);
}
}
}
},
lazyAddEntryTitle({type, target}) {
const cell = target.closest(REVEAL_DATES_FOR);
if (cell) {
const link = $('.style-name-link', cell) || cell;
if (type === 'mouseover' && !link.title) {
debounce(Events.addEntryTitle, 50, link);
} else {
debounce.unregister(Events.addEntryTitle);
}
}
},
name(event, entry) {
if (newUI.enabled) Events.edit(event, entry);
},
toggle(event, entry) {
API.styles.toggle(entry.styleId, this.matches('.enable') || this.checked);
},
update(event, entry) {
const json = entry.updatedCode;
json.id = entry.styleId;
(json.usercssData ? API.usercss : API.styles).install(json);
},
};
async function handleUpdateForId(id, opts) {
handleUpdate(await API.styles.get(id), opts);
bulkChangeQueue.time = performance.now();
}
function handleUpdate(style, {reason, method} = {}) {
if (reason === 'editPreview' || reason === 'editPreviewEnd') return;
let entry;
let oldEntry = $entry(style);
if (oldEntry && method === 'styleUpdated') {
handleToggledOrCodeOnly();
}
entry = entry || createStyleElement({style});
if (oldEntry) {
if (oldEntry.styleNameLowerCase === entry.styleNameLowerCase) {
$('#installed').replaceChild(entry, oldEntry);
} else {
oldEntry.remove();
}
}
if ((reason === 'update' || reason === 'install') && entry.matches('.updatable')) {
updaterUI.handleUpdateInstalled(entry, reason);
}
filters.filterAndAppend({entry}).then(sorter.update);
if (!entry.matches('.hidden') && reason !== 'import' && reason !== 'sync') {
animateElement(entry);
requestAnimationFrame(() => scrollElementIntoView(entry));
}
getFaviconImgSrc(entry);
function handleToggledOrCodeOnly() {
const newStyleMeta = getStyleWithNoCode(style);
const diff = objectDiff(oldEntry.styleMeta, newStyleMeta)
.filter(({key, path}) => path || (!key.startsWith('original') && !key.endsWith('Date')));
if (diff.length === 0) {
// only code was modified
entry = oldEntry;
oldEntry = null;
}
if (diff.length === 1 && diff[0].key === 'enabled') {
oldEntry.classList.toggle('enabled', style.enabled);
oldEntry.classList.toggle('disabled', !style.enabled);
$$('input', oldEntry).forEach(el => (el.checked = style.enabled));
oldEntry.styleMeta = newStyleMeta;
entry = oldEntry;
oldEntry = null;
}
}
}
function handleDelete(id) {
const node = $entry(id);
if (node) {
node.remove();
if (node.matches('.can-update')) {
const btnApply = $('#apply-all-updates');
btnApply.dataset.value = Number(btnApply.dataset.value) - 1;
}
filters.showStats();
}
}
function objectDiff(first, second, path = '') {
const diff = [];
for (const key in first) {
const a = first[key];
const b = second[key];
if (a === b) {
continue;
}
if (b === undefined) {
diff.push({path, key, values: [a], type: 'removed'});
continue;
}
if (a && typeof a.filter === 'function' && b && typeof b.filter === 'function') {
if (
a.length !== b.length ||
a.some((el, i) => {
const result = !el || typeof el !== 'object'
? el !== b[i]
: objectDiff(el, b[i], path + key + '[' + i + '].').length;
return result;
})
) {
diff.push({path, key, values: [a, b], type: 'changed'});
}
} else if (typeof a === 'object' && typeof b === 'object') {
diff.push(...objectDiff(a, b, path + key + '.'));
} else {
diff.push({path, key, values: [a, b], type: 'changed'});
}
}
for (const key in second) {
if (!(key in first)) {
diff.push({path, key, values: [second[key]], type: 'added'});
}
}
return diff;
}
return {
Events,
handleBulkChange() {
for (const msg of bulkChangeQueue) {
const {id} = msg.style;
if (msg.method === 'styleDeleted') {
handleDelete(id);
bulkChangeQueue.time = performance.now();
} else {
handleUpdateForId(id, msg);
}
}
bulkChangeQueue.length = 0;
},
handleVisibilityChange() {
switch (document.visibilityState) {
// page restored without reloading via history navigation (currently only in FF)
// the catch here is that DOM may be outdated so we'll at least refresh the just edited style
// assuming other changes aren't important enough to justify making a complicated DOM sync
case 'visible': {
const id = sessionStore.justEditedStyleId;
if (id) {
handleUpdateForId(Number(id), {method: 'styleUpdated'});
delete sessionStore.justEditedStyleId;
}
break;
}
// going away
case 'hidden':
history.replaceState({scrollY: window.scrollY}, document.title);
break;
}
},
};
});

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