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

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

View File

@ -0,0 +1,154 @@
'use strict';
/* Populates API */
define(require => {
const {
URLS,
activateTab,
findExistingTab,
getActiveTab,
isTabReplaceable,
openURL,
} = require('/js/toolbox');
const {API, msg} = require('/js/msg');
const {createWorker} = require('/js/worker-util');
const prefs = require('/js/prefs');
Object.assign(API, ...[
require('./icon-manager'),
require('./openusercss-api'),
require('./search-db'),
], /** @namespace API */ {
browserCommands: {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
reload: () => chrome.runtime.reload(),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
},
/** @type {StyleManager} */
styles: require('./style-manager'),
/** @type {Sync} */
sync: require('./sync'),
/** @type {StyleUpdater} */
updater: require('./update'),
/** @type {UsercssHelper} */
usercss: Object.assign({},
require('./usercss-api-helper'),
require('./usercss-install-helper')),
/** @type {BackgroundWorker} */
worker: createWorker({
url: '/background/background-worker.js',
}),
/** @returns {string} */
getTabUrlPrefix() {
const {url} = this.sender.tab;
if (url.startsWith(URLS.ownOrigin)) {
return 'stylus';
}
return url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
/** @returns {PrefsValues} */
getPrefs: () => prefs.values,
setPref(key, value) {
prefs.set(key, value);
},
/**
* Opens the editor or activates an existing tab
* @param {{
id?: number
domain?: string
'url-prefix'?: string
}} params
* @returns {Promise<chrome.tabs.Tab>}
*/
openEditor(params) {
const u = new URL(chrome.runtime.getURL('edit.html'));
u.search = new URLSearchParams(params);
return openURL({
url: `${u}`,
currentWindow: null,
newWindow: prefs.get('openEditInWindow') && Object.assign({},
prefs.get('openEditInWindow.popup') && {type: 'popup'},
prefs.get('windowPosition')),
});
},
/** @returns {Promise<chrome.tabs.Tab>} */
async openManage({options = false, search, searchMode} = {}) {
let url = chrome.runtime.getURL('manage.html');
if (search) {
url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`;
}
if (options) {
url += '#stylus-options';
}
let tab = await findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true,
});
if (tab) {
await activateTab(tab);
if (url !== (tab.pendingUrl || tab.url)) {
await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error);
}
return tab;
}
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window
},
/**
* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent
* when the tab is ready, which is needed in the popup, otherwise another
* extension could force the tab to open in foreground thus auto-closing the
* popup (in Chrome at least) and preventing the sendMessage code from running
* @returns {Promise<chrome.tabs.Tab>}
*/
async openURL(opts) {
const tab = await openURL(opts);
if (opts.message) {
await onTabReady(tab);
await msg.sendTab(tab.id, opts.message);
}
return tab;
function onTabReady(tab) {
return new Promise((resolve, reject) =>
setTimeout(function ping(numTries = 10, delay = 100) {
msg.sendTab(tab.id, {method: 'ping'})
.catch(() => false)
.then(pong => pong
? resolve(tab)
: numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) ||
reject('timeout'));
}));
}
},
});
msg.on((msg, sender) => {
if (msg.method === 'invokeAPI') {
const fn = msg.path.reduce((res, name) => res && res[name], API);
if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`);
const res = typeof fn === 'function'
? fn.apply({msg, sender}, msg.args)
: fn;
return res === undefined ? null : res;
}
});
});

View File

@ -1,84 +1,44 @@
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
'use strict';
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);
},
compileUsercss,
parseUsercssMeta(text, indexOffset = 0) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.parse(text, indexOffset);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.nullifyInvalidVars(vars);
},
});
let BUILDERS;
const bgw = /** @namespace BackgroundWorker */ {
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};
});
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;
},
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;
}, {});
}
parseMozFormat(...args) {
return require('/js/moz-parser').extractSections(...args);
},
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];
}
}
parseUsercssMeta(text) {
return require('/js/meta-parser').parse(text);
},
function getUsercssCompiler(preprocessor) {
const BUILDER = {
default: {
nullifyInvalidVars(vars) {
return require('/js/meta-parser').nullifyInvalidVars(vars);
},
};
createAPI(bgw);
function createBuilders() {
BUILDERS = Object.assign(Object.create(null));
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');
}
return BUILDER[preprocessor];
};
}
return BUILDER.default;
}
function getVarValue(va, prop) {
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
// TODO: handle customized image
return va.options.find(o => o.name === va[prop]).value;
}
if ((va.type === 'number' || va.type === 'range') && va.units) {
return va[prop] + va.units;
}
return va[prop];
}
function simplifyVars(vars) {
if (!vars) {
return {};
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
return Object.keys(vars).reduce((output, key) => {
const va = vars[key];
output[key] = Object.assign({}, va, {
value: va.value === null || va.value === undefined ?
getVarValue(va, 'default') : getVarValue(va, 'value'),
});
return output;
}, {});
}
return bgw;
});

View File

@ -1,178 +1,39 @@
/* global
activateTab
API
chromeLocal
findExistingTab
FIREFOX
getActiveTab
isTabReplaceable
msg
openURL
prefs
semverCompare
URLS
workerUtil
*/
'use strict';
//#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')),
});
},
/** @returns {Promise<chrome.tabs.Tab>} */
async openManage({options = false, search, searchMode} = {}) {
let url = chrome.runtime.getURL('manage.html');
if (search) {
url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`;
}
if (options) {
url += '#stylus-options';
}
let tab = await findExistingTab({
url,
currentWindow: null,
ignoreHash: true,
ignoreSearch: true,
});
if (tab) {
await activateTab(tab);
if (url !== (tab.pendingUrl || tab.url)) {
await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error);
}
return tab;
}
tab = await getActiveTab();
return isTabReplaceable(tab, url)
? activateTab(tab, {url})
: browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window
},
/**
* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent
* when the tab is ready, which is needed in the popup, otherwise another
* extension could force the tab to open in foreground thus auto-closing the
* popup (in Chrome at least) and preventing the sendMessage code from running
* @returns {Promise<chrome.tabs.Tab>}
*/
async openURL(opts) {
const tab = await openURL(opts);
if (opts.message) {
await onTabReady(tab);
await msg.sendTab(tab.id, opts.message);
}
return tab;
function onTabReady(tab) {
return new Promise((resolve, reject) =>
setTimeout(function ping(numTries = 10, delay = 100) {
msg.sendTab(tab.id, {method: 'ping'})
.catch(() => false)
.then(pong => pong
? resolve(tab)
: numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) ||
reject('timeout'));
}));
}
},
});
//#endregion
//#region browserCommands
const browserCommands = {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
reload: () => chrome.runtime.reload(),
};
if (chrome.commands) {
chrome.commands.onCommand.addListener(command => browserCommands[command]());
}
if (FIREFOX && browser.commands && browser.commands.update) {
// register hotkeys in FF
const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, (name, value) => {
try {
name = name.split('.')[1];
if (value.trim()) {
browser.commands.update({name, shortcut: value});
} else {
browser.commands.reset(name);
}
} catch (e) {}
// 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'});
});
}
//#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;
if (chrome.commands) {
chrome.commands.onCommand.addListener(id => API.browserCommands[id]());
}
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason !== 'update') return;
const [a, b, c] = (previousVersion || '').split('.');
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
require(['./remove-unused-storage']);
}
});
});
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason !== 'update') return;
if (semverCompare(previousVersion, '1.5.13') <= 0) {
// Removing unused stuff
// TODO: delete this entire block by the middle of 2021
try {
localStorage.clear();
} catch (e) {}
setTimeout(async () => {
const del = Object.keys(await chromeLocal.get())
.filter(key => key.startsWith('usoSearchCache'));
if (del.length) chromeLocal.remove(del);
}, 15e3);
}
});
msg.broadcast({method: 'backgroundReady'});
//#endregion

View File

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

View File

@ -1,25 +1,24 @@
/* global
FIREFOX
ignoreChromeError
msg
URLS
*/
'use strict';
/*
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;
// expand * as .*?
const wildcardAsRegExp = (s, flags) => new RegExp(
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
.replace(/\*/g, '.*?'), flags);
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
.replace(/\*/g, '.*?'), flags);
for (const cs of SCRIPTS) {
cs.matches = cs.matches.map(m => (
m === ALL_URLS ? m : wildcardAsRegExp(m)
@ -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,56 +1,59 @@
/* 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++;
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]);
}
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++;
}
return output;
}),
data[PREFIX + item.id] = item;
}
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 => {
INC = 1;
for (const key in result) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
INC = id + 1;
}
async function prepareInc() {
INC = 1;
for (const key in await chromeLocal.get()) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
INC = id + 1;
}
}
});
}
}
}
return function dbExecChromeStorage(method, ...args) {
return METHODS[method](...args);
};
});

View File

@ -1,25 +1,25 @@
/* global chromeLocal workerUtil createChromeStorageDB */
/* exported db */
/*
Initialize a database. There are some problems using IndexedDB in Firefox:
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;
function safeCall(method, data) {
const {browserAction = {}} = chrome;
const fn = browserAction[method];
if (fn) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
fn.call(browserAction, data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
fn.call(browserAction, data);
}
const pending = [];
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
pending.push(loadImage(url)
.then(imageData => {
data.imageData[key] = imageData;
}));
}
Promise.all(pending).then(() => {
delete data.path;
chrome.browserAction.setIcon(data, ignoreChromeError);
});
});
}
function setBadgeText(data) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
chrome.browserAction.setBadgeText(data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
chrome.browserAction.setBadgeText(data);
}
}
function extendNative(target) {
return new Proxy(target, {
get: (target, prop) => {
// FIXME: do we really need this?
if (!chrome.browserAction ||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
return () => {};
}
if (target[prop]) {
return target[prop];
}
return chrome.browserAction[prop].bind(chrome.browserAction);
},
});
}
})();
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,74 +1,69 @@
/* 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;
function createModes() {
return Object.assign(Object.create(null), {
code: (style, test) =>
style.usercssData
? test(stripMeta(style))
: searchSections(style, test, 'code'),
const MODES = Object.assign(Object.create(null), {
code: (style, test) =>
style.usercssData
? test(stripMeta(style))
: searchSections(style, test, 'code'),
meta: (style, test, part) =>
METAKEYS.some(key => test(style[key])) ||
meta: (style, test, part) =>
METAKEYS.some(key => test(style[key])) ||
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
searchSections(style, test, 'funcs'),
name: (style, test) =>
test(style.customName) ||
test(style.name),
name: (style, test) =>
test(style.customName) ||
test(style.name),
all: (style, test) =>
MODES.meta(style, test, 'all') ||
!style.usercssData && MODES.code(style, test),
});
all: (style, test) =>
MODES.meta(style, test, 'all') ||
!style.usercssData && MODES.code(style, test),
});
}
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API.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,113 +1,125 @@
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
/* exported tokenManager */
'use strict';
const tokenManager = (() => {
const AUTH = {
dropbox: {
flow: 'token',
clientId: 'zg52vphuapvpng9',
authURL: 'https://www.dropbox.com/oauth2/authorize',
tokenURL: 'https://api.dropboxapi.com/oauth2/token',
revoke: token =>
fetch('https://api.dropboxapi.com/2/auth/token/revoke', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
}),
},
google: {
flow: 'code',
clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com',
clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf',
authURL: 'https://accounts.google.com/o/oauth2/v2/auth',
authQuery: {
// NOTE: Google needs 'prompt' parameter to deliver multiple refresh
// tokens for multiple machines.
// https://stackoverflow.com/q/18519185
access_type: 'offline',
prompt: 'consent',
},
tokenURL: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
revoke: token => {
const params = {token};
return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
},
},
onedrive: {
flow: 'code',
clientId: '3864ce03-867c-4ad8-9856-371a097d47b1',
clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w',
authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
redirect_uri: FIREFOX ?
'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' :
'https://' + location.hostname + '.chromiumapp.org/',
scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
},
};
define(require => {
const {FIREFOX} = require('/js/toolbox');
const {chromeLocal} = require('/js/storage-util');
const AUTH = createAuth();
const NETWORK_LATENCY = 30; // seconds
return {getToken, revokeToken, getClientId, buildKeys};
let exports;
const {
function getClientId(name) {
return AUTH[name].clientId;
}
buildKeys,
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;
}
} = exports = {
function getToken(name, interactive) {
const k = buildKeys(name);
return chromeLocal.get(k.LIST)
.then(obj => {
if (!obj[k.TOKEN]) {
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);
}
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);
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);
}
} catch (e) {
console.error(e);
}
}
await chromeLocal.remove(k.LIST);
await chromeLocal.remove(k.LIST);
},
};
function createAuth() {
return {
dropbox: {
flow: 'token',
clientId: 'zg52vphuapvpng9',
authURL: 'https://www.dropbox.com/oauth2/authorize',
tokenURL: 'https://api.dropboxapi.com/oauth2/token',
revoke: token =>
fetch('https://api.dropboxapi.com/2/auth/token/revoke', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
}),
},
google: {
flow: 'code',
clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com',
clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf',
authURL: 'https://accounts.google.com/o/oauth2/v2/auth',
authQuery: {
// NOTE: Google needs 'prompt' parameter to deliver multiple refresh
// tokens for multiple machines.
// https://stackoverflow.com/q/18519185
access_type: 'offline',
prompt: 'consent',
},
tokenURL: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
revoke: token => {
const params = {token};
return postQuery(
`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
},
},
onedrive: {
flow: 'code',
clientId: '3864ce03-867c-4ad8-9856-371a097d47b1',
clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w',
authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
redirect_uri: FIREFOX ?
'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' :
'https://' + location.hostname + '.chromiumapp.org/',
scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
},
};
}
function refreshToken(name, k, obj) {
async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) {
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 => {
if (!result.refresh_token) {
// reuse old refresh token
result.refresh_token = obj[k.REFRESH];
}
return handleTokenResult(result, k);
});
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,52 +157,54 @@ 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)
);
if (params.get('state') !== state) {
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
}
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params.entries()) {
obj[key] = value;
}
return obj;
}
const code = params.get('code');
const body = {
code,
grant_type: 'authorization_code',
client_id: provider.clientId,
redirect_uri: query.redirect_uri,
};
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body);
})
.then(result => handleTokenResult(result, k));
});
const params = new URLSearchParams(
provider.flow === 'token' ?
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}`);
}
let result;
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params) {
obj[key] = value;
}
result = obj;
} else {
const code = params.get('code');
const body = {
code,
grant_type: 'authorization_code',
client_id: provider.clientId,
redirect_uri: query.redirect_uri,
};
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
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 => {
if (r.ok) {
return r.json();
}
return r.text()
.then(body => {
const err = new Error(`failed to fetch (${r.status}): ${body}`);
err.code = r.status;
throw err;
});
});
const r = await fetch(url, options);
if (r.ok) {
return r.json();
}
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,173 +41,174 @@
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({
save = true,
ignoreDigest,
observe,
} = {}) {
resetInterval();
checkingAll = true;
const port = observe && chrome.runtime.connect({name: 'updater'});
const styles = (await API.styles.getAll())
.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
await Promise.all(
styles.map(style =>
checkStyle({style, port, save, ignoreDigest})));
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
}
/** @type {StyleUpdater} */
const updater = /** @namespace StyleUpdater */ {
/**
* @param {{
id?: number
style?: StyleObj
port?: chrome.runtime.Port
save?: boolean = true
ignoreDigest?: boolean
}} opts
* @returns {{
style: StyleObj
updated?: boolean
error?: any
STATES: UpdaterStates
}}
Original style digests are calculated in these cases:
* style is installed or updated from server
* non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
Update check proceeds in these cases:
* style has the original digest and it's equal to the current digest
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
* [ignoreDigest: none/false] style doesn't yet have the original digest
so we compare the code to the server code and if it's the same we save the digest,
otherwise we skip the style and report MAYBE_EDITED status
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
async function checkStyle(opts) {
const {
id,
style = await API.styles.get(id),
async checkAllStyles({
save = true,
ignoreDigest,
port,
save,
} = opts;
const ucd = style.usercssData;
let res, state;
try {
await checkIfEdited();
res = {
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
updated: true,
};
state = STATES.UPDATED;
} catch (err) {
const error = err === 0 && STATES.UNREACHABLE ||
err && err.message ||
err;
res = {error, style, STATES};
state = `${STATES.SKIPPED} (${error})`;
}
log(`${state} #${style.id} ${style.customName || style.name}`);
if (port) port.postMessage(res);
return res;
observe,
} = {}) {
resetInterval();
checkingAll = true;
const port = observe && chrome.runtime.connect({name: 'updater'});
const styles = (await API.styles.getAll())
.filter(style => style.updateUrl);
if (port) port.postMessage({count: styles.length});
log('');
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
await Promise.all(
styles.map(style =>
updater.checkStyle({style, port, save, ignoreDigest})));
if (port) port.postMessage({done: true});
if (port) port.disconnect();
log('');
checkingAll = false;
},
async function checkIfEdited() {
if (!ignoreDigest &&
style.originalDigest &&
style.originalDigest !== await calcStyleDigest(style)) {
return Promise.reject(STATES.EDITED);
}
}
/**
* @param {{
id?: number
style?: StyleObj
port?: chrome.runtime.Port
save?: boolean = true
ignoreDigest?: boolean
}} opts
* @returns {{
style: StyleObj
updated?: boolean
error?: any
STATES: UpdaterStates
}}
async function updateUSO() {
const md5 = await tryDownload(style.md5Url);
if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5);
}
const json = await tryDownload(style.updateUrl, {responseType: 'json'});
if (!styleJSONseemsValid(json)) {
return Promise.reject(STATES.ERROR_JSON);
}
// USO may not provide a correctly updated originalMd5 (#555)
json.originalMd5 = md5;
return json;
}
Original style digests are calculated in these cases:
* style is installed or updated from server
* non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
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 delta = semverCompare(json.usercssData.version, ucd.version);
if (!delta && !ignoreDigest) {
// re-install is invalid in a soft upgrade
const sameCode = text === style.sourceCode;
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
}
if (delta < 0) {
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return usercss.buildCode(json);
}
Update check proceeds in these cases:
* style has the original digest and it's equal to the current digest
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
* [ignoreDigest: none/false] style doesn't yet have the original digest
so we compare the code to the server code and if it's the same we save the digest,
otherwise we skip the style and report MAYBE_EDITED status
async function maybeSave(json) {
json.id = style.id;
json.updateDate = Date.now();
// keep current state
delete json.customName;
delete json.enabled;
const newStyle = Object.assign({}, style, json);
// update digest even if save === false as there might be just a space added etc.
if (!ucd && styleSectionsEqual(json, style)) {
style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
return Promise.reject(STATES.SAME_CODE);
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
async checkStyle(opts) {
const {
id,
style = await API.styles.get(id),
ignoreDigest,
port,
save,
} = opts;
const ucd = style.usercssData;
let res, state;
try {
await checkIfEdited();
res = {
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
updated: true,
};
state = STATES.UPDATED;
} catch (err) {
const error = err === 0 && STATES.UNREACHABLE ||
err && err.message ||
err;
res = {error, style, STATES};
state = `${STATES.SKIPPED} (${error})`;
}
if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.MAYBE_EDITED);
}
return !save ? newStyle :
(ucd ? API.usercss : API.styles).install(newStyle);
}
log(`${state} #${style.id} ${style.customName || style.name}`);
if (port) port.postMessage(res);
return res;
async function tryDownload(url, params) {
let {retryDelay = 1000} = opts;
while (true) {
try {
return await download(url, params);
} catch (code) {
if (!RETRY_ERRORS.includes(code) ||
retryDelay > MIN_INTERVAL_MS) {
return Promise.reject(code);
}
async function checkIfEdited() {
if (!ignoreDigest &&
style.originalDigest &&
style.originalDigest !== await calcStyleDigest(style)) {
return Promise.reject(STATES.EDITED);
}
retryDelay *= 1.25;
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
async function updateUSO() {
const md5 = await tryDownload(style.md5Url);
if (!md5 || md5.length !== 32) {
return Promise.reject(STATES.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.SAME_MD5);
}
const json = await tryDownload(style.updateUrl, {responseType: 'json'});
if (!styleJSONseemsValid(json)) {
return Promise.reject(STATES.ERROR_JSON);
}
// USO may not provide a correctly updated originalMd5 (#555)
json.originalMd5 = md5;
return json;
}
async function updateUsercss() {
// TODO: when sourceCode is > 100kB use http range request(s) for version check
const text = await tryDownload(style.updateUrl);
const json = await API.usercss.buildMeta({sourceCode: text});
await require(['js!/vendor/semver-bundle/semver']); /* global semverCompare */
const delta = semverCompare(json.usercssData.version, ucd.version);
if (!delta && !ignoreDigest) {
// re-install is invalid in a soft upgrade
const sameCode = text === style.sourceCode;
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
}
if (delta < 0) {
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return API.usercss.buildCode(json);
}
async function maybeSave(json) {
json.id = style.id;
json.updateDate = Date.now();
// keep current state
delete json.customName;
delete json.enabled;
const newStyle = Object.assign({}, style, json);
// update digest even if save === false as there might be just a space added etc.
if (!ucd && styleSectionsEqual(json, style)) {
style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
return Promise.reject(STATES.SAME_CODE);
}
if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(STATES.MAYBE_EDITED);
}
return !save ? newStyle :
(ucd ? API.usercss.install : API.styles.install)(newStyle);
}
async function tryDownload(url, params) {
let {retryDelay = 1000} = opts;
while (true) {
try {
return await download(url, params);
} catch (code) {
if (!RETRY_ERRORS.includes(code) ||
retryDelay > MIN_INTERVAL_MS) {
return Promise.reject(code);
}
}
retryDelay *= 1.25;
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
},
getStates: () => STATES,
};
function schedule() {
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,81 +1,161 @@
/* global
API
deepCopy
usercss
*/
'use strict';
API.usercss = {
define(require => {
const {API} = require('/js/msg');
const {deepCopy, download} = require('/js/toolbox');
async build({
styleId,
sourceCode,
vars,
checkDup,
metaOnly,
assignVars,
}) {
let style = await usercss.buildMeta(sourceCode);
const dup = (checkDup || assignVars) &&
await API.usercss.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup);
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);
}
style = await usercss.buildCode(style);
}
return {style, dup};
},
},
async buildMeta(style) {
if (style.usercssData) {
async build({
styleId,
sourceCode,
vars,
checkDup,
metaOnly,
assignVars,
initialUrl,
}) {
// downloading here while install-usercss page is loading to avoid the wait
if (initialUrl) sourceCode = await download(initialUrl);
const style = await usercss.buildMeta({sourceCode});
const dup = (checkDup || assignVars) &&
await usercss.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
await usercss.buildCode(style);
}
return {style, dup};
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(usercss.rxMETA);
const i = match.index;
const j = i + match[0].length;
const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j);
const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
const recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
}
style.sections = sections;
return style;
}
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return Object.assign(await usercss.buildMeta(sourceCode), style);
},
},
async configVars(id, vars) {
let style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
style = await usercss.buildCode(style);
style = await API.styles.install(style, 'config');
return style.usercssData.vars;
},
async editSave(style) {
return API.styles.editSave(await API.usercss.parse(style));
},
async find(styleOrData) {
if (styleOrData.id) {
return API.styles.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of await API.styles.getAll()) {
const data = dup.usercssData;
if (data &&
data.name === name &&
data.namespace === namespace) {
return dup;
async buildMeta(style) {
if (style.usercssData) {
return style;
}
}
},
// remember normalized sourceCode
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
style = Object.assign({
enabled: true,
sections: [],
}, style);
const match = code.match(usercss.rxMETA);
if (!match) {
return Promise.reject(new Error('Could not find metadata.'));
}
try {
code = blankOut(code, 0, match.index) + match[0];
const {metadata} = await API.worker.parseUsercssMeta(code);
style.usercssData = metadata;
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
for (const [key, value] of Object.entries(GLOBAL_METAS)) {
if (metadata[key] !== undefined) {
style[value || key] = metadata[key];
}
}
return style;
} catch (err) {
if (err.code) {
const args = ERR_ARGS_IS_LIST.includes(err.code)
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
: err.args;
const msg = chrome.i18n.getMessage(`meta_${err.code}`, args);
if (msg) err.message = msg;
}
return Promise.reject(err);
}
},
async install(style) {
return API.styles.install(await API.usercss.parse(style));
},
async configVars(id, vars) {
let style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
await usercss.buildCode(style);
style = await API.styles.install(style, 'config');
return style.usercssData.vars;
},
async parse(style) {
style = await API.usercss.buildMeta(style);
// preserve style.vars during update
const dup = await API.usercss.find(style);
if (dup) {
style.id = dup.id;
await usercss.assignVars(style, dup);
}
return usercss.buildCode(style);
},
};
async editSave(style) {
return API.styles.editSave(await usercss.parse(style));
},
async find(styleOrData) {
if (styleOrData.id) {
return API.styles.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of await API.styles.getAll()) {
const data = dup.usercssData;
if (data &&
data.name === name &&
data.namespace === namespace) {
return dup;
}
}
},
async install(style) {
return API.styles.install(await usercss.parse(style));
},
async parse(style) {
style = await usercss.buildMeta(style);
// preserve style.vars during update
const dup = await usercss.find(style);
if (dup) {
style.id = dup.id;
await usercss.assignVars(style, dup);
}
return usercss.buildCode(style);
},
};
/** Replaces everything with spaces to keep the original length,
* but preserves the line breaks to keep the original line/col relation */
function blankOut(str, start = 0, end = str.length) {
return str.slice(start, end).replace(/[^\r\n]/g, ' ');
}
return usercss;
});

View File

@ -1,36 +1,24 @@
/* global
API
download
openURL
tabManager
URLS
*/
'use strict';
(() => {
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 => {
// 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;
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) {
if (e.origin === location.origin &&
e.data &&
e.data.name &&
e.data.type === 'style-version-query') {
removeEventListener('message', onMessage);
const style = await API.usercss.find(e.data) || {};
const {version} = style.usercssData || {};
postMessage({type: 'style-version', version}, '*');
}
});
}
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(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
})
).then(_emitUpdate);
const value = !styles.length
? []
: await docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
});
_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,58 +1,41 @@
/* 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) => {
const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') {
delete extraKeys[key];
break;
}
}
if (value) {
extraKeys[value] = 'beautify';
}
});
/**
* @param {HTMLElement} btn - the button element shown in the UI
* @param {function():CodeMirror[]} getScope
*/
function initBeautifyButton(btn, getScope) {
btn.addEventListener('click', () => beautify(getScope()));
btn.addEventListener('contextmenu', e => {
e.preventDefault();
beautify(getScope(), false);
});
}
/**
* @param {CodeMirror[]} scope
* @param {?boolean} ui
*/
function beautify(scope, ui = true) {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
window.css_beautify = window.exports.css_beautify;
prefs.subscribe([HOTKEY_ID], (key, value) => {
const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') {
delete extraKeys[key];
break;
}
})
.then(doBeautify);
}
if (value) {
extraKeys[value] = 'beautify';
}
}, {runNow: true});
function doBeautify() {
/**
* @name beautify
* @param {CodeMirror[]} scope
* @param {boolean} [ui=true]
*/
async function beautify(scope, ui = true) {
const tabs = prefs.get('editor.indentWithTabs');
const 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,53 +43,55 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
const KM = CodeMirror.keyMap;
const extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) {
KM.sublime['Ctrl-G'] = 'jumpToLine';
KM.emacsy['Ctrl-G'] = 'jumpToLine';
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
KM.macDefault['Cmd-J'] = 'jumpToLine';
}
if (!extras.includes('autocomplete')) {
// will be used by 'sublime' on PC via fallthrough
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else
KM.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap
KM.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys
}
if (!extras.includes('blockComment')) {
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
}
if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
// Note: modifier order in CodeMirror is S-C-A
for (const char of ['N', 'T', 'W']) {
for (const remap of [
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
]) {
const oldKey = remap.from + char;
for (const km of Object.values(KM)) {
const command = km[oldKey];
if (!command) continue;
for (const newMod of remap.to) {
const newKey = newMod + char;
if (newKey in km) continue;
km[newKey] = command;
delete km[oldKey];
break;
require(Object.values(editor.lazyKeymaps || {}), () => {
const KM = CodeMirror.keyMap;
const extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) {
KM.sublime['Ctrl-G'] = 'jumpToLine';
KM.emacsy['Ctrl-G'] = 'jumpToLine';
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
KM.macDefault['Cmd-J'] = 'jumpToLine';
}
if (!extras.includes('autocomplete')) {
// will be used by 'sublime' on PC via fallthrough
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else
KM.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap
KM.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys
}
if (!extras.includes('blockComment')) {
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
}
if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
// Note: modifier order in CodeMirror is S-C-A
for (const char of ['N', 'T', 'W']) {
for (const remap of [
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
]) {
const oldKey = remap.from + char;
for (const km of Object.values(KM)) {
const command = km[oldKey];
if (!command) continue;
for (const newMod of remap.to) {
const newKey = newMod + char;
if (newKey in km) continue;
km[newKey] = command;
delete km[oldKey];
break;
}
}
}
}
}
}
});
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 bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
}
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();
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
initNameArea();
initBeautifyButton($('#beautify'), () => editor.getEditors());
initResizeListener();
detectLayout();
lazyInit();
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !style.id);
const toc = [];
const elToc = $('#toc');
elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target));
if (editor.isUsercss) {
SourceEditor();
} else {
SectionsEditor();
}
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true});
dirty.onChange(updateDirty);
(async function init() {
await preinit;
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
initNameArea();
initBeautifyButton($('#beautify'));
initResizeListener();
detectLayout(true);
await editor.ready;
editor.ready = true;
$('#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));
// enabling after init to prevent flash of validation failure on an empty name
$('#name').required = !editor.isUsercss;
$('#save-button').onclick = editor.save;
(editor.isUsercss ? require('./source-editor') : require('./sections-editor'))();
await editor.ready;
editor.ready = true;
editor.dirty.onChange(editor.updateDirty);
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);
}
// enabling after init to prevent flash of validation failure on an empty name
$('#name').required = !editor.isUsercss;
$('#save-button').onclick = editor.save;
function initEmptyStyle(params) {
return {
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
enabled: true,
sections: [
DocFuncMapper.toSection([...params], {code: ''}),
],
};
}
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();
});
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,424 +204,241 @@ 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;
/* 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) {
detectWindowedState();
chrome.tabs.onAttached.addListener(onAttached);
}
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;
async function patchHistoryBack(tab) {
ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
await onDOMready();
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
}
}
while (toc.length > sections.length) {
elToc.lastElementChild.remove();
toc.length--;
async function detectWindowedState() {
isSimpleWindow =
(await browser.windows.getCurrent()).type === 'popup';
isWindowed = isSimpleWindow || (
prefs.get('openEditInWindow') &&
history.length === 1 &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
if (isSimpleWindow) {
await onDOMready();
initPopupButton();
}
}
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);
function initPopupButton() {
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
const onIconsetChanged = (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
};
prefs.subscribe('iconset', onIconsetChanged, {runNow: true});
document.body.appendChild(btn);
window.on('keydown', e => getEventKeyName(e) === POPUP_HOTKEY && embedPopup());
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup'; // adds to keymap help
}
async function onAttached(tabId, info) {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
const win = await browser.windows.get(info.newWindowId, {populate: true});
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
// FF-only because Chrome retardedly resets the size during dragging
if (openEditInWindow && FIREFOX) {
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
}
}
})();
/* Stuff not needed for the main init so we can let it run at its own tempo */
function 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);
function onRuntimeMessage(request) {
const {style} = request;
switch (request.method) {
case 'styleUpdated':
if (editor.style.id === style.id &&
!['editPreview', 'editPreviewEnd', 'editSave', 'config'].includes(request.reason)) {
Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id))
.then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated));
}
break;
case 'styleDeleted':
if (editor.style.id === style.id) {
closeCurrentTab();
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
}
}
async function patchHistoryBack(tab) {
ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
await onDOMready();
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
function beforeUnload(e) {
sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition'));
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
scrollY: window.scrollY,
cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
focus: cm.hasFocus(),
height: cm.display.wrapper.style.height.replace('100vh', ''),
parentHeight: cm.display.wrapper.parentElement.offsetHeight,
sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex],
})),
});
const activeElement = document.activeElement;
if (activeElement) {
// blurring triggers 'change' or 'input' event if needed
activeElement.blur();
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
if (editor && editor.dirty.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
e.returnValue = t('styleChangesNotSaved');
}
}
function canSaveWindowPos() {
return isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized();
}
function saveWindowPos() {
if (canSaveWindowPos()) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
if (window.scrollY >= scrollPoint && !headerFixed) {
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
$('body').classList.add('fixed-header');
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
function detectLayout(now) {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
if (now) fixedHeader();
else debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
document.body.classList.remove('compact-layout', 'fixed-header');
window.off('scroll', fixedHeader);
}
for (const type of ['options', 'toc', 'lint']) {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
function isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
function embedPopup() {
const ID = 'popup-iframe';
const SEL = '#' + ID;
if ($(SEL)) return;
const frame = $create('iframe', {
id: ID,
src: chrome.runtime.getManifest().browser_action.default_popup,
height: 600,
width: prefs.get('popupWidth'),
onload() {
frame.onload = null;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', e => getEventKeyName(e) === 'Escape' && embedPopup._close());
pw.close = embedPopup._close;
if (pw.IntersectionObserver) {
let loaded;
new pw.IntersectionObserver(([e]) => {
const el = pw.document.scrollingElement;
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
const hasSB = h > el.offsetHeight;
const {width} = e.boundingClientRect;
frame.height = h;
if (!hasSB !== !frame._scrollbarWidth || frame.width - width) {
frame._scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + frame._scrollbarWidth;
}
if (!loaded) {
loaded = true;
frame.dataset.loaded = '';
}
}).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(() => {
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (frame._scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}).observe(body, {attributes: true, attributeFilter: ['style']});
},
});
// saving the listener here so it's the same function reference for window.off
if (!embedPopup._close) {
embedPopup._close = () => {
$remove(SEL);
window.off('mousedown', embedPopup._close);
};
}
window.on('mousedown', embedPopup._close);
document.body.appendChild(frame);
}
/** resize on 'undo close' */
function restoreWindowSize() {
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
if (pos && pos.left != null && chrome.windows) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
async function detectWindowedState() {
isSimpleWindow =
(await browser.windows.getCurrent()).type === 'popup';
isWindowed = isSimpleWindow || (
prefs.get('openEditInWindow') &&
history.length === 1 &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
if (isSimpleWindow) {
await onDOMready();
initPopupButton();
}
}
function initPopupButton() {
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
const onIconsetChanged = (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
};
prefs.subscribe('iconset', onIconsetChanged, {now: true});
document.body.appendChild(btn);
window.on('keydown', e => getEventKeyName(e) === POPUP_HOTKEY && embedPopup());
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup'; // adds to keymap help
}
async function onAttached(tabId, info) {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
const win = await browser.windows.get(info.newWindowId, {populate: true});
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
// FF-only because Chrome retardedly resets the size during dragging
if (openEditInWindow && FIREFOX) {
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
}
}
function onRuntimeMessage(request) {
const {style} = request;
switch (request.method) {
case 'styleUpdated':
if (editor.style.id === style.id &&
!['editPreview', 'editPreviewEnd', 'editSave', 'config'].includes(request.reason)) {
Promise.resolve(request.codeIsUpdated === false ? style : API.styles.get(style.id))
.then(newStyle => editor.replaceStyle(newStyle, request.codeIsUpdated));
}
break;
case 'styleDeleted':
if (editor.style.id === style.id) {
closeCurrentTab();
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
}
}
function beforeUnload(e) {
sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition'));
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
scrollY: window.scrollY,
cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
focus: cm.hasFocus(),
height: cm.display.wrapper.style.height.replace('100vh', ''),
parentHeight: cm.display.wrapper.parentElement.offsetHeight,
sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex],
})),
});
const activeElement = document.activeElement;
if (activeElement) {
// blurring triggers 'change' or 'input' event if needed
activeElement.blur();
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
if (editor && editor.dirty.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
e.returnValue = t('styleChangesNotSaved');
}
}
function showHelp(title = '', body) {
const div = $('#help-popup');
div.className = '';
const contents = $('.contents', div);
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
}
$('.title', div).textContent = title;
showHelp.close = showHelp.close || (event => {
const canClose =
!event ||
event.type === 'click' ||
(
event.key === 'Escape' &&
!event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey &&
!$('.CodeMirror-hints, #message-box') &&
(
!document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
if (!canClose) {
return;
}
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(() => {
messageBox.confirm(t('confirmDiscardChanges'))
.then(ok => ok && showHelp.close());
});
return;
}
if (div.contains(document.activeElement) && showHelp.originalFocus) {
showHelp.originalFocus.focus();
}
div.style.display = '';
contents.textContent = '';
clearTimeout(contents.timer);
window.off('keydown', showHelp.close, true);
window.dispatchEvent(new Event('closeHelp'));
});
window.on('keydown', showHelp.close, true);
$('.dismiss', div).onclick = showHelp.close;
// reset any inline styles
div.style = 'display: block';
showHelp.originalFocus = document.activeElement;
return div;
}
/* exported showCodeMirrorPopup */
function showCodeMirrorPopup(title, html, options) {
const popup = showHelp(title, html);
popup.classList.add('big');
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
matchBrackets: true,
styleActiveLine: true,
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
}, options));
cm.focus();
rerouteHotkeys(false);
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
const onKeyDown = event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
const search = $('#search-replace-dialog');
const area = search && search.contains(document.activeElement) ? search : popup;
moveFocus(area, event.shiftKey ? -1 : 1);
event.preventDefault();
}
};
window.on('keydown', onKeyDown, true);
window.on('closeHelp', () => {
window.off('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
}
function canSaveWindowPos() {
return isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized();
}
function saveWindowPos() {
if (canSaveWindowPos()) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
if (window.scrollY >= scrollPoint && !headerFixed) {
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
$('body').classList.add('fixed-header');
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
function detectLayout() {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
document.body.classList.remove('compact-layout', 'fixed-header');
window.off('scroll', fixedHeader);
}
for (const type of ['options', 'toc', 'lint']) {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
function isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
function embedPopup() {
const ID = 'popup-iframe';
const SEL = '#' + ID;
if ($(SEL)) return;
const frame = $create('iframe', {
id: ID,
src: chrome.runtime.getManifest().browser_action.default_popup,
height: 600,
width: prefs.get('popupWidth'),
onload() {
frame.onload = null;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', e => getEventKeyName(e) === 'Escape' && embedPopup._close());
pw.close = embedPopup._close;
if (pw.IntersectionObserver) {
let loaded;
new pw.IntersectionObserver(([e]) => {
const el = pw.document.scrollingElement;
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
const hasSB = h > el.offsetHeight;
const {width} = e.boundingClientRect;
frame.height = h;
if (!hasSB !== !frame._scrollbarWidth || frame.width - width) {
frame._scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + frame._scrollbarWidth;
}
if (!loaded) {
loaded = true;
frame.dataset.loaded = '';
}
}).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(() => {
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (frame._scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}).observe(body, {attributes: true, attributeFilter: ['style']});
},
});
// saving the listener here so it's the same function reference for window.off
if (!embedPopup._close) {
embedPopup._close = () => {
$.remove(SEL);
window.off('mousedown', embedPopup._close);
};
}
window.on('mousedown', embedPopup._close);
document.body.appendChild(frame);
}
});

View File

@ -1,91 +1,90 @@
/* global importScripts workerUtil CSSLint require metaParser */
'use strict';
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 =>
({
const ruleRetriever = {
csslint() {
return require('/js/csslint/csslint').getRules().map(rule => {
const output = {};
for (const [key, value] of Object.entries(rule)) {
if (typeof value !== 'function') {
output[key] = value;
}
}
return output;
});
},
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;
for (const id of Object.keys(stylelint.rules)) {
const ruleCode = String(stylelint.rules[id]);
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
const possible = m[1];
const set = [];
while ((mStr = rxString.exec(possible))) {
const s = mStr[1];
if (s.includes(' ')) {
set.push(...s.split(/\s+/));
} else {
set.push(s);
}
}
if (possible.includes('ignoreAtRules')) {
set.push('ignoreAtRules');
}
if (possible.includes('ignoreShorthands')) {
set.push('ignoreShorthands');
}
if (set.length) {
sets.push(set);
}
}
if (sets.length) {
options[id] = sets;
}
}
return options;
},
};
/** @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;
},
getStylelintRules,
getCsslintRules,
});
}));
return result;
},
function getCsslintRules() {
loadScript('/vendor-overwrites/csslint/csslint.js');
return CSSLint.getRules().map(rule => {
const output = {};
for (const [key, value] of Object.entries(rule)) {
if (typeof value !== 'function') {
output[key] = value;
}
}
return output;
async stylelint(code, config) {
require('/vendor/stylelint-bundle/stylelint-bundle.min');
const {results: [res]} = await self.require('stylelint').lint({code, config});
delete res._postcssResult; // huge and unused
return res;
},
});
}
function getStylelintRules() {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
const stylelint = require('stylelint');
const options = {};
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
const rxString = /"([-\w\s]{3,}?)"/g;
for (const id of Object.keys(stylelint.rules)) {
const ruleCode = String(stylelint.rules[id]);
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
const possible = m[1];
const set = [];
while ((mStr = rxString.exec(possible))) {
const s = mStr[1];
if (s.includes(' ')) {
set.push(...s.split(/\s+/));
} else {
set.push(s);
}
}
if (possible.includes('ignoreAtRules')) {
set.push('ignoreAtRules');
}
if (possible.includes('ignoreShorthands')) {
set.push('ignoreShorthands');
}
if (set.length) {
sets.push(set);
}
}
if (sets.length) {
options[id] = sets;
}
}
return options;
}
});

199
edit/editor.js Normal file
View File

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

View File

@ -1,21 +1,24 @@
/* global
$
$$
$create
chromeLocal
CodeMirror
colorMimicry
debounce
editor
focusAccessibility
onDOMready
stringAsRegExp
t
tryRegExp
*/
'use strict';
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,48 +1,45 @@
/* 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() {
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
const keyMapSorted = Object.keys(keyMap)
.map(key => ({key, cmd: keyMap[key]}))
.sort((a, b) => (a.cmd < b.cmd || (a.cmd === b.cmd && a.key < b.key) ? -1 : 1));
const table = t.template.keymapHelp.cloneNode(true);
const tBody = table.tBodies[0];
const row = tBody.rows[0];
const cellA = row.children[0];
const cellB = row.children[1];
tBody.textContent = '';
for (const {key, cmd} of keyMapSorted) {
cellA.textContent = key;
cellB.textContent = cmd;
tBody.appendChild(row.cloneNode(true));
}
let tBody, inputs;
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
return function showKeymapHelp() {
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
const keyMapSorted = Object.keys(keyMap)
.map(key => ({key, cmd: keyMap[key]}))
.sort((a, b) => (a.cmd < b.cmd || (a.cmd === b.cmd && a.key < b.key) ? -1 : 1));
const table = t.template.keymapHelp.cloneNode(true);
table.oninput = filterTable;
tBody = table.tBodies[0];
const row = tBody.rows[0];
const cellA = row.children[0];
const cellB = row.children[1];
tBody.textContent = '';
for (const {key, cmd} of keyMapSorted) {
cellA.textContent = key;
cellB.textContent = cmd;
tBody.appendChild(row.cloneNode(true));
}
const inputs = $$('input', table);
inputs[0].addEventListener('keydown', hotkeyHandler);
inputs[1].focus();
helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
table.oninput = filterTable;
inputs = $$('input', table);
inputs[0].on('keydown', hotkeyHandler);
inputs[1].focus();
};
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,7 +31,24 @@ Object.assign(linter, (() => {
updateCount();
});
return {refreshReport};
return {
getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
issues.add(tr.getAnnotation());
}
}
return issues;
},
refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
},
};
function updateCount() {
const issueCount = Array.from(tables.values())
@ -41,16 +57,6 @@ Object.assign(linter, (() => {
$('#issue-count').textContent = issueCount;
}
function getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
issues.add(tr.getAnnotation());
}
}
return issues;
}
function findNextSibling(tables, cm) {
const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1;
@ -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 = {
function update(_data) {
data = _data;
if (!previewer) {
if (!data.id || !data.enabled || !enabled) {
return;
/**
* @param {Function} [fn] - preprocessor
* @param {boolean} [show]
*/
init(fn, show) {
preprocess = fn;
if (show != null) {
livePreview.show(show);
}
previewer = createPreviewer();
}
previewer.update(data);
}
},
show(state) {
$('#preview-label').classList.toggle('hidden', !state);
},
update(newData) {
data = newData;
if (!port) {
if (!data.id || !data.enabled || !enabled) {
return;
}
createPreviewer();
}
updatePreviewer(data);
},
};
function createPreviewer() {
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 => {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index !== undefined) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || String(err)}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
}
);
}
function disconnect() {
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 != null) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || err}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => {
messageBoxProxy.alert(err.message || `${err}`, 'pre');
};
}
}
return livePreview;
});

View File

@ -1,13 +1,10 @@
/* global
CodeMirror
debounce
deepEqual
trimCommentLabel
*/
'use strict';
/* 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
* @property {number} line
* @property {number} ch
*/
/** @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) {
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
regExpTester.toggle(true);
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
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,80 +1,57 @@
/* 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;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
const isShown = () => Boolean($('.regexp-report'));
function uninit() {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isInit = false;
}
const regexpTester = /** @namespace RegExpTester */{
function onTabUpdate(tabId, info) {
if (info.url) {
update();
}
}
function isShown() {
return Boolean($('.regexp-report'));
}
function toggle(state = !isShown()) {
if (state && !isShown()) {
if (!isInit) {
init();
toggle(state = !isShown()) {
if (state && !isShown()) {
if (!isWatching) {
isWatching = true;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
helpPopup.show('', $create('.regexp-report'));
} else if (!state && isShown()) {
unwatch();
helpPopup.close();
}
showHelp('', $create('.regexp-report'));
} else if (!state && isShown()) {
if (isInit) {
uninit();
}
// TODO: need a closeHelp function
$('#help-popup .dismiss').onclick();
}
}
},
function update(newRegexps) {
if (!isShown()) {
if (isInit) {
uninit();
async update(newRegexps) {
if (!isShown()) {
unwatch();
return;
}
return;
}
if (newRegexps) {
currentRegexps = newRegexps;
}
const regexps = currentRegexps.map(text => {
const rxData = Object.assign({text}, cachedRegexps.get(text));
if (!rxData.urls) {
cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));
if (newRegexps) {
currentRegexps = newRegexps;
}
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
browser.tabs.query({}).then(tabs => {
const regexps = currentRegexps.map(text => {
const rxData = Object.assign({text}, cachedRegexps.get(text));
if (!rxData.urls) {
cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));
}
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
const tabs = await browser.tabs.query({});
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
const 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: [
t('styleRegexpTestPartial'),
t.template.regexpTestPartial.cloneNode(true),
]},
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,25 +155,38 @@ const regExpTester = (() => {
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
report.appendChild(note);
adjustNote(report, note);
});
},
};
function onClick(event) {
const a = event.target.closest('a');
if (a) {
event.preventDefault();
openURL({
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
currentWindow: null,
});
} else if (event.target.closest('details')) {
setTimeout(adjustNote);
}
}
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
}
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
function onClick(event) {
const a = event.target.closest('a');
if (a) {
event.preventDefault();
openURL({
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
currentWindow: null,
});
} else if (event.target.closest('details')) {
setTimeout(adjustNote);
}
}
return {toggle, update};
})();
function onTabUpdate(tabId, info) {
if (info.url) {
regexpTester.update();
}
}
function unwatch() {
if (isWatching) {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isWatching = false;
}
}
return regexpTester;
});

View File

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

View File

@ -1,45 +1,48 @@
/* global
$
$$
$create
API
clipString
CodeMirror
createLivePreview
createSection
debounce
editor
FIREFOX
ignoreChromeError
linter
messageBox
prefs
rerouteHotkeys
sectionsToMozFormat
sessionStore
showCodeMirrorPopup
showHelp
t
*/
'use strict';
/* 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);
@ -643,8 +645,8 @@ function SectionsEditor() {
function maybeImportOnPaste(cm, event) {
const text = event.clipboardData.getData('text') || '';
if (/@-moz-document/i.test(text) &&
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
) {
event.preventDefault();
showMozillaFormatImport(text);
@ -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,219 +1,231 @@
/* 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';
let CodeMirror;
// TODO: maybe move to sections-util.js
const DocFuncMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
domains: 'domain',
regexps: 'regexp',
},
FROM_CSS: {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
},
/**
* @param {Object} section
* @param {function(func:string, value:string)} fn
*/
forEachProp(section, fn) {
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
const props = section[propName];
if (props) props.forEach(value => fn(func, value));
}
}
this.notifyChange(wasDirty);
}
remove(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
this._dirty.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
this.notifyChange(wasDirty);
}
modify(obj, oldValue, newValue) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
if (oldValue !== newValue) {
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
},
/**
* @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
* @returns {Object} section
*/
toSection(funcItems, section = {}) {
for (const item of funcItems) {
const [func, value] = item || [];
const propName = DocFuncMapper.FROM_CSS[func];
if (propName) {
const props = section[propName] || (section[propName] = []);
if (Array.isArray(value)) props.push(...value);
else props.push(value);
}
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
this._dirty.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
this.notifyChange(wasDirty);
}
clear(obj) {
const wasDirty = this.isDirty();
if (obj === undefined) {
this._dirty.clear();
} else {
this._dirty.delete(obj);
}
this.notifyChange(wasDirty);
}
isDirty() {
return this._dirty.size > 0;
}
onChange(cb, add = true) {
this._onchange[add ? 'add' : 'delete'](cb);
}
notifyChange(wasDirty) {
if (wasDirty !== this.isDirty()) {
this._onchange.forEach(cb => cb());
}
}
has(key) {
return this._dirty.has(key);
}
}
/* exported DocFuncMapper */
const DocFuncMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
domains: 'domain',
regexps: 'regexp',
},
FROM_CSS: {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
},
/**
* @param {Object} section
* @param {function(func:string, value:string)} fn
*/
forEachProp(section, fn) {
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
const props = section[propName];
if (props) props.forEach(value => fn(func, value));
}
},
/**
* @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
* @returns {Object} section
*/
toSection(funcItems, section = {}) {
for (const item of funcItems) {
const [func, value] = item || [];
const propName = DocFuncMapper.FROM_CSS[func];
if (propName) {
const props = section[propName] || (section[propName] = []);
if (Array.isArray(value)) props.push(...value);
else props.push(value);
}
}
return section;
},
};
/* exported sectionsToMozFormat */
function sectionsToMozFormat(style) {
return style.sections.map(section => {
const cssFuncs = [];
DocFuncMapper.forEachProp(section, (type, value) =>
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
return cssFuncs.length ?
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
section.code;
}).join('\n\n');
}
/* exported trimCommentLabel */
function trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
}
/* exported clipString */
function clipString(str, limit = 100) {
return str.length <= limit ? str : str.substr(0, limit) + '...';
}
/* exported memoize */
function memoize(fn) {
let cached = false;
let result;
return (...args) => {
if (!cached) {
result = fn(...args);
cached = true;
}
return result;
return section;
},
};
}
/* exported createHotkeyInput */
/**
* @param {!string} prefId
* @param {?function(isEnter:boolean)} onDone
*/
function createHotkeyInput(prefId, onDone = () => {}) {
return $create('input', {
type: 'search',
spellcheck: false,
value: prefs.get(prefId),
onkeydown(event) {
const key = CodeMirror.keyName(event);
if (key === 'Tab' || key === 'Shift-Tab') {
return;
}
event.preventDefault();
event.stopPropagation();
switch (key) {
case 'Enter':
if (this.checkValidity()) onDone(true);
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;
case 'Esc':
onDone(false);
}
const div = $('#help-popup');
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(async () => {
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
return ok && util.helpPopup.close();
});
return;
default:
// disallow: [Shift?] characters, modifiers-only, [modifiers?] + Esc, Tab, nav keys
if (!key || new RegExp('^(' + [
'(Back)?Space',
'(Shift-)?.', // a single character
'(Shift-?|Ctrl-?|Alt-?|Cmd-?){0,2}(|Esc|Tab|(Page)?(Up|Down)|Left|Right|Home|End|Insert|Delete)',
].join('|') + ')$', 'i').test(key)) {
this.value = key || this.value;
this.setCustomValidity('Not allowed');
}
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,
value: prefs.get(prefId),
onkeydown(event) {
const key = CodeMirror.keyName(event);
if (key === 'Tab' || key === 'Shift-Tab') {
return;
}
}
this.value = key;
this.setCustomValidity('');
prefs.set(prefId, key);
event.preventDefault();
event.stopPropagation();
switch (key) {
case 'Enter':
if (this.checkValidity()) onDone(true);
return;
case 'Esc':
onDone(false);
return;
default:
// disallow: [Shift?] characters, modifiers-only, [modifiers?] + Esc, Tab, nav keys
if (!key || new RegExp('^(' + [
'(Back)?Space',
'(Shift-)?.', // a single character
'(Shift-?|Ctrl-?|Alt-?|Cmd-?){0,2}(|Esc|Tab|(Page)?(Up|Down)|Left|Right|Home|End|Insert|Delete)',
].join('|') + ')$', 'i').test(key)) {
this.value = key || this.value;
this.setCustomValidity('Not allowed');
return;
}
}
this.value = key;
this.setCustomValidity('');
prefs.set(prefId, key);
},
oninput() {
// fired on pressing "x" to clear the field
prefs.set(prefId, '');
},
onpaste(event) {
event.preventDefault();
},
});
},
oninput() {
// fired on pressing "x" to clear the field
prefs.set(prefId, '');
async rerouteHotkeys(...args) {
require(['./reroute-hotkeys'], res => res(...args));
},
onpaste(event) {
event.preventDefault();
sectionsToMozFormat(style) {
return style.sections.map(section => {
const cssFuncs = [];
DocFuncMapper.forEachProp(section, (type, value) =>
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
return cssFuncs.length ?
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
section.code;
}).join('\n\n');
},
});
}
showCodeMirrorPopup(title, html, options) {
const popup = util.helpPopup.show(title, html);
popup.classList.add('big');
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
matchBrackets: true,
styleActiveLine: true,
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
}, options));
cm.focus();
util.rerouteHotkeys(false);
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
const onKeyDown = event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
const search = $('#search-replace-dialog');
const area = search && search.contains(document.activeElement) ? search : popup;
moveFocus(area, event.shiftKey ? -1 : 1);
event.preventDefault();
}
};
window.on('keydown', onKeyDown, true);
window.on('closeHelp', () => {
window.off('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
util.rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
},
trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return util.clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
},
};
return util;
});

View File

@ -7,44 +7,21 @@
<title>Loading...</title>
<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);
const CodeMirror = await cmReady;
cm = CodeMirror($('.main'), {
value: sourceCode || style.sourceCode,
readOnly: true,
colorpicker: true,
theme,
});
if (error) {
showBuildError(error);
}
$('#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;
if (!style) {
return;
}
function makeExternalLink() {
const urls = [
data.homepageURL && [data.homepageURL, t('externalHomepage')],
data.supportURL && [data.supportURL, t('externalSupport')],
];
return (data.homepageURL || data.supportURL) && (
$create('div', [
$create('h3', t('externalLink')),
$create('ul', urls.map(args => args &&
$create('li',
$createLink(...args)
)
)),
]));
}
}
function showError(err) {
$('.warnings').textContent = '';
if (err) {
$('.warnings').appendChild(buildWarning(err));
}
$('.warnings').classList.toggle('visible', Boolean(err));
$('.container').classList.toggle('has-warnings', Boolean(err));
adjustCodeHeight();
}
function install(style) {
installed = style;
$$.remove('.warning');
$('button.install').disabled = true;
$('button.install').classList.add('installed');
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
$('h2.installed').classList.add('active');
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : '';
updateMeta(style);
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
location.href = '/edit.html?id=' + style.id;
} else {
API.openEditor({id: style.id});
if (!liveReload.enabled) {
if (tabId < 0 && history.length > 1) {
history.back();
} else {
closeCurrentTab();
}
}
}
}
function initSourceCode(sourceCode) {
cm.setValue(sourceCode);
cm.refresh();
API.usercss.build({sourceCode, checkDup: true})
.then(init)
.catch(err => {
$('#header').classList.add('meta-init-error');
console.error(err);
showError(err);
});
}
function buildWarning(err) {
const contents = Array.isArray(err) ?
[$create('pre', err.join('\n'))] :
[err && err.message && $create('pre', err.message) || err || 'Unknown error'];
if (Number.isInteger(err.index) && typeof contents[0] === 'string') {
const pos = cm.posFromIndex(err.index);
contents[0] = `${pos.line + 1}:${pos.ch + 1} ` + contents[0];
contents.push($create('pre', drawLinePointer(pos)));
setTimeout(() => {
cm.scrollIntoView({line: pos.line + 1, ch: pos.ch}, window.innerHeight / 4);
cm.setCursor(pos.line, pos.ch + 1);
cm.focus();
});
}
return $create('.warning', [
t('parseUsercssError'),
'\n',
...contents,
]);
}
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
const pointer = ' '.repeat(pos.ch) + '^';
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
const leftPad = start !== 0 ? '...' : '';
const rightPad = end !== line.length ? '...' : '';
return (
leftPad +
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
rightPad +
'\n' +
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
pointer.slice(start, end)
);
}
function init({style, dup}) {
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
@ -279,21 +141,186 @@
}
}
function getAppliesTo(style) {
function *_gen() {
for (const section of style.sections) {
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
if (section[type]) {
yield *section[type];
}
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();
}
}
}
const result = [..._gen()];
if (!result.length) {
result.push(chrome.i18n.getMessage('appliesToEverything'));
}
async function getAppliesTo(style) {
if (style.sectionsPromise) {
try {
style.sections = await style.sectionsPromise;
} catch (error) {
showBuildError(error);
return [];
} finally {
delete style.sectionsPromise;
}
}
return result;
let numGlobals = 0;
const res = [];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
for (const section of style.sections) {
const targets = [].concat(...TARGETS.map(t => section[t]).filter(Boolean));
res.push(...targets);
numGlobals += !targets.length && !styleCodeEmpty(section.code);
}
res.sort();
if (!res.length || numGlobals) {
res.push(t('appliesToEverything'));
}
return [...new Set(res)];
}
function adjustCodeHeight() {
@ -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,71 +1,66 @@
/* exported createCache */
'use strict';
// create a FIFO limit-size map.
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get,
set,
delete: delete_,
clear,
has: id => map.has(id),
entries: function *() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
values: function *() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
/**
* Creates a FIFO limit-size map.
*/
function get(id) {
const item = map.get(id);
return item && item.data;
}
function set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
}
function delete_(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
}
function clear() {
map.clear();
index = lastIndex = 0;
}
}
define(require =>
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get(id) {
const item = map.get(id);
return item && item.data;
},
set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
},
delete(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
},
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
@ -3191,9 +3191,9 @@ self.parserlib = (() => {
for (const msg of messages) {
const {line, col} = msg;
if (L1 === L2 && line === L1 && C1 <= col && col <= C2 ||
line === L1 && col >= C1 ||
line === L2 && col <= C2 ||
line > L1 && line < L2) {
line === L1 && col >= C1 ||
line === L2 && col <= C2 ||
line > L1 && line < L2) {
messages.delete(msg);
isClean = false;
}
@ -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;
});

866
js/dom.js
View File

@ -1,41 +1,394 @@
/* 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, {
on: addEventListener,
off: removeEventListener,
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
onOff(enable, ...args) {
(enable ? addEventListener : removeEventListener).apply(this, args);
},
});
Object.assign(EventTarget.prototype, {
on: addEventListener,
off: removeEventListener,
});
$.isTextInput = (el = {}) =>
el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
/** @type {Prefs} */
let prefs;
$.remove = (selector, base = document) => {
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
if (el) {
el.remove();
//#region Exports
/** @type {DOM} */
let dom;
const {
$,
$$,
$create,
} = dom = /** @namespace DOM */ {
$(selector, base = document) {
// we have ids with . like #manage.onlyEnabled which looks like #id.class
// so since getElementById is superfast we'll try it anyway
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
return byId || base.querySelector(selector);
},
$$(selector, base = document) {
return [...base.querySelectorAll(selector)];
},
$isTextInput(el = {}) {
return el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
},
$remove(selector, base = document) {
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
if (el) {
el.remove();
}
},
$$remove(selector, base = document) {
for (const el of base.querySelectorAll(selector)) {
el.remove();
}
},
/*
$create('tag#id.class.class', ?[children])
$create('tag#id.class.class', ?textContentOrChildNode)
$create('tag#id.class.class', {properties}, ?[children])
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
tag is 'div' by default, #id and .class are optional
$create([children])
$create({propertiesAndOptions})
$create({propertiesAndOptions}, ?[children])
tag: string, default 'div'
appendChild: element/string or an array of elements/strings
dataset: object
any DOM property: assigned as is
tag may include namespace like 'ns:tag'
*/
$create(selector = 'div', properties, children) {
let ns, tag, opt;
if (typeof selector === 'string') {
if (Array.isArray(properties) ||
properties instanceof Node ||
typeof properties !== 'object') {
opt = {};
children = properties;
} else {
opt = properties || {};
children = children || opt.appendChild;
}
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
const id = selector.slice(idStart + 1, classStart);
if (id) {
opt.id = id;
}
const cls = selector.slice(classStart + 1);
if (cls) {
opt[selector.includes(':') ? 'class' : 'className'] =
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
}
tag = selector.slice(0, Math.min(idStart, classStart));
} else if (Array.isArray(selector)) {
tag = 'div';
opt = {};
children = selector;
} else {
opt = selector;
tag = opt.tag;
children = opt.appendChild || properties;
}
if (tag && tag.includes(':')) {
[ns, tag] = tag.split(':');
if (ns === 'SVG' || ns === 'svg') {
ns = 'http://www.w3.org/2000/svg';
}
}
const element = ns ? document.createElementNS(ns, tag) :
tag === 'fragment' ? document.createDocumentFragment() :
document.createElement(tag || 'div');
for (const child of Array.isArray(children) ? children : [children]) {
if (child) {
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
}
}
for (const [key, val] of Object.entries(opt)) {
switch (key) {
case 'dataset':
Object.assign(element.dataset, val);
break;
case 'attributes':
Object.entries(val).forEach(attr => element.setAttribute(...attr));
break;
case 'style': {
const t = typeof val;
if (t === 'string') element.style.cssText = val;
if (t === 'object') Object.assign(element.style, val);
break;
}
case 'tag':
case 'appendChild':
break;
default: {
if (ns) {
const i = key.indexOf(':') + 1;
const attrNS = i && `http://www.w3.org/1999/${key.slice(0, i - 1)}`;
element.setAttributeNS(attrNS || null, key, val);
} else {
element[key] = val;
}
}
}
}
return element;
},
$createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener',
};
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
}
opt.appendChild = opt.appendChild || content;
return dom.$create(opt);
},
/**
* @param {HTMLElement} el
* @param {string} [cls] - class name that defines or starts an animation
* @param [removeExtraClasses] - class names to remove at animation end in the *same* paint frame,
* which is needed in e.g. Firefox as it may call resolve() in the next frame
* @returns {Promise<void>}
*/
animateElement(el, cls = 'highlight', ...removeExtraClasses) {
return !el ? Promise.resolve(el) : new Promise(resolve => {
let onDone = () => {
el.classList.remove(cls, ...removeExtraClasses);
onDone = null;
resolve();
};
requestAnimationFrame(() => {
if (onDone) {
const style = getComputedStyle(el);
if (style.animationName === 'none' || !parseFloat(style.animationDuration)) {
el.off('animationend', onDone);
onDone();
}
}
});
el.on('animationend', onDone, {once: true});
el.classList.add(cls);
});
},
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
focusAccessibility: {
// last event's focusedViaClick
lastFocusedViaClick: false,
// to avoid a full layout recalc due to changes on body/root
// we modify the closest focusable element (like input or button or anything with tabindex=0)
closest(el) {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
el = el.control;
labelSeen = true;
}
if (el.tabIndex >= 0) return el;
}
},
},
getEventKeyName(e, letterAsCode) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
(e.altKey ? 'Alt-' : '') +
(e.metaKey ? 'Meta-' : '');
return `${
mods === e.key + '-' ? '' : mods
}${
e.key
? e.key.length === 1 && letterAsCode ? e.code : e.key
: 'LMR'[e.button]
}`;
},
/** @type {MessageBox} however properties are resolved asynchronously! */
messageBoxProxy: new Proxy({}, {
get(_, name) {
return async (...args) => (await require(['/js/dlg/message-box']))[name](...args);
},
}),
/**
* Switches to the next/previous keyboard-focusable element.
* Doesn't check `visibility` or `display` via getComputedStyle for simplicity.
* @param {HTMLElement} rootElement
* @param {Number} step - for exmaple 1 or -1 (or 0 to focus the first focusable el in the box)
* @returns {HTMLElement|false|undefined} -
* HTMLElement: focus changed,
* false: focus unchanged,
* undefined: nothing to focus
*/
moveFocus(rootElement, step) {
const elements = [...rootElement.getElementsByTagName('*')];
const activeEl = document.activeElement;
const activeIndex = step ? Math.max(step < 0 ? 0 : -1, elements.indexOf(activeEl)) : -1;
const num = elements.length;
if (!step) step = 1;
for (let i = 1; i < num; i++) {
const el = elements[(activeIndex + i * step + num) % num];
if (!el.disabled && el.tabIndex >= 0) {
el.focus();
return activeEl !== el && el;
}
}
},
onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
},
scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return;
const {top, height} = element.getBoundingClientRect();
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
window.scrollBy(0, top - windowHeight / 2 + height);
}
},
/**
* Accepts an array of pref names (values are fetched via prefs.get)
* and establishes a two-way connection between the document elements and the actual prefs
*/
setupLivePrefs(ids = Object.keys(prefs.defaults).filter(id => $('#' + id))) {
let forceUpdate = true;
prefs.subscribe(ids, updateElement, {runNow: true});
forceUpdate = false;
ids.forEach(id => $('#' + id).on('change', onChange));
function onChange() {
prefs.set(this.id, this[getPropName(this)]);
}
function getPropName(el) {
return el.type === 'checkbox' ? 'checked'
: el.type === 'number' ? 'valueAsNumber' :
'value';
}
function updateElement(id, value) {
const el = $('#' + id);
if (el) {
const prop = getPropName(el);
if (el[prop] !== value || forceUpdate) {
el[prop] = value;
el.dispatchEvent(new Event('change', {bubbles: true}));
}
} else {
prefs.unsubscribe(ids, updateElement);
}
}
},
// Accepts an array of pref names (values are fetched via prefs.get)
// and establishes a two-way connection between the document elements and the actual prefs
waitForSelector(selector, {stopOnDomReady = true} = {}) {
// TODO: if used concurrently see if it's worth reworking to use just one observer internally
return Promise.resolve($(selector) || new Promise(resolve => {
const mo = new MutationObserver(() => {
const el = $(selector);
if (el) {
mo.disconnect();
resolve(el);
} else if (stopOnDomReady && document.readyState === 'complete') {
mo.disconnect();
}
});
mo.observe(document, {childList: true, subtree: true});
}));
},
};
//#endregion
//#region Init
require(['/js/prefs'], p => {
prefs = p;
dom.waitForSelector('details[data-pref]')
.then(() => requestAnimationFrame(initCollapsibles));
if (!chrome.app) {
// add favicon in Firefox
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
for (const size of [38, 32, 19, 16]) {
document.head.appendChild($create('link', {
rel: 'icon',
href: `/images/icon/${iconset}${size}.png`,
sizes: size + 'x' + size,
}));
}
}
});
require(['/js/toolbox'], m => {
m.debounce(addTooltipsToEllipsized, 500);
window.on('resize', () => m.debounce(addTooltipsToEllipsized, 100));
});
window.on('mousedown', suppressFocusRingOnClick, {passive: true});
window.on('keydown', keepFocusRingOnTabbing, {passive: true});
dom.onDOMready().then(() => {
dom.$remove('#firefox-transitions-bug-suppressor');
});
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
};
// set language for a) CSS :lang pseudo and b) hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
document.on('click', keepAddressOnDummyClick);
document.on('wheel', changeFocusedInputOnWheel, {capture: true, passive: false});
$$.remove = (selector, base = document) => {
for (const el of base.querySelectorAll(selector)) {
el.remove();
//#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();
}
};
{
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
const addTooltipsToEllipsized = () => {
/** 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;
@ -53,334 +406,44 @@ $$.remove = (selector, base = document) => {
btn.title = '';
}
}
};
// enqueue after DOMContentLoaded/load events
setTimeout(addTooltipsToEllipsized, 500);
// throttle on continuous resizing
let timer;
window.on('resize', () => {
clearTimeout(timer);
timer = setTimeout(addTooltipsToEllipsized, 100);
});
}
onDOMready().then(() => {
$.remove('#firefox-transitions-bug-suppressor');
initCollapsibles();
focusAccessibility();
if (!chrome.app && chrome.windows && typeof prefs !== 'undefined') {
// add favicon in Firefox
prefs.initializing.then(() => {
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
for (const size of [38, 32, 19, 16]) {
document.head.appendChild($create('link', {
rel: 'icon',
href: `/images/icon/${iconset}${size}.png`,
sizes: size + 'x' + size,
}));
}
});
}
});
// set language for CSS :lang and [FF-only] hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
// avoid adding # to the page URL when clicking dummy links
document.on('click', e => {
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
});
// update inputs on mousewheel when focused
document.on('wheel', event => {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}, {
capture: true,
passive: false,
});
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
}
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return;
const {top, height} = element.getBoundingClientRect();
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
window.scrollBy(0, top - windowHeight / 2 + height);
}
}
/**
* @param {HTMLElement} el
* @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();
// 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);
}
}
});
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();
};
const prefMap = {};
for (const el of $$('details[data-pref]')) {
prefMap[el.dataset.pref] = el;
($('h2', el) || el).on('click', onClick);
}
};
element.on('change', onChange);
element.on('input', onChange);
}
function $(selector, base = document) {
// we have ids with . like #manage.onlyEnabled which looks like #id.class
// so since getElementById is superfast we'll try it anyway
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
return byId || base.querySelector(selector);
}
function $$(selector, base = document) {
return [...base.querySelectorAll(selector)];
}
function $create(selector = 'div', properties, children) {
/*
$create('tag#id.class.class', ?[children])
$create('tag#id.class.class', ?textContentOrChildNode)
$create('tag#id.class.class', {properties}, ?[children])
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
tag is 'div' by default, #id and .class are optional
$create([children])
$create({propertiesAndOptions})
$create({propertiesAndOptions}, ?[children])
tag: string, default 'div'
appendChild: element/string or an array of elements/strings
dataset: object
any DOM property: assigned as is
tag may include namespace like 'ns:tag'
*/
let ns, tag, opt;
if (typeof selector === 'string') {
if (Array.isArray(properties) ||
properties instanceof Node ||
typeof properties !== 'object') {
opt = {};
children = properties;
} else {
opt = properties || {};
children = children || opt.appendChild;
}
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
const id = selector.slice(idStart + 1, classStart);
if (id) {
opt.id = id;
}
const cls = selector.slice(classStart + 1);
if (cls) {
opt[selector.includes(':') ? 'class' : 'className'] =
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
}
tag = selector.slice(0, Math.min(idStart, classStart));
} else if (Array.isArray(selector)) {
tag = 'div';
opt = {};
children = selector;
} else {
opt = selector;
tag = opt.tag;
delete opt.tag;
children = opt.appendChild || properties;
delete opt.appendChild;
}
if (tag && tag.includes(':')) {
([ns, tag] = tag.split(':'));
}
const element = ns
? document.createElementNS(ns === 'SVG' || ns === 'svg' ? 'http://www.w3.org/2000/svg' : ns, tag)
: tag === 'fragment'
? document.createDocumentFragment()
: document.createElement(tag || 'div');
for (const child of Array.isArray(children) ? children : [children]) {
if (child) {
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
}
}
if (opt.dataset) {
Object.assign(element.dataset, opt.dataset);
delete opt.dataset;
}
if (opt.attributes) {
for (const attr in opt.attributes) {
element.setAttribute(attr, opt.attributes[attr]);
}
delete opt.attributes;
}
if (opt.style) {
if (typeof opt.style === 'string') element.style.cssText = opt.style;
if (typeof opt.style === 'object') Object.assign(element.style, opt.style);
delete opt.style;
}
if (ns) {
for (const attr in opt) {
const i = attr.indexOf(':') + 1;
const attrNS = i && `http://www.w3.org/1999/${attr.slice(0, i - 1)}`;
element.setAttributeNS(attrNS || null, attr, opt[attr]);
}
} else {
Object.assign(element, opt);
}
return element;
}
function $createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener',
};
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
}
opt.appendChild = opt.appendChild || content;
return $create(opt);
}
// makes <details> with [data-pref] save/restore their state
function initCollapsibles({bindClickOn = 'h2'} = {}) {
const prefMap = {};
const elements = $$('details[data-pref]');
if (!elements.length) {
return;
}
for (const el of elements) {
const key = el.dataset.pref;
prefMap[key] = el;
el.open = prefs.get(key);
(bindClickOn && $(bindClickOn, el) || el).on('click', onClick);
}
prefs.subscribe(Object.keys(prefMap), (key, value) => {
const el = prefMap[key];
if (el.open !== value) {
el.open = value;
}
});
function onClick(event) {
if (event.target.closest('.intercepts-click')) {
event.preventDefault();
} else {
setTimeout(saveState, 0, event.target.closest('details'));
}
}
function saveState(el) {
if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
prefs.set(el.dataset.pref, el.open);
}
}
}
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
function focusAccessibility() {
// last event's focusedViaClick
focusAccessibility.lastFocusedViaClick = false;
// to avoid a full layout recalc due to changes on body/root
// we modify the closest focusable element (like input or button or anything with tabindex=0)
focusAccessibility.closest = el => {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
el = el.control;
labelSeen = true;
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;
}
if (el.tabIndex >= 0) return el;
}, {runNow: true});
}
function keepAddressOnDummyClick(e) {
// avoid adding # to the page URL when clicking dummy links
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
};
// suppress outline on click
window.on('mousedown', ({target}) => {
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 => {
}
function keepFocusRingOnTabbing(event) {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false;
dom.focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
@ -389,102 +452,19 @@ function focusAccessibility() {
}
});
}
}, {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;
function suppressFocusRingOnClick({target}) {
const el = dom.focusAccessibility.closest(target);
if (el) {
dom.focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
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) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
(e.altKey ? 'Alt-' : '') +
(e.metaKey ? 'Meta-' : '');
return `${
mods === e.key + '-' ? '' : mods
}${
e.key
? e.key.length === 1 && letterAsCode ? e.code : e.key
: 'LMR'[e.button]
}`;
}
//#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,144 +13,152 @@ Object.assign(t, {
/((?!\s)\W){10}/,
')',
/(?!\b|\s|$)/,
].map(rx => rx.source || rx).join(''), 'gu'),
].map(rx => rx.source || rx).join(''), 'gu');
HTML(html) {
return typeof html !== 'string'
? html
: /<\w+/.test(html) // check for html tags
? t.createHtml(html.replace(/>\n\s*</g, '><').trim())
: document.createTextNode(html);
},
function t(key, params, strict = true) {
const s = chrome.i18n.getMessage(key, params);
if (!s && strict) throw `Missing string "${key}"`;
return s;
}
NodeList(nodes) {
const PREFIX = 'i18n-';
for (let n = nodes.length; --n >= 0;) {
const node = nodes[n];
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
if (node.localName === 'template') {
t.createTemplate(node);
continue;
}
for (let a = node.attributes.length; --a >= 0;) {
const attr = node.attributes[a];
const name = attr.nodeName;
if (!name.startsWith(PREFIX)) {
Object.assign(t, {
template: {},
HTML(html) {
return typeof html !== 'string'
? html
: /<\w+/.test(html) // check for html tags
? t.createHtml(html.replace(/>\n\s*</g, '><').trim())
: document.createTextNode(html);
},
NodeList(nodes) {
const PREFIX = 'i18n-';
for (let n = nodes.length; --n >= 0;) {
const node = nodes[n];
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const type = name.substr(PREFIX.length);
const value = t(attr.value);
let toInsert, before;
switch (type) {
case 'word-break':
// we already know that: hasWordBreak
break;
case 'text':
before = node.firstChild;
// fallthrough to text-append
case 'text-append':
toInsert = t.createText(value);
break;
case 'html': {
toInsert = t.createHtml(value);
break;
if (node.localName === 'template') {
t.createTemplate(node);
continue;
}
for (let a = node.attributes.length; --a >= 0;) {
const attr = node.attributes[a];
const name = attr.nodeName;
if (!name.startsWith(PREFIX)) {
continue;
}
default:
node.setAttribute(type, value);
}
t.stopObserver();
if (toInsert) {
node.insertBefore(toInsert, before || null);
}
node.removeAttribute(name);
}
}
},
/** Adds soft hyphens every 10 characters to ensure the long words break before breaking the layout */
breakWord(text) {
return text.length <= 10 ? text :
text.replace(t.RX_WORD_BREAK, '$&\u00AD');
},
createTemplate(node) {
const elements = node.content.querySelectorAll('*');
t.NodeList(elements);
t.template[node.dataset.id] = elements[0];
// compress inter-tag whitespace to reduce number of DOM nodes by 25%
const walker = document.createTreeWalker(elements[0], NodeFilter.SHOW_TEXT);
const toRemove = [];
while (walker.nextNode()) {
const textNode = walker.currentNode;
if (!/[\xA0\S]/.test(textNode.nodeValue)) { // allow \xA0 to keep &nbsp;
toRemove.push(textNode);
}
}
t.stopObserver();
toRemove.forEach(el => el.remove());
},
createText(str) {
return document.createTextNode(t.breakWord(str));
},
createHtml(str, trusted) {
const root = t.DOMParser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
t.NodeList(root.getElementsByTagName('*'));
}
const bin = document.createDocumentFragment();
while (root.firstChild) {
bin.appendChild(root.firstChild);
}
return bin;
},
sanitizeHtml(root) {
const toRemove = [];
const walker = document.createTreeWalker(root);
for (let n; (n = walker.nextNode());) {
if (n.nodeType === Node.TEXT_NODE) {
n.nodeValue = t.breakWord(n.nodeValue);
} else if (t.ALLOWED_TAGS.includes(n.localName)) {
for (const attr of n.attributes) {
if (n.localName !== 'a' || attr.localName !== 'href' || !/^https?:/.test(n.href)) {
n.removeAttribute(attr.name);
const type = name.substr(PREFIX.length);
const value = t(attr.value);
let toInsert, before;
switch (type) {
case 'word-break':
// we already know that: hasWordBreak
break;
case 'text':
before = node.firstChild;
// fallthrough to text-append
case 'text-append':
toInsert = t.createText(value);
break;
case 'html': {
toInsert = t.createHtml(value);
break;
}
default:
node.setAttribute(type, value);
}
t.stopObserver();
if (toInsert) {
node.insertBefore(toInsert, before || null);
}
node.removeAttribute(name);
}
} else {
toRemove.push(n);
}
}
for (const n of toRemove) {
const parent = n.parentNode;
if (parent) parent.removeChild(n); // not using .remove() as there may be a non-element
}
},
},
formatDate(date) {
if (!date) {
return '';
}
try {
const newDate = new Date(Number(date) || date);
const string = newDate.toLocaleDateString([chrome.i18n.getUILanguage(), 'en'], {
day: '2-digit',
month: 'short',
year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit',
});
return string === 'Invalid Date' ? '' : string;
} catch (e) {
return '';
}
},
});
/** 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(RX_WORD_BREAK, '$&\u00AD');
},
createTemplate(node) {
const elements = node.content.querySelectorAll('*');
t.NodeList(elements);
t.template[node.dataset.id] = elements[0];
// compress inter-tag whitespace to reduce number of DOM nodes by 25%
const walker = document.createTreeWalker(elements[0], NodeFilter.SHOW_TEXT);
const toRemove = [];
while (walker.nextNode()) {
const textNode = walker.currentNode;
if (!/[\xA0\S]/.test(textNode.nodeValue)) { // allow \xA0 to keep &nbsp;
toRemove.push(textNode);
}
}
t.stopObserver();
toRemove.forEach(el => el.remove());
},
createText(str) {
return document.createTextNode(t.breakWord(str));
},
createHtml(str, trusted) {
const root = parser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
t.NodeList(root.getElementsByTagName('*'));
}
const bin = document.createDocumentFragment();
while (root.firstChild) {
bin.appendChild(root.firstChild);
}
return bin;
},
sanitizeHtml(root) {
const toRemove = [];
const walker = document.createTreeWalker(root);
for (let n; (n = walker.nextNode());) {
if (n.nodeType === Node.TEXT_NODE) {
n.nodeValue = t.breakWord(n.nodeValue);
} else if (ALLOWED_TAGS.includes(n.localName)) {
for (const attr of n.attributes) {
if (n.localName !== 'a' || attr.localName !== 'href' || !/^https?:/.test(n.href)) {
n.removeAttribute(attr.name);
}
}
} else {
toRemove.push(n);
}
}
for (const n of toRemove) {
const parent = n.parentNode;
if (parent) parent.removeChild(n); // not using .remove() as there may be a non-element
}
},
formatDate(date) {
if (!date) {
return '';
}
try {
const newDate = new Date(Number(date) || date);
const string = newDate.toLocaleDateString([chrome.i18n.getUILanguage(), 'en'], {
day: '2-digit',
month: 'short',
year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit',
});
return string === 'Invalid Date' ? '' : string;
} catch (e) {
return '';
}
},
});
(() => {
const observer = new MutationObserver(process);
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,
lint: looseParser.parse,
parse: parser.parse,
nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
if (va.value !== null) {
try {
parser.validateVar(va);
} catch (err) {
va.value = null;
}
}
}
return vars;
},
};
function parse(text, indexOffset) {
try {
return parser.parse(text);
} catch (err) {
if (typeof err.index === 'number') {
err.index += indexOffset;
}
throw err;
}
}
function lint(text) {
return looseParser.parse(text);
}
function nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
if (va.value === null) {
continue;
}
try {
parser.validateVar(va);
} catch (err) {
va.value = null;
}
}
return vars;
}
})();
});

View File

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

130
js/msg.js
View File

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

View File

@ -1,12 +1,17 @@
/* global msg API */
/* global deepCopy debounce */ // not used in content scripts
'use strict';
// 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,50 +16,52 @@ 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});
}
updateHash(hash) {
/* hash: String
function updateSearch(key, value) {
const u = new URL(location);
u.searchParams[value ? 'set' : 'delete'](key, value);
history.replaceState(history.state, null, `${u}`);
update(true);
}
function updateHash(hash) {
/* hash: String
Send an empty string to remove the hash.
*/
if (buffer.length > 1) {
if (!hash && !buffer[buffer.length - 2].includes('#') ||
hash && buffer[buffer.length - 2].endsWith(hash)) {
history.back();
return;
Send an empty string to remove the hash.
*/
if (buffer.length > 1) {
if (!hash && !buffer[buffer.length - 2].includes('#') ||
hash && buffer[buffer.length - 2].endsWith(hash)) {
history.back();
return;
}
}
}
if (!hash) {
hash = ' ';
}
history.pushState(history.state, null, hash);
update();
}
if (!hash) {
hash = ' ';
}
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,90 +1,94 @@
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
'use strict';
function styleCodeEmpty(code) {
if (!code) {
return true;
}
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
}
}
return false;
}
define(require => {
const exports = {
/** Checks if section is global i.e. has no targets at all */
function styleSectionGlobal(section) {
return (!section.regexps || !section.regexps.length) &&
(!section.urlPrefixes || !section.urlPrefixes.length) &&
(!section.urls || !section.urls.length) &&
(!section.domains || !section.domains.length);
}
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('');
},
/**
* 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}) {
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));
styleCodeEmpty(code) {
if (!code) {
return true;
}
const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
}
}
return false;
},
styleJSONseemsValid(json) {
return json
&& typeof json.name == 'string'
&& json.name.trim()
&& Array.isArray(json.sections)
&& typeof (json.sections[0] || {}).code === 'string';
},
/**
* Checks if section is global i.e. has no targets at all
*/
styleSectionGlobal(section) {
return (!section.regexps || !section.regexps.length) &&
(!section.urlPrefixes || !section.urlPrefixes.length) &&
(!section.urls || !section.urls.length) &&
(!section.domains || !section.domains.length);
},
/**
* The sections are checked in successive order because it matters when many sections
* match the same URL and they have rules with the same CSS specificity
* @param {Object} a - first style object
* @param {Object} b - second style object
* @returns {?boolean}
*/
styleSectionsEqual({sections: a}, {sections: b}) {
const targets = ['urls', 'urlPrefixes', 'domains', 'regexps'];
return a && b && a.length === b.length && a.every(sameSection);
function sameSection(secA, i) {
return equalOrEmpty(secA.code, b[i].code, 'string', (a, b) => a === b) &&
targets.every(target => equalOrEmpty(secA[target], b[i][target], 'array', arrayMirrors));
}
function equalOrEmpty(a, b, type, comparator) {
const typeA = type === 'array' ? Array.isArray(a) : typeof a === type;
const typeB = type === 'array' ? Array.isArray(b) : typeof b === type;
return typeA && typeB && comparator(a, b) ||
(a == null || typeA && !a.length) &&
(b == null || typeB && !b.length);
}
function arrayMirrors(a, b) {
return a.length === b.length &&
a.every(el => b.includes(el)) &&
b.every(el => a.includes(el));
}
},
};
function byte2hex(b) {
return (0x100 + b).toString(16).slice(1);
}
function equalOrEmpty(a, b, type, comparator) {
const typeA = type === 'array' ? Array.isArray(a) : typeof a === type;
const typeB = type === 'array' ? Array.isArray(b) : typeof b === type;
return typeA && typeB && comparator(a, b) ||
(a == null || typeA && !a.length) &&
(b == null || typeB && !b.length);
function normalizeStyleSections({sections}) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => /** @namespace StyleSection */({
code: section.code || '',
urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
}
function arrayMirrors(a, b) {
return a.length === b.length &&
a.every(el => b.includes(el)) &&
b.every(el => a.includes(el));
}
}
function normalizeStyleSections({sections}) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => /** @namespace StyleSection */({
code: section.code || '',
urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
}
function calcStyleDigest(style) {
const jsonString = style.usercssData ?
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
const text = new TextEncoder('utf-8').encode(jsonString);
return crypto.subtle.digest('SHA-1', text).then(hex);
function hex(buffer) {
const parts = [];
const PAD8 = '00000000';
const view = new DataView(buffer);
for (let i = 0; i < view.byteLength; i += 4) {
parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8));
}
return parts.join('');
}
}
function styleJSONseemsValid(json) {
return json
&& json.name
&& json.name.trim()
&& Array.isArray(json.sections)
&& json.sections
&& json.sections.length
&& typeof json.sections.every === 'function'
&& typeof json.sections[0].code === 'string';
}
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,84 +1,118 @@
'use strict';
const workerUtil = {
if (typeof define !== 'function') {
const defines = {};
let currentPath = '/js/worker-util.js';
createWorker({url, lifeTime = 300}) {
let worker;
let id;
let timer;
const pendingResponse = new Map();
return new Proxy({}, {
get: (target, prop) =>
(...args) => {
if (!worker) {
init();
}
return invoke(prop, args);
},
});
function init() {
id = 0;
worker = new Worker(url);
worker.onmessage = onMessage;
}
function uninit() {
worker.onmessage = null;
worker.terminate();
worker = null;
}
function onMessage({data: {id, data, error}}) {
pendingResponse.get(id)[error ? 'reject' : 'resolve'](data);
pendingResponse.delete(id);
if (!pendingResponse.size && lifeTime >= 0) {
timer = setTimeout(uninit, lifeTime * 1000);
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];
};
function invoke(action, args) {
return new Promise((resolve, reject) => {
pendingResponse.set(id, {resolve, reject});
clearTimeout(timer);
worker.postMessage({id, action, args});
id++;
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);
});
}
},
};
}
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);
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;
let id;
let timer;
const pendingResponse = new Map();
return new Proxy({}, {
get(target, prop) {
return (...args) => {
if (!worker) init();
return invoke(prop, args);
};
},
});
function init() {
id = 0;
worker = new Worker('/js/worker-util.js?' + new URLSearchParams({[GUEST]: url}));
worker.onmessage = onMessage;
}
self.postMessage({id, data, error});
};
},
cloneError(err) {
return Object.assign({
name: err.name,
stack: err.stack,
message: err.message,
lineNumber: err.lineNumber,
columnNumber: err.columnNumber,
fileName: err.fileName,
}, err);
},
function uninit() {
worker.onmessage = null;
worker.terminate();
worker = null;
}
loadScript(...urls) {
urls = urls.filter(u => !workerUtil._loadedScripts.has(u));
if (!urls.length) {
return;
}
self.importScripts(...urls);
urls.forEach(u => workerUtil._loadedScripts.add(u));
},
function onMessage({data: {id, data, error}}) {
pendingResponse.get(id)[error ? 'reject' : 'resolve'](data);
pendingResponse.delete(id);
if (!pendingResponse.size && lifeTime >= 0) {
timer = setTimeout(uninit, lifeTime * 1000);
}
}
_loadedScripts: new Set(),
};
function invoke(action, args) {
return new Promise((resolve, reject) => {
pendingResponse.set(id, {resolve, reject});
clearTimeout(timer);
worker.postMessage({id, action, args});
id++;
});
}
},
};
if (self.WorkerGlobalScope) {
Promise.resolve().then(() =>
require(new URLSearchParams(location.search).get(GUEST)));
}
return exports;
});

View File

@ -5,11 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
}

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