use executeScript for early injection + split/async'ify

* split, regroup, and async'ify files
* consistent window scrolling in scrollToEditor and jumpToPos
* rework waitForSelector and collapsible <details>
* parserlib: fast section extraction, tweaks and speedups
* csslint: "simple-not" rule
* csslint: enable and fix "selector-newline" rule
* blank paint frame workaround for new Chrome
* extract stuff from edit.js and load on demand
* simplify regexpTester::isShown
* move MozDocMapper to sections-util.js
* extract fitSelectBox()
* initialize router earlier
* use helpPopup.close()
* fix autofocus in popups, follow-up to 5bb1b5ef
* clone objects in prefs.get() + cosmetics
* reuse getAll result for INC
This commit is contained in:
tophf 2020-12-26 15:59:49 +03:00
parent 05e1455280
commit 922c66a141
124 changed files with 7266 additions and 7589 deletions

View File

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

View File

@ -8,6 +8,9 @@ env:
es6: true
webextensions: true
globals:
require: readonly # in polyfill.js
rules:
accessor-pairs: [2]
array-bracket-spacing: [2, never]
@ -42,7 +45,15 @@ rules:
id-blacklist: [0]
id-length: [0]
id-match: [0]
indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}]
indent: [2, 2, {
SwitchCase: 1,
ignoreComments: true,
ignoredNodes: [
"TemplateLiteral > *",
"ConditionalExpression",
"ForStatement"
]
}]
jsx-quotes: [0]
key-spacing: [0]
keyword-spacing: [2]
@ -86,7 +97,7 @@ rules:
no-empty: [2, {allowEmptyCatch: true}]
no-eq-null: [0]
no-eval: [2]
no-ex-assign: [2]
no-ex-assign: [0]
no-extend-native: [2]
no-extra-bind: [2]
no-extra-boolean-cast: [2]
@ -136,6 +147,9 @@ rules:
no-proto: [2]
no-redeclare: [2]
no-regex-spaces: [2]
no-restricted-globals: [2, name, event]
# `name` and `event` (in Chrome) are built-in globals
# but we don't use these globals so it's most likely a mistake/typo
no-restricted-imports: [0]
no-restricted-modules: [2, domain, freelist, smalloc, sys]
no-restricted-syntax: [2, WithStatement]
@ -163,7 +177,7 @@ rules:
no-unreachable: [2]
no-unsafe-finally: [2]
no-unsafe-negation: [2]
no-unused-expressions: [1]
no-unused-expressions: [2]
no-unused-labels: [0]
no-unused-vars: [2, {args: after-used}]
no-use-before-define: [2, nofunc]
@ -220,3 +234,7 @@ overrides:
webextensions: false
parserOptions:
ecmaVersion: 2017
- files: ["**/*worker*.js"]
env:
worker: true

View File

@ -1,177 +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({
/** @namespace ApiWorker */
workerUtil.createAPI({
parseMozFormat(arg) {
loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js');
return parseMozFormat(arg);
},
compileUsercss,
parseUsercssMeta(text, indexOffset = 0) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
return metaParser.parse(text, indexOffset);
async compileUsercss(...args) {
require(['/js/usercss-compiler']); /* global compileUsercss */
return compileUsercss(...args);
},
nullifyInvalidVars(vars) {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
require(['/js/meta-parser']); /* global metaParser */
return metaParser.nullifyInvalidVars(vars);
},
parseMozFormat(...args) {
require(['/js/moz-parser']); /* global extractSections */
return extractSections(...args);
},
parseUsercssMeta(text) {
require(['/js/meta-parser']);
return metaParser.parse(text);
},
});
function compileUsercss(preprocessor, code, vars) {
loadScript(
'/vendor-overwrites/csslint/parserlib.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/moz-parser.js'
);
const builder = getUsercssCompiler(preprocessor);
vars = simpleVars(vars);
return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code)
.then(code => parseMozFormat({code}))
.then(({sections, errors}) => {
if (builder.postprocess) {
builder.postprocess(sections, vars);
}
return {sections, errors};
});
function simpleVars(vars) {
if (!vars) {
return {};
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
return Object.keys(vars).reduce((output, key) => {
const va = vars[key];
output[key] = Object.assign({}, va, {
value: va.value === null || va.value === undefined ?
getVarValue(va, 'default') : getVarValue(va, 'value'),
});
return output;
}, {});
}
function getVarValue(va, prop) {
if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') {
// TODO: handle customized image
return va.options.find(o => o.name === va[prop]).value;
}
if ((va.type === 'number' || va.type === 'range') && va.units) {
return va[prop] + va.units;
}
return va[prop];
}
}
function getUsercssCompiler(preprocessor) {
const BUILDER = {
default: {
postprocess(sections, vars) {
loadScript('/js/sections-util.js');
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n';
for (const section of sections) {
if (!styleCodeEmpty(section.code)) {
section.code = varDef + section.code;
}
}
},
},
stylus: {
preprocess(source, vars) {
loadScript('/vendor/stylus-lang-bundle/stylus-renderer.min.js');
return new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
new self.StylusRenderer(varDef + source)
.render((err, output) => err ? reject(err) : resolve(output));
});
},
},
less: {
preprocess(source, vars) {
if (!self.less) {
self.less = {
logLevel: 0,
useFileCache: false,
};
}
loadScript('/vendor/less-bundle/less.min.js');
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
return self.less.render(varDefs + source)
.then(({css}) => css);
},
},
uso: {
preprocess(source, vars) {
loadScript('/vendor-overwrites/colorpicker/colorconverter.js');
const pool = new Map();
return Promise.resolve(doReplace(source));
function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), name);
}
return null;
}
const {type, value} = vars[name];
switch (type) {
case 'color': {
let color = pool.get(rgbName || name);
if (color == null) {
color = colorConverter.parse(value);
if (color) {
if (color.type === 'hsl') {
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
}
const {r, g, b} = color;
color = rgbName
? `${r}, ${g}, ${b}`
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
// the pool stores `false` for bad colors to differentiate from a yet unknown color
pool.set(rgbName || name, color || false);
}
return color || null;
}
case 'dropdown':
case 'select': // prevent infinite recursion
pool.set(name, '');
return doReplace(value);
}
return value;
}
function doReplace(text) {
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
if (!pool.has(name)) {
const value = getValue(name);
pool.set(name, value === null ? match : value);
}
return pool.get(name);
});
}
},
},
};
if (preprocessor) {
if (!BUILDER[preprocessor]) {
throw new Error('unknwon preprocessor');
}
return BUILDER[preprocessor];
}
return BUILDER.default;
}

View File

@ -1,42 +1,43 @@
/* global API msg */// msg.js
/* global addAPI bgReady */// common.js
/* global createWorker */// worker-util.js
/* global prefs */
/* global styleMan */
/* global syncMan */
/* global updateMan */
/* global usercssMan */
/* global
activateTab
API
chromeLocal
findExistingTab
FIREFOX
URLS
activateTab
download
findExistingTab
getActiveTab
isTabReplaceable
msg
openURL
prefs
semverCompare
URLS
workerUtil
*/
*/ // toolbox.js
'use strict';
//#region API
Object.assign(API, {
addAPI(/** @namespace API */ {
/** @type {ApiWorker} */
worker: workerUtil.createWorker({
url: '/background/background-worker.js',
}),
styles: styleMan,
sync: syncMan,
updater: updateMan,
usercss: usercssMan,
/** @type {BackgroundWorker} */
worker: createWorker({url: '/background/background-worker'}),
download(url, opts) {
return typeof url === 'string' && url.startsWith(URLS.uso) &&
this.sender.url.startsWith(URLS.uso) &&
download(url, opts || {});
},
/** @returns {string} */
getTabUrlPrefix() {
const {url} = this.sender.tab;
if (url.startsWith(URLS.ownOrigin)) {
return 'stylus';
}
return url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
/** @returns {Prefs} */
getPrefs: () => prefs.values,
setPref(key, value) {
prefs.set(key, value);
return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1];
},
/**
@ -118,65 +119,63 @@ Object.assign(API, {
}));
}
},
prefs: {
getValues: () => prefs.__values, // will be deepCopy'd by apiHandler
set: prefs.set,
},
});
//#endregion
//#region browserCommands
//#region Events
const browserCommands = {
openManage: () => API.openManage(),
openOptions: () => API.openManage({options: true}),
reload: () => chrome.runtime.reload(),
styleDisableAll(info) {
prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
},
reload: () => chrome.runtime.reload(),
};
if (chrome.commands) {
chrome.commands.onCommand.addListener(command => browserCommands[command]());
}
if (FIREFOX && browser.commands && browser.commands.update) {
// register hotkeys in FF
const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, (name, value) => {
try {
name = name.split('.')[1];
if (value.trim()) {
browser.commands.update({name, shortcut: value});
} else {
browser.commands.reset(name);
}
} catch (e) {}
});
chrome.commands.onCommand.addListener(id => browserCommands[id]());
}
//#endregion
//#region Init
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason === 'update') {
const [a, b, c] = (previousVersion || '').split('.');
if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13
require(['/background/remove-unused-storage']);
}
}
});
msg.on((msg, sender) => {
if (msg.method === 'invokeAPI') {
const fn = msg.path.reduce((res, name) => res && res[name], API);
if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`);
const res = fn.apply({msg, sender}, msg.args);
let res = msg.path.reduce((res, name) => res && res[name], API);
if (!res) throw new Error(`Unknown API.${msg.path.join('.')}`);
res = res.apply({msg, sender}, msg.args);
return res === undefined ? null : res;
}
});
chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
if (reason !== 'update') return;
if (semverCompare(previousVersion, '1.5.13') <= 0) {
// Removing unused stuff
// TODO: delete this entire block by the middle of 2021
try {
localStorage.clear();
} catch (e) {}
setTimeout(async () => {
const del = Object.keys(await chromeLocal.get())
.filter(key => key.startsWith('usoSearchCache'));
if (del.length) chromeLocal.remove(del);
}, 15e3);
}
});
msg.broadcast({method: 'backgroundReady'});
//#endregion
Promise.all([
bgReady.styles,
/* These are loaded conditionally.
Each item uses `require` individually so IDE can jump to the source and track usage. */
FIREFOX &&
require(['/background/style-via-api']),
FIREFOX && ((browser.commands || {}).update) &&
require(['/background/browser-cmd-hotkeys']),
!FIREFOX &&
require(['/background/content-scripts']),
chrome.contextMenus &&
require(['/background/context-menus']),
]).then(() => {
bgReady._resolveAll();
msg.isBgReady = true;
msg.broadcast({method: 'backgroundReady'});
});

View File

@ -0,0 +1,22 @@
/* global prefs */
'use strict';
/*
Registers hotkeys in FF
*/
(() => {
const hotkeyPrefs = prefs.knownKeys.filter(k => k.startsWith('hotkey.'));
prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true});
async function updateHotkey(name, value) {
try {
name = name.split('.')[1];
if (value.trim()) {
await browser.commands.update({name, shortcut: value});
} else {
await browser.commands.reset(name);
}
} catch (e) {}
}
})();

31
background/common.js Normal file
View File

@ -0,0 +1,31 @@
/* global API */// msg.js
'use strict';
/**
* Common stuff that's loaded first so it's immediately available to all background scripts
*/
/* exported
addAPI
bgReady
compareRevision
*/
const bgReady = {};
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
bgReady.all = new Promise(r => (bgReady._resolveAll = r));
function addAPI(methods) {
for (const [key, val] of Object.entries(methods)) {
const old = API[key];
if (old && Object.prototype.toString.call(old) === '[object Object]') {
Object.assign(old, val);
} else {
API[key] = val;
}
}
}
function compareRevision(rev1, rev2) {
return rev1 - rev2;
}

View File

@ -1,18 +1,14 @@
/* global
FIREFOX
ignoreChromeError
msg
URLS
*/
/* global bgReady */// common.js
/* global msg */
/* global URLS ignoreChromeError */// toolbox.js
'use strict';
/*
Reinject content scripts when the extension is reloaded/updated.
Firefox handles this automatically.
Not used in Firefox as it reinjects automatically.
*/
// eslint-disable-next-line no-unused-expressions
!FIREFOX && (() => {
bgReady.all.then(() => {
const NTP = 'chrome://newtab/';
const ALL_URLS = '<all_urls>';
const SCRIPTS = chrome.runtime.getManifest().content_scripts;
@ -118,4 +114,4 @@
function onBusyTabRemoved(tabId) {
trackBusyTab(tabId, false);
}
})();
});

View File

@ -1,16 +1,10 @@
/* global
browserCommands
CHROME
FIREFOX
ignoreChromeError
msg
prefs
URLS
*/
/* global browserCommands */// background.js
/* global msg */
/* global prefs */
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
'use strict';
// eslint-disable-next-line no-unused-expressions
chrome.contextMenus && (() => {
(() => {
const contextMenus = {
'show-badge': {
title: 'menuShowBadge',
@ -52,13 +46,13 @@ chrome.contextMenus && (() => {
/(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;
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 && id in prefs.defaults),
prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)),
togglePresence);
createContextMenus(keys);

View File

@ -1,49 +1,56 @@
/* 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++;
get(id) {
return chromeLocal.getValue(PREFIX + id);
},
async getAll() {
const all = await chromeLocal.get();
if (!INC) prepareInc(all);
return Object.entries(all)
.map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val)
.filter(Boolean);
},
async put(item) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
await chromeLocal.setValue(PREFIX + item.id, item);
return item.id;
},
async putMany(items) {
const data = {};
for (const item of items) {
if (!item.id) {
if (!INC) await prepareInc();
item.id = INC++;
}
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;
}),
await chromeLocal.set(data);
return items.map(_ => _.id);
},
};
return {
exec: (method, ...args) => METHODS[method](...args),
};
function prepareInc() {
if (INC) return Promise.resolve();
return chromeLocal.get().then(result => {
async function prepareInc(data) {
INC = 1;
for (const key in result) {
for (const key in data || await chromeLocal.get()) {
if (key.startsWith(PREFIX)) {
const id = Number(key.slice(PREFIX.length));
if (id >= INC) {
@ -51,6 +58,9 @@ function createChromeStorageDB() {
}
}
}
});
}
return function dbExecChromeStorage(method, ...args) {
return METHODS[method](...args);
};
}

View File

@ -1,14 +1,15 @@
/* global chromeLocal workerUtil createChromeStorageDB */
/* exported db */
/* global chromeLocal */// storage-util.js
/* global cloneError */// worker-util.js
'use strict';
/*
Initialize a database. There are some problems using IndexedDB in Firefox:
https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/
Some of them are fixed in FF59:
https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/
*/
'use strict';
/* exported db */
const db = (() => {
const DATABASE = 'stylish';
const STORE = 'styles';
@ -44,13 +45,14 @@ const db = (() => {
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null
}
function useChromeStorage(err) {
async function useChromeStorage(err) {
chromeLocal.setValue(FALLBACK, true);
if (err) {
chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err));
console.warn('Failed to access indexedDB. Switched to storage API.', err);
}
return createChromeStorageDB().exec;
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
return createChromeStorageDB();
}
async function dbExecIndexedDB(method, ...args) {

View File

@ -1,48 +1,36 @@
/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API */
/* exported iconManager */
/* global API */// msg.js
/* global addAPI bgReady */// common.js
/* global prefs */
/* global tabMan */
/* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js
'use strict';
const iconManager = (() => {
(() => {
const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38];
const staleBadges = new Set();
const imageDataCache = new Map();
// https://github.com/openstyles/stylus/issues/335
let hasCanvas = loadImage(`/images/icon/${ICON_SIZES[0]}.png`)
.then(({data}) => (hasCanvas = data.some(b => b !== 255)));
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor));
prefs.subscribe([
'show-badge',
], () => debounce(refreshAllIconsBadgeText));
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons));
prefs.initializing.then(() => {
refreshIconBadgeColor();
refreshAllIconsBadgeText();
refreshAllIcons();
});
Object.assign(API, {
/** @param {(number|string)[]} styleIds
* @param {boolean} [lazyBadge=false] preventing flicker during page load */
addAPI(/** @namespace API */ {
/**
* @param {(number|string)[]} styleIds
* @param {boolean} [lazyBadge=false] preventing flicker during page load
*/
updateIconBadge(styleIds, {lazyBadge} = {}) {
// FIXME: in some cases, we only have to redraw the badge. is it worth a optimization?
const {frameId, tab: {id: tabId}} = this.sender;
const value = styleIds.length ? styleIds.map(Number) : undefined;
tabManager.set(tabId, 'styleIds', frameId, value);
tabMan.set(tabId, 'styleIds', frameId, value);
debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0);
staleBadges.add(tabId);
if (!frameId) refreshIcon(tabId, true);
},
});
navigatorUtil.onCommitted(({tabId, frameId}) => {
if (!frameId) tabManager.set(tabId, 'styleIds', undefined);
chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => {
if (!frameId) tabMan.set(tabId, 'styleIds', undefined);
});
chrome.runtime.onConnect.addListener(port => {
@ -51,15 +39,30 @@ const iconManager = (() => {
}
});
bgReady.all.then(() => {
prefs.subscribe([
'disableAll',
'badgeDisabled',
'badgeNormal',
], () => debounce(refreshIconBadgeColor), {runNow: true});
prefs.subscribe([
'show-badge',
], () => debounce(refreshAllIconsBadgeText), {runNow: true});
prefs.subscribe([
'disableAll',
'iconset',
], () => debounce(refreshAllIcons), {runNow: true});
});
function onPortDisconnected({sender}) {
if (tabManager.get(sender.tab.id, 'styleIds')) {
if (tabMan.get(sender.tab.id, 'styleIds')) {
API.updateIconBadge.call({sender}, [], {lazyBadge: true});
}
}
function refreshIconBadgeText(tabId) {
const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : '';
iconUtil.setBadgeText({tabId, text});
setBadgeText({tabId, text});
}
function getIconName(hasStyles = false) {
@ -69,15 +72,15 @@ const iconManager = (() => {
}
function refreshIcon(tabId, force = false) {
const oldIcon = tabManager.get(tabId, 'icon');
const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0));
const oldIcon = tabMan.get(tabId, 'icon');
const newIcon = getIconName(tabMan.get(tabId, 'styleIds', 0));
// (changing the icon only for the main page, frameId = 0)
if (!force && oldIcon === newIcon) {
return;
}
tabManager.set(tabId, 'icon', newIcon);
iconUtil.setIcon({
tabMan.set(tabId, 'icon', newIcon);
setIcon({
path: getIconPath(newIcon),
tabId,
});
@ -96,33 +99,55 @@ const iconManager = (() => {
/** @return {number | ''} */
function getStyleCount(tabId) {
const allIds = new Set();
const data = tabManager.get(tabId, 'styleIds') || {};
const data = tabMan.get(tabId, 'styleIds') || {};
Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id)));
return allIds.size || '';
}
// Caches imageData for icon paths
async function loadImage(url) {
const {OffscreenCanvas} = self.createImageBitmap && self || {};
const img = OffscreenCanvas
? await createImageBitmap(await (await fetch(url)).blob())
: await new Promise((resolve, reject) =>
Object.assign(new Image(), {
src: url,
onload: e => resolve(e.target),
onerror: reject,
}));
const {width: w, height: h} = img;
const canvas = OffscreenCanvas
? new OffscreenCanvas(w, h)
: Object.assign(document.createElement('canvas'), {width: w, height: h});
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
const result = ctx.getImageData(0, 0, w, h);
imageDataCache.set(url, result);
return result;
}
function refreshGlobalIcon() {
iconUtil.setIcon({
setIcon({
path: getIconPath(getIconName()),
});
}
function refreshIconBadgeColor() {
const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal');
iconUtil.setBadgeBackgroundColor({
setBadgeBackgroundColor({
color,
});
}
function refreshAllIcons() {
for (const tabId of tabManager.list()) {
for (const tabId of tabMan.list()) {
refreshIcon(tabId);
}
refreshGlobalIcon();
}
function refreshAllIconsBadgeText() {
for (const tabId of tabManager.list()) {
for (const tabId of tabMan.list()) {
refreshIconBadgeText(tabId);
}
}
@ -133,4 +158,40 @@ const iconManager = (() => {
}
staleBadges.clear();
}
function safeCall(method, data) {
const {browserAction = {}} = chrome;
const fn = browserAction[method];
if (fn) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
fn.call(browserAction, data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
fn.call(browserAction, data);
}
}
}
/** @param {chrome.browserAction.TabIconDetails} data */
async function setIcon(data) {
if (hasCanvas === true || await hasCanvas) {
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
data.imageData[key] = imageDataCache.get(url) || await loadImage(url);
}
delete data.path;
}
safeCall('setIcon', data);
}
/** @param {chrome.browserAction.BadgeTextDetails} data */
function setBadgeText(data) {
safeCall('setBadgeText', data);
}
/** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */
function setBadgeBackgroundColor(data) {
safeCall('setBadgeBackgroundColor', data);
}
})();

View File

@ -1,91 +0,0 @@
/* global ignoreChromeError */
/* exported iconUtil */
'use strict';
const iconUtil = (() => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// https://github.com/openstyles/stylus/issues/335
let noCanvas;
const imageDataCache = new Map();
// test if canvas is usable
const canvasReady = loadImage('/images/icon/16.png')
.then(imageData => {
noCanvas = imageData.data.every(b => b === 255);
});
return extendNative({
/*
Cache imageData for paths
*/
setIcon,
setBadgeText,
});
function loadImage(url) {
let result = imageDataCache.get(url);
if (!result) {
result = new Promise((resolve, reject) => {
const img = new Image();
img.src = url;
img.onload = () => {
const w = canvas.width = img.width;
const h = canvas.height = img.height;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
resolve(ctx.getImageData(0, 0, w, h));
};
img.onerror = reject;
});
imageDataCache.set(url, result);
}
return result;
}
function setIcon(data) {
canvasReady.then(() => {
if (noCanvas) {
chrome.browserAction.setIcon(data, ignoreChromeError);
return;
}
const pending = [];
data.imageData = {};
for (const [key, url] of Object.entries(data.path)) {
pending.push(loadImage(url)
.then(imageData => {
data.imageData[key] = imageData;
}));
}
Promise.all(pending).then(() => {
delete data.path;
chrome.browserAction.setIcon(data, ignoreChromeError);
});
});
}
function setBadgeText(data) {
try {
// Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320
chrome.browserAction.setBadgeText(data, ignoreChromeError);
} catch (e) {
// FIXME: skip pre-rendered tabs?
chrome.browserAction.setBadgeText(data);
}
}
function extendNative(target) {
return new Proxy(target, {
get: (target, prop) => {
// FIXME: do we really need this?
if (!chrome.browserAction ||
!['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
return () => {};
}
if (target[prop]) {
return target[prop];
}
return chrome.browserAction[prop].bind(chrome.browserAction);
},
});
}
})();

View File

@ -0,0 +1,82 @@
/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js
/* global bgReady */// common.js
/* global msg */
'use strict';
/* exported navMan */
const navMan = (() => {
const listeners = new Set();
chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed'));
chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history'));
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash'));
return {
/** @param {function(data: Object, type: ('committed'|'history'|'hash'))} fn */
onUrlChange(fn) {
listeners.add(fn);
},
};
/** @this {string} type */
async function onNavigation(data) {
if (CHROME &&
URLS.chromeProtectsNTP &&
data.url.startsWith('https://www.google.') &&
data.url.includes('/_/chrome/newtab?')) {
// Modern Chrome switched to WebUI NTP so this is obsolete, but there may be exceptions
// TODO: investigate, and maybe use a separate listener for CHROME <= ver
const tab = await browser.tabs.get(data.tabId);
const url = tab.pendingUrl || tab.url;
if (url === 'chrome://newtab/') {
data.url = url;
}
}
listeners.forEach(fn => fn(data, this));
}
/** @this {string} type */
function onFakeNavigation(data) {
onNavigation.call(this, data);
msg.sendTab(data.tabId, {method: 'urlChanged'}, {frameId: data.frameId})
.catch(msg.ignoreError);
}
})();
bgReady.all.then(() => {
/*
* Expose style version on greasyfork/sleazyfork 1) info page and 2) code page
* Not using manifest.json as adding a content script disables the extension on update.
*/
const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$';
chrome.webNavigation.onCommitted.addListener(({tabId}) => {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-greasyfork.js',
runAt: 'document_start',
});
}, {
url: [
{hostEquals: 'greasyfork.org', urlMatches},
{hostEquals: 'sleazyfork.org', urlMatches},
],
});
/*
* FF misses some about:blank iframes so we inject our content script explicitly
*/
if (FIREFOX) {
chrome.webNavigation.onDOMContentLoaded.addListener(async ({tabId, frameId}) => {
if (frameId &&
!await msg.sendTab(tabId, {method: 'ping'}, {frameId}).catch(ignoreChromeError)) {
for (const file of chrome.runtime.getManifest().content_scripts[0].js) {
chrome.tabs.executeScript(tabId, {
frameId,
file,
matchAboutBlank: true,
}, ignoreChromeError);
}
}
}, {
url: [{urlEquals: 'about:blank'}],
});
}
});

View File

@ -1,103 +0,0 @@
/* global
CHROME
FIREFOX
ignoreChromeError
msg
URLS
*/
'use strict';
(() => {
/** @type {Set<function(data: Object, type: string)>} */
const listeners = new Set();
/** @type {NavigatorUtil} */
const navigatorUtil = window.navigatorUtil = new Proxy({
onUrlChange(fn) {
listeners.add(fn);
},
}, {
get(target, prop) {
return target[prop] ||
(target = chrome.webNavigation[prop]).addListener.bind(target);
},
});
navigatorUtil.onCommitted(onNavigation.bind('committed'));
navigatorUtil.onHistoryStateUpdated(onFakeNavigation.bind('history'));
navigatorUtil.onReferenceFragmentUpdated(onFakeNavigation.bind('hash'));
navigatorUtil.onCommitted(runGreasyforkContentScript, {
// expose style version on greasyfork/sleazyfork 1) info page and 2) code page
url: ['greasyfork', 'sleazyfork'].map(host => ({
hostEquals: host + '.org',
urlMatches: '/scripts/\\d+[^/]*(/code)?([?#].*)?$',
})),
});
if (FIREFOX) {
navigatorUtil.onDOMContentLoaded(runMainContentScripts, {
url: [{
urlEquals: 'about:blank',
}],
});
}
/** @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);
}
/** FF misses some about:blank iframes so we inject our content script explicitly */
async function runMainContentScripts({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);
}
}
}
function runGreasyforkContentScript({tabId}) {
chrome.tabs.executeScript(tabId, {
file: '/content/install-hook-greasyfork.js',
runAt: 'document_start',
});
}
})();
/**
* @typedef NavigatorUtil
* @property {NavigatorUtilEvent} onBeforeNavigate
* @property {NavigatorUtilEvent} onCommitted
* @property {NavigatorUtilEvent} onCompleted
* @property {NavigatorUtilEvent} onCreatedNavigationTarget
* @property {NavigatorUtilEvent} onDOMContentLoaded
* @property {NavigatorUtilEvent} onErrorOccurred
* @property {NavigatorUtilEvent} onHistoryStateUpdated
* @property {NavigatorUtilEvent} onReferenceFragmentUpdated
* @property {NavigatorUtilEvent} onTabReplaced
*/
/**
* @typedef {function(cb: function, filters: WebNavigationEventFilter?)} NavigatorUtilEvent
*/

View File

@ -1,6 +1,8 @@
/* global API */
/* global addAPI */// common.js
'use strict';
/* CURRENTLY UNUSED */
(() => {
// begin:nanographql - Tiny graphQL client library
// Author: yoshuawuyts (https://github.com/yoshuawuyts)
@ -26,10 +28,9 @@
// end:nanographql
const api = 'https://api.openusercss.org';
const doQuery = ({id}, queryString) => {
const doQuery = async ({id}, queryString) => {
const query = gql(queryString);
return fetch(api, {
return (await fetch(api, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
@ -37,11 +38,10 @@
body: query({
id,
}),
})
.then(res => res.json());
})).json();
};
API.openusercss = {
addAPI(/** @namespace- API */ { // TODO: remove "-" when this is implemented
/**
* This function can be used to retrieve a theme object from the
* GraphQL API, set above
@ -99,5 +99,5 @@
}
}
`),
};
});
})();

View File

@ -0,0 +1,15 @@
/* global chromeLocal */// storage-util.js
'use strict';
// Removing unused stuff from storage on extension update
// TODO: delete this by the middle of 2021
try {
localStorage.clear();
} catch (e) {}
setTimeout(async () => {
const del = Object.keys(await chromeLocal.get())
.filter(key => key.startsWith('usoSearchCache'));
if (del.length) chromeLocal.remove(del);
}, 15e3);

View File

@ -1,17 +1,10 @@
/* global
API
calcStyleDigest
createCache
db
msg
prefs
stringAsRegExp
styleCodeEmpty
styleSectionGlobal
tabManager
tryRegExp
URLS
*/
/* global API msg */// msg.js
/* global URLS stringAsRegExp tryRegExp */// toolbox.js
/* global bgReady compareRevision */// common.js
/* global calcStyleDigest styleCodeEmpty styleSectionGlobal */// sections-util.js
/* global db */
/* global prefs */
/* global tabMan */
'use strict';
/*
@ -20,27 +13,25 @@ is added/updated, it broadcast a message to content script and the content
script would try to fetch the new code.
The live preview feature relies on `runtime.connect` and `port.onDisconnect`
to cleanup the temporary code. See /edit/live-preview.js.
to cleanup the temporary code. See livePreview in /edit.
*/
/* exported styleManager */
const styleManager = API.styles = (() => {
const styleMan = (() => {
//#region Declarations
const ready = init();
/**
* @typedef StyleMapData
* @property {StyleObj} style
* @property {?StyleObj} [preview]
* @property {Set<string>} appliesTo - urls
*/
/** @typedef {{
style: StyleObj
preview?: StyleObj
appliesTo: Set<string>
}} StyleMapData */
/** @type {Map<number,StyleMapData>} */
const dataMap = new Map();
const uuidIndex = new Map();
/** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */
/** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */
const cachedStyleForUrl = createCache({
onDeleted: (url, cache) => {
onDeleted(url, cache) {
for (const section of Object.values(cache.sections)) {
const data = id2data(section.id);
if (data) data.appliesTo.delete(url);
@ -51,40 +42,25 @@ const styleManager = API.styles = (() => {
const compileRe = createCompiler(text => `^(${text})$`);
const compileSloppyRe = createCompiler(text => `^${text}$`);
const compileExclusion = createCompiler(buildExclusion);
const DUMMY_URL = {
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
password: '',
pathname: '',
port: '',
protocol: '',
search: '',
searchParams: new URLSearchParams(),
username: '',
};
const MISSING_PROPS = {
name: style => `ID: ${style.id}`,
_id: () => uuidv4(),
_rev: () => Date.now(),
};
const DELETE_IF_NULL = ['id', 'customName'];
//#endregion
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = init();
chrome.runtime.onConnect.addListener(handleLivePreview);
//#region Public surface
//#endregion
//#region Exports
// Sorted alphabetically
return {
compareRevision,
/** @returns {Promise<number>} style id */
async delete(id, reason) {
await ready;
if (ready.then) await ready;
const data = id2data(id);
await db.exec('delete', id);
if (reason !== 'sync') {
@ -105,18 +81,18 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<number>} style id */
async deleteByUUID(_id, rev) {
await ready;
if (ready.then) await ready;
const id = uuidIndex.get(_id);
const oldDoc = id && id2style(id);
if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) {
// FIXME: does it make sense to set reason to 'sync' in deleteByUUID?
return API.styles.delete(id, 'sync');
return styleMan.delete(id, 'sync');
}
},
/** @returns {Promise<StyleObj>} */
async editSave(style) {
await ready;
if (ready.then) await ready;
style = mergeWithMapped(style);
style.updateDate = Date.now();
return handleSave(await saveStyle(style), 'editSave');
@ -124,7 +100,7 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<?StyleObj>} */
async find(filter) {
await ready;
if (ready.then) await ready;
const filterEntries = Object.entries(filter);
for (const {style} of dataMap.values()) {
if (filterEntries.every(([key, val]) => style[key] === val)) {
@ -136,23 +112,26 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<StyleObj[]>} */
async getAll() {
await ready;
if (ready.then) await ready;
return Array.from(dataMap.values(), data2style);
},
/** @returns {Promise<StyleObj>} */
async getByUUID(uuid) {
await ready;
if (ready.then) await ready;
return id2style(uuidIndex.get(uuid));
},
/** @returns {Promise<StyleSectionsToApply>} */
async getSectionsByUrl(url, id, isInitialApply) {
await ready;
if (ready.then) await ready;
if (isInitialApply && prefs.get('disableAll')) {
return {disableAll: true};
}
/* Chrome hides text frament from location.href of the page e.g. #:~:text=foo
so we'll use the real URL reported by webNavigation API */
const {tab, frameId} = this.sender;
url = tab && tabManager.get(tab.id, 'url', frameId) || url;
const {tab, frameId} = this && this.sender || {};
url = tab && tabMan.get(tab.id, 'url', frameId) || url;
let cache = cachedStyleForUrl.get(url);
if (!cache) {
cache = {
@ -164,24 +143,20 @@ const styleManager = API.styles = (() => {
} else if (cache.maybeMatch.size) {
buildCache(cache, url, Array.from(cache.maybeMatch, id2data).filter(Boolean));
}
const res = id
return id
? cache.sections[id] ? {[id]: cache.sections[id]} : {}
: cache.sections;
// Avoiding flicker of needlessly applied styles by providing both styles & pref in one API call
return isInitialApply && prefs.get('disableAll')
? Object.assign({disableAll: true}, res)
: res;
},
/** @returns {Promise<StyleObj>} */
async get(id) {
await ready;
if (ready.then) await ready;
return id2style(id);
},
/** @returns {Promise<StylesByUrlResult[]>} */
async getByUrl(url, id = null) {
await ready;
if (ready.then) await ready;
// FIXME: do we want to cache this? Who would like to open popup rapidly
// or search the DB with the same URL?
const result = [];
@ -223,7 +198,7 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<StyleObj[]>} */
async importMany(items) {
await ready;
if (ready.then) await ready;
items.forEach(beforeSave);
const events = await db.exec('putMany', items);
return Promise.all(items.map((item, i) => {
@ -234,13 +209,13 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<StyleObj>} */
async import(data) {
await ready;
if (ready.then) await ready;
return handleSave(await saveStyle(data), 'import');
},
/** @returns {Promise<StyleObj>} */
async install(style, reason = null) {
await ready;
if (ready.then) await ready;
reason = reason || dataMap.has(style.id) ? 'update' : 'install';
style = mergeWithMapped(style);
const url = !style.url && style.updateUrl && (
@ -255,7 +230,7 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<?StyleObj>} */
async putByUUID(doc) {
await ready;
if (ready.then) await ready;
const id = uuidIndex.get(doc._id);
if (id) {
doc.id = id;
@ -280,7 +255,7 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<number>} style id */
async toggle(id, enabled) {
await ready;
if (ready.then) await ready;
const style = Object.assign({}, id2style(id), {enabled});
handleSave(await saveStyle(style), 'toggle', false);
return id;
@ -297,8 +272,8 @@ const styleManager = API.styles = (() => {
/** @returns {Promise<?StyleObj>} */
removeInclusion: removeIncludeExclude.bind(null, 'inclusions'),
};
//#endregion
//#endregion
//#region Implementation
/** @returns {StyleMapData} */
@ -366,12 +341,8 @@ const styleManager = API.styles = (() => {
});
}
function compareRevision(rev1, rev2) {
return rev1 - rev2;
}
async function addIncludeExclude(type, id, rule) {
await ready;
if (ready.then) await ready;
const style = Object.assign({}, id2style(id));
const list = style[type] || (style[type] = []);
if (list.includes(rule)) {
@ -382,7 +353,7 @@ const styleManager = API.styles = (() => {
}
async function removeIncludeExclude(type, id, rule) {
await ready;
if (ready.then) await ready;
const style = Object.assign({}, id2style(id));
const list = style[type];
if (!list || !list.includes(rule)) {
@ -494,6 +465,8 @@ const styleManager = API.styles = (() => {
storeInMap(style);
uuidIndex.set(style._id, style.id);
}
ready = true;
bgReady._resolveStyles();
}
function addMissingProps(style) {
@ -661,7 +634,20 @@ const styleManager = API.styles = (() => {
try {
return new URL(url);
} catch (err) {
return DUMMY_URL;
return {
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
password: '',
pathname: '',
port: '',
protocol: '',
search: '',
searchParams: new URLSearchParams(),
username: '',
};
}
}
@ -677,5 +663,67 @@ const styleManager = API.styles = (() => {
function hex4dashed(num, i) {
return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : '');
}
//#endregion
})();
/** Creates 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(id) {
const item = map.get(id);
return item && item.data;
},
set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
},
delete(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
},
clear() {
map.clear();
index = lastIndex = 0;
},
has: id => map.has(id),
*entries() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
*values() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
}

View File

@ -1,10 +1,6 @@
/* global
API
debounce
stringAsRegExp
tryRegExp
usercss
*/
/* global API */// msg.js
/* global URLS debounce stringAsRegExp tryRegExp */// toolbox.js
/* global addAPI */// common.js
'use strict';
(() => {
@ -14,12 +10,12 @@
const extractMeta = style =>
style.usercssData
? (style.sourceCode.match(usercss.RX_META) || [''])[0]
? (style.sourceCode.match(URLS.rxMETA) || [''])[0]
: null;
const stripMeta = style =>
style.usercssData
? style.sourceCode.replace(usercss.RX_META, '')
? style.sourceCode.replace(URLS.rxMETA, '')
: null;
const MODES = Object.assign(Object.create(null), {
@ -42,6 +38,8 @@
!style.usercssData && MODES.code(style, test),
});
addAPI(/** @namespace API */ {
styles: {
/**
* @param params
* @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed")
@ -49,7 +47,7 @@
* @param {number[]} [params.ids] - if not specified, all styles are searched
* @returns {number[]} - array of matched styles ids
*/
API.searchDB = async ({query, mode = 'all', ids}) => {
async searchDB({query, mode = 'all', ids}) {
let res = [];
if (mode === 'url' && query) {
res = (await API.styles.getByUrl(query)).map(r => r.style.id);
@ -57,7 +55,7 @@
const modeHandler = MODES[mode];
const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query);
const rx = m && tryRegExp(m[1], m[2]);
const test = rx ? rx.test.bind(rx) : makeTester(query);
const test = rx ? rx.test.bind(rx) : createTester(query);
res = (await API.styles.getAll())
.filter(style =>
(!ids || ids.includes(style.id)) &&
@ -66,9 +64,11 @@
if (cache.size) debounce(clearCache, 60e3);
}
return res;
};
},
},
});
function makeTester(query) {
function createTester(query) {
const flags = `u${lower(query) === query ? 'i' : ''}`;
const words = query
.split(/(".*?")|\s+/)

View File

@ -1,7 +1,14 @@
/* global API CHROME prefs */
/* global API */// msg.js
/* global addAPI */// common.js
/* global isEmptyObj */// toolbox.js
/* global prefs */
'use strict';
API.styleViaAPI = !CHROME && (() => {
/**
* Uses chrome.tabs.insertCSS
*/
(() => {
const ACTIONS = {
styleApply,
styleDeleted,
@ -11,25 +18,25 @@ API.styleViaAPI = !CHROME && (() => {
prefChanged,
updateCount,
};
const NOP = Promise.resolve(new Error('NOP'));
const NOP = new Error('NOP');
const onError = () => {};
/* <tabId>: Object
<frameId>: Object
url: String, non-enumerable
<styleId>: Array of strings
section code */
const cache = new Map();
let observingTabs = false;
return function (request) {
const action = ACTIONS[request.method];
return !action ? NOP :
action(request, this.sender)
.catch(onError)
.then(maybeToggleObserver);
};
addAPI(/** @namespace API */ {
async styleViaAPI(request) {
try {
const fn = ACTIONS[request.method];
return fn ? fn(request, this.sender) : NOP;
} catch (e) {}
maybeToggleObserver();
},
});
function updateCount(request, sender) {
const {tab, frameId} = sender;
@ -125,7 +132,7 @@ API.styleViaAPI = !CHROME && (() => {
}
const {tab, frameId} = sender;
const {tabFrames, frameStyles} = getCachedData(tab.id, frameId);
if (isEmpty(frameStyles)) {
if (isEmptyObj(frameStyles)) {
return NOP;
}
removeFrameIfEmpty(tab.id, frameId, tabFrames, {});
@ -162,7 +169,7 @@ API.styleViaAPI = !CHROME && (() => {
const tabFrames = cache.get(tabId);
if (tabFrames && frameId in tabFrames) {
delete tabFrames[frameId];
if (isEmpty(tabFrames)) {
if (isEmptyObj(tabFrames)) {
onTabRemoved(tabId);
}
}
@ -178,9 +185,9 @@ API.styleViaAPI = !CHROME && (() => {
}
function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) {
if (isEmpty(frameStyles)) {
if (isEmptyObj(frameStyles)) {
delete tabFrames[frameId];
if (isEmpty(tabFrames)) {
if (isEmptyObj(tabFrames)) {
cache.delete(tabId);
}
return true;
@ -223,11 +230,4 @@ API.styleViaAPI = !CHROME && (() => {
return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true})
.catch(onError);
}
function isEmpty(obj) {
for (const k in obj) {
return false;
}
return true;
}
})();

View File

@ -1,105 +1,103 @@
/* global
API
CHROME
prefs
*/
/* global API */// msg.js
/* global CHROME */// toolbox.js
/* global prefs */
'use strict';
// eslint-disable-next-line no-unused-expressions
CHROME && (async () => {
(() => {
const idCSP = 'patchCsp';
const idOFF = 'disableAll';
const idXHR = 'styleViaXhr';
const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by *
const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/');
/** @type {Object<string,StylesToPass>} */
const stylesToPass = {};
const enabled = {};
const state = {};
const injectedCode = CHROME && `${data => {
if (self.INJECTED !== 1) { // storing data only if apply.js hasn't run yet
window[Symbol.for('styles')] = data;
}
}}`;
await prefs.initializing;
prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true});
toggle();
prefs.subscribe([idXHR, idOFF, idCSP], toggle);
function toggle() {
const csp = prefs.get(idCSP) && !prefs.get(idOFF);
const xhr = prefs.get(idXHR) && !prefs.get(idOFF) && Boolean(chrome.declarativeContent);
if (xhr === enabled.xhr && csp === enabled.csp) {
const off = prefs.get(idOFF);
const csp = prefs.get(idCSP) && !off;
const xhr = prefs.get(idXHR) && !off;
if (xhr === state.xhr && csp === state.csp && off === state.off) {
return;
}
// Need to unregister first so that the optional EXTRA_HEADERS is properly registered
const reqFilter = {
urls: ['*://*/*'],
types: ['main_frame', 'sub_frame'],
};
chrome.webNavigation.onCommitted.removeListener(injectData);
chrome.webRequest.onBeforeRequest.removeListener(prepareStyles);
chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders);
if (xhr || csp) {
const reqFilter = {
urls: ['<all_urls>'],
types: ['main_frame', 'sub_frame'],
};
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
// We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered
chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [
'blocking',
'responseHeaders',
xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
].filter(Boolean));
}
if (enabled.xhr !== xhr) {
enabled.xhr = xhr;
toggleEarlyInjection();
if (CHROME ? !off : xhr || csp) {
chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter);
}
enabled.csp = csp;
if (CHROME && !off) {
chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]});
}
/** Runs content scripts earlier than document_start */
function toggleEarlyInjection() {
const api = chrome.declarativeContent;
if (!api) return;
api.onPageChanged.removeRules([idXHR], async () => {
if (enabled.xhr) {
api.onPageChanged.addRules([{
id: idXHR,
conditions: [
new api.PageStateMatcher({
pageUrl: {urlContains: '://'},
}),
],
actions: [
new api.RequestContentScript({
js: chrome.runtime.getManifest().content_scripts[0].js,
allFrames: true,
}),
],
}]);
}
});
state.csp = csp;
state.off = off;
state.xhr = xhr;
}
/** @param {chrome.webRequest.WebRequestBodyDetails} req */
async function prepareStyles(req) {
const sections = await API.styles.getSectionsByUrl(req.url);
if (Object.keys(sections).length) {
stylesToPass[req.requestId] = !enabled.xhr ? true :
URL.createObjectURL(new Blob([JSON.stringify(sections)]))
.slice(blobUrlPrefix.length);
setTimeout(cleanUp, 600e3, req.requestId);
stylesToPass[req2key(req)] = /** @namespace StylesToPass */ {
blobId: '',
str: JSON.stringify(sections),
timer: setTimeout(cleanUp, 600e3, req),
};
}
function injectData(req) {
const data = stylesToPass[req2key(req)];
if (data && !data.injected) {
data.injected = true;
chrome.tabs.executeScript(req.tabId, {
frameId: req.frameId,
runAt: 'document_start',
code: `(${injectedCode})(${data.str})`,
});
if (!state.xhr) cleanUp(req);
}
}
/** @param {chrome.webRequest.WebResponseHeadersDetails} req */
function modifyHeaders(req) {
const {responseHeaders} = req;
const id = stylesToPass[req.requestId];
if (!id) {
const data = stylesToPass[req2key(req)];
if (!data || data.str === '{}') {
cleanUp(req);
return;
}
if (enabled.xhr) {
if (state.xhr) {
data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length);
responseHeaders.push({
name: 'Set-Cookie',
value: `${chrome.runtime.id}=${id}`,
value: `${chrome.runtime.id}=${data.blobId}`,
});
}
const csp = enabled.csp &&
const csp = state.csp &&
responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy');
if (csp) {
patchCsp(csp);
}
if (enabled.xhr || csp) {
if (state.xhr || csp) {
return {responseHeaders};
}
}
@ -115,7 +113,7 @@ CHROME && (async () => {
patchCspSrc(src, 'img-src', 'data:', '*');
patchCspSrc(src, 'font-src', 'data:', '*');
// Allow our DOM styles
patchCspSrc(src, 'style-src', '\'unsafe-inline\'');
patchCspSrc(src, 'style-src', "'unsafe-inline'");
// Allow our XHR cookies in CSP sandbox (known case: raw github urls)
if (src.sandbox && !src.sandbox.includes('allow-same-origin')) {
src.sandbox.push('allow-same-origin');
@ -136,9 +134,19 @@ CHROME && (async () => {
}
}
function cleanUp(key) {
const blobId = stylesToPass[key];
function cleanUp(req) {
const key = req2key(req);
const data = stylesToPass[key];
if (data) {
delete stylesToPass[key];
if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId);
clearTimeout(data.timer);
if (data.blobId) {
URL.revokeObjectURL(blobUrlPrefix + data.blobId);
}
}
}
function req2key(req) {
return req.tabId + ':' + req.frameId;
}
})();

View File

@ -1,32 +1,156 @@
/* global
API
chromeLocal
dbToCloud
msg
prefs
styleManager
tokenManager
*/
/* exported sync */
/* global API msg */// msg.js
/* global chromeLocal */// storage-util.js
/* global compareRevision */// common.js
/* global prefs */
/* global tokenMan */
'use strict';
const sync = API.sync = (() => {
const syncMan = (() => {
//#region Init
const SYNC_DELAY = 1; // minutes
const SYNC_INTERVAL = 30; // minutes
/** @typedef API.sync.Status */
const status = {
/** @type {'connected'|'connecting'|'disconnected'|'disconnecting'} */
state: 'disconnected',
const STATES = Object.freeze({
connected: 'connected',
connecting: 'connecting',
disconnected: 'disconnected',
disconnecting: 'disconnecting',
});
const STORAGE_KEY = 'sync/state/';
const status = /** @namespace SyncManager.Status */ {
STATES,
state: STATES.disconnected,
syncing: false,
progress: null,
currentDriveName: null,
errorMessage: null,
login: false,
};
let ctrl;
let currentDrive;
const ctrl = dbToCloud.dbToCloud({
/** @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);
},
@ -52,151 +176,20 @@ const sync = API.sync = (() => {
}
emitStatusChange();
},
compareRevision(a, b) {
return styleManager.compareRevision(a, b);
},
compareRevision,
getState(drive) {
const key = `sync/state/${drive.name}`;
return chromeLocal.getValue(key);
return chromeLocal.getValue(STORAGE_KEY + drive.name);
},
setState(drive, state) {
const key = `sync/state/${drive.name}`;
return chromeLocal.setValue(key, state);
return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
},
});
const ready = prefs.initializing.then(() => {
prefs.subscribe('sync.enabled',
(_, val) => val === 'none'
? sync.stop()
: sync.start(val, true),
{now: true});
});
chrome.alarms.onAlarm.addListener(info => {
if (info.name === 'syncNow') {
sync.syncNow();
}
});
// Sorted alphabetically
return {
async delete(...args) {
await ready;
if (!currentDrive) return;
schedule();
return ctrl.delete(...args);
},
/**
* @returns {Promise<API.sync.Status>}
*/
async getStatus() {
return status;
},
async login(name = prefs.get('sync.enabled')) {
await ready;
try {
await 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
await tokenManager.getToken(name);
}
throw err;
}
status.login = true;
emitStatusChange();
},
async put(...args) {
await ready;
if (!currentDrive) return;
schedule();
return ctrl.put(...args);
},
async start(name, fromPref = false) {
await ready;
if (currentDrive) {
return;
}
currentDrive = getDrive(name);
ctrl.use(currentDrive);
status.state = 'connecting';
status.currentDriveName = currentDrive.name;
status.login = true;
emitStatusChange();
try {
if (!fromPref) {
await sync.login(name).catch(handle401Error);
}
await sync.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 sync.stop();
}
}
prefs.set('sync.enabled', name);
status.state = 'connected';
schedule(SYNC_INTERVAL);
emitStatusChange();
},
async stop() {
await ready;
if (!currentDrive) {
return;
}
chrome.alarms.clear('syncNow');
status.state = 'disconnecting';
emitStatusChange();
try {
await ctrl.stop();
await tokenManager.revokeToken(currentDrive.name);
await chromeLocal.remove(`sync/state/${currentDrive.name}`);
} catch (e) {
}
currentDrive = null;
prefs.set('sync.enabled', 'none');
status.state = 'disconnected';
status.currentDriveName = null;
status.login = false;
emitStatusChange();
},
async syncNow() {
await ready;
if (!currentDrive) {
return Promise.reject(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();
},
};
function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', {
delayInMinutes: delay,
periodInMinutes: SYNC_INTERVAL,
});
}
async function handle401Error(err) {
let emit;
if (err.code === 401) {
await tokenManager.revokeToken(currentDrive.name).catch(console.error);
await tokenMan.revokeToken(currentDrive.name).catch(console.error);
emit = true;
} else if (/User interaction required|Requires user interaction/i.test(err.message)) {
emit = true;
@ -215,9 +208,18 @@ const sync = API.sync = (() => {
function getDrive(name) {
if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
return dbToCloud.drive[name]({
getAccessToken: () => tokenManager.getToken(name),
getAccessToken: () => tokenMan.getToken(name),
});
}
throw new Error(`unknown cloud name: ${name}`);
}
function schedule(delay = SYNC_DELAY) {
chrome.alarms.create('syncNow', {
delayInMinutes: delay,
periodInMinutes: SYNC_INTERVAL,
});
}
//#endregion
})();

View File

@ -1,15 +1,17 @@
/* global navigatorUtil */
/* exported tabManager */
/* global bgReady */// common.js
/* global navMan */
'use strict';
const tabManager = (() => {
const listeners = [];
const tabMan = (() => {
const listeners = new Set();
const cache = new Map();
chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId));
chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed));
navigatorUtil.onUrlChange(({tabId, frameId, url}) => {
const oldUrl = !frameId && tabManager.get(tabId, 'url', frameId);
tabManager.set(tabId, 'url', frameId, url);
bgReady.all.then(() => {
navMan.onUrlChange(({tabId, frameId, url}) => {
const oldUrl = !frameId && tabMan.get(tabId, 'url', frameId);
tabMan.set(tabId, 'url', frameId, url);
if (frameId) return;
for (const fn of listeners) {
try {
@ -19,14 +21,17 @@ const tabManager = (() => {
}
}
});
});
return {
onUpdate(fn) {
listeners.push(fn);
listeners.add(fn);
},
get(tabId, ...keys) {
return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId));
},
/**
* number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta
* (tabId, 'foo', 123) will set tabId's meta to {foo: 123},
@ -47,8 +52,10 @@ const tabManager = (() => {
meta[lastKey] = value;
}
},
list() {
return cache.keys();
},
};
})();

View File

@ -1,8 +1,9 @@
/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */
/* exported tokenManager */
/* global FIREFOX */// toolbox.js
/* global chromeLocal */// storage-util.js
'use strict';
const tokenManager = (() => {
/* exported tokenMan */
const tokenMan = (() => {
const AUTH = {
dropbox: {
flow: 'token',
@ -50,13 +51,9 @@ const tokenManager = (() => {
};
const NETWORK_LATENCY = 30; // seconds
return {getToken, revokeToken, getClientId, buildKeys};
return {
function getClientId(name) {
return AUTH[name].clientId;
}
function buildKeys(name) {
buildKeys(name) {
const k = {
TOKEN: `secure/token/${name}/token`,
EXPIRE: `secure/token/${name}/expire`,
@ -64,50 +61,48 @@ const tokenManager = (() => {
};
k.LIST = Object.values(k);
return k;
}
},
function getToken(name, interactive) {
const k = buildKeys(name);
return chromeLocal.get(k.LIST)
.then(obj => {
if (!obj[k.TOKEN]) {
return authUser(name, k, interactive);
}
getClientId(name) {
return AUTH[name].clientId;
},
async getToken(name, interactive) {
const k = tokenMan.buildKeys(name);
const obj = await chromeLocal.get(k.LIST);
if (obj[k.TOKEN]) {
if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
return obj[k.TOKEN];
}
if (obj[k.REFRESH]) {
return refreshToken(name, k, obj)
.catch(err => {
if (err.code === 401) {
return authUser(name, k, interactive);
try {
return await refreshToken(name, k, obj);
} catch (err) {
if (err.code !== 401) throw err;
}
}
throw err;
});
}
return authUser(name, k, interactive);
});
}
},
async function revokeToken(name) {
async revokeToken(name) {
const provider = AUTH[name];
const k = buildKeys(name);
const k = tokenMan.buildKeys(name);
if (provider.revoke) {
try {
const token = await chromeLocal.getValue(k.TOKEN);
if (token) {
await provider.revoke(token);
}
if (token) await provider.revoke(token);
} catch (e) {
console.error(e);
}
}
await chromeLocal.remove(k.LIST);
}
},
};
function refreshToken(name, k, obj) {
async function refreshToken(name, k, obj) {
if (!obj[k.REFRESH]) {
return Promise.reject(new Error('no refresh token'));
throw new Error('No refresh token');
}
const provider = AUTH[name];
const body = {
@ -119,17 +114,17 @@ const tokenManager = (() => {
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body)
.then(result => {
const result = await postQuery(provider.tokenURL, body);
if (!result.refresh_token) {
// reuse old refresh token
result.refresh_token = obj[k.REFRESH];
}
return handleTokenResult(result, k);
});
}
function authUser(name, k, interactive = false) {
async function authUser(name, k, interactive = false) {
await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
/* global webextLaunchWebAuthFlow */
const provider = AUTH[name];
const state = Math.random().toFixed(8).slice(2);
const query = {
@ -145,27 +140,27 @@ const tokenManager = (() => {
Object.assign(query, provider.authQuery);
}
const url = `${provider.authURL}?${new URLSearchParams(query)}`;
return webextLaunchWebAuthFlow({
const finalUrl = await webextLaunchWebAuthFlow({
url,
interactive,
redirect_uri: query.redirect_uri,
})
.then(url => {
});
const params = new URLSearchParams(
provider.flow === 'token' ?
new URL(url).hash.slice(1) :
new URL(url).search.slice(1)
new URL(finalUrl).hash.slice(1) :
new URL(finalUrl).search.slice(1)
);
if (params.get('state') !== state) {
throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`);
throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`);
}
let result;
if (provider.flow === 'token') {
const obj = {};
for (const [key, value] of params.entries()) {
for (const [key, value] of params) {
obj[key] = value;
}
return obj;
}
result = obj;
} else {
const code = params.get('code');
const body = {
code,
@ -176,21 +171,23 @@ const tokenManager = (() => {
if (provider.clientSecret) {
body.client_secret = provider.clientSecret;
}
return postQuery(provider.tokenURL, body);
})
.then(result => handleTokenResult(result, k));
result = await postQuery(provider.tokenURL, body);
}
return handleTokenResult(result, k);
}
function handleTokenResult(result, k) {
return chromeLocal.set({
async function handleTokenResult(result, k) {
await chromeLocal.set({
[k.TOKEN]: result.access_token,
[k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined,
[k.EXPIRE]: result.expires_in
? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
: undefined,
[k.REFRESH]: result.refresh_token,
})
.then(() => result.access_token);
});
return result.access_token;
}
function postQuery(url, body) {
async function postQuery(url, body) {
const options = {
method: 'POST',
headers: {
@ -198,17 +195,13 @@ const tokenManager = (() => {
},
body: body ? new URLSearchParams(body) : null,
};
return fetch(url, options)
.then(r => {
const r = await fetch(url, options);
if (r.ok) {
return r.json();
}
return r.text()
.then(body => {
const err = new Error(`failed to fetch (${r.status}): ${body}`);
const text = await r.text();
const err = new Error(`Failed to fetch (${r.status}): ${text}`);
err.code = r.status;
throw err;
});
});
}
})();

View File

@ -1,19 +1,12 @@
/* global
API
calcStyleDigest
chromeLocal
debounce
download
ignoreChromeError
prefs
semverCompare
styleJSONseemsValid
styleSectionsEqual
usercss
*/
/* 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',
@ -28,6 +21,7 @@
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
};
const ALARM_NAME = 'scheduledUpdate';
const MIN_INTERVAL_MS = 60e3;
const RETRY_ERRORS = [
@ -39,18 +33,18 @@
let logQueue = [];
let logLastWriteTime = 0;
API.updater = {
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,
};
chromeLocal.getValue('lastUpdateTime').then(val => {
lastUpdateTime = val || Date.now();
prefs.subscribe('updateInterval', schedule, {now: true});
chrome.alarms.onAlarm.addListener(onAlarm);
});
async function checkAllStyles({
save = true,
ignoreDigest,
@ -157,7 +151,8 @@
async function updateUsercss() {
// TODO: when sourceCode is > 100kB use http range request(s) for version check
const text = await tryDownload(style.updateUrl);
const json = await usercss.buildMeta(text);
const 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
@ -168,7 +163,7 @@
// downgrade is always invalid
return Promise.reject(STATES.ERROR_VERSION);
}
return usercss.buildCode(json);
return API.usercss.buildCode(json);
}
async function maybeSave(json) {
@ -187,7 +182,7 @@
return Promise.reject(STATES.MAYBE_EDITED);
}
return !save ? newStyle :
(ucd ? API.usercss : API.styles).install(newStyle);
(ucd ? API.usercss.install : API.styles.install)(newStyle);
}
async function tryDownload(url, params) {

View File

@ -1,81 +0,0 @@
/* global
API
deepCopy
usercss
*/
'use strict';
API.usercss = {
async build({
styleId,
sourceCode,
vars,
checkDup,
metaOnly,
assignVars,
}) {
let style = await usercss.buildMeta(sourceCode);
const dup = (checkDup || assignVars) &&
await API.usercss.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
style = await usercss.buildCode(style);
}
return {style, dup};
},
async buildMeta(style) {
if (style.usercssData) {
return style;
}
// allow sourceCode to be normalized
const {sourceCode} = style;
delete style.sourceCode;
return Object.assign(await usercss.buildMeta(sourceCode), style);
},
async configVars(id, vars) {
let style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
style = await usercss.buildCode(style);
style = await API.styles.install(style, 'config');
return style.usercssData.vars;
},
async editSave(style) {
return API.styles.editSave(await API.usercss.parse(style));
},
async find(styleOrData) {
if (styleOrData.id) {
return API.styles.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of await API.styles.getAll()) {
const data = dup.usercssData;
if (data &&
data.name === name &&
data.namespace === namespace) {
return dup;
}
}
},
async install(style) {
return API.styles.install(await API.usercss.parse(style));
},
async parse(style) {
style = await API.usercss.buildMeta(style);
// preserve style.vars during update
const dup = await API.usercss.find(style);
if (dup) {
style.id = dup.id;
await usercss.assignVars(style, dup);
}
return usercss.buildCode(style);
},
};

View File

@ -1,37 +1,22 @@
/* global
API
download
openURL
tabManager
URLS
*/
/* global URLS download openURL */// toolbox.js
/* global addAPI bgReady */// common.js
/* global tabMan */// msg.js
'use strict';
(() => {
bgReady.all.then(() => {
const installCodeCache = {};
const clearInstallCode = url => delete installCodeCache[url];
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
const isContentTypeText = type => /^text\/(?!html)/i.test(type);
// in Firefox we have to use a content script to read file://
const fileLoader = !chrome.app && (
async tabId =>
(await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
const urlLoader =
async (tabId, url) => (
url.startsWith('file:') ||
tabManager.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
API.usercss.getInstallCode = url => {
addAPI(/** @namespace API */ {
usercss: {
getInstallCode(url) {
// when the installer tab is reloaded after the cache is expired, this will throw intentionally
const {code, timer} = installCodeCache[url];
clearInstallCode(url);
clearTimeout(timer);
return code;
};
},
},
});
// `glob`: pathname match pattern for webRequest
// `rx`: pathname regex to verify the URL really looks like a raw usercss
@ -48,17 +33,7 @@
},
};
// Faster installation on known distribution sites to avoid flicker of css text
chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => {
const u = new URL(url);
const m = maybeDistro[u.hostname];
if (!m || m.rx.test(u.pathname)) {
openInstallerPage(tabId, url, {});
// Silently suppress navigation.
// Don't redirect to the install URL as it'll flash the text!
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
}
}, {
chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, {
urls: [
URLS.usoArchiveRaw + 'usercss/*.user.css',
'*://greasyfork.org/scripts/*/code/*.user.css',
@ -70,27 +45,63 @@
types: ['main_frame'],
}, ['blocking']);
// Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}, {
chrome.webRequest.onHeadersReceived.addListener(rememberContentType, {
urls: makeUsercssGlobs('*', '/*'),
types: ['main_frame'],
}, ['responseHeaders']);
tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
tabMan.onUpdate(maybeInstall);
function clearInstallCode(url) {
return delete installCodeCache[url];
}
/** Sites may be using custom types like text/stylus so this coarse filter only excludes html */
function isContentTypeText(type) {
return /^text\/(?!html)/i.test(type);
}
// in Firefox we have to use a content script to read file://
async function loadFromFile(tabId) {
return (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0];
}
async function loadFromUrl(tabId, url) {
return (
url.startsWith('file:') ||
tabMan.get(tabId, isContentTypeText.name) ||
isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
) && download(url);
}
function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
}
async function maybeInstall({tabId, url, oldUrl = ''}) {
if (url.includes('.user.') &&
/^(https?|file|ftps?):/.test(url) &&
/\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
!oldUrl.startsWith(URLS.installUsercss)) {
const inTab = url.startsWith('file:') && Boolean(fileLoader);
const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
if (/==userstyle==/i.test(code) && !/^\s*</.test(code)) {
const inTab = url.startsWith('file:') && !chrome.app;
const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url);
if (!/^\s*</.test(code) && URLS.rxMETA.test(code)) {
openInstallerPage(tabId, url, {code, inTab});
}
}
});
}
/** Faster installation on known distribution sites to avoid flicker of css text */
function maybeInstallFromDistro({tabId, url}) {
const u = new URL(url);
const m = maybeDistro[u.hostname];
if (!m || m.rx.test(u.pathname)) {
openInstallerPage(tabId, url, {});
// Silently suppress navigation.
// Don't redirect to the install URL as it'll flash the text!
return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
}
}
function openInstallerPage(tabId, url, {code, inTab} = {}) {
const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
@ -110,7 +121,9 @@
}
}
function makeUsercssGlobs(host, path) {
return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(',');
/** Remember Content-Type to avoid wasting time to re-fetch in loadFromUrl **/
function rememberContentType({tabId, responseHeaders}) {
const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
tabMan.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
}
})();
});

View File

@ -0,0 +1,152 @@
/* global API */// msg.js
/* global URLS deepCopy download */// toolbox.js
'use strict';
const usercssMan = {
GLOBAL_META: Object.entries({
author: null,
description: null,
homepageURL: 'url',
updateURL: 'updateUrl',
name: null,
}),
async assignVars(style, oldStyle) {
const meta = style.usercssData;
const vars = meta.vars;
const oldVars = oldStyle.usercssData.vars;
if (vars && oldVars) {
// The type of var might be changed during the update. Set value to null if the value is invalid.
for (const [key, v] of Object.entries(vars)) {
const old = oldVars[key] && oldVars[key].value;
if (old) v.value = old;
}
meta.vars = await API.worker.nullifyInvalidVars(vars);
}
},
async build({
styleId,
sourceCode,
vars,
checkDup,
metaOnly,
assignVars,
initialUrl,
}) {
// downloading here while install-usercss page is loading to avoid the wait
if (initialUrl) sourceCode = await download(initialUrl);
const style = await usercssMan.buildMeta({sourceCode});
const dup = (checkDup || assignVars) &&
await usercssMan.find(styleId ? {id: styleId} : style);
if (!metaOnly) {
if (vars || assignVars) {
await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup);
}
await usercssMan.buildCode(style);
}
return {style, dup};
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(URLS.rxMETA);
const i = match.index;
const j = i + match[0].length;
const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j);
const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
const recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
}
style.sections = sections;
return style;
},
async buildMeta(style) {
if (style.usercssData) {
return style;
}
// remember normalized sourceCode
let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n');
style = Object.assign({
enabled: true,
sections: [],
}, style);
const match = code.match(URLS.rxMETA);
if (!match) {
return Promise.reject(new Error('Could not find metadata.'));
}
try {
code = blankOut(code, 0, match.index) + match[0];
const {metadata} = await API.worker.parseUsercssMeta(code);
style.usercssData = metadata;
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
for (const [key, globalKey] of usercssMan.GLOBAL_META) {
const val = metadata[key];
if (val !== undefined) {
style[globalKey || key] = val;
}
}
return style;
} catch (err) {
if (err.code) {
const args = err.code === 'missingMandatory' || err.code === 'missingChar'
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
: err.args;
const msg = chrome.i18n.getMessage(`meta_${(err.code)}`, args);
if (msg) err.message = msg;
}
return Promise.reject(err);
}
},
async configVars(id, vars) {
const style = deepCopy(await API.styles.get(id));
style.usercssData.vars = vars;
await usercssMan.buildCode(style);
return (await API.styles.install(style, 'config'))
.usercssData.vars;
},
async editSave(style) {
return API.styles.editSave(await usercssMan.parse(style));
},
async find(styleOrData) {
if (styleOrData.id) {
return API.styles.get(styleOrData.id);
}
const {name, namespace} = styleOrData.usercssData || styleOrData;
for (const dup of await API.styles.getAll()) {
const data = dup.usercssData;
if (data &&
data.name === name &&
data.namespace === namespace) {
return dup;
}
}
},
async install(style) {
return API.styles.install(await usercssMan.parse(style));
},
async parse(style) {
style = await usercssMan.buildMeta(style);
// preserve style.vars during update
const dup = await usercssMan.find(style);
if (dup) {
style.id = dup.id;
await usercssMan.assignVars(style, dup);
}
return usercssMan.buildCode(style);
},
};
/** Replaces everything with spaces to keep the original length,
* but preserves the line breaks to keep the original line/col relation */
function blankOut(str, start = 0, end = str.length) {
return str.slice(start, end).replace(/[^\r\n]/g, ' ');
}

View File

@ -1,21 +1,22 @@
/* global msg API prefs createStyleInjector */
/* global API msg */// msg.js
/* global StyleInjector */
/* global prefs */
'use strict';
// Chrome reruns content script when documentElement is replaced.
// Note, we're checking against a literal `1`, not just `if (truthy)`,
// because <html id="INJECTED"> is exposed per HTML spec as a global variable and `window.INJECTED`.
(() => {
if (window.INJECTED === 1) return;
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
self.INJECTED = 1;
let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html';
const IS_FRAME = window !== parent;
const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument;
const styleInjector = createStyleInjector({
let hasStyles = false;
let isTab = !chrome.tabs || location.pathname !== '/popup.html';
const isFrame = window !== parent;
const isFrameAboutBlank = isFrame && location.href === 'about:blank';
const isUnstylable = !chrome.app && document instanceof XMLDocument;
const styleInjector = StyleInjector({
compare: (a, b) => a.id - b.id,
onUpdate: onInjectorUpdate,
});
// dynamic about: and javascript: iframes don't have a URL yet so we'll use their parent
const matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href) || location.href;
// save it now because chrome.runtime will be unavailable in the orphaned script
const orphanEventId = chrome.runtime.id;
@ -25,16 +26,16 @@ self.INJECTED !== 1 && (() => {
/** @type chrome.runtime.Port */
let port;
let lazyBadge = IS_FRAME;
let lazyBadge = isFrame;
let parentDomain;
// Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
const initializing = init();
const ready = init();
// the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
if (!IS_TAB) {
if (!isTab) {
chrome.tabs.getCurrent(tab => {
IS_TAB = Boolean(tab);
isTab = Boolean(tab);
if (tab && styleInjector.list.length) updateCount();
});
}
@ -50,30 +51,39 @@ self.INJECTED !== 1 && (() => {
if (!isOrphaned) {
updateCount();
const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
onOff(['disableAll'], updateDisableAll);
if (IS_FRAME) {
onOff('disableAll', updateDisableAll);
if (isFrame) {
updateExposeIframes();
onOff(['exposeIframes'], updateExposeIframes);
onOff('exposeIframes', updateExposeIframes);
}
}
}
async function init() {
if (STYLE_VIA_API) {
if (isUnstylable) {
await API.styleViaAPI({method: 'styleApply'});
} else {
const styles = chrome.app && !chrome.tabs && getStylesViaXhr() ||
await API.styles.getSectionsByUrl(getMatchUrl(), null, true);
if (styles.disableAll) {
delete styles.disableAll;
styleInjector.toggle(false);
}
const SYM_ID = 'styles';
const SYM = Symbol.for(SYM_ID);
const styles =
window[SYM] ||
(isFrameAboutBlank
? tryCatch(() => parent[parent.Symbol.for(SYM_ID)])
: chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr)) ||
await API.styles.getSectionsByUrl(matchUrl, null, true);
hasStyles = !styles.disableAll;
if (hasStyles) {
window[SYM] = styles;
await styleInjector.apply(styles);
} else {
delete window[SYM];
prefs.subscribe('disableAll', updateDisableAll);
}
}
}
/** Must be executed inside try/catch */
function getStylesViaXhr() {
try {
const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
const url = 'blob:' + chrome.runtime.getURL(blobId);
document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
@ -82,71 +92,56 @@ self.INJECTED !== 1 && (() => {
xhr.send();
URL.revokeObjectURL(url);
return JSON.parse(xhr.response);
} catch (e) {}
}
function getMatchUrl() {
let matchUrl = location.href;
if (!chrome.tabs && !matchUrl.match(/^(http|file|chrome|ftp)/)) {
// dynamic about: and javascript: iframes don't have an URL yet
// so we'll try the parent frame which is guaranteed to have a real URL
try {
if (IS_FRAME) {
matchUrl = parent.location.href;
}
} catch (e) {}
}
return matchUrl;
}
function applyOnMessage(request) {
if (STYLE_VIA_API) {
if (request.method === 'urlChanged') {
const {method} = request;
if (isUnstylable) {
if (method === 'urlChanged') {
request.method = 'styleReplaceAll';
}
if (/^(style|updateCount)/.test(request.method)) {
if (/^(style|updateCount)/.test(method)) {
API.styleViaAPI(request);
return;
}
}
switch (request.method) {
const {style} = request;
switch (method) {
case 'ping':
return true;
case 'styleDeleted':
styleInjector.remove(request.style.id);
styleInjector.remove(style.id);
break;
case 'styleUpdated':
if (request.style.enabled) {
API.styles.getSectionsByUrl(getMatchUrl(), request.style.id)
.then(sections => {
if (!sections[request.style.id]) {
styleInjector.remove(request.style.id);
if (style.enabled) {
API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
sections[style.id]
? styleInjector.apply(sections)
: styleInjector.remove(style.id));
} else {
styleInjector.apply(sections);
}
});
} else {
styleInjector.remove(request.style.id);
styleInjector.remove(style.id);
}
break;
case 'styleAdded':
if (request.style.enabled) {
API.styles.getSectionsByUrl(getMatchUrl(), request.style.id)
if (style.enabled) {
API.styles.getSectionsByUrl(matchUrl, style.id)
.then(styleInjector.apply);
}
break;
case 'urlChanged':
API.styles.getSectionsByUrl(getMatchUrl())
.then(styleInjector.replace);
API.styles.getSectionsByUrl(matchUrl).then(sections => {
hasStyles = true;
styleInjector.replace(sections);
});
break;
case 'backgroundReady':
initializing.catch(err =>
ready.catch(err =>
msg.isIgnorableError(err)
? init()
: console.error(err));
@ -159,8 +154,10 @@ self.INJECTED !== 1 && (() => {
}
function updateDisableAll(key, disableAll) {
if (STYLE_VIA_API) {
if (isUnstylable) {
API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
} else if (!hasStyles && !disableAll) {
init();
} else {
styleInjector.toggle(!disableAll);
}
@ -182,8 +179,8 @@ self.INJECTED !== 1 && (() => {
}
function updateCount() {
if (!IS_TAB) return;
if (IS_FRAME) {
if (!isTab) return;
if (isFrame) {
if (!port && styleInjector.list.length) {
port = chrome.runtime.connect({name: 'iframe'});
} else if (port && !styleInjector.list.length) {
@ -191,23 +188,25 @@ self.INJECTED !== 1 && (() => {
}
if (lazyBadge && performance.now() > 1000) lazyBadge = false;
}
(STYLE_VIA_API ?
(isUnstylable ?
API.styleViaAPI({method: 'updateCount'}) :
API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
).catch(msg.ignoreError);
}
function orphanCheck() {
function tryCatch(func, ...args) {
try {
if (chrome.i18n.getUILanguage()) return;
return func(...args);
} catch (e) {}
}
function orphanCheck() {
if (tryCatch(() => chrome.i18n.getUILanguage())) return;
// In Chrome content script is orphaned on an extension update/reload
// so we need to detach event listeners
window.removeEventListener(orphanEventId, orphanCheck, true);
isOrphaned = true;
styleInjector.clear();
try {
msg.off(applyOnMessage);
} catch (e) {}
tryCatch(msg.off, applyOnMessage);
}
})();

View File

@ -1,4 +1,4 @@
/* global API */
/* global API */// msg.js
'use strict';
// onCommitted may fire twice

View File

@ -1,4 +1,4 @@
/* global API */
/* global API */// msg.js
'use strict';
(() => {
@ -55,7 +55,7 @@
window.addEventListener('message', installedHandler);
};
const doHandshake = () => {
const doHandshake = event => {
// This is a representation of features that Stylus is capable of
const implementedFeatures = [
'install-usercss',
@ -106,7 +106,7 @@
&& event.data.type === 'ouc-handshake-question'
&& allowedOrigins.includes(event.origin)
) {
doHandshake();
doHandshake(event);
}
};

View File

@ -1,4 +1,4 @@
/* global cloneInto msg API */
/* global API msg */// msg.js
'use strict';
// eslint-disable-next-line no-unused-expressions
@ -14,13 +14,6 @@
msg.on(onMessage);
onDOMready().then(() => {
window.postMessage({
direction: 'from-content-script',
message: 'StylishInstalled',
}, '*');
});
let currentMd5;
const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
Promise.all([
@ -119,7 +112,7 @@
if (typeof cloneInto !== 'undefined') {
// Firefox requires explicit cloning, however USO can't process our messages anyway
// because USO tries to use a global "event" variable deprecated in Firefox
detail = cloneInto({detail}, document);
detail = cloneInto({detail}, document); /* global cloneInto */
} else {
detail = {detail};
}
@ -172,7 +165,7 @@
});
}
function saveStyleCode(message, name, addProps = {}) {
async function saveStyleCode(message, name, addProps = {}) {
const isNew = message === 'styleInstall';
const needsConfirmation = isNew || !saveStyleCode.confirmed;
if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
@ -180,22 +173,19 @@
}
saveStyleCode.confirmed = true;
enableUpdateButton(false);
return getStyleJson().then(json => {
const json = await getStyleJson();
if (!json) {
prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
'https://github.com/openstyles/stylus/issues/195');
return;
}
// Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
return API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}))
.then(style => {
const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}));
if (!isNew && style.updateUrl.includes('?')) {
enableUpdateButton(true);
} else {
sendEvent({type: 'styleInstalledChrome'});
}
});
});
function enableUpdateButton(state) {
const important = s => s.replace(/;/g, '!important;');
@ -218,11 +208,11 @@
return e ? e.getAttribute('href') : null;
}
async function getResource(url, type = 'text') {
async function getResource(url, opts) {
try {
return url.startsWith('#')
? document.getElementById(url.slice(1)).textContent
: await (await fetch(url))[type];
: await API.download(url, opts);
} catch (error) {
alert('Error\n' + error.message);
return Promise.reject(error);
@ -231,32 +221,19 @@
// USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
// instead of "https://update.userstyles.org/#####.md5"
function tryFixMd5(style) {
if (style && style.md5Url && style.md5Url.includes('update.update')) {
style.md5Url = style.md5Url.replace('update.update', 'update');
}
return style;
}
function getStyleJson() {
return getResource(getStyleURL(), 'json')
.then(style => {
if (!style || !Array.isArray(style.sections) || style.sections.length) {
return style;
}
async function getStyleJson() {
try {
const style = await getResource(getStyleURL(), {responseType: 'json'});
const codeElement = document.getElementById('stylish-code');
if (codeElement && !codeElement.textContent.trim()) {
if (!style || !Array.isArray(style.sections) || style.sections.length ||
codeElement && !codeElement.textContent.trim()) {
return style;
}
return getResource(getMeta('stylish-update-url'))
.then(code => API.worker.parseMozFormat({code}))
.then(result => {
style.sections = result.sections;
const code = await getResource(getMeta('stylish-update-url'));
style.sections = (await API.worker.parseMozFormat({code})).sections;
if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
return style;
});
})
.then(tryFixMd5)
.catch(() => null);
} catch (e) {}
}
/**
@ -290,7 +267,7 @@
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
: new Promise(resolve => window.addEventListener('load', resolve, {once: true}));
}
function openSettings(countdown = 10e3) {
@ -329,6 +306,7 @@
function inPageContext(eventId) {
document.currentScript.remove();
window.isInstalled = true;
const origMethods = {
json: Response.prototype.json,
byId: document.getElementById,

View File

@ -1,6 +1,7 @@
'use strict';
self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
/** @type {function(opts):StyleInjector} */
window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({
compare,
onUpdate = () => {},
}) => {
@ -17,22 +18,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
// will store the original method refs because the page can override them
let creationDoc, createElement, createElementNS;
return {
return /** @namespace StyleInjector */ {
list,
apply(styleMap) {
async apply(styleMap) {
const styles = _styleMapToArray(styleMap);
return (
!styles.length ?
Promise.resolve([]) :
docRootObserver.evade(() => {
const value = !styles.length
? []
: await docRootObserver.evade(() => {
if (!isTransitionPatched && isEnabled) {
_applyTransitionPatch(styles);
}
return styles.map(_addUpdate);
})
).then(_emitUpdate);
});
_emitUpdate();
return value;
},
clear() {
@ -155,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({
docRootObserver[onOff]();
}
function _emitUpdate(value) {
function _emitUpdate() {
_toggleObservers(list.length);
onUpdate();
return value;
}
/*

View File

@ -4,8 +4,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="global.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
<link rel="stylesheet" href="msgbox/msgbox.css">
<style id="firefox-transitions-bug-suppressor">
/* restrict to FF */
@ -21,94 +19,59 @@
<link id="cm-theme" rel="stylesheet">
<script src="js/polyfill.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/toolbox.js"></script>
<script src="js/msg.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="edit/util.js"></script>
<script src="edit/edit.js"></script> <!-- run it ASAP to send a request for the style -->
<script src="js/sections-util.js"></script>
<script src="edit/base.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
<script src="vendor/codemirror/mode/css/css.js"></script>
<script src="vendor/codemirror/mode/stylus/stylus.js"></script>
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<script src="vendor/codemirror/addon/dialog/dialog.js"></script>
<script src="vendor/codemirror/addon/edit/closebrackets.js"></script>
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="vendor/codemirror/addon/scroll/annotatescrollbar.js"></script>
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
<script src="vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="vendor/codemirror/addon/comment/comment.js"></script>
<script src="vendor/codemirror/addon/selection/active-line.js"></script>
<script src="vendor/codemirror/addon/edit/matchbrackets.js"></script>
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
<script src="vendor/codemirror/addon/fold/comment-fold.js"></script>
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/lint/lint.js"></script>
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/hint/show-hint.js"></script>
<script src="vendor/codemirror/addon/hint/css-hint.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
<script src="vendor-overwrites/codemirror-addon/match-highlighter.js"></script>
<script src="vendor/lz-string-unsafe/lz-string-unsafe.min.js"></script>
<script src="msgbox/msgbox.js" async></script>
<script src="js/color/color-converter.js"></script>
<script src="js/color/color-mimicry.js"></script>
<script src="js/color/color-picker.js"></script>
<script src="js/color/color-view.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/worker-util.js"></script>
<link href="edit/codemirror-default.css" rel="stylesheet">
<script src="edit/util.js"></script>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/codemirror-default.js"></script>
<script src="edit/codemirror-factory.js"></script>
<script src="edit/regexp-tester.js"></script>
<script src="edit/live-preview.js"></script>
<script src="edit/moz-section-finder.js"></script>
<script src="edit/moz-section-widget.js"></script>
<script src="edit/reroute-hotkeys.js"></script>
<link href="edit/global-search.css" rel="stylesheet">
<script src="edit/global-search.js"></script>
<script src="edit/colorpicker-helper.js"></script>
<script src="edit/linter-manager.js"></script>
<script src="edit/beautify.js"></script>
<script src="edit/show-keymap-help.js"></script>
<script src="edit/codemirror-themes.js"></script>
<script src="edit/source-editor.js"></script>
<script src="edit/sections-editor-section.js"></script>
<script src="edit/sections-editor.js"></script>
<script src="js/worker-util.js"></script>
<script src="edit/linter.js"></script>
<script src="edit/linter-defaults.js"></script>
<script src="edit/linter-engines.js"></script>
<script src="edit/linter-meta.js"></script>
<script src="edit/linter-help-dialog.js"></script>
<script src="edit/linter-report.js"></script>
<script src="edit/linter-config-dialog.js"></script>
<script src="edit/edit.js"></script>
<template data-id="appliesTo">
<li class="applies-to-item">
@ -276,6 +239,16 @@
</tbody>
</table>
</template>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet">
<link href="vendor/codemirror/addon/hint/show-hint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/lint/lint.css" rel="stylesheet">
<link href="vendor/codemirror/addon/search/matchesonscrollbar.css" rel="stylesheet">
<link href="js/color/color-picker.css" rel="stylesheet">
<link href="edit/codemirror-default.css" rel="stylesheet">
<link href="edit/edit.css" rel="stylesheet">
</head>
<body id="stylus-edit">
@ -498,6 +471,5 @@
</symbol>
</svg>
</body>
</html>

234
edit/autocomplete.js Normal file
View File

@ -0,0 +1,234 @@
/* global CodeMirror */
/* global debounce */// toolbox.js
/* global editor */
/* global prefs */
'use strict';
/* Registers 'hint' helper and 'autocompleteOnTyping' option in CodeMirror */
(() => {
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
const cssMime = CodeMirror.mimeModes['text/css'];
const docFuncs = addSuffix(cssMime.documentTypes, '(');
const {tokenHooks} = cssMime;
const originalCommentHook = tokenHooks['/'];
const originalHelper = CodeMirror.hint.css || (() => {});
let cssProps, cssMedia;
CodeMirror.defineOption('autocompleteOnTyping', prefs.get('editor.autocompleteOnTyping'),
(cm, value) => {
cm[value ? 'on' : 'off']('changes', autocompleteOnTyping);
cm[value ? 'on' : 'off']('pick', autocompletePicked);
});
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
tokenHooks['/'] = tokenizeUsoVariables;
function helper(cm) {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
const isStylusLang = cm.doc.mode.name === 'stylus';
const type = style && style.split(' ', 1)[0] || 'prop?';
if (!type || type === 'comment' || type === 'string') {
return originalHelper(cm);
}
// not using getTokenAt until the need is unavoidable because it reparses text
// and runs a whole lot of complex calc inside which is slow on long lines
// especially if autocomplete is auto-shown on each keystroke
let prev, end, state;
let i = index;
while (
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
(prev = i > 2 ? styles[i - 2] : 0) &&
isSameToken(text, style, prev)
) i -= 2;
i = index;
while (
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
(end = styles[i]) &&
isSameToken(text, style, end)
) i += 2;
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
const str = text.slice(prev, end);
const left = text.slice(prev, ch).trim();
let leftLC = left.toLowerCase();
let list = [];
switch (leftLC[0]) {
case '!':
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
break;
case '@':
list = [
'@-moz-document',
'@charset',
'@font-face',
'@import',
'@keyframes',
'@media',
'@namespace',
'@page',
'@supports',
'@viewport',
];
break;
case '#': // prevents autocomplete for #hex colors
break;
case '-': // --variable
case '(': // var(
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
? findAllCssVars(cm, left)
: [];
prev += str.startsWith('(');
leftLC = left;
break;
case '/': // USO vars
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
prev += 4;
end -= 4;
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
leftLC = left.slice(4);
}
break;
case 'u': // url(), url-prefix()
case 'd': // domain()
case 'r': // regexp()
if (/^(variable|tag|error)/.test(type) &&
docFuncs.some(s => s.startsWith(leftLC)) &&
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
end++;
list = docFuncs;
}
break;
default:
// properties and media features
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
if (!cssProps) initCssProps();
if (type === 'prop?') {
prev += leftLC.length;
leftLC = '';
}
list = state === 'atBlock_parens' ? cssMedia : cssProps;
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
end += execAt(rxCONSUME, end, text)[0].length;
} else {
return isStylusLang
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
: originalHelper(cm);
}
}
return {
list: (list || []).filter(s => s.startsWith(leftLC)),
from: {line, ch: prev + str.match(/^\s*/)[0].length},
to: {line, ch: end},
};
}
function initCssProps() {
cssProps = addSuffix(cssMime.propertyKeywords).sort();
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
}
function addSuffix(obj, suffix = ': ') {
return Object.keys(obj).map(k => k + suffix);
}
function getMediaKeys([k, v]) {
return k === 'mediaFeatures' && addSuffix(v) ||
k.startsWith('media') && Object.keys(v);
}
/** makes sure we don't process a different adjacent comment */
function isSameToken(text, style, i) {
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
}
function findAllCssVars(cm, leftPart) {
// simplified regex without CSS escapes
const rx = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'g');
const list = new Set();
cm.eachLine(({text}) => {
for (let m; (m = rx.exec(text));) {
list.add(m[1]);
}
});
return [...list].sort();
}
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] === 'comment') {
const {string, start, pos} = stream;
if (testAt(/\/\*\[\[/y, start, string) &&
testAt(/]]\*\//y, pos - 4, string)) {
const vars = (editor.style.usercssData || {}).vars;
token[0] =
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
? USO_VALID_VAR
: USO_INVALID_VAR;
}
}
return token;
}
function execAt(rx, index, text) {
rx.lastIndex = index;
return rx.exec(text);
}
function testAt(rx, index, text) {
rx.lastIndex = Math.max(0, index);
return rx.test(text);
}
function autocompleteOnTyping(cm, [info], debounced) {
const lastLine = info.text[info.text.length - 1];
if (cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!lastLine) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (lastLine.match(/[-a-z!]+$/i)) {
cm.state.autocompletePicked = false;
cm.options.hintOptions.completeSingle = false;
cm.execCommand('autocomplete');
setTimeout(() => {
cm.options.hintOptions.completeSingle = true;
});
}
}
function autocompletePicked(cm) {
cm.state.autocompletePicked = true;
}
})();

412
edit/base.js Normal file
View File

@ -0,0 +1,412 @@
/* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
/* global API */// msg.js
/* global CODEMIRROR_THEMES */
/* global CodeMirror */
/* global MozDocMapper */// sections-util.js
/* global initBeautifyButton */// beautify.js
/* global prefs */
/* global t */// localization.js
/* global
FIREFOX
debounce
getOwnTab
sessionStore
tryCatch
tryJSONparse
*/// toolbox.js
'use strict';
/**
* @type Editor
* @namespace Editor
*/
const editor = {
dirty: DirtyReporter(),
isUsercss: false,
isWindowed: false,
livePreview: null,
/** @type {'customName'|'name'} */
nameTarget: 'name',
previewDelay: 200, // Chrome devtools uses 200
scrollInfo: null,
updateTitle(isDirty = editor.dirty.isDirty()) {
const {customName, name} = editor.style;
document.title = `${
isDirty ? '* ' : ''
}${
customName || name || t('styleMissingName')
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
},
};
//#region pre-init
const baseInit = (() => {
const lazyKeymaps = {
emacs: '/vendor/codemirror/keymap/emacs',
vim: '/vendor/codemirror/keymap/vim',
};
const domReady = waitForSelector('#sections');
return {
domReady,
ready: Promise.all([
domReady,
loadStyle(),
prefs.ready.then(() =>
Promise.all([
loadTheme(),
loadKeymaps(),
])),
]),
};
/** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
function loadKeymaps() {
const km = prefs.get('editor.keyMap');
return /emacs/i.test(km) && require([lazyKeymaps.emacs]) ||
/vim/i.test(km) && require([lazyKeymaps.vim]);
}
async function loadStyle() {
const params = new URLSearchParams(location.search);
const id = Number(params.get('id'));
const style = id && await API.styles.get(id) || {
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
enabled: true,
sections: [
MozDocMapper.toSection([...params], {code: ''}),
],
};
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
editor.lazyKeymaps = lazyKeymaps;
editor.style = style;
editor.updateTitle(false);
document.documentElement.classList.toggle('usercss', editor.isUsercss);
sessionStore.justEditedStyleId = style.id || '';
// no such style so let's clear the invalid URL parameters
if (!style.id) history.replaceState({}, '', location.pathname);
}
/** Preloads the theme so CodeMirror can use the correct metrics in its first render */
async function loadTheme() {
const theme = prefs.get('editor.theme');
if (theme !== 'default') {
const el = $('#cm-theme');
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
el2.id = el.id;
el.remove();
if (!el2.sheet) {
prefs.set('editor.theme', 'default');
}
}
}
})();
//#endregion
//#region init layout/resize
baseInit.domReady.then(() => {
let headerHeight;
detectLayout(true);
window.on('resize', () => detectLayout());
function detectLayout(now) {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
if (now) fixedHeader();
else debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
document.body.classList.remove('compact-layout', 'fixed-header');
window.off('scroll', fixedHeader);
}
for (const type of ['options', 'toc', 'lint']) {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
if (window.scrollY >= scrollPoint && !headerFixed) {
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
$('body').classList.add('fixed-header');
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
});
//#endregion
//#region init header
baseInit.ready.then(() => {
initBeautifyButton($('#beautify'));
initKeymapElement();
initNameArea();
initThemeElement();
setupLivePrefs();
$('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !editor.style.id);
require(Object.values(editor.lazyKeymaps), () => {
initKeymapElement();
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
});
function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command);
if (key) {
return key;
}
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
key = ft && findKeyForCommand(command, ft);
if (key) {
return key;
}
}
return '';
}
function initNameArea() {
const nameEl = $('#name');
const resetEl = $('#reset-name');
const isCustomName = editor.style.updateUrl || editor.isUsercss;
editor.nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => {
editor.updateName(true);
resetEl.hidden = false;
});
resetEl.hidden = !editor.style.customName;
resetEl.onclick = () => {
const {style} = editor;
nameEl.focus();
nameEl.select();
// trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name;
editor.updateName(true);
}
style.customName = null; // to delete it from db
resetEl.hidden = true;
};
const enabledEl = $('#enabled');
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
}
function initThemeElement() {
$('#editor.theme').append(...[
$create('option', {value: 'default'}, t('defaultTheme')),
...CODEMIRROR_THEMES.map(s => $create('option', s)),
]);
// move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme'));
}
function initKeymapElement() {
// move 'pc' or 'mac' prefix to the end of the displayed label
const maps = Object.keys(CodeMirror.keyMap)
.map(name => ({
value: name,
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
}))
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
const fragment = document.createDocumentFragment();
let bin = fragment;
let groupName;
// group suffixed maps in <optgroup>
maps.forEach(({value, name}, i) => {
groupName = !name.includes('-') ? name : groupName;
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
if (groupWithNext) {
if (bin === fragment) {
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
}
}
const el = bin.appendChild($create('option', {value}, name));
if (value === prefs.defaults['editor.keyMap']) {
el.dataset.default = '';
el.title = t('defaultTheme');
}
if (!groupWithNext) bin = fragment;
});
const selector = $('#editor.keyMap');
selector.textContent = '';
selector.appendChild(fragment);
selector.value = prefs.get('editor.keyMap');
}
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
const extraKeys = CodeMirror.defaults.extraKeys;
for (const el of $$('[data-hotkey-tooltip]')) {
if (el._hotkeyTooltipKeyMap !== mapName) {
el._hotkeyTooltipKeyMap = mapName;
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
const cmd = el.dataset.hotkeyTooltip;
const key = cmd[0] === '=' ? cmd.slice(1) :
findKeyForCommand(cmd, mapName) ||
extraKeys && findKeyForCommand(cmd, extraKeys);
const newTitle = title + (title && key ? '\n' : '') + (key || '');
if (el.title !== newTitle) el.title = newTitle;
}
}
}
});
//#endregion
//#region init windowed mode
(() => {
let ownTabId;
if (chrome.windows) {
initWindowedMode();
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
// resize the window on 'undo close'
if (pos && pos.left != null) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
getOwnTab().then(async tab => {
ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
await baseInit.domReady;
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
}
});
async function initWindowedMode() {
chrome.tabs.onAttached.addListener(onTabAttached);
const isSimple = (await browser.windows.getCurrent()).type === 'popup';
if (isSimple) require(['/edit/embedded-popup']);
editor.isWindowed = isSimple || (
history.length === 1 &&
await prefs.ready && prefs.get('openEditInWindow') &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
}
async function onTabAttached(tabId, info) {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
const win = await browser.windows.get(info.newWindowId, {populate: true});
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
// FF-only because Chrome retardedly resets the size during dragging
if (openEditInWindow && FIREFOX) {
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
}
})();
//#endregion
//#region internals
/** @returns DirtyReporter */
function DirtyReporter() {
const data = new Map();
const listeners = new Set();
const notifyChange = wasDirty => {
if (wasDirty !== (data.size > 0)) {
listeners.forEach(cb => cb());
}
};
/** @namespace DirtyReporter */
return {
add(obj, value) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
data.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
data.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
}
}
notifyChange(wasDirty);
},
clear(obj) {
const wasDirty = data.size > 0;
if (obj === undefined) {
data.clear();
} else {
data.delete(obj);
}
notifyChange(wasDirty);
},
has(key) {
return data.has(key);
},
isDirty() {
return data.size > 0;
},
modify(obj, oldValue, newValue) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
if (oldValue !== newValue) {
data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
data.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
notifyChange(wasDirty);
},
onChange(cb, add = true) {
listeners[add ? 'add' : 'delete'](cb);
},
remove(obj, value) {
const wasDirty = data.size > 0;
const saved = data.get(obj);
if (!saved) {
data.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
data.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
notifyChange(wasDirty);
},
};
}
//#endregion

View File

@ -1,20 +1,18 @@
/* global loadScript css_beautify showHelp prefs t $ $create */
/* global editor createHotkeyInput moveFocus CodeMirror */
/* exported initBeautifyButton */
/* global $ $create moveFocus */// dom.js
/* global CodeMirror */
/* global createHotkeyInput helpPopup */// util.js
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
const HOTKEY_ID = 'editor.beautify.hotkey';
prefs.initializing.then(() => {
CodeMirror.defaults.extraKeys[prefs.get(HOTKEY_ID) || ''] = 'beautify';
CodeMirror.commands.beautify = cm => {
// using per-section mode when code editor or applies-to block is focused
const isPerSection = cm.display.wrapper.parentElement.contains(document.activeElement);
beautify(isPerSection ? [cm] : editor.getEditors(), false);
};
});
prefs.subscribe([HOTKEY_ID], (key, value) => {
prefs.subscribe('editor.beautify.hotkey', (key, value) => {
const {extraKeys} = CodeMirror.defaults;
for (const [key, cmd] of Object.entries(extraKeys)) {
if (cmd === 'beautify') {
@ -25,50 +23,28 @@ prefs.subscribe([HOTKEY_ID], (key, value) => {
if (value) {
extraKeys[value] = 'beautify';
}
});
/**
* @param {HTMLElement} btn - the button element shown in the UI
* @param {function():CodeMirror[]} getScope
*/
function initBeautifyButton(btn, getScope) {
btn.addEventListener('click', () => beautify(getScope()));
btn.addEventListener('contextmenu', e => {
e.preventDefault();
beautify(getScope(), false);
});
}
}, {runNow: true});
/**
* @name beautify
* @param {CodeMirror[]} scope
* @param {?boolean} ui
* @param {boolean} [ui=true]
*/
function beautify(scope, ui = true) {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
window.css_beautify = window.exports.css_beautify;
}
})
.then(doBeautify);
function doBeautify() {
async function beautify(scope, ui = true) {
await require(['/vendor-overwrites/beautify/beautify-css-mod']); /* global css_beautify */
const tabs = prefs.get('editor.indentWithTabs');
const options = Object.assign({}, prefs.get('editor.beautify'));
for (const k of Object.keys(prefs.defaults['editor.beautify'])) {
if (!(k in options)) options[k] = prefs.defaults['editor.beautify'][k];
}
const options = Object.assign(prefs.defaults['editor.beautify'], prefs.get('editor.beautify'));
options.indent_size = tabs ? 1 : prefs.get('editor.tabSize');
options.indent_char = tabs ? '\t' : ' ';
if (ui) {
createBeautifyUI(scope, options);
}
for (const cm of scope) {
setTimeout(doBeautifyEditor, 0, cm, options);
setTimeout(beautifyEditor, 0, cm, options, ui);
}
}
function doBeautifyEditor(cm, options) {
function beautifyEditor(cm, options, ui) {
const pos = options.translate_positions =
[].concat.apply([], cm.doc.sel.ranges.map(r =>
[Object.assign({}, r.anchor), Object.assign({}, r.head)]));
@ -95,7 +71,7 @@ function beautify(scope, ui = true) {
}
function createBeautifyUI(scope, options) {
showHelp(t('styleBeautify'),
helpPopup.show(t('styleBeautify'),
$create([
$create('.beautify-options', [
$createOption('.selector1,', 'selector_separator_newline'),
@ -109,13 +85,12 @@ function beautify(scope, ui = true) {
]),
$create('p.beautify-hint', [
$create('span', t('styleBeautifyHint') + '\u00A0'),
createHotkeyInput(HOTKEY_ID, () => moveFocus($('#help-popup'), 1)),
createHotkeyInput('editor.beautify.hotkey', () => moveFocus($('#help-popup'), 0)),
]),
$create('.buttons', [
$create('button', {
attributes: {role: 'close'},
// showHelp.close will be defined after showHelp() is invoked
onclick: () => showHelp.close(),
onclick: helpPopup.close,
}, t('confirmClose')),
$create('button', {
attributes: {role: 'undo'},
@ -145,7 +120,7 @@ function beautify(scope, ui = true) {
if (target.parentNode.hasAttribute('newline')) {
target.parentNode.setAttribute('newline', value.toString());
}
doBeautify();
beautify(scope, false);
};
function $createOption(label, optionName, indent) {
@ -185,4 +160,11 @@ function beautify(scope, ui = true) {
);
}
}
/* exported initBeautifyButton */
function initBeautifyButton(btn, scope) {
btn.onclick = btn.oncontextmenu = e => {
e.preventDefault();
beautify(scope || editor.getEditors(), e.type === 'click');
};
}

View File

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

View File

@ -1,13 +1,11 @@
/* global
$
CodeMirror
prefs
t
*/
/* global $ */// dom.js
/* global CodeMirror */
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
(function () {
(() => {
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap');
@ -43,6 +41,7 @@
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
// Adding hotkeys to some keymaps except 'basic' which is primitive by design
require(Object.values(typeof editor === 'object' && editor.lazyKeymaps || {}), () => {
const KM = CodeMirror.keyMap;
const extras = Object.values(CodeMirror.defaults.extraKeys);
if (!extras.includes('jumpToLine')) {
@ -90,6 +89,7 @@
}
}
}
});
const cssMime = CodeMirror.mimeModes['text/css'];
Object.assign(cssMime.propertyKeywords, {
@ -142,12 +142,16 @@
jumpToPos(pos, end = pos) {
const {curOp} = this;
if (!curOp) this.startOperation();
const coords = this.cursorCoords(pos, 'window');
const b = this.display.wrapper.getBoundingClientRect();
if (coords.top < Math.max(0, b.top + this.defaultTextHeight() * 2) ||
coords.bottom > Math.min(window.innerHeight, b.bottom - 100)) {
this.scrollIntoView(pos, b.height / 2);
const y = this.cursorCoords(pos, 'window').top;
const rect = this.display.wrapper.getBoundingClientRect();
// case 1) outside of CM viewport or too close to edge so tell CM to render a new viewport
if (y < rect.top + 50 || y > rect.bottom - 100) {
this.scrollIntoView(pos, rect.height / 2);
// case 2) inside CM viewport but outside of window viewport so just scroll the window
} else if (y < 0 || y > innerHeight) {
editor.scrollToEditor(this);
}
// Using prototype since our bookmark patch sets cm.setSelection to jumpToPos
CodeMirror.prototype.setSelection.call(this, pos, end);
if (!curOp) this.endOperation();
},

View File

@ -1,25 +1,24 @@
/* global
$
CodeMirror
debounce
editor
loadScript
prefs
rerouteHotkeys
*/
/* global $ */// dom.js
/* global CodeMirror */
/* global editor */
/* global prefs */
/* global rerouteHotkeys */// util.js
'use strict';
//#region cmFactory
(() => {
/*
All cm instances created by this module are collected so we can broadcast prefs
settings to them. You should `cmFactory.destroy(cm)` to unregister the listener
when the instance is not used anymore.
*/
(() => {
//#region Factory
const cms = new Set();
let lazyOpt;
const cmFactory = window.cmFactory = {
create(place, options) {
const cm = CodeMirror(place, options);
const {wrapper} = cm.display;
@ -38,9 +37,11 @@
cms.add(cm);
return cm;
},
destroy(cm) {
cms.delete(cm);
},
globalSetOption(key, value) {
CodeMirror.defaults[key] = value;
if (cms.size > 4 && lazyOpt && lazyOpt.names.includes(key)) {
@ -52,28 +53,28 @@
};
const handledPrefs = {
// handled in colorpicker-helper.js
'editor.colorpicker'() {},
/** @returns {?Promise<void>} */
'editor.theme'(key, value) {
const elt = $('#cm-theme');
'editor.colorpicker'() {}, // handled in colorpicker-helper.js
async 'editor.theme'(key, value) {
let el2;
const el = $('#cm-theme');
if (value === 'default') {
elt.href = '';
el.href = '';
} else {
const url = chrome.runtime.getURL(`vendor/codemirror/theme/${value}.css`);
if (url !== elt.href) {
const path = `/vendor/codemirror/theme/${value}.css`;
if (el.href !== location.origin + path) {
// avoid flicker: wait for the second stylesheet to load, then apply the theme
return loadScript(url, true).then(([newElt]) => {
cmFactory.globalSetOption('theme', value);
elt.remove();
newElt.id = elt.id;
});
el2 = await require([path]);
}
}
cmFactory.globalSetOption('theme', value);
if (el2) {
el.remove();
el2.id = el.id;
}
},
};
const pref2opt = k => k.slice('editor.'.length);
const mirroredPrefs = Object.keys(prefs.defaults).filter(k =>
const mirroredPrefs = prefs.knownKeys.filter(k =>
!handledPrefs[k] &&
k.startsWith('editor.') &&
Object.hasOwnProperty.call(CodeMirror.defaults, pref2opt(k)));
@ -125,12 +126,14 @@
return lazyOpt._observer;
},
};
})();
//#endregion
//#endregion
//#region Commands
(() => {
Object.assign(CodeMirror.commands, {
commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
},
toggleEditorFocus(cm) {
if (!cm) return;
if (cm.hasFocus()) {
@ -139,9 +142,6 @@
cm.focus();
}
},
commentSelection(cm) {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
},
});
for (const cmd of [
'nextEditor',
@ -151,11 +151,10 @@
]) {
CodeMirror.commands[cmd] = (...args) => editor[cmd](...args);
}
})();
//#endregion
//#endregion
//#region CM option handlers
(() => {
const {insertTab, insertSoftTab} = CodeMirror.commands;
Object.entries({
tabSize(cm, value) {
@ -164,11 +163,6 @@
indentWithTabs(cm, value) {
CodeMirror.commands.insertTab = value ? insertTab : insertSoftTab;
},
autocompleteOnTyping(cm, value) {
const onOff = value ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
},
matchHighlight(cm, value) {
const showToken = value === 'token' && /[#.\-\w]/;
const opt = (showToken || value === 'selection') && {
@ -246,237 +240,12 @@
};
}
function autocompleteOnTyping(cm, [info], debounced) {
const lastLine = info.text[info.text.length - 1];
if (
cm.state.completionActive ||
info.origin && !info.origin.includes('input') ||
!lastLine
) {
return;
}
if (cm.state.autocompletePicked) {
cm.state.autocompletePicked = false;
return;
}
if (!debounced) {
debounce(autocompleteOnTyping, 100, cm, [info], true);
return;
}
if (lastLine.match(/[-a-z!]+$/i)) {
cm.state.autocompletePicked = false;
cm.options.hintOptions.completeSingle = false;
cm.execCommand('autocomplete');
setTimeout(() => {
cm.options.hintOptions.completeSingle = true;
});
}
}
function autocompletePicked(cm) {
cm.state.autocompletePicked = true;
}
})();
//#endregion
//#region Autocomplete
(() => {
const AT_RULES = [
'@-moz-document',
'@charset',
'@font-face',
'@import',
'@keyframes',
'@media',
'@namespace',
'@page',
'@supports',
'@viewport',
];
const USO_VAR = 'uso-variable';
const USO_VALID_VAR = 'variable-3 ' + USO_VAR;
const USO_INVALID_VAR = 'error ' + USO_VAR;
const rxVAR = /(^|[^-.\w\u0080-\uFFFF])var\(/iyu;
const rxCONSUME = /([-\w]*\s*:\s?)?/yu;
const cssMime = CodeMirror.mimeModes['text/css'];
const docFuncs = addSuffix(cssMime.documentTypes, '(');
const {tokenHooks} = cssMime;
const originalCommentHook = tokenHooks['/'];
const originalHelper = CodeMirror.hint.css || (() => {});
let cssProps, cssMedia;
CodeMirror.registerHelper('hint', 'css', helper);
CodeMirror.registerHelper('hint', 'stylus', helper);
tokenHooks['/'] = tokenizeUsoVariables;
function helper(cm) {
const pos = cm.getCursor();
const {line, ch} = pos;
const {styles, text} = cm.getLineHandle(line);
const {style, index} = cm.getStyleAtPos({styles, pos: ch}) || {};
const isStylusLang = cm.doc.mode.name === 'stylus';
const type = style && style.split(' ', 1)[0] || 'prop?';
if (!type || type === 'comment' || type === 'string') {
return originalHelper(cm);
}
// not using getTokenAt until the need is unavoidable because it reparses text
// and runs a whole lot of complex calc inside which is slow on long lines
// especially if autocomplete is auto-shown on each keystroke
let prev, end, state;
let i = index;
while (
(prev == null || `${styles[i - 1]}`.startsWith(type)) &&
(prev = i > 2 ? styles[i - 2] : 0) &&
isSameToken(text, style, prev)
) i -= 2;
i = index;
while (
(end == null || `${styles[i + 1]}`.startsWith(type)) &&
(end = styles[i]) &&
isSameToken(text, style, end)
) i += 2;
const getTokenState = () => state || (state = cm.getTokenAt(pos, true).state.state);
const str = text.slice(prev, end);
const left = text.slice(prev, ch).trim();
let leftLC = left.toLowerCase();
let list = [];
switch (leftLC[0]) {
case '!':
list = '!important'.startsWith(leftLC) ? ['!important'] : [];
break;
case '@':
list = AT_RULES;
break;
case '#': // prevents autocomplete for #hex colors
break;
case '-': // --variable
case '(': // var(
list = str.startsWith('--') || testAt(rxVAR, ch - 4, text)
? findAllCssVars(cm, left)
: [];
prev += str.startsWith('(');
leftLC = left;
break;
case '/': // USO vars
if (str.startsWith('/*[[') && str.endsWith(']]*/')) {
prev += 4;
end -= 4;
end -= text.slice(end - 4, end) === '-rgb' ? 4 : 0;
list = Object.keys((editor.style.usercssData || {}).vars || {}).sort();
leftLC = left.slice(4);
}
break;
case 'u': // url(), url-prefix()
case 'd': // domain()
case 'r': // regexp()
if (/^(variable|tag|error)/.test(type) &&
docFuncs.some(s => s.startsWith(leftLC)) &&
/^(top|documentTypes|atBlock)/.test(getTokenState())) {
end++;
list = docFuncs;
}
break;
default:
// properties and media features
if (/^(prop(erty|\?)|atom|error)/.test(type) &&
/^(block|atBlock_parens|maybeprop)/.test(getTokenState())) {
if (!cssProps) initCssProps();
if (type === 'prop?') {
prev += leftLC.length;
leftLC = '';
}
list = state === 'atBlock_parens' ? cssMedia : cssProps;
end -= /\W$/u.test(str); // e.g. don't consume ) when inside ()
end += execAt(rxCONSUME, end, text)[0].length;
} else {
return isStylusLang
? CodeMirror.hint.fromList(cm, {words: CodeMirror.hintWords.stylus})
: originalHelper(cm);
}
}
return {
list: (list || []).filter(s => s.startsWith(leftLC)),
from: {line, ch: prev + str.match(/^\s*/)[0].length},
to: {line, ch: end},
};
}
function initCssProps() {
cssProps = addSuffix(cssMime.propertyKeywords).sort();
cssMedia = [].concat(...Object.entries(cssMime).map(getMediaKeys).filter(Boolean)).sort();
}
function addSuffix(obj, suffix = ': ') {
return Object.keys(obj).map(k => k + suffix);
}
function getMediaKeys([k, v]) {
return k === 'mediaFeatures' && addSuffix(v) ||
k.startsWith('media') && Object.keys(v);
}
/** makes sure we don't process a different adjacent comment */
function isSameToken(text, style, i) {
return !style || text[i] !== '/' && text[i + 1] !== '*' ||
!style.startsWith(USO_VALID_VAR) && !style.startsWith(USO_INVALID_VAR);
}
function findAllCssVars(cm, leftPart) {
// simplified regex without CSS escapes
const rx = new RegExp(
'(?:^|[\\s/;{])(' +
(leftPart.startsWith('--') ? leftPart : '--') +
(leftPart.length <= 2 ? '[a-zA-Z_\u0080-\uFFFF]' : '') +
'[-0-9a-zA-Z_\u0080-\uFFFF]*)',
'g');
const list = new Set();
cm.eachLine(({text}) => {
for (let m; (m = rx.exec(text));) {
list.add(m[1]);
}
});
return [...list].sort();
}
function tokenizeUsoVariables(stream) {
const token = originalCommentHook.apply(this, arguments);
if (token[1] === 'comment') {
const {string, start, pos} = stream;
if (testAt(/\/\*\[\[/y, start, string) &&
testAt(/]]\*\//y, pos - 4, string)) {
const vars = (editor.style.usercssData || {}).vars;
token[0] =
vars && vars.hasOwnProperty(string.slice(start + 4, pos - 4).replace(/-rgb$/, ''))
? USO_VALID_VAR
: USO_INVALID_VAR;
}
}
return token;
}
function execAt(rx, index, text) {
rx.lastIndex = index;
return rx.exec(text);
}
function testAt(rx, index, text) {
rx.lastIndex = Math.max(0, index);
return rx.test(text);
}
})();
//#endregion
//#region Bookmarks
(() => {
const CLS = 'gutter-bookmark';
const BRAND = 'sublimeBookmark';
const CLICK_AREA = 'CodeMirror-linenumbers';
const BM_CLS = 'gutter-bookmark';
const BM_BRAND = 'sublimeBookmark';
const BM_CLICKER = 'CodeMirror-linenumbers';
const {markText} = CodeMirror.prototype;
for (const name of ['prevBookmark', 'nextBookmark']) {
const cmdFn = CodeMirror.commands[name];
@ -494,27 +263,29 @@
Object.assign(CodeMirror.prototype, {
markText() {
const marker = markText.apply(this, arguments);
if (marker[BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', CLS);
if (marker[BM_BRAND]) {
this.doc.addLineClass(marker.lines[0], 'gutter', BM_CLS);
marker.clear = clearMarker;
}
return marker;
},
});
function clearMarker() {
const line = this.lines[0];
const spans = line.markedSpans;
delete this.clear; // removing our patch from the instance...
this.clear(); // ...and using the original prototype
if (!spans || spans.some(span => span.marker[BRAND])) {
this.doc.removeLineClass(line, 'gutter', CLS);
if (!spans || spans.some(span => span.marker[BM_BRAND])) {
this.doc.removeLineClass(line, 'gutter', BM_CLS);
}
}
function onGutterClick(cm, line, name, e) {
switch (name === CLICK_AREA && e.button) {
switch (name === BM_CLICKER && e.button) {
case 0: {
// main button: toggle
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BRAND]);
const [mark] = cm.findMarks({line, ch: 0}, {line, ch: 1e9}, m => m[BM_BRAND]);
cm.setCursor(mark ? mark.find(-1) : {line, ch: 0});
cm.execCommand('toggleBookmark');
break;
@ -525,11 +296,13 @@
break;
}
}
function onGutterContextMenu(cm, line, name, e) {
if (name === CLICK_AREA) {
if (name === BM_CLICKER) {
cm.execCommand(e.ctrlKey ? 'prevBookmark' : 'nextBookmark');
e.preventDefault();
}
}
})();
//#endregion
})();

View File

@ -1,81 +0,0 @@
/* global CodeMirror showHelp cmFactory onDOMready $ prefs t createHotkeyInput */
'use strict';
(() => {
onDOMready().then(() => {
$('#colorpicker-settings').onclick = configureColorpicker;
});
prefs.subscribe('editor.colorpicker.hotkey', registerHotkey);
prefs.subscribe('editor.colorpicker', setColorpickerOption, {now: true});
function setColorpickerOption(id, enabled) {
const defaults = CodeMirror.defaults;
const keyName = prefs.get('editor.colorpicker.hotkey');
defaults.colorpicker = enabled;
if (enabled) {
if (keyName) {
CodeMirror.commands.colorpicker = invokeColorpicker;
defaults.extraKeys = defaults.extraKeys || {};
defaults.extraKeys[keyName] = 'colorpicker';
}
defaults.colorpicker = {
tooltip: t('colorpickerTooltip'),
popup: {
tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'),
paletteLine: t('numberedLine'),
paletteHint: t('colorpickerPaletteHint'),
hexUppercase: prefs.get('editor.colorpicker.hexUppercase'),
embedderCallback: state => {
['hexUppercase', 'color']
.filter(name => state[name] !== prefs.get('editor.colorpicker.' + name))
.forEach(name => prefs.set('editor.colorpicker.' + name, state[name]));
},
get maxHeight() {
return prefs.get('editor.colorpicker.maxHeight');
},
set maxHeight(h) {
prefs.set('editor.colorpicker.maxHeight', h);
},
},
};
} else {
if (defaults.extraKeys) {
delete defaults.extraKeys[keyName];
}
}
cmFactory.globalSetOption('colorpicker', defaults.colorpicker);
}
function registerHotkey(id, hotkey) {
CodeMirror.commands.colorpicker = invokeColorpicker;
const extraKeys = CodeMirror.defaults.extraKeys;
for (const key in extraKeys) {
if (extraKeys[key] === 'colorpicker') {
delete extraKeys[key];
break;
}
}
if (hotkey) {
extraKeys[hotkey] = 'colorpicker';
}
}
function invokeColorpicker(cm) {
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
}
function configureColorpicker(event) {
event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', () => {
$('#help-popup .dismiss').onclick();
});
const popup = showHelp(t('helpKeyMapHotkey'), input);
if (this instanceof Element) {
const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
}
input.focus();
}
})();

View File

@ -1,431 +1,51 @@
/* global
$
$$
$create
API
clipString
closeCurrentTab
CodeMirror
CODEMIRROR_THEMES
debounce
deepEqual
DirtyReporter
DocFuncMapper
FIREFOX
getEventKeyName
getOwnTab
initBeautifyButton
linter
messageBox
moveFocus
msg
onDOMready
prefs
rerouteHotkeys
SectionsEditor
sessionStore
setupLivePrefs
SourceEditor
t
tryCatch
tryJSONparse
*/
/* global $ $create messageBoxProxy */// dom.js
/* global API msg */// msg.js
/* global CodeMirror */
/* global SectionsEditor */
/* global SourceEditor */
/* global baseInit */
/* global clipString createHotkeyInput helpPopup */// util.js
/* global closeCurrentTab deepEqual sessionStore tryJSONparse */// toolbox.js
/* global cmFactory */
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/** @type {EditorBase|SourceEditor|SectionsEditor} */
const editor = {
isUsercss: false,
previewDelay: 200, // Chrome devtools uses 200
};
let isSimpleWindow;
let isWindowed;
let headerHeight;
window.on('beforeunload', beforeUnload);
msg.onExtension(onRuntimeMessage);
lazyInit();
(async function init() {
let style;
let nameTarget;
let wasDirty = false;
const dirty = new DirtyReporter();
await Promise.all([
initStyle(),
prefs.initializing
.then(initTheme),
onDOMready(),
]);
const scrollInfo = style.id && tryJSONparse(sessionStore['editorScrollInfo' + style.id]);
/** @namespace EditorBase */
Object.assign(editor, {
style,
dirty,
scrollInfo,
updateName,
updateToc,
toggleStyle,
applyScrollInfo(cm, si = ((scrollInfo || {}).cms || [])[0]) {
if (si && si.sel) {
cm.operation(() => {
cm.setSelections(...si.sel, {scroll: false});
cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2);
});
}
},
});
prefs.subscribe('editor.linter', updateLinter);
prefs.subscribe('editor.keyMap', showHotkeyInTooltip);
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
showHotkeyInTooltip();
buildThemeElement();
buildKeymapElement();
setupLivePrefs();
initNameArea();
initBeautifyButton($('#beautify'), () => editor.getEditors());
initResizeListener();
detectLayout();
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle');
$('#preview-label').classList.toggle('hidden', !style.id);
const toc = [];
const elToc = $('#toc');
elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target));
if (editor.isUsercss) {
SourceEditor();
} else {
SectionsEditor();
}
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true});
dirty.onChange(updateDirty);
//#region init
baseInit.ready.then(async () => {
await new Promise(requestAnimationFrame);
(editor.isUsercss ? SourceEditor : SectionsEditor)();
await editor.ready;
editor.ready = true;
editor.dirty.onChange(editor.updateDirty);
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {runNow: true});
prefs.subscribe('editor.linter', (key, value) => {
document.body.classList.toggle('linter-disabled', value === '');
linterMan.run();
});
// enabling after init to prevent flash of validation failure on an empty name
$('#name').required = !editor.isUsercss;
$('#save-button').onclick = editor.save;
async function initStyle() {
const params = new URLSearchParams(location.search);
const id = Number(params.get('id'));
style = id ? await API.styles.get(id) : initEmptyStyle(params);
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
document.documentElement.classList.toggle('usercss', editor.isUsercss);
sessionStore.justEditedStyleId = style.id || '';
// no such style so let's clear the invalid URL parameters
if (!style.id) history.replaceState({}, '', location.pathname);
updateTitle(false);
}
function initEmptyStyle(params) {
return {
name: params.get('domain') ||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
'',
enabled: true,
sections: [
DocFuncMapper.toSection([...params], {code: ''}),
],
};
}
function initNameArea() {
const nameEl = $('#name');
const resetEl = $('#reset-name');
const isCustomName = style.updateUrl || editor.isUsercss;
nameTarget = isCustomName ? 'customName' : 'name';
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
nameEl.title = isCustomName ? t('customNameHint') : '';
nameEl.on('input', () => {
updateName(true);
resetEl.hidden = false;
$('#toc').onclick = e =>
editor.jumpToEditor([...$('#toc').children].indexOf(e.target));
$('#keyMap-help').onclick = () =>
require(['/edit/show-keymap-help'], () => showKeymapHelp()); /* global showKeymapHelp */
$('#linter-settings').onclick = () =>
require(['/edit/linter-dialogs'], () => linterMan.showLintConfig());
$('#lint-help').onclick = () =>
require(['/edit/linter-dialogs'], () => linterMan.showLintHelp());
require([
'/edit/autocomplete',
'/edit/global-search',
]);
});
resetEl.hidden = !style.customName;
resetEl.onclick = () => {
const style = editor.style;
nameEl.focus();
nameEl.select();
// trying to make it undoable via Ctrl-Z
if (!document.execCommand('insertText', false, style.name)) {
nameEl.value = style.name;
updateName(true);
}
style.customName = null; // to delete it from db
resetEl.hidden = true;
};
const enabledEl = $('#enabled');
enabledEl.onchange = () => updateEnabledness(enabledEl.checked);
}
function initResizeListener() {
const {onBoundsChanged} = chrome.windows || {};
if (onBoundsChanged) {
// * movement is reported even if the window wasn't resized
// * fired just once when done so debounce is not needed
onBoundsChanged.addListener(async wnd => {
// getting the current window id as it may change if the user attached/detached the tab
const {id} = await browser.windows.getCurrent();
if (id === wnd.id) saveWindowPos();
});
}
window.on('resize', () => {
if (!onBoundsChanged) debounce(saveWindowPos, 100);
detectLayout();
});
}
function initTheme() {
return new Promise(resolve => {
const theme = prefs.get('editor.theme');
const el = $('#cm-theme');
if (theme === 'default') {
resolve();
} else {
// preload the theme so CodeMirror can use the correct metrics
el.href = `vendor/codemirror/theme/${theme}.css`;
el.on('load', resolve, {once: true});
el.on('error', () => {
prefs.set('editor.theme', 'default');
resolve();
}, {once: true});
}
});
}
function findKeyForCommand(command, map) {
if (typeof map === 'string') map = CodeMirror.keyMap[map];
let key = Object.keys(map).find(k => map[k] === command);
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 buildThemeElement() {
const elOptions = [chrome.i18n.getMessage('defaultTheme'), ...CODEMIRROR_THEMES]
.map(s => $create('option', s));
elOptions[0].value = 'default';
$('#editor.theme').append(...elOptions);
// move the theme after built-in CSS so that its same-specificity selectors win
document.head.appendChild($('#cm-theme'));
}
function buildKeymapElement() {
// 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;
});
$('#editor.keyMap').appendChild(fragment);
}
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;
}
}
}
function toggleStyle() {
$('#enabled').checked = !style.enabled;
updateEnabledness(!style.enabled);
}
function updateDirty() {
const isDirty = dirty.isDirty();
if (wasDirty !== isDirty) {
wasDirty = isDirty;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
}
updateTitle();
}
function updateEnabledness(enabled) {
dirty.modify('enabled', style.enabled, enabled);
style.enabled = enabled;
editor.updateLivePreview();
}
function updateName(isUserInput) {
if (!editor) return;
if (isUserInput) {
const {value} = $('#name');
dirty.modify('name', style[nameTarget] || style.name, value);
style[nameTarget] = value;
}
updateTitle();
}
function updateTitle(isDirty = dirty.isDirty()) {
document.title = `${
isDirty ? '* ' : ''
}${
style.customName || style.name || t('styleMissingName')
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
}
function updateLinter(key, value) {
$('body').classList.toggle('linter-disabled', value === '');
linter.run();
}
function updateToc(added = editor.sections) {
const {sections} = editor;
const first = sections.indexOf(added[0]);
const elFirst = elToc.children[first];
if (first >= 0 && (!added.focus || !elFirst)) {
for (let el = elFirst, i = first; i < sections.length; i++) {
const entry = sections[i].tocEntry;
if (!deepEqual(entry, toc[i])) {
if (!el) el = elToc.appendChild($create('li', {tabIndex: 0}));
el.tabIndex = entry.removed ? -1 : 0;
toc[i] = Object.assign({}, entry);
const s = el.textContent = clipString(entry.label) || (
entry.target == null
? t('appliesToEverything')
: clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : ''));
if (s.length > 30) el.title = s;
}
el = el.nextElementSibling;
}
}
while (toc.length > sections.length) {
elToc.lastElementChild.remove();
toc.length--;
}
if (added.focus) {
const cls = 'current';
const old = $('.' + cls, elToc);
const el = elFirst || elToc.children[first];
if (old && old !== el) old.classList.remove(cls);
el.classList.add(cls);
}
}
})();
/* Stuff not needed for the main init so we can let it run at its own tempo */
function lazyInit() {
let ownTabId;
// not using `await` so we don't block the subsequent code
getOwnTab().then(patchHistoryBack);
// no windows on android
if (chrome.windows) {
restoreWindowSize();
detectWindowedState();
chrome.tabs.onAttached.addListener(onAttached);
}
async function patchHistoryBack(tab) {
ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
await onDOMready();
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
}
}
/** resize on 'undo close' */
function restoreWindowSize() {
const pos = tryJSONparse(sessionStore.windowPos);
delete sessionStore.windowPos;
if (pos && pos.left != null && chrome.windows) {
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
}
}
async function detectWindowedState() {
isSimpleWindow =
(await browser.windows.getCurrent()).type === 'popup';
isWindowed = isSimpleWindow || (
prefs.get('openEditInWindow') &&
history.length === 1 &&
(await browser.windows.getAll()).length > 1 &&
(await browser.tabs.query({currentWindow: true})).length === 1
);
if (isSimpleWindow) {
await onDOMready();
initPopupButton();
}
}
function initPopupButton() {
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
const onIconsetChanged = (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
};
prefs.subscribe('iconset', onIconsetChanged, {now: true});
document.body.appendChild(btn);
window.on('keydown', e => getEventKeyName(e) === POPUP_HOTKEY && embedPopup());
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup'; // adds to keymap help
}
async function onAttached(tabId, info) {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
const win = await browser.windows.get(info.newWindowId, {populate: true});
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
// FF-only because Chrome retardedly resets the size during dragging
if (openEditInWindow && FIREFOX) {
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
}
}
function onRuntimeMessage(request) {
msg.onExtension(request => {
const {style} = request;
switch (request.method) {
case 'styleUpdated':
@ -444,10 +64,29 @@ function onRuntimeMessage(request) {
document.execCommand('delete');
break;
}
}
});
function beforeUnload(e) {
sessionStore.windowPos = JSON.stringify(canSaveWindowPos() && prefs.get('windowPosition'));
window.on('beforeunload', e => {
let pos;
if (editor.isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
( // only if not maximized
screenX > 0 || outerWidth < screen.availWidth ||
screenY > 0 || outerHeight < screen.availHeight ||
screenX <= -10 || outerWidth >= screen.availWidth + 10 ||
screenY <= -10 || outerHeight >= screen.availHeight + 10
)
) {
pos = {
left: screenX,
top: screenY,
width: outerWidth,
height: outerHeight,
};
prefs.set('windowPosition', pos);
}
sessionStore.windowPos = JSON.stringify(pos || {});
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({
scrollY: window.scrollY,
cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({
@ -464,227 +103,264 @@ function beforeUnload(e) {
// refocus if unloading was canceled
setTimeout(() => activeElement.focus());
}
if (editor && editor.dirty.isDirty()) {
if (editor.dirty.isDirty()) {
// neither confirm() nor custom messages work in modern browsers but just in case
e.returnValue = t('styleChangesNotSaved');
}
}
function showHelp(title = '', body) {
const div = $('#help-popup');
div.className = '';
const contents = $('.contents', div);
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
}
$('.title', div).textContent = title;
showHelp.close = showHelp.close || (event => {
const canClose =
!event ||
event.type === 'click' ||
(
event.key === 'Escape' &&
!event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey &&
!$('.CodeMirror-hints, #message-box') &&
(
!document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
if (!canClose) {
return;
}
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(() => {
messageBox.confirm(t('confirmDiscardChanges'))
.then(ok => ok && showHelp.close());
});
return;
}
if (div.contains(document.activeElement) && showHelp.originalFocus) {
showHelp.originalFocus.focus();
}
div.style.display = '';
contents.textContent = '';
clearTimeout(contents.timer);
window.off('keydown', showHelp.close, true);
window.dispatchEvent(new Event('closeHelp'));
});
window.on('keydown', showHelp.close, true);
$('.dismiss', div).onclick = showHelp.close;
//#endregion
//#region editor methods
// reset any inline styles
div.style = 'display: block';
(() => {
const toc = [];
let tocElem;
showHelp.originalFocus = document.activeElement;
return div;
}
const {dirty} = editor;
let {style} = editor;
let wasDirty = false;
/* exported showCodeMirrorPopup */
function showCodeMirrorPopup(title, html, options) {
const popup = showHelp(title, html);
popup.classList.add('big');
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
matchBrackets: true,
styleActiveLine: true,
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
}, options));
cm.focus();
rerouteHotkeys(false);
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
const onKeyDown = event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
const search = $('#search-replace-dialog');
const area = search && search.contains(document.activeElement) ? search : popup;
moveFocus(area, event.shiftKey ? -1 : 1);
event.preventDefault();
}
};
window.on('keydown', onKeyDown, true);
window.on('closeHelp', () => {
window.off('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
}
function canSaveWindowPos() {
return isWindowed &&
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized();
}
function saveWindowPos() {
if (canSaveWindowPos()) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
function fixedHeader() {
const headerFixed = $('.fixed-header');
if (!headerFixed) headerHeight = $('#header').clientHeight;
const scrollPoint = headerHeight - 43;
if (window.scrollY >= scrollPoint && !headerFixed) {
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
$('body').classList.add('fixed-header');
} else if (window.scrollY < scrollPoint && headerFixed) {
$('body').classList.remove('fixed-header');
}
}
function detectLayout() {
const compact = window.innerWidth <= 850;
if (compact) {
document.body.classList.add('compact-layout');
if (!editor.isUsercss) {
debounce(fixedHeader, 250);
window.on('scroll', fixedHeader, {passive: true});
}
} else {
document.body.classList.remove('compact-layout', 'fixed-header');
window.off('scroll', fixedHeader);
}
for (const type of ['options', 'toc', 'lint']) {
const el = $(`details[data-pref="editor.${type}.expanded"]`);
el.open = compact ? false : prefs.get(el.dataset.pref);
}
}
function isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
function embedPopup() {
const ID = 'popup-iframe';
const SEL = '#' + ID;
if ($(SEL)) return;
const frame = $create('iframe', {
id: ID,
src: chrome.runtime.getManifest().browser_action.default_popup,
height: 600,
width: prefs.get('popupWidth'),
onload() {
frame.onload = null;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', e => getEventKeyName(e) === 'Escape' && embedPopup._close());
pw.close = embedPopup._close;
if (pw.IntersectionObserver) {
let loaded;
new pw.IntersectionObserver(([e]) => {
const el = pw.document.scrollingElement;
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
const hasSB = h > el.offsetHeight;
const {width} = e.boundingClientRect;
frame.height = h;
if (!hasSB !== !frame._scrollbarWidth || frame.width - width) {
frame._scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + frame._scrollbarWidth;
}
if (!loaded) {
loaded = true;
frame.dataset.loaded = '';
}
}).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(() => {
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (frame._scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}).observe(body, {attributes: true, attributeFilter: ['style']});
Object.defineProperties(editor, {
scrollInfo: {
get: () => style.id && tryJSONparse(sessionStore['editorScrollInfo' + style.id]) || {},
},
style: {
get: () => style,
set: val => (style = val),
},
});
// saving the listener here so it's the same function reference for window.off
if (!embedPopup._close) {
embedPopup._close = () => {
$.remove(SEL);
window.off('mousedown', embedPopup._close);
/** @namespace Editor */
Object.assign(editor, {
applyScrollInfo(cm, si = (editor.scrollInfo.cms || [])[0]) {
if (si && si.sel) {
cm.operation(() => {
cm.setSelections(...si.sel, {scroll: false});
cm.scrollIntoView(cm.getCursor(), si.parentHeight / 2);
});
}
},
toggleStyle() {
$('#enabled').checked = !style.enabled;
editor.updateEnabledness(!style.enabled);
},
updateDirty() {
const isDirty = dirty.isDirty();
if (wasDirty !== isDirty) {
wasDirty = isDirty;
document.body.classList.toggle('dirty', isDirty);
$('#save-button').disabled = !isDirty;
}
editor.updateTitle();
},
updateEnabledness(enabled) {
dirty.modify('enabled', style.enabled, enabled);
style.enabled = enabled;
editor.updateLivePreview();
},
updateName(isUserInput) {
if (!editor) return;
if (isUserInput) {
const {value} = $('#name');
dirty.modify('name', style[editor.nameTarget] || style.name, value);
style[editor.nameTarget] = value;
}
editor.updateTitle();
},
updateToc(added = editor.sections) {
if (!prefs.get('editor.toc.expanded')) return;
if (!tocElem) tocElem = $('#toc');
const {sections} = editor;
const first = sections.indexOf(added[0]);
const elFirst = tocElem.children[first];
if (first >= 0 && (!added.focus || !elFirst)) {
for (let el = elFirst, i = first; i < sections.length; i++) {
const entry = sections[i].tocEntry;
if (!deepEqual(entry, toc[i])) {
if (!el) el = tocElem.appendChild($create('li', {tabIndex: 0}));
el.tabIndex = entry.removed ? -1 : 0;
toc[i] = Object.assign({}, entry);
const s = el.textContent = clipString(entry.label) || (
entry.target == null
? t('appliesToEverything')
: clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : ''));
if (s.length > 30) el.title = s;
}
el = el.nextElementSibling;
}
}
while (toc.length > sections.length) {
tocElem.lastElementChild.remove();
toc.length--;
}
if (added.focus) {
const cls = 'current';
const old = $('.' + cls, tocElem);
const el = elFirst || tocElem.children[first];
if (old && old !== el) old.classList.remove(cls);
el.classList.add(cls);
}
},
});
})();
//#endregion
//#region editor livePreview
editor.livePreview = (() => {
let data;
let port;
let preprocess;
let enabled = prefs.get('editor.livePreview');
prefs.subscribe('editor.livePreview', (key, value) => {
if (!value) {
if (port) {
port.disconnect();
port = null;
}
} else if (data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
createPreviewer();
updatePreviewer(data);
}
enabled = value;
});
return {
/**
* @param {Function} [fn] - preprocessor
* @param {boolean} [show]
*/
init(fn, show) {
preprocess = fn;
if (show != null) toggle(show);
},
toggle,
update(newData) {
data = newData;
if (!port) {
if (!data.id || !data.enabled || !enabled) {
return;
}
createPreviewer();
}
updatePreviewer(data);
},
};
function createPreviewer() {
port = chrome.runtime.connect({name: 'livePreview'});
port.onDisconnect.addListener(err => {
throw err;
});
}
function toggle(state) {
$('#preview-label').classList.toggle('hidden', !state);
}
async function updatePreviewer(data) {
const errorContainer = $('#preview-errors');
try {
port.postMessage(preprocess ? await preprocess(data) : data);
errorContainer.classList.add('hidden');
} catch (err) {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index != null) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || err}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => {
messageBoxProxy.alert(err.message || `${err}`, 'pre');
};
}
window.on('mousedown', embedPopup._close);
document.body.appendChild(frame);
}
})();
//#endregion
//#region colorpickerHelper
(async function colorpickerHelper() {
prefs.subscribe('editor.colorpicker.hotkey', (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';
}
});
prefs.subscribe('editor.colorpicker', (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);
}, {runNow: true});
await baseInit.domReady;
$('#colorpicker-settings').onclick = function (event) {
event.preventDefault();
const input = createHotkeyInput('editor.colorpicker.hotkey', () => helpPopup.close());
const popup = helpPopup.show(t('helpKeyMapHotkey'), input);
const bounds = this.getBoundingClientRect();
popup.style.left = bounds.right + 10 + 'px';
popup.style.top = bounds.top - popup.clientHeight / 2 + 'px';
popup.style.right = 'auto';
input.focus();
};
function invokeColorpicker(cm) {
cm.state.colorpicker.openPopup(prefs.get('editor.colorpicker.color'));
}
})();
//#endregion

View File

@ -1,46 +1,48 @@
/* 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
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}}));
},
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;
getRules(linter) {
return ruleRetriever[linter](); // eslint-disable-line no-use-before-define
},
metalint: code => {
loadScript(
'/vendor/usercss-meta/usercss-meta.min.js',
'/vendor-overwrites/colorpicker/colorconverter.js',
'/js/meta-parser.js'
);
metalint(code) {
require(['/js/meta-parser']); /* global metaParser */
const result = metaParser.lint(code);
// extract needed info
result.errors = result.errors.map(err =>
({
result.errors = result.errors.map(err => ({
code: err.code,
args: err.args,
message: err.message,
index: err.index,
})
);
}));
return result;
},
getStylelintRules,
getCsslintRules,
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 getCsslintRules() {
loadScript('/vendor-overwrites/csslint/csslint.js');
const ruleRetriever = {
csslint() {
require(['/js/csslint/csslint']);
return CSSLint.getRules().map(rule => {
const output = {};
for (const [key, value] of Object.entries(rule)) {
@ -50,16 +52,15 @@ function getCsslintRules() {
}
return output;
});
}
},
function getStylelintRules() {
loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js');
const stylelint = require('stylelint');
stylelint() {
require(['/vendor/stylelint-bundle/stylelint-bundle.min']);
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]);
for (const [id, rule] of Object.entries(self.require('stylelint').rules)) {
const ruleCode = `${rule}`;
const sets = [];
let m, mStr;
while ((m = rxPossible.exec(ruleCode))) {
@ -88,4 +89,6 @@ function getStylelintRules() {
}
}
return options;
}
},
};
})();

114
edit/embedded-popup.js Normal file
View File

@ -0,0 +1,114 @@
/* global $ $create $remove getEventKeyName */// dom.js
/* global CodeMirror */
/* global baseInit */// base.js
/* global prefs */
/* global t */// localization.js
'use strict';
(() => {
const ID = 'popup-iframe';
const SEL = '#' + ID;
const URL = chrome.runtime.getManifest().browser_action.default_popup;
const POPUP_HOTKEY = 'Shift-Ctrl-Alt-S';
/** @type {HTMLIFrameElement} */
let frame;
let isLoaded;
let scrollbarWidth;
const btn = $create('img', {
id: 'popup-button',
title: t('optionsCustomizePopup') + '\n' + POPUP_HOTKEY,
onclick: embedPopup,
});
document.documentElement.appendChild(btn);
baseInit.domReady.then(() => {
document.body.appendChild(btn);
// Adding a dummy command to show in keymap help popup
CodeMirror.defaults.extraKeys[POPUP_HOTKEY] = 'openStylusPopup';
});
prefs.subscribe('iconset', (_, val) => {
const prefix = `images/icon/${val ? 'light/' : ''}`;
btn.srcset = `${prefix}16.png 1x,${prefix}32.png 2x`;
}, {runNow: true});
window.on('keydown', e => {
if (getEventKeyName(e) === POPUP_HOTKEY) {
embedPopup();
}
});
function embedPopup() {
if ($(SEL)) return;
isLoaded = false;
scrollbarWidth = 0;
frame = $create('iframe', {
id: ID,
src: URL,
height: 600,
width: prefs.get('popupWidth'),
onload: initFrame,
});
window.on('mousedown', removePopup);
document.body.appendChild(frame);
}
function initFrame() {
frame = this;
frame.focus();
const pw = frame.contentWindow;
const body = pw.document.body;
pw.on('keydown', removePopupOnEsc);
pw.close = removePopup;
if (pw.IntersectionObserver) {
new pw.IntersectionObserver(onIntersect).observe(body.appendChild(
$create('div', {style: {height: '1px', marginTop: '-1px'}})
));
} else {
frame.dataset.loaded = '';
frame.height = body.scrollHeight;
}
new pw.MutationObserver(onMutation).observe(body, {
attributes: true,
attributeFilter: ['style'],
});
}
function onMutation() {
const body = frame.contentDocument.body;
const bs = body.style;
const w = parseFloat(bs.minWidth || bs.width) + (scrollbarWidth || 0);
const h = parseFloat(bs.minHeight || body.offsetHeight);
if (frame.width - w) frame.width = w;
if (frame.height - h) frame.height = h;
}
function onIntersect([e]) {
const pw = frame.contentWindow;
const el = pw.document.scrollingElement;
const h = e.isIntersecting && !pw.scrollY ? el.offsetHeight : el.scrollHeight;
const hasSB = h > el.offsetHeight;
const {width} = e.boundingClientRect;
frame.height = h;
if (!hasSB !== !scrollbarWidth || frame.width - width) {
scrollbarWidth = hasSB ? width - el.offsetWidth : 0;
frame.width = width + scrollbarWidth;
}
if (!isLoaded) {
isLoaded = true;
frame.dataset.loaded = '';
}
}
function removePopup() {
frame = null;
$remove(SEL);
window.off('mousedown', removePopup);
}
function removePopupOnEsc(e) {
if (getEventKeyName(e) === 'Escape') {
removePopup();
}
}
})();

View File

@ -1,21 +1,14 @@
/* global
$
$$
$create
chromeLocal
CodeMirror
colorMimicry
debounce
editor
focusAccessibility
onDOMready
stringAsRegExp
t
tryRegExp
*/
/* global $ $$ $create $remove focusAccessibility */// dom.js
/* global CodeMirror */
/* global chromeLocal */// storage-util.js
/* global colorMimicry */
/* global debounce stringAsRegExp tryRegExp */// toolbox.js
/* global editor */
/* global t */// localization.js
'use strict';
onDOMready().then(() => {
(() => {
require(['/edit/global-search.css']);
//region Constants and state
@ -138,13 +131,13 @@ onDOMready().then(() => {
},
onfocusout() {
if (!state.dialog.contains(document.activeElement)) {
state.dialog.addEventListener('focusin', EVENTS.onfocusin);
state.dialog.removeEventListener('focusout', EVENTS.onfocusout);
state.dialog.on('focusin', EVENTS.onfocusin);
state.dialog.off('focusout', EVENTS.onfocusout);
}
},
onfocusin() {
state.dialog.addEventListener('focusout', EVENTS.onfocusout);
state.dialog.removeEventListener('focusin', EVENTS.onfocusin);
state.dialog.on('focusout', EVENTS.onfocusout);
state.dialog.off('focusin', EVENTS.onfocusin);
trimUndoHistory();
enableUndoButton(state.undoHistory.length);
if (state.find) doSearch({canAdvance: false});
@ -189,7 +182,6 @@ onDOMready().then(() => {
Object.assign(CodeMirror.commands, COMMANDS);
readStorage();
return;
//region Find
@ -577,7 +569,7 @@ onDOMready().then(() => {
const dialog = state.dialog = t.template.searchReplaceDialog.cloneNode(true);
Object.assign(dialog, DIALOG_PROPS.dialog);
dialog.addEventListener('focusout', EVENTS.onfocusout);
dialog.on('focusout', EVENTS.onfocusout);
dialog.dataset.type = type;
dialog.style.pointerEvents = 'auto';
@ -590,9 +582,9 @@ onDOMready().then(() => {
state.tally = $('[data-type="tally"]', dialog);
const colors = {
body: colorMimicry.get(document.body, {bg: 'backgroundColor'}),
input: colorMimicry.get($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry.get($$('svg.info')[1], {fill: 'fill'}),
body: colorMimicry(document.body, {bg: 'backgroundColor'}),
input: colorMimicry($('input:not(:disabled)'), {bg: 'backgroundColor'}),
icon: colorMimicry($$('svg.info')[1], {fill: 'fill'}),
};
document.documentElement.appendChild(
$(DIALOG_STYLE_SELECTOR) ||
@ -652,7 +644,7 @@ onDOMready().then(() => {
function destroyDialog({restoreFocus = false} = {}) {
state.input = null;
$.remove(DIALOG_SELECTOR);
$remove(DIALOG_SELECTOR);
debounce.unregister(doSearch);
makeTargetVisible(null);
if (restoreFocus) {
@ -795,7 +787,6 @@ onDOMready().then(() => {
});
if (!cm.curOp) cm.startOperation();
if (!state.firstRun) {
editor.scrollToEditor(cm);
cm.jumpToPos(pos.from, pos.to);
}
// focus or expose as the current search target
@ -960,4 +951,4 @@ onDOMready().then(() => {
}
//endregion
});
})();

View File

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

View File

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

226
edit/linter-dialogs.js Normal file
View File

@ -0,0 +1,226 @@
/* global $ $create $createLink messageBoxProxy */// dom.js
/* global chromeSync */// storage-util.js
/* global editor */
/* global helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
/* global linterMan */
/* global t */// localization.js
/* global tryJSONparse */// toolbox.js
'use strict';
(() => {
/** @type {{csslint:{}, stylelint:{}}} */
const RULES = {};
let cm;
let defaultConfig;
let isStylelint;
let linter;
let popup;
linterMan.showLintConfig = async () => {
linter = $('#editor.linter').value;
if (!linter) {
return;
}
if (!RULES[linter]) {
linterMan.worker.getRules(linter).then(res => (RULES[linter] = res));
}
await require([
'/vendor/codemirror/mode/javascript/javascript',
'/vendor/codemirror/addon/lint/json-lint',
'/vendor/jsonlint/jsonlint',
]);
const config = await chromeSync.getLZValue(chromeSync.LZ_KEY[linter]);
const title = t('linterConfigPopupTitle', isStylelint ? 'Stylelint' : 'CSSLint');
isStylelint = linter === 'stylelint';
defaultConfig = stringifyConfig(linterMan.DEFAULTS[linter]);
popup = showCodeMirrorPopup(title, null, {
extraKeys: {'Ctrl-Enter': onConfigSave},
hintOptions: {hint},
lint: true,
mode: 'application/json',
value: config ? stringifyConfig(config) : defaultConfig,
});
$('.contents', popup).appendChild(
$create('div', [
$create('p', [
$createLink(
isStylelint
? 'https://stylelint.io/user-guide/rules/'
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
t('linterRulesLink')),
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '',
]),
$create('.buttons', [
$create('button.save', {onclick: onConfigSave, title: 'Ctrl-Enter'},
t('styleSaveLabel')),
$create('button.cancel', {onclick: onConfigCancel}, t('confirmClose')),
$create('button.reset', {onclick: onConfigReset, title: t('linterResetMessage')},
t('genericResetLabel')),
]),
]));
cm = popup.codebox;
cm.focus();
cm.on('changes', updateConfigButtons);
updateConfigButtons();
rerouteHotkeys(false);
window.on('closeHelp', onConfigClose, {once: true});
};
linterMan.showLintHelp = async () => {
// FIXME: implement a linterChooser?
const linter = $('#editor.linter').value;
const baseUrl = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
// some CSSLint rules do not have a url
: 'https://github.com/CSSLint/csslint/issues/535';
let headerLink, template;
if (linter === 'csslint') {
headerLink = $createLink('https://github.com/CSSLint/csslint/wiki/Rules', 'CSSLint');
template = ({rule: ruleID}) => {
const rule = RULES.csslint.find(rule => rule.id === ruleID);
return rule &&
$create('li', [
$create('b', $createLink(rule.url || baseUrl, rule.name)),
$create('br'),
rule.desc,
]);
};
} else {
headerLink = $createLink(baseUrl, 'stylelint');
template = rule =>
$create('li',
rule === 'CssSyntaxError' ? rule : $createLink(baseUrl + rule, rule));
}
const header = t('linterIssuesHelp', '\x01').split('\x01');
const activeRules = new Set([...linterMan.getIssues()].map(issue => issue.rule));
helpPopup.show(t('linterIssues'),
$create([
header[0], headerLink, header[1],
$create('ul.rules', [...activeRules].map(template)),
]));
};
function getLexicalDepth(lexicalState) {
let depth = 0;
while ((lexicalState = lexicalState.prev)) {
depth++;
}
return depth;
}
function hint(cm) {
const rules = RULES[linter];
let ruleIds, options;
if (isStylelint) {
ruleIds = Object.keys(rules);
options = rules;
} else {
ruleIds = rules.map(r => r.id);
options = {};
}
const cursor = cm.getCursor();
const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor);
const {line, ch} = cursor;
const quoted = string.startsWith('"');
const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim();
const depth = getLexicalDepth(lexical);
const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1});
let [, prevWord] = search.find(true) || [];
let words = [];
if (depth === 1 && isStylelint) {
words = quoted ? ['rules'] : [];
} else if ((depth === 1 || depth === 2) && type && type.includes('property')) {
words = ruleIds;
} else if (depth === 2 || depth === 3 && lexical.type === ']') {
words = !quoted ? ['true', 'false', 'null'] :
ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || [];
} else if (depth === 4 && prevWord === 'severity') {
words = ['error', 'warning'];
} else if (depth === 4) {
words = ['ignore', 'ignoreAtRules', 'except', 'severity'];
} else if (depth === 5 && lexical.type === ']' && quoted) {
while (prevWord && !ruleIds.includes(prevWord)) {
prevWord = (search.find(true) || [])[1];
}
words = (options[prevWord] || []).slice(-1)[0] || ruleIds;
}
return {
list: words.filter(word => word.startsWith(leftPart)),
from: {line, ch: start + (quoted ? 1 : 0)},
to: {line, ch: string.endsWith('"') ? end - 1 : end},
};
}
function onConfigCancel() {
helpPopup.close();
editor.closestVisible().focus();
}
function onConfigClose() {
rerouteHotkeys(true);
cm = null;
}
function onConfigReset(event) {
event.preventDefault();
cm.setValue(defaultConfig);
cm.focus();
updateConfigButtons();
}
async function onConfigSave(event) {
if (event instanceof Event) {
event.preventDefault();
}
const json = tryJSONparse(cm.getValue());
if (!json) {
showLinterErrorMessage(linter, t('linterJSONError'), popup);
cm.focus();
return;
}
let invalid;
if (isStylelint) {
invalid = Object.keys(json.rules).filter(k => !RULES.stylelint.hasOwnProperty(k));
} else {
const ids = RULES.csslint.map(r => r.id);
invalid = Object.keys(json).filter(k => !ids.includes(k));
}
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$create('ul', invalid.map(name => $create('li', name))),
], popup);
return;
}
chromeSync.setLZValue(chromeSync.LZ_KEY[linter], json);
cm.markClean();
cm.focus();
updateConfigButtons();
}
function stringifyConfig(config) {
return JSON.stringify(config, null, 2)
.replace(/,\n\s+{\n\s+("severity":\s"\w+")\n\s+}/g, ', {$1}');
}
async function showLinterErrorMessage(title, contents, popup) {
await messageBoxProxy.show({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
});
if (popup && popup.codebox) {
popup.codebox.focus();
}
}
function updateConfigButtons() {
$('.save', popup).disabled = cm.isClean();
$('.reset', popup).disabled = cm.getValue() === defaultConfig;
$('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel');
}
})();

View File

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

View File

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

420
edit/linter-manager.js Normal file
View File

@ -0,0 +1,420 @@
/* global $ $create */// dom.js
/* global chromeSync */// storage-util.js
/* global clipString */// util.js
/* global createWorker */// worker-util.js
/* global editor */
/* global prefs */
'use strict';
//#region linterMan
const linterMan = (() => {
const cms = new Map();
const linters = [];
const lintingUpdatedListeners = [];
const unhookListeners = [];
return {
/** @type {EditorWorker} */
worker: createWorker({url: '/edit/editor-worker'}),
disableForEditor(cm) {
cm.setOption('lint', false);
cms.delete(cm);
for (const cb of unhookListeners) {
cb(cm);
}
},
/**
* @param {Object} cm
* @param {string} [code] - to be used to avoid slowdowns when creating a lot of cms.
* Enables lint option only if there are problems, thus avoiding a _very_ costly layout
* update when lint gutter is added to a lot of editors simultaneously.
*/
enableForEditor(cm, code) {
if (cms.has(cm)) return;
cms.set(cm, null);
if (code) {
enableOnProblems(cm, code);
} else {
cm.setOption('lint', {getAnnotations, onUpdateLinting});
}
},
onLintingUpdated(fn) {
lintingUpdatedListeners.push(fn);
},
onUnhook(fn) {
unhookListeners.push(fn);
},
register(fn) {
linters.push(fn);
},
run() {
for (const cm of cms.keys()) {
cm.performLint();
}
},
};
async function enableOnProblems(cm, code) {
const results = await getAnnotations(code, {}, cm);
if (results.length || cm.display.renderedView) {
cms.set(cm, results);
cm.setOption('lint', {getAnnotations: getCachedAnnotations, onUpdateLinting});
} else {
cms.delete(cm);
}
}
async function getAnnotations(...args) {
const results = await Promise.all(linters.map(fn => fn(...args)));
return [].concat(...results.filter(Boolean));
}
function getCachedAnnotations(code, opt, cm) {
const results = cms.get(cm);
cms.set(cm, null);
cm.options.lint.getAnnotations = getAnnotations;
return results;
}
function onUpdateLinting(...args) {
for (const fn of lintingUpdatedListeners) {
fn(...args);
}
}
})();
//#endregion
//#region DEFAULTS
linterMan.DEFAULTS = {
stylelint: {
rules: {
'at-rule-no-unknown': [true, {
'ignoreAtRules': ['extend', 'extends', 'css', 'block'],
'severity': 'warning',
}],
'block-no-empty': [true, {severity: 'warning'}],
'color-no-invalid-hex': [true, {severity: 'warning'}],
'declaration-block-no-duplicate-properties': [true, {
'ignore': ['consecutive-duplicates-with-different-values'],
'severity': 'warning',
}],
'declaration-block-no-shorthand-property-overrides': [true, {severity: 'warning'}],
'font-family-no-duplicate-names': [true, {severity: 'warning'}],
'function-calc-no-unspaced-operator': [true, {severity: 'warning'}],
'function-linear-gradient-no-nonstandard-direction': [true, {severity: 'warning'}],
'keyframe-declaration-no-important': [true, {severity: 'warning'}],
'media-feature-name-no-unknown': [true, {severity: 'warning'}],
'no-empty-source': false,
'no-extra-semicolons': [true, {severity: 'warning'}],
'no-invalid-double-slash-comments': [true, {severity: 'warning'}],
'property-no-unknown': [true, {severity: 'warning'}],
'selector-pseudo-class-no-unknown': [true, {severity: 'warning'}],
'selector-pseudo-element-no-unknown': [true, {severity: 'warning'}],
'selector-type-no-unknown': false, // for scss/less/stylus-lang
'string-no-newline': [true, {severity: 'warning'}],
'unit-no-unknown': [true, {severity: 'warning'}],
'comment-no-empty': false,
'declaration-block-no-redundant-longhand-properties': false,
'shorthand-property-no-redundant-values': false,
},
},
csslint: {
'display-property-grouping': 1,
'duplicate-properties': 1,
'empty-rules': 1,
'errors': 1,
'known-properties': 1,
'selector-newline': 1,
'simple-not': 1,
'warnings': 1,
// disabled
'adjoining-classes': 0,
'box-model': 0,
'box-sizing': 0,
'bulletproof-font-face': 0,
'compatible-vendor-prefixes': 0,
'duplicate-background-images': 0,
'fallback-colors': 0,
'floats': 0,
'font-faces': 0,
'font-sizes': 0,
'gradients': 0,
'ids': 0,
'import': 0,
'import-ie-limit': 0,
'important': 0,
'order-alphabetical': 0,
'outline-none': 0,
'overqualified-elements': 0,
'qualified-headings': 0,
'regex-selectors': 0,
'rules-count': 0,
'selector-max': 0,
'selector-max-approaching': 0,
'shorthand': 0,
'star-property-hack': 0,
'text-indent': 0,
'underscore-property-hack': 0,
'unique-headings': 0,
'universal-selector': 0,
'unqualified-attributes': 0,
'vendor-prefix': 0,
'zero-units': 0,
},
};
//#endregion
//#region ENGINES
(() => {
const configs = new Map();
const {DEFAULTS, worker} = linterMan;
const ENGINES = {
csslint: {
validMode: mode => mode === 'css',
getConfig: config => Object.assign({}, DEFAULTS.csslint, config),
async lint(text, config) {
const results = await worker.csslint(text, config);
return results
.map(({line, col: ch, message, rule, type: severity}) => line && {
message,
from: {line: line - 1, ch: ch - 1},
to: {line: line - 1, ch},
rule: rule.id,
severity,
})
.filter(Boolean);
},
},
stylelint: {
validMode: () => true,
getConfig: config => ({
syntax: 'sugarss',
rules: Object.assign({}, DEFAULTS.stylelint.rules, config && config.rules),
}),
async lint(text, config, mode) {
const raw = await worker.stylelint(text, config);
if (!raw) {
return [];
}
// Hiding the errors about "//" comments as we're preprocessing only when saving/applying
// and we can't just pre-remove the comments since "//" may be inside a string token
const slashCommentAllowed = mode === 'text/x-less' || mode === 'stylus';
const res = [];
for (const w of raw.warnings) {
const msg = w.text.match(/^(?:Unexpected\s+)?(.*?)\s*\([^()]+\)$|$/)[1] || w.text;
if (!slashCommentAllowed || !(
w.rule === 'no-invalid-double-slash-comments' ||
w.rule === 'property-no-unknown' && msg.includes('"//"')
)) {
res.push({
from: {line: w.line - 1, ch: w.column - 1},
to: {line: w.line - 1, ch: w.column},
message: msg.slice(0, 1).toUpperCase() + msg.slice(1),
severity: w.severity,
rule: w.rule,
});
}
}
return res;
},
},
};
linterMan.register(async (text, _options, cm) => {
const linter = prefs.get('editor.linter');
if (linter) {
const {mode} = cm.options;
const currentFirst = Object.entries(ENGINES).sort(([a]) => a === linter ? -1 : 1);
for (const [name, engine] of currentFirst) {
if (engine.validMode(mode)) {
const cfg = configs.get(name) || await getConfig(name);
return ENGINES[name].lint(text, cfg, mode);
}
}
}
});
chrome.storage.onChanged.addListener(changes => {
for (const name of Object.keys(ENGINES)) {
if (chromeSync.LZ_KEY[name] in changes) {
getConfig(name).then(linterMan.run);
}
}
});
async function getConfig(name) {
const rawCfg = await chromeSync.getLZValue(chromeSync.LZ_KEY[name]);
const cfg = ENGINES[name].getConfig(rawCfg);
configs.set(name, cfg);
return cfg;
}
})();
//#endregion
//#region Reports
(() => {
const tables = new Map();
linterMan.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
let table = tables.get(cm);
if (!table) {
table = createTable(cm);
tables.set(cm, table);
const container = $('.lint-report-container');
const nextSibling = findNextSibling(tables, cm);
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
}
table.updateCaption();
table.updateAnnotations(annotations);
updateCount();
});
linterMan.onUnhook(cm => {
const table = tables.get(cm);
if (table) {
table.element.remove();
tables.delete(cm);
}
updateCount();
});
Object.assign(linterMan, {
getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
issues.add(tr.getAnnotation());
}
}
return issues;
},
refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
},
});
function updateCount() {
const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount;
}
function findNextSibling(tables, cm) {
const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1;
while (i < editors.length) {
if (tables.has(editors[i])) {
return editors[i];
}
i++;
}
}
function createTable(cm) {
const caption = $create('caption');
const tbody = $create('tbody');
const table = $create('table', [caption, tbody]);
const trs = [];
return {
element: table,
trs,
updateAnnotations,
updateCaption,
};
function updateCaption() {
caption.textContent = editor.getEditorTitle(cm);
}
function updateAnnotations(lines) {
let i = 0;
for (const anno of getAnnotations()) {
let tr;
if (i < trs.length) {
tr = trs[i];
} else {
tr = createTr();
trs.push(tr);
tbody.append(tr.element);
}
tr.update(anno);
i++;
}
if (i === 0) {
trs.length = 0;
tbody.textContent = '';
} else {
while (trs.length > i) {
trs.pop().element.remove();
}
}
table.classList.toggle('empty', trs.length === 0);
function *getAnnotations() {
for (const line of lines.filter(Boolean)) {
yield *line;
}
}
}
function createTr() {
let anno;
const severityIcon = $create('div');
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
const line = $create('td', {attributes: {role: 'line'}});
const col = $create('td', {attributes: {role: 'col'}});
const message = $create('td', {attributes: {role: 'message'}});
const trElement = $create('tr', {
onclick: () => gotoLintIssue(cm, anno),
}, [
severity,
line,
$create('td', {attributes: {role: 'sep'}}, ':'),
col,
message,
]);
return {
element: trElement,
update,
getAnnotation: () => anno,
};
function update(_anno) {
anno = _anno;
trElement.className = anno.severity;
severity.dataset.rule = anno.rule;
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
severityIcon.textContent = anno.severity;
line.textContent = anno.from.line + 1;
col.textContent = anno.from.ch + 1;
message.title = clipString(anno.message, 1000) +
(anno.rule ? `\n(${anno.rule})` : '');
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
}
}
}
function gotoLintIssue(cm, anno) {
editor.scrollToEditor(cm);
cm.focus();
cm.jumpToPos(anno.from);
}
})();
//#endregion

View File

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

View File

@ -1,161 +0,0 @@
/* global linter editor clipString createLinterHelpDialog $ $create */
'use strict';
Object.assign(linter, (() => {
const tables = new Map();
const helpDialog = createLinterHelpDialog(getIssues);
document.addEventListener('DOMContentLoaded', () => {
$('#lint-help').addEventListener('click', helpDialog.show);
}, {once: true});
linter.onLintingUpdated((annotationsNotSorted, annotations, cm) => {
let table = tables.get(cm);
if (!table) {
table = createTable(cm);
tables.set(cm, table);
const container = $('.lint-report-container');
const nextSibling = findNextSibling(tables, cm);
container.insertBefore(table.element, nextSibling && tables.get(nextSibling).element);
}
table.updateCaption();
table.updateAnnotations(annotations);
updateCount();
});
linter.onUnhook(cm => {
const table = tables.get(cm);
if (table) {
table.element.remove();
tables.delete(cm);
}
updateCount();
});
return {refreshReport};
function updateCount() {
const issueCount = Array.from(tables.values())
.reduce((sum, table) => sum + table.trs.length, 0);
$('#lint').classList.toggle('hidden-unless-compact', issueCount === 0);
$('#issue-count').textContent = issueCount;
}
function getIssues() {
const issues = new Set();
for (const table of tables.values()) {
for (const tr of table.trs) {
issues.add(tr.getAnnotation());
}
}
return issues;
}
function findNextSibling(tables, cm) {
const editors = editor.getEditors();
let i = editors.indexOf(cm) + 1;
while (i < editors.length) {
if (tables.has(editors[i])) {
return editors[i];
}
i++;
}
}
function refreshReport() {
for (const table of tables.values()) {
table.updateCaption();
}
}
function createTable(cm) {
const caption = $create('caption');
const tbody = $create('tbody');
const table = $create('table', [caption, tbody]);
const trs = [];
return {
element: table,
trs,
updateAnnotations,
updateCaption,
};
function updateCaption() {
caption.textContent = editor.getEditorTitle(cm);
}
function updateAnnotations(lines) {
let i = 0;
for (const anno of getAnnotations()) {
let tr;
if (i < trs.length) {
tr = trs[i];
} else {
tr = createTr();
trs.push(tr);
tbody.append(tr.element);
}
tr.update(anno);
i++;
}
if (i === 0) {
trs.length = 0;
tbody.textContent = '';
} else {
while (trs.length > i) {
trs.pop().element.remove();
}
}
table.classList.toggle('empty', trs.length === 0);
function *getAnnotations() {
for (const line of lines.filter(Boolean)) {
yield *line;
}
}
}
function createTr() {
let anno;
const severityIcon = $create('div');
const severity = $create('td', {attributes: {role: 'severity'}}, severityIcon);
const line = $create('td', {attributes: {role: 'line'}});
const col = $create('td', {attributes: {role: 'col'}});
const message = $create('td', {attributes: {role: 'message'}});
const trElement = $create('tr', {
onclick: () => gotoLintIssue(cm, anno),
}, [
severity,
line,
$create('td', {attributes: {role: 'sep'}}, ':'),
col,
message,
]);
return {
element: trElement,
update,
getAnnotation: () => anno,
};
function update(_anno) {
anno = _anno;
trElement.className = anno.severity;
severity.dataset.rule = anno.rule;
severityIcon.className = `CodeMirror-lint-marker CodeMirror-lint-marker-${anno.severity}`;
severityIcon.textContent = anno.severity;
line.textContent = anno.from.line + 1;
col.textContent = anno.from.ch + 1;
message.title = clipString(anno.message, 1000) +
(anno.rule ? `\n(${anno.rule})` : '');
message.textContent = clipString(anno.message, 100).replace(/ at line.*/, '');
}
}
}
function gotoLintIssue(cm, anno) {
editor.scrollToEditor(cm);
cm.focus();
cm.jumpToPos(anno.from);
}
})());

View File

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

View File

@ -1,74 +0,0 @@
/* global messageBox editor $ prefs */
/* exported createLivePreview */
'use strict';
function createLivePreview(preprocess, shouldShow) {
let data;
let previewer;
let enabled = prefs.get('editor.livePreview');
const label = $('#preview-label');
const errorContainer = $('#preview-errors');
prefs.subscribe(['editor.livePreview'], (key, value) => {
if (value && data && data.id && (data.enabled || editor.dirty.has('enabled'))) {
previewer = createPreviewer();
previewer.update(data);
}
if (!value && previewer) {
previewer.disconnect();
previewer = null;
}
enabled = value;
});
if (shouldShow != null) show(shouldShow);
return {update, show};
function show(state) {
label.classList.toggle('hidden', !state);
}
function update(_data) {
data = _data;
if (!previewer) {
if (!data.id || !data.enabled || !enabled) {
return;
}
previewer = createPreviewer();
}
previewer.update(data);
}
function createPreviewer() {
const port = chrome.runtime.connect({
name: 'livePreview',
});
port.onDisconnect.addListener(err => {
throw err;
});
return {update, disconnect};
function update(data) {
Promise.resolve()
.then(() => preprocess ? preprocess(data) : data)
.then(data => port.postMessage(data))
.then(
() => errorContainer.classList.add('hidden'),
err => {
if (Array.isArray(err)) {
err = err.join('\n');
} else if (err && err.index !== undefined) {
// FIXME: this would fail if editors[0].getValue() !== data.sourceCode
const pos = editor.getEditors()[0].posFromIndex(err.index);
err.message = `${pos.line}:${pos.ch} ${err.message || String(err)}`;
}
errorContainer.classList.remove('hidden');
errorContainer.onclick = () => messageBox.alert(err.message || String(err), 'pre');
}
);
}
function disconnect() {
port.disconnect();
}
}
}

View File

@ -1,9 +1,6 @@
/* global
CodeMirror
debounce
deepEqual
trimCommentLabel
*/
/* global CodeMirror */
/* global debounce deepEqual */// toolbox.js
/* global trimCommentLabel */// util.js
'use strict';
/* exported MozSectionFinder */
@ -26,7 +23,7 @@ function MozSectionFinder(cm) {
/** @type {CodeMirror.Pos} */
let updTo;
const MozSectionFinder = {
const finder = {
IGNORE_ORIGIN: KEY,
EQ_SKIP_KEYS: [
'mark',
@ -45,10 +42,11 @@ function MozSectionFinder(cm) {
const NOP = () => 0;
data = {fn: NOP};
keptAlive.set(id, data);
MozSectionFinder.on(NOP);
finder.on(NOP);
}
data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
},
on(fn) {
const {listeners} = getState();
const needsInit = !listeners.size;
@ -58,6 +56,7 @@ function MozSectionFinder(cm) {
update();
}
},
off(fn) {
const {listeners, sections} = getState();
if (listeners.size) {
@ -69,15 +68,16 @@ function MozSectionFinder(cm) {
}
}
},
onOff(fn, enable) {
MozSectionFinder[enable ? 'on' : 'off'](fn);
finder[enable ? 'on' : 'off'](fn);
},
/** @param {MozSection} [section] */
updatePositions(section) {
(section ? [section] : getState().sections).forEach(setPositionFromMark);
},
};
return MozSectionFinder;
/** @returns {MozSectionCmState} */
function getState() {
@ -97,7 +97,7 @@ function MozSectionFinder(cm) {
if (!updFrom) updFrom = {line: Infinity, ch: 0};
if (!updTo) updTo = {line: -1, ch: 0};
for (const c of changes) {
if (c.origin !== MozSectionFinder.IGNORE_ORIGIN) {
if (c.origin !== finder.IGNORE_ORIGIN) {
updFrom = minPos(c.from, updFrom);
updTo = maxPos(CodeMirror.changeEnd(c), updTo);
}
@ -387,11 +387,13 @@ function MozSectionFinder(cm) {
/** @this {MozSectionFunc[]} new functions */
function isSameFunc(func, i) {
return deepEqual(func, this[i], MozSectionFinder.EQ_SKIP_KEYS);
}
return deepEqual(func, this[i], finder.EQ_SKIP_KEYS);
}
/** @typedef CodeMirror.Pos
* @property {number} line
* @property {number} ch
*/
return finder;
}

View File

@ -1,24 +1,16 @@
/* global
$
$create
CodeMirror
colorMimicry
messageBox
MozSectionFinder
msg
prefs
regExpTester
t
tryCatch
*/
/* global $ $create messageBoxProxy */// dom.js
/* global CodeMirror */
/* global MozSectionFinder */
/* global colorMimicry */
/* global editor */
/* global msg */
/* global prefs */
/* global t */// localization.js
/* global tryCatch */// toolbox.js
'use strict';
/* exported MozSectionWidget */
function MozSectionWidget(
cm,
finder = MozSectionFinder(cm),
onDirectChange = () => 0
) {
function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
let TPL, EVENTS, CLICK_ROUTE;
const KEY = 'MozSectionWidget';
const C_CONTAINER = '.applies-to';
@ -36,7 +28,9 @@ function MozSectionWidget(
const {cmpPos} = CodeMirror;
let enabled = false;
let funcHeight = 0;
/** @type {HTMLStyleElement} */
let actualStyle;
return {
toggle(enable) {
if (Boolean(enable) !== enabled) {
@ -71,7 +65,7 @@ function MozSectionWidget(
'.remove-applies-to'(elItem, func) {
const funcs = getFuncsFor(elItem);
if (funcs.length < 2) {
messageBox({
messageBoxProxy.show({
contents: t('appliesRemoveError'),
buttons: [t('confirmClose')],
});
@ -110,7 +104,7 @@ function MozSectionWidget(
if (part === 'value' && func === getFuncsFor(el)[0]) {
const sec = getSectionFor(el);
sec.tocEntry.target = el.value;
if (!sec.tocEntry.label) onDirectChange([sec]);
if (!sec.tocEntry.label) editor.updateToc([sec]);
}
cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
},
@ -176,13 +170,13 @@ function MozSectionWidget(
const MIN_LUMA = .05;
const MIN_LUMA_DIFF = .4;
const color = {
wrapper: colorMimicry.get(cm.display.wrapper),
gutter: colorMimicry.get(cm.display.gutters, {
wrapper: colorMimicry(cm.display.wrapper),
gutter: colorMimicry(cm.display.gutters, {
bg: 'backgroundColor',
border: 'borderRightColor',
}),
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
};
const hasBorder =
color.gutter.style.borderRightWidth !== '0px' &&
@ -421,10 +415,12 @@ function MozSectionWidget(
f.value.clear();
}
function showRegExpTester(el) {
async function showRegExpTester(el) {
/* global regexpTester */
await require(['/edit/regexp-tester']);
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
regExpTester.toggle(true);
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
regexpTester.toggle(true);
regexpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
}
function fromDoubleslash(s) {

View File

@ -1,62 +1,39 @@
/* global
$
$create
openURL
showHelp
t
tryRegExp
URLS
*/
/* exported regExpTester */
/* global $create */// dom.js
/* global URLS openURL tryRegExp */// toolbox.js
/* global helpPopup */// util.js
/* global t */// localization.js
'use strict';
const regExpTester = (() => {
const regexpTester = (() => {
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = new Map();
let currentRegexps = [];
let isInit = false;
let isWatching = false;
let isShown = false;
function init() {
isInit = true;
window.on('closeHelp', () => regexpTester.toggle(false));
return {
toggle(state = !isShown) {
if (state && !isShown) {
if (!isWatching) {
isWatching = true;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
helpPopup.show('', $create('.regexp-report'));
isShown = true;
} else if (!state && isShown) {
unwatch();
helpPopup.close();
isShown = false;
}
},
function uninit() {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isInit = false;
}
function onTabUpdate(tabId, info) {
if (info.url) {
update();
}
}
function isShown() {
return Boolean($('.regexp-report'));
}
function toggle(state = !isShown()) {
if (state && !isShown()) {
if (!isInit) {
init();
}
showHelp('', $create('.regexp-report'));
} else if (!state && isShown()) {
if (isInit) {
uninit();
}
// TODO: need a closeHelp function
$('#help-popup .dismiss').onclick();
}
}
function update(newRegexps) {
if (!isShown()) {
if (isInit) {
uninit();
}
async update(newRegexps) {
if (!isShown) {
unwatch();
return;
}
if (newRegexps) {
@ -74,7 +51,7 @@ const regExpTester = (() => {
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
browser.tabs.query({}).then(tabs => {
const tabs = await browser.tabs.query({});
const supported = tabs.map(tab => tab.pendingUrl || tab.url).filter(URLS.supported);
const unique = [...new Set(supported).values()];
for (const rxData of regexps) {
@ -92,10 +69,12 @@ const regExpTester = (() => {
}
const stats = {
full: {data: [], label: t('styleRegexpTestFull')},
partial: {data: [], label: [
partial: {
data: [], label: [
t('styleRegexpTestPartial'),
t.template.regexpTestPartial.cloneNode(true),
]},
],
},
none: {data: [], label: t('styleRegexpTestNone')},
invalid: {data: [], label: t('styleRegexpTestInvalid')},
};
@ -167,7 +146,7 @@ const regExpTester = (() => {
}
}
}
showHelp(t('styleRegexpTestTitle'), report);
helpPopup.show(t('styleRegexpTestTitle'), report);
report.onclick = onClick;
const note = $create('p.regexp-report-note',
@ -176,7 +155,12 @@ const regExpTester = (() => {
.map(s => (s.startsWith('\\') ? $create('code', s) : s)));
report.appendChild(note);
adjustNote(report, note);
});
},
};
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
}
function onClick(event) {
const a = event.target.closest('a');
@ -191,10 +175,16 @@ const regExpTester = (() => {
}
}
function adjustNote(report, note) {
report.style.paddingBottom = note.offsetHeight + 'px';
function onTabUpdate(tabId, info) {
if (info.url) {
regexpTester.update();
}
}
return {toggle, update};
function unwatch() {
if (isWatching) {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isWatching = false;
}
}
})();

View File

@ -1,49 +0,0 @@
/* global CodeMirror editor debounce */
/* exported rerouteHotkeys */
'use strict';
const rerouteHotkeys = (() => {
// reroute handling to nearest editor when keypress resolves to one of these commands
const REROUTED = new Set([
'save',
'toggleStyle',
'jumpToLine',
'nextEditor', 'prevEditor',
'toggleEditorFocus',
'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
'colorpicker',
'beautify',
]);
return rerouteHotkeys;
// note that this function relies on `editor`. Calling this function before
// the editor is initialized may throw an error.
function rerouteHotkeys(enable, immediately) {
if (!immediately) {
debounce(rerouteHotkeys, 0, enable, true);
} else if (enable) {
document.addEventListener('keydown', rerouteHandler);
} else {
document.removeEventListener('keydown', rerouteHandler);
}
}
function rerouteHandler(event) {
const keyName = CodeMirror.keyName(event);
if (!keyName) {
return;
}
const rerouteCommand = name => {
if (REROUTED.has(name)) {
CodeMirror.commands[name](editor.closestVisible(event.target));
return true;
}
};
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
event.preventDefault();
event.stopPropagation();
}
}
})();

View File

@ -1,21 +1,15 @@
/* global
$
cmFactory
debounce
DocFuncMapper
editor
initBeautifyButton
linter
prefs
regExpTester
t
trimCommentLabel
tryRegExp
*/
/* global $ */// dom.js
/* global MozDocMapper trimCommentLabel */// util.js
/* global cmFactory */
/* global debounce tryRegExp */// toolbox.js
/* global editor */
/* global initBeautifyButton */// beautify.js
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported createSection */
/**
* @param {StyleSection} originalSection
* @param {function():number} genId
@ -43,7 +37,7 @@ function createSection(originalSection, genId, si) {
const appliesToContainer = $('.applies-to-list', el);
const appliesTo = [];
DocFuncMapper.forEachProp(originalSection, (type, value) =>
MozDocMapper.forEachProp(originalSection, (type, value) =>
insertApplyAfter({type, value}));
if (!appliesTo.length) {
insertApplyAfter({all: true});
@ -64,10 +58,10 @@ function createSection(originalSection, genId, si) {
appliesTo,
getModel() {
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
return DocFuncMapper.toSection(items, {code: cm.getValue()});
return MozDocMapper.toSection(items, {code: cm.getValue()});
},
remove() {
linter.disableForEditor(cm);
linterMan.disableForEditor(cm);
el.classList.add('removed');
removed = true;
appliesTo.forEach(a => a.remove());
@ -79,7 +73,7 @@ function createSection(originalSection, genId, si) {
cmFactory.destroy(cm);
},
restore() {
linter.enableForEditor(cm);
linterMan.enableForEditor(cm);
el.classList.remove('removed');
removed = false;
appliesTo.forEach(a => a.restore());
@ -102,7 +96,7 @@ function createSection(originalSection, genId, si) {
},
};
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true});
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {runNow: true});
return section;
@ -120,11 +114,8 @@ function createSection(originalSection, genId, si) {
emitSectionChange('code');
});
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
$('.test-regexp', el).onclick = () => {
regExpTester.toggle();
updateRegexpTester();
};
initBeautifyButton($('.beautify-section', el), () => [cm]);
$('.test-regexp', el).onclick = () => updateRegexpTester(true);
initBeautifyButton($('.beautify-section', el), [cm]);
}
function handleKeydown(cm, event) {
@ -165,15 +156,22 @@ function createSection(originalSection, genId, si) {
}
}
function updateRegexpTester() {
async function updateRegexpTester(toggle) {
const isLoaded = typeof regexpTester === 'object';
if (toggle && !isLoaded) {
await require(['/edit/regexp-tester']); /* global regexpTester */
}
if (toggle != null && isLoaded) {
regexpTester.toggle(toggle);
}
const regexps = appliesTo.filter(a => a.type === 'regexp')
.map(a => a.value);
if (regexps.length) {
el.classList.add('has-regexp');
regExpTester.update(regexps);
if (isLoaded) regexpTester.update(regexps);
} else {
el.classList.remove('has-regexp');
regExpTester.toggle(false);
if (isLoaded) regexpTester.toggle(false);
}
}
@ -211,7 +209,7 @@ function createSection(originalSection, genId, si) {
function updateTocPrefToggled(key, val) {
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
el.onOff(val, 'focusin', updateTocFocus);
(val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
if (val) {
updateTocEntry();
if (el.contains(document.activeElement)) {

View File

@ -1,45 +1,30 @@
/* global
$
$$
$create
API
clipString
CodeMirror
createLivePreview
createSection
debounce
editor
FIREFOX
ignoreChromeError
linter
messageBox
prefs
rerouteHotkeys
sectionsToMozFormat
sessionStore
showCodeMirrorPopup
showHelp
t
*/
/* global $ $$ $create $remove messageBoxProxy */// dom.js
/* global API */// msg.js
/* global CodeMirror */
/* global FIREFOX URLS debounce ignoreChromeError sessionStore */// toolbox.js
/* global MozDocMapper clipString helpPopup rerouteHotkeys showCodeMirrorPopup */// util.js
/* global createSection */// sections-editor-section.js
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported SectionsEditor */
function SectionsEditor() {
const {style, dirty} = editor;
const {style, /** @type DirtyReporter */dirty} = editor;
const container = $('#sections');
/** @type {EditorSection[]} */
const sections = [];
const xo = window.IntersectionObserver &&
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
const livePreview = createLivePreview(null, style.id);
let INC_ID = 0; // an increment id that is used by various object to track the order
let sectionOrder = '';
let headerOffset; // in compact mode the header is at the top so it reduces the available height
container.classList.add('section-editor');
updateHeader();
editor.livePreview.init(null, style.id);
container.classList.add('section-editor');
$('#to-mozilla').on('click', showMozillaFormat);
$('#to-mozilla-help').on('click', showToMozillaHelp);
$('#from-mozilla').on('click', () => showMozillaFormatImport());
@ -50,7 +35,7 @@ function SectionsEditor() {
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
}
/** @namespace SectionsEditor */
/** @namespace Editor */
Object.assign(editor, {
sections,
@ -95,7 +80,7 @@ function SectionsEditor() {
dirty.clear('name');
// FIXME: avoid recreating all editors?
if (codeIsUpdated !== false) {
await initSections(newStyle.sections, {replace: true, pristine: true});
await initSections(newStyle.sections, {replace: true});
}
Object.assign(style, newStyle);
updateHeader();
@ -105,7 +90,7 @@ function SectionsEditor() {
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
$('#heading').textContent = t('editStyleHeading');
}
livePreview.show(Boolean(style.id));
editor.livePreview.toggle(Boolean(style.id));
updateLivePreview();
},
@ -124,22 +109,17 @@ function SectionsEditor() {
},
scrollToEditor(cm) {
const section = sections.find(s => s.cm === cm).el;
const bounds = section.getBoundingClientRect();
if (
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
(bounds.top < 0 && bounds.bottom < window.innerHeight)
) {
if (bounds.top < 0) {
window.scrollBy(0, bounds.top - 1);
} else {
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
}
const {el} = sections.find(s => s.cm === cm);
const r = el.getBoundingClientRect();
const h = window.innerHeight;
if (r.bottom > h && r.top > 0 ||
r.bottom < h && r.top < 0) {
window.scrollBy(0, (r.top + r.bottom - h) / 2 | 0);
}
},
});
editor.ready = initSections(style.sections, {pristine: true});
editor.ready = initSections(style.sections);
/** @param {EditorSection} section */
function fitToContent(section) {
@ -156,7 +136,7 @@ function SectionsEditor() {
return;
}
if (headerOffset == null) {
headerOffset = container.getBoundingClientRect().top;
headerOffset = container.getBoundingClientRect().top + scrollY | 0;
}
contentHeight += 9; // border & resize grip
cm.off('update', resize);
@ -194,13 +174,13 @@ function SectionsEditor() {
progressElement.title = progress + '%';
});
} else {
$.remove(progressElement);
$remove(progressElement);
}
}
function showToMozillaHelp(event) {
event.preventDefault();
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
helpPopup.show(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
}
/**
@ -336,7 +316,7 @@ function SectionsEditor() {
function showMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
popup.codebox.setValue(sectionsToMozFormat(getModel()));
popup.codebox.setValue(MozDocMapper.styleToCss(getModel()));
popup.codebox.execCommand('selectAll');
}
@ -378,9 +358,9 @@ function SectionsEditor() {
lockPageUI(true);
try {
const code = popup.codebox.getValue().trim();
if (!/==userstyle==/i.test(code) ||
if (!URLS.rxMETA.test(code) ||
!await getPreprocessor(code) ||
await messageBox.confirm(
await messageBoxProxy.confirm(
t('importPreprocessor'), 'pre-line',
t('importPreprocessorTitle'))
) {
@ -392,7 +372,7 @@ function SectionsEditor() {
replace: replaceOldStyle,
focusOn: replaceOldStyle ? 0 : false,
});
$('.dismiss').dispatchEvent(new Event('click'));
helpPopup.close();
}
} catch (err) {
showError(err);
@ -416,7 +396,7 @@ function SectionsEditor() {
}
function showError(errors) {
messageBox({
messageBoxProxy.show({
className: 'center danger',
title: t('styleFromMozillaFormatError'),
contents: $create('pre',
@ -433,7 +413,7 @@ function SectionsEditor() {
sectionOrder = validSections.map(s => s.id).join(',');
dirty.modify('sectionOrder', oldOrder, sectionOrder);
container.dataset.sectionCount = validSections.length;
linter.refreshReport();
linterMan.refreshReport();
editor.updateToc();
}
@ -446,7 +426,7 @@ function SectionsEditor() {
function validate() {
if (!$('#name').reportValidity()) {
messageBox.alert(t('styleMissingName'));
messageBoxProxy.alert(t('styleMissingName'));
return false;
}
for (const section of sections) {
@ -455,7 +435,7 @@ function SectionsEditor() {
continue;
}
if (!apply.valueEl.reportValidity()) {
messageBox.alert(t('styleBadRegexp'));
messageBoxProxy.alert(t('styleBadRegexp'));
return false;
}
}
@ -487,13 +467,12 @@ function SectionsEditor() {
}
function updateLivePreviewNow() {
livePreview.update(getModel());
editor.livePreview.update(getModel());
}
async function initSections(src, {
focusOn = 0,
replace = false,
pristine = false,
} = {}) {
if (replace) {
sections.forEach(s => s.remove(true));
@ -505,6 +484,8 @@ function SectionsEditor() {
si.scrollY2 = si.scrollY + window.innerHeight;
container.style.height = si.scrollY2 + 'px';
scrollTo(0, si.scrollY);
// only restore focus if it's the first CM to avoid derpy quirks
focusOn = si.cms[0].focus && 0;
rerouteHotkeys(true);
} else {
si = null;
@ -524,8 +505,8 @@ function SectionsEditor() {
if (si) forceRefresh = y < si.scrollY2 && (y += si.cms[i].parentHeight) > si.scrollY;
insertSectionAfter(src[i], null, forceRefresh, si && si.cms[i]);
setGlobalProgress(i, src.length);
if (pristine) dirty.clear();
if (i === focusOn && !si) sections[i].cm.focus();
dirty.clear();
if (i === focusOn) sections[i].cm.focus();
}
if (!si) requestAnimationFrame(fitToAvailableSpace);
container.style.removeProperty('height');
@ -627,7 +608,7 @@ function SectionsEditor() {
/** @param {EditorSection} section */
function registerEvents(section) {
const {el, cm} = section;
$('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp'));
$('.applies-to-help', el).onclick = () => helpPopup.show(t('appliesLabel'), t('appliesHelp'));
$('.remove-section', el).onclick = () => removeSection(section);
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
@ -653,7 +634,7 @@ function SectionsEditor() {
function refreshOnView(cm, {code, force} = {}) {
if (code) {
linter.enableForEditor(cm, code);
linterMan.enableForEditor(cm, code);
}
if (force || !xo) {
refreshOnViewNow(cm);
@ -679,7 +660,7 @@ function SectionsEditor() {
}
async function refreshOnViewNow(cm) {
linter.enableForEditor(cm);
linterMan.enableForEditor(cm);
cm.refresh();
}

View File

@ -1,21 +1,13 @@
/* global
$
$$
$create
CodeMirror
onDOMready
prefs
showHelp
stringAsRegExp
t
*/
/* global $$ $create */// dom.js
/* global CodeMirror */
/* global helpPopup */// util.js
/* global prefs */
/* global stringAsRegExp */// toolbox.js
/* global t */// localization.js
'use strict';
onDOMready().then(() => {
$('#keyMap-help').addEventListener('click', showKeyMapHelp);
});
function showKeyMapHelp() {
/* exported showKeymapHelp */
function showKeymapHelp() {
const keyMap = mergeKeyMaps({}, prefs.get('editor.keyMap'), CodeMirror.defaults.extraKeys);
const keyMapSorted = Object.keys(keyMap)
.map(key => ({key, cmd: keyMap[key]}))
@ -32,17 +24,19 @@ function showKeyMapHelp() {
tBody.appendChild(row.cloneNode(true));
}
showHelp(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
helpPopup.show(t('cm_keyMap') + ': ' + prefs.get('editor.keyMap'), table);
const inputs = $$('input', table);
inputs[0].addEventListener('keydown', hotkeyHandler);
inputs[0].on('keydown', hotkeyHandler);
inputs[1].focus();
table.oninput = filterTable;
function hotkeyHandler(event) {
const keyName = CodeMirror.keyName(event);
if (keyName === 'Esc' || keyName === 'Tab' || keyName === 'Shift-Tab') {
if (keyName === 'Esc' ||
keyName === 'Tab' ||
keyName === 'Shift-Tab') {
return;
}
event.preventDefault();
@ -90,6 +84,7 @@ function showKeyMapHelp() {
}
}
}
function mergeKeyMaps(merged, ...more) {
more.forEach(keyMap => {
if (typeof keyMap === 'string') {
@ -102,7 +97,7 @@ function showKeyMapHelp() {
if (typeof cmd === 'function') {
// for 'emacs' keymap: provide at least something meaningful (hotkeys and the function body)
// for 'vim*' keymaps: almost nothing as it doesn't rely on CM keymap mechanism
cmd = cmd.toString().replace(/^function.*?\{[\s\r\n]*([\s\S]+?)[\s\r\n]*\}$/, '$1');
cmd = cmd.toString().replace(/^function.*?{[\s\r\n]*([\s\S]+?)[\s\r\n]*}$/, '$1');
merged[key] = cmd.length <= 200 ? cmd : cmd.substr(0, 200) + '...';
} else {
merged[key] = cmd;

View File

@ -1,36 +1,26 @@
/* global
$
$$
$create
API
chromeSync
cmFactory
CodeMirror
createLivePreview
createMetaCompiler
debounce
editor
linter
messageBox
MozSectionFinder
MozSectionWidget
prefs
sectionsToMozFormat
sessionStore
t
*/
/* global $ $$remove $create $isTextInput messageBoxProxy */// dom.js
/* global API */// msg.js
/* global CodeMirror */
/* global MozDocMapper */// util.js
/* global MozSectionFinder */
/* global MozSectionWidget */
/* global URLS debounce sessionStore */// toolbox.js
/* global chromeSync */// storage-util.js
/* global cmFactory */
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported SourceEditor */
function SourceEditor() {
const {style, dirty} = editor;
const {style, /** @type DirtyReporter */dirty} = editor;
let savedGeneration;
let placeholderName = '';
let prevMode = NaN;
$$.remove('.sectioned-only');
$$remove('.sectioned-only');
$('#header').on('wheel', headerOnScroll);
$('#sections').textContent = '';
$('#sections').appendChild($create('.single-editor'));
@ -39,16 +29,26 @@ function SourceEditor() {
const cm = cmFactory.create($('.single-editor'));
const sectionFinder = MozSectionFinder(cm);
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc);
const livePreview = createLivePreview(preprocess, style.id);
/** @namespace SourceEditor */
const sectionWidget = MozSectionWidget(cm, sectionFinder);
editor.livePreview.init(preprocess, style.id);
createMetaCompiler(meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
/** @namespace Editor */
Object.assign(editor, {
sections: sectionFinder.sections,
replaceStyle,
updateLivePreview,
closestVisible: () => cm,
getEditors: () => [cm],
scrollToEditor: () => {},
getEditorTitle: () => '',
save,
getSearchableInputs: () => [],
prevEditor: nextPrevSection.bind(null, -1),
nextEditor: nextPrevSection.bind(null, 1),
jumpToEditor(i) {
@ -58,23 +58,37 @@ function SourceEditor() {
cm.jumpToPos(sec.start);
}
},
closestVisible: () => cm,
getSearchableInputs: () => [],
updateLivePreview,
async save() {
if (!dirty.isDirty()) return;
const sourceCode = cm.getValue();
try {
const {customName, enabled, id} = style;
if (!id &&
(await API.usercss.build({sourceCode, checkDup: true, metaOnly: true})).dup) {
messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
} else {
await replaceStyle(
await API.usercss.editSave({customName, enabled, id, sourceCode}));
}
} catch (err) {
const i = err.index;
const isNameEmpty = i > 0 &&
err.code === 'missingValue' &&
sourceCode.slice(sourceCode.lastIndexOf('\n', i - 1), i).trim().endsWith('@name');
return isNameEmpty
? saveTemplate(sourceCode)
: showSaveError(err);
}
},
scrollToEditor: () => {},
});
createMetaCompiler(cm, meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
prefs.subscribeMany({
'editor.linter': updateLinterSwitch,
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
}, {now: true});
}, {runNow: true});
editor.applyScrollInfo(cm);
cm.clearHistory();
cm.markClean();
@ -88,31 +102,29 @@ function SourceEditor() {
const mode = getModeName();
if (mode === prevMode) return;
prevMode = mode;
linter.run();
linterMan.run();
updateLinterSwitch();
});
setTimeout(linter.enableForEditor, 0, cm);
if (!$.isTextInput(document.activeElement)) {
setTimeout(linterMan.enableForEditor, 0, cm);
if (!$isTextInput(document.activeElement)) {
cm.focus();
}
function preprocess(style) {
return API.usercss.build({
async function preprocess(style) {
const {style: newStyle} = await API.usercss.build({
styleId: style.id,
sourceCode: style.sourceCode,
assignVars: true,
})
.then(({style: newStyle}) => {
});
delete newStyle.enabled;
return Object.assign(style, newStyle);
});
}
function updateLivePreview() {
if (!style.id) {
return;
}
livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
editor.livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
}
function updateLinterSwitch() {
@ -140,10 +152,10 @@ function SourceEditor() {
async function setupNewStyle(style) {
style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) +
`/* ${t('usercssReplaceTemplateSectionBody')} */`;
let section = sectionsToMozFormat(style);
let section = MozDocMapper.styleToCss(style);
if (!section.includes('@-moz-document')) {
style.sections[0].domains = ['example.com'];
section = sectionsToMozFormat(style);
section = MozDocMapper.styleToCss(style);
}
const DEFAULT_CODE = `
/* ==UserStyle==
@ -199,7 +211,7 @@ function SourceEditor() {
return;
}
Promise.resolve(messageBox.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
Promise.resolve(messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))).then(ok => {
if (!ok) return;
updateEnvironment();
if (!sameCode) {
@ -223,77 +235,29 @@ function SourceEditor() {
Object.assign(style, newStyle);
$('#preview-label').classList.remove('hidden');
updateMeta();
livePreview.show(Boolean(style.id));
editor.livePreview.toggle(Boolean(style.id));
}
}
function save() {
if (!dirty.isDirty()) return;
const code = cm.getValue();
return ensureUniqueStyle(code)
.then(() => API.usercss.editSave({
id: style.id,
enabled: style.enabled,
sourceCode: code,
customName: style.customName,
}))
.then(replaceStyle)
.catch(err => {
if (err.handled) return;
const contents = Array.isArray(err) ?
$create('pre', err.join('\n')) :
[err.message || String(err)];
if (Number.isInteger(err.index)) {
const pos = cm.posFromIndex(err.index);
const meta = drawLinePointer(pos);
// save template
if (err.code === 'missingValue' && meta.includes('@name')) {
async function saveTemplate(code) {
if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
const key = chromeSync.LZ_KEY.usercssTemplate;
messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok &&
chromeSync.setLZValue(key, code)
.then(() => chromeSync.getLZValue(key))
.then(saved => saved !== code && messageBox.alert(t('syncStorageErrorSaving'))));
return;
await chromeSync.setLZValue(key, code);
if (await chromeSync.getLZValue(key) !== code) {
messageBoxProxy.alert(t('syncStorageErrorSaving'));
}
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
contents.push($create('pre', meta));
}
messageBox.alert(contents, 'pre');
});
}
function ensureUniqueStyle(code) {
return style.id ? Promise.resolve() :
API.usercss.build({
sourceCode: code,
checkDup: true,
metaOnly: true,
}).then(({dup}) => {
if (dup) {
messageBox.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
return Promise.reject({handled: true});
}
});
}
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
const pointer = ' '.repeat(pos.ch) + '^';
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
const leftPad = start !== 0 ? '...' : '';
const rightPad = end !== line.length ? '...' : '';
return (
leftPad +
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
rightPad +
'\n' +
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
pointer.slice(start, end)
);
function showSaveError(err) {
err = Array.isArray(err) ? err : [err];
const text = err.map(e => e.message || e).join('\n');
const points = err.map(e =>
e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1} // csslint code parser
).filter(Boolean);
cm.setSelections(points.map(p => ({anchor: p, head: p})));
messageBoxProxy.alert($create('pre', text), 'pre');
}
function nextPrevSection(dir) {
@ -334,4 +298,36 @@ function SourceEditor() {
return (mode.name || mode || '') +
(mode.helperType || '');
}
function createMetaCompiler(onUpdated) {
let meta = null;
let metaIndex = null;
let cache = [];
linterMan.register(async (text, options, _cm) => {
if (_cm !== cm) {
return;
}
const match = text.match(URLS.rxMETA);
if (!match) {
return [];
}
if (match[0] === meta && match.index === metaIndex) {
return cache;
}
const {metadata, errors} = await linterMan.worker.metalint(match[0]);
if (errors.every(err => err.code === 'unknownMeta')) {
onUpdated(metadata);
}
cache = errors.map(err => ({
from: cm.posFromIndex((err.index || 0) + match.index),
to: cm.posFromIndex((err.index || 0) + match.index),
message: err.code && t(`meta_${err.code}`, err.args, false) || err.message,
severity: err.code === 'unknownMeta' ? 'warning' : 'error',
rule: err.code,
}));
meta = match[0];
metaIndex = match.index;
return cache;
});
}
}

View File

@ -1,178 +1,109 @@
/* global
$create
CodeMirror
prefs
*/
/* global $ $create getEventKeyName messageBoxProxy moveFocus */// dom.js
/* global CodeMirror */
/* global debounce */// toolbox.js
/* global editor */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported DirtyReporter */
class DirtyReporter {
constructor() {
this._dirty = new Map();
this._onchange = new Set();
}
const helpPopup = {
add(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
this._dirty.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
show(title = '', body) {
const div = $('#help-popup');
const contents = $('.contents', div);
div.className = '';
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? t.HTML(body) : body);
}
}
this.notifyChange(wasDirty);
}
remove(obj, value) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
this._dirty.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
this._dirty.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
this.notifyChange(wasDirty);
}
modify(obj, oldValue, newValue) {
const wasDirty = this.isDirty();
const saved = this._dirty.get(obj);
if (!saved) {
if (oldValue !== newValue) {
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
this._dirty.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
this.notifyChange(wasDirty);
}
clear(obj) {
const wasDirty = this.isDirty();
if (obj === undefined) {
this._dirty.clear();
} else {
this._dirty.delete(obj);
}
this.notifyChange(wasDirty);
}
isDirty() {
return this._dirty.size > 0;
}
onChange(cb, add = true) {
this._onchange[add ? 'add' : 'delete'](cb);
}
notifyChange(wasDirty) {
if (wasDirty !== this.isDirty()) {
this._onchange.forEach(cb => cb());
}
}
has(key) {
return this._dirty.has(key);
}
}
/* exported DocFuncMapper */
const DocFuncMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
domains: 'domain',
regexps: 'regexp',
$('.title', div).textContent = title;
$('.dismiss', div).onclick = helpPopup.close;
window.on('keydown', helpPopup.close, true);
// reset any inline styles
div.style = 'display: block';
helpPopup.originalFocus = document.activeElement;
return div;
},
FROM_CSS: {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
},
/**
* @param {Object} section
* @param {function(func:string, value:string)} fn
*/
forEachProp(section, fn) {
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
const props = section[propName];
if (props) props.forEach(value => fn(func, value));
close(event) {
const canClose =
!event ||
event.type === 'click' || (
getEventKeyName(event) === 'Escape' &&
!$('.CodeMirror-hints, #message-box') && (
!document.activeElement ||
!document.activeElement.closest('#search-replace-dialog') &&
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
const div = $('#help-popup');
if (!canClose || !div) {
return;
}
},
/**
* @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
* @returns {Object} section
*/
toSection(funcItems, section = {}) {
for (const item of funcItems) {
const [func, value] = item || [];
const propName = DocFuncMapper.FROM_CSS[func];
if (propName) {
const props = section[propName] || (section[propName] = []);
if (Array.isArray(value)) props.push(...value);
else props.push(value);
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
setTimeout(async () => {
const ok = await messageBoxProxy.confirm(t('confirmDiscardChanges'));
return ok && helpPopup.close();
});
return;
}
if (div.contains(document.activeElement) && helpPopup.originalFocus) {
helpPopup.originalFocus.focus();
}
return section;
const contents = $('.contents', div);
div.style.display = '';
contents.textContent = '';
window.off('keydown', helpPopup.close, true);
window.dispatchEvent(new Event('closeHelp'));
},
};
/* exported sectionsToMozFormat */
function sectionsToMozFormat(style) {
return style.sections.map(section => {
const cssFuncs = [];
DocFuncMapper.forEachProp(section, (type, value) =>
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
return cssFuncs.length ?
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
section.code;
}).join('\n\n');
}
// reroute handling to nearest editor when keypress resolves to one of these commands
Object.assign(rerouteHotkeys, {
commands: [
'beautify',
'colorpicker',
'find',
'findNext',
'findPrev',
'jumpToLine',
'nextEditor',
'prevEditor',
'replace',
'replaceAll',
'save',
'toggleEditorFocus',
'toggleStyle',
],
/* exported trimCommentLabel */
function trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
}
toggle(enable) {
document[enable ? 'on' : 'off']('keydown', rerouteHotkeys.handler);
},
handler(event) {
const keyName = CodeMirror.keyName(event);
if (!keyName) {
return;
}
const rerouteCommand = name => {
if (rerouteHotkeys.commands.includes(name)) {
CodeMirror.commands[name](editor.closestVisible(event.target));
return true;
}
};
if (CodeMirror.lookupKey(keyName, CodeMirror.defaults.keyMap, rerouteCommand) === 'handled' ||
CodeMirror.lookupKey(keyName, CodeMirror.defaults.extraKeys, rerouteCommand) === 'handled') {
event.preventDefault();
event.stopPropagation();
}
},
});
/* exported clipString */
function clipString(str, limit = 100) {
return str.length <= limit ? str : str.substr(0, limit) + '...';
}
/* exported memoize */
function memoize(fn) {
let cached = false;
let result;
return (...args) => {
if (!cached) {
result = fn(...args);
cached = true;
}
return result;
};
}
/* exported createHotkeyInput */
/**
* @param {!string} prefId
* @param {?function(isEnter:boolean)} onDone
*/
function createHotkeyInput(prefId, onDone = () => {}) {
return $create('input', {
type: 'search',
@ -217,3 +148,59 @@ function createHotkeyInput(prefId, onDone = () => {}) {
},
});
}
function rerouteHotkeys(enable, immediately) {
if (immediately) {
rerouteHotkeys.toggle(enable);
} else {
debounce(rerouteHotkeys.toggle, 0, enable);
}
}
/* exported showCodeMirrorPopup */
function showCodeMirrorPopup(title, html, options) {
const popup = helpPopup.show(title, html);
popup.classList.add('big');
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
mode: 'css',
lineNumbers: true,
lineWrapping: prefs.get('editor.lineWrapping'),
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
matchBrackets: true,
styleActiveLine: true,
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap'),
}, options));
cm.focus();
rerouteHotkeys(false);
document.documentElement.style.pointerEvents = 'none';
popup.style.pointerEvents = 'auto';
const onKeyDown = event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
const search = $('#search-replace-dialog');
const area = search && search.contains(document.activeElement) ? search : popup;
moveFocus(area, event.shiftKey ? -1 : 1);
event.preventDefault();
}
};
window.on('keydown', onKeyDown, true);
window.on('closeHelp', () => {
window.off('keydown', onKeyDown, true);
document.documentElement.style.removeProperty('pointer-events');
rerouteHotkeys(true);
cm = popup.codebox = null;
}, {once: true});
return popup;
}
/* exported trimCommentLabel */
function trimCommentLabel(str, limit = 1000) {
// stripping /*** foo ***/ to foo
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
}

View File

@ -7,44 +7,21 @@
<title>Loading...</title>
<link href="global.css" rel="stylesheet">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
<script src="js/polyfill.js"></script>
<script src="js/msg.js"></script>
<script src="js/messaging.js"></script>
<script src="js/toolbox.js"></script>
<script src="install-usercss/preinit.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/storage-util.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="vendor/semver-bundle/semver.js"></script>
<link href="msgbox/msgbox.css" rel="stylesheet">
<script src="msgbox/msgbox.js"></script>
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
<script src="vendor/codemirror/lib/codemirror.js"></script>
<script src="vendor/codemirror/keymap/sublime.js"></script>
<script src="vendor/codemirror/keymap/emacs.js"></script>
<script src="vendor/codemirror/keymap/vim.js"></script>
<script src="vendor/codemirror/mode/css/css.js"></script>
<script src="vendor/codemirror/addon/search/searchcursor.js"></script>
<link href="vendor/codemirror/addon/fold/foldgutter.css" rel="stylesheet" />
<script src="vendor/codemirror/addon/fold/foldcode.js"></script>
<script src="vendor/codemirror/addon/fold/foldgutter.js"></script>
<script src="vendor/codemirror/addon/fold/brace-fold.js"></script>
<script src="vendor/codemirror/addon/fold/indent-fold.js"></script>
<link href="vendor-overwrites/colorpicker/colorpicker.css" rel="stylesheet">
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorview.js"></script>
<script src="edit/codemirror-default.js"></script>
<link rel="stylesheet" href="edit/codemirror-default.css">
<link href="install-usercss/install-usercss.css" rel="stylesheet">
</head>
<body id="stylus-install-usercss">
<div class="container">
@ -92,13 +69,13 @@
</div>
</div>
<script src="install-usercss/install-usercss.js"></script>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">
<symbol id="svg-icon-checked" viewBox="0 0 1000 1000">
<path fill-rule="evenodd" d="M983.2,184.3L853,69.8c-4-3.5-9.3-5.3-14.5-5c-5.3,0.4-10.3,2.8-13.8,6.8L352.3,609.2L184.4,386.9c-3.2-4.2-8-7-13.2-7.8c-5.3-0.8-10.6,0.6-14.9,3.9L18,487.5c-8.8,6.7-10.6,19.3-3.9,28.1L325,927.2c3.6,4.8,9.3,7.7,15.3,8c0.2,0,0.5,0,0.7,0c5.8,0,11.3-2.5,15.1-6.8L985,212.6C992.3,204.3,991.5,191.6,983.2,184.3z"/>
</symbol>
</svg>
<script src="js/dlg/message-box.js"></script>
<script src="install-usercss/install-usercss.js"></script>
</body>
</html>

View File

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

View File

@ -1,34 +1,23 @@
/* global CodeMirror semverCompare closeCurrentTab messageBox download
$ $$ $create $createLink t prefs API */
/* global $ $create $createLink $$remove */
/* global API */// msg.js
/* global closeCurrentTab */// toolbox.js
/* global messageBox */
/* global prefs */
/* global preinit */
/* global t */// localization.js
'use strict';
(() => {
const params = new URLSearchParams(location.search);
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
const initialUrl = params.get('updateUrl');
let cm;
let initialUrl;
let installed;
let installedDup;
let liveReload;
let tabId;
let installed = null;
let installedDup = null;
const liveReload = initLiveReload();
liveReload.ready.then(initSourceCode, error => messageBox.alert(error, 'pre'));
const theme = prefs.get('editor.theme');
const cm = CodeMirror($('.main'), {
readOnly: true,
colorpicker: true,
theme,
});
if (theme !== 'default') {
document.head.appendChild($create('link', {
rel: 'stylesheet',
href: `vendor/codemirror/theme/${theme}.css`,
}));
}
window.addEventListener('resize', adjustCodeHeight);
window.on('resize', adjustCodeHeight);
// "History back" in Firefox (for now) restores the old DOM including the messagebox,
// which stays after installing since we don't want to wait for the fadeout animation before resolving.
document.addEventListener('visibilitychange', () => {
document.on('visibilitychange', () => {
if (messageBox.element) messageBox.element.remove();
if (installed) liveReload.onToggled();
});
@ -40,192 +29,64 @@
}
}, 200);
function updateMeta(style, dup = installedDup) {
installedDup = dup;
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
cm.setPreprocessor(data.preprocessor);
const installButtonLabel = t(
installed ? 'installButtonInstalled' :
!dup ? 'installButton' :
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
);
document.title = `${installButtonLabel} ${data.name}`;
$('.install').textContent = installButtonLabel;
$('.install').classList.add(
installed ? 'installed' :
!dup ? 'install' :
versionTest > 0 ? 'update' :
'reinstall');
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
$('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description;
if (data.author) {
$('.meta-author').parentNode.style.display = '';
$('.meta-author').textContent = '';
$('.meta-author').appendChild(makeAuthor(data.author));
} else {
$('.meta-author').parentNode.style.display = 'none';
/*
* 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');
if (theme !== 'default') {
require([`/vendor/codemirror/theme/${theme}.css`]); // not awaiting as it may be absent
}
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
$('.meta-license').textContent = data.license;
$('.applies-to').textContent = '';
getAppliesTo(style).forEach(pattern =>
$('.applies-to').appendChild($create('li', pattern)));
$('.external-link').textContent = '';
const externalLink = makeExternalLink();
if (externalLink) {
$('.external-link').appendChild(externalLink);
}
$('#header').classList.add('meta-init');
$('#header').classList.remove('meta-init-error');
setTimeout(() => $.remove('.lds-spinner'), 1000);
showError('');
requestAnimationFrame(adjustCodeHeight);
function makeAuthor(text) {
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
if (!match) {
return document.createTextNode(text);
}
const [, name, email, url] = match;
const frag = document.createDocumentFragment();
if (email) {
frag.appendChild($createLink(`mailto:${email}`, name));
} else {
frag.appendChild($create('span', name));
}
if (url) {
frag.appendChild($createLink(url,
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
$create('SVG:path', {
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
}))
));
}
return frag;
}
function makeExternalLink() {
const urls = [
data.homepageURL && [data.homepageURL, t('externalHomepage')],
data.supportURL && [data.supportURL, t('externalSupport')],
];
return (data.homepageURL || data.supportURL) && (
$create('div', [
$create('h3', t('externalLink')),
$create('ul', urls.map(args => args &&
$create('li',
$createLink(...args)
)
)),
const 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',
]));
}
}
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();
}
({tabId, initialUrl} = await preinit);
liveReload = initLiveReload();
function install(style) {
installed = style;
$$.remove('.warning');
$('button.install').disabled = true;
$('button.install').classList.add('installed');
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
$('h2.installed').classList.add('active');
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : '';
updateMeta(style);
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
location.href = '/edit.html?id=' + style.id;
} else {
API.openEditor({id: style.id});
if (!liveReload.enabled) {
if (tabId < 0 && history.length > 1) {
history.back();
} else {
closeCurrentTab();
const {dup, style, error, sourceCode} = await preinit.ready;
if (!style && sourceCode == null) {
messageBox.alert(isNaN(error) ? error : 'HTTP Error ' + error, 'pre');
return;
}
}
}
}
function initSourceCode(sourceCode) {
cm.setValue(sourceCode);
cm.refresh();
API.usercss.build({sourceCode, checkDup: true})
.then(init)
.catch(err => {
$('#header').classList.add('meta-init-error');
console.error(err);
showError(err);
await scriptsReady;
cm = CodeMirror($('.main'), {
value: sourceCode || style.sourceCode,
readOnly: true,
colorpicker: true,
theme,
});
if (error) {
showBuildError(error);
}
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();
});
if (!style) {
return;
}
return $create('.warning', [
t('parseUsercssError'),
'\n',
...contents,
]);
}
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
const numTabs = pos.ch + 1 - line.slice(0, pos.ch + 1).replace(/\t/g, '').length;
const pointer = ' '.repeat(pos.ch) + '^';
const start = Math.max(Math.min(pos.ch - SIZE / 2, line.length - SIZE), 0);
const end = Math.min(Math.max(pos.ch + SIZE / 2, SIZE), line.length);
const leftPad = start !== 0 ? '...' : '';
const rightPad = end !== line.length ? '...' : '';
return (
leftPad +
line.slice(start, end).replace(/\t/g, ' '.repeat(cm.options.tabSize)) +
rightPad +
'\n' +
' '.repeat(leftPad.length + numTabs * cm.options.tabSize) +
pointer.slice(start, end)
);
}
function init({style, dup}) {
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
@ -277,23 +138,188 @@
} else {
$('.live-reload').remove();
}
})();
function updateMeta(style, dup = installedDup) {
installedDup = dup;
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
cm.setPreprocessor(data.preprocessor);
const installButtonLabel = t(
installed ? 'installButtonInstalled' :
!dup ? 'installButton' :
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
);
document.title = `${installButtonLabel} ${data.name}`;
$('.install').textContent = installButtonLabel;
$('.install').classList.add(
installed ? 'installed' :
!dup ? 'install' :
versionTest > 0 ? 'update' :
'reinstall');
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
$('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description;
if (data.author) {
$('.meta-author').parentNode.style.display = '';
$('.meta-author').textContent = '';
$('.meta-author').appendChild(makeAuthor(data.author));
} else {
$('.meta-author').parentNode.style.display = 'none';
}
function getAppliesTo(style) {
function *_gen() {
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
$('.meta-license').textContent = data.license;
$('.applies-to').textContent = '';
getAppliesTo(style).then(list =>
$('.applies-to').append(...list.map(s => $create('li', s))));
$('.external-link').textContent = '';
const externalLink = makeExternalLink();
if (externalLink) {
$('.external-link').appendChild(externalLink);
}
$('#header').dataset.arrivedFast = performance.now() < 500;
$('#header').classList.add('meta-init');
$('#header').classList.remove('meta-init-error');
setTimeout(() => $$remove('.lds-spinner'), 1000);
showError('');
requestAnimationFrame(adjustCodeHeight);
function makeAuthor(text) {
const match = text.match(/^(.+?)(?:\s+<(.+?)>)?(?:\s+\((.+?)\))?$/);
if (!match) {
return document.createTextNode(text);
}
const [, name, email, url] = match;
const frag = document.createDocumentFragment();
if (email) {
frag.appendChild($createLink(`mailto:${email}`, name));
} else {
frag.appendChild($create('span', name));
}
if (url) {
frag.appendChild($createLink(url,
$create('SVG:svg.svg-icon', {viewBox: '0 0 20 20'},
$create('SVG:path', {
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z',
}))
));
}
return frag;
}
function makeExternalLink() {
const urls = [
data.homepageURL && [data.homepageURL, t('externalHomepage')],
data.supportURL && [data.supportURL, t('externalSupport')],
];
return (data.homepageURL || data.supportURL) && (
$create('div', [
$create('h3', t('externalLink')),
$create('ul', urls.map(args => args &&
$create('li',
$createLink(...args)
)
)),
]));
}
}
function showError(err) {
$('.warnings').textContent = '';
$('.warnings').classList.toggle('visible', Boolean(err));
$('.container').classList.toggle('has-warnings', Boolean(err));
err = Array.isArray(err) ? err : [err];
if (err[0]) {
let i;
if ((i = err[0].index) >= 0 ||
(i = err[0].offset) >= 0) {
cm.jumpToPos(cm.posFromIndex(i));
cm.setSelections(err.map(e => {
const pos = e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1}; // csslint code parser
return pos && {anchor: pos, head: pos};
}).filter(Boolean));
cm.focus();
}
$('.warnings').appendChild(
$create('.warning', [
t('parseUsercssError'),
'\n',
...err.map(e => e.message ? $create('pre', e.message) : e || 'Unknown error'),
]));
}
adjustCodeHeight();
}
function showBuildError(error) {
$('#header').classList.add('meta-init-error');
console.error(error);
showError(error);
}
function install(style) {
installed = style;
$$remove('.warning');
$('button.install').disabled = true;
$('button.install').classList.add('installed');
$('#live-reload-install-hint').classList.toggle('hidden', !liveReload.enabled);
$('h2.installed').classList.add('active');
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : '';
updateMeta(style);
if (!liveReload.enabled && !prefs.get('openEditInWindow')) {
location.href = '/edit.html?id=' + style.id;
} else {
API.openEditor({id: style.id});
if (!liveReload.enabled) {
if (tabId < 0 && history.length > 1) {
history.back();
} else {
closeCurrentTab();
}
}
}
}
async function getAppliesTo(style) {
if (style.sectionsPromise) {
try {
style.sections = await style.sectionsPromise;
} catch (error) {
showBuildError(error);
return [];
} finally {
delete style.sectionsPromise;
}
}
let numGlobals = 0;
const res = [];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
for (const section of style.sections) {
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
if (section[type]) {
yield *section[type];
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'));
}
}
}
const result = [..._gen()];
if (!result.length) {
result.push(chrome.i18n.getMessage('appliesToEverything'));
}
return result;
return [...new Set(res)];
}
function adjustCodeHeight() {
@ -311,24 +337,12 @@
const DELAY = 500;
let isEnabled = false;
let timer = 0;
/** @type function(?options):Promise<string|null> */
let getData = null;
/** @type Promise */
let sequence = null;
if (tabId < 0) {
getData = DirectDownloader();
sequence = API.usercss.getInstallCode(initialUrl)
.then(code => code || getData())
.catch(getData);
} else {
getData = PortDownloader();
sequence = getData({timer: false});
}
const getData = preinit.getData;
let sequence = preinit.ready;
return {
get enabled() {
return isEnabled;
},
ready: sequence,
onToggled(e) {
if (e) isEnabled = e.target.checked;
if (installed || installedDup) {
@ -377,42 +391,4 @@
.catch(showError);
});
}
function DirectDownloader() {
let oldCode = null;
return async () => {
const code = await download(initialUrl);
if (oldCode !== code) {
oldCode = code;
return code;
}
};
}
function PortDownloader() {
const resolvers = new Map();
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
port.onMessage.addListener(({id, code, error}) => {
const r = resolvers.get(id);
resolvers.delete(id);
if (error) {
r.reject(error);
} else {
r.resolve(code);
}
});
port.onDisconnect.addListener(async () => {
const tab = await browser.tabs.get(tabId).catch(() => ({}));
if (tab.url === initialUrl) {
location.reload();
} else {
closeCurrentTab();
}
});
return (opts = {}) => new Promise((resolve, reject) => {
const id = performance.now();
resolvers.set(id, {resolve, reject});
opts.id = id;
port.postMessage(opts);
});
}
}
})();

View File

@ -0,0 +1,90 @@
/* global API */// msg.js
/* global closeCurrentTab download */// toolbox.js
'use strict';
/* exported preinit */
const preinit = (() => {
const params = new URLSearchParams(location.search);
const tabId = params.has('tabId') ? Number(params.get('tabId')) : -1;
const initialUrl = params.get('updateUrl');
/** @type function(?options):Promise<?string> */
let getData;
/** @type {Promise<?string>} */
let firstGet;
if (tabId < 0) {
getData = DirectDownloader();
firstGet = API.usercss.getInstallCode(initialUrl)
.then(code => code || getData())
.catch(getData);
} else {
getData = PortDownloader();
firstGet = getData({timer: false});
}
function DirectDownloader() {
let oldCode = null;
return async () => {
const code = await download(initialUrl);
if (oldCode !== code) {
oldCode = code;
return code;
}
};
}
function PortDownloader() {
const resolvers = new Map();
const port = chrome.tabs.connect(tabId, {name: 'downloadSelf'});
port.onMessage.addListener(({id, code, error}) => {
const r = resolvers.get(id);
resolvers.delete(id);
if (error) {
r.reject(error);
} else {
r.resolve(code);
}
});
port.onDisconnect.addListener(async () => {
const tab = await browser.tabs.get(tabId).catch(() => ({}));
if (tab.url === initialUrl) {
location.reload();
} else {
closeCurrentTab();
}
});
return (opts = {}) => new Promise((resolve, reject) => {
const id = performance.now();
resolvers.set(id, {resolve, reject});
opts.id = id;
port.postMessage(opts);
});
}
return {
getData,
initialUrl,
tabId,
/** @type {Promise<{style, dup} | {error}>} */
ready: (async () => {
let sourceCode;
try {
sourceCode = await firstGet;
} catch (error) {
return {error};
}
try {
const data = await API.usercss.build({sourceCode, checkDup: true, metaOnly: true});
Object.defineProperty(data.style, 'sectionsPromise', {
value: API.usercss.buildCode(data.style).then(style => style.sections),
configurable: true,
});
return data;
} catch (error) {
return {error, sourceCode};
}
})(),
};
})();

View File

@ -1,71 +0,0 @@
/* exported createCache */
'use strict';
// create a FIFO limit-size map.
function createCache({size = 1000, onDeleted} = {}) {
const map = new Map();
const buffer = Array(size);
let index = 0;
let lastIndex = 0;
return {
get,
set,
delete: delete_,
clear,
has: id => map.has(id),
entries: function *() {
for (const [id, item] of map) {
yield [id, item.data];
}
},
values: function *() {
for (const item of map.values()) {
yield item.data;
}
},
get size() {
return map.size;
},
};
function get(id) {
const item = map.get(id);
return item && item.data;
}
function set(id, data) {
if (map.size === size) {
// full
map.delete(buffer[lastIndex].id);
if (onDeleted) {
onDeleted(buffer[lastIndex].id, buffer[lastIndex].data);
}
lastIndex = (lastIndex + 1) % size;
}
const item = {id, data, index};
map.set(id, item);
buffer[index] = item;
index = (index + 1) % size;
}
function delete_(id) {
const item = map.get(id);
if (!item) {
return false;
}
map.delete(item.id);
const lastItem = buffer[lastIndex];
lastItem.index = item.index;
buffer[item.index] = lastItem;
lastIndex = (lastIndex + 1) % size;
if (onDeleted) {
onDeleted(item.id, item.data);
}
return true;
}
function clear() {
map.clear();
index = lastIndex = 0;
}
}

89
js/color/color-mimicry.js Normal file
View File

@ -0,0 +1,89 @@
/* global $create */// dom.js
/* global debounce */// toolbox.js
'use strict';
/* exported colorMimicry */
/**
* Calculates real color of an element:
* colorMimicry(cm.display.gutters, {bg: 'backgroundColor'})
* colorMimicry('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
*/
function colorMimicry(el, targets, dummyContainer = document.body) {
const styleCache = colorMimicry.styleCache || (colorMimicry.styleCache = new Map());
targets = targets || {};
targets.fore = 'color';
const colors = {};
const done = {};
let numDone = 0;
let numTotal = 0;
const rootStyle = getStyle(document.documentElement);
for (const k in targets) {
const base = {r: 255, g: 255, b: 255, a: 1};
blend(base, rootStyle[targets[k]]);
colors[k] = base;
numTotal++;
}
const isDummy = typeof el === 'string';
if (isDummy) {
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
}
for (let current = el; current; current = current && current.parentElement) {
const style = getStyle(current);
for (const k in targets) {
if (!done[k]) {
done[k] = blend(colors[k], style[targets[k]]);
numDone += done[k] ? 1 : 0;
if (numDone === numTotal) {
current = null;
break;
}
}
}
colors.style = colors.style || style;
}
if (isDummy) {
el.remove();
}
for (const k in targets) {
const {r, g, b, a} = colors[k];
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
// https://www.w3.org/TR/AERT#color-contrast
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
}
debounce(clearCache);
return colors;
function blend(base, color) {
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
if (a === 255) {
base.r = r;
base.g = g;
base.b = b;
base.a = 1;
} else if (a) {
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
const q1 = a / 255 / mixedA;
const q2 = base.a * (1 - mixedA) / mixedA;
base.r = Math.round(r * q1 + base.r * q2);
base.g = Math.round(g * q1 + base.g * q2);
base.b = Math.round(b * q1 + base.b * q2);
base.a = mixedA;
}
return Math.abs(base.a - 1) < 1e-3;
}
// speed-up for sequential invocations within the same event loop cycle
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
function getStyle(el) {
let style = styleCache.get(el);
if (!style) {
style = getComputedStyle(el);
styleCache.set(el, style);
}
return style;
}
function clearCache() {
styleCache.clear();
}
}

View File

@ -1,9 +1,9 @@
/* global colorConverter $create debounce */
/* exported colorMimicry */
/* global colorConverter */
/* global colorMimicry */
'use strict';
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
const cm = this;
const cm = window.CodeMirror && this;
const CSS_PREFIX = 'colorpicker-';
const HUE_COLORS = [
{hex: '#ff0000', start: .0},
@ -679,7 +679,7 @@
function onCloseRequest(event) {
if (event.detail !== PUBLIC_API) {
hide();
} else if (!prevFocusedElement) {
} else if (!prevFocusedElement && cm) {
// we're between mousedown and mouseup and colorview wants to re-open us in this cm
// so we'll prevent onMouseUp from hiding us to avoid flicker
prevFocusedElement = cm.display.input;
@ -867,9 +867,9 @@
function guessTheme() {
const el = options.guessBrightness ||
((cm.display.renderedView || [])[0] || {}).text ||
cm.display.lineDiv;
const bgLuma = window.colorMimicry.get(el, {bg: 'backgroundColor'}).bgLuma;
cm && ((cm.display.renderedView || [])[0] || {}).text ||
cm && cm.display.lineDiv;
const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma;
return bgLuma < .5 ? 'dark' : 'light';
}
@ -893,92 +893,3 @@
//endregion
};
//////////////////////////////////////////////////////////////////
// eslint-disable-next-line no-var
var colorMimicry = (() => {
const styleCache = new Map();
return {get};
// Calculates real color of an element:
// colorMimicry.get(cm.display.gutters, {bg: 'backgroundColor'})
// colorMimicry.get('input.foo.bar', null, $('some.parent.to.host.the.dummy'))
function get(el, targets, dummyContainer = document.body) {
targets = targets || {};
targets.fore = 'color';
const colors = {};
const done = {};
let numDone = 0;
let numTotal = 0;
const rootStyle = getStyle(document.documentElement);
for (const k in targets) {
const base = {r: 255, g: 255, b: 255, a: 1};
blend(base, rootStyle[targets[k]]);
colors[k] = base;
numTotal++;
}
const isDummy = typeof el === 'string';
if (isDummy) {
el = dummyContainer.appendChild($create(el, {style: 'display: none'}));
}
for (let current = el; current; current = current && current.parentElement) {
const style = getStyle(current);
for (const k in targets) {
if (!done[k]) {
done[k] = blend(colors[k], style[targets[k]]);
numDone += done[k] ? 1 : 0;
if (numDone === numTotal) {
current = null;
break;
}
}
}
colors.style = colors.style || style;
}
if (isDummy) {
el.remove();
}
for (const k in targets) {
const {r, g, b, a} = colors[k];
colors[k] = `rgba(${r}, ${g}, ${b}, ${a})`;
// https://www.w3.org/TR/AERT#color-contrast
colors[k + 'Luma'] = (r * .299 + g * .587 + b * .114) / 256;
}
debounce(clearCache);
return colors;
}
function blend(base, color) {
const [r, g, b, a = 255] = (color.match(/\d+/g) || []).map(Number);
if (a === 255) {
base.r = r;
base.g = g;
base.b = b;
base.a = 1;
} else if (a) {
const mixedA = 1 - (1 - a / 255) * (1 - base.a);
const q1 = a / 255 / mixedA;
const q2 = base.a * (1 - mixedA) / mixedA;
base.r = Math.round(r * q1 + base.r * q2);
base.g = Math.round(g * q1 + base.g * q2);
base.b = Math.round(b * q1 + base.b * q2);
base.a = mixedA;
}
return Math.abs(base.a - 1) < 1e-3;
}
// speed-up for sequential invocations within the same event loop cycle
// (we're assuming the invoker doesn't force CSSOM to refresh between the calls)
function getStyle(el) {
let style = styleCache.get(el);
if (!style) {
style = getComputedStyle(el);
styleCache.set(el, style);
}
return style;
}
function clearCache() {
styleCache.clear();
}
})();

View File

@ -1,4 +1,5 @@
/* global CodeMirror colorConverter */
/* global CodeMirror */
/* global colorConverter */
'use strict';
(() => {
@ -99,7 +100,7 @@
const cache = new Set();
class ColorSwatch {
constructor(cm, options) {
constructor(cm, options = {}) {
this.cm = cm;
this.options = options;
this.markersToRemove = [];

View File

@ -38,7 +38,7 @@ class Reporter {
* @param {Object} ruleset - The set of rules to work with, including if
* they are errors or warnings.
* @param {Object} allow - explicitly allowed lines
* @param {[][]} ingore - list of line ranges to be ignored
* @param {[][]} ignore - list of line ranges to be ignored
*/
constructor(lines, ruleset, allow, ignore) {
this.messages = [];
@ -204,7 +204,8 @@ var CSSLint = (() => {
try {
parser.parse(text, {reuseCache});
} catch (ex) {
reporter.error('Fatal error, cannot continue: ' + ex.message, ex.line, ex.col, {});
reporter.error('Fatal error, cannot continue!\n' + ex.stack,
ex.line || 1, ex.col || 1, {});
}
const report = {
@ -324,23 +325,8 @@ var CSSLint = (() => {
//endregion
//region Util
// expose for testing purposes
CSSLint._Reporter = Reporter;
CSSLint.Util = {
indexOf(values, value) {
if (typeof values.indexOf === 'function') {
return values.indexOf(value);
}
for (let i = 0, len = values.length; i < len; i++) {
if (values[i] === value) {
return i;
}
}
return -1;
},
registerBlockEvents(parser, start, end, property) {
for (const e of [
'document',
@ -643,7 +629,7 @@ CSSLint.addRule({
if (inKeyFrame &&
typeof inKeyFrame === 'string' &&
name.startsWith(inKeyFrame) ||
CSSLint.Util.indexOf(applyTo, name) < 0) {
applyTo.indexOf(name) < 0) {
return;
}
properties.push(event.property);
@ -657,7 +643,7 @@ CSSLint.addRule({
for (const name of properties) {
for (const prop in compatiblePrefixes) {
const variations = compatiblePrefixes[prop];
if (CSSLint.Util.indexOf(variations, name.text) <= -1) continue;
if (variations.indexOf(name.text) <= -1) continue;
if (!propertyGroups[prop]) {
propertyGroups[prop] = {
@ -667,7 +653,7 @@ CSSLint.addRule({
};
}
if (CSSLint.Util.indexOf(propertyGroups[prop].actual, name.text) === -1) {
if (propertyGroups[prop].actual.indexOf(name.text) === -1) {
propertyGroups[prop].actual.push(name.text);
propertyGroups[prop].actualNodes.push(name);
}
@ -680,7 +666,7 @@ CSSLint.addRule({
if (value.full.length <= actual.length) continue;
for (const item of value.full) {
if (CSSLint.Util.indexOf(actual, item) !== -1) continue;
if (actual.indexOf(item) !== -1) continue;
const propertiesSpecified =
actual.length === 1 ?
@ -1122,7 +1108,9 @@ CSSLint.addRule({
parser.addListener('import', () => count++);
parser.addListener('endstylesheet', () => {
if (count > MAX_IMPORT_COUNT) {
reporter.rollupError(`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`, this);
reporter.rollupError(
`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`,
this);
}
});
},
@ -1179,9 +1167,8 @@ CSSLint.addRule({
init(parser, reporter) {
parser.addListener('property', event => {
if (event.invalid) {
reporter.report(event.invalid.message, event.line, event.col, this);
}
const inv = event.invalid;
if (inv) reporter.report(inv.message, inv.line, inv.col, this);
});
},
});
@ -1422,13 +1409,10 @@ CSSLint.addRule({
init(parser, reporter) {
parser.addListener('startrule', event => {
for (const {parts} of event.selectors) {
for (let p = 0, pLen = parts.length; p < pLen; p++) {
for (let n = p + 1; n < pLen; n++) {
if (parts[p].type === 'descendant' &&
parts[n].line > parts[p].line) {
for (let i = 0, p, pn; i < parts.length - 1 && (p = parts[i]); i++) {
if (p.type === 'descendant' && (pn = parts[i + 1]).line > p.line) {
reporter.report('newline character found in selector (forgot a comma?)',
parts[p].line, parts[0].col, this);
}
pn.line, pn.col, this);
}
}
}
@ -1491,6 +1475,37 @@ CSSLint.addRule({
},
});
CSSLint.addRule({
id: 'simple-not',
name: 'Require use of simple selectors inside :not()',
desc: 'A complex selector inside :not() is only supported by CSS4-compliant browsers.',
browsers: 'All',
init(parser, reporter) {
parser.addListener('startrule', e => {
for (const sel of e.selectors) {
if (!/:not\(/i.test(sel.text)) continue;
for (const part of sel.parts) {
if (!part.modifiers) continue;
for (const mod of part.modifiers) {
if (mod.type !== 'not') continue;
const {args} = mod;
const {parts} = args[0];
if (args.length > 1 ||
parts.length !== 1 ||
parts[0].modifiers.length + (parts[0].elementName ? 1 : 0) > 1 ||
/^:not\(/i.test(parts[0])) {
reporter.report(
`Simple selector expected, but found '${args.join(', ')}'`,
args[0].line, args[0].col, this);
}
}
}
}
});
},
});
CSSLint.addRule({
id: 'star-property-hack',
name: 'Disallow properties with a star prefix',

View File

@ -676,8 +676,9 @@ self.parserlib = (() => {
x: 'resolution',
ar: 'dimension',
};
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]+/yu;
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]+/yu;
// Sticky `y` flag must be used in expressions used with peekTest and readMatch
const rxIdentStart = /[-\\_a-zA-Z\u00A0-\uFFFF]/u;
const rxNameChar = /[-\\_\da-zA-Z\u00A0-\uFFFF]/u;
const rxNameCharNoEsc = /[-_\da-zA-Z\u00A0-\uFFFF]+/yu; // must not match \\
const rxUnquotedUrlCharNoEsc = /[-!#$%&*-[\]-~\u00A0-\uFFFF]+/yu; // must not match \\
const rxVendorPrefix = /^-(webkit|moz|ms|o)-(.+)/i;
@ -1174,6 +1175,7 @@ self.parserlib = (() => {
//#region Tokens
/* https://www.w3.org/TR/css3-syntax/#lexical */
/** @type {Object<string,number|Object>} */
const Tokens = Object.assign([], {
EOF: {}, // must be the first token
}, {
@ -1530,8 +1532,10 @@ self.parserlib = (() => {
constructor(matchFunc, toString, options) {
this.matchFunc = matchFunc;
/** @type {function(?number):string} */
this.toString = typeof toString === 'function' ? toString : () => toString;
if (options) this.options = options;
/** @type {?Matcher[]} */
this.options = options;
}
/**
@ -2013,11 +2017,6 @@ self.parserlib = (() => {
// individual media query
class MediaQuery extends SyntaxUnit {
/**
* @param {String} modifier The modifier "not" or "only" (or null).
* @param {String} mediaType The type of media (i.e., "print").
* @param {Array} features Array of selectors parts making up this selector.
*/
constructor(modifier, mediaType, features, pos) {
const text = (modifier ? modifier + ' ' : '') +
(mediaType ? mediaType : '') +
@ -2045,9 +2044,6 @@ self.parserlib = (() => {
* including multiple selectors (those separated by commas).
*/
class Selector extends SyntaxUnit {
/**
* @param {SelectorPart[]} parts
*/
constructor(parts, pos) {
super(parts.join(' '), pos, TYPES.SELECTOR_TYPE);
this.parts = parts;
@ -2061,10 +2057,6 @@ self.parserlib = (() => {
* Does not include combinators such as spaces, +, >, etc.
*/
class SelectorPart extends SyntaxUnit {
/**
* @param {String} elementName or null if there's none
* @param {SelectorSubPart[]} modifiers - may be empty
*/
constructor(elementName, modifiers, text, pos) {
super(text, pos, TYPES.SELECTOR_PART_TYPE);
this.elementName = elementName;
@ -2076,9 +2068,6 @@ self.parserlib = (() => {
* Selector modifier string
*/
class SelectorSubPart extends SyntaxUnit {
/**
* @param {string} type - elementName id class attribute pseudo any not
*/
constructor(text, type, pos) {
super(text, pos, TYPES.SELECTOR_SUB_PART_TYPE);
this.type = type;
@ -2136,21 +2125,15 @@ self.parserlib = (() => {
}
return 0;
}
/**
* @return {int} The numeric value for the specificity.
*/
valueOf() {
return (this.a * 1000) + (this.b * 100) + (this.c * 10) + this.d;
return this.a * 1000 + this.b * 100 + this.c * 10 + this.d;
}
/**
* @return {String} The string representation of specificity.
*/
toString() {
return this.a + ',' + this.b + ',' + this.c + ',' + this.d;
return `${this.a},${this.b},${this.c},${this.d}`;
}
/**
* Calculates the specificity of the given selector.
* @param {Selector} The selector to calculate specificity for.
* @param {Selector} selector The selector to calculate specificity for.
* @return {Specificity} The specificity of the selector.
*/
static calculate(selector) {
@ -2192,7 +2175,6 @@ self.parserlib = (() => {
class PropertyName extends SyntaxUnit {
constructor(text, hack, pos) {
super(text, pos, TYPES.PROPERTY_NAME_TYPE);
// type of IE hack applied ("*", "_", or null).
this.hack = hack;
}
toString() {
@ -2205,9 +2187,6 @@ self.parserlib = (() => {
* separated by commas, this type represents just one of the values.
*/
class PropertyValue extends SyntaxUnit {
/**
* @param {PropertyValuePart[]} parts An array of value parts making up this value.
*/
constructor(parts, pos) {
super(parts.join(' '), pos, TYPES.PROPERTY_VALUE_TYPE);
this.parts = parts;
@ -2225,12 +2204,7 @@ self.parserlib = (() => {
const {value, type} = token;
super(value, token, TYPES.PROPERTY_VALUE_PART_TYPE);
this.tokenType = type;
if (token.expr) this.expr = token.expr;
// There can be ambiguity with escape sequences in identifiers, as
// well as with "color" parts which are also "identifiers", so record
// an explicit hint when the token generating this PropertyValuePart
// was an identifier.
this.wasIdent = type === Tokens.IDENT;
this.expr = token.expr || null;
switch (type) {
case Tokens.ANGLE:
case Tokens.DIMENSION:
@ -2286,7 +2260,6 @@ self.parserlib = (() => {
}
}
// A utility class that allows for easy iteration over the various parts of a property value.
class PropertyValueIterator {
/**
* @param {PropertyValue} value
@ -2368,7 +2341,7 @@ self.parserlib = (() => {
/** @param {PropertyValuePart} p */
function vtIsIdent(p) {
return p.type === 'identifier' || p.wasIdent;
return p.tokenType === Tokens.IDENT;
}
/** @param {PropertyValuePart} p */
@ -2684,13 +2657,19 @@ self.parserlib = (() => {
const reader = this._reader;
/** @namespace parserlib.Token */
const tok = {
value: '',
type: Tokens.CHAR,
col: reader._col,
line: reader._line,
offset: reader._cursor,
};
const a = tok.value = reader.read();
const b = reader.peek();
let a = tok.value = reader.read();
let b = reader.peek();
if (a === '\\') {
if (b === '\n' || b === '\f') return tok;
a = this.readEscape();
b = reader.peek();
}
switch (a) {
case ' ':
case '\n':
@ -2744,7 +2723,7 @@ self.parserlib = (() => {
case "'":
return this.stringToken(a, tok);
case '#':
if ((rxNameChar.lastIndex = 0, rxNameChar.test(b))) {
if (rxNameChar.test(b)) {
tok.type = Tokens.HASH;
tok.value = this.readName(a);
}
@ -2767,7 +2746,7 @@ self.parserlib = (() => {
}
} else if (b >= '0' && b <= '9' || b === '.' && reader.peekTest(/\.\d/y)) {
this.numberToken(a, tok);
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(b))) {
} else if (rxIdentStart.test(b)) {
this.identOrFunctionToken(a, tok);
} else {
tok.type = Tokens.MINUS;
@ -2805,10 +2784,6 @@ self.parserlib = (() => {
tok.value = '<!--';
}
return tok;
case '\\':
return b !== '\r' && b !== '\n' && b !== '\f' ?
this.identOrFunctionToken(this.readEscape(), tok) :
tok;
// EOF
case null:
tok.type = Tokens.EOF;
@ -2821,7 +2796,7 @@ self.parserlib = (() => {
}
if (a >= '0' && a <= '9') {
this.numberToken(a, tok);
} else if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(a))) {
} else if (rxIdentStart.test(a)) {
this.identOrFunctionToken(a, tok);
} else {
tok.type = typeMap.get(a) || Tokens.CHAR;
@ -2911,7 +2886,7 @@ self.parserlib = (() => {
let tt = Tokens.NUMBER;
let units, type;
const c = reader.peek();
if ((rxIdentStart.lastIndex = 0, rxIdentStart.test(c))) {
if (rxIdentStart.test(c)) {
units = this.readName(reader.read());
type = UNITS[units] || UNITS[lower(units)];
tt = type && Tokens[type.toUpperCase()] ||
@ -3063,6 +3038,76 @@ self.parserlib = (() => {
this._reader.readCount(2 - first.length) +
this._reader.readMatch(/([^*]|\*(?!\/))*(\*\/|$)/y);
}
/**
* @param {boolean} [omitComments]
* @param {string} [stopOn] - goes to the parent if used at the top nesting level of the value,
specifying an empty string will stop after consuming the first encountered top block.
* @returns {?string}
*/
readDeclValue({omitComments, stopOn = ';!})'} = {}) {
const reader = this._reader;
const value = [];
const endings = [];
let end = stopOn;
const rx = stopOn.includes(';')
? /([^;!'"{}()[\]/\\]|\/(?!\*))+/y
: /([^'"{}()[\]/\\]|\/(?!\*))+/y;
while (!reader.eof()) {
const chunk = reader.readMatch(rx);
if (chunk) {
value.push(chunk);
}
reader.mark();
const c = reader.read();
if (!endings.length && stopOn.includes(c)) {
reader.reset();
break;
}
value.push(c);
if (c === '\\') {
value[value.length - 1] = this.readEscape();
} else if (c === '/') {
value[value.length - 1] = this.readComment(c);
if (omitComments) value.pop();
} else if (c === '"' || c === "'") {
value[value.length - 1] = this.readString(c);
} else if (c === '{' || c === '(' || c === '[') {
endings.push(end);
end = c === '{' ? '}' : c === '(' ? ')' : ']';
} else if (c === '}' || c === ')' || c === ']') {
if (!end.includes(c)) {
reader.reset();
return null;
}
end = endings.pop();
if (!end && !stopOn) {
break;
}
}
}
return fastJoin(value);
}
readUnknownSym() {
const reader = this._reader;
const prelude = [];
let block;
while (true) {
if (reader.eof()) this.throwUnexpected();
const c = reader.peek();
if (c === '{') {
block = this.readDeclValue({stopOn: ''});
break;
} else if (c === ';') {
reader.read();
break;
} else {
prelude.push(this.readDeclValue({omitComments: true, stopOn: ';{'}));
}
}
return {prelude, block};
}
}
//#endregion
@ -3347,11 +3392,13 @@ self.parserlib = (() => {
class Parser extends EventTarget {
/**
* @param {Object} [options]
* @param {Boolean} [options.starHack] - allows IE6 star hack
* @param {Boolean} [options.underscoreHack] - interprets leading underscores as IE6-7 for known properties
* @param {Boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing syntax errors
* @param {Boolean} [options.strict] - stop on errors instead of reporting them and continuing
* @param {Boolean} [options.skipValidation] - skip syntax validation
* @param {boolean} [options.ieFilters] - accepts IE < 8 filters instead of throwing
* @param {boolean} [options.skipValidation] - skip syntax validation
* @param {boolean} [options.starHack] - allows IE6 star hack
* @param {boolean} [options.strict] - stop on errors instead of reporting them and continuing
* @param {boolean} [options.topDocOnly] - quickly extract all top-level @-moz-document,
their {}-block contents is retrieved as text using _simpleBlock()
* @param {boolean} [options.underscoreHack] - interprets leading _ as IE6-7 for known props
*/
constructor(options) {
super();
@ -3361,14 +3408,12 @@ self.parserlib = (() => {
}
/**
* @param {String|{type: string, ...}} event
* @param {Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
* @param {string|Object} event
* @param {parserlib.Token|SyntaxUnit} [token=this._tokenStream._token] - sets the position
*/
fire(event, token = this._tokenStream._token) {
if (typeof event === 'string') {
event = {type: event};
} else if (event.message && event.message.includes('/*[[')) {
return;
}
if (event.offset === undefined && token) {
event.offset = token.offset;
@ -3393,18 +3438,28 @@ self.parserlib = (() => {
this._skipCruft();
}
}
for (let tt, token; (tt = (token = stream.LT(1)).type) > Tokens.EOF; this._skipCruft()) {
const {topDocOnly} = this.options;
const allowedActions = topDocOnly ? Parser.ACTIONS.topDoc : Parser.ACTIONS.stylesheet;
for (let tt, token; (tt = (token = stream.get(true)).type); this._skipCruft()) {
try {
let action = Parser.ACTIONS.stylesheet.get(tt);
let action = allowedActions.get(tt);
if (action) {
action.call(this, stream.get(true));
action.call(this, token);
continue;
}
action = Parser.ACTIONS.stylesheetMisplaced.get(tt);
if (action) {
action.call(this, stream.get(true), true);
action.call(this, token, true);
throw new SyntaxError(Tokens[tt].text + ' not allowed here.', token);
}
if (topDocOnly) {
stream.readDeclValue({stopOn: '{}'});
if (stream._reader.peek() === '{') {
stream.readDeclValue({stopOn: ''});
}
continue;
}
stream.unget();
if (!this._ruleset() && stream.peek() !== Tokens.EOF) {
stream.throwUnexpected(stream.get(true));
}
@ -3677,10 +3732,14 @@ self.parserlib = (() => {
}
stream.mustMatch(Tokens.LBRACE);
this.fire({type: 'startdocument', functions, prefix}, start);
if (this.options.topDocOnly) {
stream.readDeclValue({stopOn: '}'});
} else {
this._ws();
let action;
do action = Parser.ACTIONS.document.get(stream.peek());
while (action ? action.call(this, stream.get(true)) || true : this._ruleset());
}
stream.mustMatch(Tokens.RBRACE);
this.fire({type: 'enddocument', functions, prefix});
this._ws();
@ -3960,22 +4019,11 @@ self.parserlib = (() => {
_negation(start) {
const stream = this._tokenStream;
let value = start.value + this._ws();
const value = [start.value, this._ws()];
const args = this._selectorsGroup();
if (!args) stream.throwUnexpected(stream.LT(1));
const arg = args[0];
const parts = arg.parts;
if (args.length > 1 ||
parts.length !== 1 ||
parts[0].modifiers.length + (parts[0].elementName ? 1 : 0) > 1 ||
/^:not\b/i.test(parts[0])) {
this.fire({
type: 'warning',
message: `Simple selector expected, but found '${args.join(', ')}'`,
}, arg);
}
value += arg + this._ws() + stream.mustMatch(Tokens.RPAREN).value;
return Object.assign(new SelectorSubPart(value, 'not', start), {args: [arg]});
value.push(...args, this._ws(), stream.mustMatch(Tokens.RPAREN).value);
return Object.assign(new SelectorSubPart(fastJoin(value), 'not', start), {args});
}
_declaration(consumeSemicolon) {
@ -4061,67 +4109,14 @@ self.parserlib = (() => {
}
_customProperty() {
const stream = this._tokenStream;
const reader = stream._reader;
const value = [];
// These chars belong to the parent if used at the top nesting level of the property's value
const UNGET = ';!})';
let end = UNGET;
const endings = [];
readValue:
while (!reader.eof()) {
const chunk = reader.readMatch(/([^;!'"{}()[\]/]|\/(?!\*))+/y);
if (chunk) {
value.push(chunk);
}
reader.mark();
const c = reader.read();
value.push(c);
switch (c) {
case '/':
value[value.length - 1] = stream.readComment(c);
continue;
case '"':
case "'":
value[value.length - 1] = stream.readString(c);
continue;
case '{':
case '(':
case '[':
endings.push(end);
end = c === '{' ? '}' : c === '(' ? ')' : ']';
continue;
case ';':
case '!':
if (endings.length) {
continue;
}
reader.reset();
// fallthrough
case '}':
case ')':
case ']':
if (!end.includes(c)) {
reader.reset();
return null;
}
end = endings.pop();
if (end) {
continue;
}
if (UNGET.includes(c)) {
reader.reset();
value.pop();
}
break readValue;
}
}
if (!value[0]) return null;
const token = stream._token;
token.value = fastJoin(value);
const value = this._tokenStream.readDeclValue();
if (value) {
const token = this._tokenStream._token;
token.value = value;
token.type = Tokens.IDENT;
return new PropertyValue([new PropertyValuePart(token)], token);
}
}
_term(inFunction) {
const stream = this._tokenStream;
@ -4400,64 +4395,12 @@ self.parserlib = (() => {
}
_unknownSym(start) {
const stream = this._tokenStream;
if (this.options.strict) {
throw new SyntaxError('Unknown @ rule.', start);
}
const {prelude, block} = this._tokenStream.readUnknownSym();
this.fire({type: 'unknown-at-rule', name: start.value, prelude, block}, start);
this._ws();
const simpleValue =
stream.match(Tokens.IDENT) && SyntaxUnit.fromToken(stream._token) ||
stream.peek() === Tokens.FUNCTION && this._function({asText: true}) ||
this._unknownBlock(TT.LParenBracket);
this._ws();
const blockValue = this._unknownBlock();
if (!blockValue) {
stream.match(Tokens.SEMICOLON);
}
this.fire({
type: 'unknown-at-rule',
name: start.value,
simpleValue,
blockValue,
}, start);
this._ws();
}
_unknownBlock(canStartWith = [Tokens.LBRACE]) {
const stream = this._tokenStream;
if (!canStartWith.includes(stream.peek())) {
return null;
}
stream.get();
const start = stream._token;
const reader = stream._reader;
reader.mark();
reader._cursor = start.offset;
reader._line = start.line;
reader._col = start.col;
const value = [];
const endings = [];
let blockEnd;
while (!reader.eof()) {
const chunk = reader.readMatch(/[^{}()[\]]*[{}()[\]]?/y);
const c = chunk.slice(-1);
value.push(chunk);
if (c === '{' || c === '(' || c === '[') {
endings.push(blockEnd);
blockEnd = c === '{' ? '}' : c === '(' ? ')' : ']';
} else if (c === '}' || c === ')' || c === ']') {
if (c !== blockEnd) {
break;
}
blockEnd = endings.pop();
if (!blockEnd) {
stream.resetLT();
return new SyntaxUnit(fastJoin(value), start);
}
}
}
reader.reset();
return null;
}
_verifyEnd() {
@ -4577,6 +4520,12 @@ self.parserlib = (() => {
[Tokens.NAMESPACE_SYM, Parser.prototype._namespace],
]),
topDoc: new Map([
symDocument,
symUnknown,
[Tokens.S, Parser.prototype._ws],
]),
document: new Map([
symMedia,
symDocMisplaced,

View File

@ -1,19 +1,22 @@
/* global
$
$create
$createLink
API
debounce
deepCopy
messageBox
prefs
setupLivePrefs
t
*/
/* exported configDialog */
/* global $ $create $createLink $remove messageBoxProxy setupLivePrefs */// dom.js
/* global API */// msg.js
/* global debounce deepCopy */// toolbox.js
/* global messageBox */
/* global prefs */
/* global t */// localization.js
'use strict';
function configDialog(style) {
/* exported configDialog */
async function configDialog(style) {
await require([
'/js/color/color-converter',
'/js/color/color-mimicry',
'/js/color/color-picker',
'/js/color/color-picker.css',
'/js/dlg/config-dialog.css',
]);
const AUTOSAVE_DELAY = 500;
let saving = false;
@ -32,7 +35,7 @@ function configDialog(style) {
renderValues();
vars.forEach(renderValueState);
return messageBox({
return messageBoxProxy.show({
title: `${style.customName || style.name} v${data.version}`,
className: 'config-dialog' + (isPopup ? ' stylus-popup' : ''),
contents: [
@ -82,7 +85,7 @@ function configDialog(style) {
adjustSizeForPopup(box);
}
box.addEventListener('change', onchange);
box.on('change', onchange);
buttons.save = $('[data-cmd="save"]', box);
buttons.default = $('[data-cmd="default"]', box);
buttons.close = $('[data-cmd="close"]', box);
@ -118,20 +121,18 @@ function configDialog(style) {
buttons.close.textContent = t(someDirty ? 'confirmCancel' : 'confirmClose');
}
function save({anyChangeIsDirty = false} = {}, bgStyle) {
if (saving) {
debounce(save, 0, ...arguments);
return;
async function save({anyChangeIsDirty = false} = {}, bgStyle) {
for (let delay = 1; saving && delay < 1000; delay *= 2) {
await new Promise(resolve => setTimeout(resolve, delay));
}
if (!vars.length ||
!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
if (saving) {
throw 'Could not save: still saving previous results...';
}
if (!vars.some(va => va.dirty || anyChangeIsDirty && va.value !== va.savedValue)) {
return;
}
if (!bgStyle) {
API.styles.get(style.id)
.catch(() => ({}))
.then(bgStyle => save({anyChangeIsDirty}, bgStyle));
return;
bgStyle = await API.styles.get(style.id).catch(() => ({}));
}
style = style.sections ? Object.assign({}, style) : style;
style.enabled = true;
@ -147,13 +148,12 @@ function configDialog(style) {
if (!bgva) {
error = 'deleted';
delete styleVars[va.name];
} else
if (bgva.type !== va.type) {
} else if (bgva.type !== va.type) {
error = ['type ', '*' + va.type, ' != ', '*' + bgva.type];
} else
if ((va.type === 'select' || va.type === 'dropdown') &&
!isDefault(va) &&
bgva.options.every(o => o.name !== va.value)) {
} else if (
(va.type === 'select' || va.type === 'dropdown') &&
!isDefault(va) && bgva.options.every(o => o.name !== va.value)
) {
error = `'${va.value}' not in the updated '${va.type}' list`;
} else if (!va.dirty && (!anyChangeIsDirty || va.value === va.savedValue)) {
continue;
@ -182,22 +182,22 @@ function configDialog(style) {
return;
}
saving = true;
return API.usercss.configVars(style.id, style.usercssData.vars)
.then(newVars => {
try {
const newVars = await API.usercss.configVars(style.id, style.usercssData.vars);
varsInitial = getInitialValues(newVars);
vars.forEach(va => onchange({target: va.input, justSaved: true}));
renderValues();
updateButtons();
$.remove('.config-error');
})
.catch(errors => {
$remove('.config-error');
} catch (errors) {
const el = $('.config-error', messageBox.element) ||
$('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error'));
el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors.message || String(errors);
})
.then(() => {
el.textContent =
el.title = (Array.isArray(errors) ? errors : [errors])
.map(e => e.message || `${e}`)
.join('\n');
}
saving = false;
});
}
function useDefault() {
@ -401,7 +401,7 @@ function configDialog(style) {
function showColorpicker(event) {
event.preventDefault();
window.removeEventListener('keydown', messageBox.listeners.key, true);
window.off('keydown', messageBox.listeners.key, true);
const box = $('#message-box-contents');
colorpicker.show({
va: this.va,
@ -424,7 +424,7 @@ function configDialog(style) {
function restoreEscInDialog() {
if (!$('.colorpicker-popup') && messageBox.element) {
window.addEventListener('keydown', messageBox.listeners.key, true);
window.on('keydown', messageBox.listeners.key, true);
}
}
@ -434,12 +434,12 @@ function configDialog(style) {
let {offsetWidth: width, offsetHeight: height} = contents;
contents.style = '';
const colorpicker = document.body.appendChild(
const elPicker = document.body.appendChild(
$create('.colorpicker-popup', {style: 'display: none!important'}));
const PADDING = 50;
const MIN_WIDTH = parseFloat(getComputedStyle(colorpicker).width) || 350;
const MIN_WIDTH = parseFloat(getComputedStyle(elPicker).width) || 350;
const MIN_HEIGHT = 250 + PADDING;
colorpicker.remove();
elPicker.remove();
width = constrain(MIN_WIDTH, 798, width + PADDING);
height = constrain(MIN_HEIGHT, 598, height + PADDING);

View File

@ -1,13 +1,17 @@
/* global
$
$create
animateElement
focusAccessibility
moveFocus
t
*/
/* global $ $create animateElement focusAccessibility moveFocus */// dom.js
/* global t */// localization.js
'use strict';
// TODO: convert this singleton mess so we can show many boxes at once
/* global messageBox */
window.messageBox = {
element: null,
listeners: null,
_blockScroll: null,
_originalFocus: null,
_resolve: null,
};
/**
* @param {Object} params
* @param {String} params.title
@ -26,22 +30,25 @@
* resolves to an object with optionally present properties depending on the interaction:
* {button: Number, enter: Boolean, esc: Boolean}
*/
function messageBox({
messageBox.show = async ({
title,
contents,
className = '',
buttons = [],
onshow,
blockScroll,
}) {
initOwnListeners();
}) => {
await require(['/js/dlg/message-box.css']);
if (!messageBox.listeners) initOwnListeners();
bindGlobalListeners();
createElement();
document.body.appendChild(messageBox.element);
messageBox.originalFocus = document.activeElement;
// skip external links like feedback
while ((moveFocus(messageBox.element, 1) || {}).target === '_blank') {/*NOP*/}
messageBox._originalFocus = document.activeElement;
// focus the first focusable child but skip the first external link which is usually `feedback`
if ((moveFocus(messageBox.element, 0) || {}).target === '_blank') {
moveFocus(messageBox.element, 1);
}
// suppress focus outline when invoked via click
if (focusAccessibility.lastFocusedViaClick && document.activeElement) {
document.activeElement.dataset.focusedViaClick = '';
@ -56,12 +63,12 @@ function messageBox({
$('#message-box-close-icon').hidden = true;
}
return new Promise(_resolve => {
messageBox.resolve = _resolve;
return new Promise(resolve => {
messageBox._resolve = resolve;
});
function initOwnListeners() {
messageBox.listeners = messageBox.listeners || {
messageBox.listeners = {
closeIcon() {
resolveWith({button: -1});
},
@ -93,18 +100,18 @@ function messageBox({
resolveWith(key === 'Enter' ? {enter: true} : {esc: true});
},
scroll() {
scrollTo(blockScroll.x, blockScroll.y);
scrollTo(messageBox._blockScroll.x, messageBox._blockScroll.y);
},
};
}
function resolveWith(value) {
setTimeout(messageBox._resolve, 0, value);
unbindGlobalListeners();
setTimeout(messageBox.resolve, 0, value);
animateElement(messageBox.element, 'fadeout')
.then(removeSelf);
if (messageBox.element.contains(document.activeElement)) {
messageBox.originalFocus.focus();
messageBox._originalFocus.focus();
}
}
@ -137,33 +144,33 @@ function messageBox({
}
function bindGlobalListeners() {
blockScroll = blockScroll && {x: scrollX, y: scrollY};
messageBox._blockScroll = blockScroll && {x: scrollX, y: scrollY};
if (blockScroll) {
window.addEventListener('scroll', messageBox.listeners.scroll);
window.on('scroll', messageBox.listeners.scroll, {passive: false});
}
window.addEventListener('keydown', messageBox.listeners.key, true);
window.on('keydown', messageBox.listeners.key, true);
}
function unbindGlobalListeners() {
window.removeEventListener('keydown', messageBox.listeners.key, true);
window.removeEventListener('scroll', messageBox.listeners.scroll);
window.off('keydown', messageBox.listeners.key, true);
window.off('scroll', messageBox.listeners.scroll);
}
function removeSelf() {
messageBox.element.remove();
messageBox.element = null;
messageBox.resolve = null;
}
messageBox._resolve = null;
}
};
/**
* @param {String|Node|Array<String|Node>} contents
* @param {String} [className] like 'pre' for monospace font
* @param {String} [title]
* @returns {Promise<Boolean>} same as messageBox
* @returns {Promise<Boolean>} same as show()
*/
messageBox.alert = (contents, className, title) =>
messageBox({
messageBox.show({
title,
contents,
className: `center ${className || ''}`,
@ -176,10 +183,12 @@ messageBox.alert = (contents, className, title) =>
* @param {String} [title]
* @returns {Promise<Boolean>} resolves to true when confirmed
*/
messageBox.confirm = (contents, className, title) =>
messageBox({
messageBox.confirm = async (contents, className, title) => {
const res = await messageBox.show({
title,
contents,
className: `center ${className || ''}`,
buttons: [t('confirmYes'), t('confirmNo')],
}).then(result => result.button === 0 || result.enter);
});
return res.button === 0 || res.enter;
};

806
js/dom.js
View File

@ -1,138 +1,200 @@
/* global debounce */// toolbox.js
/* global prefs */
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
setupLivePrefs moveFocus */
'use strict';
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
/* exported
$$remove
$createLink
$isTextInput
animateElement
getEventKeyName
messageBoxProxy
moveFocus
scrollElementIntoView
setupLivePrefs
*/
Object.assign(EventTarget.prototype, {
on: addEventListener,
off: removeEventListener,
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
onOff(enable, ...args) {
(enable ? addEventListener : removeEventListener).apply(this, args);
});
//#region Exports
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
const focusAccessibility = {
// last event's focusedViaClick
lastFocusedViaClick: false,
// to avoid a full layout recalc due to changes on body/root
// we modify the closest focusable element (like input or button or anything with tabindex=0)
closest(el) {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
el = el.control;
labelSeen = true;
}
if (el.tabIndex >= 0) return el;
}
},
};
/**
* Autoloads message-box.js
* @alias messageBox
*/
window.messageBoxProxy = new Proxy({}, {
get(_, name) {
return async (...args) => {
await require([
'/js/dlg/message-box', /* global messageBox */
'/js/dlg/message-box.css',
]);
window.messageBoxProxy = messageBox;
return messageBox[name](...args);
};
},
});
$.isTextInput = (el = {}) =>
el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
function $(selector, base = document) {
// we have ids with . like #manage.onlyEnabled which looks like #id.class
// so since getElementById is superfast we'll try it anyway
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
return byId || base.querySelector(selector);
}
$.remove = (selector, base = document) => {
function $$(selector, base = document) {
return [...base.querySelectorAll(selector)];
}
function $isTextInput(el = {}) {
return el.localName === 'textarea' ||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
}
function $remove(selector, base = document) {
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
if (el) {
el.remove();
}
};
}
$$.remove = (selector, base = document) => {
function $$remove(selector, base = document) {
for (const el of base.querySelectorAll(selector)) {
el.remove();
}
}
/*
$create('tag#id.class.class', ?[children])
$create('tag#id.class.class', ?textContentOrChildNode)
$create('tag#id.class.class', {properties}, ?[children])
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
tag is 'div' by default, #id and .class are optional
$create([children])
$create({propertiesAndOptions})
$create({propertiesAndOptions}, ?[children])
tag: string, default 'div'
appendChild: element/string or an array of elements/strings
dataset: object
any DOM property: assigned as is
tag may include namespace like 'ns:tag'
*/
function $create(selector = 'div', properties, children) {
let ns, tag, opt;
if (typeof selector === 'string') {
if (Array.isArray(properties) ||
properties instanceof Node ||
typeof properties !== 'object') {
opt = {};
children = properties;
} else {
opt = properties || {};
children = children || opt.appendChild;
}
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
const id = selector.slice(idStart + 1, classStart);
if (id) {
opt.id = id;
}
const cls = selector.slice(classStart + 1);
if (cls) {
opt[selector.includes(':') ? 'class' : 'className'] =
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
}
tag = selector.slice(0, Math.min(idStart, classStart));
} else if (Array.isArray(selector)) {
tag = 'div';
opt = {};
children = selector;
} else {
opt = selector;
tag = opt.tag;
children = opt.appendChild || properties;
}
if (tag && tag.includes(':')) {
[ns, tag] = tag.split(':');
if (ns === 'SVG' || ns === 'svg') {
ns = 'http://www.w3.org/2000/svg';
}
}
const element = ns ? document.createElementNS(ns, tag) :
tag === 'fragment' ? document.createDocumentFragment() :
document.createElement(tag || 'div');
for (const child of Array.isArray(children) ? children : [children]) {
if (child) {
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
}
}
for (const [key, val] of Object.entries(opt)) {
switch (key) {
case 'dataset':
Object.assign(element.dataset, val);
break;
case 'attributes':
Object.entries(val).forEach(attr => element.setAttribute(...attr));
break;
case 'style': {
const t = typeof val;
if (t === 'string') element.style.cssText = val;
if (t === 'object') Object.assign(element.style, val);
break;
}
case 'tag':
case 'appendChild':
break;
default: {
if (ns) {
const i = key.indexOf(':') + 1;
const attrNS = i && `http://www.w3.org/1999/${key.slice(0, i - 1)}`;
element.setAttributeNS(attrNS || null, key, val);
} else {
element[key] = val;
}
}
}
}
return element;
}
function $createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener',
};
{
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
const addTooltipsToEllipsized = () => {
for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) {
continue;
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
}
const width = btn.offsetWidth;
if (!width || btn.preresizeClientWidth === width) {
continue;
opt.appendChild = opt.appendChild || content;
return $create(opt);
}
btn.preresizeClientWidth = width;
if (btn.scrollWidth > width) {
const text = btn.textContent;
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
btn.titleIsForEllipsis = true;
} else if (btn.title) {
btn.title = '';
}
}
};
// enqueue after DOMContentLoaded/load events
setTimeout(addTooltipsToEllipsized, 500);
// throttle on continuous resizing
let timer;
window.on('resize', () => {
clearTimeout(timer);
timer = setTimeout(addTooltipsToEllipsized, 100);
});
}
onDOMready().then(() => {
$.remove('#firefox-transitions-bug-suppressor');
initCollapsibles();
focusAccessibility();
if (!chrome.app && chrome.windows && typeof prefs !== 'undefined') {
// add favicon in Firefox
prefs.initializing.then(() => {
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
for (const size of [38, 32, 19, 16]) {
document.head.appendChild($create('link', {
rel: 'icon',
href: `/images/icon/${iconset}${size}.png`,
sizes: size + 'x' + size,
}));
}
});
}
});
// set language for CSS :lang and [FF-only] hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
// avoid adding # to the page URL when clicking dummy links
document.on('click', e => {
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
});
// update inputs on mousewheel when focused
document.on('wheel', event => {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}, {
capture: true,
passive: false,
});
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
}
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return;
const {top, height} = element.getBoundingClientRect();
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
window.scrollBy(0, top - windowHeight / 2 + height);
}
}
/**
* @param {HTMLElement} el
@ -162,234 +224,19 @@ function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
});
}
function enforceInputRange(element) {
const min = Number(element.min);
const max = Number(element.max);
const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true}));
const onChange = ({type}) => {
if (type === 'input' && element.checkValidity()) {
doNotify();
} else if (type === 'change' && !element.checkValidity()) {
element.value = Math.max(min, Math.min(max, Number(element.value)));
doNotify();
}
};
element.on('change', onChange);
element.on('input', onChange);
}
function $(selector, base = document) {
// we have ids with . like #manage.onlyEnabled which looks like #id.class
// so since getElementById is superfast we'll try it anyway
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
return byId || base.querySelector(selector);
}
function $$(selector, base = document) {
return [...base.querySelectorAll(selector)];
}
function $create(selector = 'div', properties, children) {
/*
$create('tag#id.class.class', ?[children])
$create('tag#id.class.class', ?textContentOrChildNode)
$create('tag#id.class.class', {properties}, ?[children])
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
tag is 'div' by default, #id and .class are optional
$create([children])
$create({propertiesAndOptions})
$create({propertiesAndOptions}, ?[children])
tag: string, default 'div'
appendChild: element/string or an array of elements/strings
dataset: object
any DOM property: assigned as is
tag may include namespace like 'ns:tag'
*/
let ns, tag, opt;
if (typeof selector === 'string') {
if (Array.isArray(properties) ||
properties instanceof Node ||
typeof properties !== 'object') {
opt = {};
children = properties;
} else {
opt = properties || {};
children = children || opt.appendChild;
}
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
const id = selector.slice(idStart + 1, classStart);
if (id) {
opt.id = id;
}
const cls = selector.slice(classStart + 1);
if (cls) {
opt[selector.includes(':') ? 'class' : 'className'] =
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
}
tag = selector.slice(0, Math.min(idStart, classStart));
} else if (Array.isArray(selector)) {
tag = 'div';
opt = {};
children = selector;
} else {
opt = selector;
tag = opt.tag;
delete opt.tag;
children = opt.appendChild || properties;
delete opt.appendChild;
}
if (tag && tag.includes(':')) {
([ns, tag] = tag.split(':'));
}
const element = ns
? document.createElementNS(ns === 'SVG' || ns === 'svg' ? 'http://www.w3.org/2000/svg' : ns, tag)
: tag === 'fragment'
? document.createDocumentFragment()
: document.createElement(tag || 'div');
for (const child of Array.isArray(children) ? children : [children]) {
if (child) {
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
}
}
if (opt.dataset) {
Object.assign(element.dataset, opt.dataset);
delete opt.dataset;
}
if (opt.attributes) {
for (const attr in opt.attributes) {
element.setAttribute(attr, opt.attributes[attr]);
}
delete opt.attributes;
}
if (opt.style) {
if (typeof opt.style === 'string') element.style.cssText = opt.style;
if (typeof opt.style === 'object') Object.assign(element.style, opt.style);
delete opt.style;
}
if (ns) {
for (const attr in opt) {
const i = attr.indexOf(':') + 1;
const attrNS = i && `http://www.w3.org/1999/${attr.slice(0, i - 1)}`;
element.setAttributeNS(attrNS || null, attr, opt[attr]);
}
} else {
Object.assign(element, opt);
}
return element;
}
function $createLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener',
};
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
}
opt.appendChild = opt.appendChild || content;
return $create(opt);
}
// makes <details> with [data-pref] save/restore their state
function initCollapsibles({bindClickOn = 'h2'} = {}) {
const prefMap = {};
const elements = $$('details[data-pref]');
if (!elements.length) {
return;
}
for (const el of elements) {
const key = el.dataset.pref;
prefMap[key] = el;
el.open = prefs.get(key);
(bindClickOn && $(bindClickOn, el) || el).on('click', onClick);
}
prefs.subscribe(Object.keys(prefMap), (key, value) => {
const el = prefMap[key];
if (el.open !== value) {
el.open = value;
}
});
function onClick(event) {
if (event.target.closest('.intercepts-click')) {
event.preventDefault();
} else {
setTimeout(saveState, 0, event.target.closest('details'));
}
}
function saveState(el) {
if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
prefs.set(el.dataset.pref, el.open);
}
}
}
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
function focusAccessibility() {
// last event's focusedViaClick
focusAccessibility.lastFocusedViaClick = false;
// to avoid a full layout recalc due to changes on body/root
// we modify the closest focusable element (like input or button or anything with tabindex=0)
focusAccessibility.closest = el => {
let labelSeen;
for (; el; el = el.parentElement) {
if (el.localName === 'label' && el.control && !labelSeen) {
el = el.control;
labelSeen = true;
}
if (el.tabIndex >= 0) return el;
}
};
// suppress outline on click
window.on('mousedown', ({target}) => {
const el = focusAccessibility.closest(target);
if (el) {
focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
}
}, {passive: true});
// keep outline on Tab or Shift-Tab key
window.on('keydown', event => {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
el = el.closest('[data-focused-via-click]');
if (el) delete el.dataset.focusedViaClick;
}
});
}
}, {passive: true});
function getEventKeyName(e, letterAsCode) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
(e.altKey ? 'Alt-' : '') +
(e.metaKey ? 'Meta-' : '');
return `${
mods === e.key + '-' ? '' : mods
}${
e.key
? e.key.length === 1 && letterAsCode ? e.code : e.key
: 'Mouse' + ('LMR'[e.button] || e.button)
}`;
}
/**
@ -417,74 +264,237 @@ function moveFocus(rootElement, step) {
}
}
// Accepts an array of pref names (values are fetched via prefs.get)
// and establishes a two-way connection between the document elements and the actual prefs
function setupLivePrefs(
IDs = Object.getOwnPropertyNames(prefs.defaults)
.filter(id => $('#' + id))
) {
for (const id of IDs) {
const element = $('#' + id);
updateElement({id, element, force: true});
element.on('change', onChange);
function onDOMready() {
return document.readyState !== 'loading'
? Promise.resolve()
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
}
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return;
const {top, height} = element.getBoundingClientRect();
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
window.scrollBy(0, top - windowHeight / 2 + height);
}
}
/**
* Accepts an array of pref names (values are fetched via prefs.get)
* and establishes a two-way connection between the document elements and the actual prefs
*/
function setupLivePrefs(ids = prefs.knownKeys.filter(id => $('#' + id))) {
let forceUpdate = true;
prefs.subscribe(ids, updateElement, {runNow: true});
forceUpdate = false;
ids.forEach(id => $('#' + id).on('change', onChange));
function onChange() {
const value = getInputValue(this);
if (prefs.get(this.id) !== value) {
prefs.set(this.id, value);
prefs.set(this.id, this[getPropName(this)]);
}
function getPropName(el) {
return el.type === 'checkbox' ? 'checked'
: el.type === 'number' ? 'valueAsNumber' :
'value';
}
function updateElement({
id,
value = prefs.get(id),
element = $('#' + id),
force,
}) {
if (!element) {
prefs.unsubscribe(IDs, updateElement);
return;
function updateElement(id, value) {
const el = $('#' + id);
if (el) {
const prop = getPropName(el);
if (el[prop] !== value || forceUpdate) {
el[prop] = value;
el.dispatchEvent(new Event('change', {bubbles: true}));
}
setInputValue(element, value, force);
}
function getInputValue(input) {
if (input.type === 'checkbox') {
return input.checked;
}
if (input.type === 'number') {
return Number(input.value);
}
return input.value;
}
function setInputValue(input, value, force = false) {
if (force || getInputValue(input) !== value) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
prefs.unsubscribe(ids, updateElement);
}
}
}
/* exported getEventKeyName */
/**
* @param {KeyboardEvent|MouseEvent} e
* @param {boolean} [letterAsCode] - use locale-independent KeyA..KeyZ for single-letter chars
* @param {string} selector - beware of $ quirks with `#dotted.id` that won't work with $$
* @param {Object} [opt]
* @param {function(HTMLElement, HTMLElement[]):boolean} [opt.recur] - called on each match
with (firstMatchingElement, allMatchingElements) parameters until stopOnDomReady,
you can also return `false` to disconnect the observer
* @param {boolean} [opt.stopOnDomReady] - stop observing on DOM ready
* @returns {Promise<HTMLElement>} - resolves on first match
*/
function getEventKeyName(e, letterAsCode) {
const mods =
(e.shiftKey ? 'Shift-' : '') +
(e.ctrlKey ? 'Ctrl-' : '') +
(e.altKey ? 'Alt-' : '') +
(e.metaKey ? 'Meta-' : '');
return `${
mods === e.key + '-' ? '' : mods
}${
e.key
? e.key.length === 1 && letterAsCode ? e.code : e.key
: 'LMR'[e.button]
}`;
function waitForSelector(selector, {recur, stopOnDomReady = true} = {}) {
let el = $(selector);
let elems, isResolved;
return el && (!recur || recur(el, (elems = $$(selector))) === false)
? Promise.resolve(el)
: new Promise(resolve => {
const mo = new MutationObserver(() => {
if (!el) el = $(selector);
if (!el) return;
if (!recur ||
callRecur() === false ||
stopOnDomReady && document.readyState === 'complete') {
mo.disconnect();
}
if (!isResolved) {
isResolved = true;
resolve(el);
}
});
mo.observe(document, {childList: true, subtree: true});
});
function callRecur() {
const all = $$(selector); // simpler and faster than analyzing each node in `mutations`
const added = !elems ? all : all.filter(el => !elems.includes(el));
if (added.length) {
elems = all;
return recur(added[0], added);
}
}
}
//#endregion
//#region Internals
(() => {
const Collapsible = {
bindEvents(_, elems) {
const prefKeys = [];
for (const el of elems) {
prefKeys.push(el.dataset.pref);
($('h2', el) || el).on('click', Collapsible.saveOnClick);
}
prefs.subscribe(prefKeys, Collapsible.updateOnPrefChange, {runNow: true});
},
canSave(el) {
return !el.matches('.compact-layout .ignore-pref-if-compact');
},
async saveOnClick(event) {
if (event.target.closest('.intercepts-click')) {
event.preventDefault();
} else {
const el = event.target.closest('details');
await new Promise(setTimeout);
if (Collapsible.canSave(el)) {
prefs.set(el.dataset.pref, el.open);
}
}
},
updateOnPrefChange(key, value) {
const el = $(`details[data-pref="${key}"]`);
if (el.open !== value && Collapsible.canSave(el)) {
el.open = value;
}
},
};
window.on('mousedown', suppressFocusRingOnClick, {passive: true});
window.on('keydown', keepFocusRingOnTabbing, {passive: true});
if (!/^Win\d+/.test(navigator.platform)) {
document.documentElement.classList.add('non-windows');
}
// set language for a) CSS :lang pseudo and b) hyphenation
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
document.on('click', keepAddressOnDummyClick);
document.on('wheel', changeFocusedInputOnWheel, {capture: true, passive: false});
Promise.resolve().then(async () => {
if (!chrome.app) addFaviconFF();
await prefs.ready;
waitForSelector('details[data-pref]', {recur: Collapsible.bindEvents});
});
onDOMready().then(() => {
$remove('#firefox-transitions-bug-suppressor');
debounce(addTooltipsToEllipsized, 500);
window.on('resize', () => debounce(addTooltipsToEllipsized, 100));
});
function addFaviconFF() {
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
for (const size of [38, 32, 19, 16]) {
document.head.appendChild($create('link', {
rel: 'icon',
href: `/images/icon/${iconset}${size}.png`,
sizes: size + 'x' + size,
}));
}
}
function changeFocusedInputOnWheel(event) {
const el = document.activeElement;
if (!el || el !== event.target && !el.contains(event.target)) {
return;
}
const isSelect = el.tagName === 'SELECT';
if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
}
event.stopImmediatePropagation();
}
/** Displays a full text tooltip on buttons with ellipsis overflow and no inherent title */
function addTooltipsToEllipsized() {
for (const btn of document.getElementsByTagName('button')) {
if (btn.title && !btn.titleIsForEllipsis) {
continue;
}
const width = btn.offsetWidth;
if (!width || btn.preresizeClientWidth === width) {
continue;
}
btn.preresizeClientWidth = width;
if (btn.scrollWidth > width) {
const text = btn.textContent;
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
btn.titleIsForEllipsis = true;
} else if (btn.title) {
btn.title = '';
}
}
}
function keepAddressOnDummyClick(e) {
// avoid adding # to the page URL when clicking dummy links
if (e.target.closest('a[href="#"]')) {
e.preventDefault();
}
}
function keepFocusRingOnTabbing(event) {
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
focusAccessibility.lastFocusedViaClick = false;
setTimeout(() => {
let el = document.activeElement;
if (el) {
el = el.closest('[data-focused-via-click]');
if (el) delete el.dataset.focusedViaClick;
}
});
}
}
function suppressFocusRingOnClick({target}) {
const el = focusAccessibility.closest(target);
if (el) {
focusAccessibility.lastFocusedViaClick = true;
if (el.dataset.focusedViaClick === undefined) {
el.dataset.focusedViaClick = '';
}
}
}
})();
//#endregion

View File

@ -1,15 +1,17 @@
'use strict';
function t(key, params) {
//#region Exports
function t(key, params, strict = true) {
const s = chrome.i18n.getMessage(key, params);
if (!s) throw `Missing string "${key}"`;
if (!s && strict) throw `Missing string "${key}"`;
return s;
}
Object.assign(t, {
template: {},
DOMParser: new DOMParser(),
ALLOWED_TAGS: 'a,b,code,i,sub,sup,wbr'.split(','),
parser: new DOMParser(),
ALLOWED_TAGS: ['a', 'b', 'code', 'i', 'sub', 'sup', 'wbr'],
RX_WORD_BREAK: new RegExp([
'(',
/[\d\w\u007B-\uFFFF]{10}/,
@ -103,7 +105,7 @@ Object.assign(t, {
},
createHtml(str, trusted) {
const root = t.DOMParser.parseFromString(str, 'text/html').body;
const root = t.parser.parseFromString(str, 'text/html').body;
if (!trusted) {
t.sanitizeHtml(root);
} else if (str.includes('i18n-')) {
@ -156,6 +158,9 @@ Object.assign(t, {
},
});
//#endregion
//#region Internals
(() => {
const observer = new MutationObserver(process);
let observing = false;
@ -187,3 +192,5 @@ Object.assign(t, {
}
}
})();
//#endregion

View File

@ -1,8 +1,8 @@
/* global usercssMeta colorConverter */
/* exported metaParser */
'use strict';
/* exported metaParser */
const metaParser = (() => {
require(['/vendor/usercss-meta/usercss-meta.min']); /* global usercssMeta */
const {createParser, ParseError} = usercssMeta;
const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);
const options = {
@ -27,6 +27,7 @@ const metaParser = (() => {
}
},
color: state => {
require(['/js/color/color-converter']); /* global colorConverter */
const color = colorConverter.parse(state.value);
if (!color) {
throw new ParseError({
@ -40,39 +41,27 @@ const metaParser = (() => {
},
};
const parser = createParser(options);
const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'}));
const looseParser = createParser(Object.assign({}, options, {
allowErrors: true,
unknownKey: 'throw',
}));
return {
parse,
lint,
nullifyInvalidVars,
};
function parse(text, indexOffset) {
try {
return parser.parse(text);
} catch (err) {
if (typeof err.index === 'number') {
err.index += indexOffset;
}
throw err;
}
}
lint: looseParser.parse,
parse: parser.parse,
function lint(text) {
return looseParser.parse(text);
}
function nullifyInvalidVars(vars) {
nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
if (va.value === null) {
continue;
}
if (va.value !== null) {
try {
parser.validateVar(va);
} catch (err) {
va.value = null;
}
}
return vars;
}
return vars;
},
};
})();

View File

@ -1,24 +1,29 @@
/* global parserlib */
/* exported parseMozFormat */
'use strict';
require([
'/js/csslint/parserlib', /* global parserlib */
'/js/sections-util', /* global MozDocMapper */
]);
/* exported extractSections */
/**
* Extracts @-moz-document blocks into sections and the code between them into global sections.
* Puts the global comments into the following section to minimize the amount of global sections.
* Doesn't move the comment with ==UserStyle== inside.
* @param {string} code
* @param {number} styleId - used to preserve parserCache on subsequent runs over the same style
* @param {Object} _
* @param {string} _.code
* @param {boolean} [_.fast] - uses topDocOnly option to extract sections as text
* @param {number} [_.styleId] - used to preserve parserCache on subsequent runs over the same style
* @returns {{sections: Array, errors: Array}}
* @property {?number} lastStyleId
*/
function parseMozFormat({code, styleId}) {
const CssToProperty = {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
};
function extractSections({code, styleId, fast = true}) {
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/;
const parser = new parserlib.css.Parser({starHack: true, skipValidation: true});
const parser = new parserlib.css.Parser({
starHack: true,
skipValidation: true,
topDocOnly: fast,
});
const sectionStack = [{code: '', start: 0}];
const errors = [];
const sections = [];
@ -34,7 +39,6 @@ function parseMozFormat({code, styleId}) {
};
// move last comment before @-moz-document inside the section
if (!lastCmt.includes('AGENT_SHEET') &&
!lastCmt.includes('==') &&
!/==userstyle==/i.test(lastCmt)) {
if (lastCmt) {
section.code = lastCmt + '\n';
@ -48,12 +52,12 @@ function parseMozFormat({code, styleId}) {
lastSection.code = '';
}
for (const {name, expr, uri} of e.functions) {
const aType = CssToProperty[name.toLowerCase()];
const aType = MozDocMapper.FROM_CSS[name.toLowerCase()];
const p0 = expr && expr.parts[0];
if (p0 && aType === 'regexps') {
const s = p0.text;
if (hasSingleEscapes.test(p0.text)) {
const isQuoted = (s.startsWith('"') || s.startsWith("'")) && s.endsWith(s[0]);
const isQuoted = /^['"]/.test(s) && s.endsWith(s[0]);
p0.value = isQuoted ? s.slice(1, -1) : s;
}
}
@ -83,7 +87,7 @@ function parseMozFormat({code, styleId}) {
try {
parser.parse(mozStyle, {
reuseCache: !parseMozFormat.styleId || styleId === parseMozFormat.styleId,
reuseCache: !extractSections.lastStyleId || styleId === extractSections.lastStyleId,
});
} catch (e) {
errors.push(e);
@ -94,7 +98,8 @@ function parseMozFormat({code, styleId}) {
}
err.message = `${err.line}:${err.col} ${err.message}`;
}
parseMozFormat.styleId = styleId;
extractSections.lastStyleId = styleId;
return {sections, errors};
function doAddSection(section) {

116
js/msg.js
View File

@ -1,8 +1,9 @@
/* global deepCopy getOwnTab URLS */ // not used in content scripts
/* global URLS deepCopy deepMerge getOwnTab */// toolbox.js - not used in content scripts
'use strict';
// eslint-disable-next-line no-unused-expressions
window.INJECTED !== 1 && (() => {
(() => {
if (window.INJECTED === 1) return;
const TARGETS = Object.assign(Object.create(null), {
all: ['both', 'tab', 'extension'],
extension: ['both', 'extension'],
@ -21,38 +22,12 @@ window.INJECTED !== 1 && (() => {
extension: new Set(),
};
let bg = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage();
const isBg = bg === window;
if (!isBg && (!bg || !bg.document || bg.document.readyState === 'loading')) {
bg = null;
}
// TODO: maybe move into polyfill.js and hook addListener to wrap/unwrap automatically
chrome.runtime.onMessage.addListener(onRuntimeMessage);
// TODO: maybe move into polyfill.js and hook addListener + sendMessage so they wrap/unwrap automatically
const wrapData = data => ({
data,
});
const wrapError = error => ({
error: Object.assign({
message: error.message || `${error}`,
stack: error.stack,
}, error), // passing custom properties e.g. `error.index`
});
const unwrapResponse = ({data, error} = {error: {message: ERR_NO_RECEIVER}}) =>
error
? Promise.reject(Object.assign(new Error(error.message), error))
: data;
chrome.runtime.onMessage.addListener(({data, target}, sender, sendResponse) => {
const res = window.msg._execute(TARGETS[target] || TARGETS.all, data, sender);
if (res instanceof Promise) {
res.then(wrapData, wrapError).then(sendResponse);
return true;
}
if (res !== undefined) sendResponse(wrapData(res));
});
// This direct assignment allows IDEs to provide autocomplete for msg methods automatically
const msg = window.msg = {
isBg,
isBg: getExtBg() === window,
async broadcast(data) {
const requests = [msg.send(data, 'both').catch(msg.ignoreError)];
@ -73,8 +48,8 @@ window.INJECTED !== 1 && (() => {
},
isIgnorableError(err) {
const msg = `${err && err.message || err}`;
return msg.includes(ERR_NO_RECEIVER) || msg.includes(ERR_PORT_CLOSED);
const text = `${err && err.message || err}`;
return text.includes(ERR_NO_RECEIVER) || text.includes(ERR_PORT_CLOSED);
},
ignoreError(err) {
@ -113,6 +88,11 @@ window.INJECTED !== 1 && (() => {
_execute(types, ...args) {
let result;
if (!(args[0] instanceof Object)) {
/* Data from other windows must be deep-copied to allow for GC in Chrome and
merely survive in FF as it kills cross-window objects when their tab is closed. */
args = args.map(deepCopy);
}
for (const type of types) {
for (const fn of handler[type]) {
let res;
@ -130,30 +110,64 @@ window.INJECTED !== 1 && (() => {
},
};
const apiHandler = !isBg && {
get({PATH}, name) {
function getExtBg() {
const fn = chrome.extension.getBackgroundPage;
const bg = fn && fn();
return bg === window || bg && bg.msg && bg.msg.isBgReady ? bg : null;
}
function onRuntimeMessage({data, target}, sender, sendResponse) {
const res = msg._execute(TARGETS[target] || TARGETS.all, data, sender);
if (res instanceof Promise) {
res.then(wrapData, wrapError).then(sendResponse);
return true;
}
if (res !== undefined) sendResponse(wrapData(res));
}
function wrapData(data) {
return {data};
}
function wrapError(error) {
return {
error: Object.assign({
message: error.message || `${error}`,
stack: error.stack,
}, error), // passing custom properties e.g. `error.index`
};
}
function unwrapResponse({data, error} = {error: {message: ERR_NO_RECEIVER}}) {
return error
? Promise.reject(Object.assign(new Error(error.message), error))
: data;
}
const apiHandler = !msg.isBg && {
get({path}, name) {
const fn = () => {};
fn.PATH = [...PATH, name];
fn.path = [...path, name];
return new Proxy(fn, apiHandler);
},
async apply({PATH: path}, thisObj, args) {
if (!bg && chrome.tabs) {
bg = await browser.runtime.getBackgroundPage().catch(() => {});
}
async apply({path}, thisObj, args) {
const bg = getExtBg() ||
chrome.tabs && await browser.runtime.getBackgroundPage().catch(() => {});
const message = {method: 'invokeAPI', path, args};
// content scripts and probably private tabs
let res;
// content scripts, probably private tabs, and our extension tab during Chrome startup
if (!bg) {
return msg.send(message);
}
// in FF, the object would become a dead object when the window
// is closed, so we have to clone the object into background.
const res = bg.msg._execute(TARGETS.extension, bg.deepCopy(message), {
res = msg.send(message);
} else {
res = deepMerge(await bg.msg._execute(TARGETS.extension, message, {
frameId: 0, // false in case of our Options frame but we really want to fetch styles early
tab: NEEDS_TAB_IN_SENDER.includes(path.join('.')) && await getOwnTab(),
url: location.href,
});
return deepCopy(await res);
}));
}
return res;
},
};
window.API = isBg ? {} : new Proxy({PATH: []}, apiHandler);
/** @type {API} */
window.API = msg.isBg ? {} : new Proxy({path: []}, apiHandler);
})();

View File

@ -1,7 +1,10 @@
'use strict';
// eslint-disable-next-line no-unused-expressions
self.INJECTED !== 1 && (() => {
(() => {
/* Chrome reinjects content script when documentElement is replaced so we ignore it
by checking against a literal `1`, not just `if (truthy)`, because <html id="INJECTED">
is exposed per HTML spec as a global `window.INJECTED` */
if (window.INJECTED === 1) return;
//#region for content scripts and our extension pages
@ -66,6 +69,35 @@ self.INJECTED !== 1 && (() => {
//#region for our extension pages
window.require = async function require(urls, cb) {
const promises = [];
const all = [];
const toLoad = [];
for (let url of Array.isArray(urls) ? urls : [urls]) {
const isCss = url.endsWith('.css');
const tag = isCss ? 'link' : 'script';
const attr = isCss ? 'href' : 'src';
if (!isCss && !url.endsWith('.js')) url += '.js';
if (url.startsWith('/')) url = url.slice(1);
let el = document.head.querySelector(`${tag}[${attr}$="${url}"]`);
if (!el) {
el = document.createElement(tag);
toLoad.push(el);
promises.push(new Promise((resolve, reject) => {
el.onload = resolve;
el.onerror = reject;
el[attr] = url;
if (isCss) el.rel = 'stylesheet';
}).catch(console.warn));
}
all.push(el);
}
if (toLoad.length) document.head.append(...toLoad);
if (promises.length) await Promise.all(promises);
if (cb) cb(...all);
return all[0];
};
if (!(new URLSearchParams({foo: 1})).get('foo')) {
// TODO: remove when minimum_chrome_version >= 61
window.URLSearchParams = class extends URLSearchParams {

View File

@ -1,12 +1,22 @@
/* global msg API */
/* global deepCopy debounce */ // not used in content scripts
/* global API msg */// msg.js
/* global debounce deepMerge */// toolbox.js - not used in content scripts
'use strict';
// eslint-disable-next-line no-unused-expressions
window.INJECTED !== 1 && (() => {
(() => {
if (window.INJECTED === 1) return;
const STORAGE_KEY = 'settings';
const clone = msg.isBg ? deepCopy : (val => JSON.parse(JSON.stringify(val)));
const defaults = /** @namespace Prefs */{
const clone = typeof deepMerge === 'function'
? deepMerge
: val =>
typeof val === 'object' && val
? JSON.parse(JSON.stringify(val))
: val;
/**
* @type PrefsValues
* @namespace PrefsValues
*/
const defaults = {
'openEditInWindow': false, // new editor opens in a own browser window
'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
'windowPosition': {}, // detached window position
@ -112,34 +122,53 @@ window.INJECTED !== 1 && (() => {
'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
};
const knownKeys = Object.keys(defaults);
/** @type {PrefsValues} */
const values = clone(defaults);
const onChange = {
any: new Set(),
specific: {},
};
// getPrefs may fail on browser startup in the active tab as it loads before the background script
const initializing = (msg.isBg ? readStorage() : API.getPrefs().catch(readStorage))
.then(setAll);
// API fails in the active tab during Chrome startup as it loads the tab before bg
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = (msg.isBg ? readStorage() : API.prefs.getValues().catch(readStorage))
.then(data => {
setAll(data);
ready = true;
});
chrome.storage.onChanged.addListener(async (changes, area) => {
const data = area === 'sync' && changes[STORAGE_KEY];
if (data) {
await initializing;
if (ready.then) await ready;
setAll(data.newValue);
}
});
// This direct assignment allows IDEs to provide correct autocomplete for methods
const prefs = window.prefs = {
STORAGE_KEY,
initializing,
defaults,
knownKeys,
ready,
/** @type {PrefsValues} */
defaults: new Proxy({}, {
get: (_, key) => clone(defaults[key]),
}),
/** @type {PrefsValues} */
get values() {
return deepCopy(values);
return clone(values);
},
__defaults: defaults, // direct reference, be careful!
__values: values, // direct reference, be careful!
get(key) {
return isKnown(key) && values[key];
const res = values[key];
if (res !== undefined || isKnown(key)) {
return clone(res);
}
},
set(key, val, isSynced) {
if (!isKnown(key)) return;
const oldValue = values[key];
@ -155,36 +184,45 @@ window.INJECTED !== 1 && (() => {
emitChange(key, val, isSynced);
}
},
reset(key) {
prefs.set(key, clone(defaults[key]));
},
/**
* @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
* @param {function(key:string, value:any)} fn
* @param {function(key:string?, value:any?)} fn
* @param {Object} [opts]
* @param {boolean} [opts.now] - when truthy, the listener is called immediately:
* @param {boolean} [opts.runNow] - when truthy, the listener is called immediately:
* 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
* 2) if `keys` is falsy, no key/value will be provided
*/
subscribe(keys, fn, {now} = {}) {
async subscribe(keys, fn, {runNow} = {}) {
const toRun = [];
if (keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) {
if (!isKnown(key)) continue;
const listeners = onChange.specific[key] ||
(onChange.specific[key] = new Set());
listeners.add(fn);
if (now) fn(key, values[key]);
if (runNow) toRun.push({fn, key});
}
} else {
onChange.any.add(fn);
if (now) fn();
if (runNow) toRun.push({fn});
}
if (toRun.length) {
if (ready.then) await ready;
toRun.forEach(({fn, key}) => fn(key, values[key]));
}
},
subscribeMany(data, opts) {
for (const [k, fn] of Object.entries(data)) {
prefs.subscribe(k, fn, opts);
}
},
unsubscribe(keys, fn) {
if (keys) {
for (const key of keys) {
@ -203,7 +241,7 @@ window.INJECTED !== 1 && (() => {
};
function isKnown(key) {
const res = defaults.hasOwnProperty(key);
const res = knownKeys.includes(key);
if (!res) console.warn('Unknown preference "%s"', key);
return res;
}
@ -228,14 +266,13 @@ window.INJECTED !== 1 && (() => {
if (msg.isBg) {
debounce(updateStorage);
} else {
API.setPref(key, value);
API.prefs.set(key, value);
}
}
}
function readStorage() {
return browser.storage.sync.get(STORAGE_KEY)
.then(data => data[STORAGE_KEY]);
async function readStorage() {
return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
}
function updateStorage() {

View File

@ -1,66 +1,17 @@
/* global deepEqual msg */
/* exported router */
/* global deepEqual */// toolbox.js
/* global msg */
'use strict';
const router = (() => {
const buffer = [];
const watchers = [];
document.addEventListener('DOMContentLoaded', () => update());
window.addEventListener('popstate', () => update());
window.addEventListener('hashchange', () => update());
msg.on(e => {
if (e.method === 'pushState' && e.url !== location.href) {
history.pushState(history.state, null, e.url);
update();
return true;
}
});
return {watch, updateSearch, getSearch, updateHash};
const router = {
buffer: [],
watchers: [],
function watch(options, callback) {
/* Watch search params or hash and get notified on change.
options: {search?: Array<key: String>, hash?: String}
callback: (Array<value: String | null> | Boolean) => void
`hash` should always start with '#'.
When watching search params, callback receives a list of values.
When watching hash, callback receives a boolean.
*/
watchers.push({options, callback});
}
function updateSearch(key, value) {
const u = new URL(location);
u.searchParams[value ? 'set' : 'delete'](key, value);
history.replaceState(history.state, null, `${u}`);
update(true);
}
function updateHash(hash) {
/* hash: String
Send an empty string to remove the hash.
*/
if (buffer.length > 1) {
if (!hash && !buffer[buffer.length - 2].includes('#') ||
hash && buffer[buffer.length - 2].endsWith(hash)) {
history.back();
return;
}
}
if (!hash) {
hash = ' ';
}
history.pushState(history.state, null, hash);
update();
}
function getSearch(key) {
getSearch(key) {
return new URLSearchParams(location.search).get(key);
}
},
function update(replace) {
update(replace) {
const {buffer} = router;
if (!buffer.length) {
buffer.push(location.href);
} else if (buffer[buffer.length - 1] === location.href) {
@ -72,7 +23,7 @@ const router = (() => {
} else {
buffer.push(location.href);
}
for (const {options, callback} of watchers) {
for (const {options, callback} of router.watchers) {
let state;
if (options.hash) {
state = options.hash === location.hash;
@ -85,5 +36,55 @@ const router = (() => {
callback(state);
}
}
},
/**
* @param {string} hash - empty string removes the hash
*/
updateHash(hash) {
const {buffer} = router;
if (buffer.length > 1) {
if (!hash && !buffer[buffer.length - 2].includes('#') ||
hash && buffer[buffer.length - 2].endsWith(hash)) {
history.back();
return;
}
})();
}
if (!hash) {
hash = ' ';
}
history.pushState(history.state, null, hash);
router.update();
},
updateSearch(key, value) {
const u = new URL(location);
u.searchParams[value ? 'set' : 'delete'](key, value);
history.replaceState(history.state, null, `${u}`);
router.update(true);
},
watch(options, callback) {
/* Watch search params or hash and get notified on change.
options: {search?: Array<key: String>, hash?: String}
callback: (Array<value: String | null> | Boolean) => void
`hash` should always start with '#'.
When watching search params, callback receives a list of values.
When watching hash, callback receives a boolean.
*/
router.watchers.push({options, callback});
},
};
document.on('DOMContentLoaded', () => router.update());
window.on('popstate', () => router.update());
window.on('hashchange', () => router.update());
msg.on(e => {
if (e.method === 'pushState' && e.url !== location.href) {
history.pushState(history.state, null, e.url);
router.update();
return true;
}
});

View File

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

View File

@ -1,11 +1,77 @@
/* exported styleSectionsEqual styleCodeEmpty styleSectionGlobal calcStyleDigest styleJSONseemsValid */
'use strict';
/* exported
calcStyleDigest
MozDocMapper
styleCodeEmpty
styleJSONseemsValid
styleSectionGlobal
styleSectionsEqual
*/
const MozDocMapper = {
TO_CSS: {
urls: 'url',
urlPrefixes: 'url-prefix',
domains: 'domain',
regexps: 'regexp',
},
FROM_CSS: {
'url': 'urls',
'url-prefix': 'urlPrefixes',
'domain': 'domains',
'regexp': 'regexps',
},
/**
* @param {Object} section
* @param {function(func:string, value:string)} fn
*/
forEachProp(section, fn) {
for (const [propName, func] of Object.entries(MozDocMapper.TO_CSS)) {
const props = section[propName];
if (props) props.forEach(value => fn(func, value));
}
},
/**
* @param {Array<?[type,value]>} funcItems
* @param {?Object} [section]
* @returns {Object} section
*/
toSection(funcItems, section = {}) {
for (const item of funcItems) {
const [func, value] = item || [];
const propName = MozDocMapper.FROM_CSS[func];
if (propName) {
const props = section[propName] || (section[propName] = []);
if (Array.isArray(value)) props.push(...value);
else props.push(value);
}
}
return section;
},
/**
* @param {StyleObj} style
* @returns {string}
*/
styleToCss(style) {
const res = [];
for (const section of style.sections) {
const funcs = [];
MozDocMapper.forEachProp(section, (type, value) =>
funcs.push(`${type}("${value.replace(/[\\"]/g, '\\$&')}")`));
res.push(funcs.length
? `@-moz-document ${funcs.join(', ')} {\n${section.code}\n}`
: section.code);
}
return res.join('\n\n');
},
};
function styleCodeEmpty(code) {
if (!code) {
return true;
}
const rx = /\s+|\/\*[\s\S]*?\*\/|@namespace[^;]+;|@charset[^;]+;/giy;
const rx = /\s+|\/\*([^*]|\*(?!\/))*(\*\/|$)|@namespace[^;]+;|@charset[^;]+;/giyu;
while (rx.exec(code)) {
if (rx.lastIndex === code.length) {
return true;
@ -50,41 +116,27 @@ function styleSectionsEqual({sections: a}, {sections: b}) {
}
}
function normalizeStyleSections({sections}) {
async function calcStyleDigest(style) {
// retain known properties in an arbitrarily predefined order
return (sections || []).map(section => /** @namespace StyleSection */({
const src = style.usercssData
? style.sourceCode
// retain known properties in an arbitrarily predefined order
: JSON.stringify((style.sections || []).map(section => /** @namespace StyleSection */({
code: section.code || '',
urls: section.urls || [],
urlPrefixes: section.urlPrefixes || [],
domains: section.domains || [],
regexps: section.regexps || [],
}));
}
function calcStyleDigest(style) {
const jsonString = style.usercssData ?
style.sourceCode : JSON.stringify(normalizeStyleSections(style));
const text = new TextEncoder('utf-8').encode(jsonString);
return crypto.subtle.digest('SHA-1', text).then(hex);
function hex(buffer) {
const parts = [];
const PAD8 = '00000000';
const view = new DataView(buffer);
for (let i = 0; i < view.byteLength; i += 4) {
parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8));
}
return parts.join('');
}
})));
const srcBytes = new TextEncoder().encode(src);
const res = await crypto.subtle.digest('SHA-1', srcBytes);
return Array.from(new Uint8Array(res), b => (0x100 + b).toString(16).slice(1)).join('');
}
function styleJSONseemsValid(json) {
return json
&& json.name
&& typeof json.name == 'string'
&& json.name.trim()
&& Array.isArray(json.sections)
&& json.sections
&& json.sections.length
&& typeof json.sections.every === 'function'
&& typeof json.sections[0].code === 'string';
&& typeof (json.sections[0] || {}).code === 'string';
}

View File

@ -1,7 +1,9 @@
/* global loadScript tryJSONparse */
/* global tryJSONparse */// toolbox.js
'use strict';
(() => {
let LZString;
/** @namespace StorageExtras */
const StorageExtras = {
async getValue(key) {
@ -14,9 +16,9 @@
return (await this.getLZValues([key]))[key];
},
async getLZValues(keys = Object.values(this.LZ_KEY)) {
const [data, LZString] = await Promise.all([
const [data] = await Promise.all([
this.get(keys),
this.getLZString(),
LZString || loadLZString(),
]);
for (const key of keys) {
const value = data[key];
@ -25,16 +27,9 @@
return data;
},
async setLZValue(key, value) {
const LZString = await this.getLZString();
if (!LZString) await loadLZString();
return this.setValue(key, LZString.compressToUTF16(JSON.stringify(value)));
},
async getLZString() {
if (!window.LZString) {
await loadScript('/vendor/lz-string-unsafe/lz-string-unsafe.min.js');
window.LZString = window.LZString || window.LZStringUnsafe;
}
return window.LZString;
},
};
/** @namespace StorageExtrasSync */
const StorageExtrasSync = {
@ -44,6 +39,12 @@
usercssTemplate: 'usercssTemplate',
},
};
async function loadLZString() {
await require(['/vendor/lz-string-unsafe/lz-string-unsafe.min']);
LZString = window.LZString || window.LZStringUnsafe;
}
/** @type {chrome.storage.StorageArea|StorageExtras} */
window.chromeLocal = Object.assign(browser.storage.local, StorageExtras);
/** @type {chrome.storage.StorageArea|StorageExtras|StorageExtrasSync} */

View File

@ -1,13 +1,16 @@
'use strict';
/* exported
CHROME_POPUP_BORDER_BUG
capitalize
CHROME_HAS_BORDER_BUG
closeCurrentTab
deepEqual
download
getActiveTab
getStyleWithNoCode
getOwnTab
getTab
ignoreChromeError
isEmptyObj
onTabReady
openURL
sessionStore
@ -15,7 +18,6 @@
tryCatch
tryRegExp
*/
'use strict';
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]);
const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
@ -23,7 +25,7 @@ const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi');
let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
// see PR #781
const CHROME_HAS_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
const CHROME_POPUP_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
if (!CHROME && !chrome.browserAction.openPopup) {
// in FF pre-57 legacy addons can override useragent so we assume the worst
@ -40,12 +42,6 @@ if (!CHROME && !chrome.browserAction.openPopup) {
const URLS = {
ownOrigin: chrome.runtime.getURL(''),
// FIXME delete?
optionsUI: [
chrome.runtime.getURL('options.html'),
'chrome://extensions/?options=' + chrome.runtime.id,
],
configureCommands:
OPERA ? 'opera://settings/configureCommands'
: 'chrome://extensions/configureCommands',
@ -75,6 +71,8 @@ const URLS = {
// TODO: remove when "minimum_chrome_version": "61" or higher
chromeProtectsNTP: CHROME >= 61,
rxMETA: /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i,
uso: 'https://userstyles.org/',
usoJson: 'https://userstyles.org/styles/chrome/',
@ -101,9 +99,11 @@ const URLS = {
),
};
if (!chrome.extension.getBackgroundPage || chrome.extension.getBackgroundPage() !== window) {
const cls = FIREFOX ? 'firefox' : OPERA ? 'opera' : VIVALDI ? 'vivaldi' : '';
if (cls) document.documentElement.classList.add(cls);
if (FIREFOX || OPERA || VIVALDI) {
document.documentElement.classList.add(
FIREFOX && 'firefox' ||
OPERA && 'opera' ||
VIVALDI && 'vivaldi');
}
// FF57+ supports openerTabId, but not in Android
@ -114,9 +114,8 @@ function getOwnTab() {
return browser.tabs.getCurrent();
}
function getActiveTab() {
return browser.tabs.query({currentWindow: true, active: true})
.then(tabs => tabs[0]);
async function getActiveTab() {
return (await browser.tabs.query({currentWindow: true, active: true}))[0];
}
function urlToMatchPattern(url, ignoreSearch) {
@ -134,13 +133,13 @@ function urlToMatchPattern(url, ignoreSearch) {
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
}
function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
async function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
url = new URL(url);
return browser.tabs.query({url: urlToMatchPattern(url, ignoreSearch), currentWindow})
// FIXME: is tab.url always normalized?
.then(tabs => tabs.find(matchTab));
function matchTab(tab) {
const tabs = await browser.tabs.query({
url: urlToMatchPattern(url, ignoreSearch),
currentWindow,
});
return tabs.find(tab => {
const tabUrl = new URL(tab.pendingUrl || tab.url);
return tabUrl.protocol === url.protocol &&
tabUrl.username === url.username &&
@ -150,7 +149,7 @@ function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch =
tabUrl.pathname === url.pathname &&
(ignoreSearch || tabUrl.search === url.search) &&
(ignoreHash || tabUrl.hash === url.hash);
}
});
}
/**
@ -186,7 +185,7 @@ async function openURL({
});
}
if (newWindow && browser.windows) {
return (await browser.windows.create(Object.assign({url}, newWindow)).tabs)[0];
return (await browser.windows.create(Object.assign({url}, newWindow))).tabs[0];
}
tab = await getActiveTab() || {url: ''};
if (isTabReplaceable(tab, url)) {
@ -197,20 +196,17 @@ async function openURL({
return browser.tabs.create(Object.assign({url, index, active}, opener));
}
// replace empty tab (NTP or about:blank)
// except when new URL is chrome:// or chrome-extension:// and the empty tab is
// in incognito
/**
* Replaces empty tab (NTP or about:blank)
* except when new URL is chrome:// or chrome-extension:// and the empty tab is in incognito
*/
function isTabReplaceable(tab, newUrl) {
if (!tab || !URLS.emptyTab.includes(tab.pendingUrl || tab.url)) {
return false;
}
if (tab.incognito && newUrl.startsWith('chrome')) {
return false;
}
return true;
return tab &&
URLS.emptyTab.includes(tab.pendingUrl || tab.url) &&
!(tab.incognito && newUrl.startsWith('chrome'));
}
function activateTab(tab, {url, index, openerTabId} = {}) {
async function activateTab(tab, {url, index, openerTabId} = {}) {
const options = {active: true};
if (url) {
options.url = url;
@ -218,63 +214,64 @@ function activateTab(tab, {url, index, openerTabId} = {}) {
if (openerTabId != null && openerTabIdSupported) {
options.openerTabId = openerTabId;
}
return Promise.all([
await Promise.all([
browser.tabs.update(tab.id, options),
browser.windows && browser.windows.update(tab.windowId, {focused: true}),
index != null && browser.tabs.move(tab.id, {index}),
])
.then(() => tab);
]);
return tab;
}
function stringAsRegExp(s, flags, asString) {
s = s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&');
return asString ? s : new RegExp(s, flags);
}
function ignoreChromeError() {
// eslint-disable-next-line no-unused-expressions
chrome.runtime.lastError;
}
function getStyleWithNoCode(style) {
const stripped = deepCopy(style);
for (const section of stripped.sections) section.code = null;
stripped.sourceCode = null;
return stripped;
function isEmptyObj(obj) {
if (obj) {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
return false;
}
}
}
return true;
}
// js engine can't optimize the entire function if it contains try-catch
// so we should keep it isolated from normal code in a minimal wrapper
// Update: might get fixed in V8 TurboFan in the future
/**
* js engine can't optimize the entire function if it contains try-catch
* so we should keep it isolated from normal code in a minimal wrapper
* 2020 update: probably fixed at least in V8
*/
function tryCatch(func, ...args) {
try {
return func(...args);
} catch (e) {}
}
function tryRegExp(regexp, flags) {
try {
return new RegExp(regexp, flags);
} catch (e) {}
}
function tryJSONparse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {}
}
const debounce = Object.assign((fn, delay, ...args) => {
function debounce(fn, delay, ...args) {
clearTimeout(debounce.timers.get(fn));
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
}, {
}
Object.assign(debounce, {
timers: new Map(),
run(fn, ...args) {
debounce.timers.delete(fn);
@ -286,27 +283,28 @@ const debounce = Object.assign((fn, delay, ...args) => {
},
});
function deepCopy(obj) {
if (!obj || typeof obj !== 'object') return obj;
// N.B. the copy should be an explicit literal
if (Array.isArray(obj)) {
const copy = [];
for (const v of obj) {
copy.push(!v || typeof v !== 'object' ? v : deepCopy(v));
function deepMerge(src, dst) {
if (!src || typeof src !== 'object') {
return src;
}
return copy;
if (Array.isArray(src)) {
// using `Array` that belongs to this `window`; not using Array.from as it's slower
if (!dst) dst = Array.prototype.map.call(src, deepCopy);
else for (const v of src) dst.push(deepMerge(v));
} else {
// using an explicit {} that belongs to this `window`
if (!dst) dst = {};
for (const [k, v] of Object.entries(src)) {
dst[k] = deepMerge(v, dst[k]);
}
const copy = {};
const hasOwnProperty = Object.prototype.hasOwnProperty;
for (const k in obj) {
if (!hasOwnProperty.call(obj, k)) continue;
const v = obj[k];
copy[k] = !v || typeof v !== 'object' ? v : deepCopy(v);
}
return copy;
return dst;
}
/** Useful in arr.map(deepCopy) to ignore the extra parameters passed by map() */
function deepCopy(src) {
return deepMerge(src);
}
function deepEqual(a, b, ignoredKeys) {
if (!a || !b) return a === b;
@ -451,13 +449,10 @@ function download(url, {
}
}
function closeCurrentTab() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
getOwnTab().then(tab => {
if (tab) {
chrome.tabs.remove(tab.id);
}
});
async function closeCurrentTab() {
// https://bugzil.la/1409375
const tab = await getOwnTab();
if (tab) chrome.tabs.remove(tab.id);
}
function capitalize(s) {

128
js/usercss-compiler.js Normal file
View File

@ -0,0 +1,128 @@
'use strict';
const BUILDERS = Object.assign(Object.create(null), {
default: {
post(sections, vars) {
require(['/js/sections-util']); /* global styleCodeEmpty */
let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('');
if (!varDef) return;
varDef = ':root {\n' + varDef + '}\n';
for (const section of sections) {
if (!styleCodeEmpty(section.code)) {
section.code = varDef + section.code;
}
}
},
},
stylus: {
pre(source, vars) {
require(['/vendor/stylus-lang-bundle/stylus-renderer.min']); /* global StylusRenderer */
return new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
new StylusRenderer(varDef + source)
.render((err, output) => err ? reject(err) : resolve(output));
});
},
},
less: {
async pre(source, vars) {
if (!self.less) {
self.less = {
logLevel: 0,
useFileCache: false,
};
}
require(['/vendor/less-bundle/less.min']); /* global less */
const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join('');
return (await less.render(varDefs + source)).css;
},
},
uso: {
async pre(source, vars) {
require(['/js/color/color-converter']); /* global colorConverter */
const pool = new Map();
return doReplace(source);
function getValue(name, rgbName) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), name);
}
return null;
}
const {type, value} = vars[name];
switch (type) {
case 'color': {
let color = pool.get(rgbName || name);
if (color == null) {
color = colorConverter.parse(value);
if (color) {
if (color.type === 'hsl') {
color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color));
}
const {r, g, b} = color;
color = rgbName
? `${r}, ${g}, ${b}`
: `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
// the pool stores `false` for bad colors to differentiate from a yet unknown color
pool.set(rgbName || name, color || false);
}
return color || null;
}
case 'dropdown':
case 'select': // prevent infinite recursion
pool.set(name, '');
return doReplace(value);
}
return value;
}
function doReplace(text) {
return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => {
if (!pool.has(name)) {
const value = getValue(name);
pool.set(name, value === null ? match : value);
}
return pool.get(name);
});
}
},
},
});
/* exported compileUsercss */
async function compileUsercss(preprocessor, code, vars) {
let builder = BUILDERS[preprocessor];
if (!builder) {
builder = BUILDERS.default;
if (preprocessor != null) console.warn(`Unknown preprocessor "${preprocessor}"`);
}
// simplify vars by merging `va.default` to `va.value`, so BUILDER don't
// need to test each va's default value.
vars = Object.entries(vars || {}).reduce((output, [key, va]) => {
// TODO: handle customized image
const prop = va.value == null ? 'default' : 'value';
const value =
/^(select|dropdown|image)$/.test(va.type) ?
va.options.find(o => o.name === va[prop]).value :
/^(number|range)$/.test(va.type) && va.units ?
va[prop] + va.units :
va[prop];
output[key] = Object.assign({}, va, {value});
return output;
}, {});
if (builder.pre) {
code = await builder.pre(code, vars);
}
require(['/js/moz-parser']); /* global extractSections */
const res = extractSections({code});
if (builder.post) {
builder.post(res.sections, vars);
}
return res;
}

View File

@ -1,81 +0,0 @@
/* global API */
/* exported usercss */
'use strict';
const usercss = (() => {
const GLOBAL_METAS = {
author: undefined,
description: undefined,
homepageURL: 'url',
updateURL: 'updateUrl',
name: undefined,
};
const RX_META = /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i;
const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']);
return {
RX_META,
// Methods are sorted alphabetically
async assignVars(style, oldStyle) {
const vars = style.usercssData.vars;
const oldVars = oldStyle.usercssData.vars;
if (vars && oldVars) {
// The type of var might be changed during the update. Set value to null if the value is invalid.
for (const [key, v] of Object.entries(vars)) {
const old = oldVars[key] && oldVars[key].value;
if (old) v.value = old;
}
style.usercssData.vars = await API.worker.nullifyInvalidVars(vars);
}
},
async buildCode(style) {
const {sourceCode: code, usercssData: {vars, preprocessor}} = style;
const match = code.match(RX_META);
const codeNoMeta = code.slice(0, match.index) + code.slice(match.index + match[0].length);
const {sections, errors} = API.worker.compileUsercss(preprocessor, codeNoMeta, vars);
const recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
}
style.sections = sections;
return style;
},
async buildMeta(sourceCode) {
sourceCode = sourceCode.replace(/\r\n?/g, '\n');
const style = {
enabled: true,
sections: [],
sourceCode,
};
const match = sourceCode.match(RX_META);
if (!match) {
return Promise.reject(new Error('Could not find metadata.'));
}
try {
const {metadata} = await API.worker.parseUsercssMeta(match[0], match.index);
style.usercssData = metadata;
// https://github.com/openstyles/stylus/issues/560#issuecomment-440561196
for (const [key, value] of Object.entries(GLOBAL_METAS)) {
if (metadata[key] !== undefined) {
style[value || key] = metadata[key];
}
}
return style;
} catch (err) {
if (err.code) {
const args = ERR_ARGS_IS_LIST.has(err.code)
? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ')
: err.args;
const msg = chrome.i18n.getMessage(`meta_${err.code}`, args);
if (msg) err.message = msg;
}
return Promise.reject(err);
}
},
};
})();

View File

@ -1,25 +1,23 @@
'use strict';
const workerUtil = {
createWorker({url, lifeTime = 300}) {
/* exported createWorker */
function createWorker({url, lifeTime = 300}) {
let worker;
let id;
let timer;
const pendingResponse = new Map();
return new Proxy({}, {
get: (target, prop) =>
(...args) => {
if (!worker) {
init();
}
get(target, prop) {
return (...args) => {
if (!worker) init();
return invoke(prop, args);
};
},
});
function init() {
id = 0;
worker = new Worker(url);
worker = new Worker('/js/worker-util.js?' + new URLSearchParams({url}));
worker.onmessage = onMessage;
}
@ -45,22 +43,23 @@ const workerUtil = {
id++;
});
}
},
}
createAPI(methods) {
/* exported createWorkerApi */
function createWorkerApi(methods) {
self.onmessage = async ({data: {id, action, args}}) => {
let data, error;
try {
data = await methods[action](...args);
} catch (err) {
error = true;
data = workerUtil.cloneError(err);
data = cloneError(err);
}
self.postMessage({id, data, error});
};
},
}
cloneError(err) {
function cloneError(err) {
return Object.assign({
name: err.name,
stack: err.stack,
@ -69,16 +68,22 @@ const workerUtil = {
columnNumber: err.columnNumber,
fileName: err.fileName,
}, err);
},
loadScript(...urls) {
urls = urls.filter(u => !workerUtil._loadedScripts.has(u));
if (!urls.length) {
return;
}
self.importScripts(...urls);
urls.forEach(u => workerUtil._loadedScripts.add(u));
},
_loadedScripts: new Set(),
if (self.WorkerGlobalScope) {
const loadedUrls = [];
self.require = urls => {
const toLoad = (Array.isArray(urls) ? urls : [urls])
.map(u => u.endsWith('.js') ? u : u + '.js')
.filter(u => !loadedUrls.includes(u));
if (toLoad) {
loadedUrls.push(...toLoad);
importScripts(...toLoad);
}
};
const url = new URLSearchParams(location.search).get('url');
if (url) require(url);
}

View File

@ -5,11 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title i18n-text="manageTitle"></title>
<link rel="stylesheet" href="global.css">
<link rel="stylesheet" href="options/onoffswitch.css">
<link rel="stylesheet" href="manage/manage.css">
<link rel="stylesheet" href="manage/config-dialog.css">
<link rel="stylesheet" href="msgbox/msgbox.css">
<link rel="stylesheet" href="vendor-overwrites/colorpicker/colorpicker.css">
<style id="firefox-transitions-bug-suppressor">
/* restrict to FF */
@ -163,29 +158,24 @@
</template>
<script src="js/polyfill.js"></script>
<script src="js/dom.js"></script>
<script src="js/messaging.js"></script>
<script src="js/msg.js"></script>
<script src="js/toolbox.js"></script>
<script src="js/prefs.js"></script>
<script src="js/dom.js"></script>
<script src="js/localization.js"></script>
<script src="js/router.js"></script>
<script src="content/style-injector.js"></script>
<script src="content/apply.js"></script>
<script src="js/localization.js"></script>
<script src="manage/events.js"></script>
<script src="manage/filters.js"></script>
<script src="manage/sort.js"></script>
<script src="manage/render.js"></script>
<script src="manage/sorter.js"></script>
<script src="manage/manage.js"></script>
<script src="vendor-overwrites/colorpicker/colorconverter.js"></script>
<script src="vendor-overwrites/colorpicker/colorpicker.js"></script>
<script src="manage/config-dialog.js"></script>
<script src="manage/updater-ui.js"></script>
<script src="manage/object-diff.js"></script>
<script src="manage/import-export.js"></script>
<script src="manage/incremental-search.js"></script>
<script src="msgbox/msgbox.js"></script>
<script src="js/sections-util.js"></script>
<script src="js/storage-util.js"></script>
<script src="js/script-loader.js"></script>
<link rel="stylesheet" href="options/onoffswitch.css">
<link rel="stylesheet" href="manage/manage.css">
</head>
<body id="stylus-manage" i18n-dragndrop-hint="dragDropMessage">
@ -221,7 +211,7 @@
</div>
</label>
<div class="select-resizer">
<select id="manage.onlyEnabled.invert">
<select id="manage.onlyEnabled.invert" class="fit-width">
<option i18n-text="manageOnlyEnabled" value="false"></option>
<option i18n-text="manageOnlyDisabled" value="true"></option>
</select>
@ -239,7 +229,7 @@
</div>
</label>
<div class="select-resizer">
<select id="manage.onlyLocal.invert" i18n-title="manageOnlyLocalTooltip">
<select id="manage.onlyLocal.invert" i18n-title="manageOnlyLocalTooltip" class="fit-width">
<option i18n-text="manageOnlyLocal" value="false"></option>
<option i18n-text="manageOnlyExternal" value="true"></option>
</select>
@ -257,7 +247,7 @@
</div>
</label>
<div class="select-resizer">
<select id="manage.onlyUsercss.invert">
<select id="manage.onlyUsercss.invert" class="fit-width">
<option i18n-text="manageOnlyUsercss" value="false"></option>
<option i18n-text="manageOnlyNonUsercss" value="true"></option>
</select>
@ -280,7 +270,7 @@
data-filter=":not(.not-matching)"
data-filter-hide=".not-matching">
<div class="select-wrapper">
<select id="searchMode">
<select id="searchMode" class="fit-width">
<option i18n-text="searchStylesName" value="name"></option>
<option i18n-text="searchStylesMeta" value="meta" selected></option>
<option i18n-text="searchStylesCode" value="code"></option>

283
manage/events.js Normal file
View File

@ -0,0 +1,283 @@
/* global API */// msg.js
/* global changeQueue installed newUI */// manage.js
/* global checkUpdate handleUpdateInstalled */// updater-ui.js
/* global createStyleElement createTargetsElement getFaviconSrc */// render.js
/* global debounce getOwnTab openURL sessionStore */// toolbox.js
/* global filterAndAppend showFiltersStats */// filters.js
/* global sorter */
/* global t */// localization.js
/* global
$
$$
$entry
animateElement
getEventKeyName
messageBoxProxy
scrollElementIntoView
*/// dom.js
'use strict';
const Events = {
addEntryTitle(link) {
const style = link.closest('.entry').styleMeta;
const ucd = style.usercssData;
link.title =
`${t('dateInstalled')}: ${t.formatDate(style.installDate) || '—'}\n` +
`${t('dateUpdated')}: ${t.formatDate(style.updateDate) || '—'}\n` +
(ucd ? `UserCSS, v.${ucd.version}` : '');
},
check(event, entry) {
checkUpdate(entry, {single: true});
},
async config(event, {styleMeta}) {
await require(['/js/dlg/config-dialog']); /* global configDialog */
configDialog(styleMeta);
},
async delete(event, entry) {
const id = entry.styleId;
animateElement(entry);
const {button} = await messageBoxProxy.show({
title: t('deleteStyleConfirm'),
contents: entry.styleMeta.customName || entry.styleMeta.name,
className: 'danger center',
buttons: [t('confirmDelete'), t('confirmCancel')],
});
if (button === 0) {
API.styles.delete(id);
}
const deleteButton = $('#message-box-buttons > button');
if (deleteButton) deleteButton.removeAttribute('data-focused-via-click');
},
async edit(event, entry) {
if (event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
const key = getEventKeyName(event);
const url = $('[href]', entry).href;
const ownTab = await getOwnTab();
if (key === 'MouseL') {
sessionStore['manageStylesHistory' + ownTab.id] = url;
location.href = url;
} else if (chrome.windows && key === 'Shift-MouseL') {
API.openEditor({id: entry.styleId});
} else {
openURL({
url,
index: ownTab.index + 1,
active: key === 'Shift-MouseM' || key === 'Shift-Ctrl-MouseL',
});
}
},
expandTargets(event, entry) {
if (!entry._allTargetsRendered) {
createTargetsElement({entry, expanded: true});
setTimeout(getFaviconSrc, 0, entry);
}
this.closest('.applies-to').classList.toggle('expanded');
},
async external(event) {
// Not handling Shift-click - the built-in 'open in a new window' command
if (getEventKeyName(event) !== 'Shift-MouseL') {
const {index} = await getOwnTab();
openURL({
url: event.target.closest('a').href,
index: index + 1,
active: !event.ctrlKey || event.shiftKey,
});
}
},
entryClicked(event) {
const target = event.target;
const entry = target.closest('.entry');
for (const selector in Events.ENTRY_ROUTES) {
for (let el = target; el && el !== entry; el = el.parentElement) {
if (el.matches(selector)) {
return Events.ENTRY_ROUTES[selector].call(el, event, entry);
}
}
}
},
lazyAddEntryTitle({type, target}) {
const cell = target.closest('h2.style-name, [data-type=age]');
if (cell) {
const link = $('.style-name-link', cell) || cell;
if (type === 'mouseover' && !link.title) {
debounce(Events.addEntryTitle, 50, link);
} else {
debounce.unregister(Events.addEntryTitle);
}
}
},
name(event, entry) {
if (newUI.enabled) Events.edit(event, entry);
},
toggle(event, entry) {
API.styles.toggle(entry.styleId, this.matches('.enable') || this.checked);
},
update(event, entry) {
const json = entry.updatedCode;
json.id = entry.styleId;
(json.usercssData ? API.usercss.install : API.styles.install)(json);
},
};
Events.ENTRY_ROUTES = {
'input, .enable, .disable': Events.toggle,
'.style-name': Events.name,
'.homepage': Events.external,
'.check-update': Events.check,
'.update': Events.update,
'.delete': Events.delete,
'.applies-to .expander': Events.expandTargets,
'.configure-usercss': Events.config,
};
/* exported handleBulkChange */
function handleBulkChange() {
for (const msg of changeQueue) {
const {id} = msg.style;
if (msg.method === 'styleDeleted') {
handleDelete(id);
changeQueue.time = performance.now();
} else {
handleUpdateForId(id, msg);
}
}
changeQueue.length = 0;
}
function handleDelete(id) {
const node = $entry(id);
if (node) {
node.remove();
if (node.matches('.can-update')) {
const btnApply = $('#apply-all-updates');
btnApply.dataset.value = Number(btnApply.dataset.value) - 1;
}
showFiltersStats();
}
}
function handleUpdate(style, {reason, method} = {}) {
if (reason === 'editPreview' || reason === 'editPreviewEnd') return;
let entry;
let oldEntry = $entry(style);
if (oldEntry && method === 'styleUpdated') {
handleToggledOrCodeOnly();
}
entry = entry || createStyleElement({style});
if (oldEntry) {
if (oldEntry.styleNameLowerCase === entry.styleNameLowerCase) {
installed.replaceChild(entry, oldEntry);
} else {
oldEntry.remove();
}
}
if ((reason === 'update' || reason === 'install') && entry.matches('.updatable')) {
handleUpdateInstalled(entry, reason);
}
filterAndAppend({entry}).then(sorter.update);
if (!entry.matches('.hidden') && reason !== 'import' && reason !== 'sync') {
animateElement(entry);
requestAnimationFrame(() => scrollElementIntoView(entry));
}
getFaviconSrc(entry);
function handleToggledOrCodeOnly() {
style.sections.forEach(s => (s.code = null));
style.sourceCode = null;
const diff = objectDiff(oldEntry.styleMeta, style)
.filter(({key, path}) => path || (!key.startsWith('original') && !key.endsWith('Date')));
if (diff.length === 0) {
// only code was modified
entry = oldEntry;
oldEntry = null;
}
if (diff.length === 1 && diff[0].key === 'enabled') {
oldEntry.classList.toggle('enabled', style.enabled);
oldEntry.classList.toggle('disabled', !style.enabled);
$$('input', oldEntry).forEach(el => (el.checked = style.enabled));
oldEntry.styleMeta = style;
entry = oldEntry;
oldEntry = null;
}
}
}
async function handleUpdateForId(id, opts) {
handleUpdate(await API.styles.get(id), opts);
changeQueue.time = performance.now();
}
/* exported handleVisibilityChange */
function handleVisibilityChange() {
switch (document.visibilityState) {
// page restored without reloading via history navigation (currently only in FF)
// the catch here is that DOM may be outdated so we'll at least refresh the just edited style
// assuming other changes aren't important enough to justify making a complicated DOM sync
case 'visible': {
const id = sessionStore.justEditedStyleId;
if (id) {
handleUpdateForId(Number(id), {method: 'styleUpdated'});
delete sessionStore.justEditedStyleId;
}
break;
}
// going away
case 'hidden':
history.replaceState({scrollY: window.scrollY}, document.title);
break;
}
}
function objectDiff(first, second, path = '') {
const diff = [];
for (const key in first) {
const a = first[key];
const b = second[key];
if (a === b) {
continue;
}
if (b === undefined) {
diff.push({path, key, values: [a], type: 'removed'});
continue;
}
if (a && typeof a.filter === 'function' && b && typeof b.filter === 'function') {
if (
a.length !== b.length ||
a.some((el, i) => {
const result = !el || typeof el !== 'object'
? el !== b[i]
: objectDiff(el, b[i], path + key + '[' + i + '].').length;
return result;
})
) {
diff.push({path, key, values: [a, b], type: 'changed'});
}
} else if (typeof a === 'object' && typeof b === 'object') {
diff.push(...objectDiff(a, b, path + key + '.'));
} else {
diff.push({path, key, values: [a, b], type: 'changed'});
}
}
for (const key in second) {
if (!(key in first)) {
diff.push({path, key, values: [second[key]], type: 'added'});
}
}
return diff;
}

Some files were not shown because too many files have changed in this diff Show More