API groups + use executeScript for early injection (#1149)
* parserlib: fast section extraction, tweaks and speedups
* csslint: "simple-not" rule
* csslint: enable and fix "selector-newline" rule
* simplify db: resolve with result
* simplify download()
* remove noCode param as it wastes more time/memory on copying
* styleManager: switch style<->data names to reflect their actual contents
* inline method bodies to avoid indirection and enable better autocomplete/hint/jump support in IDE
* upgrade getEventKeyName to handle mouse clicks
* don't trust location.href as it hides text fragment
* getAllKeys is implemented since Chrome48, FF44
* allow recoverable css errors + async'ify usercss.js
* openManage: unminimize windows
* remove the obsolete Chrome pre-65 workaround
* fix temporal dead zone in apply.js
* ff bug workaround for simple editor window
* consistent window scrolling in scrollToEditor and jumpToPos
* rework waitForSelector and collapsible <details>
* blank paint frame workaround for new Chrome
* extract stuff from edit.js and load on demand
* simplify regexpTester::isShown
* move MozDocMapper to sections-util.js
* extract fitSelectBox()
* initialize router earlier
* use helpPopup.close()
* fix autofocus in popups, follow-up to 5bb1b5ef
* clone objects in prefs.get() + cosmetics
* reuse getAll result for INC
This commit is contained in:
parent
06823bd5b4
commit
fdbfb23547
|
@ -1,4 +1,2 @@
|
||||||
vendor/
|
vendor/
|
||||||
vendor-overwrites/*
|
vendor-overwrites/
|
||||||
!vendor-overwrites/colorpicker
|
|
||||||
!vendor-overwrites/csslint
|
|
||||||
|
|
|
@ -8,6 +8,9 @@ env:
|
||||||
es6: true
|
es6: true
|
||||||
webextensions: true
|
webextensions: true
|
||||||
|
|
||||||
|
globals:
|
||||||
|
require: readonly # in polyfill.js
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
accessor-pairs: [2]
|
accessor-pairs: [2]
|
||||||
array-bracket-spacing: [2, never]
|
array-bracket-spacing: [2, never]
|
||||||
|
@ -42,7 +45,15 @@ rules:
|
||||||
id-blacklist: [0]
|
id-blacklist: [0]
|
||||||
id-length: [0]
|
id-length: [0]
|
||||||
id-match: [0]
|
id-match: [0]
|
||||||
indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}]
|
indent: [2, 2, {
|
||||||
|
SwitchCase: 1,
|
||||||
|
ignoreComments: true,
|
||||||
|
ignoredNodes: [
|
||||||
|
"TemplateLiteral > *",
|
||||||
|
"ConditionalExpression",
|
||||||
|
"ForStatement"
|
||||||
|
]
|
||||||
|
}]
|
||||||
jsx-quotes: [0]
|
jsx-quotes: [0]
|
||||||
key-spacing: [0]
|
key-spacing: [0]
|
||||||
keyword-spacing: [2]
|
keyword-spacing: [2]
|
||||||
|
@ -86,7 +97,7 @@ rules:
|
||||||
no-empty: [2, {allowEmptyCatch: true}]
|
no-empty: [2, {allowEmptyCatch: true}]
|
||||||
no-eq-null: [0]
|
no-eq-null: [0]
|
||||||
no-eval: [2]
|
no-eval: [2]
|
||||||
no-ex-assign: [2]
|
no-ex-assign: [0]
|
||||||
no-extend-native: [2]
|
no-extend-native: [2]
|
||||||
no-extra-bind: [2]
|
no-extra-bind: [2]
|
||||||
no-extra-boolean-cast: [2]
|
no-extra-boolean-cast: [2]
|
||||||
|
@ -136,6 +147,9 @@ rules:
|
||||||
no-proto: [2]
|
no-proto: [2]
|
||||||
no-redeclare: [2]
|
no-redeclare: [2]
|
||||||
no-regex-spaces: [2]
|
no-regex-spaces: [2]
|
||||||
|
no-restricted-globals: [2, name, event]
|
||||||
|
# `name` and `event` (in Chrome) are built-in globals
|
||||||
|
# but we don't use these globals so it's most likely a mistake/typo
|
||||||
no-restricted-imports: [0]
|
no-restricted-imports: [0]
|
||||||
no-restricted-modules: [2, domain, freelist, smalloc, sys]
|
no-restricted-modules: [2, domain, freelist, smalloc, sys]
|
||||||
no-restricted-syntax: [2, WithStatement]
|
no-restricted-syntax: [2, WithStatement]
|
||||||
|
@ -163,7 +177,7 @@ rules:
|
||||||
no-unreachable: [2]
|
no-unreachable: [2]
|
||||||
no-unsafe-finally: [2]
|
no-unsafe-finally: [2]
|
||||||
no-unsafe-negation: [2]
|
no-unsafe-negation: [2]
|
||||||
no-unused-expressions: [1]
|
no-unused-expressions: [2]
|
||||||
no-unused-labels: [0]
|
no-unused-labels: [0]
|
||||||
no-unused-vars: [2, {args: after-used}]
|
no-unused-vars: [2, {args: after-used}]
|
||||||
no-use-before-define: [2, nofunc]
|
no-use-before-define: [2, nofunc]
|
||||||
|
@ -220,3 +234,7 @@ overrides:
|
||||||
webextensions: false
|
webextensions: false
|
||||||
parserOptions:
|
parserOptions:
|
||||||
ecmaVersion: 2017
|
ecmaVersion: 2017
|
||||||
|
|
||||||
|
- files: ["**/*worker*.js"]
|
||||||
|
env:
|
||||||
|
worker: true
|
||||||
|
|
|
@ -47,15 +47,15 @@ See our [contributing](./.github/CONTRIBUTING.md) page for more details.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/):
|
Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/):
|
||||||
|
|
||||||
Copyright © 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com)
|
Copyright © 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com)
|
||||||
|
|
||||||
Current Stylus:
|
Current Stylus:
|
||||||
|
|
||||||
Copyright © 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors)
|
Copyright © 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors)
|
||||||
|
|
||||||
**[GNU GPLv3](./LICENSE)**
|
**[GNU GPLv3](./LICENSE)**
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
|
|
|
@ -1,176 +1,26 @@
|
||||||
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
|
/* global createWorkerApi */// worker-util.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
importScripts('/js/worker-util.js');
|
/** @namespace BackgroundWorker */
|
||||||
const {loadScript} = workerUtil;
|
createWorkerApi({
|
||||||
|
|
||||||
workerUtil.createAPI({
|
async compileUsercss(...args) {
|
||||||
parseMozFormat(arg) {
|
require(['/js/usercss-compiler']); /* global compileUsercss */
|
||||||
loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
|
return compileUsercss(...args);
|
||||||
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) {
|
nullifyInvalidVars(vars) {
|
||||||
loadScript(
|
require(['/js/meta-parser']); /* global metaParser */
|
||||||
'/vendor/usercss-meta/usercss-meta.min.js',
|
|
||||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
|
||||||
'/js/meta-parser.js'
|
|
||||||
);
|
|
||||||
return metaParser.nullifyInvalidVars(vars);
|
return metaParser.nullifyInvalidVars(vars);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
parseMozFormat(...args) {
|
||||||
|
require(['/js/moz-parser']); /* global extractSections */
|
||||||
|
return extractSections(...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
parseUsercssMeta(text) {
|
||||||
|
require(['/js/meta-parser']);
|
||||||
|
return metaParser.parse(text);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function compileUsercss(preprocessor, code, vars) {
|
|
||||||
loadScript(
|
|
||||||
'/vendor-overwrites/csslint/parserlib.js',
|
|
||||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
|
||||||
'/js/moz-parser.js'
|
|
||||||
);
|
|
||||||
const builder = getUsercssCompiler(preprocessor);
|
|
||||||
vars = simpleVars(vars);
|
|
||||||
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code)
|
|
||||||
.then(code => parseMozFormat({code}))
|
|
||||||
.then(({sections, errors}) => {
|
|
||||||
if (builder.postprocess) {
|
|
||||||
builder.postprocess(sections, vars);
|
|
||||||
}
|
|
||||||
return {sections, errors};
|
|
||||||
});
|
|
||||||
|
|
||||||
function simpleVars(vars) {
|
|
||||||
if (!vars) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
|
|
||||||
// need to test each va's default value.
|
|
||||||
return Object.keys(vars).reduce((output, key) => {
|
|
||||||
const va = vars[key];
|
|
||||||
output[key] = Object.assign({}, va, {
|
|
||||||
value: va.value === null || va.value === undefined ?
|
|
||||||
getVarValue(va, 'default') : getVarValue(va, 'value'),
|
|
||||||
});
|
|
||||||
return output;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVarValue(va, prop) {
|
|
||||||
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
|
|
||||||
// TODO: handle customized image
|
|
||||||
return va.options.find(o => o.name === va[prop]).value;
|
|
||||||
}
|
|
||||||
if ((va.type === 'number' || va.type === 'range') && va.units) {
|
|
||||||
return va[prop] + va.units;
|
|
||||||
}
|
|
||||||
return va[prop];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUsercssCompiler(preprocessor) {
|
|
||||||
const BUILDER = {
|
|
||||||
default: {
|
|
||||||
postprocess(sections, vars) {
|
|
||||||
loadScript('/js/sections-util.js');
|
|
||||||
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
|
|
||||||
if (!varDef) return;
|
|
||||||
varDef = ':root {\n' + varDef + '}\n';
|
|
||||||
for (const section of sections) {
|
|
||||||
if (!styleCodeEmpty(section.code)) {
|
|
||||||
section.code = varDef + section.code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stylus: {
|
|
||||||
preprocess(source, vars) {
|
|
||||||
loadScript('/vendor/stylus-lang-bundle/stylus-renderer.min.js');
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
|
|
||||||
new self.StylusRenderer(varDef + source)
|
|
||||||
.render((err, output) => err ? reject(err) : resolve(output));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
less: {
|
|
||||||
preprocess(source, vars) {
|
|
||||||
if (!self.less) {
|
|
||||||
self.less = {
|
|
||||||
logLevel: 0,
|
|
||||||
useFileCache: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
loadScript('/vendor/less-bundle/less.min.js');
|
|
||||||
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
|
|
||||||
return self.less.render(varDefs + source)
|
|
||||||
.then(({css}) => css);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
uso: {
|
|
||||||
preprocess(source, vars) {
|
|
||||||
loadScript('/vendor-overwrites/colorpicker/colorconverter.js');
|
|
||||||
const pool = new Map();
|
|
||||||
return Promise.resolve(doReplace(source));
|
|
||||||
|
|
||||||
function getValue(name, rgbName) {
|
|
||||||
if (!vars.hasOwnProperty(name)) {
|
|
||||||
if (name.endsWith('-rgb')) {
|
|
||||||
return getValue(name.slice(0, -4), name);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const {type, value} = vars[name];
|
|
||||||
switch (type) {
|
|
||||||
case 'color': {
|
|
||||||
let color = pool.get(rgbName || name);
|
|
||||||
if (color == null) {
|
|
||||||
color = colorConverter.parse(value);
|
|
||||||
if (color) {
|
|
||||||
if (color.type === 'hsl') {
|
|
||||||
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
|
|
||||||
}
|
|
||||||
const {r, g, b} = color;
|
|
||||||
color = rgbName
|
|
||||||
? `${r}, ${g}, ${b}`
|
|
||||||
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
|
||||||
}
|
|
||||||
// the pool stores `false` for bad colors to differentiate from a yet unknown color
|
|
||||||
pool.set(rgbName || name, color || false);
|
|
||||||
}
|
|
||||||
return color || null;
|
|
||||||
}
|
|
||||||
case 'dropdown':
|
|
||||||
case 'select': // prevent infinite recursion
|
|
||||||
pool.set(name, '');
|
|
||||||
return doReplace(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function doReplace(text) {
|
|
||||||
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
|
|
||||||
if (!pool.has(name)) {
|
|
||||||
const value = getValue(name);
|
|
||||||
pool.set(name, value === null ? match : value);
|
|
||||||
}
|
|
||||||
return pool.get(name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (preprocessor) {
|
|
||||||
if (!BUILDER[preprocessor]) {
|
|
||||||
throw new Error('unknwon preprocessor');
|
|
||||||
}
|
|
||||||
return BUILDER[preprocessor];
|
|
||||||
}
|
|
||||||
return BUILDER.default;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,72 +1,105 @@
|
||||||
/* global download prefs openURL FIREFOX CHROME
|
/* global API msg */// msg.js
|
||||||
URLS ignoreChromeError chromeLocal semverCompare
|
/* global addAPI bgReady */// common.js
|
||||||
styleManager msg navigatorUtil workerUtil contentScripts sync
|
/* global createWorker */// worker-util.js
|
||||||
findExistingTab activateTab isTabReplaceable getActiveTab
|
/* global prefs */
|
||||||
*/
|
/* global styleMan */
|
||||||
|
/* global syncMan */
|
||||||
|
/* global updateMan */
|
||||||
|
/* global usercssMan */
|
||||||
|
/* global
|
||||||
|
FIREFOX
|
||||||
|
URLS
|
||||||
|
activateTab
|
||||||
|
download
|
||||||
|
findExistingTab
|
||||||
|
getActiveTab
|
||||||
|
isTabReplaceable
|
||||||
|
openURL
|
||||||
|
*/ // toolbox.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// eslint-disable-next-line no-var
|
//#region API
|
||||||
var backgroundWorker = workerUtil.createWorker({
|
|
||||||
url: '/background/background-worker.js',
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-var
|
addAPI(/** @namespace API */ {
|
||||||
var browserCommands, contextMenus;
|
|
||||||
|
|
||||||
// *************************************************************************
|
styles: styleMan,
|
||||||
// browser commands
|
sync: syncMan,
|
||||||
browserCommands = {
|
updater: updateMan,
|
||||||
openManage,
|
usercss: usercssMan,
|
||||||
openOptions: () => openManage({options: true}),
|
/** @type {BackgroundWorker} */
|
||||||
styleDisableAll(info) {
|
worker: createWorker({url: '/background/background-worker'}),
|
||||||
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
|
|
||||||
|
download(url, opts) {
|
||||||
|
return typeof url === 'string' && url.startsWith(URLS.uso) &&
|
||||||
|
this.sender.url.startsWith(URLS.uso) &&
|
||||||
|
download(url, opts || {});
|
||||||
},
|
},
|
||||||
reload: () => chrome.runtime.reload(),
|
|
||||||
};
|
|
||||||
|
|
||||||
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
|
|
||||||
deleteStyle: styleManager.deleteStyle,
|
|
||||||
editSave: styleManager.editSave,
|
|
||||||
findStyle: styleManager.findStyle,
|
|
||||||
getAllStyles: styleManager.getAllStyles, // used by importer
|
|
||||||
getSectionsByUrl: styleManager.getSectionsByUrl,
|
|
||||||
getStyle: styleManager.get,
|
|
||||||
getStylesByUrl: styleManager.getStylesByUrl,
|
|
||||||
importStyle: styleManager.importStyle,
|
|
||||||
importManyStyles: styleManager.importMany,
|
|
||||||
installStyle: styleManager.installStyle,
|
|
||||||
styleExists: styleManager.styleExists,
|
|
||||||
toggleStyle: styleManager.toggleStyle,
|
|
||||||
|
|
||||||
addInclusion: styleManager.addInclusion,
|
|
||||||
removeInclusion: styleManager.removeInclusion,
|
|
||||||
addExclusion: styleManager.addExclusion,
|
|
||||||
removeExclusion: styleManager.removeExclusion,
|
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
getTabUrlPrefix() {
|
getTabUrlPrefix() {
|
||||||
const {url} = this.sender.tab;
|
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
|
||||||
if (url.startsWith(URLS.ownOrigin)) {
|
},
|
||||||
return 'stylus';
|
|
||||||
|
/**
|
||||||
|
* Opens the editor or activates an existing tab
|
||||||
|
* @param {{
|
||||||
|
id?: number
|
||||||
|
domain?: string
|
||||||
|
'url-prefix'?: string
|
||||||
|
}} params
|
||||||
|
* @returns {Promise<chrome.tabs.Tab>}
|
||||||
|
*/
|
||||||
|
async openEditor(params) {
|
||||||
|
const u = new URL(chrome.runtime.getURL('edit.html'));
|
||||||
|
u.search = new URLSearchParams(params);
|
||||||
|
const wnd = prefs.get('openEditInWindow');
|
||||||
|
const wndPos = wnd && prefs.get('windowPosition');
|
||||||
|
const wndBase = wnd && prefs.get('openEditInWindow.popup') ? {type: 'popup'} : {};
|
||||||
|
const ffBug = wnd && FIREFOX; // https://bugzil.la/1271047
|
||||||
|
const tab = await openURL({
|
||||||
|
url: `${u}`,
|
||||||
|
currentWindow: null,
|
||||||
|
newWindow: Object.assign(wndBase, !ffBug && wndPos),
|
||||||
|
});
|
||||||
|
if (ffBug) await browser.windows.update(tab.windowId, wndPos);
|
||||||
|
return tab;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @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}`;
|
||||||
}
|
}
|
||||||
return url.match(/^([\w-]+:\/+[^/#]+)/)[1];
|
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
|
||||||
},
|
},
|
||||||
|
|
||||||
download(msg) {
|
/**
|
||||||
delete msg.method;
|
* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent
|
||||||
return download(msg.url, msg);
|
* 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
|
||||||
parseCss({code}) {
|
* popup (in Chrome at least) and preventing the sendMessage code from running
|
||||||
return backgroundWorker.parseMozFormat({code});
|
* @returns {Promise<chrome.tabs.Tab>}
|
||||||
},
|
*/
|
||||||
getPrefs: () => prefs.values,
|
|
||||||
setPref: (key, value) => prefs.set(key, value),
|
|
||||||
|
|
||||||
openEditor,
|
|
||||||
|
|
||||||
/* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent when the tab is ready,
|
|
||||||
which is needed in the popup, otherwise another extension could force the tab to open in foreground
|
|
||||||
thus auto-closing the popup (in Chrome at least) and preventing the sendMessage code from running */
|
|
||||||
async openURL(opts) {
|
async openURL(opts) {
|
||||||
const tab = await openURL(opts);
|
const tab = await openURL(opts);
|
||||||
if (opts.message) {
|
if (opts.message) {
|
||||||
|
@ -87,251 +120,62 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
optionsCustomizeHotkeys() {
|
prefs: {
|
||||||
return browserCommands.openOptions()
|
getValues: () => prefs.__values, // will be deepCopy'd by apiHandler
|
||||||
.then(() => new Promise(resolve => setTimeout(resolve, 500)))
|
set: prefs.set,
|
||||||
.then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'}));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
syncStart: sync.start,
|
|
||||||
syncStop: sync.stop,
|
|
||||||
syncNow: sync.syncNow,
|
|
||||||
getSyncStatus: sync.getStatus,
|
|
||||||
syncLogin: sync.login,
|
|
||||||
|
|
||||||
openManage,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// *************************************************************************
|
//#endregion
|
||||||
// register all listeners
|
//#region Events
|
||||||
msg.on(onRuntimeMessage);
|
|
||||||
|
|
||||||
// tell apply.js to refresh styles for non-committed navigation
|
const browserCommands = {
|
||||||
navigatorUtil.onUrlChange(({tabId, frameId}, type) => {
|
openManage: () => API.openManage(),
|
||||||
if (type !== 'committed') {
|
openOptions: () => API.openManage({options: true}),
|
||||||
msg.sendTab(tabId, {method: 'urlChanged'}, {frameId})
|
reload: () => chrome.runtime.reload(),
|
||||||
.catch(msg.ignoreError);
|
styleDisableAll(info) {
|
||||||
}
|
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
|
||||||
});
|
|
||||||
|
|
||||||
if (FIREFOX) {
|
|
||||||
// FF misses some about:blank iframes so we inject our content script explicitly
|
|
||||||
navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, {
|
|
||||||
url: [
|
|
||||||
{urlEquals: 'about:blank'},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chrome.contextMenus) {
|
|
||||||
chrome.contextMenus.onClicked.addListener((info, tab) =>
|
|
||||||
contextMenus[info.menuItemId].click(info, tab));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chrome.commands) {
|
|
||||||
// Not available in Firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1240350
|
|
||||||
chrome.commands.onCommand.addListener(command => browserCommands[command]());
|
|
||||||
}
|
|
||||||
|
|
||||||
// *************************************************************************
|
|
||||||
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
|
|
||||||
if (reason !== 'update') return;
|
|
||||||
if (semverCompare(previousVersion, '1.5.13') <= 0) {
|
|
||||||
// Removing unused stuff
|
|
||||||
// TODO: delete this entire block by the middle of 2021
|
|
||||||
try {
|
|
||||||
localStorage.clear();
|
|
||||||
} catch (e) {}
|
|
||||||
setTimeout(async () => {
|
|
||||||
const del = Object.keys(await chromeLocal.get())
|
|
||||||
.filter(key => key.startsWith('usoSearchCache'));
|
|
||||||
if (del.length) chromeLocal.remove(del);
|
|
||||||
}, 15e3);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// *************************************************************************
|
|
||||||
// context menus
|
|
||||||
contextMenus = {
|
|
||||||
'show-badge': {
|
|
||||||
title: 'menuShowBadge',
|
|
||||||
click: info => prefs.set(info.menuItemId, info.checked),
|
|
||||||
},
|
|
||||||
'disableAll': {
|
|
||||||
title: 'disableAllStyles',
|
|
||||||
click: browserCommands.styleDisableAll,
|
|
||||||
},
|
|
||||||
'open-manager': {
|
|
||||||
title: 'openStylesManager',
|
|
||||||
click: browserCommands.openManage,
|
|
||||||
},
|
|
||||||
'open-options': {
|
|
||||||
title: 'openOptions',
|
|
||||||
click: browserCommands.openOptions,
|
|
||||||
},
|
|
||||||
'reload': {
|
|
||||||
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
|
|
||||||
title: 'reload',
|
|
||||||
click: browserCommands.reload,
|
|
||||||
},
|
|
||||||
'editor.contextDelete': {
|
|
||||||
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
|
|
||||||
title: 'editDeleteText',
|
|
||||||
type: 'normal',
|
|
||||||
contexts: ['editable'],
|
|
||||||
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
|
|
||||||
click: (info, tab) => {
|
|
||||||
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
|
|
||||||
.catch(msg.ignoreError);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createContextMenus(ids) {
|
if (chrome.commands) {
|
||||||
for (const id of ids) {
|
chrome.commands.onCommand.addListener(id => browserCommands[id]());
|
||||||
let item = contextMenus[id];
|
}
|
||||||
if (item.presentIf && !await item.presentIf()) {
|
|
||||||
continue;
|
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
|
||||||
|
if (reason === 'update') {
|
||||||
|
const [a, b, c] = (previousVersion || '').split('.');
|
||||||
|
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
|
||||||
|
require(['/background/remove-unused-storage']);
|
||||||
}
|
}
|
||||||
item = Object.assign({id}, item);
|
|
||||||
delete item.presentIf;
|
|
||||||
item.title = chrome.i18n.getMessage(item.title);
|
|
||||||
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
|
|
||||||
item.type = 'checkbox';
|
|
||||||
item.checked = prefs.get(id);
|
|
||||||
}
|
|
||||||
if (!item.contexts) {
|
|
||||||
item.contexts = ['browser_action'];
|
|
||||||
}
|
|
||||||
delete item.click;
|
|
||||||
chrome.contextMenus.create(item, ignoreChromeError);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if (chrome.contextMenus) {
|
msg.on((msg, sender) => {
|
||||||
// "Delete" item in context menu for browsers that don't have it
|
if (msg.method === 'invokeAPI') {
|
||||||
if (CHROME &&
|
let res = msg.path.reduce((res, name) => res && res[name], API);
|
||||||
// looking at the end of UA string
|
if (!res) throw new Error(`Unknown API.${msg.path.join('.')}`);
|
||||||
/(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) &&
|
res = res.apply({msg, sender}, msg.args);
|
||||||
// skip forks with Flash as those are likely to have the menu e.g. CentBrowser
|
return res === undefined ? null : res;
|
||||||
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) {
|
|
||||||
prefs.defaults['editor.contextDelete'] = true;
|
|
||||||
}
|
}
|
||||||
// circumvent the bug with disabling check marks in Chrome 62-64
|
});
|
||||||
const toggleCheckmark = CHROME >= 62 && CHROME <= 64 ?
|
|
||||||
(id => chrome.contextMenus.remove(id, () => createContextMenus([id]) + ignoreChromeError())) :
|
|
||||||
((id, checked) => chrome.contextMenus.update(id, {checked}, ignoreChromeError));
|
|
||||||
|
|
||||||
const togglePresence = (id, checked) => {
|
//#endregion
|
||||||
if (checked) {
|
|
||||||
createContextMenus([id]);
|
|
||||||
} else {
|
|
||||||
chrome.contextMenus.remove(id, ignoreChromeError);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const keys = Object.keys(contextMenus);
|
Promise.all([
|
||||||
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark);
|
bgReady.styles,
|
||||||
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && id in prefs.defaults), togglePresence);
|
/* These are loaded conditionally.
|
||||||
createContextMenus(keys);
|
Each item uses `require` individually so IDE can jump to the source and track usage. */
|
||||||
}
|
FIREFOX &&
|
||||||
|
require(['/background/style-via-api']),
|
||||||
// reinject content scripts when the extension is reloaded/updated. Firefox
|
FIREFOX && ((browser.commands || {}).update) &&
|
||||||
// would handle this automatically.
|
require(['/background/browser-cmd-hotkeys']),
|
||||||
if (!FIREFOX) {
|
!FIREFOX &&
|
||||||
setTimeout(contentScripts.injectToAllTabs, 0);
|
require(['/background/content-scripts']),
|
||||||
}
|
chrome.contextMenus &&
|
||||||
|
require(['/background/context-menus']),
|
||||||
// register hotkeys
|
]).then(() => {
|
||||||
if (FIREFOX && browser.commands && browser.commands.update) {
|
bgReady._resolveAll();
|
||||||
const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.'));
|
msg.isBgReady = true;
|
||||||
prefs.subscribe(hotkeyPrefs, (name, value) => {
|
msg.broadcast({method: 'backgroundReady'});
|
||||||
try {
|
});
|
||||||
name = name.split('.')[1];
|
|
||||||
if (value.trim()) {
|
|
||||||
browser.commands.update({name, shortcut: value});
|
|
||||||
} else {
|
|
||||||
browser.commands.reset(name);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.broadcast({method: 'backgroundReady'});
|
|
||||||
|
|
||||||
function webNavIframeHelperFF({tabId, frameId}) {
|
|
||||||
if (!frameId) return;
|
|
||||||
msg.sendTab(tabId, {method: 'ping'}, {frameId})
|
|
||||||
.catch(() => false)
|
|
||||||
.then(pong => {
|
|
||||||
if (pong) return;
|
|
||||||
// insert apply.js to iframe
|
|
||||||
const files = chrome.runtime.getManifest().content_scripts[0].js;
|
|
||||||
for (const file of files) {
|
|
||||||
chrome.tabs.executeScript(tabId, {
|
|
||||||
frameId,
|
|
||||||
file,
|
|
||||||
matchAboutBlank: true,
|
|
||||||
}, ignoreChromeError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRuntimeMessage(msg, sender) {
|
|
||||||
if (msg.method !== 'invokeAPI') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fn = window.API_METHODS[msg.name];
|
|
||||||
if (!fn) {
|
|
||||||
throw new Error(`unknown API: ${msg.name}`);
|
|
||||||
}
|
|
||||||
const res = fn.apply({msg, sender}, msg.args);
|
|
||||||
return res === undefined ? null : res;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditor(params) {
|
|
||||||
/* Open the editor. Activate if it is already opened
|
|
||||||
|
|
||||||
params: {
|
|
||||||
id?: Number,
|
|
||||||
domain?: String,
|
|
||||||
'url-prefix'?: String
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
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')),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function 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});
|
|
||||||
}
|
|
||||||
|
|
22
background/browser-cmd-hotkeys.js
Normal file
22
background/browser-cmd-hotkeys.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/* global prefs */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Registers hotkeys in FF
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const hotkeyPrefs = prefs.knownKeys.filter(k => k.startsWith('hotkey.'));
|
||||||
|
prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true});
|
||||||
|
|
||||||
|
async function updateHotkey(name, value) {
|
||||||
|
try {
|
||||||
|
name = name.split('.')[1];
|
||||||
|
if (value.trim()) {
|
||||||
|
await browser.commands.update({name, shortcut: value});
|
||||||
|
} else {
|
||||||
|
await browser.commands.reset(name);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
})();
|
31
background/common.js
Normal file
31
background/common.js
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
/* global API */// msg.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common stuff that's loaded first so it's immediately available to all background scripts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* exported
|
||||||
|
addAPI
|
||||||
|
bgReady
|
||||||
|
compareRevision
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bgReady = {};
|
||||||
|
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
|
||||||
|
bgReady.all = new Promise(r => (bgReady._resolveAll = r));
|
||||||
|
|
||||||
|
function addAPI(methods) {
|
||||||
|
for (const [key, val] of Object.entries(methods)) {
|
||||||
|
const old = API[key];
|
||||||
|
if (old && Object.prototype.toString.call(old) === '[object Object]') {
|
||||||
|
Object.assign(old, val);
|
||||||
|
} else {
|
||||||
|
API[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRevision(rev1, rev2) {
|
||||||
|
return rev1 - rev2;
|
||||||
|
}
|
|
@ -1,15 +1,21 @@
|
||||||
/* global msg ignoreChromeError URLS */
|
/* global bgReady */// common.js
|
||||||
/* exported contentScripts */
|
/* global msg */
|
||||||
|
/* global URLS ignoreChromeError */// toolbox.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const contentScripts = (() => {
|
/*
|
||||||
|
Reinject content scripts when the extension is reloaded/updated.
|
||||||
|
Not used in Firefox as it reinjects automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
bgReady.all.then(() => {
|
||||||
const NTP = 'chrome://newtab/';
|
const NTP = 'chrome://newtab/';
|
||||||
const ALL_URLS = '<all_urls>';
|
const ALL_URLS = '<all_urls>';
|
||||||
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
|
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
|
||||||
// expand * as .*?
|
// expand * as .*?
|
||||||
const wildcardAsRegExp = (s, flags) => new RegExp(
|
const wildcardAsRegExp = (s, flags) => new RegExp(
|
||||||
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
|
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
|
||||||
.replace(/\*/g, '.*?'), flags);
|
.replace(/\*/g, '.*?'), flags);
|
||||||
for (const cs of SCRIPTS) {
|
for (const cs of SCRIPTS) {
|
||||||
cs.matches = cs.matches.map(m => (
|
cs.matches = cs.matches.map(m => (
|
||||||
m === ALL_URLS ? m : wildcardAsRegExp(m)
|
m === ALL_URLS ? m : wildcardAsRegExp(m)
|
||||||
|
@ -18,21 +24,7 @@ const contentScripts = (() => {
|
||||||
const busyTabs = new Set();
|
const busyTabs = new Set();
|
||||||
let busyTabsTimer;
|
let busyTabsTimer;
|
||||||
|
|
||||||
// expose version on greasyfork/sleazyfork 1) info page and 2) code page
|
setTimeout(injectToAllTabs);
|
||||||
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
|
|
||||||
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
|
|
||||||
chrome.tabs.executeScript(tabId, {
|
|
||||||
file: '/content/install-hook-greasyfork.js',
|
|
||||||
runAt: 'document_start',
|
|
||||||
});
|
|
||||||
}, {
|
|
||||||
url: [
|
|
||||||
{hostEquals: 'greasyfork.org', urlMatches},
|
|
||||||
{hostEquals: 'sleazyfork.org', urlMatches},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {injectToTab, injectToAllTabs};
|
|
||||||
|
|
||||||
function injectToTab({url, tabId, frameId = null}) {
|
function injectToTab({url, tabId, frameId = null}) {
|
||||||
for (const script of SCRIPTS) {
|
for (const script of SCRIPTS) {
|
||||||
|
@ -122,4 +114,4 @@ const contentScripts = (() => {
|
||||||
function onBusyTabRemoved(tabId) {
|
function onBusyTabRemoved(tabId) {
|
||||||
trackBusyTab(tabId, false);
|
trackBusyTab(tabId, false);
|
||||||
}
|
}
|
||||||
})();
|
});
|
||||||
|
|
101
background/context-menus.js
Normal file
101
background/context-menus.js
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
/* global browserCommands */// background.js
|
||||||
|
/* global msg */
|
||||||
|
/* global prefs */
|
||||||
|
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const contextMenus = {
|
||||||
|
'show-badge': {
|
||||||
|
title: 'menuShowBadge',
|
||||||
|
click: info => prefs.set(info.menuItemId, info.checked),
|
||||||
|
},
|
||||||
|
'disableAll': {
|
||||||
|
title: 'disableAllStyles',
|
||||||
|
click: browserCommands.styleDisableAll,
|
||||||
|
},
|
||||||
|
'open-manager': {
|
||||||
|
title: 'openStylesManager',
|
||||||
|
click: browserCommands.openManage,
|
||||||
|
},
|
||||||
|
'open-options': {
|
||||||
|
title: 'openOptions',
|
||||||
|
click: browserCommands.openOptions,
|
||||||
|
},
|
||||||
|
'reload': {
|
||||||
|
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
|
||||||
|
title: 'reload',
|
||||||
|
click: browserCommands.reload,
|
||||||
|
},
|
||||||
|
'editor.contextDelete': {
|
||||||
|
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
|
||||||
|
title: 'editDeleteText',
|
||||||
|
type: 'normal',
|
||||||
|
contexts: ['editable'],
|
||||||
|
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
|
||||||
|
click: (info, tab) => {
|
||||||
|
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
|
||||||
|
.catch(msg.ignoreError);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Delete" item in context menu for browsers that don't have it
|
||||||
|
if (CHROME &&
|
||||||
|
// looking at the end of UA string
|
||||||
|
/(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) &&
|
||||||
|
// skip forks with Flash as those are likely to have the menu e.g. CentBrowser
|
||||||
|
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) {
|
||||||
|
prefs.__defaults['editor.contextDelete'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(contextMenus);
|
||||||
|
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'),
|
||||||
|
CHROME >= 62 && CHROME <= 64 ? toggleCheckmarkBugged : toggleCheckmark);
|
||||||
|
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)),
|
||||||
|
togglePresence);
|
||||||
|
|
||||||
|
createContextMenus(keys);
|
||||||
|
|
||||||
|
chrome.contextMenus.onClicked.addListener((info, tab) =>
|
||||||
|
contextMenus[info.menuItemId].click(info, tab));
|
||||||
|
|
||||||
|
async function createContextMenus(ids) {
|
||||||
|
for (const id of ids) {
|
||||||
|
let item = contextMenus[id];
|
||||||
|
if (item.presentIf && !await item.presentIf()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item = Object.assign({id}, item);
|
||||||
|
delete item.presentIf;
|
||||||
|
item.title = chrome.i18n.getMessage(item.title);
|
||||||
|
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
|
||||||
|
item.type = 'checkbox';
|
||||||
|
item.checked = prefs.get(id);
|
||||||
|
}
|
||||||
|
if (!item.contexts) {
|
||||||
|
item.contexts = ['browser_action'];
|
||||||
|
}
|
||||||
|
delete item.click;
|
||||||
|
chrome.contextMenus.create(item, ignoreChromeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheckmark(id, checked) {
|
||||||
|
chrome.contextMenus.update(id, {checked}, ignoreChromeError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Circumvents the bug with disabling check marks in Chrome 62-64 */
|
||||||
|
async function toggleCheckmarkBugged(id) {
|
||||||
|
await browser.contextMenus.remove(id).catch(ignoreChromeError);
|
||||||
|
createContextMenus([id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePresence(id, checked) {
|
||||||
|
if (checked) {
|
||||||
|
createContextMenus([id]);
|
||||||
|
} else {
|
||||||
|
chrome.contextMenus.remove(id, ignoreChromeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
|
@ -1,67 +1,66 @@
|
||||||
/* global chromeLocal */
|
/* global chromeLocal */// storage-util.js
|
||||||
/* exported createChromeStorageDB */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
/* exported createChromeStorageDB */
|
||||||
function createChromeStorageDB() {
|
function createChromeStorageDB() {
|
||||||
let INC;
|
let INC;
|
||||||
|
|
||||||
const PREFIX = 'style-';
|
const PREFIX = 'style-';
|
||||||
const METHODS = {
|
const METHODS = {
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
return chromeLocal.remove(PREFIX + id);
|
||||||
|
},
|
||||||
|
|
||||||
// FIXME: we don't use this method at all. Should we remove this?
|
// FIXME: we don't use this method at all. Should we remove this?
|
||||||
get: id => chromeLocal.getValue(PREFIX + id),
|
get(id) {
|
||||||
put: obj =>
|
return chromeLocal.getValue(PREFIX + id);
|
||||||
// FIXME: should we clone the object?
|
},
|
||||||
Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++})))
|
|
||||||
.then(() => chromeLocal.setValue(PREFIX + obj.id, obj))
|
async getAll() {
|
||||||
.then(() => obj.id),
|
const all = await chromeLocal.get();
|
||||||
putMany: items => prepareInc()
|
if (!INC) prepareInc(all);
|
||||||
.then(() =>
|
return Object.entries(all)
|
||||||
chromeLocal.set(items.reduce((data, item) => {
|
.map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
|
||||||
if (!item.id) item.id = INC++;
|
.filter(Boolean);
|
||||||
data[PREFIX + item.id] = item;
|
},
|
||||||
return data;
|
|
||||||
}, {})))
|
async put(item) {
|
||||||
.then(() => items.map(i => i.id)),
|
if (!item.id) {
|
||||||
delete: id => chromeLocal.remove(PREFIX + id),
|
if (!INC) await prepareInc();
|
||||||
getAll: () => chromeLocal.get()
|
item.id = INC++;
|
||||||
.then(result => {
|
}
|
||||||
const output = [];
|
await chromeLocal.setValue(PREFIX + item.id, item);
|
||||||
for (const key in result) {
|
return item.id;
|
||||||
if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) {
|
},
|
||||||
output.push(result[key]);
|
|
||||||
}
|
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};
|
async function prepareInc(data) {
|
||||||
|
INC = 1;
|
||||||
function exec(method, ...args) {
|
for (const key in data || await chromeLocal.get()) {
|
||||||
if (METHODS[method]) {
|
if (key.startsWith(PREFIX)) {
|
||||||
return METHODS[method](...args)
|
const id = Number(key.slice(PREFIX.length));
|
||||||
.then(result => {
|
if (id >= INC) {
|
||||||
if (method === 'putMany' && result.map) {
|
INC = id + 1;
|
||||||
return result.map(r => ({target: {result: r}}));
|
|
||||||
}
|
|
||||||
return {target: {result}};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`unknown DB method ${method}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareInc() {
|
|
||||||
if (INC) return Promise.resolve();
|
|
||||||
return chromeLocal.get().then(result => {
|
|
||||||
INC = 1;
|
|
||||||
for (const key in result) {
|
|
||||||
if (key.startsWith(PREFIX)) {
|
|
||||||
const id = Number(key.slice(PREFIX.length));
|
|
||||||
if (id >= INC) {
|
|
||||||
INC = id + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return function dbExecChromeStorage(method, ...args) {
|
||||||
|
return METHODS[method](...args);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
/* global chromeLocal workerUtil createChromeStorageDB */
|
/* global chromeLocal */// storage-util.js
|
||||||
/* exported db */
|
/* global cloneError */// worker-util.js
|
||||||
/*
|
|
||||||
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';
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Initialize a database. There are some problems using IndexedDB in Firefox:
|
||||||
|
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
|
||||||
|
Some of them are fixed in FF59:
|
||||||
|
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* exported db */
|
||||||
const db = (() => {
|
const db = (() => {
|
||||||
const DATABASE = 'stylish';
|
const DATABASE = 'stylish';
|
||||||
const STORE = 'styles';
|
const STORE = 'styles';
|
||||||
|
@ -33,32 +34,25 @@ const db = (() => {
|
||||||
case false: break;
|
case false: break;
|
||||||
default: await testDB();
|
default: await testDB();
|
||||||
}
|
}
|
||||||
return useIndexedDB();
|
chromeLocal.setValue(FALLBACK, false);
|
||||||
|
return dbExecIndexedDB;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testDB() {
|
async function testDB() {
|
||||||
let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1);
|
|
||||||
// throws if result is null
|
|
||||||
e = e.target.result[0];
|
|
||||||
const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
|
const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
|
||||||
await dbExecIndexedDB('put', {id});
|
await dbExecIndexedDB('put', {id});
|
||||||
e = await dbExecIndexedDB('get', id);
|
const e = await dbExecIndexedDB('get', id);
|
||||||
// throws if result or id is null
|
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
|
||||||
await dbExecIndexedDB('delete', e.target.result.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useChromeStorage(err) {
|
async function useChromeStorage(err) {
|
||||||
chromeLocal.setValue(FALLBACK, true);
|
chromeLocal.setValue(FALLBACK, true);
|
||||||
if (err) {
|
if (err) {
|
||||||
chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
|
chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err));
|
||||||
console.warn('Failed to access indexedDB. Switched to storage API.', err);
|
console.warn('Failed to access indexedDB. Switched to storage API.', err);
|
||||||
}
|
}
|
||||||
return createChromeStorageDB().exec;
|
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
|
||||||
}
|
return createChromeStorageDB();
|
||||||
|
|
||||||
function useIndexedDB() {
|
|
||||||
chromeLocal.setValue(FALLBACK, false);
|
|
||||||
return dbExecIndexedDB;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dbExecIndexedDB(method, ...args) {
|
async function dbExecIndexedDB(method, ...args) {
|
||||||
|
@ -70,8 +64,9 @@ const db = (() => {
|
||||||
|
|
||||||
function storeRequest(store, method, ...args) {
|
function storeRequest(store, method, ...args) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
/** @type {IDBRequest} */
|
||||||
const request = store[method](...args);
|
const request = store[method](...args);
|
||||||
request.onsuccess = resolve;
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = reject;
|
request.onerror = reject;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,48 +1,36 @@
|
||||||
/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API_METHODS */
|
/* global API */// msg.js
|
||||||
/* exported iconManager */
|
/* global addAPI bgReady */// common.js
|
||||||
|
/* global prefs */
|
||||||
|
/* global tabMan */
|
||||||
|
/* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const iconManager = (() => {
|
(() => {
|
||||||
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
|
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
|
||||||
const staleBadges = new Set();
|
const staleBadges = new Set();
|
||||||
|
const imageDataCache = new Map();
|
||||||
|
// https://github.com/openstyles/stylus/issues/335
|
||||||
|
let hasCanvas = loadImage(`/images/icon/${ICON_SIZES[0]}.png`)
|
||||||
|
.then(({data}) => (hasCanvas = data.some(b => b !== 255)));
|
||||||
|
|
||||||
prefs.subscribe([
|
addAPI(/** @namespace API */ {
|
||||||
'disableAll',
|
/**
|
||||||
'badgeDisabled',
|
* @param {(number|string)[]} styleIds
|
||||||
'badgeNormal',
|
* @param {boolean} [lazyBadge=false] preventing flicker during page load
|
||||||
], () => debounce(refreshIconBadgeColor));
|
*/
|
||||||
|
|
||||||
prefs.subscribe([
|
|
||||||
'show-badge',
|
|
||||||
], () => debounce(refreshAllIconsBadgeText));
|
|
||||||
|
|
||||||
prefs.subscribe([
|
|
||||||
'disableAll',
|
|
||||||
'iconset',
|
|
||||||
], () => debounce(refreshAllIcons));
|
|
||||||
|
|
||||||
prefs.initializing.then(() => {
|
|
||||||
refreshIconBadgeColor();
|
|
||||||
refreshAllIconsBadgeText();
|
|
||||||
refreshAllIcons();
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(API_METHODS, {
|
|
||||||
/** @param {(number|string)[]} styleIds
|
|
||||||
* @param {boolean} [lazyBadge=false] preventing flicker during page load */
|
|
||||||
updateIconBadge(styleIds, {lazyBadge} = {}) {
|
updateIconBadge(styleIds, {lazyBadge} = {}) {
|
||||||
// FIXME: in some cases, we only have to redraw the badge. is it worth a optimization?
|
// FIXME: in some cases, we only have to redraw the badge. is it worth a optimization?
|
||||||
const {frameId, tab: {id: tabId}} = this.sender;
|
const {frameId, tab: {id: tabId}} = this.sender;
|
||||||
const value = styleIds.length ? styleIds.map(Number) : undefined;
|
const value = styleIds.length ? styleIds.map(Number) : undefined;
|
||||||
tabManager.set(tabId, 'styleIds', frameId, value);
|
tabMan.set(tabId, 'styleIds', frameId, value);
|
||||||
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
|
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
|
||||||
staleBadges.add(tabId);
|
staleBadges.add(tabId);
|
||||||
if (!frameId) refreshIcon(tabId, true);
|
if (!frameId) refreshIcon(tabId, true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
navigatorUtil.onCommitted(({tabId, frameId}) => {
|
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
|
||||||
if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
|
if (!frameId) tabMan.set(tabId, 'styleIds', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
chrome.runtime.onConnect.addListener(port => {
|
chrome.runtime.onConnect.addListener(port => {
|
||||||
|
@ -51,15 +39,30 @@ const iconManager = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bgReady.all.then(() => {
|
||||||
|
prefs.subscribe([
|
||||||
|
'disableAll',
|
||||||
|
'badgeDisabled',
|
||||||
|
'badgeNormal',
|
||||||
|
], () => debounce(refreshIconBadgeColor), {runNow: true});
|
||||||
|
prefs.subscribe([
|
||||||
|
'show-badge',
|
||||||
|
], () => debounce(refreshAllIconsBadgeText), {runNow: true});
|
||||||
|
prefs.subscribe([
|
||||||
|
'disableAll',
|
||||||
|
'iconset',
|
||||||
|
], () => debounce(refreshAllIcons), {runNow: true});
|
||||||
|
});
|
||||||
|
|
||||||
function onPortDisconnected({sender}) {
|
function onPortDisconnected({sender}) {
|
||||||
if (tabManager.get(sender.tab.id, 'styleIds')) {
|
if (tabMan.get(sender.tab.id, 'styleIds')) {
|
||||||
API_METHODS.updateIconBadge.call({sender}, [], {lazyBadge: true});
|
API.updateIconBadge.call({sender}, [], {lazyBadge: true});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshIconBadgeText(tabId) {
|
function refreshIconBadgeText(tabId) {
|
||||||
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
|
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
|
||||||
iconUtil.setBadgeText({tabId, text});
|
setBadgeText({tabId, text});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIconName(hasStyles = false) {
|
function getIconName(hasStyles = false) {
|
||||||
|
@ -69,15 +72,15 @@ const iconManager = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshIcon(tabId, force = false) {
|
function refreshIcon(tabId, force = false) {
|
||||||
const oldIcon = tabManager.get(tabId, 'icon');
|
const oldIcon = tabMan.get(tabId, 'icon');
|
||||||
const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0));
|
const newIcon = getIconName(tabMan.get(tabId, 'styleIds', 0));
|
||||||
// (changing the icon only for the main page, frameId = 0)
|
// (changing the icon only for the main page, frameId = 0)
|
||||||
|
|
||||||
if (!force && oldIcon === newIcon) {
|
if (!force && oldIcon === newIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tabManager.set(tabId, 'icon', newIcon);
|
tabMan.set(tabId, 'icon', newIcon);
|
||||||
iconUtil.setIcon({
|
setIcon({
|
||||||
path: getIconPath(newIcon),
|
path: getIconPath(newIcon),
|
||||||
tabId,
|
tabId,
|
||||||
});
|
});
|
||||||
|
@ -96,33 +99,55 @@ const iconManager = (() => {
|
||||||
/** @return {number | ''} */
|
/** @return {number | ''} */
|
||||||
function getStyleCount(tabId) {
|
function getStyleCount(tabId) {
|
||||||
const allIds = new Set();
|
const allIds = new Set();
|
||||||
const data = tabManager.get(tabId, 'styleIds') || {};
|
const data = tabMan.get(tabId, 'styleIds') || {};
|
||||||
Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id)));
|
Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id)));
|
||||||
return allIds.size || '';
|
return allIds.size || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caches imageData for icon paths
|
||||||
|
async function loadImage(url) {
|
||||||
|
const {OffscreenCanvas} = self.createImageBitmap && self || {};
|
||||||
|
const img = OffscreenCanvas
|
||||||
|
? await createImageBitmap(await (await fetch(url)).blob())
|
||||||
|
: await new Promise((resolve, reject) =>
|
||||||
|
Object.assign(new Image(), {
|
||||||
|
src: url,
|
||||||
|
onload: e => resolve(e.target),
|
||||||
|
onerror: reject,
|
||||||
|
}));
|
||||||
|
const {width: w, height: h} = img;
|
||||||
|
const canvas = OffscreenCanvas
|
||||||
|
? new OffscreenCanvas(w, h)
|
||||||
|
: Object.assign(document.createElement('canvas'), {width: w, height: h});
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
const result = ctx.getImageData(0, 0, w, h);
|
||||||
|
imageDataCache.set(url, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshGlobalIcon() {
|
function refreshGlobalIcon() {
|
||||||
iconUtil.setIcon({
|
setIcon({
|
||||||
path: getIconPath(getIconName()),
|
path: getIconPath(getIconName()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshIconBadgeColor() {
|
function refreshIconBadgeColor() {
|
||||||
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
|
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
|
||||||
iconUtil.setBadgeBackgroundColor({
|
setBadgeBackgroundColor({
|
||||||
color,
|
color,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshAllIcons() {
|
function refreshAllIcons() {
|
||||||
for (const tabId of tabManager.list()) {
|
for (const tabId of tabMan.list()) {
|
||||||
refreshIcon(tabId);
|
refreshIcon(tabId);
|
||||||
}
|
}
|
||||||
refreshGlobalIcon();
|
refreshGlobalIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshAllIconsBadgeText() {
|
function refreshAllIconsBadgeText() {
|
||||||
for (const tabId of tabManager.list()) {
|
for (const tabId of tabMan.list()) {
|
||||||
refreshIconBadgeText(tabId);
|
refreshIconBadgeText(tabId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -133,4 +158,40 @@ const iconManager = (() => {
|
||||||
}
|
}
|
||||||
staleBadges.clear();
|
staleBadges.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeCall(method, data) {
|
||||||
|
const {browserAction = {}} = chrome;
|
||||||
|
const fn = browserAction[method];
|
||||||
|
if (fn) {
|
||||||
|
try {
|
||||||
|
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
|
||||||
|
fn.call(browserAction, data, ignoreChromeError);
|
||||||
|
} catch (e) {
|
||||||
|
// FIXME: skip pre-rendered tabs?
|
||||||
|
fn.call(browserAction, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {chrome.browserAction.TabIconDetails} data */
|
||||||
|
async function setIcon(data) {
|
||||||
|
if (hasCanvas === true || await hasCanvas) {
|
||||||
|
data.imageData = {};
|
||||||
|
for (const [key, url] of Object.entries(data.path)) {
|
||||||
|
data.imageData[key] = imageDataCache.get(url) || await loadImage(url);
|
||||||
|
}
|
||||||
|
delete data.path;
|
||||||
|
}
|
||||||
|
safeCall('setIcon', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {chrome.browserAction.BadgeTextDetails} data */
|
||||||
|
function setBadgeText(data) {
|
||||||
|
safeCall('setBadgeText', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */
|
||||||
|
function setBadgeBackgroundColor(data) {
|
||||||
|
safeCall('setBadgeBackgroundColor', data);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,91 +0,0 @@
|
||||||
/* global ignoreChromeError */
|
|
||||||
/* exported iconUtil */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const iconUtil = (() => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
// https://github.com/openstyles/stylus/issues/335
|
|
||||||
let noCanvas;
|
|
||||||
const imageDataCache = new Map();
|
|
||||||
// test if canvas is usable
|
|
||||||
const canvasReady = loadImage('/images/icon/16.png')
|
|
||||||
.then(imageData => {
|
|
||||||
noCanvas = imageData.data.every(b => b === 255);
|
|
||||||
});
|
|
||||||
|
|
||||||
return extendNative({
|
|
||||||
/*
|
|
||||||
Cache imageData for paths
|
|
||||||
*/
|
|
||||||
setIcon,
|
|
||||||
setBadgeText,
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadImage(url) {
|
|
||||||
let result = imageDataCache.get(url);
|
|
||||||
if (!result) {
|
|
||||||
result = new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.src = url;
|
|
||||||
img.onload = () => {
|
|
||||||
const w = canvas.width = img.width;
|
|
||||||
const h = canvas.height = img.height;
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
ctx.drawImage(img, 0, 0, w, h);
|
|
||||||
resolve(ctx.getImageData(0, 0, w, h));
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
});
|
|
||||||
imageDataCache.set(url, result);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setIcon(data) {
|
|
||||||
canvasReady.then(() => {
|
|
||||||
if (noCanvas) {
|
|
||||||
chrome.browserAction.setIcon(data, ignoreChromeError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pending = [];
|
|
||||||
data.imageData = {};
|
|
||||||
for (const [key, url] of Object.entries(data.path)) {
|
|
||||||
pending.push(loadImage(url)
|
|
||||||
.then(imageData => {
|
|
||||||
data.imageData[key] = imageData;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Promise.all(pending).then(() => {
|
|
||||||
delete data.path;
|
|
||||||
chrome.browserAction.setIcon(data, ignoreChromeError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBadgeText(data) {
|
|
||||||
try {
|
|
||||||
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
|
|
||||||
chrome.browserAction.setBadgeText(data, ignoreChromeError);
|
|
||||||
} catch (e) {
|
|
||||||
// FIXME: skip pre-rendered tabs?
|
|
||||||
chrome.browserAction.setBadgeText(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extendNative(target) {
|
|
||||||
return new Proxy(target, {
|
|
||||||
get: (target, prop) => {
|
|
||||||
// FIXME: do we really need this?
|
|
||||||
if (!chrome.browserAction ||
|
|
||||||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
if (target[prop]) {
|
|
||||||
return target[prop];
|
|
||||||
}
|
|
||||||
return chrome.browserAction[prop].bind(chrome.browserAction);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
82
background/navigation-manager.js
Normal file
82
background/navigation-manager.js
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
|
||||||
|
/* global bgReady */// common.js
|
||||||
|
/* global msg */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* exported navMan */
|
||||||
|
const navMan = (() => {
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed'));
|
||||||
|
chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history'));
|
||||||
|
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** @param {function(data: Object, type: ('committed'|'history'|'hash'))} fn */
|
||||||
|
onUrlChange(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @this {string} type */
|
||||||
|
async function onNavigation(data) {
|
||||||
|
if (CHROME &&
|
||||||
|
URLS.chromeProtectsNTP &&
|
||||||
|
data.url.startsWith('https://www.google.') &&
|
||||||
|
data.url.includes('/_/chrome/newtab?')) {
|
||||||
|
// Modern Chrome switched to WebUI NTP so this is obsolete, but there may be exceptions
|
||||||
|
// TODO: investigate, and maybe use a separate listener for CHROME <= ver
|
||||||
|
const tab = await browser.tabs.get(data.tabId);
|
||||||
|
const url = tab.pendingUrl || tab.url;
|
||||||
|
if (url === 'chrome://newtab/') {
|
||||||
|
data.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listeners.forEach(fn => fn(data, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @this {string} type */
|
||||||
|
function onFakeNavigation(data) {
|
||||||
|
onNavigation.call(this, data);
|
||||||
|
msg.sendTab(data.tabId, {method: 'urlChanged'}, {frameId: data.frameId})
|
||||||
|
.catch(msg.ignoreError);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
bgReady.all.then(() => {
|
||||||
|
/*
|
||||||
|
* Expose style version on greasyfork/sleazyfork 1) info page and 2) code page
|
||||||
|
* Not using manifest.json as adding a content script disables the extension on update.
|
||||||
|
*/
|
||||||
|
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
|
||||||
|
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
|
||||||
|
chrome.tabs.executeScript(tabId, {
|
||||||
|
file: '/content/install-hook-greasyfork.js',
|
||||||
|
runAt: 'document_start',
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
url: [
|
||||||
|
{hostEquals: 'greasyfork.org', urlMatches},
|
||||||
|
{hostEquals: 'sleazyfork.org', urlMatches},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
* FF misses some about:blank iframes so we inject our content script explicitly
|
||||||
|
*/
|
||||||
|
if (FIREFOX) {
|
||||||
|
chrome.webNavigation.onDOMContentLoaded.addListener(async ({tabId, frameId}) => {
|
||||||
|
if (frameId &&
|
||||||
|
!await msg.sendTab(tabId, {method: 'ping'}, {frameId}).catch(ignoreChromeError)) {
|
||||||
|
for (const file of chrome.runtime.getManifest().content_scripts[0].js) {
|
||||||
|
chrome.tabs.executeScript(tabId, {
|
||||||
|
frameId,
|
||||||
|
file,
|
||||||
|
matchAboutBlank: true,
|
||||||
|
}, ignoreChromeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
url: [{urlEquals: 'about:blank'}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,75 +0,0 @@
|
||||||
/* global CHROME URLS */
|
|
||||||
/* exported navigatorUtil */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const navigatorUtil = (() => {
|
|
||||||
const handler = {
|
|
||||||
urlChange: null,
|
|
||||||
};
|
|
||||||
return extendNative({onUrlChange});
|
|
||||||
|
|
||||||
function onUrlChange(fn) {
|
|
||||||
initUrlChange();
|
|
||||||
handler.urlChange.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initUrlChange() {
|
|
||||||
if (handler.urlChange) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handler.urlChange = [];
|
|
||||||
|
|
||||||
chrome.webNavigation.onCommitted.addListener(data =>
|
|
||||||
fixNTPUrl(data)
|
|
||||||
.then(() => executeCallbacks(handler.urlChange, data, 'committed'))
|
|
||||||
.catch(console.error)
|
|
||||||
);
|
|
||||||
|
|
||||||
chrome.webNavigation.onHistoryStateUpdated.addListener(data =>
|
|
||||||
fixNTPUrl(data)
|
|
||||||
.then(() => executeCallbacks(handler.urlChange, data, 'historyStateUpdated'))
|
|
||||||
.catch(console.error)
|
|
||||||
);
|
|
||||||
|
|
||||||
chrome.webNavigation.onReferenceFragmentUpdated.addListener(data =>
|
|
||||||
fixNTPUrl(data)
|
|
||||||
.then(() => executeCallbacks(handler.urlChange, data, 'referenceFragmentUpdated'))
|
|
||||||
.catch(console.error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixNTPUrl(data) {
|
|
||||||
if (
|
|
||||||
!CHROME ||
|
|
||||||
!URLS.chromeProtectsNTP ||
|
|
||||||
!data.url.startsWith('https://www.google.') ||
|
|
||||||
!data.url.includes('/_/chrome/newtab?')
|
|
||||||
) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return browser.tabs.get(data.tabId)
|
|
||||||
.then(tab => {
|
|
||||||
const url = tab.pendingUrl || tab.url;
|
|
||||||
if (url === 'chrome://newtab/') {
|
|
||||||
data.url = url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeCallbacks(callbacks, data, type) {
|
|
||||||
for (const cb of callbacks) {
|
|
||||||
cb(data, type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extendNative(target) {
|
|
||||||
return new Proxy(target, {
|
|
||||||
get: (target, prop) => {
|
|
||||||
if (target[prop]) {
|
|
||||||
return target[prop];
|
|
||||||
}
|
|
||||||
return chrome.webNavigation[prop].addListener.bind(chrome.webNavigation[prop]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
/* global addAPI */// common.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
/* CURRENTLY UNUSED */
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
// begin:nanographql - Tiny graphQL client library
|
// begin:nanographql - Tiny graphQL client library
|
||||||
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
|
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
|
||||||
|
@ -25,10 +28,9 @@
|
||||||
// end:nanographql
|
// end:nanographql
|
||||||
|
|
||||||
const api = 'https://api.openusercss.org';
|
const api = 'https://api.openusercss.org';
|
||||||
const doQuery = ({id}, queryString) => {
|
const doQuery = async ({id}, queryString) => {
|
||||||
const query = gql(queryString);
|
const query = gql(queryString);
|
||||||
|
return (await fetch(api, {
|
||||||
return fetch(api, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
@ -36,11 +38,10 @@
|
||||||
body: query({
|
body: query({
|
||||||
id,
|
id,
|
||||||
}),
|
}),
|
||||||
})
|
})).json();
|
||||||
.then(res => res.json());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
|
addAPI(/** @namespace- API */ { // TODO: remove "-" when this is implemented
|
||||||
/**
|
/**
|
||||||
* This function can be used to retrieve a theme object from the
|
* This function can be used to retrieve a theme object from the
|
||||||
* GraphQL API, set above
|
* GraphQL API, set above
|
||||||
|
|
15
background/remove-unused-storage.js
Normal file
15
background/remove-unused-storage.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/* global chromeLocal */// storage-util.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Removing unused stuff from storage on extension update
|
||||||
|
// TODO: delete this by the middle of 2021
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const del = Object.keys(await chromeLocal.get())
|
||||||
|
.filter(key => key.startsWith('usoSearchCache'));
|
||||||
|
if (del.length) chromeLocal.remove(del);
|
||||||
|
}, 15e3);
|
File diff suppressed because it is too large
Load Diff
|
@ -1,11 +1,6 @@
|
||||||
/* global
|
/* global API */// msg.js
|
||||||
API_METHODS
|
/* global URLS debounce stringAsRegExp tryRegExp */// toolbox.js
|
||||||
debounce
|
/* global addAPI */// common.js
|
||||||
stringAsRegExp
|
|
||||||
styleManager
|
|
||||||
tryRegExp
|
|
||||||
usercss
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
@ -15,12 +10,12 @@
|
||||||
|
|
||||||
const extractMeta = style =>
|
const extractMeta = style =>
|
||||||
style.usercssData
|
style.usercssData
|
||||||
? (style.sourceCode.match(usercss.RX_META) || [''])[0]
|
? (style.sourceCode.match(URLS.rxMETA) || [''])[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const stripMeta = style =>
|
const stripMeta = style =>
|
||||||
style.usercssData
|
style.usercssData
|
||||||
? style.sourceCode.replace(usercss.RX_META, '')
|
? style.sourceCode.replace(URLS.rxMETA, '')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const MODES = Object.assign(Object.create(null), {
|
const MODES = Object.assign(Object.create(null), {
|
||||||
|
@ -31,8 +26,8 @@
|
||||||
|
|
||||||
meta: (style, test, part) =>
|
meta: (style, test, part) =>
|
||||||
METAKEYS.some(key => test(style[key])) ||
|
METAKEYS.some(key => test(style[key])) ||
|
||||||
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
|
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
|
||||||
searchSections(style, test, 'funcs'),
|
searchSections(style, test, 'funcs'),
|
||||||
|
|
||||||
name: (style, test) =>
|
name: (style, test) =>
|
||||||
test(style.customName) ||
|
test(style.customName) ||
|
||||||
|
@ -43,33 +38,37 @@
|
||||||
!style.usercssData && MODES.code(style, test),
|
!style.usercssData && MODES.code(style, test),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
addAPI(/** @namespace API */ {
|
||||||
* @param params
|
styles: {
|
||||||
* @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 params
|
||||||
* @param {number[]} [params.ids] - if not specified, all styles are searched
|
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
|
||||||
* @returns {number[]} - array of matched styles ids
|
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
|
||||||
*/
|
* @param {number[]} [params.ids] - if not specified, all styles are searched
|
||||||
API_METHODS.searchDB = async ({query, mode = 'all', ids}) => {
|
* @returns {number[]} - array of matched styles ids
|
||||||
let res = [];
|
*/
|
||||||
if (mode === 'url' && query) {
|
async searchDB({query, mode = 'all', ids}) {
|
||||||
res = (await styleManager.getStylesByUrl(query)).map(r => r.data.id);
|
let res = [];
|
||||||
} else if (mode in MODES) {
|
if (mode === 'url' && query) {
|
||||||
const modeHandler = MODES[mode];
|
res = (await API.styles.getByUrl(query)).map(r => r.style.id);
|
||||||
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
|
} else if (mode in MODES) {
|
||||||
const rx = m && tryRegExp(m[1], m[2]);
|
const modeHandler = MODES[mode];
|
||||||
const test = rx ? rx.test.bind(rx) : makeTester(query);
|
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
|
||||||
res = (await styleManager.getAllStyles())
|
const rx = m && tryRegExp(m[1], m[2]);
|
||||||
.filter(style =>
|
const test = rx ? rx.test.bind(rx) : createTester(query);
|
||||||
(!ids || ids.includes(style.id)) &&
|
res = (await API.styles.getAll())
|
||||||
(!query || modeHandler(style, test)))
|
.filter(style =>
|
||||||
.map(style => style.id);
|
(!ids || ids.includes(style.id)) &&
|
||||||
if (cache.size) debounce(clearCache, 60e3);
|
(!query || modeHandler(style, test)))
|
||||||
}
|
.map(style => style.id);
|
||||||
return res;
|
if (cache.size) debounce(clearCache, 60e3);
|
||||||
};
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function makeTester(query) {
|
function createTester(query) {
|
||||||
const flags = `u${lower(query) === query ? 'i' : ''}`;
|
const flags = `u${lower(query) === query ? 'i' : ''}`;
|
||||||
const words = query
|
const words = query
|
||||||
.split(/(".*?")|\s+/)
|
.split(/(".*?")|\s+/)
|
|
@ -1,7 +1,14 @@
|
||||||
/* global API_METHODS styleManager CHROME prefs */
|
/* global API */// msg.js
|
||||||
|
/* global addAPI */// common.js
|
||||||
|
/* global isEmptyObj */// toolbox.js
|
||||||
|
/* global prefs */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
API_METHODS.styleViaAPI = !CHROME && (() => {
|
/**
|
||||||
|
* Uses chrome.tabs.insertCSS
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
const ACTIONS = {
|
const ACTIONS = {
|
||||||
styleApply,
|
styleApply,
|
||||||
styleDeleted,
|
styleDeleted,
|
||||||
|
@ -11,25 +18,25 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
prefChanged,
|
prefChanged,
|
||||||
updateCount,
|
updateCount,
|
||||||
};
|
};
|
||||||
const NOP = Promise.resolve(new Error('NOP'));
|
const NOP = new Error('NOP');
|
||||||
const onError = () => {};
|
const onError = () => {};
|
||||||
|
|
||||||
/* <tabId>: Object
|
/* <tabId>: Object
|
||||||
<frameId>: Object
|
<frameId>: Object
|
||||||
url: String, non-enumerable
|
url: String, non-enumerable
|
||||||
<styleId>: Array of strings
|
<styleId>: Array of strings
|
||||||
section code */
|
section code */
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
|
|
||||||
let observingTabs = false;
|
let observingTabs = false;
|
||||||
|
|
||||||
return function (request) {
|
addAPI(/** @namespace API */ {
|
||||||
const action = ACTIONS[request.method];
|
async styleViaAPI(request) {
|
||||||
return !action ? NOP :
|
try {
|
||||||
action(request, this.sender)
|
const fn = ACTIONS[request.method];
|
||||||
.catch(onError)
|
return fn ? fn(request, this.sender) : NOP;
|
||||||
.then(maybeToggleObserver);
|
} catch (e) {}
|
||||||
};
|
maybeToggleObserver();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function updateCount(request, sender) {
|
function updateCount(request, sender) {
|
||||||
const {tab, frameId} = sender;
|
const {tab, frameId} = sender;
|
||||||
|
@ -37,7 +44,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
throw new Error('we do not count styles for frames');
|
throw new Error('we do not count styles for frames');
|
||||||
}
|
}
|
||||||
const {frameStyles} = getCachedData(tab.id, frameId);
|
const {frameStyles} = getCachedData(tab.id, frameId);
|
||||||
API_METHODS.updateIconBadge.call({sender}, Object.keys(frameStyles));
|
API.updateIconBadge.call({sender}, Object.keys(frameStyles));
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) {
|
function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) {
|
||||||
|
@ -48,7 +55,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
if (id === null && !ignoreUrlCheck && frameStyles.url === url) {
|
if (id === null && !ignoreUrlCheck && frameStyles.url === url) {
|
||||||
return NOP;
|
return NOP;
|
||||||
}
|
}
|
||||||
return styleManager.getSectionsByUrl(url, id).then(sections => {
|
return API.styles.getSectionsByUrl(url, id).then(sections => {
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
for (const section of Object.values(sections)) {
|
for (const section of Object.values(sections)) {
|
||||||
const styleId = section.id;
|
const styleId = section.id;
|
||||||
|
@ -125,7 +132,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
}
|
}
|
||||||
const {tab, frameId} = sender;
|
const {tab, frameId} = sender;
|
||||||
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
|
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
|
||||||
if (isEmpty(frameStyles)) {
|
if (isEmptyObj(frameStyles)) {
|
||||||
return NOP;
|
return NOP;
|
||||||
}
|
}
|
||||||
removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
|
removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
|
||||||
|
@ -162,7 +169,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
const tabFrames = cache.get(tabId);
|
const tabFrames = cache.get(tabId);
|
||||||
if (tabFrames && frameId in tabFrames) {
|
if (tabFrames && frameId in tabFrames) {
|
||||||
delete tabFrames[frameId];
|
delete tabFrames[frameId];
|
||||||
if (isEmpty(tabFrames)) {
|
if (isEmptyObj(tabFrames)) {
|
||||||
onTabRemoved(tabId);
|
onTabRemoved(tabId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -178,9 +185,9 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
|
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
|
||||||
if (isEmpty(frameStyles)) {
|
if (isEmptyObj(frameStyles)) {
|
||||||
delete tabFrames[frameId];
|
delete tabFrames[frameId];
|
||||||
if (isEmpty(tabFrames)) {
|
if (isEmptyObj(tabFrames)) {
|
||||||
cache.delete(tabId);
|
cache.delete(tabId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
@ -223,11 +230,4 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||||
return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true})
|
return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true})
|
||||||
.catch(onError);
|
.catch(onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmpty(obj) {
|
|
||||||
for (const k in obj) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,101 +1,103 @@
|
||||||
/* global API CHROME prefs */
|
/* global API */// msg.js
|
||||||
|
/* global CHROME */// toolbox.js
|
||||||
|
/* global prefs */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
(() => {
|
||||||
CHROME && (async () => {
|
|
||||||
const idCSP = 'patchCsp';
|
const idCSP = 'patchCsp';
|
||||||
const idOFF = 'disableAll';
|
const idOFF = 'disableAll';
|
||||||
const idXHR = 'styleViaXhr';
|
const idXHR = 'styleViaXhr';
|
||||||
const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by *
|
const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by *
|
||||||
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
|
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
|
||||||
|
/** @type {Object<string,StylesToPass>} */
|
||||||
const stylesToPass = {};
|
const stylesToPass = {};
|
||||||
const enabled = {};
|
const state = {};
|
||||||
|
const injectedCode = CHROME && `${data => {
|
||||||
|
if (self.INJECTED !== 1) { // storing data only if apply.js hasn't run yet
|
||||||
|
window[Symbol.for('styles')] = data;
|
||||||
|
}
|
||||||
|
}}`;
|
||||||
|
|
||||||
await prefs.initializing;
|
toggle();
|
||||||
prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true});
|
prefs.subscribe([idXHR, idOFF, idCSP], toggle);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
const csp = prefs.get(idCSP) && !prefs.get(idOFF);
|
const off = prefs.get(idOFF);
|
||||||
const xhr = prefs.get(idXHR) && !prefs.get(idOFF) && Boolean(chrome.declarativeContent);
|
const csp = prefs.get(idCSP) && !off;
|
||||||
if (xhr === enabled.xhr && csp === enabled.csp) {
|
const xhr = prefs.get(idXHR) && !off;
|
||||||
|
if (xhr === state.xhr && csp === state.csp && off === state.off) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Need to unregister first so that the optional EXTRA_HEADERS is properly registered
|
const reqFilter = {
|
||||||
|
urls: ['*://*/*'],
|
||||||
|
types: ['main_frame', 'sub_frame'],
|
||||||
|
};
|
||||||
|
chrome.webNavigation.onCommitted.removeListener(injectData);
|
||||||
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
|
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
|
||||||
chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders);
|
chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders);
|
||||||
if (xhr || csp) {
|
if (xhr || csp) {
|
||||||
const reqFilter = {
|
// We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered
|
||||||
urls: ['<all_urls>'],
|
|
||||||
types: ['main_frame', 'sub_frame'],
|
|
||||||
};
|
|
||||||
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
|
|
||||||
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
|
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
|
||||||
'blocking',
|
'blocking',
|
||||||
'responseHeaders',
|
'responseHeaders',
|
||||||
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
|
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
|
||||||
].filter(Boolean));
|
].filter(Boolean));
|
||||||
}
|
}
|
||||||
if (enabled.xhr !== xhr) {
|
if (CHROME ? !off : xhr || csp) {
|
||||||
enabled.xhr = xhr;
|
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
|
||||||
toggleEarlyInjection();
|
|
||||||
}
|
}
|
||||||
enabled.csp = csp;
|
if (CHROME && !off) {
|
||||||
}
|
chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]});
|
||||||
|
}
|
||||||
/** Runs content scripts earlier than document_start */
|
state.csp = csp;
|
||||||
function toggleEarlyInjection() {
|
state.off = off;
|
||||||
const api = chrome.declarativeContent;
|
state.xhr = xhr;
|
||||||
if (!api) return;
|
|
||||||
api.onPageChanged.removeRules([idXHR], async () => {
|
|
||||||
if (enabled.xhr) {
|
|
||||||
api.onPageChanged.addRules([{
|
|
||||||
id: idXHR,
|
|
||||||
conditions: [
|
|
||||||
new api.PageStateMatcher({
|
|
||||||
pageUrl: {urlContains: '://'},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
new api.RequestContentScript({
|
|
||||||
js: chrome.runtime.getManifest().content_scripts[0].js,
|
|
||||||
allFrames: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
|
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
|
||||||
function prepareStyles(req) {
|
async function prepareStyles(req) {
|
||||||
API.getSectionsByUrl(req.url).then(sections => {
|
const sections = await API.styles.getSectionsByUrl(req.url);
|
||||||
if (Object.keys(sections).length) {
|
stylesToPass[req2key(req)] = /** @namespace StylesToPass */ {
|
||||||
stylesToPass[req.requestId] = !enabled.xhr ? true :
|
blobId: '',
|
||||||
URL.createObjectURL(new Blob([JSON.stringify(sections)])).slice(blobUrlPrefix.length);
|
str: JSON.stringify(sections),
|
||||||
setTimeout(cleanUp, 600e3, req.requestId);
|
timer: setTimeout(cleanUp, 600e3, req),
|
||||||
}
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function injectData(req) {
|
||||||
|
const data = stylesToPass[req2key(req)];
|
||||||
|
if (data && !data.injected) {
|
||||||
|
data.injected = true;
|
||||||
|
chrome.tabs.executeScript(req.tabId, {
|
||||||
|
frameId: req.frameId,
|
||||||
|
runAt: 'document_start',
|
||||||
|
code: `(${injectedCode})(${data.str})`,
|
||||||
|
});
|
||||||
|
if (!state.xhr) cleanUp(req);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
|
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
|
||||||
function modifyHeaders(req) {
|
function modifyHeaders(req) {
|
||||||
const {responseHeaders} = req;
|
const {responseHeaders} = req;
|
||||||
const id = stylesToPass[req.requestId];
|
const data = stylesToPass[req2key(req)];
|
||||||
if (!id) {
|
if (!data || data.str === '{}') {
|
||||||
|
cleanUp(req);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (enabled.xhr) {
|
if (state.xhr) {
|
||||||
|
data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length);
|
||||||
responseHeaders.push({
|
responseHeaders.push({
|
||||||
name: 'Set-Cookie',
|
name: 'Set-Cookie',
|
||||||
value: `${chrome.runtime.id}=${id}`,
|
value: `${chrome.runtime.id}=${data.blobId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const csp = enabled.csp &&
|
const csp = state.csp &&
|
||||||
responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy');
|
responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy');
|
||||||
if (csp) {
|
if (csp) {
|
||||||
patchCsp(csp);
|
patchCsp(csp);
|
||||||
}
|
}
|
||||||
if (enabled.xhr || csp) {
|
if (state.xhr || csp) {
|
||||||
return {responseHeaders};
|
return {responseHeaders};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,7 +113,7 @@ CHROME && (async () => {
|
||||||
patchCspSrc(src, 'img-src', 'data:', '*');
|
patchCspSrc(src, 'img-src', 'data:', '*');
|
||||||
patchCspSrc(src, 'font-src', 'data:', '*');
|
patchCspSrc(src, 'font-src', 'data:', '*');
|
||||||
// Allow our DOM styles
|
// Allow our DOM styles
|
||||||
patchCspSrc(src, 'style-src', '\'unsafe-inline\'');
|
patchCspSrc(src, 'style-src', "'unsafe-inline'");
|
||||||
// Allow our XHR cookies in CSP sandbox (known case: raw github urls)
|
// Allow our XHR cookies in CSP sandbox (known case: raw github urls)
|
||||||
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
|
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
|
||||||
src.sandbox.push('allow-same-origin');
|
src.sandbox.push('allow-same-origin');
|
||||||
|
@ -132,9 +134,19 @@ CHROME && (async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanUp(key) {
|
function cleanUp(req) {
|
||||||
const blobId = stylesToPass[key];
|
const key = req2key(req);
|
||||||
delete stylesToPass[key];
|
const data = stylesToPass[key];
|
||||||
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
|
if (data) {
|
||||||
|
delete stylesToPass[key];
|
||||||
|
clearTimeout(data.timer);
|
||||||
|
if (data.blobId) {
|
||||||
|
URL.revokeObjectURL(blobUrlPrefix + data.blobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function req2key(req) {
|
||||||
|
return req.tabId + ':' + req.frameId;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
225
background/sync-manager.js
Normal file
225
background/sync-manager.js
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
/* global API msg */// msg.js
|
||||||
|
/* global chromeLocal */// storage-util.js
|
||||||
|
/* global compareRevision */// common.js
|
||||||
|
/* global prefs */
|
||||||
|
/* global tokenMan */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const syncMan = (() => {
|
||||||
|
//#region Init
|
||||||
|
|
||||||
|
const SYNC_DELAY = 1; // minutes
|
||||||
|
const SYNC_INTERVAL = 30; // minutes
|
||||||
|
const STATES = Object.freeze({
|
||||||
|
connected: 'connected',
|
||||||
|
connecting: 'connecting',
|
||||||
|
disconnected: 'disconnected',
|
||||||
|
disconnecting: 'disconnecting',
|
||||||
|
});
|
||||||
|
const STORAGE_KEY = 'sync/state/';
|
||||||
|
const status = /** @namespace SyncManager.Status */ {
|
||||||
|
STATES,
|
||||||
|
state: STATES.disconnected,
|
||||||
|
syncing: false,
|
||||||
|
progress: null,
|
||||||
|
currentDriveName: null,
|
||||||
|
errorMessage: null,
|
||||||
|
login: false,
|
||||||
|
};
|
||||||
|
let ctrl;
|
||||||
|
let currentDrive;
|
||||||
|
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
|
||||||
|
let ready = prefs.ready.then(() => {
|
||||||
|
ready = true;
|
||||||
|
prefs.subscribe('sync.enabled',
|
||||||
|
(_, val) => val === 'none'
|
||||||
|
? syncMan.stop()
|
||||||
|
: syncMan.start(val, true),
|
||||||
|
{runNow: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.alarms.onAlarm.addListener(info => {
|
||||||
|
if (info.name === 'syncNow') {
|
||||||
|
syncMan.syncNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region Exports
|
||||||
|
|
||||||
|
return {
|
||||||
|
|
||||||
|
async delete(...args) {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
if (!currentDrive) return;
|
||||||
|
schedule();
|
||||||
|
return ctrl.delete(...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {Promise<SyncManager.Status>} */
|
||||||
|
async getStatus() {
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(name = prefs.get('sync.enabled')) {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
try {
|
||||||
|
await tokenMan.getToken(name, true);
|
||||||
|
} catch (err) {
|
||||||
|
if (/Authorization page could not be loaded/i.test(err.message)) {
|
||||||
|
// FIXME: Chrome always fails at the first login so we try again
|
||||||
|
await tokenMan.getToken(name);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
status.login = true;
|
||||||
|
emitStatusChange();
|
||||||
|
},
|
||||||
|
|
||||||
|
async put(...args) {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
if (!currentDrive) return;
|
||||||
|
schedule();
|
||||||
|
return ctrl.put(...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
async start(name, fromPref = false) {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
if (!ctrl) await initController();
|
||||||
|
if (currentDrive) return;
|
||||||
|
currentDrive = getDrive(name);
|
||||||
|
ctrl.use(currentDrive);
|
||||||
|
status.state = STATES.connecting;
|
||||||
|
status.currentDriveName = currentDrive.name;
|
||||||
|
status.login = true;
|
||||||
|
emitStatusChange();
|
||||||
|
try {
|
||||||
|
if (!fromPref) {
|
||||||
|
await syncMan.login(name).catch(handle401Error);
|
||||||
|
}
|
||||||
|
await syncMan.syncNow();
|
||||||
|
status.errorMessage = null;
|
||||||
|
} catch (err) {
|
||||||
|
status.errorMessage = err.message;
|
||||||
|
// FIXME: should we move this logic to options.js?
|
||||||
|
if (!fromPref) {
|
||||||
|
console.error(err);
|
||||||
|
return syncMan.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.set('sync.enabled', name);
|
||||||
|
status.state = STATES.connected;
|
||||||
|
schedule(SYNC_INTERVAL);
|
||||||
|
emitStatusChange();
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
if (!currentDrive) return;
|
||||||
|
chrome.alarms.clear('syncNow');
|
||||||
|
status.state = STATES.disconnecting;
|
||||||
|
emitStatusChange();
|
||||||
|
try {
|
||||||
|
await ctrl.stop();
|
||||||
|
await tokenMan.revokeToken(currentDrive.name);
|
||||||
|
await chromeLocal.remove(STORAGE_KEY + currentDrive.name);
|
||||||
|
} catch (e) {}
|
||||||
|
currentDrive = null;
|
||||||
|
prefs.set('sync.enabled', 'none');
|
||||||
|
status.state = STATES.disconnected;
|
||||||
|
status.currentDriveName = null;
|
||||||
|
status.login = false;
|
||||||
|
emitStatusChange();
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncNow() {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
if (!currentDrive) throw new Error('cannot sync when disconnected');
|
||||||
|
try {
|
||||||
|
await (ctrl.isInit() ? ctrl.syncNow() : ctrl.start()).catch(handle401Error);
|
||||||
|
status.errorMessage = null;
|
||||||
|
} catch (err) {
|
||||||
|
status.errorMessage = err.message;
|
||||||
|
}
|
||||||
|
emitStatusChange();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region Utils
|
||||||
|
|
||||||
|
async function initController() {
|
||||||
|
await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
|
||||||
|
ctrl = dbToCloud.dbToCloud({
|
||||||
|
onGet(id) {
|
||||||
|
return API.styles.getByUUID(id);
|
||||||
|
},
|
||||||
|
onPut(doc) {
|
||||||
|
return API.styles.putByUUID(doc);
|
||||||
|
},
|
||||||
|
onDelete(id, rev) {
|
||||||
|
return API.styles.deleteByUUID(id, rev);
|
||||||
|
},
|
||||||
|
async onFirstSync() {
|
||||||
|
for (const i of await API.styles.getAll()) {
|
||||||
|
ctrl.put(i._id, i._rev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProgress(e) {
|
||||||
|
if (e.phase === 'start') {
|
||||||
|
status.syncing = true;
|
||||||
|
} else if (e.phase === 'end') {
|
||||||
|
status.syncing = false;
|
||||||
|
status.progress = null;
|
||||||
|
} else {
|
||||||
|
status.progress = e;
|
||||||
|
}
|
||||||
|
emitStatusChange();
|
||||||
|
},
|
||||||
|
compareRevision,
|
||||||
|
getState(drive) {
|
||||||
|
return chromeLocal.getValue(STORAGE_KEY + drive.name);
|
||||||
|
},
|
||||||
|
setState(drive, state) {
|
||||||
|
return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle401Error(err) {
|
||||||
|
let emit;
|
||||||
|
if (err.code === 401) {
|
||||||
|
await tokenMan.revokeToken(currentDrive.name).catch(console.error);
|
||||||
|
emit = true;
|
||||||
|
} else if (/User interaction required|Requires user interaction/i.test(err.message)) {
|
||||||
|
emit = true;
|
||||||
|
}
|
||||||
|
if (emit) {
|
||||||
|
status.login = false;
|
||||||
|
emitStatusChange();
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitStatusChange() {
|
||||||
|
msg.broadcastExtension({method: 'syncStatusUpdate', status});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrive(name) {
|
||||||
|
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
|
||||||
|
return dbToCloud.drive[name]({
|
||||||
|
getAccessToken: () => tokenMan.getToken(name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unknown cloud name: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(delay = SYNC_DELAY) {
|
||||||
|
chrome.alarms.create('syncNow', {
|
||||||
|
delayInMinutes: delay,
|
||||||
|
periodInMinutes: SYNC_INTERVAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
})();
|
|
@ -1,236 +0,0 @@
|
||||||
/* global dbToCloud styleManager chromeLocal prefs tokenManager msg */
|
|
||||||
/* exported sync */
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const sync = (() => {
|
|
||||||
const SYNC_DELAY = 1; // minutes
|
|
||||||
const SYNC_INTERVAL = 30; // minutes
|
|
||||||
|
|
||||||
const status = {
|
|
||||||
state: 'disconnected',
|
|
||||||
syncing: false,
|
|
||||||
progress: null,
|
|
||||||
currentDriveName: null,
|
|
||||||
errorMessage: null,
|
|
||||||
login: false,
|
|
||||||
};
|
|
||||||
let currentDrive;
|
|
||||||
const ctrl = dbToCloud.dbToCloud({
|
|
||||||
onGet(id) {
|
|
||||||
return styleManager.getByUUID(id);
|
|
||||||
},
|
|
||||||
onPut(doc) {
|
|
||||||
return styleManager.putByUUID(doc);
|
|
||||||
},
|
|
||||||
onDelete(id, rev) {
|
|
||||||
return styleManager.deleteByUUID(id, rev);
|
|
||||||
},
|
|
||||||
onFirstSync() {
|
|
||||||
return styleManager.getAllStyles()
|
|
||||||
.then(styles => {
|
|
||||||
styles.forEach(i => ctrl.put(i._id, i._rev));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onProgress,
|
|
||||||
compareRevision(a, b) {
|
|
||||||
return styleManager.compareRevision(a, b);
|
|
||||||
},
|
|
||||||
getState(drive) {
|
|
||||||
const key = `sync/state/${drive.name}`;
|
|
||||||
return chromeLocal.getValue(key);
|
|
||||||
},
|
|
||||||
setState(drive, state) {
|
|
||||||
const key = `sync/state/${drive.name}`;
|
|
||||||
return chromeLocal.setValue(key, state);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const initializing = prefs.initializing.then(() => {
|
|
||||||
prefs.subscribe(['sync.enabled'], onPrefChange);
|
|
||||||
onPrefChange(null, prefs.get('sync.enabled'));
|
|
||||||
});
|
|
||||||
|
|
||||||
chrome.alarms.onAlarm.addListener(info => {
|
|
||||||
if (info.name === 'syncNow') {
|
|
||||||
syncNow().catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.assign({
|
|
||||||
getStatus: () => status,
|
|
||||||
}, ensurePrepared({
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
put: (...args) => {
|
|
||||||
if (!currentDrive) return;
|
|
||||||
schedule();
|
|
||||||
return ctrl.put(...args);
|
|
||||||
},
|
|
||||||
delete: (...args) => {
|
|
||||||
if (!currentDrive) return;
|
|
||||||
schedule();
|
|
||||||
return ctrl.delete(...args);
|
|
||||||
},
|
|
||||||
syncNow,
|
|
||||||
login,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function ensurePrepared(obj) {
|
|
||||||
return Object.entries(obj).reduce((o, [key, fn]) => {
|
|
||||||
o[key] = (...args) =>
|
|
||||||
initializing.then(() => fn(...args));
|
|
||||||
return o;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onProgress(e) {
|
|
||||||
if (e.phase === 'start') {
|
|
||||||
status.syncing = true;
|
|
||||||
} else if (e.phase === 'end') {
|
|
||||||
status.syncing = false;
|
|
||||||
status.progress = null;
|
|
||||||
} else {
|
|
||||||
status.progress = e;
|
|
||||||
}
|
|
||||||
emitStatusChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedule(delay = SYNC_DELAY) {
|
|
||||||
chrome.alarms.create('syncNow', {
|
|
||||||
delayInMinutes: delay,
|
|
||||||
periodInMinutes: SYNC_INTERVAL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPrefChange(key, value) {
|
|
||||||
if (value === 'none') {
|
|
||||||
stop().catch(console.error);
|
|
||||||
} else {
|
|
||||||
start(value, true).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withFinally(p, cleanup) {
|
|
||||||
return p.then(
|
|
||||||
result => {
|
|
||||||
cleanup(undefined, result);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
err => {
|
|
||||||
cleanup(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncNow() {
|
|
||||||
if (!currentDrive) {
|
|
||||||
return Promise.reject(new Error('cannot sync when disconnected'));
|
|
||||||
}
|
|
||||||
return withFinally(
|
|
||||||
(ctrl.isInit() ? ctrl.syncNow() : ctrl.start())
|
|
||||||
.catch(handle401Error),
|
|
||||||
err => {
|
|
||||||
status.errorMessage = err ? err.message : null;
|
|
||||||
emitStatusChange();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle401Error(err) {
|
|
||||||
if (err.code === 401) {
|
|
||||||
return tokenManager.revokeToken(currentDrive.name)
|
|
||||||
.catch(console.error)
|
|
||||||
.then(() => {
|
|
||||||
status.login = false;
|
|
||||||
emitStatusChange();
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (/User interaction required|Requires user interaction/i.test(err.message)) {
|
|
||||||
status.login = false;
|
|
||||||
emitStatusChange();
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitStatusChange() {
|
|
||||||
msg.broadcastExtension({method: 'syncStatusUpdate', status});
|
|
||||||
}
|
|
||||||
|
|
||||||
function login(name = prefs.get('sync.enabled')) {
|
|
||||||
return tokenManager.getToken(name, true)
|
|
||||||
.catch(err => {
|
|
||||||
if (/Authorization page could not be loaded/i.test(err.message)) {
|
|
||||||
// FIXME: Chrome always fails at the first login so we try again
|
|
||||||
return tokenManager.getToken(name);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
status.login = true;
|
|
||||||
emitStatusChange();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function start(name, fromPref = false) {
|
|
||||||
if (currentDrive) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
currentDrive = getDrive(name);
|
|
||||||
ctrl.use(currentDrive);
|
|
||||||
status.state = 'connecting';
|
|
||||||
status.currentDriveName = currentDrive.name;
|
|
||||||
status.login = true;
|
|
||||||
emitStatusChange();
|
|
||||||
return withFinally(
|
|
||||||
(fromPref ? Promise.resolve() : login(name))
|
|
||||||
.catch(handle401Error)
|
|
||||||
.then(() => syncNow()),
|
|
||||||
err => {
|
|
||||||
status.errorMessage = err ? err.message : null;
|
|
||||||
// FIXME: should we move this logic to options.js?
|
|
||||||
if (err && !fromPref) {
|
|
||||||
console.error(err);
|
|
||||||
return stop();
|
|
||||||
}
|
|
||||||
prefs.set('sync.enabled', name);
|
|
||||||
schedule(SYNC_INTERVAL);
|
|
||||||
status.state = 'connected';
|
|
||||||
emitStatusChange();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDrive(name) {
|
|
||||||
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
|
|
||||||
return dbToCloud.drive[name]({
|
|
||||||
getAccessToken: () => tokenManager.getToken(name),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error(`unknown cloud name: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
if (!currentDrive) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
chrome.alarms.clear('syncNow');
|
|
||||||
status.state = 'disconnecting';
|
|
||||||
emitStatusChange();
|
|
||||||
return withFinally(
|
|
||||||
ctrl.stop()
|
|
||||||
.then(() => tokenManager.revokeToken(currentDrive.name))
|
|
||||||
.then(() => chromeLocal.remove(`sync/state/${currentDrive.name}`)),
|
|
||||||
() => {
|
|
||||||
currentDrive = null;
|
|
||||||
prefs.set('sync.enabled', 'none');
|
|
||||||
status.state = 'disconnected';
|
|
||||||
status.currentDriveName = null;
|
|
||||||
status.login = false;
|
|
||||||
emitStatusChange();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
|
@ -1,32 +1,37 @@
|
||||||
/* global navigatorUtil */
|
/* global bgReady */// common.js
|
||||||
/* exported tabManager */
|
/* global navMan */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const tabManager = (() => {
|
const tabMan = (() => {
|
||||||
const listeners = [];
|
const listeners = new Set();
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
|
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
|
||||||
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
|
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
|
||||||
navigatorUtil.onUrlChange(({tabId, frameId, url}) => {
|
|
||||||
if (frameId) return;
|
bgReady.all.then(() => {
|
||||||
const oldUrl = tabManager.get(tabId, 'url');
|
navMan.onUrlChange(({tabId, frameId, url}) => {
|
||||||
tabManager.set(tabId, 'url', url);
|
const oldUrl = !frameId && tabMan.get(tabId, 'url', frameId);
|
||||||
for (const fn of listeners) {
|
tabMan.set(tabId, 'url', frameId, url);
|
||||||
try {
|
if (frameId) return;
|
||||||
fn({tabId, url, oldUrl});
|
for (const fn of listeners) {
|
||||||
} catch (err) {
|
try {
|
||||||
console.error(err);
|
fn({tabId, url, oldUrl});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onUpdate(fn) {
|
onUpdate(fn) {
|
||||||
listeners.push(fn);
|
listeners.add(fn);
|
||||||
},
|
},
|
||||||
|
|
||||||
get(tabId, ...keys) {
|
get(tabId, ...keys) {
|
||||||
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
|
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
|
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
|
||||||
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
|
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
|
||||||
|
@ -47,8 +52,10 @@ const tabManager = (() => {
|
||||||
meta[lastKey] = value;
|
meta[lastKey] = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
list() {
|
list() {
|
||||||
return cache.keys();
|
return cache.keys();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
|
/* global FIREFOX */// toolbox.js
|
||||||
/* exported tokenManager */
|
/* global chromeLocal */// storage-util.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const tokenManager = (() => {
|
/* exported tokenMan */
|
||||||
|
const tokenMan = (() => {
|
||||||
const AUTH = {
|
const AUTH = {
|
||||||
dropbox: {
|
dropbox: {
|
||||||
flow: 'token',
|
flow: 'token',
|
||||||
|
@ -50,64 +51,58 @@ const tokenManager = (() => {
|
||||||
};
|
};
|
||||||
const NETWORK_LATENCY = 30; // seconds
|
const NETWORK_LATENCY = 30; // seconds
|
||||||
|
|
||||||
return {getToken, revokeToken, getClientId, buildKeys};
|
return {
|
||||||
|
|
||||||
function getClientId(name) {
|
buildKeys(name) {
|
||||||
return AUTH[name].clientId;
|
const k = {
|
||||||
}
|
TOKEN: `secure/token/${name}/token`,
|
||||||
|
EXPIRE: `secure/token/${name}/expire`,
|
||||||
|
REFRESH: `secure/token/${name}/refresh`,
|
||||||
|
};
|
||||||
|
k.LIST = Object.values(k);
|
||||||
|
return k;
|
||||||
|
},
|
||||||
|
|
||||||
function buildKeys(name) {
|
getClientId(name) {
|
||||||
const k = {
|
return AUTH[name].clientId;
|
||||||
TOKEN: `secure/token/${name}/token`,
|
},
|
||||||
EXPIRE: `secure/token/${name}/expire`,
|
|
||||||
REFRESH: `secure/token/${name}/refresh`,
|
|
||||||
};
|
|
||||||
k.LIST = Object.values(k);
|
|
||||||
return k;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getToken(name, interactive) {
|
async getToken(name, interactive) {
|
||||||
const k = buildKeys(name);
|
const k = tokenMan.buildKeys(name);
|
||||||
return chromeLocal.get(k.LIST)
|
const obj = await chromeLocal.get(k.LIST);
|
||||||
.then(obj => {
|
if (obj[k.TOKEN]) {
|
||||||
if (!obj[k.TOKEN]) {
|
|
||||||
return authUser(name, k, interactive);
|
|
||||||
}
|
|
||||||
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
|
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
|
||||||
return obj[k.TOKEN];
|
return obj[k.TOKEN];
|
||||||
}
|
}
|
||||||
if (obj[k.REFRESH]) {
|
if (obj[k.REFRESH]) {
|
||||||
return refreshToken(name, k, obj)
|
try {
|
||||||
.catch(err => {
|
return await refreshToken(name, k, obj);
|
||||||
if (err.code === 401) {
|
} catch (err) {
|
||||||
return authUser(name, k, interactive);
|
if (err.code !== 401) throw err;
|
||||||
}
|
}
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return authUser(name, k, interactive);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeToken(name) {
|
|
||||||
const provider = AUTH[name];
|
|
||||||
const k = buildKeys(name);
|
|
||||||
if (provider.revoke) {
|
|
||||||
try {
|
|
||||||
const token = await chromeLocal.getValue(k.TOKEN);
|
|
||||||
if (token) {
|
|
||||||
await provider.revoke(token);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
}
|
return authUser(name, k, interactive);
|
||||||
await chromeLocal.remove(k.LIST);
|
},
|
||||||
}
|
|
||||||
|
|
||||||
function refreshToken(name, k, obj) {
|
async revokeToken(name) {
|
||||||
|
const provider = AUTH[name];
|
||||||
|
const k = tokenMan.buildKeys(name);
|
||||||
|
if (provider.revoke) {
|
||||||
|
try {
|
||||||
|
const token = await chromeLocal.getValue(k.TOKEN);
|
||||||
|
if (token) await provider.revoke(token);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await chromeLocal.remove(k.LIST);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function refreshToken(name, k, obj) {
|
||||||
if (!obj[k.REFRESH]) {
|
if (!obj[k.REFRESH]) {
|
||||||
return Promise.reject(new Error('no refresh token'));
|
throw new Error('No refresh token');
|
||||||
}
|
}
|
||||||
const provider = AUTH[name];
|
const provider = AUTH[name];
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -119,17 +114,17 @@ const tokenManager = (() => {
|
||||||
if (provider.clientSecret) {
|
if (provider.clientSecret) {
|
||||||
body.client_secret = provider.clientSecret;
|
body.client_secret = provider.clientSecret;
|
||||||
}
|
}
|
||||||
return postQuery(provider.tokenURL, body)
|
const result = await postQuery(provider.tokenURL, body);
|
||||||
.then(result => {
|
if (!result.refresh_token) {
|
||||||
if (!result.refresh_token) {
|
// reuse old refresh token
|
||||||
// reuse old refresh token
|
result.refresh_token = obj[k.REFRESH];
|
||||||
result.refresh_token = obj[k.REFRESH];
|
}
|
||||||
}
|
return handleTokenResult(result, k);
|
||||||
return handleTokenResult(result, k);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function authUser(name, k, interactive = false) {
|
async function authUser(name, k, interactive = false) {
|
||||||
|
await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
|
||||||
|
/* global webextLaunchWebAuthFlow */
|
||||||
const provider = AUTH[name];
|
const provider = AUTH[name];
|
||||||
const state = Math.random().toFixed(8).slice(2);
|
const state = Math.random().toFixed(8).slice(2);
|
||||||
const query = {
|
const query = {
|
||||||
|
@ -145,52 +140,54 @@ const tokenManager = (() => {
|
||||||
Object.assign(query, provider.authQuery);
|
Object.assign(query, provider.authQuery);
|
||||||
}
|
}
|
||||||
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
|
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
|
||||||
return webextLaunchWebAuthFlow({
|
const finalUrl = await webextLaunchWebAuthFlow({
|
||||||
url,
|
url,
|
||||||
interactive,
|
interactive,
|
||||||
redirect_uri: query.redirect_uri,
|
redirect_uri: query.redirect_uri,
|
||||||
})
|
});
|
||||||
.then(url => {
|
const params = new URLSearchParams(
|
||||||
const params = new URLSearchParams(
|
provider.flow === 'token' ?
|
||||||
provider.flow === 'token' ?
|
new URL(finalUrl).hash.slice(1) :
|
||||||
new URL(url).hash.slice(1) :
|
new URL(finalUrl).search.slice(1)
|
||||||
new URL(url).search.slice(1)
|
);
|
||||||
);
|
if (params.get('state') !== state) {
|
||||||
if (params.get('state') !== state) {
|
throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
|
||||||
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
|
}
|
||||||
}
|
let result;
|
||||||
if (provider.flow === 'token') {
|
if (provider.flow === 'token') {
|
||||||
const obj = {};
|
const obj = {};
|
||||||
for (const [key, value] of params.entries()) {
|
for (const [key, value] of params) {
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
}
|
}
|
||||||
return obj;
|
result = obj;
|
||||||
}
|
} else {
|
||||||
const code = params.get('code');
|
const code = params.get('code');
|
||||||
const body = {
|
const body = {
|
||||||
code,
|
code,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
client_id: provider.clientId,
|
client_id: provider.clientId,
|
||||||
redirect_uri: query.redirect_uri,
|
redirect_uri: query.redirect_uri,
|
||||||
};
|
};
|
||||||
if (provider.clientSecret) {
|
if (provider.clientSecret) {
|
||||||
body.client_secret = provider.clientSecret;
|
body.client_secret = provider.clientSecret;
|
||||||
}
|
}
|
||||||
return postQuery(provider.tokenURL, body);
|
result = await postQuery(provider.tokenURL, body);
|
||||||
})
|
}
|
||||||
.then(result => handleTokenResult(result, k));
|
return handleTokenResult(result, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTokenResult(result, k) {
|
async function handleTokenResult(result, k) {
|
||||||
return chromeLocal.set({
|
await chromeLocal.set({
|
||||||
[k.TOKEN]: result.access_token,
|
[k.TOKEN]: result.access_token,
|
||||||
[k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined,
|
[k.EXPIRE]: result.expires_in
|
||||||
|
? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
|
||||||
|
: undefined,
|
||||||
[k.REFRESH]: result.refresh_token,
|
[k.REFRESH]: result.refresh_token,
|
||||||
})
|
});
|
||||||
.then(() => result.access_token);
|
return result.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function postQuery(url, body) {
|
async function postQuery(url, body) {
|
||||||
const options = {
|
const options = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -198,17 +195,13 @@ const tokenManager = (() => {
|
||||||
},
|
},
|
||||||
body: body ? new URLSearchParams(body) : null,
|
body: body ? new URLSearchParams(body) : null,
|
||||||
};
|
};
|
||||||
return fetch(url, options)
|
const r = await fetch(url, options);
|
||||||
.then(r => {
|
if (r.ok) {
|
||||||
if (r.ok) {
|
return r.json();
|
||||||
return r.json();
|
}
|
||||||
}
|
const text = await r.text();
|
||||||
return r.text()
|
const err = new Error(`Failed to fetch (${r.status}): ${text}`);
|
||||||
.then(body => {
|
err.code = r.status;
|
||||||
const err = new Error(`failed to fetch (${r.status}): ${body}`);
|
throw err;
|
||||||
err.code = r.status;
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
251
background/update-manager.js
Normal file
251
background/update-manager.js
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
/* global API */// msg.js
|
||||||
|
/* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
|
||||||
|
/* global chromeLocal */// storage-util.js
|
||||||
|
/* global debounce download ignoreChromeError */// toolbox.js
|
||||||
|
/* global prefs */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* exported updateMan */
|
||||||
|
const updateMan = (() => {
|
||||||
|
const STATES = /** @namespace UpdaterStates */ {
|
||||||
|
UPDATED: 'updated',
|
||||||
|
SKIPPED: 'skipped',
|
||||||
|
UNREACHABLE: 'server unreachable',
|
||||||
|
// details for SKIPPED status
|
||||||
|
EDITED: 'locally edited',
|
||||||
|
MAYBE_EDITED: 'may be locally edited',
|
||||||
|
SAME_MD5: 'up-to-date: MD5 is unchanged',
|
||||||
|
SAME_CODE: 'up-to-date: code sections are unchanged',
|
||||||
|
SAME_VERSION: 'up-to-date: version is unchanged',
|
||||||
|
ERROR_MD5: 'error: MD5 is invalid',
|
||||||
|
ERROR_JSON: 'error: JSON is invalid',
|
||||||
|
ERROR_VERSION: 'error: version is older than installed style',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALARM_NAME = 'scheduledUpdate';
|
||||||
|
const MIN_INTERVAL_MS = 60e3;
|
||||||
|
const RETRY_ERRORS = [
|
||||||
|
503, // service unavailable
|
||||||
|
429, // too many requests
|
||||||
|
];
|
||||||
|
let lastUpdateTime;
|
||||||
|
let checkingAll = false;
|
||||||
|
let logQueue = [];
|
||||||
|
let logLastWriteTime = 0;
|
||||||
|
|
||||||
|
chromeLocal.getValue('lastUpdateTime').then(val => {
|
||||||
|
lastUpdateTime = val || Date.now();
|
||||||
|
prefs.subscribe('updateInterval', schedule, {runNow: true});
|
||||||
|
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkAllStyles,
|
||||||
|
checkStyle,
|
||||||
|
getStates: () => STATES,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkAllStyles({
|
||||||
|
save = true,
|
||||||
|
ignoreDigest,
|
||||||
|
observe,
|
||||||
|
} = {}) {
|
||||||
|
resetInterval();
|
||||||
|
checkingAll = true;
|
||||||
|
const port = observe && chrome.runtime.connect({name: 'updater'});
|
||||||
|
const styles = (await API.styles.getAll())
|
||||||
|
.filter(style => style.updateUrl);
|
||||||
|
if (port) port.postMessage({count: styles.length});
|
||||||
|
log('');
|
||||||
|
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
|
||||||
|
await Promise.all(
|
||||||
|
styles.map(style =>
|
||||||
|
checkStyle({style, port, save, ignoreDigest})));
|
||||||
|
if (port) port.postMessage({done: true});
|
||||||
|
if (port) port.disconnect();
|
||||||
|
log('');
|
||||||
|
checkingAll = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
id?: number
|
||||||
|
style?: StyleObj
|
||||||
|
port?: chrome.runtime.Port
|
||||||
|
save?: boolean = true
|
||||||
|
ignoreDigest?: boolean
|
||||||
|
}} opts
|
||||||
|
* @returns {{
|
||||||
|
style: StyleObj
|
||||||
|
updated?: boolean
|
||||||
|
error?: any
|
||||||
|
STATES: UpdaterStates
|
||||||
|
}}
|
||||||
|
|
||||||
|
Original style digests are calculated in these cases:
|
||||||
|
* style is installed or updated from server
|
||||||
|
* non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
|
||||||
|
|
||||||
|
Update check proceeds in these cases:
|
||||||
|
* style has the original digest and it's equal to the current digest
|
||||||
|
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
|
||||||
|
* [ignoreDigest: none/false] style doesn't yet have the original digest
|
||||||
|
so we compare the code to the server code and if it's the same we save the digest,
|
||||||
|
otherwise we skip the style and report MAYBE_EDITED status
|
||||||
|
|
||||||
|
'ignoreDigest' option is set on the second manual individual update check on the manage page.
|
||||||
|
*/
|
||||||
|
async function checkStyle(opts) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
style = await API.styles.get(id),
|
||||||
|
ignoreDigest,
|
||||||
|
port,
|
||||||
|
save,
|
||||||
|
} = opts;
|
||||||
|
const ucd = style.usercssData;
|
||||||
|
let res, state;
|
||||||
|
try {
|
||||||
|
await checkIfEdited();
|
||||||
|
res = {
|
||||||
|
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
|
||||||
|
updated: true,
|
||||||
|
};
|
||||||
|
state = STATES.UPDATED;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err === 0 && STATES.UNREACHABLE ||
|
||||||
|
err && err.message ||
|
||||||
|
err;
|
||||||
|
res = {error, style, STATES};
|
||||||
|
state = `${STATES.SKIPPED} (${error})`;
|
||||||
|
}
|
||||||
|
log(`${state} #${style.id} ${style.customName || style.name}`);
|
||||||
|
if (port) port.postMessage(res);
|
||||||
|
return res;
|
||||||
|
|
||||||
|
async function checkIfEdited() {
|
||||||
|
if (!ignoreDigest &&
|
||||||
|
style.originalDigest &&
|
||||||
|
style.originalDigest !== await calcStyleDigest(style)) {
|
||||||
|
return Promise.reject(STATES.EDITED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUSO() {
|
||||||
|
const md5 = await tryDownload(style.md5Url);
|
||||||
|
if (!md5 || md5.length !== 32) {
|
||||||
|
return Promise.reject(STATES.ERROR_MD5);
|
||||||
|
}
|
||||||
|
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
|
||||||
|
return Promise.reject(STATES.SAME_MD5);
|
||||||
|
}
|
||||||
|
const json = await tryDownload(style.updateUrl, {responseType: 'json'});
|
||||||
|
if (!styleJSONseemsValid(json)) {
|
||||||
|
return Promise.reject(STATES.ERROR_JSON);
|
||||||
|
}
|
||||||
|
// USO may not provide a correctly updated originalMd5 (#555)
|
||||||
|
json.originalMd5 = md5;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUsercss() {
|
||||||
|
// TODO: when sourceCode is > 100kB use http range request(s) for version check
|
||||||
|
const text = await tryDownload(style.updateUrl);
|
||||||
|
const json = await API.usercss.buildMeta({sourceCode: text});
|
||||||
|
await require(['/vendor/semver-bundle/semver']); /* global semverCompare */
|
||||||
|
const delta = semverCompare(json.usercssData.version, ucd.version);
|
||||||
|
if (!delta && !ignoreDigest) {
|
||||||
|
// re-install is invalid in a soft upgrade
|
||||||
|
const sameCode = text === style.sourceCode;
|
||||||
|
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
|
||||||
|
}
|
||||||
|
if (delta < 0) {
|
||||||
|
// downgrade is always invalid
|
||||||
|
return Promise.reject(STATES.ERROR_VERSION);
|
||||||
|
}
|
||||||
|
return API.usercss.buildCode(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeSave(json) {
|
||||||
|
json.id = style.id;
|
||||||
|
json.updateDate = Date.now();
|
||||||
|
// keep current state
|
||||||
|
delete json.customName;
|
||||||
|
delete json.enabled;
|
||||||
|
const newStyle = Object.assign({}, style, json);
|
||||||
|
// update digest even if save === false as there might be just a space added etc.
|
||||||
|
if (!ucd && styleSectionsEqual(json, style)) {
|
||||||
|
style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
|
||||||
|
return Promise.reject(STATES.SAME_CODE);
|
||||||
|
}
|
||||||
|
if (!style.originalDigest && !ignoreDigest) {
|
||||||
|
return Promise.reject(STATES.MAYBE_EDITED);
|
||||||
|
}
|
||||||
|
return !save ? newStyle :
|
||||||
|
(ucd ? API.usercss.install : API.styles.install)(newStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryDownload(url, params) {
|
||||||
|
let {retryDelay = 1000} = opts;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await download(url, params);
|
||||||
|
} catch (code) {
|
||||||
|
if (!RETRY_ERRORS.includes(code) ||
|
||||||
|
retryDelay > MIN_INTERVAL_MS) {
|
||||||
|
return Promise.reject(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retryDelay *= 1.25;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
|
||||||
|
if (interval > 0) {
|
||||||
|
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
|
||||||
|
chrome.alarms.create(ALARM_NAME, {
|
||||||
|
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAlarm({name}) {
|
||||||
|
if (name === ALARM_NAME) checkAllStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetInterval() {
|
||||||
|
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(text) {
|
||||||
|
logQueue.push({text, time: new Date().toLocaleString()});
|
||||||
|
debounce(flushQueue, text && checkingAll ? 1000 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushQueue(lines) {
|
||||||
|
if (!lines) {
|
||||||
|
flushQueue(await chromeLocal.getValue('updateLog') || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const time = Date.now() - logLastWriteTime > 11e3 ?
|
||||||
|
logQueue[0].time + ' ' :
|
||||||
|
'';
|
||||||
|
if (logQueue[0] && !logQueue[0].text) {
|
||||||
|
logQueue.shift();
|
||||||
|
if (lines[lines.length - 1]) lines.push('');
|
||||||
|
}
|
||||||
|
lines.splice(0, lines.length - 1000);
|
||||||
|
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
|
||||||
|
lines.push(...logQueue.slice(1).map(item => item.text));
|
||||||
|
|
||||||
|
chromeLocal.setValue('updateLog', lines);
|
||||||
|
logLastWriteTime = Date.now();
|
||||||
|
logQueue = [];
|
||||||
|
}
|
||||||
|
})();
|
|
@ -1,290 +0,0 @@
|
||||||
/* global
|
|
||||||
API_METHODS
|
|
||||||
calcStyleDigest
|
|
||||||
chromeLocal
|
|
||||||
debounce
|
|
||||||
download
|
|
||||||
getStyleWithNoCode
|
|
||||||
ignoreChromeError
|
|
||||||
prefs
|
|
||||||
semverCompare
|
|
||||||
styleJSONseemsValid
|
|
||||||
styleManager
|
|
||||||
styleSectionsEqual
|
|
||||||
tryJSONparse
|
|
||||||
usercss
|
|
||||||
*/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
|
|
||||||
const STATES = {
|
|
||||||
UPDATED: 'updated',
|
|
||||||
SKIPPED: 'skipped',
|
|
||||||
|
|
||||||
// details for SKIPPED status
|
|
||||||
EDITED: 'locally edited',
|
|
||||||
MAYBE_EDITED: 'may be locally edited',
|
|
||||||
SAME_MD5: 'up-to-date: MD5 is unchanged',
|
|
||||||
SAME_CODE: 'up-to-date: code sections are unchanged',
|
|
||||||
SAME_VERSION: 'up-to-date: version is unchanged',
|
|
||||||
ERROR_MD5: 'error: MD5 is invalid',
|
|
||||||
ERROR_JSON: 'error: JSON is invalid',
|
|
||||||
ERROR_VERSION: 'error: version is older than installed style',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALARM_NAME = 'scheduledUpdate';
|
|
||||||
const MIN_INTERVAL_MS = 60e3;
|
|
||||||
|
|
||||||
let lastUpdateTime;
|
|
||||||
let checkingAll = false;
|
|
||||||
let logQueue = [];
|
|
||||||
let logLastWriteTime = 0;
|
|
||||||
|
|
||||||
const retrying = new Set();
|
|
||||||
|
|
||||||
API_METHODS.updateCheckAll = checkAllStyles;
|
|
||||||
API_METHODS.updateCheck = checkStyle;
|
|
||||||
API_METHODS.getUpdaterStates = () => STATES;
|
|
||||||
|
|
||||||
chromeLocal.getValue('lastUpdateTime').then(val => {
|
|
||||||
lastUpdateTime = val || Date.now();
|
|
||||||
prefs.subscribe('updateInterval', schedule, {now: true});
|
|
||||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {checkAllStyles, checkStyle, STATES};
|
|
||||||
|
|
||||||
function checkAllStyles({
|
|
||||||
save = true,
|
|
||||||
ignoreDigest,
|
|
||||||
observe,
|
|
||||||
} = {}) {
|
|
||||||
resetInterval();
|
|
||||||
checkingAll = true;
|
|
||||||
retrying.clear();
|
|
||||||
const port = observe && chrome.runtime.connect({name: 'updater'});
|
|
||||||
return styleManager.getAllStyles().then(styles => {
|
|
||||||
styles = styles.filter(style => style.updateUrl);
|
|
||||||
if (port) port.postMessage({count: styles.length});
|
|
||||||
log('');
|
|
||||||
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
|
|
||||||
return Promise.all(
|
|
||||||
styles.map(style =>
|
|
||||||
checkStyle({style, port, save, ignoreDigest})));
|
|
||||||
}).then(() => {
|
|
||||||
if (port) port.postMessage({done: true});
|
|
||||||
if (port) port.disconnect();
|
|
||||||
log('');
|
|
||||||
checkingAll = false;
|
|
||||||
retrying.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkStyle({
|
|
||||||
id,
|
|
||||||
style,
|
|
||||||
port,
|
|
||||||
save = true,
|
|
||||||
ignoreDigest,
|
|
||||||
}) {
|
|
||||||
/*
|
|
||||||
Original style digests are calculated in these cases:
|
|
||||||
* style is installed or updated from server
|
|
||||||
* style is checked for an update and its code is equal to the server code
|
|
||||||
|
|
||||||
Update check proceeds in these cases:
|
|
||||||
* style has the original digest and it's equal to the current digest
|
|
||||||
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
|
|
||||||
* [ignoreDigest: none/false] style doesn't yet have the original digest
|
|
||||||
so we compare the code to the server code and if it's the same we save the digest,
|
|
||||||
otherwise we skip the style and report MAYBE_EDITED status
|
|
||||||
|
|
||||||
'ignoreDigest' option is set on the second manual individual update check on the manage page.
|
|
||||||
*/
|
|
||||||
return fetchStyle()
|
|
||||||
.then(() => {
|
|
||||||
if (!ignoreDigest) {
|
|
||||||
return calcStyleDigest(style)
|
|
||||||
.then(checkIfEdited);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
if (style.usercssData) {
|
|
||||||
return maybeUpdateUsercss();
|
|
||||||
}
|
|
||||||
return maybeUpdateUSO();
|
|
||||||
})
|
|
||||||
.then(maybeSave)
|
|
||||||
.then(reportSuccess)
|
|
||||||
.catch(reportFailure);
|
|
||||||
|
|
||||||
function fetchStyle() {
|
|
||||||
if (style) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return styleManager.get(id)
|
|
||||||
.then(style_ => {
|
|
||||||
style = style_;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportSuccess(saved) {
|
|
||||||
log(STATES.UPDATED + ` #${style.id} ${style.customName || style.name}`);
|
|
||||||
const info = {updated: true, style: saved};
|
|
||||||
if (port) port.postMessage(info);
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportFailure(error) {
|
|
||||||
if ((
|
|
||||||
error === 503 || // Service Unavailable
|
|
||||||
error === 429 // Too Many Requests
|
|
||||||
) && !retrying.has(id)) {
|
|
||||||
retrying.add(id);
|
|
||||||
return new Promise(resolve => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(checkStyle({id, style, port, save, ignoreDigest}));
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
error = error === 0 ? 'server unreachable' : error;
|
|
||||||
// UserCSS metadata error returns an object; e.g. "Invalid @var color..."
|
|
||||||
if (typeof error === 'object' && error.message) {
|
|
||||||
error = error.message;
|
|
||||||
}
|
|
||||||
log(STATES.SKIPPED + ` (${error}) #${style.id} ${style.customName || style.name}`);
|
|
||||||
const info = {error, STATES, style: getStyleWithNoCode(style)};
|
|
||||||
if (port) port.postMessage(info);
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkIfEdited(digest) {
|
|
||||||
if (style.originalDigest && style.originalDigest !== digest) {
|
|
||||||
return Promise.reject(STATES.EDITED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeUpdateUSO() {
|
|
||||||
return download(style.md5Url).then(md5 => {
|
|
||||||
if (!md5 || md5.length !== 32) {
|
|
||||||
return Promise.reject(STATES.ERROR_MD5);
|
|
||||||
}
|
|
||||||
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
|
|
||||||
return Promise.reject(STATES.SAME_MD5);
|
|
||||||
}
|
|
||||||
// USO can't handle POST requests for style json
|
|
||||||
return download(style.updateUrl, {body: null})
|
|
||||||
.then(text => {
|
|
||||||
const style = tryJSONparse(text);
|
|
||||||
if (style) {
|
|
||||||
// USO may not provide a correctly updated originalMd5 (#555)
|
|
||||||
style.originalMd5 = md5;
|
|
||||||
}
|
|
||||||
return style;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeUpdateUsercss() {
|
|
||||||
// TODO: when sourceCode is > 100kB use http range request(s) for version check
|
|
||||||
return download(style.updateUrl).then(text =>
|
|
||||||
usercss.buildMeta(text).then(json => {
|
|
||||||
const {usercssData: {version}} = style;
|
|
||||||
const {usercssData: {version: newVersion}} = json;
|
|
||||||
switch (Math.sign(semverCompare(version, newVersion))) {
|
|
||||||
case 0:
|
|
||||||
// re-install is invalid in a soft upgrade
|
|
||||||
if (!ignoreDigest) {
|
|
||||||
const sameCode = text === style.sourceCode;
|
|
||||||
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
// downgrade is always invalid
|
|
||||||
return Promise.reject(STATES.ERROR_VERSION);
|
|
||||||
}
|
|
||||||
return usercss.buildCode(json);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeSave(json = {}) {
|
|
||||||
// usercss is already validated while building
|
|
||||||
if (!json.usercssData && !styleJSONseemsValid(json)) {
|
|
||||||
return Promise.reject(STATES.ERROR_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
json.id = style.id;
|
|
||||||
json.updateDate = Date.now();
|
|
||||||
|
|
||||||
// keep current state
|
|
||||||
delete json.enabled;
|
|
||||||
|
|
||||||
const newStyle = Object.assign({}, style, json);
|
|
||||||
if (!style.usercssData && styleSectionsEqual(json, style)) {
|
|
||||||
// update digest even if save === false as there might be just a space added etc.
|
|
||||||
return styleManager.installStyle(newStyle)
|
|
||||||
.then(saved => {
|
|
||||||
style.originalDigest = saved.originalDigest;
|
|
||||||
return Promise.reject(STATES.SAME_CODE);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!style.originalDigest && !ignoreDigest) {
|
|
||||||
return Promise.reject(STATES.MAYBE_EDITED);
|
|
||||||
}
|
|
||||||
|
|
||||||
return save ?
|
|
||||||
API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) :
|
|
||||||
newStyle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedule() {
|
|
||||||
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
|
|
||||||
if (interval > 0) {
|
|
||||||
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
|
|
||||||
chrome.alarms.create(ALARM_NAME, {
|
|
||||||
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAlarm({name}) {
|
|
||||||
if (name === ALARM_NAME) checkAllStyles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetInterval() {
|
|
||||||
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
|
|
||||||
schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(text) {
|
|
||||||
logQueue.push({text, time: new Date().toLocaleString()});
|
|
||||||
debounce(flushQueue, text && checkingAll ? 1000 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushQueue(lines) {
|
|
||||||
if (!lines) {
|
|
||||||
flushQueue(await chromeLocal.getValue('updateLog') || []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const time = Date.now() - logLastWriteTime > 11e3 ?
|
|
||||||
logQueue[0].time + ' ' :
|
|
||||||
'';
|
|
||||||
if (logQueue[0] && !logQueue[0].text) {
|
|
||||||
logQueue.shift();
|
|
||||||
if (lines[lines.length - 1]) lines.push('');
|
|
||||||
}
|
|
||||||
lines.splice(0, lines.length - 1000);
|
|
||||||
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
|
|
||||||
lines.push(...logQueue.slice(1).map(item => item.text));
|
|
||||||
|
|
||||||
chromeLocal.setValue('updateLog', lines);
|
|
||||||
logLastWriteTime = Date.now();
|
|
||||||
logQueue = [];
|
|
||||||
}
|
|
||||||
})();
|
|
|
@ -1,132 +0,0 @@
|
||||||
/* global API_METHODS usercss styleManager deepCopy */
|
|
||||||
/* exported usercssHelper */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const usercssHelper = (() => {
|
|
||||||
API_METHODS.installUsercss = installUsercss;
|
|
||||||
API_METHODS.editSaveUsercss = editSaveUsercss;
|
|
||||||
API_METHODS.configUsercssVars = configUsercssVars;
|
|
||||||
|
|
||||||
API_METHODS.buildUsercss = build;
|
|
||||||
API_METHODS.buildUsercssMeta = buildMeta;
|
|
||||||
API_METHODS.findUsercss = find;
|
|
||||||
|
|
||||||
function buildMeta(style) {
|
|
||||||
if (style.usercssData) {
|
|
||||||
return Promise.resolve(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow sourceCode to be normalized
|
|
||||||
const {sourceCode} = style;
|
|
||||||
delete style.sourceCode;
|
|
||||||
|
|
||||||
return usercss.buildMeta(sourceCode)
|
|
||||||
.then(newStyle => Object.assign(newStyle, style));
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignVars(style) {
|
|
||||||
return find(style)
|
|
||||||
.then(dup => {
|
|
||||||
if (dup) {
|
|
||||||
style.id = dup.id;
|
|
||||||
// preserve style.vars during update
|
|
||||||
return usercss.assignVars(style, dup)
|
|
||||||
.then(() => style);
|
|
||||||
}
|
|
||||||
return style;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the source, find the duplication, and build sections with variables
|
|
||||||
* @param _
|
|
||||||
* @param {String} _.sourceCode
|
|
||||||
* @param {Boolean=} _.checkDup
|
|
||||||
* @param {Boolean=} _.metaOnly
|
|
||||||
* @param {Object} _.vars
|
|
||||||
* @param {Boolean=} _.assignVars
|
|
||||||
* @returns {Promise<{style, dup:Boolean?}>}
|
|
||||||
*/
|
|
||||||
function build({
|
|
||||||
styleId,
|
|
||||||
sourceCode,
|
|
||||||
checkDup,
|
|
||||||
metaOnly,
|
|
||||||
vars,
|
|
||||||
assignVars = false,
|
|
||||||
}) {
|
|
||||||
return usercss.buildMeta(sourceCode)
|
|
||||||
.then(style => {
|
|
||||||
const findDup = checkDup || assignVars ?
|
|
||||||
find(styleId ? {id: styleId} : style) : Promise.resolve();
|
|
||||||
return Promise.all([
|
|
||||||
metaOnly ? style : doBuild(style, findDup),
|
|
||||||
findDup,
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
.then(([style, dup]) => ({style, dup}));
|
|
||||||
|
|
||||||
function doBuild(style, findDup) {
|
|
||||||
if (vars || assignVars) {
|
|
||||||
const getOld = vars ? Promise.resolve({usercssData: {vars}}) : findDup;
|
|
||||||
return getOld
|
|
||||||
.then(oldStyle => usercss.assignVars(style, oldStyle))
|
|
||||||
.then(() => usercss.buildCode(style));
|
|
||||||
}
|
|
||||||
return usercss.buildCode(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the style within aditional properties then inherit variable values
|
|
||||||
// from the old style.
|
|
||||||
function parse(style) {
|
|
||||||
return buildMeta(style)
|
|
||||||
.then(buildMeta)
|
|
||||||
.then(assignVars)
|
|
||||||
.then(usercss.buildCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: simplify this to `installUsercss(sourceCode)`?
|
|
||||||
function installUsercss(style) {
|
|
||||||
return parse(style)
|
|
||||||
.then(styleManager.installStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: simplify this to `editSaveUsercss({sourceCode, exclusions})`?
|
|
||||||
function editSaveUsercss(style) {
|
|
||||||
return parse(style)
|
|
||||||
.then(styleManager.editSave);
|
|
||||||
}
|
|
||||||
|
|
||||||
function configUsercssVars(id, vars) {
|
|
||||||
return styleManager.get(id)
|
|
||||||
.then(style => {
|
|
||||||
const newStyle = deepCopy(style);
|
|
||||||
newStyle.usercssData.vars = vars;
|
|
||||||
return usercss.buildCode(newStyle);
|
|
||||||
})
|
|
||||||
.then(style => styleManager.installStyle(style, 'config'))
|
|
||||||
.then(style => style.usercssData.vars);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Style|{name:string, namespace:string}} styleOrData
|
|
||||||
* @returns {Style}
|
|
||||||
*/
|
|
||||||
function find(styleOrData) {
|
|
||||||
if (styleOrData.id) {
|
|
||||||
return styleManager.get(styleOrData.id);
|
|
||||||
}
|
|
||||||
const {name, namespace} = styleOrData.usercssData || styleOrData;
|
|
||||||
return styleManager.getAllStyles().then(styleList => {
|
|
||||||
for (const dup of styleList) {
|
|
||||||
const data = dup.usercssData;
|
|
||||||
if (!data) continue;
|
|
||||||
if (data.name === name &&
|
|
||||||
data.namespace === namespace) {
|
|
||||||
return dup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
|
@ -1,37 +1,22 @@
|
||||||
/* global
|
/* global URLS download openURL */// toolbox.js
|
||||||
API_METHODS
|
/* global addAPI bgReady */// common.js
|
||||||
download
|
/* global tabMan */// msg.js
|
||||||
openURL
|
|
||||||
tabManager
|
|
||||||
URLS
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(() => {
|
bgReady.all.then(() => {
|
||||||
const installCodeCache = {};
|
const installCodeCache = {};
|
||||||
const clearInstallCode = url => delete installCodeCache[url];
|
|
||||||
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
|
|
||||||
const isContentTypeText = type => /^text\/(?!html)/i.test(type);
|
|
||||||
|
|
||||||
// in Firefox we have to use a content script to read file://
|
addAPI(/** @namespace API */ {
|
||||||
const fileLoader = !chrome.app && (
|
usercss: {
|
||||||
async tabId =>
|
getInstallCode(url) {
|
||||||
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
|
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
|
||||||
|
const {code, timer} = installCodeCache[url];
|
||||||
const urlLoader =
|
clearInstallCode(url);
|
||||||
async (tabId, url) => (
|
clearTimeout(timer);
|
||||||
url.startsWith('file:') ||
|
return code;
|
||||||
tabManager.get(tabId, isContentTypeText.name) ||
|
},
|
||||||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
|
},
|
||||||
) && download(url);
|
});
|
||||||
|
|
||||||
API_METHODS.getUsercssInstallCode = url => {
|
|
||||||
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
|
|
||||||
const {code, timer} = installCodeCache[url];
|
|
||||||
clearInstallCode(url);
|
|
||||||
clearTimeout(timer);
|
|
||||||
return code;
|
|
||||||
};
|
|
||||||
|
|
||||||
// `glob`: pathname match pattern for webRequest
|
// `glob`: pathname match pattern for webRequest
|
||||||
// `rx`: pathname regex to verify the URL really looks like a raw usercss
|
// `rx`: pathname regex to verify the URL really looks like a raw usercss
|
||||||
|
@ -48,17 +33,7 @@
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Faster installation on known distribution sites to avoid flicker of css text
|
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
|
||||||
chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => {
|
|
||||||
const u = new URL(url);
|
|
||||||
const m = maybeDistro[u.hostname];
|
|
||||||
if (!m || m.rx.test(u.pathname)) {
|
|
||||||
openInstallerPage(tabId, url, {});
|
|
||||||
// Silently suppress navigation.
|
|
||||||
// Don't redirect to the install URL as it'll flash the text!
|
|
||||||
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
urls: [
|
urls: [
|
||||||
URLS.usoArchiveRaw + 'usercss/*.user.css',
|
URLS.usoArchiveRaw + 'usercss/*.user.css',
|
||||||
'*://greasyfork.org/scripts/*/code/*.user.css',
|
'*://greasyfork.org/scripts/*/code/*.user.css',
|
||||||
|
@ -70,27 +45,63 @@
|
||||||
types: ['main_frame'],
|
types: ['main_frame'],
|
||||||
}, ['blocking']);
|
}, ['blocking']);
|
||||||
|
|
||||||
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
|
chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
|
||||||
chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
|
|
||||||
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
|
|
||||||
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
|
|
||||||
}, {
|
|
||||||
urls: makeUsercssGlobs('*', '/*'),
|
urls: makeUsercssGlobs('*', '/*'),
|
||||||
types: ['main_frame'],
|
types: ['main_frame'],
|
||||||
}, ['responseHeaders']);
|
}, ['responseHeaders']);
|
||||||
|
|
||||||
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
|
tabMan.onUpdate(maybeInstall);
|
||||||
|
|
||||||
|
function clearInstallCode(url) {
|
||||||
|
return delete installCodeCache[url];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
|
||||||
|
function isContentTypeText(type) {
|
||||||
|
return /^text\/(?!html)/i.test(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// in Firefox we have to use a content script to read file://
|
||||||
|
async function loadFromFile(tabId) {
|
||||||
|
return (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromUrl(tabId, url) {
|
||||||
|
return (
|
||||||
|
url.startsWith('file:') ||
|
||||||
|
tabMan.get(tabId, isContentTypeText.name) ||
|
||||||
|
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
|
||||||
|
) && download(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUsercssGlobs(host, path) {
|
||||||
|
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeInstall({tabId, url, oldUrl = ''}) {
|
||||||
if (url.includes('.user.') &&
|
if (url.includes('.user.') &&
|
||||||
/^(https?|file|ftps?):/.test(url) &&
|
/^(https?|file|ftps?):/.test(url) &&
|
||||||
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
|
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
|
||||||
!oldUrl.startsWith(URLS.installUsercss)) {
|
!oldUrl.startsWith(URLS.installUsercss)) {
|
||||||
const inTab = url.startsWith('file:') && Boolean(fileLoader);
|
const inTab = url.startsWith('file:') && !chrome.app;
|
||||||
const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
|
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
|
||||||
if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) {
|
if (!/^\s*</.test(code) && URLS.rxMETA.test(code)) {
|
||||||
openInstallerPage(tabId, url, {code, inTab});
|
openInstallerPage(tabId, url, {code, inTab});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/** Faster installation on known distribution sites to avoid flicker of css text */
|
||||||
|
function maybeInstallFromDistro({tabId, url}) {
|
||||||
|
const u = new URL(url);
|
||||||
|
const m = maybeDistro[u.hostname];
|
||||||
|
if (!m || m.rx.test(u.pathname)) {
|
||||||
|
openInstallerPage(tabId, url, {});
|
||||||
|
// Silently suppress navigation.
|
||||||
|
// Don't redirect to the install URL as it'll flash the text!
|
||||||
|
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openInstallerPage(tabId, url, {code, inTab} = {}) {
|
function openInstallerPage(tabId, url, {code, inTab} = {}) {
|
||||||
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
|
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
|
||||||
|
@ -110,7 +121,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUsercssGlobs(host, path) {
|
/** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
|
||||||
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
|
function rememberContentType({tabId, responseHeaders}) {
|
||||||
|
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
|
||||||
|
tabMan.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
|
||||||
}
|
}
|
||||||
})();
|
});
|
||||||
|
|
152
background/usercss-manager.js
Normal file
152
background/usercss-manager.js
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
/* global API */// msg.js
|
||||||
|
/* global URLS deepCopy download */// toolbox.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const usercssMan = {
|
||||||
|
|
||||||
|
GLOBAL_META: Object.entries({
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
homepageURL: 'url',
|
||||||
|
updateURL: 'updateUrl',
|
||||||
|
name: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
async assignVars(style, oldStyle) {
|
||||||
|
const meta = style.usercssData;
|
||||||
|
const vars = meta.vars;
|
||||||
|
const oldVars = oldStyle.usercssData.vars;
|
||||||
|
if (vars && oldVars) {
|
||||||
|
// The type of var might be changed during the update. Set value to null if the value is invalid.
|
||||||
|
for (const [key, v] of Object.entries(vars)) {
|
||||||
|
const old = oldVars[key] && oldVars[key].value;
|
||||||
|
if (old) v.value = old;
|
||||||
|
}
|
||||||
|
meta.vars = await API.worker.nullifyInvalidVars(vars);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async build({
|
||||||
|
styleId,
|
||||||
|
sourceCode,
|
||||||
|
vars,
|
||||||
|
checkDup,
|
||||||
|
metaOnly,
|
||||||
|
assignVars,
|
||||||
|
initialUrl,
|
||||||
|
}) {
|
||||||
|
// downloading here while install-usercss page is loading to avoid the wait
|
||||||
|
if (initialUrl) sourceCode = await download(initialUrl);
|
||||||
|
const style = await usercssMan.buildMeta({sourceCode});
|
||||||
|
const dup = (checkDup || assignVars) &&
|
||||||
|
await usercssMan.find(styleId ? {id: styleId} : style);
|
||||||
|
if (!metaOnly) {
|
||||||
|
if (vars || assignVars) {
|
||||||
|
await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup);
|
||||||
|
}
|
||||||
|
await usercssMan.buildCode(style);
|
||||||
|
}
|
||||||
|
return {style, dup};
|
||||||
|
},
|
||||||
|
|
||||||
|
async buildCode(style) {
|
||||||
|
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
|
||||||
|
const match = code.match(URLS.rxMETA);
|
||||||
|
const i = match.index;
|
||||||
|
const j = i + match[0].length;
|
||||||
|
const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j);
|
||||||
|
const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
|
||||||
|
const recoverable = errors.every(e => e.recoverable);
|
||||||
|
if (!sections.length || !recoverable) {
|
||||||
|
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
|
||||||
|
}
|
||||||
|
style.sections = sections;
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
|
||||||
|
async buildMeta(style) {
|
||||||
|
if (style.usercssData) {
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
// remember normalized sourceCode
|
||||||
|
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
|
||||||
|
style = Object.assign({
|
||||||
|
enabled: true,
|
||||||
|
sections: [],
|
||||||
|
}, style);
|
||||||
|
const match = code.match(URLS.rxMETA);
|
||||||
|
if (!match) {
|
||||||
|
return Promise.reject(new Error('Could not find metadata.'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
code = blankOut(code, 0, match.index) + match[0];
|
||||||
|
const {metadata} = await API.worker.parseUsercssMeta(code);
|
||||||
|
style.usercssData = metadata;
|
||||||
|
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
|
||||||
|
for (const [key, globalKey] of usercssMan.GLOBAL_META) {
|
||||||
|
const val = metadata[key];
|
||||||
|
if (val !== undefined) {
|
||||||
|
style[globalKey || key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code) {
|
||||||
|
const args = err.code === 'missingMandatory' || err.code === 'missingChar'
|
||||||
|
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
|
||||||
|
: err.args;
|
||||||
|
const msg = chrome.i18n.getMessage(`meta_${(err.code)}`, args);
|
||||||
|
if (msg) err.message = msg;
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async configVars(id, vars) {
|
||||||
|
const style = deepCopy(await API.styles.get(id));
|
||||||
|
style.usercssData.vars = vars;
|
||||||
|
await usercssMan.buildCode(style);
|
||||||
|
return (await API.styles.install(style, 'config'))
|
||||||
|
.usercssData.vars;
|
||||||
|
},
|
||||||
|
|
||||||
|
async editSave(style) {
|
||||||
|
return API.styles.editSave(await usercssMan.parse(style));
|
||||||
|
},
|
||||||
|
|
||||||
|
async find(styleOrData) {
|
||||||
|
if (styleOrData.id) {
|
||||||
|
return API.styles.get(styleOrData.id);
|
||||||
|
}
|
||||||
|
const {name, namespace} = styleOrData.usercssData || styleOrData;
|
||||||
|
for (const dup of await API.styles.getAll()) {
|
||||||
|
const data = dup.usercssData;
|
||||||
|
if (data &&
|
||||||
|
data.name === name &&
|
||||||
|
data.namespace === namespace) {
|
||||||
|
return dup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async install(style) {
|
||||||
|
return API.styles.install(await usercssMan.parse(style));
|
||||||
|
},
|
||||||
|
|
||||||
|
async parse(style) {
|
||||||
|
style = await usercssMan.buildMeta(style);
|
||||||
|
// preserve style.vars during update
|
||||||
|
const dup = await usercssMan.find(style);
|
||||||
|
if (dup) {
|
||||||
|
style.id = dup.id;
|
||||||
|
await usercssMan.assignVars(style, dup);
|
||||||
|
}
|
||||||
|
return usercssMan.buildCode(style);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replaces everything with spaces to keep the original length,
|
||||||
|
* but preserves the line breaks to keep the original line/col relation */
|
||||||
|
function blankOut(str, start = 0, end = str.length) {
|
||||||
|
return str.slice(start, end).replace(/[^\r\n]/g, ' ');
|
||||||
|
}
|
178
content/apply.js
178
content/apply.js
|
@ -1,34 +1,22 @@
|
||||||
/* global msg API prefs createStyleInjector */
|
/* global API msg */// msg.js
|
||||||
|
/* global StyleInjector */
|
||||||
|
/* global prefs */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Chrome reruns content script when documentElement is replaced.
|
(() => {
|
||||||
// Note, we're checking against a literal `1`, not just `if (truthy)`,
|
if (window.INJECTED === 1) return;
|
||||||
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
let hasStyles = false;
|
||||||
self.INJECTED !== 1 && (() => {
|
let isTab = !chrome.tabs || location.pathname !== '/popup.html';
|
||||||
self.INJECTED = 1;
|
const isFrame = window !== parent;
|
||||||
|
const isFrameAboutBlank = isFrame && location.href === 'about:blank';
|
||||||
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
|
const isUnstylable = !chrome.app && document instanceof XMLDocument;
|
||||||
const IS_FRAME = window !== parent;
|
const styleInjector = StyleInjector({
|
||||||
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
|
|
||||||
const styleInjector = createStyleInjector({
|
|
||||||
compare: (a, b) => a.id - b.id,
|
compare: (a, b) => a.id - b.id,
|
||||||
onUpdate: onInjectorUpdate,
|
onUpdate: onInjectorUpdate,
|
||||||
});
|
});
|
||||||
const initializing = init();
|
// dynamic about: and javascript: iframes don't have a URL yet so we'll use their parent
|
||||||
/** @type chrome.runtime.Port */
|
const matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href) || location.href;
|
||||||
let port;
|
|
||||||
let lazyBadge = IS_FRAME;
|
|
||||||
let parentDomain;
|
|
||||||
|
|
||||||
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
|
|
||||||
if (!IS_TAB) {
|
|
||||||
chrome.tabs.getCurrent(tab => {
|
|
||||||
IS_TAB = Boolean(tab);
|
|
||||||
if (tab && styleInjector.list.length) updateCount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// save it now because chrome.runtime will be unavailable in the orphaned script
|
// save it now because chrome.runtime will be unavailable in the orphaned script
|
||||||
const orphanEventId = chrome.runtime.id;
|
const orphanEventId = chrome.runtime.id;
|
||||||
|
@ -36,6 +24,22 @@ self.INJECTED !== 1 && (() => {
|
||||||
// firefox doesn't orphanize content scripts so the old elements stay
|
// firefox doesn't orphanize content scripts so the old elements stay
|
||||||
if (!chrome.app) styleInjector.clearOrphans();
|
if (!chrome.app) styleInjector.clearOrphans();
|
||||||
|
|
||||||
|
/** @type chrome.runtime.Port */
|
||||||
|
let port;
|
||||||
|
let lazyBadge = isFrame;
|
||||||
|
let parentDomain;
|
||||||
|
|
||||||
|
// Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
|
||||||
|
const ready = init();
|
||||||
|
|
||||||
|
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
|
||||||
|
if (!isTab) {
|
||||||
|
chrome.tabs.getCurrent(tab => {
|
||||||
|
isTab = Boolean(tab);
|
||||||
|
if (tab && styleInjector.list.length) updateCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
msg.onTab(applyOnMessage);
|
msg.onTab(applyOnMessage);
|
||||||
|
|
||||||
if (!chrome.tabs) {
|
if (!chrome.tabs) {
|
||||||
|
@ -47,103 +51,97 @@ self.INJECTED !== 1 && (() => {
|
||||||
if (!isOrphaned) {
|
if (!isOrphaned) {
|
||||||
updateCount();
|
updateCount();
|
||||||
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
|
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
|
||||||
onOff(['disableAll'], updateDisableAll);
|
onOff('disableAll', updateDisableAll);
|
||||||
if (IS_FRAME) {
|
if (isFrame) {
|
||||||
updateExposeIframes();
|
updateExposeIframes();
|
||||||
onOff(['exposeIframes'], updateExposeIframes);
|
onOff('exposeIframes', updateExposeIframes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (STYLE_VIA_API) {
|
if (isUnstylable) {
|
||||||
await API.styleViaAPI({method: 'styleApply'});
|
await API.styleViaAPI({method: 'styleApply'});
|
||||||
} else {
|
} else {
|
||||||
const styles = chrome.app && !chrome.tabs && getStylesViaXhr() ||
|
const SYM_ID = 'styles';
|
||||||
await API.getSectionsByUrl(getMatchUrl(), null, true);
|
const SYM = Symbol.for(SYM_ID);
|
||||||
if (styles.disableAll) {
|
const styles =
|
||||||
delete styles.disableAll;
|
window[SYM] ||
|
||||||
styleInjector.toggle(false);
|
(isFrameAboutBlank
|
||||||
|
? tryCatch(() => parent[parent.Symbol.for(SYM_ID)])
|
||||||
|
: chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr)) ||
|
||||||
|
await API.styles.getSectionsByUrl(matchUrl, null, true);
|
||||||
|
hasStyles = !styles.disableAll;
|
||||||
|
if (hasStyles) {
|
||||||
|
window[SYM] = styles;
|
||||||
|
await styleInjector.apply(styles);
|
||||||
|
} else {
|
||||||
|
delete window[SYM];
|
||||||
|
prefs.subscribe('disableAll', updateDisableAll);
|
||||||
}
|
}
|
||||||
await styleInjector.apply(styles);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Must be executed inside try/catch */
|
||||||
function getStylesViaXhr() {
|
function getStylesViaXhr() {
|
||||||
try {
|
const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
|
||||||
const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
|
const url = 'blob:' + chrome.runtime.getURL(blobId);
|
||||||
const url = 'blob:' + chrome.runtime.getURL(blobId);
|
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
|
||||||
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
|
const xhr = new XMLHttpRequest();
|
||||||
const xhr = new XMLHttpRequest();
|
xhr.open('GET', url, false); // synchronous
|
||||||
xhr.open('GET', url, false); // synchronous
|
xhr.send();
|
||||||
xhr.send();
|
URL.revokeObjectURL(url);
|
||||||
URL.revokeObjectURL(url);
|
return JSON.parse(xhr.response);
|
||||||
return JSON.parse(xhr.response);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMatchUrl() {
|
|
||||||
let matchUrl = location.href;
|
|
||||||
if (!chrome.tabs && !matchUrl.match(/^(http|file|chrome|ftp)/)) {
|
|
||||||
// dynamic about: and javascript: iframes don't have an URL yet
|
|
||||||
// so we'll try the parent frame which is guaranteed to have a real URL
|
|
||||||
try {
|
|
||||||
if (IS_FRAME) {
|
|
||||||
matchUrl = parent.location.href;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
return matchUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOnMessage(request) {
|
function applyOnMessage(request) {
|
||||||
if (STYLE_VIA_API) {
|
const {method} = request;
|
||||||
if (request.method === 'urlChanged') {
|
if (isUnstylable) {
|
||||||
|
if (method === 'urlChanged') {
|
||||||
request.method = 'styleReplaceAll';
|
request.method = 'styleReplaceAll';
|
||||||
}
|
}
|
||||||
if (/^(style|updateCount)/.test(request.method)) {
|
if (/^(style|updateCount)/.test(method)) {
|
||||||
API.styleViaAPI(request);
|
API.styleViaAPI(request);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (request.method) {
|
const {style} = request;
|
||||||
|
switch (method) {
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'styleDeleted':
|
case 'styleDeleted':
|
||||||
styleInjector.remove(request.style.id);
|
styleInjector.remove(style.id);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'styleUpdated':
|
case 'styleUpdated':
|
||||||
if (request.style.enabled) {
|
if (style.enabled) {
|
||||||
API.getSectionsByUrl(getMatchUrl(), request.style.id)
|
API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
|
||||||
.then(sections => {
|
sections[style.id]
|
||||||
if (!sections[request.style.id]) {
|
? styleInjector.apply(sections)
|
||||||
styleInjector.remove(request.style.id);
|
: styleInjector.remove(style.id));
|
||||||
} else {
|
|
||||||
styleInjector.apply(sections);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
styleInjector.remove(request.style.id);
|
styleInjector.remove(style.id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'styleAdded':
|
case 'styleAdded':
|
||||||
if (request.style.enabled) {
|
if (style.enabled) {
|
||||||
API.getSectionsByUrl(getMatchUrl(), request.style.id)
|
API.styles.getSectionsByUrl(matchUrl, style.id)
|
||||||
.then(styleInjector.apply);
|
.then(styleInjector.apply);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'urlChanged':
|
case 'urlChanged':
|
||||||
API.getSectionsByUrl(getMatchUrl())
|
API.styles.getSectionsByUrl(matchUrl).then(sections => {
|
||||||
.then(styleInjector.replace);
|
hasStyles = true;
|
||||||
|
styleInjector.replace(sections);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'backgroundReady':
|
case 'backgroundReady':
|
||||||
initializing.catch(err =>
|
ready.catch(err =>
|
||||||
msg.isIgnorableError(err)
|
msg.isIgnorableError(err)
|
||||||
? init()
|
? init()
|
||||||
: console.error(err));
|
: console.error(err));
|
||||||
|
@ -156,8 +154,10 @@ self.INJECTED !== 1 && (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDisableAll(key, disableAll) {
|
function updateDisableAll(key, disableAll) {
|
||||||
if (STYLE_VIA_API) {
|
if (isUnstylable) {
|
||||||
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
|
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
|
||||||
|
} else if (!hasStyles && !disableAll) {
|
||||||
|
init();
|
||||||
} else {
|
} else {
|
||||||
styleInjector.toggle(!disableAll);
|
styleInjector.toggle(!disableAll);
|
||||||
}
|
}
|
||||||
|
@ -179,8 +179,8 @@ self.INJECTED !== 1 && (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
if (!IS_TAB) return;
|
if (!isTab) return;
|
||||||
if (IS_FRAME) {
|
if (isFrame) {
|
||||||
if (!port && styleInjector.list.length) {
|
if (!port && styleInjector.list.length) {
|
||||||
port = chrome.runtime.connect({name: 'iframe'});
|
port = chrome.runtime.connect({name: 'iframe'});
|
||||||
} else if (port && !styleInjector.list.length) {
|
} else if (port && !styleInjector.list.length) {
|
||||||
|
@ -188,23 +188,25 @@ self.INJECTED !== 1 && (() => {
|
||||||
}
|
}
|
||||||
if (lazyBadge && performance.now() > 1000) lazyBadge = false;
|
if (lazyBadge && performance.now() > 1000) lazyBadge = false;
|
||||||
}
|
}
|
||||||
(STYLE_VIA_API ?
|
(isUnstylable ?
|
||||||
API.styleViaAPI({method: 'updateCount'}) :
|
API.styleViaAPI({method: 'updateCount'}) :
|
||||||
API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
|
API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
|
||||||
).catch(msg.ignoreError);
|
).catch(msg.ignoreError);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orphanCheck() {
|
function tryCatch(func, ...args) {
|
||||||
try {
|
try {
|
||||||
if (chrome.i18n.getUILanguage()) return;
|
return func(...args);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function orphanCheck() {
|
||||||
|
if (tryCatch(() => chrome.i18n.getUILanguage())) return;
|
||||||
// In Chrome content script is orphaned on an extension update/reload
|
// In Chrome content script is orphaned on an extension update/reload
|
||||||
// so we need to detach event listeners
|
// so we need to detach event listeners
|
||||||
window.removeEventListener(orphanEventId, orphanCheck, true);
|
window.removeEventListener(orphanEventId, orphanCheck, true);
|
||||||
isOrphaned = true;
|
isOrphaned = true;
|
||||||
styleInjector.clear();
|
styleInjector.clear();
|
||||||
try {
|
tryCatch(msg.off, applyOnMessage);
|
||||||
msg.off(applyOnMessage);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* global API */
|
/* global API */// msg.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// onCommitted may fire twice
|
// onCommitted may fire twice
|
||||||
|
@ -13,7 +13,7 @@ if (window.INJECTED_GREASYFORK !== 1) {
|
||||||
e.data.name &&
|
e.data.name &&
|
||||||
e.data.type === 'style-version-query') {
|
e.data.type === 'style-version-query') {
|
||||||
removeEventListener('message', onMessage);
|
removeEventListener('message', onMessage);
|
||||||
const style = await API.findUsercss(e.data) || {};
|
const style = await API.usercss.find(e.data) || {};
|
||||||
const {version} = style.usercssData || {};
|
const {version} = style.usercssData || {};
|
||||||
postMessage({type: 'style-version', version}, '*');
|
postMessage({type: 'style-version', version}, '*');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* global API */
|
/* global API */// msg.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
&& event.data.type === 'ouc-is-installed'
|
&& event.data.type === 'ouc-is-installed'
|
||||||
&& allowedOrigins.includes(event.origin)
|
&& allowedOrigins.includes(event.origin)
|
||||||
) {
|
) {
|
||||||
API.findUsercss({
|
API.usercss.find({
|
||||||
name: event.data.name,
|
name: event.data.name,
|
||||||
namespace: event.data.namespace,
|
namespace: event.data.namespace,
|
||||||
}).then(style => {
|
}).then(style => {
|
||||||
|
@ -55,7 +55,7 @@
|
||||||
window.addEventListener('message', installedHandler);
|
window.addEventListener('message', installedHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doHandshake = () => {
|
const doHandshake = event => {
|
||||||
// This is a representation of features that Stylus is capable of
|
// This is a representation of features that Stylus is capable of
|
||||||
const implementedFeatures = [
|
const implementedFeatures = [
|
||||||
'install-usercss',
|
'install-usercss',
|
||||||
|
@ -106,7 +106,7 @@
|
||||||
&& event.data.type === 'ouc-handshake-question'
|
&& event.data.type === 'ouc-handshake-question'
|
||||||
&& allowedOrigins.includes(event.origin)
|
&& allowedOrigins.includes(event.origin)
|
||||||
) {
|
) {
|
||||||
doHandshake();
|
doHandshake(event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@
|
||||||
&& event.data.type === 'ouc-install-usercss'
|
&& event.data.type === 'ouc-install-usercss'
|
||||||
&& allowedOrigins.includes(event.origin)
|
&& allowedOrigins.includes(event.origin)
|
||||||
) {
|
) {
|
||||||
API.installUsercss({
|
API.usercss.install({
|
||||||
name: event.data.title,
|
name: event.data.title,
|
||||||
sourceCode: event.data.code,
|
sourceCode: event.data.code,
|
||||||
}).then(style => {
|
}).then(style => {
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// preventing reregistration if reinjected by tabs.executeScript for whatever reason, just in case
|
// preventing reregistration if reinjected by tabs.executeScript for whatever reason, just in case
|
||||||
if (typeof self.oldCode !== 'string') {
|
if (typeof window.oldCode !== 'string') {
|
||||||
self.oldCode = (document.querySelector('body > pre') || document.body).textContent;
|
window.oldCode = (document.querySelector('body > pre') || document.body).textContent;
|
||||||
chrome.runtime.onConnect.addListener(port => {
|
chrome.runtime.onConnect.addListener(port => {
|
||||||
if (port.name !== 'downloadSelf') return;
|
if (port.name !== 'downloadSelf') return;
|
||||||
port.onMessage.addListener(({id, force}) => {
|
port.onMessage.addListener(async ({id, force}) => {
|
||||||
fetch(location.href, {mode: 'same-origin'})
|
const msg = {id};
|
||||||
.then(r => r.text())
|
try {
|
||||||
.then(code => ({id, code: force || code !== self.oldCode ? code : null}))
|
const code = await (await fetch(location.href, {mode: 'same-origin'})).text();
|
||||||
.catch(error => ({id, error: error.message || `${error}`}))
|
if (code !== window.oldCode || force) {
|
||||||
.then(msg => {
|
msg.code = window.oldCode = code;
|
||||||
port.postMessage(msg);
|
}
|
||||||
if (msg.code != null) self.oldCode = msg.code;
|
} catch (error) {
|
||||||
});
|
msg.error = error.message || `${error}`;
|
||||||
|
}
|
||||||
|
port.postMessage(msg);
|
||||||
});
|
});
|
||||||
// FF keeps content scripts connected on navigation https://github.com/openstyles/stylus/issues/864
|
// FF keeps content scripts connected on navigation https://github.com/openstyles/stylus/issues/864
|
||||||
addEventListener('pagehide', () => port.disconnect(), {once: true});
|
addEventListener('pagehide', () => port.disconnect(), {once: true});
|
||||||
|
@ -21,4 +23,4 @@ if (typeof self.oldCode !== 'string') {
|
||||||
}
|
}
|
||||||
|
|
||||||
// passing the result to tabs.executeScript
|
// passing the result to tabs.executeScript
|
||||||
self.oldCode; // eslint-disable-line no-unused-expressions
|
window.oldCode; // eslint-disable-line no-unused-expressions
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* global cloneInto msg API */
|
/* global API msg */// msg.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
@ -14,17 +14,10 @@
|
||||||
|
|
||||||
msg.on(onMessage);
|
msg.on(onMessage);
|
||||||
|
|
||||||
onDOMready().then(() => {
|
|
||||||
window.postMessage({
|
|
||||||
direction: 'from-content-script',
|
|
||||||
message: 'StylishInstalled',
|
|
||||||
}, '*');
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentMd5;
|
let currentMd5;
|
||||||
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
|
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
API.findStyle({md5Url}),
|
API.styles.find({md5Url}),
|
||||||
getResource(md5Url),
|
getResource(md5Url),
|
||||||
onDOMready(),
|
onDOMready(),
|
||||||
]).then(checkUpdatability);
|
]).then(checkUpdatability);
|
||||||
|
@ -119,7 +112,7 @@
|
||||||
if (typeof cloneInto !== 'undefined') {
|
if (typeof cloneInto !== 'undefined') {
|
||||||
// Firefox requires explicit cloning, however USO can't process our messages anyway
|
// Firefox requires explicit cloning, however USO can't process our messages anyway
|
||||||
// because USO tries to use a global "event" variable deprecated in Firefox
|
// because USO tries to use a global "event" variable deprecated in Firefox
|
||||||
detail = cloneInto({detail}, document);
|
detail = cloneInto({detail}, document); /* global cloneInto */
|
||||||
} else {
|
} else {
|
||||||
detail = {detail};
|
detail = {detail};
|
||||||
}
|
}
|
||||||
|
@ -154,9 +147,9 @@
|
||||||
|
|
||||||
function doInstall() {
|
function doInstall() {
|
||||||
let oldStyle;
|
let oldStyle;
|
||||||
return API.findStyle({
|
return API.styles.find({
|
||||||
md5Url: getMeta('stylish-md5-url') || location.href,
|
md5Url: getMeta('stylish-md5-url') || location.href,
|
||||||
}, true)
|
})
|
||||||
.then(_oldStyle => {
|
.then(_oldStyle => {
|
||||||
oldStyle = _oldStyle;
|
oldStyle = _oldStyle;
|
||||||
return oldStyle ?
|
return oldStyle ?
|
||||||
|
@ -172,7 +165,7 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStyleCode(message, name, addProps = {}) {
|
async function saveStyleCode(message, name, addProps = {}) {
|
||||||
const isNew = message === 'styleInstall';
|
const isNew = message === 'styleInstall';
|
||||||
const needsConfirmation = isNew || !saveStyleCode.confirmed;
|
const needsConfirmation = isNew || !saveStyleCode.confirmed;
|
||||||
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
|
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
|
||||||
|
@ -180,22 +173,19 @@
|
||||||
}
|
}
|
||||||
saveStyleCode.confirmed = true;
|
saveStyleCode.confirmed = true;
|
||||||
enableUpdateButton(false);
|
enableUpdateButton(false);
|
||||||
return getStyleJson().then(json => {
|
const json = await getStyleJson();
|
||||||
if (!json) {
|
if (!json) {
|
||||||
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
|
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
|
||||||
'https://github.com/openstyles/stylus/issues/195');
|
'https://github.com/openstyles/stylus/issues/195');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
|
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
|
||||||
return API.installStyle(Object.assign(json, addProps, {originalMd5: currentMd5}))
|
const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}));
|
||||||
.then(style => {
|
if (!isNew && style.updateUrl.includes('?')) {
|
||||||
if (!isNew && style.updateUrl.includes('?')) {
|
enableUpdateButton(true);
|
||||||
enableUpdateButton(true);
|
} else {
|
||||||
} else {
|
sendEvent({type: 'styleInstalledChrome'});
|
||||||
sendEvent({type: 'styleInstalledChrome'});
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function enableUpdateButton(state) {
|
function enableUpdateButton(state) {
|
||||||
const important = s => s.replace(/;/g, '!important;');
|
const important = s => s.replace(/;/g, '!important;');
|
||||||
|
@ -218,50 +208,32 @@
|
||||||
return e ? e.getAttribute('href') : null;
|
return e ? e.getAttribute('href') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResource(url, options) {
|
async function getResource(url, opts) {
|
||||||
if (url.startsWith('#')) {
|
try {
|
||||||
return Promise.resolve(document.getElementById(url.slice(1)).textContent);
|
return url.startsWith('#')
|
||||||
|
? document.getElementById(url.slice(1)).textContent
|
||||||
|
: await API.download(url, opts);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error\n' + error.message);
|
||||||
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
return API.download(Object.assign({
|
|
||||||
url,
|
|
||||||
timeout: 60e3,
|
|
||||||
// USO can't handle POST requests for style json
|
|
||||||
body: null,
|
|
||||||
}, options))
|
|
||||||
.catch(error => {
|
|
||||||
alert('Error' + (error ? '\n' + error : ''));
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
|
// USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
|
||||||
// instead of "https://update.userstyles.org/#####.md5"
|
// instead of "https://update.userstyles.org/#####.md5"
|
||||||
function tryFixMd5(style) {
|
async function getStyleJson() {
|
||||||
if (style && style.md5Url && style.md5Url.includes('update.update')) {
|
try {
|
||||||
style.md5Url = style.md5Url.replace('update.update', 'update');
|
const style = await getResource(getStyleURL(), {responseType: 'json'});
|
||||||
}
|
const codeElement = document.getElementById('stylish-code');
|
||||||
return style;
|
if (!style || !Array.isArray(style.sections) || style.sections.length ||
|
||||||
}
|
codeElement && !codeElement.textContent.trim()) {
|
||||||
|
return style;
|
||||||
function getStyleJson() {
|
}
|
||||||
return getResource(getStyleURL(), {responseType: 'json'})
|
const code = await getResource(getMeta('stylish-update-url'));
|
||||||
.then(style => {
|
style.sections = (await API.worker.parseMozFormat({code})).sections;
|
||||||
if (!style || !Array.isArray(style.sections) || style.sections.length) {
|
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
|
||||||
return style;
|
return style;
|
||||||
}
|
} catch (e) {}
|
||||||
const codeElement = document.getElementById('stylish-code');
|
|
||||||
if (codeElement && !codeElement.textContent.trim()) {
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
return getResource(getMeta('stylish-update-url'))
|
|
||||||
.then(code => API.parseCss({code}))
|
|
||||||
.then(result => {
|
|
||||||
style.sections = result.sections;
|
|
||||||
return style;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(tryFixMd5)
|
|
||||||
.catch(() => null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -295,7 +267,7 @@
|
||||||
function onDOMready() {
|
function onDOMready() {
|
||||||
return document.readyState !== 'loading'
|
return document.readyState !== 'loading'
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
|
: new Promise(resolve => window.addEventListener('load', resolve, {once: true}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(countdown = 10e3) {
|
function openSettings(countdown = 10e3) {
|
||||||
|
@ -334,6 +306,7 @@
|
||||||
|
|
||||||
function inPageContext(eventId) {
|
function inPageContext(eventId) {
|
||||||
document.currentScript.remove();
|
document.currentScript.remove();
|
||||||
|
window.isInstalled = true;
|
||||||
const origMethods = {
|
const origMethods = {
|
||||||
json: Response.prototype.json,
|
json: Response.prototype.json,
|
||||||
byId: document.getElementById,
|
byId: document.getElementById,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
/** @type {function(opts):StyleInjector} */
|
||||||
|
window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
|
||||||
compare,
|
compare,
|
||||||
onUpdate = () => {},
|
onUpdate = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -8,8 +9,6 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
||||||
const PATCH_ID = 'transition-patch';
|
const PATCH_ID = 'transition-patch';
|
||||||
// styles are out of order if any of these elements is injected between them
|
// styles are out of order if any of these elements is injected between them
|
||||||
const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']);
|
const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']);
|
||||||
// detect Chrome 65 via a feature it added since browser version can be spoofed
|
|
||||||
const isChromePre65 = chrome.app && typeof Worklet !== 'function';
|
|
||||||
const docRewriteObserver = RewriteObserver(_sort);
|
const docRewriteObserver = RewriteObserver(_sort);
|
||||||
const docRootObserver = RootObserver(_sortIfNeeded);
|
const docRootObserver = RootObserver(_sortIfNeeded);
|
||||||
const list = [];
|
const list = [];
|
||||||
|
@ -19,22 +18,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
||||||
// will store the original method refs because the page can override them
|
// will store the original method refs because the page can override them
|
||||||
let creationDoc, createElement, createElementNS;
|
let creationDoc, createElement, createElementNS;
|
||||||
|
|
||||||
return {
|
return /** @namespace StyleInjector */ {
|
||||||
|
|
||||||
list,
|
list,
|
||||||
|
|
||||||
apply(styleMap) {
|
async apply(styleMap) {
|
||||||
const styles = _styleMapToArray(styleMap);
|
const styles = _styleMapToArray(styleMap);
|
||||||
return (
|
const value = !styles.length
|
||||||
!styles.length ?
|
? []
|
||||||
Promise.resolve([]) :
|
: await docRootObserver.evade(() => {
|
||||||
docRootObserver.evade(() => {
|
if (!isTransitionPatched && isEnabled) {
|
||||||
if (!isTransitionPatched && isEnabled) {
|
_applyTransitionPatch(styles);
|
||||||
_applyTransitionPatch(styles);
|
}
|
||||||
}
|
return styles.map(_addUpdate);
|
||||||
return styles.map(_addUpdate);
|
});
|
||||||
})
|
_emitUpdate();
|
||||||
).then(_emitUpdate);
|
return value;
|
||||||
},
|
},
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
@ -157,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
||||||
docRootObserver[onOff]();
|
docRootObserver[onOff]();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _emitUpdate(value) {
|
function _emitUpdate() {
|
||||||
_toggleObservers(list.length);
|
_toggleObservers(list.length);
|
||||||
onUpdate();
|
onUpdate();
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -232,17 +230,8 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
||||||
|
|
||||||
function _update({id, code}) {
|
function _update({id, code}) {
|
||||||
const style = table.get(id);
|
const style = table.get(id);
|
||||||
if (style.code === code) return;
|
if (style.code !== code) {
|
||||||
style.code = code;
|
style.code = code;
|
||||||
// workaround for Chrome devtools bug fixed in v65
|
|
||||||
if (isChromePre65) {
|
|
||||||
const oldEl = style.el;
|
|
||||||
style.el = _createStyle(id, code);
|
|
||||||
if (isEnabled) {
|
|
||||||
oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling);
|
|
||||||
oldEl.remove();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
style.el.textContent = code;
|
style.el.textContent = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
80
edit.html
80
edit.html
|
@ -4,8 +4,6 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<link href="global.css" rel="stylesheet">
|
<link href="global.css" rel="stylesheet">
|
||||||
<link href="edit/edit.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="msgbox/msgbox.css">
|
|
||||||
|
|
||||||
<style id="firefox-transitions-bug-suppressor">
|
<style id="firefox-transitions-bug-suppressor">
|
||||||
/* restrict to FF */
|
/* restrict to FF */
|
||||||
|
@ -21,94 +19,59 @@
|
||||||
<link id="cm-theme" rel="stylesheet">
|
<link id="cm-theme" rel="stylesheet">
|
||||||
|
|
||||||
<script src="js/polyfill.js"></script>
|
<script src="js/polyfill.js"></script>
|
||||||
<script src="js/dom.js"></script>
|
<script src="js/toolbox.js"></script>
|
||||||
<script src="js/messaging.js"></script>
|
|
||||||
<script src="js/msg.js"></script>
|
<script src="js/msg.js"></script>
|
||||||
<script src="js/prefs.js"></script>
|
<script src="js/prefs.js"></script>
|
||||||
|
<script src="js/dom.js"></script>
|
||||||
<script src="js/localization.js"></script>
|
<script src="js/localization.js"></script>
|
||||||
<script src="js/script-loader.js"></script>
|
|
||||||
<script src="js/storage-util.js"></script>
|
|
||||||
|
|
||||||
<script src="content/style-injector.js"></script>
|
<script src="content/style-injector.js"></script>
|
||||||
<script src="content/apply.js"></script>
|
<script src="content/apply.js"></script>
|
||||||
|
|
||||||
<script src="edit/util.js"></script>
|
<script src="js/sections-util.js"></script>
|
||||||
<script src="edit/edit.js"></script> <!-- run it ASAP to send a request for the style -->
|
<script src="edit/base.js"></script>
|
||||||
|
|
||||||
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
|
|
||||||
<script src="vendor/codemirror/lib/codemirror.js"></script>
|
<script src="vendor/codemirror/lib/codemirror.js"></script>
|
||||||
|
|
||||||
<script src="vendor/codemirror/mode/css/css.js"></script>
|
<script src="vendor/codemirror/mode/css/css.js"></script>
|
||||||
<script src="vendor/codemirror/mode/stylus/stylus.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/dialog/dialog.js"></script>
|
||||||
|
|
||||||
<script src="vendor/codemirror/addon/edit/closebrackets.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/scroll/annotatescrollbar.js"></script>
|
||||||
<script src="vendor/codemirror/addon/search/searchcursor.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/comment/comment.js"></script>
|
||||||
<script src="vendor/codemirror/addon/selection/active-line.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/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/foldcode.js"></script>
|
||||||
<script src="vendor/codemirror/addon/fold/foldgutter.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/brace-fold.js"></script>
|
||||||
<script src="vendor/codemirror/addon/fold/indent-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/fold/comment-fold.js"></script>
|
||||||
|
|
||||||
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet" />
|
|
||||||
<script src="vendor/codemirror/addon/lint/lint.js"></script>
|
<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/show-hint.js"></script>
|
||||||
<script src="vendor/codemirror/addon/hint/css-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/sublime.js"></script>
|
||||||
<script src="vendor/codemirror/keymap/emacs.js"></script>
|
|
||||||
<script src="vendor/codemirror/keymap/vim.js"></script>
|
|
||||||
|
|
||||||
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
|
|
||||||
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
|
|
||||||
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
|
|
||||||
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
|
|
||||||
|
|
||||||
<script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script>
|
<script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script>
|
||||||
|
<script src="vendor/lz-string-unsafe/lz-string-unsafe.min.js"></script>
|
||||||
|
|
||||||
<script src="msgbox/msgbox.js" async></script>
|
<script src="js/color/color-converter.js"></script>
|
||||||
|
<script src="js/color/color-mimicry.js"></script>
|
||||||
|
<script src="js/color/color-picker.js"></script>
|
||||||
|
<script src="js/color/color-view.js"></script>
|
||||||
|
<script src="js/storage-util.js"></script>
|
||||||
|
<script src="js/worker-util.js"></script>
|
||||||
|
|
||||||
<link href="edit/codemirror-default.css" rel="stylesheet">
|
<script src="edit/util.js"></script>
|
||||||
|
<script src="edit/codemirror-themes.js"></script>
|
||||||
<script src="edit/codemirror-default.js"></script>
|
<script src="edit/codemirror-default.js"></script>
|
||||||
<script src="edit/codemirror-factory.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-finder.js"></script>
|
||||||
<script src="edit/moz-section-widget.js"></script>
|
<script src="edit/moz-section-widget.js"></script>
|
||||||
<script src="edit/reroute-hotkeys.js"></script>
|
<script src="edit/linter-manager.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/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/source-editor.js"></script>
|
||||||
<script src="edit/sections-editor-section.js"></script>
|
<script src="edit/sections-editor-section.js"></script>
|
||||||
<script src="edit/sections-editor.js"></script>
|
<script src="edit/sections-editor.js"></script>
|
||||||
|
<script src="edit/edit.js"></script>
|
||||||
<script src="js/worker-util.js"></script>
|
|
||||||
<script src="edit/linter.js"></script>
|
|
||||||
<script src="edit/linter-defaults.js"></script>
|
|
||||||
<script src="edit/linter-engines.js"></script>
|
|
||||||
<script src="edit/linter-meta.js"></script>
|
|
||||||
<script src="edit/linter-help-dialog.js"></script>
|
|
||||||
<script src="edit/linter-report.js"></script>
|
|
||||||
<script src="edit/linter-config-dialog.js"></script>
|
|
||||||
|
|
||||||
<template data-id="appliesTo">
|
<template data-id="appliesTo">
|
||||||
<li class="applies-to-item">
|
<li class="applies-to-item">
|
||||||
|
@ -276,6 +239,16 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
|
||||||
|
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
|
||||||
|
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet">
|
||||||
|
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet">
|
||||||
|
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet">
|
||||||
|
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
|
||||||
|
<link href="js/color/color-picker.css" rel="stylesheet">
|
||||||
|
<link href="edit/codemirror-default.css" rel="stylesheet">
|
||||||
|
<link href="edit/edit.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="stylus-edit">
|
<body id="stylus-edit">
|
||||||
|
@ -498,6 +471,5 @@
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
234
edit/autocomplete.js
Normal file
234
edit/autocomplete.js
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
/* global CodeMirror */
|
||||||
|
/* global debounce */// toolbox.js
|
||||||
|
/* global editor */
|
||||||
|
/* global prefs */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const USO_VAR = 'uso-variable';
|
||||||
|
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
|
||||||
|
const USO_INVALID_VAR = 'error ' + USO_VAR;
|
||||||
|
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
|
||||||
|
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
|
||||||
|
const cssMime = CodeMirror.mimeModes['text/css'];
|
||||||
|
const docFuncs = addSuffix(cssMime.documentTypes, '(');
|
||||||
|
const {tokenHooks} = cssMime;
|
||||||
|
const originalCommentHook = tokenHooks['/'];
|
||||||
|
const originalHelper = CodeMirror.hint.css || (() => {});
|
||||||
|
let cssProps, cssMedia;
|
||||||
|
|
||||||
|
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'),
|
||||||
|
(cm, value) => {
|
||||||
|
cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
|
||||||
|
cm[value ? 'on' : 'off']('pick', autocompletePicked);
|
||||||
|
});
|
||||||
|
|
||||||
|
CodeMirror.registerHelper('hint', 'css', helper);
|
||||||
|
CodeMirror.registerHelper('hint', 'stylus', helper);
|
||||||
|
|
||||||
|
tokenHooks['/'] = tokenizeUsoVariables;
|
||||||
|
|
||||||
|
function helper(cm) {
|
||||||
|
const pos = cm.getCursor();
|
||||||
|
const {line, ch} = pos;
|
||||||
|
const {styles, text} = cm.getLineHandle(line);
|
||||||
|
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
|
||||||
|
const isStylusLang = cm.doc.mode.name === 'stylus';
|
||||||
|
const type = style && style.split(' ', 1)[0] || 'prop?';
|
||||||
|
if (!type || type === 'comment' || type === 'string') {
|
||||||
|
return originalHelper(cm);
|
||||||
|
}
|
||||||
|
// not using getTokenAt until the need is unavoidable because it reparses text
|
||||||
|
// and runs a whole lot of complex calc inside which is slow on long lines
|
||||||
|
// especially if autocomplete is auto-shown on each keystroke
|
||||||
|
let prev, end, state;
|
||||||
|
let i = index;
|
||||||
|
while (
|
||||||
|
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
|
||||||
|
(prev = i > 2 ? styles[i - 2] : 0) &&
|
||||||
|
isSameToken(text, style, prev)
|
||||||
|
) i -= 2;
|
||||||
|
i = index;
|
||||||
|
while (
|
||||||
|
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
|
||||||
|
(end = styles[i]) &&
|
||||||
|
isSameToken(text, style, end)
|
||||||
|
) i += 2;
|
||||||
|
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
|
||||||
|
const str = text.slice(prev, end);
|
||||||
|
const left = text.slice(prev, ch).trim();
|
||||||
|
let leftLC = left.toLowerCase();
|
||||||
|
let list = [];
|
||||||
|
switch (leftLC[0]) {
|
||||||
|
|
||||||
|
case '!':
|
||||||
|
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '@':
|
||||||
|
list = [
|
||||||
|
'@-moz-document',
|
||||||
|
'@charset',
|
||||||
|
'@font-face',
|
||||||
|
'@import',
|
||||||
|
'@keyframes',
|
||||||
|
'@media',
|
||||||
|
'@namespace',
|
||||||
|
'@page',
|
||||||
|
'@supports',
|
||||||
|
'@viewport',
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '#': // prevents autocomplete for #hex colors
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '-': // --variable
|
||||||
|
case '(': // var(
|
||||||
|
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
|
||||||
|
? findAllCssVars(cm, left)
|
||||||
|
: [];
|
||||||
|
prev += str.startsWith('(');
|
||||||
|
leftLC = left;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '/': // USO vars
|
||||||
|
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
|
||||||
|
prev += 4;
|
||||||
|
end -= 4;
|
||||||
|
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
|
||||||
|
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
|
||||||
|
leftLC = left.slice(4);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'u': // url(), url-prefix()
|
||||||
|
case 'd': // domain()
|
||||||
|
case 'r': // regexp()
|
||||||
|
if (/^(variable|tag|error)/.test(type) &&
|
||||||
|
docFuncs.some(s => s.startsWith(leftLC)) &&
|
||||||
|
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
|
||||||
|
end++;
|
||||||
|
list = docFuncs;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// properties and media features
|
||||||
|
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
|
||||||
|
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
|
||||||
|
if (!cssProps) initCssProps();
|
||||||
|
if (type === 'prop?') {
|
||||||
|
prev += leftLC.length;
|
||||||
|
leftLC = '';
|
||||||
|
}
|
||||||
|
list = state === 'atBlock_parens' ? cssMedia : cssProps;
|
||||||
|
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
|
||||||
|
end += execAt(rxCONSUME, end, text)[0].length;
|
||||||
|
} else {
|
||||||
|
return isStylusLang
|
||||||
|
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
|
||||||
|
: originalHelper(cm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
list: (list || []).filter(s => s.startsWith(leftLC)),
|
||||||
|
from: {line, ch: prev + str.match(/^\s*/)[0].length},
|
||||||
|
to: {line, ch: end},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCssProps() {
|
||||||
|
cssProps = addSuffix(cssMime.propertyKeywords).sort();
|
||||||
|
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSuffix(obj, suffix = ': ') {
|
||||||
|
return Object.keys(obj).map(k => k + suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaKeys([k, v]) {
|
||||||
|
return k === 'mediaFeatures' && addSuffix(v) ||
|
||||||
|
k.startsWith('media') && Object.keys(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** makes sure we don't process a different adjacent comment */
|
||||||
|
function isSameToken(text, style, i) {
|
||||||
|
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
|
||||||
|
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAllCssVars(cm, leftPart) {
|
||||||
|
// simplified regex without CSS escapes
|
||||||
|
const rx = new RegExp(
|
||||||
|
'(?:^|[\\s/;{])(' +
|
||||||
|
(leftPart.startsWith('--') ? leftPart : '--') +
|
||||||
|
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
|
||||||
|
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
|
||||||
|
'g');
|
||||||
|
const list = new Set();
|
||||||
|
cm.eachLine(({text}) => {
|
||||||
|
for (let m; (m = rx.exec(text));) {
|
||||||
|
list.add(m[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...list].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeUsoVariables(stream) {
|
||||||
|
const token = originalCommentHook.apply(this, arguments);
|
||||||
|
if (token[1] === 'comment') {
|
||||||
|
const {string, start, pos} = stream;
|
||||||
|
if (testAt(/\/\*\[\[/y, start, string) &&
|
||||||
|
testAt(/]]\*\//y, pos - 4, string)) {
|
||||||
|
const vars = (editor.style.usercssData || {}).vars;
|
||||||
|
token[0] =
|
||||||
|
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
|
||||||
|
? USO_VALID_VAR
|
||||||
|
: USO_INVALID_VAR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execAt(rx, index, text) {
|
||||||
|
rx.lastIndex = index;
|
||||||
|
return rx.exec(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testAt(rx, index, text) {
|
||||||
|
rx.lastIndex = Math.max(0, index);
|
||||||
|
return rx.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autocompleteOnTyping(cm, [info], debounced) {
|
||||||
|
const lastLine = info.text[info.text.length - 1];
|
||||||
|
if (cm.state.completionActive ||
|
||||||
|
info.origin && !info.origin.includes('input') ||
|
||||||
|
!lastLine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cm.state.autocompletePicked) {
|
||||||
|
cm.state.autocompletePicked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!debounced) {
|
||||||
|
debounce(autocompleteOnTyping, 100, cm, [info], true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastLine.match(/[-a-z!]+$/i)) {
|
||||||
|
cm.state.autocompletePicked = false;
|
||||||
|
cm.options.hintOptions.completeSingle = false;
|
||||||
|
cm.execCommand('autocomplete');
|
||||||
|
setTimeout(() => {
|
||||||
|
cm.options.hintOptions.completeSingle = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autocompletePicked(cm) {
|
||||||
|
cm.state.autocompletePicked = true;
|
||||||
|
}
|
||||||
|
})();
|
412
edit/base.js
Normal file
412
edit/base.js
Normal file
|
@ -0,0 +1,412 @@
|
||||||
|
/* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
|
||||||
|
/* global API */// msg.js
|
||||||
|
/* global CODEMIRROR_THEMES */
|
||||||
|
/* global CodeMirror */
|
||||||
|
/* global MozDocMapper */// sections-util.js
|
||||||
|
/* global initBeautifyButton */// beautify.js
|
||||||
|
/* global prefs */
|
||||||
|
/* global t */// localization.js
|
||||||
|
/* global
|
||||||
|
FIREFOX
|
||||||
|
debounce
|
||||||
|
getOwnTab
|
||||||
|
sessionStore
|
||||||
|
tryCatch
|
||||||
|
tryJSONparse
|
||||||
|
*/// toolbox.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type Editor
|
||||||
|
* @namespace Editor
|
||||||
|
*/
|
||||||
|
const editor = {
|
||||||
|
dirty: DirtyReporter(),
|
||||||
|
isUsercss: false,
|
||||||
|
isWindowed: false,
|
||||||
|
livePreview: null,
|
||||||
|
/** @type {'customName'|'name'} */
|
||||||
|
nameTarget: 'name',
|
||||||
|
previewDelay: 200, // Chrome devtools uses 200
|
||||||
|
scrollInfo: null,
|
||||||
|
|
||||||
|
updateTitle(isDirty = editor.dirty.isDirty()) {
|
||||||
|
const {customName, name} = editor.style;
|
||||||
|
document.title = `${
|
||||||
|
isDirty ? '* ' : ''
|
||||||
|
}${
|
||||||
|
customName || name || t('styleMissingName')
|
||||||
|
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
//#region pre-init
|
||||||
|
|
||||||
|
const baseInit = (() => {
|
||||||
|
const lazyKeymaps = {
|
||||||
|
emacs: '/vendor/codemirror/keymap/emacs',
|
||||||
|
vim: '/vendor/codemirror/keymap/vim',
|
||||||
|
};
|
||||||
|
const domReady = waitForSelector('#sections');
|
||||||
|
|
||||||
|
return {
|
||||||
|
domReady,
|
||||||
|
ready: Promise.all([
|
||||||
|
domReady,
|
||||||
|
loadStyle(),
|
||||||
|
prefs.ready.then(() =>
|
||||||
|
Promise.all([
|
||||||
|
loadTheme(),
|
||||||
|
loadKeymaps(),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
|
||||||
|
function loadKeymaps() {
|
||||||
|
const km = prefs.get('editor.keyMap');
|
||||||
|
return /emacs/i.test(km) && require([lazyKeymaps.emacs]) ||
|
||||||
|
/vim/i.test(km) && require([lazyKeymaps.vim]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStyle() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const id = Number(params.get('id'));
|
||||||
|
const style = id && await API.styles.get(id) || {
|
||||||
|
name: params.get('domain') ||
|
||||||
|
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
|
||||||
|
'',
|
||||||
|
enabled: true,
|
||||||
|
sections: [
|
||||||
|
MozDocMapper.toSection([...params], {code: ''}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
|
||||||
|
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
|
||||||
|
editor.lazyKeymaps = lazyKeymaps;
|
||||||
|
editor.style = style;
|
||||||
|
editor.updateTitle(false);
|
||||||
|
document.documentElement.classList.toggle('usercss', editor.isUsercss);
|
||||||
|
sessionStore.justEditedStyleId = style.id || '';
|
||||||
|
// no such style so let's clear the invalid URL parameters
|
||||||
|
if (!style.id) history.replaceState({}, '', location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preloads the theme so CodeMirror can use the correct metrics in its first render */
|
||||||
|
async function loadTheme() {
|
||||||
|
const theme = prefs.get('editor.theme');
|
||||||
|
if (theme !== 'default') {
|
||||||
|
const el = $('#cm-theme');
|
||||||
|
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
|
||||||
|
el2.id = el.id;
|
||||||
|
el.remove();
|
||||||
|
if (!el2.sheet) {
|
||||||
|
prefs.set('editor.theme', 'default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region init layout/resize
|
||||||
|
|
||||||
|
baseInit.domReady.then(() => {
|
||||||
|
let headerHeight;
|
||||||
|
detectLayout(true);
|
||||||
|
window.on('resize', () => detectLayout());
|
||||||
|
|
||||||
|
function detectLayout(now) {
|
||||||
|
const compact = window.innerWidth <= 850;
|
||||||
|
if (compact) {
|
||||||
|
document.body.classList.add('compact-layout');
|
||||||
|
if (!editor.isUsercss) {
|
||||||
|
if (now) fixedHeader();
|
||||||
|
else debounce(fixedHeader, 250);
|
||||||
|
window.on('scroll', fixedHeader, {passive: true});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('compact-layout', 'fixed-header');
|
||||||
|
window.off('scroll', fixedHeader);
|
||||||
|
}
|
||||||
|
for (const type of ['options', 'toc', 'lint']) {
|
||||||
|
const el = $(`details[data-pref="editor.${type}.expanded"]`);
|
||||||
|
el.open = compact ? false : prefs.get(el.dataset.pref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedHeader() {
|
||||||
|
const headerFixed = $('.fixed-header');
|
||||||
|
if (!headerFixed) headerHeight = $('#header').clientHeight;
|
||||||
|
const scrollPoint = headerHeight - 43;
|
||||||
|
if (window.scrollY >= scrollPoint && !headerFixed) {
|
||||||
|
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
|
||||||
|
$('body').classList.add('fixed-header');
|
||||||
|
} else if (window.scrollY < scrollPoint && headerFixed) {
|
||||||
|
$('body').classList.remove('fixed-header');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region init header
|
||||||
|
|
||||||
|
baseInit.ready.then(() => {
|
||||||
|
initBeautifyButton($('#beautify'));
|
||||||
|
initKeymapElement();
|
||||||
|
initNameArea();
|
||||||
|
initThemeElement();
|
||||||
|
setupLivePrefs();
|
||||||
|
|
||||||
|
$('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
|
||||||
|
$('#preview-label').classList.toggle('hidden', !editor.style.id);
|
||||||
|
|
||||||
|
require(Object.values(editor.lazyKeymaps), () => {
|
||||||
|
initKeymapElement();
|
||||||
|
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
|
||||||
|
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
|
||||||
|
});
|
||||||
|
|
||||||
|
function findKeyForCommand(command, map) {
|
||||||
|
if (typeof map === 'string') map = CodeMirror.keyMap[map];
|
||||||
|
let key = Object.keys(map).find(k => map[k] === command);
|
||||||
|
if (key) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
|
||||||
|
key = ft && findKeyForCommand(command, ft);
|
||||||
|
if (key) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNameArea() {
|
||||||
|
const nameEl = $('#name');
|
||||||
|
const resetEl = $('#reset-name');
|
||||||
|
const isCustomName = editor.style.updateUrl || editor.isUsercss;
|
||||||
|
editor.nameTarget = isCustomName ? 'customName' : 'name';
|
||||||
|
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
|
||||||
|
nameEl.title = isCustomName ? t('customNameHint') : '';
|
||||||
|
nameEl.on('input', () => {
|
||||||
|
editor.updateName(true);
|
||||||
|
resetEl.hidden = false;
|
||||||
|
});
|
||||||
|
resetEl.hidden = !editor.style.customName;
|
||||||
|
resetEl.onclick = () => {
|
||||||
|
const {style} = editor;
|
||||||
|
nameEl.focus();
|
||||||
|
nameEl.select();
|
||||||
|
// trying to make it undoable via Ctrl-Z
|
||||||
|
if (!document.execCommand('insertText', false, style.name)) {
|
||||||
|
nameEl.value = style.name;
|
||||||
|
editor.updateName(true);
|
||||||
|
}
|
||||||
|
style.customName = null; // to delete it from db
|
||||||
|
resetEl.hidden = true;
|
||||||
|
};
|
||||||
|
const enabledEl = $('#enabled');
|
||||||
|
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initThemeElement() {
|
||||||
|
$('#editor.theme').append(...[
|
||||||
|
$create('option', {value: 'default'}, t('defaultTheme')),
|
||||||
|
...CODEMIRROR_THEMES.map(s => $create('option', s)),
|
||||||
|
]);
|
||||||
|
// move the theme after built-in CSS so that its same-specificity selectors win
|
||||||
|
document.head.appendChild($('#cm-theme'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initKeymapElement() {
|
||||||
|
// move 'pc' or 'mac' prefix to the end of the displayed label
|
||||||
|
const maps = Object.keys(CodeMirror.keyMap)
|
||||||
|
.map(name => ({
|
||||||
|
value: name,
|
||||||
|
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
|
||||||
|
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let bin = fragment;
|
||||||
|
let groupName;
|
||||||
|
// group suffixed maps in <optgroup>
|
||||||
|
maps.forEach(({value, name}, i) => {
|
||||||
|
groupName = !name.includes('-') ? name : groupName;
|
||||||
|
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
|
||||||
|
if (groupWithNext) {
|
||||||
|
if (bin === fragment) {
|
||||||
|
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = bin.appendChild($create('option', {value}, name));
|
||||||
|
if (value === prefs.defaults['editor.keyMap']) {
|
||||||
|
el.dataset.default = '';
|
||||||
|
el.title = t('defaultTheme');
|
||||||
|
}
|
||||||
|
if (!groupWithNext) bin = fragment;
|
||||||
|
});
|
||||||
|
const selector = $('#editor.keyMap');
|
||||||
|
selector.textContent = '';
|
||||||
|
selector.appendChild(fragment);
|
||||||
|
selector.value = prefs.get('editor.keyMap');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
|
||||||
|
const extraKeys = CodeMirror.defaults.extraKeys;
|
||||||
|
for (const el of $$('[data-hotkey-tooltip]')) {
|
||||||
|
if (el._hotkeyTooltipKeyMap !== mapName) {
|
||||||
|
el._hotkeyTooltipKeyMap = mapName;
|
||||||
|
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
|
||||||
|
const cmd = el.dataset.hotkeyTooltip;
|
||||||
|
const key = cmd[0] === '=' ? cmd.slice(1) :
|
||||||
|
findKeyForCommand(cmd, mapName) ||
|
||||||
|
extraKeys && findKeyForCommand(cmd, extraKeys);
|
||||||
|
const newTitle = title + (title && key ? '\n' : '') + (key || '');
|
||||||
|
if (el.title !== newTitle) el.title = newTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region init windowed mode
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
let ownTabId;
|
||||||
|
if (chrome.windows) {
|
||||||
|
initWindowedMode();
|
||||||
|
const pos = tryJSONparse(sessionStore.windowPos);
|
||||||
|
delete sessionStore.windowPos;
|
||||||
|
// resize the window on 'undo close'
|
||||||
|
if (pos && pos.left != null) {
|
||||||
|
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwnTab().then(async tab => {
|
||||||
|
ownTabId = tab.id;
|
||||||
|
// use browser history back when 'back to manage' is clicked
|
||||||
|
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
|
||||||
|
await baseInit.domReady;
|
||||||
|
$('#cancel-button').onclick = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
history.back();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initWindowedMode() {
|
||||||
|
chrome.tabs.onAttached.addListener(onTabAttached);
|
||||||
|
const isSimple = (await browser.windows.getCurrent()).type === 'popup';
|
||||||
|
if (isSimple) require(['/edit/embedded-popup']);
|
||||||
|
editor.isWindowed = isSimple || (
|
||||||
|
history.length === 1 &&
|
||||||
|
await prefs.ready && prefs.get('openEditInWindow') &&
|
||||||
|
(await browser.windows.getAll()).length > 1 &&
|
||||||
|
(await browser.tabs.query({currentWindow: true})).length === 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTabAttached(tabId, info) {
|
||||||
|
if (tabId !== ownTabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (info.newPosition !== 0) {
|
||||||
|
prefs.set('openEditInWindow', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const win = await browser.windows.get(info.newWindowId, {populate: true});
|
||||||
|
// If there's only one tab in this window, it's been dragged to new window
|
||||||
|
const openEditInWindow = win.tabs.length === 1;
|
||||||
|
// FF-only because Chrome retardedly resets the size during dragging
|
||||||
|
if (openEditInWindow && FIREFOX) {
|
||||||
|
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
|
||||||
|
}
|
||||||
|
prefs.set('openEditInWindow', openEditInWindow);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region internals
|
||||||
|
|
||||||
|
/** @returns DirtyReporter */
|
||||||
|
function DirtyReporter() {
|
||||||
|
const data = new Map();
|
||||||
|
const listeners = new Set();
|
||||||
|
const notifyChange = wasDirty => {
|
||||||
|
if (wasDirty !== (data.size > 0)) {
|
||||||
|
listeners.forEach(cb => cb());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/** @namespace DirtyReporter */
|
||||||
|
return {
|
||||||
|
add(obj, value) {
|
||||||
|
const wasDirty = data.size > 0;
|
||||||
|
const saved = data.get(obj);
|
||||||
|
if (!saved) {
|
||||||
|
data.set(obj, {type: 'add', newValue: value});
|
||||||
|
} else if (saved.type === 'remove') {
|
||||||
|
if (saved.savedValue === value) {
|
||||||
|
data.delete(obj);
|
||||||
|
} else {
|
||||||
|
saved.newValue = value;
|
||||||
|
saved.type = 'modify';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyChange(wasDirty);
|
||||||
|
},
|
||||||
|
clear(obj) {
|
||||||
|
const wasDirty = data.size > 0;
|
||||||
|
if (obj === undefined) {
|
||||||
|
data.clear();
|
||||||
|
} else {
|
||||||
|
data.delete(obj);
|
||||||
|
}
|
||||||
|
notifyChange(wasDirty);
|
||||||
|
},
|
||||||
|
has(key) {
|
||||||
|
return data.has(key);
|
||||||
|
},
|
||||||
|
isDirty() {
|
||||||
|
return data.size > 0;
|
||||||
|
},
|
||||||
|
modify(obj, oldValue, newValue) {
|
||||||
|
const wasDirty = data.size > 0;
|
||||||
|
const saved = data.get(obj);
|
||||||
|
if (!saved) {
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
||||||
|
}
|
||||||
|
} else if (saved.type === 'modify') {
|
||||||
|
if (saved.savedValue === newValue) {
|
||||||
|
data.delete(obj);
|
||||||
|
} else {
|
||||||
|
saved.newValue = newValue;
|
||||||
|
}
|
||||||
|
} else if (saved.type === 'add') {
|
||||||
|
saved.newValue = newValue;
|
||||||
|
}
|
||||||
|
notifyChange(wasDirty);
|
||||||
|
},
|
||||||
|
onChange(cb, add = true) {
|
||||||
|
listeners[add ? 'add' : 'delete'](cb);
|
||||||
|
},
|
||||||
|
remove(obj, value) {
|
||||||
|
const wasDirty = data.size > 0;
|
||||||
|
const saved = data.get(obj);
|
||||||
|
if (!saved) {
|
||||||
|
data.set(obj, {type: 'remove', savedValue: value});
|
||||||
|
} else if (saved.type === 'add') {
|
||||||
|
data.delete(obj);
|
||||||
|
} else if (saved.type === 'modify') {
|
||||||
|
saved.type = 'remove';
|
||||||
|
}
|
||||||
|
notifyChange(wasDirty);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
312
edit/beautify.js
312
edit/beautify.js
|
@ -1,20 +1,18 @@
|
||||||
/* global loadScript css_beautify showHelp prefs t $ $create */
|
/* global $ $create moveFocus */// dom.js
|
||||||
/* global editor createHotkeyInput moveFocus CodeMirror */
|
/* global CodeMirror */
|
||||||
/* exported initBeautifyButton */
|
/* global createHotkeyInput helpPopup */// util.js
|
||||||
|
/* global editor */
|
||||||
|
/* global prefs */
|
||||||
|
/* global t */// localization.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const HOTKEY_ID = 'editor.beautify.hotkey';
|
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.initializing.then(() => {
|
prefs.subscribe('editor.beautify.hotkey', (key, value) => {
|
||||||
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;
|
const {extraKeys} = CodeMirror.defaults;
|
||||||
for (const [key, cmd] of Object.entries(extraKeys)) {
|
for (const [key, cmd] of Object.entries(extraKeys)) {
|
||||||
if (cmd === 'beautify') {
|
if (cmd === 'beautify') {
|
||||||
|
@ -25,164 +23,148 @@ prefs.subscribe([HOTKEY_ID], (key, value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
extraKeys[value] = 'beautify';
|
extraKeys[value] = 'beautify';
|
||||||
}
|
}
|
||||||
});
|
}, {runNow: true});
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {HTMLElement} btn - the button element shown in the UI
|
|
||||||
* @param {function():CodeMirror[]} getScope
|
|
||||||
*/
|
|
||||||
function initBeautifyButton(btn, getScope) {
|
|
||||||
btn.addEventListener('click', () => beautify(getScope()));
|
|
||||||
btn.addEventListener('contextmenu', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
beautify(getScope(), false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @name beautify
|
||||||
* @param {CodeMirror[]} scope
|
* @param {CodeMirror[]} scope
|
||||||
* @param {?boolean} ui
|
* @param {boolean} [ui=true]
|
||||||
*/
|
*/
|
||||||
function beautify(scope, ui = true) {
|
async function beautify(scope, ui = true) {
|
||||||
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
|
await require(['/vendor-overwrites/beautify/beautify-css-mod']); /* global css_beautify */
|
||||||
.then(() => {
|
const tabs = prefs.get('editor.indentWithTabs');
|
||||||
if (!window.css_beautify && window.exports) {
|
const options = Object.assign(prefs.defaults['editor.beautify'], prefs.get('editor.beautify'));
|
||||||
window.css_beautify = window.exports.css_beautify;
|
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
|
||||||
}
|
options.indent_char = tabs ? '\t' : ' ';
|
||||||
})
|
if (ui) {
|
||||||
.then(doBeautify);
|
createBeautifyUI(scope, options);
|
||||||
|
}
|
||||||
|
for (const cm of scope) {
|
||||||
|
setTimeout(beautifyEditor, 0, cm, options, ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function doBeautify() {
|
function beautifyEditor(cm, options, ui) {
|
||||||
const tabs = prefs.get('editor.indentWithTabs');
|
const pos = options.translate_positions =
|
||||||
const options = Object.assign({}, prefs.get('editor.beautify'));
|
[].concat.apply([], cm.doc.sel.ranges.map(r =>
|
||||||
for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
|
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
|
||||||
if (!(k in options)) options[k] = prefs.defaults['editor.beautify'][k];
|
const text = cm.getValue();
|
||||||
|
const newText = css_beautify(text, options);
|
||||||
|
if (newText !== text) {
|
||||||
|
if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) {
|
||||||
|
// clear the list if last change wasn't a css-beautify
|
||||||
|
cm.beautifyChange = {};
|
||||||
}
|
}
|
||||||
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
|
cm.setValue(newText);
|
||||||
options.indent_char = tabs ? '\t' : ' ';
|
const selections = [];
|
||||||
|
for (let i = 0; i < pos.length; i += 2) {
|
||||||
|
selections.push({anchor: pos[i], head: pos[i + 1]});
|
||||||
|
}
|
||||||
|
const {scrollX, scrollY} = window;
|
||||||
|
cm.setSelections(selections);
|
||||||
|
window.scrollTo(scrollX, scrollY);
|
||||||
|
cm.beautifyChange[cm.changeGeneration()] = true;
|
||||||
if (ui) {
|
if (ui) {
|
||||||
createBeautifyUI(scope, options);
|
$('#help-popup button[role="close"]').disabled = false;
|
||||||
}
|
|
||||||
for (const cm of scope) {
|
|
||||||
setTimeout(doBeautifyEditor, 0, cm, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function doBeautifyEditor(cm, options) {
|
|
||||||
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);
|
|
||||||
if (newText !== text) {
|
|
||||||
if (!cm.beautifyChange || !cm.beautifyChange[cm.changeGeneration()]) {
|
|
||||||
// clear the list if last change wasn't a css-beautify
|
|
||||||
cm.beautifyChange = {};
|
|
||||||
}
|
|
||||||
cm.setValue(newText);
|
|
||||||
const selections = [];
|
|
||||||
for (let i = 0; i < pos.length; i += 2) {
|
|
||||||
selections.push({anchor: pos[i], head: pos[i + 1]});
|
|
||||||
}
|
|
||||||
const {scrollX, scrollY} = window;
|
|
||||||
cm.setSelections(selections);
|
|
||||||
window.scrollTo(scrollX, scrollY);
|
|
||||||
cm.beautifyChange[cm.changeGeneration()] = true;
|
|
||||||
if (ui) {
|
|
||||||
$('#help-popup button[role="close"]').disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBeautifyUI(scope, options) {
|
|
||||||
showHelp(t('styleBeautify'),
|
|
||||||
$create([
|
|
||||||
$create('.beautify-options', [
|
|
||||||
$createOption('.selector1,', 'selector_separator_newline'),
|
|
||||||
$createOption('.selector2', 'newline_before_open_brace'),
|
|
||||||
$createOption('{', 'newline_after_open_brace'),
|
|
||||||
$createOption('border: none;', 'newline_between_properties', true),
|
|
||||||
$createOption('display: block;', 'newline_before_close_brace', true),
|
|
||||||
$createOption('}', 'newline_between_rules'),
|
|
||||||
$createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'),
|
|
||||||
$createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'),
|
|
||||||
]),
|
|
||||||
$create('p.beautify-hint', [
|
|
||||||
$create('span', t('styleBeautifyHint') + '\u00A0'),
|
|
||||||
createHotkeyInput(HOTKEY_ID, () => moveFocus($('#help-popup'), 1)),
|
|
||||||
]),
|
|
||||||
$create('.buttons', [
|
|
||||||
$create('button', {
|
|
||||||
attributes: {role: 'close'},
|
|
||||||
// showHelp.close will be defined after showHelp() is invoked
|
|
||||||
onclick: () => showHelp.close(),
|
|
||||||
}, t('confirmClose')),
|
|
||||||
$create('button', {
|
|
||||||
attributes: {role: 'undo'},
|
|
||||||
onclick() {
|
|
||||||
let undoable = false;
|
|
||||||
for (const cm of scope) {
|
|
||||||
const data = cm.beautifyChange;
|
|
||||||
if (!data || !data[cm.changeGeneration()]) continue;
|
|
||||||
delete data[cm.changeGeneration()];
|
|
||||||
const {scrollX, scrollY} = window;
|
|
||||||
cm.undo();
|
|
||||||
cm.scrollIntoView(cm.getCursor());
|
|
||||||
window.scrollTo(scrollX, scrollY);
|
|
||||||
undoable |= data[cm.changeGeneration()];
|
|
||||||
}
|
|
||||||
this.disabled = !undoable;
|
|
||||||
},
|
|
||||||
}, t(scope.length === 1 ? 'undo' : 'undoGlobal')),
|
|
||||||
]),
|
|
||||||
]));
|
|
||||||
|
|
||||||
$('#help-popup').className = 'wide';
|
|
||||||
|
|
||||||
$('.beautify-options').onchange = ({target}) => {
|
|
||||||
const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0;
|
|
||||||
prefs.set('editor.beautify', Object.assign(options, {[target.dataset.option]: value}));
|
|
||||||
if (target.parentNode.hasAttribute('newline')) {
|
|
||||||
target.parentNode.setAttribute('newline', value.toString());
|
|
||||||
}
|
|
||||||
doBeautify();
|
|
||||||
};
|
|
||||||
|
|
||||||
function $createOption(label, optionName, indent) {
|
|
||||||
const value = options[optionName];
|
|
||||||
return (
|
|
||||||
$create('div', {attributes: {newline: value}}, [
|
|
||||||
$create('span', indent ? {attributes: {indent: ''}} : {}, label),
|
|
||||||
$create('div.select-resizer', [
|
|
||||||
$create('select', {dataset: {option: optionName}}, [
|
|
||||||
$create('option', {selected: !value}, '\xA0'),
|
|
||||||
$create('option', {selected: value}, '\\n'),
|
|
||||||
]),
|
|
||||||
$create('SVG:svg.svg-icon.select-arrow', {viewBox: '0 0 1792 1792'}, [
|
|
||||||
$create('SVG: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',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function $createLabeledCheckbox(optionName, i18nKey) {
|
|
||||||
return (
|
|
||||||
$create('label', {style: 'display: block; clear: both;'}, [
|
|
||||||
$create('input', {
|
|
||||||
type: 'checkbox',
|
|
||||||
dataset: {option: optionName},
|
|
||||||
checked: options[optionName] !== false,
|
|
||||||
}),
|
|
||||||
$create('SVG:svg.svg-icon.checked',
|
|
||||||
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
|
|
||||||
t(i18nKey),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBeautifyUI(scope, options) {
|
||||||
|
helpPopup.show(t('styleBeautify'),
|
||||||
|
$create([
|
||||||
|
$create('.beautify-options', [
|
||||||
|
$createOption('.selector1,', 'selector_separator_newline'),
|
||||||
|
$createOption('.selector2', 'newline_before_open_brace'),
|
||||||
|
$createOption('{', 'newline_after_open_brace'),
|
||||||
|
$createOption('border: none;', 'newline_between_properties', true),
|
||||||
|
$createOption('display: block;', 'newline_before_close_brace', true),
|
||||||
|
$createOption('}', 'newline_between_rules'),
|
||||||
|
$createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'),
|
||||||
|
$createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'),
|
||||||
|
]),
|
||||||
|
$create('p.beautify-hint', [
|
||||||
|
$create('span', t('styleBeautifyHint') + '\u00A0'),
|
||||||
|
createHotkeyInput('editor.beautify.hotkey', () => moveFocus($('#help-popup'), 0)),
|
||||||
|
]),
|
||||||
|
$create('.buttons', [
|
||||||
|
$create('button', {
|
||||||
|
attributes: {role: 'close'},
|
||||||
|
onclick: helpPopup.close,
|
||||||
|
}, t('confirmClose')),
|
||||||
|
$create('button', {
|
||||||
|
attributes: {role: 'undo'},
|
||||||
|
onclick() {
|
||||||
|
let undoable = false;
|
||||||
|
for (const cm of scope) {
|
||||||
|
const data = cm.beautifyChange;
|
||||||
|
if (!data || !data[cm.changeGeneration()]) continue;
|
||||||
|
delete data[cm.changeGeneration()];
|
||||||
|
const {scrollX, scrollY} = window;
|
||||||
|
cm.undo();
|
||||||
|
cm.scrollIntoView(cm.getCursor());
|
||||||
|
window.scrollTo(scrollX, scrollY);
|
||||||
|
undoable |= data[cm.changeGeneration()];
|
||||||
|
}
|
||||||
|
this.disabled = !undoable;
|
||||||
|
},
|
||||||
|
}, t(scope.length === 1 ? 'undo' : 'undoGlobal')),
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$('#help-popup').className = 'wide';
|
||||||
|
|
||||||
|
$('.beautify-options').onchange = ({target}) => {
|
||||||
|
const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0;
|
||||||
|
prefs.set('editor.beautify', Object.assign(options, {[target.dataset.option]: value}));
|
||||||
|
if (target.parentNode.hasAttribute('newline')) {
|
||||||
|
target.parentNode.setAttribute('newline', value.toString());
|
||||||
|
}
|
||||||
|
beautify(scope, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
function $createOption(label, optionName, indent) {
|
||||||
|
const value = options[optionName];
|
||||||
|
return (
|
||||||
|
$create('div', {attributes: {newline: value}}, [
|
||||||
|
$create('span', indent ? {attributes: {indent: ''}} : {}, label),
|
||||||
|
$create('div.select-resizer', [
|
||||||
|
$create('select', {dataset: {option: optionName}}, [
|
||||||
|
$create('option', {selected: !value}, '\xA0'),
|
||||||
|
$create('option', {selected: value}, '\\n'),
|
||||||
|
]),
|
||||||
|
$create('SVG:svg.svg-icon.select-arrow', {viewBox: '0 0 1792 1792'}, [
|
||||||
|
$create('SVG: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',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function $createLabeledCheckbox(optionName, i18nKey) {
|
||||||
|
return (
|
||||||
|
$create('label', {style: 'display: block; clear: both;'}, [
|
||||||
|
$create('input', {
|
||||||
|
type: 'checkbox',
|
||||||
|
dataset: {option: optionName},
|
||||||
|
checked: options[optionName] !== false,
|
||||||
|
}),
|
||||||
|
$create('SVG:svg.svg-icon.checked',
|
||||||
|
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
|
||||||
|
t(i18nKey),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* exported initBeautifyButton */
|
||||||
|
function initBeautifyButton(btn, scope) {
|
||||||
|
btn.onclick = btn.oncontextmenu = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
beautify(scope || editor.getEditors(), e.type === 'click');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -16,9 +16,6 @@
|
||||||
/* Not using the ring-color hack as it became ugly in new Chrome */
|
/* Not using the ring-color hack as it became ugly in new Chrome */
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
.CodeMirror-lint-mark-warning {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
.CodeMirror-dialog {
|
.CodeMirror-dialog {
|
||||||
animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
|
animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
/* global
|
/* global $ */// dom.js
|
||||||
$
|
/* global CodeMirror */
|
||||||
CodeMirror
|
/* global editor */
|
||||||
prefs
|
/* global prefs */
|
||||||
t
|
/* global t */// localization.js
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(function () {
|
(() => {
|
||||||
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
|
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
|
||||||
if (!prefs.get('editor.keyMap')) {
|
if (!prefs.get('editor.keyMap')) {
|
||||||
prefs.reset('editor.keyMap');
|
prefs.reset('editor.keyMap');
|
||||||
|
@ -43,53 +41,55 @@
|
||||||
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
|
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
|
||||||
|
|
||||||
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
|
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
|
||||||
const KM = CodeMirror.keyMap;
|
require(Object.values(typeof editor === 'object' && editor.lazyKeymaps || {}), () => {
|
||||||
const extras = Object.values(CodeMirror.defaults.extraKeys);
|
const KM = CodeMirror.keyMap;
|
||||||
if (!extras.includes('jumpToLine')) {
|
const extras = Object.values(CodeMirror.defaults.extraKeys);
|
||||||
KM.sublime['Ctrl-G'] = 'jumpToLine';
|
if (!extras.includes('jumpToLine')) {
|
||||||
KM.emacsy['Ctrl-G'] = 'jumpToLine';
|
KM.sublime['Ctrl-G'] = 'jumpToLine';
|
||||||
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
|
KM.emacsy['Ctrl-G'] = 'jumpToLine';
|
||||||
KM.macDefault['Cmd-J'] = 'jumpToLine';
|
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
|
||||||
}
|
KM.macDefault['Cmd-J'] = 'jumpToLine';
|
||||||
if (!extras.includes('autocomplete')) {
|
}
|
||||||
// will be used by 'sublime' on PC via fallthrough
|
if (!extras.includes('autocomplete')) {
|
||||||
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
|
// will be used by 'sublime' on PC via fallthrough
|
||||||
// OSX uses Ctrl-Space and Cmd-Space for something else
|
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
|
||||||
KM.macDefault['Alt-Space'] = 'autocomplete';
|
// OSX uses Ctrl-Space and Cmd-Space for something else
|
||||||
// copied from 'emacs' keymap
|
KM.macDefault['Alt-Space'] = 'autocomplete';
|
||||||
KM.emacsy['Alt-/'] = 'autocomplete';
|
// copied from 'emacs' keymap
|
||||||
// 'vim' and 'emacs' define their own autocomplete hotkeys
|
KM.emacsy['Alt-/'] = 'autocomplete';
|
||||||
}
|
// 'vim' and 'emacs' define their own autocomplete hotkeys
|
||||||
if (!extras.includes('blockComment')) {
|
}
|
||||||
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
|
if (!extras.includes('blockComment')) {
|
||||||
}
|
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
|
||||||
if (navigator.appVersion.includes('Windows')) {
|
}
|
||||||
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
|
if (navigator.appVersion.includes('Windows')) {
|
||||||
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
|
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
|
||||||
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
|
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
|
||||||
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
|
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
|
||||||
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
|
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
|
||||||
// Note: modifier order in CodeMirror is S-C-A
|
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
|
||||||
for (const char of ['N', 'T', 'W']) {
|
// Note: modifier order in CodeMirror is S-C-A
|
||||||
for (const remap of [
|
for (const char of ['N', 'T', 'W']) {
|
||||||
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
|
for (const remap of [
|
||||||
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
|
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
|
||||||
]) {
|
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
|
||||||
const oldKey = remap.from + char;
|
]) {
|
||||||
for (const km of Object.values(KM)) {
|
const oldKey = remap.from + char;
|
||||||
const command = km[oldKey];
|
for (const km of Object.values(KM)) {
|
||||||
if (!command) continue;
|
const command = km[oldKey];
|
||||||
for (const newMod of remap.to) {
|
if (!command) continue;
|
||||||
const newKey = newMod + char;
|
for (const newMod of remap.to) {
|
||||||
if (newKey in km) continue;
|
const newKey = newMod + char;
|
||||||
km[newKey] = command;
|
if (newKey in km) continue;
|
||||||
delete km[oldKey];
|
km[newKey] = command;
|
||||||
break;
|
delete km[oldKey];
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const cssMime = CodeMirror.mimeModes['text/css'];
|
const cssMime = CodeMirror.mimeModes['text/css'];
|
||||||
Object.assign(cssMime.propertyKeywords, {
|
Object.assign(cssMime.propertyKeywords, {
|
||||||
|
@ -142,12 +142,16 @@
|
||||||
jumpToPos(pos, end = pos) {
|
jumpToPos(pos, end = pos) {
|
||||||
const {curOp} = this;
|
const {curOp} = this;
|
||||||
if (!curOp) this.startOperation();
|
if (!curOp) this.startOperation();
|
||||||
const coords = this.cursorCoords(pos, 'window');
|
const y = this.cursorCoords(pos, 'window').top;
|
||||||
const b = this.display.wrapper.getBoundingClientRect();
|
const rect = this.display.wrapper.getBoundingClientRect();
|
||||||
if (coords.top < Math.max(0, b.top + this.defaultTextHeight() * 2) ||
|
// case 1) outside of CM viewport or too close to edge so tell CM to render a new viewport
|
||||||
coords.bottom > Math.min(window.innerHeight, b.bottom - 100)) {
|
if (y < rect.top + 50 || y > rect.bottom - 100) {
|
||||||
this.scrollIntoView(pos, b.height / 2);
|
this.scrollIntoView(pos, rect.height / 2);
|
||||||
|
// case 2) inside CM viewport but outside of window viewport so just scroll the window
|
||||||
|
} else if (y < 0 || y > innerHeight) {
|
||||||
|
editor.scrollToEditor(this);
|
||||||
}
|
}
|
||||||
|
// Using prototype since our bookmark patch sets cm.setSelection to jumpToPos
|
||||||
CodeMirror.prototype.setSelection.call(this, pos, end);
|
CodeMirror.prototype.setSelection.call(this, pos, end);
|
||||||
if (!curOp) this.endOperation();
|
if (!curOp) this.endOperation();
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,25 +1,24 @@
|
||||||
/* global
|
/* global $ */// dom.js
|
||||||
$
|
/* global CodeMirror */
|
||||||
CodeMirror
|
/* global editor */
|
||||||
debounce
|
/* global prefs */
|
||||||
editor
|
/* global rerouteHotkeys */// util.js
|
||||||
loadScript
|
|
||||||
prefs
|
|
||||||
rerouteHotkeys
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
//#region cmFactory
|
/*
|
||||||
(() => {
|
|
||||||
/*
|
|
||||||
All cm instances created by this module are collected so we can broadcast prefs
|
All cm instances created by this module are collected so we can broadcast prefs
|
||||||
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
|
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
|
||||||
when the instance is not used anymore.
|
when the instance is not used anymore.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
//#region Factory
|
||||||
|
|
||||||
const cms = new Set();
|
const cms = new Set();
|
||||||
let lazyOpt;
|
let lazyOpt;
|
||||||
|
|
||||||
const cmFactory = window.cmFactory = {
|
const cmFactory = window.cmFactory = {
|
||||||
|
|
||||||
create(place, options) {
|
create(place, options) {
|
||||||
const cm = CodeMirror(place, options);
|
const cm = CodeMirror(place, options);
|
||||||
const {wrapper} = cm.display;
|
const {wrapper} = cm.display;
|
||||||
|
@ -38,9 +37,11 @@
|
||||||
cms.add(cm);
|
cms.add(cm);
|
||||||
return cm;
|
return cm;
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy(cm) {
|
destroy(cm) {
|
||||||
cms.delete(cm);
|
cms.delete(cm);
|
||||||
},
|
},
|
||||||
|
|
||||||
globalSetOption(key, value) {
|
globalSetOption(key, value) {
|
||||||
CodeMirror.defaults[key] = value;
|
CodeMirror.defaults[key] = value;
|
||||||
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
|
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
|
||||||
|
@ -52,28 +53,28 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handledPrefs = {
|
const handledPrefs = {
|
||||||
// handled in colorpicker-helper.js
|
'editor.colorpicker'() {}, // handled in colorpicker-helper.js
|
||||||
'editor.colorpicker'() {},
|
async 'editor.theme'(key, value) {
|
||||||
/** @returns {?Promise<void>} */
|
let el2;
|
||||||
'editor.theme'(key, value) {
|
const el = $('#cm-theme');
|
||||||
const elt = $('#cm-theme');
|
|
||||||
if (value === 'default') {
|
if (value === 'default') {
|
||||||
elt.href = '';
|
el.href = '';
|
||||||
} else {
|
} else {
|
||||||
const url = chrome.runtime.getURL(`vendor/codemirror/theme/${value}.css`);
|
const path = `/vendor/codemirror/theme/${value}.css`;
|
||||||
if (url !== elt.href) {
|
if (el.href !== location.origin + path) {
|
||||||
// avoid flicker: wait for the second stylesheet to load, then apply the theme
|
// avoid flicker: wait for the second stylesheet to load, then apply the theme
|
||||||
return loadScript(url, true).then(([newElt]) => {
|
el2 = await require([path]);
|
||||||
cmFactory.globalSetOption('theme', value);
|
|
||||||
elt.remove();
|
|
||||||
newElt.id = elt.id;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cmFactory.globalSetOption('theme', value);
|
||||||
|
if (el2) {
|
||||||
|
el.remove();
|
||||||
|
el2.id = el.id;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const pref2opt = k => k.slice('editor.'.length);
|
const pref2opt = k => k.slice('editor.'.length);
|
||||||
const mirroredPrefs = Object.keys(prefs.defaults).filter(k =>
|
const mirroredPrefs = prefs.knownKeys.filter(k =>
|
||||||
!handledPrefs[k] &&
|
!handledPrefs[k] &&
|
||||||
k.startsWith('editor.') &&
|
k.startsWith('editor.') &&
|
||||||
Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
|
Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
|
||||||
|
@ -125,12 +126,14 @@
|
||||||
return lazyOpt._observer;
|
return lazyOpt._observer;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})();
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#region Commands
|
//#endregion
|
||||||
(() => {
|
//#region Commands
|
||||||
|
|
||||||
Object.assign(CodeMirror.commands, {
|
Object.assign(CodeMirror.commands, {
|
||||||
|
commentSelection(cm) {
|
||||||
|
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
|
||||||
|
},
|
||||||
toggleEditorFocus(cm) {
|
toggleEditorFocus(cm) {
|
||||||
if (!cm) return;
|
if (!cm) return;
|
||||||
if (cm.hasFocus()) {
|
if (cm.hasFocus()) {
|
||||||
|
@ -139,9 +142,6 @@
|
||||||
cm.focus();
|
cm.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
commentSelection(cm) {
|
|
||||||
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
for (const cmd of [
|
for (const cmd of [
|
||||||
'nextEditor',
|
'nextEditor',
|
||||||
|
@ -151,11 +151,10 @@
|
||||||
]) {
|
]) {
|
||||||
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
|
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#region CM option handlers
|
//#endregion
|
||||||
(() => {
|
//#region CM option handlers
|
||||||
|
|
||||||
const {insertTab, insertSoftTab} = CodeMirror.commands;
|
const {insertTab, insertSoftTab} = CodeMirror.commands;
|
||||||
Object.entries({
|
Object.entries({
|
||||||
tabSize(cm, value) {
|
tabSize(cm, value) {
|
||||||
|
@ -164,11 +163,6 @@
|
||||||
indentWithTabs(cm, value) {
|
indentWithTabs(cm, value) {
|
||||||
CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
|
CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
|
||||||
},
|
},
|
||||||
autocompleteOnTyping(cm, value) {
|
|
||||||
const onOff = value ? 'on' : 'off';
|
|
||||||
cm[onOff]('changes', autocompleteOnTyping);
|
|
||||||
cm[onOff]('pick', autocompletePicked);
|
|
||||||
},
|
|
||||||
matchHighlight(cm, value) {
|
matchHighlight(cm, value) {
|
||||||
const showToken = value === 'token' && /[#.\-\w]/;
|
const showToken = value === 'token' && /[#.\-\w]/;
|
||||||
const opt = (showToken || value === 'selection') && {
|
const opt = (showToken || value === 'selection') && {
|
||||||
|
@ -246,237 +240,12 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function autocompleteOnTyping(cm, [info], debounced) {
|
//#endregion
|
||||||
const lastLine = info.text[info.text.length - 1];
|
//#region Bookmarks
|
||||||
if (
|
|
||||||
cm.state.completionActive ||
|
|
||||||
info.origin && !info.origin.includes('input') ||
|
|
||||||
!lastLine
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cm.state.autocompletePicked) {
|
|
||||||
cm.state.autocompletePicked = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!debounced) {
|
|
||||||
debounce(autocompleteOnTyping, 100, cm, [info], true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastLine.match(/[-a-z!]+$/i)) {
|
|
||||||
cm.state.autocompletePicked = false;
|
|
||||||
cm.options.hintOptions.completeSingle = false;
|
|
||||||
cm.execCommand('autocomplete');
|
|
||||||
setTimeout(() => {
|
|
||||||
cm.options.hintOptions.completeSingle = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function autocompletePicked(cm) {
|
const BM_CLS = 'gutter-bookmark';
|
||||||
cm.state.autocompletePicked = true;
|
const BM_BRAND = 'sublimeBookmark';
|
||||||
}
|
const BM_CLICKER = 'CodeMirror-linenumbers';
|
||||||
})();
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#region Autocomplete
|
|
||||||
(() => {
|
|
||||||
const AT_RULES = [
|
|
||||||
'@-moz-document',
|
|
||||||
'@charset',
|
|
||||||
'@font-face',
|
|
||||||
'@import',
|
|
||||||
'@keyframes',
|
|
||||||
'@media',
|
|
||||||
'@namespace',
|
|
||||||
'@page',
|
|
||||||
'@supports',
|
|
||||||
'@viewport',
|
|
||||||
];
|
|
||||||
const USO_VAR = 'uso-variable';
|
|
||||||
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
|
|
||||||
const USO_INVALID_VAR = 'error ' + USO_VAR;
|
|
||||||
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
|
|
||||||
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
|
|
||||||
const cssMime = CodeMirror.mimeModes['text/css'];
|
|
||||||
const docFuncs = addSuffix(cssMime.documentTypes, '(');
|
|
||||||
const {tokenHooks} = cssMime;
|
|
||||||
const originalCommentHook = tokenHooks['/'];
|
|
||||||
const originalHelper = CodeMirror.hint.css || (() => {});
|
|
||||||
let cssProps, cssMedia;
|
|
||||||
CodeMirror.registerHelper('hint', 'css', helper);
|
|
||||||
CodeMirror.registerHelper('hint', 'stylus', helper);
|
|
||||||
tokenHooks['/'] = tokenizeUsoVariables;
|
|
||||||
|
|
||||||
function helper(cm) {
|
|
||||||
const pos = cm.getCursor();
|
|
||||||
const {line, ch} = pos;
|
|
||||||
const {styles, text} = cm.getLineHandle(line);
|
|
||||||
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
|
|
||||||
const isStylusLang = cm.doc.mode.name === 'stylus';
|
|
||||||
const type = style && style.split(' ', 1)[0] || 'prop?';
|
|
||||||
if (!type || type === 'comment' || type === 'string') {
|
|
||||||
return originalHelper(cm);
|
|
||||||
}
|
|
||||||
// not using getTokenAt until the need is unavoidable because it reparses text
|
|
||||||
// and runs a whole lot of complex calc inside which is slow on long lines
|
|
||||||
// especially if autocomplete is auto-shown on each keystroke
|
|
||||||
let prev, end, state;
|
|
||||||
let i = index;
|
|
||||||
while (
|
|
||||||
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
|
|
||||||
(prev = i > 2 ? styles[i - 2] : 0) &&
|
|
||||||
isSameToken(text, style, prev)
|
|
||||||
) i -= 2;
|
|
||||||
i = index;
|
|
||||||
while (
|
|
||||||
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
|
|
||||||
(end = styles[i]) &&
|
|
||||||
isSameToken(text, style, end)
|
|
||||||
) i += 2;
|
|
||||||
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
|
|
||||||
const str = text.slice(prev, end);
|
|
||||||
const left = text.slice(prev, ch).trim();
|
|
||||||
let leftLC = left.toLowerCase();
|
|
||||||
let list = [];
|
|
||||||
switch (leftLC[0]) {
|
|
||||||
|
|
||||||
case '!':
|
|
||||||
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '@':
|
|
||||||
list = AT_RULES;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '#': // prevents autocomplete for #hex colors
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '-': // --variable
|
|
||||||
case '(': // var(
|
|
||||||
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
|
|
||||||
? findAllCssVars(cm, left)
|
|
||||||
: [];
|
|
||||||
prev += str.startsWith('(');
|
|
||||||
leftLC = left;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '/': // USO vars
|
|
||||||
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
|
|
||||||
prev += 4;
|
|
||||||
end -= 4;
|
|
||||||
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
|
|
||||||
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
|
|
||||||
leftLC = left.slice(4);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'u': // url(), url-prefix()
|
|
||||||
case 'd': // domain()
|
|
||||||
case 'r': // regexp()
|
|
||||||
if (/^(variable|tag|error)/.test(type) &&
|
|
||||||
docFuncs.some(s => s.startsWith(leftLC)) &&
|
|
||||||
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
|
|
||||||
end++;
|
|
||||||
list = docFuncs;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// properties and media features
|
|
||||||
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
|
|
||||||
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
|
|
||||||
if (!cssProps) initCssProps();
|
|
||||||
if (type === 'prop?') {
|
|
||||||
prev += leftLC.length;
|
|
||||||
leftLC = '';
|
|
||||||
}
|
|
||||||
list = state === 'atBlock_parens' ? cssMedia : cssProps;
|
|
||||||
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
|
|
||||||
end += execAt(rxCONSUME, end, text)[0].length;
|
|
||||||
} else {
|
|
||||||
return isStylusLang
|
|
||||||
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
|
|
||||||
: originalHelper(cm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
list: (list || []).filter(s => s.startsWith(leftLC)),
|
|
||||||
from: {line, ch: prev + str.match(/^\s*/)[0].length},
|
|
||||||
to: {line, ch: end},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function initCssProps() {
|
|
||||||
cssProps = addSuffix(cssMime.propertyKeywords).sort();
|
|
||||||
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSuffix(obj, suffix = ': ') {
|
|
||||||
return Object.keys(obj).map(k => k + suffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMediaKeys([k, v]) {
|
|
||||||
return k === 'mediaFeatures' && addSuffix(v) ||
|
|
||||||
k.startsWith('media') && Object.keys(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** makes sure we don't process a different adjacent comment */
|
|
||||||
function isSameToken(text, style, i) {
|
|
||||||
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
|
|
||||||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findAllCssVars(cm, leftPart) {
|
|
||||||
// simplified regex without CSS escapes
|
|
||||||
const rx = new RegExp(
|
|
||||||
'(?:^|[\\s/;{])(' +
|
|
||||||
(leftPart.startsWith('--') ? leftPart : '--') +
|
|
||||||
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
|
|
||||||
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
|
|
||||||
'g');
|
|
||||||
const list = new Set();
|
|
||||||
cm.eachLine(({text}) => {
|
|
||||||
for (let m; (m = rx.exec(text));) {
|
|
||||||
list.add(m[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return [...list].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeUsoVariables(stream) {
|
|
||||||
const token = originalCommentHook.apply(this, arguments);
|
|
||||||
if (token[1] === 'comment') {
|
|
||||||
const {string, start, pos} = stream;
|
|
||||||
if (testAt(/\/\*\[\[/y, start, string) &&
|
|
||||||
testAt(/]]\*\//y, pos - 4, string)) {
|
|
||||||
const vars = (editor.style.usercssData || {}).vars;
|
|
||||||
token[0] =
|
|
||||||
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
|
|
||||||
? USO_VALID_VAR
|
|
||||||
: USO_INVALID_VAR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
function execAt(rx, index, text) {
|
|
||||||
rx.lastIndex = index;
|
|
||||||
return rx.exec(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function testAt(rx, index, text) {
|
|
||||||
rx.lastIndex = Math.max(0, index);
|
|
||||||
return rx.test(text);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#region Bookmarks
|
|
||||||
(() => {
|
|
||||||
const CLS = 'gutter-bookmark';
|
|
||||||
const BRAND = 'sublimeBookmark';
|
|
||||||
const CLICK_AREA = 'CodeMirror-linenumbers';
|
|
||||||
const {markText} = CodeMirror.prototype;
|
const {markText} = CodeMirror.prototype;
|
||||||
for (const name of ['prevBookmark', 'nextBookmark']) {
|
for (const name of ['prevBookmark', 'nextBookmark']) {
|
||||||
const cmdFn = CodeMirror.commands[name];
|
const cmdFn = CodeMirror.commands[name];
|
||||||
|
@ -494,27 +263,29 @@
|
||||||
Object.assign(CodeMirror.prototype, {
|
Object.assign(CodeMirror.prototype, {
|
||||||
markText() {
|
markText() {
|
||||||
const marker = markText.apply(this, arguments);
|
const marker = markText.apply(this, arguments);
|
||||||
if (marker[BRAND]) {
|
if (marker[BM_BRAND]) {
|
||||||
this.doc.addLineClass(marker.lines[0], 'gutter', CLS);
|
this.doc.addLineClass(marker.lines[0], 'gutter', BM_CLS);
|
||||||
marker.clear = clearMarker;
|
marker.clear = clearMarker;
|
||||||
}
|
}
|
||||||
return marker;
|
return marker;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearMarker() {
|
function clearMarker() {
|
||||||
const line = this.lines[0];
|
const line = this.lines[0];
|
||||||
const spans = line.markedSpans;
|
const spans = line.markedSpans;
|
||||||
delete this.clear; // removing our patch from the instance...
|
delete this.clear; // removing our patch from the instance...
|
||||||
this.clear(); // ...and using the original prototype
|
this.clear(); // ...and using the original prototype
|
||||||
if (!spans || spans.some(span => span.marker[BRAND])) {
|
if (!spans || spans.some(span => span.marker[BM_BRAND])) {
|
||||||
this.doc.removeLineClass(line, 'gutter', CLS);
|
this.doc.removeLineClass(line, 'gutter', BM_CLS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGutterClick(cm, line, name, e) {
|
function onGutterClick(cm, line, name, e) {
|
||||||
switch (name === CLICK_AREA && e.button) {
|
switch (name === BM_CLICKER && e.button) {
|
||||||
case 0: {
|
case 0: {
|
||||||
// main button: toggle
|
// main button: toggle
|
||||||
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BRAND]);
|
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BM_BRAND]);
|
||||||
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
|
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
|
||||||
cm.execCommand('toggleBookmark');
|
cm.execCommand('toggleBookmark');
|
||||||
break;
|
break;
|
||||||
|
@ -525,11 +296,13 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGutterContextMenu(cm, line, name, e) {
|
function onGutterContextMenu(cm, line, name, e) {
|
||||||
if (name === CLICK_AREA) {
|
if (name === BM_CLICKER) {
|
||||||
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
|
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
})();
|
})();
|
||||||
//#endregion
|
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
onDOMready().then(() => {
|
|
||||||
$('#colorpicker-settings').onclick = configureColorpicker;
|
|
||||||
});
|
|
||||||
prefs.subscribe('editor.colorpicker.hotkey', registerHotkey);
|
|
||||||
prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true});
|
|
||||||
|
|
||||||
function setColorpickerOption(id, enabled) {
|
|
||||||
const defaults = CodeMirror.defaults;
|
|
||||||
const keyName = prefs.get('editor.colorpicker.hotkey');
|
|
||||||
defaults.colorpicker = enabled;
|
|
||||||
if (enabled) {
|
|
||||||
if (keyName) {
|
|
||||||
CodeMirror.commands.colorpicker = invokeColorpicker;
|
|
||||||
defaults.extraKeys = defaults.extraKeys || {};
|
|
||||||
defaults.extraKeys[keyName] = 'colorpicker';
|
|
||||||
}
|
|
||||||
defaults.colorpicker = {
|
|
||||||
tooltip: t('colorpickerTooltip'),
|
|
||||||
popup: {
|
|
||||||
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
|
|
||||||
paletteLine: t('numberedLine'),
|
|
||||||
paletteHint: t('colorpickerPaletteHint'),
|
|
||||||
hexUppercase: prefs.get('editor.colorpicker.hexUppercase'),
|
|
||||||
embedderCallback: state => {
|
|
||||||
['hexUppercase', 'color']
|
|
||||||
.filter(name => state[name] !== prefs.get('editor.colorpicker.' + name))
|
|
||||||
.forEach(name => prefs.set('editor.colorpicker.' + name, state[name]));
|
|
||||||
},
|
|
||||||
get maxHeight() {
|
|
||||||
return prefs.get('editor.colorpicker.maxHeight');
|
|
||||||
},
|
|
||||||
set maxHeight(h) {
|
|
||||||
prefs.set('editor.colorpicker.maxHeight', h);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
if (defaults.extraKeys) {
|
|
||||||
delete defaults.extraKeys[keyName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerHotkey(id, hotkey) {
|
|
||||||
CodeMirror.commands.colorpicker = invokeColorpicker;
|
|
||||||
const extraKeys = CodeMirror.defaults.extraKeys;
|
|
||||||
for (const key in extraKeys) {
|
|
||||||
if (extraKeys[key] === 'colorpicker') {
|
|
||||||
delete extraKeys[key];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hotkey) {
|
|
||||||
extraKeys[hotkey] = 'colorpicker';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function invokeColorpicker(cm) {
|
|
||||||
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureColorpicker(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = createHotkeyInput('editor.colorpicker.hotkey', () => {
|
|
||||||
$('#help-popup .dismiss').onclick();
|
|
||||||
});
|
|
||||||
const popup = showHelp(t('helpKeyMapHotkey'), input);
|
|
||||||
if (this instanceof Element) {
|
|
||||||
const bounds = this.getBoundingClientRect();
|
|
||||||
popup.style.left = bounds.right + 10 + 'px';
|
|
||||||
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
|
|
||||||
popup.style.right = 'auto';
|
|
||||||
}
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
})();
|
|
966
edit/edit.js
966
edit/edit.js
File diff suppressed because it is too large
Load Diff
|
@ -1,91 +1,94 @@
|
||||||
/* global importScripts workerUtil CSSLint require metaParser */
|
/* global createWorkerApi */// worker-util.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
importScripts('/js/worker-util.js');
|
(() => {
|
||||||
const {loadScript} = workerUtil;
|
const {require} = self; // self.require will be overwritten by StyleLint
|
||||||
|
|
||||||
/** @namespace EditorWorker */
|
/** @namespace EditorWorker */
|
||||||
workerUtil.createAPI({
|
createWorkerApi({
|
||||||
csslint: (code, config) => {
|
|
||||||
loadScript('/vendor-overwrites/csslint/parserlib.js', '/vendor-overwrites/csslint/csslint.js');
|
async csslint(code, config) {
|
||||||
return CSSLint.verify(code, config).messages
|
require(['/js/csslint/parserlib', '/js/csslint/csslint']); /* global CSSLint */
|
||||||
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
|
return CSSLint
|
||||||
},
|
.verify(code, config).messages
|
||||||
stylelint: async (code, config) => {
|
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
|
||||||
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
|
},
|
||||||
const {results: [res]} = await require('stylelint').lint({code, config});
|
|
||||||
delete res._postcssResult; // huge and unused
|
getRules(linter) {
|
||||||
return res;
|
return ruleRetriever[linter](); // eslint-disable-line no-use-before-define
|
||||||
},
|
},
|
||||||
metalint: code => {
|
|
||||||
loadScript(
|
metalint(code) {
|
||||||
'/vendor/usercss-meta/usercss-meta.min.js',
|
require(['/js/meta-parser']); /* global metaParser */
|
||||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
const result = metaParser.lint(code);
|
||||||
'/js/meta-parser.js'
|
// extract needed info
|
||||||
);
|
result.errors = result.errors.map(err => ({
|
||||||
const result = metaParser.lint(code);
|
|
||||||
// extract needed info
|
|
||||||
result.errors = result.errors.map(err =>
|
|
||||||
({
|
|
||||||
code: err.code,
|
code: err.code,
|
||||||
args: err.args,
|
args: err.args,
|
||||||
message: err.message,
|
message: err.message,
|
||||||
index: err.index,
|
index: err.index,
|
||||||
})
|
}));
|
||||||
);
|
return result;
|
||||||
return result;
|
},
|
||||||
},
|
|
||||||
getStylelintRules,
|
|
||||||
getCsslintRules,
|
|
||||||
});
|
|
||||||
|
|
||||||
function getCsslintRules() {
|
async stylelint(code, config) {
|
||||||
loadScript('/vendor-overwrites/csslint/csslint.js');
|
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
|
||||||
return CSSLint.getRules().map(rule => {
|
const {results: [res]} = await self.require('stylelint').lint({code, config});
|
||||||
const output = {};
|
delete res._postcssResult; // huge and unused
|
||||||
for (const [key, value] of Object.entries(rule)) {
|
return res;
|
||||||
if (typeof value !== 'function') {
|
},
|
||||||
output[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function getStylelintRules() {
|
const ruleRetriever = {
|
||||||
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
|
|
||||||
const stylelint = require('stylelint');
|
csslint() {
|
||||||
const options = {};
|
require(['/js/csslint/csslint']);
|
||||||
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
|
return CSSLint.getRules().map(rule => {
|
||||||
const rxString = /"([-\w\s]{3,}?)"/g;
|
const output = {};
|
||||||
for (const id of Object.keys(stylelint.rules)) {
|
for (const [key, value] of Object.entries(rule)) {
|
||||||
const ruleCode = String(stylelint.rules[id]);
|
if (typeof value !== 'function') {
|
||||||
const sets = [];
|
output[key] = value;
|
||||||
let m, mStr;
|
}
|
||||||
while ((m = rxPossible.exec(ruleCode))) {
|
}
|
||||||
const possible = m[1];
|
return output;
|
||||||
const set = [];
|
});
|
||||||
while ((mStr = rxString.exec(possible))) {
|
},
|
||||||
const s = mStr[1];
|
|
||||||
if (s.includes(' ')) {
|
stylelint() {
|
||||||
set.push(...s.split(/\s+/));
|
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
|
||||||
} else {
|
const options = {};
|
||||||
set.push(s);
|
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
|
||||||
|
const rxString = /"([-\w\s]{3,}?)"/g;
|
||||||
|
for (const [id, rule] of Object.entries(self.require('stylelint').rules)) {
|
||||||
|
const ruleCode = `${rule}`;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (possible.includes('ignoreAtRules')) {
|
return options;
|
||||||
set.push('ignoreAtRules');
|
},
|
||||||
}
|
};
|
||||||
if (possible.includes('ignoreShorthands')) {
|
})();
|
||||||
set.push('ignoreShorthands');
|
|
||||||
}
|
|
||||||
if (set.length) {
|
|
||||||
sets.push(set);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sets.length) {
|
|
||||||
options[id] = sets;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
114
edit/embedded-popup.js
Normal file
114
edit/embedded-popup.js
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
/* global $ $create $remove getEventKeyName */// dom.js
|
||||||
|
/* global CodeMirror */
|
||||||
|
/* global baseInit */// base.js
|
||||||
|
/* global prefs */
|
||||||
|
/* global t */// localization.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const ID = 'popup-iframe';
|
||||||
|
const SEL = '#' + ID;
|
||||||
|
const URL = chrome.runtime.getManifest().browser_action.default_popup;
|
||||||
|
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
|
||||||
|
/** @type {HTMLIFrameElement} */
|
||||||
|
let frame;
|
||||||
|
let isLoaded;
|
||||||
|
let scrollbarWidth;
|
||||||
|
|
||||||
|
const btn = $create('img', {
|
||||||
|
id: 'popup-button',
|
||||||
|
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
|
||||||
|
onclick: embedPopup,
|
||||||
|
});
|
||||||
|
document.documentElement.appendChild(btn);
|
||||||
|
baseInit.domReady.then(() => {
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
// Adding a dummy command to show in keymap help popup
|
||||||
|
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup';
|
||||||
|
});
|
||||||
|
|
||||||
|
prefs.subscribe('iconset', (_, val) => {
|
||||||
|
const prefix = `images/icon/${val ? 'light/' : ''}`;
|
||||||
|
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
|
||||||
|
}, {runNow: true});
|
||||||
|
|
||||||
|
window.on('keydown', e => {
|
||||||
|
if (getEventKeyName(e) === POPUP_HOTKEY) {
|
||||||
|
embedPopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function embedPopup() {
|
||||||
|
if ($(SEL)) return;
|
||||||
|
isLoaded = false;
|
||||||
|
scrollbarWidth = 0;
|
||||||
|
frame = $create('iframe', {
|
||||||
|
id: ID,
|
||||||
|
src: URL,
|
||||||
|
height: 600,
|
||||||
|
width: prefs.get('popupWidth'),
|
||||||
|
onload: initFrame,
|
||||||
|
});
|
||||||
|
window.on('mousedown', removePopup);
|
||||||
|
document.body.appendChild(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFrame() {
|
||||||
|
frame = this;
|
||||||
|
frame.focus();
|
||||||
|
const pw = frame.contentWindow;
|
||||||
|
const body = pw.document.body;
|
||||||
|
pw.on('keydown', removePopupOnEsc);
|
||||||
|
pw.close = removePopup;
|
||||||
|
if (pw.IntersectionObserver) {
|
||||||
|
new pw.IntersectionObserver(onIntersect).observe(body.appendChild(
|
||||||
|
$create('div', {style: {height: '1px', marginTop: '-1px'}})
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
frame.dataset.loaded = '';
|
||||||
|
frame.height = body.scrollHeight;
|
||||||
|
}
|
||||||
|
new pw.MutationObserver(onMutation).observe(body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMutation() {
|
||||||
|
const body = frame.contentDocument.body;
|
||||||
|
const bs = body.style;
|
||||||
|
const w = parseFloat(bs.minWidth || bs.width) + (scrollbarWidth || 0);
|
||||||
|
const h = parseFloat(bs.minHeight || body.offsetHeight);
|
||||||
|
if (frame.width - w) frame.width = w;
|
||||||
|
if (frame.height - h) frame.height = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIntersect([e]) {
|
||||||
|
const pw = frame.contentWindow;
|
||||||
|
const el = pw.document.scrollingElement;
|
||||||
|
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
|
||||||
|
const hasSB = h > el.offsetHeight;
|
||||||
|
const {width} = e.boundingClientRect;
|
||||||
|
frame.height = h;
|
||||||
|
if (!hasSB !== !scrollbarWidth || frame.width - width) {
|
||||||
|
scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
|
||||||
|
frame.width = width + scrollbarWidth;
|
||||||
|
}
|
||||||
|
if (!isLoaded) {
|
||||||
|
isLoaded = true;
|
||||||
|
frame.dataset.loaded = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePopup() {
|
||||||
|
frame = null;
|
||||||
|
$remove(SEL);
|
||||||
|
window.off('mousedown', removePopup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePopupOnEsc(e) {
|
||||||
|
if (getEventKeyName(e) === 'Escape') {
|
||||||
|
removePopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
|
@ -1,21 +1,14 @@
|
||||||
/* global
|
/* global $ $$ $create $remove focusAccessibility */// dom.js
|
||||||
$
|
/* global CodeMirror */
|
||||||
$$
|
/* global chromeLocal */// storage-util.js
|
||||||
$create
|
/* global colorMimicry */
|
||||||
chromeLocal
|
/* global debounce stringAsRegExp tryRegExp */// toolbox.js
|
||||||
CodeMirror
|
/* global editor */
|
||||||
colorMimicry
|
/* global t */// localization.js
|
||||||
debounce
|
|
||||||
editor
|
|
||||||
focusAccessibility
|
|
||||||
onDOMready
|
|
||||||
stringAsRegExp
|
|
||||||
t
|
|
||||||
tryRegExp
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
onDOMready().then(() => {
|
(() => {
|
||||||
|
require(['/edit/global-search.css']);
|
||||||
|
|
||||||
//region Constants and state
|
//region Constants and state
|
||||||
|
|
||||||
|
@ -138,13 +131,13 @@ onDOMready().then(() => {
|
||||||
},
|
},
|
||||||
onfocusout() {
|
onfocusout() {
|
||||||
if (!state.dialog.contains(document.activeElement)) {
|
if (!state.dialog.contains(document.activeElement)) {
|
||||||
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
|
state.dialog.on('focusin', EVENTS.onfocusin);
|
||||||
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
|
state.dialog.off('focusout', EVENTS.onfocusout);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onfocusin() {
|
onfocusin() {
|
||||||
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
|
state.dialog.on('focusout', EVENTS.onfocusout);
|
||||||
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
|
state.dialog.off('focusin', EVENTS.onfocusin);
|
||||||
trimUndoHistory();
|
trimUndoHistory();
|
||||||
enableUndoButton(state.undoHistory.length);
|
enableUndoButton(state.undoHistory.length);
|
||||||
if (state.find) doSearch({canAdvance: false});
|
if (state.find) doSearch({canAdvance: false});
|
||||||
|
@ -189,7 +182,6 @@ onDOMready().then(() => {
|
||||||
|
|
||||||
Object.assign(CodeMirror.commands, COMMANDS);
|
Object.assign(CodeMirror.commands, COMMANDS);
|
||||||
readStorage();
|
readStorage();
|
||||||
return;
|
|
||||||
|
|
||||||
//region Find
|
//region Find
|
||||||
|
|
||||||
|
@ -577,7 +569,7 @@ onDOMready().then(() => {
|
||||||
|
|
||||||
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
|
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
|
||||||
Object.assign(dialog, DIALOG_PROPS.dialog);
|
Object.assign(dialog, DIALOG_PROPS.dialog);
|
||||||
dialog.addEventListener('focusout', EVENTS.onfocusout);
|
dialog.on('focusout', EVENTS.onfocusout);
|
||||||
dialog.dataset.type = type;
|
dialog.dataset.type = type;
|
||||||
dialog.style.pointerEvents = 'auto';
|
dialog.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
@ -590,9 +582,9 @@ onDOMready().then(() => {
|
||||||
state.tally = $('[data-type="tally"]', dialog);
|
state.tally = $('[data-type="tally"]', dialog);
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
|
body: colorMimicry(document.body, {bg: 'backgroundColor'}),
|
||||||
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
|
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
|
||||||
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
|
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
|
||||||
};
|
};
|
||||||
document.documentElement.appendChild(
|
document.documentElement.appendChild(
|
||||||
$(DIALOG_STYLE_SELECTOR) ||
|
$(DIALOG_STYLE_SELECTOR) ||
|
||||||
|
@ -652,7 +644,7 @@ onDOMready().then(() => {
|
||||||
|
|
||||||
function destroyDialog({restoreFocus = false} = {}) {
|
function destroyDialog({restoreFocus = false} = {}) {
|
||||||
state.input = null;
|
state.input = null;
|
||||||
$.remove(DIALOG_SELECTOR);
|
$remove(DIALOG_SELECTOR);
|
||||||
debounce.unregister(doSearch);
|
debounce.unregister(doSearch);
|
||||||
makeTargetVisible(null);
|
makeTargetVisible(null);
|
||||||
if (restoreFocus) {
|
if (restoreFocus) {
|
||||||
|
@ -795,7 +787,6 @@ onDOMready().then(() => {
|
||||||
});
|
});
|
||||||
if (!cm.curOp) cm.startOperation();
|
if (!cm.curOp) cm.startOperation();
|
||||||
if (!state.firstRun) {
|
if (!state.firstRun) {
|
||||||
editor.scrollToEditor(cm);
|
|
||||||
cm.jumpToPos(pos.from, pos.to);
|
cm.jumpToPos(pos.from, pos.to);
|
||||||
}
|
}
|
||||||
// focus or expose as the current search target
|
// focus or expose as the current search target
|
||||||
|
@ -960,4 +951,4 @@ onDOMready().then(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
});
|
})();
|
||||||
|
|
|
@ -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};
|
|
||||||
})();
|
|
226
edit/linter-dialogs.js
Normal file
226
edit/linter-dialogs.js
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
/* global $ $create $createLink messageBoxProxy */// dom.js
|
||||||
|
/* global chromeSync */// storage-util.js
|
||||||
|
/* global editor */
|
||||||
|
/* global helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
|
||||||
|
/* global linterMan */
|
||||||
|
/* global t */// localization.js
|
||||||
|
/* global tryJSONparse */// toolbox.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
/** @type {{csslint:{}, stylelint:{}}} */
|
||||||
|
const RULES = {};
|
||||||
|
let cm;
|
||||||
|
let defaultConfig;
|
||||||
|
let isStylelint;
|
||||||
|
let linter;
|
||||||
|
let popup;
|
||||||
|
|
||||||
|
linterMan.showLintConfig = async () => {
|
||||||
|
linter = $('#editor.linter').value;
|
||||||
|
if (!linter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!RULES[linter]) {
|
||||||
|
linterMan.worker.getRules(linter).then(res => (RULES[linter] = res));
|
||||||
|
}
|
||||||
|
await require([
|
||||||
|
'/vendor/codemirror/mode/javascript/javascript',
|
||||||
|
'/vendor/codemirror/addon/lint/json-lint',
|
||||||
|
'/vendor/jsonlint/jsonlint',
|
||||||
|
]);
|
||||||
|
const config = await chromeSync.getLZValue(chromeSync.LZ_KEY[linter]);
|
||||||
|
const title = t('linterConfigPopupTitle', isStylelint ? 'Stylelint' : 'CSSLint');
|
||||||
|
isStylelint = linter === 'stylelint';
|
||||||
|
defaultConfig = stringifyConfig(linterMan.DEFAULTS[linter]);
|
||||||
|
popup = showCodeMirrorPopup(title, null, {
|
||||||
|
extraKeys: {'Ctrl-Enter': onConfigSave},
|
||||||
|
hintOptions: {hint},
|
||||||
|
lint: true,
|
||||||
|
mode: 'application/json',
|
||||||
|
value: config ? stringifyConfig(config) : defaultConfig,
|
||||||
|
});
|
||||||
|
$('.contents', popup).appendChild(
|
||||||
|
$create('div', [
|
||||||
|
$create('p', [
|
||||||
|
$createLink(
|
||||||
|
isStylelint
|
||||||
|
? 'https://stylelint.io/user-guide/rules/'
|
||||||
|
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
|
||||||
|
t('linterRulesLink')),
|
||||||
|
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
|
||||||
|
]),
|
||||||
|
$create('.buttons', [
|
||||||
|
$create('button.save', {onclick: onConfigSave, title: 'Ctrl-Enter'},
|
||||||
|
t('styleSaveLabel')),
|
||||||
|
$create('button.cancel', {onclick: onConfigCancel}, t('confirmClose')),
|
||||||
|
$create('button.reset', {onclick: onConfigReset, title: t('linterResetMessage')},
|
||||||
|
t('genericResetLabel')),
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
cm = popup.codebox;
|
||||||
|
cm.focus();
|
||||||
|
cm.on('changes', updateConfigButtons);
|
||||||
|
updateConfigButtons();
|
||||||
|
rerouteHotkeys(false);
|
||||||
|
window.on('closeHelp', onConfigClose, {once: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
linterMan.showLintHelp = async () => {
|
||||||
|
// FIXME: implement a linterChooser?
|
||||||
|
const linter = $('#editor.linter').value;
|
||||||
|
const baseUrl = linter === 'stylelint'
|
||||||
|
? 'https://stylelint.io/user-guide/rules/'
|
||||||
|
// some CSSLint rules do not have a url
|
||||||
|
: 'https://github.com/CSSLint/csslint/issues/535';
|
||||||
|
let headerLink, template;
|
||||||
|
if (linter === 'csslint') {
|
||||||
|
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
|
||||||
|
template = ({rule: ruleID}) => {
|
||||||
|
const rule = RULES.csslint.find(rule => rule.id === ruleID);
|
||||||
|
return rule &&
|
||||||
|
$create('li', [
|
||||||
|
$create('b', $createLink(rule.url || baseUrl, rule.name)),
|
||||||
|
$create('br'),
|
||||||
|
rule.desc,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
headerLink = $createLink(baseUrl, 'stylelint');
|
||||||
|
template = rule =>
|
||||||
|
$create('li',
|
||||||
|
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
|
||||||
|
}
|
||||||
|
const header = t('linterIssuesHelp', '\x01').split('\x01');
|
||||||
|
const activeRules = new Set([...linterMan.getIssues()].map(issue => issue.rule));
|
||||||
|
helpPopup.show(t('linterIssues'),
|
||||||
|
$create([
|
||||||
|
header[0], headerLink, header[1],
|
||||||
|
$create('ul.rules', [...activeRules].map(template)),
|
||||||
|
]));
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLexicalDepth(lexicalState) {
|
||||||
|
let depth = 0;
|
||||||
|
while ((lexicalState = lexicalState.prev)) {
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hint(cm) {
|
||||||
|
const rules = RULES[linter];
|
||||||
|
let ruleIds, options;
|
||||||
|
if (isStylelint) {
|
||||||
|
ruleIds = Object.keys(rules);
|
||||||
|
options = rules;
|
||||||
|
} else {
|
||||||
|
ruleIds = rules.map(r => r.id);
|
||||||
|
options = {};
|
||||||
|
}
|
||||||
|
const cursor = cm.getCursor();
|
||||||
|
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
|
||||||
|
const {line, ch} = cursor;
|
||||||
|
|
||||||
|
const quoted = string.startsWith('"');
|
||||||
|
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
|
||||||
|
const depth = getLexicalDepth(lexical);
|
||||||
|
|
||||||
|
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
|
||||||
|
let [, prevWord] = search.find(true) || [];
|
||||||
|
let words = [];
|
||||||
|
|
||||||
|
if (depth === 1 && isStylelint) {
|
||||||
|
words = quoted ? ['rules'] : [];
|
||||||
|
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
|
||||||
|
words = ruleIds;
|
||||||
|
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
|
||||||
|
words = !quoted ? ['true', 'false', 'null'] :
|
||||||
|
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
|
||||||
|
} else if (depth === 4 && prevWord === 'severity') {
|
||||||
|
words = ['error', 'warning'];
|
||||||
|
} else if (depth === 4) {
|
||||||
|
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
|
||||||
|
} else if (depth === 5 && lexical.type === ']' && quoted) {
|
||||||
|
while (prevWord && !ruleIds.includes(prevWord)) {
|
||||||
|
prevWord = (search.find(true) || [])[1];
|
||||||
|
}
|
||||||
|
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
list: words.filter(word => word.startsWith(leftPart)),
|
||||||
|
from: {line, ch: start + (quoted ? 1 : 0)},
|
||||||
|
to: {line, ch: string.endsWith('"') ? end - 1 : end},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigCancel() {
|
||||||
|
helpPopup.close();
|
||||||
|
editor.closestVisible().focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigClose() {
|
||||||
|
rerouteHotkeys(true);
|
||||||
|
cm = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigReset(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
cm.setValue(defaultConfig);
|
||||||
|
cm.focus();
|
||||||
|
updateConfigButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfigSave(event) {
|
||||||
|
if (event instanceof Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
const json = tryJSONparse(cm.getValue());
|
||||||
|
if (!json) {
|
||||||
|
showLinterErrorMessage(linter, t('linterJSONError'), popup);
|
||||||
|
cm.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let invalid;
|
||||||
|
if (isStylelint) {
|
||||||
|
invalid = Object.keys(json.rules).filter(k => !RULES.stylelint.hasOwnProperty(k));
|
||||||
|
} else {
|
||||||
|
const ids = RULES.csslint.map(r => r.id);
|
||||||
|
invalid = Object.keys(json).filter(k => !ids.includes(k));
|
||||||
|
}
|
||||||
|
if (invalid.length) {
|
||||||
|
showLinterErrorMessage(linter, [
|
||||||
|
t('linterInvalidConfigError'),
|
||||||
|
$create('ul', invalid.map(name => $create('li', name))),
|
||||||
|
], popup);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chromeSync.setLZValue(chromeSync.LZ_KEY[linter], json);
|
||||||
|
cm.markClean();
|
||||||
|
cm.focus();
|
||||||
|
updateConfigButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyConfig(config) {
|
||||||
|
return JSON.stringify(config, null, 2)
|
||||||
|
.replace(/,\n\s+{\n\s+("severity":\s"\w+")\n\s+}/g, ', {$1}');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLinterErrorMessage(title, contents, popup) {
|
||||||
|
await messageBoxProxy.show({
|
||||||
|
title,
|
||||||
|
contents,
|
||||||
|
className: 'danger center lint-config',
|
||||||
|
buttons: [t('confirmOK')],
|
||||||
|
});
|
||||||
|
if (popup && popup.codebox) {
|
||||||
|
popup.codebox.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfigButtons() {
|
||||||
|
$('.save', popup).disabled = cm.isClean();
|
||||||
|
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
|
||||||
|
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
|
||||||
|
}
|
||||||
|
})();
|
|
@ -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)),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
420
edit/linter-manager.js
Normal file
420
edit/linter-manager.js
Normal file
|
@ -0,0 +1,420 @@
|
||||||
|
/* global $ $create */// dom.js
|
||||||
|
/* global chromeSync */// storage-util.js
|
||||||
|
/* global clipString */// util.js
|
||||||
|
/* global createWorker */// worker-util.js
|
||||||
|
/* global editor */
|
||||||
|
/* global prefs */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
//#region linterMan
|
||||||
|
|
||||||
|
const linterMan = (() => {
|
||||||
|
const cms = new Map();
|
||||||
|
const linters = [];
|
||||||
|
const lintingUpdatedListeners = [];
|
||||||
|
const unhookListeners = [];
|
||||||
|
return {
|
||||||
|
|
||||||
|
/** @type {EditorWorker} */
|
||||||
|
worker: createWorker({url: '/edit/editor-worker'}),
|
||||||
|
|
||||||
|
disableForEditor(cm) {
|
||||||
|
cm.setOption('lint', false);
|
||||||
|
cms.delete(cm);
|
||||||
|
for (const cb of unhookListeners) {
|
||||||
|
cb(cm);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} cm
|
||||||
|
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
|
||||||
|
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
|
||||||
|
* update when lint gutter is added to a lot of editors simultaneously.
|
||||||
|
*/
|
||||||
|
enableForEditor(cm, code) {
|
||||||
|
if (cms.has(cm)) return;
|
||||||
|
cms.set(cm, null);
|
||||||
|
if (code) {
|
||||||
|
enableOnProblems(cm, code);
|
||||||
|
} else {
|
||||||
|
cm.setOption('lint', {getAnnotations, onUpdateLinting});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onLintingUpdated(fn) {
|
||||||
|
lintingUpdatedListeners.push(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnhook(fn) {
|
||||||
|
unhookListeners.push(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
register(fn) {
|
||||||
|
linters.push(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
run() {
|
||||||
|
for (const cm of cms.keys()) {
|
||||||
|
cm.performLint();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enableOnProblems(cm, code) {
|
||||||
|
const results = await getAnnotations(code, {}, cm);
|
||||||
|
if (results.length || cm.display.renderedView) {
|
||||||
|
cms.set(cm, results);
|
||||||
|
cm.setOption('lint', {getAnnotations: getCachedAnnotations, onUpdateLinting});
|
||||||
|
} else {
|
||||||
|
cms.delete(cm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAnnotations(...args) {
|
||||||
|
const results = await Promise.all(linters.map(fn => fn(...args)));
|
||||||
|
return [].concat(...results.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedAnnotations(code, opt, cm) {
|
||||||
|
const results = cms.get(cm);
|
||||||
|
cms.set(cm, null);
|
||||||
|
cm.options.lint.getAnnotations = getAnnotations;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateLinting(...args) {
|
||||||
|
for (const fn of lintingUpdatedListeners) {
|
||||||
|
fn(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region DEFAULTS
|
||||||
|
|
||||||
|
linterMan.DEFAULTS = {
|
||||||
|
stylelint: {
|
||||||
|
rules: {
|
||||||
|
'at-rule-no-unknown': [true, {
|
||||||
|
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
|
||||||
|
'severity': 'warning',
|
||||||
|
}],
|
||||||
|
'block-no-empty': [true, {severity: 'warning'}],
|
||||||
|
'color-no-invalid-hex': [true, {severity: 'warning'}],
|
||||||
|
'declaration-block-no-duplicate-properties': [true, {
|
||||||
|
'ignore': ['consecutive-duplicates-with-different-values'],
|
||||||
|
'severity': 'warning',
|
||||||
|
}],
|
||||||
|
'declaration-block-no-shorthand-property-overrides': [true, {severity: 'warning'}],
|
||||||
|
'font-family-no-duplicate-names': [true, {severity: 'warning'}],
|
||||||
|
'function-calc-no-unspaced-operator': [true, {severity: 'warning'}],
|
||||||
|
'function-linear-gradient-no-nonstandard-direction': [true, {severity: 'warning'}],
|
||||||
|
'keyframe-declaration-no-important': [true, {severity: 'warning'}],
|
||||||
|
'media-feature-name-no-unknown': [true, {severity: 'warning'}],
|
||||||
|
'no-empty-source': false,
|
||||||
|
'no-extra-semicolons': [true, {severity: 'warning'}],
|
||||||
|
'no-invalid-double-slash-comments': [true, {severity: 'warning'}],
|
||||||
|
'property-no-unknown': [true, {severity: 'warning'}],
|
||||||
|
'selector-pseudo-class-no-unknown': [true, {severity: 'warning'}],
|
||||||
|
'selector-pseudo-element-no-unknown': [true, {severity: 'warning'}],
|
||||||
|
'selector-type-no-unknown': false, // for scss/less/stylus-lang
|
||||||
|
'string-no-newline': [true, {severity: 'warning'}],
|
||||||
|
'unit-no-unknown': [true, {severity: 'warning'}],
|
||||||
|
'comment-no-empty': false,
|
||||||
|
'declaration-block-no-redundant-longhand-properties': false,
|
||||||
|
'shorthand-property-no-redundant-values': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
csslint: {
|
||||||
|
'display-property-grouping': 1,
|
||||||
|
'duplicate-properties': 1,
|
||||||
|
'empty-rules': 1,
|
||||||
|
'errors': 1,
|
||||||
|
'known-properties': 1,
|
||||||
|
'selector-newline': 1,
|
||||||
|
'simple-not': 1,
|
||||||
|
'warnings': 1,
|
||||||
|
// disabled
|
||||||
|
'adjoining-classes': 0,
|
||||||
|
'box-model': 0,
|
||||||
|
'box-sizing': 0,
|
||||||
|
'bulletproof-font-face': 0,
|
||||||
|
'compatible-vendor-prefixes': 0,
|
||||||
|
'duplicate-background-images': 0,
|
||||||
|
'fallback-colors': 0,
|
||||||
|
'floats': 0,
|
||||||
|
'font-faces': 0,
|
||||||
|
'font-sizes': 0,
|
||||||
|
'gradients': 0,
|
||||||
|
'ids': 0,
|
||||||
|
'import': 0,
|
||||||
|
'import-ie-limit': 0,
|
||||||
|
'important': 0,
|
||||||
|
'order-alphabetical': 0,
|
||||||
|
'outline-none': 0,
|
||||||
|
'overqualified-elements': 0,
|
||||||
|
'qualified-headings': 0,
|
||||||
|
'regex-selectors': 0,
|
||||||
|
'rules-count': 0,
|
||||||
|
'selector-max': 0,
|
||||||
|
'selector-max-approaching': 0,
|
||||||
|
'shorthand': 0,
|
||||||
|
'star-property-hack': 0,
|
||||||
|
'text-indent': 0,
|
||||||
|
'underscore-property-hack': 0,
|
||||||
|
'unique-headings': 0,
|
||||||
|
'universal-selector': 0,
|
||||||
|
'unqualified-attributes': 0,
|
||||||
|
'vendor-prefix': 0,
|
||||||
|
'zero-units': 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region ENGINES
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const configs = new Map();
|
||||||
|
const {DEFAULTS, worker} = linterMan;
|
||||||
|
const ENGINES = {
|
||||||
|
csslint: {
|
||||||
|
validMode: mode => mode === 'css',
|
||||||
|
getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
|
||||||
|
async lint(text, config) {
|
||||||
|
const results = await worker.csslint(text, config);
|
||||||
|
return results
|
||||||
|
.map(({line, col: ch, message, rule, type: severity}) => line && {
|
||||||
|
message,
|
||||||
|
from: {line: line - 1, ch: ch - 1},
|
||||||
|
to: {line: line - 1, ch},
|
||||||
|
rule: rule.id,
|
||||||
|
severity,
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stylelint: {
|
||||||
|
validMode: () => true,
|
||||||
|
getConfig: config => ({
|
||||||
|
syntax: 'sugarss',
|
||||||
|
rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
|
||||||
|
}),
|
||||||
|
async lint(text, config, mode) {
|
||||||
|
const raw = await worker.stylelint(text, config);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
|
||||||
|
// and we can't just pre-remove the comments since "//" may be inside a string token
|
||||||
|
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
|
||||||
|
const res = [];
|
||||||
|
for (const w of raw.warnings) {
|
||||||
|
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
|
||||||
|
if (!slashCommentAllowed || !(
|
||||||
|
w.rule === 'no-invalid-double-slash-comments' ||
|
||||||
|
w.rule === 'property-no-unknown' && msg.includes('"//"')
|
||||||
|
)) {
|
||||||
|
res.push({
|
||||||
|
from: {line: w.line - 1, ch: w.column - 1},
|
||||||
|
to: {line: w.line - 1, ch: w.column},
|
||||||
|
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
|
||||||
|
severity: w.severity,
|
||||||
|
rule: w.rule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
linterMan.register(async (text, _options, cm) => {
|
||||||
|
const linter = prefs.get('editor.linter');
|
||||||
|
if (linter) {
|
||||||
|
const {mode} = cm.options;
|
||||||
|
const currentFirst = Object.entries(ENGINES).sort(([a]) => a === linter ? -1 : 1);
|
||||||
|
for (const [name, engine] of currentFirst) {
|
||||||
|
if (engine.validMode(mode)) {
|
||||||
|
const cfg = configs.get(name) || await getConfig(name);
|
||||||
|
return ENGINES[name].lint(text, cfg, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener(changes => {
|
||||||
|
for (const name of Object.keys(ENGINES)) {
|
||||||
|
if (chromeSync.LZ_KEY[name] in changes) {
|
||||||
|
getConfig(name).then(linterMan.run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getConfig(name) {
|
||||||
|
const rawCfg = await chromeSync.getLZValue(chromeSync.LZ_KEY[name]);
|
||||||
|
const cfg = ENGINES[name].getConfig(rawCfg);
|
||||||
|
configs.set(name, cfg);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region Reports
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const tables = new Map();
|
||||||
|
|
||||||
|
linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
|
||||||
|
let table = tables.get(cm);
|
||||||
|
if (!table) {
|
||||||
|
table = createTable(cm);
|
||||||
|
tables.set(cm, table);
|
||||||
|
const container = $('.lint-report-container');
|
||||||
|
const nextSibling = findNextSibling(tables, cm);
|
||||||
|
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
|
||||||
|
}
|
||||||
|
table.updateCaption();
|
||||||
|
table.updateAnnotations(annotations);
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
linterMan.onUnhook(cm => {
|
||||||
|
const table = tables.get(cm);
|
||||||
|
if (table) {
|
||||||
|
table.element.remove();
|
||||||
|
tables.delete(cm);
|
||||||
|
}
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(linterMan, {
|
||||||
|
|
||||||
|
getIssues() {
|
||||||
|
const issues = new Set();
|
||||||
|
for (const table of tables.values()) {
|
||||||
|
for (const tr of table.trs) {
|
||||||
|
issues.add(tr.getAnnotation());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshReport() {
|
||||||
|
for (const table of tables.values()) {
|
||||||
|
table.updateCaption();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
const issueCount = Array.from(tables.values())
|
||||||
|
.reduce((sum, table) => sum + table.trs.length, 0);
|
||||||
|
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
|
||||||
|
$('#issue-count').textContent = issueCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextSibling(tables, cm) {
|
||||||
|
const editors = editor.getEditors();
|
||||||
|
let i = editors.indexOf(cm) + 1;
|
||||||
|
while (i < editors.length) {
|
||||||
|
if (tables.has(editors[i])) {
|
||||||
|
return editors[i];
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTable(cm) {
|
||||||
|
const caption = $create('caption');
|
||||||
|
const tbody = $create('tbody');
|
||||||
|
const table = $create('table', [caption, tbody]);
|
||||||
|
const trs = [];
|
||||||
|
return {
|
||||||
|
element: table,
|
||||||
|
trs,
|
||||||
|
updateAnnotations,
|
||||||
|
updateCaption,
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateCaption() {
|
||||||
|
caption.textContent = editor.getEditorTitle(cm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnnotations(lines) {
|
||||||
|
let i = 0;
|
||||||
|
for (const anno of getAnnotations()) {
|
||||||
|
let tr;
|
||||||
|
if (i < trs.length) {
|
||||||
|
tr = trs[i];
|
||||||
|
} else {
|
||||||
|
tr = createTr();
|
||||||
|
trs.push(tr);
|
||||||
|
tbody.append(tr.element);
|
||||||
|
}
|
||||||
|
tr.update(anno);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i === 0) {
|
||||||
|
trs.length = 0;
|
||||||
|
tbody.textContent = '';
|
||||||
|
} else {
|
||||||
|
while (trs.length > i) {
|
||||||
|
trs.pop().element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.classList.toggle('empty', trs.length === 0);
|
||||||
|
|
||||||
|
function *getAnnotations() {
|
||||||
|
for (const line of lines.filter(Boolean)) {
|
||||||
|
yield *line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTr() {
|
||||||
|
let anno;
|
||||||
|
const severityIcon = $create('div');
|
||||||
|
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
|
||||||
|
const line = $create('td', {attributes: {role: 'line'}});
|
||||||
|
const col = $create('td', {attributes: {role: 'col'}});
|
||||||
|
const message = $create('td', {attributes: {role: 'message'}});
|
||||||
|
|
||||||
|
const trElement = $create('tr', {
|
||||||
|
onclick: () => gotoLintIssue(cm, anno),
|
||||||
|
}, [
|
||||||
|
severity,
|
||||||
|
line,
|
||||||
|
$create('td', {attributes: {role: 'sep'}}, ':'),
|
||||||
|
col,
|
||||||
|
message,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
element: trElement,
|
||||||
|
update,
|
||||||
|
getAnnotation: () => anno,
|
||||||
|
};
|
||||||
|
|
||||||
|
function update(_anno) {
|
||||||
|
anno = _anno;
|
||||||
|
trElement.className = anno.severity;
|
||||||
|
severity.dataset.rule = anno.rule;
|
||||||
|
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
|
||||||
|
severityIcon.textContent = anno.severity;
|
||||||
|
line.textContent = anno.from.line + 1;
|
||||||
|
col.textContent = anno.from.ch + 1;
|
||||||
|
message.title = clipString(anno.message, 1000) +
|
||||||
|
(anno.rule ? `\n(${anno.rule})` : '');
|
||||||
|
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoLintIssue(cm, anno) {
|
||||||
|
editor.scrollToEditor(cm);
|
||||||
|
cm.focus();
|
||||||
|
cm.jumpToPos(anno.from);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
|
@ -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,161 +0,0 @@
|
||||||
/* global linter editor clipString createLinterHelpDialog $ $create */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
Object.assign(linter, (() => {
|
|
||||||
const tables = new Map();
|
|
||||||
const helpDialog = createLinterHelpDialog(getIssues);
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
$('#lint-help').addEventListener('click', helpDialog.show);
|
|
||||||
}, {once: true});
|
|
||||||
|
|
||||||
linter.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
|
|
||||||
let table = tables.get(cm);
|
|
||||||
if (!table) {
|
|
||||||
table = createTable(cm);
|
|
||||||
tables.set(cm, table);
|
|
||||||
const container = $('.lint-report-container');
|
|
||||||
const nextSibling = findNextSibling(tables, cm);
|
|
||||||
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
|
|
||||||
}
|
|
||||||
table.updateCaption();
|
|
||||||
table.updateAnnotations(annotations);
|
|
||||||
updateCount();
|
|
||||||
});
|
|
||||||
|
|
||||||
linter.onUnhook(cm => {
|
|
||||||
const table = tables.get(cm);
|
|
||||||
if (table) {
|
|
||||||
table.element.remove();
|
|
||||||
tables.delete(cm);
|
|
||||||
}
|
|
||||||
updateCount();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {refreshReport};
|
|
||||||
|
|
||||||
function updateCount() {
|
|
||||||
const issueCount = Array.from(tables.values())
|
|
||||||
.reduce((sum, table) => sum + table.trs.length, 0);
|
|
||||||
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
|
|
||||||
$('#issue-count').textContent = issueCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIssues() {
|
|
||||||
const issues = new Set();
|
|
||||||
for (const table of tables.values()) {
|
|
||||||
for (const tr of table.trs) {
|
|
||||||
issues.add(tr.getAnnotation());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNextSibling(tables, cm) {
|
|
||||||
const editors = editor.getEditors();
|
|
||||||
let i = editors.indexOf(cm) + 1;
|
|
||||||
while (i < editors.length) {
|
|
||||||
if (tables.has(editors[i])) {
|
|
||||||
return editors[i];
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshReport() {
|
|
||||||
for (const table of tables.values()) {
|
|
||||||
table.updateCaption();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTable(cm) {
|
|
||||||
const caption = $create('caption');
|
|
||||||
const tbody = $create('tbody');
|
|
||||||
const table = $create('table', [caption, tbody]);
|
|
||||||
const trs = [];
|
|
||||||
return {
|
|
||||||
element: table,
|
|
||||||
trs,
|
|
||||||
updateAnnotations,
|
|
||||||
updateCaption,
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateCaption() {
|
|
||||||
caption.textContent = editor.getEditorTitle(cm);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAnnotations(lines) {
|
|
||||||
let i = 0;
|
|
||||||
for (const anno of getAnnotations()) {
|
|
||||||
let tr;
|
|
||||||
if (i < trs.length) {
|
|
||||||
tr = trs[i];
|
|
||||||
} else {
|
|
||||||
tr = createTr();
|
|
||||||
trs.push(tr);
|
|
||||||
tbody.append(tr.element);
|
|
||||||
}
|
|
||||||
tr.update(anno);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (i === 0) {
|
|
||||||
trs.length = 0;
|
|
||||||
tbody.textContent = '';
|
|
||||||
} else {
|
|
||||||
while (trs.length > i) {
|
|
||||||
trs.pop().element.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
table.classList.toggle('empty', trs.length === 0);
|
|
||||||
|
|
||||||
function *getAnnotations() {
|
|
||||||
for (const line of lines.filter(Boolean)) {
|
|
||||||
yield *line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTr() {
|
|
||||||
let anno;
|
|
||||||
const severityIcon = $create('div');
|
|
||||||
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
|
|
||||||
const line = $create('td', {attributes: {role: 'line'}});
|
|
||||||
const col = $create('td', {attributes: {role: 'col'}});
|
|
||||||
const message = $create('td', {attributes: {role: 'message'}});
|
|
||||||
|
|
||||||
const trElement = $create('tr', {
|
|
||||||
onclick: () => gotoLintIssue(cm, anno),
|
|
||||||
}, [
|
|
||||||
severity,
|
|
||||||
line,
|
|
||||||
$create('td', {attributes: {role: 'sep'}}, ':'),
|
|
||||||
col,
|
|
||||||
message,
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
element: trElement,
|
|
||||||
update,
|
|
||||||
getAnnotation: () => anno,
|
|
||||||
};
|
|
||||||
|
|
||||||
function update(_anno) {
|
|
||||||
anno = _anno;
|
|
||||||
trElement.className = anno.severity;
|
|
||||||
severity.dataset.rule = anno.rule;
|
|
||||||
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
|
|
||||||
severityIcon.textContent = anno.severity;
|
|
||||||
line.textContent = anno.from.line + 1;
|
|
||||||
col.textContent = anno.from.ch + 1;
|
|
||||||
message.title = clipString(anno.message, 1000) +
|
|
||||||
(anno.rule ? `\n(${anno.rule})` : '');
|
|
||||||
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function gotoLintIssue(cm, anno) {
|
|
||||||
editor.scrollToEditor(cm);
|
|
||||||
cm.focus();
|
|
||||||
cm.jumpToPos(anno.from);
|
|
||||||
}
|
|
||||||
})());
|
|
|
@ -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 +0,0 @@
|
||||||
/* global messageBox editor $ prefs */
|
|
||||||
/* exported createLivePreview */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function createLivePreview(preprocess, shouldShow) {
|
|
||||||
let data;
|
|
||||||
let previewer;
|
|
||||||
let enabled = prefs.get('editor.livePreview');
|
|
||||||
const label = $('#preview-label');
|
|
||||||
const errorContainer = $('#preview-errors');
|
|
||||||
|
|
||||||
prefs.subscribe(['editor.livePreview'], (key, value) => {
|
|
||||||
if (value && data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
|
|
||||||
previewer = createPreviewer();
|
|
||||||
previewer.update(data);
|
|
||||||
}
|
|
||||||
if (!value && previewer) {
|
|
||||||
previewer.disconnect();
|
|
||||||
previewer = null;
|
|
||||||
}
|
|
||||||
enabled = value;
|
|
||||||
});
|
|
||||||
if (shouldShow != null) show(shouldShow);
|
|
||||||
return {update, show};
|
|
||||||
|
|
||||||
function show(state) {
|
|
||||||
label.classList.toggle('hidden', !state);
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(_data) {
|
|
||||||
data = _data;
|
|
||||||
if (!previewer) {
|
|
||||||
if (!data.id || !data.enabled || !enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
previewer = createPreviewer();
|
|
||||||
}
|
|
||||||
previewer.update(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPreviewer() {
|
|
||||||
const port = chrome.runtime.connect({
|
|
||||||
name: 'livePreview',
|
|
||||||
});
|
|
||||||
port.onDisconnect.addListener(err => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
return {update, disconnect};
|
|
||||||
|
|
||||||
function update(data) {
|
|
||||||
Promise.resolve()
|
|
||||||
.then(() => preprocess ? preprocess(data) : data)
|
|
||||||
.then(data => port.postMessage(data))
|
|
||||||
.then(
|
|
||||||
() => errorContainer.classList.add('hidden'),
|
|
||||||
err => {
|
|
||||||
if (Array.isArray(err)) {
|
|
||||||
err = err.join('\n');
|
|
||||||
} else if (err && err.index !== undefined) {
|
|
||||||
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
|
|
||||||
const pos = editor.getEditors()[0].posFromIndex(err.index);
|
|
||||||
err.message = `${pos.line}:${pos.ch} ${err.message || String(err)}`;
|
|
||||||
}
|
|
||||||
errorContainer.classList.remove('hidden');
|
|
||||||
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
port.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +1,6 @@
|
||||||
/* global
|
/* global CodeMirror */
|
||||||
CodeMirror
|
/* global debounce deepEqual */// toolbox.js
|
||||||
debounce
|
/* global trimCommentLabel */// util.js
|
||||||
deepEqual
|
|
||||||
trimCommentLabel
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* exported MozSectionFinder */
|
/* exported MozSectionFinder */
|
||||||
|
@ -26,7 +23,7 @@ function MozSectionFinder(cm) {
|
||||||
/** @type {CodeMirror.Pos} */
|
/** @type {CodeMirror.Pos} */
|
||||||
let updTo;
|
let updTo;
|
||||||
|
|
||||||
const MozSectionFinder = {
|
const finder = {
|
||||||
IGNORE_ORIGIN: KEY,
|
IGNORE_ORIGIN: KEY,
|
||||||
EQ_SKIP_KEYS: [
|
EQ_SKIP_KEYS: [
|
||||||
'mark',
|
'mark',
|
||||||
|
@ -45,10 +42,11 @@ function MozSectionFinder(cm) {
|
||||||
const NOP = () => 0;
|
const NOP = () => 0;
|
||||||
data = {fn: NOP};
|
data = {fn: NOP};
|
||||||
keptAlive.set(id, data);
|
keptAlive.set(id, data);
|
||||||
MozSectionFinder.on(NOP);
|
finder.on(NOP);
|
||||||
}
|
}
|
||||||
data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
|
data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
|
||||||
},
|
},
|
||||||
|
|
||||||
on(fn) {
|
on(fn) {
|
||||||
const {listeners} = getState();
|
const {listeners} = getState();
|
||||||
const needsInit = !listeners.size;
|
const needsInit = !listeners.size;
|
||||||
|
@ -58,6 +56,7 @@ function MozSectionFinder(cm) {
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
off(fn) {
|
off(fn) {
|
||||||
const {listeners, sections} = getState();
|
const {listeners, sections} = getState();
|
||||||
if (listeners.size) {
|
if (listeners.size) {
|
||||||
|
@ -69,15 +68,16 @@ function MozSectionFinder(cm) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onOff(fn, enable) {
|
onOff(fn, enable) {
|
||||||
MozSectionFinder[enable ? 'on' : 'off'](fn);
|
finder[enable ? 'on' : 'off'](fn);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** @param {MozSection} [section] */
|
/** @param {MozSection} [section] */
|
||||||
updatePositions(section) {
|
updatePositions(section) {
|
||||||
(section ? [section] : getState().sections).forEach(setPositionFromMark);
|
(section ? [section] : getState().sections).forEach(setPositionFromMark);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return MozSectionFinder;
|
|
||||||
|
|
||||||
/** @returns {MozSectionCmState} */
|
/** @returns {MozSectionCmState} */
|
||||||
function getState() {
|
function getState() {
|
||||||
|
@ -97,7 +97,7 @@ function MozSectionFinder(cm) {
|
||||||
if (!updFrom) updFrom = {line: Infinity, ch: 0};
|
if (!updFrom) updFrom = {line: Infinity, ch: 0};
|
||||||
if (!updTo) updTo = {line: -1, ch: 0};
|
if (!updTo) updTo = {line: -1, ch: 0};
|
||||||
for (const c of changes) {
|
for (const c of changes) {
|
||||||
if (c.origin !== MozSectionFinder.IGNORE_ORIGIN) {
|
if (c.origin !== finder.IGNORE_ORIGIN) {
|
||||||
updFrom = minPos(c.from, updFrom);
|
updFrom = minPos(c.from, updFrom);
|
||||||
updTo = maxPos(CodeMirror.changeEnd(c), updTo);
|
updTo = maxPos(CodeMirror.changeEnd(c), updTo);
|
||||||
}
|
}
|
||||||
|
@ -387,11 +387,13 @@ function MozSectionFinder(cm) {
|
||||||
|
|
||||||
/** @this {MozSectionFunc[]} new functions */
|
/** @this {MozSectionFunc[]} new functions */
|
||||||
function isSameFunc(func, i) {
|
function isSameFunc(func, i) {
|
||||||
return deepEqual(func, this[i], MozSectionFinder.EQ_SKIP_KEYS);
|
return deepEqual(func, this[i], finder.EQ_SKIP_KEYS);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** @typedef CodeMirror.Pos
|
/** @typedef CodeMirror.Pos
|
||||||
* @property {number} line
|
* @property {number} line
|
||||||
* @property {number} ch
|
* @property {number} ch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
return finder;
|
||||||
|
}
|
||||||
|
|
|
@ -1,24 +1,16 @@
|
||||||
/* global
|
/* global $ $create messageBoxProxy */// dom.js
|
||||||
$
|
/* global CodeMirror */
|
||||||
$create
|
/* global MozSectionFinder */
|
||||||
CodeMirror
|
/* global colorMimicry */
|
||||||
colorMimicry
|
/* global editor */
|
||||||
messageBox
|
/* global msg */
|
||||||
MozSectionFinder
|
/* global prefs */
|
||||||
msg
|
/* global t */// localization.js
|
||||||
prefs
|
/* global tryCatch */// toolbox.js
|
||||||
regExpTester
|
|
||||||
t
|
|
||||||
tryCatch
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* exported MozSectionWidget */
|
/* exported MozSectionWidget */
|
||||||
function MozSectionWidget(
|
function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
|
||||||
cm,
|
|
||||||
finder = MozSectionFinder(cm),
|
|
||||||
onDirectChange = () => 0
|
|
||||||
) {
|
|
||||||
let TPL, EVENTS, CLICK_ROUTE;
|
let TPL, EVENTS, CLICK_ROUTE;
|
||||||
const KEY = 'MozSectionWidget';
|
const KEY = 'MozSectionWidget';
|
||||||
const C_CONTAINER = '.applies-to';
|
const C_CONTAINER = '.applies-to';
|
||||||
|
@ -36,7 +28,9 @@ function MozSectionWidget(
|
||||||
const {cmpPos} = CodeMirror;
|
const {cmpPos} = CodeMirror;
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let funcHeight = 0;
|
let funcHeight = 0;
|
||||||
|
/** @type {HTMLStyleElement} */
|
||||||
let actualStyle;
|
let actualStyle;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toggle(enable) {
|
toggle(enable) {
|
||||||
if (Boolean(enable) !== enabled) {
|
if (Boolean(enable) !== enabled) {
|
||||||
|
@ -71,7 +65,7 @@ function MozSectionWidget(
|
||||||
'.remove-applies-to'(elItem, func) {
|
'.remove-applies-to'(elItem, func) {
|
||||||
const funcs = getFuncsFor(elItem);
|
const funcs = getFuncsFor(elItem);
|
||||||
if (funcs.length < 2) {
|
if (funcs.length < 2) {
|
||||||
messageBox({
|
messageBoxProxy.show({
|
||||||
contents: t('appliesRemoveError'),
|
contents: t('appliesRemoveError'),
|
||||||
buttons: [t('confirmClose')],
|
buttons: [t('confirmClose')],
|
||||||
});
|
});
|
||||||
|
@ -110,7 +104,7 @@ function MozSectionWidget(
|
||||||
if (part === 'value' && func === getFuncsFor(el)[0]) {
|
if (part === 'value' && func === getFuncsFor(el)[0]) {
|
||||||
const sec = getSectionFor(el);
|
const sec = getSectionFor(el);
|
||||||
sec.tocEntry.target = el.value;
|
sec.tocEntry.target = el.value;
|
||||||
if (!sec.tocEntry.label) onDirectChange([sec]);
|
if (!sec.tocEntry.label) editor.updateToc([sec]);
|
||||||
}
|
}
|
||||||
cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
|
cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
|
||||||
},
|
},
|
||||||
|
@ -176,13 +170,13 @@ function MozSectionWidget(
|
||||||
const MIN_LUMA = .05;
|
const MIN_LUMA = .05;
|
||||||
const MIN_LUMA_DIFF = .4;
|
const MIN_LUMA_DIFF = .4;
|
||||||
const color = {
|
const color = {
|
||||||
wrapper: colorMimicry.get(cm.display.wrapper),
|
wrapper: colorMimicry(cm.display.wrapper),
|
||||||
gutter: colorMimicry.get(cm.display.gutters, {
|
gutter: colorMimicry(cm.display.gutters, {
|
||||||
bg: 'backgroundColor',
|
bg: 'backgroundColor',
|
||||||
border: 'borderRightColor',
|
border: 'borderRightColor',
|
||||||
}),
|
}),
|
||||||
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
|
line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
|
||||||
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
|
comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
|
||||||
};
|
};
|
||||||
const hasBorder =
|
const hasBorder =
|
||||||
color.gutter.style.borderRightWidth !== '0px' &&
|
color.gutter.style.borderRightWidth !== '0px' &&
|
||||||
|
@ -421,10 +415,12 @@ function MozSectionWidget(
|
||||||
f.value.clear();
|
f.value.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRegExpTester(el) {
|
async function showRegExpTester(el) {
|
||||||
|
/* global regexpTester */
|
||||||
|
await require(['/edit/regexp-tester']);
|
||||||
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
|
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
|
||||||
regExpTester.toggle(true);
|
regexpTester.toggle(true);
|
||||||
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
|
regexpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromDoubleslash(s) {
|
function fromDoubleslash(s) {
|
||||||
|
|
|
@ -1,80 +1,57 @@
|
||||||
/* global
|
/* global $create */// dom.js
|
||||||
$
|
/* global URLS openURL tryRegExp */// toolbox.js
|
||||||
$create
|
/* global helpPopup */// util.js
|
||||||
openURL
|
/* global t */// localization.js
|
||||||
showHelp
|
|
||||||
t
|
|
||||||
tryRegExp
|
|
||||||
URLS
|
|
||||||
*/
|
|
||||||
/* exported regExpTester */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const regExpTester = (() => {
|
const regexpTester = (() => {
|
||||||
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
|
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
|
||||||
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
|
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
|
||||||
const cachedRegexps = new Map();
|
const cachedRegexps = new Map();
|
||||||
let currentRegexps = [];
|
let currentRegexps = [];
|
||||||
let isInit = false;
|
let isWatching = false;
|
||||||
|
let isShown = false;
|
||||||
|
|
||||||
function init() {
|
window.on('closeHelp', () => regexpTester.toggle(false));
|
||||||
isInit = true;
|
|
||||||
chrome.tabs.onUpdated.addListener(onTabUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uninit() {
|
return {
|
||||||
chrome.tabs.onUpdated.removeListener(onTabUpdate);
|
|
||||||
isInit = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTabUpdate(tabId, info) {
|
toggle(state = !isShown) {
|
||||||
if (info.url) {
|
if (state && !isShown) {
|
||||||
update();
|
if (!isWatching) {
|
||||||
}
|
isWatching = true;
|
||||||
}
|
chrome.tabs.onUpdated.addListener(onTabUpdate);
|
||||||
|
}
|
||||||
function isShown() {
|
helpPopup.show('', $create('.regexp-report'));
|
||||||
return Boolean($('.regexp-report'));
|
isShown = true;
|
||||||
}
|
} else if (!state && isShown) {
|
||||||
|
unwatch();
|
||||||
function toggle(state = !isShown()) {
|
helpPopup.close();
|
||||||
if (state && !isShown()) {
|
isShown = false;
|
||||||
if (!isInit) {
|
|
||||||
init();
|
|
||||||
}
|
}
|
||||||
showHelp('', $create('.regexp-report'));
|
},
|
||||||
} else if (!state && isShown()) {
|
|
||||||
if (isInit) {
|
|
||||||
uninit();
|
|
||||||
}
|
|
||||||
// TODO: need a closeHelp function
|
|
||||||
$('#help-popup .dismiss').onclick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(newRegexps) {
|
async update(newRegexps) {
|
||||||
if (!isShown()) {
|
if (!isShown) {
|
||||||
if (isInit) {
|
unwatch();
|
||||||
uninit();
|
return;
|
||||||
}
|
}
|
||||||
return;
|
if (newRegexps) {
|
||||||
}
|
currentRegexps = newRegexps;
|
||||||
if (newRegexps) {
|
|
||||||
currentRegexps = newRegexps;
|
|
||||||
}
|
|
||||||
const regexps = currentRegexps.map(text => {
|
|
||||||
const rxData = Object.assign({text}, cachedRegexps.get(text));
|
|
||||||
if (!rxData.urls) {
|
|
||||||
cachedRegexps.set(text, Object.assign(rxData, {
|
|
||||||
// imitate buggy Stylish-for-chrome
|
|
||||||
rx: tryRegExp('^' + text + '$'),
|
|
||||||
urls: new Map(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return rxData;
|
const regexps = currentRegexps.map(text => {
|
||||||
});
|
const rxData = Object.assign({text}, cachedRegexps.get(text));
|
||||||
const getMatchInfo = m => m && {text: m[0], pos: m.index};
|
if (!rxData.urls) {
|
||||||
browser.tabs.query({}).then(tabs => {
|
cachedRegexps.set(text, Object.assign(rxData, {
|
||||||
|
// imitate buggy Stylish-for-chrome
|
||||||
|
rx: tryRegExp('^' + text + '$'),
|
||||||
|
urls: new Map(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return rxData;
|
||||||
|
});
|
||||||
|
const getMatchInfo = m => m && {text: m[0], pos: m.index};
|
||||||
|
const tabs = await browser.tabs.query({});
|
||||||
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
|
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
|
||||||
const unique = [...new Set(supported).values()];
|
const unique = [...new Set(supported).values()];
|
||||||
for (const rxData of regexps) {
|
for (const rxData of regexps) {
|
||||||
|
@ -92,10 +69,12 @@ const regExpTester = (() => {
|
||||||
}
|
}
|
||||||
const stats = {
|
const stats = {
|
||||||
full: {data: [], label: t('styleRegexpTestFull')},
|
full: {data: [], label: t('styleRegexpTestFull')},
|
||||||
partial: {data: [], label: [
|
partial: {
|
||||||
t('styleRegexpTestPartial'),
|
data: [], label: [
|
||||||
t.template.regexpTestPartial.cloneNode(true),
|
t('styleRegexpTestPartial'),
|
||||||
]},
|
t.template.regexpTestPartial.cloneNode(true),
|
||||||
|
],
|
||||||
|
},
|
||||||
none: {data: [], label: t('styleRegexpTestNone')},
|
none: {data: [], label: t('styleRegexpTestNone')},
|
||||||
invalid: {data: [], label: t('styleRegexpTestInvalid')},
|
invalid: {data: [], label: t('styleRegexpTestInvalid')},
|
||||||
};
|
};
|
||||||
|
@ -167,7 +146,7 @@ const regExpTester = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showHelp(t('styleRegexpTestTitle'), report);
|
helpPopup.show(t('styleRegexpTestTitle'), report);
|
||||||
report.onclick = onClick;
|
report.onclick = onClick;
|
||||||
|
|
||||||
const note = $create('p.regexp-report-note',
|
const note = $create('p.regexp-report-note',
|
||||||
|
@ -176,25 +155,36 @@ const regExpTester = (() => {
|
||||||
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
|
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
|
||||||
report.appendChild(note);
|
report.appendChild(note);
|
||||||
adjustNote(report, note);
|
adjustNote(report, note);
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function onClick(event) {
|
function adjustNote(report, note) {
|
||||||
const a = event.target.closest('a');
|
report.style.paddingBottom = note.offsetHeight + 'px';
|
||||||
if (a) {
|
}
|
||||||
event.preventDefault();
|
|
||||||
openURL({
|
|
||||||
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
|
|
||||||
currentWindow: null,
|
|
||||||
});
|
|
||||||
} else if (event.target.closest('details')) {
|
|
||||||
setTimeout(adjustNote);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustNote(report, note) {
|
function onClick(event) {
|
||||||
report.style.paddingBottom = note.offsetHeight + 'px';
|
const a = event.target.closest('a');
|
||||||
|
if (a) {
|
||||||
|
event.preventDefault();
|
||||||
|
openURL({
|
||||||
|
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
|
||||||
|
currentWindow: null,
|
||||||
|
});
|
||||||
|
} else if (event.target.closest('details')) {
|
||||||
|
setTimeout(adjustNote);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {toggle, update};
|
function onTabUpdate(tabId, info) {
|
||||||
|
if (info.url) {
|
||||||
|
regexpTester.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwatch() {
|
||||||
|
if (isWatching) {
|
||||||
|
chrome.tabs.onUpdated.removeListener(onTabUpdate);
|
||||||
|
isWatching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,49 +0,0 @@
|
||||||
/* global CodeMirror editor debounce */
|
|
||||||
/* exported rerouteHotkeys */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const rerouteHotkeys = (() => {
|
|
||||||
// reroute handling to nearest editor when keypress resolves to one of these commands
|
|
||||||
const REROUTED = new Set([
|
|
||||||
'save',
|
|
||||||
'toggleStyle',
|
|
||||||
'jumpToLine',
|
|
||||||
'nextEditor', 'prevEditor',
|
|
||||||
'toggleEditorFocus',
|
|
||||||
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
|
|
||||||
'colorpicker',
|
|
||||||
'beautify',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return rerouteHotkeys;
|
|
||||||
|
|
||||||
// note that this function relies on `editor`. Calling this function before
|
|
||||||
// the editor is initialized may throw an error.
|
|
||||||
function rerouteHotkeys(enable, immediately) {
|
|
||||||
if (!immediately) {
|
|
||||||
debounce(rerouteHotkeys, 0, enable, true);
|
|
||||||
} else if (enable) {
|
|
||||||
document.addEventListener('keydown', rerouteHandler);
|
|
||||||
} else {
|
|
||||||
document.removeEventListener('keydown', rerouteHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rerouteHandler(event) {
|
|
||||||
const keyName = CodeMirror.keyName(event);
|
|
||||||
if (!keyName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rerouteCommand = name => {
|
|
||||||
if (REROUTED.has(name)) {
|
|
||||||
CodeMirror.commands[name](editor.closestVisible(event.target));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
|
|
||||||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
|
@ -1,21 +1,15 @@
|
||||||
/* global
|
/* global $ */// dom.js
|
||||||
$
|
/* global MozDocMapper trimCommentLabel */// util.js
|
||||||
cmFactory
|
/* global cmFactory */
|
||||||
debounce
|
/* global debounce tryRegExp */// toolbox.js
|
||||||
DocFuncMapper
|
/* global editor */
|
||||||
editor
|
/* global initBeautifyButton */// beautify.js
|
||||||
initBeautifyButton
|
/* global linterMan */
|
||||||
linter
|
/* global prefs */
|
||||||
prefs
|
/* global t */// localization.js
|
||||||
regExpTester
|
|
||||||
t
|
|
||||||
trimCommentLabel
|
|
||||||
tryRegExp
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* exported createSection */
|
/* exported createSection */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {StyleSection} originalSection
|
* @param {StyleSection} originalSection
|
||||||
* @param {function():number} genId
|
* @param {function():number} genId
|
||||||
|
@ -43,7 +37,7 @@ function createSection(originalSection, genId, si) {
|
||||||
|
|
||||||
const appliesToContainer = $('.applies-to-list', el);
|
const appliesToContainer = $('.applies-to-list', el);
|
||||||
const appliesTo = [];
|
const appliesTo = [];
|
||||||
DocFuncMapper.forEachProp(originalSection, (type, value) =>
|
MozDocMapper.forEachProp(originalSection, (type, value) =>
|
||||||
insertApplyAfter({type, value}));
|
insertApplyAfter({type, value}));
|
||||||
if (!appliesTo.length) {
|
if (!appliesTo.length) {
|
||||||
insertApplyAfter({all: true});
|
insertApplyAfter({all: true});
|
||||||
|
@ -64,10 +58,10 @@ function createSection(originalSection, genId, si) {
|
||||||
appliesTo,
|
appliesTo,
|
||||||
getModel() {
|
getModel() {
|
||||||
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
|
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
|
||||||
return DocFuncMapper.toSection(items, {code: cm.getValue()});
|
return MozDocMapper.toSection(items, {code: cm.getValue()});
|
||||||
},
|
},
|
||||||
remove() {
|
remove() {
|
||||||
linter.disableForEditor(cm);
|
linterMan.disableForEditor(cm);
|
||||||
el.classList.add('removed');
|
el.classList.add('removed');
|
||||||
removed = true;
|
removed = true;
|
||||||
appliesTo.forEach(a => a.remove());
|
appliesTo.forEach(a => a.remove());
|
||||||
|
@ -79,7 +73,7 @@ function createSection(originalSection, genId, si) {
|
||||||
cmFactory.destroy(cm);
|
cmFactory.destroy(cm);
|
||||||
},
|
},
|
||||||
restore() {
|
restore() {
|
||||||
linter.enableForEditor(cm);
|
linterMan.enableForEditor(cm);
|
||||||
el.classList.remove('removed');
|
el.classList.remove('removed');
|
||||||
removed = false;
|
removed = false;
|
||||||
appliesTo.forEach(a => a.restore());
|
appliesTo.forEach(a => a.restore());
|
||||||
|
@ -102,7 +96,7 @@ function createSection(originalSection, genId, si) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true});
|
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {runNow: true});
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
|
|
||||||
|
@ -120,11 +114,8 @@ function createSection(originalSection, genId, si) {
|
||||||
emitSectionChange('code');
|
emitSectionChange('code');
|
||||||
});
|
});
|
||||||
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
|
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
|
||||||
$('.test-regexp', el).onclick = () => {
|
$('.test-regexp', el).onclick = () => updateRegexpTester(true);
|
||||||
regExpTester.toggle();
|
initBeautifyButton($('.beautify-section', el), [cm]);
|
||||||
updateRegexpTester();
|
|
||||||
};
|
|
||||||
initBeautifyButton($('.beautify-section', el), () => [cm]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(cm, event) {
|
function handleKeydown(cm, event) {
|
||||||
|
@ -165,15 +156,22 @@ function createSection(originalSection, genId, si) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRegexpTester() {
|
async function updateRegexpTester(toggle) {
|
||||||
|
const isLoaded = typeof regexpTester === 'object';
|
||||||
|
if (toggle && !isLoaded) {
|
||||||
|
await require(['/edit/regexp-tester']); /* global regexpTester */
|
||||||
|
}
|
||||||
|
if (toggle != null && isLoaded) {
|
||||||
|
regexpTester.toggle(toggle);
|
||||||
|
}
|
||||||
const regexps = appliesTo.filter(a => a.type === 'regexp')
|
const regexps = appliesTo.filter(a => a.type === 'regexp')
|
||||||
.map(a => a.value);
|
.map(a => a.value);
|
||||||
if (regexps.length) {
|
if (regexps.length) {
|
||||||
el.classList.add('has-regexp');
|
el.classList.add('has-regexp');
|
||||||
regExpTester.update(regexps);
|
if (isLoaded) regexpTester.update(regexps);
|
||||||
} else {
|
} else {
|
||||||
el.classList.remove('has-regexp');
|
el.classList.remove('has-regexp');
|
||||||
regExpTester.toggle(false);
|
if (isLoaded) regexpTester.toggle(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,7 +209,7 @@ function createSection(originalSection, genId, si) {
|
||||||
|
|
||||||
function updateTocPrefToggled(key, val) {
|
function updateTocPrefToggled(key, val) {
|
||||||
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
|
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
|
||||||
el.onOff(val, 'focusin', updateTocFocus);
|
(val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
|
||||||
if (val) {
|
if (val) {
|
||||||
updateTocEntry();
|
updateTocEntry();
|
||||||
if (el.contains(document.activeElement)) {
|
if (el.contains(document.activeElement)) {
|
||||||
|
|
|
@ -1,45 +1,30 @@
|
||||||
/* global
|
/* global $ $$ $create $remove messageBoxProxy */// dom.js
|
||||||
$
|
/* global API */// msg.js
|
||||||
$$
|
/* global CodeMirror */
|
||||||
$create
|
/* global FIREFOX URLS debounce ignoreChromeError sessionStore */// toolbox.js
|
||||||
API
|
/* global MozDocMapper clipString helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
|
||||||
clipString
|
/* global createSection */// sections-editor-section.js
|
||||||
CodeMirror
|
/* global editor */
|
||||||
createLivePreview
|
/* global linterMan */
|
||||||
createSection
|
/* global prefs */
|
||||||
debounce
|
/* global t */// localization.js
|
||||||
editor
|
|
||||||
FIREFOX
|
|
||||||
ignoreChromeError
|
|
||||||
linter
|
|
||||||
messageBox
|
|
||||||
prefs
|
|
||||||
rerouteHotkeys
|
|
||||||
sectionsToMozFormat
|
|
||||||
sessionStore
|
|
||||||
showCodeMirrorPopup
|
|
||||||
showHelp
|
|
||||||
t
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* exported SectionsEditor */
|
/* exported SectionsEditor */
|
||||||
|
|
||||||
function SectionsEditor() {
|
function SectionsEditor() {
|
||||||
const {style, dirty} = editor;
|
const {style, /** @type DirtyReporter */dirty} = editor;
|
||||||
const container = $('#sections');
|
const container = $('#sections');
|
||||||
/** @type {EditorSection[]} */
|
/** @type {EditorSection[]} */
|
||||||
const sections = [];
|
const sections = [];
|
||||||
const xo = window.IntersectionObserver &&
|
const xo = window.IntersectionObserver &&
|
||||||
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
|
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
|
||||||
const livePreview = createLivePreview(null, style.id);
|
|
||||||
|
|
||||||
let INC_ID = 0; // an increment id that is used by various object to track the order
|
let INC_ID = 0; // an increment id that is used by various object to track the order
|
||||||
let sectionOrder = '';
|
let sectionOrder = '';
|
||||||
let headerOffset; // in compact mode the header is at the top so it reduces the available height
|
let headerOffset; // in compact mode the header is at the top so it reduces the available height
|
||||||
|
|
||||||
container.classList.add('section-editor');
|
|
||||||
updateHeader();
|
updateHeader();
|
||||||
|
editor.livePreview.init(null, style.id);
|
||||||
|
container.classList.add('section-editor');
|
||||||
$('#to-mozilla').on('click', showMozillaFormat);
|
$('#to-mozilla').on('click', showMozillaFormat);
|
||||||
$('#to-mozilla-help').on('click', showToMozillaHelp);
|
$('#to-mozilla-help').on('click', showToMozillaHelp);
|
||||||
$('#from-mozilla').on('click', () => showMozillaFormatImport());
|
$('#from-mozilla').on('click', () => showMozillaFormatImport());
|
||||||
|
@ -50,7 +35,7 @@ function SectionsEditor() {
|
||||||
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
|
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @namespace SectionsEditor */
|
/** @namespace Editor */
|
||||||
Object.assign(editor, {
|
Object.assign(editor, {
|
||||||
|
|
||||||
sections,
|
sections,
|
||||||
|
@ -95,7 +80,7 @@ function SectionsEditor() {
|
||||||
dirty.clear('name');
|
dirty.clear('name');
|
||||||
// FIXME: avoid recreating all editors?
|
// FIXME: avoid recreating all editors?
|
||||||
if (codeIsUpdated !== false) {
|
if (codeIsUpdated !== false) {
|
||||||
await initSections(newStyle.sections, {replace: true, pristine: true});
|
await initSections(newStyle.sections, {replace: true});
|
||||||
}
|
}
|
||||||
Object.assign(style, newStyle);
|
Object.assign(style, newStyle);
|
||||||
updateHeader();
|
updateHeader();
|
||||||
|
@ -105,7 +90,7 @@ function SectionsEditor() {
|
||||||
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
|
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
|
||||||
$('#heading').textContent = t('editStyleHeading');
|
$('#heading').textContent = t('editStyleHeading');
|
||||||
}
|
}
|
||||||
livePreview.show(Boolean(style.id));
|
editor.livePreview.toggle(Boolean(style.id));
|
||||||
updateLivePreview();
|
updateLivePreview();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -117,29 +102,24 @@ function SectionsEditor() {
|
||||||
if (!validate(newStyle)) {
|
if (!validate(newStyle)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newStyle = await API.editSave(newStyle);
|
newStyle = await API.styles.editSave(newStyle);
|
||||||
destroyRemovedSections();
|
destroyRemovedSections();
|
||||||
sessionStore.justEditedStyleId = newStyle.id;
|
sessionStore.justEditedStyleId = newStyle.id;
|
||||||
editor.replaceStyle(newStyle, false);
|
editor.replaceStyle(newStyle, false);
|
||||||
},
|
},
|
||||||
|
|
||||||
scrollToEditor(cm) {
|
scrollToEditor(cm) {
|
||||||
const section = sections.find(s => s.cm === cm).el;
|
const {el} = sections.find(s => s.cm === cm);
|
||||||
const bounds = section.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
if (
|
const h = window.innerHeight;
|
||||||
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
|
if (r.bottom > h && r.top > 0 ||
|
||||||
(bounds.top < 0 && bounds.bottom < window.innerHeight)
|
r.bottom < h && r.top < 0) {
|
||||||
) {
|
window.scrollBy(0, (r.top + r.bottom - h) / 2 | 0);
|
||||||
if (bounds.top < 0) {
|
|
||||||
window.scrollBy(0, bounds.top - 1);
|
|
||||||
} else {
|
|
||||||
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.ready = initSections(style.sections, {pristine: true});
|
editor.ready = initSections(style.sections);
|
||||||
|
|
||||||
/** @param {EditorSection} section */
|
/** @param {EditorSection} section */
|
||||||
function fitToContent(section) {
|
function fitToContent(section) {
|
||||||
|
@ -156,7 +136,7 @@ function SectionsEditor() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (headerOffset == null) {
|
if (headerOffset == null) {
|
||||||
headerOffset = container.getBoundingClientRect().top;
|
headerOffset = container.getBoundingClientRect().top + scrollY | 0;
|
||||||
}
|
}
|
||||||
contentHeight += 9; // border & resize grip
|
contentHeight += 9; // border & resize grip
|
||||||
cm.off('update', resize);
|
cm.off('update', resize);
|
||||||
|
@ -194,13 +174,13 @@ function SectionsEditor() {
|
||||||
progressElement.title = progress + '%';
|
progressElement.title = progress + '%';
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$.remove(progressElement);
|
$remove(progressElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToMozillaHelp(event) {
|
function showToMozillaHelp(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
|
helpPopup.show(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -336,7 +316,7 @@ function SectionsEditor() {
|
||||||
|
|
||||||
function showMozillaFormat() {
|
function showMozillaFormat() {
|
||||||
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
|
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
|
||||||
popup.codebox.setValue(sectionsToMozFormat(getModel()));
|
popup.codebox.setValue(MozDocMapper.styleToCss(getModel()));
|
||||||
popup.codebox.execCommand('selectAll');
|
popup.codebox.execCommand('selectAll');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -378,22 +358,21 @@ function SectionsEditor() {
|
||||||
lockPageUI(true);
|
lockPageUI(true);
|
||||||
try {
|
try {
|
||||||
const code = popup.codebox.getValue().trim();
|
const code = popup.codebox.getValue().trim();
|
||||||
if (!/==userstyle==/i.test(code) ||
|
if (!URLS.rxMETA.test(code) ||
|
||||||
!await getPreprocessor(code) ||
|
!await getPreprocessor(code) ||
|
||||||
await messageBox.confirm(
|
await messageBoxProxy.confirm(
|
||||||
t('importPreprocessor'), 'pre-line',
|
t('importPreprocessor'), 'pre-line',
|
||||||
t('importPreprocessorTitle'))
|
t('importPreprocessorTitle'))
|
||||||
) {
|
) {
|
||||||
const {sections, errors} = await API.parseCss({code});
|
const {sections, errors} = await API.worker.parseMozFormat({code});
|
||||||
// shouldn't happen but just in case
|
if (!sections.length || errors.some(e => !e.recoverable)) {
|
||||||
if (!sections.length || errors.length) {
|
await Promise.reject(errors);
|
||||||
throw errors;
|
|
||||||
}
|
}
|
||||||
await initSections(sections, {
|
await initSections(sections, {
|
||||||
replace: replaceOldStyle,
|
replace: replaceOldStyle,
|
||||||
focusOn: replaceOldStyle ? 0 : false,
|
focusOn: replaceOldStyle ? 0 : false,
|
||||||
});
|
});
|
||||||
$('.dismiss').dispatchEvent(new Event('click'));
|
helpPopup.close();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
|
@ -403,7 +382,7 @@ function SectionsEditor() {
|
||||||
|
|
||||||
async function getPreprocessor(code) {
|
async function getPreprocessor(code) {
|
||||||
try {
|
try {
|
||||||
return (await API.buildUsercssMeta({sourceCode: code})).usercssData.preprocessor;
|
return (await API.usercss.buildMeta({sourceCode: code})).usercssData.preprocessor;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -417,10 +396,12 @@ function SectionsEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(errors) {
|
function showError(errors) {
|
||||||
messageBox({
|
messageBoxProxy.show({
|
||||||
className: 'center danger',
|
className: 'center danger',
|
||||||
title: t('styleFromMozillaFormatError'),
|
title: t('styleFromMozillaFormatError'),
|
||||||
contents: $create('pre', Array.isArray(errors) ? errors.join('\n') : errors),
|
contents: $create('pre',
|
||||||
|
(Array.isArray(errors) ? errors : [errors])
|
||||||
|
.map(e => e.message || e).join('\n')),
|
||||||
buttons: [t('confirmClose')],
|
buttons: [t('confirmClose')],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -432,7 +413,7 @@ function SectionsEditor() {
|
||||||
sectionOrder = validSections.map(s => s.id).join(',');
|
sectionOrder = validSections.map(s => s.id).join(',');
|
||||||
dirty.modify('sectionOrder', oldOrder, sectionOrder);
|
dirty.modify('sectionOrder', oldOrder, sectionOrder);
|
||||||
container.dataset.sectionCount = validSections.length;
|
container.dataset.sectionCount = validSections.length;
|
||||||
linter.refreshReport();
|
linterMan.refreshReport();
|
||||||
editor.updateToc();
|
editor.updateToc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -445,7 +426,7 @@ function SectionsEditor() {
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
if (!$('#name').reportValidity()) {
|
if (!$('#name').reportValidity()) {
|
||||||
messageBox.alert(t('styleMissingName'));
|
messageBoxProxy.alert(t('styleMissingName'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
|
@ -454,7 +435,7 @@ function SectionsEditor() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!apply.valueEl.reportValidity()) {
|
if (!apply.valueEl.reportValidity()) {
|
||||||
messageBox.alert(t('styleBadRegexp'));
|
messageBoxProxy.alert(t('styleBadRegexp'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -486,13 +467,12 @@ function SectionsEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLivePreviewNow() {
|
function updateLivePreviewNow() {
|
||||||
livePreview.update(getModel());
|
editor.livePreview.update(getModel());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initSections(src, {
|
async function initSections(src, {
|
||||||
focusOn = 0,
|
focusOn = 0,
|
||||||
replace = false,
|
replace = false,
|
||||||
pristine = false,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (replace) {
|
if (replace) {
|
||||||
sections.forEach(s => s.remove(true));
|
sections.forEach(s => s.remove(true));
|
||||||
|
@ -504,6 +484,8 @@ function SectionsEditor() {
|
||||||
si.scrollY2 = si.scrollY + window.innerHeight;
|
si.scrollY2 = si.scrollY + window.innerHeight;
|
||||||
container.style.height = si.scrollY2 + 'px';
|
container.style.height = si.scrollY2 + 'px';
|
||||||
scrollTo(0, si.scrollY);
|
scrollTo(0, si.scrollY);
|
||||||
|
// only restore focus if it's the first CM to avoid derpy quirks
|
||||||
|
focusOn = si.cms[0].focus && 0;
|
||||||
rerouteHotkeys(true);
|
rerouteHotkeys(true);
|
||||||
} else {
|
} else {
|
||||||
si = null;
|
si = null;
|
||||||
|
@ -523,8 +505,8 @@ function SectionsEditor() {
|
||||||
if (si) forceRefresh = y < si.scrollY2 && (y += si.cms[i].parentHeight) > si.scrollY;
|
if (si) forceRefresh = y < si.scrollY2 && (y += si.cms[i].parentHeight) > si.scrollY;
|
||||||
insertSectionAfter(src[i], null, forceRefresh, si && si.cms[i]);
|
insertSectionAfter(src[i], null, forceRefresh, si && si.cms[i]);
|
||||||
setGlobalProgress(i, src.length);
|
setGlobalProgress(i, src.length);
|
||||||
if (pristine) dirty.clear();
|
dirty.clear();
|
||||||
if (i === focusOn && !si) sections[i].cm.focus();
|
if (i === focusOn) sections[i].cm.focus();
|
||||||
}
|
}
|
||||||
if (!si) requestAnimationFrame(fitToAvailableSpace);
|
if (!si) requestAnimationFrame(fitToAvailableSpace);
|
||||||
container.style.removeProperty('height');
|
container.style.removeProperty('height');
|
||||||
|
@ -626,7 +608,7 @@ function SectionsEditor() {
|
||||||
/** @param {EditorSection} section */
|
/** @param {EditorSection} section */
|
||||||
function registerEvents(section) {
|
function registerEvents(section) {
|
||||||
const {el, cm} = section;
|
const {el, cm} = section;
|
||||||
$('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp'));
|
$('.applies-to-help', el).onclick = () => helpPopup.show(t('appliesLabel'), t('appliesHelp'));
|
||||||
$('.remove-section', el).onclick = () => removeSection(section);
|
$('.remove-section', el).onclick = () => removeSection(section);
|
||||||
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
|
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
|
||||||
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
|
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
|
||||||
|
@ -642,8 +624,8 @@ function SectionsEditor() {
|
||||||
function maybeImportOnPaste(cm, event) {
|
function maybeImportOnPaste(cm, event) {
|
||||||
const text = event.clipboardData.getData('text') || '';
|
const text = event.clipboardData.getData('text') || '';
|
||||||
if (/@-moz-document/i.test(text) &&
|
if (/@-moz-document/i.test(text) &&
|
||||||
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
|
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
|
||||||
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
|
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
showMozillaFormatImport(text);
|
showMozillaFormatImport(text);
|
||||||
|
@ -652,7 +634,7 @@ function SectionsEditor() {
|
||||||
|
|
||||||
function refreshOnView(cm, {code, force} = {}) {
|
function refreshOnView(cm, {code, force} = {}) {
|
||||||
if (code) {
|
if (code) {
|
||||||
linter.enableForEditor(cm, code);
|
linterMan.enableForEditor(cm, code);
|
||||||
}
|
}
|
||||||
if (force || !xo) {
|
if (force || !xo) {
|
||||||
refreshOnViewNow(cm);
|
refreshOnViewNow(cm);
|
||||||
|
@ -678,7 +660,7 @@ function SectionsEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOnViewNow(cm) {
|
async function refreshOnViewNow(cm) {
|
||||||
linter.enableForEditor(cm);
|
linterMan.enableForEditor(cm);
|
||||||
cm.refresh();
|
cm.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,13 @@
|
||||||
/* global
|
/* global $$ $create */// dom.js
|
||||||
$
|
/* global CodeMirror */
|
||||||
$$
|
/* global helpPopup */// util.js
|
||||||
$create
|
/* global prefs */
|
||||||
CodeMirror
|
/* global stringAsRegExp */// toolbox.js
|
||||||
onDOMready
|
/* global t */// localization.js
|
||||||
prefs
|
|
||||||
showHelp
|
|
||||||
stringAsRegExp
|
|
||||||
t
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
onDOMready().then(() => {
|
/* exported showKeymapHelp */
|
||||||
$('#keyMap-help').addEventListener('click', showKeyMapHelp);
|
function showKeymapHelp() {
|
||||||
});
|
|
||||||
|
|
||||||
function showKeyMapHelp() {
|
|
||||||
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
|
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
|
||||||
const keyMapSorted = Object.keys(keyMap)
|
const keyMapSorted = Object.keys(keyMap)
|
||||||
.map(key => ({key, cmd: keyMap[key]}))
|
.map(key => ({key, cmd: keyMap[key]}))
|
||||||
|
@ -32,17 +24,19 @@ function showKeyMapHelp() {
|
||||||
tBody.appendChild(row.cloneNode(true));
|
tBody.appendChild(row.cloneNode(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
|
helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
|
||||||
|
|
||||||
const inputs = $$('input', table);
|
const inputs = $$('input', table);
|
||||||
inputs[0].addEventListener('keydown', hotkeyHandler);
|
inputs[0].on('keydown', hotkeyHandler);
|
||||||
inputs[1].focus();
|
inputs[1].focus();
|
||||||
|
|
||||||
table.oninput = filterTable;
|
table.oninput = filterTable;
|
||||||
|
|
||||||
function hotkeyHandler(event) {
|
function hotkeyHandler(event) {
|
||||||
const keyName = CodeMirror.keyName(event);
|
const keyName = CodeMirror.keyName(event);
|
||||||
if (keyName === 'Esc' || keyName === 'Tab' || keyName === 'Shift-Tab') {
|
if (keyName === 'Esc' ||
|
||||||
|
keyName === 'Tab' ||
|
||||||
|
keyName === 'Shift-Tab') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -90,6 +84,7 @@ function showKeyMapHelp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeKeyMaps(merged, ...more) {
|
function mergeKeyMaps(merged, ...more) {
|
||||||
more.forEach(keyMap => {
|
more.forEach(keyMap => {
|
||||||
if (typeof keyMap === 'string') {
|
if (typeof keyMap === 'string') {
|
||||||
|
@ -102,7 +97,7 @@ function showKeyMapHelp() {
|
||||||
if (typeof cmd === 'function') {
|
if (typeof cmd === 'function') {
|
||||||
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body)
|
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body)
|
||||||
// for 'vim*' keymaps: almost nothing as it doesn't rely on CM keymap mechanism
|
// for 'vim*' keymaps: almost nothing as it doesn't rely on CM keymap mechanism
|
||||||
cmd = cmd.toString().replace(/^function.*?\{[\s\r\n]*([\s\S]+?)[\s\r\n]*\}$/, '$1');
|
cmd = cmd.toString().replace(/^function.*?{[\s\r\n]*([\s\S]+?)[\s\r\n]*}$/, '$1');
|
||||||
merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
|
merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
|
||||||
} else {
|
} else {
|
||||||
merged[key] = cmd;
|
merged[key] = cmd;
|
||||||
|
|
|
@ -1,36 +1,26 @@
|
||||||
/* global
|
/* global $ $$remove $create $isTextInput messageBoxProxy */// dom.js
|
||||||
$
|
/* global API */// msg.js
|
||||||
$$
|
/* global CodeMirror */
|
||||||
$create
|
/* global MozDocMapper */// util.js
|
||||||
API
|
/* global MozSectionFinder */
|
||||||
chromeSync
|
/* global MozSectionWidget */
|
||||||
cmFactory
|
/* global URLS debounce sessionStore */// toolbox.js
|
||||||
CodeMirror
|
/* global chromeSync */// storage-util.js
|
||||||
createLivePreview
|
/* global cmFactory */
|
||||||
createMetaCompiler
|
/* global editor */
|
||||||
debounce
|
/* global linterMan */
|
||||||
editor
|
/* global prefs */
|
||||||
linter
|
/* global t */// localization.js
|
||||||
messageBox
|
|
||||||
MozSectionFinder
|
|
||||||
MozSectionWidget
|
|
||||||
prefs
|
|
||||||
sectionsToMozFormat
|
|
||||||
sessionStore
|
|
||||||
t
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* exported SourceEditor */
|
/* exported SourceEditor */
|
||||||
|
|
||||||
function SourceEditor() {
|
function SourceEditor() {
|
||||||
const {style, dirty} = editor;
|
const {style, /** @type DirtyReporter */dirty} = editor;
|
||||||
let savedGeneration;
|
let savedGeneration;
|
||||||
let placeholderName = '';
|
let placeholderName = '';
|
||||||
let prevMode = NaN;
|
let prevMode = NaN;
|
||||||
|
|
||||||
$$.remove('.sectioned-only');
|
$$remove('.sectioned-only');
|
||||||
$('#header').on('wheel', headerOnScroll);
|
$('#header').on('wheel', headerOnScroll);
|
||||||
$('#sections').textContent = '';
|
$('#sections').textContent = '';
|
||||||
$('#sections').appendChild($create('.single-editor'));
|
$('#sections').appendChild($create('.single-editor'));
|
||||||
|
@ -39,16 +29,26 @@ function SourceEditor() {
|
||||||
|
|
||||||
const cm = cmFactory.create($('.single-editor'));
|
const cm = cmFactory.create($('.single-editor'));
|
||||||
const sectionFinder = MozSectionFinder(cm);
|
const sectionFinder = MozSectionFinder(cm);
|
||||||
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc);
|
const sectionWidget = MozSectionWidget(cm, sectionFinder);
|
||||||
const livePreview = createLivePreview(preprocess, style.id);
|
editor.livePreview.init(preprocess, style.id);
|
||||||
/** @namespace SourceEditor */
|
createMetaCompiler(meta => {
|
||||||
|
style.usercssData = meta;
|
||||||
|
style.name = meta.name;
|
||||||
|
style.url = meta.homepageURL || style.installationUrl;
|
||||||
|
updateMeta();
|
||||||
|
});
|
||||||
|
updateMeta();
|
||||||
|
cm.setValue(style.sourceCode);
|
||||||
|
|
||||||
|
/** @namespace Editor */
|
||||||
Object.assign(editor, {
|
Object.assign(editor, {
|
||||||
sections: sectionFinder.sections,
|
sections: sectionFinder.sections,
|
||||||
replaceStyle,
|
replaceStyle,
|
||||||
|
updateLivePreview,
|
||||||
|
closestVisible: () => cm,
|
||||||
getEditors: () => [cm],
|
getEditors: () => [cm],
|
||||||
scrollToEditor: () => {},
|
|
||||||
getEditorTitle: () => '',
|
getEditorTitle: () => '',
|
||||||
save,
|
getSearchableInputs: () => [],
|
||||||
prevEditor: nextPrevSection.bind(null, -1),
|
prevEditor: nextPrevSection.bind(null, -1),
|
||||||
nextEditor: nextPrevSection.bind(null, 1),
|
nextEditor: nextPrevSection.bind(null, 1),
|
||||||
jumpToEditor(i) {
|
jumpToEditor(i) {
|
||||||
|
@ -58,23 +58,37 @@ function SourceEditor() {
|
||||||
cm.jumpToPos(sec.start);
|
cm.jumpToPos(sec.start);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
closestVisible: () => cm,
|
async save() {
|
||||||
getSearchableInputs: () => [],
|
if (!dirty.isDirty()) return;
|
||||||
updateLivePreview,
|
const sourceCode = cm.getValue();
|
||||||
|
try {
|
||||||
|
const {customName, enabled, id} = style;
|
||||||
|
if (!id &&
|
||||||
|
(await API.usercss.build({sourceCode, checkDup: true, metaOnly: true})).dup) {
|
||||||
|
messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
|
||||||
|
} else {
|
||||||
|
await replaceStyle(
|
||||||
|
await API.usercss.editSave({customName, enabled, id, sourceCode}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const i = err.index;
|
||||||
|
const isNameEmpty = i > 0 &&
|
||||||
|
err.code === 'missingValue' &&
|
||||||
|
sourceCode.slice(sourceCode.lastIndexOf('\n', i - 1), i).trim().endsWith('@name');
|
||||||
|
return isNameEmpty
|
||||||
|
? saveTemplate(sourceCode)
|
||||||
|
: showSaveError(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollToEditor: () => {},
|
||||||
});
|
});
|
||||||
createMetaCompiler(cm, meta => {
|
|
||||||
style.usercssData = meta;
|
|
||||||
style.name = meta.name;
|
|
||||||
style.url = meta.homepageURL || style.installationUrl;
|
|
||||||
updateMeta();
|
|
||||||
});
|
|
||||||
updateMeta();
|
|
||||||
cm.setValue(style.sourceCode);
|
|
||||||
prefs.subscribeMany({
|
prefs.subscribeMany({
|
||||||
'editor.linter': updateLinterSwitch,
|
'editor.linter': updateLinterSwitch,
|
||||||
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
|
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
|
||||||
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
|
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
|
||||||
}, {now: true});
|
}, {runNow: true});
|
||||||
|
|
||||||
editor.applyScrollInfo(cm);
|
editor.applyScrollInfo(cm);
|
||||||
cm.clearHistory();
|
cm.clearHistory();
|
||||||
cm.markClean();
|
cm.markClean();
|
||||||
|
@ -88,31 +102,29 @@ function SourceEditor() {
|
||||||
const mode = getModeName();
|
const mode = getModeName();
|
||||||
if (mode === prevMode) return;
|
if (mode === prevMode) return;
|
||||||
prevMode = mode;
|
prevMode = mode;
|
||||||
linter.run();
|
linterMan.run();
|
||||||
updateLinterSwitch();
|
updateLinterSwitch();
|
||||||
});
|
});
|
||||||
setTimeout(linter.enableForEditor, 0, cm);
|
setTimeout(linterMan.enableForEditor, 0, cm);
|
||||||
if (!$.isTextInput(document.activeElement)) {
|
if (!$isTextInput(document.activeElement)) {
|
||||||
cm.focus();
|
cm.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function preprocess(style) {
|
async function preprocess(style) {
|
||||||
return API.buildUsercss({
|
const {style: newStyle} = await API.usercss.build({
|
||||||
styleId: style.id,
|
styleId: style.id,
|
||||||
sourceCode: style.sourceCode,
|
sourceCode: style.sourceCode,
|
||||||
assignVars: true,
|
assignVars: true,
|
||||||
})
|
});
|
||||||
.then(({style: newStyle}) => {
|
delete newStyle.enabled;
|
||||||
delete newStyle.enabled;
|
return Object.assign(style, newStyle);
|
||||||
return Object.assign(style, newStyle);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLivePreview() {
|
function updateLivePreview() {
|
||||||
if (!style.id) {
|
if (!style.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
|
editor.livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLinterSwitch() {
|
function updateLinterSwitch() {
|
||||||
|
@ -140,10 +152,10 @@ function SourceEditor() {
|
||||||
async function setupNewStyle(style) {
|
async function setupNewStyle(style) {
|
||||||
style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
|
style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
|
||||||
`/* ${t('usercssReplaceTemplateSectionBody')} */`;
|
`/* ${t('usercssReplaceTemplateSectionBody')} */`;
|
||||||
let section = sectionsToMozFormat(style);
|
let section = MozDocMapper.styleToCss(style);
|
||||||
if (!section.includes('@-moz-document')) {
|
if (!section.includes('@-moz-document')) {
|
||||||
style.sections[0].domains = ['example.com'];
|
style.sections[0].domains = ['example.com'];
|
||||||
section = sectionsToMozFormat(style);
|
section = MozDocMapper.styleToCss(style);
|
||||||
}
|
}
|
||||||
const DEFAULT_CODE = `
|
const DEFAULT_CODE = `
|
||||||
/* ==UserStyle==
|
/* ==UserStyle==
|
||||||
|
@ -199,7 +211,7 @@ function SourceEditor() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.resolve(messageBox.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
|
Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
updateEnvironment();
|
updateEnvironment();
|
||||||
if (!sameCode) {
|
if (!sameCode) {
|
||||||
|
@ -223,77 +235,29 @@ function SourceEditor() {
|
||||||
Object.assign(style, newStyle);
|
Object.assign(style, newStyle);
|
||||||
$('#preview-label').classList.remove('hidden');
|
$('#preview-label').classList.remove('hidden');
|
||||||
updateMeta();
|
updateMeta();
|
||||||
livePreview.show(Boolean(style.id));
|
editor.livePreview.toggle(Boolean(style.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
async function saveTemplate(code) {
|
||||||
if (!dirty.isDirty()) return;
|
if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
|
||||||
const code = cm.getValue();
|
const key = chromeSync.LZ_KEY.usercssTemplate;
|
||||||
return ensureUniqueStyle(code)
|
await chromeSync.setLZValue(key, code);
|
||||||
.then(() => API.editSaveUsercss({
|
if (await chromeSync.getLZValue(key) !== code) {
|
||||||
id: style.id,
|
messageBoxProxy.alert(t('syncStorageErrorSaving'));
|
||||||
enabled: style.enabled,
|
}
|
||||||
sourceCode: code,
|
}
|
||||||
customName: style.customName,
|
|
||||||
}))
|
|
||||||
.then(replaceStyle)
|
|
||||||
.catch(err => {
|
|
||||||
if (err.handled) return;
|
|
||||||
const contents = Array.isArray(err) ?
|
|
||||||
$create('pre', err.join('\n')) :
|
|
||||||
[err.message || String(err)];
|
|
||||||
if (Number.isInteger(err.index)) {
|
|
||||||
const pos = cm.posFromIndex(err.index);
|
|
||||||
const meta = drawLinePointer(pos);
|
|
||||||
|
|
||||||
// save template
|
|
||||||
if (err.code === 'missingValue' && meta.includes('@name')) {
|
|
||||||
const key = chromeSync.LZ_KEY.usercssTemplate;
|
|
||||||
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
|
|
||||||
chromeSync.setLZValue(key, code)
|
|
||||||
.then(() => chromeSync.getLZValue(key))
|
|
||||||
.then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving'))));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
|
|
||||||
contents.push($create('pre', meta));
|
|
||||||
}
|
|
||||||
messageBox.alert(contents, 'pre');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureUniqueStyle(code) {
|
function showSaveError(err) {
|
||||||
return style.id ? Promise.resolve() :
|
err = Array.isArray(err) ? err : [err];
|
||||||
API.buildUsercss({
|
const text = err.map(e => e.message || e).join('\n');
|
||||||
sourceCode: code,
|
const points = err.map(e =>
|
||||||
checkDup: true,
|
e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
|
||||||
metaOnly: true,
|
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1} // csslint code parser
|
||||||
}).then(({dup}) => {
|
).filter(Boolean);
|
||||||
if (dup) {
|
cm.setSelections(points.map(p => ({anchor: p, head: p})));
|
||||||
messageBox.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
|
messageBoxProxy.alert($create('pre', text), 'pre');
|
||||||
return Promise.reject({handled: true});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawLinePointer(pos) {
|
|
||||||
const SIZE = 60;
|
|
||||||
const line = cm.getLine(pos.line);
|
|
||||||
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
|
|
||||||
const pointer = ' '.repeat(pos.ch) + '^';
|
|
||||||
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
|
|
||||||
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
|
|
||||||
const leftPad = start !== 0 ? '...' : '';
|
|
||||||
const rightPad = end !== line.length ? '...' : '';
|
|
||||||
return (
|
|
||||||
leftPad +
|
|
||||||
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
|
|
||||||
rightPad +
|
|
||||||
'\n' +
|
|
||||||
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
|
|
||||||
pointer.slice(start, end)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextPrevSection(dir) {
|
function nextPrevSection(dir) {
|
||||||
|
@ -334,4 +298,36 @@ function SourceEditor() {
|
||||||
return (mode.name || mode || '') +
|
return (mode.name || mode || '') +
|
||||||
(mode.helperType || '');
|
(mode.helperType || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMetaCompiler(onUpdated) {
|
||||||
|
let meta = null;
|
||||||
|
let metaIndex = null;
|
||||||
|
let cache = [];
|
||||||
|
linterMan.register(async (text, options, _cm) => {
|
||||||
|
if (_cm !== cm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = text.match(URLS.rxMETA);
|
||||||
|
if (!match) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (match[0] === meta && match.index === metaIndex) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
const {metadata, errors} = await linterMan.worker.metalint(match[0]);
|
||||||
|
if (errors.every(err => err.code === 'unknownMeta')) {
|
||||||
|
onUpdated(metadata);
|
||||||
|
}
|
||||||
|
cache = errors.map(err => ({
|
||||||
|
from: cm.posFromIndex((err.index || 0) + match.index),
|
||||||
|
to: cm.posFromIndex((err.index || 0) + match.index),
|
||||||
|
message: err.code && t(`meta_${err.code}`, err.args, false) || err.message,
|
||||||
|
severity: err.code === 'unknownMeta' ? 'warning' : 'error',
|
||||||
|
rule: err.code,
|
||||||
|
}));
|
||||||
|
meta = match[0];
|
||||||
|
metaIndex = match.index;
|
||||||
|
return cache;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
303
edit/util.js
303
edit/util.js
|
@ -1,178 +1,109 @@
|
||||||
/* global
|
/* global $ $create getEventKeyName messageBoxProxy moveFocus */// dom.js
|
||||||
$create
|
/* global CodeMirror */
|
||||||
CodeMirror
|
/* global debounce */// toolbox.js
|
||||||
prefs
|
/* global editor */
|
||||||
*/
|
/* global prefs */
|
||||||
|
/* global t */// localization.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/* exported DirtyReporter */
|
const helpPopup = {
|
||||||
class DirtyReporter {
|
|
||||||
constructor() {
|
|
||||||
this._dirty = new Map();
|
|
||||||
this._onchange = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
add(obj, value) {
|
show(title = '', body) {
|
||||||
const wasDirty = this.isDirty();
|
const div = $('#help-popup');
|
||||||
const saved = this._dirty.get(obj);
|
const contents = $('.contents', div);
|
||||||
if (!saved) {
|
div.className = '';
|
||||||
this._dirty.set(obj, {type: 'add', newValue: value});
|
contents.textContent = '';
|
||||||
} else if (saved.type === 'remove') {
|
if (body) {
|
||||||
if (saved.savedValue === value) {
|
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
|
||||||
this._dirty.delete(obj);
|
|
||||||
} else {
|
|
||||||
saved.newValue = value;
|
|
||||||
saved.type = 'modify';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.notifyChange(wasDirty);
|
$('.title', div).textContent = title;
|
||||||
}
|
$('.dismiss', div).onclick = helpPopup.close;
|
||||||
|
window.on('keydown', helpPopup.close, true);
|
||||||
remove(obj, value) {
|
// reset any inline styles
|
||||||
const wasDirty = this.isDirty();
|
div.style = 'display: block';
|
||||||
const saved = this._dirty.get(obj);
|
helpPopup.originalFocus = document.activeElement;
|
||||||
if (!saved) {
|
return div;
|
||||||
this._dirty.set(obj, {type: 'remove', savedValue: value});
|
|
||||||
} else if (saved.type === 'add') {
|
|
||||||
this._dirty.delete(obj);
|
|
||||||
} else if (saved.type === 'modify') {
|
|
||||||
saved.type = 'remove';
|
|
||||||
}
|
|
||||||
this.notifyChange(wasDirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
modify(obj, oldValue, newValue) {
|
|
||||||
const wasDirty = this.isDirty();
|
|
||||||
const saved = this._dirty.get(obj);
|
|
||||||
if (!saved) {
|
|
||||||
if (oldValue !== newValue) {
|
|
||||||
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
|
||||||
}
|
|
||||||
} else if (saved.type === 'modify') {
|
|
||||||
if (saved.savedValue === newValue) {
|
|
||||||
this._dirty.delete(obj);
|
|
||||||
} else {
|
|
||||||
saved.newValue = newValue;
|
|
||||||
}
|
|
||||||
} else if (saved.type === 'add') {
|
|
||||||
saved.newValue = newValue;
|
|
||||||
}
|
|
||||||
this.notifyChange(wasDirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(obj) {
|
|
||||||
const wasDirty = this.isDirty();
|
|
||||||
if (obj === undefined) {
|
|
||||||
this._dirty.clear();
|
|
||||||
} else {
|
|
||||||
this._dirty.delete(obj);
|
|
||||||
}
|
|
||||||
this.notifyChange(wasDirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
isDirty() {
|
|
||||||
return this._dirty.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(cb, add = true) {
|
|
||||||
this._onchange[add ? 'add' : 'delete'](cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyChange(wasDirty) {
|
|
||||||
if (wasDirty !== this.isDirty()) {
|
|
||||||
this._onchange.forEach(cb => cb());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
has(key) {
|
|
||||||
return this._dirty.has(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* exported DocFuncMapper */
|
|
||||||
const DocFuncMapper = {
|
|
||||||
TO_CSS: {
|
|
||||||
urls: 'url',
|
|
||||||
urlPrefixes: 'url-prefix',
|
|
||||||
domains: 'domain',
|
|
||||||
regexps: 'regexp',
|
|
||||||
},
|
},
|
||||||
FROM_CSS: {
|
|
||||||
'url': 'urls',
|
close(event) {
|
||||||
'url-prefix': 'urlPrefixes',
|
const canClose =
|
||||||
'domain': 'domains',
|
!event ||
|
||||||
'regexp': 'regexps',
|
event.type === 'click' || (
|
||||||
},
|
getEventKeyName(event) === 'Escape' &&
|
||||||
/**
|
!$('.CodeMirror-hints, #message-box') && (
|
||||||
* @param {Object} section
|
!document.activeElement ||
|
||||||
* @param {function(func:string, value:string)} fn
|
!document.activeElement.closest('#search-replace-dialog') &&
|
||||||
*/
|
document.activeElement.matches(':not(input), .can-close-on-esc')
|
||||||
forEachProp(section, fn) {
|
)
|
||||||
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
|
);
|
||||||
const props = section[propName];
|
const div = $('#help-popup');
|
||||||
if (props) props.forEach(value => fn(func, value));
|
if (!canClose || !div) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
},
|
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
|
||||||
/**
|
setTimeout(async () => {
|
||||||
* @param {Array<?[type,value]>} funcItems
|
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
|
||||||
* @param {?Object} [section]
|
return ok && helpPopup.close();
|
||||||
* @returns {Object} section
|
});
|
||||||
*/
|
return;
|
||||||
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;
|
if (div.contains(document.activeElement) && helpPopup.originalFocus) {
|
||||||
|
helpPopup.originalFocus.focus();
|
||||||
|
}
|
||||||
|
const contents = $('.contents', div);
|
||||||
|
div.style.display = '';
|
||||||
|
contents.textContent = '';
|
||||||
|
window.off('keydown', helpPopup.close, true);
|
||||||
|
window.dispatchEvent(new Event('closeHelp'));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* exported sectionsToMozFormat */
|
// reroute handling to nearest editor when keypress resolves to one of these commands
|
||||||
function sectionsToMozFormat(style) {
|
Object.assign(rerouteHotkeys, {
|
||||||
return style.sections.map(section => {
|
commands: [
|
||||||
const cssFuncs = [];
|
'beautify',
|
||||||
DocFuncMapper.forEachProp(section, (type, value) =>
|
'colorpicker',
|
||||||
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
|
'find',
|
||||||
return cssFuncs.length ?
|
'findNext',
|
||||||
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
|
'findPrev',
|
||||||
section.code;
|
'jumpToLine',
|
||||||
}).join('\n\n');
|
'nextEditor',
|
||||||
}
|
'prevEditor',
|
||||||
|
'replace',
|
||||||
|
'replaceAll',
|
||||||
|
'save',
|
||||||
|
'toggleEditorFocus',
|
||||||
|
'toggleStyle',
|
||||||
|
],
|
||||||
|
|
||||||
/* exported trimCommentLabel */
|
toggle(enable) {
|
||||||
function trimCommentLabel(str, limit = 1000) {
|
document[enable ? 'on' : 'off']('keydown', rerouteHotkeys.handler);
|
||||||
// stripping /*** foo ***/ to foo
|
},
|
||||||
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
|
|
||||||
}
|
handler(event) {
|
||||||
|
const keyName = CodeMirror.keyName(event);
|
||||||
|
if (!keyName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rerouteCommand = name => {
|
||||||
|
if (rerouteHotkeys.commands.includes(name)) {
|
||||||
|
CodeMirror.commands[name](editor.closestVisible(event.target));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
|
||||||
|
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/* exported clipString */
|
|
||||||
function clipString(str, limit = 100) {
|
function clipString(str, limit = 100) {
|
||||||
return str.length <= limit ? str : str.substr(0, limit) + '...';
|
return str.length <= limit ? str : str.substr(0, limit) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* exported memoize */
|
|
||||||
function memoize(fn) {
|
|
||||||
let cached = false;
|
|
||||||
let result;
|
|
||||||
return (...args) => {
|
|
||||||
if (!cached) {
|
|
||||||
result = fn(...args);
|
|
||||||
cached = true;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* exported createHotkeyInput */
|
/* exported createHotkeyInput */
|
||||||
/**
|
|
||||||
* @param {!string} prefId
|
|
||||||
* @param {?function(isEnter:boolean)} onDone
|
|
||||||
*/
|
|
||||||
function createHotkeyInput(prefId, onDone = () => {}) {
|
function createHotkeyInput(prefId, onDone = () => {}) {
|
||||||
return $create('input', {
|
return $create('input', {
|
||||||
type: 'search',
|
type: 'search',
|
||||||
|
@ -217,3 +148,59 @@ function createHotkeyInput(prefId, onDone = () => {}) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rerouteHotkeys(enable, immediately) {
|
||||||
|
if (immediately) {
|
||||||
|
rerouteHotkeys.toggle(enable);
|
||||||
|
} else {
|
||||||
|
debounce(rerouteHotkeys.toggle, 0, enable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* exported showCodeMirrorPopup */
|
||||||
|
function showCodeMirrorPopup(title, html, options) {
|
||||||
|
const popup = helpPopup.show(title, html);
|
||||||
|
popup.classList.add('big');
|
||||||
|
|
||||||
|
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
|
||||||
|
mode: 'css',
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: prefs.get('editor.lineWrapping'),
|
||||||
|
foldGutter: true,
|
||||||
|
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||||
|
matchBrackets: true,
|
||||||
|
styleActiveLine: true,
|
||||||
|
theme: prefs.get('editor.theme'),
|
||||||
|
keyMap: prefs.get('editor.keyMap'),
|
||||||
|
}, options));
|
||||||
|
cm.focus();
|
||||||
|
rerouteHotkeys(false);
|
||||||
|
|
||||||
|
document.documentElement.style.pointerEvents = 'none';
|
||||||
|
popup.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
const onKeyDown = event => {
|
||||||
|
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||||
|
const search = $('#search-replace-dialog');
|
||||||
|
const area = search && search.contains(document.activeElement) ? search : popup;
|
||||||
|
moveFocus(area, event.shiftKey ? -1 : 1);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.on('keydown', onKeyDown, true);
|
||||||
|
|
||||||
|
window.on('closeHelp', () => {
|
||||||
|
window.off('keydown', onKeyDown, true);
|
||||||
|
document.documentElement.style.removeProperty('pointer-events');
|
||||||
|
rerouteHotkeys(true);
|
||||||
|
cm = popup.codebox = null;
|
||||||
|
}, {once: true});
|
||||||
|
|
||||||
|
return popup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* exported trimCommentLabel */
|
||||||
|
function trimCommentLabel(str, limit = 1000) {
|
||||||
|
// stripping /*** foo ***/ to foo
|
||||||
|
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
|
||||||
|
}
|
||||||
|
|
|
@ -7,44 +7,21 @@
|
||||||
<title>Loading...</title>
|
<title>Loading...</title>
|
||||||
|
|
||||||
<link href="global.css" rel="stylesheet">
|
<link href="global.css" rel="stylesheet">
|
||||||
<link href="install-usercss/install-usercss.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<script src="js/polyfill.js"></script>
|
<script src="js/polyfill.js"></script>
|
||||||
<script src="js/msg.js"></script>
|
<script src="js/msg.js"></script>
|
||||||
<script src="js/messaging.js"></script>
|
<script src="js/toolbox.js"></script>
|
||||||
|
|
||||||
|
<script src="install-usercss/preinit.js"></script>
|
||||||
|
|
||||||
<script src="js/prefs.js"></script>
|
<script src="js/prefs.js"></script>
|
||||||
<script src="js/dom.js"></script>
|
<script src="js/dom.js"></script>
|
||||||
<script src="js/localization.js"></script>
|
<script src="js/localization.js"></script>
|
||||||
<script src="js/script-loader.js"></script>
|
|
||||||
<script src="js/storage-util.js"></script>
|
|
||||||
<script src="content/style-injector.js"></script>
|
<script src="content/style-injector.js"></script>
|
||||||
<script src="content/apply.js"></script>
|
<script src="content/apply.js"></script>
|
||||||
<script src="vendor/semver-bundle/semver.js"></script>
|
|
||||||
|
|
||||||
<link href="msgbox/msgbox.css" rel="stylesheet">
|
<link href="install-usercss/install-usercss.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">
|
|
||||||
</head>
|
</head>
|
||||||
<body id="stylus-install-usercss">
|
<body id="stylus-install-usercss">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -92,13 +69,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="install-usercss/install-usercss.js"></script>
|
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
|
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
|
||||||
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000">
|
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000">
|
||||||
<path fill-rule="evenodd" d="M983.2,184.3L853,69.8c-4-3.5-9.3-5.3-14.5-5c-5.3,0.4-10.3,2.8-13.8,6.8L352.3,609.2L184.4,386.9c-3.2-4.2-8-7-13.2-7.8c-5.3-0.8-10.6,0.6-14.9,3.9L18,487.5c-8.8,6.7-10.6,19.3-3.9,28.1L325,927.2c3.6,4.8,9.3,7.7,15.3,8c0.2,0,0.5,0,0.7,0c5.8,0,11.3-2.5,15.1-6.8L985,212.6C992.3,204.3,991.5,191.6,983.2,184.3z"/>
|
<path fill-rule="evenodd" d="M983.2,184.3L853,69.8c-4-3.5-9.3-5.3-14.5-5c-5.3,0.4-10.3,2.8-13.8,6.8L352.3,609.2L184.4,386.9c-3.2-4.2-8-7-13.2-7.8c-5.3-0.8-10.6,0.6-14.9,3.9L18,487.5c-8.8,6.7-10.6,19.3-3.9,28.1L325,927.2c3.6,4.8,9.3,7.7,15.3,8c0.2,0,0.5,0,0.7,0c5.8,0,11.3-2.5,15.1-6.8L985,212.6C992.3,204.3,991.5,191.6,983.2,184.3z"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<script src="js/dlg/message-box.js"></script>
|
||||||
|
<script src="install-usercss/install-usercss.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -301,6 +301,10 @@ li {
|
||||||
user-select: auto;
|
user-select: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#header.meta-init[data-arrived-fast="true"] > * {
|
||||||
|
transition-duration: .1s;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1,417 +1,394 @@
|
||||||
/* global CodeMirror semverCompare closeCurrentTab messageBox download
|
/* global $ $create $createLink $$remove */
|
||||||
$ $$ $create $createLink t prefs API */
|
/* global API */// msg.js
|
||||||
|
/* global closeCurrentTab */// toolbox.js
|
||||||
|
/* global messageBox */
|
||||||
|
/* global prefs */
|
||||||
|
/* global preinit */
|
||||||
|
/* global t */// localization.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(() => {
|
let cm;
|
||||||
const params = new URLSearchParams(location.search);
|
let initialUrl;
|
||||||
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
|
let installed;
|
||||||
const initialUrl = params.get('updateUrl');
|
let installedDup;
|
||||||
|
let liveReload;
|
||||||
|
let tabId;
|
||||||
|
|
||||||
let installed = null;
|
window.on('resize', adjustCodeHeight);
|
||||||
let installedDup = null;
|
// "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.on('visibilitychange', () => {
|
||||||
|
if (messageBox.element) messageBox.element.remove();
|
||||||
|
if (installed) liveReload.onToggled();
|
||||||
|
});
|
||||||
|
|
||||||
const liveReload = initLiveReload();
|
setTimeout(() => {
|
||||||
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre'));
|
if (!installed) {
|
||||||
|
$('#header').appendChild($create('.lds-spinner',
|
||||||
|
new Array(12).fill($create('div')).map(e => e.cloneNode())));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preinit starts to download as early as possible,
|
||||||
|
* then the critical rendering path scripts are loaded in html,
|
||||||
|
* then the meta of the downloaded code is parsed in the background worker,
|
||||||
|
* then CodeMirror scripts/css are added so they can load while the worker runs in parallel,
|
||||||
|
* then the meta response arrives from API and is immediately displayed in CodeMirror,
|
||||||
|
* then the sections of code are parsed in the background worker and displayed.
|
||||||
|
*/
|
||||||
|
(async function init() {
|
||||||
const theme = prefs.get('editor.theme');
|
const theme = prefs.get('editor.theme');
|
||||||
const cm = CodeMirror($('.main'), {
|
if (theme !== 'default') {
|
||||||
|
require([`/vendor/codemirror/theme/${theme}.css`]); // not awaiting as it may be absent
|
||||||
|
}
|
||||||
|
const scriptsReady = require([
|
||||||
|
'/vendor/codemirror/lib/codemirror', /* global CodeMirror */
|
||||||
|
]).then(() => require([
|
||||||
|
'/vendor/codemirror/keymap/sublime',
|
||||||
|
'/vendor/codemirror/keymap/emacs',
|
||||||
|
'/vendor/codemirror/keymap/vim', // TODO: load conditionally
|
||||||
|
'/vendor/codemirror/mode/css/css',
|
||||||
|
'/vendor/codemirror/addon/search/searchcursor',
|
||||||
|
'/vendor/codemirror/addon/fold/foldcode',
|
||||||
|
'/vendor/codemirror/addon/fold/foldgutter',
|
||||||
|
'/vendor/codemirror/addon/fold/brace-fold',
|
||||||
|
'/vendor/codemirror/addon/fold/indent-fold',
|
||||||
|
'/vendor/codemirror/addon/selection/active-line',
|
||||||
|
'/vendor/codemirror/lib/codemirror.css',
|
||||||
|
'/vendor/codemirror/addon/fold/foldgutter.css',
|
||||||
|
'/vendor/semver-bundle/semver', /* global semverCompare */
|
||||||
|
'/js/sections-util', /* global styleCodeEmpty */
|
||||||
|
'/js/color/color-converter',
|
||||||
|
'/edit/codemirror-default.css',
|
||||||
|
])).then(() => require([
|
||||||
|
'/edit/codemirror-default',
|
||||||
|
'/js/color/color-view',
|
||||||
|
]));
|
||||||
|
|
||||||
|
({tabId, initialUrl} = await preinit);
|
||||||
|
liveReload = initLiveReload();
|
||||||
|
|
||||||
|
const {dup, style, error, sourceCode} = await preinit.ready;
|
||||||
|
if (!style && sourceCode == null) {
|
||||||
|
messageBox.alert(isNaN(error) ? error : 'HTTP Error ' + error, 'pre');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await scriptsReady;
|
||||||
|
cm = CodeMirror($('.main'), {
|
||||||
|
value: sourceCode || style.sourceCode,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
colorpicker: true,
|
colorpicker: true,
|
||||||
theme,
|
theme,
|
||||||
});
|
});
|
||||||
if (theme !== 'default') {
|
if (error) {
|
||||||
document.head.appendChild($create('link', {
|
showBuildError(error);
|
||||||
rel: 'stylesheet',
|
|
||||||
href: `vendor/codemirror/theme/${theme}.css`,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', adjustCodeHeight);
|
if (!style) {
|
||||||
// "History back" in Firefox (for now) restores the old DOM including the messagebox,
|
return;
|
||||||
// which stays after installing since we don't want to wait for the fadeout animation before resolving.
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (messageBox.element) messageBox.element.remove();
|
|
||||||
if (installed) liveReload.onToggled();
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!installed) {
|
|
||||||
$('#header').appendChild($create('.lds-spinner',
|
|
||||||
new Array(12).fill($create('div')).map(e => e.cloneNode())));
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
|
|
||||||
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).forEach(pattern =>
|
|
||||||
$('.applies-to').appendChild($create('li', pattern)));
|
|
||||||
|
|
||||||
$('.external-link').textContent = '';
|
|
||||||
const externalLink = makeExternalLink();
|
|
||||||
if (externalLink) {
|
|
||||||
$('.external-link').appendChild(externalLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#header').classList.add('meta-init');
|
|
||||||
$('#header').classList.remove('meta-init-error');
|
|
||||||
setTimeout(() => $.remove('.lds-spinner'), 1000);
|
|
||||||
|
|
||||||
showError('');
|
|
||||||
requestAnimationFrame(adjustCodeHeight);
|
|
||||||
|
|
||||||
function makeAuthor(text) {
|
|
||||||
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
|
|
||||||
if (!match) {
|
|
||||||
return document.createTextNode(text);
|
|
||||||
}
|
|
||||||
const [, name, email, url] = match;
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
if (email) {
|
|
||||||
frag.appendChild($createLink(`mailto:${email}`, name));
|
|
||||||
} else {
|
|
||||||
frag.appendChild($create('span', name));
|
|
||||||
}
|
|
||||||
if (url) {
|
|
||||||
frag.appendChild($createLink(url,
|
|
||||||
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
|
|
||||||
$create('SVG:path', {
|
|
||||||
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
|
|
||||||
}))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return frag;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeExternalLink() {
|
|
||||||
const urls = [
|
|
||||||
data.homepageURL && [data.homepageURL, t('externalHomepage')],
|
|
||||||
data.supportURL && [data.supportURL, t('externalSupport')],
|
|
||||||
];
|
|
||||||
return (data.homepageURL || data.supportURL) && (
|
|
||||||
$create('div', [
|
|
||||||
$create('h3', t('externalLink')),
|
|
||||||
$create('ul', urls.map(args => args &&
|
|
||||||
$create('li',
|
|
||||||
$createLink(...args)
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const data = style.usercssData;
|
||||||
|
const dupData = dup && dup.usercssData;
|
||||||
|
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||||
|
|
||||||
function showError(err) {
|
updateMeta(style, dup);
|
||||||
$('.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) {
|
// update UI
|
||||||
installed = style;
|
if (versionTest < 0) {
|
||||||
|
$('.actions').parentNode.insertBefore(
|
||||||
$$.remove('.warning');
|
$create('.warning', t('versionInvalidOlder')),
|
||||||
$('button.install').disabled = true;
|
$('.actions')
|
||||||
$('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.buildUsercss({sourceCode, checkDup: true})
|
|
||||||
.then(init)
|
|
||||||
.catch(err => {
|
|
||||||
$('#header').classList.add('meta-init-error');
|
|
||||||
console.error(err);
|
|
||||||
showError(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWarning(err) {
|
|
||||||
const contents = Array.isArray(err) ?
|
|
||||||
[$create('pre', err.join('\n'))] :
|
|
||||||
[err && err.message && $create('pre', err.message) || err || 'Unknown error'];
|
|
||||||
if (Number.isInteger(err.index) && typeof contents[0] === 'string') {
|
|
||||||
const pos = cm.posFromIndex(err.index);
|
|
||||||
contents[0] = `${pos.line + 1}:${pos.ch + 1} ` + contents[0];
|
|
||||||
contents.push($create('pre', drawLinePointer(pos)));
|
|
||||||
setTimeout(() => {
|
|
||||||
cm.scrollIntoView({line: pos.line + 1, ch: pos.ch}, window.innerHeight / 4);
|
|
||||||
cm.setCursor(pos.line, pos.ch + 1);
|
|
||||||
cm.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return $create('.warning', [
|
|
||||||
t('parseUsercssError'),
|
|
||||||
'\n',
|
|
||||||
...contents,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawLinePointer(pos) {
|
|
||||||
const SIZE = 60;
|
|
||||||
const line = cm.getLine(pos.line);
|
|
||||||
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
|
|
||||||
const pointer = ' '.repeat(pos.ch) + '^';
|
|
||||||
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
|
|
||||||
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
|
|
||||||
const leftPad = start !== 0 ? '...' : '';
|
|
||||||
const rightPad = end !== line.length ? '...' : '';
|
|
||||||
return (
|
|
||||||
leftPad +
|
|
||||||
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
|
|
||||||
rightPad +
|
|
||||||
'\n' +
|
|
||||||
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
|
|
||||||
pointer.slice(start, end)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
$('button.install').onclick = () => {
|
||||||
|
(!dup ?
|
||||||
|
Promise.resolve(true) :
|
||||||
|
messageBox.confirm(t('styleInstallOverwrite', [
|
||||||
|
data.name + (dup.customName ? ` (${dup.customName})` : ''),
|
||||||
|
dupData.version,
|
||||||
|
data.version,
|
||||||
|
]))
|
||||||
|
).then(ok => ok &&
|
||||||
|
API.usercss.install(style)
|
||||||
|
.then(install)
|
||||||
|
.catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function init({style, dup}) {
|
// set updateUrl
|
||||||
const data = style.usercssData;
|
const checker = $('.set-update-url input[type=checkbox]');
|
||||||
const dupData = dup && dup.usercssData;
|
const updateUrl = new URL(style.updateUrl || initialUrl);
|
||||||
const versionTest = dup && semverCompare(data.version, dupData.version);
|
if (dup && dup.updateUrl === updateUrl.href) {
|
||||||
|
checker.checked = true;
|
||||||
updateMeta(style, dup);
|
// there is no way to "unset" updateUrl, you can only overwrite it.
|
||||||
|
checker.disabled = true;
|
||||||
// update UI
|
} else if (updateUrl.protocol !== 'file:') {
|
||||||
if (versionTest < 0) {
|
checker.checked = true;
|
||||||
$('.actions').parentNode.insertBefore(
|
style.updateUrl = updateUrl.href;
|
||||||
$create('.warning', t('versionInvalidOlder')),
|
|
||||||
$('.actions')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$('button.install').onclick = () => {
|
|
||||||
(!dup ?
|
|
||||||
Promise.resolve(true) :
|
|
||||||
messageBox.confirm(t('styleInstallOverwrite', [
|
|
||||||
data.name + (dup.customName ? ` (${dup.customName})` : ''),
|
|
||||||
dupData.version,
|
|
||||||
data.version,
|
|
||||||
]))
|
|
||||||
).then(ok => ok &&
|
|
||||||
API.installUsercss(style)
|
|
||||||
.then(install)
|
|
||||||
.catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// set updateUrl
|
|
||||||
const checker = $('.set-update-url input[type=checkbox]');
|
|
||||||
const updateUrl = new URL(style.updateUrl || initialUrl);
|
|
||||||
if (dup && dup.updateUrl === updateUrl.href) {
|
|
||||||
checker.checked = true;
|
|
||||||
// there is no way to "unset" updateUrl, you can only overwrite it.
|
|
||||||
checker.disabled = true;
|
|
||||||
} else if (updateUrl.protocol !== 'file:') {
|
|
||||||
checker.checked = true;
|
|
||||||
style.updateUrl = updateUrl.href;
|
|
||||||
}
|
|
||||||
checker.onchange = () => {
|
|
||||||
style.updateUrl = checker.checked ? updateUrl.href : null;
|
|
||||||
};
|
|
||||||
checker.onchange();
|
|
||||||
$('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
|
|
||||||
updateUrl.href.slice(0, 300) + '...';
|
|
||||||
|
|
||||||
if (initialUrl.startsWith('file:')) {
|
|
||||||
$('.live-reload input').onchange = liveReload.onToggled;
|
|
||||||
} else {
|
|
||||||
$('.live-reload').remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
checker.onchange = () => {
|
||||||
|
style.updateUrl = checker.checked ? updateUrl.href : null;
|
||||||
|
};
|
||||||
|
checker.onchange();
|
||||||
|
$('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
|
||||||
|
updateUrl.href.slice(0, 300) + '...';
|
||||||
|
|
||||||
function getAppliesTo(style) {
|
if (initialUrl.startsWith('file:')) {
|
||||||
function *_gen() {
|
$('.live-reload input').onchange = liveReload.onToggled;
|
||||||
for (const section of style.sections) {
|
} else {
|
||||||
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
|
$('.live-reload').remove();
|
||||||
if (section[type]) {
|
|
||||||
yield *section[type];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = [..._gen()];
|
|
||||||
if (!result.length) {
|
|
||||||
result.push(chrome.i18n.getMessage('appliesToEverything'));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustCodeHeight() {
|
|
||||||
// Chrome-only bug (apparently): it doesn't limit the scroller element height
|
|
||||||
const scroller = cm.display.scroller;
|
|
||||||
const prevWindowHeight = adjustCodeHeight.prevWindowHeight;
|
|
||||||
if (scroller.scrollHeight === scroller.clientHeight ||
|
|
||||||
prevWindowHeight && window.innerHeight !== prevWindowHeight) {
|
|
||||||
adjustCodeHeight.prevWindowHeight = window.innerHeight;
|
|
||||||
cm.setSize(null, $('.main').offsetHeight - $('.warnings').offsetHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initLiveReload() {
|
|
||||||
const DELAY = 500;
|
|
||||||
let isEnabled = false;
|
|
||||||
let timer = 0;
|
|
||||||
/** @type function(?options):Promise<string|null> */
|
|
||||||
let getData = null;
|
|
||||||
/** @type Promise */
|
|
||||||
let sequence = null;
|
|
||||||
if (tabId < 0) {
|
|
||||||
getData = DirectDownloader();
|
|
||||||
sequence = API.getUsercssInstallCode(initialUrl)
|
|
||||||
.then(code => code || getData())
|
|
||||||
.catch(getData);
|
|
||||||
} else {
|
|
||||||
getData = PortDownloader();
|
|
||||||
sequence = getData({timer: false});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
get enabled() {
|
|
||||||
return isEnabled;
|
|
||||||
},
|
|
||||||
ready: sequence,
|
|
||||||
onToggled(e) {
|
|
||||||
if (e) isEnabled = e.target.checked;
|
|
||||||
if (installed || installedDup) {
|
|
||||||
if (isEnabled) {
|
|
||||||
check({force: true});
|
|
||||||
} else {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
$('.install').disabled = isEnabled;
|
|
||||||
Object.assign($('#live-reload-install-hint'), {
|
|
||||||
hidden: !isEnabled,
|
|
||||||
textContent: t(`liveReloadInstallHint${tabId >= 0 ? 'FF' : ''}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
function check(opts) {
|
|
||||||
getData(opts)
|
|
||||||
.then(update, logError)
|
|
||||||
.then(() => {
|
|
||||||
timer = 0;
|
|
||||||
start();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function logError(error) {
|
|
||||||
console.warn(t('liveReloadError', error));
|
|
||||||
}
|
|
||||||
function start() {
|
|
||||||
timer = timer || setTimeout(check, DELAY);
|
|
||||||
}
|
|
||||||
function stop() {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = 0;
|
|
||||||
}
|
|
||||||
function update(code) {
|
|
||||||
if (code == null) return;
|
|
||||||
sequence = sequence.catch(console.error).then(() => {
|
|
||||||
const {id} = installed || installedDup;
|
|
||||||
const scrollInfo = cm.getScrollInfo();
|
|
||||||
const cursor = cm.getCursor();
|
|
||||||
cm.setValue(code);
|
|
||||||
cm.setCursor(cursor);
|
|
||||||
cm.scrollTo(scrollInfo.left, scrollInfo.top);
|
|
||||||
return API.installUsercss({id, sourceCode: code})
|
|
||||||
.then(updateMeta)
|
|
||||||
.catch(showError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function DirectDownloader() {
|
|
||||||
let oldCode = null;
|
|
||||||
const passChangedCode = code => {
|
|
||||||
const isSame = code === oldCode;
|
|
||||||
oldCode = code;
|
|
||||||
return isSame ? null : code;
|
|
||||||
};
|
|
||||||
return () => download(initialUrl).then(passChangedCode);
|
|
||||||
}
|
|
||||||
function PortDownloader() {
|
|
||||||
const resolvers = new Map();
|
|
||||||
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
|
|
||||||
port.onMessage.addListener(({id, code, error}) => {
|
|
||||||
const r = resolvers.get(id);
|
|
||||||
resolvers.delete(id);
|
|
||||||
if (error) {
|
|
||||||
r.reject(error);
|
|
||||||
} else {
|
|
||||||
r.resolve(code);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
port.onDisconnect.addListener(async () => {
|
|
||||||
const tab = await browser.tabs.get(tabId).catch(() => ({}));
|
|
||||||
if (tab.url === initialUrl) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
closeCurrentTab();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (opts = {}) => new Promise((resolve, reject) => {
|
|
||||||
const id = performance.now();
|
|
||||||
resolvers.set(id, {resolve, reject});
|
|
||||||
opts.id = id;
|
|
||||||
port.postMessage(opts);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function updateMeta(style, dup = installedDup) {
|
||||||
|
installedDup = dup;
|
||||||
|
const data = style.usercssData;
|
||||||
|
const dupData = dup && dup.usercssData;
|
||||||
|
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||||
|
|
||||||
|
cm.setPreprocessor(data.preprocessor);
|
||||||
|
|
||||||
|
const installButtonLabel = t(
|
||||||
|
installed ? 'installButtonInstalled' :
|
||||||
|
!dup ? 'installButton' :
|
||||||
|
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
|
||||||
|
);
|
||||||
|
document.title = `${installButtonLabel} ${data.name}`;
|
||||||
|
|
||||||
|
$('.install').textContent = installButtonLabel;
|
||||||
|
$('.install').classList.add(
|
||||||
|
installed ? 'installed' :
|
||||||
|
!dup ? 'install' :
|
||||||
|
versionTest > 0 ? 'update' :
|
||||||
|
'reinstall');
|
||||||
|
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
|
||||||
|
$('.meta-name').textContent = data.name;
|
||||||
|
$('.meta-version').textContent = data.version;
|
||||||
|
$('.meta-description').textContent = data.description;
|
||||||
|
|
||||||
|
if (data.author) {
|
||||||
|
$('.meta-author').parentNode.style.display = '';
|
||||||
|
$('.meta-author').textContent = '';
|
||||||
|
$('.meta-author').appendChild(makeAuthor(data.author));
|
||||||
|
} else {
|
||||||
|
$('.meta-author').parentNode.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
|
||||||
|
$('.meta-license').textContent = data.license;
|
||||||
|
|
||||||
|
$('.applies-to').textContent = '';
|
||||||
|
getAppliesTo(style).then(list =>
|
||||||
|
$('.applies-to').append(...list.map(s => $create('li', s))));
|
||||||
|
|
||||||
|
$('.external-link').textContent = '';
|
||||||
|
const externalLink = makeExternalLink();
|
||||||
|
if (externalLink) {
|
||||||
|
$('.external-link').appendChild(externalLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#header').dataset.arrivedFast = performance.now() < 500;
|
||||||
|
$('#header').classList.add('meta-init');
|
||||||
|
$('#header').classList.remove('meta-init-error');
|
||||||
|
setTimeout(() => $$remove('.lds-spinner'), 1000);
|
||||||
|
|
||||||
|
showError('');
|
||||||
|
requestAnimationFrame(adjustCodeHeight);
|
||||||
|
|
||||||
|
function makeAuthor(text) {
|
||||||
|
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
|
||||||
|
if (!match) {
|
||||||
|
return document.createTextNode(text);
|
||||||
|
}
|
||||||
|
const [, name, email, url] = match;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (email) {
|
||||||
|
frag.appendChild($createLink(`mailto:${email}`, name));
|
||||||
|
} else {
|
||||||
|
frag.appendChild($create('span', name));
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
frag.appendChild($createLink(url,
|
||||||
|
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
|
||||||
|
$create('SVG:path', {
|
||||||
|
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExternalLink() {
|
||||||
|
const urls = [
|
||||||
|
data.homepageURL && [data.homepageURL, t('externalHomepage')],
|
||||||
|
data.supportURL && [data.supportURL, t('externalSupport')],
|
||||||
|
];
|
||||||
|
return (data.homepageURL || data.supportURL) && (
|
||||||
|
$create('div', [
|
||||||
|
$create('h3', t('externalLink')),
|
||||||
|
$create('ul', urls.map(args => args &&
|
||||||
|
$create('li',
|
||||||
|
$createLink(...args)
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(err) {
|
||||||
|
$('.warnings').textContent = '';
|
||||||
|
$('.warnings').classList.toggle('visible', Boolean(err));
|
||||||
|
$('.container').classList.toggle('has-warnings', Boolean(err));
|
||||||
|
err = Array.isArray(err) ? err : [err];
|
||||||
|
if (err[0]) {
|
||||||
|
let i;
|
||||||
|
if ((i = err[0].index) >= 0 ||
|
||||||
|
(i = err[0].offset) >= 0) {
|
||||||
|
cm.jumpToPos(cm.posFromIndex(i));
|
||||||
|
cm.setSelections(err.map(e => {
|
||||||
|
const pos = e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
|
||||||
|
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1}; // csslint code parser
|
||||||
|
return pos && {anchor: pos, head: pos};
|
||||||
|
}).filter(Boolean));
|
||||||
|
cm.focus();
|
||||||
|
}
|
||||||
|
$('.warnings').appendChild(
|
||||||
|
$create('.warning', [
|
||||||
|
t('parseUsercssError'),
|
||||||
|
'\n',
|
||||||
|
...err.map(e => e.message ? $create('pre', e.message) : e || 'Unknown error'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
adjustCodeHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBuildError(error) {
|
||||||
|
$('#header').classList.add('meta-init-error');
|
||||||
|
console.error(error);
|
||||||
|
showError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function install(style) {
|
||||||
|
installed = style;
|
||||||
|
|
||||||
|
$$remove('.warning');
|
||||||
|
$('button.install').disabled = true;
|
||||||
|
$('button.install').classList.add('installed');
|
||||||
|
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
|
||||||
|
$('h2.installed').classList.add('active');
|
||||||
|
$('.set-update-url input[type=checkbox]').disabled = true;
|
||||||
|
$('.set-update-url').title = style.updateUrl ?
|
||||||
|
t('installUpdateFrom', style.updateUrl) : '';
|
||||||
|
|
||||||
|
updateMeta(style);
|
||||||
|
|
||||||
|
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
|
||||||
|
location.href = '/edit.html?id=' + style.id;
|
||||||
|
} else {
|
||||||
|
API.openEditor({id: style.id});
|
||||||
|
if (!liveReload.enabled) {
|
||||||
|
if (tabId < 0 && history.length > 1) {
|
||||||
|
history.back();
|
||||||
|
} else {
|
||||||
|
closeCurrentTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppliesTo(style) {
|
||||||
|
if (style.sectionsPromise) {
|
||||||
|
try {
|
||||||
|
style.sections = await style.sectionsPromise;
|
||||||
|
} catch (error) {
|
||||||
|
showBuildError(error);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
delete style.sectionsPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let numGlobals = 0;
|
||||||
|
const res = [];
|
||||||
|
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
|
||||||
|
for (const section of style.sections) {
|
||||||
|
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() {
|
||||||
|
// Chrome-only bug (apparently): it doesn't limit the scroller element height
|
||||||
|
const scroller = cm.display.scroller;
|
||||||
|
const prevWindowHeight = adjustCodeHeight.prevWindowHeight;
|
||||||
|
if (scroller.scrollHeight === scroller.clientHeight ||
|
||||||
|
prevWindowHeight && window.innerHeight !== prevWindowHeight) {
|
||||||
|
adjustCodeHeight.prevWindowHeight = window.innerHeight;
|
||||||
|
cm.setSize(null, $('.main').offsetHeight - $('.warnings').offsetHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLiveReload() {
|
||||||
|
const DELAY = 500;
|
||||||
|
let isEnabled = false;
|
||||||
|
let timer = 0;
|
||||||
|
const getData = preinit.getData;
|
||||||
|
let sequence = preinit.ready;
|
||||||
|
return {
|
||||||
|
get enabled() {
|
||||||
|
return isEnabled;
|
||||||
|
},
|
||||||
|
onToggled(e) {
|
||||||
|
if (e) isEnabled = e.target.checked;
|
||||||
|
if (installed || installedDup) {
|
||||||
|
if (isEnabled) {
|
||||||
|
check({force: true});
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
$('.install').disabled = isEnabled;
|
||||||
|
Object.assign($('#live-reload-install-hint'), {
|
||||||
|
hidden: !isEnabled,
|
||||||
|
textContent: t(`liveReloadInstallHint${tabId >= 0 ? 'FF' : ''}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function check(opts) {
|
||||||
|
getData(opts)
|
||||||
|
.then(update, logError)
|
||||||
|
.then(() => {
|
||||||
|
timer = 0;
|
||||||
|
start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function logError(error) {
|
||||||
|
console.warn(t('liveReloadError', error));
|
||||||
|
}
|
||||||
|
function start() {
|
||||||
|
timer = timer || setTimeout(check, DELAY);
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = 0;
|
||||||
|
}
|
||||||
|
function update(code) {
|
||||||
|
if (code == null) return;
|
||||||
|
sequence = sequence.catch(console.error).then(() => {
|
||||||
|
const {id} = installed || installedDup;
|
||||||
|
const scrollInfo = cm.getScrollInfo();
|
||||||
|
const cursor = cm.getCursor();
|
||||||
|
cm.setValue(code);
|
||||||
|
cm.setCursor(cursor);
|
||||||
|
cm.scrollTo(scrollInfo.left, scrollInfo.top);
|
||||||
|
return API.usercss.install({id, sourceCode: code})
|
||||||
|
.then(updateMeta)
|
||||||
|
.catch(showError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
90
install-usercss/preinit.js
Normal file
90
install-usercss/preinit.js
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
/* global API */// msg.js
|
||||||
|
/* global closeCurrentTab download */// toolbox.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* exported preinit */
|
||||||
|
const preinit = (() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
|
||||||
|
const initialUrl = params.get('updateUrl');
|
||||||
|
|
||||||
|
/** @type function(?options):Promise<?string> */
|
||||||
|
let getData;
|
||||||
|
/** @type {Promise<?string>} */
|
||||||
|
let firstGet;
|
||||||
|
if (tabId < 0) {
|
||||||
|
getData = DirectDownloader();
|
||||||
|
firstGet = API.usercss.getInstallCode(initialUrl)
|
||||||
|
.then(code => code || getData())
|
||||||
|
.catch(getData);
|
||||||
|
} else {
|
||||||
|
getData = PortDownloader();
|
||||||
|
firstGet = getData({timer: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectDownloader() {
|
||||||
|
let oldCode = null;
|
||||||
|
return async () => {
|
||||||
|
const code = await download(initialUrl);
|
||||||
|
if (oldCode !== code) {
|
||||||
|
oldCode = code;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortDownloader() {
|
||||||
|
const resolvers = new Map();
|
||||||
|
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
|
||||||
|
port.onMessage.addListener(({id, code, error}) => {
|
||||||
|
const r = resolvers.get(id);
|
||||||
|
resolvers.delete(id);
|
||||||
|
if (error) {
|
||||||
|
r.reject(error);
|
||||||
|
} else {
|
||||||
|
r.resolve(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port.onDisconnect.addListener(async () => {
|
||||||
|
const tab = await browser.tabs.get(tabId).catch(() => ({}));
|
||||||
|
if (tab.url === initialUrl) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
closeCurrentTab();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (opts = {}) => new Promise((resolve, reject) => {
|
||||||
|
const id = performance.now();
|
||||||
|
resolvers.set(id, {resolve, reject});
|
||||||
|
opts.id = id;
|
||||||
|
port.postMessage(opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
|
||||||
|
getData,
|
||||||
|
initialUrl,
|
||||||
|
tabId,
|
||||||
|
|
||||||
|
/** @type {Promise<{style, dup} | {error}>} */
|
||||||
|
ready: (async () => {
|
||||||
|
let sourceCode;
|
||||||
|
try {
|
||||||
|
sourceCode = await firstGet;
|
||||||
|
} catch (error) {
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await API.usercss.build({sourceCode, checkDup: true, metaOnly: true});
|
||||||
|
Object.defineProperty(data.style, 'sectionsPromise', {
|
||||||
|
value: API.usercss.buildCode(data.style).then(style => style.sections),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return {error, sourceCode};
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
})();
|
71
js/cache.js
71
js/cache.js
|
@ -1,71 +0,0 @@
|
||||||
/* exported createCache */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// create a FIFO limit-size map.
|
|
||||||
function createCache({size = 1000, onDeleted} = {}) {
|
|
||||||
const map = new Map();
|
|
||||||
const buffer = Array(size);
|
|
||||||
let index = 0;
|
|
||||||
let lastIndex = 0;
|
|
||||||
return {
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
delete: delete_,
|
|
||||||
clear,
|
|
||||||
has: id => map.has(id),
|
|
||||||
entries: function *() {
|
|
||||||
for (const [id, item] of map) {
|
|
||||||
yield [id, item.data];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
values: function *() {
|
|
||||||
for (const item of map.values()) {
|
|
||||||
yield item.data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get size() {
|
|
||||||
return map.size;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function get(id) {
|
|
||||||
const item = map.get(id);
|
|
||||||
return item && item.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function set(id, data) {
|
|
||||||
if (map.size === size) {
|
|
||||||
// full
|
|
||||||
map.delete(buffer[lastIndex].id);
|
|
||||||
if (onDeleted) {
|
|
||||||
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
|
|
||||||
}
|
|
||||||
lastIndex = (lastIndex + 1) % size;
|
|
||||||
}
|
|
||||||
const item = {id, data, index};
|
|
||||||
map.set(id, item);
|
|
||||||
buffer[index] = item;
|
|
||||||
index = (index + 1) % size;
|
|
||||||
}
|
|
||||||
|
|
||||||
function delete_(id) {
|
|
||||||
const item = map.get(id);
|
|
||||||
if (!item) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
map.delete(item.id);
|
|
||||||
const lastItem = buffer[lastIndex];
|
|
||||||
lastItem.index = item.index;
|
|
||||||
buffer[item.index] = lastItem;
|
|
||||||
lastIndex = (lastIndex + 1) % size;
|
|
||||||
if (onDeleted) {
|
|
||||||
onDeleted(item.id, item.data);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
map.clear();
|
|
||||||
index = lastIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
89
js/color/color-mimicry.js
Normal file
89
js/color/color-mimicry.js
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
/* global $create */// dom.js
|
||||||
|
/* global debounce */// toolbox.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* exported colorMimicry */
|
||||||
|
/**
|
||||||
|
* Calculates real color of an element:
|
||||||
|
* colorMimicry(cm.display.gutters, {bg: 'backgroundColor'})
|
||||||
|
* colorMimicry('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
|
||||||
|
*/
|
||||||
|
function colorMimicry(el, targets, dummyContainer = document.body) {
|
||||||
|
const styleCache = colorMimicry.styleCache || (colorMimicry.styleCache = new Map());
|
||||||
|
targets = targets || {};
|
||||||
|
targets.fore = 'color';
|
||||||
|
const colors = {};
|
||||||
|
const done = {};
|
||||||
|
let numDone = 0;
|
||||||
|
let numTotal = 0;
|
||||||
|
const rootStyle = getStyle(document.documentElement);
|
||||||
|
for (const k in targets) {
|
||||||
|
const base = {r: 255, g: 255, b: 255, a: 1};
|
||||||
|
blend(base, rootStyle[targets[k]]);
|
||||||
|
colors[k] = base;
|
||||||
|
numTotal++;
|
||||||
|
}
|
||||||
|
const isDummy = typeof el === 'string';
|
||||||
|
if (isDummy) {
|
||||||
|
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
|
||||||
|
}
|
||||||
|
for (let current = el; current; current = current && current.parentElement) {
|
||||||
|
const style = getStyle(current);
|
||||||
|
for (const k in targets) {
|
||||||
|
if (!done[k]) {
|
||||||
|
done[k] = blend(colors[k], style[targets[k]]);
|
||||||
|
numDone += done[k] ? 1 : 0;
|
||||||
|
if (numDone === numTotal) {
|
||||||
|
current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
colors.style = colors.style || style;
|
||||||
|
}
|
||||||
|
if (isDummy) {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
for (const k in targets) {
|
||||||
|
const {r, g, b, a} = colors[k];
|
||||||
|
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||||
|
// https://www.w3.org/TR/AERT#color-contrast
|
||||||
|
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
|
||||||
|
}
|
||||||
|
debounce(clearCache);
|
||||||
|
return colors;
|
||||||
|
|
||||||
|
function blend(base, color) {
|
||||||
|
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
|
||||||
|
if (a === 255) {
|
||||||
|
base.r = r;
|
||||||
|
base.g = g;
|
||||||
|
base.b = b;
|
||||||
|
base.a = 1;
|
||||||
|
} else if (a) {
|
||||||
|
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
|
||||||
|
const q1 = a / 255 / mixedA;
|
||||||
|
const q2 = base.a * (1 - mixedA) / mixedA;
|
||||||
|
base.r = Math.round(r * q1 + base.r * q2);
|
||||||
|
base.g = Math.round(g * q1 + base.g * q2);
|
||||||
|
base.b = Math.round(b * q1 + base.b * q2);
|
||||||
|
base.a = mixedA;
|
||||||
|
}
|
||||||
|
return Math.abs(base.a - 1) < 1e-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// speed-up for sequential invocations within the same event loop cycle
|
||||||
|
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
|
||||||
|
function getStyle(el) {
|
||||||
|
let style = styleCache.get(el);
|
||||||
|
if (!style) {
|
||||||
|
style = getComputedStyle(el);
|
||||||
|
styleCache.set(el, style);
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
styleCache.clear();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
/* global colorConverter $create debounce */
|
/* global colorConverter */
|
||||||
/* exported colorMimicry */
|
/* global colorMimicry */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
|
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
|
||||||
const cm = this;
|
const cm = window.CodeMirror && this;
|
||||||
const CSS_PREFIX = 'colorpicker-';
|
const CSS_PREFIX = 'colorpicker-';
|
||||||
const HUE_COLORS = [
|
const HUE_COLORS = [
|
||||||
{hex: '#ff0000', start: .0},
|
{hex: '#ff0000', start: .0},
|
||||||
|
@ -679,7 +679,7 @@
|
||||||
function onCloseRequest(event) {
|
function onCloseRequest(event) {
|
||||||
if (event.detail !== PUBLIC_API) {
|
if (event.detail !== PUBLIC_API) {
|
||||||
hide();
|
hide();
|
||||||
} else if (!prevFocusedElement) {
|
} else if (!prevFocusedElement && cm) {
|
||||||
// we're between mousedown and mouseup and colorview wants to re-open us in this cm
|
// we're between mousedown and mouseup and colorview wants to re-open us in this cm
|
||||||
// so we'll prevent onMouseUp from hiding us to avoid flicker
|
// so we'll prevent onMouseUp from hiding us to avoid flicker
|
||||||
prevFocusedElement = cm.display.input;
|
prevFocusedElement = cm.display.input;
|
||||||
|
@ -867,9 +867,9 @@
|
||||||
|
|
||||||
function guessTheme() {
|
function guessTheme() {
|
||||||
const el = options.guessBrightness ||
|
const el = options.guessBrightness ||
|
||||||
((cm.display.renderedView || [])[0] || {}).text ||
|
cm && ((cm.display.renderedView || [])[0] || {}).text ||
|
||||||
cm.display.lineDiv;
|
cm && cm.display.lineDiv;
|
||||||
const bgLuma = window.colorMimicry.get(el, {bg: 'backgroundColor'}).bgLuma;
|
const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma;
|
||||||
return bgLuma < .5 ? 'dark' : 'light';
|
return bgLuma < .5 ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -893,92 +893,3 @@
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
};
|
};
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var colorMimicry = (() => {
|
|
||||||
const styleCache = new Map();
|
|
||||||
return {get};
|
|
||||||
|
|
||||||
// Calculates real color of an element:
|
|
||||||
// colorMimicry.get(cm.display.gutters, {bg: 'backgroundColor'})
|
|
||||||
// colorMimicry.get('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
|
|
||||||
function get(el, targets, dummyContainer = document.body) {
|
|
||||||
targets = targets || {};
|
|
||||||
targets.fore = 'color';
|
|
||||||
const colors = {};
|
|
||||||
const done = {};
|
|
||||||
let numDone = 0;
|
|
||||||
let numTotal = 0;
|
|
||||||
const rootStyle = getStyle(document.documentElement);
|
|
||||||
for (const k in targets) {
|
|
||||||
const base = {r: 255, g: 255, b: 255, a: 1};
|
|
||||||
blend(base, rootStyle[targets[k]]);
|
|
||||||
colors[k] = base;
|
|
||||||
numTotal++;
|
|
||||||
}
|
|
||||||
const isDummy = typeof el === 'string';
|
|
||||||
if (isDummy) {
|
|
||||||
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
|
|
||||||
}
|
|
||||||
for (let current = el; current; current = current && current.parentElement) {
|
|
||||||
const style = getStyle(current);
|
|
||||||
for (const k in targets) {
|
|
||||||
if (!done[k]) {
|
|
||||||
done[k] = blend(colors[k], style[targets[k]]);
|
|
||||||
numDone += done[k] ? 1 : 0;
|
|
||||||
if (numDone === numTotal) {
|
|
||||||
current = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
colors.style = colors.style || style;
|
|
||||||
}
|
|
||||||
if (isDummy) {
|
|
||||||
el.remove();
|
|
||||||
}
|
|
||||||
for (const k in targets) {
|
|
||||||
const {r, g, b, a} = colors[k];
|
|
||||||
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
||||||
// https://www.w3.org/TR/AERT#color-contrast
|
|
||||||
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
|
|
||||||
}
|
|
||||||
debounce(clearCache);
|
|
||||||
return colors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function blend(base, color) {
|
|
||||||
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
|
|
||||||
if (a === 255) {
|
|
||||||
base.r = r;
|
|
||||||
base.g = g;
|
|
||||||
base.b = b;
|
|
||||||
base.a = 1;
|
|
||||||
} else if (a) {
|
|
||||||
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
|
|
||||||
const q1 = a / 255 / mixedA;
|
|
||||||
const q2 = base.a * (1 - mixedA) / mixedA;
|
|
||||||
base.r = Math.round(r * q1 + base.r * q2);
|
|
||||||
base.g = Math.round(g * q1 + base.g * q2);
|
|
||||||
base.b = Math.round(b * q1 + base.b * q2);
|
|
||||||
base.a = mixedA;
|
|
||||||
}
|
|
||||||
return Math.abs(base.a - 1) < 1e-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// speed-up for sequential invocations within the same event loop cycle
|
|
||||||
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
|
|
||||||
function getStyle(el) {
|
|
||||||
let style = styleCache.get(el);
|
|
||||||
if (!style) {
|
|
||||||
style = getComputedStyle(el);
|
|
||||||
styleCache.set(el, style);
|
|
||||||
}
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCache() {
|
|
||||||
styleCache.clear();
|
|
||||||
}
|
|
||||||
})();
|
|
|
@ -1,4 +1,5 @@
|
||||||
/* global CodeMirror colorConverter */
|
/* global CodeMirror */
|
||||||
|
/* global colorConverter */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
@ -99,7 +100,7 @@
|
||||||
const cache = new Set();
|
const cache = new Set();
|
||||||
|
|
||||||
class ColorSwatch {
|
class ColorSwatch {
|
||||||
constructor(cm, options) {
|
constructor(cm, options = {}) {
|
||||||
this.cm = cm;
|
this.cm = cm;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.markersToRemove = [];
|
this.markersToRemove = [];
|
|
@ -38,7 +38,7 @@ class Reporter {
|
||||||
* @param {Object} ruleset - The set of rules to work with, including if
|
* @param {Object} ruleset - The set of rules to work with, including if
|
||||||
* they are errors or warnings.
|
* they are errors or warnings.
|
||||||
* @param {Object} allow - explicitly allowed lines
|
* @param {Object} allow - explicitly allowed lines
|
||||||
* @param {[][]} ingore - list of line ranges to be ignored
|
* @param {[][]} ignore - list of line ranges to be ignored
|
||||||
*/
|
*/
|
||||||
constructor(lines, ruleset, allow, ignore) {
|
constructor(lines, ruleset, allow, ignore) {
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
|
@ -145,7 +145,7 @@ var CSSLint = (() => {
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
a.id < b.id ? -1 :
|
a.id < b.id ? -1 :
|
||||||
a.id > b.id ? 1 : 0);
|
a.id > b.id ? 1 : 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
getRuleset() {
|
getRuleset() {
|
||||||
|
@ -204,7 +204,8 @@ var CSSLint = (() => {
|
||||||
try {
|
try {
|
||||||
parser.parse(text, {reuseCache});
|
parser.parse(text, {reuseCache});
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
reporter.error('Fatal error, cannot continue: ' + ex.message, ex.line, ex.col, {});
|
reporter.error('Fatal error, cannot continue!\n' + ex.stack,
|
||||||
|
ex.line || 1, ex.col || 1, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
|
@ -324,23 +325,8 @@ var CSSLint = (() => {
|
||||||
//endregion
|
//endregion
|
||||||
//region Util
|
//region Util
|
||||||
|
|
||||||
// expose for testing purposes
|
|
||||||
CSSLint._Reporter = Reporter;
|
|
||||||
|
|
||||||
CSSLint.Util = {
|
CSSLint.Util = {
|
||||||
|
|
||||||
indexOf(values, value) {
|
|
||||||
if (typeof values.indexOf === 'function') {
|
|
||||||
return values.indexOf(value);
|
|
||||||
}
|
|
||||||
for (let i = 0, len = values.length; i < len; i++) {
|
|
||||||
if (values[i] === value) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
},
|
|
||||||
|
|
||||||
registerBlockEvents(parser, start, end, property) {
|
registerBlockEvents(parser, start, end, property) {
|
||||||
for (const e of [
|
for (const e of [
|
||||||
'document',
|
'document',
|
||||||
|
@ -643,7 +629,7 @@ CSSLint.addRule({
|
||||||
if (inKeyFrame &&
|
if (inKeyFrame &&
|
||||||
typeof inKeyFrame === 'string' &&
|
typeof inKeyFrame === 'string' &&
|
||||||
name.startsWith(inKeyFrame) ||
|
name.startsWith(inKeyFrame) ||
|
||||||
CSSLint.Util.indexOf(applyTo, name) < 0) {
|
applyTo.indexOf(name) < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
properties.push(event.property);
|
properties.push(event.property);
|
||||||
|
@ -657,7 +643,7 @@ CSSLint.addRule({
|
||||||
for (const name of properties) {
|
for (const name of properties) {
|
||||||
for (const prop in compatiblePrefixes) {
|
for (const prop in compatiblePrefixes) {
|
||||||
const variations = compatiblePrefixes[prop];
|
const variations = compatiblePrefixes[prop];
|
||||||
if (CSSLint.Util.indexOf(variations, name.text) <= -1) continue;
|
if (variations.indexOf(name.text) <= -1) continue;
|
||||||
|
|
||||||
if (!propertyGroups[prop]) {
|
if (!propertyGroups[prop]) {
|
||||||
propertyGroups[prop] = {
|
propertyGroups[prop] = {
|
||||||
|
@ -667,7 +653,7 @@ CSSLint.addRule({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CSSLint.Util.indexOf(propertyGroups[prop].actual, name.text) === -1) {
|
if (propertyGroups[prop].actual.indexOf(name.text) === -1) {
|
||||||
propertyGroups[prop].actual.push(name.text);
|
propertyGroups[prop].actual.push(name.text);
|
||||||
propertyGroups[prop].actualNodes.push(name);
|
propertyGroups[prop].actualNodes.push(name);
|
||||||
}
|
}
|
||||||
|
@ -680,7 +666,7 @@ CSSLint.addRule({
|
||||||
if (value.full.length <= actual.length) continue;
|
if (value.full.length <= actual.length) continue;
|
||||||
|
|
||||||
for (const item of value.full) {
|
for (const item of value.full) {
|
||||||
if (CSSLint.Util.indexOf(actual, item) !== -1) continue;
|
if (actual.indexOf(item) !== -1) continue;
|
||||||
|
|
||||||
const propertiesSpecified =
|
const propertiesSpecified =
|
||||||
actual.length === 1 ?
|
actual.length === 1 ?
|
||||||
|
@ -1122,7 +1108,9 @@ CSSLint.addRule({
|
||||||
parser.addListener('import', () => count++);
|
parser.addListener('import', () => count++);
|
||||||
parser.addListener('endstylesheet', () => {
|
parser.addListener('endstylesheet', () => {
|
||||||
if (count > MAX_IMPORT_COUNT) {
|
if (count > MAX_IMPORT_COUNT) {
|
||||||
reporter.rollupError(`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`, this);
|
reporter.rollupError(
|
||||||
|
`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`,
|
||||||
|
this);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -1179,9 +1167,8 @@ CSSLint.addRule({
|
||||||
|
|
||||||
init(parser, reporter) {
|
init(parser, reporter) {
|
||||||
parser.addListener('property', event => {
|
parser.addListener('property', event => {
|
||||||
if (event.invalid) {
|
const inv = event.invalid;
|
||||||
reporter.report(event.invalid.message, event.line, event.col, this);
|
if (inv) reporter.report(inv.message, inv.line, inv.col, this);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1422,13 +1409,10 @@ CSSLint.addRule({
|
||||||
init(parser, reporter) {
|
init(parser, reporter) {
|
||||||
parser.addListener('startrule', event => {
|
parser.addListener('startrule', event => {
|
||||||
for (const {parts} of event.selectors) {
|
for (const {parts} of event.selectors) {
|
||||||
for (let p = 0, pLen = parts.length; p < pLen; p++) {
|
for (let i = 0, p, pn; i < parts.length - 1 && (p = parts[i]); i++) {
|
||||||
for (let n = p + 1; n < pLen; n++) {
|
if (p.type === 'descendant' && (pn = parts[i + 1]).line > p.line) {
|
||||||
if (parts[p].type === 'descendant' &&
|
reporter.report('newline character found in selector (forgot a comma?)',
|
||||||
parts[n].line > parts[p].line) {
|
pn.line, pn.col, this);
|
||||||
reporter.report('newline character found in selector (forgot a comma?)',
|
|
||||||
parts[p].line, parts[0].col, this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1491,6 +1475,37 @@ CSSLint.addRule({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
CSSLint.addRule({
|
||||||
|
id: 'simple-not',
|
||||||
|
name: 'Require use of simple selectors inside :not()',
|
||||||
|
desc: 'A complex selector inside :not() is only supported by CSS4-compliant browsers.',
|
||||||
|
browsers: 'All',
|
||||||
|
|
||||||
|
init(parser, reporter) {
|
||||||
|
parser.addListener('startrule', e => {
|
||||||
|
for (const sel of e.selectors) {
|
||||||
|
if (!/:not\(/i.test(sel.text)) continue;
|
||||||
|
for (const part of sel.parts) {
|
||||||
|
if (!part.modifiers) continue;
|
||||||
|
for (const mod of part.modifiers) {
|
||||||
|
if (mod.type !== 'not') continue;
|
||||||
|
const {args} = mod;
|
||||||
|
const {parts} = args[0];
|
||||||
|
if (args.length > 1 ||
|
||||||
|
parts.length !== 1 ||
|
||||||
|
parts[0].modifiers.length + (parts[0].elementName ? 1 : 0) > 1 ||
|
||||||
|
/^:not\(/i.test(parts[0])) {
|
||||||
|
reporter.report(
|
||||||
|
`Simple selector expected, but found '${args.join(', ')}'`,
|
||||||
|
args[0].line, args[0].col, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
CSSLint.addRule({
|
CSSLint.addRule({
|
||||||
id: 'star-property-hack',
|
id: 'star-property-hack',
|
||||||
name: 'Disallow properties with a star prefix',
|
name: 'Disallow properties with a star prefix',
|
|
@ -676,8 +676,9 @@ self.parserlib = (() => {
|
||||||
x: 'resolution',
|
x: 'resolution',
|
||||||
ar: 'dimension',
|
ar: 'dimension',
|
||||||
};
|
};
|
||||||
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]+/yu;
|
// Sticky `y` flag must be used in expressions used with peekTest and readMatch
|
||||||
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]+/yu;
|
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]/u;
|
||||||
|
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]/u;
|
||||||
const rxNameCharNoEsc = /[-_\da-zA-Z\u00A0-\uFFFF]+/yu; // must not match \\
|
const rxNameCharNoEsc = /[-_\da-zA-Z\u00A0-\uFFFF]+/yu; // must not match \\
|
||||||
const rxUnquotedUrlCharNoEsc = /[-!#$%&*-[\]-~\u00A0-\uFFFF]+/yu; // must not match \\
|
const rxUnquotedUrlCharNoEsc = /[-!#$%&*-[\]-~\u00A0-\uFFFF]+/yu; // must not match \\
|
||||||
const rxVendorPrefix = /^-(webkit|moz|ms|o)-(.+)/i;
|
const rxVendorPrefix = /^-(webkit|moz|ms|o)-(.+)/i;
|
||||||
|
@ -1174,6 +1175,7 @@ self.parserlib = (() => {
|
||||||
//#region Tokens
|
//#region Tokens
|
||||||
|
|
||||||
/* https://www.w3.org/TR/css3-syntax/#lexical */
|
/* https://www.w3.org/TR/css3-syntax/#lexical */
|
||||||
|
/** @type {Object<string,number|Object>} */
|
||||||
const Tokens = Object.assign([], {
|
const Tokens = Object.assign([], {
|
||||||
EOF: {}, // must be the first token
|
EOF: {}, // must be the first token
|
||||||
}, {
|
}, {
|
||||||
|
@ -1530,8 +1532,10 @@ self.parserlib = (() => {
|
||||||
|
|
||||||
constructor(matchFunc, toString, options) {
|
constructor(matchFunc, toString, options) {
|
||||||
this.matchFunc = matchFunc;
|
this.matchFunc = matchFunc;
|
||||||
|
/** @type {function(?number):string} */
|
||||||
this.toString = typeof toString === 'function' ? toString : () => toString;
|
this.toString = typeof toString === 'function' ? toString : () => toString;
|
||||||
if (options) this.options = options;
|
/** @type {?Matcher[]} */
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2013,11 +2017,6 @@ self.parserlib = (() => {
|
||||||
|
|
||||||
// individual media query
|
// individual media query
|
||||||
class MediaQuery extends SyntaxUnit {
|
class MediaQuery extends SyntaxUnit {
|
||||||
/**
|
|
||||||
* @param {String} modifier The modifier "not" or "only" (or null).
|
|
||||||
* @param {String} mediaType The type of media (i.e., "print").
|
|
||||||
* @param {Array} features Array of selectors parts making up this selector.
|
|
||||||
*/
|
|
||||||
constructor(modifier, mediaType, features, pos) {
|
constructor(modifier, mediaType, features, pos) {
|
||||||
const text = (modifier ? modifier + ' ' : '') +
|
const text = (modifier ? modifier + ' ' : '') +
|
||||||
(mediaType ? mediaType : '') +
|
(mediaType ? mediaType : '') +
|
||||||
|
@ -2045,9 +2044,6 @@ self.parserlib = (() => {
|
||||||
* including multiple selectors (those separated by commas).
|
* including multiple selectors (those separated by commas).
|
||||||
*/
|
*/
|
||||||
class Selector extends SyntaxUnit {
|
class Selector extends SyntaxUnit {
|
||||||
/**
|
|
||||||
* @param {SelectorPart[]} parts
|
|
||||||
*/
|
|
||||||
constructor(parts, pos) {
|
constructor(parts, pos) {
|
||||||
super(parts.join(' '), pos, TYPES.SELECTOR_TYPE);
|
super(parts.join(' '), pos, TYPES.SELECTOR_TYPE);
|
||||||
this.parts = parts;
|
this.parts = parts;
|
||||||
|
@ -2061,10 +2057,6 @@ self.parserlib = (() => {
|
||||||
* Does not include combinators such as spaces, +, >, etc.
|
* Does not include combinators such as spaces, +, >, etc.
|
||||||
*/
|
*/
|
||||||
class SelectorPart extends SyntaxUnit {
|
class SelectorPart extends SyntaxUnit {
|
||||||
/**
|
|
||||||
* @param {String} elementName or null if there's none
|
|
||||||
* @param {SelectorSubPart[]} modifiers - may be empty
|
|
||||||
*/
|
|
||||||
constructor(elementName, modifiers, text, pos) {
|
constructor(elementName, modifiers, text, pos) {
|
||||||
super(text, pos, TYPES.SELECTOR_PART_TYPE);
|
super(text, pos, TYPES.SELECTOR_PART_TYPE);
|
||||||
this.elementName = elementName;
|
this.elementName = elementName;
|
||||||
|
@ -2076,9 +2068,6 @@ self.parserlib = (() => {
|
||||||
* Selector modifier string
|
* Selector modifier string
|
||||||
*/
|
*/
|
||||||
class SelectorSubPart extends SyntaxUnit {
|
class SelectorSubPart extends SyntaxUnit {
|
||||||
/**
|
|
||||||
* @param {string} type - elementName id class attribute pseudo any not
|
|
||||||
*/
|
|
||||||
constructor(text, type, pos) {
|
constructor(text, type, pos) {
|
||||||
super(text, pos, TYPES.SELECTOR_SUB_PART_TYPE);
|
super(text, pos, TYPES.SELECTOR_SUB_PART_TYPE);
|
||||||
this.type = type;
|
this.type = type;
|
||||||
|
@ -2136,21 +2125,15 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* @return {int} The numeric value for the specificity.
|
|
||||||
*/
|
|
||||||
valueOf() {
|
valueOf() {
|
||||||
return (this.a * 1000) + (this.b * 100) + (this.c * 10) + this.d;
|
return this.a * 1000 + this.b * 100 + this.c * 10 + this.d;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* @return {String} The string representation of specificity.
|
|
||||||
*/
|
|
||||||
toString() {
|
toString() {
|
||||||
return this.a + ',' + this.b + ',' + this.c + ',' + this.d;
|
return `${this.a},${this.b},${this.c},${this.d}`;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Calculates the specificity of the given selector.
|
* Calculates the specificity of the given selector.
|
||||||
* @param {Selector} The selector to calculate specificity for.
|
* @param {Selector} selector The selector to calculate specificity for.
|
||||||
* @return {Specificity} The specificity of the selector.
|
* @return {Specificity} The specificity of the selector.
|
||||||
*/
|
*/
|
||||||
static calculate(selector) {
|
static calculate(selector) {
|
||||||
|
@ -2192,7 +2175,6 @@ self.parserlib = (() => {
|
||||||
class PropertyName extends SyntaxUnit {
|
class PropertyName extends SyntaxUnit {
|
||||||
constructor(text, hack, pos) {
|
constructor(text, hack, pos) {
|
||||||
super(text, pos, TYPES.PROPERTY_NAME_TYPE);
|
super(text, pos, TYPES.PROPERTY_NAME_TYPE);
|
||||||
// type of IE hack applied ("*", "_", or null).
|
|
||||||
this.hack = hack;
|
this.hack = hack;
|
||||||
}
|
}
|
||||||
toString() {
|
toString() {
|
||||||
|
@ -2205,9 +2187,6 @@ self.parserlib = (() => {
|
||||||
* separated by commas, this type represents just one of the values.
|
* separated by commas, this type represents just one of the values.
|
||||||
*/
|
*/
|
||||||
class PropertyValue extends SyntaxUnit {
|
class PropertyValue extends SyntaxUnit {
|
||||||
/**
|
|
||||||
* @param {PropertyValuePart[]} parts An array of value parts making up this value.
|
|
||||||
*/
|
|
||||||
constructor(parts, pos) {
|
constructor(parts, pos) {
|
||||||
super(parts.join(' '), pos, TYPES.PROPERTY_VALUE_TYPE);
|
super(parts.join(' '), pos, TYPES.PROPERTY_VALUE_TYPE);
|
||||||
this.parts = parts;
|
this.parts = parts;
|
||||||
|
@ -2225,12 +2204,7 @@ self.parserlib = (() => {
|
||||||
const {value, type} = token;
|
const {value, type} = token;
|
||||||
super(value, token, TYPES.PROPERTY_VALUE_PART_TYPE);
|
super(value, token, TYPES.PROPERTY_VALUE_PART_TYPE);
|
||||||
this.tokenType = type;
|
this.tokenType = type;
|
||||||
if (token.expr) this.expr = token.expr;
|
this.expr = token.expr || null;
|
||||||
// There can be ambiguity with escape sequences in identifiers, as
|
|
||||||
// well as with "color" parts which are also "identifiers", so record
|
|
||||||
// an explicit hint when the token generating this PropertyValuePart
|
|
||||||
// was an identifier.
|
|
||||||
this.wasIdent = type === Tokens.IDENT;
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Tokens.ANGLE:
|
case Tokens.ANGLE:
|
||||||
case Tokens.DIMENSION:
|
case Tokens.DIMENSION:
|
||||||
|
@ -2286,7 +2260,6 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A utility class that allows for easy iteration over the various parts of a property value.
|
|
||||||
class PropertyValueIterator {
|
class PropertyValueIterator {
|
||||||
/**
|
/**
|
||||||
* @param {PropertyValue} value
|
* @param {PropertyValue} value
|
||||||
|
@ -2368,7 +2341,7 @@ self.parserlib = (() => {
|
||||||
|
|
||||||
/** @param {PropertyValuePart} p */
|
/** @param {PropertyValuePart} p */
|
||||||
function vtIsIdent(p) {
|
function vtIsIdent(p) {
|
||||||
return p.type === 'identifier' || p.wasIdent;
|
return p.tokenType === Tokens.IDENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {PropertyValuePart} p */
|
/** @param {PropertyValuePart} p */
|
||||||
|
@ -2684,13 +2657,19 @@ self.parserlib = (() => {
|
||||||
const reader = this._reader;
|
const reader = this._reader;
|
||||||
/** @namespace parserlib.Token */
|
/** @namespace parserlib.Token */
|
||||||
const tok = {
|
const tok = {
|
||||||
|
value: '',
|
||||||
type: Tokens.CHAR,
|
type: Tokens.CHAR,
|
||||||
col: reader._col,
|
col: reader._col,
|
||||||
line: reader._line,
|
line: reader._line,
|
||||||
offset: reader._cursor,
|
offset: reader._cursor,
|
||||||
};
|
};
|
||||||
const a = tok.value = reader.read();
|
let a = tok.value = reader.read();
|
||||||
const b = reader.peek();
|
let b = reader.peek();
|
||||||
|
if (a === '\\') {
|
||||||
|
if (b === '\n' || b === '\f') return tok;
|
||||||
|
a = this.readEscape();
|
||||||
|
b = reader.peek();
|
||||||
|
}
|
||||||
switch (a) {
|
switch (a) {
|
||||||
case ' ':
|
case ' ':
|
||||||
case '\n':
|
case '\n':
|
||||||
|
@ -2744,7 +2723,7 @@ self.parserlib = (() => {
|
||||||
case "'":
|
case "'":
|
||||||
return this.stringToken(a, tok);
|
return this.stringToken(a, tok);
|
||||||
case '#':
|
case '#':
|
||||||
if ((rxNameChar.lastIndex = 0, rxNameChar.test(b))) {
|
if (rxNameChar.test(b)) {
|
||||||
tok.type = Tokens.HASH;
|
tok.type = Tokens.HASH;
|
||||||
tok.value = this.readName(a);
|
tok.value = this.readName(a);
|
||||||
}
|
}
|
||||||
|
@ -2767,7 +2746,7 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
} else if (b >= '0' && b <= '9' || b === '.' && reader.peekTest(/\.\d/y)) {
|
} else if (b >= '0' && b <= '9' || b === '.' && reader.peekTest(/\.\d/y)) {
|
||||||
this.numberToken(a, tok);
|
this.numberToken(a, tok);
|
||||||
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(b))) {
|
} else if (rxIdentStart.test(b)) {
|
||||||
this.identOrFunctionToken(a, tok);
|
this.identOrFunctionToken(a, tok);
|
||||||
} else {
|
} else {
|
||||||
tok.type = Tokens.MINUS;
|
tok.type = Tokens.MINUS;
|
||||||
|
@ -2805,10 +2784,6 @@ self.parserlib = (() => {
|
||||||
tok.value = '<!--';
|
tok.value = '<!--';
|
||||||
}
|
}
|
||||||
return tok;
|
return tok;
|
||||||
case '\\':
|
|
||||||
return b !== '\r' && b !== '\n' && b !== '\f' ?
|
|
||||||
this.identOrFunctionToken(this.readEscape(), tok) :
|
|
||||||
tok;
|
|
||||||
// EOF
|
// EOF
|
||||||
case null:
|
case null:
|
||||||
tok.type = Tokens.EOF;
|
tok.type = Tokens.EOF;
|
||||||
|
@ -2821,7 +2796,7 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
if (a >= '0' && a <= '9') {
|
if (a >= '0' && a <= '9') {
|
||||||
this.numberToken(a, tok);
|
this.numberToken(a, tok);
|
||||||
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(a))) {
|
} else if (rxIdentStart.test(a)) {
|
||||||
this.identOrFunctionToken(a, tok);
|
this.identOrFunctionToken(a, tok);
|
||||||
} else {
|
} else {
|
||||||
tok.type = typeMap.get(a) || Tokens.CHAR;
|
tok.type = typeMap.get(a) || Tokens.CHAR;
|
||||||
|
@ -2911,7 +2886,7 @@ self.parserlib = (() => {
|
||||||
let tt = Tokens.NUMBER;
|
let tt = Tokens.NUMBER;
|
||||||
let units, type;
|
let units, type;
|
||||||
const c = reader.peek();
|
const c = reader.peek();
|
||||||
if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(c))) {
|
if (rxIdentStart.test(c)) {
|
||||||
units = this.readName(reader.read());
|
units = this.readName(reader.read());
|
||||||
type = UNITS[units] || UNITS[lower(units)];
|
type = UNITS[units] || UNITS[lower(units)];
|
||||||
tt = type && Tokens[type.toUpperCase()] ||
|
tt = type && Tokens[type.toUpperCase()] ||
|
||||||
|
@ -3063,6 +3038,76 @@ self.parserlib = (() => {
|
||||||
this._reader.readCount(2 - first.length) +
|
this._reader.readCount(2 - first.length) +
|
||||||
this._reader.readMatch(/([^*]|\*(?!\/))*(\*\/|$)/y);
|
this._reader.readMatch(/([^*]|\*(?!\/))*(\*\/|$)/y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} [omitComments]
|
||||||
|
* @param {string} [stopOn] - goes to the parent if used at the top nesting level of the value,
|
||||||
|
specifying an empty string will stop after consuming the first encountered top block.
|
||||||
|
* @returns {?string}
|
||||||
|
*/
|
||||||
|
readDeclValue({omitComments, stopOn = ';!})'} = {}) {
|
||||||
|
const reader = this._reader;
|
||||||
|
const value = [];
|
||||||
|
const endings = [];
|
||||||
|
let end = stopOn;
|
||||||
|
const rx = stopOn.includes(';')
|
||||||
|
? /([^;!'"{}()[\]/\\]|\/(?!\*))+/y
|
||||||
|
: /([^'"{}()[\]/\\]|\/(?!\*))+/y;
|
||||||
|
while (!reader.eof()) {
|
||||||
|
const chunk = reader.readMatch(rx);
|
||||||
|
if (chunk) {
|
||||||
|
value.push(chunk);
|
||||||
|
}
|
||||||
|
reader.mark();
|
||||||
|
const c = reader.read();
|
||||||
|
if (!endings.length && stopOn.includes(c)) {
|
||||||
|
reader.reset();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value.push(c);
|
||||||
|
if (c === '\\') {
|
||||||
|
value[value.length - 1] = this.readEscape();
|
||||||
|
} else if (c === '/') {
|
||||||
|
value[value.length - 1] = this.readComment(c);
|
||||||
|
if (omitComments) value.pop();
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
value[value.length - 1] = this.readString(c);
|
||||||
|
} else if (c === '{' || c === '(' || c === '[') {
|
||||||
|
endings.push(end);
|
||||||
|
end = c === '{' ? '}' : c === '(' ? ')' : ']';
|
||||||
|
} else if (c === '}' || c === ')' || c === ']') {
|
||||||
|
if (!end.includes(c)) {
|
||||||
|
reader.reset();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
end = endings.pop();
|
||||||
|
if (!end && !stopOn) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fastJoin(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
readUnknownSym() {
|
||||||
|
const reader = this._reader;
|
||||||
|
const prelude = [];
|
||||||
|
let block;
|
||||||
|
while (true) {
|
||||||
|
if (reader.eof()) this.throwUnexpected();
|
||||||
|
const c = reader.peek();
|
||||||
|
if (c === '{') {
|
||||||
|
block = this.readDeclValue({stopOn: ''});
|
||||||
|
break;
|
||||||
|
} else if (c === ';') {
|
||||||
|
reader.read();
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
prelude.push(this.readDeclValue({omitComments: true, stopOn: ';{'}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {prelude, block};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
@ -3191,9 +3236,9 @@ self.parserlib = (() => {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const {line, col} = msg;
|
const {line, col} = msg;
|
||||||
if (L1 === L2 && line === L1 && C1 <= col && col <= C2 ||
|
if (L1 === L2 && line === L1 && C1 <= col && col <= C2 ||
|
||||||
line === L1 && col >= C1 ||
|
line === L1 && col >= C1 ||
|
||||||
line === L2 && col <= C2 ||
|
line === L2 && col <= C2 ||
|
||||||
line > L1 && line < L2) {
|
line > L1 && line < L2) {
|
||||||
messages.delete(msg);
|
messages.delete(msg);
|
||||||
isClean = false;
|
isClean = false;
|
||||||
}
|
}
|
||||||
|
@ -3347,11 +3392,13 @@ self.parserlib = (() => {
|
||||||
class Parser extends EventTarget {
|
class Parser extends EventTarget {
|
||||||
/**
|
/**
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {Boolean} [options.starHack] - allows IE6 star hack
|
* @param {boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing
|
||||||
* @param {Boolean} [options.underscoreHack] - interprets leading underscores as IE6-7 for known properties
|
* @param {boolean} [options.skipValidation] - skip syntax validation
|
||||||
* @param {Boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing syntax errors
|
* @param {boolean} [options.starHack] - allows IE6 star hack
|
||||||
* @param {Boolean} [options.strict] - stop on errors instead of reporting them and continuing
|
* @param {boolean} [options.strict] - stop on errors instead of reporting them and continuing
|
||||||
* @param {Boolean} [options.skipValidation] - skip syntax validation
|
* @param {boolean} [options.topDocOnly] - quickly extract all top-level @-moz-document,
|
||||||
|
their {}-block contents is retrieved as text using _simpleBlock()
|
||||||
|
* @param {boolean} [options.underscoreHack] - interprets leading _ as IE6-7 for known props
|
||||||
*/
|
*/
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super();
|
super();
|
||||||
|
@ -3361,14 +3408,12 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {String|{type: string, ...}} event
|
* @param {string|Object} event
|
||||||
* @param {Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
|
* @param {parserlib.Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
|
||||||
*/
|
*/
|
||||||
fire(event, token = this._tokenStream._token) {
|
fire(event, token = this._tokenStream._token) {
|
||||||
if (typeof event === 'string') {
|
if (typeof event === 'string') {
|
||||||
event = {type: event};
|
event = {type: event};
|
||||||
} else if (event.message && event.message.includes('/*[[')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (event.offset === undefined && token) {
|
if (event.offset === undefined && token) {
|
||||||
event.offset = token.offset;
|
event.offset = token.offset;
|
||||||
|
@ -3393,18 +3438,28 @@ self.parserlib = (() => {
|
||||||
this._skipCruft();
|
this._skipCruft();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let tt, token; (tt = (token = stream.LT(1)).type) > Tokens.EOF; this._skipCruft()) {
|
const {topDocOnly} = this.options;
|
||||||
|
const allowedActions = topDocOnly ? Parser.ACTIONS.topDoc : Parser.ACTIONS.stylesheet;
|
||||||
|
for (let tt, token; (tt = (token = stream.get(true)).type); this._skipCruft()) {
|
||||||
try {
|
try {
|
||||||
let action = Parser.ACTIONS.stylesheet.get(tt);
|
let action = allowedActions.get(tt);
|
||||||
if (action) {
|
if (action) {
|
||||||
action.call(this, stream.get(true));
|
action.call(this, token);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
action = Parser.ACTIONS.stylesheetMisplaced.get(tt);
|
action = Parser.ACTIONS.stylesheetMisplaced.get(tt);
|
||||||
if (action) {
|
if (action) {
|
||||||
action.call(this, stream.get(true), true);
|
action.call(this, token, true);
|
||||||
throw new SyntaxError(Tokens[tt].text + ' not allowed here.', token);
|
throw new SyntaxError(Tokens[tt].text + ' not allowed here.', token);
|
||||||
}
|
}
|
||||||
|
if (topDocOnly) {
|
||||||
|
stream.readDeclValue({stopOn: '{}'});
|
||||||
|
if (stream._reader.peek() === '{') {
|
||||||
|
stream.readDeclValue({stopOn: ''});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stream.unget();
|
||||||
if (!this._ruleset() && stream.peek() !== Tokens.EOF) {
|
if (!this._ruleset() && stream.peek() !== Tokens.EOF) {
|
||||||
stream.throwUnexpected(stream.get(true));
|
stream.throwUnexpected(stream.get(true));
|
||||||
}
|
}
|
||||||
|
@ -3677,10 +3732,14 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
stream.mustMatch(Tokens.LBRACE);
|
stream.mustMatch(Tokens.LBRACE);
|
||||||
this.fire({type: 'startdocument', functions, prefix}, start);
|
this.fire({type: 'startdocument', functions, prefix}, start);
|
||||||
this._ws();
|
if (this.options.topDocOnly) {
|
||||||
let action;
|
stream.readDeclValue({stopOn: '}'});
|
||||||
do action = Parser.ACTIONS.document.get(stream.peek());
|
} else {
|
||||||
while (action ? action.call(this, stream.get(true)) || true : this._ruleset());
|
this._ws();
|
||||||
|
let action;
|
||||||
|
do action = Parser.ACTIONS.document.get(stream.peek());
|
||||||
|
while (action ? action.call(this, stream.get(true)) || true : this._ruleset());
|
||||||
|
}
|
||||||
stream.mustMatch(Tokens.RBRACE);
|
stream.mustMatch(Tokens.RBRACE);
|
||||||
this.fire({type: 'enddocument', functions, prefix});
|
this.fire({type: 'enddocument', functions, prefix});
|
||||||
this._ws();
|
this._ws();
|
||||||
|
@ -3960,22 +4019,11 @@ self.parserlib = (() => {
|
||||||
|
|
||||||
_negation(start) {
|
_negation(start) {
|
||||||
const stream = this._tokenStream;
|
const stream = this._tokenStream;
|
||||||
let value = start.value + this._ws();
|
const value = [start.value, this._ws()];
|
||||||
const args = this._selectorsGroup();
|
const args = this._selectorsGroup();
|
||||||
if (!args) stream.throwUnexpected(stream.LT(1));
|
if (!args) stream.throwUnexpected(stream.LT(1));
|
||||||
const arg = args[0];
|
value.push(...args, this._ws(), stream.mustMatch(Tokens.RPAREN).value);
|
||||||
const parts = arg.parts;
|
return Object.assign(new SelectorSubPart(fastJoin(value), 'not', start), {args});
|
||||||
if (args.length > 1 ||
|
|
||||||
parts.length !== 1 ||
|
|
||||||
parts[0].modifiers.length + (parts[0].elementName ? 1 : 0) > 1 ||
|
|
||||||
/^:not\b/i.test(parts[0])) {
|
|
||||||
this.fire({
|
|
||||||
type: 'warning',
|
|
||||||
message: `Simple selector expected, but found '${args.join(', ')}'`,
|
|
||||||
}, arg);
|
|
||||||
}
|
|
||||||
value += arg + this._ws() + stream.mustMatch(Tokens.RPAREN).value;
|
|
||||||
return Object.assign(new SelectorSubPart(value, 'not', start), {args: [arg]});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_declaration(consumeSemicolon) {
|
_declaration(consumeSemicolon) {
|
||||||
|
@ -4061,66 +4109,13 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
_customProperty() {
|
_customProperty() {
|
||||||
const stream = this._tokenStream;
|
const value = this._tokenStream.readDeclValue();
|
||||||
const reader = stream._reader;
|
if (value) {
|
||||||
const value = [];
|
const token = this._tokenStream._token;
|
||||||
// These chars belong to the parent if used at the top nesting level of the property's value
|
token.value = value;
|
||||||
const UNGET = ';!})';
|
token.type = Tokens.IDENT;
|
||||||
let end = UNGET;
|
return new PropertyValue([new PropertyValuePart(token)], token);
|
||||||
const endings = [];
|
|
||||||
readValue:
|
|
||||||
while (!reader.eof()) {
|
|
||||||
const chunk = reader.readMatch(/([^;!'"{}()[\]/]|\/(?!\*))+/y);
|
|
||||||
if (chunk) {
|
|
||||||
value.push(chunk);
|
|
||||||
}
|
|
||||||
reader.mark();
|
|
||||||
const c = reader.read();
|
|
||||||
value.push(c);
|
|
||||||
switch (c) {
|
|
||||||
case '/':
|
|
||||||
value[value.length - 1] = stream.readComment(c);
|
|
||||||
continue;
|
|
||||||
case '"':
|
|
||||||
case "'":
|
|
||||||
value[value.length - 1] = stream.readString(c);
|
|
||||||
continue;
|
|
||||||
case '{':
|
|
||||||
case '(':
|
|
||||||
case '[':
|
|
||||||
endings.push(end);
|
|
||||||
end = c === '{' ? '}' : c === '(' ? ')' : ']';
|
|
||||||
continue;
|
|
||||||
case ';':
|
|
||||||
case '!':
|
|
||||||
if (endings.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
reader.reset();
|
|
||||||
// fallthrough
|
|
||||||
case '}':
|
|
||||||
case ')':
|
|
||||||
case ']':
|
|
||||||
if (!end.includes(c)) {
|
|
||||||
reader.reset();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
end = endings.pop();
|
|
||||||
if (end) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (UNGET.includes(c)) {
|
|
||||||
reader.reset();
|
|
||||||
value.pop();
|
|
||||||
}
|
|
||||||
break readValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!value[0]) return null;
|
|
||||||
const token = stream._token;
|
|
||||||
token.value = fastJoin(value);
|
|
||||||
token.type = Tokens.IDENT;
|
|
||||||
return new PropertyValue([new PropertyValuePart(token)], token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_term(inFunction) {
|
_term(inFunction) {
|
||||||
|
@ -4400,64 +4395,12 @@ self.parserlib = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
_unknownSym(start) {
|
_unknownSym(start) {
|
||||||
const stream = this._tokenStream;
|
|
||||||
if (this.options.strict) {
|
if (this.options.strict) {
|
||||||
throw new SyntaxError('Unknown @ rule.', start);
|
throw new SyntaxError('Unknown @ rule.', start);
|
||||||
}
|
}
|
||||||
|
const {prelude, block} = this._tokenStream.readUnknownSym();
|
||||||
|
this.fire({type: 'unknown-at-rule', name: start.value, prelude, block}, start);
|
||||||
this._ws();
|
this._ws();
|
||||||
const simpleValue =
|
|
||||||
stream.match(Tokens.IDENT) && SyntaxUnit.fromToken(stream._token) ||
|
|
||||||
stream.peek() === Tokens.FUNCTION && this._function({asText: true}) ||
|
|
||||||
this._unknownBlock(TT.LParenBracket);
|
|
||||||
this._ws();
|
|
||||||
const blockValue = this._unknownBlock();
|
|
||||||
if (!blockValue) {
|
|
||||||
stream.match(Tokens.SEMICOLON);
|
|
||||||
}
|
|
||||||
this.fire({
|
|
||||||
type: 'unknown-at-rule',
|
|
||||||
name: start.value,
|
|
||||||
simpleValue,
|
|
||||||
blockValue,
|
|
||||||
}, start);
|
|
||||||
this._ws();
|
|
||||||
}
|
|
||||||
|
|
||||||
_unknownBlock(canStartWith = [Tokens.LBRACE]) {
|
|
||||||
const stream = this._tokenStream;
|
|
||||||
if (!canStartWith.includes(stream.peek())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
stream.get();
|
|
||||||
const start = stream._token;
|
|
||||||
const reader = stream._reader;
|
|
||||||
reader.mark();
|
|
||||||
reader._cursor = start.offset;
|
|
||||||
reader._line = start.line;
|
|
||||||
reader._col = start.col;
|
|
||||||
const value = [];
|
|
||||||
const endings = [];
|
|
||||||
let blockEnd;
|
|
||||||
while (!reader.eof()) {
|
|
||||||
const chunk = reader.readMatch(/[^{}()[\]]*[{}()[\]]?/y);
|
|
||||||
const c = chunk.slice(-1);
|
|
||||||
value.push(chunk);
|
|
||||||
if (c === '{' || c === '(' || c === '[') {
|
|
||||||
endings.push(blockEnd);
|
|
||||||
blockEnd = c === '{' ? '}' : c === '(' ? ')' : ']';
|
|
||||||
} else if (c === '}' || c === ')' || c === ']') {
|
|
||||||
if (c !== blockEnd) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
blockEnd = endings.pop();
|
|
||||||
if (!blockEnd) {
|
|
||||||
stream.resetLT();
|
|
||||||
return new SyntaxUnit(fastJoin(value), start);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.reset();
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_verifyEnd() {
|
_verifyEnd() {
|
||||||
|
@ -4577,6 +4520,12 @@ self.parserlib = (() => {
|
||||||
[Tokens.NAMESPACE_SYM, Parser.prototype._namespace],
|
[Tokens.NAMESPACE_SYM, Parser.prototype._namespace],
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
topDoc: new Map([
|
||||||
|
symDocument,
|
||||||
|
symUnknown,
|
||||||
|
[Tokens.S, Parser.prototype._ws],
|
||||||
|
]),
|
||||||
|
|
||||||
document: new Map([
|
document: new Map([
|
||||||
symMedia,
|
symMedia,
|
||||||
symDocMisplaced,
|
symDocMisplaced,
|
|
@ -1,19 +1,22 @@
|
||||||
/* global
|
/* global $ $create $createLink $remove messageBoxProxy setupLivePrefs */// dom.js
|
||||||
$
|
/* global API */// msg.js
|
||||||
$create
|
/* global debounce deepCopy */// toolbox.js
|
||||||
$createLink
|
/* global messageBox */
|
||||||
API
|
/* global prefs */
|
||||||
debounce
|
/* global t */// localization.js
|
||||||
deepCopy
|
|
||||||
messageBox
|
|
||||||
prefs
|
|
||||||
setupLivePrefs
|
|
||||||
t
|
|
||||||
*/
|
|
||||||
/* exported configDialog */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function configDialog(style) {
|
/* exported configDialog */
|
||||||
|
async function configDialog(style) {
|
||||||
|
|
||||||
|
await require([
|
||||||
|
'/js/color/color-converter',
|
||||||
|
'/js/color/color-mimicry',
|
||||||
|
'/js/color/color-picker',
|
||||||
|
'/js/color/color-picker.css',
|
||||||
|
'/js/dlg/config-dialog.css',
|
||||||
|
]);
|
||||||
|
|
||||||
const AUTOSAVE_DELAY = 500;
|
const AUTOSAVE_DELAY = 500;
|
||||||
let saving = false;
|
let saving = false;
|
||||||
|
|
||||||
|
@ -32,7 +35,7 @@ function configDialog(style) {
|
||||||
renderValues();
|
renderValues();
|
||||||
vars.forEach(renderValueState);
|
vars.forEach(renderValueState);
|
||||||
|
|
||||||
return messageBox({
|
return messageBoxProxy.show({
|
||||||
title: `${style.customName || style.name} v${data.version}`,
|
title: `${style.customName || style.name} v${data.version}`,
|
||||||
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
|
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
|
||||||
contents: [
|
contents: [
|
||||||
|
@ -82,7 +85,7 @@ function configDialog(style) {
|
||||||
adjustSizeForPopup(box);
|
adjustSizeForPopup(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
box.addEventListener('change', onchange);
|
box.on('change', onchange);
|
||||||
buttons.save = $('[data-cmd="save"]', box);
|
buttons.save = $('[data-cmd="save"]', box);
|
||||||
buttons.default = $('[data-cmd="default"]', box);
|
buttons.default = $('[data-cmd="default"]', box);
|
||||||
buttons.close = $('[data-cmd="close"]', box);
|
buttons.close = $('[data-cmd="close"]', box);
|
||||||
|
@ -118,20 +121,18 @@ function configDialog(style) {
|
||||||
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
|
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
|
||||||
}
|
}
|
||||||
|
|
||||||
function save({anyChangeIsDirty = false} = {}, bgStyle) {
|
async function save({anyChangeIsDirty = false} = {}, bgStyle) {
|
||||||
if (saving) {
|
for (let delay = 1; saving && delay < 1000; delay *= 2) {
|
||||||
debounce(save, 0, ...arguments);
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!vars.length ||
|
if (saving) {
|
||||||
!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
|
throw 'Could not save: still saving previous results...';
|
||||||
|
}
|
||||||
|
if (!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!bgStyle) {
|
if (!bgStyle) {
|
||||||
API.getStyle(style.id, true)
|
bgStyle = await API.styles.get(style.id).catch(() => ({}));
|
||||||
.catch(() => ({}))
|
|
||||||
.then(bgStyle => save({anyChangeIsDirty}, bgStyle));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
style = style.sections ? Object.assign({}, style) : style;
|
style = style.sections ? Object.assign({}, style) : style;
|
||||||
style.enabled = true;
|
style.enabled = true;
|
||||||
|
@ -147,13 +148,12 @@ function configDialog(style) {
|
||||||
if (!bgva) {
|
if (!bgva) {
|
||||||
error = 'deleted';
|
error = 'deleted';
|
||||||
delete styleVars[va.name];
|
delete styleVars[va.name];
|
||||||
} else
|
} else if (bgva.type !== va.type) {
|
||||||
if (bgva.type !== va.type) {
|
|
||||||
error = ['type ', '*' + va.type, ' != ', '*' + bgva.type];
|
error = ['type ', '*' + va.type, ' != ', '*' + bgva.type];
|
||||||
} else
|
} else if (
|
||||||
if ((va.type === 'select' || va.type === 'dropdown') &&
|
(va.type === 'select' || va.type === 'dropdown') &&
|
||||||
!isDefault(va) &&
|
!isDefault(va) && bgva.options.every(o => o.name !== va.value)
|
||||||
bgva.options.every(o => o.name !== va.value)) {
|
) {
|
||||||
error = `'${va.value}' not in the updated '${va.type}' list`;
|
error = `'${va.value}' not in the updated '${va.type}' list`;
|
||||||
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
|
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
|
||||||
continue;
|
continue;
|
||||||
|
@ -182,22 +182,22 @@ function configDialog(style) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
return API.configUsercssVars(style.id, style.usercssData.vars)
|
try {
|
||||||
.then(newVars => {
|
const newVars = await API.usercss.configVars(style.id, style.usercssData.vars);
|
||||||
varsInitial = getInitialValues(newVars);
|
varsInitial = getInitialValues(newVars);
|
||||||
vars.forEach(va => onchange({target: va.input, justSaved: true}));
|
vars.forEach(va => onchange({target: va.input, justSaved: true}));
|
||||||
renderValues();
|
renderValues();
|
||||||
updateButtons();
|
updateButtons();
|
||||||
$.remove('.config-error');
|
$remove('.config-error');
|
||||||
})
|
} catch (errors) {
|
||||||
.catch(errors => {
|
const el = $('.config-error', messageBox.element) ||
|
||||||
const el = $('.config-error', messageBox.element) ||
|
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
|
||||||
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
|
el.textContent =
|
||||||
el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors.message || String(errors);
|
el.title = (Array.isArray(errors) ? errors : [errors])
|
||||||
})
|
.map(e => e.message || `${e}`)
|
||||||
.then(() => {
|
.join('\n');
|
||||||
saving = false;
|
}
|
||||||
});
|
saving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDefault() {
|
function useDefault() {
|
||||||
|
@ -401,7 +401,7 @@ function configDialog(style) {
|
||||||
|
|
||||||
function showColorpicker(event) {
|
function showColorpicker(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.removeEventListener('keydown', messageBox.listeners.key, true);
|
window.off('keydown', messageBox.listeners.key, true);
|
||||||
const box = $('#message-box-contents');
|
const box = $('#message-box-contents');
|
||||||
colorpicker.show({
|
colorpicker.show({
|
||||||
va: this.va,
|
va: this.va,
|
||||||
|
@ -424,7 +424,7 @@ function configDialog(style) {
|
||||||
|
|
||||||
function restoreEscInDialog() {
|
function restoreEscInDialog() {
|
||||||
if (!$('.colorpicker-popup') && messageBox.element) {
|
if (!$('.colorpicker-popup') && messageBox.element) {
|
||||||
window.addEventListener('keydown', messageBox.listeners.key, true);
|
window.on('keydown', messageBox.listeners.key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -434,12 +434,12 @@ function configDialog(style) {
|
||||||
let {offsetWidth: width, offsetHeight: height} = contents;
|
let {offsetWidth: width, offsetHeight: height} = contents;
|
||||||
contents.style = '';
|
contents.style = '';
|
||||||
|
|
||||||
const colorpicker = document.body.appendChild(
|
const elPicker = document.body.appendChild(
|
||||||
$create('.colorpicker-popup', {style: 'display: none!important'}));
|
$create('.colorpicker-popup', {style: 'display: none!important'}));
|
||||||
const PADDING = 50;
|
const PADDING = 50;
|
||||||
const MIN_WIDTH = parseFloat(getComputedStyle(colorpicker).width) || 350;
|
const MIN_WIDTH = parseFloat(getComputedStyle(elPicker).width) || 350;
|
||||||
const MIN_HEIGHT = 250 + PADDING;
|
const MIN_HEIGHT = 250 + PADDING;
|
||||||
colorpicker.remove();
|
elPicker.remove();
|
||||||
|
|
||||||
width = constrain(MIN_WIDTH, 798, width + PADDING);
|
width = constrain(MIN_WIDTH, 798, width + PADDING);
|
||||||
height = constrain(MIN_HEIGHT, 598, height + PADDING);
|
height = constrain(MIN_HEIGHT, 598, height + PADDING);
|
|
@ -1,13 +1,17 @@
|
||||||
/* global
|
/* global $ $create animateElement focusAccessibility moveFocus */// dom.js
|
||||||
$
|
/* global t */// localization.js
|
||||||
$create
|
|
||||||
animateElement
|
|
||||||
focusAccessibility
|
|
||||||
moveFocus
|
|
||||||
t
|
|
||||||
*/
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// TODO: convert this singleton mess so we can show many boxes at once
|
||||||
|
/* global messageBox */
|
||||||
|
window.messageBox = {
|
||||||
|
element: null,
|
||||||
|
listeners: null,
|
||||||
|
_blockScroll: null,
|
||||||
|
_originalFocus: null,
|
||||||
|
_resolve: null,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {String} params.title
|
* @param {String} params.title
|
||||||
|
@ -26,22 +30,25 @@
|
||||||
* resolves to an object with optionally present properties depending on the interaction:
|
* resolves to an object with optionally present properties depending on the interaction:
|
||||||
* {button: Number, enter: Boolean, esc: Boolean}
|
* {button: Number, enter: Boolean, esc: Boolean}
|
||||||
*/
|
*/
|
||||||
function messageBox({
|
messageBox.show = async ({
|
||||||
title,
|
title,
|
||||||
contents,
|
contents,
|
||||||
className = '',
|
className = '',
|
||||||
buttons = [],
|
buttons = [],
|
||||||
onshow,
|
onshow,
|
||||||
blockScroll,
|
blockScroll,
|
||||||
}) {
|
}) => {
|
||||||
initOwnListeners();
|
await require(['/js/dlg/message-box.css']);
|
||||||
|
if (!messageBox.listeners) initOwnListeners();
|
||||||
bindGlobalListeners();
|
bindGlobalListeners();
|
||||||
createElement();
|
createElement();
|
||||||
document.body.appendChild(messageBox.element);
|
document.body.appendChild(messageBox.element);
|
||||||
|
|
||||||
messageBox.originalFocus = document.activeElement;
|
messageBox._originalFocus = document.activeElement;
|
||||||
// skip external links like feedback
|
// focus the first focusable child but skip the first external link which is usually `feedback`
|
||||||
while ((moveFocus(messageBox.element, 1) || {}).target === '_blank') {/*NOP*/}
|
if ((moveFocus(messageBox.element, 0) || {}).target === '_blank') {
|
||||||
|
moveFocus(messageBox.element, 1);
|
||||||
|
}
|
||||||
// suppress focus outline when invoked via click
|
// suppress focus outline when invoked via click
|
||||||
if (focusAccessibility.lastFocusedViaClick && document.activeElement) {
|
if (focusAccessibility.lastFocusedViaClick && document.activeElement) {
|
||||||
document.activeElement.dataset.focusedViaClick = '';
|
document.activeElement.dataset.focusedViaClick = '';
|
||||||
|
@ -56,12 +63,12 @@ function messageBox({
|
||||||
$('#message-box-close-icon').hidden = true;
|
$('#message-box-close-icon').hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise(_resolve => {
|
return new Promise(resolve => {
|
||||||
messageBox.resolve = _resolve;
|
messageBox._resolve = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
function initOwnListeners() {
|
function initOwnListeners() {
|
||||||
messageBox.listeners = messageBox.listeners || {
|
messageBox.listeners = {
|
||||||
closeIcon() {
|
closeIcon() {
|
||||||
resolveWith({button: -1});
|
resolveWith({button: -1});
|
||||||
},
|
},
|
||||||
|
@ -93,18 +100,18 @@ function messageBox({
|
||||||
resolveWith(key === 'Enter' ? {enter: true} : {esc: true});
|
resolveWith(key === 'Enter' ? {enter: true} : {esc: true});
|
||||||
},
|
},
|
||||||
scroll() {
|
scroll() {
|
||||||
scrollTo(blockScroll.x, blockScroll.y);
|
scrollTo(messageBox._blockScroll.x, messageBox._blockScroll.y);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWith(value) {
|
function resolveWith(value) {
|
||||||
|
setTimeout(messageBox._resolve, 0, value);
|
||||||
unbindGlobalListeners();
|
unbindGlobalListeners();
|
||||||
setTimeout(messageBox.resolve, 0, value);
|
|
||||||
animateElement(messageBox.element, 'fadeout')
|
animateElement(messageBox.element, 'fadeout')
|
||||||
.then(removeSelf);
|
.then(removeSelf);
|
||||||
if (messageBox.element.contains(document.activeElement)) {
|
if (messageBox.element.contains(document.activeElement)) {
|
||||||
messageBox.originalFocus.focus();
|
messageBox._originalFocus.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,33 +144,33 @@ function messageBox({
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindGlobalListeners() {
|
function bindGlobalListeners() {
|
||||||
blockScroll = blockScroll && {x: scrollX, y: scrollY};
|
messageBox._blockScroll = blockScroll && {x: scrollX, y: scrollY};
|
||||||
if (blockScroll) {
|
if (blockScroll) {
|
||||||
window.addEventListener('scroll', messageBox.listeners.scroll);
|
window.on('scroll', messageBox.listeners.scroll, {passive: false});
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', messageBox.listeners.key, true);
|
window.on('keydown', messageBox.listeners.key, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unbindGlobalListeners() {
|
function unbindGlobalListeners() {
|
||||||
window.removeEventListener('keydown', messageBox.listeners.key, true);
|
window.off('keydown', messageBox.listeners.key, true);
|
||||||
window.removeEventListener('scroll', messageBox.listeners.scroll);
|
window.off('scroll', messageBox.listeners.scroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSelf() {
|
function removeSelf() {
|
||||||
messageBox.element.remove();
|
messageBox.element.remove();
|
||||||
messageBox.element = null;
|
messageBox.element = null;
|
||||||
messageBox.resolve = null;
|
messageBox._resolve = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {String|Node|Array<String|Node>} contents
|
* @param {String|Node|Array<String|Node>} contents
|
||||||
* @param {String} [className] like 'pre' for monospace font
|
* @param {String} [className] like 'pre' for monospace font
|
||||||
* @param {String} [title]
|
* @param {String} [title]
|
||||||
* @returns {Promise<Boolean>} same as messageBox
|
* @returns {Promise<Boolean>} same as show()
|
||||||
*/
|
*/
|
||||||
messageBox.alert = (contents, className, title) =>
|
messageBox.alert = (contents, className, title) =>
|
||||||
messageBox({
|
messageBox.show({
|
||||||
title,
|
title,
|
||||||
contents,
|
contents,
|
||||||
className: `center ${className || ''}`,
|
className: `center ${className || ''}`,
|
||||||
|
@ -176,10 +183,12 @@ messageBox.alert = (contents, className, title) =>
|
||||||
* @param {String} [title]
|
* @param {String} [title]
|
||||||
* @returns {Promise<Boolean>} resolves to true when confirmed
|
* @returns {Promise<Boolean>} resolves to true when confirmed
|
||||||
*/
|
*/
|
||||||
messageBox.confirm = (contents, className, title) =>
|
messageBox.confirm = async (contents, className, title) => {
|
||||||
messageBox({
|
const res = await messageBox.show({
|
||||||
title,
|
title,
|
||||||
contents,
|
contents,
|
||||||
className: `center ${className || ''}`,
|
className: `center ${className || ''}`,
|
||||||
buttons: [t('confirmYes'), t('confirmNo')],
|
buttons: [t('confirmYes'), t('confirmNo')],
|
||||||
}).then(result => result.button === 0 || result.enter);
|
});
|
||||||
|
return res.button === 0 || res.enter;
|
||||||
|
};
|
797
js/dom.js
797
js/dom.js
|
@ -1,139 +1,201 @@
|
||||||
|
/* global debounce */// toolbox.js
|
||||||
/* global prefs */
|
/* global prefs */
|
||||||
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
|
|
||||||
setupLivePrefs moveFocus */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (!/^Win\d+/.test(navigator.platform)) {
|
/* exported
|
||||||
document.documentElement.classList.add('non-windows');
|
$$remove
|
||||||
}
|
$createLink
|
||||||
|
$isTextInput
|
||||||
|
animateElement
|
||||||
|
getEventKeyName
|
||||||
|
messageBoxProxy
|
||||||
|
moveFocus
|
||||||
|
scrollElementIntoView
|
||||||
|
setupLivePrefs
|
||||||
|
*/
|
||||||
|
|
||||||
Object.assign(EventTarget.prototype, {
|
Object.assign(EventTarget.prototype, {
|
||||||
on: addEventListener,
|
on: addEventListener,
|
||||||
off: removeEventListener,
|
off: removeEventListener,
|
||||||
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
|
});
|
||||||
onOff(enable, ...args) {
|
|
||||||
(enable ? addEventListener : removeEventListener).apply(this, args);
|
//#region Exports
|
||||||
|
|
||||||
|
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
|
||||||
|
const focusAccessibility = {
|
||||||
|
// last event's focusedViaClick
|
||||||
|
lastFocusedViaClick: false,
|
||||||
|
// to avoid a full layout recalc due to changes on body/root
|
||||||
|
// we modify the closest focusable element (like input or button or anything with tabindex=0)
|
||||||
|
closest(el) {
|
||||||
|
let labelSeen;
|
||||||
|
for (; el; el = el.parentElement) {
|
||||||
|
if (el.localName === 'label' && el.control && !labelSeen) {
|
||||||
|
el = el.control;
|
||||||
|
labelSeen = true;
|
||||||
|
}
|
||||||
|
if (el.tabIndex >= 0) return el;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autoloads message-box.js
|
||||||
|
* @alias messageBox
|
||||||
|
*/
|
||||||
|
window.messageBoxProxy = new Proxy({}, {
|
||||||
|
get(_, name) {
|
||||||
|
return async (...args) => {
|
||||||
|
await require([
|
||||||
|
'/js/dlg/message-box', /* global messageBox */
|
||||||
|
'/js/dlg/message-box.css',
|
||||||
|
]);
|
||||||
|
window.messageBoxProxy = messageBox;
|
||||||
|
return messageBox[name](...args);
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
$.isTextInput = (el = {}) =>
|
function $(selector, base = document) {
|
||||||
el.localName === 'textarea' ||
|
// we have ids with . like #manage.onlyEnabled which looks like #id.class
|
||||||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
|
// so since getElementById is superfast we'll try it anyway
|
||||||
|
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
|
||||||
|
return byId || base.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
$.remove = (selector, base = document) => {
|
function $$(selector, base = document) {
|
||||||
|
return [...base.querySelectorAll(selector)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function $isTextInput(el = {}) {
|
||||||
|
return el.localName === 'textarea' ||
|
||||||
|
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function $remove(selector, base = document) {
|
||||||
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
|
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
|
||||||
if (el) {
|
if (el) {
|
||||||
el.remove();
|
el.remove();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
$$.remove = (selector, base = document) => {
|
function $$remove(selector, base = document) {
|
||||||
for (const el of base.querySelectorAll(selector)) {
|
for (const el of base.querySelectorAll(selector)) {
|
||||||
el.remove();
|
el.remove();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
{
|
/*
|
||||||
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
|
$create('tag#id.class.class', ?[children])
|
||||||
const addTooltipsToEllipsized = () => {
|
$create('tag#id.class.class', ?textContentOrChildNode)
|
||||||
for (const btn of document.getElementsByTagName('button')) {
|
$create('tag#id.class.class', {properties}, ?[children])
|
||||||
if (btn.title && !btn.titleIsForEllipsis) {
|
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
|
||||||
continue;
|
tag is 'div' by default, #id and .class are optional
|
||||||
|
|
||||||
|
$create([children])
|
||||||
|
|
||||||
|
$create({propertiesAndOptions})
|
||||||
|
$create({propertiesAndOptions}, ?[children])
|
||||||
|
tag: string, default 'div'
|
||||||
|
appendChild: element/string or an array of elements/strings
|
||||||
|
dataset: object
|
||||||
|
any DOM property: assigned as is
|
||||||
|
|
||||||
|
tag may include namespace like 'ns:tag'
|
||||||
|
*/
|
||||||
|
function $create(selector = 'div', properties, children) {
|
||||||
|
let ns, tag, opt;
|
||||||
|
if (typeof selector === 'string') {
|
||||||
|
if (Array.isArray(properties) ||
|
||||||
|
properties instanceof Node ||
|
||||||
|
typeof properties !== 'object') {
|
||||||
|
opt = {};
|
||||||
|
children = properties;
|
||||||
|
} else {
|
||||||
|
opt = properties || {};
|
||||||
|
children = children || opt.appendChild;
|
||||||
|
}
|
||||||
|
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
|
||||||
|
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
|
||||||
|
const id = selector.slice(idStart + 1, classStart);
|
||||||
|
if (id) {
|
||||||
|
opt.id = id;
|
||||||
|
}
|
||||||
|
const cls = selector.slice(classStart + 1);
|
||||||
|
if (cls) {
|
||||||
|
opt[selector.includes(':') ? 'class' : 'className'] =
|
||||||
|
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
|
||||||
|
}
|
||||||
|
tag = selector.slice(0, Math.min(idStart, classStart));
|
||||||
|
} else if (Array.isArray(selector)) {
|
||||||
|
tag = 'div';
|
||||||
|
opt = {};
|
||||||
|
children = selector;
|
||||||
|
} else {
|
||||||
|
opt = selector;
|
||||||
|
tag = opt.tag;
|
||||||
|
children = opt.appendChild || properties;
|
||||||
|
}
|
||||||
|
if (tag && tag.includes(':')) {
|
||||||
|
[ns, tag] = tag.split(':');
|
||||||
|
if (ns === 'SVG' || ns === 'svg') {
|
||||||
|
ns = 'http://www.w3.org/2000/svg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const element = ns ? document.createElementNS(ns, tag) :
|
||||||
|
tag === 'fragment' ? document.createDocumentFragment() :
|
||||||
|
document.createElement(tag || 'div');
|
||||||
|
for (const child of Array.isArray(children) ? children : [children]) {
|
||||||
|
if (child) {
|
||||||
|
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, val] of Object.entries(opt)) {
|
||||||
|
switch (key) {
|
||||||
|
case 'dataset':
|
||||||
|
Object.assign(element.dataset, val);
|
||||||
|
break;
|
||||||
|
case 'attributes':
|
||||||
|
Object.entries(val).forEach(attr => element.setAttribute(...attr));
|
||||||
|
break;
|
||||||
|
case 'style': {
|
||||||
|
const t = typeof val;
|
||||||
|
if (t === 'string') element.style.cssText = val;
|
||||||
|
if (t === 'object') Object.assign(element.style, val);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
const width = btn.offsetWidth;
|
case 'tag':
|
||||||
if (!width || btn.preresizeClientWidth === width) {
|
case 'appendChild':
|
||||||
continue;
|
break;
|
||||||
}
|
default: {
|
||||||
btn.preresizeClientWidth = width;
|
if (ns) {
|
||||||
if (btn.scrollWidth > width) {
|
const i = key.indexOf(':') + 1;
|
||||||
const text = btn.textContent;
|
const attrNS = i && `http://www.w3.org/1999/${key.slice(0, i - 1)}`;
|
||||||
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
|
element.setAttributeNS(attrNS || null, key, val);
|
||||||
btn.titleIsForEllipsis = true;
|
} else {
|
||||||
} else if (btn.title) {
|
element[key] = val;
|
||||||
btn.title = '';
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function $createLink(href = '', content) {
|
||||||
|
const opt = {
|
||||||
|
tag: 'a',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener',
|
||||||
};
|
};
|
||||||
// enqueue after DOMContentLoaded/load events
|
if (typeof href === 'object') {
|
||||||
setTimeout(addTooltipsToEllipsized, 500);
|
Object.assign(opt, href);
|
||||||
// throttle on continuous resizing
|
} else {
|
||||||
let timer;
|
opt.href = href;
|
||||||
window.on('resize', () => {
|
}
|
||||||
clearTimeout(timer);
|
opt.appendChild = opt.appendChild || content;
|
||||||
timer = setTimeout(addTooltipsToEllipsized, 100);
|
return $create(opt);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {HTMLElement} el
|
||||||
* @param {string} [cls] - class name that defines or starts an animation
|
* @param {string} [cls] - class name that defines or starts an animation
|
||||||
|
@ -162,234 +224,19 @@ function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEventKeyName(e, letterAsCode) {
|
||||||
function enforceInputRange(element) {
|
const mods =
|
||||||
const min = Number(element.min);
|
(e.shiftKey ? 'Shift-' : '') +
|
||||||
const max = Number(element.max);
|
(e.ctrlKey ? 'Ctrl-' : '') +
|
||||||
const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true}));
|
(e.altKey ? 'Alt-' : '') +
|
||||||
const onChange = ({type}) => {
|
(e.metaKey ? 'Meta-' : '');
|
||||||
if (type === 'input' && element.checkValidity()) {
|
return `${
|
||||||
doNotify();
|
mods === e.key + '-' ? '' : mods
|
||||||
} else if (type === 'change' && !element.checkValidity()) {
|
}${
|
||||||
element.value = Math.max(min, Math.min(max, Number(element.value)));
|
e.key
|
||||||
doNotify();
|
? e.key.length === 1 && letterAsCode ? e.code : e.key
|
||||||
}
|
: 'Mouse' + ('LMR'[e.button] || e.button)
|
||||||
};
|
}`;
|
||||||
element.on('change', onChange);
|
|
||||||
element.on('input', onChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function $(selector, base = document) {
|
|
||||||
// we have ids with . like #manage.onlyEnabled which looks like #id.class
|
|
||||||
// so since getElementById is superfast we'll try it anyway
|
|
||||||
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
|
|
||||||
return byId || base.querySelector(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function $$(selector, base = document) {
|
|
||||||
return [...base.querySelectorAll(selector)];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function $create(selector = 'div', properties, children) {
|
|
||||||
/*
|
|
||||||
$create('tag#id.class.class', ?[children])
|
|
||||||
$create('tag#id.class.class', ?textContentOrChildNode)
|
|
||||||
$create('tag#id.class.class', {properties}, ?[children])
|
|
||||||
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
|
|
||||||
tag is 'div' by default, #id and .class are optional
|
|
||||||
|
|
||||||
$create([children])
|
|
||||||
|
|
||||||
$create({propertiesAndOptions})
|
|
||||||
$create({propertiesAndOptions}, ?[children])
|
|
||||||
tag: string, default 'div'
|
|
||||||
appendChild: element/string or an array of elements/strings
|
|
||||||
dataset: object
|
|
||||||
any DOM property: assigned as is
|
|
||||||
|
|
||||||
tag may include namespace like 'ns:tag'
|
|
||||||
*/
|
|
||||||
let ns, tag, opt;
|
|
||||||
|
|
||||||
if (typeof selector === 'string') {
|
|
||||||
if (Array.isArray(properties) ||
|
|
||||||
properties instanceof Node ||
|
|
||||||
typeof properties !== 'object') {
|
|
||||||
opt = {};
|
|
||||||
children = properties;
|
|
||||||
} else {
|
|
||||||
opt = properties || {};
|
|
||||||
children = children || opt.appendChild;
|
|
||||||
}
|
|
||||||
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
|
|
||||||
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
|
|
||||||
const id = selector.slice(idStart + 1, classStart);
|
|
||||||
if (id) {
|
|
||||||
opt.id = id;
|
|
||||||
}
|
|
||||||
const cls = selector.slice(classStart + 1);
|
|
||||||
if (cls) {
|
|
||||||
opt[selector.includes(':') ? 'class' : 'className'] =
|
|
||||||
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
|
|
||||||
}
|
|
||||||
tag = selector.slice(0, Math.min(idStart, classStart));
|
|
||||||
|
|
||||||
} else if (Array.isArray(selector)) {
|
|
||||||
tag = 'div';
|
|
||||||
opt = {};
|
|
||||||
children = selector;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
opt = selector;
|
|
||||||
tag = opt.tag;
|
|
||||||
delete opt.tag;
|
|
||||||
children = opt.appendChild || properties;
|
|
||||||
delete opt.appendChild;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tag && tag.includes(':')) {
|
|
||||||
([ns, tag] = tag.split(':'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = ns
|
|
||||||
? document.createElementNS(ns === 'SVG' || ns === 'svg' ? 'http://www.w3.org/2000/svg' : ns, tag)
|
|
||||||
: tag === 'fragment'
|
|
||||||
? document.createDocumentFragment()
|
|
||||||
: document.createElement(tag || 'div');
|
|
||||||
|
|
||||||
for (const child of Array.isArray(children) ? children : [children]) {
|
|
||||||
if (child) {
|
|
||||||
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.dataset) {
|
|
||||||
Object.assign(element.dataset, opt.dataset);
|
|
||||||
delete opt.dataset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.attributes) {
|
|
||||||
for (const attr in opt.attributes) {
|
|
||||||
element.setAttribute(attr, opt.attributes[attr]);
|
|
||||||
}
|
|
||||||
delete opt.attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.style) {
|
|
||||||
if (typeof opt.style === 'string') element.style.cssText = opt.style;
|
|
||||||
if (typeof opt.style === 'object') Object.assign(element.style, opt.style);
|
|
||||||
delete opt.style;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ns) {
|
|
||||||
for (const attr in opt) {
|
|
||||||
const i = attr.indexOf(':') + 1;
|
|
||||||
const attrNS = i && `http://www.w3.org/1999/${attr.slice(0, i - 1)}`;
|
|
||||||
element.setAttributeNS(attrNS || null, attr, opt[attr]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Object.assign(element, opt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function $createLink(href = '', content) {
|
|
||||||
const opt = {
|
|
||||||
tag: 'a',
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
};
|
|
||||||
if (typeof href === 'object') {
|
|
||||||
Object.assign(opt, href);
|
|
||||||
} else {
|
|
||||||
opt.href = href;
|
|
||||||
}
|
|
||||||
opt.appendChild = opt.appendChild || content;
|
|
||||||
return $create(opt);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// makes <details> with [data-pref] save/restore their state
|
|
||||||
function initCollapsibles({bindClickOn = 'h2'} = {}) {
|
|
||||||
const prefMap = {};
|
|
||||||
const elements = $$('details[data-pref]');
|
|
||||||
if (!elements.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const el of elements) {
|
|
||||||
const key = el.dataset.pref;
|
|
||||||
prefMap[key] = el;
|
|
||||||
el.open = prefs.get(key);
|
|
||||||
(bindClickOn && $(bindClickOn, el) || el).on('click', onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.subscribe(Object.keys(prefMap), (key, value) => {
|
|
||||||
const el = prefMap[key];
|
|
||||||
if (el.open !== value) {
|
|
||||||
el.open = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onClick(event) {
|
|
||||||
if (event.target.closest('.intercepts-click')) {
|
|
||||||
event.preventDefault();
|
|
||||||
} else {
|
|
||||||
setTimeout(saveState, 0, event.target.closest('details'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveState(el) {
|
|
||||||
if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
|
|
||||||
prefs.set(el.dataset.pref, el.open);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
|
|
||||||
function focusAccessibility() {
|
|
||||||
// last event's focusedViaClick
|
|
||||||
focusAccessibility.lastFocusedViaClick = false;
|
|
||||||
// to avoid a full layout recalc due to changes on body/root
|
|
||||||
// we modify the closest focusable element (like input or button or anything with tabindex=0)
|
|
||||||
focusAccessibility.closest = el => {
|
|
||||||
let labelSeen;
|
|
||||||
for (; el; el = el.parentElement) {
|
|
||||||
if (el.localName === 'label' && el.control && !labelSeen) {
|
|
||||||
el = el.control;
|
|
||||||
labelSeen = true;
|
|
||||||
}
|
|
||||||
if (el.tabIndex >= 0) return el;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// suppress outline on click
|
|
||||||
window.on('mousedown', ({target}) => {
|
|
||||||
const el = focusAccessibility.closest(target);
|
|
||||||
if (el) {
|
|
||||||
focusAccessibility.lastFocusedViaClick = true;
|
|
||||||
if (el.dataset.focusedViaClick === undefined) {
|
|
||||||
el.dataset.focusedViaClick = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, {passive: true});
|
|
||||||
// keep outline on Tab or Shift-Tab key
|
|
||||||
window.on('keydown', event => {
|
|
||||||
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
||||||
focusAccessibility.lastFocusedViaClick = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
let el = document.activeElement;
|
|
||||||
if (el) {
|
|
||||||
el = el.closest('[data-focused-via-click]');
|
|
||||||
if (el) delete el.dataset.focusedViaClick;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, {passive: true});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -417,69 +264,237 @@ function moveFocus(rootElement, step) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepts an array of pref names (values are fetched via prefs.get)
|
function onDOMready() {
|
||||||
// and establishes a two-way connection between the document elements and the actual prefs
|
return document.readyState !== 'loading'
|
||||||
function setupLivePrefs(
|
? Promise.resolve()
|
||||||
IDs = Object.getOwnPropertyNames(prefs.defaults)
|
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
|
||||||
.filter(id => $('#' + id))
|
}
|
||||||
) {
|
|
||||||
for (const id of IDs) {
|
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
|
||||||
const element = $('#' + id);
|
// align to the top/bottom of the visible area if wasn't visible
|
||||||
updateElement({id, element, force: true});
|
if (!element.parentNode) return;
|
||||||
element.on('change', onChange);
|
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);
|
||||||
}
|
}
|
||||||
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts an array of pref names (values are fetched via prefs.get)
|
||||||
|
* and establishes a two-way connection between the document elements and the actual prefs
|
||||||
|
*/
|
||||||
|
function setupLivePrefs(ids = prefs.knownKeys.filter(id => $('#' + id))) {
|
||||||
|
let forceUpdate = true;
|
||||||
|
prefs.subscribe(ids, updateElement, {runNow: true});
|
||||||
|
forceUpdate = false;
|
||||||
|
ids.forEach(id => $('#' + id).on('change', onChange));
|
||||||
|
|
||||||
function onChange() {
|
function onChange() {
|
||||||
const value = getInputValue(this);
|
prefs.set(this.id, this[getPropName(this)]);
|
||||||
if (prefs.get(this.id) !== value) {
|
|
||||||
prefs.set(this.id, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function updateElement({
|
|
||||||
id,
|
function getPropName(el) {
|
||||||
value = prefs.get(id),
|
return el.type === 'checkbox' ? 'checked'
|
||||||
element = $('#' + id),
|
: el.type === 'number' ? 'valueAsNumber' :
|
||||||
force,
|
'value';
|
||||||
}) {
|
|
||||||
if (!element) {
|
|
||||||
prefs.unsubscribe(IDs, updateElement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInputValue(element, value, force);
|
|
||||||
}
|
}
|
||||||
function getInputValue(input) {
|
|
||||||
if (input.type === 'checkbox') {
|
function updateElement(id, value) {
|
||||||
return input.checked;
|
const el = $('#' + id);
|
||||||
}
|
if (el) {
|
||||||
if (input.type === 'number') {
|
const prop = getPropName(el);
|
||||||
return Number(input.value);
|
if (el[prop] !== value || forceUpdate) {
|
||||||
}
|
el[prop] = value;
|
||||||
return input.value;
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
}
|
|
||||||
function setInputValue(input, value, force = false) {
|
|
||||||
if (force || getInputValue(input) !== value) {
|
|
||||||
if (input.type === 'checkbox') {
|
|
||||||
input.checked = value;
|
|
||||||
} else {
|
|
||||||
input.value = value;
|
|
||||||
}
|
}
|
||||||
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
|
} else {
|
||||||
|
prefs.unsubscribe(ids, updateElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* exported getEventKeyName */
|
|
||||||
/**
|
/**
|
||||||
* @param {KeyboardEvent} e
|
* @param {string} selector - beware of $ quirks with `#dotted.id` that won't work with $$
|
||||||
* @param {boolean} [letterAsCode] - use locale-independent KeyA..KeyZ for single-letter chars
|
* @param {Object} [opt]
|
||||||
|
* @param {function(HTMLElement, HTMLElement[]):boolean} [opt.recur] - called on each match
|
||||||
|
with (firstMatchingElement, allMatchingElements) parameters until stopOnDomReady,
|
||||||
|
you can also return `false` to disconnect the observer
|
||||||
|
* @param {boolean} [opt.stopOnDomReady] - stop observing on DOM ready
|
||||||
|
* @returns {Promise<HTMLElement>} - resolves on first match
|
||||||
*/
|
*/
|
||||||
function getEventKeyName(e, letterAsCode) {
|
function waitForSelector(selector, {recur, stopOnDomReady = true} = {}) {
|
||||||
const mods =
|
let el = $(selector);
|
||||||
(e.shiftKey ? 'Shift-' : '') +
|
let elems, isResolved;
|
||||||
(e.ctrlKey ? 'Ctrl-' : '') +
|
return el && (!recur || recur(el, (elems = $$(selector))) === false)
|
||||||
(e.altKey ? 'Alt-' : '') +
|
? Promise.resolve(el)
|
||||||
(e.metaKey ? 'Meta-' : '');
|
: new Promise(resolve => {
|
||||||
return (mods === e.key + '-' ? '' : mods) +
|
const mo = new MutationObserver(() => {
|
||||||
(e.key.length === 1 && letterAsCode ? e.code : e.key);
|
if (!el) el = $(selector);
|
||||||
|
if (!el) return;
|
||||||
|
if (!recur ||
|
||||||
|
callRecur() === false ||
|
||||||
|
stopOnDomReady && document.readyState === 'complete') {
|
||||||
|
mo.disconnect();
|
||||||
|
}
|
||||||
|
if (!isResolved) {
|
||||||
|
isResolved = true;
|
||||||
|
resolve(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mo.observe(document, {childList: true, subtree: true});
|
||||||
|
});
|
||||||
|
function callRecur() {
|
||||||
|
const all = $$(selector); // simpler and faster than analyzing each node in `mutations`
|
||||||
|
const added = !elems ? all : all.filter(el => !elems.includes(el));
|
||||||
|
if (added.length) {
|
||||||
|
elems = all;
|
||||||
|
return recur(added[0], added);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region Internals
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
|
||||||
|
const Collapsible = {
|
||||||
|
bindEvents(_, elems) {
|
||||||
|
const prefKeys = [];
|
||||||
|
for (const el of elems) {
|
||||||
|
prefKeys.push(el.dataset.pref);
|
||||||
|
($('h2', el) || el).on('click', Collapsible.saveOnClick);
|
||||||
|
}
|
||||||
|
prefs.subscribe(prefKeys, Collapsible.updateOnPrefChange, {runNow: true});
|
||||||
|
},
|
||||||
|
canSave(el) {
|
||||||
|
return !el.matches('.compact-layout .ignore-pref-if-compact');
|
||||||
|
},
|
||||||
|
async saveOnClick(event) {
|
||||||
|
if (event.target.closest('.intercepts-click')) {
|
||||||
|
event.preventDefault();
|
||||||
|
} else {
|
||||||
|
const el = event.target.closest('details');
|
||||||
|
await new Promise(setTimeout);
|
||||||
|
if (Collapsible.canSave(el)) {
|
||||||
|
prefs.set(el.dataset.pref, el.open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateOnPrefChange(key, value) {
|
||||||
|
const el = $(`details[data-pref="${key}"]`);
|
||||||
|
if (el.open !== value && Collapsible.canSave(el)) {
|
||||||
|
el.open = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.on('mousedown', suppressFocusRingOnClick, {passive: true});
|
||||||
|
window.on('keydown', keepFocusRingOnTabbing, {passive: true});
|
||||||
|
|
||||||
|
if (!/^Win\d+/.test(navigator.platform)) {
|
||||||
|
document.documentElement.classList.add('non-windows');
|
||||||
|
}
|
||||||
|
// set language for a) CSS :lang pseudo and b) hyphenation
|
||||||
|
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
|
||||||
|
document.on('click', keepAddressOnDummyClick);
|
||||||
|
document.on('wheel', changeFocusedInputOnWheel, {capture: true, passive: false});
|
||||||
|
|
||||||
|
Promise.resolve().then(async () => {
|
||||||
|
if (!chrome.app) addFaviconFF();
|
||||||
|
await prefs.ready;
|
||||||
|
waitForSelector('details[data-pref]', {recur: Collapsible.bindEvents});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDOMready().then(() => {
|
||||||
|
$remove('#firefox-transitions-bug-suppressor');
|
||||||
|
debounce(addTooltipsToEllipsized, 500);
|
||||||
|
window.on('resize', () => debounce(addTooltipsToEllipsized, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
function addFaviconFF() {
|
||||||
|
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
|
||||||
|
for (const size of [38, 32, 19, 16]) {
|
||||||
|
document.head.appendChild($create('link', {
|
||||||
|
rel: 'icon',
|
||||||
|
href: `/images/icon/${iconset}${size}.png`,
|
||||||
|
sizes: size + 'x' + size,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeFocusedInputOnWheel(event) {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el || el !== event.target && !el.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isSelect = el.tagName === 'SELECT';
|
||||||
|
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
|
||||||
|
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
|
||||||
|
const old = el[key];
|
||||||
|
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
|
||||||
|
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
|
||||||
|
if (el[key] !== old) {
|
||||||
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays a full text tooltip on buttons with ellipsis overflow and no inherent title */
|
||||||
|
function addTooltipsToEllipsized() {
|
||||||
|
for (const btn of document.getElementsByTagName('button')) {
|
||||||
|
if (btn.title && !btn.titleIsForEllipsis) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const width = btn.offsetWidth;
|
||||||
|
if (!width || btn.preresizeClientWidth === width) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
btn.preresizeClientWidth = width;
|
||||||
|
if (btn.scrollWidth > width) {
|
||||||
|
const text = btn.textContent;
|
||||||
|
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
|
||||||
|
btn.titleIsForEllipsis = true;
|
||||||
|
} else if (btn.title) {
|
||||||
|
btn.title = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepAddressOnDummyClick(e) {
|
||||||
|
// avoid adding # to the page URL when clicking dummy links
|
||||||
|
if (e.target.closest('a[href="#"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepFocusRingOnTabbing(event) {
|
||||||
|
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||||
|
focusAccessibility.lastFocusedViaClick = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
let el = document.activeElement;
|
||||||
|
if (el) {
|
||||||
|
el = el.closest('[data-focused-via-click]');
|
||||||
|
if (el) delete el.dataset.focusedViaClick;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressFocusRingOnClick({target}) {
|
||||||
|
const el = focusAccessibility.closest(target);
|
||||||
|
if (el) {
|
||||||
|
focusAccessibility.lastFocusedViaClick = true;
|
||||||
|
if (el.dataset.focusedViaClick === undefined) {
|
||||||
|
el.dataset.focusedViaClick = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function t(key, params) {
|
//#region Exports
|
||||||
|
|
||||||
|
function t(key, params, strict = true) {
|
||||||
const s = chrome.i18n.getMessage(key, params);
|
const s = chrome.i18n.getMessage(key, params);
|
||||||
if (!s) throw `Missing string "${key}"`;
|
if (!s && strict) throw `Missing string "${key}"`;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(t, {
|
Object.assign(t, {
|
||||||
template: {},
|
template: {},
|
||||||
DOMParser: new DOMParser(),
|
parser: new DOMParser(),
|
||||||
ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','),
|
ALLOWED_TAGS: ['a', 'b', 'code', 'i', 'sub', 'sup', 'wbr'],
|
||||||
RX_WORD_BREAK: new RegExp([
|
RX_WORD_BREAK: new RegExp([
|
||||||
'(',
|
'(',
|
||||||
/[\d\w\u007B-\uFFFF]{10}/,
|
/[\d\w\u007B-\uFFFF]{10}/,
|
||||||
|
@ -103,7 +105,7 @@ Object.assign(t, {
|
||||||
},
|
},
|
||||||
|
|
||||||
createHtml(str, trusted) {
|
createHtml(str, trusted) {
|
||||||
const root = t.DOMParser.parseFromString(str, 'text/html').body;
|
const root = t.parser.parseFromString(str, 'text/html').body;
|
||||||
if (!trusted) {
|
if (!trusted) {
|
||||||
t.sanitizeHtml(root);
|
t.sanitizeHtml(root);
|
||||||
} else if (str.includes('i18n-')) {
|
} else if (str.includes('i18n-')) {
|
||||||
|
@ -156,6 +158,9 @@ Object.assign(t, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region Internals
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const observer = new MutationObserver(process);
|
const observer = new MutationObserver(process);
|
||||||
let observing = false;
|
let observing = false;
|
||||||
|
@ -187,3 +192,5 @@ Object.assign(t, {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
/* global usercssMeta colorConverter */
|
|
||||||
/* exported metaParser */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
/* exported metaParser */
|
||||||
const metaParser = (() => {
|
const metaParser = (() => {
|
||||||
|
require(['/vendor/usercss-meta/usercss-meta.min']); /* global usercssMeta */
|
||||||
const {createParser, ParseError} = usercssMeta;
|
const {createParser, ParseError} = usercssMeta;
|
||||||
const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
|
const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
|
||||||
const options = {
|
const options = {
|
||||||
|
@ -27,6 +27,7 @@ const metaParser = (() => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
color: state => {
|
color: state => {
|
||||||
|
require(['/js/color/color-converter']); /* global colorConverter */
|
||||||
const color = colorConverter.parse(state.value);
|
const color = colorConverter.parse(state.value);
|
||||||
if (!color) {
|
if (!color) {
|
||||||
throw new ParseError({
|
throw new ParseError({
|
||||||
|
@ -40,39 +41,27 @@ const metaParser = (() => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const parser = createParser(options);
|
const parser = createParser(options);
|
||||||
const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'}));
|
const looseParser = createParser(Object.assign({}, options, {
|
||||||
|
allowErrors: true,
|
||||||
|
unknownKey: 'throw',
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parse,
|
|
||||||
lint,
|
lint: looseParser.parse,
|
||||||
nullifyInvalidVars,
|
parse: parser.parse,
|
||||||
|
|
||||||
|
nullifyInvalidVars(vars) {
|
||||||
|
for (const va of Object.values(vars)) {
|
||||||
|
if (va.value !== null) {
|
||||||
|
try {
|
||||||
|
parser.validateVar(va);
|
||||||
|
} catch (err) {
|
||||||
|
va.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parse(text, indexOffset) {
|
|
||||||
try {
|
|
||||||
return parser.parse(text);
|
|
||||||
} catch (err) {
|
|
||||||
if (typeof err.index === 'number') {
|
|
||||||
err.index += indexOffset;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lint(text) {
|
|
||||||
return looseParser.parse(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nullifyInvalidVars(vars) {
|
|
||||||
for (const va of Object.values(vars)) {
|
|
||||||
if (va.value === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
parser.validateVar(va);
|
|
||||||
} catch (err) {
|
|
||||||
va.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return vars;
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,24 +1,29 @@
|
||||||
/* global parserlib */
|
|
||||||
/* exported parseMozFormat */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
require([
|
||||||
|
'/js/csslint/parserlib', /* global parserlib */
|
||||||
|
'/js/sections-util', /* global MozDocMapper */
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* exported extractSections */
|
||||||
/**
|
/**
|
||||||
* Extracts @-moz-document blocks into sections and the code between them into global sections.
|
* 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.
|
* Puts the global comments into the following section to minimize the amount of global sections.
|
||||||
* Doesn't move the comment with ==UserStyle== inside.
|
* Doesn't move the comment with ==UserStyle== inside.
|
||||||
* @param {string} code
|
* @param {Object} _
|
||||||
* @param {number} styleId - used to preserve parserCache on subsequent runs over the same style
|
* @param {string} _.code
|
||||||
|
* @param {boolean} [_.fast] - uses topDocOnly option to extract sections as text
|
||||||
|
* @param {number} [_.styleId] - used to preserve parserCache on subsequent runs over the same style
|
||||||
* @returns {{sections: Array, errors: Array}}
|
* @returns {{sections: Array, errors: Array}}
|
||||||
|
* @property {?number} lastStyleId
|
||||||
*/
|
*/
|
||||||
function parseMozFormat({code, styleId}) {
|
function extractSections({code, styleId, fast = true}) {
|
||||||
const CssToProperty = {
|
|
||||||
'url': 'urls',
|
|
||||||
'url-prefix': 'urlPrefixes',
|
|
||||||
'domain': 'domains',
|
|
||||||
'regexp': 'regexps',
|
|
||||||
};
|
|
||||||
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/;
|
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/;
|
||||||
const parser = new parserlib.css.Parser({starHack: true, skipValidation: true});
|
const parser = new parserlib.css.Parser({
|
||||||
|
starHack: true,
|
||||||
|
skipValidation: true,
|
||||||
|
topDocOnly: fast,
|
||||||
|
});
|
||||||
const sectionStack = [{code: '', start: 0}];
|
const sectionStack = [{code: '', start: 0}];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const sections = [];
|
const sections = [];
|
||||||
|
@ -34,7 +39,6 @@ function parseMozFormat({code, styleId}) {
|
||||||
};
|
};
|
||||||
// move last comment before @-moz-document inside the section
|
// move last comment before @-moz-document inside the section
|
||||||
if (!lastCmt.includes('AGENT_SHEET') &&
|
if (!lastCmt.includes('AGENT_SHEET') &&
|
||||||
!lastCmt.includes('==') &&
|
|
||||||
!/==userstyle==/i.test(lastCmt)) {
|
!/==userstyle==/i.test(lastCmt)) {
|
||||||
if (lastCmt) {
|
if (lastCmt) {
|
||||||
section.code = lastCmt + '\n';
|
section.code = lastCmt + '\n';
|
||||||
|
@ -48,12 +52,12 @@ function parseMozFormat({code, styleId}) {
|
||||||
lastSection.code = '';
|
lastSection.code = '';
|
||||||
}
|
}
|
||||||
for (const {name, expr, uri} of e.functions) {
|
for (const {name, expr, uri} of e.functions) {
|
||||||
const aType = CssToProperty[name.toLowerCase()];
|
const aType = MozDocMapper.FROM_CSS[name.toLowerCase()];
|
||||||
const p0 = expr && expr.parts[0];
|
const p0 = expr && expr.parts[0];
|
||||||
if (p0 && aType === 'regexps') {
|
if (p0 && aType === 'regexps') {
|
||||||
const s = p0.text;
|
const s = p0.text;
|
||||||
if (hasSingleEscapes.test(p0.text)) {
|
if (hasSingleEscapes.test(p0.text)) {
|
||||||
const isQuoted = (s.startsWith('"') || s.startsWith("'")) && s.endsWith(s[0]);
|
const isQuoted = /^['"]/.test(s) && s.endsWith(s[0]);
|
||||||
p0.value = isQuoted ? s.slice(1, -1) : s;
|
p0.value = isQuoted ? s.slice(1, -1) : s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,17 +82,24 @@ function parseMozFormat({code, styleId}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
parser.addListener('error', e => {
|
parser.addListener('error', e => {
|
||||||
errors.push(`${e.line}:${e.col} ${e.message.replace(/ at line \d.+$/, '')}`);
|
errors.push(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
parser.parse(mozStyle, {
|
parser.parse(mozStyle, {
|
||||||
reuseCache: !parseMozFormat.styleId || styleId === parseMozFormat.styleId,
|
reuseCache: !extractSections.lastStyleId || styleId === extractSections.lastStyleId,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.push(e.message);
|
errors.push(e);
|
||||||
}
|
}
|
||||||
parseMozFormat.styleId = styleId;
|
for (const err of errors) {
|
||||||
|
for (const [k, v] of Object.entries(err)) {
|
||||||
|
if (typeof v === 'object') delete err[k];
|
||||||
|
}
|
||||||
|
err.message = `${err.line}:${err.col} ${err.message}`;
|
||||||
|
}
|
||||||
|
extractSections.lastStyleId = styleId;
|
||||||
|
|
||||||
return {sections, errors};
|
return {sections, errors};
|
||||||
|
|
||||||
function doAddSection(section) {
|
function doAddSection(section) {
|
||||||
|
|
131
js/msg.js
131
js/msg.js
|
@ -1,8 +1,9 @@
|
||||||
/* global deepCopy getOwnTab URLS */ // not used in content scripts
|
/* global URLS deepCopy deepMerge getOwnTab */// toolbox.js - not used in content scripts
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
(() => {
|
||||||
window.INJECTED !== 1 && (() => {
|
if (window.INJECTED === 1) return;
|
||||||
|
|
||||||
const TARGETS = Object.assign(Object.create(null), {
|
const TARGETS = Object.assign(Object.create(null), {
|
||||||
all: ['both', 'tab', 'extension'],
|
all: ['both', 'tab', 'extension'],
|
||||||
extension: ['both', 'extension'],
|
extension: ['both', 'extension'],
|
||||||
|
@ -21,38 +22,12 @@ window.INJECTED !== 1 && (() => {
|
||||||
extension: new Set(),
|
extension: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let bg = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage();
|
// TODO: maybe move into polyfill.js and hook addListener to wrap/unwrap automatically
|
||||||
const isBg = bg === window;
|
chrome.runtime.onMessage.addListener(onRuntimeMessage);
|
||||||
if (!isBg && (!bg || !bg.document || bg.document.readyState === 'loading')) {
|
|
||||||
bg = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: maybe move into polyfill.js and hook addListener + sendMessage so they wrap/unwrap automatically
|
|
||||||
const wrapData = data => ({
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
const wrapError = error => ({
|
|
||||||
error: Object.assign({
|
|
||||||
message: error.message || `${error}`,
|
|
||||||
stack: error.stack,
|
|
||||||
}, error), // passing custom properties e.g. `error.index`
|
|
||||||
});
|
|
||||||
const unwrapResponse = ({data, error} = {error: {message: ERR_NO_RECEIVER}}) =>
|
|
||||||
error
|
|
||||||
? Promise.reject(Object.assign(new Error(error.message), error))
|
|
||||||
: data;
|
|
||||||
chrome.runtime.onMessage.addListener(({data, target}, sender, sendResponse) => {
|
|
||||||
const res = window.msg._execute(TARGETS[target] || TARGETS.all, data, sender);
|
|
||||||
if (res instanceof Promise) {
|
|
||||||
res.then(wrapData, wrapError).then(sendResponse);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (res !== undefined) sendResponse(wrapData(res));
|
|
||||||
});
|
|
||||||
|
|
||||||
// This direct assignment allows IDEs to provide autocomplete for msg methods automatically
|
|
||||||
const msg = window.msg = {
|
const msg = window.msg = {
|
||||||
isBg,
|
|
||||||
|
isBg: getExtBg() === window,
|
||||||
|
|
||||||
async broadcast(data) {
|
async broadcast(data) {
|
||||||
const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
|
const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
|
||||||
|
@ -73,8 +48,8 @@ window.INJECTED !== 1 && (() => {
|
||||||
},
|
},
|
||||||
|
|
||||||
isIgnorableError(err) {
|
isIgnorableError(err) {
|
||||||
const msg = `${err && err.message || err}`;
|
const text = `${err && err.message || err}`;
|
||||||
return msg.includes(ERR_NO_RECEIVER) || msg.includes(ERR_PORT_CLOSED);
|
return text.includes(ERR_NO_RECEIVER) || text.includes(ERR_PORT_CLOSED);
|
||||||
},
|
},
|
||||||
|
|
||||||
ignoreError(err) {
|
ignoreError(err) {
|
||||||
|
@ -113,6 +88,11 @@ window.INJECTED !== 1 && (() => {
|
||||||
|
|
||||||
_execute(types, ...args) {
|
_execute(types, ...args) {
|
||||||
let result;
|
let result;
|
||||||
|
if (!(args[0] instanceof Object)) {
|
||||||
|
/* Data from other windows must be deep-copied to allow for GC in Chrome and
|
||||||
|
merely survive in FF as it kills cross-window objects when their tab is closed. */
|
||||||
|
args = args.map(deepCopy);
|
||||||
|
}
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
for (const fn of handler[type]) {
|
for (const fn of handler[type]) {
|
||||||
let res;
|
let res;
|
||||||
|
@ -130,27 +110,64 @@ window.INJECTED !== 1 && (() => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
window.API = new Proxy({}, {
|
function getExtBg() {
|
||||||
get(target, name) {
|
const fn = chrome.extension.getBackgroundPage;
|
||||||
// using a named function for convenience when debugging
|
const bg = fn && fn();
|
||||||
return async function invokeAPI(...args) {
|
return bg === window || bg && bg.msg && bg.msg.isBgReady ? bg : null;
|
||||||
if (!bg && chrome.tabs) {
|
}
|
||||||
bg = await browser.runtime.getBackgroundPage().catch(() => {});
|
|
||||||
}
|
function onRuntimeMessage({data, target}, sender, sendResponse) {
|
||||||
const message = {method: 'invokeAPI', name, args};
|
const res = msg._execute(TARGETS[target] || TARGETS.all, data, sender);
|
||||||
// content scripts and probably private tabs
|
if (res instanceof Promise) {
|
||||||
if (!bg) {
|
res.then(wrapData, wrapError).then(sendResponse);
|
||||||
return msg.send(message);
|
return true;
|
||||||
}
|
}
|
||||||
// in FF, the object would become a dead object when the window
|
if (res !== undefined) sendResponse(wrapData(res));
|
||||||
// is closed, so we have to clone the object into background.
|
}
|
||||||
const res = bg.msg._execute(TARGETS.extension, bg.deepCopy(message), {
|
|
||||||
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
|
function wrapData(data) {
|
||||||
tab: NEEDS_TAB_IN_SENDER.includes(name) && await getOwnTab(),
|
return {data};
|
||||||
url: location.href,
|
}
|
||||||
});
|
|
||||||
return deepCopy(await res);
|
function wrapError(error) {
|
||||||
};
|
return {
|
||||||
|
error: Object.assign({
|
||||||
|
message: error.message || `${error}`,
|
||||||
|
stack: error.stack,
|
||||||
|
}, error), // passing custom properties e.g. `error.index`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapResponse({data, error} = {error: {message: ERR_NO_RECEIVER}}) {
|
||||||
|
return error
|
||||||
|
? Promise.reject(Object.assign(new Error(error.message), error))
|
||||||
|
: data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiHandler = !msg.isBg && {
|
||||||
|
get({path}, name) {
|
||||||
|
const fn = () => {};
|
||||||
|
fn.path = [...path, name];
|
||||||
|
return new Proxy(fn, apiHandler);
|
||||||
},
|
},
|
||||||
});
|
async apply({path}, thisObj, args) {
|
||||||
|
const bg = getExtBg() ||
|
||||||
|
chrome.tabs && await browser.runtime.getBackgroundPage().catch(() => {});
|
||||||
|
const message = {method: 'invokeAPI', path, args};
|
||||||
|
let res;
|
||||||
|
// content scripts, probably private tabs, and our extension tab during Chrome startup
|
||||||
|
if (!bg) {
|
||||||
|
res = msg.send(message);
|
||||||
|
} else {
|
||||||
|
res = deepMerge(await bg.msg._execute(TARGETS.extension, message, {
|
||||||
|
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
|
||||||
|
tab: NEEDS_TAB_IN_SENDER.includes(path.join('.')) && await getOwnTab(),
|
||||||
|
url: location.href,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
/** @type {API} */
|
||||||
|
window.API = msg.isBg ? {} : new Proxy({path: []}, apiHandler);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
(() => {
|
||||||
self.INJECTED !== 1 && (() => {
|
/* Chrome reinjects content script when documentElement is replaced so we ignore it
|
||||||
|
by checking against a literal `1`, not just `if (truthy)`, because <html id="INJECTED">
|
||||||
|
is exposed per HTML spec as a global `window.INJECTED` */
|
||||||
|
if (window.INJECTED === 1) return;
|
||||||
|
|
||||||
//#region for content scripts and our extension pages
|
//#region for content scripts and our extension pages
|
||||||
|
|
||||||
|
@ -66,6 +69,35 @@ self.INJECTED !== 1 && (() => {
|
||||||
|
|
||||||
//#region for our extension pages
|
//#region for our extension pages
|
||||||
|
|
||||||
|
window.require = async function require(urls, cb) {
|
||||||
|
const promises = [];
|
||||||
|
const all = [];
|
||||||
|
const toLoad = [];
|
||||||
|
for (let url of Array.isArray(urls) ? urls : [urls]) {
|
||||||
|
const isCss = url.endsWith('.css');
|
||||||
|
const tag = isCss ? 'link' : 'script';
|
||||||
|
const attr = isCss ? 'href' : 'src';
|
||||||
|
if (!isCss && !url.endsWith('.js')) url += '.js';
|
||||||
|
if (url.startsWith('/')) url = url.slice(1);
|
||||||
|
let el = document.head.querySelector(`${tag}[${attr}$="${url}"]`);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(tag);
|
||||||
|
toLoad.push(el);
|
||||||
|
promises.push(new Promise((resolve, reject) => {
|
||||||
|
el.onload = resolve;
|
||||||
|
el.onerror = reject;
|
||||||
|
el[attr] = url;
|
||||||
|
if (isCss) el.rel = 'stylesheet';
|
||||||
|
}).catch(console.warn));
|
||||||
|
}
|
||||||
|
all.push(el);
|
||||||
|
}
|
||||||
|
if (toLoad.length) document.head.append(...toLoad);
|
||||||
|
if (promises.length) await Promise.all(promises);
|
||||||
|
if (cb) cb(...all);
|
||||||
|
return all[0];
|
||||||
|
};
|
||||||
|
|
||||||
if (!(new URLSearchParams({foo: 1})).get('foo')) {
|
if (!(new URLSearchParams({foo: 1})).get('foo')) {
|
||||||
// TODO: remove when minimum_chrome_version >= 61
|
// TODO: remove when minimum_chrome_version >= 61
|
||||||
window.URLSearchParams = class extends URLSearchParams {
|
window.URLSearchParams = class extends URLSearchParams {
|
||||||
|
|
85
js/prefs.js
85
js/prefs.js
|
@ -1,11 +1,21 @@
|
||||||
/* global msg API */
|
/* global API msg */// msg.js
|
||||||
/* global deepCopy debounce */ // not used in content scripts
|
/* global debounce deepMerge */// toolbox.js - not used in content scripts
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
(() => {
|
||||||
window.INJECTED !== 1 && (() => {
|
if (window.INJECTED === 1) return;
|
||||||
|
|
||||||
const STORAGE_KEY = 'settings';
|
const STORAGE_KEY = 'settings';
|
||||||
const clone = msg.isBg ? deepCopy : (val => JSON.parse(JSON.stringify(val)));
|
const clone = typeof deepMerge === 'function'
|
||||||
|
? deepMerge
|
||||||
|
: val =>
|
||||||
|
typeof val === 'object' && val
|
||||||
|
? JSON.parse(JSON.stringify(val))
|
||||||
|
: val;
|
||||||
|
/**
|
||||||
|
* @type PrefsValues
|
||||||
|
* @namespace PrefsValues
|
||||||
|
*/
|
||||||
const defaults = {
|
const defaults = {
|
||||||
'openEditInWindow': false, // new editor opens in a own browser window
|
'openEditInWindow': false, // new editor opens in a own browser window
|
||||||
'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
|
'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
|
||||||
|
@ -112,34 +122,53 @@ window.INJECTED !== 1 && (() => {
|
||||||
|
|
||||||
'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
|
'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
|
||||||
};
|
};
|
||||||
|
const knownKeys = Object.keys(defaults);
|
||||||
|
/** @type {PrefsValues} */
|
||||||
const values = clone(defaults);
|
const values = clone(defaults);
|
||||||
const onChange = {
|
const onChange = {
|
||||||
any: new Set(),
|
any: new Set(),
|
||||||
specific: {},
|
specific: {},
|
||||||
};
|
};
|
||||||
// getPrefs may fail on browser startup in the active tab as it loads before the background script
|
// API fails in the active tab during Chrome startup as it loads the tab before bg
|
||||||
const initializing = (msg.isBg ? readStorage() : API.getPrefs().catch(readStorage))
|
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
|
||||||
.then(setAll);
|
let ready = (msg.isBg ? readStorage() : API.prefs.getValues().catch(readStorage))
|
||||||
|
.then(data => {
|
||||||
|
setAll(data);
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||||
const data = area === 'sync' && changes[STORAGE_KEY];
|
const data = area === 'sync' && changes[STORAGE_KEY];
|
||||||
if (data) {
|
if (data) {
|
||||||
await initializing;
|
if (ready.then) await ready;
|
||||||
setAll(data.newValue);
|
setAll(data.newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// This direct assignment allows IDEs to provide correct autocomplete for methods
|
|
||||||
const prefs = window.prefs = {
|
const prefs = window.prefs = {
|
||||||
|
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
initializing,
|
knownKeys,
|
||||||
defaults,
|
ready,
|
||||||
|
/** @type {PrefsValues} */
|
||||||
|
defaults: new Proxy({}, {
|
||||||
|
get: (_, key) => clone(defaults[key]),
|
||||||
|
}),
|
||||||
|
/** @type {PrefsValues} */
|
||||||
get values() {
|
get values() {
|
||||||
return deepCopy(values);
|
return clone(values);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
__defaults: defaults, // direct reference, be careful!
|
||||||
|
__values: values, // direct reference, be careful!
|
||||||
|
|
||||||
get(key) {
|
get(key) {
|
||||||
return isKnown(key) && values[key];
|
const res = values[key];
|
||||||
|
if (res !== undefined || isKnown(key)) {
|
||||||
|
return clone(res);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
set(key, val, isSynced) {
|
set(key, val, isSynced) {
|
||||||
if (!isKnown(key)) return;
|
if (!isKnown(key)) return;
|
||||||
const oldValue = values[key];
|
const oldValue = values[key];
|
||||||
|
@ -155,36 +184,45 @@ window.INJECTED !== 1 && (() => {
|
||||||
emitChange(key, val, isSynced);
|
emitChange(key, val, isSynced);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
reset(key) {
|
reset(key) {
|
||||||
prefs.set(key, clone(defaults[key]));
|
prefs.set(key, clone(defaults[key]));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
|
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
|
||||||
* @param {function(key:string, value:any)} fn
|
* @param {function(key:string?, value:any?)} fn
|
||||||
* @param {Object} [opts]
|
* @param {Object} [opts]
|
||||||
* @param {boolean} [opts.now] - when truthy, the listener is called immediately:
|
* @param {boolean} [opts.runNow] - when truthy, the listener is called immediately:
|
||||||
* 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
|
* 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
|
||||||
* 2) if `keys` is falsy, no key/value will be provided
|
* 2) if `keys` is falsy, no key/value will be provided
|
||||||
*/
|
*/
|
||||||
subscribe(keys, fn, {now} = {}) {
|
async subscribe(keys, fn, {runNow} = {}) {
|
||||||
|
const toRun = [];
|
||||||
if (keys) {
|
if (keys) {
|
||||||
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
||||||
if (!isKnown(key)) continue;
|
if (!isKnown(key)) continue;
|
||||||
const listeners = onChange.specific[key] ||
|
const listeners = onChange.specific[key] ||
|
||||||
(onChange.specific[key] = new Set());
|
(onChange.specific[key] = new Set());
|
||||||
listeners.add(fn);
|
listeners.add(fn);
|
||||||
if (now) fn(key, values[key]);
|
if (runNow) toRun.push({fn, key});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onChange.any.add(fn);
|
onChange.any.add(fn);
|
||||||
if (now) fn();
|
if (runNow) toRun.push({fn});
|
||||||
|
}
|
||||||
|
if (toRun.length) {
|
||||||
|
if (ready.then) await ready;
|
||||||
|
toRun.forEach(({fn, key}) => fn(key, values[key]));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeMany(data, opts) {
|
subscribeMany(data, opts) {
|
||||||
for (const [k, fn] of Object.entries(data)) {
|
for (const [k, fn] of Object.entries(data)) {
|
||||||
prefs.subscribe(k, fn, opts);
|
prefs.subscribe(k, fn, opts);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
unsubscribe(keys, fn) {
|
unsubscribe(keys, fn) {
|
||||||
if (keys) {
|
if (keys) {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
@ -203,7 +241,7 @@ window.INJECTED !== 1 && (() => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function isKnown(key) {
|
function isKnown(key) {
|
||||||
const res = defaults.hasOwnProperty(key);
|
const res = knownKeys.includes(key);
|
||||||
if (!res) console.warn('Unknown preference "%s"', key);
|
if (!res) console.warn('Unknown preference "%s"', key);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
@ -228,14 +266,13 @@ window.INJECTED !== 1 && (() => {
|
||||||
if (msg.isBg) {
|
if (msg.isBg) {
|
||||||
debounce(updateStorage);
|
debounce(updateStorage);
|
||||||
} else {
|
} else {
|
||||||
API.setPref(key, value);
|
API.prefs.set(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStorage() {
|
async function readStorage() {
|
||||||
return browser.storage.sync.get(STORAGE_KEY)
|
return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
|
||||||
.then(data => data[STORAGE_KEY]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStorage() {
|
function updateStorage() {
|
||||||
|
|
121
js/router.js
121
js/router.js
|
@ -1,66 +1,17 @@
|
||||||
/* global deepEqual msg */
|
/* global deepEqual */// toolbox.js
|
||||||
/* exported router */
|
/* global msg */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const router = (() => {
|
const router = {
|
||||||
const buffer = [];
|
buffer: [],
|
||||||
const watchers = [];
|
watchers: [],
|
||||||
document.addEventListener('DOMContentLoaded', () => update());
|
|
||||||
window.addEventListener('popstate', () => update());
|
|
||||||
window.addEventListener('hashchange', () => update());
|
|
||||||
msg.on(e => {
|
|
||||||
if (e.method === 'pushState' && e.url !== location.href) {
|
|
||||||
history.pushState(history.state, null, e.url);
|
|
||||||
update();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {watch, updateSearch, getSearch, updateHash};
|
|
||||||
|
|
||||||
function watch(options, callback) {
|
getSearch(key) {
|
||||||
/* Watch search params or hash and get notified on change.
|
|
||||||
|
|
||||||
options: {search?: Array<key: String>, hash?: String}
|
|
||||||
callback: (Array<value: String | null> | Boolean) => void
|
|
||||||
|
|
||||||
`hash` should always start with '#'.
|
|
||||||
When watching search params, callback receives a list of values.
|
|
||||||
When watching hash, callback receives a boolean.
|
|
||||||
*/
|
|
||||||
watchers.push({options, callback});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSearch(key, value) {
|
|
||||||
const u = new URL(location);
|
|
||||||
u.searchParams[value ? 'set' : 'delete'](key, value);
|
|
||||||
history.replaceState(history.state, null, `${u}`);
|
|
||||||
update(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHash(hash) {
|
|
||||||
/* hash: String
|
|
||||||
|
|
||||||
Send an empty string to remove the hash.
|
|
||||||
*/
|
|
||||||
if (buffer.length > 1) {
|
|
||||||
if (!hash && !buffer[buffer.length - 2].includes('#') ||
|
|
||||||
hash && buffer[buffer.length - 2].endsWith(hash)) {
|
|
||||||
history.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hash) {
|
|
||||||
hash = ' ';
|
|
||||||
}
|
|
||||||
history.pushState(history.state, null, hash);
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSearch(key) {
|
|
||||||
return new URLSearchParams(location.search).get(key);
|
return new URLSearchParams(location.search).get(key);
|
||||||
}
|
},
|
||||||
|
|
||||||
function update(replace) {
|
update(replace) {
|
||||||
|
const {buffer} = router;
|
||||||
if (!buffer.length) {
|
if (!buffer.length) {
|
||||||
buffer.push(location.href);
|
buffer.push(location.href);
|
||||||
} else if (buffer[buffer.length - 1] === location.href) {
|
} else if (buffer[buffer.length - 1] === location.href) {
|
||||||
|
@ -72,7 +23,7 @@ const router = (() => {
|
||||||
} else {
|
} else {
|
||||||
buffer.push(location.href);
|
buffer.push(location.href);
|
||||||
}
|
}
|
||||||
for (const {options, callback} of watchers) {
|
for (const {options, callback} of router.watchers) {
|
||||||
let state;
|
let state;
|
||||||
if (options.hash) {
|
if (options.hash) {
|
||||||
state = options.hash === location.hash;
|
state = options.hash === location.hash;
|
||||||
|
@ -85,5 +36,55 @@ const router = (() => {
|
||||||
callback(state);
|
callback(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} hash - empty string removes the hash
|
||||||
|
*/
|
||||||
|
updateHash(hash) {
|
||||||
|
const {buffer} = router;
|
||||||
|
if (buffer.length > 1) {
|
||||||
|
if (!hash && !buffer[buffer.length - 2].includes('#') ||
|
||||||
|
hash && buffer[buffer.length - 2].endsWith(hash)) {
|
||||||
|
history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hash) {
|
||||||
|
hash = ' ';
|
||||||
|
}
|
||||||
|
history.pushState(history.state, null, hash);
|
||||||
|
router.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSearch(key, value) {
|
||||||
|
const u = new URL(location);
|
||||||
|
u.searchParams[value ? 'set' : 'delete'](key, value);
|
||||||
|
history.replaceState(history.state, null, `${u}`);
|
||||||
|
router.update(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
watch(options, callback) {
|
||||||
|
/* Watch search params or hash and get notified on change.
|
||||||
|
|
||||||
|
options: {search?: Array<key: String>, hash?: String}
|
||||||
|
callback: (Array<value: String | null> | Boolean) => void
|
||||||
|
|
||||||
|
`hash` should always start with '#'.
|
||||||
|
When watching search params, callback receives a list of values.
|
||||||
|
When watching hash, callback receives a boolean.
|
||||||
|
*/
|
||||||
|
router.watchers.push({options, callback});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.on('DOMContentLoaded', () => router.update());
|
||||||
|
window.on('popstate', () => router.update());
|
||||||
|
window.on('hashchange', () => router.update());
|
||||||
|
msg.on(e => {
|
||||||
|
if (e.method === 'pushState' && e.url !== location.href) {
|
||||||
|
history.pushState(history.state, null, e.url);
|
||||||
|
router.update();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
})();
|
});
|
||||||
|
|
|
@ -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,11 +1,77 @@
|
||||||
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
/* exported
|
||||||
|
calcStyleDigest
|
||||||
|
MozDocMapper
|
||||||
|
styleCodeEmpty
|
||||||
|
styleJSONseemsValid
|
||||||
|
styleSectionGlobal
|
||||||
|
styleSectionsEqual
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MozDocMapper = {
|
||||||
|
TO_CSS: {
|
||||||
|
urls: 'url',
|
||||||
|
urlPrefixes: 'url-prefix',
|
||||||
|
domains: 'domain',
|
||||||
|
regexps: 'regexp',
|
||||||
|
},
|
||||||
|
FROM_CSS: {
|
||||||
|
'url': 'urls',
|
||||||
|
'url-prefix': 'urlPrefixes',
|
||||||
|
'domain': 'domains',
|
||||||
|
'regexp': 'regexps',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {Object} section
|
||||||
|
* @param {function(func:string, value:string)} fn
|
||||||
|
*/
|
||||||
|
forEachProp(section, fn) {
|
||||||
|
for (const [propName, func] of Object.entries(MozDocMapper.TO_CSS)) {
|
||||||
|
const props = section[propName];
|
||||||
|
if (props) props.forEach(value => fn(func, value));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {Array<?[type,value]>} funcItems
|
||||||
|
* @param {?Object} [section]
|
||||||
|
* @returns {Object} section
|
||||||
|
*/
|
||||||
|
toSection(funcItems, section = {}) {
|
||||||
|
for (const item of funcItems) {
|
||||||
|
const [func, value] = item || [];
|
||||||
|
const propName = MozDocMapper.FROM_CSS[func];
|
||||||
|
if (propName) {
|
||||||
|
const props = section[propName] || (section[propName] = []);
|
||||||
|
if (Array.isArray(value)) props.push(...value);
|
||||||
|
else props.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {StyleObj} style
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
styleToCss(style) {
|
||||||
|
const res = [];
|
||||||
|
for (const section of style.sections) {
|
||||||
|
const funcs = [];
|
||||||
|
MozDocMapper.forEachProp(section, (type, value) =>
|
||||||
|
funcs.push(`${type}("${value.replace(/[\\"]/g, '\\$&')}")`));
|
||||||
|
res.push(funcs.length
|
||||||
|
? `@-moz-document ${funcs.join(', ')} {\n${section.code}\n}`
|
||||||
|
: section.code);
|
||||||
|
}
|
||||||
|
return res.join('\n\n');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function styleCodeEmpty(code) {
|
function styleCodeEmpty(code) {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
|
const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
|
||||||
while (rx.exec(code)) {
|
while (rx.exec(code)) {
|
||||||
if (rx.lastIndex === code.length) {
|
if (rx.lastIndex === code.length) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -17,9 +83,9 @@ function styleCodeEmpty(code) {
|
||||||
/** Checks if section is global i.e. has no targets at all */
|
/** Checks if section is global i.e. has no targets at all */
|
||||||
function styleSectionGlobal(section) {
|
function styleSectionGlobal(section) {
|
||||||
return (!section.regexps || !section.regexps.length) &&
|
return (!section.regexps || !section.regexps.length) &&
|
||||||
(!section.urlPrefixes || !section.urlPrefixes.length) &&
|
(!section.urlPrefixes || !section.urlPrefixes.length) &&
|
||||||
(!section.urls || !section.urls.length) &&
|
(!section.urls || !section.urls.length) &&
|
||||||
(!section.domains || !section.domains.length);
|
(!section.domains || !section.domains.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,41 +116,27 @@ function styleSectionsEqual({sections: a}, {sections: b}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStyleSections({sections}) {
|
async function calcStyleDigest(style) {
|
||||||
// retain known properties in an arbitrarily predefined order
|
// retain known properties in an arbitrarily predefined order
|
||||||
return (sections || []).map(section => /** @namespace StyleSection */({
|
const src = style.usercssData
|
||||||
code: section.code || '',
|
? style.sourceCode
|
||||||
urls: section.urls || [],
|
// retain known properties in an arbitrarily predefined order
|
||||||
urlPrefixes: section.urlPrefixes || [],
|
: JSON.stringify((style.sections || []).map(section => /** @namespace StyleSection */({
|
||||||
domains: section.domains || [],
|
code: section.code || '',
|
||||||
regexps: section.regexps || [],
|
urls: section.urls || [],
|
||||||
}));
|
urlPrefixes: section.urlPrefixes || [],
|
||||||
}
|
domains: section.domains || [],
|
||||||
|
regexps: section.regexps || [],
|
||||||
function calcStyleDigest(style) {
|
})));
|
||||||
const jsonString = style.usercssData ?
|
const srcBytes = new TextEncoder().encode(src);
|
||||||
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
|
const res = await crypto.subtle.digest('SHA-1', srcBytes);
|
||||||
const text = new TextEncoder('utf-8').encode(jsonString);
|
return Array.from(new Uint8Array(res), b => (0x100 + b).toString(16).slice(1)).join('');
|
||||||
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) {
|
function styleJSONseemsValid(json) {
|
||||||
return json
|
return json
|
||||||
&& json.name
|
&& typeof json.name == 'string'
|
||||||
&& json.name.trim()
|
&& json.name.trim()
|
||||||
&& Array.isArray(json.sections)
|
&& Array.isArray(json.sections)
|
||||||
&& json.sections
|
&& typeof (json.sections[0] || {}).code === 'string';
|
||||||
&& json.sections.length
|
|
||||||
&& typeof json.sections.every === 'function'
|
|
||||||
&& typeof json.sections[0].code === 'string';
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
/* global loadScript tryJSONparse */
|
/* global tryJSONparse */// toolbox.js
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
let LZString;
|
||||||
|
|
||||||
/** @namespace StorageExtras */
|
/** @namespace StorageExtras */
|
||||||
const StorageExtras = {
|
const StorageExtras = {
|
||||||
async getValue(key) {
|
async getValue(key) {
|
||||||
|
@ -14,9 +16,9 @@
|
||||||
return (await this.getLZValues([key]))[key];
|
return (await this.getLZValues([key]))[key];
|
||||||
},
|
},
|
||||||
async getLZValues(keys = Object.values(this.LZ_KEY)) {
|
async getLZValues(keys = Object.values(this.LZ_KEY)) {
|
||||||
const [data, LZString] = await Promise.all([
|
const [data] = await Promise.all([
|
||||||
this.get(keys),
|
this.get(keys),
|
||||||
this.getLZString(),
|
LZString || loadLZString(),
|
||||||
]);
|
]);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
@ -25,16 +27,9 @@
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
async setLZValue(key, value) {
|
async setLZValue(key, value) {
|
||||||
const LZString = await this.getLZString();
|
if (!LZString) await loadLZString();
|
||||||
return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value)));
|
return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value)));
|
||||||
},
|
},
|
||||||
async getLZString() {
|
|
||||||
if (!window.LZString) {
|
|
||||||
await loadScript('/vendor/lz-string-unsafe/lz-string-unsafe.min.js');
|
|
||||||
window.LZString = window.LZString || window.LZStringUnsafe;
|
|
||||||
}
|
|
||||||
return window.LZString;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
/** @namespace StorageExtrasSync */
|
/** @namespace StorageExtrasSync */
|
||||||
const StorageExtrasSync = {
|
const StorageExtrasSync = {
|
||||||
|
@ -44,6 +39,12 @@
|
||||||
usercssTemplate: 'usercssTemplate',
|
usercssTemplate: 'usercssTemplate',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function loadLZString() {
|
||||||
|
await require(['/vendor/lz-string-unsafe/lz-string-unsafe.min']);
|
||||||
|
LZString = window.LZString || window.LZStringUnsafe;
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {chrome.storage.StorageArea|StorageExtras} */
|
/** @type {chrome.storage.StorageArea|StorageExtras} */
|
||||||
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras);
|
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras);
|
||||||
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */
|
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
CHROME_POPUP_BORDER_BUG
|
||||||
capitalize
|
capitalize
|
||||||
CHROME_HAS_BORDER_BUG
|
|
||||||
closeCurrentTab
|
closeCurrentTab
|
||||||
deepEqual
|
deepEqual
|
||||||
download
|
download
|
||||||
getActiveTab
|
getActiveTab
|
||||||
getStyleWithNoCode
|
getOwnTab
|
||||||
getTab
|
getTab
|
||||||
ignoreChromeError
|
ignoreChromeError
|
||||||
|
isEmptyObj
|
||||||
onTabReady
|
onTabReady
|
||||||
openURL
|
openURL
|
||||||
sessionStore
|
sessionStore
|
||||||
|
@ -15,7 +18,6 @@
|
||||||
tryCatch
|
tryCatch
|
||||||
tryRegExp
|
tryRegExp
|
||||||
*/
|
*/
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]);
|
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 OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
|
||||||
|
@ -23,7 +25,7 @@ const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi');
|
||||||
let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
|
let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
|
||||||
|
|
||||||
// see PR #781
|
// see PR #781
|
||||||
const CHROME_HAS_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
|
const CHROME_POPUP_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
|
||||||
|
|
||||||
if (!CHROME && !chrome.browserAction.openPopup) {
|
if (!CHROME && !chrome.browserAction.openPopup) {
|
||||||
// in FF pre-57 legacy addons can override useragent so we assume the worst
|
// in FF pre-57 legacy addons can override useragent so we assume the worst
|
||||||
|
@ -40,12 +42,6 @@ if (!CHROME && !chrome.browserAction.openPopup) {
|
||||||
const URLS = {
|
const URLS = {
|
||||||
ownOrigin: chrome.runtime.getURL(''),
|
ownOrigin: chrome.runtime.getURL(''),
|
||||||
|
|
||||||
// FIXME delete?
|
|
||||||
optionsUI: [
|
|
||||||
chrome.runtime.getURL('options.html'),
|
|
||||||
'chrome://extensions/?options=' + chrome.runtime.id,
|
|
||||||
],
|
|
||||||
|
|
||||||
configureCommands:
|
configureCommands:
|
||||||
OPERA ? 'opera://settings/configureCommands'
|
OPERA ? 'opera://settings/configureCommands'
|
||||||
: 'chrome://extensions/configureCommands',
|
: 'chrome://extensions/configureCommands',
|
||||||
|
@ -75,6 +71,8 @@ const URLS = {
|
||||||
// TODO: remove when "minimum_chrome_version": "61" or higher
|
// TODO: remove when "minimum_chrome_version": "61" or higher
|
||||||
chromeProtectsNTP: CHROME >= 61,
|
chromeProtectsNTP: CHROME >= 61,
|
||||||
|
|
||||||
|
rxMETA: /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i,
|
||||||
|
|
||||||
uso: 'https://userstyles.org/',
|
uso: 'https://userstyles.org/',
|
||||||
usoJson: 'https://userstyles.org/styles/chrome/',
|
usoJson: 'https://userstyles.org/styles/chrome/',
|
||||||
|
|
||||||
|
@ -84,10 +82,13 @@ const URLS = {
|
||||||
url &&
|
url &&
|
||||||
url.startsWith(URLS.usoArchiveRaw) &&
|
url.startsWith(URLS.usoArchiveRaw) &&
|
||||||
parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]),
|
parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]),
|
||||||
|
extractUsoArchiveInstallUrl: url => {
|
||||||
|
const id = URLS.extractUsoArchiveId(url);
|
||||||
|
return id ? `${URLS.usoArchive}?style=${id}` : '';
|
||||||
|
},
|
||||||
|
|
||||||
extractGreasyForkId: url =>
|
extractGreasyForkInstallUrl: url =>
|
||||||
/^https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/(\d+)[^/]*\/code\/[^/]*\.user\.css$/.test(url) &&
|
/^(https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/\d+)[^/]*\/code\/[^/]*\.user\.css$|$/.exec(url)[1],
|
||||||
RegExp.$1,
|
|
||||||
|
|
||||||
supported: url => (
|
supported: url => (
|
||||||
url.startsWith('http') ||
|
url.startsWith('http') ||
|
||||||
|
@ -98,11 +99,11 @@ const URLS = {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window) {
|
if (FIREFOX || OPERA || VIVALDI) {
|
||||||
window.API_METHODS = {};
|
document.documentElement.classList.add(
|
||||||
} else {
|
FIREFOX && 'firefox' ||
|
||||||
const cls = FIREFOX ? 'firefox' : OPERA ? 'opera' : VIVALDI ? 'vivaldi' : '';
|
OPERA && 'opera' ||
|
||||||
if (cls) document.documentElement.classList.add(cls);
|
VIVALDI && 'vivaldi');
|
||||||
}
|
}
|
||||||
|
|
||||||
// FF57+ supports openerTabId, but not in Android
|
// FF57+ supports openerTabId, but not in Android
|
||||||
|
@ -113,9 +114,8 @@ function getOwnTab() {
|
||||||
return browser.tabs.getCurrent();
|
return browser.tabs.getCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveTab() {
|
async function getActiveTab() {
|
||||||
return browser.tabs.query({currentWindow: true, active: true})
|
return (await browser.tabs.query({currentWindow: true, active: true}))[0];
|
||||||
.then(tabs => tabs[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlToMatchPattern(url, ignoreSearch) {
|
function urlToMatchPattern(url, ignoreSearch) {
|
||||||
|
@ -133,13 +133,13 @@ function urlToMatchPattern(url, ignoreSearch) {
|
||||||
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
|
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
|
async function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
|
||||||
url = new URL(url);
|
url = new URL(url);
|
||||||
return browser.tabs.query({url: urlToMatchPattern(url, ignoreSearch), currentWindow})
|
const tabs = await browser.tabs.query({
|
||||||
// FIXME: is tab.url always normalized?
|
url: urlToMatchPattern(url, ignoreSearch),
|
||||||
.then(tabs => tabs.find(matchTab));
|
currentWindow,
|
||||||
|
});
|
||||||
function matchTab(tab) {
|
return tabs.find(tab => {
|
||||||
const tabUrl = new URL(tab.pendingUrl || tab.url);
|
const tabUrl = new URL(tab.pendingUrl || tab.url);
|
||||||
return tabUrl.protocol === url.protocol &&
|
return tabUrl.protocol === url.protocol &&
|
||||||
tabUrl.username === url.username &&
|
tabUrl.username === url.username &&
|
||||||
|
@ -149,7 +149,7 @@ function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch =
|
||||||
tabUrl.pathname === url.pathname &&
|
tabUrl.pathname === url.pathname &&
|
||||||
(ignoreSearch || tabUrl.search === url.search) &&
|
(ignoreSearch || tabUrl.search === url.search) &&
|
||||||
(ignoreHash || tabUrl.hash === url.hash);
|
(ignoreHash || tabUrl.hash === url.hash);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -185,7 +185,7 @@ async function openURL({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (newWindow && browser.windows) {
|
if (newWindow && browser.windows) {
|
||||||
return (await browser.windows.create(Object.assign({url}, newWindow)).tabs)[0];
|
return (await browser.windows.create(Object.assign({url}, newWindow))).tabs[0];
|
||||||
}
|
}
|
||||||
tab = await getActiveTab() || {url: ''};
|
tab = await getActiveTab() || {url: ''};
|
||||||
if (isTabReplaceable(tab, url)) {
|
if (isTabReplaceable(tab, url)) {
|
||||||
|
@ -196,20 +196,17 @@ async function openURL({
|
||||||
return browser.tabs.create(Object.assign({url, index, active}, opener));
|
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
|
* Replaces empty tab (NTP or about:blank)
|
||||||
// in incognito
|
* except when new URL is chrome:// or chrome-extension:// and the empty tab is in incognito
|
||||||
|
*/
|
||||||
function isTabReplaceable(tab, newUrl) {
|
function isTabReplaceable(tab, newUrl) {
|
||||||
if (!tab || !URLS.emptyTab.includes(tab.pendingUrl || tab.url)) {
|
return tab &&
|
||||||
return false;
|
URLS.emptyTab.includes(tab.pendingUrl || tab.url) &&
|
||||||
}
|
!(tab.incognito && newUrl.startsWith('chrome'));
|
||||||
if (tab.incognito && newUrl.startsWith('chrome')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateTab(tab, {url, index, openerTabId} = {}) {
|
async function activateTab(tab, {url, index, openerTabId} = {}) {
|
||||||
const options = {active: true};
|
const options = {active: true};
|
||||||
if (url) {
|
if (url) {
|
||||||
options.url = url;
|
options.url = url;
|
||||||
|
@ -217,62 +214,64 @@ function activateTab(tab, {url, index, openerTabId} = {}) {
|
||||||
if (openerTabId != null && openerTabIdSupported) {
|
if (openerTabId != null && openerTabIdSupported) {
|
||||||
options.openerTabId = openerTabId;
|
options.openerTabId = openerTabId;
|
||||||
}
|
}
|
||||||
return Promise.all([
|
await Promise.all([
|
||||||
browser.tabs.update(tab.id, options),
|
browser.tabs.update(tab.id, options),
|
||||||
browser.windows && browser.windows.update(tab.windowId, {focused: true}),
|
browser.windows && browser.windows.update(tab.windowId, {focused: true}),
|
||||||
index != null && browser.tabs.move(tab.id, {index}),
|
index != null && browser.tabs.move(tab.id, {index}),
|
||||||
])
|
]);
|
||||||
.then(() => tab);
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringAsRegExp(s, flags, asString) {
|
||||||
function stringAsRegExp(s, flags) {
|
s = s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&');
|
||||||
return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags);
|
return asString ? s : new RegExp(s, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function ignoreChromeError() {
|
function ignoreChromeError() {
|
||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
chrome.runtime.lastError;
|
chrome.runtime.lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmptyObj(obj) {
|
||||||
function getStyleWithNoCode(style) {
|
if (obj) {
|
||||||
const stripped = deepCopy(style);
|
for (const k in obj) {
|
||||||
for (const section of stripped.sections) section.code = null;
|
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||||
stripped.sourceCode = null;
|
return false;
|
||||||
return stripped;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
// js engine can't optimize the entire function if it contains try-catch
|
* 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
|
* so we should keep it isolated from normal code in a minimal wrapper
|
||||||
// Update: might get fixed in V8 TurboFan in the future
|
* 2020 update: probably fixed at least in V8
|
||||||
|
*/
|
||||||
function tryCatch(func, ...args) {
|
function tryCatch(func, ...args) {
|
||||||
try {
|
try {
|
||||||
return func(...args);
|
return func(...args);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function tryRegExp(regexp, flags) {
|
function tryRegExp(regexp, flags) {
|
||||||
try {
|
try {
|
||||||
return new RegExp(regexp, flags);
|
return new RegExp(regexp, flags);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function tryJSONparse(jsonString) {
|
function tryJSONparse(jsonString) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(jsonString);
|
return JSON.parse(jsonString);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function debounce(fn, delay, ...args) {
|
||||||
const debounce = Object.assign((fn, delay, ...args) => {
|
|
||||||
clearTimeout(debounce.timers.get(fn));
|
clearTimeout(debounce.timers.get(fn));
|
||||||
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
|
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
|
||||||
}, {
|
}
|
||||||
|
|
||||||
|
Object.assign(debounce, {
|
||||||
timers: new Map(),
|
timers: new Map(),
|
||||||
run(fn, ...args) {
|
run(fn, ...args) {
|
||||||
debounce.timers.delete(fn);
|
debounce.timers.delete(fn);
|
||||||
|
@ -284,27 +283,28 @@ const debounce = Object.assign((fn, delay, ...args) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function deepMerge(src, dst) {
|
||||||
function deepCopy(obj) {
|
if (!src || typeof src !== 'object') {
|
||||||
if (!obj || typeof obj !== 'object') return obj;
|
return src;
|
||||||
// N.B. the copy should be an explicit literal
|
}
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(src)) {
|
||||||
const copy = [];
|
// using `Array` that belongs to this `window`; not using Array.from as it's slower
|
||||||
for (const v of obj) {
|
if (!dst) dst = Array.prototype.map.call(src, deepCopy);
|
||||||
copy.push(!v || typeof v !== 'object' ? v : deepCopy(v));
|
else for (const v of src) dst.push(deepMerge(v));
|
||||||
|
} else {
|
||||||
|
// using an explicit {} that belongs to this `window`
|
||||||
|
if (!dst) dst = {};
|
||||||
|
for (const [k, v] of Object.entries(src)) {
|
||||||
|
dst[k] = deepMerge(v, dst[k]);
|
||||||
}
|
}
|
||||||
return copy;
|
|
||||||
}
|
}
|
||||||
const copy = {};
|
return dst;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Useful in arr.map(deepCopy) to ignore the extra parameters passed by map() */
|
||||||
|
function deepCopy(src) {
|
||||||
|
return deepMerge(src);
|
||||||
|
}
|
||||||
|
|
||||||
function deepEqual(a, b, ignoredKeys) {
|
function deepEqual(a, b, ignoredKeys) {
|
||||||
if (!a || !b) return a === b;
|
if (!a || !b) return a === b;
|
||||||
|
@ -371,70 +371,49 @@ function download(url, {
|
||||||
requiredStatusCode = 200,
|
requiredStatusCode = 200,
|
||||||
timeout = 60e3, // connection timeout, USO is that bad
|
timeout = 60e3, // connection timeout, USO is that bad
|
||||||
loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
|
loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
|
||||||
headers = {
|
headers,
|
||||||
'Content-type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const queryPos = url.indexOf('?');
|
/* USO can't handle POST requests for style json and XHR/fetch can't handle super long URL
|
||||||
if (queryPos > 0 && body === undefined) {
|
* so we need to collapse all long variables and expand them in the response */
|
||||||
method = 'POST';
|
const queryPos = url.startsWith(URLS.uso) ? url.indexOf('?') : -1;
|
||||||
body = url.slice(queryPos);
|
if (queryPos >= 0) {
|
||||||
url = url.slice(0, queryPos);
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// * USO can't handle POST requests for style json
|
|
||||||
// * XHR/fetch can't handle long URL
|
|
||||||
// So we need to collapse all long variables and expand them in the response
|
|
||||||
const usoVars = [];
|
const usoVars = [];
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let xhr;
|
const xhr = new XMLHttpRequest();
|
||||||
const u = new URL(collapseUsoVars(url));
|
const u = new URL(collapseUsoVars(url));
|
||||||
const onTimeout = () => {
|
const onTimeout = () => {
|
||||||
if (xhr) xhr.abort();
|
xhr.abort();
|
||||||
reject(new Error('Timeout fetching ' + u.href));
|
reject(new Error('Timeout fetching ' + u.href));
|
||||||
};
|
};
|
||||||
let timer = setTimeout(onTimeout, timeout);
|
let timer = setTimeout(onTimeout, timeout);
|
||||||
const switchTimer = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
|
|
||||||
};
|
|
||||||
if (u.protocol === 'file:' && FIREFOX) { // TODO: maybe remove this since FF68+ can't do it anymore
|
|
||||||
// https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path
|
|
||||||
// FIXME: add FetchController when it is available.
|
|
||||||
fetch(u.href, {mode: 'same-origin'})
|
|
||||||
.then(r => {
|
|
||||||
switchTimer();
|
|
||||||
return r.status === 200 ? r.text() : Promise.reject(r.status);
|
|
||||||
})
|
|
||||||
.catch(reject)
|
|
||||||
.then(text => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(text);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
xhr = new XMLHttpRequest();
|
|
||||||
xhr.onreadystatechange = () => {
|
xhr.onreadystatechange = () => {
|
||||||
if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
|
if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
|
||||||
xhr.onreadystatechange = null;
|
xhr.onreadystatechange = null;
|
||||||
switchTimer();
|
clearTimeout(timer);
|
||||||
|
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onloadend = event => {
|
xhr.onload = () =>
|
||||||
clearTimeout(timer);
|
xhr.status === requiredStatusCode || !requiredStatusCode || u.protocol === 'file:'
|
||||||
if (event.type !== 'error' && (
|
? resolve(expandUsoVars(xhr.response))
|
||||||
xhr.status === requiredStatusCode || !requiredStatusCode ||
|
: reject(xhr.status);
|
||||||
u.protocol === 'file:')) {
|
xhr.onerror = () => reject(xhr.status);
|
||||||
resolve(expandUsoVars(xhr.response));
|
xhr.onloadend = () => clearTimeout(timer);
|
||||||
} else {
|
|
||||||
reject(xhr.status);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = xhr.onloadend;
|
|
||||||
xhr.responseType = responseType;
|
xhr.responseType = responseType;
|
||||||
xhr.open(method, u.href, true);
|
xhr.open(method, u.href);
|
||||||
for (const key in headers) {
|
for (const [name, value] of Object.entries(headers || {})) {
|
||||||
xhr.setRequestHeader(key, headers[key]);
|
xhr.setRequestHeader(name, value);
|
||||||
}
|
}
|
||||||
xhr.send(body);
|
xhr.send(body);
|
||||||
});
|
});
|
||||||
|
@ -470,13 +449,10 @@ function download(url, {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCurrentTab() {
|
async function closeCurrentTab() {
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
|
// https://bugzil.la/1409375
|
||||||
getOwnTab().then(tab => {
|
const tab = await getOwnTab();
|
||||||
if (tab) {
|
if (tab) chrome.tabs.remove(tab.id);
|
||||||
chrome.tabs.remove(tab.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(s) {
|
function capitalize(s) {
|
128
js/usercss-compiler.js
Normal file
128
js/usercss-compiler.js
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BUILDERS = Object.assign(Object.create(null), {
|
||||||
|
|
||||||
|
default: {
|
||||||
|
post(sections, vars) {
|
||||||
|
require(['/js/sections-util']); /* global styleCodeEmpty */
|
||||||
|
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
|
||||||
|
if (!varDef) return;
|
||||||
|
varDef = ':root {\n' + varDef + '}\n';
|
||||||
|
for (const section of sections) {
|
||||||
|
if (!styleCodeEmpty(section.code)) {
|
||||||
|
section.code = varDef + section.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
stylus: {
|
||||||
|
pre(source, vars) {
|
||||||
|
require(['/vendor/stylus-lang-bundle/stylus-renderer.min']); /* global StylusRenderer */
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
|
||||||
|
new StylusRenderer(varDef + source)
|
||||||
|
.render((err, output) => err ? reject(err) : resolve(output));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
less: {
|
||||||
|
async pre(source, vars) {
|
||||||
|
if (!self.less) {
|
||||||
|
self.less = {
|
||||||
|
logLevel: 0,
|
||||||
|
useFileCache: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
require(['/vendor/less-bundle/less.min']); /* global less */
|
||||||
|
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
|
||||||
|
return (await less.render(varDefs + source)).css;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
uso: {
|
||||||
|
async pre(source, vars) {
|
||||||
|
require(['/js/color/color-converter']); /* global colorConverter */
|
||||||
|
const pool = new Map();
|
||||||
|
return doReplace(source);
|
||||||
|
|
||||||
|
function getValue(name, rgbName) {
|
||||||
|
if (!vars.hasOwnProperty(name)) {
|
||||||
|
if (name.endsWith('-rgb')) {
|
||||||
|
return getValue(name.slice(0, -4), name);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const {type, value} = vars[name];
|
||||||
|
switch (type) {
|
||||||
|
case 'color': {
|
||||||
|
let color = pool.get(rgbName || name);
|
||||||
|
if (color == null) {
|
||||||
|
color = colorConverter.parse(value);
|
||||||
|
if (color) {
|
||||||
|
if (color.type === 'hsl') {
|
||||||
|
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
|
||||||
|
}
|
||||||
|
const {r, g, b} = color;
|
||||||
|
color = rgbName
|
||||||
|
? `${r}, ${g}, ${b}`
|
||||||
|
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||||
|
}
|
||||||
|
// the pool stores `false` for bad colors to differentiate from a yet unknown color
|
||||||
|
pool.set(rgbName || name, color || false);
|
||||||
|
}
|
||||||
|
return color || null;
|
||||||
|
}
|
||||||
|
case 'dropdown':
|
||||||
|
case 'select': // prevent infinite recursion
|
||||||
|
pool.set(name, '');
|
||||||
|
return doReplace(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doReplace(text) {
|
||||||
|
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
|
||||||
|
if (!pool.has(name)) {
|
||||||
|
const value = getValue(name);
|
||||||
|
pool.set(name, value === null ? match : value);
|
||||||
|
}
|
||||||
|
return pool.get(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* exported compileUsercss */
|
||||||
|
async function compileUsercss(preprocessor, code, vars) {
|
||||||
|
let builder = BUILDERS[preprocessor];
|
||||||
|
if (!builder) {
|
||||||
|
builder = BUILDERS.default;
|
||||||
|
if (preprocessor != null) console.warn(`Unknown preprocessor "${preprocessor}"`);
|
||||||
|
}
|
||||||
|
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
|
||||||
|
// need to test each va's default value.
|
||||||
|
vars = Object.entries(vars || {}).reduce((output, [key, va]) => {
|
||||||
|
// TODO: handle customized image
|
||||||
|
const prop = va.value == null ? 'default' : 'value';
|
||||||
|
const value =
|
||||||
|
/^(select|dropdown|image)$/.test(va.type) ?
|
||||||
|
va.options.find(o => o.name === va[prop]).value :
|
||||||
|
/^(number|range)$/.test(va.type) && va.units ?
|
||||||
|
va[prop] + va.units :
|
||||||
|
va[prop];
|
||||||
|
output[key] = Object.assign({}, va, {value});
|
||||||
|
return output;
|
||||||
|
}, {});
|
||||||
|
if (builder.pre) {
|
||||||
|
code = await builder.pre(code, vars);
|
||||||
|
}
|
||||||
|
require(['/js/moz-parser']); /* global extractSections */
|
||||||
|
const res = extractSections({code});
|
||||||
|
if (builder.post) {
|
||||||
|
builder.post(res.sections, vars);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user