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:
tophf 2021-01-01 17:27:58 +03:00 committed by GitHub
parent 06823bd5b4
commit fdbfb23547
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 8336 additions and 8877 deletions

View File

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

View File

@ -8,6 +8,9 @@ env:
es6: true es6: true
webextensions: true webextensions: true
globals:
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

View File

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

View File

@ -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;
}

View File

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

View 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
View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'}],
});
}
});

View File

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

View File

@ -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

View 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

View File

@ -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+/)

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 = [];
}
})();

View File

@ -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 = [];
}
})();

View File

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

View File

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

View 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, ' ');
}

View File

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

View File

@ -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}, '*');
} }

View File

@ -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 => {

View File

@ -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

View File

@ -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,

View File

@ -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;
} }
} }

View File

@ -4,8 +4,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet"> <link href="global.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
<link rel="stylesheet" href="msgbox/msgbox.css">
<style id="firefox-transitions-bug-suppressor"> <style id="firefox-transitions-bug-suppressor">
/* restrict to FF */ /* restrict to FF */
@ -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
View 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
View 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

View File

@ -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');
};
}

View File

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

View File

@ -1,13 +1,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();
}, },

View File

@ -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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,197 +0,0 @@
/* global memoize editorWorker showCodeMirrorPopup loadScript messageBox
LINTER_DEFAULTS rerouteHotkeys $ $create $createLink tryJSONparse t
chromeSync */
'use strict';
(() => {
document.addEventListener('DOMContentLoaded', () => {
$('#linter-settings').addEventListener('click', showLintConfig);
}, {once: true});
function stringifyConfig(config) {
return JSON.stringify(config, null, 2)
.replace(/,\n\s+\{\n\s+("severity":\s"\w+")\n\s+\}/g, ', {$1}');
}
function showLinterErrorMessage(title, contents, popup) {
messageBox({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
}).then(() => popup && popup.codebox && popup.codebox.focus());
}
function showLintConfig() {
const linter = $('#editor.linter').value;
if (!linter) {
return;
}
const storageName = chromeSync.LZ_KEY[linter];
const getRules = memoize(linter === 'stylelint' ?
editorWorker.getStylelintRules : editorWorker.getCsslintRules);
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
const defaultConfig = stringifyConfig(
linter === 'stylelint' ? LINTER_DEFAULTS.STYLELINT : LINTER_DEFAULTS.CSSLINT
);
const title = t('linterConfigPopupTitle', linterTitle);
const popup = showCodeMirrorPopup(title, null, {
lint: false,
extraKeys: {'Ctrl-Enter': save},
hintOptions: {hint},
});
$('.contents', popup).appendChild(makeFooter());
let cm = popup.codebox;
cm.focus();
chromeSync.getLZValue(storageName).then(config => {
cm.setValue(config ? stringifyConfig(config) : defaultConfig);
cm.clearHistory();
cm.markClean();
updateButtonState();
});
cm.on('changes', updateButtonState);
rerouteHotkeys(false);
window.addEventListener('closeHelp', () => {
rerouteHotkeys(true);
cm = null;
}, {once: true});
loadScript([
'/vendor/codemirror/mode/javascript/javascript.js',
'/vendor/codemirror/addon/lint/json-lint.js',
'/vendor/jsonlint/jsonlint.js',
]).then(() => {
cm.setOption('mode', 'application/json');
cm.setOption('lint', true);
});
function findInvalidRules(config, linter) {
return getRules()
.then(rules => {
if (linter === 'stylelint') {
return Object.keys(config.rules).filter(k => !config.rules.hasOwnProperty(k));
}
const ruleSet = new Set(rules.map(r => r.id));
return Object.keys(config).filter(k => !ruleSet.has(k));
});
}
function makeFooter() {
return $create('div', [
$create('p', [
$createLink(
linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
t('linterRulesLink')),
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
]),
$create('.buttons', [
$create('button.save', {onclick: save, title: 'Ctrl-Enter'}, t('styleSaveLabel')),
$create('button.cancel', {onclick: cancel}, t('confirmClose')),
$create('button.reset', {onclick: reset, title: t('linterResetMessage')}, t('genericResetLabel')),
]),
]);
}
function save(event) {
if (event instanceof Event) {
event.preventDefault();
}
const json = tryJSONparse(cm.getValue());
if (!json) {
showLinterErrorMessage(linter, t('linterJSONError'), popup);
cm.focus();
return;
}
findInvalidRules(json, linter).then(invalid => {
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$create('ul', invalid.map(name => $create('li', name))),
], popup);
return;
}
chromeSync.setLZValue(storageName, json);
cm.markClean();
cm.focus();
updateButtonState();
});
}
function reset(event) {
event.preventDefault();
cm.setValue(defaultConfig);
cm.focus();
updateButtonState();
}
function cancel(event) {
event.preventDefault();
$('.dismiss').dispatchEvent(new Event('click'));
}
function updateButtonState() {
$('.save', popup).disabled = cm.isClean();
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
}
function hint(cm) {
return getRules().then(rules => {
let ruleIds, options;
if (linter === 'stylelint') {
ruleIds = Object.keys(rules);
options = rules;
} else {
ruleIds = rules.map(r => r.id);
options = {};
}
const cursor = cm.getCursor();
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
const {line, ch} = cursor;
const quoted = string.startsWith('"');
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
const depth = getLexicalDepth(lexical);
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
let [, prevWord] = search.find(true) || [];
let words = [];
if (depth === 1 && linter === 'stylelint') {
words = quoted ? ['rules'] : [];
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
words = ruleIds;
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
words = !quoted ? ['true', 'false', 'null'] :
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
} else if (depth === 4 && prevWord === 'severity') {
words = ['error', 'warning'];
} else if (depth === 4) {
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
} else if (depth === 5 && lexical.type === ']' && quoted) {
while (prevWord && !ruleIds.includes(prevWord)) {
prevWord = (search.find(true) || [])[1];
}
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
}
return {
list: words.filter(word => word.startsWith(leftPart)),
from: {line, ch: start + (quoted ? 1 : 0)},
to: {line, ch: string.endsWith('"') ? end - 1 : end},
};
});
}
function getLexicalDepth(lexicalState) {
let depth = 0;
while ((lexicalState = lexicalState.prev)) {
depth++;
}
return depth;
}
}
})();

View File

@ -1,222 +0,0 @@
/* exported LINTER_DEFAULTS */
'use strict';
const LINTER_DEFAULTS = (() => {
const SEVERITY = {severity: 'warning'};
const STYLELINT = {
// 'sugarss' is a indent-based syntax like Sass or Stylus
// ref: https://github.com/postcss/postcss#syntaxes
// syntax: 'sugarss',
// ** recommended rules **
// ref: https://github.com/stylelint/stylelint-config-recommended/blob/master/index.js
rules: {
'at-rule-no-unknown': [true, {
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
'severity': 'warning',
}],
'block-no-empty': [true, SEVERITY],
'color-no-invalid-hex': [true, SEVERITY],
'declaration-block-no-duplicate-properties': [true, {
'ignore': ['consecutive-duplicates-with-different-values'],
'severity': 'warning',
}],
'declaration-block-no-shorthand-property-overrides': [true, SEVERITY],
'font-family-no-duplicate-names': [true, SEVERITY],
'function-calc-no-unspaced-operator': [true, SEVERITY],
'function-linear-gradient-no-nonstandard-direction': [true, SEVERITY],
'keyframe-declaration-no-important': [true, SEVERITY],
'media-feature-name-no-unknown': [true, SEVERITY],
/* recommended true */
'no-empty-source': false,
'no-extra-semicolons': [true, SEVERITY],
'no-invalid-double-slash-comments': [true, SEVERITY],
'property-no-unknown': [true, SEVERITY],
'selector-pseudo-class-no-unknown': [true, SEVERITY],
'selector-pseudo-element-no-unknown': [true, SEVERITY],
'selector-type-no-unknown': false, // for scss/less/stylus-lang
'string-no-newline': [true, SEVERITY],
'unit-no-unknown': [true, SEVERITY],
// ** non-essential rules
'comment-no-empty': false,
'declaration-block-no-redundant-longhand-properties': false,
'shorthand-property-no-redundant-values': false,
// ** stylistic rules **
/*
'at-rule-empty-line-before': [
'always',
{
'except': [
'blockless-after-same-name-blockless',
'first-nested'
],
'ignore': [
'after-comment'
]
}
],
'at-rule-name-case': 'lower',
'at-rule-name-space-after': 'always-single-line',
'at-rule-semicolon-newline-after': 'always',
'block-closing-brace-empty-line-before': 'never',
'block-closing-brace-newline-after': 'always',
'block-closing-brace-newline-before': 'always-multi-line',
'block-closing-brace-space-before': 'always-single-line',
'block-opening-brace-newline-after': 'always-multi-line',
'block-opening-brace-space-after': 'always-single-line',
'block-opening-brace-space-before': 'always',
'color-hex-case': 'lower',
'color-hex-length': 'short',
'comment-empty-line-before': [
'always',
{
'except': [
'first-nested'
],
'ignore': [
'stylelint-commands'
]
}
],
'comment-whitespace-inside': 'always',
'custom-property-empty-line-before': [
'always',
{
'except': [
'after-custom-property',
'first-nested'
],
'ignore': [
'after-comment',
'inside-single-line-block'
]
}
],
'declaration-bang-space-after': 'never',
'declaration-bang-space-before': 'always',
'declaration-block-semicolon-newline-after': 'always-multi-line',
'declaration-block-semicolon-space-after': 'always-single-line',
'declaration-block-semicolon-space-before': 'never',
'declaration-block-single-line-max-declarations': 1,
'declaration-block-trailing-semicolon': 'always',
'declaration-colon-newline-after': 'always-multi-line',
'declaration-colon-space-after': 'always-single-line',
'declaration-colon-space-before': 'never',
'declaration-empty-line-before': [
'always',
{
'except': [
'after-declaration',
'first-nested'
],
'ignore': [
'after-comment',
'inside-single-line-block'
]
}
],
'function-comma-newline-after': 'always-multi-line',
'function-comma-space-after': 'always-single-line',
'function-comma-space-before': 'never',
'function-max-empty-lines': 0,
'function-name-case': 'lower',
'function-parentheses-newline-inside': 'always-multi-line',
'function-parentheses-space-inside': 'never-single-line',
'function-whitespace-after': 'always',
'indentation': 2,
'length-zero-no-unit': true,
'max-empty-lines': 1,
'media-feature-colon-space-after': 'always',
'media-feature-colon-space-before': 'never',
'media-feature-name-case': 'lower',
'media-feature-parentheses-space-inside': 'never',
'media-feature-range-operator-space-after': 'always',
'media-feature-range-operator-space-before': 'always',
'media-query-list-comma-newline-after': 'always-multi-line',
'media-query-list-comma-space-after': 'always-single-line',
'media-query-list-comma-space-before': 'never',
'no-eol-whitespace': true,
'no-missing-end-of-source-newline': true,
'number-leading-zero': 'always',
'number-no-trailing-zeros': true,
'property-case': 'lower',
'rule-empty-line-before': [
'always-multi-line',
{
'except': [
'first-nested'
],
'ignore': [
'after-comment'
]
}
],
'selector-attribute-brackets-space-inside': 'never',
'selector-attribute-operator-space-after': 'never',
'selector-attribute-operator-space-before': 'never',
'selector-combinator-space-after': 'always',
'selector-combinator-space-before': 'always',
'selector-descendant-combinator-no-non-space': true,
'selector-list-comma-newline-after': 'always',
'selector-list-comma-space-before': 'never',
'selector-max-empty-lines': 0,
'selector-pseudo-class-case': 'lower',
'selector-pseudo-class-parentheses-space-inside': 'never',
'selector-pseudo-element-case': 'lower',
'selector-pseudo-element-colon-notation': 'double',
'selector-type-case': 'lower',
'unit-case': 'lower',
'value-list-comma-newline-after': 'always-multi-line',
'value-list-comma-space-after': 'always-single-line',
'value-list-comma-space-before': 'never',
'value-list-max-empty-lines': 0
*/
},
};
const CSSLINT = {
// Default warnings
'display-property-grouping': 1,
'duplicate-properties': 1,
'empty-rules': 1,
'errors': 1,
'warnings': 1,
'known-properties': 1,
// Default disabled
'adjoining-classes': 0,
'box-model': 0,
'box-sizing': 0,
'bulletproof-font-face': 0,
'compatible-vendor-prefixes': 0,
'duplicate-background-images': 0,
'fallback-colors': 0,
'floats': 0,
'font-faces': 0,
'font-sizes': 0,
'gradients': 0,
'ids': 0,
'import': 0,
'import-ie-limit': 0,
'important': 0,
'order-alphabetical': 0,
'outline-none': 0,
'overqualified-elements': 0,
'qualified-headings': 0,
'regex-selectors': 0,
'rules-count': 0,
'selector-max': 0,
'selector-max-approaching': 0,
'selector-newline': 0,
'shorthand': 0,
'star-property-hack': 0,
'text-indent': 0,
'underscore-property-hack': 0,
'unique-headings': 0,
'universal-selector': 0,
'unqualified-attributes': 0,
'vendor-prefix': 0,
'zero-units': 0,
};
return {STYLELINT, CSSLINT, SEVERITY};
})();

226
edit/linter-dialogs.js Normal file
View 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');
}
})();

View File

@ -1,115 +0,0 @@
/* global LINTER_DEFAULTS linter editorWorker prefs chromeSync */
'use strict';
(() => {
registerLinters({
csslint: {
storageName: chromeSync.LZ_KEY.csslint,
lint: csslint,
validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config),
},
stylelint: {
storageName: chromeSync.LZ_KEY.stylelint,
lint: stylelint,
validMode: () => true,
getConfig: config => ({
syntax: 'sugarss',
rules: Object.assign({}, LINTER_DEFAULTS.STYLELINT.rules, config && config.rules),
}),
},
});
async function stylelint(text, config, mode) {
const raw = await editorWorker.stylelint(text, config);
if (!raw) {
return [];
}
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
// and we can't just pre-remove the comments since "//" may be inside a string token or whatever
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
const res = [];
for (const w of raw.warnings) {
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
if (!slashCommentAllowed || !(
w.rule === 'no-invalid-double-slash-comments' ||
w.rule === 'property-no-unknown' && msg.includes('"//"')
)) {
res.push({
from: {line: w.line - 1, ch: w.column - 1},
to: {line: w.line - 1, ch: w.column},
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
severity: w.severity,
rule: w.rule,
});
}
}
return res;
}
function csslint(text, config) {
return editorWorker.csslint(text, config)
.then(results =>
results
.map(({line, col: ch, message, rule, type: severity}) => line && {
message,
from: {line: line - 1, ch: ch - 1},
to: {line: line - 1, ch},
rule: rule.id,
severity,
})
.filter(Boolean)
);
}
function registerLinters(engines) {
const configs = new Map();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') {
return;
}
for (const [name, engine] of Object.entries(engines)) {
if (changes.hasOwnProperty(engine.storageName)) {
chromeSync.getLZValue(engine.storageName)
.then(config => {
configs.set(name, engine.getConfig(config));
linter.run();
});
}
}
});
linter.register((text, options, cm) => {
const selectedLinter = prefs.get('editor.linter');
if (!selectedLinter) {
return;
}
const mode = cm.getOption('mode');
if (engines[selectedLinter].validMode(mode)) {
return runLint(selectedLinter);
}
for (const [name, engine] of Object.entries(engines)) {
if (engine.validMode(mode)) {
return runLint(name);
}
}
function runLint(name) {
return getConfig(name)
.then(config => engines[name].lint(text, config, mode));
}
});
function getConfig(name) {
if (configs.has(name)) {
return Promise.resolve(configs.get(name));
}
return chromeSync.getLZValue(engines[name].storageName)
.then(config => {
configs.set(name, engines[name].getConfig(config));
return configs.get(name);
});
}
}
})();

View File

@ -1,52 +0,0 @@
/* global showHelp editorWorker memoize $ $create $createLink t */
/* exported createLinterHelpDialog */
'use strict';
function createLinterHelpDialog(getIssues) {
let csslintRules;
const prepareCsslintRules = memoize(() =>
editorWorker.getCsslintRules()
.then(rules => {
csslintRules = rules;
})
);
return {show};
function show() {
// FIXME: implement a linterChooser?
const linter = $('#editor.linter').value;
const baseUrl = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
// some CSSLint rules do not have a url
: 'https://github.com/CSSLint/csslint/issues/535';
let headerLink, template;
if (linter === 'csslint') {
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
template = ({rule: ruleID}) => {
const rule = csslintRules.find(rule => rule.id === ruleID);
return rule &&
$create('li', [
$create('b', $createLink(rule.url || baseUrl, rule.name)),
$create('br'),
rule.desc,
]);
};
} else {
headerLink = $createLink(baseUrl, 'stylelint');
template = rule =>
$create('li',
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
}
const header = t('linterIssuesHelp', '\x01').split('\x01');
const activeRules = new Set([...getIssues()].map(issue => issue.rule));
Promise.resolve(linter === 'csslint' && prepareCsslintRules())
.then(() =>
showHelp(t('linterIssues'),
$create([
header[0], headerLink, header[1],
$create('ul.rules', [...activeRules].map(template)),
])
)
);
}
}

420
edit/linter-manager.js Normal file
View 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

View File

@ -1,44 +0,0 @@
/* global linter editorWorker */
/* exported createMetaCompiler */
'use strict';
/**
* @param {CodeMirror} cm
* @param {function(meta:Object)} onUpdated
*/
function createMetaCompiler(cm, onUpdated) {
let meta = null;
let metaIndex = null;
let cache = [];
linter.register((text, options, _cm) => {
if (_cm !== cm) {
return;
}
const match = text.match(/\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i);
if (!match) {
return [];
}
if (match[0] === meta && match.index === metaIndex) {
return cache;
}
return editorWorker.metalint(match[0])
.then(({metadata, errors}) => {
if (errors.every(err => err.code === 'unknownMeta')) {
onUpdated(metadata);
}
cache = errors.map(err =>
({
from: cm.posFromIndex((err.index || 0) + match.index),
to: cm.posFromIndex((err.index || 0) + match.index),
message: err.code && chrome.i18n.getMessage(`meta_${err.code}`, err.args) || err.message,
severity: err.code === 'unknownMeta' ? 'warning' : 'error',
rule: err.code,
})
);
meta = match[0];
metaIndex = match.index;
return cache;
});
});
}

View File

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

View File

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

View File

@ -1,74 +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();
}
}
}

View File

@ -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;
}

View File

@ -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) {

View File

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

View File

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

View File

@ -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)) {

View File

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

View File

@ -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;

View File

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

View File

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

View File

@ -7,44 +7,21 @@
<title>Loading...</title> <title>Loading...</title>
<link href="global.css" rel="stylesheet"> <link href="global.css" rel="stylesheet">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
<script src="js/polyfill.js"></script> <script src="js/polyfill.js"></script>
<script src="js/msg.js"></script> <script src="js/msg.js"></script>
<script src="js/messaging.js"></script> <script src="js/toolbox.js"></script>
<script src="install-usercss/preinit.js"></script>
<script src="js/prefs.js"></script> <script src="js/prefs.js"></script>
<script src="js/dom.js"></script> <script src="js/dom.js"></script>
<script src="js/localization.js"></script> <script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script> <script src="content/style-injector.js"></script>
<script src="content/apply.js"></script> <script src="content/apply.js"></script>
<script src="vendor/semver-bundle/semver.js"></script>
<link href="msgbox/msgbox.css" rel="stylesheet"> <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>

View File

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

View File

@ -1,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);
});
}
}

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

View File

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

View File

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

View File

@ -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 = [];

View File

@ -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',

View File

@ -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,

View File

@ -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);

View File

@ -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
View File

@ -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

View File

@ -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

View File

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

View File

@ -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
View File

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

View File

@ -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 {

View File

@ -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() {

View File

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

View File

@ -1,50 +0,0 @@
/* exported loadScript */
'use strict';
// loadScript(script: Array<Promise|string>|string): Promise
const loadScript = (() => {
const cache = new Map();
function inject(file) {
if (!cache.has(file)) {
cache.set(file, doInject(file));
}
return cache.get(file);
}
function doInject(file) {
return new Promise((resolve, reject) => {
let el;
if (file.endsWith('.js')) {
el = document.createElement('script');
el.src = file;
} else {
el = document.createElement('link');
el.rel = 'stylesheet';
el.href = file;
}
el.onload = () => {
el.onload = null;
el.onerror = null;
resolve(el);
};
el.onerror = () => {
el.onload = null;
el.onerror = null;
reject(new Error(`Failed to load script: ${file}`));
};
document.head.appendChild(el);
});
}
return (files, noCache = false) => {
if (!Array.isArray(files)) {
files = [files];
}
return Promise.all(files.map(f =>
typeof f !== 'string' ? f :
noCache ? doInject(f) :
inject(f)
));
};
})();

View File

@ -1,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';
} }

View File

@ -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} */

View File

@ -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
View 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