API groups + use executeScript for early injection (#1149)
* parserlib: fast section extraction, tweaks and speedups
* csslint: "simple-not" rule
* csslint: enable and fix "selector-newline" rule
* simplify db: resolve with result
* simplify download()
* remove noCode param as it wastes more time/memory on copying
* styleManager: switch style<->data names to reflect their actual contents
* inline method bodies to avoid indirection and enable better autocomplete/hint/jump support in IDE
* upgrade getEventKeyName to handle mouse clicks
* don't trust location.href as it hides text fragment
* getAllKeys is implemented since Chrome48, FF44
* allow recoverable css errors + async'ify usercss.js
* openManage: unminimize windows
* remove the obsolete Chrome pre-65 workaround
* fix temporal dead zone in apply.js
* ff bug workaround for simple editor window
* consistent window scrolling in scrollToEditor and jumpToPos
* rework waitForSelector and collapsible <details>
* blank paint frame workaround for new Chrome
* extract stuff from edit.js and load on demand
* simplify regexpTester::isShown
* move MozDocMapper to sections-util.js
* extract fitSelectBox()
* initialize router earlier
* use helpPopup.close()
* fix autofocus in popups, follow-up to 5bb1b5ef
* clone objects in prefs.get() + cosmetics
* reuse getAll result for INC
This commit is contained in:
parent
06823bd5b4
commit
fdbfb23547
|
@ -1,4 +1,2 @@
|
|||
vendor/
|
||||
vendor-overwrites/*
|
||||
!vendor-overwrites/colorpicker
|
||||
!vendor-overwrites/csslint
|
||||
vendor-overwrites/
|
||||
|
|
|
@ -8,6 +8,9 @@ env:
|
|||
es6: true
|
||||
webextensions: true
|
||||
|
||||
globals:
|
||||
require: readonly # in polyfill.js
|
||||
|
||||
rules:
|
||||
accessor-pairs: [2]
|
||||
array-bracket-spacing: [2, never]
|
||||
|
@ -42,7 +45,15 @@ rules:
|
|||
id-blacklist: [0]
|
||||
id-length: [0]
|
||||
id-match: [0]
|
||||
indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}]
|
||||
indent: [2, 2, {
|
||||
SwitchCase: 1,
|
||||
ignoreComments: true,
|
||||
ignoredNodes: [
|
||||
"TemplateLiteral > *",
|
||||
"ConditionalExpression",
|
||||
"ForStatement"
|
||||
]
|
||||
}]
|
||||
jsx-quotes: [0]
|
||||
key-spacing: [0]
|
||||
keyword-spacing: [2]
|
||||
|
@ -86,7 +97,7 @@ rules:
|
|||
no-empty: [2, {allowEmptyCatch: true}]
|
||||
no-eq-null: [0]
|
||||
no-eval: [2]
|
||||
no-ex-assign: [2]
|
||||
no-ex-assign: [0]
|
||||
no-extend-native: [2]
|
||||
no-extra-bind: [2]
|
||||
no-extra-boolean-cast: [2]
|
||||
|
@ -136,6 +147,9 @@ rules:
|
|||
no-proto: [2]
|
||||
no-redeclare: [2]
|
||||
no-regex-spaces: [2]
|
||||
no-restricted-globals: [2, name, event]
|
||||
# `name` and `event` (in Chrome) are built-in globals
|
||||
# but we don't use these globals so it's most likely a mistake/typo
|
||||
no-restricted-imports: [0]
|
||||
no-restricted-modules: [2, domain, freelist, smalloc, sys]
|
||||
no-restricted-syntax: [2, WithStatement]
|
||||
|
@ -163,7 +177,7 @@ rules:
|
|||
no-unreachable: [2]
|
||||
no-unsafe-finally: [2]
|
||||
no-unsafe-negation: [2]
|
||||
no-unused-expressions: [1]
|
||||
no-unused-expressions: [2]
|
||||
no-unused-labels: [0]
|
||||
no-unused-vars: [2, {args: after-used}]
|
||||
no-use-before-define: [2, nofunc]
|
||||
|
@ -220,3 +234,7 @@ overrides:
|
|||
webextensions: false
|
||||
parserOptions:
|
||||
ecmaVersion: 2017
|
||||
|
||||
- files: ["**/*worker*.js"]
|
||||
env:
|
||||
worker: true
|
||||
|
|
|
@ -1,176 +1,26 @@
|
|||
/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */
|
||||
/* global createWorkerApi */// worker-util.js
|
||||
'use strict';
|
||||
|
||||
importScripts('/js/worker-util.js');
|
||||
const {loadScript} = workerUtil;
|
||||
/** @namespace BackgroundWorker */
|
||||
createWorkerApi({
|
||||
|
||||
workerUtil.createAPI({
|
||||
parseMozFormat(arg) {
|
||||
loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
|
||||
return parseMozFormat(arg);
|
||||
},
|
||||
compileUsercss,
|
||||
parseUsercssMeta(text, indexOffset = 0) {
|
||||
loadScript(
|
||||
'/vendor/usercss-meta/usercss-meta.min.js',
|
||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
||||
'/js/meta-parser.js'
|
||||
);
|
||||
return metaParser.parse(text, indexOffset);
|
||||
async compileUsercss(...args) {
|
||||
require(['/js/usercss-compiler']); /* global compileUsercss */
|
||||
return compileUsercss(...args);
|
||||
},
|
||||
|
||||
nullifyInvalidVars(vars) {
|
||||
loadScript(
|
||||
'/vendor/usercss-meta/usercss-meta.min.js',
|
||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
||||
'/js/meta-parser.js'
|
||||
);
|
||||
require(['/js/meta-parser']); /* global metaParser */
|
||||
return metaParser.nullifyInvalidVars(vars);
|
||||
},
|
||||
|
||||
parseMozFormat(...args) {
|
||||
require(['/js/moz-parser']); /* global extractSections */
|
||||
return extractSections(...args);
|
||||
},
|
||||
|
||||
parseUsercssMeta(text) {
|
||||
require(['/js/meta-parser']);
|
||||
return metaParser.parse(text);
|
||||
},
|
||||
});
|
||||
|
||||
function compileUsercss(preprocessor, code, vars) {
|
||||
loadScript(
|
||||
'/vendor-overwrites/csslint/parserlib.js',
|
||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
||||
'/js/moz-parser.js'
|
||||
);
|
||||
const builder = getUsercssCompiler(preprocessor);
|
||||
vars = simpleVars(vars);
|
||||
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code)
|
||||
.then(code => parseMozFormat({code}))
|
||||
.then(({sections, errors}) => {
|
||||
if (builder.postprocess) {
|
||||
builder.postprocess(sections, vars);
|
||||
}
|
||||
return {sections, errors};
|
||||
});
|
||||
|
||||
function simpleVars(vars) {
|
||||
if (!vars) {
|
||||
return {};
|
||||
}
|
||||
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
|
||||
// need to test each va's default value.
|
||||
return Object.keys(vars).reduce((output, key) => {
|
||||
const va = vars[key];
|
||||
output[key] = Object.assign({}, va, {
|
||||
value: va.value === null || va.value === undefined ?
|
||||
getVarValue(va, 'default') : getVarValue(va, 'value'),
|
||||
});
|
||||
return output;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getVarValue(va, prop) {
|
||||
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
|
||||
// TODO: handle customized image
|
||||
return va.options.find(o => o.name === va[prop]).value;
|
||||
}
|
||||
if ((va.type === 'number' || va.type === 'range') && va.units) {
|
||||
return va[prop] + va.units;
|
||||
}
|
||||
return va[prop];
|
||||
}
|
||||
}
|
||||
|
||||
function getUsercssCompiler(preprocessor) {
|
||||
const BUILDER = {
|
||||
default: {
|
||||
postprocess(sections, vars) {
|
||||
loadScript('/js/sections-util.js');
|
||||
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
|
||||
if (!varDef) return;
|
||||
varDef = ':root {\n' + varDef + '}\n';
|
||||
for (const section of sections) {
|
||||
if (!styleCodeEmpty(section.code)) {
|
||||
section.code = varDef + section.code;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
stylus: {
|
||||
preprocess(source, vars) {
|
||||
loadScript('/vendor/stylus-lang-bundle/stylus-renderer.min.js');
|
||||
return new Promise((resolve, reject) => {
|
||||
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
|
||||
new self.StylusRenderer(varDef + source)
|
||||
.render((err, output) => err ? reject(err) : resolve(output));
|
||||
});
|
||||
},
|
||||
},
|
||||
less: {
|
||||
preprocess(source, vars) {
|
||||
if (!self.less) {
|
||||
self.less = {
|
||||
logLevel: 0,
|
||||
useFileCache: false,
|
||||
};
|
||||
}
|
||||
loadScript('/vendor/less-bundle/less.min.js');
|
||||
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
|
||||
return self.less.render(varDefs + source)
|
||||
.then(({css}) => css);
|
||||
},
|
||||
},
|
||||
uso: {
|
||||
preprocess(source, vars) {
|
||||
loadScript('/vendor-overwrites/colorpicker/colorconverter.js');
|
||||
const pool = new Map();
|
||||
return Promise.resolve(doReplace(source));
|
||||
|
||||
function getValue(name, rgbName) {
|
||||
if (!vars.hasOwnProperty(name)) {
|
||||
if (name.endsWith('-rgb')) {
|
||||
return getValue(name.slice(0, -4), name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const {type, value} = vars[name];
|
||||
switch (type) {
|
||||
case 'color': {
|
||||
let color = pool.get(rgbName || name);
|
||||
if (color == null) {
|
||||
color = colorConverter.parse(value);
|
||||
if (color) {
|
||||
if (color.type === 'hsl') {
|
||||
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
|
||||
}
|
||||
const {r, g, b} = color;
|
||||
color = rgbName
|
||||
? `${r}, ${g}, ${b}`
|
||||
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
// the pool stores `false` for bad colors to differentiate from a yet unknown color
|
||||
pool.set(rgbName || name, color || false);
|
||||
}
|
||||
return color || null;
|
||||
}
|
||||
case 'dropdown':
|
||||
case 'select': // prevent infinite recursion
|
||||
pool.set(name, '');
|
||||
return doReplace(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function doReplace(text) {
|
||||
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
|
||||
if (!pool.has(name)) {
|
||||
const value = getValue(name);
|
||||
pool.set(name, value === null ? match : value);
|
||||
}
|
||||
return pool.get(name);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (preprocessor) {
|
||||
if (!BUILDER[preprocessor]) {
|
||||
throw new Error('unknwon preprocessor');
|
||||
}
|
||||
return BUILDER[preprocessor];
|
||||
}
|
||||
return BUILDER.default;
|
||||
}
|
||||
|
|
|
@ -1,72 +1,105 @@
|
|||
/* global download prefs openURL FIREFOX CHROME
|
||||
URLS ignoreChromeError chromeLocal semverCompare
|
||||
styleManager msg navigatorUtil workerUtil contentScripts sync
|
||||
findExistingTab activateTab isTabReplaceable getActiveTab
|
||||
*/
|
||||
|
||||
/* global API msg */// msg.js
|
||||
/* global addAPI bgReady */// common.js
|
||||
/* global createWorker */// worker-util.js
|
||||
/* global prefs */
|
||||
/* global styleMan */
|
||||
/* global syncMan */
|
||||
/* global updateMan */
|
||||
/* global usercssMan */
|
||||
/* global
|
||||
FIREFOX
|
||||
URLS
|
||||
activateTab
|
||||
download
|
||||
findExistingTab
|
||||
getActiveTab
|
||||
isTabReplaceable
|
||||
openURL
|
||||
*/ // toolbox.js
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line no-var
|
||||
var backgroundWorker = workerUtil.createWorker({
|
||||
url: '/background/background-worker.js',
|
||||
});
|
||||
//#region API
|
||||
|
||||
// eslint-disable-next-line no-var
|
||||
var browserCommands, contextMenus;
|
||||
addAPI(/** @namespace API */ {
|
||||
|
||||
// *************************************************************************
|
||||
// browser commands
|
||||
browserCommands = {
|
||||
openManage,
|
||||
openOptions: () => openManage({options: true}),
|
||||
styleDisableAll(info) {
|
||||
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
|
||||
styles: styleMan,
|
||||
sync: syncMan,
|
||||
updater: updateMan,
|
||||
usercss: usercssMan,
|
||||
/** @type {BackgroundWorker} */
|
||||
worker: createWorker({url: '/background/background-worker'}),
|
||||
|
||||
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() {
|
||||
const {url} = this.sender.tab;
|
||||
if (url.startsWith(URLS.ownOrigin)) {
|
||||
return 'stylus';
|
||||
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return download(msg.url, msg);
|
||||
},
|
||||
parseCss({code}) {
|
||||
return backgroundWorker.parseMozFormat({code});
|
||||
},
|
||||
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 */
|
||||
/**
|
||||
* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent
|
||||
* when the tab is ready, which is needed in the popup, otherwise another
|
||||
* extension could force the tab to open in foreground thus auto-closing the
|
||||
* popup (in Chrome at least) and preventing the sendMessage code from running
|
||||
* @returns {Promise<chrome.tabs.Tab>}
|
||||
*/
|
||||
async openURL(opts) {
|
||||
const tab = await openURL(opts);
|
||||
if (opts.message) {
|
||||
|
@ -87,251 +120,62 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, {
|
|||
}
|
||||
},
|
||||
|
||||
optionsCustomizeHotkeys() {
|
||||
return browserCommands.openOptions()
|
||||
.then(() => new Promise(resolve => setTimeout(resolve, 500)))
|
||||
.then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'}));
|
||||
prefs: {
|
||||
getValues: () => prefs.__values, // will be deepCopy'd by apiHandler
|
||||
set: prefs.set,
|
||||
},
|
||||
|
||||
syncStart: sync.start,
|
||||
syncStop: sync.stop,
|
||||
syncNow: sync.syncNow,
|
||||
getSyncStatus: sync.getStatus,
|
||||
syncLogin: sync.login,
|
||||
|
||||
openManage,
|
||||
});
|
||||
|
||||
// *************************************************************************
|
||||
// register all listeners
|
||||
msg.on(onRuntimeMessage);
|
||||
//#endregion
|
||||
//#region Events
|
||||
|
||||
// tell apply.js to refresh styles for non-committed navigation
|
||||
navigatorUtil.onUrlChange(({tabId, frameId}, type) => {
|
||||
if (type !== 'committed') {
|
||||
msg.sendTab(tabId, {method: 'urlChanged'}, {frameId})
|
||||
.catch(msg.ignoreError);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
const browserCommands = {
|
||||
openManage: () => API.openManage(),
|
||||
openOptions: () => API.openManage({options: true}),
|
||||
reload: () => chrome.runtime.reload(),
|
||||
styleDisableAll(info) {
|
||||
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
|
||||
},
|
||||
};
|
||||
|
||||
async function createContextMenus(ids) {
|
||||
for (const id of ids) {
|
||||
let item = contextMenus[id];
|
||||
if (item.presentIf && !await item.presentIf()) {
|
||||
continue;
|
||||
if (chrome.commands) {
|
||||
chrome.commands.onCommand.addListener(id => browserCommands[id]());
|
||||
}
|
||||
|
||||
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) {
|
||||
// "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;
|
||||
msg.on((msg, sender) => {
|
||||
if (msg.method === 'invokeAPI') {
|
||||
let res = msg.path.reduce((res, name) => res && res[name], API);
|
||||
if (!res) throw new Error(`Unknown API.${msg.path.join('.')}`);
|
||||
res = res.apply({msg, sender}, msg.args);
|
||||
return res === undefined ? null : res;
|
||||
}
|
||||
// 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) => {
|
||||
if (checked) {
|
||||
createContextMenus([id]);
|
||||
} else {
|
||||
chrome.contextMenus.remove(id, ignoreChromeError);
|
||||
}
|
||||
};
|
||||
//#endregion
|
||||
|
||||
const keys = Object.keys(contextMenus);
|
||||
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark);
|
||||
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && id in prefs.defaults), togglePresence);
|
||||
createContextMenus(keys);
|
||||
}
|
||||
|
||||
// reinject content scripts when the extension is reloaded/updated. Firefox
|
||||
// would handle this automatically.
|
||||
if (!FIREFOX) {
|
||||
setTimeout(contentScripts.injectToAllTabs, 0);
|
||||
}
|
||||
|
||||
// register hotkeys
|
||||
if (FIREFOX && browser.commands && browser.commands.update) {
|
||||
const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.'));
|
||||
prefs.subscribe(hotkeyPrefs, (name, value) => {
|
||||
try {
|
||||
name = name.split('.')[1];
|
||||
if (value.trim()) {
|
||||
browser.commands.update({name, shortcut: value});
|
||||
} else {
|
||||
browser.commands.reset(name);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
Promise.all([
|
||||
bgReady.styles,
|
||||
/* These are loaded conditionally.
|
||||
Each item uses `require` individually so IDE can jump to the source and track usage. */
|
||||
FIREFOX &&
|
||||
require(['/background/style-via-api']),
|
||||
FIREFOX && ((browser.commands || {}).update) &&
|
||||
require(['/background/browser-cmd-hotkeys']),
|
||||
!FIREFOX &&
|
||||
require(['/background/content-scripts']),
|
||||
chrome.contextMenus &&
|
||||
require(['/background/context-menus']),
|
||||
]).then(() => {
|
||||
bgReady._resolveAll();
|
||||
msg.isBgReady = true;
|
||||
msg.broadcast({method: 'backgroundReady'});
|
||||
});
|
||||
|
|
22
background/browser-cmd-hotkeys.js
Normal file
22
background/browser-cmd-hotkeys.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
Registers hotkeys in FF
|
||||
*/
|
||||
|
||||
(() => {
|
||||
const hotkeyPrefs = prefs.knownKeys.filter(k => k.startsWith('hotkey.'));
|
||||
prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true});
|
||||
|
||||
async function updateHotkey(name, value) {
|
||||
try {
|
||||
name = name.split('.')[1];
|
||||
if (value.trim()) {
|
||||
await browser.commands.update({name, shortcut: value});
|
||||
} else {
|
||||
await browser.commands.reset(name);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
31
background/common.js
Normal file
31
background/common.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
/* global API */// msg.js
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Common stuff that's loaded first so it's immediately available to all background scripts
|
||||
*/
|
||||
|
||||
/* exported
|
||||
addAPI
|
||||
bgReady
|
||||
compareRevision
|
||||
*/
|
||||
|
||||
const bgReady = {};
|
||||
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
|
||||
bgReady.all = new Promise(r => (bgReady._resolveAll = r));
|
||||
|
||||
function addAPI(methods) {
|
||||
for (const [key, val] of Object.entries(methods)) {
|
||||
const old = API[key];
|
||||
if (old && Object.prototype.toString.call(old) === '[object Object]') {
|
||||
Object.assign(old, val);
|
||||
} else {
|
||||
API[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareRevision(rev1, rev2) {
|
||||
return rev1 - rev2;
|
||||
}
|
|
@ -1,15 +1,21 @@
|
|||
/* global msg ignoreChromeError URLS */
|
||||
/* exported contentScripts */
|
||||
/* global bgReady */// common.js
|
||||
/* global msg */
|
||||
/* global URLS ignoreChromeError */// toolbox.js
|
||||
'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 ALL_URLS = '<all_urls>';
|
||||
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
|
||||
// expand * as .*?
|
||||
const wildcardAsRegExp = (s, flags) => new RegExp(
|
||||
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
|
||||
.replace(/\*/g, '.*?'), flags);
|
||||
s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
|
||||
.replace(/\*/g, '.*?'), flags);
|
||||
for (const cs of SCRIPTS) {
|
||||
cs.matches = cs.matches.map(m => (
|
||||
m === ALL_URLS ? m : wildcardAsRegExp(m)
|
||||
|
@ -18,21 +24,7 @@ const contentScripts = (() => {
|
|||
const busyTabs = new Set();
|
||||
let busyTabsTimer;
|
||||
|
||||
// expose version on greasyfork/sleazyfork 1) info page and 2) code page
|
||||
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};
|
||||
setTimeout(injectToAllTabs);
|
||||
|
||||
function injectToTab({url, tabId, frameId = null}) {
|
||||
for (const script of SCRIPTS) {
|
||||
|
@ -122,4 +114,4 @@ const contentScripts = (() => {
|
|||
function onBusyTabRemoved(tabId) {
|
||||
trackBusyTab(tabId, false);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
101
background/context-menus.js
Normal file
101
background/context-menus.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
/* global browserCommands */// background.js
|
||||
/* global msg */
|
||||
/* global prefs */
|
||||
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
const contextMenus = {
|
||||
'show-badge': {
|
||||
title: 'menuShowBadge',
|
||||
click: info => prefs.set(info.menuItemId, info.checked),
|
||||
},
|
||||
'disableAll': {
|
||||
title: 'disableAllStyles',
|
||||
click: browserCommands.styleDisableAll,
|
||||
},
|
||||
'open-manager': {
|
||||
title: 'openStylesManager',
|
||||
click: browserCommands.openManage,
|
||||
},
|
||||
'open-options': {
|
||||
title: 'openOptions',
|
||||
click: browserCommands.openOptions,
|
||||
},
|
||||
'reload': {
|
||||
presentIf: async () => (await browser.management.getSelf()).installType === 'development',
|
||||
title: 'reload',
|
||||
click: browserCommands.reload,
|
||||
},
|
||||
'editor.contextDelete': {
|
||||
presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'),
|
||||
title: 'editDeleteText',
|
||||
type: 'normal',
|
||||
contexts: ['editable'],
|
||||
documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
|
||||
click: (info, tab) => {
|
||||
msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension')
|
||||
.catch(msg.ignoreError);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// "Delete" item in context menu for browsers that don't have it
|
||||
if (CHROME &&
|
||||
// looking at the end of UA string
|
||||
/(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) &&
|
||||
// skip forks with Flash as those are likely to have the menu e.g. CentBrowser
|
||||
!Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) {
|
||||
prefs.__defaults['editor.contextDelete'] = true;
|
||||
}
|
||||
|
||||
const keys = Object.keys(contextMenus);
|
||||
prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'),
|
||||
CHROME >= 62 && CHROME <= 64 ? toggleCheckmarkBugged : toggleCheckmark);
|
||||
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)),
|
||||
togglePresence);
|
||||
|
||||
createContextMenus(keys);
|
||||
|
||||
chrome.contextMenus.onClicked.addListener((info, tab) =>
|
||||
contextMenus[info.menuItemId].click(info, tab));
|
||||
|
||||
async function createContextMenus(ids) {
|
||||
for (const id of ids) {
|
||||
let item = contextMenus[id];
|
||||
if (item.presentIf && !await item.presentIf()) {
|
||||
continue;
|
||||
}
|
||||
item = Object.assign({id}, item);
|
||||
delete item.presentIf;
|
||||
item.title = chrome.i18n.getMessage(item.title);
|
||||
if (!item.type && typeof prefs.defaults[id] === 'boolean') {
|
||||
item.type = 'checkbox';
|
||||
item.checked = prefs.get(id);
|
||||
}
|
||||
if (!item.contexts) {
|
||||
item.contexts = ['browser_action'];
|
||||
}
|
||||
delete item.click;
|
||||
chrome.contextMenus.create(item, ignoreChromeError);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCheckmark(id, checked) {
|
||||
chrome.contextMenus.update(id, {checked}, ignoreChromeError);
|
||||
}
|
||||
|
||||
/** Circumvents the bug with disabling check marks in Chrome 62-64 */
|
||||
async function toggleCheckmarkBugged(id) {
|
||||
await browser.contextMenus.remove(id).catch(ignoreChromeError);
|
||||
createContextMenus([id]);
|
||||
}
|
||||
|
||||
function togglePresence(id, checked) {
|
||||
if (checked) {
|
||||
createContextMenus([id]);
|
||||
} else {
|
||||
chrome.contextMenus.remove(id, ignoreChromeError);
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -1,67 +1,66 @@
|
|||
/* global chromeLocal */
|
||||
/* exported createChromeStorageDB */
|
||||
/* global chromeLocal */// storage-util.js
|
||||
'use strict';
|
||||
|
||||
/* exported createChromeStorageDB */
|
||||
function createChromeStorageDB() {
|
||||
let INC;
|
||||
|
||||
const PREFIX = 'style-';
|
||||
const METHODS = {
|
||||
|
||||
delete(id) {
|
||||
return chromeLocal.remove(PREFIX + id);
|
||||
},
|
||||
|
||||
// FIXME: we don't use this method at all. Should we remove this?
|
||||
get: id => chromeLocal.getValue(PREFIX + id),
|
||||
put: obj =>
|
||||
// FIXME: should we clone the object?
|
||||
Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++})))
|
||||
.then(() => chromeLocal.setValue(PREFIX + obj.id, obj))
|
||||
.then(() => obj.id),
|
||||
putMany: items => prepareInc()
|
||||
.then(() =>
|
||||
chromeLocal.set(items.reduce((data, item) => {
|
||||
if (!item.id) item.id = INC++;
|
||||
data[PREFIX + item.id] = item;
|
||||
return data;
|
||||
}, {})))
|
||||
.then(() => items.map(i => i.id)),
|
||||
delete: id => chromeLocal.remove(PREFIX + id),
|
||||
getAll: () => chromeLocal.get()
|
||||
.then(result => {
|
||||
const output = [];
|
||||
for (const key in result) {
|
||||
if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) {
|
||||
output.push(result[key]);
|
||||
}
|
||||
get(id) {
|
||||
return chromeLocal.getValue(PREFIX + id);
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
const all = await chromeLocal.get();
|
||||
if (!INC) prepareInc(all);
|
||||
return Object.entries(all)
|
||||
.map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
async put(item) {
|
||||
if (!item.id) {
|
||||
if (!INC) await prepareInc();
|
||||
item.id = INC++;
|
||||
}
|
||||
await chromeLocal.setValue(PREFIX + item.id, item);
|
||||
return item.id;
|
||||
},
|
||||
|
||||
async putMany(items) {
|
||||
const data = {};
|
||||
for (const item of items) {
|
||||
if (!item.id) {
|
||||
if (!INC) await prepareInc();
|
||||
item.id = INC++;
|
||||
}
|
||||
return output;
|
||||
}),
|
||||
data[PREFIX + item.id] = item;
|
||||
}
|
||||
await chromeLocal.set(data);
|
||||
return items.map(_ => _.id);
|
||||
},
|
||||
};
|
||||
|
||||
return {exec};
|
||||
|
||||
function exec(method, ...args) {
|
||||
if (METHODS[method]) {
|
||||
return METHODS[method](...args)
|
||||
.then(result => {
|
||||
if (method === 'putMany' && result.map) {
|
||||
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;
|
||||
}
|
||||
async function prepareInc(data) {
|
||||
INC = 1;
|
||||
for (const key in data || await chromeLocal.get()) {
|
||||
if (key.startsWith(PREFIX)) {
|
||||
const id = Number(key.slice(PREFIX.length));
|
||||
if (id >= INC) {
|
||||
INC = id + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return function dbExecChromeStorage(method, ...args) {
|
||||
return METHODS[method](...args);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
/* global chromeLocal workerUtil createChromeStorageDB */
|
||||
/* exported db */
|
||||
/*
|
||||
Initialize a database. There are some problems using IndexedDB in Firefox:
|
||||
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
|
||||
|
||||
Some of them are fixed in FF59:
|
||||
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
|
||||
*/
|
||||
/* global chromeLocal */// storage-util.js
|
||||
/* global cloneError */// worker-util.js
|
||||
'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 DATABASE = 'stylish';
|
||||
const STORE = 'styles';
|
||||
|
@ -33,32 +34,25 @@ const db = (() => {
|
|||
case false: break;
|
||||
default: await testDB();
|
||||
}
|
||||
return useIndexedDB();
|
||||
chromeLocal.setValue(FALLBACK, false);
|
||||
return dbExecIndexedDB;
|
||||
}
|
||||
|
||||
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()}`;
|
||||
await dbExecIndexedDB('put', {id});
|
||||
e = await dbExecIndexedDB('get', id);
|
||||
// throws if result or id is null
|
||||
await dbExecIndexedDB('delete', e.target.result.id);
|
||||
const e = await dbExecIndexedDB('get', id);
|
||||
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
|
||||
}
|
||||
|
||||
function useChromeStorage(err) {
|
||||
async function useChromeStorage(err) {
|
||||
chromeLocal.setValue(FALLBACK, true);
|
||||
if (err) {
|
||||
chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
|
||||
chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err));
|
||||
console.warn('Failed to access indexedDB. Switched to storage API.', err);
|
||||
}
|
||||
return createChromeStorageDB().exec;
|
||||
}
|
||||
|
||||
function useIndexedDB() {
|
||||
chromeLocal.setValue(FALLBACK, false);
|
||||
return dbExecIndexedDB;
|
||||
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
|
||||
return createChromeStorageDB();
|
||||
}
|
||||
|
||||
async function dbExecIndexedDB(method, ...args) {
|
||||
|
@ -70,8 +64,9 @@ const db = (() => {
|
|||
|
||||
function storeRequest(store, method, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
/** @type {IDBRequest} */
|
||||
const request = store[method](...args);
|
||||
request.onsuccess = resolve;
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,48 +1,36 @@
|
|||
/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API_METHODS */
|
||||
/* exported iconManager */
|
||||
/* global API */// msg.js
|
||||
/* global addAPI bgReady */// common.js
|
||||
/* global prefs */
|
||||
/* global tabMan */
|
||||
/* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
const iconManager = (() => {
|
||||
(() => {
|
||||
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
|
||||
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([
|
||||
'disableAll',
|
||||
'badgeDisabled',
|
||||
'badgeNormal',
|
||||
], () => 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 */
|
||||
addAPI(/** @namespace API */ {
|
||||
/**
|
||||
* @param {(number|string)[]} styleIds
|
||||
* @param {boolean} [lazyBadge=false] preventing flicker during page load
|
||||
*/
|
||||
updateIconBadge(styleIds, {lazyBadge} = {}) {
|
||||
// FIXME: in some cases, we only have to redraw the badge. is it worth a optimization?
|
||||
const {frameId, tab: {id: tabId}} = this.sender;
|
||||
const value = styleIds.length ? styleIds.map(Number) : undefined;
|
||||
tabManager.set(tabId, 'styleIds', frameId, value);
|
||||
tabMan.set(tabId, 'styleIds', frameId, value);
|
||||
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
|
||||
staleBadges.add(tabId);
|
||||
if (!frameId) refreshIcon(tabId, true);
|
||||
},
|
||||
});
|
||||
|
||||
navigatorUtil.onCommitted(({tabId, frameId}) => {
|
||||
if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
|
||||
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
|
||||
if (!frameId) tabMan.set(tabId, 'styleIds', undefined);
|
||||
});
|
||||
|
||||
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}) {
|
||||
if (tabManager.get(sender.tab.id, 'styleIds')) {
|
||||
API_METHODS.updateIconBadge.call({sender}, [], {lazyBadge: true});
|
||||
if (tabMan.get(sender.tab.id, 'styleIds')) {
|
||||
API.updateIconBadge.call({sender}, [], {lazyBadge: true});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshIconBadgeText(tabId) {
|
||||
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
|
||||
iconUtil.setBadgeText({tabId, text});
|
||||
setBadgeText({tabId, text});
|
||||
}
|
||||
|
||||
function getIconName(hasStyles = false) {
|
||||
|
@ -69,15 +72,15 @@ const iconManager = (() => {
|
|||
}
|
||||
|
||||
function refreshIcon(tabId, force = false) {
|
||||
const oldIcon = tabManager.get(tabId, 'icon');
|
||||
const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0));
|
||||
const oldIcon = tabMan.get(tabId, 'icon');
|
||||
const newIcon = getIconName(tabMan.get(tabId, 'styleIds', 0));
|
||||
// (changing the icon only for the main page, frameId = 0)
|
||||
|
||||
if (!force && oldIcon === newIcon) {
|
||||
return;
|
||||
}
|
||||
tabManager.set(tabId, 'icon', newIcon);
|
||||
iconUtil.setIcon({
|
||||
tabMan.set(tabId, 'icon', newIcon);
|
||||
setIcon({
|
||||
path: getIconPath(newIcon),
|
||||
tabId,
|
||||
});
|
||||
|
@ -96,33 +99,55 @@ const iconManager = (() => {
|
|||
/** @return {number | ''} */
|
||||
function getStyleCount(tabId) {
|
||||
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)));
|
||||
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() {
|
||||
iconUtil.setIcon({
|
||||
setIcon({
|
||||
path: getIconPath(getIconName()),
|
||||
});
|
||||
}
|
||||
|
||||
function refreshIconBadgeColor() {
|
||||
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
|
||||
iconUtil.setBadgeBackgroundColor({
|
||||
setBadgeBackgroundColor({
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAllIcons() {
|
||||
for (const tabId of tabManager.list()) {
|
||||
for (const tabId of tabMan.list()) {
|
||||
refreshIcon(tabId);
|
||||
}
|
||||
refreshGlobalIcon();
|
||||
}
|
||||
|
||||
function refreshAllIconsBadgeText() {
|
||||
for (const tabId of tabManager.list()) {
|
||||
for (const tabId of tabMan.list()) {
|
||||
refreshIconBadgeText(tabId);
|
||||
}
|
||||
}
|
||||
|
@ -133,4 +158,40 @@ const iconManager = (() => {
|
|||
}
|
||||
staleBadges.clear();
|
||||
}
|
||||
|
||||
function safeCall(method, data) {
|
||||
const {browserAction = {}} = chrome;
|
||||
const fn = browserAction[method];
|
||||
if (fn) {
|
||||
try {
|
||||
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
|
||||
fn.call(browserAction, data, ignoreChromeError);
|
||||
} catch (e) {
|
||||
// FIXME: skip pre-rendered tabs?
|
||||
fn.call(browserAction, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {chrome.browserAction.TabIconDetails} data */
|
||||
async function setIcon(data) {
|
||||
if (hasCanvas === true || await hasCanvas) {
|
||||
data.imageData = {};
|
||||
for (const [key, url] of Object.entries(data.path)) {
|
||||
data.imageData[key] = imageDataCache.get(url) || await loadImage(url);
|
||||
}
|
||||
delete data.path;
|
||||
}
|
||||
safeCall('setIcon', data);
|
||||
}
|
||||
|
||||
/** @param {chrome.browserAction.BadgeTextDetails} data */
|
||||
function setBadgeText(data) {
|
||||
safeCall('setBadgeText', data);
|
||||
}
|
||||
|
||||
/** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */
|
||||
function setBadgeBackgroundColor(data) {
|
||||
safeCall('setBadgeBackgroundColor', data);
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1,91 +0,0 @@
|
|||
/* global ignoreChromeError */
|
||||
/* exported iconUtil */
|
||||
'use strict';
|
||||
|
||||
const iconUtil = (() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
// https://github.com/openstyles/stylus/issues/335
|
||||
let noCanvas;
|
||||
const imageDataCache = new Map();
|
||||
// test if canvas is usable
|
||||
const canvasReady = loadImage('/images/icon/16.png')
|
||||
.then(imageData => {
|
||||
noCanvas = imageData.data.every(b => b === 255);
|
||||
});
|
||||
|
||||
return extendNative({
|
||||
/*
|
||||
Cache imageData for paths
|
||||
*/
|
||||
setIcon,
|
||||
setBadgeText,
|
||||
});
|
||||
|
||||
function loadImage(url) {
|
||||
let result = imageDataCache.get(url);
|
||||
if (!result) {
|
||||
result = new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
const w = canvas.width = img.width;
|
||||
const h = canvas.height = img.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
resolve(ctx.getImageData(0, 0, w, h));
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
imageDataCache.set(url, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setIcon(data) {
|
||||
canvasReady.then(() => {
|
||||
if (noCanvas) {
|
||||
chrome.browserAction.setIcon(data, ignoreChromeError);
|
||||
return;
|
||||
}
|
||||
const pending = [];
|
||||
data.imageData = {};
|
||||
for (const [key, url] of Object.entries(data.path)) {
|
||||
pending.push(loadImage(url)
|
||||
.then(imageData => {
|
||||
data.imageData[key] = imageData;
|
||||
}));
|
||||
}
|
||||
Promise.all(pending).then(() => {
|
||||
delete data.path;
|
||||
chrome.browserAction.setIcon(data, ignoreChromeError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setBadgeText(data) {
|
||||
try {
|
||||
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
|
||||
chrome.browserAction.setBadgeText(data, ignoreChromeError);
|
||||
} catch (e) {
|
||||
// FIXME: skip pre-rendered tabs?
|
||||
chrome.browserAction.setBadgeText(data);
|
||||
}
|
||||
}
|
||||
|
||||
function extendNative(target) {
|
||||
return new Proxy(target, {
|
||||
get: (target, prop) => {
|
||||
// FIXME: do we really need this?
|
||||
if (!chrome.browserAction ||
|
||||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
|
||||
return () => {};
|
||||
}
|
||||
if (target[prop]) {
|
||||
return target[prop];
|
||||
}
|
||||
return chrome.browserAction[prop].bind(chrome.browserAction);
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
82
background/navigation-manager.js
Normal file
82
background/navigation-manager.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
|
||||
/* global bgReady */// common.js
|
||||
/* global msg */
|
||||
'use strict';
|
||||
|
||||
/* exported navMan */
|
||||
const navMan = (() => {
|
||||
const listeners = new Set();
|
||||
|
||||
chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed'));
|
||||
chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history'));
|
||||
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash'));
|
||||
|
||||
return {
|
||||
/** @param {function(data: Object, type: ('committed'|'history'|'hash'))} fn */
|
||||
onUrlChange(fn) {
|
||||
listeners.add(fn);
|
||||
},
|
||||
};
|
||||
|
||||
/** @this {string} type */
|
||||
async function onNavigation(data) {
|
||||
if (CHROME &&
|
||||
URLS.chromeProtectsNTP &&
|
||||
data.url.startsWith('https://www.google.') &&
|
||||
data.url.includes('/_/chrome/newtab?')) {
|
||||
// Modern Chrome switched to WebUI NTP so this is obsolete, but there may be exceptions
|
||||
// TODO: investigate, and maybe use a separate listener for CHROME <= ver
|
||||
const tab = await browser.tabs.get(data.tabId);
|
||||
const url = tab.pendingUrl || tab.url;
|
||||
if (url === 'chrome://newtab/') {
|
||||
data.url = url;
|
||||
}
|
||||
}
|
||||
listeners.forEach(fn => fn(data, this));
|
||||
}
|
||||
|
||||
/** @this {string} type */
|
||||
function onFakeNavigation(data) {
|
||||
onNavigation.call(this, data);
|
||||
msg.sendTab(data.tabId, {method: 'urlChanged'}, {frameId: data.frameId})
|
||||
.catch(msg.ignoreError);
|
||||
}
|
||||
})();
|
||||
|
||||
bgReady.all.then(() => {
|
||||
/*
|
||||
* Expose style version on greasyfork/sleazyfork 1) info page and 2) code page
|
||||
* Not using manifest.json as adding a content script disables the extension on update.
|
||||
*/
|
||||
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
|
||||
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
|
||||
chrome.tabs.executeScript(tabId, {
|
||||
file: '/content/install-hook-greasyfork.js',
|
||||
runAt: 'document_start',
|
||||
});
|
||||
}, {
|
||||
url: [
|
||||
{hostEquals: 'greasyfork.org', urlMatches},
|
||||
{hostEquals: 'sleazyfork.org', urlMatches},
|
||||
],
|
||||
});
|
||||
/*
|
||||
* FF misses some about:blank iframes so we inject our content script explicitly
|
||||
*/
|
||||
if (FIREFOX) {
|
||||
chrome.webNavigation.onDOMContentLoaded.addListener(async ({tabId, frameId}) => {
|
||||
if (frameId &&
|
||||
!await msg.sendTab(tabId, {method: 'ping'}, {frameId}).catch(ignoreChromeError)) {
|
||||
for (const file of chrome.runtime.getManifest().content_scripts[0].js) {
|
||||
chrome.tabs.executeScript(tabId, {
|
||||
frameId,
|
||||
file,
|
||||
matchAboutBlank: true,
|
||||
}, ignoreChromeError);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
url: [{urlEquals: 'about:blank'}],
|
||||
});
|
||||
}
|
||||
});
|
|
@ -1,75 +0,0 @@
|
|||
/* global CHROME URLS */
|
||||
/* exported navigatorUtil */
|
||||
'use strict';
|
||||
|
||||
const navigatorUtil = (() => {
|
||||
const handler = {
|
||||
urlChange: null,
|
||||
};
|
||||
return extendNative({onUrlChange});
|
||||
|
||||
function onUrlChange(fn) {
|
||||
initUrlChange();
|
||||
handler.urlChange.push(fn);
|
||||
}
|
||||
|
||||
function initUrlChange() {
|
||||
if (handler.urlChange) {
|
||||
return;
|
||||
}
|
||||
handler.urlChange = [];
|
||||
|
||||
chrome.webNavigation.onCommitted.addListener(data =>
|
||||
fixNTPUrl(data)
|
||||
.then(() => executeCallbacks(handler.urlChange, data, 'committed'))
|
||||
.catch(console.error)
|
||||
);
|
||||
|
||||
chrome.webNavigation.onHistoryStateUpdated.addListener(data =>
|
||||
fixNTPUrl(data)
|
||||
.then(() => executeCallbacks(handler.urlChange, data, 'historyStateUpdated'))
|
||||
.catch(console.error)
|
||||
);
|
||||
|
||||
chrome.webNavigation.onReferenceFragmentUpdated.addListener(data =>
|
||||
fixNTPUrl(data)
|
||||
.then(() => executeCallbacks(handler.urlChange, data, 'referenceFragmentUpdated'))
|
||||
.catch(console.error)
|
||||
);
|
||||
}
|
||||
|
||||
function fixNTPUrl(data) {
|
||||
if (
|
||||
!CHROME ||
|
||||
!URLS.chromeProtectsNTP ||
|
||||
!data.url.startsWith('https://www.google.') ||
|
||||
!data.url.includes('/_/chrome/newtab?')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return browser.tabs.get(data.tabId)
|
||||
.then(tab => {
|
||||
const url = tab.pendingUrl || tab.url;
|
||||
if (url === 'chrome://newtab/') {
|
||||
data.url = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function executeCallbacks(callbacks, data, type) {
|
||||
for (const cb of callbacks) {
|
||||
cb(data, type);
|
||||
}
|
||||
}
|
||||
|
||||
function extendNative(target) {
|
||||
return new Proxy(target, {
|
||||
get: (target, prop) => {
|
||||
if (target[prop]) {
|
||||
return target[prop];
|
||||
}
|
||||
return chrome.webNavigation[prop].addListener.bind(chrome.webNavigation[prop]);
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
|
@ -1,5 +1,8 @@
|
|||
/* global addAPI */// common.js
|
||||
'use strict';
|
||||
|
||||
/* CURRENTLY UNUSED */
|
||||
|
||||
(() => {
|
||||
// begin:nanographql - Tiny graphQL client library
|
||||
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
|
||||
|
@ -25,10 +28,9 @@
|
|||
// end:nanographql
|
||||
|
||||
const api = 'https://api.openusercss.org';
|
||||
const doQuery = ({id}, queryString) => {
|
||||
const doQuery = async ({id}, queryString) => {
|
||||
const query = gql(queryString);
|
||||
|
||||
return fetch(api, {
|
||||
return (await fetch(api, {
|
||||
method: 'POST',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -36,11 +38,10 @@
|
|||
body: query({
|
||||
id,
|
||||
}),
|
||||
})
|
||||
.then(res => res.json());
|
||||
})).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
|
||||
* GraphQL API, set above
|
||||
|
|
15
background/remove-unused-storage.js
Normal file
15
background/remove-unused-storage.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
/* global chromeLocal */// storage-util.js
|
||||
'use strict';
|
||||
|
||||
// Removing unused stuff from storage on extension update
|
||||
// TODO: delete this by the middle of 2021
|
||||
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch (e) {}
|
||||
|
||||
setTimeout(async () => {
|
||||
const del = Object.keys(await chromeLocal.get())
|
||||
.filter(key => key.startsWith('usoSearchCache'));
|
||||
if (del.length) chromeLocal.remove(del);
|
||||
}, 15e3);
|
File diff suppressed because it is too large
Load Diff
|
@ -1,11 +1,6 @@
|
|||
/* global
|
||||
API_METHODS
|
||||
debounce
|
||||
stringAsRegExp
|
||||
styleManager
|
||||
tryRegExp
|
||||
usercss
|
||||
*/
|
||||
/* global API */// msg.js
|
||||
/* global URLS debounce stringAsRegExp tryRegExp */// toolbox.js
|
||||
/* global addAPI */// common.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
|
@ -15,12 +10,12 @@
|
|||
|
||||
const extractMeta = style =>
|
||||
style.usercssData
|
||||
? (style.sourceCode.match(usercss.RX_META) || [''])[0]
|
||||
? (style.sourceCode.match(URLS.rxMETA) || [''])[0]
|
||||
: null;
|
||||
|
||||
const stripMeta = style =>
|
||||
style.usercssData
|
||||
? style.sourceCode.replace(usercss.RX_META, '')
|
||||
? style.sourceCode.replace(URLS.rxMETA, '')
|
||||
: null;
|
||||
|
||||
const MODES = Object.assign(Object.create(null), {
|
||||
|
@ -31,8 +26,8 @@
|
|||
|
||||
meta: (style, test, part) =>
|
||||
METAKEYS.some(key => test(style[key])) ||
|
||||
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
|
||||
searchSections(style, test, 'funcs'),
|
||||
test(part === 'all' ? style.sourceCode : extractMeta(style)) ||
|
||||
searchSections(style, test, 'funcs'),
|
||||
|
||||
name: (style, test) =>
|
||||
test(style.customName) ||
|
||||
|
@ -43,33 +38,37 @@
|
|||
!style.usercssData && MODES.code(style, test),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param params
|
||||
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
|
||||
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
|
||||
* @param {number[]} [params.ids] - if not specified, all styles are searched
|
||||
* @returns {number[]} - array of matched styles ids
|
||||
*/
|
||||
API_METHODS.searchDB = async ({query, mode = 'all', ids}) => {
|
||||
let res = [];
|
||||
if (mode === 'url' && query) {
|
||||
res = (await styleManager.getStylesByUrl(query)).map(r => r.data.id);
|
||||
} else if (mode in MODES) {
|
||||
const modeHandler = MODES[mode];
|
||||
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
|
||||
const rx = m && tryRegExp(m[1], m[2]);
|
||||
const test = rx ? rx.test.bind(rx) : makeTester(query);
|
||||
res = (await styleManager.getAllStyles())
|
||||
.filter(style =>
|
||||
(!ids || ids.includes(style.id)) &&
|
||||
(!query || modeHandler(style, test)))
|
||||
.map(style => style.id);
|
||||
if (cache.size) debounce(clearCache, 60e3);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
addAPI(/** @namespace API */ {
|
||||
styles: {
|
||||
/**
|
||||
* @param params
|
||||
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
|
||||
* @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all]
|
||||
* @param {number[]} [params.ids] - if not specified, all styles are searched
|
||||
* @returns {number[]} - array of matched styles ids
|
||||
*/
|
||||
async searchDB({query, mode = 'all', ids}) {
|
||||
let res = [];
|
||||
if (mode === 'url' && query) {
|
||||
res = (await API.styles.getByUrl(query)).map(r => r.style.id);
|
||||
} else if (mode in MODES) {
|
||||
const modeHandler = MODES[mode];
|
||||
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
|
||||
const rx = m && tryRegExp(m[1], m[2]);
|
||||
const test = rx ? rx.test.bind(rx) : createTester(query);
|
||||
res = (await API.styles.getAll())
|
||||
.filter(style =>
|
||||
(!ids || ids.includes(style.id)) &&
|
||||
(!query || modeHandler(style, test)))
|
||||
.map(style => style.id);
|
||||
if (cache.size) debounce(clearCache, 60e3);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function makeTester(query) {
|
||||
function createTester(query) {
|
||||
const flags = `u${lower(query) === query ? 'i' : ''}`;
|
||||
const words = query
|
||||
.split(/(".*?")|\s+/)
|
|
@ -1,7 +1,14 @@
|
|||
/* global API_METHODS styleManager CHROME prefs */
|
||||
/* global API */// msg.js
|
||||
/* global addAPI */// common.js
|
||||
/* global isEmptyObj */// toolbox.js
|
||||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
API_METHODS.styleViaAPI = !CHROME && (() => {
|
||||
/**
|
||||
* Uses chrome.tabs.insertCSS
|
||||
*/
|
||||
|
||||
(() => {
|
||||
const ACTIONS = {
|
||||
styleApply,
|
||||
styleDeleted,
|
||||
|
@ -11,25 +18,25 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
prefChanged,
|
||||
updateCount,
|
||||
};
|
||||
const NOP = Promise.resolve(new Error('NOP'));
|
||||
const NOP = new Error('NOP');
|
||||
const onError = () => {};
|
||||
|
||||
/* <tabId>: Object
|
||||
<frameId>: Object
|
||||
url: String, non-enumerable
|
||||
<styleId>: Array of strings
|
||||
section code */
|
||||
const cache = new Map();
|
||||
|
||||
let observingTabs = false;
|
||||
|
||||
return function (request) {
|
||||
const action = ACTIONS[request.method];
|
||||
return !action ? NOP :
|
||||
action(request, this.sender)
|
||||
.catch(onError)
|
||||
.then(maybeToggleObserver);
|
||||
};
|
||||
addAPI(/** @namespace API */ {
|
||||
async styleViaAPI(request) {
|
||||
try {
|
||||
const fn = ACTIONS[request.method];
|
||||
return fn ? fn(request, this.sender) : NOP;
|
||||
} catch (e) {}
|
||||
maybeToggleObserver();
|
||||
},
|
||||
});
|
||||
|
||||
function updateCount(request, sender) {
|
||||
const {tab, frameId} = sender;
|
||||
|
@ -37,7 +44,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
throw new Error('we do not count styles for frames');
|
||||
}
|
||||
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}) {
|
||||
|
@ -48,7 +55,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
if (id === null && !ignoreUrlCheck && frameStyles.url === url) {
|
||||
return NOP;
|
||||
}
|
||||
return styleManager.getSectionsByUrl(url, id).then(sections => {
|
||||
return API.styles.getSectionsByUrl(url, id).then(sections => {
|
||||
const tasks = [];
|
||||
for (const section of Object.values(sections)) {
|
||||
const styleId = section.id;
|
||||
|
@ -125,7 +132,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
}
|
||||
const {tab, frameId} = sender;
|
||||
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
|
||||
if (isEmpty(frameStyles)) {
|
||||
if (isEmptyObj(frameStyles)) {
|
||||
return NOP;
|
||||
}
|
||||
removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
|
||||
|
@ -162,7 +169,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
const tabFrames = cache.get(tabId);
|
||||
if (tabFrames && frameId in tabFrames) {
|
||||
delete tabFrames[frameId];
|
||||
if (isEmpty(tabFrames)) {
|
||||
if (isEmptyObj(tabFrames)) {
|
||||
onTabRemoved(tabId);
|
||||
}
|
||||
}
|
||||
|
@ -178,9 +185,9 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
}
|
||||
|
||||
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
|
||||
if (isEmpty(frameStyles)) {
|
||||
if (isEmptyObj(frameStyles)) {
|
||||
delete tabFrames[frameId];
|
||||
if (isEmpty(tabFrames)) {
|
||||
if (isEmptyObj(tabFrames)) {
|
||||
cache.delete(tabId);
|
||||
}
|
||||
return true;
|
||||
|
@ -223,11 +230,4 @@ API_METHODS.styleViaAPI = !CHROME && (() => {
|
|||
return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true})
|
||||
.catch(onError);
|
||||
}
|
||||
|
||||
function isEmpty(obj) {
|
||||
for (const k in obj) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1,101 +1,103 @@
|
|||
/* global API CHROME prefs */
|
||||
/* global API */// msg.js
|
||||
/* global CHROME */// toolbox.js
|
||||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
CHROME && (async () => {
|
||||
(() => {
|
||||
const idCSP = 'patchCsp';
|
||||
const idOFF = 'disableAll';
|
||||
const idXHR = 'styleViaXhr';
|
||||
const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by *
|
||||
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
|
||||
/** @type {Object<string,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;
|
||||
prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true});
|
||||
toggle();
|
||||
prefs.subscribe([idXHR, idOFF, idCSP], toggle);
|
||||
|
||||
function toggle() {
|
||||
const csp = prefs.get(idCSP) && !prefs.get(idOFF);
|
||||
const xhr = prefs.get(idXHR) && !prefs.get(idOFF) && Boolean(chrome.declarativeContent);
|
||||
if (xhr === enabled.xhr && csp === enabled.csp) {
|
||||
const off = prefs.get(idOFF);
|
||||
const csp = prefs.get(idCSP) && !off;
|
||||
const xhr = prefs.get(idXHR) && !off;
|
||||
if (xhr === state.xhr && csp === state.csp && off === state.off) {
|
||||
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.onHeadersReceived.removeListener(modifyHeaders);
|
||||
if (xhr || csp) {
|
||||
const reqFilter = {
|
||||
urls: ['<all_urls>'],
|
||||
types: ['main_frame', 'sub_frame'],
|
||||
};
|
||||
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
|
||||
// We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered
|
||||
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
|
||||
'blocking',
|
||||
'responseHeaders',
|
||||
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
|
||||
].filter(Boolean));
|
||||
}
|
||||
if (enabled.xhr !== xhr) {
|
||||
enabled.xhr = xhr;
|
||||
toggleEarlyInjection();
|
||||
if (CHROME ? !off : xhr || csp) {
|
||||
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
|
||||
}
|
||||
enabled.csp = csp;
|
||||
}
|
||||
|
||||
/** Runs content scripts earlier than document_start */
|
||||
function toggleEarlyInjection() {
|
||||
const api = chrome.declarativeContent;
|
||||
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,
|
||||
}),
|
||||
],
|
||||
}]);
|
||||
}
|
||||
});
|
||||
if (CHROME && !off) {
|
||||
chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]});
|
||||
}
|
||||
state.csp = csp;
|
||||
state.off = off;
|
||||
state.xhr = xhr;
|
||||
}
|
||||
|
||||
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
|
||||
function prepareStyles(req) {
|
||||
API.getSectionsByUrl(req.url).then(sections => {
|
||||
if (Object.keys(sections).length) {
|
||||
stylesToPass[req.requestId] = !enabled.xhr ? true :
|
||||
URL.createObjectURL(new Blob([JSON.stringify(sections)])).slice(blobUrlPrefix.length);
|
||||
setTimeout(cleanUp, 600e3, req.requestId);
|
||||
}
|
||||
});
|
||||
async function prepareStyles(req) {
|
||||
const sections = await API.styles.getSectionsByUrl(req.url);
|
||||
stylesToPass[req2key(req)] = /** @namespace StylesToPass */ {
|
||||
blobId: '',
|
||||
str: JSON.stringify(sections),
|
||||
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 */
|
||||
function modifyHeaders(req) {
|
||||
const {responseHeaders} = req;
|
||||
const id = stylesToPass[req.requestId];
|
||||
if (!id) {
|
||||
const data = stylesToPass[req2key(req)];
|
||||
if (!data || data.str === '{}') {
|
||||
cleanUp(req);
|
||||
return;
|
||||
}
|
||||
if (enabled.xhr) {
|
||||
if (state.xhr) {
|
||||
data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length);
|
||||
responseHeaders.push({
|
||||
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');
|
||||
if (csp) {
|
||||
patchCsp(csp);
|
||||
}
|
||||
if (enabled.xhr || csp) {
|
||||
if (state.xhr || csp) {
|
||||
return {responseHeaders};
|
||||
}
|
||||
}
|
||||
|
@ -111,7 +113,7 @@ CHROME && (async () => {
|
|||
patchCspSrc(src, 'img-src', 'data:', '*');
|
||||
patchCspSrc(src, 'font-src', 'data:', '*');
|
||||
// Allow our DOM styles
|
||||
patchCspSrc(src, 'style-src', '\'unsafe-inline\'');
|
||||
patchCspSrc(src, 'style-src', "'unsafe-inline'");
|
||||
// Allow our XHR cookies in CSP sandbox (known case: raw github urls)
|
||||
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
|
||||
src.sandbox.push('allow-same-origin');
|
||||
|
@ -132,9 +134,19 @@ CHROME && (async () => {
|
|||
}
|
||||
}
|
||||
|
||||
function cleanUp(key) {
|
||||
const blobId = stylesToPass[key];
|
||||
delete stylesToPass[key];
|
||||
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
|
||||
function cleanUp(req) {
|
||||
const key = req2key(req);
|
||||
const data = stylesToPass[key];
|
||||
if (data) {
|
||||
delete stylesToPass[key];
|
||||
clearTimeout(data.timer);
|
||||
if (data.blobId) {
|
||||
URL.revokeObjectURL(blobUrlPrefix + data.blobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function req2key(req) {
|
||||
return req.tabId + ':' + req.frameId;
|
||||
}
|
||||
})();
|
||||
|
|
225
background/sync-manager.js
Normal file
225
background/sync-manager.js
Normal file
|
@ -0,0 +1,225 @@
|
|||
/* global API msg */// msg.js
|
||||
/* global chromeLocal */// storage-util.js
|
||||
/* global compareRevision */// common.js
|
||||
/* global prefs */
|
||||
/* global tokenMan */
|
||||
'use strict';
|
||||
|
||||
const syncMan = (() => {
|
||||
//#region Init
|
||||
|
||||
const SYNC_DELAY = 1; // minutes
|
||||
const SYNC_INTERVAL = 30; // minutes
|
||||
const STATES = Object.freeze({
|
||||
connected: 'connected',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'disconnected',
|
||||
disconnecting: 'disconnecting',
|
||||
});
|
||||
const STORAGE_KEY = 'sync/state/';
|
||||
const status = /** @namespace SyncManager.Status */ {
|
||||
STATES,
|
||||
state: STATES.disconnected,
|
||||
syncing: false,
|
||||
progress: null,
|
||||
currentDriveName: null,
|
||||
errorMessage: null,
|
||||
login: false,
|
||||
};
|
||||
let ctrl;
|
||||
let currentDrive;
|
||||
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
|
||||
let ready = prefs.ready.then(() => {
|
||||
ready = true;
|
||||
prefs.subscribe('sync.enabled',
|
||||
(_, val) => val === 'none'
|
||||
? syncMan.stop()
|
||||
: syncMan.start(val, true),
|
||||
{runNow: true});
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener(info => {
|
||||
if (info.name === 'syncNow') {
|
||||
syncMan.syncNow();
|
||||
}
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Exports
|
||||
|
||||
return {
|
||||
|
||||
async delete(...args) {
|
||||
if (ready.then) await ready;
|
||||
if (!currentDrive) return;
|
||||
schedule();
|
||||
return ctrl.delete(...args);
|
||||
},
|
||||
|
||||
/** @returns {Promise<SyncManager.Status>} */
|
||||
async getStatus() {
|
||||
return status;
|
||||
},
|
||||
|
||||
async login(name = prefs.get('sync.enabled')) {
|
||||
if (ready.then) await ready;
|
||||
try {
|
||||
await tokenMan.getToken(name, true);
|
||||
} catch (err) {
|
||||
if (/Authorization page could not be loaded/i.test(err.message)) {
|
||||
// FIXME: Chrome always fails at the first login so we try again
|
||||
await tokenMan.getToken(name);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
status.login = true;
|
||||
emitStatusChange();
|
||||
},
|
||||
|
||||
async put(...args) {
|
||||
if (ready.then) await ready;
|
||||
if (!currentDrive) return;
|
||||
schedule();
|
||||
return ctrl.put(...args);
|
||||
},
|
||||
|
||||
async start(name, fromPref = false) {
|
||||
if (ready.then) await ready;
|
||||
if (!ctrl) await initController();
|
||||
if (currentDrive) return;
|
||||
currentDrive = getDrive(name);
|
||||
ctrl.use(currentDrive);
|
||||
status.state = STATES.connecting;
|
||||
status.currentDriveName = currentDrive.name;
|
||||
status.login = true;
|
||||
emitStatusChange();
|
||||
try {
|
||||
if (!fromPref) {
|
||||
await syncMan.login(name).catch(handle401Error);
|
||||
}
|
||||
await syncMan.syncNow();
|
||||
status.errorMessage = null;
|
||||
} catch (err) {
|
||||
status.errorMessage = err.message;
|
||||
// FIXME: should we move this logic to options.js?
|
||||
if (!fromPref) {
|
||||
console.error(err);
|
||||
return syncMan.stop();
|
||||
}
|
||||
}
|
||||
prefs.set('sync.enabled', name);
|
||||
status.state = STATES.connected;
|
||||
schedule(SYNC_INTERVAL);
|
||||
emitStatusChange();
|
||||
},
|
||||
|
||||
async stop() {
|
||||
if (ready.then) await ready;
|
||||
if (!currentDrive) return;
|
||||
chrome.alarms.clear('syncNow');
|
||||
status.state = STATES.disconnecting;
|
||||
emitStatusChange();
|
||||
try {
|
||||
await ctrl.stop();
|
||||
await tokenMan.revokeToken(currentDrive.name);
|
||||
await chromeLocal.remove(STORAGE_KEY + currentDrive.name);
|
||||
} catch (e) {}
|
||||
currentDrive = null;
|
||||
prefs.set('sync.enabled', 'none');
|
||||
status.state = STATES.disconnected;
|
||||
status.currentDriveName = null;
|
||||
status.login = false;
|
||||
emitStatusChange();
|
||||
},
|
||||
|
||||
async syncNow() {
|
||||
if (ready.then) await ready;
|
||||
if (!currentDrive) throw new Error('cannot sync when disconnected');
|
||||
try {
|
||||
await (ctrl.isInit() ? ctrl.syncNow() : ctrl.start()).catch(handle401Error);
|
||||
status.errorMessage = null;
|
||||
} catch (err) {
|
||||
status.errorMessage = err.message;
|
||||
}
|
||||
emitStatusChange();
|
||||
},
|
||||
};
|
||||
|
||||
//#endregion
|
||||
//#region Utils
|
||||
|
||||
async function initController() {
|
||||
await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
|
||||
ctrl = dbToCloud.dbToCloud({
|
||||
onGet(id) {
|
||||
return API.styles.getByUUID(id);
|
||||
},
|
||||
onPut(doc) {
|
||||
return API.styles.putByUUID(doc);
|
||||
},
|
||||
onDelete(id, rev) {
|
||||
return API.styles.deleteByUUID(id, rev);
|
||||
},
|
||||
async onFirstSync() {
|
||||
for (const i of await API.styles.getAll()) {
|
||||
ctrl.put(i._id, i._rev);
|
||||
}
|
||||
},
|
||||
onProgress(e) {
|
||||
if (e.phase === 'start') {
|
||||
status.syncing = true;
|
||||
} else if (e.phase === 'end') {
|
||||
status.syncing = false;
|
||||
status.progress = null;
|
||||
} else {
|
||||
status.progress = e;
|
||||
}
|
||||
emitStatusChange();
|
||||
},
|
||||
compareRevision,
|
||||
getState(drive) {
|
||||
return chromeLocal.getValue(STORAGE_KEY + drive.name);
|
||||
},
|
||||
setState(drive, state) {
|
||||
return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handle401Error(err) {
|
||||
let emit;
|
||||
if (err.code === 401) {
|
||||
await tokenMan.revokeToken(currentDrive.name).catch(console.error);
|
||||
emit = true;
|
||||
} else if (/User interaction required|Requires user interaction/i.test(err.message)) {
|
||||
emit = true;
|
||||
}
|
||||
if (emit) {
|
||||
status.login = false;
|
||||
emitStatusChange();
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
function emitStatusChange() {
|
||||
msg.broadcastExtension({method: 'syncStatusUpdate', status});
|
||||
}
|
||||
|
||||
function getDrive(name) {
|
||||
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
|
||||
return dbToCloud.drive[name]({
|
||||
getAccessToken: () => tokenMan.getToken(name),
|
||||
});
|
||||
}
|
||||
throw new Error(`unknown cloud name: ${name}`);
|
||||
}
|
||||
|
||||
function schedule(delay = SYNC_DELAY) {
|
||||
chrome.alarms.create('syncNow', {
|
||||
delayInMinutes: delay,
|
||||
periodInMinutes: SYNC_INTERVAL,
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
})();
|
|
@ -1,236 +0,0 @@
|
|||
/* global dbToCloud styleManager chromeLocal prefs tokenManager msg */
|
||||
/* exported sync */
|
||||
|
||||
'use strict';
|
||||
|
||||
const sync = (() => {
|
||||
const SYNC_DELAY = 1; // minutes
|
||||
const SYNC_INTERVAL = 30; // minutes
|
||||
|
||||
const status = {
|
||||
state: 'disconnected',
|
||||
syncing: false,
|
||||
progress: null,
|
||||
currentDriveName: null,
|
||||
errorMessage: null,
|
||||
login: false,
|
||||
};
|
||||
let currentDrive;
|
||||
const ctrl = dbToCloud.dbToCloud({
|
||||
onGet(id) {
|
||||
return styleManager.getByUUID(id);
|
||||
},
|
||||
onPut(doc) {
|
||||
return styleManager.putByUUID(doc);
|
||||
},
|
||||
onDelete(id, rev) {
|
||||
return styleManager.deleteByUUID(id, rev);
|
||||
},
|
||||
onFirstSync() {
|
||||
return styleManager.getAllStyles()
|
||||
.then(styles => {
|
||||
styles.forEach(i => ctrl.put(i._id, i._rev));
|
||||
});
|
||||
},
|
||||
onProgress,
|
||||
compareRevision(a, b) {
|
||||
return styleManager.compareRevision(a, b);
|
||||
},
|
||||
getState(drive) {
|
||||
const key = `sync/state/${drive.name}`;
|
||||
return chromeLocal.getValue(key);
|
||||
},
|
||||
setState(drive, state) {
|
||||
const key = `sync/state/${drive.name}`;
|
||||
return chromeLocal.setValue(key, state);
|
||||
},
|
||||
});
|
||||
|
||||
const initializing = prefs.initializing.then(() => {
|
||||
prefs.subscribe(['sync.enabled'], onPrefChange);
|
||||
onPrefChange(null, prefs.get('sync.enabled'));
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener(info => {
|
||||
if (info.name === 'syncNow') {
|
||||
syncNow().catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.assign({
|
||||
getStatus: () => status,
|
||||
}, ensurePrepared({
|
||||
start,
|
||||
stop,
|
||||
put: (...args) => {
|
||||
if (!currentDrive) return;
|
||||
schedule();
|
||||
return ctrl.put(...args);
|
||||
},
|
||||
delete: (...args) => {
|
||||
if (!currentDrive) return;
|
||||
schedule();
|
||||
return ctrl.delete(...args);
|
||||
},
|
||||
syncNow,
|
||||
login,
|
||||
}));
|
||||
|
||||
function ensurePrepared(obj) {
|
||||
return Object.entries(obj).reduce((o, [key, fn]) => {
|
||||
o[key] = (...args) =>
|
||||
initializing.then(() => fn(...args));
|
||||
return o;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function onProgress(e) {
|
||||
if (e.phase === 'start') {
|
||||
status.syncing = true;
|
||||
} else if (e.phase === 'end') {
|
||||
status.syncing = false;
|
||||
status.progress = null;
|
||||
} else {
|
||||
status.progress = e;
|
||||
}
|
||||
emitStatusChange();
|
||||
}
|
||||
|
||||
function schedule(delay = SYNC_DELAY) {
|
||||
chrome.alarms.create('syncNow', {
|
||||
delayInMinutes: delay,
|
||||
periodInMinutes: SYNC_INTERVAL,
|
||||
});
|
||||
}
|
||||
|
||||
function onPrefChange(key, value) {
|
||||
if (value === 'none') {
|
||||
stop().catch(console.error);
|
||||
} else {
|
||||
start(value, true).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function withFinally(p, cleanup) {
|
||||
return p.then(
|
||||
result => {
|
||||
cleanup(undefined, result);
|
||||
return result;
|
||||
},
|
||||
err => {
|
||||
cleanup(err);
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function syncNow() {
|
||||
if (!currentDrive) {
|
||||
return Promise.reject(new Error('cannot sync when disconnected'));
|
||||
}
|
||||
return withFinally(
|
||||
(ctrl.isInit() ? ctrl.syncNow() : ctrl.start())
|
||||
.catch(handle401Error),
|
||||
err => {
|
||||
status.errorMessage = err ? err.message : null;
|
||||
emitStatusChange();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handle401Error(err) {
|
||||
if (err.code === 401) {
|
||||
return tokenManager.revokeToken(currentDrive.name)
|
||||
.catch(console.error)
|
||||
.then(() => {
|
||||
status.login = false;
|
||||
emitStatusChange();
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
if (/User interaction required|Requires user interaction/i.test(err.message)) {
|
||||
status.login = false;
|
||||
emitStatusChange();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
function emitStatusChange() {
|
||||
msg.broadcastExtension({method: 'syncStatusUpdate', status});
|
||||
}
|
||||
|
||||
function login(name = prefs.get('sync.enabled')) {
|
||||
return tokenManager.getToken(name, true)
|
||||
.catch(err => {
|
||||
if (/Authorization page could not be loaded/i.test(err.message)) {
|
||||
// FIXME: Chrome always fails at the first login so we try again
|
||||
return tokenManager.getToken(name);
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.then(() => {
|
||||
status.login = true;
|
||||
emitStatusChange();
|
||||
});
|
||||
}
|
||||
|
||||
function start(name, fromPref = false) {
|
||||
if (currentDrive) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
currentDrive = getDrive(name);
|
||||
ctrl.use(currentDrive);
|
||||
status.state = 'connecting';
|
||||
status.currentDriveName = currentDrive.name;
|
||||
status.login = true;
|
||||
emitStatusChange();
|
||||
return withFinally(
|
||||
(fromPref ? Promise.resolve() : login(name))
|
||||
.catch(handle401Error)
|
||||
.then(() => syncNow()),
|
||||
err => {
|
||||
status.errorMessage = err ? err.message : null;
|
||||
// FIXME: should we move this logic to options.js?
|
||||
if (err && !fromPref) {
|
||||
console.error(err);
|
||||
return stop();
|
||||
}
|
||||
prefs.set('sync.enabled', name);
|
||||
schedule(SYNC_INTERVAL);
|
||||
status.state = 'connected';
|
||||
emitStatusChange();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getDrive(name) {
|
||||
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
|
||||
return dbToCloud.drive[name]({
|
||||
getAccessToken: () => tokenManager.getToken(name),
|
||||
});
|
||||
}
|
||||
throw new Error(`unknown cloud name: ${name}`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!currentDrive) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
chrome.alarms.clear('syncNow');
|
||||
status.state = 'disconnecting';
|
||||
emitStatusChange();
|
||||
return withFinally(
|
||||
ctrl.stop()
|
||||
.then(() => tokenManager.revokeToken(currentDrive.name))
|
||||
.then(() => chromeLocal.remove(`sync/state/${currentDrive.name}`)),
|
||||
() => {
|
||||
currentDrive = null;
|
||||
prefs.set('sync.enabled', 'none');
|
||||
status.state = 'disconnected';
|
||||
status.currentDriveName = null;
|
||||
status.login = false;
|
||||
emitStatusChange();
|
||||
}
|
||||
);
|
||||
}
|
||||
})();
|
|
@ -1,32 +1,37 @@
|
|||
/* global navigatorUtil */
|
||||
/* exported tabManager */
|
||||
/* global bgReady */// common.js
|
||||
/* global navMan */
|
||||
'use strict';
|
||||
|
||||
const tabManager = (() => {
|
||||
const listeners = [];
|
||||
const tabMan = (() => {
|
||||
const listeners = new Set();
|
||||
const cache = new Map();
|
||||
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
|
||||
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
|
||||
navigatorUtil.onUrlChange(({tabId, frameId, url}) => {
|
||||
if (frameId) return;
|
||||
const oldUrl = tabManager.get(tabId, 'url');
|
||||
tabManager.set(tabId, 'url', url);
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn({tabId, url, oldUrl});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
bgReady.all.then(() => {
|
||||
navMan.onUrlChange(({tabId, frameId, url}) => {
|
||||
const oldUrl = !frameId && tabMan.get(tabId, 'url', frameId);
|
||||
tabMan.set(tabId, 'url', frameId, url);
|
||||
if (frameId) return;
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn({tabId, url, oldUrl});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
onUpdate(fn) {
|
||||
listeners.push(fn);
|
||||
listeners.add(fn);
|
||||
},
|
||||
|
||||
get(tabId, ...keys) {
|
||||
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
|
||||
},
|
||||
|
||||
/**
|
||||
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
|
||||
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
|
||||
|
@ -47,8 +52,10 @@ const tabManager = (() => {
|
|||
meta[lastKey] = value;
|
||||
}
|
||||
},
|
||||
|
||||
list() {
|
||||
return cache.keys();
|
||||
},
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
|
||||
/* exported tokenManager */
|
||||
/* global FIREFOX */// toolbox.js
|
||||
/* global chromeLocal */// storage-util.js
|
||||
'use strict';
|
||||
|
||||
const tokenManager = (() => {
|
||||
/* exported tokenMan */
|
||||
const tokenMan = (() => {
|
||||
const AUTH = {
|
||||
dropbox: {
|
||||
flow: 'token',
|
||||
|
@ -50,64 +51,58 @@ const tokenManager = (() => {
|
|||
};
|
||||
const NETWORK_LATENCY = 30; // seconds
|
||||
|
||||
return {getToken, revokeToken, getClientId, buildKeys};
|
||||
return {
|
||||
|
||||
function getClientId(name) {
|
||||
return AUTH[name].clientId;
|
||||
}
|
||||
buildKeys(name) {
|
||||
const k = {
|
||||
TOKEN: `secure/token/${name}/token`,
|
||||
EXPIRE: `secure/token/${name}/expire`,
|
||||
REFRESH: `secure/token/${name}/refresh`,
|
||||
};
|
||||
k.LIST = Object.values(k);
|
||||
return k;
|
||||
},
|
||||
|
||||
function buildKeys(name) {
|
||||
const k = {
|
||||
TOKEN: `secure/token/${name}/token`,
|
||||
EXPIRE: `secure/token/${name}/expire`,
|
||||
REFRESH: `secure/token/${name}/refresh`,
|
||||
};
|
||||
k.LIST = Object.values(k);
|
||||
return k;
|
||||
}
|
||||
getClientId(name) {
|
||||
return AUTH[name].clientId;
|
||||
},
|
||||
|
||||
function getToken(name, interactive) {
|
||||
const k = buildKeys(name);
|
||||
return chromeLocal.get(k.LIST)
|
||||
.then(obj => {
|
||||
if (!obj[k.TOKEN]) {
|
||||
return authUser(name, k, interactive);
|
||||
}
|
||||
async getToken(name, interactive) {
|
||||
const k = tokenMan.buildKeys(name);
|
||||
const obj = await chromeLocal.get(k.LIST);
|
||||
if (obj[k.TOKEN]) {
|
||||
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
|
||||
return obj[k.TOKEN];
|
||||
}
|
||||
if (obj[k.REFRESH]) {
|
||||
return refreshToken(name, k, obj)
|
||||
.catch(err => {
|
||||
if (err.code === 401) {
|
||||
return authUser(name, k, interactive);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
try {
|
||||
return await refreshToken(name, k, obj);
|
||||
} catch (err) {
|
||||
if (err.code !== 401) throw err;
|
||||
}
|
||||
}
|
||||
return authUser(name, k, interactive);
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeToken(name) {
|
||||
const provider = AUTH[name];
|
||||
const k = buildKeys(name);
|
||||
if (provider.revoke) {
|
||||
try {
|
||||
const token = await chromeLocal.getValue(k.TOKEN);
|
||||
if (token) {
|
||||
await provider.revoke(token);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
await chromeLocal.remove(k.LIST);
|
||||
}
|
||||
return authUser(name, k, interactive);
|
||||
},
|
||||
|
||||
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]) {
|
||||
return Promise.reject(new Error('no refresh token'));
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
const provider = AUTH[name];
|
||||
const body = {
|
||||
|
@ -119,17 +114,17 @@ const tokenManager = (() => {
|
|||
if (provider.clientSecret) {
|
||||
body.client_secret = provider.clientSecret;
|
||||
}
|
||||
return postQuery(provider.tokenURL, body)
|
||||
.then(result => {
|
||||
if (!result.refresh_token) {
|
||||
// reuse old refresh token
|
||||
result.refresh_token = obj[k.REFRESH];
|
||||
}
|
||||
return handleTokenResult(result, k);
|
||||
});
|
||||
const result = await postQuery(provider.tokenURL, body);
|
||||
if (!result.refresh_token) {
|
||||
// reuse old refresh token
|
||||
result.refresh_token = obj[k.REFRESH];
|
||||
}
|
||||
return handleTokenResult(result, k);
|
||||
}
|
||||
|
||||
function authUser(name, k, interactive = false) {
|
||||
async function authUser(name, k, interactive = false) {
|
||||
await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
|
||||
/* global webextLaunchWebAuthFlow */
|
||||
const provider = AUTH[name];
|
||||
const state = Math.random().toFixed(8).slice(2);
|
||||
const query = {
|
||||
|
@ -145,52 +140,54 @@ const tokenManager = (() => {
|
|||
Object.assign(query, provider.authQuery);
|
||||
}
|
||||
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
|
||||
return webextLaunchWebAuthFlow({
|
||||
const finalUrl = await webextLaunchWebAuthFlow({
|
||||
url,
|
||||
interactive,
|
||||
redirect_uri: query.redirect_uri,
|
||||
})
|
||||
.then(url => {
|
||||
const params = new URLSearchParams(
|
||||
provider.flow === 'token' ?
|
||||
new URL(url).hash.slice(1) :
|
||||
new URL(url).search.slice(1)
|
||||
);
|
||||
if (params.get('state') !== state) {
|
||||
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
|
||||
}
|
||||
if (provider.flow === 'token') {
|
||||
const obj = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
obj[key] = value;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
const code = params.get('code');
|
||||
const body = {
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
client_id: provider.clientId,
|
||||
redirect_uri: query.redirect_uri,
|
||||
};
|
||||
if (provider.clientSecret) {
|
||||
body.client_secret = provider.clientSecret;
|
||||
}
|
||||
return postQuery(provider.tokenURL, body);
|
||||
})
|
||||
.then(result => handleTokenResult(result, k));
|
||||
});
|
||||
const params = new URLSearchParams(
|
||||
provider.flow === 'token' ?
|
||||
new URL(finalUrl).hash.slice(1) :
|
||||
new URL(finalUrl).search.slice(1)
|
||||
);
|
||||
if (params.get('state') !== state) {
|
||||
throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
|
||||
}
|
||||
let result;
|
||||
if (provider.flow === 'token') {
|
||||
const obj = {};
|
||||
for (const [key, value] of params) {
|
||||
obj[key] = value;
|
||||
}
|
||||
result = obj;
|
||||
} else {
|
||||
const code = params.get('code');
|
||||
const body = {
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
client_id: provider.clientId,
|
||||
redirect_uri: query.redirect_uri,
|
||||
};
|
||||
if (provider.clientSecret) {
|
||||
body.client_secret = provider.clientSecret;
|
||||
}
|
||||
result = await postQuery(provider.tokenURL, body);
|
||||
}
|
||||
return handleTokenResult(result, k);
|
||||
}
|
||||
|
||||
function handleTokenResult(result, k) {
|
||||
return chromeLocal.set({
|
||||
async function handleTokenResult(result, k) {
|
||||
await chromeLocal.set({
|
||||
[k.TOKEN]: result.access_token,
|
||||
[k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined,
|
||||
[k.EXPIRE]: result.expires_in
|
||||
? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
|
||||
: undefined,
|
||||
[k.REFRESH]: result.refresh_token,
|
||||
})
|
||||
.then(() => result.access_token);
|
||||
});
|
||||
return result.access_token;
|
||||
}
|
||||
|
||||
function postQuery(url, body) {
|
||||
async function postQuery(url, body) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -198,17 +195,13 @@ const tokenManager = (() => {
|
|||
},
|
||||
body: body ? new URLSearchParams(body) : null,
|
||||
};
|
||||
return fetch(url, options)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
return r.json();
|
||||
}
|
||||
return r.text()
|
||||
.then(body => {
|
||||
const err = new Error(`failed to fetch (${r.status}): ${body}`);
|
||||
err.code = r.status;
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
const r = await fetch(url, options);
|
||||
if (r.ok) {
|
||||
return r.json();
|
||||
}
|
||||
const text = await r.text();
|
||||
const err = new Error(`Failed to fetch (${r.status}): ${text}`);
|
||||
err.code = r.status;
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
|
251
background/update-manager.js
Normal file
251
background/update-manager.js
Normal file
|
@ -0,0 +1,251 @@
|
|||
/* global API */// msg.js
|
||||
/* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
|
||||
/* global chromeLocal */// storage-util.js
|
||||
/* global debounce download ignoreChromeError */// toolbox.js
|
||||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
/* exported updateMan */
|
||||
const updateMan = (() => {
|
||||
const STATES = /** @namespace UpdaterStates */ {
|
||||
UPDATED: 'updated',
|
||||
SKIPPED: 'skipped',
|
||||
UNREACHABLE: 'server unreachable',
|
||||
// details for SKIPPED status
|
||||
EDITED: 'locally edited',
|
||||
MAYBE_EDITED: 'may be locally edited',
|
||||
SAME_MD5: 'up-to-date: MD5 is unchanged',
|
||||
SAME_CODE: 'up-to-date: code sections are unchanged',
|
||||
SAME_VERSION: 'up-to-date: version is unchanged',
|
||||
ERROR_MD5: 'error: MD5 is invalid',
|
||||
ERROR_JSON: 'error: JSON is invalid',
|
||||
ERROR_VERSION: 'error: version is older than installed style',
|
||||
};
|
||||
|
||||
const ALARM_NAME = 'scheduledUpdate';
|
||||
const MIN_INTERVAL_MS = 60e3;
|
||||
const RETRY_ERRORS = [
|
||||
503, // service unavailable
|
||||
429, // too many requests
|
||||
];
|
||||
let lastUpdateTime;
|
||||
let checkingAll = false;
|
||||
let logQueue = [];
|
||||
let logLastWriteTime = 0;
|
||||
|
||||
chromeLocal.getValue('lastUpdateTime').then(val => {
|
||||
lastUpdateTime = val || Date.now();
|
||||
prefs.subscribe('updateInterval', schedule, {runNow: true});
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
});
|
||||
|
||||
return {
|
||||
checkAllStyles,
|
||||
checkStyle,
|
||||
getStates: () => STATES,
|
||||
};
|
||||
|
||||
async function checkAllStyles({
|
||||
save = true,
|
||||
ignoreDigest,
|
||||
observe,
|
||||
} = {}) {
|
||||
resetInterval();
|
||||
checkingAll = true;
|
||||
const port = observe && chrome.runtime.connect({name: 'updater'});
|
||||
const styles = (await API.styles.getAll())
|
||||
.filter(style => style.updateUrl);
|
||||
if (port) port.postMessage({count: styles.length});
|
||||
log('');
|
||||
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
|
||||
await Promise.all(
|
||||
styles.map(style =>
|
||||
checkStyle({style, port, save, ignoreDigest})));
|
||||
if (port) port.postMessage({done: true});
|
||||
if (port) port.disconnect();
|
||||
log('');
|
||||
checkingAll = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
id?: number
|
||||
style?: StyleObj
|
||||
port?: chrome.runtime.Port
|
||||
save?: boolean = true
|
||||
ignoreDigest?: boolean
|
||||
}} opts
|
||||
* @returns {{
|
||||
style: StyleObj
|
||||
updated?: boolean
|
||||
error?: any
|
||||
STATES: UpdaterStates
|
||||
}}
|
||||
|
||||
Original style digests are calculated in these cases:
|
||||
* style is installed or updated from server
|
||||
* non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
|
||||
|
||||
Update check proceeds in these cases:
|
||||
* style has the original digest and it's equal to the current digest
|
||||
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
|
||||
* [ignoreDigest: none/false] style doesn't yet have the original digest
|
||||
so we compare the code to the server code and if it's the same we save the digest,
|
||||
otherwise we skip the style and report MAYBE_EDITED status
|
||||
|
||||
'ignoreDigest' option is set on the second manual individual update check on the manage page.
|
||||
*/
|
||||
async function checkStyle(opts) {
|
||||
const {
|
||||
id,
|
||||
style = await API.styles.get(id),
|
||||
ignoreDigest,
|
||||
port,
|
||||
save,
|
||||
} = opts;
|
||||
const ucd = style.usercssData;
|
||||
let res, state;
|
||||
try {
|
||||
await checkIfEdited();
|
||||
res = {
|
||||
style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
|
||||
updated: true,
|
||||
};
|
||||
state = STATES.UPDATED;
|
||||
} catch (err) {
|
||||
const error = err === 0 && STATES.UNREACHABLE ||
|
||||
err && err.message ||
|
||||
err;
|
||||
res = {error, style, STATES};
|
||||
state = `${STATES.SKIPPED} (${error})`;
|
||||
}
|
||||
log(`${state} #${style.id} ${style.customName || style.name}`);
|
||||
if (port) port.postMessage(res);
|
||||
return res;
|
||||
|
||||
async function checkIfEdited() {
|
||||
if (!ignoreDigest &&
|
||||
style.originalDigest &&
|
||||
style.originalDigest !== await calcStyleDigest(style)) {
|
||||
return Promise.reject(STATES.EDITED);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUSO() {
|
||||
const md5 = await tryDownload(style.md5Url);
|
||||
if (!md5 || md5.length !== 32) {
|
||||
return Promise.reject(STATES.ERROR_MD5);
|
||||
}
|
||||
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
|
||||
return Promise.reject(STATES.SAME_MD5);
|
||||
}
|
||||
const json = await tryDownload(style.updateUrl, {responseType: 'json'});
|
||||
if (!styleJSONseemsValid(json)) {
|
||||
return Promise.reject(STATES.ERROR_JSON);
|
||||
}
|
||||
// USO may not provide a correctly updated originalMd5 (#555)
|
||||
json.originalMd5 = md5;
|
||||
return json;
|
||||
}
|
||||
|
||||
async function updateUsercss() {
|
||||
// TODO: when sourceCode is > 100kB use http range request(s) for version check
|
||||
const text = await tryDownload(style.updateUrl);
|
||||
const json = await API.usercss.buildMeta({sourceCode: text});
|
||||
await require(['/vendor/semver-bundle/semver']); /* global semverCompare */
|
||||
const delta = semverCompare(json.usercssData.version, ucd.version);
|
||||
if (!delta && !ignoreDigest) {
|
||||
// re-install is invalid in a soft upgrade
|
||||
const sameCode = text === style.sourceCode;
|
||||
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
|
||||
}
|
||||
if (delta < 0) {
|
||||
// downgrade is always invalid
|
||||
return Promise.reject(STATES.ERROR_VERSION);
|
||||
}
|
||||
return API.usercss.buildCode(json);
|
||||
}
|
||||
|
||||
async function maybeSave(json) {
|
||||
json.id = style.id;
|
||||
json.updateDate = Date.now();
|
||||
// keep current state
|
||||
delete json.customName;
|
||||
delete json.enabled;
|
||||
const newStyle = Object.assign({}, style, json);
|
||||
// update digest even if save === false as there might be just a space added etc.
|
||||
if (!ucd && styleSectionsEqual(json, style)) {
|
||||
style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
|
||||
return Promise.reject(STATES.SAME_CODE);
|
||||
}
|
||||
if (!style.originalDigest && !ignoreDigest) {
|
||||
return Promise.reject(STATES.MAYBE_EDITED);
|
||||
}
|
||||
return !save ? newStyle :
|
||||
(ucd ? API.usercss.install : API.styles.install)(newStyle);
|
||||
}
|
||||
|
||||
async function tryDownload(url, params) {
|
||||
let {retryDelay = 1000} = opts;
|
||||
while (true) {
|
||||
try {
|
||||
return await download(url, params);
|
||||
} catch (code) {
|
||||
if (!RETRY_ERRORS.includes(code) ||
|
||||
retryDelay > MIN_INTERVAL_MS) {
|
||||
return Promise.reject(code);
|
||||
}
|
||||
}
|
||||
retryDelay *= 1.25;
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
|
||||
if (interval > 0) {
|
||||
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
|
||||
chrome.alarms.create(ALARM_NAME, {
|
||||
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
|
||||
});
|
||||
} else {
|
||||
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
|
||||
}
|
||||
}
|
||||
|
||||
function onAlarm({name}) {
|
||||
if (name === ALARM_NAME) checkAllStyles();
|
||||
}
|
||||
|
||||
function resetInterval() {
|
||||
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
|
||||
schedule();
|
||||
}
|
||||
|
||||
function log(text) {
|
||||
logQueue.push({text, time: new Date().toLocaleString()});
|
||||
debounce(flushQueue, text && checkingAll ? 1000 : 0);
|
||||
}
|
||||
|
||||
async function flushQueue(lines) {
|
||||
if (!lines) {
|
||||
flushQueue(await chromeLocal.getValue('updateLog') || []);
|
||||
return;
|
||||
}
|
||||
const time = Date.now() - logLastWriteTime > 11e3 ?
|
||||
logQueue[0].time + ' ' :
|
||||
'';
|
||||
if (logQueue[0] && !logQueue[0].text) {
|
||||
logQueue.shift();
|
||||
if (lines[lines.length - 1]) lines.push('');
|
||||
}
|
||||
lines.splice(0, lines.length - 1000);
|
||||
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
|
||||
lines.push(...logQueue.slice(1).map(item => item.text));
|
||||
|
||||
chromeLocal.setValue('updateLog', lines);
|
||||
logLastWriteTime = Date.now();
|
||||
logQueue = [];
|
||||
}
|
||||
})();
|
|
@ -1,290 +0,0 @@
|
|||
/* global
|
||||
API_METHODS
|
||||
calcStyleDigest
|
||||
chromeLocal
|
||||
debounce
|
||||
download
|
||||
getStyleWithNoCode
|
||||
ignoreChromeError
|
||||
prefs
|
||||
semverCompare
|
||||
styleJSONseemsValid
|
||||
styleManager
|
||||
styleSectionsEqual
|
||||
tryJSONparse
|
||||
usercss
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
|
||||
const STATES = {
|
||||
UPDATED: 'updated',
|
||||
SKIPPED: 'skipped',
|
||||
|
||||
// details for SKIPPED status
|
||||
EDITED: 'locally edited',
|
||||
MAYBE_EDITED: 'may be locally edited',
|
||||
SAME_MD5: 'up-to-date: MD5 is unchanged',
|
||||
SAME_CODE: 'up-to-date: code sections are unchanged',
|
||||
SAME_VERSION: 'up-to-date: version is unchanged',
|
||||
ERROR_MD5: 'error: MD5 is invalid',
|
||||
ERROR_JSON: 'error: JSON is invalid',
|
||||
ERROR_VERSION: 'error: version is older than installed style',
|
||||
};
|
||||
|
||||
const ALARM_NAME = 'scheduledUpdate';
|
||||
const MIN_INTERVAL_MS = 60e3;
|
||||
|
||||
let lastUpdateTime;
|
||||
let checkingAll = false;
|
||||
let logQueue = [];
|
||||
let logLastWriteTime = 0;
|
||||
|
||||
const retrying = new Set();
|
||||
|
||||
API_METHODS.updateCheckAll = checkAllStyles;
|
||||
API_METHODS.updateCheck = checkStyle;
|
||||
API_METHODS.getUpdaterStates = () => STATES;
|
||||
|
||||
chromeLocal.getValue('lastUpdateTime').then(val => {
|
||||
lastUpdateTime = val || Date.now();
|
||||
prefs.subscribe('updateInterval', schedule, {now: true});
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
});
|
||||
|
||||
return {checkAllStyles, checkStyle, STATES};
|
||||
|
||||
function checkAllStyles({
|
||||
save = true,
|
||||
ignoreDigest,
|
||||
observe,
|
||||
} = {}) {
|
||||
resetInterval();
|
||||
checkingAll = true;
|
||||
retrying.clear();
|
||||
const port = observe && chrome.runtime.connect({name: 'updater'});
|
||||
return styleManager.getAllStyles().then(styles => {
|
||||
styles = styles.filter(style => style.updateUrl);
|
||||
if (port) port.postMessage({count: styles.length});
|
||||
log('');
|
||||
log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
|
||||
return Promise.all(
|
||||
styles.map(style =>
|
||||
checkStyle({style, port, save, ignoreDigest})));
|
||||
}).then(() => {
|
||||
if (port) port.postMessage({done: true});
|
||||
if (port) port.disconnect();
|
||||
log('');
|
||||
checkingAll = false;
|
||||
retrying.clear();
|
||||
});
|
||||
}
|
||||
|
||||
function checkStyle({
|
||||
id,
|
||||
style,
|
||||
port,
|
||||
save = true,
|
||||
ignoreDigest,
|
||||
}) {
|
||||
/*
|
||||
Original style digests are calculated in these cases:
|
||||
* style is installed or updated from server
|
||||
* style is checked for an update and its code is equal to the server code
|
||||
|
||||
Update check proceeds in these cases:
|
||||
* style has the original digest and it's equal to the current digest
|
||||
* [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
|
||||
* [ignoreDigest: none/false] style doesn't yet have the original digest
|
||||
so we compare the code to the server code and if it's the same we save the digest,
|
||||
otherwise we skip the style and report MAYBE_EDITED status
|
||||
|
||||
'ignoreDigest' option is set on the second manual individual update check on the manage page.
|
||||
*/
|
||||
return fetchStyle()
|
||||
.then(() => {
|
||||
if (!ignoreDigest) {
|
||||
return calcStyleDigest(style)
|
||||
.then(checkIfEdited);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (style.usercssData) {
|
||||
return maybeUpdateUsercss();
|
||||
}
|
||||
return maybeUpdateUSO();
|
||||
})
|
||||
.then(maybeSave)
|
||||
.then(reportSuccess)
|
||||
.catch(reportFailure);
|
||||
|
||||
function fetchStyle() {
|
||||
if (style) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return styleManager.get(id)
|
||||
.then(style_ => {
|
||||
style = style_;
|
||||
});
|
||||
}
|
||||
|
||||
function reportSuccess(saved) {
|
||||
log(STATES.UPDATED + ` #${style.id} ${style.customName || style.name}`);
|
||||
const info = {updated: true, style: saved};
|
||||
if (port) port.postMessage(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
function reportFailure(error) {
|
||||
if ((
|
||||
error === 503 || // Service Unavailable
|
||||
error === 429 // Too Many Requests
|
||||
) && !retrying.has(id)) {
|
||||
retrying.add(id);
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(checkStyle({id, style, port, save, ignoreDigest}));
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
error = error === 0 ? 'server unreachable' : error;
|
||||
// UserCSS metadata error returns an object; e.g. "Invalid @var color..."
|
||||
if (typeof error === 'object' && error.message) {
|
||||
error = error.message;
|
||||
}
|
||||
log(STATES.SKIPPED + ` (${error}) #${style.id} ${style.customName || style.name}`);
|
||||
const info = {error, STATES, style: getStyleWithNoCode(style)};
|
||||
if (port) port.postMessage(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
function checkIfEdited(digest) {
|
||||
if (style.originalDigest && style.originalDigest !== digest) {
|
||||
return Promise.reject(STATES.EDITED);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeUpdateUSO() {
|
||||
return download(style.md5Url).then(md5 => {
|
||||
if (!md5 || md5.length !== 32) {
|
||||
return Promise.reject(STATES.ERROR_MD5);
|
||||
}
|
||||
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
|
||||
return Promise.reject(STATES.SAME_MD5);
|
||||
}
|
||||
// USO can't handle POST requests for style json
|
||||
return download(style.updateUrl, {body: null})
|
||||
.then(text => {
|
||||
const style = tryJSONparse(text);
|
||||
if (style) {
|
||||
// USO may not provide a correctly updated originalMd5 (#555)
|
||||
style.originalMd5 = md5;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function maybeUpdateUsercss() {
|
||||
// TODO: when sourceCode is > 100kB use http range request(s) for version check
|
||||
return download(style.updateUrl).then(text =>
|
||||
usercss.buildMeta(text).then(json => {
|
||||
const {usercssData: {version}} = style;
|
||||
const {usercssData: {version: newVersion}} = json;
|
||||
switch (Math.sign(semverCompare(version, newVersion))) {
|
||||
case 0:
|
||||
// re-install is invalid in a soft upgrade
|
||||
if (!ignoreDigest) {
|
||||
const sameCode = text === style.sourceCode;
|
||||
return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION);
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
// downgrade is always invalid
|
||||
return Promise.reject(STATES.ERROR_VERSION);
|
||||
}
|
||||
return usercss.buildCode(json);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function maybeSave(json = {}) {
|
||||
// usercss is already validated while building
|
||||
if (!json.usercssData && !styleJSONseemsValid(json)) {
|
||||
return Promise.reject(STATES.ERROR_JSON);
|
||||
}
|
||||
|
||||
json.id = style.id;
|
||||
json.updateDate = Date.now();
|
||||
|
||||
// keep current state
|
||||
delete json.enabled;
|
||||
|
||||
const newStyle = Object.assign({}, style, json);
|
||||
if (!style.usercssData && styleSectionsEqual(json, style)) {
|
||||
// update digest even if save === false as there might be just a space added etc.
|
||||
return styleManager.installStyle(newStyle)
|
||||
.then(saved => {
|
||||
style.originalDigest = saved.originalDigest;
|
||||
return Promise.reject(STATES.SAME_CODE);
|
||||
});
|
||||
}
|
||||
|
||||
if (!style.originalDigest && !ignoreDigest) {
|
||||
return Promise.reject(STATES.MAYBE_EDITED);
|
||||
}
|
||||
|
||||
return save ?
|
||||
API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) :
|
||||
newStyle;
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
|
||||
if (interval > 0) {
|
||||
const elapsed = Math.max(0, Date.now() - lastUpdateTime);
|
||||
chrome.alarms.create(ALARM_NAME, {
|
||||
when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
|
||||
});
|
||||
} else {
|
||||
chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
|
||||
}
|
||||
}
|
||||
|
||||
function onAlarm({name}) {
|
||||
if (name === ALARM_NAME) checkAllStyles();
|
||||
}
|
||||
|
||||
function resetInterval() {
|
||||
chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
|
||||
schedule();
|
||||
}
|
||||
|
||||
function log(text) {
|
||||
logQueue.push({text, time: new Date().toLocaleString()});
|
||||
debounce(flushQueue, text && checkingAll ? 1000 : 0);
|
||||
}
|
||||
|
||||
async function flushQueue(lines) {
|
||||
if (!lines) {
|
||||
flushQueue(await chromeLocal.getValue('updateLog') || []);
|
||||
return;
|
||||
}
|
||||
const time = Date.now() - logLastWriteTime > 11e3 ?
|
||||
logQueue[0].time + ' ' :
|
||||
'';
|
||||
if (logQueue[0] && !logQueue[0].text) {
|
||||
logQueue.shift();
|
||||
if (lines[lines.length - 1]) lines.push('');
|
||||
}
|
||||
lines.splice(0, lines.length - 1000);
|
||||
lines.push(time + (logQueue[0] && logQueue[0].text || ''));
|
||||
lines.push(...logQueue.slice(1).map(item => item.text));
|
||||
|
||||
chromeLocal.setValue('updateLog', lines);
|
||||
logLastWriteTime = Date.now();
|
||||
logQueue = [];
|
||||
}
|
||||
})();
|
|
@ -1,132 +0,0 @@
|
|||
/* global API_METHODS usercss styleManager deepCopy */
|
||||
/* exported usercssHelper */
|
||||
'use strict';
|
||||
|
||||
const usercssHelper = (() => {
|
||||
API_METHODS.installUsercss = installUsercss;
|
||||
API_METHODS.editSaveUsercss = editSaveUsercss;
|
||||
API_METHODS.configUsercssVars = configUsercssVars;
|
||||
|
||||
API_METHODS.buildUsercss = build;
|
||||
API_METHODS.buildUsercssMeta = buildMeta;
|
||||
API_METHODS.findUsercss = find;
|
||||
|
||||
function buildMeta(style) {
|
||||
if (style.usercssData) {
|
||||
return Promise.resolve(style);
|
||||
}
|
||||
|
||||
// allow sourceCode to be normalized
|
||||
const {sourceCode} = style;
|
||||
delete style.sourceCode;
|
||||
|
||||
return usercss.buildMeta(sourceCode)
|
||||
.then(newStyle => Object.assign(newStyle, style));
|
||||
}
|
||||
|
||||
function assignVars(style) {
|
||||
return find(style)
|
||||
.then(dup => {
|
||||
if (dup) {
|
||||
style.id = dup.id;
|
||||
// preserve style.vars during update
|
||||
return usercss.assignVars(style, dup)
|
||||
.then(() => style);
|
||||
}
|
||||
return style;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the source, find the duplication, and build sections with variables
|
||||
* @param _
|
||||
* @param {String} _.sourceCode
|
||||
* @param {Boolean=} _.checkDup
|
||||
* @param {Boolean=} _.metaOnly
|
||||
* @param {Object} _.vars
|
||||
* @param {Boolean=} _.assignVars
|
||||
* @returns {Promise<{style, dup:Boolean?}>}
|
||||
*/
|
||||
function build({
|
||||
styleId,
|
||||
sourceCode,
|
||||
checkDup,
|
||||
metaOnly,
|
||||
vars,
|
||||
assignVars = false,
|
||||
}) {
|
||||
return usercss.buildMeta(sourceCode)
|
||||
.then(style => {
|
||||
const findDup = checkDup || assignVars ?
|
||||
find(styleId ? {id: styleId} : style) : Promise.resolve();
|
||||
return Promise.all([
|
||||
metaOnly ? style : doBuild(style, findDup),
|
||||
findDup,
|
||||
]);
|
||||
})
|
||||
.then(([style, dup]) => ({style, dup}));
|
||||
|
||||
function doBuild(style, findDup) {
|
||||
if (vars || assignVars) {
|
||||
const getOld = vars ? Promise.resolve({usercssData: {vars}}) : findDup;
|
||||
return getOld
|
||||
.then(oldStyle => usercss.assignVars(style, oldStyle))
|
||||
.then(() => usercss.buildCode(style));
|
||||
}
|
||||
return usercss.buildCode(style);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the style within aditional properties then inherit variable values
|
||||
// from the old style.
|
||||
function parse(style) {
|
||||
return buildMeta(style)
|
||||
.then(buildMeta)
|
||||
.then(assignVars)
|
||||
.then(usercss.buildCode);
|
||||
}
|
||||
|
||||
// FIXME: simplify this to `installUsercss(sourceCode)`?
|
||||
function installUsercss(style) {
|
||||
return parse(style)
|
||||
.then(styleManager.installStyle);
|
||||
}
|
||||
|
||||
// FIXME: simplify this to `editSaveUsercss({sourceCode, exclusions})`?
|
||||
function editSaveUsercss(style) {
|
||||
return parse(style)
|
||||
.then(styleManager.editSave);
|
||||
}
|
||||
|
||||
function configUsercssVars(id, vars) {
|
||||
return styleManager.get(id)
|
||||
.then(style => {
|
||||
const newStyle = deepCopy(style);
|
||||
newStyle.usercssData.vars = vars;
|
||||
return usercss.buildCode(newStyle);
|
||||
})
|
||||
.then(style => styleManager.installStyle(style, 'config'))
|
||||
.then(style => style.usercssData.vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Style|{name:string, namespace:string}} styleOrData
|
||||
* @returns {Style}
|
||||
*/
|
||||
function find(styleOrData) {
|
||||
if (styleOrData.id) {
|
||||
return styleManager.get(styleOrData.id);
|
||||
}
|
||||
const {name, namespace} = styleOrData.usercssData || styleOrData;
|
||||
return styleManager.getAllStyles().then(styleList => {
|
||||
for (const dup of styleList) {
|
||||
const data = dup.usercssData;
|
||||
if (!data) continue;
|
||||
if (data.name === name &&
|
||||
data.namespace === namespace) {
|
||||
return dup;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
|
@ -1,37 +1,22 @@
|
|||
/* global
|
||||
API_METHODS
|
||||
download
|
||||
openURL
|
||||
tabManager
|
||||
URLS
|
||||
*/
|
||||
/* global URLS download openURL */// toolbox.js
|
||||
/* global addAPI bgReady */// common.js
|
||||
/* global tabMan */// msg.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
bgReady.all.then(() => {
|
||||
const installCodeCache = {};
|
||||
const clearInstallCode = url => delete installCodeCache[url];
|
||||
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
|
||||
const isContentTypeText = type => /^text\/(?!html)/i.test(type);
|
||||
|
||||
// in Firefox we have to use a content script to read file://
|
||||
const fileLoader = !chrome.app && (
|
||||
async tabId =>
|
||||
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
|
||||
|
||||
const urlLoader =
|
||||
async (tabId, url) => (
|
||||
url.startsWith('file:') ||
|
||||
tabManager.get(tabId, isContentTypeText.name) ||
|
||||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
|
||||
) && download(url);
|
||||
|
||||
API_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;
|
||||
};
|
||||
addAPI(/** @namespace API */ {
|
||||
usercss: {
|
||||
getInstallCode(url) {
|
||||
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
|
||||
const {code, timer} = installCodeCache[url];
|
||||
clearInstallCode(url);
|
||||
clearTimeout(timer);
|
||||
return code;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// `glob`: pathname match pattern for webRequest
|
||||
// `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(({tabId, url}) => {
|
||||
const u = new URL(url);
|
||||
const m = maybeDistro[u.hostname];
|
||||
if (!m || m.rx.test(u.pathname)) {
|
||||
openInstallerPage(tabId, url, {});
|
||||
// Silently suppress navigation.
|
||||
// Don't redirect to the install URL as it'll flash the text!
|
||||
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
|
||||
}
|
||||
}, {
|
||||
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
|
||||
urls: [
|
||||
URLS.usoArchiveRaw + 'usercss/*.user.css',
|
||||
'*://greasyfork.org/scripts/*/code/*.user.css',
|
||||
|
@ -70,27 +45,63 @@
|
|||
types: ['main_frame'],
|
||||
}, ['blocking']);
|
||||
|
||||
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
|
||||
chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
|
||||
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
|
||||
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
|
||||
}, {
|
||||
chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
|
||||
urls: makeUsercssGlobs('*', '/*'),
|
||||
types: ['main_frame'],
|
||||
}, ['responseHeaders']);
|
||||
|
||||
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
|
||||
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.') &&
|
||||
/^(https?|file|ftps?):/.test(url) &&
|
||||
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
|
||||
!oldUrl.startsWith(URLS.installUsercss)) {
|
||||
const inTab = url.startsWith('file:') && Boolean(fileLoader);
|
||||
const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
|
||||
if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) {
|
||||
const inTab = url.startsWith('file:') && !chrome.app;
|
||||
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
|
||||
if (!/^\s*</.test(code) && URLS.rxMETA.test(code)) {
|
||||
openInstallerPage(tabId, url, {code, inTab});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Faster installation on known distribution sites to avoid flicker of css text */
|
||||
function maybeInstallFromDistro({tabId, url}) {
|
||||
const u = new URL(url);
|
||||
const m = maybeDistro[u.hostname];
|
||||
if (!m || m.rx.test(u.pathname)) {
|
||||
openInstallerPage(tabId, url, {});
|
||||
// Silently suppress navigation.
|
||||
// Don't redirect to the install URL as it'll flash the text!
|
||||
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
|
||||
}
|
||||
}
|
||||
|
||||
function openInstallerPage(tabId, url, {code, inTab} = {}) {
|
||||
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
|
||||
|
@ -110,7 +121,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
function makeUsercssGlobs(host, path) {
|
||||
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
|
||||
/** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
|
||||
function rememberContentType({tabId, responseHeaders}) {
|
||||
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
|
||||
tabMan.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
152
background/usercss-manager.js
Normal file
152
background/usercss-manager.js
Normal file
|
@ -0,0 +1,152 @@
|
|||
/* global API */// msg.js
|
||||
/* global URLS deepCopy download */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
const usercssMan = {
|
||||
|
||||
GLOBAL_META: Object.entries({
|
||||
author: null,
|
||||
description: null,
|
||||
homepageURL: 'url',
|
||||
updateURL: 'updateUrl',
|
||||
name: null,
|
||||
}),
|
||||
|
||||
async assignVars(style, oldStyle) {
|
||||
const meta = style.usercssData;
|
||||
const vars = meta.vars;
|
||||
const oldVars = oldStyle.usercssData.vars;
|
||||
if (vars && oldVars) {
|
||||
// The type of var might be changed during the update. Set value to null if the value is invalid.
|
||||
for (const [key, v] of Object.entries(vars)) {
|
||||
const old = oldVars[key] && oldVars[key].value;
|
||||
if (old) v.value = old;
|
||||
}
|
||||
meta.vars = await API.worker.nullifyInvalidVars(vars);
|
||||
}
|
||||
},
|
||||
|
||||
async build({
|
||||
styleId,
|
||||
sourceCode,
|
||||
vars,
|
||||
checkDup,
|
||||
metaOnly,
|
||||
assignVars,
|
||||
initialUrl,
|
||||
}) {
|
||||
// downloading here while install-usercss page is loading to avoid the wait
|
||||
if (initialUrl) sourceCode = await download(initialUrl);
|
||||
const style = await usercssMan.buildMeta({sourceCode});
|
||||
const dup = (checkDup || assignVars) &&
|
||||
await usercssMan.find(styleId ? {id: styleId} : style);
|
||||
if (!metaOnly) {
|
||||
if (vars || assignVars) {
|
||||
await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup);
|
||||
}
|
||||
await usercssMan.buildCode(style);
|
||||
}
|
||||
return {style, dup};
|
||||
},
|
||||
|
||||
async buildCode(style) {
|
||||
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
|
||||
const match = code.match(URLS.rxMETA);
|
||||
const i = match.index;
|
||||
const j = i + match[0].length;
|
||||
const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j);
|
||||
const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
|
||||
const recoverable = errors.every(e => e.recoverable);
|
||||
if (!sections.length || !recoverable) {
|
||||
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
|
||||
}
|
||||
style.sections = sections;
|
||||
return style;
|
||||
},
|
||||
|
||||
async buildMeta(style) {
|
||||
if (style.usercssData) {
|
||||
return style;
|
||||
}
|
||||
// remember normalized sourceCode
|
||||
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
|
||||
style = Object.assign({
|
||||
enabled: true,
|
||||
sections: [],
|
||||
}, style);
|
||||
const match = code.match(URLS.rxMETA);
|
||||
if (!match) {
|
||||
return Promise.reject(new Error('Could not find metadata.'));
|
||||
}
|
||||
try {
|
||||
code = blankOut(code, 0, match.index) + match[0];
|
||||
const {metadata} = await API.worker.parseUsercssMeta(code);
|
||||
style.usercssData = metadata;
|
||||
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
|
||||
for (const [key, globalKey] of usercssMan.GLOBAL_META) {
|
||||
const val = metadata[key];
|
||||
if (val !== undefined) {
|
||||
style[globalKey || key] = val;
|
||||
}
|
||||
}
|
||||
return style;
|
||||
} catch (err) {
|
||||
if (err.code) {
|
||||
const args = err.code === 'missingMandatory' || err.code === 'missingChar'
|
||||
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
|
||||
: err.args;
|
||||
const msg = chrome.i18n.getMessage(`meta_${(err.code)}`, args);
|
||||
if (msg) err.message = msg;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
},
|
||||
|
||||
async configVars(id, vars) {
|
||||
const style = deepCopy(await API.styles.get(id));
|
||||
style.usercssData.vars = vars;
|
||||
await usercssMan.buildCode(style);
|
||||
return (await API.styles.install(style, 'config'))
|
||||
.usercssData.vars;
|
||||
},
|
||||
|
||||
async editSave(style) {
|
||||
return API.styles.editSave(await usercssMan.parse(style));
|
||||
},
|
||||
|
||||
async find(styleOrData) {
|
||||
if (styleOrData.id) {
|
||||
return API.styles.get(styleOrData.id);
|
||||
}
|
||||
const {name, namespace} = styleOrData.usercssData || styleOrData;
|
||||
for (const dup of await API.styles.getAll()) {
|
||||
const data = dup.usercssData;
|
||||
if (data &&
|
||||
data.name === name &&
|
||||
data.namespace === namespace) {
|
||||
return dup;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async install(style) {
|
||||
return API.styles.install(await usercssMan.parse(style));
|
||||
},
|
||||
|
||||
async parse(style) {
|
||||
style = await usercssMan.buildMeta(style);
|
||||
// preserve style.vars during update
|
||||
const dup = await usercssMan.find(style);
|
||||
if (dup) {
|
||||
style.id = dup.id;
|
||||
await usercssMan.assignVars(style, dup);
|
||||
}
|
||||
return usercssMan.buildCode(style);
|
||||
},
|
||||
};
|
||||
|
||||
/** Replaces everything with spaces to keep the original length,
|
||||
* but preserves the line breaks to keep the original line/col relation */
|
||||
function blankOut(str, start = 0, end = str.length) {
|
||||
return str.slice(start, end).replace(/[^\r\n]/g, ' ');
|
||||
}
|
178
content/apply.js
178
content/apply.js
|
@ -1,34 +1,22 @@
|
|||
/* global msg API prefs createStyleInjector */
|
||||
/* global API msg */// msg.js
|
||||
/* global StyleInjector */
|
||||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
// Chrome reruns content script when documentElement is replaced.
|
||||
// Note, we're checking against a literal `1`, not just `if (truthy)`,
|
||||
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
|
||||
(() => {
|
||||
if (window.INJECTED === 1) return;
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
self.INJECTED !== 1 && (() => {
|
||||
self.INJECTED = 1;
|
||||
|
||||
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
|
||||
const IS_FRAME = window !== parent;
|
||||
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
|
||||
const styleInjector = createStyleInjector({
|
||||
let hasStyles = false;
|
||||
let isTab = !chrome.tabs || location.pathname !== '/popup.html';
|
||||
const isFrame = window !== parent;
|
||||
const isFrameAboutBlank = isFrame && location.href === 'about:blank';
|
||||
const isUnstylable = !chrome.app && document instanceof XMLDocument;
|
||||
const styleInjector = StyleInjector({
|
||||
compare: (a, b) => a.id - b.id,
|
||||
onUpdate: onInjectorUpdate,
|
||||
});
|
||||
const initializing = init();
|
||||
/** @type chrome.runtime.Port */
|
||||
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();
|
||||
});
|
||||
}
|
||||
// dynamic about: and javascript: iframes don't have a URL yet so we'll use their parent
|
||||
const matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href) || location.href;
|
||||
|
||||
// save it now because chrome.runtime will be unavailable in the orphaned script
|
||||
const orphanEventId = chrome.runtime.id;
|
||||
|
@ -36,6 +24,22 @@ self.INJECTED !== 1 && (() => {
|
|||
// firefox doesn't orphanize content scripts so the old elements stay
|
||||
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);
|
||||
|
||||
if (!chrome.tabs) {
|
||||
|
@ -47,103 +51,97 @@ self.INJECTED !== 1 && (() => {
|
|||
if (!isOrphaned) {
|
||||
updateCount();
|
||||
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
|
||||
onOff(['disableAll'], updateDisableAll);
|
||||
if (IS_FRAME) {
|
||||
onOff('disableAll', updateDisableAll);
|
||||
if (isFrame) {
|
||||
updateExposeIframes();
|
||||
onOff(['exposeIframes'], updateExposeIframes);
|
||||
onOff('exposeIframes', updateExposeIframes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (STYLE_VIA_API) {
|
||||
if (isUnstylable) {
|
||||
await API.styleViaAPI({method: 'styleApply'});
|
||||
} else {
|
||||
const styles = chrome.app && !chrome.tabs && getStylesViaXhr() ||
|
||||
await API.getSectionsByUrl(getMatchUrl(), null, true);
|
||||
if (styles.disableAll) {
|
||||
delete styles.disableAll;
|
||||
styleInjector.toggle(false);
|
||||
const SYM_ID = 'styles';
|
||||
const SYM = Symbol.for(SYM_ID);
|
||||
const styles =
|
||||
window[SYM] ||
|
||||
(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() {
|
||||
try {
|
||||
const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
|
||||
const url = 'blob:' + chrome.runtime.getURL(blobId);
|
||||
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, false); // synchronous
|
||||
xhr.send();
|
||||
URL.revokeObjectURL(url);
|
||||
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;
|
||||
const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
|
||||
const url = 'blob:' + chrome.runtime.getURL(blobId);
|
||||
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, false); // synchronous
|
||||
xhr.send();
|
||||
URL.revokeObjectURL(url);
|
||||
return JSON.parse(xhr.response);
|
||||
}
|
||||
|
||||
function applyOnMessage(request) {
|
||||
if (STYLE_VIA_API) {
|
||||
if (request.method === 'urlChanged') {
|
||||
const {method} = request;
|
||||
if (isUnstylable) {
|
||||
if (method === 'urlChanged') {
|
||||
request.method = 'styleReplaceAll';
|
||||
}
|
||||
if (/^(style|updateCount)/.test(request.method)) {
|
||||
if (/^(style|updateCount)/.test(method)) {
|
||||
API.styleViaAPI(request);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
const {style} = request;
|
||||
switch (method) {
|
||||
case 'ping':
|
||||
return true;
|
||||
|
||||
case 'styleDeleted':
|
||||
styleInjector.remove(request.style.id);
|
||||
styleInjector.remove(style.id);
|
||||
break;
|
||||
|
||||
case 'styleUpdated':
|
||||
if (request.style.enabled) {
|
||||
API.getSectionsByUrl(getMatchUrl(), request.style.id)
|
||||
.then(sections => {
|
||||
if (!sections[request.style.id]) {
|
||||
styleInjector.remove(request.style.id);
|
||||
} else {
|
||||
styleInjector.apply(sections);
|
||||
}
|
||||
});
|
||||
if (style.enabled) {
|
||||
API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
|
||||
sections[style.id]
|
||||
? styleInjector.apply(sections)
|
||||
: styleInjector.remove(style.id));
|
||||
} else {
|
||||
styleInjector.remove(request.style.id);
|
||||
styleInjector.remove(style.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'styleAdded':
|
||||
if (request.style.enabled) {
|
||||
API.getSectionsByUrl(getMatchUrl(), request.style.id)
|
||||
if (style.enabled) {
|
||||
API.styles.getSectionsByUrl(matchUrl, style.id)
|
||||
.then(styleInjector.apply);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'urlChanged':
|
||||
API.getSectionsByUrl(getMatchUrl())
|
||||
.then(styleInjector.replace);
|
||||
API.styles.getSectionsByUrl(matchUrl).then(sections => {
|
||||
hasStyles = true;
|
||||
styleInjector.replace(sections);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'backgroundReady':
|
||||
initializing.catch(err =>
|
||||
ready.catch(err =>
|
||||
msg.isIgnorableError(err)
|
||||
? init()
|
||||
: console.error(err));
|
||||
|
@ -156,8 +154,10 @@ self.INJECTED !== 1 && (() => {
|
|||
}
|
||||
|
||||
function updateDisableAll(key, disableAll) {
|
||||
if (STYLE_VIA_API) {
|
||||
if (isUnstylable) {
|
||||
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
|
||||
} else if (!hasStyles && !disableAll) {
|
||||
init();
|
||||
} else {
|
||||
styleInjector.toggle(!disableAll);
|
||||
}
|
||||
|
@ -179,8 +179,8 @@ self.INJECTED !== 1 && (() => {
|
|||
}
|
||||
|
||||
function updateCount() {
|
||||
if (!IS_TAB) return;
|
||||
if (IS_FRAME) {
|
||||
if (!isTab) return;
|
||||
if (isFrame) {
|
||||
if (!port && styleInjector.list.length) {
|
||||
port = chrome.runtime.connect({name: 'iframe'});
|
||||
} else if (port && !styleInjector.list.length) {
|
||||
|
@ -188,23 +188,25 @@ self.INJECTED !== 1 && (() => {
|
|||
}
|
||||
if (lazyBadge && performance.now() > 1000) lazyBadge = false;
|
||||
}
|
||||
(STYLE_VIA_API ?
|
||||
(isUnstylable ?
|
||||
API.styleViaAPI({method: 'updateCount'}) :
|
||||
API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
|
||||
).catch(msg.ignoreError);
|
||||
}
|
||||
|
||||
function orphanCheck() {
|
||||
function tryCatch(func, ...args) {
|
||||
try {
|
||||
if (chrome.i18n.getUILanguage()) return;
|
||||
return func(...args);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function orphanCheck() {
|
||||
if (tryCatch(() => chrome.i18n.getUILanguage())) return;
|
||||
// In Chrome content script is orphaned on an extension update/reload
|
||||
// so we need to detach event listeners
|
||||
window.removeEventListener(orphanEventId, orphanCheck, true);
|
||||
isOrphaned = true;
|
||||
styleInjector.clear();
|
||||
try {
|
||||
msg.off(applyOnMessage);
|
||||
} catch (e) {}
|
||||
tryCatch(msg.off, applyOnMessage);
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global API */
|
||||
/* global API */// msg.js
|
||||
'use strict';
|
||||
|
||||
// onCommitted may fire twice
|
||||
|
@ -13,7 +13,7 @@ if (window.INJECTED_GREASYFORK !== 1) {
|
|||
e.data.name &&
|
||||
e.data.type === 'style-version-query') {
|
||||
removeEventListener('message', onMessage);
|
||||
const style = await API.findUsercss(e.data) || {};
|
||||
const style = await API.usercss.find(e.data) || {};
|
||||
const {version} = style.usercssData || {};
|
||||
postMessage({type: 'style-version', version}, '*');
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global API */
|
||||
/* global API */// msg.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
|
@ -34,7 +34,7 @@
|
|||
&& event.data.type === 'ouc-is-installed'
|
||||
&& allowedOrigins.includes(event.origin)
|
||||
) {
|
||||
API.findUsercss({
|
||||
API.usercss.find({
|
||||
name: event.data.name,
|
||||
namespace: event.data.namespace,
|
||||
}).then(style => {
|
||||
|
@ -55,7 +55,7 @@
|
|||
window.addEventListener('message', installedHandler);
|
||||
};
|
||||
|
||||
const doHandshake = () => {
|
||||
const doHandshake = event => {
|
||||
// This is a representation of features that Stylus is capable of
|
||||
const implementedFeatures = [
|
||||
'install-usercss',
|
||||
|
@ -106,7 +106,7 @@
|
|||
&& event.data.type === 'ouc-handshake-question'
|
||||
&& allowedOrigins.includes(event.origin)
|
||||
) {
|
||||
doHandshake();
|
||||
doHandshake(event);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -129,7 +129,7 @@
|
|||
&& event.data.type === 'ouc-install-usercss'
|
||||
&& allowedOrigins.includes(event.origin)
|
||||
) {
|
||||
API.installUsercss({
|
||||
API.usercss.install({
|
||||
name: event.data.title,
|
||||
sourceCode: event.data.code,
|
||||
}).then(style => {
|
||||
|
|
|
@ -1,19 +1,21 @@
|
|||
'use strict';
|
||||
|
||||
// preventing reregistration if reinjected by tabs.executeScript for whatever reason, just in case
|
||||
if (typeof self.oldCode !== 'string') {
|
||||
self.oldCode = (document.querySelector('body > pre') || document.body).textContent;
|
||||
if (typeof window.oldCode !== 'string') {
|
||||
window.oldCode = (document.querySelector('body > pre') || document.body).textContent;
|
||||
chrome.runtime.onConnect.addListener(port => {
|
||||
if (port.name !== 'downloadSelf') return;
|
||||
port.onMessage.addListener(({id, force}) => {
|
||||
fetch(location.href, {mode: 'same-origin'})
|
||||
.then(r => r.text())
|
||||
.then(code => ({id, code: force || code !== self.oldCode ? code : null}))
|
||||
.catch(error => ({id, error: error.message || `${error}`}))
|
||||
.then(msg => {
|
||||
port.postMessage(msg);
|
||||
if (msg.code != null) self.oldCode = msg.code;
|
||||
});
|
||||
port.onMessage.addListener(async ({id, force}) => {
|
||||
const msg = {id};
|
||||
try {
|
||||
const code = await (await fetch(location.href, {mode: 'same-origin'})).text();
|
||||
if (code !== window.oldCode || force) {
|
||||
msg.code = window.oldCode = 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
|
||||
addEventListener('pagehide', () => port.disconnect(), {once: true});
|
||||
|
@ -21,4 +23,4 @@ if (typeof self.oldCode !== 'string') {
|
|||
}
|
||||
|
||||
// passing the result to tabs.executeScript
|
||||
self.oldCode; // eslint-disable-line no-unused-expressions
|
||||
window.oldCode; // eslint-disable-line no-unused-expressions
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global cloneInto msg API */
|
||||
/* global API msg */// msg.js
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
@ -14,17 +14,10 @@
|
|||
|
||||
msg.on(onMessage);
|
||||
|
||||
onDOMready().then(() => {
|
||||
window.postMessage({
|
||||
direction: 'from-content-script',
|
||||
message: 'StylishInstalled',
|
||||
}, '*');
|
||||
});
|
||||
|
||||
let currentMd5;
|
||||
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
|
||||
Promise.all([
|
||||
API.findStyle({md5Url}),
|
||||
API.styles.find({md5Url}),
|
||||
getResource(md5Url),
|
||||
onDOMready(),
|
||||
]).then(checkUpdatability);
|
||||
|
@ -119,7 +112,7 @@
|
|||
if (typeof cloneInto !== 'undefined') {
|
||||
// Firefox requires explicit cloning, however USO can't process our messages anyway
|
||||
// because USO tries to use a global "event" variable deprecated in Firefox
|
||||
detail = cloneInto({detail}, document);
|
||||
detail = cloneInto({detail}, document); /* global cloneInto */
|
||||
} else {
|
||||
detail = {detail};
|
||||
}
|
||||
|
@ -154,9 +147,9 @@
|
|||
|
||||
function doInstall() {
|
||||
let oldStyle;
|
||||
return API.findStyle({
|
||||
return API.styles.find({
|
||||
md5Url: getMeta('stylish-md5-url') || location.href,
|
||||
}, true)
|
||||
})
|
||||
.then(_oldStyle => {
|
||||
oldStyle = _oldStyle;
|
||||
return oldStyle ?
|
||||
|
@ -172,7 +165,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
function saveStyleCode(message, name, addProps = {}) {
|
||||
async function saveStyleCode(message, name, addProps = {}) {
|
||||
const isNew = message === 'styleInstall';
|
||||
const needsConfirmation = isNew || !saveStyleCode.confirmed;
|
||||
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
|
||||
|
@ -180,22 +173,19 @@
|
|||
}
|
||||
saveStyleCode.confirmed = true;
|
||||
enableUpdateButton(false);
|
||||
return getStyleJson().then(json => {
|
||||
if (!json) {
|
||||
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
|
||||
'https://github.com/openstyles/stylus/issues/195');
|
||||
return;
|
||||
}
|
||||
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
|
||||
return API.installStyle(Object.assign(json, addProps, {originalMd5: currentMd5}))
|
||||
.then(style => {
|
||||
if (!isNew && style.updateUrl.includes('?')) {
|
||||
enableUpdateButton(true);
|
||||
} else {
|
||||
sendEvent({type: 'styleInstalledChrome'});
|
||||
}
|
||||
});
|
||||
});
|
||||
const json = await getStyleJson();
|
||||
if (!json) {
|
||||
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
|
||||
'https://github.com/openstyles/stylus/issues/195');
|
||||
return;
|
||||
}
|
||||
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
|
||||
const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}));
|
||||
if (!isNew && style.updateUrl.includes('?')) {
|
||||
enableUpdateButton(true);
|
||||
} else {
|
||||
sendEvent({type: 'styleInstalledChrome'});
|
||||
}
|
||||
|
||||
function enableUpdateButton(state) {
|
||||
const important = s => s.replace(/;/g, '!important;');
|
||||
|
@ -218,50 +208,32 @@
|
|||
return e ? e.getAttribute('href') : null;
|
||||
}
|
||||
|
||||
function getResource(url, options) {
|
||||
if (url.startsWith('#')) {
|
||||
return Promise.resolve(document.getElementById(url.slice(1)).textContent);
|
||||
async function getResource(url, opts) {
|
||||
try {
|
||||
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"
|
||||
// instead of "https://update.userstyles.org/#####.md5"
|
||||
function tryFixMd5(style) {
|
||||
if (style && style.md5Url && style.md5Url.includes('update.update')) {
|
||||
style.md5Url = style.md5Url.replace('update.update', 'update');
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function getStyleJson() {
|
||||
return getResource(getStyleURL(), {responseType: 'json'})
|
||||
.then(style => {
|
||||
if (!style || !Array.isArray(style.sections) || style.sections.length) {
|
||||
return style;
|
||||
}
|
||||
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);
|
||||
async function getStyleJson() {
|
||||
try {
|
||||
const style = await getResource(getStyleURL(), {responseType: 'json'});
|
||||
const codeElement = document.getElementById('stylish-code');
|
||||
if (!style || !Array.isArray(style.sections) || style.sections.length ||
|
||||
codeElement && !codeElement.textContent.trim()) {
|
||||
return style;
|
||||
}
|
||||
const code = await getResource(getMeta('stylish-update-url'));
|
||||
style.sections = (await API.worker.parseMozFormat({code})).sections;
|
||||
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
|
||||
return style;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -295,7 +267,7 @@
|
|||
function onDOMready() {
|
||||
return document.readyState !== 'loading'
|
||||
? Promise.resolve()
|
||||
: new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
|
||||
: new Promise(resolve => window.addEventListener('load', resolve, {once: true}));
|
||||
}
|
||||
|
||||
function openSettings(countdown = 10e3) {
|
||||
|
@ -334,6 +306,7 @@
|
|||
|
||||
function inPageContext(eventId) {
|
||||
document.currentScript.remove();
|
||||
window.isInstalled = true;
|
||||
const origMethods = {
|
||||
json: Response.prototype.json,
|
||||
byId: document.getElementById,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
||||
/** @type {function(opts):StyleInjector} */
|
||||
window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
|
||||
compare,
|
||||
onUpdate = () => {},
|
||||
}) => {
|
||||
|
@ -8,8 +9,6 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
|||
const PATCH_ID = 'transition-patch';
|
||||
// styles are out of order if any of these elements is injected between them
|
||||
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 docRootObserver = RootObserver(_sortIfNeeded);
|
||||
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
|
||||
let creationDoc, createElement, createElementNS;
|
||||
|
||||
return {
|
||||
return /** @namespace StyleInjector */ {
|
||||
|
||||
list,
|
||||
|
||||
apply(styleMap) {
|
||||
async apply(styleMap) {
|
||||
const styles = _styleMapToArray(styleMap);
|
||||
return (
|
||||
!styles.length ?
|
||||
Promise.resolve([]) :
|
||||
docRootObserver.evade(() => {
|
||||
if (!isTransitionPatched && isEnabled) {
|
||||
_applyTransitionPatch(styles);
|
||||
}
|
||||
return styles.map(_addUpdate);
|
||||
})
|
||||
).then(_emitUpdate);
|
||||
const value = !styles.length
|
||||
? []
|
||||
: await docRootObserver.evade(() => {
|
||||
if (!isTransitionPatched && isEnabled) {
|
||||
_applyTransitionPatch(styles);
|
||||
}
|
||||
return styles.map(_addUpdate);
|
||||
});
|
||||
_emitUpdate();
|
||||
return value;
|
||||
},
|
||||
|
||||
clear() {
|
||||
|
@ -157,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
|||
docRootObserver[onOff]();
|
||||
}
|
||||
|
||||
function _emitUpdate(value) {
|
||||
function _emitUpdate() {
|
||||
_toggleObservers(list.length);
|
||||
onUpdate();
|
||||
return value;
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -232,17 +230,8 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
|
|||
|
||||
function _update({id, code}) {
|
||||
const style = table.get(id);
|
||||
if (style.code === code) return;
|
||||
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 {
|
||||
if (style.code !== code) {
|
||||
style.code = code;
|
||||
style.el.textContent = code;
|
||||
}
|
||||
}
|
||||
|
|
80
edit.html
80
edit.html
|
@ -4,8 +4,6 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link href="global.css" rel="stylesheet">
|
||||
<link href="edit/edit.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="msgbox/msgbox.css">
|
||||
|
||||
<style id="firefox-transitions-bug-suppressor">
|
||||
/* restrict to FF */
|
||||
|
@ -21,94 +19,59 @@
|
|||
<link id="cm-theme" rel="stylesheet">
|
||||
|
||||
<script src="js/polyfill.js"></script>
|
||||
<script src="js/dom.js"></script>
|
||||
<script src="js/messaging.js"></script>
|
||||
<script src="js/toolbox.js"></script>
|
||||
<script src="js/msg.js"></script>
|
||||
<script src="js/prefs.js"></script>
|
||||
<script src="js/dom.js"></script>
|
||||
<script src="js/localization.js"></script>
|
||||
<script src="js/script-loader.js"></script>
|
||||
<script src="js/storage-util.js"></script>
|
||||
|
||||
<script src="content/style-injector.js"></script>
|
||||
<script src="content/apply.js"></script>
|
||||
|
||||
<script src="edit/util.js"></script>
|
||||
<script src="edit/edit.js"></script> <!-- run it ASAP to send a request for the style -->
|
||||
<script src="js/sections-util.js"></script>
|
||||
<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/mode/css/css.js"></script>
|
||||
<script src="vendor/codemirror/mode/stylus/stylus.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
|
||||
<script src="vendor/codemirror/addon/dialog/dialog.js"></script>
|
||||
|
||||
<script src="vendor/codemirror/addon/edit/closebrackets.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
|
||||
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
|
||||
<script src="vendor/codemirror/addon/scroll/annotatescrollbar.js"></script>
|
||||
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
|
||||
|
||||
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
|
||||
<script src="vendor/codemirror/addon/comment/comment.js"></script>
|
||||
<script src="vendor/codemirror/addon/selection/active-line.js"></script>
|
||||
<script src="vendor/codemirror/addon/edit/matchbrackets.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
|
||||
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/comment-fold.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet" />
|
||||
<script src="vendor/codemirror/addon/lint/lint.js"></script>
|
||||
|
||||
|
||||
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet" />
|
||||
<script src="vendor/codemirror/addon/hint/show-hint.js"></script>
|
||||
<script src="vendor/codemirror/addon/hint/css-hint.js"></script>
|
||||
|
||||
<script src="vendor/codemirror/keymap/sublime.js"></script>
|
||||
<script src="vendor/codemirror/keymap/emacs.js"></script>
|
||||
<script src="vendor/codemirror/keymap/vim.js"></script>
|
||||
|
||||
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
|
||||
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
|
||||
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
|
||||
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
|
||||
|
||||
<script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script>
|
||||
<script src="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-factory.js"></script>
|
||||
<script src="edit/regexp-tester.js"></script>
|
||||
<script src="edit/live-preview.js"></script>
|
||||
<script src="edit/moz-section-finder.js"></script>
|
||||
<script src="edit/moz-section-widget.js"></script>
|
||||
<script src="edit/reroute-hotkeys.js"></script>
|
||||
<link href="edit/global-search.css" rel="stylesheet">
|
||||
<script src="edit/global-search.js"></script>
|
||||
<script src="edit/colorpicker-helper.js"></script>
|
||||
<script src="edit/linter-manager.js"></script>
|
||||
<script src="edit/beautify.js"></script>
|
||||
<script src="edit/show-keymap-help.js"></script>
|
||||
<script src="edit/codemirror-themes.js"></script>
|
||||
<script src="edit/source-editor.js"></script>
|
||||
<script src="edit/sections-editor-section.js"></script>
|
||||
<script src="edit/sections-editor.js"></script>
|
||||
|
||||
<script src="js/worker-util.js"></script>
|
||||
<script src="edit/linter.js"></script>
|
||||
<script src="edit/linter-defaults.js"></script>
|
||||
<script src="edit/linter-engines.js"></script>
|
||||
<script src="edit/linter-meta.js"></script>
|
||||
<script src="edit/linter-help-dialog.js"></script>
|
||||
<script src="edit/linter-report.js"></script>
|
||||
<script src="edit/linter-config-dialog.js"></script>
|
||||
<script src="edit/edit.js"></script>
|
||||
|
||||
<template data-id="appliesTo">
|
||||
<li class="applies-to-item">
|
||||
|
@ -276,6 +239,16 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<body id="stylus-edit">
|
||||
|
@ -498,6 +471,5 @@
|
|||
</symbol>
|
||||
|
||||
</svg>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
234
edit/autocomplete.js
Normal file
234
edit/autocomplete.js
Normal file
|
@ -0,0 +1,234 @@
|
|||
/* global CodeMirror */
|
||||
/* global debounce */// toolbox.js
|
||||
/* global editor */
|
||||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
/* Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */
|
||||
|
||||
(() => {
|
||||
const USO_VAR = 'uso-variable';
|
||||
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
|
||||
const USO_INVALID_VAR = 'error ' + USO_VAR;
|
||||
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
|
||||
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
|
||||
const cssMime = CodeMirror.mimeModes['text/css'];
|
||||
const docFuncs = addSuffix(cssMime.documentTypes, '(');
|
||||
const {tokenHooks} = cssMime;
|
||||
const originalCommentHook = tokenHooks['/'];
|
||||
const originalHelper = CodeMirror.hint.css || (() => {});
|
||||
let cssProps, cssMedia;
|
||||
|
||||
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'),
|
||||
(cm, value) => {
|
||||
cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
|
||||
cm[value ? 'on' : 'off']('pick', autocompletePicked);
|
||||
});
|
||||
|
||||
CodeMirror.registerHelper('hint', 'css', helper);
|
||||
CodeMirror.registerHelper('hint', 'stylus', helper);
|
||||
|
||||
tokenHooks['/'] = tokenizeUsoVariables;
|
||||
|
||||
function helper(cm) {
|
||||
const pos = cm.getCursor();
|
||||
const {line, ch} = pos;
|
||||
const {styles, text} = cm.getLineHandle(line);
|
||||
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
|
||||
const isStylusLang = cm.doc.mode.name === 'stylus';
|
||||
const type = style && style.split(' ', 1)[0] || 'prop?';
|
||||
if (!type || type === 'comment' || type === 'string') {
|
||||
return originalHelper(cm);
|
||||
}
|
||||
// not using getTokenAt until the need is unavoidable because it reparses text
|
||||
// and runs a whole lot of complex calc inside which is slow on long lines
|
||||
// especially if autocomplete is auto-shown on each keystroke
|
||||
let prev, end, state;
|
||||
let i = index;
|
||||
while (
|
||||
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
|
||||
(prev = i > 2 ? styles[i - 2] : 0) &&
|
||||
isSameToken(text, style, prev)
|
||||
) i -= 2;
|
||||
i = index;
|
||||
while (
|
||||
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
|
||||
(end = styles[i]) &&
|
||||
isSameToken(text, style, end)
|
||||
) i += 2;
|
||||
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
|
||||
const str = text.slice(prev, end);
|
||||
const left = text.slice(prev, ch).trim();
|
||||
let leftLC = left.toLowerCase();
|
||||
let list = [];
|
||||
switch (leftLC[0]) {
|
||||
|
||||
case '!':
|
||||
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
|
||||
break;
|
||||
|
||||
case '@':
|
||||
list = [
|
||||
'@-moz-document',
|
||||
'@charset',
|
||||
'@font-face',
|
||||
'@import',
|
||||
'@keyframes',
|
||||
'@media',
|
||||
'@namespace',
|
||||
'@page',
|
||||
'@supports',
|
||||
'@viewport',
|
||||
];
|
||||
break;
|
||||
|
||||
case '#': // prevents autocomplete for #hex colors
|
||||
break;
|
||||
|
||||
case '-': // --variable
|
||||
case '(': // var(
|
||||
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
|
||||
? findAllCssVars(cm, left)
|
||||
: [];
|
||||
prev += str.startsWith('(');
|
||||
leftLC = left;
|
||||
break;
|
||||
|
||||
case '/': // USO vars
|
||||
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
|
||||
prev += 4;
|
||||
end -= 4;
|
||||
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
|
||||
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
|
||||
leftLC = left.slice(4);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'u': // url(), url-prefix()
|
||||
case 'd': // domain()
|
||||
case 'r': // regexp()
|
||||
if (/^(variable|tag|error)/.test(type) &&
|
||||
docFuncs.some(s => s.startsWith(leftLC)) &&
|
||||
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
|
||||
end++;
|
||||
list = docFuncs;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// properties and media features
|
||||
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
|
||||
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
|
||||
if (!cssProps) initCssProps();
|
||||
if (type === 'prop?') {
|
||||
prev += leftLC.length;
|
||||
leftLC = '';
|
||||
}
|
||||
list = state === 'atBlock_parens' ? cssMedia : cssProps;
|
||||
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
|
||||
end += execAt(rxCONSUME, end, text)[0].length;
|
||||
} else {
|
||||
return isStylusLang
|
||||
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
|
||||
: originalHelper(cm);
|
||||
}
|
||||
}
|
||||
return {
|
||||
list: (list || []).filter(s => s.startsWith(leftLC)),
|
||||
from: {line, ch: prev + str.match(/^\s*/)[0].length},
|
||||
to: {line, ch: end},
|
||||
};
|
||||
}
|
||||
|
||||
function initCssProps() {
|
||||
cssProps = addSuffix(cssMime.propertyKeywords).sort();
|
||||
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
|
||||
}
|
||||
|
||||
function addSuffix(obj, suffix = ': ') {
|
||||
return Object.keys(obj).map(k => k + suffix);
|
||||
}
|
||||
|
||||
function getMediaKeys([k, v]) {
|
||||
return k === 'mediaFeatures' && addSuffix(v) ||
|
||||
k.startsWith('media') && Object.keys(v);
|
||||
}
|
||||
|
||||
/** makes sure we don't process a different adjacent comment */
|
||||
function isSameToken(text, style, i) {
|
||||
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
|
||||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
|
||||
}
|
||||
|
||||
function findAllCssVars(cm, leftPart) {
|
||||
// simplified regex without CSS escapes
|
||||
const rx = new RegExp(
|
||||
'(?:^|[\\s/;{])(' +
|
||||
(leftPart.startsWith('--') ? leftPart : '--') +
|
||||
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
|
||||
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
|
||||
'g');
|
||||
const list = new Set();
|
||||
cm.eachLine(({text}) => {
|
||||
for (let m; (m = rx.exec(text));) {
|
||||
list.add(m[1]);
|
||||
}
|
||||
});
|
||||
return [...list].sort();
|
||||
}
|
||||
|
||||
function tokenizeUsoVariables(stream) {
|
||||
const token = originalCommentHook.apply(this, arguments);
|
||||
if (token[1] === 'comment') {
|
||||
const {string, start, pos} = stream;
|
||||
if (testAt(/\/\*\[\[/y, start, string) &&
|
||||
testAt(/]]\*\//y, pos - 4, string)) {
|
||||
const vars = (editor.style.usercssData || {}).vars;
|
||||
token[0] =
|
||||
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
|
||||
? USO_VALID_VAR
|
||||
: USO_INVALID_VAR;
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function execAt(rx, index, text) {
|
||||
rx.lastIndex = index;
|
||||
return rx.exec(text);
|
||||
}
|
||||
|
||||
function testAt(rx, index, text) {
|
||||
rx.lastIndex = Math.max(0, index);
|
||||
return rx.test(text);
|
||||
}
|
||||
|
||||
function autocompleteOnTyping(cm, [info], debounced) {
|
||||
const lastLine = info.text[info.text.length - 1];
|
||||
if (cm.state.completionActive ||
|
||||
info.origin && !info.origin.includes('input') ||
|
||||
!lastLine) {
|
||||
return;
|
||||
}
|
||||
if (cm.state.autocompletePicked) {
|
||||
cm.state.autocompletePicked = false;
|
||||
return;
|
||||
}
|
||||
if (!debounced) {
|
||||
debounce(autocompleteOnTyping, 100, cm, [info], true);
|
||||
return;
|
||||
}
|
||||
if (lastLine.match(/[-a-z!]+$/i)) {
|
||||
cm.state.autocompletePicked = false;
|
||||
cm.options.hintOptions.completeSingle = false;
|
||||
cm.execCommand('autocomplete');
|
||||
setTimeout(() => {
|
||||
cm.options.hintOptions.completeSingle = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function autocompletePicked(cm) {
|
||||
cm.state.autocompletePicked = true;
|
||||
}
|
||||
})();
|
412
edit/base.js
Normal file
412
edit/base.js
Normal file
|
@ -0,0 +1,412 @@
|
|||
/* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
|
||||
/* global API */// msg.js
|
||||
/* global CODEMIRROR_THEMES */
|
||||
/* global CodeMirror */
|
||||
/* global MozDocMapper */// sections-util.js
|
||||
/* global initBeautifyButton */// beautify.js
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
/* global
|
||||
FIREFOX
|
||||
debounce
|
||||
getOwnTab
|
||||
sessionStore
|
||||
tryCatch
|
||||
tryJSONparse
|
||||
*/// toolbox.js
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @type Editor
|
||||
* @namespace Editor
|
||||
*/
|
||||
const editor = {
|
||||
dirty: DirtyReporter(),
|
||||
isUsercss: false,
|
||||
isWindowed: false,
|
||||
livePreview: null,
|
||||
/** @type {'customName'|'name'} */
|
||||
nameTarget: 'name',
|
||||
previewDelay: 200, // Chrome devtools uses 200
|
||||
scrollInfo: null,
|
||||
|
||||
updateTitle(isDirty = editor.dirty.isDirty()) {
|
||||
const {customName, name} = editor.style;
|
||||
document.title = `${
|
||||
isDirty ? '* ' : ''
|
||||
}${
|
||||
customName || name || t('styleMissingName')
|
||||
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
|
||||
},
|
||||
};
|
||||
|
||||
//#region pre-init
|
||||
|
||||
const baseInit = (() => {
|
||||
const lazyKeymaps = {
|
||||
emacs: '/vendor/codemirror/keymap/emacs',
|
||||
vim: '/vendor/codemirror/keymap/vim',
|
||||
};
|
||||
const domReady = waitForSelector('#sections');
|
||||
|
||||
return {
|
||||
domReady,
|
||||
ready: Promise.all([
|
||||
domReady,
|
||||
loadStyle(),
|
||||
prefs.ready.then(() =>
|
||||
Promise.all([
|
||||
loadTheme(),
|
||||
loadKeymaps(),
|
||||
])),
|
||||
]),
|
||||
};
|
||||
|
||||
/** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
|
||||
function loadKeymaps() {
|
||||
const km = prefs.get('editor.keyMap');
|
||||
return /emacs/i.test(km) && require([lazyKeymaps.emacs]) ||
|
||||
/vim/i.test(km) && require([lazyKeymaps.vim]);
|
||||
}
|
||||
|
||||
async function loadStyle() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = Number(params.get('id'));
|
||||
const style = id && await API.styles.get(id) || {
|
||||
name: params.get('domain') ||
|
||||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
|
||||
'',
|
||||
enabled: true,
|
||||
sections: [
|
||||
MozDocMapper.toSection([...params], {code: ''}),
|
||||
],
|
||||
};
|
||||
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
|
||||
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
|
||||
editor.lazyKeymaps = lazyKeymaps;
|
||||
editor.style = style;
|
||||
editor.updateTitle(false);
|
||||
document.documentElement.classList.toggle('usercss', editor.isUsercss);
|
||||
sessionStore.justEditedStyleId = style.id || '';
|
||||
// no such style so let's clear the invalid URL parameters
|
||||
if (!style.id) history.replaceState({}, '', location.pathname);
|
||||
}
|
||||
|
||||
/** Preloads the theme so CodeMirror can use the correct metrics in its first render */
|
||||
async function loadTheme() {
|
||||
const theme = prefs.get('editor.theme');
|
||||
if (theme !== 'default') {
|
||||
const el = $('#cm-theme');
|
||||
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
|
||||
el2.id = el.id;
|
||||
el.remove();
|
||||
if (!el2.sheet) {
|
||||
prefs.set('editor.theme', 'default');
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
||||
//#region init layout/resize
|
||||
|
||||
baseInit.domReady.then(() => {
|
||||
let headerHeight;
|
||||
detectLayout(true);
|
||||
window.on('resize', () => detectLayout());
|
||||
|
||||
function detectLayout(now) {
|
||||
const compact = window.innerWidth <= 850;
|
||||
if (compact) {
|
||||
document.body.classList.add('compact-layout');
|
||||
if (!editor.isUsercss) {
|
||||
if (now) fixedHeader();
|
||||
else debounce(fixedHeader, 250);
|
||||
window.on('scroll', fixedHeader, {passive: true});
|
||||
}
|
||||
} else {
|
||||
document.body.classList.remove('compact-layout', 'fixed-header');
|
||||
window.off('scroll', fixedHeader);
|
||||
}
|
||||
for (const type of ['options', 'toc', 'lint']) {
|
||||
const el = $(`details[data-pref="editor.${type}.expanded"]`);
|
||||
el.open = compact ? false : prefs.get(el.dataset.pref);
|
||||
}
|
||||
}
|
||||
|
||||
function fixedHeader() {
|
||||
const headerFixed = $('.fixed-header');
|
||||
if (!headerFixed) headerHeight = $('#header').clientHeight;
|
||||
const scrollPoint = headerHeight - 43;
|
||||
if (window.scrollY >= scrollPoint && !headerFixed) {
|
||||
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
|
||||
$('body').classList.add('fixed-header');
|
||||
} else if (window.scrollY < scrollPoint && headerFixed) {
|
||||
$('body').classList.remove('fixed-header');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region init header
|
||||
|
||||
baseInit.ready.then(() => {
|
||||
initBeautifyButton($('#beautify'));
|
||||
initKeymapElement();
|
||||
initNameArea();
|
||||
initThemeElement();
|
||||
setupLivePrefs();
|
||||
|
||||
$('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
|
||||
$('#preview-label').classList.toggle('hidden', !editor.style.id);
|
||||
|
||||
require(Object.values(editor.lazyKeymaps), () => {
|
||||
initKeymapElement();
|
||||
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
|
||||
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
|
||||
});
|
||||
|
||||
function findKeyForCommand(command, map) {
|
||||
if (typeof map === 'string') map = CodeMirror.keyMap[map];
|
||||
let key = Object.keys(map).find(k => map[k] === command);
|
||||
if (key) {
|
||||
return key;
|
||||
}
|
||||
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
|
||||
key = ft && findKeyForCommand(command, ft);
|
||||
if (key) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function initNameArea() {
|
||||
const nameEl = $('#name');
|
||||
const resetEl = $('#reset-name');
|
||||
const isCustomName = editor.style.updateUrl || editor.isUsercss;
|
||||
editor.nameTarget = isCustomName ? 'customName' : 'name';
|
||||
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
|
||||
nameEl.title = isCustomName ? t('customNameHint') : '';
|
||||
nameEl.on('input', () => {
|
||||
editor.updateName(true);
|
||||
resetEl.hidden = false;
|
||||
});
|
||||
resetEl.hidden = !editor.style.customName;
|
||||
resetEl.onclick = () => {
|
||||
const {style} = editor;
|
||||
nameEl.focus();
|
||||
nameEl.select();
|
||||
// trying to make it undoable via Ctrl-Z
|
||||
if (!document.execCommand('insertText', false, style.name)) {
|
||||
nameEl.value = style.name;
|
||||
editor.updateName(true);
|
||||
}
|
||||
style.customName = null; // to delete it from db
|
||||
resetEl.hidden = true;
|
||||
};
|
||||
const enabledEl = $('#enabled');
|
||||
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
|
||||
}
|
||||
|
||||
function initThemeElement() {
|
||||
$('#editor.theme').append(...[
|
||||
$create('option', {value: 'default'}, t('defaultTheme')),
|
||||
...CODEMIRROR_THEMES.map(s => $create('option', s)),
|
||||
]);
|
||||
// move the theme after built-in CSS so that its same-specificity selectors win
|
||||
document.head.appendChild($('#cm-theme'));
|
||||
}
|
||||
|
||||
function initKeymapElement() {
|
||||
// move 'pc' or 'mac' prefix to the end of the displayed label
|
||||
const maps = Object.keys(CodeMirror.keyMap)
|
||||
.map(name => ({
|
||||
value: name,
|
||||
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
|
||||
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
|
||||
}))
|
||||
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
let bin = fragment;
|
||||
let groupName;
|
||||
// group suffixed maps in <optgroup>
|
||||
maps.forEach(({value, name}, i) => {
|
||||
groupName = !name.includes('-') ? name : groupName;
|
||||
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
|
||||
if (groupWithNext) {
|
||||
if (bin === fragment) {
|
||||
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
|
||||
}
|
||||
}
|
||||
const el = bin.appendChild($create('option', {value}, name));
|
||||
if (value === prefs.defaults['editor.keyMap']) {
|
||||
el.dataset.default = '';
|
||||
el.title = t('defaultTheme');
|
||||
}
|
||||
if (!groupWithNext) bin = fragment;
|
||||
});
|
||||
const selector = $('#editor.keyMap');
|
||||
selector.textContent = '';
|
||||
selector.appendChild(fragment);
|
||||
selector.value = prefs.get('editor.keyMap');
|
||||
}
|
||||
|
||||
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
|
||||
const extraKeys = CodeMirror.defaults.extraKeys;
|
||||
for (const el of $$('[data-hotkey-tooltip]')) {
|
||||
if (el._hotkeyTooltipKeyMap !== mapName) {
|
||||
el._hotkeyTooltipKeyMap = mapName;
|
||||
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
|
||||
const cmd = el.dataset.hotkeyTooltip;
|
||||
const key = cmd[0] === '=' ? cmd.slice(1) :
|
||||
findKeyForCommand(cmd, mapName) ||
|
||||
extraKeys && findKeyForCommand(cmd, extraKeys);
|
||||
const newTitle = title + (title && key ? '\n' : '') + (key || '');
|
||||
if (el.title !== newTitle) el.title = newTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region init windowed mode
|
||||
|
||||
(() => {
|
||||
let ownTabId;
|
||||
if (chrome.windows) {
|
||||
initWindowedMode();
|
||||
const pos = tryJSONparse(sessionStore.windowPos);
|
||||
delete sessionStore.windowPos;
|
||||
// resize the window on 'undo close'
|
||||
if (pos && pos.left != null) {
|
||||
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
|
||||
}
|
||||
}
|
||||
|
||||
getOwnTab().then(async tab => {
|
||||
ownTabId = tab.id;
|
||||
// use browser history back when 'back to manage' is clicked
|
||||
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
|
||||
await baseInit.domReady;
|
||||
$('#cancel-button').onclick = event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function initWindowedMode() {
|
||||
chrome.tabs.onAttached.addListener(onTabAttached);
|
||||
const isSimple = (await browser.windows.getCurrent()).type === 'popup';
|
||||
if (isSimple) require(['/edit/embedded-popup']);
|
||||
editor.isWindowed = isSimple || (
|
||||
history.length === 1 &&
|
||||
await prefs.ready && prefs.get('openEditInWindow') &&
|
||||
(await browser.windows.getAll()).length > 1 &&
|
||||
(await browser.tabs.query({currentWindow: true})).length === 1
|
||||
);
|
||||
}
|
||||
|
||||
async function onTabAttached(tabId, info) {
|
||||
if (tabId !== ownTabId) {
|
||||
return;
|
||||
}
|
||||
if (info.newPosition !== 0) {
|
||||
prefs.set('openEditInWindow', false);
|
||||
return;
|
||||
}
|
||||
const win = await browser.windows.get(info.newWindowId, {populate: true});
|
||||
// If there's only one tab in this window, it's been dragged to new window
|
||||
const openEditInWindow = win.tabs.length === 1;
|
||||
// FF-only because Chrome retardedly resets the size during dragging
|
||||
if (openEditInWindow && FIREFOX) {
|
||||
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
|
||||
}
|
||||
prefs.set('openEditInWindow', openEditInWindow);
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
||||
//#region internals
|
||||
|
||||
/** @returns DirtyReporter */
|
||||
function DirtyReporter() {
|
||||
const data = new Map();
|
||||
const listeners = new Set();
|
||||
const notifyChange = wasDirty => {
|
||||
if (wasDirty !== (data.size > 0)) {
|
||||
listeners.forEach(cb => cb());
|
||||
}
|
||||
};
|
||||
/** @namespace DirtyReporter */
|
||||
return {
|
||||
add(obj, value) {
|
||||
const wasDirty = data.size > 0;
|
||||
const saved = data.get(obj);
|
||||
if (!saved) {
|
||||
data.set(obj, {type: 'add', newValue: value});
|
||||
} else if (saved.type === 'remove') {
|
||||
if (saved.savedValue === value) {
|
||||
data.delete(obj);
|
||||
} else {
|
||||
saved.newValue = value;
|
||||
saved.type = 'modify';
|
||||
}
|
||||
}
|
||||
notifyChange(wasDirty);
|
||||
},
|
||||
clear(obj) {
|
||||
const wasDirty = data.size > 0;
|
||||
if (obj === undefined) {
|
||||
data.clear();
|
||||
} else {
|
||||
data.delete(obj);
|
||||
}
|
||||
notifyChange(wasDirty);
|
||||
},
|
||||
has(key) {
|
||||
return data.has(key);
|
||||
},
|
||||
isDirty() {
|
||||
return data.size > 0;
|
||||
},
|
||||
modify(obj, oldValue, newValue) {
|
||||
const wasDirty = data.size > 0;
|
||||
const saved = data.get(obj);
|
||||
if (!saved) {
|
||||
if (oldValue !== newValue) {
|
||||
data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
||||
}
|
||||
} else if (saved.type === 'modify') {
|
||||
if (saved.savedValue === newValue) {
|
||||
data.delete(obj);
|
||||
} else {
|
||||
saved.newValue = newValue;
|
||||
}
|
||||
} else if (saved.type === 'add') {
|
||||
saved.newValue = newValue;
|
||||
}
|
||||
notifyChange(wasDirty);
|
||||
},
|
||||
onChange(cb, add = true) {
|
||||
listeners[add ? 'add' : 'delete'](cb);
|
||||
},
|
||||
remove(obj, value) {
|
||||
const wasDirty = data.size > 0;
|
||||
const saved = data.get(obj);
|
||||
if (!saved) {
|
||||
data.set(obj, {type: 'remove', savedValue: value});
|
||||
} else if (saved.type === 'add') {
|
||||
data.delete(obj);
|
||||
} else if (saved.type === 'modify') {
|
||||
saved.type = 'remove';
|
||||
}
|
||||
notifyChange(wasDirty);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//#endregion
|
312
edit/beautify.js
312
edit/beautify.js
|
@ -1,20 +1,18 @@
|
|||
/* global loadScript css_beautify showHelp prefs t $ $create */
|
||||
/* global editor createHotkeyInput moveFocus CodeMirror */
|
||||
/* exported initBeautifyButton */
|
||||
/* global $ $create moveFocus */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global createHotkeyInput helpPopup */// util.js
|
||||
/* global editor */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'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(() => {
|
||||
CodeMirror.defaults.extraKeys[prefs.get(HOTKEY_ID) || ''] = 'beautify';
|
||||
CodeMirror.commands.beautify = cm => {
|
||||
// using per-section mode when code editor or applies-to block is focused
|
||||
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
|
||||
beautify(isPerSection ? [cm] : editor.getEditors(), false);
|
||||
};
|
||||
});
|
||||
|
||||
prefs.subscribe([HOTKEY_ID], (key, value) => {
|
||||
prefs.subscribe('editor.beautify.hotkey', (key, value) => {
|
||||
const {extraKeys} = CodeMirror.defaults;
|
||||
for (const [key, cmd] of Object.entries(extraKeys)) {
|
||||
if (cmd === 'beautify') {
|
||||
|
@ -25,164 +23,148 @@ prefs.subscribe([HOTKEY_ID], (key, value) => {
|
|||
if (value) {
|
||||
extraKeys[value] = 'beautify';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} btn - the button element shown in the UI
|
||||
* @param {function():CodeMirror[]} getScope
|
||||
*/
|
||||
function initBeautifyButton(btn, getScope) {
|
||||
btn.addEventListener('click', () => beautify(getScope()));
|
||||
btn.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
beautify(getScope(), false);
|
||||
});
|
||||
}
|
||||
}, {runNow: true});
|
||||
|
||||
/**
|
||||
* @name beautify
|
||||
* @param {CodeMirror[]} scope
|
||||
* @param {?boolean} ui
|
||||
* @param {boolean} [ui=true]
|
||||
*/
|
||||
function beautify(scope, ui = true) {
|
||||
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
|
||||
.then(() => {
|
||||
if (!window.css_beautify && window.exports) {
|
||||
window.css_beautify = window.exports.css_beautify;
|
||||
}
|
||||
})
|
||||
.then(doBeautify);
|
||||
async function beautify(scope, ui = true) {
|
||||
await require(['/vendor-overwrites/beautify/beautify-css-mod']); /* global css_beautify */
|
||||
const tabs = prefs.get('editor.indentWithTabs');
|
||||
const options = Object.assign(prefs.defaults['editor.beautify'], prefs.get('editor.beautify'));
|
||||
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
|
||||
options.indent_char = tabs ? '\t' : ' ';
|
||||
if (ui) {
|
||||
createBeautifyUI(scope, options);
|
||||
}
|
||||
for (const cm of scope) {
|
||||
setTimeout(beautifyEditor, 0, cm, options, ui);
|
||||
}
|
||||
}
|
||||
|
||||
function doBeautify() {
|
||||
const tabs = prefs.get('editor.indentWithTabs');
|
||||
const options = Object.assign({}, prefs.get('editor.beautify'));
|
||||
for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
|
||||
if (!(k in options)) options[k] = prefs.defaults['editor.beautify'][k];
|
||||
function beautifyEditor(cm, options, ui) {
|
||||
const pos = options.translate_positions =
|
||||
[].concat.apply([], cm.doc.sel.ranges.map(r =>
|
||||
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
|
||||
const text = cm.getValue();
|
||||
const newText = css_beautify(text, options);
|
||||
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');
|
||||
options.indent_char = tabs ? '\t' : ' ';
|
||||
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) {
|
||||
createBeautifyUI(scope, options);
|
||||
}
|
||||
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),
|
||||
])
|
||||
);
|
||||
$('#help-popup button[role="close"]').disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBeautifyUI(scope, options) {
|
||||
helpPopup.show(t('styleBeautify'),
|
||||
$create([
|
||||
$create('.beautify-options', [
|
||||
$createOption('.selector1,', 'selector_separator_newline'),
|
||||
$createOption('.selector2', 'newline_before_open_brace'),
|
||||
$createOption('{', 'newline_after_open_brace'),
|
||||
$createOption('border: none;', 'newline_between_properties', true),
|
||||
$createOption('display: block;', 'newline_before_close_brace', true),
|
||||
$createOption('}', 'newline_between_rules'),
|
||||
$createLabeledCheckbox('preserve_newlines', 'styleBeautifyPreserveNewlines'),
|
||||
$createLabeledCheckbox('indent_conditional', 'styleBeautifyIndentConditional'),
|
||||
]),
|
||||
$create('p.beautify-hint', [
|
||||
$create('span', t('styleBeautifyHint') + '\u00A0'),
|
||||
createHotkeyInput('editor.beautify.hotkey', () => moveFocus($('#help-popup'), 0)),
|
||||
]),
|
||||
$create('.buttons', [
|
||||
$create('button', {
|
||||
attributes: {role: 'close'},
|
||||
onclick: helpPopup.close,
|
||||
}, t('confirmClose')),
|
||||
$create('button', {
|
||||
attributes: {role: 'undo'},
|
||||
onclick() {
|
||||
let undoable = false;
|
||||
for (const cm of scope) {
|
||||
const data = cm.beautifyChange;
|
||||
if (!data || !data[cm.changeGeneration()]) continue;
|
||||
delete data[cm.changeGeneration()];
|
||||
const {scrollX, scrollY} = window;
|
||||
cm.undo();
|
||||
cm.scrollIntoView(cm.getCursor());
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
undoable |= data[cm.changeGeneration()];
|
||||
}
|
||||
this.disabled = !undoable;
|
||||
},
|
||||
}, t(scope.length === 1 ? 'undo' : 'undoGlobal')),
|
||||
]),
|
||||
]));
|
||||
|
||||
$('#help-popup').className = 'wide';
|
||||
|
||||
$('.beautify-options').onchange = ({target}) => {
|
||||
const value = target.type === 'checkbox' ? target.checked : target.selectedIndex > 0;
|
||||
prefs.set('editor.beautify', Object.assign(options, {[target.dataset.option]: value}));
|
||||
if (target.parentNode.hasAttribute('newline')) {
|
||||
target.parentNode.setAttribute('newline', value.toString());
|
||||
}
|
||||
beautify(scope, false);
|
||||
};
|
||||
|
||||
function $createOption(label, optionName, indent) {
|
||||
const value = options[optionName];
|
||||
return (
|
||||
$create('div', {attributes: {newline: value}}, [
|
||||
$create('span', indent ? {attributes: {indent: ''}} : {}, label),
|
||||
$create('div.select-resizer', [
|
||||
$create('select', {dataset: {option: optionName}}, [
|
||||
$create('option', {selected: !value}, '\xA0'),
|
||||
$create('option', {selected: value}, '\\n'),
|
||||
]),
|
||||
$create('SVG:svg.svg-icon.select-arrow', {viewBox: '0 0 1792 1792'}, [
|
||||
$create('SVG:path', {
|
||||
'fill-rule': 'evenodd',
|
||||
'd': 'M1408 704q0 26-19 45l-448 448q-19 19-45 ' +
|
||||
'19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z',
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function $createLabeledCheckbox(optionName, i18nKey) {
|
||||
return (
|
||||
$create('label', {style: 'display: block; clear: both;'}, [
|
||||
$create('input', {
|
||||
type: 'checkbox',
|
||||
dataset: {option: optionName},
|
||||
checked: options[optionName] !== false,
|
||||
}),
|
||||
$create('SVG:svg.svg-icon.checked',
|
||||
$create('SVG:use', {'xlink:href': '#svg-icon-checked'})),
|
||||
t(i18nKey),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* exported initBeautifyButton */
|
||||
function initBeautifyButton(btn, scope) {
|
||||
btn.onclick = btn.oncontextmenu = e => {
|
||||
e.preventDefault();
|
||||
beautify(scope || editor.getEditors(), e.type === 'click');
|
||||
};
|
||||
}
|
||||
|
|
|
@ -16,9 +16,6 @@
|
|||
/* Not using the ring-color hack as it became ugly in new Chrome */
|
||||
outline: none !important;
|
||||
}
|
||||
.CodeMirror-lint-mark-warning {
|
||||
background: none;
|
||||
}
|
||||
.CodeMirror-dialog {
|
||||
animation: highlight 3s cubic-bezier(.18, .02, 0, .94);
|
||||
}
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
/* global
|
||||
$
|
||||
CodeMirror
|
||||
prefs
|
||||
t
|
||||
*/
|
||||
|
||||
/* global $ */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global editor */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
(() => {
|
||||
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
|
||||
if (!prefs.get('editor.keyMap')) {
|
||||
prefs.reset('editor.keyMap');
|
||||
|
@ -43,53 +41,55 @@
|
|||
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
|
||||
|
||||
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
|
||||
const KM = CodeMirror.keyMap;
|
||||
const extras = Object.values(CodeMirror.defaults.extraKeys);
|
||||
if (!extras.includes('jumpToLine')) {
|
||||
KM.sublime['Ctrl-G'] = 'jumpToLine';
|
||||
KM.emacsy['Ctrl-G'] = 'jumpToLine';
|
||||
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
|
||||
KM.macDefault['Cmd-J'] = 'jumpToLine';
|
||||
}
|
||||
if (!extras.includes('autocomplete')) {
|
||||
// will be used by 'sublime' on PC via fallthrough
|
||||
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
|
||||
// OSX uses Ctrl-Space and Cmd-Space for something else
|
||||
KM.macDefault['Alt-Space'] = 'autocomplete';
|
||||
// copied from 'emacs' keymap
|
||||
KM.emacsy['Alt-/'] = 'autocomplete';
|
||||
// 'vim' and 'emacs' define their own autocomplete hotkeys
|
||||
}
|
||||
if (!extras.includes('blockComment')) {
|
||||
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
|
||||
}
|
||||
if (navigator.appVersion.includes('Windows')) {
|
||||
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
|
||||
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
|
||||
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
|
||||
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
|
||||
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
|
||||
// Note: modifier order in CodeMirror is S-C-A
|
||||
for (const char of ['N', 'T', 'W']) {
|
||||
for (const remap of [
|
||||
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
|
||||
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
|
||||
]) {
|
||||
const oldKey = remap.from + char;
|
||||
for (const km of Object.values(KM)) {
|
||||
const command = km[oldKey];
|
||||
if (!command) continue;
|
||||
for (const newMod of remap.to) {
|
||||
const newKey = newMod + char;
|
||||
if (newKey in km) continue;
|
||||
km[newKey] = command;
|
||||
delete km[oldKey];
|
||||
break;
|
||||
require(Object.values(typeof editor === 'object' && editor.lazyKeymaps || {}), () => {
|
||||
const KM = CodeMirror.keyMap;
|
||||
const extras = Object.values(CodeMirror.defaults.extraKeys);
|
||||
if (!extras.includes('jumpToLine')) {
|
||||
KM.sublime['Ctrl-G'] = 'jumpToLine';
|
||||
KM.emacsy['Ctrl-G'] = 'jumpToLine';
|
||||
KM.pcDefault['Ctrl-J'] = 'jumpToLine';
|
||||
KM.macDefault['Cmd-J'] = 'jumpToLine';
|
||||
}
|
||||
if (!extras.includes('autocomplete')) {
|
||||
// will be used by 'sublime' on PC via fallthrough
|
||||
KM.pcDefault['Ctrl-Space'] = 'autocomplete';
|
||||
// OSX uses Ctrl-Space and Cmd-Space for something else
|
||||
KM.macDefault['Alt-Space'] = 'autocomplete';
|
||||
// copied from 'emacs' keymap
|
||||
KM.emacsy['Alt-/'] = 'autocomplete';
|
||||
// 'vim' and 'emacs' define their own autocomplete hotkeys
|
||||
}
|
||||
if (!extras.includes('blockComment')) {
|
||||
KM.sublime['Shift-Ctrl-/'] = 'commentSelection';
|
||||
}
|
||||
if (navigator.appVersion.includes('Windows')) {
|
||||
// 'pcDefault' keymap on Windows should have F3/Shift-F3/Ctrl-R
|
||||
if (!extras.includes('findNext')) KM.pcDefault['F3'] = 'findNext';
|
||||
if (!extras.includes('findPrev')) KM.pcDefault['Shift-F3'] = 'findPrev';
|
||||
if (!extras.includes('replace')) KM.pcDefault['Ctrl-R'] = 'replace';
|
||||
// try to remap non-interceptable (Shift-)Ctrl-N/T/W hotkeys
|
||||
// Note: modifier order in CodeMirror is S-C-A
|
||||
for (const char of ['N', 'T', 'W']) {
|
||||
for (const remap of [
|
||||
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
|
||||
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']},
|
||||
]) {
|
||||
const oldKey = remap.from + char;
|
||||
for (const km of Object.values(KM)) {
|
||||
const command = km[oldKey];
|
||||
if (!command) continue;
|
||||
for (const newMod of remap.to) {
|
||||
const newKey = newMod + char;
|
||||
if (newKey in km) continue;
|
||||
km[newKey] = command;
|
||||
delete km[oldKey];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cssMime = CodeMirror.mimeModes['text/css'];
|
||||
Object.assign(cssMime.propertyKeywords, {
|
||||
|
@ -142,12 +142,16 @@
|
|||
jumpToPos(pos, end = pos) {
|
||||
const {curOp} = this;
|
||||
if (!curOp) this.startOperation();
|
||||
const coords = this.cursorCoords(pos, 'window');
|
||||
const b = this.display.wrapper.getBoundingClientRect();
|
||||
if (coords.top < Math.max(0, b.top + this.defaultTextHeight() * 2) ||
|
||||
coords.bottom > Math.min(window.innerHeight, b.bottom - 100)) {
|
||||
this.scrollIntoView(pos, b.height / 2);
|
||||
const y = this.cursorCoords(pos, 'window').top;
|
||||
const rect = this.display.wrapper.getBoundingClientRect();
|
||||
// case 1) outside of CM viewport or too close to edge so tell CM to render a new viewport
|
||||
if (y < rect.top + 50 || y > rect.bottom - 100) {
|
||||
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);
|
||||
if (!curOp) this.endOperation();
|
||||
},
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
/* global
|
||||
$
|
||||
CodeMirror
|
||||
debounce
|
||||
editor
|
||||
loadScript
|
||||
prefs
|
||||
rerouteHotkeys
|
||||
*/
|
||||
/* global $ */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global editor */
|
||||
/* global prefs */
|
||||
/* global rerouteHotkeys */// util.js
|
||||
'use strict';
|
||||
|
||||
//#region cmFactory
|
||||
(() => {
|
||||
/*
|
||||
/*
|
||||
All cm instances created by this module are collected so we can broadcast prefs
|
||||
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
|
||||
when the instance is not used anymore.
|
||||
*/
|
||||
*/
|
||||
|
||||
(() => {
|
||||
//#region Factory
|
||||
|
||||
const cms = new Set();
|
||||
let lazyOpt;
|
||||
|
||||
const cmFactory = window.cmFactory = {
|
||||
|
||||
create(place, options) {
|
||||
const cm = CodeMirror(place, options);
|
||||
const {wrapper} = cm.display;
|
||||
|
@ -38,9 +37,11 @@
|
|||
cms.add(cm);
|
||||
return cm;
|
||||
},
|
||||
|
||||
destroy(cm) {
|
||||
cms.delete(cm);
|
||||
},
|
||||
|
||||
globalSetOption(key, value) {
|
||||
CodeMirror.defaults[key] = value;
|
||||
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
|
||||
|
@ -52,28 +53,28 @@
|
|||
};
|
||||
|
||||
const handledPrefs = {
|
||||
// handled in colorpicker-helper.js
|
||||
'editor.colorpicker'() {},
|
||||
/** @returns {?Promise<void>} */
|
||||
'editor.theme'(key, value) {
|
||||
const elt = $('#cm-theme');
|
||||
'editor.colorpicker'() {}, // handled in colorpicker-helper.js
|
||||
async 'editor.theme'(key, value) {
|
||||
let el2;
|
||||
const el = $('#cm-theme');
|
||||
if (value === 'default') {
|
||||
elt.href = '';
|
||||
el.href = '';
|
||||
} else {
|
||||
const url = chrome.runtime.getURL(`vendor/codemirror/theme/${value}.css`);
|
||||
if (url !== elt.href) {
|
||||
const path = `/vendor/codemirror/theme/${value}.css`;
|
||||
if (el.href !== location.origin + path) {
|
||||
// avoid flicker: wait for the second stylesheet to load, then apply the theme
|
||||
return loadScript(url, true).then(([newElt]) => {
|
||||
cmFactory.globalSetOption('theme', value);
|
||||
elt.remove();
|
||||
newElt.id = elt.id;
|
||||
});
|
||||
el2 = await require([path]);
|
||||
}
|
||||
}
|
||||
cmFactory.globalSetOption('theme', value);
|
||||
if (el2) {
|
||||
el.remove();
|
||||
el2.id = el.id;
|
||||
}
|
||||
},
|
||||
};
|
||||
const pref2opt = k => k.slice('editor.'.length);
|
||||
const mirroredPrefs = Object.keys(prefs.defaults).filter(k =>
|
||||
const mirroredPrefs = prefs.knownKeys.filter(k =>
|
||||
!handledPrefs[k] &&
|
||||
k.startsWith('editor.') &&
|
||||
Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
|
||||
|
@ -125,12 +126,14 @@
|
|||
return lazyOpt._observer;
|
||||
},
|
||||
};
|
||||
})();
|
||||
//#endregion
|
||||
|
||||
//#region Commands
|
||||
(() => {
|
||||
//#endregion
|
||||
//#region Commands
|
||||
|
||||
Object.assign(CodeMirror.commands, {
|
||||
commentSelection(cm) {
|
||||
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
|
||||
},
|
||||
toggleEditorFocus(cm) {
|
||||
if (!cm) return;
|
||||
if (cm.hasFocus()) {
|
||||
|
@ -139,9 +142,6 @@
|
|||
cm.focus();
|
||||
}
|
||||
},
|
||||
commentSelection(cm) {
|
||||
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
|
||||
},
|
||||
});
|
||||
for (const cmd of [
|
||||
'nextEditor',
|
||||
|
@ -151,11 +151,10 @@
|
|||
]) {
|
||||
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
|
||||
}
|
||||
})();
|
||||
//#endregion
|
||||
|
||||
//#region CM option handlers
|
||||
(() => {
|
||||
//#endregion
|
||||
//#region CM option handlers
|
||||
|
||||
const {insertTab, insertSoftTab} = CodeMirror.commands;
|
||||
Object.entries({
|
||||
tabSize(cm, value) {
|
||||
|
@ -164,11 +163,6 @@
|
|||
indentWithTabs(cm, value) {
|
||||
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) {
|
||||
const showToken = value === 'token' && /[#.\-\w]/;
|
||||
const opt = (showToken || value === 'selection') && {
|
||||
|
@ -246,237 +240,12 @@
|
|||
};
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
//#region Bookmarks
|
||||
|
||||
function autocompletePicked(cm) {
|
||||
cm.state.autocompletePicked = true;
|
||||
}
|
||||
})();
|
||||
//#endregion
|
||||
|
||||
//#region Autocomplete
|
||||
(() => {
|
||||
const AT_RULES = [
|
||||
'@-moz-document',
|
||||
'@charset',
|
||||
'@font-face',
|
||||
'@import',
|
||||
'@keyframes',
|
||||
'@media',
|
||||
'@namespace',
|
||||
'@page',
|
||||
'@supports',
|
||||
'@viewport',
|
||||
];
|
||||
const USO_VAR = 'uso-variable';
|
||||
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
|
||||
const USO_INVALID_VAR = 'error ' + USO_VAR;
|
||||
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
|
||||
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
|
||||
const cssMime = CodeMirror.mimeModes['text/css'];
|
||||
const docFuncs = addSuffix(cssMime.documentTypes, '(');
|
||||
const {tokenHooks} = cssMime;
|
||||
const originalCommentHook = tokenHooks['/'];
|
||||
const originalHelper = CodeMirror.hint.css || (() => {});
|
||||
let cssProps, cssMedia;
|
||||
CodeMirror.registerHelper('hint', 'css', helper);
|
||||
CodeMirror.registerHelper('hint', 'stylus', helper);
|
||||
tokenHooks['/'] = tokenizeUsoVariables;
|
||||
|
||||
function helper(cm) {
|
||||
const pos = cm.getCursor();
|
||||
const {line, ch} = pos;
|
||||
const {styles, text} = cm.getLineHandle(line);
|
||||
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
|
||||
const isStylusLang = cm.doc.mode.name === 'stylus';
|
||||
const type = style && style.split(' ', 1)[0] || 'prop?';
|
||||
if (!type || type === 'comment' || type === 'string') {
|
||||
return originalHelper(cm);
|
||||
}
|
||||
// not using getTokenAt until the need is unavoidable because it reparses text
|
||||
// and runs a whole lot of complex calc inside which is slow on long lines
|
||||
// especially if autocomplete is auto-shown on each keystroke
|
||||
let prev, end, state;
|
||||
let i = index;
|
||||
while (
|
||||
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
|
||||
(prev = i > 2 ? styles[i - 2] : 0) &&
|
||||
isSameToken(text, style, prev)
|
||||
) i -= 2;
|
||||
i = index;
|
||||
while (
|
||||
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
|
||||
(end = styles[i]) &&
|
||||
isSameToken(text, style, end)
|
||||
) i += 2;
|
||||
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
|
||||
const str = text.slice(prev, end);
|
||||
const left = text.slice(prev, ch).trim();
|
||||
let leftLC = left.toLowerCase();
|
||||
let list = [];
|
||||
switch (leftLC[0]) {
|
||||
|
||||
case '!':
|
||||
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
|
||||
break;
|
||||
|
||||
case '@':
|
||||
list = AT_RULES;
|
||||
break;
|
||||
|
||||
case '#': // prevents autocomplete for #hex colors
|
||||
break;
|
||||
|
||||
case '-': // --variable
|
||||
case '(': // var(
|
||||
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
|
||||
? findAllCssVars(cm, left)
|
||||
: [];
|
||||
prev += str.startsWith('(');
|
||||
leftLC = left;
|
||||
break;
|
||||
|
||||
case '/': // USO vars
|
||||
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
|
||||
prev += 4;
|
||||
end -= 4;
|
||||
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
|
||||
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
|
||||
leftLC = left.slice(4);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'u': // url(), url-prefix()
|
||||
case 'd': // domain()
|
||||
case 'r': // regexp()
|
||||
if (/^(variable|tag|error)/.test(type) &&
|
||||
docFuncs.some(s => s.startsWith(leftLC)) &&
|
||||
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
|
||||
end++;
|
||||
list = docFuncs;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// properties and media features
|
||||
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
|
||||
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
|
||||
if (!cssProps) initCssProps();
|
||||
if (type === 'prop?') {
|
||||
prev += leftLC.length;
|
||||
leftLC = '';
|
||||
}
|
||||
list = state === 'atBlock_parens' ? cssMedia : cssProps;
|
||||
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
|
||||
end += execAt(rxCONSUME, end, text)[0].length;
|
||||
} else {
|
||||
return isStylusLang
|
||||
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
|
||||
: originalHelper(cm);
|
||||
}
|
||||
}
|
||||
return {
|
||||
list: (list || []).filter(s => s.startsWith(leftLC)),
|
||||
from: {line, ch: prev + str.match(/^\s*/)[0].length},
|
||||
to: {line, ch: end},
|
||||
};
|
||||
}
|
||||
|
||||
function initCssProps() {
|
||||
cssProps = addSuffix(cssMime.propertyKeywords).sort();
|
||||
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
|
||||
}
|
||||
|
||||
function addSuffix(obj, suffix = ': ') {
|
||||
return Object.keys(obj).map(k => k + suffix);
|
||||
}
|
||||
|
||||
function getMediaKeys([k, v]) {
|
||||
return k === 'mediaFeatures' && addSuffix(v) ||
|
||||
k.startsWith('media') && Object.keys(v);
|
||||
}
|
||||
|
||||
/** makes sure we don't process a different adjacent comment */
|
||||
function isSameToken(text, style, i) {
|
||||
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
|
||||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
|
||||
}
|
||||
|
||||
function findAllCssVars(cm, leftPart) {
|
||||
// simplified regex without CSS escapes
|
||||
const rx = new RegExp(
|
||||
'(?:^|[\\s/;{])(' +
|
||||
(leftPart.startsWith('--') ? leftPart : '--') +
|
||||
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
|
||||
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
|
||||
'g');
|
||||
const list = new Set();
|
||||
cm.eachLine(({text}) => {
|
||||
for (let m; (m = rx.exec(text));) {
|
||||
list.add(m[1]);
|
||||
}
|
||||
});
|
||||
return [...list].sort();
|
||||
}
|
||||
|
||||
function tokenizeUsoVariables(stream) {
|
||||
const token = originalCommentHook.apply(this, arguments);
|
||||
if (token[1] === 'comment') {
|
||||
const {string, start, pos} = stream;
|
||||
if (testAt(/\/\*\[\[/y, start, string) &&
|
||||
testAt(/]]\*\//y, pos - 4, string)) {
|
||||
const vars = (editor.style.usercssData || {}).vars;
|
||||
token[0] =
|
||||
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
|
||||
? USO_VALID_VAR
|
||||
: USO_INVALID_VAR;
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function execAt(rx, index, text) {
|
||||
rx.lastIndex = index;
|
||||
return rx.exec(text);
|
||||
}
|
||||
|
||||
function testAt(rx, index, text) {
|
||||
rx.lastIndex = Math.max(0, index);
|
||||
return rx.test(text);
|
||||
}
|
||||
})();
|
||||
//#endregion
|
||||
|
||||
//#region Bookmarks
|
||||
(() => {
|
||||
const CLS = 'gutter-bookmark';
|
||||
const BRAND = 'sublimeBookmark';
|
||||
const CLICK_AREA = 'CodeMirror-linenumbers';
|
||||
const BM_CLS = 'gutter-bookmark';
|
||||
const BM_BRAND = 'sublimeBookmark';
|
||||
const BM_CLICKER = 'CodeMirror-linenumbers';
|
||||
const {markText} = CodeMirror.prototype;
|
||||
for (const name of ['prevBookmark', 'nextBookmark']) {
|
||||
const cmdFn = CodeMirror.commands[name];
|
||||
|
@ -494,27 +263,29 @@
|
|||
Object.assign(CodeMirror.prototype, {
|
||||
markText() {
|
||||
const marker = markText.apply(this, arguments);
|
||||
if (marker[BRAND]) {
|
||||
this.doc.addLineClass(marker.lines[0], 'gutter', CLS);
|
||||
if (marker[BM_BRAND]) {
|
||||
this.doc.addLineClass(marker.lines[0], 'gutter', BM_CLS);
|
||||
marker.clear = clearMarker;
|
||||
}
|
||||
return marker;
|
||||
},
|
||||
});
|
||||
|
||||
function clearMarker() {
|
||||
const line = this.lines[0];
|
||||
const spans = line.markedSpans;
|
||||
delete this.clear; // removing our patch from the instance...
|
||||
this.clear(); // ...and using the original prototype
|
||||
if (!spans || spans.some(span => span.marker[BRAND])) {
|
||||
this.doc.removeLineClass(line, 'gutter', CLS);
|
||||
if (!spans || spans.some(span => span.marker[BM_BRAND])) {
|
||||
this.doc.removeLineClass(line, 'gutter', BM_CLS);
|
||||
}
|
||||
}
|
||||
|
||||
function onGutterClick(cm, line, name, e) {
|
||||
switch (name === CLICK_AREA && e.button) {
|
||||
switch (name === BM_CLICKER && e.button) {
|
||||
case 0: {
|
||||
// main button: toggle
|
||||
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BRAND]);
|
||||
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BM_BRAND]);
|
||||
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
|
||||
cm.execCommand('toggleBookmark');
|
||||
break;
|
||||
|
@ -525,11 +296,13 @@
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGutterContextMenu(cm, line, name, e) {
|
||||
if (name === CLICK_AREA) {
|
||||
if (name === BM_CLICKER) {
|
||||
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
})();
|
||||
//#endregion
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
onDOMready().then(() => {
|
||||
$('#colorpicker-settings').onclick = configureColorpicker;
|
||||
});
|
||||
prefs.subscribe('editor.colorpicker.hotkey', registerHotkey);
|
||||
prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true});
|
||||
|
||||
function setColorpickerOption(id, enabled) {
|
||||
const defaults = CodeMirror.defaults;
|
||||
const keyName = prefs.get('editor.colorpicker.hotkey');
|
||||
defaults.colorpicker = enabled;
|
||||
if (enabled) {
|
||||
if (keyName) {
|
||||
CodeMirror.commands.colorpicker = invokeColorpicker;
|
||||
defaults.extraKeys = defaults.extraKeys || {};
|
||||
defaults.extraKeys[keyName] = 'colorpicker';
|
||||
}
|
||||
defaults.colorpicker = {
|
||||
tooltip: t('colorpickerTooltip'),
|
||||
popup: {
|
||||
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
|
||||
paletteLine: t('numberedLine'),
|
||||
paletteHint: t('colorpickerPaletteHint'),
|
||||
hexUppercase: prefs.get('editor.colorpicker.hexUppercase'),
|
||||
embedderCallback: state => {
|
||||
['hexUppercase', 'color']
|
||||
.filter(name => state[name] !== prefs.get('editor.colorpicker.' + name))
|
||||
.forEach(name => prefs.set('editor.colorpicker.' + name, state[name]));
|
||||
},
|
||||
get maxHeight() {
|
||||
return prefs.get('editor.colorpicker.maxHeight');
|
||||
},
|
||||
set maxHeight(h) {
|
||||
prefs.set('editor.colorpicker.maxHeight', h);
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
if (defaults.extraKeys) {
|
||||
delete defaults.extraKeys[keyName];
|
||||
}
|
||||
}
|
||||
cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
|
||||
}
|
||||
|
||||
function registerHotkey(id, hotkey) {
|
||||
CodeMirror.commands.colorpicker = invokeColorpicker;
|
||||
const extraKeys = CodeMirror.defaults.extraKeys;
|
||||
for (const key in extraKeys) {
|
||||
if (extraKeys[key] === 'colorpicker') {
|
||||
delete extraKeys[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hotkey) {
|
||||
extraKeys[hotkey] = 'colorpicker';
|
||||
}
|
||||
}
|
||||
|
||||
function invokeColorpicker(cm) {
|
||||
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
|
||||
}
|
||||
|
||||
function configureColorpicker(event) {
|
||||
event.preventDefault();
|
||||
const input = createHotkeyInput('editor.colorpicker.hotkey', () => {
|
||||
$('#help-popup .dismiss').onclick();
|
||||
});
|
||||
const popup = showHelp(t('helpKeyMapHotkey'), input);
|
||||
if (this instanceof Element) {
|
||||
const bounds = this.getBoundingClientRect();
|
||||
popup.style.left = bounds.right + 10 + 'px';
|
||||
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
|
||||
popup.style.right = 'auto';
|
||||
}
|
||||
input.focus();
|
||||
}
|
||||
})();
|
966
edit/edit.js
966
edit/edit.js
File diff suppressed because it is too large
Load Diff
|
@ -1,91 +1,94 @@
|
|||
/* global importScripts workerUtil CSSLint require metaParser */
|
||||
/* global createWorkerApi */// worker-util.js
|
||||
'use strict';
|
||||
|
||||
importScripts('/js/worker-util.js');
|
||||
const {loadScript} = workerUtil;
|
||||
(() => {
|
||||
const {require} = self; // self.require will be overwritten by StyleLint
|
||||
|
||||
/** @namespace EditorWorker */
|
||||
workerUtil.createAPI({
|
||||
csslint: (code, config) => {
|
||||
loadScript('/vendor-overwrites/csslint/parserlib.js', '/vendor-overwrites/csslint/csslint.js');
|
||||
return CSSLint.verify(code, config).messages
|
||||
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
|
||||
},
|
||||
stylelint: async (code, config) => {
|
||||
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
|
||||
const {results: [res]} = await require('stylelint').lint({code, config});
|
||||
delete res._postcssResult; // huge and unused
|
||||
return res;
|
||||
},
|
||||
metalint: code => {
|
||||
loadScript(
|
||||
'/vendor/usercss-meta/usercss-meta.min.js',
|
||||
'/vendor-overwrites/colorpicker/colorconverter.js',
|
||||
'/js/meta-parser.js'
|
||||
);
|
||||
const result = metaParser.lint(code);
|
||||
// extract needed info
|
||||
result.errors = result.errors.map(err =>
|
||||
({
|
||||
/** @namespace EditorWorker */
|
||||
createWorkerApi({
|
||||
|
||||
async csslint(code, config) {
|
||||
require(['/js/csslint/parserlib', '/js/csslint/csslint']); /* global CSSLint */
|
||||
return CSSLint
|
||||
.verify(code, config).messages
|
||||
.map(m => Object.assign(m, {rule: {id: m.rule.id}}));
|
||||
},
|
||||
|
||||
getRules(linter) {
|
||||
return ruleRetriever[linter](); // eslint-disable-line no-use-before-define
|
||||
},
|
||||
|
||||
metalint(code) {
|
||||
require(['/js/meta-parser']); /* global metaParser */
|
||||
const result = metaParser.lint(code);
|
||||
// extract needed info
|
||||
result.errors = result.errors.map(err => ({
|
||||
code: err.code,
|
||||
args: err.args,
|
||||
message: err.message,
|
||||
index: err.index,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
},
|
||||
getStylelintRules,
|
||||
getCsslintRules,
|
||||
});
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
|
||||
function getCsslintRules() {
|
||||
loadScript('/vendor-overwrites/csslint/csslint.js');
|
||||
return CSSLint.getRules().map(rule => {
|
||||
const output = {};
|
||||
for (const [key, value] of Object.entries(rule)) {
|
||||
if (typeof value !== 'function') {
|
||||
output[key] = value;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
async stylelint(code, config) {
|
||||
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
|
||||
const {results: [res]} = await self.require('stylelint').lint({code, config});
|
||||
delete res._postcssResult; // huge and unused
|
||||
return res;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getStylelintRules() {
|
||||
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
|
||||
const stylelint = require('stylelint');
|
||||
const options = {};
|
||||
const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g;
|
||||
const rxString = /"([-\w\s]{3,}?)"/g;
|
||||
for (const id of Object.keys(stylelint.rules)) {
|
||||
const ruleCode = String(stylelint.rules[id]);
|
||||
const sets = [];
|
||||
let m, mStr;
|
||||
while ((m = rxPossible.exec(ruleCode))) {
|
||||
const possible = m[1];
|
||||
const set = [];
|
||||
while ((mStr = rxString.exec(possible))) {
|
||||
const s = mStr[1];
|
||||
if (s.includes(' ')) {
|
||||
set.push(...s.split(/\s+/));
|
||||
} else {
|
||||
set.push(s);
|
||||
const ruleRetriever = {
|
||||
|
||||
csslint() {
|
||||
require(['/js/csslint/csslint']);
|
||||
return CSSLint.getRules().map(rule => {
|
||||
const output = {};
|
||||
for (const [key, value] of Object.entries(rule)) {
|
||||
if (typeof value !== 'function') {
|
||||
output[key] = value;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
});
|
||||
},
|
||||
|
||||
stylelint() {
|
||||
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
|
||||
const options = {};
|
||||
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')) {
|
||||
set.push('ignoreAtRules');
|
||||
}
|
||||
if (possible.includes('ignoreShorthands')) {
|
||||
set.push('ignoreShorthands');
|
||||
}
|
||||
if (set.length) {
|
||||
sets.push(set);
|
||||
}
|
||||
}
|
||||
if (sets.length) {
|
||||
options[id] = sets;
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
return options;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
|
114
edit/embedded-popup.js
Normal file
114
edit/embedded-popup.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
/* global $ $create $remove getEventKeyName */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global baseInit */// base.js
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
const ID = 'popup-iframe';
|
||||
const SEL = '#' + ID;
|
||||
const URL = chrome.runtime.getManifest().browser_action.default_popup;
|
||||
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
|
||||
/** @type {HTMLIFrameElement} */
|
||||
let frame;
|
||||
let isLoaded;
|
||||
let scrollbarWidth;
|
||||
|
||||
const btn = $create('img', {
|
||||
id: 'popup-button',
|
||||
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
|
||||
onclick: embedPopup,
|
||||
});
|
||||
document.documentElement.appendChild(btn);
|
||||
baseInit.domReady.then(() => {
|
||||
document.body.appendChild(btn);
|
||||
// Adding a dummy command to show in keymap help popup
|
||||
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup';
|
||||
});
|
||||
|
||||
prefs.subscribe('iconset', (_, val) => {
|
||||
const prefix = `images/icon/${val ? 'light/' : ''}`;
|
||||
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
|
||||
}, {runNow: true});
|
||||
|
||||
window.on('keydown', e => {
|
||||
if (getEventKeyName(e) === POPUP_HOTKEY) {
|
||||
embedPopup();
|
||||
}
|
||||
});
|
||||
|
||||
function embedPopup() {
|
||||
if ($(SEL)) return;
|
||||
isLoaded = false;
|
||||
scrollbarWidth = 0;
|
||||
frame = $create('iframe', {
|
||||
id: ID,
|
||||
src: URL,
|
||||
height: 600,
|
||||
width: prefs.get('popupWidth'),
|
||||
onload: initFrame,
|
||||
});
|
||||
window.on('mousedown', removePopup);
|
||||
document.body.appendChild(frame);
|
||||
}
|
||||
|
||||
function initFrame() {
|
||||
frame = this;
|
||||
frame.focus();
|
||||
const pw = frame.contentWindow;
|
||||
const body = pw.document.body;
|
||||
pw.on('keydown', removePopupOnEsc);
|
||||
pw.close = removePopup;
|
||||
if (pw.IntersectionObserver) {
|
||||
new pw.IntersectionObserver(onIntersect).observe(body.appendChild(
|
||||
$create('div', {style: {height: '1px', marginTop: '-1px'}})
|
||||
));
|
||||
} else {
|
||||
frame.dataset.loaded = '';
|
||||
frame.height = body.scrollHeight;
|
||||
}
|
||||
new pw.MutationObserver(onMutation).observe(body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
}
|
||||
|
||||
function onMutation() {
|
||||
const body = frame.contentDocument.body;
|
||||
const bs = body.style;
|
||||
const w = parseFloat(bs.minWidth || bs.width) + (scrollbarWidth || 0);
|
||||
const h = parseFloat(bs.minHeight || body.offsetHeight);
|
||||
if (frame.width - w) frame.width = w;
|
||||
if (frame.height - h) frame.height = h;
|
||||
}
|
||||
|
||||
function onIntersect([e]) {
|
||||
const pw = frame.contentWindow;
|
||||
const el = pw.document.scrollingElement;
|
||||
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
|
||||
const hasSB = h > el.offsetHeight;
|
||||
const {width} = e.boundingClientRect;
|
||||
frame.height = h;
|
||||
if (!hasSB !== !scrollbarWidth || frame.width - width) {
|
||||
scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
|
||||
frame.width = width + scrollbarWidth;
|
||||
}
|
||||
if (!isLoaded) {
|
||||
isLoaded = true;
|
||||
frame.dataset.loaded = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removePopup() {
|
||||
frame = null;
|
||||
$remove(SEL);
|
||||
window.off('mousedown', removePopup);
|
||||
}
|
||||
|
||||
function removePopupOnEsc(e) {
|
||||
if (getEventKeyName(e) === 'Escape') {
|
||||
removePopup();
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -1,21 +1,14 @@
|
|||
/* global
|
||||
$
|
||||
$$
|
||||
$create
|
||||
chromeLocal
|
||||
CodeMirror
|
||||
colorMimicry
|
||||
debounce
|
||||
editor
|
||||
focusAccessibility
|
||||
onDOMready
|
||||
stringAsRegExp
|
||||
t
|
||||
tryRegExp
|
||||
*/
|
||||
/* global $ $$ $create $remove focusAccessibility */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global chromeLocal */// storage-util.js
|
||||
/* global colorMimicry */
|
||||
/* global debounce stringAsRegExp tryRegExp */// toolbox.js
|
||||
/* global editor */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
onDOMready().then(() => {
|
||||
(() => {
|
||||
require(['/edit/global-search.css']);
|
||||
|
||||
//region Constants and state
|
||||
|
||||
|
@ -138,13 +131,13 @@ onDOMready().then(() => {
|
|||
},
|
||||
onfocusout() {
|
||||
if (!state.dialog.contains(document.activeElement)) {
|
||||
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
|
||||
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
|
||||
state.dialog.on('focusin', EVENTS.onfocusin);
|
||||
state.dialog.off('focusout', EVENTS.onfocusout);
|
||||
}
|
||||
},
|
||||
onfocusin() {
|
||||
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
|
||||
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
|
||||
state.dialog.on('focusout', EVENTS.onfocusout);
|
||||
state.dialog.off('focusin', EVENTS.onfocusin);
|
||||
trimUndoHistory();
|
||||
enableUndoButton(state.undoHistory.length);
|
||||
if (state.find) doSearch({canAdvance: false});
|
||||
|
@ -189,7 +182,6 @@ onDOMready().then(() => {
|
|||
|
||||
Object.assign(CodeMirror.commands, COMMANDS);
|
||||
readStorage();
|
||||
return;
|
||||
|
||||
//region Find
|
||||
|
||||
|
@ -577,7 +569,7 @@ onDOMready().then(() => {
|
|||
|
||||
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
|
||||
Object.assign(dialog, DIALOG_PROPS.dialog);
|
||||
dialog.addEventListener('focusout', EVENTS.onfocusout);
|
||||
dialog.on('focusout', EVENTS.onfocusout);
|
||||
dialog.dataset.type = type;
|
||||
dialog.style.pointerEvents = 'auto';
|
||||
|
||||
|
@ -590,9 +582,9 @@ onDOMready().then(() => {
|
|||
state.tally = $('[data-type="tally"]', dialog);
|
||||
|
||||
const colors = {
|
||||
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
|
||||
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
|
||||
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
|
||||
body: colorMimicry(document.body, {bg: 'backgroundColor'}),
|
||||
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
|
||||
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
|
||||
};
|
||||
document.documentElement.appendChild(
|
||||
$(DIALOG_STYLE_SELECTOR) ||
|
||||
|
@ -652,7 +644,7 @@ onDOMready().then(() => {
|
|||
|
||||
function destroyDialog({restoreFocus = false} = {}) {
|
||||
state.input = null;
|
||||
$.remove(DIALOG_SELECTOR);
|
||||
$remove(DIALOG_SELECTOR);
|
||||
debounce.unregister(doSearch);
|
||||
makeTargetVisible(null);
|
||||
if (restoreFocus) {
|
||||
|
@ -795,7 +787,6 @@ onDOMready().then(() => {
|
|||
});
|
||||
if (!cm.curOp) cm.startOperation();
|
||||
if (!state.firstRun) {
|
||||
editor.scrollToEditor(cm);
|
||||
cm.jumpToPos(pos.from, pos.to);
|
||||
}
|
||||
// focus or expose as the current search target
|
||||
|
@ -960,4 +951,4 @@ onDOMready().then(() => {
|
|||
}
|
||||
|
||||
//endregion
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -1,197 +0,0 @@
|
|||
/* global memoize editorWorker showCodeMirrorPopup loadScript messageBox
|
||||
LINTER_DEFAULTS rerouteHotkeys $ $create $createLink tryJSONparse t
|
||||
chromeSync */
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#linter-settings').addEventListener('click', showLintConfig);
|
||||
}, {once: true});
|
||||
|
||||
function stringifyConfig(config) {
|
||||
return JSON.stringify(config, null, 2)
|
||||
.replace(/,\n\s+\{\n\s+("severity":\s"\w+")\n\s+\}/g, ', {$1}');
|
||||
}
|
||||
|
||||
function showLinterErrorMessage(title, contents, popup) {
|
||||
messageBox({
|
||||
title,
|
||||
contents,
|
||||
className: 'danger center lint-config',
|
||||
buttons: [t('confirmOK')],
|
||||
}).then(() => popup && popup.codebox && popup.codebox.focus());
|
||||
}
|
||||
|
||||
function showLintConfig() {
|
||||
const linter = $('#editor.linter').value;
|
||||
if (!linter) {
|
||||
return;
|
||||
}
|
||||
const storageName = chromeSync.LZ_KEY[linter];
|
||||
const getRules = memoize(linter === 'stylelint' ?
|
||||
editorWorker.getStylelintRules : editorWorker.getCsslintRules);
|
||||
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
|
||||
const defaultConfig = stringifyConfig(
|
||||
linter === 'stylelint' ? LINTER_DEFAULTS.STYLELINT : LINTER_DEFAULTS.CSSLINT
|
||||
);
|
||||
const title = t('linterConfigPopupTitle', linterTitle);
|
||||
const popup = showCodeMirrorPopup(title, null, {
|
||||
lint: false,
|
||||
extraKeys: {'Ctrl-Enter': save},
|
||||
hintOptions: {hint},
|
||||
});
|
||||
$('.contents', popup).appendChild(makeFooter());
|
||||
|
||||
let cm = popup.codebox;
|
||||
cm.focus();
|
||||
chromeSync.getLZValue(storageName).then(config => {
|
||||
cm.setValue(config ? stringifyConfig(config) : defaultConfig);
|
||||
cm.clearHistory();
|
||||
cm.markClean();
|
||||
updateButtonState();
|
||||
});
|
||||
cm.on('changes', updateButtonState);
|
||||
|
||||
rerouteHotkeys(false);
|
||||
window.addEventListener('closeHelp', () => {
|
||||
rerouteHotkeys(true);
|
||||
cm = null;
|
||||
}, {once: true});
|
||||
|
||||
loadScript([
|
||||
'/vendor/codemirror/mode/javascript/javascript.js',
|
||||
'/vendor/codemirror/addon/lint/json-lint.js',
|
||||
'/vendor/jsonlint/jsonlint.js',
|
||||
]).then(() => {
|
||||
cm.setOption('mode', 'application/json');
|
||||
cm.setOption('lint', true);
|
||||
});
|
||||
|
||||
function findInvalidRules(config, linter) {
|
||||
return getRules()
|
||||
.then(rules => {
|
||||
if (linter === 'stylelint') {
|
||||
return Object.keys(config.rules).filter(k => !config.rules.hasOwnProperty(k));
|
||||
}
|
||||
const ruleSet = new Set(rules.map(r => r.id));
|
||||
return Object.keys(config).filter(k => !ruleSet.has(k));
|
||||
});
|
||||
}
|
||||
|
||||
function makeFooter() {
|
||||
return $create('div', [
|
||||
$create('p', [
|
||||
$createLink(
|
||||
linter === 'stylelint'
|
||||
? 'https://stylelint.io/user-guide/rules/'
|
||||
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
|
||||
t('linterRulesLink')),
|
||||
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
|
||||
]),
|
||||
$create('.buttons', [
|
||||
$create('button.save', {onclick: save, title: 'Ctrl-Enter'}, t('styleSaveLabel')),
|
||||
$create('button.cancel', {onclick: cancel}, t('confirmClose')),
|
||||
$create('button.reset', {onclick: reset, title: t('linterResetMessage')}, t('genericResetLabel')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function save(event) {
|
||||
if (event instanceof Event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const json = tryJSONparse(cm.getValue());
|
||||
if (!json) {
|
||||
showLinterErrorMessage(linter, t('linterJSONError'), popup);
|
||||
cm.focus();
|
||||
return;
|
||||
}
|
||||
findInvalidRules(json, linter).then(invalid => {
|
||||
if (invalid.length) {
|
||||
showLinterErrorMessage(linter, [
|
||||
t('linterInvalidConfigError'),
|
||||
$create('ul', invalid.map(name => $create('li', name))),
|
||||
], popup);
|
||||
return;
|
||||
}
|
||||
chromeSync.setLZValue(storageName, json);
|
||||
cm.markClean();
|
||||
cm.focus();
|
||||
updateButtonState();
|
||||
});
|
||||
}
|
||||
|
||||
function reset(event) {
|
||||
event.preventDefault();
|
||||
cm.setValue(defaultConfig);
|
||||
cm.focus();
|
||||
updateButtonState();
|
||||
}
|
||||
|
||||
function cancel(event) {
|
||||
event.preventDefault();
|
||||
$('.dismiss').dispatchEvent(new Event('click'));
|
||||
}
|
||||
|
||||
function updateButtonState() {
|
||||
$('.save', popup).disabled = cm.isClean();
|
||||
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
|
||||
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
|
||||
}
|
||||
|
||||
function hint(cm) {
|
||||
return getRules().then(rules => {
|
||||
let ruleIds, options;
|
||||
if (linter === 'stylelint') {
|
||||
ruleIds = Object.keys(rules);
|
||||
options = rules;
|
||||
} else {
|
||||
ruleIds = rules.map(r => r.id);
|
||||
options = {};
|
||||
}
|
||||
const cursor = cm.getCursor();
|
||||
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
|
||||
const {line, ch} = cursor;
|
||||
|
||||
const quoted = string.startsWith('"');
|
||||
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
|
||||
const depth = getLexicalDepth(lexical);
|
||||
|
||||
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
|
||||
let [, prevWord] = search.find(true) || [];
|
||||
let words = [];
|
||||
|
||||
if (depth === 1 && linter === 'stylelint') {
|
||||
words = quoted ? ['rules'] : [];
|
||||
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
|
||||
words = ruleIds;
|
||||
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
|
||||
words = !quoted ? ['true', 'false', 'null'] :
|
||||
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
|
||||
} else if (depth === 4 && prevWord === 'severity') {
|
||||
words = ['error', 'warning'];
|
||||
} else if (depth === 4) {
|
||||
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
|
||||
} else if (depth === 5 && lexical.type === ']' && quoted) {
|
||||
while (prevWord && !ruleIds.includes(prevWord)) {
|
||||
prevWord = (search.find(true) || [])[1];
|
||||
}
|
||||
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
|
||||
}
|
||||
return {
|
||||
list: words.filter(word => word.startsWith(leftPart)),
|
||||
from: {line, ch: start + (quoted ? 1 : 0)},
|
||||
to: {line, ch: string.endsWith('"') ? end - 1 : end},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getLexicalDepth(lexicalState) {
|
||||
let depth = 0;
|
||||
while ((lexicalState = lexicalState.prev)) {
|
||||
depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -1,222 +0,0 @@
|
|||
/* exported LINTER_DEFAULTS */
|
||||
'use strict';
|
||||
|
||||
const LINTER_DEFAULTS = (() => {
|
||||
const SEVERITY = {severity: 'warning'};
|
||||
const STYLELINT = {
|
||||
// 'sugarss' is a indent-based syntax like Sass or Stylus
|
||||
// ref: https://github.com/postcss/postcss#syntaxes
|
||||
// syntax: 'sugarss',
|
||||
// ** recommended rules **
|
||||
// ref: https://github.com/stylelint/stylelint-config-recommended/blob/master/index.js
|
||||
rules: {
|
||||
'at-rule-no-unknown': [true, {
|
||||
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
|
||||
'severity': 'warning',
|
||||
}],
|
||||
'block-no-empty': [true, SEVERITY],
|
||||
'color-no-invalid-hex': [true, SEVERITY],
|
||||
'declaration-block-no-duplicate-properties': [true, {
|
||||
'ignore': ['consecutive-duplicates-with-different-values'],
|
||||
'severity': 'warning',
|
||||
}],
|
||||
'declaration-block-no-shorthand-property-overrides': [true, SEVERITY],
|
||||
'font-family-no-duplicate-names': [true, SEVERITY],
|
||||
'function-calc-no-unspaced-operator': [true, SEVERITY],
|
||||
'function-linear-gradient-no-nonstandard-direction': [true, SEVERITY],
|
||||
'keyframe-declaration-no-important': [true, SEVERITY],
|
||||
'media-feature-name-no-unknown': [true, SEVERITY],
|
||||
/* recommended true */
|
||||
'no-empty-source': false,
|
||||
'no-extra-semicolons': [true, SEVERITY],
|
||||
'no-invalid-double-slash-comments': [true, SEVERITY],
|
||||
'property-no-unknown': [true, SEVERITY],
|
||||
'selector-pseudo-class-no-unknown': [true, SEVERITY],
|
||||
'selector-pseudo-element-no-unknown': [true, SEVERITY],
|
||||
'selector-type-no-unknown': false, // for scss/less/stylus-lang
|
||||
'string-no-newline': [true, SEVERITY],
|
||||
'unit-no-unknown': [true, SEVERITY],
|
||||
|
||||
// ** non-essential rules
|
||||
'comment-no-empty': false,
|
||||
'declaration-block-no-redundant-longhand-properties': false,
|
||||
'shorthand-property-no-redundant-values': false,
|
||||
|
||||
// ** stylistic rules **
|
||||
/*
|
||||
'at-rule-empty-line-before': [
|
||||
'always',
|
||||
{
|
||||
'except': [
|
||||
'blockless-after-same-name-blockless',
|
||||
'first-nested'
|
||||
],
|
||||
'ignore': [
|
||||
'after-comment'
|
||||
]
|
||||
}
|
||||
],
|
||||
'at-rule-name-case': 'lower',
|
||||
'at-rule-name-space-after': 'always-single-line',
|
||||
'at-rule-semicolon-newline-after': 'always',
|
||||
'block-closing-brace-empty-line-before': 'never',
|
||||
'block-closing-brace-newline-after': 'always',
|
||||
'block-closing-brace-newline-before': 'always-multi-line',
|
||||
'block-closing-brace-space-before': 'always-single-line',
|
||||
'block-opening-brace-newline-after': 'always-multi-line',
|
||||
'block-opening-brace-space-after': 'always-single-line',
|
||||
'block-opening-brace-space-before': 'always',
|
||||
'color-hex-case': 'lower',
|
||||
'color-hex-length': 'short',
|
||||
'comment-empty-line-before': [
|
||||
'always',
|
||||
{
|
||||
'except': [
|
||||
'first-nested'
|
||||
],
|
||||
'ignore': [
|
||||
'stylelint-commands'
|
||||
]
|
||||
}
|
||||
],
|
||||
'comment-whitespace-inside': 'always',
|
||||
'custom-property-empty-line-before': [
|
||||
'always',
|
||||
{
|
||||
'except': [
|
||||
'after-custom-property',
|
||||
'first-nested'
|
||||
],
|
||||
'ignore': [
|
||||
'after-comment',
|
||||
'inside-single-line-block'
|
||||
]
|
||||
}
|
||||
],
|
||||
'declaration-bang-space-after': 'never',
|
||||
'declaration-bang-space-before': 'always',
|
||||
'declaration-block-semicolon-newline-after': 'always-multi-line',
|
||||
'declaration-block-semicolon-space-after': 'always-single-line',
|
||||
'declaration-block-semicolon-space-before': 'never',
|
||||
'declaration-block-single-line-max-declarations': 1,
|
||||
'declaration-block-trailing-semicolon': 'always',
|
||||
'declaration-colon-newline-after': 'always-multi-line',
|
||||
'declaration-colon-space-after': 'always-single-line',
|
||||
'declaration-colon-space-before': 'never',
|
||||
'declaration-empty-line-before': [
|
||||
'always',
|
||||
{
|
||||
'except': [
|
||||
'after-declaration',
|
||||
'first-nested'
|
||||
],
|
||||
'ignore': [
|
||||
'after-comment',
|
||||
'inside-single-line-block'
|
||||
]
|
||||
}
|
||||
],
|
||||
'function-comma-newline-after': 'always-multi-line',
|
||||
'function-comma-space-after': 'always-single-line',
|
||||
'function-comma-space-before': 'never',
|
||||
'function-max-empty-lines': 0,
|
||||
'function-name-case': 'lower',
|
||||
'function-parentheses-newline-inside': 'always-multi-line',
|
||||
'function-parentheses-space-inside': 'never-single-line',
|
||||
'function-whitespace-after': 'always',
|
||||
'indentation': 2,
|
||||
'length-zero-no-unit': true,
|
||||
'max-empty-lines': 1,
|
||||
'media-feature-colon-space-after': 'always',
|
||||
'media-feature-colon-space-before': 'never',
|
||||
'media-feature-name-case': 'lower',
|
||||
'media-feature-parentheses-space-inside': 'never',
|
||||
'media-feature-range-operator-space-after': 'always',
|
||||
'media-feature-range-operator-space-before': 'always',
|
||||
'media-query-list-comma-newline-after': 'always-multi-line',
|
||||
'media-query-list-comma-space-after': 'always-single-line',
|
||||
'media-query-list-comma-space-before': 'never',
|
||||
'no-eol-whitespace': true,
|
||||
'no-missing-end-of-source-newline': true,
|
||||
'number-leading-zero': 'always',
|
||||
'number-no-trailing-zeros': true,
|
||||
'property-case': 'lower',
|
||||
'rule-empty-line-before': [
|
||||
'always-multi-line',
|
||||
{
|
||||
'except': [
|
||||
'first-nested'
|
||||
],
|
||||
'ignore': [
|
||||
'after-comment'
|
||||
]
|
||||
}
|
||||
],
|
||||
'selector-attribute-brackets-space-inside': 'never',
|
||||
'selector-attribute-operator-space-after': 'never',
|
||||
'selector-attribute-operator-space-before': 'never',
|
||||
'selector-combinator-space-after': 'always',
|
||||
'selector-combinator-space-before': 'always',
|
||||
'selector-descendant-combinator-no-non-space': true,
|
||||
'selector-list-comma-newline-after': 'always',
|
||||
'selector-list-comma-space-before': 'never',
|
||||
'selector-max-empty-lines': 0,
|
||||
'selector-pseudo-class-case': 'lower',
|
||||
'selector-pseudo-class-parentheses-space-inside': 'never',
|
||||
'selector-pseudo-element-case': 'lower',
|
||||
'selector-pseudo-element-colon-notation': 'double',
|
||||
'selector-type-case': 'lower',
|
||||
'unit-case': 'lower',
|
||||
'value-list-comma-newline-after': 'always-multi-line',
|
||||
'value-list-comma-space-after': 'always-single-line',
|
||||
'value-list-comma-space-before': 'never',
|
||||
'value-list-max-empty-lines': 0
|
||||
*/
|
||||
},
|
||||
};
|
||||
const CSSLINT = {
|
||||
// Default warnings
|
||||
'display-property-grouping': 1,
|
||||
'duplicate-properties': 1,
|
||||
'empty-rules': 1,
|
||||
'errors': 1,
|
||||
'warnings': 1,
|
||||
'known-properties': 1,
|
||||
|
||||
// Default disabled
|
||||
'adjoining-classes': 0,
|
||||
'box-model': 0,
|
||||
'box-sizing': 0,
|
||||
'bulletproof-font-face': 0,
|
||||
'compatible-vendor-prefixes': 0,
|
||||
'duplicate-background-images': 0,
|
||||
'fallback-colors': 0,
|
||||
'floats': 0,
|
||||
'font-faces': 0,
|
||||
'font-sizes': 0,
|
||||
'gradients': 0,
|
||||
'ids': 0,
|
||||
'import': 0,
|
||||
'import-ie-limit': 0,
|
||||
'important': 0,
|
||||
'order-alphabetical': 0,
|
||||
'outline-none': 0,
|
||||
'overqualified-elements': 0,
|
||||
'qualified-headings': 0,
|
||||
'regex-selectors': 0,
|
||||
'rules-count': 0,
|
||||
'selector-max': 0,
|
||||
'selector-max-approaching': 0,
|
||||
'selector-newline': 0,
|
||||
'shorthand': 0,
|
||||
'star-property-hack': 0,
|
||||
'text-indent': 0,
|
||||
'underscore-property-hack': 0,
|
||||
'unique-headings': 0,
|
||||
'universal-selector': 0,
|
||||
'unqualified-attributes': 0,
|
||||
'vendor-prefix': 0,
|
||||
'zero-units': 0,
|
||||
};
|
||||
return {STYLELINT, CSSLINT, SEVERITY};
|
||||
})();
|
226
edit/linter-dialogs.js
Normal file
226
edit/linter-dialogs.js
Normal file
|
@ -0,0 +1,226 @@
|
|||
/* global $ $create $createLink messageBoxProxy */// dom.js
|
||||
/* global chromeSync */// storage-util.js
|
||||
/* global editor */
|
||||
/* global helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
|
||||
/* global linterMan */
|
||||
/* global t */// localization.js
|
||||
/* global tryJSONparse */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
/** @type {{csslint:{}, stylelint:{}}} */
|
||||
const RULES = {};
|
||||
let cm;
|
||||
let defaultConfig;
|
||||
let isStylelint;
|
||||
let linter;
|
||||
let popup;
|
||||
|
||||
linterMan.showLintConfig = async () => {
|
||||
linter = $('#editor.linter').value;
|
||||
if (!linter) {
|
||||
return;
|
||||
}
|
||||
if (!RULES[linter]) {
|
||||
linterMan.worker.getRules(linter).then(res => (RULES[linter] = res));
|
||||
}
|
||||
await require([
|
||||
'/vendor/codemirror/mode/javascript/javascript',
|
||||
'/vendor/codemirror/addon/lint/json-lint',
|
||||
'/vendor/jsonlint/jsonlint',
|
||||
]);
|
||||
const config = await chromeSync.getLZValue(chromeSync.LZ_KEY[linter]);
|
||||
const title = t('linterConfigPopupTitle', isStylelint ? 'Stylelint' : 'CSSLint');
|
||||
isStylelint = linter === 'stylelint';
|
||||
defaultConfig = stringifyConfig(linterMan.DEFAULTS[linter]);
|
||||
popup = showCodeMirrorPopup(title, null, {
|
||||
extraKeys: {'Ctrl-Enter': onConfigSave},
|
||||
hintOptions: {hint},
|
||||
lint: true,
|
||||
mode: 'application/json',
|
||||
value: config ? stringifyConfig(config) : defaultConfig,
|
||||
});
|
||||
$('.contents', popup).appendChild(
|
||||
$create('div', [
|
||||
$create('p', [
|
||||
$createLink(
|
||||
isStylelint
|
||||
? 'https://stylelint.io/user-guide/rules/'
|
||||
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
|
||||
t('linterRulesLink')),
|
||||
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
|
||||
]),
|
||||
$create('.buttons', [
|
||||
$create('button.save', {onclick: onConfigSave, title: 'Ctrl-Enter'},
|
||||
t('styleSaveLabel')),
|
||||
$create('button.cancel', {onclick: onConfigCancel}, t('confirmClose')),
|
||||
$create('button.reset', {onclick: onConfigReset, title: t('linterResetMessage')},
|
||||
t('genericResetLabel')),
|
||||
]),
|
||||
]));
|
||||
cm = popup.codebox;
|
||||
cm.focus();
|
||||
cm.on('changes', updateConfigButtons);
|
||||
updateConfigButtons();
|
||||
rerouteHotkeys(false);
|
||||
window.on('closeHelp', onConfigClose, {once: true});
|
||||
};
|
||||
|
||||
linterMan.showLintHelp = async () => {
|
||||
// FIXME: implement a linterChooser?
|
||||
const linter = $('#editor.linter').value;
|
||||
const baseUrl = linter === 'stylelint'
|
||||
? 'https://stylelint.io/user-guide/rules/'
|
||||
// some CSSLint rules do not have a url
|
||||
: 'https://github.com/CSSLint/csslint/issues/535';
|
||||
let headerLink, template;
|
||||
if (linter === 'csslint') {
|
||||
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
|
||||
template = ({rule: ruleID}) => {
|
||||
const rule = RULES.csslint.find(rule => rule.id === ruleID);
|
||||
return rule &&
|
||||
$create('li', [
|
||||
$create('b', $createLink(rule.url || baseUrl, rule.name)),
|
||||
$create('br'),
|
||||
rule.desc,
|
||||
]);
|
||||
};
|
||||
} else {
|
||||
headerLink = $createLink(baseUrl, 'stylelint');
|
||||
template = rule =>
|
||||
$create('li',
|
||||
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
|
||||
}
|
||||
const header = t('linterIssuesHelp', '\x01').split('\x01');
|
||||
const activeRules = new Set([...linterMan.getIssues()].map(issue => issue.rule));
|
||||
helpPopup.show(t('linterIssues'),
|
||||
$create([
|
||||
header[0], headerLink, header[1],
|
||||
$create('ul.rules', [...activeRules].map(template)),
|
||||
]));
|
||||
};
|
||||
|
||||
function getLexicalDepth(lexicalState) {
|
||||
let depth = 0;
|
||||
while ((lexicalState = lexicalState.prev)) {
|
||||
depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function hint(cm) {
|
||||
const rules = RULES[linter];
|
||||
let ruleIds, options;
|
||||
if (isStylelint) {
|
||||
ruleIds = Object.keys(rules);
|
||||
options = rules;
|
||||
} else {
|
||||
ruleIds = rules.map(r => r.id);
|
||||
options = {};
|
||||
}
|
||||
const cursor = cm.getCursor();
|
||||
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
|
||||
const {line, ch} = cursor;
|
||||
|
||||
const quoted = string.startsWith('"');
|
||||
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
|
||||
const depth = getLexicalDepth(lexical);
|
||||
|
||||
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
|
||||
let [, prevWord] = search.find(true) || [];
|
||||
let words = [];
|
||||
|
||||
if (depth === 1 && isStylelint) {
|
||||
words = quoted ? ['rules'] : [];
|
||||
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
|
||||
words = ruleIds;
|
||||
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
|
||||
words = !quoted ? ['true', 'false', 'null'] :
|
||||
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
|
||||
} else if (depth === 4 && prevWord === 'severity') {
|
||||
words = ['error', 'warning'];
|
||||
} else if (depth === 4) {
|
||||
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
|
||||
} else if (depth === 5 && lexical.type === ']' && quoted) {
|
||||
while (prevWord && !ruleIds.includes(prevWord)) {
|
||||
prevWord = (search.find(true) || [])[1];
|
||||
}
|
||||
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
|
||||
}
|
||||
return {
|
||||
list: words.filter(word => word.startsWith(leftPart)),
|
||||
from: {line, ch: start + (quoted ? 1 : 0)},
|
||||
to: {line, ch: string.endsWith('"') ? end - 1 : end},
|
||||
};
|
||||
}
|
||||
|
||||
function onConfigCancel() {
|
||||
helpPopup.close();
|
||||
editor.closestVisible().focus();
|
||||
}
|
||||
|
||||
function onConfigClose() {
|
||||
rerouteHotkeys(true);
|
||||
cm = null;
|
||||
}
|
||||
|
||||
function onConfigReset(event) {
|
||||
event.preventDefault();
|
||||
cm.setValue(defaultConfig);
|
||||
cm.focus();
|
||||
updateConfigButtons();
|
||||
}
|
||||
|
||||
async function onConfigSave(event) {
|
||||
if (event instanceof Event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const json = tryJSONparse(cm.getValue());
|
||||
if (!json) {
|
||||
showLinterErrorMessage(linter, t('linterJSONError'), popup);
|
||||
cm.focus();
|
||||
return;
|
||||
}
|
||||
let invalid;
|
||||
if (isStylelint) {
|
||||
invalid = Object.keys(json.rules).filter(k => !RULES.stylelint.hasOwnProperty(k));
|
||||
} else {
|
||||
const ids = RULES.csslint.map(r => r.id);
|
||||
invalid = Object.keys(json).filter(k => !ids.includes(k));
|
||||
}
|
||||
if (invalid.length) {
|
||||
showLinterErrorMessage(linter, [
|
||||
t('linterInvalidConfigError'),
|
||||
$create('ul', invalid.map(name => $create('li', name))),
|
||||
], popup);
|
||||
return;
|
||||
}
|
||||
chromeSync.setLZValue(chromeSync.LZ_KEY[linter], json);
|
||||
cm.markClean();
|
||||
cm.focus();
|
||||
updateConfigButtons();
|
||||
}
|
||||
|
||||
function stringifyConfig(config) {
|
||||
return JSON.stringify(config, null, 2)
|
||||
.replace(/,\n\s+{\n\s+("severity":\s"\w+")\n\s+}/g, ', {$1}');
|
||||
}
|
||||
|
||||
async function showLinterErrorMessage(title, contents, popup) {
|
||||
await messageBoxProxy.show({
|
||||
title,
|
||||
contents,
|
||||
className: 'danger center lint-config',
|
||||
buttons: [t('confirmOK')],
|
||||
});
|
||||
if (popup && popup.codebox) {
|
||||
popup.codebox.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function updateConfigButtons() {
|
||||
$('.save', popup).disabled = cm.isClean();
|
||||
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
|
||||
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
|
||||
}
|
||||
})();
|
|
@ -1,115 +0,0 @@
|
|||
/* global LINTER_DEFAULTS linter editorWorker prefs chromeSync */
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
registerLinters({
|
||||
csslint: {
|
||||
storageName: chromeSync.LZ_KEY.csslint,
|
||||
lint: csslint,
|
||||
validMode: mode => mode === 'css',
|
||||
getConfig: config => Object.assign({}, LINTER_DEFAULTS.CSSLINT, config),
|
||||
},
|
||||
stylelint: {
|
||||
storageName: chromeSync.LZ_KEY.stylelint,
|
||||
lint: stylelint,
|
||||
validMode: () => true,
|
||||
getConfig: config => ({
|
||||
syntax: 'sugarss',
|
||||
rules: Object.assign({}, LINTER_DEFAULTS.STYLELINT.rules, config && config.rules),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
async function stylelint(text, config, mode) {
|
||||
const raw = await editorWorker.stylelint(text, config);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
|
||||
// and we can't just pre-remove the comments since "//" may be inside a string token or whatever
|
||||
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
|
||||
const res = [];
|
||||
for (const w of raw.warnings) {
|
||||
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
|
||||
if (!slashCommentAllowed || !(
|
||||
w.rule === 'no-invalid-double-slash-comments' ||
|
||||
w.rule === 'property-no-unknown' && msg.includes('"//"')
|
||||
)) {
|
||||
res.push({
|
||||
from: {line: w.line - 1, ch: w.column - 1},
|
||||
to: {line: w.line - 1, ch: w.column},
|
||||
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
|
||||
severity: w.severity,
|
||||
rule: w.rule,
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function csslint(text, config) {
|
||||
return editorWorker.csslint(text, config)
|
||||
.then(results =>
|
||||
results
|
||||
.map(({line, col: ch, message, rule, type: severity}) => line && {
|
||||
message,
|
||||
from: {line: line - 1, ch: ch - 1},
|
||||
to: {line: line - 1, ch},
|
||||
rule: rule.id,
|
||||
severity,
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function registerLinters(engines) {
|
||||
const configs = new Map();
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'sync') {
|
||||
return;
|
||||
}
|
||||
for (const [name, engine] of Object.entries(engines)) {
|
||||
if (changes.hasOwnProperty(engine.storageName)) {
|
||||
chromeSync.getLZValue(engine.storageName)
|
||||
.then(config => {
|
||||
configs.set(name, engine.getConfig(config));
|
||||
linter.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
linter.register((text, options, cm) => {
|
||||
const selectedLinter = prefs.get('editor.linter');
|
||||
if (!selectedLinter) {
|
||||
return;
|
||||
}
|
||||
const mode = cm.getOption('mode');
|
||||
if (engines[selectedLinter].validMode(mode)) {
|
||||
return runLint(selectedLinter);
|
||||
}
|
||||
for (const [name, engine] of Object.entries(engines)) {
|
||||
if (engine.validMode(mode)) {
|
||||
return runLint(name);
|
||||
}
|
||||
}
|
||||
|
||||
function runLint(name) {
|
||||
return getConfig(name)
|
||||
.then(config => engines[name].lint(text, config, mode));
|
||||
}
|
||||
});
|
||||
|
||||
function getConfig(name) {
|
||||
if (configs.has(name)) {
|
||||
return Promise.resolve(configs.get(name));
|
||||
}
|
||||
return chromeSync.getLZValue(engines[name].storageName)
|
||||
.then(config => {
|
||||
configs.set(name, engines[name].getConfig(config));
|
||||
return configs.get(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -1,52 +0,0 @@
|
|||
/* global showHelp editorWorker memoize $ $create $createLink t */
|
||||
/* exported createLinterHelpDialog */
|
||||
'use strict';
|
||||
|
||||
function createLinterHelpDialog(getIssues) {
|
||||
let csslintRules;
|
||||
const prepareCsslintRules = memoize(() =>
|
||||
editorWorker.getCsslintRules()
|
||||
.then(rules => {
|
||||
csslintRules = rules;
|
||||
})
|
||||
);
|
||||
return {show};
|
||||
|
||||
function show() {
|
||||
// FIXME: implement a linterChooser?
|
||||
const linter = $('#editor.linter').value;
|
||||
const baseUrl = linter === 'stylelint'
|
||||
? 'https://stylelint.io/user-guide/rules/'
|
||||
// some CSSLint rules do not have a url
|
||||
: 'https://github.com/CSSLint/csslint/issues/535';
|
||||
let headerLink, template;
|
||||
if (linter === 'csslint') {
|
||||
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
|
||||
template = ({rule: ruleID}) => {
|
||||
const rule = csslintRules.find(rule => rule.id === ruleID);
|
||||
return rule &&
|
||||
$create('li', [
|
||||
$create('b', $createLink(rule.url || baseUrl, rule.name)),
|
||||
$create('br'),
|
||||
rule.desc,
|
||||
]);
|
||||
};
|
||||
} else {
|
||||
headerLink = $createLink(baseUrl, 'stylelint');
|
||||
template = rule =>
|
||||
$create('li',
|
||||
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
|
||||
}
|
||||
const header = t('linterIssuesHelp', '\x01').split('\x01');
|
||||
const activeRules = new Set([...getIssues()].map(issue => issue.rule));
|
||||
Promise.resolve(linter === 'csslint' && prepareCsslintRules())
|
||||
.then(() =>
|
||||
showHelp(t('linterIssues'),
|
||||
$create([
|
||||
header[0], headerLink, header[1],
|
||||
$create('ul.rules', [...activeRules].map(template)),
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
420
edit/linter-manager.js
Normal file
420
edit/linter-manager.js
Normal file
|
@ -0,0 +1,420 @@
|
|||
/* global $ $create */// dom.js
|
||||
/* global chromeSync */// storage-util.js
|
||||
/* global clipString */// util.js
|
||||
/* global createWorker */// worker-util.js
|
||||
/* global editor */
|
||||
/* global prefs */
|
||||
'use strict';
|
||||
|
||||
//#region linterMan
|
||||
|
||||
const linterMan = (() => {
|
||||
const cms = new Map();
|
||||
const linters = [];
|
||||
const lintingUpdatedListeners = [];
|
||||
const unhookListeners = [];
|
||||
return {
|
||||
|
||||
/** @type {EditorWorker} */
|
||||
worker: createWorker({url: '/edit/editor-worker'}),
|
||||
|
||||
disableForEditor(cm) {
|
||||
cm.setOption('lint', false);
|
||||
cms.delete(cm);
|
||||
for (const cb of unhookListeners) {
|
||||
cb(cm);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} cm
|
||||
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
|
||||
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
|
||||
* update when lint gutter is added to a lot of editors simultaneously.
|
||||
*/
|
||||
enableForEditor(cm, code) {
|
||||
if (cms.has(cm)) return;
|
||||
cms.set(cm, null);
|
||||
if (code) {
|
||||
enableOnProblems(cm, code);
|
||||
} else {
|
||||
cm.setOption('lint', {getAnnotations, onUpdateLinting});
|
||||
}
|
||||
},
|
||||
|
||||
onLintingUpdated(fn) {
|
||||
lintingUpdatedListeners.push(fn);
|
||||
},
|
||||
|
||||
onUnhook(fn) {
|
||||
unhookListeners.push(fn);
|
||||
},
|
||||
|
||||
register(fn) {
|
||||
linters.push(fn);
|
||||
},
|
||||
|
||||
run() {
|
||||
for (const cm of cms.keys()) {
|
||||
cm.performLint();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function enableOnProblems(cm, code) {
|
||||
const results = await getAnnotations(code, {}, cm);
|
||||
if (results.length || cm.display.renderedView) {
|
||||
cms.set(cm, results);
|
||||
cm.setOption('lint', {getAnnotations: getCachedAnnotations, onUpdateLinting});
|
||||
} else {
|
||||
cms.delete(cm);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAnnotations(...args) {
|
||||
const results = await Promise.all(linters.map(fn => fn(...args)));
|
||||
return [].concat(...results.filter(Boolean));
|
||||
}
|
||||
|
||||
function getCachedAnnotations(code, opt, cm) {
|
||||
const results = cms.get(cm);
|
||||
cms.set(cm, null);
|
||||
cm.options.lint.getAnnotations = getAnnotations;
|
||||
return results;
|
||||
}
|
||||
|
||||
function onUpdateLinting(...args) {
|
||||
for (const fn of lintingUpdatedListeners) {
|
||||
fn(...args);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
||||
//#region DEFAULTS
|
||||
|
||||
linterMan.DEFAULTS = {
|
||||
stylelint: {
|
||||
rules: {
|
||||
'at-rule-no-unknown': [true, {
|
||||
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
|
||||
'severity': 'warning',
|
||||
}],
|
||||
'block-no-empty': [true, {severity: 'warning'}],
|
||||
'color-no-invalid-hex': [true, {severity: 'warning'}],
|
||||
'declaration-block-no-duplicate-properties': [true, {
|
||||
'ignore': ['consecutive-duplicates-with-different-values'],
|
||||
'severity': 'warning',
|
||||
}],
|
||||
'declaration-block-no-shorthand-property-overrides': [true, {severity: 'warning'}],
|
||||
'font-family-no-duplicate-names': [true, {severity: 'warning'}],
|
||||
'function-calc-no-unspaced-operator': [true, {severity: 'warning'}],
|
||||
'function-linear-gradient-no-nonstandard-direction': [true, {severity: 'warning'}],
|
||||
'keyframe-declaration-no-important': [true, {severity: 'warning'}],
|
||||
'media-feature-name-no-unknown': [true, {severity: 'warning'}],
|
||||
'no-empty-source': false,
|
||||
'no-extra-semicolons': [true, {severity: 'warning'}],
|
||||
'no-invalid-double-slash-comments': [true, {severity: 'warning'}],
|
||||
'property-no-unknown': [true, {severity: 'warning'}],
|
||||
'selector-pseudo-class-no-unknown': [true, {severity: 'warning'}],
|
||||
'selector-pseudo-element-no-unknown': [true, {severity: 'warning'}],
|
||||
'selector-type-no-unknown': false, // for scss/less/stylus-lang
|
||||
'string-no-newline': [true, {severity: 'warning'}],
|
||||
'unit-no-unknown': [true, {severity: 'warning'}],
|
||||
'comment-no-empty': false,
|
||||
'declaration-block-no-redundant-longhand-properties': false,
|
||||
'shorthand-property-no-redundant-values': false,
|
||||
},
|
||||
},
|
||||
csslint: {
|
||||
'display-property-grouping': 1,
|
||||
'duplicate-properties': 1,
|
||||
'empty-rules': 1,
|
||||
'errors': 1,
|
||||
'known-properties': 1,
|
||||
'selector-newline': 1,
|
||||
'simple-not': 1,
|
||||
'warnings': 1,
|
||||
// disabled
|
||||
'adjoining-classes': 0,
|
||||
'box-model': 0,
|
||||
'box-sizing': 0,
|
||||
'bulletproof-font-face': 0,
|
||||
'compatible-vendor-prefixes': 0,
|
||||
'duplicate-background-images': 0,
|
||||
'fallback-colors': 0,
|
||||
'floats': 0,
|
||||
'font-faces': 0,
|
||||
'font-sizes': 0,
|
||||
'gradients': 0,
|
||||
'ids': 0,
|
||||
'import': 0,
|
||||
'import-ie-limit': 0,
|
||||
'important': 0,
|
||||
'order-alphabetical': 0,
|
||||
'outline-none': 0,
|
||||
'overqualified-elements': 0,
|
||||
'qualified-headings': 0,
|
||||
'regex-selectors': 0,
|
||||
'rules-count': 0,
|
||||
'selector-max': 0,
|
||||
'selector-max-approaching': 0,
|
||||
'shorthand': 0,
|
||||
'star-property-hack': 0,
|
||||
'text-indent': 0,
|
||||
'underscore-property-hack': 0,
|
||||
'unique-headings': 0,
|
||||
'universal-selector': 0,
|
||||
'unqualified-attributes': 0,
|
||||
'vendor-prefix': 0,
|
||||
'zero-units': 0,
|
||||
},
|
||||
};
|
||||
|
||||
//#endregion
|
||||
//#region ENGINES
|
||||
|
||||
(() => {
|
||||
const configs = new Map();
|
||||
const {DEFAULTS, worker} = linterMan;
|
||||
const ENGINES = {
|
||||
csslint: {
|
||||
validMode: mode => mode === 'css',
|
||||
getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
|
||||
async lint(text, config) {
|
||||
const results = await worker.csslint(text, config);
|
||||
return results
|
||||
.map(({line, col: ch, message, rule, type: severity}) => line && {
|
||||
message,
|
||||
from: {line: line - 1, ch: ch - 1},
|
||||
to: {line: line - 1, ch},
|
||||
rule: rule.id,
|
||||
severity,
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
},
|
||||
stylelint: {
|
||||
validMode: () => true,
|
||||
getConfig: config => ({
|
||||
syntax: 'sugarss',
|
||||
rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
|
||||
}),
|
||||
async lint(text, config, mode) {
|
||||
const raw = await worker.stylelint(text, config);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
|
||||
// and we can't just pre-remove the comments since "//" may be inside a string token
|
||||
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
|
||||
const res = [];
|
||||
for (const w of raw.warnings) {
|
||||
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
|
||||
if (!slashCommentAllowed || !(
|
||||
w.rule === 'no-invalid-double-slash-comments' ||
|
||||
w.rule === 'property-no-unknown' && msg.includes('"//"')
|
||||
)) {
|
||||
res.push({
|
||||
from: {line: w.line - 1, ch: w.column - 1},
|
||||
to: {line: w.line - 1, ch: w.column},
|
||||
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
|
||||
severity: w.severity,
|
||||
rule: w.rule,
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
linterMan.register(async (text, _options, cm) => {
|
||||
const linter = prefs.get('editor.linter');
|
||||
if (linter) {
|
||||
const {mode} = cm.options;
|
||||
const currentFirst = Object.entries(ENGINES).sort(([a]) => a === linter ? -1 : 1);
|
||||
for (const [name, engine] of currentFirst) {
|
||||
if (engine.validMode(mode)) {
|
||||
const cfg = configs.get(name) || await getConfig(name);
|
||||
return ENGINES[name].lint(text, cfg, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(changes => {
|
||||
for (const name of Object.keys(ENGINES)) {
|
||||
if (chromeSync.LZ_KEY[name] in changes) {
|
||||
getConfig(name).then(linterMan.run);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function getConfig(name) {
|
||||
const rawCfg = await chromeSync.getLZValue(chromeSync.LZ_KEY[name]);
|
||||
const cfg = ENGINES[name].getConfig(rawCfg);
|
||||
configs.set(name, cfg);
|
||||
return cfg;
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
||||
//#region Reports
|
||||
|
||||
(() => {
|
||||
const tables = new Map();
|
||||
|
||||
linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
|
||||
let table = tables.get(cm);
|
||||
if (!table) {
|
||||
table = createTable(cm);
|
||||
tables.set(cm, table);
|
||||
const container = $('.lint-report-container');
|
||||
const nextSibling = findNextSibling(tables, cm);
|
||||
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
|
||||
}
|
||||
table.updateCaption();
|
||||
table.updateAnnotations(annotations);
|
||||
updateCount();
|
||||
});
|
||||
|
||||
linterMan.onUnhook(cm => {
|
||||
const table = tables.get(cm);
|
||||
if (table) {
|
||||
table.element.remove();
|
||||
tables.delete(cm);
|
||||
}
|
||||
updateCount();
|
||||
});
|
||||
|
||||
Object.assign(linterMan, {
|
||||
|
||||
getIssues() {
|
||||
const issues = new Set();
|
||||
for (const table of tables.values()) {
|
||||
for (const tr of table.trs) {
|
||||
issues.add(tr.getAnnotation());
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
|
||||
refreshReport() {
|
||||
for (const table of tables.values()) {
|
||||
table.updateCaption();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function updateCount() {
|
||||
const issueCount = Array.from(tables.values())
|
||||
.reduce((sum, table) => sum + table.trs.length, 0);
|
||||
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
|
||||
$('#issue-count').textContent = issueCount;
|
||||
}
|
||||
|
||||
function findNextSibling(tables, cm) {
|
||||
const editors = editor.getEditors();
|
||||
let i = editors.indexOf(cm) + 1;
|
||||
while (i < editors.length) {
|
||||
if (tables.has(editors[i])) {
|
||||
return editors[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function createTable(cm) {
|
||||
const caption = $create('caption');
|
||||
const tbody = $create('tbody');
|
||||
const table = $create('table', [caption, tbody]);
|
||||
const trs = [];
|
||||
return {
|
||||
element: table,
|
||||
trs,
|
||||
updateAnnotations,
|
||||
updateCaption,
|
||||
};
|
||||
|
||||
function updateCaption() {
|
||||
caption.textContent = editor.getEditorTitle(cm);
|
||||
}
|
||||
|
||||
function updateAnnotations(lines) {
|
||||
let i = 0;
|
||||
for (const anno of getAnnotations()) {
|
||||
let tr;
|
||||
if (i < trs.length) {
|
||||
tr = trs[i];
|
||||
} else {
|
||||
tr = createTr();
|
||||
trs.push(tr);
|
||||
tbody.append(tr.element);
|
||||
}
|
||||
tr.update(anno);
|
||||
i++;
|
||||
}
|
||||
if (i === 0) {
|
||||
trs.length = 0;
|
||||
tbody.textContent = '';
|
||||
} else {
|
||||
while (trs.length > i) {
|
||||
trs.pop().element.remove();
|
||||
}
|
||||
}
|
||||
table.classList.toggle('empty', trs.length === 0);
|
||||
|
||||
function *getAnnotations() {
|
||||
for (const line of lines.filter(Boolean)) {
|
||||
yield *line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTr() {
|
||||
let anno;
|
||||
const severityIcon = $create('div');
|
||||
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
|
||||
const line = $create('td', {attributes: {role: 'line'}});
|
||||
const col = $create('td', {attributes: {role: 'col'}});
|
||||
const message = $create('td', {attributes: {role: 'message'}});
|
||||
|
||||
const trElement = $create('tr', {
|
||||
onclick: () => gotoLintIssue(cm, anno),
|
||||
}, [
|
||||
severity,
|
||||
line,
|
||||
$create('td', {attributes: {role: 'sep'}}, ':'),
|
||||
col,
|
||||
message,
|
||||
]);
|
||||
return {
|
||||
element: trElement,
|
||||
update,
|
||||
getAnnotation: () => anno,
|
||||
};
|
||||
|
||||
function update(_anno) {
|
||||
anno = _anno;
|
||||
trElement.className = anno.severity;
|
||||
severity.dataset.rule = anno.rule;
|
||||
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
|
||||
severityIcon.textContent = anno.severity;
|
||||
line.textContent = anno.from.line + 1;
|
||||
col.textContent = anno.from.ch + 1;
|
||||
message.title = clipString(anno.message, 1000) +
|
||||
(anno.rule ? `\n(${anno.rule})` : '');
|
||||
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gotoLintIssue(cm, anno) {
|
||||
editor.scrollToEditor(cm);
|
||||
cm.focus();
|
||||
cm.jumpToPos(anno.from);
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
|
@ -1,44 +0,0 @@
|
|||
/* global linter editorWorker */
|
||||
/* exported createMetaCompiler */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {CodeMirror} cm
|
||||
* @param {function(meta:Object)} onUpdated
|
||||
*/
|
||||
function createMetaCompiler(cm, onUpdated) {
|
||||
let meta = null;
|
||||
let metaIndex = null;
|
||||
let cache = [];
|
||||
|
||||
linter.register((text, options, _cm) => {
|
||||
if (_cm !== cm) {
|
||||
return;
|
||||
}
|
||||
const match = text.match(/\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i);
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
if (match[0] === meta && match.index === metaIndex) {
|
||||
return cache;
|
||||
}
|
||||
return editorWorker.metalint(match[0])
|
||||
.then(({metadata, errors}) => {
|
||||
if (errors.every(err => err.code === 'unknownMeta')) {
|
||||
onUpdated(metadata);
|
||||
}
|
||||
cache = errors.map(err =>
|
||||
({
|
||||
from: cm.posFromIndex((err.index || 0) + match.index),
|
||||
to: cm.posFromIndex((err.index || 0) + match.index),
|
||||
message: err.code && chrome.i18n.getMessage(`meta_${err.code}`, err.args) || err.message,
|
||||
severity: err.code === 'unknownMeta' ? 'warning' : 'error',
|
||||
rule: err.code,
|
||||
})
|
||||
);
|
||||
meta = match[0];
|
||||
metaIndex = match.index;
|
||||
return cache;
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,161 +0,0 @@
|
|||
/* global linter editor clipString createLinterHelpDialog $ $create */
|
||||
'use strict';
|
||||
|
||||
Object.assign(linter, (() => {
|
||||
const tables = new Map();
|
||||
const helpDialog = createLinterHelpDialog(getIssues);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#lint-help').addEventListener('click', helpDialog.show);
|
||||
}, {once: true});
|
||||
|
||||
linter.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
|
||||
let table = tables.get(cm);
|
||||
if (!table) {
|
||||
table = createTable(cm);
|
||||
tables.set(cm, table);
|
||||
const container = $('.lint-report-container');
|
||||
const nextSibling = findNextSibling(tables, cm);
|
||||
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
|
||||
}
|
||||
table.updateCaption();
|
||||
table.updateAnnotations(annotations);
|
||||
updateCount();
|
||||
});
|
||||
|
||||
linter.onUnhook(cm => {
|
||||
const table = tables.get(cm);
|
||||
if (table) {
|
||||
table.element.remove();
|
||||
tables.delete(cm);
|
||||
}
|
||||
updateCount();
|
||||
});
|
||||
|
||||
return {refreshReport};
|
||||
|
||||
function updateCount() {
|
||||
const issueCount = Array.from(tables.values())
|
||||
.reduce((sum, table) => sum + table.trs.length, 0);
|
||||
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
|
||||
$('#issue-count').textContent = issueCount;
|
||||
}
|
||||
|
||||
function getIssues() {
|
||||
const issues = new Set();
|
||||
for (const table of tables.values()) {
|
||||
for (const tr of table.trs) {
|
||||
issues.add(tr.getAnnotation());
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function findNextSibling(tables, cm) {
|
||||
const editors = editor.getEditors();
|
||||
let i = editors.indexOf(cm) + 1;
|
||||
while (i < editors.length) {
|
||||
if (tables.has(editors[i])) {
|
||||
return editors[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshReport() {
|
||||
for (const table of tables.values()) {
|
||||
table.updateCaption();
|
||||
}
|
||||
}
|
||||
|
||||
function createTable(cm) {
|
||||
const caption = $create('caption');
|
||||
const tbody = $create('tbody');
|
||||
const table = $create('table', [caption, tbody]);
|
||||
const trs = [];
|
||||
return {
|
||||
element: table,
|
||||
trs,
|
||||
updateAnnotations,
|
||||
updateCaption,
|
||||
};
|
||||
|
||||
function updateCaption() {
|
||||
caption.textContent = editor.getEditorTitle(cm);
|
||||
}
|
||||
|
||||
function updateAnnotations(lines) {
|
||||
let i = 0;
|
||||
for (const anno of getAnnotations()) {
|
||||
let tr;
|
||||
if (i < trs.length) {
|
||||
tr = trs[i];
|
||||
} else {
|
||||
tr = createTr();
|
||||
trs.push(tr);
|
||||
tbody.append(tr.element);
|
||||
}
|
||||
tr.update(anno);
|
||||
i++;
|
||||
}
|
||||
if (i === 0) {
|
||||
trs.length = 0;
|
||||
tbody.textContent = '';
|
||||
} else {
|
||||
while (trs.length > i) {
|
||||
trs.pop().element.remove();
|
||||
}
|
||||
}
|
||||
table.classList.toggle('empty', trs.length === 0);
|
||||
|
||||
function *getAnnotations() {
|
||||
for (const line of lines.filter(Boolean)) {
|
||||
yield *line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTr() {
|
||||
let anno;
|
||||
const severityIcon = $create('div');
|
||||
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
|
||||
const line = $create('td', {attributes: {role: 'line'}});
|
||||
const col = $create('td', {attributes: {role: 'col'}});
|
||||
const message = $create('td', {attributes: {role: 'message'}});
|
||||
|
||||
const trElement = $create('tr', {
|
||||
onclick: () => gotoLintIssue(cm, anno),
|
||||
}, [
|
||||
severity,
|
||||
line,
|
||||
$create('td', {attributes: {role: 'sep'}}, ':'),
|
||||
col,
|
||||
message,
|
||||
]);
|
||||
return {
|
||||
element: trElement,
|
||||
update,
|
||||
getAnnotation: () => anno,
|
||||
};
|
||||
|
||||
function update(_anno) {
|
||||
anno = _anno;
|
||||
trElement.className = anno.severity;
|
||||
severity.dataset.rule = anno.rule;
|
||||
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
|
||||
severityIcon.textContent = anno.severity;
|
||||
line.textContent = anno.from.line + 1;
|
||||
col.textContent = anno.from.ch + 1;
|
||||
message.title = clipString(anno.message, 1000) +
|
||||
(anno.rule ? `\n(${anno.rule})` : '');
|
||||
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gotoLintIssue(cm, anno) {
|
||||
editor.scrollToEditor(cm);
|
||||
cm.focus();
|
||||
cm.jumpToPos(anno.from);
|
||||
}
|
||||
})());
|
|
@ -1,77 +0,0 @@
|
|||
/* global workerUtil */
|
||||
'use strict';
|
||||
|
||||
/* exported editorWorker */
|
||||
/** @type {EditorWorker} */
|
||||
const editorWorker = workerUtil.createWorker({
|
||||
url: '/edit/editor-worker.js',
|
||||
});
|
||||
|
||||
/* exported linter */
|
||||
const linter = (() => {
|
||||
const lintingUpdatedListeners = [];
|
||||
const unhookListeners = [];
|
||||
const linters = [];
|
||||
const cms = new Set();
|
||||
|
||||
return {
|
||||
disableForEditor(cm) {
|
||||
cm.setOption('lint', false);
|
||||
cms.delete(cm);
|
||||
for (const cb of unhookListeners) {
|
||||
cb(cm);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} cm
|
||||
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
|
||||
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
|
||||
* update when lint gutter is added to a lot of editors simultaneously.
|
||||
*/
|
||||
enableForEditor(cm, code) {
|
||||
if (cms.has(cm)) return;
|
||||
if (code) return enableOnProblems(cm, code);
|
||||
cm.setOption('lint', {getAnnotations, onUpdateLinting});
|
||||
cms.add(cm);
|
||||
},
|
||||
onLintingUpdated(cb) {
|
||||
lintingUpdatedListeners.push(cb);
|
||||
},
|
||||
onUnhook(cb) {
|
||||
unhookListeners.push(cb);
|
||||
},
|
||||
register(linterFn) {
|
||||
linters.push(linterFn);
|
||||
},
|
||||
run() {
|
||||
for (const cm of cms) {
|
||||
cm.performLint();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function enableOnProblems(cm, code) {
|
||||
const results = await getAnnotations(code, {}, cm);
|
||||
if (results.length) {
|
||||
cms.add(cm);
|
||||
cm.setOption('lint', {
|
||||
getAnnotations() {
|
||||
cm.options.lint.getAnnotations = getAnnotations;
|
||||
return results;
|
||||
},
|
||||
onUpdateLinting,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAnnotations(...args) {
|
||||
const results = await Promise.all(linters.map(fn => fn(...args)));
|
||||
return [].concat(...results.filter(Boolean));
|
||||
}
|
||||
|
||||
function onUpdateLinting(...args) {
|
||||
for (const cb of lintingUpdatedListeners) {
|
||||
cb(...args);
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -1,74 +0,0 @@
|
|||
/* global messageBox editor $ prefs */
|
||||
/* exported createLivePreview */
|
||||
'use strict';
|
||||
|
||||
function createLivePreview(preprocess, shouldShow) {
|
||||
let data;
|
||||
let previewer;
|
||||
let enabled = prefs.get('editor.livePreview');
|
||||
const label = $('#preview-label');
|
||||
const errorContainer = $('#preview-errors');
|
||||
|
||||
prefs.subscribe(['editor.livePreview'], (key, value) => {
|
||||
if (value && data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
|
||||
previewer = createPreviewer();
|
||||
previewer.update(data);
|
||||
}
|
||||
if (!value && previewer) {
|
||||
previewer.disconnect();
|
||||
previewer = null;
|
||||
}
|
||||
enabled = value;
|
||||
});
|
||||
if (shouldShow != null) show(shouldShow);
|
||||
return {update, show};
|
||||
|
||||
function show(state) {
|
||||
label.classList.toggle('hidden', !state);
|
||||
}
|
||||
|
||||
function update(_data) {
|
||||
data = _data;
|
||||
if (!previewer) {
|
||||
if (!data.id || !data.enabled || !enabled) {
|
||||
return;
|
||||
}
|
||||
previewer = createPreviewer();
|
||||
}
|
||||
previewer.update(data);
|
||||
}
|
||||
|
||||
function createPreviewer() {
|
||||
const port = chrome.runtime.connect({
|
||||
name: 'livePreview',
|
||||
});
|
||||
port.onDisconnect.addListener(err => {
|
||||
throw err;
|
||||
});
|
||||
return {update, disconnect};
|
||||
|
||||
function update(data) {
|
||||
Promise.resolve()
|
||||
.then(() => preprocess ? preprocess(data) : data)
|
||||
.then(data => port.postMessage(data))
|
||||
.then(
|
||||
() => errorContainer.classList.add('hidden'),
|
||||
err => {
|
||||
if (Array.isArray(err)) {
|
||||
err = err.join('\n');
|
||||
} else if (err && err.index !== undefined) {
|
||||
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
|
||||
const pos = editor.getEditors()[0].posFromIndex(err.index);
|
||||
err.message = `${pos.line}:${pos.ch} ${err.message || String(err)}`;
|
||||
}
|
||||
errorContainer.classList.remove('hidden');
|
||||
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
port.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,6 @@
|
|||
/* global
|
||||
CodeMirror
|
||||
debounce
|
||||
deepEqual
|
||||
trimCommentLabel
|
||||
*/
|
||||
/* global CodeMirror */
|
||||
/* global debounce deepEqual */// toolbox.js
|
||||
/* global trimCommentLabel */// util.js
|
||||
'use strict';
|
||||
|
||||
/* exported MozSectionFinder */
|
||||
|
@ -26,7 +23,7 @@ function MozSectionFinder(cm) {
|
|||
/** @type {CodeMirror.Pos} */
|
||||
let updTo;
|
||||
|
||||
const MozSectionFinder = {
|
||||
const finder = {
|
||||
IGNORE_ORIGIN: KEY,
|
||||
EQ_SKIP_KEYS: [
|
||||
'mark',
|
||||
|
@ -45,10 +42,11 @@ function MozSectionFinder(cm) {
|
|||
const NOP = () => 0;
|
||||
data = {fn: NOP};
|
||||
keptAlive.set(id, data);
|
||||
MozSectionFinder.on(NOP);
|
||||
finder.on(NOP);
|
||||
}
|
||||
data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
|
||||
},
|
||||
|
||||
on(fn) {
|
||||
const {listeners} = getState();
|
||||
const needsInit = !listeners.size;
|
||||
|
@ -58,6 +56,7 @@ function MozSectionFinder(cm) {
|
|||
update();
|
||||
}
|
||||
},
|
||||
|
||||
off(fn) {
|
||||
const {listeners, sections} = getState();
|
||||
if (listeners.size) {
|
||||
|
@ -69,15 +68,16 @@ function MozSectionFinder(cm) {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
onOff(fn, enable) {
|
||||
MozSectionFinder[enable ? 'on' : 'off'](fn);
|
||||
finder[enable ? 'on' : 'off'](fn);
|
||||
},
|
||||
|
||||
/** @param {MozSection} [section] */
|
||||
updatePositions(section) {
|
||||
(section ? [section] : getState().sections).forEach(setPositionFromMark);
|
||||
},
|
||||
};
|
||||
return MozSectionFinder;
|
||||
|
||||
/** @returns {MozSectionCmState} */
|
||||
function getState() {
|
||||
|
@ -97,7 +97,7 @@ function MozSectionFinder(cm) {
|
|||
if (!updFrom) updFrom = {line: Infinity, ch: 0};
|
||||
if (!updTo) updTo = {line: -1, ch: 0};
|
||||
for (const c of changes) {
|
||||
if (c.origin !== MozSectionFinder.IGNORE_ORIGIN) {
|
||||
if (c.origin !== finder.IGNORE_ORIGIN) {
|
||||
updFrom = minPos(c.from, updFrom);
|
||||
updTo = maxPos(CodeMirror.changeEnd(c), updTo);
|
||||
}
|
||||
|
@ -387,11 +387,13 @@ function MozSectionFinder(cm) {
|
|||
|
||||
/** @this {MozSectionFunc[]} new functions */
|
||||
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
|
||||
* @property {number} line
|
||||
* @property {number} ch
|
||||
*/
|
||||
/** @typedef CodeMirror.Pos
|
||||
* @property {number} line
|
||||
* @property {number} ch
|
||||
*/
|
||||
|
||||
return finder;
|
||||
}
|
||||
|
|
|
@ -1,24 +1,16 @@
|
|||
/* global
|
||||
$
|
||||
$create
|
||||
CodeMirror
|
||||
colorMimicry
|
||||
messageBox
|
||||
MozSectionFinder
|
||||
msg
|
||||
prefs
|
||||
regExpTester
|
||||
t
|
||||
tryCatch
|
||||
*/
|
||||
/* global $ $create messageBoxProxy */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global MozSectionFinder */
|
||||
/* global colorMimicry */
|
||||
/* global editor */
|
||||
/* global msg */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
/* global tryCatch */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
/* exported MozSectionWidget */
|
||||
function MozSectionWidget(
|
||||
cm,
|
||||
finder = MozSectionFinder(cm),
|
||||
onDirectChange = () => 0
|
||||
) {
|
||||
function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
|
||||
let TPL, EVENTS, CLICK_ROUTE;
|
||||
const KEY = 'MozSectionWidget';
|
||||
const C_CONTAINER = '.applies-to';
|
||||
|
@ -36,7 +28,9 @@ function MozSectionWidget(
|
|||
const {cmpPos} = CodeMirror;
|
||||
let enabled = false;
|
||||
let funcHeight = 0;
|
||||
/** @type {HTMLStyleElement} */
|
||||
let actualStyle;
|
||||
|
||||
return {
|
||||
toggle(enable) {
|
||||
if (Boolean(enable) !== enabled) {
|
||||
|
@ -71,7 +65,7 @@ function MozSectionWidget(
|
|||
'.remove-applies-to'(elItem, func) {
|
||||
const funcs = getFuncsFor(elItem);
|
||||
if (funcs.length < 2) {
|
||||
messageBox({
|
||||
messageBoxProxy.show({
|
||||
contents: t('appliesRemoveError'),
|
||||
buttons: [t('confirmClose')],
|
||||
});
|
||||
|
@ -110,7 +104,7 @@ function MozSectionWidget(
|
|||
if (part === 'value' && func === getFuncsFor(el)[0]) {
|
||||
const sec = getSectionFor(el);
|
||||
sec.tocEntry.target = el.value;
|
||||
if (!sec.tocEntry.label) onDirectChange([sec]);
|
||||
if (!sec.tocEntry.label) editor.updateToc([sec]);
|
||||
}
|
||||
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_DIFF = .4;
|
||||
const color = {
|
||||
wrapper: colorMimicry.get(cm.display.wrapper),
|
||||
gutter: colorMimicry.get(cm.display.gutters, {
|
||||
wrapper: colorMimicry(cm.display.wrapper),
|
||||
gutter: colorMimicry(cm.display.gutters, {
|
||||
bg: 'backgroundColor',
|
||||
border: 'borderRightColor',
|
||||
}),
|
||||
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
|
||||
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
|
||||
line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
|
||||
comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
|
||||
};
|
||||
const hasBorder =
|
||||
color.gutter.style.borderRightWidth !== '0px' &&
|
||||
|
@ -421,10 +415,12 @@ function MozSectionWidget(
|
|||
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');
|
||||
regExpTester.toggle(true);
|
||||
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
|
||||
regexpTester.toggle(true);
|
||||
regexpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
|
||||
}
|
||||
|
||||
function fromDoubleslash(s) {
|
||||
|
|
|
@ -1,80 +1,57 @@
|
|||
/* global
|
||||
$
|
||||
$create
|
||||
openURL
|
||||
showHelp
|
||||
t
|
||||
tryRegExp
|
||||
URLS
|
||||
*/
|
||||
/* exported regExpTester */
|
||||
/* global $create */// dom.js
|
||||
/* global URLS openURL tryRegExp */// toolbox.js
|
||||
/* global helpPopup */// util.js
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
const regExpTester = (() => {
|
||||
const regexpTester = (() => {
|
||||
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
|
||||
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
|
||||
const cachedRegexps = new Map();
|
||||
let currentRegexps = [];
|
||||
let isInit = false;
|
||||
let isWatching = false;
|
||||
let isShown = false;
|
||||
|
||||
function init() {
|
||||
isInit = true;
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdate);
|
||||
}
|
||||
window.on('closeHelp', () => regexpTester.toggle(false));
|
||||
|
||||
function uninit() {
|
||||
chrome.tabs.onUpdated.removeListener(onTabUpdate);
|
||||
isInit = false;
|
||||
}
|
||||
return {
|
||||
|
||||
function onTabUpdate(tabId, info) {
|
||||
if (info.url) {
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
function isShown() {
|
||||
return Boolean($('.regexp-report'));
|
||||
}
|
||||
|
||||
function toggle(state = !isShown()) {
|
||||
if (state && !isShown()) {
|
||||
if (!isInit) {
|
||||
init();
|
||||
toggle(state = !isShown) {
|
||||
if (state && !isShown) {
|
||||
if (!isWatching) {
|
||||
isWatching = true;
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdate);
|
||||
}
|
||||
helpPopup.show('', $create('.regexp-report'));
|
||||
isShown = true;
|
||||
} else if (!state && isShown) {
|
||||
unwatch();
|
||||
helpPopup.close();
|
||||
isShown = false;
|
||||
}
|
||||
showHelp('', $create('.regexp-report'));
|
||||
} else if (!state && isShown()) {
|
||||
if (isInit) {
|
||||
uninit();
|
||||
}
|
||||
// TODO: need a closeHelp function
|
||||
$('#help-popup .dismiss').onclick();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
function update(newRegexps) {
|
||||
if (!isShown()) {
|
||||
if (isInit) {
|
||||
uninit();
|
||||
async update(newRegexps) {
|
||||
if (!isShown) {
|
||||
unwatch();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (newRegexps) {
|
||||
currentRegexps = newRegexps;
|
||||
}
|
||||
const regexps = currentRegexps.map(text => {
|
||||
const rxData = Object.assign({text}, cachedRegexps.get(text));
|
||||
if (!rxData.urls) {
|
||||
cachedRegexps.set(text, Object.assign(rxData, {
|
||||
// imitate buggy Stylish-for-chrome
|
||||
rx: tryRegExp('^' + text + '$'),
|
||||
urls: new Map(),
|
||||
}));
|
||||
if (newRegexps) {
|
||||
currentRegexps = newRegexps;
|
||||
}
|
||||
return rxData;
|
||||
});
|
||||
const getMatchInfo = m => m && {text: m[0], pos: m.index};
|
||||
browser.tabs.query({}).then(tabs => {
|
||||
const regexps = currentRegexps.map(text => {
|
||||
const rxData = Object.assign({text}, cachedRegexps.get(text));
|
||||
if (!rxData.urls) {
|
||||
cachedRegexps.set(text, Object.assign(rxData, {
|
||||
// imitate buggy Stylish-for-chrome
|
||||
rx: tryRegExp('^' + text + '$'),
|
||||
urls: new Map(),
|
||||
}));
|
||||
}
|
||||
return rxData;
|
||||
});
|
||||
const getMatchInfo = m => m && {text: m[0], pos: m.index};
|
||||
const tabs = await browser.tabs.query({});
|
||||
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
|
||||
const unique = [...new Set(supported).values()];
|
||||
for (const rxData of regexps) {
|
||||
|
@ -92,10 +69,12 @@ const regExpTester = (() => {
|
|||
}
|
||||
const stats = {
|
||||
full: {data: [], label: t('styleRegexpTestFull')},
|
||||
partial: {data: [], label: [
|
||||
t('styleRegexpTestPartial'),
|
||||
t.template.regexpTestPartial.cloneNode(true),
|
||||
]},
|
||||
partial: {
|
||||
data: [], label: [
|
||||
t('styleRegexpTestPartial'),
|
||||
t.template.regexpTestPartial.cloneNode(true),
|
||||
],
|
||||
},
|
||||
none: {data: [], label: t('styleRegexpTestNone')},
|
||||
invalid: {data: [], label: t('styleRegexpTestInvalid')},
|
||||
};
|
||||
|
@ -167,7 +146,7 @@ const regExpTester = (() => {
|
|||
}
|
||||
}
|
||||
}
|
||||
showHelp(t('styleRegexpTestTitle'), report);
|
||||
helpPopup.show(t('styleRegexpTestTitle'), report);
|
||||
report.onclick = onClick;
|
||||
|
||||
const note = $create('p.regexp-report-note',
|
||||
|
@ -176,25 +155,36 @@ const regExpTester = (() => {
|
|||
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
|
||||
report.appendChild(note);
|
||||
adjustNote(report, note);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function onClick(event) {
|
||||
const a = event.target.closest('a');
|
||||
if (a) {
|
||||
event.preventDefault();
|
||||
openURL({
|
||||
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
|
||||
currentWindow: null,
|
||||
});
|
||||
} else if (event.target.closest('details')) {
|
||||
setTimeout(adjustNote);
|
||||
}
|
||||
}
|
||||
function adjustNote(report, note) {
|
||||
report.style.paddingBottom = note.offsetHeight + 'px';
|
||||
}
|
||||
|
||||
function adjustNote(report, note) {
|
||||
report.style.paddingBottom = note.offsetHeight + 'px';
|
||||
function onClick(event) {
|
||||
const a = event.target.closest('a');
|
||||
if (a) {
|
||||
event.preventDefault();
|
||||
openURL({
|
||||
url: a.href && a.getAttribute('href') !== '#' && a.href || a.textContent,
|
||||
currentWindow: null,
|
||||
});
|
||||
} else if (event.target.closest('details')) {
|
||||
setTimeout(adjustNote);
|
||||
}
|
||||
}
|
||||
|
||||
return {toggle, update};
|
||||
function onTabUpdate(tabId, info) {
|
||||
if (info.url) {
|
||||
regexpTester.update();
|
||||
}
|
||||
}
|
||||
|
||||
function unwatch() {
|
||||
if (isWatching) {
|
||||
chrome.tabs.onUpdated.removeListener(onTabUpdate);
|
||||
isWatching = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
/* global CodeMirror editor debounce */
|
||||
/* exported rerouteHotkeys */
|
||||
'use strict';
|
||||
|
||||
const rerouteHotkeys = (() => {
|
||||
// reroute handling to nearest editor when keypress resolves to one of these commands
|
||||
const REROUTED = new Set([
|
||||
'save',
|
||||
'toggleStyle',
|
||||
'jumpToLine',
|
||||
'nextEditor', 'prevEditor',
|
||||
'toggleEditorFocus',
|
||||
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
|
||||
'colorpicker',
|
||||
'beautify',
|
||||
]);
|
||||
|
||||
return rerouteHotkeys;
|
||||
|
||||
// note that this function relies on `editor`. Calling this function before
|
||||
// the editor is initialized may throw an error.
|
||||
function rerouteHotkeys(enable, immediately) {
|
||||
if (!immediately) {
|
||||
debounce(rerouteHotkeys, 0, enable, true);
|
||||
} else if (enable) {
|
||||
document.addEventListener('keydown', rerouteHandler);
|
||||
} else {
|
||||
document.removeEventListener('keydown', rerouteHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function rerouteHandler(event) {
|
||||
const keyName = CodeMirror.keyName(event);
|
||||
if (!keyName) {
|
||||
return;
|
||||
}
|
||||
const rerouteCommand = name => {
|
||||
if (REROUTED.has(name)) {
|
||||
CodeMirror.commands[name](editor.closestVisible(event.target));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
|
||||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -1,21 +1,15 @@
|
|||
/* global
|
||||
$
|
||||
cmFactory
|
||||
debounce
|
||||
DocFuncMapper
|
||||
editor
|
||||
initBeautifyButton
|
||||
linter
|
||||
prefs
|
||||
regExpTester
|
||||
t
|
||||
trimCommentLabel
|
||||
tryRegExp
|
||||
*/
|
||||
/* global $ */// dom.js
|
||||
/* global MozDocMapper trimCommentLabel */// util.js
|
||||
/* global cmFactory */
|
||||
/* global debounce tryRegExp */// toolbox.js
|
||||
/* global editor */
|
||||
/* global initBeautifyButton */// beautify.js
|
||||
/* global linterMan */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
/* exported createSection */
|
||||
|
||||
/**
|
||||
* @param {StyleSection} originalSection
|
||||
* @param {function():number} genId
|
||||
|
@ -43,7 +37,7 @@ function createSection(originalSection, genId, si) {
|
|||
|
||||
const appliesToContainer = $('.applies-to-list', el);
|
||||
const appliesTo = [];
|
||||
DocFuncMapper.forEachProp(originalSection, (type, value) =>
|
||||
MozDocMapper.forEachProp(originalSection, (type, value) =>
|
||||
insertApplyAfter({type, value}));
|
||||
if (!appliesTo.length) {
|
||||
insertApplyAfter({all: true});
|
||||
|
@ -64,10 +58,10 @@ function createSection(originalSection, genId, si) {
|
|||
appliesTo,
|
||||
getModel() {
|
||||
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() {
|
||||
linter.disableForEditor(cm);
|
||||
linterMan.disableForEditor(cm);
|
||||
el.classList.add('removed');
|
||||
removed = true;
|
||||
appliesTo.forEach(a => a.remove());
|
||||
|
@ -79,7 +73,7 @@ function createSection(originalSection, genId, si) {
|
|||
cmFactory.destroy(cm);
|
||||
},
|
||||
restore() {
|
||||
linter.enableForEditor(cm);
|
||||
linterMan.enableForEditor(cm);
|
||||
el.classList.remove('removed');
|
||||
removed = false;
|
||||
appliesTo.forEach(a => a.restore());
|
||||
|
@ -102,7 +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;
|
||||
|
||||
|
@ -120,11 +114,8 @@ function createSection(originalSection, genId, si) {
|
|||
emitSectionChange('code');
|
||||
});
|
||||
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
|
||||
$('.test-regexp', el).onclick = () => {
|
||||
regExpTester.toggle();
|
||||
updateRegexpTester();
|
||||
};
|
||||
initBeautifyButton($('.beautify-section', el), () => [cm]);
|
||||
$('.test-regexp', el).onclick = () => updateRegexpTester(true);
|
||||
initBeautifyButton($('.beautify-section', el), [cm]);
|
||||
}
|
||||
|
||||
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')
|
||||
.map(a => a.value);
|
||||
if (regexps.length) {
|
||||
el.classList.add('has-regexp');
|
||||
regExpTester.update(regexps);
|
||||
if (isLoaded) regexpTester.update(regexps);
|
||||
} else {
|
||||
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) {
|
||||
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
|
||||
el.onOff(val, 'focusin', updateTocFocus);
|
||||
(val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
|
||||
if (val) {
|
||||
updateTocEntry();
|
||||
if (el.contains(document.activeElement)) {
|
||||
|
|
|
@ -1,45 +1,30 @@
|
|||
/* global
|
||||
$
|
||||
$$
|
||||
$create
|
||||
API
|
||||
clipString
|
||||
CodeMirror
|
||||
createLivePreview
|
||||
createSection
|
||||
debounce
|
||||
editor
|
||||
FIREFOX
|
||||
ignoreChromeError
|
||||
linter
|
||||
messageBox
|
||||
prefs
|
||||
rerouteHotkeys
|
||||
sectionsToMozFormat
|
||||
sessionStore
|
||||
showCodeMirrorPopup
|
||||
showHelp
|
||||
t
|
||||
*/
|
||||
/* global $ $$ $create $remove messageBoxProxy */// dom.js
|
||||
/* global API */// msg.js
|
||||
/* global CodeMirror */
|
||||
/* global FIREFOX URLS debounce ignoreChromeError sessionStore */// toolbox.js
|
||||
/* global MozDocMapper clipString helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
|
||||
/* global createSection */// sections-editor-section.js
|
||||
/* global editor */
|
||||
/* global linterMan */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
/* exported SectionsEditor */
|
||||
|
||||
function SectionsEditor() {
|
||||
const {style, dirty} = editor;
|
||||
const {style, /** @type DirtyReporter */dirty} = editor;
|
||||
const container = $('#sections');
|
||||
/** @type {EditorSection[]} */
|
||||
const sections = [];
|
||||
const xo = window.IntersectionObserver &&
|
||||
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
|
||||
const livePreview = createLivePreview(null, style.id);
|
||||
|
||||
let INC_ID = 0; // an increment id that is used by various object to track the order
|
||||
let sectionOrder = '';
|
||||
let headerOffset; // in compact mode the header is at the top so it reduces the available height
|
||||
|
||||
container.classList.add('section-editor');
|
||||
updateHeader();
|
||||
editor.livePreview.init(null, style.id);
|
||||
container.classList.add('section-editor');
|
||||
$('#to-mozilla').on('click', showMozillaFormat);
|
||||
$('#to-mozilla-help').on('click', showToMozillaHelp);
|
||||
$('#from-mozilla').on('click', () => showMozillaFormatImport());
|
||||
|
@ -50,7 +35,7 @@ function SectionsEditor() {
|
|||
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
|
||||
}
|
||||
|
||||
/** @namespace SectionsEditor */
|
||||
/** @namespace Editor */
|
||||
Object.assign(editor, {
|
||||
|
||||
sections,
|
||||
|
@ -95,7 +80,7 @@ function SectionsEditor() {
|
|||
dirty.clear('name');
|
||||
// FIXME: avoid recreating all editors?
|
||||
if (codeIsUpdated !== false) {
|
||||
await initSections(newStyle.sections, {replace: true, pristine: true});
|
||||
await initSections(newStyle.sections, {replace: true});
|
||||
}
|
||||
Object.assign(style, newStyle);
|
||||
updateHeader();
|
||||
|
@ -105,7 +90,7 @@ function SectionsEditor() {
|
|||
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
|
||||
$('#heading').textContent = t('editStyleHeading');
|
||||
}
|
||||
livePreview.show(Boolean(style.id));
|
||||
editor.livePreview.toggle(Boolean(style.id));
|
||||
updateLivePreview();
|
||||
},
|
||||
|
||||
|
@ -117,29 +102,24 @@ function SectionsEditor() {
|
|||
if (!validate(newStyle)) {
|
||||
return;
|
||||
}
|
||||
newStyle = await API.editSave(newStyle);
|
||||
newStyle = await API.styles.editSave(newStyle);
|
||||
destroyRemovedSections();
|
||||
sessionStore.justEditedStyleId = newStyle.id;
|
||||
editor.replaceStyle(newStyle, false);
|
||||
},
|
||||
|
||||
scrollToEditor(cm) {
|
||||
const section = sections.find(s => s.cm === cm).el;
|
||||
const bounds = section.getBoundingClientRect();
|
||||
if (
|
||||
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
|
||||
(bounds.top < 0 && bounds.bottom < window.innerHeight)
|
||||
) {
|
||||
if (bounds.top < 0) {
|
||||
window.scrollBy(0, bounds.top - 1);
|
||||
} else {
|
||||
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
|
||||
}
|
||||
const {el} = sections.find(s => s.cm === cm);
|
||||
const r = el.getBoundingClientRect();
|
||||
const h = window.innerHeight;
|
||||
if (r.bottom > h && r.top > 0 ||
|
||||
r.bottom < h && r.top < 0) {
|
||||
window.scrollBy(0, (r.top + r.bottom - h) / 2 | 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
editor.ready = initSections(style.sections, {pristine: true});
|
||||
editor.ready = initSections(style.sections);
|
||||
|
||||
/** @param {EditorSection} section */
|
||||
function fitToContent(section) {
|
||||
|
@ -156,7 +136,7 @@ function SectionsEditor() {
|
|||
return;
|
||||
}
|
||||
if (headerOffset == null) {
|
||||
headerOffset = container.getBoundingClientRect().top;
|
||||
headerOffset = container.getBoundingClientRect().top + scrollY | 0;
|
||||
}
|
||||
contentHeight += 9; // border & resize grip
|
||||
cm.off('update', resize);
|
||||
|
@ -194,13 +174,13 @@ function SectionsEditor() {
|
|||
progressElement.title = progress + '%';
|
||||
});
|
||||
} else {
|
||||
$.remove(progressElement);
|
||||
$remove(progressElement);
|
||||
}
|
||||
}
|
||||
|
||||
function showToMozillaHelp(event) {
|
||||
event.preventDefault();
|
||||
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
|
||||
helpPopup.show(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -336,7 +316,7 @@ function SectionsEditor() {
|
|||
|
||||
function showMozillaFormat() {
|
||||
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
|
||||
popup.codebox.setValue(sectionsToMozFormat(getModel()));
|
||||
popup.codebox.setValue(MozDocMapper.styleToCss(getModel()));
|
||||
popup.codebox.execCommand('selectAll');
|
||||
}
|
||||
|
||||
|
@ -378,22 +358,21 @@ function SectionsEditor() {
|
|||
lockPageUI(true);
|
||||
try {
|
||||
const code = popup.codebox.getValue().trim();
|
||||
if (!/==userstyle==/i.test(code) ||
|
||||
if (!URLS.rxMETA.test(code) ||
|
||||
!await getPreprocessor(code) ||
|
||||
await messageBox.confirm(
|
||||
await messageBoxProxy.confirm(
|
||||
t('importPreprocessor'), 'pre-line',
|
||||
t('importPreprocessorTitle'))
|
||||
) {
|
||||
const {sections, errors} = await API.parseCss({code});
|
||||
// shouldn't happen but just in case
|
||||
if (!sections.length || errors.length) {
|
||||
throw errors;
|
||||
const {sections, errors} = await API.worker.parseMozFormat({code});
|
||||
if (!sections.length || errors.some(e => !e.recoverable)) {
|
||||
await Promise.reject(errors);
|
||||
}
|
||||
await initSections(sections, {
|
||||
replace: replaceOldStyle,
|
||||
focusOn: replaceOldStyle ? 0 : false,
|
||||
});
|
||||
$('.dismiss').dispatchEvent(new Event('click'));
|
||||
helpPopup.close();
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
|
@ -403,7 +382,7 @@ function SectionsEditor() {
|
|||
|
||||
async function getPreprocessor(code) {
|
||||
try {
|
||||
return (await API.buildUsercssMeta({sourceCode: code})).usercssData.preprocessor;
|
||||
return (await API.usercss.buildMeta({sourceCode: code})).usercssData.preprocessor;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
@ -417,10 +396,12 @@ function SectionsEditor() {
|
|||
}
|
||||
|
||||
function showError(errors) {
|
||||
messageBox({
|
||||
messageBoxProxy.show({
|
||||
className: 'center danger',
|
||||
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')],
|
||||
});
|
||||
}
|
||||
|
@ -432,7 +413,7 @@ function SectionsEditor() {
|
|||
sectionOrder = validSections.map(s => s.id).join(',');
|
||||
dirty.modify('sectionOrder', oldOrder, sectionOrder);
|
||||
container.dataset.sectionCount = validSections.length;
|
||||
linter.refreshReport();
|
||||
linterMan.refreshReport();
|
||||
editor.updateToc();
|
||||
}
|
||||
|
||||
|
@ -445,7 +426,7 @@ function SectionsEditor() {
|
|||
|
||||
function validate() {
|
||||
if (!$('#name').reportValidity()) {
|
||||
messageBox.alert(t('styleMissingName'));
|
||||
messageBoxProxy.alert(t('styleMissingName'));
|
||||
return false;
|
||||
}
|
||||
for (const section of sections) {
|
||||
|
@ -454,7 +435,7 @@ function SectionsEditor() {
|
|||
continue;
|
||||
}
|
||||
if (!apply.valueEl.reportValidity()) {
|
||||
messageBox.alert(t('styleBadRegexp'));
|
||||
messageBoxProxy.alert(t('styleBadRegexp'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -486,13 +467,12 @@ function SectionsEditor() {
|
|||
}
|
||||
|
||||
function updateLivePreviewNow() {
|
||||
livePreview.update(getModel());
|
||||
editor.livePreview.update(getModel());
|
||||
}
|
||||
|
||||
async function initSections(src, {
|
||||
focusOn = 0,
|
||||
replace = false,
|
||||
pristine = false,
|
||||
} = {}) {
|
||||
if (replace) {
|
||||
sections.forEach(s => s.remove(true));
|
||||
|
@ -504,6 +484,8 @@ function SectionsEditor() {
|
|||
si.scrollY2 = si.scrollY + window.innerHeight;
|
||||
container.style.height = si.scrollY2 + 'px';
|
||||
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);
|
||||
} else {
|
||||
si = null;
|
||||
|
@ -523,8 +505,8 @@ function SectionsEditor() {
|
|||
if (si) forceRefresh = y < si.scrollY2 && (y += si.cms[i].parentHeight) > si.scrollY;
|
||||
insertSectionAfter(src[i], null, forceRefresh, si && si.cms[i]);
|
||||
setGlobalProgress(i, src.length);
|
||||
if (pristine) dirty.clear();
|
||||
if (i === focusOn && !si) sections[i].cm.focus();
|
||||
dirty.clear();
|
||||
if (i === focusOn) sections[i].cm.focus();
|
||||
}
|
||||
if (!si) requestAnimationFrame(fitToAvailableSpace);
|
||||
container.style.removeProperty('height');
|
||||
|
@ -626,7 +608,7 @@ function SectionsEditor() {
|
|||
/** @param {EditorSection} section */
|
||||
function registerEvents(section) {
|
||||
const {el, cm} = section;
|
||||
$('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp'));
|
||||
$('.applies-to-help', el).onclick = () => helpPopup.show(t('appliesLabel'), t('appliesHelp'));
|
||||
$('.remove-section', el).onclick = () => removeSection(section);
|
||||
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
|
||||
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
|
||||
|
@ -642,8 +624,8 @@ function SectionsEditor() {
|
|||
function maybeImportOnPaste(cm, event) {
|
||||
const text = event.clipboardData.getData('text') || '';
|
||||
if (/@-moz-document/i.test(text) &&
|
||||
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
|
||||
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
|
||||
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
|
||||
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
|
||||
) {
|
||||
event.preventDefault();
|
||||
showMozillaFormatImport(text);
|
||||
|
@ -652,7 +634,7 @@ function SectionsEditor() {
|
|||
|
||||
function refreshOnView(cm, {code, force} = {}) {
|
||||
if (code) {
|
||||
linter.enableForEditor(cm, code);
|
||||
linterMan.enableForEditor(cm, code);
|
||||
}
|
||||
if (force || !xo) {
|
||||
refreshOnViewNow(cm);
|
||||
|
@ -678,7 +660,7 @@ function SectionsEditor() {
|
|||
}
|
||||
|
||||
async function refreshOnViewNow(cm) {
|
||||
linter.enableForEditor(cm);
|
||||
linterMan.enableForEditor(cm);
|
||||
cm.refresh();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
/* global
|
||||
$
|
||||
$$
|
||||
$create
|
||||
CodeMirror
|
||||
onDOMready
|
||||
prefs
|
||||
showHelp
|
||||
stringAsRegExp
|
||||
t
|
||||
*/
|
||||
/* global $$ $create */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global helpPopup */// util.js
|
||||
/* global prefs */
|
||||
/* global stringAsRegExp */// toolbox.js
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
onDOMready().then(() => {
|
||||
$('#keyMap-help').addEventListener('click', showKeyMapHelp);
|
||||
});
|
||||
|
||||
function showKeyMapHelp() {
|
||||
/* exported showKeymapHelp */
|
||||
function showKeymapHelp() {
|
||||
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
|
||||
const keyMapSorted = Object.keys(keyMap)
|
||||
.map(key => ({key, cmd: keyMap[key]}))
|
||||
|
@ -32,17 +24,19 @@ function showKeyMapHelp() {
|
|||
tBody.appendChild(row.cloneNode(true));
|
||||
}
|
||||
|
||||
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
|
||||
helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
|
||||
|
||||
const inputs = $$('input', table);
|
||||
inputs[0].addEventListener('keydown', hotkeyHandler);
|
||||
inputs[0].on('keydown', hotkeyHandler);
|
||||
inputs[1].focus();
|
||||
|
||||
table.oninput = filterTable;
|
||||
|
||||
function hotkeyHandler(event) {
|
||||
const keyName = CodeMirror.keyName(event);
|
||||
if (keyName === 'Esc' || keyName === 'Tab' || keyName === 'Shift-Tab') {
|
||||
if (keyName === 'Esc' ||
|
||||
keyName === 'Tab' ||
|
||||
keyName === 'Shift-Tab') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
@ -90,6 +84,7 @@ function showKeyMapHelp() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeKeyMaps(merged, ...more) {
|
||||
more.forEach(keyMap => {
|
||||
if (typeof keyMap === 'string') {
|
||||
|
@ -102,7 +97,7 @@ function showKeyMapHelp() {
|
|||
if (typeof cmd === 'function') {
|
||||
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body)
|
||||
// for 'vim*' keymaps: almost nothing as it doesn't rely on CM keymap mechanism
|
||||
cmd = cmd.toString().replace(/^function.*?\{[\s\r\n]*([\s\S]+?)[\s\r\n]*\}$/, '$1');
|
||||
cmd = cmd.toString().replace(/^function.*?{[\s\r\n]*([\s\S]+?)[\s\r\n]*}$/, '$1');
|
||||
merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
|
||||
} else {
|
||||
merged[key] = cmd;
|
||||
|
|
|
@ -1,36 +1,26 @@
|
|||
/* global
|
||||
$
|
||||
$$
|
||||
$create
|
||||
API
|
||||
chromeSync
|
||||
cmFactory
|
||||
CodeMirror
|
||||
createLivePreview
|
||||
createMetaCompiler
|
||||
debounce
|
||||
editor
|
||||
linter
|
||||
messageBox
|
||||
MozSectionFinder
|
||||
MozSectionWidget
|
||||
prefs
|
||||
sectionsToMozFormat
|
||||
sessionStore
|
||||
t
|
||||
*/
|
||||
|
||||
/* global $ $$remove $create $isTextInput messageBoxProxy */// dom.js
|
||||
/* global API */// msg.js
|
||||
/* global CodeMirror */
|
||||
/* global MozDocMapper */// util.js
|
||||
/* global MozSectionFinder */
|
||||
/* global MozSectionWidget */
|
||||
/* global URLS debounce sessionStore */// toolbox.js
|
||||
/* global chromeSync */// storage-util.js
|
||||
/* global cmFactory */
|
||||
/* global editor */
|
||||
/* global linterMan */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
/* exported SourceEditor */
|
||||
|
||||
function SourceEditor() {
|
||||
const {style, dirty} = editor;
|
||||
const {style, /** @type DirtyReporter */dirty} = editor;
|
||||
let savedGeneration;
|
||||
let placeholderName = '';
|
||||
let prevMode = NaN;
|
||||
|
||||
$$.remove('.sectioned-only');
|
||||
$$remove('.sectioned-only');
|
||||
$('#header').on('wheel', headerOnScroll);
|
||||
$('#sections').textContent = '';
|
||||
$('#sections').appendChild($create('.single-editor'));
|
||||
|
@ -39,16 +29,26 @@ function SourceEditor() {
|
|||
|
||||
const cm = cmFactory.create($('.single-editor'));
|
||||
const sectionFinder = MozSectionFinder(cm);
|
||||
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc);
|
||||
const livePreview = createLivePreview(preprocess, style.id);
|
||||
/** @namespace SourceEditor */
|
||||
const sectionWidget = MozSectionWidget(cm, sectionFinder);
|
||||
editor.livePreview.init(preprocess, style.id);
|
||||
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, {
|
||||
sections: sectionFinder.sections,
|
||||
replaceStyle,
|
||||
updateLivePreview,
|
||||
closestVisible: () => cm,
|
||||
getEditors: () => [cm],
|
||||
scrollToEditor: () => {},
|
||||
getEditorTitle: () => '',
|
||||
save,
|
||||
getSearchableInputs: () => [],
|
||||
prevEditor: nextPrevSection.bind(null, -1),
|
||||
nextEditor: nextPrevSection.bind(null, 1),
|
||||
jumpToEditor(i) {
|
||||
|
@ -58,23 +58,37 @@ function SourceEditor() {
|
|||
cm.jumpToPos(sec.start);
|
||||
}
|
||||
},
|
||||
closestVisible: () => cm,
|
||||
getSearchableInputs: () => [],
|
||||
updateLivePreview,
|
||||
async save() {
|
||||
if (!dirty.isDirty()) return;
|
||||
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({
|
||||
'editor.linter': updateLinterSwitch,
|
||||
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
|
||||
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
|
||||
}, {now: true});
|
||||
}, {runNow: true});
|
||||
|
||||
editor.applyScrollInfo(cm);
|
||||
cm.clearHistory();
|
||||
cm.markClean();
|
||||
|
@ -88,31 +102,29 @@ function SourceEditor() {
|
|||
const mode = getModeName();
|
||||
if (mode === prevMode) return;
|
||||
prevMode = mode;
|
||||
linter.run();
|
||||
linterMan.run();
|
||||
updateLinterSwitch();
|
||||
});
|
||||
setTimeout(linter.enableForEditor, 0, cm);
|
||||
if (!$.isTextInput(document.activeElement)) {
|
||||
setTimeout(linterMan.enableForEditor, 0, cm);
|
||||
if (!$isTextInput(document.activeElement)) {
|
||||
cm.focus();
|
||||
}
|
||||
|
||||
function preprocess(style) {
|
||||
return API.buildUsercss({
|
||||
async function preprocess(style) {
|
||||
const {style: newStyle} = await API.usercss.build({
|
||||
styleId: style.id,
|
||||
sourceCode: style.sourceCode,
|
||||
assignVars: true,
|
||||
})
|
||||
.then(({style: newStyle}) => {
|
||||
delete newStyle.enabled;
|
||||
return Object.assign(style, newStyle);
|
||||
});
|
||||
});
|
||||
delete newStyle.enabled;
|
||||
return Object.assign(style, newStyle);
|
||||
}
|
||||
|
||||
function updateLivePreview() {
|
||||
if (!style.id) {
|
||||
return;
|
||||
}
|
||||
livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
|
||||
editor.livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
|
||||
}
|
||||
|
||||
function updateLinterSwitch() {
|
||||
|
@ -140,10 +152,10 @@ function SourceEditor() {
|
|||
async function setupNewStyle(style) {
|
||||
style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
|
||||
`/* ${t('usercssReplaceTemplateSectionBody')} */`;
|
||||
let section = sectionsToMozFormat(style);
|
||||
let section = MozDocMapper.styleToCss(style);
|
||||
if (!section.includes('@-moz-document')) {
|
||||
style.sections[0].domains = ['example.com'];
|
||||
section = sectionsToMozFormat(style);
|
||||
section = MozDocMapper.styleToCss(style);
|
||||
}
|
||||
const DEFAULT_CODE = `
|
||||
/* ==UserStyle==
|
||||
|
@ -199,7 +211,7 @@ function SourceEditor() {
|
|||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(messageBox.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
|
||||
Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
|
||||
if (!ok) return;
|
||||
updateEnvironment();
|
||||
if (!sameCode) {
|
||||
|
@ -223,77 +235,29 @@ function SourceEditor() {
|
|||
Object.assign(style, newStyle);
|
||||
$('#preview-label').classList.remove('hidden');
|
||||
updateMeta();
|
||||
livePreview.show(Boolean(style.id));
|
||||
editor.livePreview.toggle(Boolean(style.id));
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!dirty.isDirty()) return;
|
||||
const code = cm.getValue();
|
||||
return ensureUniqueStyle(code)
|
||||
.then(() => API.editSaveUsercss({
|
||||
id: style.id,
|
||||
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');
|
||||
});
|
||||
async function saveTemplate(code) {
|
||||
if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
|
||||
const key = chromeSync.LZ_KEY.usercssTemplate;
|
||||
await chromeSync.setLZValue(key, code);
|
||||
if (await chromeSync.getLZValue(key) !== code) {
|
||||
messageBoxProxy.alert(t('syncStorageErrorSaving'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUniqueStyle(code) {
|
||||
return style.id ? Promise.resolve() :
|
||||
API.buildUsercss({
|
||||
sourceCode: code,
|
||||
checkDup: true,
|
||||
metaOnly: true,
|
||||
}).then(({dup}) => {
|
||||
if (dup) {
|
||||
messageBox.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
|
||||
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 showSaveError(err) {
|
||||
err = Array.isArray(err) ? err : [err];
|
||||
const text = err.map(e => e.message || e).join('\n');
|
||||
const points = err.map(e =>
|
||||
e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
|
||||
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1} // csslint code parser
|
||||
).filter(Boolean);
|
||||
cm.setSelections(points.map(p => ({anchor: p, head: p})));
|
||||
messageBoxProxy.alert($create('pre', text), 'pre');
|
||||
}
|
||||
|
||||
function nextPrevSection(dir) {
|
||||
|
@ -334,4 +298,36 @@ function SourceEditor() {
|
|||
return (mode.name || mode || '') +
|
||||
(mode.helperType || '');
|
||||
}
|
||||
|
||||
function createMetaCompiler(onUpdated) {
|
||||
let meta = null;
|
||||
let metaIndex = null;
|
||||
let cache = [];
|
||||
linterMan.register(async (text, options, _cm) => {
|
||||
if (_cm !== cm) {
|
||||
return;
|
||||
}
|
||||
const match = text.match(URLS.rxMETA);
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
if (match[0] === meta && match.index === metaIndex) {
|
||||
return cache;
|
||||
}
|
||||
const {metadata, errors} = await linterMan.worker.metalint(match[0]);
|
||||
if (errors.every(err => err.code === 'unknownMeta')) {
|
||||
onUpdated(metadata);
|
||||
}
|
||||
cache = errors.map(err => ({
|
||||
from: cm.posFromIndex((err.index || 0) + match.index),
|
||||
to: cm.posFromIndex((err.index || 0) + match.index),
|
||||
message: err.code && t(`meta_${err.code}`, err.args, false) || err.message,
|
||||
severity: err.code === 'unknownMeta' ? 'warning' : 'error',
|
||||
rule: err.code,
|
||||
}));
|
||||
meta = match[0];
|
||||
metaIndex = match.index;
|
||||
return cache;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
303
edit/util.js
303
edit/util.js
|
@ -1,178 +1,109 @@
|
|||
/* global
|
||||
$create
|
||||
CodeMirror
|
||||
prefs
|
||||
*/
|
||||
/* global $ $create getEventKeyName messageBoxProxy moveFocus */// dom.js
|
||||
/* global CodeMirror */
|
||||
/* global debounce */// toolbox.js
|
||||
/* global editor */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
/* exported DirtyReporter */
|
||||
class DirtyReporter {
|
||||
constructor() {
|
||||
this._dirty = new Map();
|
||||
this._onchange = new Set();
|
||||
}
|
||||
const helpPopup = {
|
||||
|
||||
add(obj, value) {
|
||||
const wasDirty = this.isDirty();
|
||||
const saved = this._dirty.get(obj);
|
||||
if (!saved) {
|
||||
this._dirty.set(obj, {type: 'add', newValue: value});
|
||||
} else if (saved.type === 'remove') {
|
||||
if (saved.savedValue === value) {
|
||||
this._dirty.delete(obj);
|
||||
} else {
|
||||
saved.newValue = value;
|
||||
saved.type = 'modify';
|
||||
}
|
||||
show(title = '', body) {
|
||||
const div = $('#help-popup');
|
||||
const contents = $('.contents', div);
|
||||
div.className = '';
|
||||
contents.textContent = '';
|
||||
if (body) {
|
||||
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
|
||||
}
|
||||
this.notifyChange(wasDirty);
|
||||
}
|
||||
|
||||
remove(obj, value) {
|
||||
const wasDirty = this.isDirty();
|
||||
const saved = this._dirty.get(obj);
|
||||
if (!saved) {
|
||||
this._dirty.set(obj, {type: 'remove', savedValue: value});
|
||||
} else if (saved.type === 'add') {
|
||||
this._dirty.delete(obj);
|
||||
} else if (saved.type === 'modify') {
|
||||
saved.type = 'remove';
|
||||
}
|
||||
this.notifyChange(wasDirty);
|
||||
}
|
||||
|
||||
modify(obj, oldValue, newValue) {
|
||||
const wasDirty = this.isDirty();
|
||||
const saved = this._dirty.get(obj);
|
||||
if (!saved) {
|
||||
if (oldValue !== newValue) {
|
||||
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
||||
}
|
||||
} 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',
|
||||
$('.title', div).textContent = title;
|
||||
$('.dismiss', div).onclick = helpPopup.close;
|
||||
window.on('keydown', helpPopup.close, true);
|
||||
// reset any inline styles
|
||||
div.style = 'display: block';
|
||||
helpPopup.originalFocus = document.activeElement;
|
||||
return div;
|
||||
},
|
||||
FROM_CSS: {
|
||||
'url': 'urls',
|
||||
'url-prefix': 'urlPrefixes',
|
||||
'domain': 'domains',
|
||||
'regexp': 'regexps',
|
||||
},
|
||||
/**
|
||||
* @param {Object} section
|
||||
* @param {function(func:string, value:string)} fn
|
||||
*/
|
||||
forEachProp(section, fn) {
|
||||
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
|
||||
const props = section[propName];
|
||||
if (props) props.forEach(value => fn(func, value));
|
||||
|
||||
close(event) {
|
||||
const canClose =
|
||||
!event ||
|
||||
event.type === 'click' || (
|
||||
getEventKeyName(event) === 'Escape' &&
|
||||
!$('.CodeMirror-hints, #message-box') && (
|
||||
!document.activeElement ||
|
||||
!document.activeElement.closest('#search-replace-dialog') &&
|
||||
document.activeElement.matches(':not(input), .can-close-on-esc')
|
||||
)
|
||||
);
|
||||
const div = $('#help-popup');
|
||||
if (!canClose || !div) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Array<?[type,value]>} funcItems
|
||||
* @param {?Object} [section]
|
||||
* @returns {Object} section
|
||||
*/
|
||||
toSection(funcItems, section = {}) {
|
||||
for (const item of funcItems) {
|
||||
const [func, value] = item || [];
|
||||
const propName = DocFuncMapper.FROM_CSS[func];
|
||||
if (propName) {
|
||||
const props = section[propName] || (section[propName] = []);
|
||||
if (Array.isArray(value)) props.push(...value);
|
||||
else props.push(value);
|
||||
}
|
||||
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
|
||||
setTimeout(async () => {
|
||||
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
|
||||
return ok && helpPopup.close();
|
||||
});
|
||||
return;
|
||||
}
|
||||
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 */
|
||||
function sectionsToMozFormat(style) {
|
||||
return style.sections.map(section => {
|
||||
const cssFuncs = [];
|
||||
DocFuncMapper.forEachProp(section, (type, value) =>
|
||||
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
|
||||
return cssFuncs.length ?
|
||||
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
|
||||
section.code;
|
||||
}).join('\n\n');
|
||||
}
|
||||
// reroute handling to nearest editor when keypress resolves to one of these commands
|
||||
Object.assign(rerouteHotkeys, {
|
||||
commands: [
|
||||
'beautify',
|
||||
'colorpicker',
|
||||
'find',
|
||||
'findNext',
|
||||
'findPrev',
|
||||
'jumpToLine',
|
||||
'nextEditor',
|
||||
'prevEditor',
|
||||
'replace',
|
||||
'replaceAll',
|
||||
'save',
|
||||
'toggleEditorFocus',
|
||||
'toggleStyle',
|
||||
],
|
||||
|
||||
/* exported trimCommentLabel */
|
||||
function trimCommentLabel(str, limit = 1000) {
|
||||
// stripping /*** foo ***/ to foo
|
||||
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
|
||||
}
|
||||
toggle(enable) {
|
||||
document[enable ? 'on' : 'off']('keydown', rerouteHotkeys.handler);
|
||||
},
|
||||
|
||||
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) {
|
||||
return str.length <= limit ? str : str.substr(0, limit) + '...';
|
||||
}
|
||||
|
||||
/* exported memoize */
|
||||
function memoize(fn) {
|
||||
let cached = false;
|
||||
let result;
|
||||
return (...args) => {
|
||||
if (!cached) {
|
||||
result = fn(...args);
|
||||
cached = true;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/* exported createHotkeyInput */
|
||||
/**
|
||||
* @param {!string} prefId
|
||||
* @param {?function(isEnter:boolean)} onDone
|
||||
*/
|
||||
function createHotkeyInput(prefId, onDone = () => {}) {
|
||||
return $create('input', {
|
||||
type: 'search',
|
||||
|
@ -217,3 +148,59 @@ function createHotkeyInput(prefId, onDone = () => {}) {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rerouteHotkeys(enable, immediately) {
|
||||
if (immediately) {
|
||||
rerouteHotkeys.toggle(enable);
|
||||
} else {
|
||||
debounce(rerouteHotkeys.toggle, 0, enable);
|
||||
}
|
||||
}
|
||||
|
||||
/* exported showCodeMirrorPopup */
|
||||
function showCodeMirrorPopup(title, html, options) {
|
||||
const popup = helpPopup.show(title, html);
|
||||
popup.classList.add('big');
|
||||
|
||||
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
|
||||
mode: 'css',
|
||||
lineNumbers: true,
|
||||
lineWrapping: prefs.get('editor.lineWrapping'),
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
matchBrackets: true,
|
||||
styleActiveLine: true,
|
||||
theme: prefs.get('editor.theme'),
|
||||
keyMap: prefs.get('editor.keyMap'),
|
||||
}, options));
|
||||
cm.focus();
|
||||
rerouteHotkeys(false);
|
||||
|
||||
document.documentElement.style.pointerEvents = 'none';
|
||||
popup.style.pointerEvents = 'auto';
|
||||
|
||||
const onKeyDown = event => {
|
||||
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
const search = $('#search-replace-dialog');
|
||||
const area = search && search.contains(document.activeElement) ? search : popup;
|
||||
moveFocus(area, event.shiftKey ? -1 : 1);
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
window.on('keydown', onKeyDown, true);
|
||||
|
||||
window.on('closeHelp', () => {
|
||||
window.off('keydown', onKeyDown, true);
|
||||
document.documentElement.style.removeProperty('pointer-events');
|
||||
rerouteHotkeys(true);
|
||||
cm = popup.codebox = null;
|
||||
}, {once: true});
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
/* exported trimCommentLabel */
|
||||
function trimCommentLabel(str, limit = 1000) {
|
||||
// stripping /*** foo ***/ to foo
|
||||
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
|
||||
}
|
||||
|
|
|
@ -7,44 +7,21 @@
|
|||
<title>Loading...</title>
|
||||
|
||||
<link href="global.css" rel="stylesheet">
|
||||
<link href="install-usercss/install-usercss.css" rel="stylesheet">
|
||||
|
||||
<script src="js/polyfill.js"></script>
|
||||
<script src="js/msg.js"></script>
|
||||
<script src="js/messaging.js"></script>
|
||||
<script src="js/toolbox.js"></script>
|
||||
|
||||
<script src="install-usercss/preinit.js"></script>
|
||||
|
||||
<script src="js/prefs.js"></script>
|
||||
<script src="js/dom.js"></script>
|
||||
<script src="js/localization.js"></script>
|
||||
<script src="js/script-loader.js"></script>
|
||||
<script src="js/storage-util.js"></script>
|
||||
|
||||
<script src="content/style-injector.js"></script>
|
||||
<script src="content/apply.js"></script>
|
||||
<script src="vendor/semver-bundle/semver.js"></script>
|
||||
|
||||
<link href="msgbox/msgbox.css" rel="stylesheet">
|
||||
<script src="msgbox/msgbox.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
|
||||
<script src="vendor/codemirror/lib/codemirror.js"></script>
|
||||
<script src="vendor/codemirror/keymap/sublime.js"></script>
|
||||
<script src="vendor/codemirror/keymap/emacs.js"></script>
|
||||
<script src="vendor/codemirror/keymap/vim.js"></script>
|
||||
|
||||
<script src="vendor/codemirror/mode/css/css.js"></script>
|
||||
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
|
||||
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
|
||||
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
|
||||
|
||||
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
|
||||
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
|
||||
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
|
||||
|
||||
<script src="edit/codemirror-default.js"></script>
|
||||
<link rel="stylesheet" href="edit/codemirror-default.css">
|
||||
<link href="install-usercss/install-usercss.css" rel="stylesheet">
|
||||
</head>
|
||||
<body id="stylus-install-usercss">
|
||||
<div class="container">
|
||||
|
@ -92,13 +69,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="install-usercss/install-usercss.js"></script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
|
||||
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000">
|
||||
<path fill-rule="evenodd" d="M983.2,184.3L853,69.8c-4-3.5-9.3-5.3-14.5-5c-5.3,0.4-10.3,2.8-13.8,6.8L352.3,609.2L184.4,386.9c-3.2-4.2-8-7-13.2-7.8c-5.3-0.8-10.6,0.6-14.9,3.9L18,487.5c-8.8,6.7-10.6,19.3-3.9,28.1L325,927.2c3.6,4.8,9.3,7.7,15.3,8c0.2,0,0.5,0,0.7,0c5.8,0,11.3-2.5,15.1-6.8L985,212.6C992.3,204.3,991.5,191.6,983.2,184.3z"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<script src="js/dlg/message-box.js"></script>
|
||||
<script src="install-usercss/install-usercss.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -301,6 +301,10 @@ li {
|
|||
user-select: auto;
|
||||
}
|
||||
|
||||
#header.meta-init[data-arrived-fast="true"] > * {
|
||||
transition-duration: .1s;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
|
|
|
@ -1,417 +1,394 @@
|
|||
/* global CodeMirror semverCompare closeCurrentTab messageBox download
|
||||
$ $$ $create $createLink t prefs API */
|
||||
/* global $ $create $createLink $$remove */
|
||||
/* global API */// msg.js
|
||||
/* global closeCurrentTab */// toolbox.js
|
||||
/* global messageBox */
|
||||
/* global prefs */
|
||||
/* global preinit */
|
||||
/* global t */// localization.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
|
||||
const initialUrl = params.get('updateUrl');
|
||||
let cm;
|
||||
let initialUrl;
|
||||
let installed;
|
||||
let installedDup;
|
||||
let liveReload;
|
||||
let tabId;
|
||||
|
||||
let installed = null;
|
||||
let installedDup = null;
|
||||
window.on('resize', adjustCodeHeight);
|
||||
// "History back" in Firefox (for now) restores the old DOM including the messagebox,
|
||||
// which stays after installing since we don't want to wait for the fadeout animation before resolving.
|
||||
document.on('visibilitychange', () => {
|
||||
if (messageBox.element) messageBox.element.remove();
|
||||
if (installed) liveReload.onToggled();
|
||||
});
|
||||
|
||||
const liveReload = initLiveReload();
|
||||
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre'));
|
||||
setTimeout(() => {
|
||||
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 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,
|
||||
colorpicker: true,
|
||||
theme,
|
||||
});
|
||||
if (theme !== 'default') {
|
||||
document.head.appendChild($create('link', {
|
||||
rel: 'stylesheet',
|
||||
href: `vendor/codemirror/theme/${theme}.css`,
|
||||
}));
|
||||
if (error) {
|
||||
showBuildError(error);
|
||||
}
|
||||
window.addEventListener('resize', adjustCodeHeight);
|
||||
// "History back" in Firefox (for now) restores the old DOM including the messagebox,
|
||||
// which stays after installing since we don't want to wait for the fadeout animation before resolving.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
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)
|
||||
)
|
||||
)),
|
||||
]));
|
||||
}
|
||||
if (!style) {
|
||||
return;
|
||||
}
|
||||
const data = style.usercssData;
|
||||
const dupData = dup && dup.usercssData;
|
||||
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||
|
||||
function showError(err) {
|
||||
$('.warnings').textContent = '';
|
||||
if (err) {
|
||||
$('.warnings').appendChild(buildWarning(err));
|
||||
}
|
||||
$('.warnings').classList.toggle('visible', Boolean(err));
|
||||
$('.container').classList.toggle('has-warnings', Boolean(err));
|
||||
adjustCodeHeight();
|
||||
}
|
||||
updateMeta(style, dup);
|
||||
|
||||
function install(style) {
|
||||
installed = style;
|
||||
|
||||
$$.remove('.warning');
|
||||
$('button.install').disabled = true;
|
||||
$('button.install').classList.add('installed');
|
||||
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
|
||||
$('h2.installed').classList.add('active');
|
||||
$('.set-update-url input[type=checkbox]').disabled = true;
|
||||
$('.set-update-url').title = style.updateUrl ?
|
||||
t('installUpdateFrom', style.updateUrl) : '';
|
||||
|
||||
updateMeta(style);
|
||||
|
||||
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
|
||||
location.href = '/edit.html?id=' + style.id;
|
||||
} else {
|
||||
API.openEditor({id: style.id});
|
||||
if (!liveReload.enabled) {
|
||||
if (tabId < 0 && history.length > 1) {
|
||||
history.back();
|
||||
} else {
|
||||
closeCurrentTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initSourceCode(sourceCode) {
|
||||
cm.setValue(sourceCode);
|
||||
cm.refresh();
|
||||
API.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)
|
||||
// update UI
|
||||
if (versionTest < 0) {
|
||||
$('.actions').parentNode.insertBefore(
|
||||
$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.usercss.install(style)
|
||||
.then(install)
|
||||
.catch(err => messageBox.alert(t('styleInstallFailed', err), 'pre'))
|
||||
);
|
||||
};
|
||||
|
||||
function init({style, dup}) {
|
||||
const data = style.usercssData;
|
||||
const dupData = dup && dup.usercssData;
|
||||
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||
|
||||
updateMeta(style, dup);
|
||||
|
||||
// update UI
|
||||
if (versionTest < 0) {
|
||||
$('.actions').parentNode.insertBefore(
|
||||
$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();
|
||||
}
|
||||
// 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) + '...';
|
||||
|
||||
function getAppliesTo(style) {
|
||||
function *_gen() {
|
||||
for (const section of style.sections) {
|
||||
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
|
||||
if (section[type]) {
|
||||
yield *section[type];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
if (initialUrl.startsWith('file:')) {
|
||||
$('.live-reload input').onchange = liveReload.onToggled;
|
||||
} else {
|
||||
$('.live-reload').remove();
|
||||
}
|
||||
})();
|
||||
|
||||
function updateMeta(style, dup = installedDup) {
|
||||
installedDup = dup;
|
||||
const data = style.usercssData;
|
||||
const dupData = dup && dup.usercssData;
|
||||
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||
|
||||
cm.setPreprocessor(data.preprocessor);
|
||||
|
||||
const installButtonLabel = t(
|
||||
installed ? 'installButtonInstalled' :
|
||||
!dup ? 'installButton' :
|
||||
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
|
||||
);
|
||||
document.title = `${installButtonLabel} ${data.name}`;
|
||||
|
||||
$('.install').textContent = installButtonLabel;
|
||||
$('.install').classList.add(
|
||||
installed ? 'installed' :
|
||||
!dup ? 'install' :
|
||||
versionTest > 0 ? 'update' :
|
||||
'reinstall');
|
||||
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
|
||||
$('.meta-name').textContent = data.name;
|
||||
$('.meta-version').textContent = data.version;
|
||||
$('.meta-description').textContent = data.description;
|
||||
|
||||
if (data.author) {
|
||||
$('.meta-author').parentNode.style.display = '';
|
||||
$('.meta-author').textContent = '';
|
||||
$('.meta-author').appendChild(makeAuthor(data.author));
|
||||
} else {
|
||||
$('.meta-author').parentNode.style.display = 'none';
|
||||
}
|
||||
|
||||
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
|
||||
$('.meta-license').textContent = data.license;
|
||||
|
||||
$('.applies-to').textContent = '';
|
||||
getAppliesTo(style).then(list =>
|
||||
$('.applies-to').append(...list.map(s => $create('li', s))));
|
||||
|
||||
$('.external-link').textContent = '';
|
||||
const externalLink = makeExternalLink();
|
||||
if (externalLink) {
|
||||
$('.external-link').appendChild(externalLink);
|
||||
}
|
||||
|
||||
$('#header').dataset.arrivedFast = performance.now() < 500;
|
||||
$('#header').classList.add('meta-init');
|
||||
$('#header').classList.remove('meta-init-error');
|
||||
setTimeout(() => $$remove('.lds-spinner'), 1000);
|
||||
|
||||
showError('');
|
||||
requestAnimationFrame(adjustCodeHeight);
|
||||
|
||||
function makeAuthor(text) {
|
||||
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
|
||||
if (!match) {
|
||||
return document.createTextNode(text);
|
||||
}
|
||||
const [, name, email, url] = match;
|
||||
const frag = document.createDocumentFragment();
|
||||
if (email) {
|
||||
frag.appendChild($createLink(`mailto:${email}`, name));
|
||||
} else {
|
||||
frag.appendChild($create('span', name));
|
||||
}
|
||||
if (url) {
|
||||
frag.appendChild($createLink(url,
|
||||
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
|
||||
$create('SVG:path', {
|
||||
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
|
||||
}))
|
||||
));
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
function makeExternalLink() {
|
||||
const urls = [
|
||||
data.homepageURL && [data.homepageURL, t('externalHomepage')],
|
||||
data.supportURL && [data.supportURL, t('externalSupport')],
|
||||
];
|
||||
return (data.homepageURL || data.supportURL) && (
|
||||
$create('div', [
|
||||
$create('h3', t('externalLink')),
|
||||
$create('ul', urls.map(args => args &&
|
||||
$create('li',
|
||||
$createLink(...args)
|
||||
)
|
||||
)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
function showError(err) {
|
||||
$('.warnings').textContent = '';
|
||||
$('.warnings').classList.toggle('visible', Boolean(err));
|
||||
$('.container').classList.toggle('has-warnings', Boolean(err));
|
||||
err = Array.isArray(err) ? err : [err];
|
||||
if (err[0]) {
|
||||
let i;
|
||||
if ((i = err[0].index) >= 0 ||
|
||||
(i = err[0].offset) >= 0) {
|
||||
cm.jumpToPos(cm.posFromIndex(i));
|
||||
cm.setSelections(err.map(e => {
|
||||
const pos = e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
|
||||
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1}; // csslint code parser
|
||||
return pos && {anchor: pos, head: pos};
|
||||
}).filter(Boolean));
|
||||
cm.focus();
|
||||
}
|
||||
$('.warnings').appendChild(
|
||||
$create('.warning', [
|
||||
t('parseUsercssError'),
|
||||
'\n',
|
||||
...err.map(e => e.message ? $create('pre', e.message) : e || 'Unknown error'),
|
||||
]));
|
||||
}
|
||||
adjustCodeHeight();
|
||||
}
|
||||
|
||||
function showBuildError(error) {
|
||||
$('#header').classList.add('meta-init-error');
|
||||
console.error(error);
|
||||
showError(error);
|
||||
}
|
||||
|
||||
function install(style) {
|
||||
installed = style;
|
||||
|
||||
$$remove('.warning');
|
||||
$('button.install').disabled = true;
|
||||
$('button.install').classList.add('installed');
|
||||
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
|
||||
$('h2.installed').classList.add('active');
|
||||
$('.set-update-url input[type=checkbox]').disabled = true;
|
||||
$('.set-update-url').title = style.updateUrl ?
|
||||
t('installUpdateFrom', style.updateUrl) : '';
|
||||
|
||||
updateMeta(style);
|
||||
|
||||
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
|
||||
location.href = '/edit.html?id=' + style.id;
|
||||
} else {
|
||||
API.openEditor({id: style.id});
|
||||
if (!liveReload.enabled) {
|
||||
if (tabId < 0 && history.length > 1) {
|
||||
history.back();
|
||||
} else {
|
||||
closeCurrentTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppliesTo(style) {
|
||||
if (style.sectionsPromise) {
|
||||
try {
|
||||
style.sections = await style.sectionsPromise;
|
||||
} catch (error) {
|
||||
showBuildError(error);
|
||||
return [];
|
||||
} finally {
|
||||
delete style.sectionsPromise;
|
||||
}
|
||||
}
|
||||
let numGlobals = 0;
|
||||
const res = [];
|
||||
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
|
||||
for (const section of style.sections) {
|
||||
const targets = [].concat(...TARGETS.map(t => section[t]).filter(Boolean));
|
||||
res.push(...targets);
|
||||
numGlobals += !targets.length && !styleCodeEmpty(section.code);
|
||||
}
|
||||
res.sort();
|
||||
if (!res.length || numGlobals) {
|
||||
res.push(t('appliesToEverything'));
|
||||
}
|
||||
return [...new Set(res)];
|
||||
}
|
||||
|
||||
function adjustCodeHeight() {
|
||||
// Chrome-only bug (apparently): it doesn't limit the scroller element height
|
||||
const scroller = cm.display.scroller;
|
||||
const prevWindowHeight = adjustCodeHeight.prevWindowHeight;
|
||||
if (scroller.scrollHeight === scroller.clientHeight ||
|
||||
prevWindowHeight && window.innerHeight !== prevWindowHeight) {
|
||||
adjustCodeHeight.prevWindowHeight = window.innerHeight;
|
||||
cm.setSize(null, $('.main').offsetHeight - $('.warnings').offsetHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function initLiveReload() {
|
||||
const DELAY = 500;
|
||||
let isEnabled = false;
|
||||
let timer = 0;
|
||||
const getData = preinit.getData;
|
||||
let sequence = preinit.ready;
|
||||
return {
|
||||
get enabled() {
|
||||
return isEnabled;
|
||||
},
|
||||
onToggled(e) {
|
||||
if (e) isEnabled = e.target.checked;
|
||||
if (installed || installedDup) {
|
||||
if (isEnabled) {
|
||||
check({force: true});
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
$('.install').disabled = isEnabled;
|
||||
Object.assign($('#live-reload-install-hint'), {
|
||||
hidden: !isEnabled,
|
||||
textContent: t(`liveReloadInstallHint${tabId >= 0 ? 'FF' : ''}`),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
function check(opts) {
|
||||
getData(opts)
|
||||
.then(update, logError)
|
||||
.then(() => {
|
||||
timer = 0;
|
||||
start();
|
||||
});
|
||||
}
|
||||
function logError(error) {
|
||||
console.warn(t('liveReloadError', error));
|
||||
}
|
||||
function start() {
|
||||
timer = timer || setTimeout(check, DELAY);
|
||||
}
|
||||
function stop() {
|
||||
clearTimeout(timer);
|
||||
timer = 0;
|
||||
}
|
||||
function update(code) {
|
||||
if (code == null) return;
|
||||
sequence = sequence.catch(console.error).then(() => {
|
||||
const {id} = installed || installedDup;
|
||||
const scrollInfo = cm.getScrollInfo();
|
||||
const cursor = cm.getCursor();
|
||||
cm.setValue(code);
|
||||
cm.setCursor(cursor);
|
||||
cm.scrollTo(scrollInfo.left, scrollInfo.top);
|
||||
return API.usercss.install({id, sourceCode: code})
|
||||
.then(updateMeta)
|
||||
.catch(showError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
90
install-usercss/preinit.js
Normal file
90
install-usercss/preinit.js
Normal file
|
@ -0,0 +1,90 @@
|
|||
/* global API */// msg.js
|
||||
/* global closeCurrentTab download */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
/* exported preinit */
|
||||
const preinit = (() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
|
||||
const initialUrl = params.get('updateUrl');
|
||||
|
||||
/** @type function(?options):Promise<?string> */
|
||||
let getData;
|
||||
/** @type {Promise<?string>} */
|
||||
let firstGet;
|
||||
if (tabId < 0) {
|
||||
getData = DirectDownloader();
|
||||
firstGet = API.usercss.getInstallCode(initialUrl)
|
||||
.then(code => code || getData())
|
||||
.catch(getData);
|
||||
} else {
|
||||
getData = PortDownloader();
|
||||
firstGet = getData({timer: false});
|
||||
}
|
||||
|
||||
function DirectDownloader() {
|
||||
let oldCode = null;
|
||||
return async () => {
|
||||
const code = await download(initialUrl);
|
||||
if (oldCode !== code) {
|
||||
oldCode = code;
|
||||
return code;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function PortDownloader() {
|
||||
const resolvers = new Map();
|
||||
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
|
||||
port.onMessage.addListener(({id, code, error}) => {
|
||||
const r = resolvers.get(id);
|
||||
resolvers.delete(id);
|
||||
if (error) {
|
||||
r.reject(error);
|
||||
} else {
|
||||
r.resolve(code);
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(async () => {
|
||||
const tab = await browser.tabs.get(tabId).catch(() => ({}));
|
||||
if (tab.url === initialUrl) {
|
||||
location.reload();
|
||||
} else {
|
||||
closeCurrentTab();
|
||||
}
|
||||
});
|
||||
return (opts = {}) => new Promise((resolve, reject) => {
|
||||
const id = performance.now();
|
||||
resolvers.set(id, {resolve, reject});
|
||||
opts.id = id;
|
||||
port.postMessage(opts);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
getData,
|
||||
initialUrl,
|
||||
tabId,
|
||||
|
||||
/** @type {Promise<{style, dup} | {error}>} */
|
||||
ready: (async () => {
|
||||
let sourceCode;
|
||||
try {
|
||||
sourceCode = await firstGet;
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
try {
|
||||
const data = await API.usercss.build({sourceCode, checkDup: true, metaOnly: true});
|
||||
Object.defineProperty(data.style, 'sectionsPromise', {
|
||||
value: API.usercss.buildCode(data.style).then(style => style.sections),
|
||||
configurable: true,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {error, sourceCode};
|
||||
}
|
||||
})(),
|
||||
};
|
||||
})();
|
71
js/cache.js
71
js/cache.js
|
@ -1,71 +0,0 @@
|
|||
/* exported createCache */
|
||||
'use strict';
|
||||
|
||||
// create a FIFO limit-size map.
|
||||
function createCache({size = 1000, onDeleted} = {}) {
|
||||
const map = new Map();
|
||||
const buffer = Array(size);
|
||||
let index = 0;
|
||||
let lastIndex = 0;
|
||||
return {
|
||||
get,
|
||||
set,
|
||||
delete: delete_,
|
||||
clear,
|
||||
has: id => map.has(id),
|
||||
entries: function *() {
|
||||
for (const [id, item] of map) {
|
||||
yield [id, item.data];
|
||||
}
|
||||
},
|
||||
values: function *() {
|
||||
for (const item of map.values()) {
|
||||
yield item.data;
|
||||
}
|
||||
},
|
||||
get size() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
|
||||
function get(id) {
|
||||
const item = map.get(id);
|
||||
return item && item.data;
|
||||
}
|
||||
|
||||
function set(id, data) {
|
||||
if (map.size === size) {
|
||||
// full
|
||||
map.delete(buffer[lastIndex].id);
|
||||
if (onDeleted) {
|
||||
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
|
||||
}
|
||||
lastIndex = (lastIndex + 1) % size;
|
||||
}
|
||||
const item = {id, data, index};
|
||||
map.set(id, item);
|
||||
buffer[index] = item;
|
||||
index = (index + 1) % size;
|
||||
}
|
||||
|
||||
function delete_(id) {
|
||||
const item = map.get(id);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
map.delete(item.id);
|
||||
const lastItem = buffer[lastIndex];
|
||||
lastItem.index = item.index;
|
||||
buffer[item.index] = lastItem;
|
||||
lastIndex = (lastIndex + 1) % size;
|
||||
if (onDeleted) {
|
||||
onDeleted(item.id, item.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
map.clear();
|
||||
index = lastIndex = 0;
|
||||
}
|
||||
}
|
89
js/color/color-mimicry.js
Normal file
89
js/color/color-mimicry.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
/* global $create */// dom.js
|
||||
/* global debounce */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
/* exported colorMimicry */
|
||||
/**
|
||||
* Calculates real color of an element:
|
||||
* colorMimicry(cm.display.gutters, {bg: 'backgroundColor'})
|
||||
* colorMimicry('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
|
||||
*/
|
||||
function colorMimicry(el, targets, dummyContainer = document.body) {
|
||||
const styleCache = colorMimicry.styleCache || (colorMimicry.styleCache = new Map());
|
||||
targets = targets || {};
|
||||
targets.fore = 'color';
|
||||
const colors = {};
|
||||
const done = {};
|
||||
let numDone = 0;
|
||||
let numTotal = 0;
|
||||
const rootStyle = getStyle(document.documentElement);
|
||||
for (const k in targets) {
|
||||
const base = {r: 255, g: 255, b: 255, a: 1};
|
||||
blend(base, rootStyle[targets[k]]);
|
||||
colors[k] = base;
|
||||
numTotal++;
|
||||
}
|
||||
const isDummy = typeof el === 'string';
|
||||
if (isDummy) {
|
||||
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
|
||||
}
|
||||
for (let current = el; current; current = current && current.parentElement) {
|
||||
const style = getStyle(current);
|
||||
for (const k in targets) {
|
||||
if (!done[k]) {
|
||||
done[k] = blend(colors[k], style[targets[k]]);
|
||||
numDone += done[k] ? 1 : 0;
|
||||
if (numDone === numTotal) {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
colors.style = colors.style || style;
|
||||
}
|
||||
if (isDummy) {
|
||||
el.remove();
|
||||
}
|
||||
for (const k in targets) {
|
||||
const {r, g, b, a} = colors[k];
|
||||
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
// https://www.w3.org/TR/AERT#color-contrast
|
||||
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
|
||||
}
|
||||
debounce(clearCache);
|
||||
return colors;
|
||||
|
||||
function blend(base, color) {
|
||||
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
|
||||
if (a === 255) {
|
||||
base.r = r;
|
||||
base.g = g;
|
||||
base.b = b;
|
||||
base.a = 1;
|
||||
} else if (a) {
|
||||
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
|
||||
const q1 = a / 255 / mixedA;
|
||||
const q2 = base.a * (1 - mixedA) / mixedA;
|
||||
base.r = Math.round(r * q1 + base.r * q2);
|
||||
base.g = Math.round(g * q1 + base.g * q2);
|
||||
base.b = Math.round(b * q1 + base.b * q2);
|
||||
base.a = mixedA;
|
||||
}
|
||||
return Math.abs(base.a - 1) < 1e-3;
|
||||
}
|
||||
|
||||
// speed-up for sequential invocations within the same event loop cycle
|
||||
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
|
||||
function getStyle(el) {
|
||||
let style = styleCache.get(el);
|
||||
if (!style) {
|
||||
style = getComputedStyle(el);
|
||||
styleCache.set(el, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
styleCache.clear();
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
/* global colorConverter $create debounce */
|
||||
/* exported colorMimicry */
|
||||
/* global colorConverter */
|
||||
/* global colorMimicry */
|
||||
'use strict';
|
||||
|
||||
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
|
||||
const cm = this;
|
||||
const cm = window.CodeMirror && this;
|
||||
const CSS_PREFIX = 'colorpicker-';
|
||||
const HUE_COLORS = [
|
||||
{hex: '#ff0000', start: .0},
|
||||
|
@ -679,7 +679,7 @@
|
|||
function onCloseRequest(event) {
|
||||
if (event.detail !== PUBLIC_API) {
|
||||
hide();
|
||||
} else if (!prevFocusedElement) {
|
||||
} else if (!prevFocusedElement && cm) {
|
||||
// we're between mousedown and mouseup and colorview wants to re-open us in this cm
|
||||
// so we'll prevent onMouseUp from hiding us to avoid flicker
|
||||
prevFocusedElement = cm.display.input;
|
||||
|
@ -867,9 +867,9 @@
|
|||
|
||||
function guessTheme() {
|
||||
const el = options.guessBrightness ||
|
||||
((cm.display.renderedView || [])[0] || {}).text ||
|
||||
cm.display.lineDiv;
|
||||
const bgLuma = window.colorMimicry.get(el, {bg: 'backgroundColor'}).bgLuma;
|
||||
cm && ((cm.display.renderedView || [])[0] || {}).text ||
|
||||
cm && cm.display.lineDiv;
|
||||
const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma;
|
||||
return bgLuma < .5 ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
|
@ -893,92 +893,3 @@
|
|||
|
||||
//endregion
|
||||
};
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// eslint-disable-next-line no-var
|
||||
var colorMimicry = (() => {
|
||||
const styleCache = new Map();
|
||||
return {get};
|
||||
|
||||
// Calculates real color of an element:
|
||||
// colorMimicry.get(cm.display.gutters, {bg: 'backgroundColor'})
|
||||
// colorMimicry.get('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
|
||||
function get(el, targets, dummyContainer = document.body) {
|
||||
targets = targets || {};
|
||||
targets.fore = 'color';
|
||||
const colors = {};
|
||||
const done = {};
|
||||
let numDone = 0;
|
||||
let numTotal = 0;
|
||||
const rootStyle = getStyle(document.documentElement);
|
||||
for (const k in targets) {
|
||||
const base = {r: 255, g: 255, b: 255, a: 1};
|
||||
blend(base, rootStyle[targets[k]]);
|
||||
colors[k] = base;
|
||||
numTotal++;
|
||||
}
|
||||
const isDummy = typeof el === 'string';
|
||||
if (isDummy) {
|
||||
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
|
||||
}
|
||||
for (let current = el; current; current = current && current.parentElement) {
|
||||
const style = getStyle(current);
|
||||
for (const k in targets) {
|
||||
if (!done[k]) {
|
||||
done[k] = blend(colors[k], style[targets[k]]);
|
||||
numDone += done[k] ? 1 : 0;
|
||||
if (numDone === numTotal) {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
colors.style = colors.style || style;
|
||||
}
|
||||
if (isDummy) {
|
||||
el.remove();
|
||||
}
|
||||
for (const k in targets) {
|
||||
const {r, g, b, a} = colors[k];
|
||||
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
// https://www.w3.org/TR/AERT#color-contrast
|
||||
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
|
||||
}
|
||||
debounce(clearCache);
|
||||
return colors;
|
||||
}
|
||||
|
||||
function blend(base, color) {
|
||||
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
|
||||
if (a === 255) {
|
||||
base.r = r;
|
||||
base.g = g;
|
||||
base.b = b;
|
||||
base.a = 1;
|
||||
} else if (a) {
|
||||
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
|
||||
const q1 = a / 255 / mixedA;
|
||||
const q2 = base.a * (1 - mixedA) / mixedA;
|
||||
base.r = Math.round(r * q1 + base.r * q2);
|
||||
base.g = Math.round(g * q1 + base.g * q2);
|
||||
base.b = Math.round(b * q1 + base.b * q2);
|
||||
base.a = mixedA;
|
||||
}
|
||||
return Math.abs(base.a - 1) < 1e-3;
|
||||
}
|
||||
|
||||
// speed-up for sequential invocations within the same event loop cycle
|
||||
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
|
||||
function getStyle(el) {
|
||||
let style = styleCache.get(el);
|
||||
if (!style) {
|
||||
style = getComputedStyle(el);
|
||||
styleCache.set(el, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
styleCache.clear();
|
||||
}
|
||||
})();
|
|
@ -1,4 +1,5 @@
|
|||
/* global CodeMirror colorConverter */
|
||||
/* global CodeMirror */
|
||||
/* global colorConverter */
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
|
@ -99,7 +100,7 @@
|
|||
const cache = new Set();
|
||||
|
||||
class ColorSwatch {
|
||||
constructor(cm, options) {
|
||||
constructor(cm, options = {}) {
|
||||
this.cm = cm;
|
||||
this.options = options;
|
||||
this.markersToRemove = [];
|
|
@ -38,7 +38,7 @@ class Reporter {
|
|||
* @param {Object} ruleset - The set of rules to work with, including if
|
||||
* they are errors or warnings.
|
||||
* @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) {
|
||||
this.messages = [];
|
||||
|
@ -145,7 +145,7 @@ var CSSLint = (() => {
|
|||
.slice()
|
||||
.sort((a, b) =>
|
||||
a.id < b.id ? -1 :
|
||||
a.id > b.id ? 1 : 0);
|
||||
a.id > b.id ? 1 : 0);
|
||||
},
|
||||
|
||||
getRuleset() {
|
||||
|
@ -204,7 +204,8 @@ var CSSLint = (() => {
|
|||
try {
|
||||
parser.parse(text, {reuseCache});
|
||||
} 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 = {
|
||||
|
@ -324,23 +325,8 @@ var CSSLint = (() => {
|
|||
//endregion
|
||||
//region Util
|
||||
|
||||
// expose for testing purposes
|
||||
CSSLint._Reporter = Reporter;
|
||||
|
||||
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) {
|
||||
for (const e of [
|
||||
'document',
|
||||
|
@ -643,7 +629,7 @@ CSSLint.addRule({
|
|||
if (inKeyFrame &&
|
||||
typeof inKeyFrame === 'string' &&
|
||||
name.startsWith(inKeyFrame) ||
|
||||
CSSLint.Util.indexOf(applyTo, name) < 0) {
|
||||
applyTo.indexOf(name) < 0) {
|
||||
return;
|
||||
}
|
||||
properties.push(event.property);
|
||||
|
@ -657,7 +643,7 @@ CSSLint.addRule({
|
|||
for (const name of properties) {
|
||||
for (const prop in compatiblePrefixes) {
|
||||
const variations = compatiblePrefixes[prop];
|
||||
if (CSSLint.Util.indexOf(variations, name.text) <= -1) continue;
|
||||
if (variations.indexOf(name.text) <= -1) continue;
|
||||
|
||||
if (!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].actualNodes.push(name);
|
||||
}
|
||||
|
@ -680,7 +666,7 @@ CSSLint.addRule({
|
|||
if (value.full.length <= actual.length) continue;
|
||||
|
||||
for (const item of value.full) {
|
||||
if (CSSLint.Util.indexOf(actual, item) !== -1) continue;
|
||||
if (actual.indexOf(item) !== -1) continue;
|
||||
|
||||
const propertiesSpecified =
|
||||
actual.length === 1 ?
|
||||
|
@ -1122,7 +1108,9 @@ CSSLint.addRule({
|
|||
parser.addListener('import', () => count++);
|
||||
parser.addListener('endstylesheet', () => {
|
||||
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) {
|
||||
parser.addListener('property', event => {
|
||||
if (event.invalid) {
|
||||
reporter.report(event.invalid.message, event.line, event.col, this);
|
||||
}
|
||||
const inv = event.invalid;
|
||||
if (inv) reporter.report(inv.message, inv.line, inv.col, this);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -1422,13 +1409,10 @@ CSSLint.addRule({
|
|||
init(parser, reporter) {
|
||||
parser.addListener('startrule', event => {
|
||||
for (const {parts} of event.selectors) {
|
||||
for (let p = 0, pLen = parts.length; p < pLen; p++) {
|
||||
for (let n = p + 1; n < pLen; n++) {
|
||||
if (parts[p].type === 'descendant' &&
|
||||
parts[n].line > parts[p].line) {
|
||||
reporter.report('newline character found in selector (forgot a comma?)',
|
||||
parts[p].line, parts[0].col, this);
|
||||
}
|
||||
for (let i = 0, p, pn; i < parts.length - 1 && (p = parts[i]); i++) {
|
||||
if (p.type === 'descendant' && (pn = parts[i + 1]).line > p.line) {
|
||||
reporter.report('newline character found in selector (forgot a comma?)',
|
||||
pn.line, pn.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({
|
||||
id: 'star-property-hack',
|
||||
name: 'Disallow properties with a star prefix',
|
|
@ -676,8 +676,9 @@ self.parserlib = (() => {
|
|||
x: 'resolution',
|
||||
ar: 'dimension',
|
||||
};
|
||||
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]+/yu;
|
||||
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]+/yu;
|
||||
// Sticky `y` flag must be used in expressions used with peekTest and readMatch
|
||||
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 rxUnquotedUrlCharNoEsc = /[-!#$%&*-[\]-~\u00A0-\uFFFF]+/yu; // must not match \\
|
||||
const rxVendorPrefix = /^-(webkit|moz|ms|o)-(.+)/i;
|
||||
|
@ -1174,6 +1175,7 @@ self.parserlib = (() => {
|
|||
//#region Tokens
|
||||
|
||||
/* https://www.w3.org/TR/css3-syntax/#lexical */
|
||||
/** @type {Object<string,number|Object>} */
|
||||
const Tokens = Object.assign([], {
|
||||
EOF: {}, // must be the first token
|
||||
}, {
|
||||
|
@ -1530,8 +1532,10 @@ self.parserlib = (() => {
|
|||
|
||||
constructor(matchFunc, toString, options) {
|
||||
this.matchFunc = matchFunc;
|
||||
/** @type {function(?number):string} */
|
||||
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
|
||||
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) {
|
||||
const text = (modifier ? modifier + ' ' : '') +
|
||||
(mediaType ? mediaType : '') +
|
||||
|
@ -2045,9 +2044,6 @@ self.parserlib = (() => {
|
|||
* including multiple selectors (those separated by commas).
|
||||
*/
|
||||
class Selector extends SyntaxUnit {
|
||||
/**
|
||||
* @param {SelectorPart[]} parts
|
||||
*/
|
||||
constructor(parts, pos) {
|
||||
super(parts.join(' '), pos, TYPES.SELECTOR_TYPE);
|
||||
this.parts = parts;
|
||||
|
@ -2061,10 +2057,6 @@ self.parserlib = (() => {
|
|||
* Does not include combinators such as spaces, +, >, etc.
|
||||
*/
|
||||
class SelectorPart extends SyntaxUnit {
|
||||
/**
|
||||
* @param {String} elementName or null if there's none
|
||||
* @param {SelectorSubPart[]} modifiers - may be empty
|
||||
*/
|
||||
constructor(elementName, modifiers, text, pos) {
|
||||
super(text, pos, TYPES.SELECTOR_PART_TYPE);
|
||||
this.elementName = elementName;
|
||||
|
@ -2076,9 +2068,6 @@ self.parserlib = (() => {
|
|||
* Selector modifier string
|
||||
*/
|
||||
class SelectorSubPart extends SyntaxUnit {
|
||||
/**
|
||||
* @param {string} type - elementName id class attribute pseudo any not
|
||||
*/
|
||||
constructor(text, type, pos) {
|
||||
super(text, pos, TYPES.SELECTOR_SUB_PART_TYPE);
|
||||
this.type = type;
|
||||
|
@ -2136,21 +2125,15 @@ self.parserlib = (() => {
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
/**
|
||||
* @return {int} The numeric value for the specificity.
|
||||
*/
|
||||
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() {
|
||||
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.
|
||||
* @param {Selector} The selector to calculate specificity for.
|
||||
* @param {Selector} selector The selector to calculate specificity for.
|
||||
* @return {Specificity} The specificity of the selector.
|
||||
*/
|
||||
static calculate(selector) {
|
||||
|
@ -2192,7 +2175,6 @@ self.parserlib = (() => {
|
|||
class PropertyName extends SyntaxUnit {
|
||||
constructor(text, hack, pos) {
|
||||
super(text, pos, TYPES.PROPERTY_NAME_TYPE);
|
||||
// type of IE hack applied ("*", "_", or null).
|
||||
this.hack = hack;
|
||||
}
|
||||
toString() {
|
||||
|
@ -2205,9 +2187,6 @@ self.parserlib = (() => {
|
|||
* separated by commas, this type represents just one of the values.
|
||||
*/
|
||||
class PropertyValue extends SyntaxUnit {
|
||||
/**
|
||||
* @param {PropertyValuePart[]} parts An array of value parts making up this value.
|
||||
*/
|
||||
constructor(parts, pos) {
|
||||
super(parts.join(' '), pos, TYPES.PROPERTY_VALUE_TYPE);
|
||||
this.parts = parts;
|
||||
|
@ -2225,12 +2204,7 @@ self.parserlib = (() => {
|
|||
const {value, type} = token;
|
||||
super(value, token, TYPES.PROPERTY_VALUE_PART_TYPE);
|
||||
this.tokenType = type;
|
||||
if (token.expr) this.expr = token.expr;
|
||||
// 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;
|
||||
this.expr = token.expr || null;
|
||||
switch (type) {
|
||||
case Tokens.ANGLE:
|
||||
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 {
|
||||
/**
|
||||
* @param {PropertyValue} value
|
||||
|
@ -2368,7 +2341,7 @@ self.parserlib = (() => {
|
|||
|
||||
/** @param {PropertyValuePart} p */
|
||||
function vtIsIdent(p) {
|
||||
return p.type === 'identifier' || p.wasIdent;
|
||||
return p.tokenType === Tokens.IDENT;
|
||||
}
|
||||
|
||||
/** @param {PropertyValuePart} p */
|
||||
|
@ -2684,13 +2657,19 @@ self.parserlib = (() => {
|
|||
const reader = this._reader;
|
||||
/** @namespace parserlib.Token */
|
||||
const tok = {
|
||||
value: '',
|
||||
type: Tokens.CHAR,
|
||||
col: reader._col,
|
||||
line: reader._line,
|
||||
offset: reader._cursor,
|
||||
};
|
||||
const a = tok.value = reader.read();
|
||||
const b = reader.peek();
|
||||
let a = tok.value = reader.read();
|
||||
let b = reader.peek();
|
||||
if (a === '\\') {
|
||||
if (b === '\n' || b === '\f') return tok;
|
||||
a = this.readEscape();
|
||||
b = reader.peek();
|
||||
}
|
||||
switch (a) {
|
||||
case ' ':
|
||||
case '\n':
|
||||
|
@ -2744,7 +2723,7 @@ self.parserlib = (() => {
|
|||
case "'":
|
||||
return this.stringToken(a, tok);
|
||||
case '#':
|
||||
if ((rxNameChar.lastIndex = 0, rxNameChar.test(b))) {
|
||||
if (rxNameChar.test(b)) {
|
||||
tok.type = Tokens.HASH;
|
||||
tok.value = this.readName(a);
|
||||
}
|
||||
|
@ -2767,7 +2746,7 @@ self.parserlib = (() => {
|
|||
}
|
||||
} else if (b >= '0' && b <= '9' || b === '.' && reader.peekTest(/\.\d/y)) {
|
||||
this.numberToken(a, tok);
|
||||
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(b))) {
|
||||
} else if (rxIdentStart.test(b)) {
|
||||
this.identOrFunctionToken(a, tok);
|
||||
} else {
|
||||
tok.type = Tokens.MINUS;
|
||||
|
@ -2805,10 +2784,6 @@ self.parserlib = (() => {
|
|||
tok.value = '<!--';
|
||||
}
|
||||
return tok;
|
||||
case '\\':
|
||||
return b !== '\r' && b !== '\n' && b !== '\f' ?
|
||||
this.identOrFunctionToken(this.readEscape(), tok) :
|
||||
tok;
|
||||
// EOF
|
||||
case null:
|
||||
tok.type = Tokens.EOF;
|
||||
|
@ -2821,7 +2796,7 @@ self.parserlib = (() => {
|
|||
}
|
||||
if (a >= '0' && a <= '9') {
|
||||
this.numberToken(a, tok);
|
||||
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(a))) {
|
||||
} else if (rxIdentStart.test(a)) {
|
||||
this.identOrFunctionToken(a, tok);
|
||||
} else {
|
||||
tok.type = typeMap.get(a) || Tokens.CHAR;
|
||||
|
@ -2911,7 +2886,7 @@ self.parserlib = (() => {
|
|||
let tt = Tokens.NUMBER;
|
||||
let units, type;
|
||||
const c = reader.peek();
|
||||
if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(c))) {
|
||||
if (rxIdentStart.test(c)) {
|
||||
units = this.readName(reader.read());
|
||||
type = UNITS[units] || UNITS[lower(units)];
|
||||
tt = type && Tokens[type.toUpperCase()] ||
|
||||
|
@ -3063,6 +3038,76 @@ self.parserlib = (() => {
|
|||
this._reader.readCount(2 - first.length) +
|
||||
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
|
||||
|
@ -3191,9 +3236,9 @@ self.parserlib = (() => {
|
|||
for (const msg of messages) {
|
||||
const {line, col} = msg;
|
||||
if (L1 === L2 && line === L1 && C1 <= col && col <= C2 ||
|
||||
line === L1 && col >= C1 ||
|
||||
line === L2 && col <= C2 ||
|
||||
line > L1 && line < L2) {
|
||||
line === L1 && col >= C1 ||
|
||||
line === L2 && col <= C2 ||
|
||||
line > L1 && line < L2) {
|
||||
messages.delete(msg);
|
||||
isClean = false;
|
||||
}
|
||||
|
@ -3347,11 +3392,13 @@ self.parserlib = (() => {
|
|||
class Parser extends EventTarget {
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.starHack] - allows IE6 star hack
|
||||
* @param {Boolean} [options.underscoreHack] - interprets leading underscores as IE6-7 for known properties
|
||||
* @param {Boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing syntax errors
|
||||
* @param {Boolean} [options.strict] - stop on errors instead of reporting them and continuing
|
||||
* @param {Boolean} [options.skipValidation] - skip syntax validation
|
||||
* @param {boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing
|
||||
* @param {boolean} [options.skipValidation] - skip syntax validation
|
||||
* @param {boolean} [options.starHack] - allows IE6 star hack
|
||||
* @param {boolean} [options.strict] - stop on errors instead of reporting them and continuing
|
||||
* @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) {
|
||||
super();
|
||||
|
@ -3361,14 +3408,12 @@ self.parserlib = (() => {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {String|{type: string, ...}} event
|
||||
* @param {Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
|
||||
* @param {string|Object} event
|
||||
* @param {parserlib.Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
|
||||
*/
|
||||
fire(event, token = this._tokenStream._token) {
|
||||
if (typeof event === 'string') {
|
||||
event = {type: event};
|
||||
} else if (event.message && event.message.includes('/*[[')) {
|
||||
return;
|
||||
}
|
||||
if (event.offset === undefined && token) {
|
||||
event.offset = token.offset;
|
||||
|
@ -3393,18 +3438,28 @@ self.parserlib = (() => {
|
|||
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 {
|
||||
let action = Parser.ACTIONS.stylesheet.get(tt);
|
||||
let action = allowedActions.get(tt);
|
||||
if (action) {
|
||||
action.call(this, stream.get(true));
|
||||
action.call(this, token);
|
||||
continue;
|
||||
}
|
||||
action = Parser.ACTIONS.stylesheetMisplaced.get(tt);
|
||||
if (action) {
|
||||
action.call(this, stream.get(true), true);
|
||||
action.call(this, token, true);
|
||||
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) {
|
||||
stream.throwUnexpected(stream.get(true));
|
||||
}
|
||||
|
@ -3677,10 +3732,14 @@ self.parserlib = (() => {
|
|||
}
|
||||
stream.mustMatch(Tokens.LBRACE);
|
||||
this.fire({type: 'startdocument', functions, prefix}, start);
|
||||
this._ws();
|
||||
let action;
|
||||
do action = Parser.ACTIONS.document.get(stream.peek());
|
||||
while (action ? action.call(this, stream.get(true)) || true : this._ruleset());
|
||||
if (this.options.topDocOnly) {
|
||||
stream.readDeclValue({stopOn: '}'});
|
||||
} else {
|
||||
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);
|
||||
this.fire({type: 'enddocument', functions, prefix});
|
||||
this._ws();
|
||||
|
@ -3960,22 +4019,11 @@ self.parserlib = (() => {
|
|||
|
||||
_negation(start) {
|
||||
const stream = this._tokenStream;
|
||||
let value = start.value + this._ws();
|
||||
const value = [start.value, this._ws()];
|
||||
const args = this._selectorsGroup();
|
||||
if (!args) stream.throwUnexpected(stream.LT(1));
|
||||
const arg = args[0];
|
||||
const parts = arg.parts;
|
||||
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]});
|
||||
value.push(...args, this._ws(), stream.mustMatch(Tokens.RPAREN).value);
|
||||
return Object.assign(new SelectorSubPart(fastJoin(value), 'not', start), {args});
|
||||
}
|
||||
|
||||
_declaration(consumeSemicolon) {
|
||||
|
@ -4061,66 +4109,13 @@ self.parserlib = (() => {
|
|||
}
|
||||
|
||||
_customProperty() {
|
||||
const stream = this._tokenStream;
|
||||
const reader = stream._reader;
|
||||
const value = [];
|
||||
// These chars belong to the parent if used at the top nesting level of the property's value
|
||||
const UNGET = ';!})';
|
||||
let end = UNGET;
|
||||
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;
|
||||
}
|
||||
const value = this._tokenStream.readDeclValue();
|
||||
if (value) {
|
||||
const token = this._tokenStream._token;
|
||||
token.value = value;
|
||||
token.type = Tokens.IDENT;
|
||||
return new PropertyValue([new PropertyValuePart(token)], token);
|
||||
}
|
||||
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) {
|
||||
|
@ -4400,64 +4395,12 @@ self.parserlib = (() => {
|
|||
}
|
||||
|
||||
_unknownSym(start) {
|
||||
const stream = this._tokenStream;
|
||||
if (this.options.strict) {
|
||||
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();
|
||||
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() {
|
||||
|
@ -4577,6 +4520,12 @@ self.parserlib = (() => {
|
|||
[Tokens.NAMESPACE_SYM, Parser.prototype._namespace],
|
||||
]),
|
||||
|
||||
topDoc: new Map([
|
||||
symDocument,
|
||||
symUnknown,
|
||||
[Tokens.S, Parser.prototype._ws],
|
||||
]),
|
||||
|
||||
document: new Map([
|
||||
symMedia,
|
||||
symDocMisplaced,
|
|
@ -1,19 +1,22 @@
|
|||
/* global
|
||||
$
|
||||
$create
|
||||
$createLink
|
||||
API
|
||||
debounce
|
||||
deepCopy
|
||||
messageBox
|
||||
prefs
|
||||
setupLivePrefs
|
||||
t
|
||||
*/
|
||||
/* exported configDialog */
|
||||
/* global $ $create $createLink $remove messageBoxProxy setupLivePrefs */// dom.js
|
||||
/* global API */// msg.js
|
||||
/* global debounce deepCopy */// toolbox.js
|
||||
/* global messageBox */
|
||||
/* global prefs */
|
||||
/* global t */// localization.js
|
||||
'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;
|
||||
let saving = false;
|
||||
|
||||
|
@ -32,7 +35,7 @@ function configDialog(style) {
|
|||
renderValues();
|
||||
vars.forEach(renderValueState);
|
||||
|
||||
return messageBox({
|
||||
return messageBoxProxy.show({
|
||||
title: `${style.customName || style.name} v${data.version}`,
|
||||
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
|
||||
contents: [
|
||||
|
@ -82,7 +85,7 @@ function configDialog(style) {
|
|||
adjustSizeForPopup(box);
|
||||
}
|
||||
|
||||
box.addEventListener('change', onchange);
|
||||
box.on('change', onchange);
|
||||
buttons.save = $('[data-cmd="save"]', box);
|
||||
buttons.default = $('[data-cmd="default"]', box);
|
||||
buttons.close = $('[data-cmd="close"]', box);
|
||||
|
@ -118,20 +121,18 @@ function configDialog(style) {
|
|||
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
|
||||
}
|
||||
|
||||
function save({anyChangeIsDirty = false} = {}, bgStyle) {
|
||||
if (saving) {
|
||||
debounce(save, 0, ...arguments);
|
||||
return;
|
||||
async function save({anyChangeIsDirty = false} = {}, bgStyle) {
|
||||
for (let delay = 1; saving && delay < 1000; delay *= 2) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
if (!vars.length ||
|
||||
!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
|
||||
if (saving) {
|
||||
throw 'Could not save: still saving previous results...';
|
||||
}
|
||||
if (!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
|
||||
return;
|
||||
}
|
||||
if (!bgStyle) {
|
||||
API.getStyle(style.id, true)
|
||||
.catch(() => ({}))
|
||||
.then(bgStyle => save({anyChangeIsDirty}, bgStyle));
|
||||
return;
|
||||
bgStyle = await API.styles.get(style.id).catch(() => ({}));
|
||||
}
|
||||
style = style.sections ? Object.assign({}, style) : style;
|
||||
style.enabled = true;
|
||||
|
@ -147,13 +148,12 @@ function configDialog(style) {
|
|||
if (!bgva) {
|
||||
error = 'deleted';
|
||||
delete styleVars[va.name];
|
||||
} else
|
||||
if (bgva.type !== va.type) {
|
||||
} else if (bgva.type !== va.type) {
|
||||
error = ['type ', '*' + va.type, ' != ', '*' + bgva.type];
|
||||
} else
|
||||
if ((va.type === 'select' || va.type === 'dropdown') &&
|
||||
!isDefault(va) &&
|
||||
bgva.options.every(o => o.name !== va.value)) {
|
||||
} else if (
|
||||
(va.type === 'select' || va.type === 'dropdown') &&
|
||||
!isDefault(va) && bgva.options.every(o => o.name !== va.value)
|
||||
) {
|
||||
error = `'${va.value}' not in the updated '${va.type}' list`;
|
||||
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
|
||||
continue;
|
||||
|
@ -182,22 +182,22 @@ function configDialog(style) {
|
|||
return;
|
||||
}
|
||||
saving = true;
|
||||
return API.configUsercssVars(style.id, style.usercssData.vars)
|
||||
.then(newVars => {
|
||||
varsInitial = getInitialValues(newVars);
|
||||
vars.forEach(va => onchange({target: va.input, justSaved: true}));
|
||||
renderValues();
|
||||
updateButtons();
|
||||
$.remove('.config-error');
|
||||
})
|
||||
.catch(errors => {
|
||||
const el = $('.config-error', messageBox.element) ||
|
||||
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
|
||||
el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors.message || String(errors);
|
||||
})
|
||||
.then(() => {
|
||||
saving = false;
|
||||
});
|
||||
try {
|
||||
const newVars = await API.usercss.configVars(style.id, style.usercssData.vars);
|
||||
varsInitial = getInitialValues(newVars);
|
||||
vars.forEach(va => onchange({target: va.input, justSaved: true}));
|
||||
renderValues();
|
||||
updateButtons();
|
||||
$remove('.config-error');
|
||||
} catch (errors) {
|
||||
const el = $('.config-error', messageBox.element) ||
|
||||
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
|
||||
el.textContent =
|
||||
el.title = (Array.isArray(errors) ? errors : [errors])
|
||||
.map(e => e.message || `${e}`)
|
||||
.join('\n');
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
function useDefault() {
|
||||
|
@ -401,7 +401,7 @@ function configDialog(style) {
|
|||
|
||||
function showColorpicker(event) {
|
||||
event.preventDefault();
|
||||
window.removeEventListener('keydown', messageBox.listeners.key, true);
|
||||
window.off('keydown', messageBox.listeners.key, true);
|
||||
const box = $('#message-box-contents');
|
||||
colorpicker.show({
|
||||
va: this.va,
|
||||
|
@ -424,7 +424,7 @@ function configDialog(style) {
|
|||
|
||||
function restoreEscInDialog() {
|
||||
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;
|
||||
contents.style = '';
|
||||
|
||||
const colorpicker = document.body.appendChild(
|
||||
const elPicker = document.body.appendChild(
|
||||
$create('.colorpicker-popup', {style: 'display: none!important'}));
|
||||
const PADDING = 50;
|
||||
const MIN_WIDTH = parseFloat(getComputedStyle(colorpicker).width) || 350;
|
||||
const MIN_WIDTH = parseFloat(getComputedStyle(elPicker).width) || 350;
|
||||
const MIN_HEIGHT = 250 + PADDING;
|
||||
colorpicker.remove();
|
||||
elPicker.remove();
|
||||
|
||||
width = constrain(MIN_WIDTH, 798, width + PADDING);
|
||||
height = constrain(MIN_HEIGHT, 598, height + PADDING);
|
|
@ -1,13 +1,17 @@
|
|||
/* global
|
||||
$
|
||||
$create
|
||||
animateElement
|
||||
focusAccessibility
|
||||
moveFocus
|
||||
t
|
||||
*/
|
||||
/* global $ $create animateElement focusAccessibility moveFocus */// dom.js
|
||||
/* global t */// localization.js
|
||||
'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 {String} params.title
|
||||
|
@ -26,22 +30,25 @@
|
|||
* resolves to an object with optionally present properties depending on the interaction:
|
||||
* {button: Number, enter: Boolean, esc: Boolean}
|
||||
*/
|
||||
function messageBox({
|
||||
messageBox.show = async ({
|
||||
title,
|
||||
contents,
|
||||
className = '',
|
||||
buttons = [],
|
||||
onshow,
|
||||
blockScroll,
|
||||
}) {
|
||||
initOwnListeners();
|
||||
}) => {
|
||||
await require(['/js/dlg/message-box.css']);
|
||||
if (!messageBox.listeners) initOwnListeners();
|
||||
bindGlobalListeners();
|
||||
createElement();
|
||||
document.body.appendChild(messageBox.element);
|
||||
|
||||
messageBox.originalFocus = document.activeElement;
|
||||
// skip external links like feedback
|
||||
while ((moveFocus(messageBox.element, 1) || {}).target === '_blank') {/*NOP*/}
|
||||
messageBox._originalFocus = document.activeElement;
|
||||
// focus the first focusable child but skip the first external link which is usually `feedback`
|
||||
if ((moveFocus(messageBox.element, 0) || {}).target === '_blank') {
|
||||
moveFocus(messageBox.element, 1);
|
||||
}
|
||||
// suppress focus outline when invoked via click
|
||||
if (focusAccessibility.lastFocusedViaClick && document.activeElement) {
|
||||
document.activeElement.dataset.focusedViaClick = '';
|
||||
|
@ -56,12 +63,12 @@ function messageBox({
|
|||
$('#message-box-close-icon').hidden = true;
|
||||
}
|
||||
|
||||
return new Promise(_resolve => {
|
||||
messageBox.resolve = _resolve;
|
||||
return new Promise(resolve => {
|
||||
messageBox._resolve = resolve;
|
||||
});
|
||||
|
||||
function initOwnListeners() {
|
||||
messageBox.listeners = messageBox.listeners || {
|
||||
messageBox.listeners = {
|
||||
closeIcon() {
|
||||
resolveWith({button: -1});
|
||||
},
|
||||
|
@ -93,18 +100,18 @@ function messageBox({
|
|||
resolveWith(key === 'Enter' ? {enter: true} : {esc: true});
|
||||
},
|
||||
scroll() {
|
||||
scrollTo(blockScroll.x, blockScroll.y);
|
||||
scrollTo(messageBox._blockScroll.x, messageBox._blockScroll.y);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWith(value) {
|
||||
setTimeout(messageBox._resolve, 0, value);
|
||||
unbindGlobalListeners();
|
||||
setTimeout(messageBox.resolve, 0, value);
|
||||
animateElement(messageBox.element, 'fadeout')
|
||||
.then(removeSelf);
|
||||
if (messageBox.element.contains(document.activeElement)) {
|
||||
messageBox.originalFocus.focus();
|
||||
messageBox._originalFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,33 +144,33 @@ function messageBox({
|
|||
}
|
||||
|
||||
function bindGlobalListeners() {
|
||||
blockScroll = blockScroll && {x: scrollX, y: scrollY};
|
||||
messageBox._blockScroll = blockScroll && {x: scrollX, y: scrollY};
|
||||
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() {
|
||||
window.removeEventListener('keydown', messageBox.listeners.key, true);
|
||||
window.removeEventListener('scroll', messageBox.listeners.scroll);
|
||||
window.off('keydown', messageBox.listeners.key, true);
|
||||
window.off('scroll', messageBox.listeners.scroll);
|
||||
}
|
||||
|
||||
function removeSelf() {
|
||||
messageBox.element.remove();
|
||||
messageBox.element = null;
|
||||
messageBox.resolve = null;
|
||||
messageBox._resolve = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {String|Node|Array<String|Node>} contents
|
||||
* @param {String} [className] like 'pre' for monospace font
|
||||
* @param {String} [title]
|
||||
* @returns {Promise<Boolean>} same as messageBox
|
||||
* @returns {Promise<Boolean>} same as show()
|
||||
*/
|
||||
messageBox.alert = (contents, className, title) =>
|
||||
messageBox({
|
||||
messageBox.show({
|
||||
title,
|
||||
contents,
|
||||
className: `center ${className || ''}`,
|
||||
|
@ -176,10 +183,12 @@ messageBox.alert = (contents, className, title) =>
|
|||
* @param {String} [title]
|
||||
* @returns {Promise<Boolean>} resolves to true when confirmed
|
||||
*/
|
||||
messageBox.confirm = (contents, className, title) =>
|
||||
messageBox({
|
||||
messageBox.confirm = async (contents, className, title) => {
|
||||
const res = await messageBox.show({
|
||||
title,
|
||||
contents,
|
||||
className: `center ${className || ''}`,
|
||||
buttons: [t('confirmYes'), t('confirmNo')],
|
||||
}).then(result => result.button === 0 || result.enter);
|
||||
});
|
||||
return res.button === 0 || res.enter;
|
||||
};
|
797
js/dom.js
797
js/dom.js
|
@ -1,139 +1,201 @@
|
|||
/* global debounce */// toolbox.js
|
||||
/* global prefs */
|
||||
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
|
||||
setupLivePrefs moveFocus */
|
||||
'use strict';
|
||||
|
||||
if (!/^Win\d+/.test(navigator.platform)) {
|
||||
document.documentElement.classList.add('non-windows');
|
||||
}
|
||||
/* exported
|
||||
$$remove
|
||||
$createLink
|
||||
$isTextInput
|
||||
animateElement
|
||||
getEventKeyName
|
||||
messageBoxProxy
|
||||
moveFocus
|
||||
scrollElementIntoView
|
||||
setupLivePrefs
|
||||
*/
|
||||
|
||||
Object.assign(EventTarget.prototype, {
|
||||
on: addEventListener,
|
||||
off: removeEventListener,
|
||||
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
|
||||
onOff(enable, ...args) {
|
||||
(enable ? addEventListener : removeEventListener).apply(this, args);
|
||||
});
|
||||
|
||||
//#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 = {}) =>
|
||||
el.localName === 'textarea' ||
|
||||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
|
||||
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);
|
||||
}
|
||||
|
||||
$.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;
|
||||
if (el) {
|
||||
el.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$$.remove = (selector, base = document) => {
|
||||
function $$remove(selector, base = document) {
|
||||
for (const el of base.querySelectorAll(selector)) {
|
||||
el.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
|
||||
const addTooltipsToEllipsized = () => {
|
||||
for (const btn of document.getElementsByTagName('button')) {
|
||||
if (btn.title && !btn.titleIsForEllipsis) {
|
||||
continue;
|
||||
/*
|
||||
$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'
|
||||
*/
|
||||
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;
|
||||
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 = '';
|
||||
case 'tag':
|
||||
case 'appendChild':
|
||||
break;
|
||||
default: {
|
||||
if (ns) {
|
||||
const i = key.indexOf(':') + 1;
|
||||
const attrNS = i && `http://www.w3.org/1999/${key.slice(0, i - 1)}`;
|
||||
element.setAttributeNS(attrNS || null, key, val);
|
||||
} else {
|
||||
element[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function $createLink(href = '', content) {
|
||||
const opt = {
|
||||
tag: 'a',
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
};
|
||||
// enqueue after DOMContentLoaded/load events
|
||||
setTimeout(addTooltipsToEllipsized, 500);
|
||||
// throttle on continuous resizing
|
||||
let timer;
|
||||
window.on('resize', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(addTooltipsToEllipsized, 100);
|
||||
});
|
||||
if (typeof href === 'object') {
|
||||
Object.assign(opt, href);
|
||||
} else {
|
||||
opt.href = href;
|
||||
}
|
||||
opt.appendChild = opt.appendChild || content;
|
||||
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 {string} [cls] - class name that defines or starts an animation
|
||||
|
@ -162,234 +224,19 @@ function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
function enforceInputRange(element) {
|
||||
const min = Number(element.min);
|
||||
const max = Number(element.max);
|
||||
const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
const onChange = ({type}) => {
|
||||
if (type === 'input' && element.checkValidity()) {
|
||||
doNotify();
|
||||
} else if (type === 'change' && !element.checkValidity()) {
|
||||
element.value = Math.max(min, Math.min(max, Number(element.value)));
|
||||
doNotify();
|
||||
}
|
||||
};
|
||||
element.on('change', onChange);
|
||||
element.on('input', onChange);
|
||||
}
|
||||
|
||||
|
||||
function $(selector, base = document) {
|
||||
// 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});
|
||||
function getEventKeyName(e, letterAsCode) {
|
||||
const mods =
|
||||
(e.shiftKey ? 'Shift-' : '') +
|
||||
(e.ctrlKey ? 'Ctrl-' : '') +
|
||||
(e.altKey ? 'Alt-' : '') +
|
||||
(e.metaKey ? 'Meta-' : '');
|
||||
return `${
|
||||
mods === e.key + '-' ? '' : mods
|
||||
}${
|
||||
e.key
|
||||
? e.key.length === 1 && letterAsCode ? e.code : e.key
|
||||
: 'Mouse' + ('LMR'[e.button] || e.button)
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -417,69 +264,237 @@ function moveFocus(rootElement, step) {
|
|||
}
|
||||
}
|
||||
|
||||
// Accepts an array of pref names (values are fetched via prefs.get)
|
||||
// and establishes a two-way connection between the document elements and the actual prefs
|
||||
function setupLivePrefs(
|
||||
IDs = Object.getOwnPropertyNames(prefs.defaults)
|
||||
.filter(id => $('#' + id))
|
||||
) {
|
||||
for (const id of IDs) {
|
||||
const element = $('#' + id);
|
||||
updateElement({id, element, force: true});
|
||||
element.on('change', onChange);
|
||||
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);
|
||||
}
|
||||
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() {
|
||||
const value = getInputValue(this);
|
||||
if (prefs.get(this.id) !== value) {
|
||||
prefs.set(this.id, value);
|
||||
}
|
||||
prefs.set(this.id, this[getPropName(this)]);
|
||||
}
|
||||
function updateElement({
|
||||
id,
|
||||
value = prefs.get(id),
|
||||
element = $('#' + id),
|
||||
force,
|
||||
}) {
|
||||
if (!element) {
|
||||
prefs.unsubscribe(IDs, updateElement);
|
||||
return;
|
||||
}
|
||||
setInputValue(element, value, force);
|
||||
|
||||
function getPropName(el) {
|
||||
return el.type === 'checkbox' ? 'checked'
|
||||
: el.type === 'number' ? 'valueAsNumber' :
|
||||
'value';
|
||||
}
|
||||
function getInputValue(input) {
|
||||
if (input.type === 'checkbox') {
|
||||
return input.checked;
|
||||
}
|
||||
if (input.type === 'number') {
|
||||
return Number(input.value);
|
||||
}
|
||||
return input.value;
|
||||
}
|
||||
function setInputValue(input, value, force = false) {
|
||||
if (force || getInputValue(input) !== value) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
|
||||
function updateElement(id, value) {
|
||||
const el = $('#' + id);
|
||||
if (el) {
|
||||
const prop = getPropName(el);
|
||||
if (el[prop] !== value || forceUpdate) {
|
||||
el[prop] = value;
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
|
||||
} else {
|
||||
prefs.unsubscribe(ids, updateElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* exported getEventKeyName */
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
* @param {boolean} [letterAsCode] - use locale-independent KeyA..KeyZ for single-letter chars
|
||||
* @param {string} selector - beware of $ quirks with `#dotted.id` that won't work with $$
|
||||
* @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) {
|
||||
const mods =
|
||||
(e.shiftKey ? 'Shift-' : '') +
|
||||
(e.ctrlKey ? 'Ctrl-' : '') +
|
||||
(e.altKey ? 'Alt-' : '') +
|
||||
(e.metaKey ? 'Meta-' : '');
|
||||
return (mods === e.key + '-' ? '' : mods) +
|
||||
(e.key.length === 1 && letterAsCode ? e.code : e.key);
|
||||
function waitForSelector(selector, {recur, stopOnDomReady = true} = {}) {
|
||||
let el = $(selector);
|
||||
let elems, isResolved;
|
||||
return el && (!recur || recur(el, (elems = $$(selector))) === false)
|
||||
? Promise.resolve(el)
|
||||
: new Promise(resolve => {
|
||||
const mo = new MutationObserver(() => {
|
||||
if (!el) el = $(selector);
|
||||
if (!el) return;
|
||||
if (!recur ||
|
||||
callRecur() === false ||
|
||||
stopOnDomReady && document.readyState === 'complete') {
|
||||
mo.disconnect();
|
||||
}
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
mo.observe(document, {childList: true, subtree: true});
|
||||
});
|
||||
function callRecur() {
|
||||
const all = $$(selector); // simpler and faster than analyzing each node in `mutations`
|
||||
const added = !elems ? all : all.filter(el => !elems.includes(el));
|
||||
if (added.length) {
|
||||
elems = all;
|
||||
return recur(added[0], added);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
//#region Internals
|
||||
|
||||
(() => {
|
||||
|
||||
const Collapsible = {
|
||||
bindEvents(_, elems) {
|
||||
const prefKeys = [];
|
||||
for (const el of elems) {
|
||||
prefKeys.push(el.dataset.pref);
|
||||
($('h2', el) || el).on('click', Collapsible.saveOnClick);
|
||||
}
|
||||
prefs.subscribe(prefKeys, Collapsible.updateOnPrefChange, {runNow: true});
|
||||
},
|
||||
canSave(el) {
|
||||
return !el.matches('.compact-layout .ignore-pref-if-compact');
|
||||
},
|
||||
async saveOnClick(event) {
|
||||
if (event.target.closest('.intercepts-click')) {
|
||||
event.preventDefault();
|
||||
} else {
|
||||
const el = event.target.closest('details');
|
||||
await new Promise(setTimeout);
|
||||
if (Collapsible.canSave(el)) {
|
||||
prefs.set(el.dataset.pref, el.open);
|
||||
}
|
||||
}
|
||||
},
|
||||
updateOnPrefChange(key, value) {
|
||||
const el = $(`details[data-pref="${key}"]`);
|
||||
if (el.open !== value && Collapsible.canSave(el)) {
|
||||
el.open = value;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
window.on('mousedown', suppressFocusRingOnClick, {passive: true});
|
||||
window.on('keydown', keepFocusRingOnTabbing, {passive: true});
|
||||
|
||||
if (!/^Win\d+/.test(navigator.platform)) {
|
||||
document.documentElement.classList.add('non-windows');
|
||||
}
|
||||
// set language for a) CSS :lang pseudo and b) hyphenation
|
||||
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
|
||||
document.on('click', keepAddressOnDummyClick);
|
||||
document.on('wheel', changeFocusedInputOnWheel, {capture: true, passive: false});
|
||||
|
||||
Promise.resolve().then(async () => {
|
||||
if (!chrome.app) addFaviconFF();
|
||||
await prefs.ready;
|
||||
waitForSelector('details[data-pref]', {recur: Collapsible.bindEvents});
|
||||
});
|
||||
|
||||
onDOMready().then(() => {
|
||||
$remove('#firefox-transitions-bug-suppressor');
|
||||
debounce(addTooltipsToEllipsized, 500);
|
||||
window.on('resize', () => debounce(addTooltipsToEllipsized, 100));
|
||||
});
|
||||
|
||||
function addFaviconFF() {
|
||||
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
|
||||
for (const size of [38, 32, 19, 16]) {
|
||||
document.head.appendChild($create('link', {
|
||||
rel: 'icon',
|
||||
href: `/images/icon/${iconset}${size}.png`,
|
||||
sizes: size + 'x' + size,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function changeFocusedInputOnWheel(event) {
|
||||
const el = document.activeElement;
|
||||
if (!el || el !== event.target && !el.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
const isSelect = el.tagName === 'SELECT';
|
||||
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
|
||||
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
|
||||
const old = el[key];
|
||||
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
|
||||
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
|
||||
if (el[key] !== old) {
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
/** Displays a full text tooltip on buttons with ellipsis overflow and no inherent title */
|
||||
function addTooltipsToEllipsized() {
|
||||
for (const btn of document.getElementsByTagName('button')) {
|
||||
if (btn.title && !btn.titleIsForEllipsis) {
|
||||
continue;
|
||||
}
|
||||
const width = btn.offsetWidth;
|
||||
if (!width || btn.preresizeClientWidth === width) {
|
||||
continue;
|
||||
}
|
||||
btn.preresizeClientWidth = width;
|
||||
if (btn.scrollWidth > width) {
|
||||
const text = btn.textContent;
|
||||
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
|
||||
btn.titleIsForEllipsis = true;
|
||||
} else if (btn.title) {
|
||||
btn.title = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keepAddressOnDummyClick(e) {
|
||||
// avoid adding # to the page URL when clicking dummy links
|
||||
if (e.target.closest('a[href="#"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function keepFocusRingOnTabbing(event) {
|
||||
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
focusAccessibility.lastFocusedViaClick = false;
|
||||
setTimeout(() => {
|
||||
let el = document.activeElement;
|
||||
if (el) {
|
||||
el = el.closest('[data-focused-via-click]');
|
||||
if (el) delete el.dataset.focusedViaClick;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function suppressFocusRingOnClick({target}) {
|
||||
const el = focusAccessibility.closest(target);
|
||||
if (el) {
|
||||
focusAccessibility.lastFocusedViaClick = true;
|
||||
if (el.dataset.focusedViaClick === undefined) {
|
||||
el.dataset.focusedViaClick = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
'use strict';
|
||||
|
||||
function t(key, params) {
|
||||
//#region Exports
|
||||
|
||||
function t(key, params, strict = true) {
|
||||
const s = chrome.i18n.getMessage(key, params);
|
||||
if (!s) throw `Missing string "${key}"`;
|
||||
if (!s && strict) throw `Missing string "${key}"`;
|
||||
return s;
|
||||
}
|
||||
|
||||
Object.assign(t, {
|
||||
template: {},
|
||||
DOMParser: new DOMParser(),
|
||||
ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','),
|
||||
parser: new DOMParser(),
|
||||
ALLOWED_TAGS: ['a', 'b', 'code', 'i', 'sub', 'sup', 'wbr'],
|
||||
RX_WORD_BREAK: new RegExp([
|
||||
'(',
|
||||
/[\d\w\u007B-\uFFFF]{10}/,
|
||||
|
@ -103,7 +105,7 @@ Object.assign(t, {
|
|||
},
|
||||
|
||||
createHtml(str, trusted) {
|
||||
const root = t.DOMParser.parseFromString(str, 'text/html').body;
|
||||
const root = t.parser.parseFromString(str, 'text/html').body;
|
||||
if (!trusted) {
|
||||
t.sanitizeHtml(root);
|
||||
} else if (str.includes('i18n-')) {
|
||||
|
@ -156,6 +158,9 @@ Object.assign(t, {
|
|||
},
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Internals
|
||||
|
||||
(() => {
|
||||
const observer = new MutationObserver(process);
|
||||
let observing = false;
|
||||
|
@ -187,3 +192,5 @@ Object.assign(t, {
|
|||
}
|
||||
}
|
||||
})();
|
||||
|
||||
//#endregion
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
/* global usercssMeta colorConverter */
|
||||
/* exported metaParser */
|
||||
'use strict';
|
||||
|
||||
/* exported metaParser */
|
||||
const metaParser = (() => {
|
||||
require(['/vendor/usercss-meta/usercss-meta.min']); /* global usercssMeta */
|
||||
const {createParser, ParseError} = usercssMeta;
|
||||
const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
|
||||
const options = {
|
||||
|
@ -27,6 +27,7 @@ const metaParser = (() => {
|
|||
}
|
||||
},
|
||||
color: state => {
|
||||
require(['/js/color/color-converter']); /* global colorConverter */
|
||||
const color = colorConverter.parse(state.value);
|
||||
if (!color) {
|
||||
throw new ParseError({
|
||||
|
@ -40,39 +41,27 @@ const metaParser = (() => {
|
|||
},
|
||||
};
|
||||
const parser = createParser(options);
|
||||
const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'}));
|
||||
const looseParser = createParser(Object.assign({}, options, {
|
||||
allowErrors: true,
|
||||
unknownKey: 'throw',
|
||||
}));
|
||||
|
||||
return {
|
||||
parse,
|
||||
lint,
|
||||
nullifyInvalidVars,
|
||||
|
||||
lint: looseParser.parse,
|
||||
parse: parser.parse,
|
||||
|
||||
nullifyInvalidVars(vars) {
|
||||
for (const va of Object.values(vars)) {
|
||||
if (va.value !== null) {
|
||||
try {
|
||||
parser.validateVar(va);
|
||||
} catch (err) {
|
||||
va.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
},
|
||||
};
|
||||
|
||||
function parse(text, indexOffset) {
|
||||
try {
|
||||
return parser.parse(text);
|
||||
} catch (err) {
|
||||
if (typeof err.index === 'number') {
|
||||
err.index += indexOffset;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function lint(text) {
|
||||
return looseParser.parse(text);
|
||||
}
|
||||
|
||||
function nullifyInvalidVars(vars) {
|
||||
for (const va of Object.values(vars)) {
|
||||
if (va.value === null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
parser.validateVar(va);
|
||||
} catch (err) {
|
||||
va.value = null;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1,24 +1,29 @@
|
|||
/* global parserlib */
|
||||
/* exported parseMozFormat */
|
||||
'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.
|
||||
* Puts the global comments into the following section to minimize the amount of global sections.
|
||||
* Doesn't move the comment with ==UserStyle== inside.
|
||||
* @param {string} code
|
||||
* @param {number} styleId - used to preserve parserCache on subsequent runs over the same style
|
||||
* @param {Object} _
|
||||
* @param {string} _.code
|
||||
* @param {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}}
|
||||
* @property {?number} lastStyleId
|
||||
*/
|
||||
function parseMozFormat({code, styleId}) {
|
||||
const CssToProperty = {
|
||||
'url': 'urls',
|
||||
'url-prefix': 'urlPrefixes',
|
||||
'domain': 'domains',
|
||||
'regexp': 'regexps',
|
||||
};
|
||||
function extractSections({code, styleId, fast = true}) {
|
||||
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 errors = [];
|
||||
const sections = [];
|
||||
|
@ -34,7 +39,6 @@ function parseMozFormat({code, styleId}) {
|
|||
};
|
||||
// move last comment before @-moz-document inside the section
|
||||
if (!lastCmt.includes('AGENT_SHEET') &&
|
||||
!lastCmt.includes('==') &&
|
||||
!/==userstyle==/i.test(lastCmt)) {
|
||||
if (lastCmt) {
|
||||
section.code = lastCmt + '\n';
|
||||
|
@ -48,12 +52,12 @@ function parseMozFormat({code, styleId}) {
|
|||
lastSection.code = '';
|
||||
}
|
||||
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];
|
||||
if (p0 && aType === 'regexps') {
|
||||
const s = p0.text;
|
||||
if (hasSingleEscapes.test(p0.text)) {
|
||||
const isQuoted = (s.startsWith('"') || s.startsWith("'")) && s.endsWith(s[0]);
|
||||
const isQuoted = /^['"]/.test(s) && s.endsWith(s[0]);
|
||||
p0.value = isQuoted ? s.slice(1, -1) : s;
|
||||
}
|
||||
}
|
||||
|
@ -78,17 +82,24 @@ function parseMozFormat({code, styleId}) {
|
|||
});
|
||||
|
||||
parser.addListener('error', e => {
|
||||
errors.push(`${e.line}:${e.col} ${e.message.replace(/ at line \d.+$/, '')}`);
|
||||
errors.push(e);
|
||||
});
|
||||
|
||||
try {
|
||||
parser.parse(mozStyle, {
|
||||
reuseCache: !parseMozFormat.styleId || styleId === parseMozFormat.styleId,
|
||||
reuseCache: !extractSections.lastStyleId || styleId === extractSections.lastStyleId,
|
||||
});
|
||||
} 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};
|
||||
|
||||
function doAddSection(section) {
|
||||
|
|
131
js/msg.js
131
js/msg.js
|
@ -1,8 +1,9 @@
|
|||
/* global deepCopy getOwnTab URLS */ // not used in content scripts
|
||||
/* global URLS deepCopy deepMerge getOwnTab */// toolbox.js - not used in content scripts
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
window.INJECTED !== 1 && (() => {
|
||||
(() => {
|
||||
if (window.INJECTED === 1) return;
|
||||
|
||||
const TARGETS = Object.assign(Object.create(null), {
|
||||
all: ['both', 'tab', 'extension'],
|
||||
extension: ['both', 'extension'],
|
||||
|
@ -21,38 +22,12 @@ window.INJECTED !== 1 && (() => {
|
|||
extension: new Set(),
|
||||
};
|
||||
|
||||
let bg = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage();
|
||||
const isBg = bg === window;
|
||||
if (!isBg && (!bg || !bg.document || bg.document.readyState === 'loading')) {
|
||||
bg = null;
|
||||
}
|
||||
// TODO: maybe move into polyfill.js and hook addListener to wrap/unwrap automatically
|
||||
chrome.runtime.onMessage.addListener(onRuntimeMessage);
|
||||
|
||||
// TODO: maybe move into polyfill.js and hook addListener + sendMessage so they wrap/unwrap automatically
|
||||
const wrapData = data => ({
|
||||
data,
|
||||
});
|
||||
const wrapError = error => ({
|
||||
error: Object.assign({
|
||||
message: error.message || `${error}`,
|
||||
stack: error.stack,
|
||||
}, error), // passing custom properties e.g. `error.index`
|
||||
});
|
||||
const unwrapResponse = ({data, error} = {error: {message: ERR_NO_RECEIVER}}) =>
|
||||
error
|
||||
? Promise.reject(Object.assign(new Error(error.message), error))
|
||||
: data;
|
||||
chrome.runtime.onMessage.addListener(({data, target}, sender, sendResponse) => {
|
||||
const res = window.msg._execute(TARGETS[target] || TARGETS.all, data, sender);
|
||||
if (res instanceof Promise) {
|
||||
res.then(wrapData, wrapError).then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
if (res !== undefined) sendResponse(wrapData(res));
|
||||
});
|
||||
|
||||
// This direct assignment allows IDEs to provide autocomplete for msg methods automatically
|
||||
const msg = window.msg = {
|
||||
isBg,
|
||||
|
||||
isBg: getExtBg() === window,
|
||||
|
||||
async broadcast(data) {
|
||||
const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
|
||||
|
@ -73,8 +48,8 @@ window.INJECTED !== 1 && (() => {
|
|||
},
|
||||
|
||||
isIgnorableError(err) {
|
||||
const msg = `${err && err.message || err}`;
|
||||
return msg.includes(ERR_NO_RECEIVER) || msg.includes(ERR_PORT_CLOSED);
|
||||
const text = `${err && err.message || err}`;
|
||||
return text.includes(ERR_NO_RECEIVER) || text.includes(ERR_PORT_CLOSED);
|
||||
},
|
||||
|
||||
ignoreError(err) {
|
||||
|
@ -113,6 +88,11 @@ window.INJECTED !== 1 && (() => {
|
|||
|
||||
_execute(types, ...args) {
|
||||
let result;
|
||||
if (!(args[0] instanceof Object)) {
|
||||
/* Data from other windows must be deep-copied to allow for GC in Chrome and
|
||||
merely survive in FF as it kills cross-window objects when their tab is closed. */
|
||||
args = args.map(deepCopy);
|
||||
}
|
||||
for (const type of types) {
|
||||
for (const fn of handler[type]) {
|
||||
let res;
|
||||
|
@ -130,27 +110,64 @@ window.INJECTED !== 1 && (() => {
|
|||
},
|
||||
};
|
||||
|
||||
window.API = new Proxy({}, {
|
||||
get(target, name) {
|
||||
// using a named function for convenience when debugging
|
||||
return async function invokeAPI(...args) {
|
||||
if (!bg && chrome.tabs) {
|
||||
bg = await browser.runtime.getBackgroundPage().catch(() => {});
|
||||
}
|
||||
const message = {method: 'invokeAPI', name, args};
|
||||
// content scripts and probably private tabs
|
||||
if (!bg) {
|
||||
return msg.send(message);
|
||||
}
|
||||
// in FF, the object would become a dead object when the window
|
||||
// is closed, so we have to clone the object into background.
|
||||
const res = bg.msg._execute(TARGETS.extension, bg.deepCopy(message), {
|
||||
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
|
||||
tab: NEEDS_TAB_IN_SENDER.includes(name) && await getOwnTab(),
|
||||
url: location.href,
|
||||
});
|
||||
return deepCopy(await res);
|
||||
};
|
||||
function getExtBg() {
|
||||
const fn = chrome.extension.getBackgroundPage;
|
||||
const bg = fn && fn();
|
||||
return bg === window || bg && bg.msg && bg.msg.isBgReady ? bg : null;
|
||||
}
|
||||
|
||||
function onRuntimeMessage({data, target}, sender, sendResponse) {
|
||||
const res = msg._execute(TARGETS[target] || TARGETS.all, data, sender);
|
||||
if (res instanceof Promise) {
|
||||
res.then(wrapData, wrapError).then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
if (res !== undefined) sendResponse(wrapData(res));
|
||||
}
|
||||
|
||||
function wrapData(data) {
|
||||
return {data};
|
||||
}
|
||||
|
||||
function wrapError(error) {
|
||||
return {
|
||||
error: Object.assign({
|
||||
message: error.message || `${error}`,
|
||||
stack: error.stack,
|
||||
}, error), // passing custom properties e.g. `error.index`
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapResponse({data, error} = {error: {message: ERR_NO_RECEIVER}}) {
|
||||
return error
|
||||
? Promise.reject(Object.assign(new Error(error.message), error))
|
||||
: data;
|
||||
}
|
||||
|
||||
const apiHandler = !msg.isBg && {
|
||||
get({path}, name) {
|
||||
const fn = () => {};
|
||||
fn.path = [...path, name];
|
||||
return new Proxy(fn, apiHandler);
|
||||
},
|
||||
});
|
||||
async apply({path}, thisObj, args) {
|
||||
const bg = getExtBg() ||
|
||||
chrome.tabs && await browser.runtime.getBackgroundPage().catch(() => {});
|
||||
const message = {method: 'invokeAPI', path, args};
|
||||
let res;
|
||||
// content scripts, probably private tabs, and our extension tab during Chrome startup
|
||||
if (!bg) {
|
||||
res = msg.send(message);
|
||||
} else {
|
||||
res = deepMerge(await bg.msg._execute(TARGETS.extension, message, {
|
||||
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
|
||||
tab: NEEDS_TAB_IN_SENDER.includes(path.join('.')) && await getOwnTab(),
|
||||
url: location.href,
|
||||
}));
|
||||
}
|
||||
return res;
|
||||
},
|
||||
};
|
||||
/** @type {API} */
|
||||
window.API = msg.isBg ? {} : new Proxy({path: []}, apiHandler);
|
||||
})();
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
'use strict';
|
||||
|
||||
// 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
|
||||
|
||||
|
@ -66,6 +69,35 @@ self.INJECTED !== 1 && (() => {
|
|||
|
||||
//#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')) {
|
||||
// TODO: remove when minimum_chrome_version >= 61
|
||||
window.URLSearchParams = class extends URLSearchParams {
|
||||
|
|
85
js/prefs.js
85
js/prefs.js
|
@ -1,11 +1,21 @@
|
|||
/* global msg API */
|
||||
/* global deepCopy debounce */ // not used in content scripts
|
||||
/* global API msg */// msg.js
|
||||
/* global debounce deepMerge */// toolbox.js - not used in content scripts
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
window.INJECTED !== 1 && (() => {
|
||||
(() => {
|
||||
if (window.INJECTED === 1) return;
|
||||
|
||||
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 = {
|
||||
'openEditInWindow': false, // new editor opens in a own browser window
|
||||
'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)
|
||||
};
|
||||
const knownKeys = Object.keys(defaults);
|
||||
/** @type {PrefsValues} */
|
||||
const values = clone(defaults);
|
||||
const onChange = {
|
||||
any: new Set(),
|
||||
specific: {},
|
||||
};
|
||||
// getPrefs may fail on browser startup in the active tab as it loads before the background script
|
||||
const initializing = (msg.isBg ? readStorage() : API.getPrefs().catch(readStorage))
|
||||
.then(setAll);
|
||||
// API fails in the active tab during Chrome startup as it loads the tab before bg
|
||||
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
|
||||
let ready = (msg.isBg ? readStorage() : API.prefs.getValues().catch(readStorage))
|
||||
.then(data => {
|
||||
setAll(data);
|
||||
ready = true;
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
const data = area === 'sync' && changes[STORAGE_KEY];
|
||||
if (data) {
|
||||
await initializing;
|
||||
if (ready.then) await ready;
|
||||
setAll(data.newValue);
|
||||
}
|
||||
});
|
||||
|
||||
// This direct assignment allows IDEs to provide correct autocomplete for methods
|
||||
const prefs = window.prefs = {
|
||||
|
||||
STORAGE_KEY,
|
||||
initializing,
|
||||
defaults,
|
||||
knownKeys,
|
||||
ready,
|
||||
/** @type {PrefsValues} */
|
||||
defaults: new Proxy({}, {
|
||||
get: (_, key) => clone(defaults[key]),
|
||||
}),
|
||||
/** @type {PrefsValues} */
|
||||
get values() {
|
||||
return deepCopy(values);
|
||||
return clone(values);
|
||||
},
|
||||
|
||||
__defaults: defaults, // direct reference, be careful!
|
||||
__values: values, // direct reference, be careful!
|
||||
|
||||
get(key) {
|
||||
return isKnown(key) && values[key];
|
||||
const res = values[key];
|
||||
if (res !== undefined || isKnown(key)) {
|
||||
return clone(res);
|
||||
}
|
||||
},
|
||||
|
||||
set(key, val, isSynced) {
|
||||
if (!isKnown(key)) return;
|
||||
const oldValue = values[key];
|
||||
|
@ -155,36 +184,45 @@ window.INJECTED !== 1 && (() => {
|
|||
emitChange(key, val, isSynced);
|
||||
}
|
||||
},
|
||||
|
||||
reset(key) {
|
||||
prefs.set(key, clone(defaults[key]));
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
|
||||
* @param {function(key:string, value:any)} fn
|
||||
* @param {function(key:string?, value:any?)} fn
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.now] - when truthy, the listener is called immediately:
|
||||
* @param {boolean} [opts.runNow] - when truthy, the listener is called immediately:
|
||||
* 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
|
||||
* 2) if `keys` is falsy, no key/value will be provided
|
||||
*/
|
||||
subscribe(keys, fn, {now} = {}) {
|
||||
async subscribe(keys, fn, {runNow} = {}) {
|
||||
const toRun = [];
|
||||
if (keys) {
|
||||
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
||||
if (!isKnown(key)) continue;
|
||||
const listeners = onChange.specific[key] ||
|
||||
(onChange.specific[key] = new Set());
|
||||
listeners.add(fn);
|
||||
if (now) fn(key, values[key]);
|
||||
if (runNow) toRun.push({fn, key});
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
for (const [k, fn] of Object.entries(data)) {
|
||||
prefs.subscribe(k, fn, opts);
|
||||
}
|
||||
},
|
||||
|
||||
unsubscribe(keys, fn) {
|
||||
if (keys) {
|
||||
for (const key of keys) {
|
||||
|
@ -203,7 +241,7 @@ window.INJECTED !== 1 && (() => {
|
|||
};
|
||||
|
||||
function isKnown(key) {
|
||||
const res = defaults.hasOwnProperty(key);
|
||||
const res = knownKeys.includes(key);
|
||||
if (!res) console.warn('Unknown preference "%s"', key);
|
||||
return res;
|
||||
}
|
||||
|
@ -228,14 +266,13 @@ window.INJECTED !== 1 && (() => {
|
|||
if (msg.isBg) {
|
||||
debounce(updateStorage);
|
||||
} else {
|
||||
API.setPref(key, value);
|
||||
API.prefs.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readStorage() {
|
||||
return browser.storage.sync.get(STORAGE_KEY)
|
||||
.then(data => data[STORAGE_KEY]);
|
||||
async function readStorage() {
|
||||
return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
|
||||
}
|
||||
|
||||
function updateStorage() {
|
||||
|
|
121
js/router.js
121
js/router.js
|
@ -1,66 +1,17 @@
|
|||
/* global deepEqual msg */
|
||||
/* exported router */
|
||||
/* global deepEqual */// toolbox.js
|
||||
/* global msg */
|
||||
'use strict';
|
||||
|
||||
const router = (() => {
|
||||
const buffer = [];
|
||||
const 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};
|
||||
const router = {
|
||||
buffer: [],
|
||||
watchers: [],
|
||||
|
||||
function watch(options, callback) {
|
||||
/* Watch search params or hash and get notified on change.
|
||||
|
||||
options: {search?: Array<key: String>, hash?: String}
|
||||
callback: (Array<value: String | null> | Boolean) => void
|
||||
|
||||
`hash` should always start with '#'.
|
||||
When watching search params, callback receives a list of values.
|
||||
When watching hash, callback receives a boolean.
|
||||
*/
|
||||
watchers.push({options, callback});
|
||||
}
|
||||
|
||||
function 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) {
|
||||
getSearch(key) {
|
||||
return new URLSearchParams(location.search).get(key);
|
||||
}
|
||||
},
|
||||
|
||||
function update(replace) {
|
||||
update(replace) {
|
||||
const {buffer} = router;
|
||||
if (!buffer.length) {
|
||||
buffer.push(location.href);
|
||||
} else if (buffer[buffer.length - 1] === location.href) {
|
||||
|
@ -72,7 +23,7 @@ const router = (() => {
|
|||
} else {
|
||||
buffer.push(location.href);
|
||||
}
|
||||
for (const {options, callback} of watchers) {
|
||||
for (const {options, callback} of router.watchers) {
|
||||
let state;
|
||||
if (options.hash) {
|
||||
state = options.hash === location.hash;
|
||||
|
@ -85,5 +36,55 @@ const router = (() => {
|
|||
callback(state);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} hash - empty string removes the hash
|
||||
*/
|
||||
updateHash(hash) {
|
||||
const {buffer} = router;
|
||||
if (buffer.length > 1) {
|
||||
if (!hash && !buffer[buffer.length - 2].includes('#') ||
|
||||
hash && buffer[buffer.length - 2].endsWith(hash)) {
|
||||
history.back();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!hash) {
|
||||
hash = ' ';
|
||||
}
|
||||
history.pushState(history.state, null, hash);
|
||||
router.update();
|
||||
},
|
||||
|
||||
updateSearch(key, value) {
|
||||
const u = new URL(location);
|
||||
u.searchParams[value ? 'set' : 'delete'](key, value);
|
||||
history.replaceState(history.state, null, `${u}`);
|
||||
router.update(true);
|
||||
},
|
||||
|
||||
watch(options, callback) {
|
||||
/* Watch search params or hash and get notified on change.
|
||||
|
||||
options: {search?: Array<key: String>, hash?: String}
|
||||
callback: (Array<value: String | null> | Boolean) => void
|
||||
|
||||
`hash` should always start with '#'.
|
||||
When watching search params, callback receives a list of values.
|
||||
When watching hash, callback receives a boolean.
|
||||
*/
|
||||
router.watchers.push({options, callback});
|
||||
},
|
||||
};
|
||||
|
||||
document.on('DOMContentLoaded', () => router.update());
|
||||
window.on('popstate', () => router.update());
|
||||
window.on('hashchange', () => router.update());
|
||||
msg.on(e => {
|
||||
if (e.method === 'pushState' && e.url !== location.href) {
|
||||
history.pushState(history.state, null, e.url);
|
||||
router.update();
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
/* exported loadScript */
|
||||
'use strict';
|
||||
|
||||
// loadScript(script: Array<Promise|string>|string): Promise
|
||||
const loadScript = (() => {
|
||||
const cache = new Map();
|
||||
|
||||
function inject(file) {
|
||||
if (!cache.has(file)) {
|
||||
cache.set(file, doInject(file));
|
||||
}
|
||||
return cache.get(file);
|
||||
}
|
||||
|
||||
function doInject(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let el;
|
||||
if (file.endsWith('.js')) {
|
||||
el = document.createElement('script');
|
||||
el.src = file;
|
||||
} else {
|
||||
el = document.createElement('link');
|
||||
el.rel = 'stylesheet';
|
||||
el.href = file;
|
||||
}
|
||||
el.onload = () => {
|
||||
el.onload = null;
|
||||
el.onerror = null;
|
||||
resolve(el);
|
||||
};
|
||||
el.onerror = () => {
|
||||
el.onload = null;
|
||||
el.onerror = null;
|
||||
reject(new Error(`Failed to load script: ${file}`));
|
||||
};
|
||||
document.head.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
return (files, noCache = false) => {
|
||||
if (!Array.isArray(files)) {
|
||||
files = [files];
|
||||
}
|
||||
return Promise.all(files.map(f =>
|
||||
typeof f !== 'string' ? f :
|
||||
noCache ? doInject(f) :
|
||||
inject(f)
|
||||
));
|
||||
};
|
||||
})();
|
|
@ -1,11 +1,77 @@
|
|||
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
|
||||
'use strict';
|
||||
|
||||
/* 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) {
|
||||
if (!code) {
|
||||
return true;
|
||||
}
|
||||
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
|
||||
const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
|
||||
while (rx.exec(code)) {
|
||||
if (rx.lastIndex === code.length) {
|
||||
return true;
|
||||
|
@ -17,9 +83,9 @@ function styleCodeEmpty(code) {
|
|||
/** Checks if section is global i.e. has no targets at all */
|
||||
function styleSectionGlobal(section) {
|
||||
return (!section.regexps || !section.regexps.length) &&
|
||||
(!section.urlPrefixes || !section.urlPrefixes.length) &&
|
||||
(!section.urls || !section.urls.length) &&
|
||||
(!section.domains || !section.domains.length);
|
||||
(!section.urlPrefixes || !section.urlPrefixes.length) &&
|
||||
(!section.urls || !section.urls.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
|
||||
return (sections || []).map(section => /** @namespace StyleSection */({
|
||||
code: section.code || '',
|
||||
urls: section.urls || [],
|
||||
urlPrefixes: section.urlPrefixes || [],
|
||||
domains: section.domains || [],
|
||||
regexps: section.regexps || [],
|
||||
}));
|
||||
}
|
||||
|
||||
function calcStyleDigest(style) {
|
||||
const jsonString = style.usercssData ?
|
||||
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
|
||||
const text = new TextEncoder('utf-8').encode(jsonString);
|
||||
return crypto.subtle.digest('SHA-1', text).then(hex);
|
||||
|
||||
function hex(buffer) {
|
||||
const parts = [];
|
||||
const PAD8 = '00000000';
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < view.byteLength; i += 4) {
|
||||
parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
const src = style.usercssData
|
||||
? style.sourceCode
|
||||
// retain known properties in an arbitrarily predefined order
|
||||
: JSON.stringify((style.sections || []).map(section => /** @namespace StyleSection */({
|
||||
code: section.code || '',
|
||||
urls: section.urls || [],
|
||||
urlPrefixes: section.urlPrefixes || [],
|
||||
domains: section.domains || [],
|
||||
regexps: section.regexps || [],
|
||||
})));
|
||||
const srcBytes = new TextEncoder().encode(src);
|
||||
const res = await crypto.subtle.digest('SHA-1', srcBytes);
|
||||
return Array.from(new Uint8Array(res), b => (0x100 + b).toString(16).slice(1)).join('');
|
||||
}
|
||||
|
||||
function styleJSONseemsValid(json) {
|
||||
return json
|
||||
&& json.name
|
||||
&& typeof json.name == 'string'
|
||||
&& json.name.trim()
|
||||
&& Array.isArray(json.sections)
|
||||
&& json.sections
|
||||
&& json.sections.length
|
||||
&& typeof json.sections.every === 'function'
|
||||
&& typeof json.sections[0].code === 'string';
|
||||
&& typeof (json.sections[0] || {}).code === 'string';
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
/* global loadScript tryJSONparse */
|
||||
/* global tryJSONparse */// toolbox.js
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
let LZString;
|
||||
|
||||
/** @namespace StorageExtras */
|
||||
const StorageExtras = {
|
||||
async getValue(key) {
|
||||
|
@ -14,9 +16,9 @@
|
|||
return (await this.getLZValues([key]))[key];
|
||||
},
|
||||
async getLZValues(keys = Object.values(this.LZ_KEY)) {
|
||||
const [data, LZString] = await Promise.all([
|
||||
const [data] = await Promise.all([
|
||||
this.get(keys),
|
||||
this.getLZString(),
|
||||
LZString || loadLZString(),
|
||||
]);
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
|
@ -25,16 +27,9 @@
|
|||
return data;
|
||||
},
|
||||
async setLZValue(key, value) {
|
||||
const LZString = await this.getLZString();
|
||||
if (!LZString) await loadLZString();
|
||||
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 */
|
||||
const StorageExtrasSync = {
|
||||
|
@ -44,6 +39,12 @@
|
|||
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} */
|
||||
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras);
|
||||
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
/* exported
|
||||
CHROME_POPUP_BORDER_BUG
|
||||
capitalize
|
||||
CHROME_HAS_BORDER_BUG
|
||||
closeCurrentTab
|
||||
deepEqual
|
||||
download
|
||||
getActiveTab
|
||||
getStyleWithNoCode
|
||||
getOwnTab
|
||||
getTab
|
||||
ignoreChromeError
|
||||
isEmptyObj
|
||||
onTabReady
|
||||
openURL
|
||||
sessionStore
|
||||
|
@ -15,7 +18,6 @@
|
|||
tryCatch
|
||||
tryRegExp
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]);
|
||||
const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
|
||||
|
@ -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]);
|
||||
|
||||
// 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) {
|
||||
// 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 = {
|
||||
ownOrigin: chrome.runtime.getURL(''),
|
||||
|
||||
// FIXME delete?
|
||||
optionsUI: [
|
||||
chrome.runtime.getURL('options.html'),
|
||||
'chrome://extensions/?options=' + chrome.runtime.id,
|
||||
],
|
||||
|
||||
configureCommands:
|
||||
OPERA ? 'opera://settings/configureCommands'
|
||||
: 'chrome://extensions/configureCommands',
|
||||
|
@ -75,6 +71,8 @@ const URLS = {
|
|||
// TODO: remove when "minimum_chrome_version": "61" or higher
|
||||
chromeProtectsNTP: CHROME >= 61,
|
||||
|
||||
rxMETA: /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i,
|
||||
|
||||
uso: 'https://userstyles.org/',
|
||||
usoJson: 'https://userstyles.org/styles/chrome/',
|
||||
|
||||
|
@ -84,10 +82,13 @@ const URLS = {
|
|||
url &&
|
||||
url.startsWith(URLS.usoArchiveRaw) &&
|
||||
parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]),
|
||||
extractUsoArchiveInstallUrl: url => {
|
||||
const id = URLS.extractUsoArchiveId(url);
|
||||
return id ? `${URLS.usoArchive}?style=${id}` : '';
|
||||
},
|
||||
|
||||
extractGreasyForkId: url =>
|
||||
/^https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/(\d+)[^/]*\/code\/[^/]*\.user\.css$/.test(url) &&
|
||||
RegExp.$1,
|
||||
extractGreasyForkInstallUrl: url =>
|
||||
/^(https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/\d+)[^/]*\/code\/[^/]*\.user\.css$|$/.exec(url)[1],
|
||||
|
||||
supported: url => (
|
||||
url.startsWith('http') ||
|
||||
|
@ -98,11 +99,11 @@ const URLS = {
|
|||
),
|
||||
};
|
||||
|
||||
if (chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window) {
|
||||
window.API_METHODS = {};
|
||||
} else {
|
||||
const cls = FIREFOX ? 'firefox' : OPERA ? 'opera' : VIVALDI ? 'vivaldi' : '';
|
||||
if (cls) document.documentElement.classList.add(cls);
|
||||
if (FIREFOX || OPERA || VIVALDI) {
|
||||
document.documentElement.classList.add(
|
||||
FIREFOX && 'firefox' ||
|
||||
OPERA && 'opera' ||
|
||||
VIVALDI && 'vivaldi');
|
||||
}
|
||||
|
||||
// FF57+ supports openerTabId, but not in Android
|
||||
|
@ -113,9 +114,8 @@ function getOwnTab() {
|
|||
return browser.tabs.getCurrent();
|
||||
}
|
||||
|
||||
function getActiveTab() {
|
||||
return browser.tabs.query({currentWindow: true, active: true})
|
||||
.then(tabs => tabs[0]);
|
||||
async function getActiveTab() {
|
||||
return (await browser.tabs.query({currentWindow: true, active: true}))[0];
|
||||
}
|
||||
|
||||
function urlToMatchPattern(url, ignoreSearch) {
|
||||
|
@ -133,13 +133,13 @@ function urlToMatchPattern(url, ignoreSearch) {
|
|||
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);
|
||||
return browser.tabs.query({url: urlToMatchPattern(url, ignoreSearch), currentWindow})
|
||||
// FIXME: is tab.url always normalized?
|
||||
.then(tabs => tabs.find(matchTab));
|
||||
|
||||
function matchTab(tab) {
|
||||
const tabs = await browser.tabs.query({
|
||||
url: urlToMatchPattern(url, ignoreSearch),
|
||||
currentWindow,
|
||||
});
|
||||
return tabs.find(tab => {
|
||||
const tabUrl = new URL(tab.pendingUrl || tab.url);
|
||||
return tabUrl.protocol === url.protocol &&
|
||||
tabUrl.username === url.username &&
|
||||
|
@ -149,7 +149,7 @@ function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch =
|
|||
tabUrl.pathname === url.pathname &&
|
||||
(ignoreSearch || tabUrl.search === url.search) &&
|
||||
(ignoreHash || tabUrl.hash === url.hash);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -185,7 +185,7 @@ async function openURL({
|
|||
});
|
||||
}
|
||||
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: ''};
|
||||
if (isTabReplaceable(tab, url)) {
|
||||
|
@ -196,20 +196,17 @@ async function openURL({
|
|||
return browser.tabs.create(Object.assign({url, index, active}, opener));
|
||||
}
|
||||
|
||||
// replace empty tab (NTP or about:blank)
|
||||
// except when new URL is chrome:// or chrome-extension:// and the empty tab is
|
||||
// in incognito
|
||||
/**
|
||||
* Replaces empty tab (NTP or about:blank)
|
||||
* except when new URL is chrome:// or chrome-extension:// and the empty tab is in incognito
|
||||
*/
|
||||
function isTabReplaceable(tab, newUrl) {
|
||||
if (!tab || !URLS.emptyTab.includes(tab.pendingUrl || tab.url)) {
|
||||
return false;
|
||||
}
|
||||
if (tab.incognito && newUrl.startsWith('chrome')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return tab &&
|
||||
URLS.emptyTab.includes(tab.pendingUrl || tab.url) &&
|
||||
!(tab.incognito && newUrl.startsWith('chrome'));
|
||||
}
|
||||
|
||||
function activateTab(tab, {url, index, openerTabId} = {}) {
|
||||
async function activateTab(tab, {url, index, openerTabId} = {}) {
|
||||
const options = {active: true};
|
||||
if (url) {
|
||||
options.url = url;
|
||||
|
@ -217,62 +214,64 @@ function activateTab(tab, {url, index, openerTabId} = {}) {
|
|||
if (openerTabId != null && openerTabIdSupported) {
|
||||
options.openerTabId = openerTabId;
|
||||
}
|
||||
return Promise.all([
|
||||
await Promise.all([
|
||||
browser.tabs.update(tab.id, options),
|
||||
browser.windows && browser.windows.update(tab.windowId, {focused: true}),
|
||||
index != null && browser.tabs.move(tab.id, {index}),
|
||||
])
|
||||
.then(() => tab);
|
||||
]);
|
||||
return tab;
|
||||
}
|
||||
|
||||
|
||||
function stringAsRegExp(s, flags) {
|
||||
return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags);
|
||||
function stringAsRegExp(s, flags, asString) {
|
||||
s = s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&');
|
||||
return asString ? s : new RegExp(s, flags);
|
||||
}
|
||||
|
||||
|
||||
function ignoreChromeError() {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
chrome.runtime.lastError;
|
||||
}
|
||||
|
||||
|
||||
function getStyleWithNoCode(style) {
|
||||
const stripped = deepCopy(style);
|
||||
for (const section of stripped.sections) section.code = null;
|
||||
stripped.sourceCode = null;
|
||||
return stripped;
|
||||
function isEmptyObj(obj) {
|
||||
if (obj) {
|
||||
for (const k in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// js engine can't optimize the entire function if it contains try-catch
|
||||
// so we should keep it isolated from normal code in a minimal wrapper
|
||||
// Update: might get fixed in V8 TurboFan in the future
|
||||
/**
|
||||
* js engine can't optimize the entire function if it contains try-catch
|
||||
* so we should keep it isolated from normal code in a minimal wrapper
|
||||
* 2020 update: probably fixed at least in V8
|
||||
*/
|
||||
function tryCatch(func, ...args) {
|
||||
try {
|
||||
return func(...args);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
||||
function tryRegExp(regexp, flags) {
|
||||
try {
|
||||
return new RegExp(regexp, flags);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
||||
function tryJSONparse(jsonString) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
||||
const debounce = Object.assign((fn, delay, ...args) => {
|
||||
function debounce(fn, delay, ...args) {
|
||||
clearTimeout(debounce.timers.get(fn));
|
||||
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
|
||||
}, {
|
||||
}
|
||||
|
||||
Object.assign(debounce, {
|
||||
timers: new Map(),
|
||||
run(fn, ...args) {
|
||||
debounce.timers.delete(fn);
|
||||
|
@ -284,27 +283,28 @@ const debounce = Object.assign((fn, delay, ...args) => {
|
|||
},
|
||||
});
|
||||
|
||||
|
||||
function deepCopy(obj) {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
// N.B. the copy should be an explicit literal
|
||||
if (Array.isArray(obj)) {
|
||||
const copy = [];
|
||||
for (const v of obj) {
|
||||
copy.push(!v || typeof v !== 'object' ? v : deepCopy(v));
|
||||
function deepMerge(src, dst) {
|
||||
if (!src || typeof src !== 'object') {
|
||||
return src;
|
||||
}
|
||||
if (Array.isArray(src)) {
|
||||
// using `Array` that belongs to this `window`; not using Array.from as it's slower
|
||||
if (!dst) dst = Array.prototype.map.call(src, deepCopy);
|
||||
else for (const v of src) dst.push(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 = {};
|
||||
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;
|
||||
return dst;
|
||||
}
|
||||
|
||||
/** Useful in arr.map(deepCopy) to ignore the extra parameters passed by map() */
|
||||
function deepCopy(src) {
|
||||
return deepMerge(src);
|
||||
}
|
||||
|
||||
function deepEqual(a, b, ignoredKeys) {
|
||||
if (!a || !b) return a === b;
|
||||
|
@ -371,70 +371,49 @@ function download(url, {
|
|||
requiredStatusCode = 200,
|
||||
timeout = 60e3, // connection timeout, USO is that bad
|
||||
loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
|
||||
headers = {
|
||||
'Content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
headers,
|
||||
} = {}) {
|
||||
const queryPos = url.indexOf('?');
|
||||
if (queryPos > 0 && body === undefined) {
|
||||
method = 'POST';
|
||||
body = url.slice(queryPos);
|
||||
url = url.slice(0, queryPos);
|
||||
/* USO can't handle POST requests for style json and XHR/fetch can't handle super long URL
|
||||
* so we need to collapse all long variables and expand them in the response */
|
||||
const queryPos = url.startsWith(URLS.uso) ? url.indexOf('?') : -1;
|
||||
if (queryPos >= 0) {
|
||||
if (body === undefined) {
|
||||
method = 'POST';
|
||||
body = url.slice(queryPos);
|
||||
url = url.slice(0, queryPos);
|
||||
}
|
||||
if (headers === undefined) {
|
||||
headers = {
|
||||
'Content-type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
}
|
||||
}
|
||||
// * 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 = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr;
|
||||
const xhr = new XMLHttpRequest();
|
||||
const u = new URL(collapseUsoVars(url));
|
||||
const onTimeout = () => {
|
||||
if (xhr) xhr.abort();
|
||||
xhr.abort();
|
||||
reject(new Error('Timeout fetching ' + u.href));
|
||||
};
|
||||
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 = () => {
|
||||
if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
|
||||
xhr.onreadystatechange = null;
|
||||
switchTimer();
|
||||
clearTimeout(timer);
|
||||
timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
|
||||
}
|
||||
};
|
||||
xhr.onloadend = event => {
|
||||
clearTimeout(timer);
|
||||
if (event.type !== 'error' && (
|
||||
xhr.status === requiredStatusCode || !requiredStatusCode ||
|
||||
u.protocol === 'file:')) {
|
||||
resolve(expandUsoVars(xhr.response));
|
||||
} else {
|
||||
reject(xhr.status);
|
||||
}
|
||||
};
|
||||
xhr.onerror = xhr.onloadend;
|
||||
xhr.onload = () =>
|
||||
xhr.status === requiredStatusCode || !requiredStatusCode || u.protocol === 'file:'
|
||||
? resolve(expandUsoVars(xhr.response))
|
||||
: reject(xhr.status);
|
||||
xhr.onerror = () => reject(xhr.status);
|
||||
xhr.onloadend = () => clearTimeout(timer);
|
||||
xhr.responseType = responseType;
|
||||
xhr.open(method, u.href, true);
|
||||
for (const key in headers) {
|
||||
xhr.setRequestHeader(key, headers[key]);
|
||||
xhr.open(method, u.href);
|
||||
for (const [name, value] of Object.entries(headers || {})) {
|
||||
xhr.setRequestHeader(name, value);
|
||||
}
|
||||
xhr.send(body);
|
||||
});
|
||||
|
@ -470,13 +449,10 @@ function download(url, {
|
|||
}
|
||||
}
|
||||
|
||||
function closeCurrentTab() {
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
|
||||
getOwnTab().then(tab => {
|
||||
if (tab) {
|
||||
chrome.tabs.remove(tab.id);
|
||||
}
|
||||
});
|
||||
async function closeCurrentTab() {
|
||||
// https://bugzil.la/1409375
|
||||
const tab = await getOwnTab();
|
||||
if (tab) chrome.tabs.remove(tab.id);
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
128
js/usercss-compiler.js
Normal file
128
js/usercss-compiler.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
const BUILDERS = Object.assign(Object.create(null), {
|
||||
|
||||
default: {
|
||||
post(sections, vars) {
|
||||
require(['/js/sections-util']); /* global styleCodeEmpty */
|
||||
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
|
||||
if (!varDef) return;
|
||||
varDef = ':root {\n' + varDef + '}\n';
|
||||
for (const section of sections) {
|
||||
if (!styleCodeEmpty(section.code)) {
|
||||
section.code = varDef + section.code;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
stylus: {
|
||||
pre(source, vars) {
|
||||
require(['/vendor/stylus-lang-bundle/stylus-renderer.min']); /* global StylusRenderer */
|
||||
return new Promise((resolve, reject) => {
|
||||
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
|
||||
new StylusRenderer(varDef + source)
|
||||
.render((err, output) => err ? reject(err) : resolve(output));
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
less: {
|
||||
async pre(source, vars) {
|
||||
if (!self.less) {
|
||||
self.less = {
|
||||
logLevel: 0,
|
||||
useFileCache: false,
|
||||
};
|
||||
}
|
||||
require(['/vendor/less-bundle/less.min']); /* global less */
|
||||
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
|
||||
return (await less.render(varDefs + source)).css;
|
||||
},
|
||||
},
|
||||
|
||||
uso: {
|
||||
async pre(source, vars) {
|
||||
require(['/js/color/color-converter']); /* global colorConverter */
|
||||
const pool = new Map();
|
||||
return doReplace(source);
|
||||
|
||||
function getValue(name, rgbName) {
|
||||
if (!vars.hasOwnProperty(name)) {
|
||||
if (name.endsWith('-rgb')) {
|
||||
return getValue(name.slice(0, -4), name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const {type, value} = vars[name];
|
||||
switch (type) {
|
||||
case 'color': {
|
||||
let color = pool.get(rgbName || name);
|
||||
if (color == null) {
|
||||
color = colorConverter.parse(value);
|
||||
if (color) {
|
||||
if (color.type === 'hsl') {
|
||||
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
|
||||
}
|
||||
const {r, g, b} = color;
|
||||
color = rgbName
|
||||
? `${r}, ${g}, ${b}`
|
||||
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
// the pool stores `false` for bad colors to differentiate from a yet unknown color
|
||||
pool.set(rgbName || name, color || false);
|
||||
}
|
||||
return color || null;
|
||||
}
|
||||
case 'dropdown':
|
||||
case 'select': // prevent infinite recursion
|
||||
pool.set(name, '');
|
||||
return doReplace(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function doReplace(text) {
|
||||
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
|
||||
if (!pool.has(name)) {
|
||||
const value = getValue(name);
|
||||
pool.set(name, value === null ? match : value);
|
||||
}
|
||||
return pool.get(name);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/* exported compileUsercss */
|
||||
async function compileUsercss(preprocessor, code, vars) {
|
||||
let builder = BUILDERS[preprocessor];
|
||||
if (!builder) {
|
||||
builder = BUILDERS.default;
|
||||
if (preprocessor != null) console.warn(`Unknown preprocessor "${preprocessor}"`);
|
||||
}
|
||||
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
|
||||
// need to test each va's default value.
|
||||
vars = Object.entries(vars || {}).reduce((output, [key, va]) => {
|
||||
// TODO: handle customized image
|
||||
const prop = va.value == null ? 'default' : 'value';
|
||||
const value =
|
||||
/^(select|dropdown|image)$/.test(va.type) ?
|
||||
va.options.find(o => o.name === va[prop]).value :
|
||||
/^(number|range)$/.test(va.type) && va.units ?
|
||||
va[prop] + va.units :
|
||||
va[prop];
|
||||
output[key] = Object.assign({}, va, {value});
|
||||
return output;
|
||||
}, {});
|
||||
if (builder.pre) {
|
||||
code = await builder.pre(code, vars);
|
||||
}
|
||||
require(['/js/moz-parser']); /* global extractSections */
|
||||
const res = extractSections({code});
|
||||
if (builder.post) {
|
||||
builder.post(res.sections, vars);
|
||||
}
|
||||
return res;
|
||||
}
|
103
js/usercss.js
103
js/usercss.js
|
@ -1,103 +0,0 @@
|
|||
/* global backgroundWorker */
|
||||
/* exported usercss */
|
||||
'use strict';
|
||||
|
||||
const usercss = (() => {
|
||||
const GLOBAL_METAS = {
|
||||
author: undefined,
|
||||
description: undefined,
|
||||
homepageURL: 'url',
|
||||
updateURL: 'updateUrl',
|
||||
name: undefined,
|
||||
};
|
||||
const RX_META = /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i;
|
||||
const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']);
|
||||
return {
|
||||
RX_META,
|
||||
buildMeta,
|
||||
buildCode,
|
||||
assignVars,
|
||||
};
|
||||
|
||||
function buildMeta(sourceCode) {
|
||||
sourceCode = sourceCode.replace(/\r\n?/g, '\n');
|
||||
|
||||
const style = {
|
||||
enabled: true,
|
||||
sourceCode,
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const match = sourceCode.match(RX_META);
|
||||
if (!match) {
|
||||
throw new Error('can not find metadata');
|
||||
}
|
||||
|
||||
return backgroundWorker.parseUsercssMeta(match[0], match.index)
|
||||
.catch(err => {
|
||||
if (err.code) {
|
||||
const args = ERR_ARGS_IS_LIST.has(err.code) ? drawList(err.args) : err.args;
|
||||
const message = chrome.i18n.getMessage(`meta_${err.code}`, args);
|
||||
if (message) {
|
||||
err.message = message;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.then(({metadata}) => {
|
||||
style.usercssData = metadata;
|
||||
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
|
||||
for (const [key, value] of Object.entries(GLOBAL_METAS)) {
|
||||
if (metadata[key] !== undefined) {
|
||||
style[value || key] = metadata[key];
|
||||
}
|
||||
}
|
||||
return style;
|
||||
});
|
||||
}
|
||||
|
||||
function drawList(items) {
|
||||
return items.map(i => i.length === 1 ? JSON.stringify(i) : i).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} style
|
||||
* @param {Boolean} [allowErrors=false]
|
||||
* @returns {(Style | {style: Style, errors: (false|String[])})} - style object
|
||||
* when allowErrors is falsy or {style, errors} object when allowErrors is truthy
|
||||
*/
|
||||
function buildCode(style, allowErrors) {
|
||||
const match = style.sourceCode.match(RX_META);
|
||||
return backgroundWorker.compileUsercss(
|
||||
style.usercssData.preprocessor,
|
||||
style.sourceCode.slice(0, match.index) + style.sourceCode.slice(match.index + match[0].length),
|
||||
style.usercssData.vars
|
||||
)
|
||||
.then(({sections, errors}) => {
|
||||
if (!errors.length) errors = false;
|
||||
if (!sections.length || errors && !allowErrors) {
|
||||
throw errors || 'Style does not contain any actual CSS to apply.';
|
||||
}
|
||||
style.sections = sections;
|
||||
return allowErrors ? {style, errors} : style;
|
||||
});
|
||||
}
|
||||
|
||||
function assignVars(style, oldStyle) {
|
||||
const {usercssData: {vars}} = style;
|
||||
const {usercssData: {vars: oldVars}} = oldStyle;
|
||||
if (!vars || !oldVars) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
// The type of var might be changed during the update. Set value to null if the value is invalid.
|
||||
for (const key of Object.keys(vars)) {
|
||||
if (oldVars[key] && oldVars[key].value) {
|
||||
vars[key].value = oldVars[key].value;
|
||||
}
|
||||
}
|
||||
return backgroundWorker.nullifyInvalidVars(vars)
|
||||
.then(vars => {
|
||||
style.usercssData.vars = vars;
|
||||
});
|
||||
}
|
||||
})();
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user