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
pull/1131/head
parent
06823bd5b4
commit
fdbfb23547
@ -1,4 +1,2 @@
|
||||
vendor/
|
||||
vendor-overwrites/*
|
||||
!vendor-overwrites/colorpicker
|
||||
!vendor-overwrites/csslint
|
||||
vendor-overwrites/
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
parseMozFormat(...args) {
|
||||
require(['/js/moz-parser']); /* global extractSections */
|
||||
return extractSections(...args);
|
||||
},
|
||||
|
||||
if (preprocessor) {
|
||||
if (!BUILDER[preprocessor]) {
|
||||
throw new Error('unknwon preprocessor');
|
||||
}
|
||||
return BUILDER[preprocessor];
|
||||
}
|
||||
return BUILDER.default;
|
||||
}
|
||||
parseUsercssMeta(text) {
|
||||
require(['/js/meta-parser']);
|
||||
return metaParser.parse(text);
|
||||
},
|
||||
});
|
||||
|
@ -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) {}
|
||||
}
|
||||
})();
|
@ -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;
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}),
|
||||
};
|
||||
get(id) {
|
||||
return chromeLocal.getValue(PREFIX + id);
|
||||
},
|
||||
|
||||
return {exec};
|
||||
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);
|
||||
},
|
||||
|
||||
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}`));
|
||||
}
|
||||
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++;
|
||||
}
|
||||
data[PREFIX + item.id] = item;
|
||||
}
|
||||
await chromeLocal.set(data);
|
||||
return items.map(_ => _.id);
|
||||
},
|
||||
};
|
||||
|
||||
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,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);
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
@ -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]);
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
@ -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
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
})();
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
@ -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, ' ');
|
||||
}
|
@ -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;
|
||||
}
|
||||
})();
|
@ -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
|
@ -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();
|
||||
}
|
||||
})();
|
@ -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;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
@ -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,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};
|
||||
})();
|
@ -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)),
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
@ -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,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,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 installed = null;
|
||||
let installedDup = null;
|
||||
|
||||
const liveReload = initLiveReload();
|
||||
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre'));
|
||||
|
||||
let cm;
|
||||
let initialUrl;
|
||||
let installed;
|
||||
let installedDup;
|
||||
let liveReload;
|
||||
let tabId;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
if (!style) {
|
||||
return;
|
||||
}
|
||||
const data = style.usercssData;
|
||||
const dupData = dup && dup.usercssData;
|
||||
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||
|
||||
cm.setPreprocessor(data.preprocessor);
|
||||
updateMeta(style, dup);
|
||||
|
||||
const installButtonLabel = t(
|
||||
installed ? 'installButtonInstalled' :
|
||||
!dup ? 'installButton' :
|
||||
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
|
||||
// update UI
|
||||
if (versionTest < 0) {
|
||||
$('.actions').parentNode.insertBefore(
|
||||
$create('.warning', t('versionInvalidOlder')),
|
||||
$('.actions')
|
||||
);
|
||||
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)
|
||||
)
|
||||
)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
$('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'))
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
||||
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 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';
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
|
||||
$('.meta-license').textContent = data.license;
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
$('.applies-to').textContent = '';
|
||||
getAppliesTo(style).then(list =>
|
||||
$('.applies-to').append(...list.map(s => $create('li', s))));
|
||||
|
||||
function init({style, dup}) {
|
||||
const data = style.usercssData;
|
||||
const dupData = dup && dup.usercssData;
|
||||
const versionTest = dup && semverCompare(data.version, dupData.version);
|
||||
$('.external-link').textContent = '';
|
||||
const externalLink = makeExternalLink();
|
||||
if (externalLink) {
|
||||
$('.external-link').appendChild(externalLink);
|
||||
}
|
||||
|
||||
updateMeta(style, dup);
|
||||
$('#header').dataset.arrivedFast = performance.now() < 500;
|
||||
$('#header').classList.add('meta-init');
|
||||
$('#header').classList.remove('meta-init-error');
|
||||
setTimeout(() => $$remove('.lds-spinner'), 1000);
|
||||
|
||||
// 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'))
|
||||
);
|
||||
};
|
||||
showError('');
|
||||
requestAnimationFrame(adjustCodeHeight);
|
||||
|
||||
// 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;
|
||||
function makeAuthor(text) {
|
||||
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
|
||||
if (!match) {
|
||||
return document.createTextNode(text);
|
||||
}
|
||||
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;
|
||||
const [, name, email, url] = match;
|
||||
const frag = document.createDocumentFragment();
|
||||
if (email) {
|
||||
frag.appendChild($createLink(`mailto:${email}`, name));
|
||||
} else {
|
||||
$('.live-reload').remove();
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
frag.appendChild($create('span', name));
|
||||
}
|
||||
const result = [..._gen()];
|
||||
if (!result.length) {
|
||||
result.push(chrome.i18n.getMessage('appliesToEverything'));
|
||||
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 result;
|
||||
return frag;
|
||||
}
|
||||
|
||||
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 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 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 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();
|
||||
}
|
||||
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);
|
||||
});
|
||||
$('.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();
|
||||
}
|
||||
}
|
||||
function DirectDownloader() {
|
||||
let oldCode = null;
|
||||
const passChangedCode = code => {
|
||||
const isSame = code === oldCode;
|
||||
oldCode = code;
|
||||
return isSame ? null : code;
|
||||
};
|
||||
return () => download(initialUrl).then(passChangedCode);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppliesTo(style) {
|
||||
if (style.sectionsPromise) {
|
||||
try {
|
||||
style.sections = await style.sectionsPromise;
|
||||
} catch (error) {
|
||||
showBuildError(error);
|
||||
return [];
|
||||
} finally {
|
||||
delete style.sectionsPromise;
|
||||
}
|
||||
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();
|
||||
}
|
||||
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 {
|
||||
closeCurrentTab();
|
||||
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();
|
||||
});
|
||||
return (opts = {}) => new Promise((resolve, reject) => {
|
||||
const id = performance.now();
|
||||
resolvers.set(id, {resolve, reject});
|
||||
opts.id = id;
|
||||
port.postMessage(opts);
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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};
|
||||
}
|
||||
})(),
|
||||
};
|
||||
})();
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,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)
|
||||
));
|
||||
};
|
||||
})();
|
@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
const BUILDERS = Object.assign(Object.create(null), {
|
||||
|
||||
default: {
|
||||
post(sections, vars) {
|
||||
require(['/js/sections-util']); /* global styleCodeEmpty */
|
||||
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
|
||||
if (!varDef) return;
|
||||
varDef = ':root {\n' + varDef + '}\n';
|
||||
for (const section of sections) {
|
||||
if (!styleCodeEmpty(section.code)) {
|
||||
section.code = varDef + section.code;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
stylus: {
|
||||
pre(source, vars) {
|
||||
require(['/vendor/stylus-lang-bundle/stylus-renderer.min']); /* global StylusRenderer */
|
||||
return new Promise((resolve, reject) => {
|
||||
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
|
||||
new StylusRenderer(varDef + source)
|
||||
.render((err, output) => err ? reject(err) : resolve(output));
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
less: {
|
||||
async pre(source, vars) {
|
||||
if (!self.less) {
|
||||
self.less = {
|
||||
logLevel: 0,
|
||||
useFileCache: false,
|
||||
};
|
||||
}
|
||||
require(['/vendor/less-bundle/less.min']); /* global less */
|
||||
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
|
||||
return (await less.render(varDefs + source)).css;
|
||||
},
|
||||
},
|
||||
|
||||
uso: {
|
||||
async pre(source, vars) {
|
||||
require(['/js/color/color-converter']); /* global colorConverter */
|
||||
const pool = new Map();
|
||||
return doReplace(source);
|
||||
|
||||
function getValue(name, rgbName) {
|
||||
if (!vars.hasOwnProperty(name)) {
|
||||
if (name.endsWith('-rgb')) {
|
||||
return getValue(name.slice(0, -4), name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const {type, value} = vars[name];
|
||||
switch (type) {
|
||||
case 'color': {
|
||||
let color = pool.get(rgbName || name);
|
||||
if (color == null) {
|
||||
color = colorConverter.parse(value);
|
||||
if (color) {
|
||||
if (color.type === 'hsl') {
|
||||
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
|
||||
}
|
||||
const {r, g, b} = color;
|
||||
color = rgbName
|
||||
? `${r}, ${g}, ${b}`
|
||||
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
// the pool stores `false` for bad colors to differentiate from a yet unknown color
|
||||
pool.set(rgbName || name, color || false);
|
||||
}
|
||||
return color || null;
|
||||
}
|
||||
case 'dropdown':
|
||||
case 'select': // prevent infinite recursion
|
||||
pool.set(name, '');
|
||||
return doReplace(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function doReplace(text) {
|
||||
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
|
||||
if (!pool.has(name)) {
|
||||
const value = getValue(name);
|
||||
pool.set(name, value === null ? match : value);
|
||||
}
|
||||
return pool.get(name);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/* exported compileUsercss */
|
||||
async function compileUsercss(preprocessor, code, vars) {
|
||||
let builder = BUILDERS[preprocessor];
|
||||
if (!builder) {
|
||||
builder = BUILDERS.default;
|
||||
if (preprocessor != null) console.warn(`Unknown preprocessor "${preprocessor}"`);
|
||||
}
|
||||
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
|
||||
// need to test each va's default value.
|
||||
vars = Object.entries(vars || {}).reduce((output, [key, va]) => {
|
||||
// TODO: handle customized image
|
||||
const prop = va.value == null ? 'default' : 'value';
|
||||
const value =
|
||||
/^(select|dropdown|image)$/.test(va.type) ?
|
||||
va.options.find(o => o.name === va[prop]).value :
|
||||
/^(number|range)$/.test(va.type) && va.units ?
|
||||
va[prop] + va.units :
|
||||
va[prop];
|
||||
output[key] = Object.assign({}, va, {value});
|
||||
return output;
|
||||
}, {});
|
||||
if (builder.pre) {
|
||||
code = await builder.pre(code, vars);
|
||||
}
|
||||
require(['/js/moz-parser']); /* global extractSections */
|
||||
const res = extractSections({code});
|
||||
if (builder.post) {
|
||||
builder.post(res.sections, vars);
|
||||
}
|
||||
return res;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue