migrate to AMD modules
This commit is contained in:
parent
c2adfbf902
commit
d054dcf42e
|
@ -1,4 +1,2 @@
|
|||
vendor/
|
||||
vendor-overwrites/*
|
||||
!vendor-overwrites/colorpicker
|
||||
!vendor-overwrites/csslint
|
||||
vendor-overwrites/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
154
background/background-api.js
Normal file
154
background/background-api.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
23
background/browser-cmd-hotkeys.js
Normal file
23
background/browser-cmd-hotkeys.js
Normal 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) {}
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
25
background/remove-unused-storage.js
Normal file
25
background/remove-unused-storage.js
Normal 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);
|
||||
};
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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) {}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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}, '*');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
240
edit.html
|
@ -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"> </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
198
edit/autocomplete.js
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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});
|
||||
},
|
||||
});
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
]);
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
878
edit/edit.js
878
edit/edit.js
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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
199
edit/editor.js
Normal 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;
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -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
238
edit/linter-dialogs.js
Normal 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');
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -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
252
edit/linter-manager.js
Normal 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;
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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
82
edit/preinit.js
Normal 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'),
|
||||
]);
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
422
edit/util.js
422
edit/util.js
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -301,6 +301,10 @@ li {
|
|||
user-select: auto;
|
||||
}
|
||||
|
||||
#header.meta-init[data-arrived-fast="true"] > * {
|
||||
transition-duration: .1s;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
90
install-usercss/preinit.js
Normal file
90
install-usercss/preinit.js
Normal 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};
|
||||
}
|
||||
})(),
|
||||
};
|
||||
});
|
131
js/cache.js
131
js/cache.js
|
@ -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
378
js/color/color-converter.js
Normal 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
90
js/color/color-mimicry.js
Normal 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();
|
||||
}
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
});
|
|
@ -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
1766
js/csslint/csslint.js
Normal file
File diff suppressed because it is too large
Load Diff
|
@ -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
457
js/dlg/config-dialog.js
Normal 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
207
js/dlg/message-box.js
Normal 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
866
js/dom.js
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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
|
||||
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
|
||||
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;
|
||||
});
|
||||
|
|
465
js/messaging.js
465
js/messaging.js
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
268
js/moz-parser.js
268
js/moz-parser.js
|
@ -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
130
js/msg.js
|
@ -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};
|
||||
});
|
||||
|
|
244
js/polyfill.js
244
js/polyfill.js
|
@ -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;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
70
js/prefs.js
70
js/prefs.js
|
@ -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;
|
||||
});
|
||||
|
|
93
js/router.js
93
js/router.js
|
@ -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 = (() => {
|
|||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
));
|
||||
};
|
||||
})();
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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
433
js/toolbox.js
Normal 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;
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
|
@ -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;
|
||||
});
|
||||
|
|
36
manage.html
36
manage.html
|
@ -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">
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user