Merge pull request #134 from eight04/dev-user-css

Install styles from *.user.css file
This commit is contained in:
tophf 2017-11-14 08:22:56 +03:00 committed by GitHub
commit 1d463d7820
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 4579 additions and 756 deletions

View File

@ -7,6 +7,10 @@
"message": "Add Style",
"description": "Title of the page for adding styles"
},
"alphaChannel": {
"message": "Opacity",
"description": "Label of color's opacity"
},
"appliesAdd": {
"message": "Add",
"description": "Label for the button to add an 'applies' entry"
@ -36,6 +40,14 @@
"message": "Applies to",
"description": "Label for 'applies to' fields on the edit/add screen"
},
"appliesLineWidgetLabel": {
"message": "Display 'Applies to' info",
"description": "Label for the checkbox to display applies-to information in the single editor"
},
"appliesLineWidgetWarning": {
"message": "Does not work with minified CSS",
"description": "A warning that applies-to information won't show properly with minified CSS"
},
"appliesRegexpOption": {
"message": "URLs matching the regexp",
"description": "Option to make the style apply to the entered string as a regular expression"
@ -44,6 +56,10 @@
"message": "Remove",
"description": "Label for the button to remove an 'applies' entry"
},
"appliesRemoveError": {
"message": "Can not remove last 'applies to' entry",
"description": "Error displayed when the last 'applies' is going to be removed"
},
"appliesSpecify": {
"message": "Specify",
"description": "Label for the button to make a style apply only to specific sites"
@ -64,6 +80,10 @@
"message": "Apply all updates",
"description": "Label for the button to apply all detected updates"
},
"author": {
"message": "Author",
"description": "Label for the style author"
},
"backupButtons": {
"message": "Backup",
"description": "Heading for backup"
@ -83,6 +103,10 @@
"updateCheckHistory": {
"message": "History of update checks"
},
"configureStyle": {
"message": "Configure",
"description": "Label for the button to configure userstyle"
},
"checkForUpdate": {
"message": "Check for update",
"description": "Label for the button to check a single style for an update"
@ -167,6 +191,14 @@
"message": "No",
"description": "'No' button in a confirm dialog"
},
"confirmDefault": {
"message": "Use default",
"description": "'Set to default' button in a confirm dialog"
},
"confirmSave": {
"message": "Save",
"description": "'Save' button in a confirm dialog"
},
"confirmStop": {
"message": "Stop",
"description": "'Stop' button in a confirm dialog"
@ -175,6 +207,10 @@
"message": "Yes",
"description": "'Yes' button in a confirm dialog"
},
"confirmClose": {
"message": "Close",
"description": "'Close' button in a confirm dialog"
},
"dbError": {
"message": "An error has occurred using the Stylus database. Would you like to visit a web page with possible solutions?",
"description": "Prompt when a DB error is encountered"
@ -257,6 +293,26 @@
"message": "Export",
"description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)"
},
"externalLink": {
"message": "External link",
"description": "Label for external links"
},
"externalHomepage": {
"message": "Homepage",
"description": "Label for the external link to style's homepage"
},
"externalSupport": {
"message": "Support",
"description": "Label for the external link to style's support site"
},
"externalFeedback": {
"message": "Feedback",
"description": "Label for the external link to send feedback for the style"
},
"externalUsercssDocument": {
"message": "Documentation for Usercss",
"description": "Label for the external link to usercss documentation"
},
"filteredStyles": {
"message": "$numShown$ shown of $numTotal$ total",
"description": "TL note - make this message short",
@ -345,10 +401,43 @@
"message": "Discard contents of current style and overwrite it with the imported style",
"description": "Label for the button to import and overwrite current style"
},
"installButton": {
"message": "Install",
"description": "Label for install button"
},
"installButtonInstalled": {
"message": "Installed",
"description": "Text displayed when the style is successfully installed"
},
"installButtonUpdate": {
"message": "Update",
"description": "Label for update button"
},
"installButtonReinstall": {
"message": "Reinstall",
"description": "Label for reinstall button"
},
"installUpdate": {
"message": "Install update",
"description": "Label for the button to install an update for a single style"
},
"installUpdateFrom": {
"message": "Currently the style is updated from $url$",
"description": "Label to describe where the style gets update",
"placeholders": {
"url": {
"content": "$1"
}
}
},
"installUpdateFromLabel": {
"message": "Check for updates",
"description": "Label for the checkbox to save current URL for update check"
},
"license": {
"message": "License",
"description": "Label for the license"
},
"linterConfigPopupTitle": {
"message": "Set $linter$ rules configuration",
"description": "Stylelint or CSSLint popup header",
@ -366,6 +455,15 @@
"message": "(Set rule as: 0 = disabled; 1 = warning; 2 = error)",
"description": "CSSLint rule config values"
},
"linterCSSLintIncompatible": {
"message": "CSSLint doesn't support $preprocessorname$ preprocessor",
"description": "The label to display when the preprocessor isn't compatible with CSSLint",
"placeholders": {
"preprocessorname": {
"content": "$1"
}
}
},
"linterInvalidConfigError": {
"message": "Not saved due to these invalid configuration settings:",
"description": "Invalid linter config will show a message followed by a list of invalid entries"
@ -395,6 +493,14 @@
"message": "See a full list of rules",
"description": "Stylelint or CSSLint rules label added immediately before a link"
},
"liveReloadLabel": {
"message": "Live reload",
"description": "The label of live-reload feature"
},
"liveReloadError": {
"message": "An error occurred while watching the file",
"description": "The label of live-reload error"
},
"manageFilters": {
"message": "Filters",
"description": "Label for filters container"
@ -483,6 +589,10 @@
"message": "More Options",
"description": "Subheading for options section on manage page."
},
"parseUsercssError": {
"message": "Stylus failed to parse usercss:",
"description": "The error message to show when stylus failed to parse usercss"
},
"popupManageTooltip": {
"message": "Shift-click or right-click opens manager with styles applicable for current site",
"description": "Tooltip for the 'Manage' button in the popup."
@ -629,6 +739,65 @@
}
}
},
"styleInstallOverwrite" : {
"message": "'$stylename$' is already installed. Overwrite?\nVersion: $oldVersion$ -> $newVersion$",
"description": "Confirmation when re-installing a style",
"placeholders": {
"stylename": {
"content": "$1"
},
"oldVersion": {
"content": "$2"
},
"newVersion": {
"content": "$3"
}
}
},
"styleInstallFailed": {
"message": "Failed to install userstyle!\n$error$",
"description": "Warning when installation failed",
"placeholders": {
"error": {
"content": "$1"
}
}
},
"styleMetaErrorCheckbox": {
"message": "Invalid @var checkbox: value must be 0 or 1",
"description": "Error displayed when the value of @var checkbox is invalid"
},
"styleMetaErrorColor": {
"message": "$color$ is not a valid color",
"description": "Error displayed when the value of @var color is invalid",
"placeholders": {
"color": {
"content": "$1"
}
}
},
"styleMetaErrorPreprocessor": {
"message": "Unsupported @preprocessor: $preprocessor$",
"description": "Error displayed when the value of @preprocessor is not supported",
"placeholders": {
"preprocessor": {
"content": "$1"
}
}
},
"styleMetaErrorSelectValueMismatch": {
"message": "Invalid @select: value doesn't exist in the list",
"description": "Error displayed when the value of @select is invalid"
},
"styleMissingMeta": {
"message": "Missing metadata @$key$",
"description": "Error displayed when a mandatory metadata is missing",
"placeholders": {
"key": {
"content": "$1"
}
}
},
"styleMissingName": {
"message": "Enter a name.",
"description": "Error displayed when user saves without providing a name"
@ -645,6 +814,10 @@
"message": "Mozilla Format",
"description": "Heading for the section with buttons to import/export Mozilla format of the style"
},
"styleFromMozillaFormatError": {
"message": "Failed to import from Mozilla format",
"description": "Label for the import error"
},
"styleFromMozillaFormatPrompt": {
"message": "Paste the Mozilla-format code",
"description": "Prompt in the dialog displayed after clicking 'Import from Mozilla format' button"
@ -666,6 +839,10 @@
}
}
},
"styleUpdateDiscardChanges": {
"message": "The style is changed outside of the editor. Would you like to reload the style?",
"description": "Confirmation to update the style in the editor"
},
"stylusUnavailableForURL": {
"message": "Stylus doesn't work on pages like this.",
"description": "Note in the toolbar pop-up when on a URL Stylus can't affect"
@ -743,6 +920,10 @@
"message": "Updates installed:",
"description": "Text that displays when an update is installed on options page. Followed by the number of currently installed updates."
},
"versionInvalidOlder": {
"message": "The version is older than the installed style.",
"description": "Displayed when the version of style is older than the installed one"
},
"writeStyleFor": {
"message": "Write style for: ",
"description": "Label for toolbar pop-up that precedes the links to write a new style"
@ -805,6 +986,9 @@
"optionsAdvancedContextDelete": {
"message": "Add 'Delete' in editor context menu"
},
"optionsAdvancedNewStyleAsUsercss": {
"message": "Write new style as usercss"
},
"optionsActions": {
"message": "Actions"
},

View File

@ -1,5 +1,6 @@
/* global dbExec, getStyles, saveStyle */
/* global handleCssTransitionBug */
/* global usercssHelper openEditor */
'use strict';
// eslint-disable-next-line no-var
@ -302,6 +303,14 @@ function onRuntimeMessage(request, sender, sendResponse) {
saveStyle(request).then(sendResponse);
return KEEP_CHANNEL_OPEN;
case 'saveUsercss':
usercssHelper.save(request, true).then(sendResponse);
return KEEP_CHANNEL_OPEN;
case 'buildUsercss':
usercssHelper.build(request, true).then(sendResponse);
return KEEP_CHANNEL_OPEN;
case 'healthCheck':
dbExec()
.then(() => sendResponse(true))
@ -313,5 +322,36 @@ function onRuntimeMessage(request, sender, sendResponse) {
.then(sendResponse)
.catch(() => sendResponse(null));
return KEEP_CHANNEL_OPEN;
case 'openUsercssInstallPage':
usercssHelper.openInstallPage(sender.tab.id, request).then(sendResponse);
return KEEP_CHANNEL_OPEN;
case 'closeTab':
closeTab(sender.tab.id, request).then(sendResponse);
return KEEP_CHANNEL_OPEN;
case 'openEditor':
openEditor(request.id);
return;
}
}
function closeTab(tabId, request) {
return new Promise(resolve => {
if (request.tabId) {
tabId = request.tabId;
}
chrome.tabs.remove(tabId, () => {
const {lastError} = chrome.runtime;
if (lastError) {
resolve({
success: false,
error: lastError.message || String(lastError)
});
return;
}
resolve({success: true});
});
});
}

View File

@ -383,12 +383,21 @@ function saveStyle(style) {
}
let existed;
let codeIsUpdated;
return maybeCalcDigest()
.then(maybeImportFix)
.then(decide);
function maybeCalcDigest() {
if (reason === 'update' || reason === 'update-digest') {
return calcStyleDigest(style).then(digest => {
style.originalDigest = digest;
return decide();
});
}
return Promise.resolve();
}
function maybeImportFix() {
if (reason === 'import') {
style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
delete style.styleDigest; // TODO: remove in the future
@ -396,7 +405,7 @@ function saveStyle(style) {
delete style.originalDigest;
}
}
return decide();
}
function decide() {
if (id !== null) {
@ -714,7 +723,8 @@ function normalizeStyleSections({sections}) {
function calcStyleDigest(style) {
const jsonString = JSON.stringify(normalizeStyleSections(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);

View File

@ -1,5 +1,6 @@
/* global getStyles, saveStyle, styleSectionsEqual, chromeLocal */
/* global calcStyleDigest */
/* global usercss semverCompare usercssHelper */
'use strict';
// eslint-disable-next-line no-var
@ -15,8 +16,10 @@ var updater = {
MAYBE_EDITED: 'may be locally edited',
SAME_MD5: 'up-to-date: MD5 is unchanged',
SAME_CODE: 'up-to-date: code sections are unchanged',
SAME_VERSION: 'up-to-date: version is unchanged',
ERROR_MD5: 'error: MD5 is invalid',
ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is older than installed style',
lastUpdateTime: parseInt(localStorage.lastUpdateTime) || Date.now(),
@ -53,9 +56,11 @@ var updater = {
'ignoreDigest' option is set on the second manual individual update check on the manage page.
*/
const maybeUpdate = style.usercssData ? maybeUpdateUsercss : maybeUpdateUSO;
return (ignoreDigest ? Promise.resolve() : calcStyleDigest(style))
.then(maybeFetchMd5)
.then(maybeFetchCode)
.then(checkIfEdited)
.then(maybeUpdate)
.then(maybeValidate)
.then(maybeSave)
.then(saved => {
observer(updater.UPDATED, saved);
@ -67,42 +72,79 @@ var updater = {
updater.log(updater.SKIPPED + ` (${err}) #${style.id} ${style.name}`);
});
function maybeFetchMd5(digest) {
if (!ignoreDigest && style.originalDigest && style.originalDigest !== digest) {
function checkIfEdited(digest) {
if (ignoreDigest) {
return;
}
if (style.originalDigest && style.originalDigest !== digest) {
return Promise.reject(updater.EDITED);
}
return download(style.md5Url);
}
function maybeFetchCode(md5) {
function maybeUpdateUSO() {
return download(style.md5Url).then(md5 => {
if (!md5 || md5.length !== 32) {
return Promise.reject(updater.ERROR_MD5);
}
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(updater.SAME_MD5);
}
return download(style.updateUrl);
return download(style.updateUrl)
.then(text => tryJSONparse(text));
});
}
function maybeSave(text) {
const json = tryJSONparse(text);
function maybeUpdateUsercss() {
return download(style.updateUrl).then(text => {
const json = usercss.buildMeta(text);
const {usercssData: {version}} = style;
const {usercssData: {version: newVersion}} = json;
switch (Math.sign(semverCompare(version, newVersion))) {
case 0:
// re-install is invalid in a soft upgrade
if (!ignoreDigest) {
return Promise.reject(updater.SAME_VERSION);
}
break;
case 1:
// downgrade is always invalid
return Promise.reject(updater.ERROR_VERSION);
}
return usercss.buildCode(json);
});
}
function maybeValidate(json) {
if (json.usercssData) {
// usercss is already validated while building
return json;
}
if (!styleJSONseemsValid(json)) {
return Promise.reject(updater.ERROR_JSON);
}
return json;
}
function maybeSave(json) {
json.id = style.id;
if (styleSectionsEqual(json, style)) {
// JSONs may have different order of items even if sections are effectively equal
// so we'll update the digest anyway
// always update digest even if (save === false)
saveStyle(Object.assign(json, {reason: 'update-digest'}));
return Promise.reject(updater.SAME_CODE);
} else if (!style.originalDigest && !ignoreDigest) {
return Promise.reject(updater.MAYBE_EDITED);
}
return !save ? json :
saveStyle(Object.assign(json, {
name: null, // keep local name customizations
reason: 'update',
}));
if (!save) {
return json;
}
json.reason = 'update';
if (json.usercssData) {
return usercssHelper.save(json);
}
json.name = null; // keep local name customizations
return saveStyle(json);
}
function styleJSONseemsValid(json) {

View File

@ -0,0 +1,89 @@
/* global usercss saveStyle getStyles */
'use strict';
// eslint-disable-next-line no-var
var usercssHelper = (() => {
function buildMeta(style) {
if (style.usercssData) {
return Promise.resolve(style);
}
try {
const {sourceCode} = style;
// allow sourceCode to be normalized
delete style.sourceCode;
return Promise.resolve(Object.assign(usercss.buildMeta(sourceCode), style));
} catch (e) {
return Promise.reject(e);
}
}
function buildCode(style) {
return usercss.buildCode(style);
}
function wrapReject(pending) {
return pending.then(result => ({success: true, result}))
.catch(err => ({success: false, result: err.message || String(err)}));
}
// Parse the source and find the duplication
function build({sourceCode, checkDup = false}, noReject) {
const pending = buildMeta({sourceCode})
.then(style => Promise.all([
buildCode(style),
checkDup && findDup(style)
]))
.then(([style, dup]) => ({style, dup}));
return noReject ? wrapReject(pending) : pending;
}
function save(style, noReject) {
const pending = buildMeta(style)
.then(assignVars)
.then(buildCode)
.then(saveStyle);
return noReject ? wrapReject(pending) : pending;
function assignVars(style) {
if (style.reason === 'config' && style.id) {
return style;
}
return findDup(style).then(dup => {
if (dup) {
style.id = dup.id;
if (style.reason !== 'config') {
// preserve style.vars during update
usercss.assignVars(style, dup);
}
}
return style;
});
}
}
function findDup(style) {
if (style.id) {
return getStyles({id: style.id}).then(s => s[0]);
}
return getStyles().then(styles =>
styles.find(target => {
if (!target.usercssData) {
return false;
}
return target.usercssData.name === style.usercssData.name &&
target.usercssData.namespace === style.usercssData.namespace;
})
);
}
function openInstallPage(tabId, request) {
const url = '/install-usercss.html' +
'?updateUrl=' + encodeURIComponent(request.updateUrl) +
'&tabId=' + tabId;
return wrapReject(openURL({url}));
}
return {build, save, findDup, openInstallPage};
})();

122
content/install-user-css.js Normal file
View File

@ -0,0 +1,122 @@
/* global runtimeSend */
'use strict';
function createSourceLoader() {
let source;
function fetchText(url) {
return new Promise((resolve, reject) => {
// you can't use fetch in Chrome under 'file:' protocol
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.addEventListener('load', () => resolve(xhr.responseText));
xhr.addEventListener('error', () => reject(xhr));
xhr.send();
});
}
function load() {
return fetchText(location.href).then(newSource => {
source = newSource;
return source;
});
}
function watch(cb) {
let timer;
const DELAY = 1000;
function start() {
if (timer) {
return;
}
timer = setTimeout(check, DELAY);
}
function stop() {
clearTimeout(timer);
timer = null;
}
function check() {
fetchText(location.href)
.then(newSource => {
if (source !== newSource) {
source = newSource;
return cb(source);
}
})
.catch(error => {
console.log(t('liveReloadError', error));
})
.then(() => {
timer = setTimeout(check, DELAY);
});
}
return {start, stop};
}
return {load, watch, source: () => source};
}
function initUsercssInstall() {
const sourceLoader = createSourceLoader();
const pendingSource = sourceLoader.load();
let watcher;
chrome.runtime.onConnect.addListener(port => {
// FIXME: is this the correct way to reject a connection?
// https://developer.chrome.com/extensions/messaging#connect
console.assert(port.name === 'usercss-install');
port.onMessage.addListener(msg => {
switch (msg.method) {
case 'getSourceCode':
pendingSource
.then(sourceCode => port.postMessage({method: msg.method + 'Response', sourceCode}))
.catch(err => port.postMessage({method: msg.method + 'Response', error: err.message || String(err)}));
break;
case 'liveReloadStart':
if (!watcher) {
watcher = sourceLoader.watch(sourceCode => {
port.postMessage({method: 'sourceCodeChanged', sourceCode});
});
}
watcher.start();
break;
case 'liveReloadStop':
watcher.stop();
break;
case 'closeTab':
if (history.length > 1) {
history.back();
} else {
runtimeSend({method: 'closeTab'});
}
break;
}
});
});
return runtimeSend({
method: 'openUsercssInstallPage',
updateUrl: location.href
}).catch(alert);
}
function isUsercss() {
if (!/text\/(css|plain)/.test(document.contentType)) {
return false;
}
if (!/==userstyle==/i.test(document.body.textContent)) {
return false;
}
return true;
}
if (isUsercss()) {
initUsercssInstall();
}

10
content/util.js Normal file
View File

@ -0,0 +1,10 @@
'use strict';
function runtimeSend(request) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
request,
({success, result}) => (success ? resolve : reject)(result)
);
});
}

View File

@ -17,9 +17,15 @@
<script src="js/messaging.js"></script>
<script src="js/prefs.js"></script>
<script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/moz-parser.js"></script>
<script src="content/apply.js"></script>
<link rel="stylesheet" href="edit/edit.css">
<script src="edit/lint.js"></script>
<script src="edit/util.js"></script>
<script src="edit/regexp-tester.js"></script>
<script src="edit/applies-to-line-widget.js"></script>
<script src="edit/source-editor.js"></script>
<script src="edit/edit.js"></script>
<script src="vendor/codemirror/lib/codemirror.js"></script>
@ -50,9 +56,14 @@
<script src="vendor/codemirror/addon/hint/show-hint.js"></script>
<script src="vendor/codemirror/addon/hint/css-hint.js"></script>
<script src="vendor/codemirror/addon/mode/loadmode.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="/edit/codemirror-default.js"></script>
<link rel="stylesheet" href="/edit/codemirror-default.css">
<link id="cm-theme" rel="stylesheet">
<template data-id="appliesTo">

View File

@ -0,0 +1,408 @@
/* global regExpTester debounce messageBox */
'use strict';
function createAppliesToLineWidget(cm) {
const APPLIES_TYPE = [
[t('appliesUrlOption'), 'url'],
[t('appliesUrlPrefixOption'), 'url-prefix'],
[t('appliesDomainOption'), 'domain'],
[t('appliesRegexpOption'), 'regexp']
];
const THROTTLE_DELAY = 400;
let widgets = [];
let fromLine, toLine, gutterStyle;
let initialized = false;
return {toggle};
function toggle(newState = !initialized) {
newState = Boolean(newState);
if (newState !== initialized) {
if (newState) {
init();
} else {
uninit();
}
}
}
function init() {
initialized = true;
gutterStyle = getComputedStyle(cm.getGutterElement());
fromLine = null;
toLine = null;
cm.on('change', onChange);
cm.on('optionChange', onOptionChange);
// is it possible to avoid flickering?
window.addEventListener('load', updateWidgetStyle);
update();
}
function uninit() {
initialized = false;
widgets.forEach(clearWidget);
widgets.length = 0;
cm.off('change', onChange);
cm.off('optionChange', onOptionChange);
window.removeEventListener('load', updateWidgetStyle);
}
function onChange(cm, {from, to, origin}) {
if (origin === 'appliesTo') {
return;
}
if (fromLine === null || toLine === null) {
fromLine = from.line;
toLine = to.line;
} else {
fromLine = Math.min(fromLine, from.line);
toLine = Math.max(toLine, to.line);
}
debounce(update, THROTTLE_DELAY);
}
function onOptionChange(cm, option) {
if (option === 'theme') {
updateWidgetStyle();
}
}
function update() {
cm.operation(doUpdate);
}
function updateWidgetStyle() {
gutterStyle = getComputedStyle(cm.getGutterElement());
widgets.forEach(setWidgetStyle);
}
function setWidgetStyle(widget) {
let borderStyle = '';
if (gutterStyle.borderRightWidth !== '0px') {
borderStyle = `${gutterStyle.borderRightWidth} ${gutterStyle.borderRightStyle} ${gutterStyle.borderRightColor}`;
} else {
borderStyle = `1px solid ${gutterStyle.color}`;
}
widget.node.style.backgroundColor = gutterStyle.backgroundColor;
widget.node.style.borderTop = borderStyle;
widget.node.style.borderBottom = borderStyle;
}
function doUpdate() {
// find which widgets needs to be update
// some widgets (lines) might be deleted
widgets = widgets.filter(w => w.line.lineNo() !== null);
let i = fromLine === null ? 0 : widgets.findIndex(w => w.line.lineNo() > fromLine) - 1;
let j = toLine === null ? 0 : widgets.findIndex(w => w.line.lineNo() > toLine);
if (i === -2) {
i = widgets.length - 1;
}
if (j < 0) {
j = widgets.length;
}
// decide search range
const fromIndex = widgets[i] ? cm.indexFromPos({line: widgets[i].line.lineNo(), ch: 0}) : 0;
const toIndex = widgets[j] ? cm.indexFromPos({line: widgets[j].line.lineNo(), ch: 0}) : cm.getValue().length;
// splice
i = Math.max(0, i);
widgets.splice(i, 0, ...createWidgets(fromIndex, toIndex, widgets.splice(i, j - i)));
fromLine = null;
toLine = null;
}
function *createWidgets(start, end, removed) {
let i = 0;
for (const section of findAppliesTo(start, end)) {
while (removed[i] && removed[i].line.lineNo() < section.pos.line) {
clearWidget(removed[i++]);
}
setupMarkers(section);
if (removed[i] && removed[i].line.lineNo() === section.pos.line) {
// reuse old widget
removed[i].section.applies.forEach(apply => {
apply.type.mark.clear();
apply.value.mark.clear();
});
removed[i].section = section;
const newNode = buildElement(section);
removed[i].node.parentNode.replaceChild(newNode, removed[i].node);
removed[i].node = newNode;
setWidgetStyle(removed[i]);
removed[i].changed();
yield removed[i];
i++;
continue;
}
// new widget
const widget = cm.addLineWidget(section.pos.line, buildElement(section), {
coverGutter: true,
noHScroll: true,
above: true
});
widget.section = section;
setWidgetStyle(widget);
yield widget;
}
removed.slice(i).forEach(clearWidget);
}
function clearWidget(widget) {
widget.clear();
widget.section.applies.forEach(clearApply);
}
function clearApply(apply) {
apply.type.mark.clear();
apply.value.mark.clear();
apply.mark.clear();
}
function setupMarkers({applies}) {
applies.forEach(setupApplyMarkers);
}
function setupApplyMarkers(apply) {
apply.type.mark = cm.markText(
cm.posFromIndex(apply.type.start),
cm.posFromIndex(apply.type.end),
{clearWhenEmpty: false}
);
apply.value.mark = cm.markText(
cm.posFromIndex(apply.value.start),
cm.posFromIndex(apply.value.end),
{clearWhenEmpty: false}
);
apply.mark = cm.markText(
cm.posFromIndex(apply.start),
cm.posFromIndex(apply.end),
{clearWhenEmpty: false}
);
}
function buildElement({applies}) {
const el = $element({className: 'applies-to', appendChild: [
$element({tag: 'label', appendChild: [
t('appliesLabel'),
// $element({tag: 'svg'})
]}),
$element({
tag: 'ul',
className: 'applies-to-list',
appendChild: applies.map(makeLi)
})
]});
if (!$('li', el)) {
$('ul', el).appendChild($element({
tag: 'li',
className: 'applies-to-everything',
textContent: t('appliesToEverything')
}));
}
return el;
function makeLi(apply) {
const el = $element({tag: 'li', appendChild: makeInput(apply)});
el.dataset.type = apply.type.text;
el.addEventListener('change', e => {
if (e.target.classList.contains('applies-type')) {
el.dataset.type = apply.type.text;
}
});
return el;
}
function makeInput(apply) {
const typeInput = $element({
tag: 'select',
className: 'applies-type',
appendChild: APPLIES_TYPE.map(([label, value]) => $element({
tag: 'option',
value: value,
textContent: label
})),
onchange() {
applyChange(apply.type, this.value);
}
});
typeInput.value = apply.type.text;
const valueInput = $element({
tag: 'input',
className: 'applies-value',
value: apply.value.text,
oninput() {
debounce(applyChange, THROTTLE_DELAY, apply.value, this.value);
},
onfocus: updateRegexpTest
});
const regexpTestButton = $element({
tag: 'button',
type: 'button',
className: 'applies-to-regexp-test',
textContent: t('styleRegexpTestButton'),
onclick() {
regExpTester.toggle();
regExpTester.update([apply.value.text]);
}
});
const removeButton = $element({
tag: 'button',
type: 'button',
className: 'applies-to-remove',
textContent: t('appliesRemove'),
onclick() {
const i = applies.indexOf(apply);
let repl;
let from;
let to;
if (applies.length < 2) {
messageBox({
contents: chrome.i18n.getMessage('appliesRemoveError'),
buttons: [t('confirmClose')]
});
return;
}
if (i === 0) {
from = apply.mark.find().from;
to = applies[i + 1].mark.find().from;
repl = '';
} else if (i === applies.length - 1) {
from = applies[i - 1].mark.find().to;
to = apply.mark.find().to;
repl = '';
} else {
from = applies[i - 1].mark.find().to;
to = applies[i + 1].mark.find().from;
repl = ', ';
}
cm.replaceRange(repl, from, to, 'appliesTo');
clearApply(apply);
this.closest('li').remove();
applies.splice(i, 1);
}
});
const addButton = $element({
tag: 'button',
type: 'button',
className: 'applies-to-add',
textContent: t('appliesAdd'),
onclick() {
const i = applies.indexOf(apply);
const pos = apply.mark.find().to;
const text = `, ${apply.type.text}("")`;
cm.replaceRange(text, pos, pos, 'appliesTo');
const newApply = createApply(
cm.indexFromPos(pos) + 2,
apply.type.text,
'',
true
);
setupApplyMarkers(newApply);
applies.splice(i + 1, 0, newApply);
this.closest('li').insertAdjacentElement('afterend', makeLi(newApply));
}
});
return [typeInput, valueInput, regexpTestButton, removeButton, addButton];
function updateRegexpTest() {
if (apply.type.text === 'regexp') {
const re = apply.value.text.trim();
if (re) {
regExpTester.update([re]);
} else {
regExpTester.update([]);
}
}
}
function applyChange(input, newText) {
const range = input.mark.find();
input.mark.clear();
cm.replaceRange(newText, range.from, range.to, 'appliesTo');
input.mark = cm.markText(
range.from,
cm.findPosH(range.from, newText.length, 'char'),
{clearWhenEmpty: false}
);
input.text = newText;
if (input === apply.type) {
const range = apply.mark.find();
apply.mark.clear();
apply.mark = cm.markText(
input.mark.find().from,
range.to,
{clearWhenEmpty: false}
);
}
updateRegexpTest();
}
}
}
function createApply(pos, typeText, valueText, isQuoted = false) {
const start = pos;
const typeStart = start;
const typeEnd = typeStart + typeText.length;
const valueStart = typeEnd + 1 + Number(isQuoted);
const valueEnd = valueStart + valueText.length;
const end = valueEnd + Number(isQuoted) + 1;
return {
start,
type: {
text: typeText,
start: typeStart,
end: typeEnd,
},
value: {
text: valueText,
start: valueStart,
end: valueEnd,
},
end
};
}
function *findAppliesTo(posStart, posEnd) {
const text = cm.getValue();
const re = /^[\t ]*@-moz-document\s+/mg;
const applyRe = /(url|url-prefix|domain|regexp)\(((['"])(?:\\\\|\\\n|\\\3|[^\n])*?\3|[^)\n]*)\)[\s,]*/iyg;
let match;
re.lastIndex = posStart;
while ((match = re.exec(text))) {
if (match.index >= posEnd) {
return;
}
const applies = [];
let m;
applyRe.lastIndex = re.lastIndex;
while ((m = applyRe.exec(text))) {
const apply = createApply(
m.index,
m[1],
unquote(m[2]),
unquote(m[2]) !== m[2]
);
applies.push(apply);
re.lastIndex = applyRe.lastIndex;
}
yield {
pos: cm.posFromIndex(match.index),
applies
};
}
}
function unquote(s) {
const first = s.charAt(0);
return (first === '"' || first === "'") && s.endsWith(first) ? s.slice(1, -1) : s;
}
}

View File

@ -0,0 +1,26 @@
.CodeMirror-hint:hover {
color: white;
background: #08f;
}
.CodeMirror {
border: solid #CCC 1px;
}
.CodeMirror-lint-mark-warning {
background: none;
}
.CodeMirror-dialog {
-webkit-animation: highlight 3s ease-out;
}
.CodeMirror-focused {
outline: -webkit-focus-ring-color auto 5px;
outline-offset: -2px;
}
.CodeMirror-search-field {
width: 10em;
}
.CodeMirror-jump-field {
width: 5em;
}
.CodeMirror-search-hint {
color: #888;
}

114
edit/codemirror-default.js Normal file
View File

@ -0,0 +1,114 @@
/* global CodeMirror prefs */
'use strict';
(function () {
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap');
}
const defaults = {
mode: 'css',
lineNumbers: true,
lineWrapping: true,
foldGutter: true,
gutters: [
'CodeMirror-linenumbers',
'CodeMirror-foldgutter',
...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []),
],
matchBrackets: true,
highlightSelectionMatches: {showToken: /[#.\-\w]/, annotateScrollbar: true},
hintOptions: {},
lintReportDelay: prefs.get('editor.lintReportDelay'),
styleActiveLine: true,
theme: 'default',
keyMap: prefs.get('editor.keyMap'),
extraKeys: {
// independent of current keyMap
'Alt-Enter': 'toggleStyle',
'Alt-PageDown': 'nextEditor',
'Alt-PageUp': 'prevEditor'
}
};
Object.assign(CodeMirror.defaults, defaults, prefs.get('editor.options'));
CodeMirror.commands.blockComment = cm => {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
};
// 'basic' keymap only has basic keys by design, so we skip it
const extraKeysCommands = {};
Object.keys(CodeMirror.defaults.extraKeys).forEach(key => {
extraKeysCommands[CodeMirror.defaults.extraKeys[key]] = true;
});
if (!extraKeysCommands.jumpToLine) {
CodeMirror.keyMap.sublime['Ctrl-G'] = 'jumpToLine';
CodeMirror.keyMap.emacsy['Ctrl-G'] = 'jumpToLine';
CodeMirror.keyMap.pcDefault['Ctrl-J'] = 'jumpToLine';
CodeMirror.keyMap.macDefault['Cmd-J'] = 'jumpToLine';
}
if (!extraKeysCommands.autocomplete) {
// will be used by 'sublime' on PC via fallthrough
CodeMirror.keyMap.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else
CodeMirror.keyMap.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap
CodeMirror.keyMap.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys
}
if (!extraKeysCommands.blockComment) {
CodeMirror.keyMap.sublime['Shift-Ctrl-/'] = 'blockComment';
}
if (navigator.appVersion.includes('Windows')) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3
if (!extraKeysCommands.findNext) {
CodeMirror.keyMap.pcDefault['F3'] = 'findNext';
}
if (!extraKeysCommands.findPrev) {
CodeMirror.keyMap.pcDefault['Shift-F3'] = 'findPrev';
}
// try to remap non-interceptable Ctrl-(Shift-)N/T/W hotkeys
['N', 'T', 'W'].forEach(char => {
[
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
// Note: modifier order in CodeMirror is S-C-A
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}
].forEach(remap => {
const oldKey = remap.from + char;
Object.keys(CodeMirror.keyMap).forEach(keyMapName => {
const keyMap = CodeMirror.keyMap[keyMapName];
const command = keyMap[oldKey];
if (!command) {
return;
}
remap.to.some(newMod => {
const newKey = newMod + char;
if (!(newKey in keyMap)) {
delete keyMap[oldKey];
keyMap[newKey] = command;
return true;
}
});
});
});
});
}
CodeMirror.modeURL = '/vendor/codemirror/mode/%N/%N.js';
const MODE = {
stylus: 'stylus',
uso: 'css'
};
CodeMirror.defineExtension('setPreprocessor', function (preprocessor) {
this.setOption('mode', MODE[preprocessor] || 'css');
CodeMirror.autoLoadMode(this, MODE[preprocessor] || 'css');
});
})();

View File

@ -141,6 +141,10 @@ h2 .svg-icon, label .svg-icon {
content: "";
opacity: .15;
}
/* footer */
#footer {
margin-top: 1em;
}
/************ content ***********/
#sections > div {
margin: 0.7rem;
@ -174,18 +178,11 @@ h2 .svg-icon, label .svg-icon {
margin-left: 0.25rem;
}
/* code */
.CodeMirror-hint:hover {
color: white;
background: #08f;
}
.code {
height: 10rem;
width: 40rem;
}
.CodeMirror {
border: solid #CCC 1px;
}
.CodeMirror-scroll {
.resize-grip-enabled .CodeMirror-scroll {
height: auto !important;;
position: absolute !important;
top: 0;
@ -193,34 +190,15 @@ h2 .svg-icon, label .svg-icon {
right: 0;
bottom: 6px; /* resize-grip height */
}
.CodeMirror-lint-mark-warning {
background: none;
}
.CodeMirror-vscrollbar {
.resize-grip-enabled .CodeMirror-vscrollbar {
margin-bottom: 7px; /* make space for resize-grip */
}
.CodeMirror-hscrollbar {
.resize-grip-enabled .CodeMirror-hscrollbar {
bottom: 7px; /* make space for resize-grip */
}
.CodeMirror-scrollbar-filler {
.resize-grip-enabled .CodeMirror-scrollbar-filler {
bottom: 7px; /* make space for resize-grip */
}
.CodeMirror-dialog {
-webkit-animation: highlight 3s ease-out;
}
.CodeMirror-focused {
outline: -webkit-focus-ring-color auto 5px;
outline-offset: -2px;
}
.CodeMirror-search-field {
width: 10em;
}
.CodeMirror-jump-field {
width: 5em;
}
.CodeMirror-search-hint {
color: #888;
}
body[data-match-highlight="token"] .cm-matchhighlight-approved .cm-matchhighlight,
body[data-match-highlight="token"] .CodeMirror-selection-highlight-scrollbar {
animation: fadein-match-highlighter 1s cubic-bezier(.97,.01,.42,.98);
@ -536,6 +514,40 @@ body[data-match-highlight="selection"] .CodeMirror-selection-highlight-scrollbar
background-color: rgba(0, 0, 0, 0.05);
}
/************ single editor **************/
#sections .single-editor {
margin: 0;
height: 100%;
box-sizing: border-box;
}
.single-editor .CodeMirror {
height: 100%;
}
/************ line widget *************/
.CodeMirror-linewidget .applies-to {
margin: 1em 0;
padding: 1em;
padding-right: calc(1em + 20px);
}
.CodeMirror-linewidget .applies-to li {
margin: 0;
}
.CodeMirror-linewidget .applies-to li + li {
margin-top: 0.35rem;
}
.CodeMirror-linewidget .applies-to li:not([data-type="regexp"]) .applies-to-regexp-test {
display: none;
}
.CodeMirror-linewidget li.applies-to-everything {
margin-top: 0.2rem;
}
/************ reponsive layouts ************/
@media(max-width:737px) {
#header {

View File

@ -1,8 +1,10 @@
/* eslint brace-style: 0, operator-linebreak: 0 */
/* global CodeMirror parserlib */
/* global onDOMscripted */
/* global loadScript */
/* global css_beautify */
/* global CSSLint initLint linterConfig updateLintReport renderLintReport updateLinter */
/* global mozParser createSourceEditor */
/* global closeCurrentTab regExpTester messageBox */
'use strict';
let styleId = null;
@ -18,6 +20,8 @@ let useHistoryBack;
const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'};
const CssToProperty = {'url': 'urls', 'url-prefix': 'urlPrefixes', 'domain': 'domains', 'regexp': 'regexps'};
let editor;
// if background page hasn't been loaded yet, increase the chances it has before DOMContentLoaded
onBackgroundReady();
@ -160,111 +164,16 @@ function setCleanSection(section) {
function initCodeMirror() {
const CM = CodeMirror;
const isWindowsOS = navigator.appVersion.indexOf('Windows') > 0;
// lint.js is not loaded initially
// CodeMirror miserably fails on keyMap='' so let's ensure it's not
if (!prefs.get('editor.keyMap')) {
prefs.reset('editor.keyMap');
}
// default option values
Object.assign(CM.defaults, {
mode: 'css',
lineNumbers: true,
lineWrapping: true,
foldGutter: true,
gutters: [
'CodeMirror-linenumbers',
'CodeMirror-foldgutter',
...(prefs.get('editor.linter') ? ['CodeMirror-lint-markers'] : []),
],
matchBrackets: true,
highlightSelectionMatches: {showToken: /[#.\-\w]/, annotateScrollbar: true},
hintOptions: {},
lint: linterConfig.getForCodeMirror(),
lintReportDelay: prefs.get('editor.lintReportDelay'),
styleActiveLine: true,
theme: 'default',
keyMap: prefs.get('editor.keyMap'),
extraKeys: {
// independent of current keyMap
'Alt-Enter': 'toggleStyle',
'Alt-PageDown': 'nextEditor',
'Alt-PageUp': 'prevEditor'
}
}, prefs.get('editor.options'));
CM.defaults.lint = linterConfig.getForCodeMirror();
// additional commands
CM.commands.jumpToLine = jumpToLine;
CM.commands.nextEditor = cm => nextPrevEditor(cm, 1);
CM.commands.prevEditor = cm => nextPrevEditor(cm, -1);
CM.commands.save = save;
CM.commands.blockComment = cm => {
cm.blockComment(cm.getCursor('from'), cm.getCursor('to'), {fullLines: false});
};
CM.commands.toggleStyle = toggleStyle;
// 'basic' keymap only has basic keys by design, so we skip it
const extraKeysCommands = {};
Object.keys(CM.defaults.extraKeys).forEach(key => {
extraKeysCommands[CM.defaults.extraKeys[key]] = true;
});
if (!extraKeysCommands.jumpToLine) {
CM.keyMap.sublime['Ctrl-G'] = 'jumpToLine';
CM.keyMap.emacsy['Ctrl-G'] = 'jumpToLine';
CM.keyMap.pcDefault['Ctrl-J'] = 'jumpToLine';
CM.keyMap.macDefault['Cmd-J'] = 'jumpToLine';
}
if (!extraKeysCommands.autocomplete) {
// will be used by 'sublime' on PC via fallthrough
CM.keyMap.pcDefault['Ctrl-Space'] = 'autocomplete';
// OSX uses Ctrl-Space and Cmd-Space for something else
CM.keyMap.macDefault['Alt-Space'] = 'autocomplete';
// copied from 'emacs' keymap
CM.keyMap.emacsy['Alt-/'] = 'autocomplete';
// 'vim' and 'emacs' define their own autocomplete hotkeys
}
if (!extraKeysCommands.blockComment) {
CM.keyMap.sublime['Shift-Ctrl-/'] = 'blockComment';
}
if (isWindowsOS) {
// 'pcDefault' keymap on Windows should have F3/Shift-F3
if (!extraKeysCommands.findNext) {
CM.keyMap.pcDefault['F3'] = 'findNext';
}
if (!extraKeysCommands.findPrev) {
CM.keyMap.pcDefault['Shift-F3'] = 'findPrev';
}
// try to remap non-interceptable Ctrl-(Shift-)N/T/W hotkeys
['N', 'T', 'W'].forEach(char => {
[
{from: 'Ctrl-', to: ['Alt-', 'Ctrl-Alt-']},
// Note: modifier order in CM is S-C-A
{from: 'Shift-Ctrl-', to: ['Ctrl-Alt-', 'Shift-Ctrl-Alt-']}
].forEach(remap => {
const oldKey = remap.from + char;
Object.keys(CM.keyMap).forEach(keyMapName => {
const keyMap = CM.keyMap[keyMapName];
const command = keyMap[oldKey];
if (!command) {
return;
}
remap.to.some(newMod => {
const newKey = newMod + char;
if (!(newKey in keyMap)) {
delete keyMap[oldKey];
keyMap[newKey] = command;
return true;
}
});
});
});
});
}
// user option values
CM.getOption = o => CodeMirror.defaults[o];
CM.setOption = (o, v) => {
@ -434,11 +343,7 @@ function acmeEventListener(event) {
return;
}
case 'autocompleteOnTyping':
editors.forEach(cm => {
const onOff = el.checked ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
});
editors.forEach(cm => setupAutocomplete(cm, el.checked));
return;
case 'matchHighlight':
switch (value) {
@ -463,8 +368,7 @@ function setupCodeMirror(textarea, index) {
cm.on('changes', indicateCodeChangeDebounced);
if (prefs.get('editor.autocompleteOnTyping')) {
cm.on('changes', autocompleteOnTyping);
cm.on('pick', autocompletePicked);
setupAutocomplete(cm);
}
wrapper.addEventListener('keydown', event => nextPrevEditorOnKeydown(cm, event), true);
cm.on('blur', () => {
@ -504,6 +408,7 @@ function setupCodeMirror(textarea, index) {
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
}
wrapper.classList.add('resize-grip-enabled');
let lastClickTime = 0;
const resizeGrip = wrapper.appendChild(template.resizeGrip.cloneNode(true));
resizeGrip.onmousedown = event => {
@ -671,12 +576,20 @@ window.onbeforeunload = () => {
rememberWindowSize();
}
document.activeElement.blur();
if (isCleanGlobal()) {
if (isClean()) {
return;
}
updateLintReportIfEnabled(null, 0);
// neither confirm() nor custom messages work in modern browsers but just in case
return t('styleChangesNotSaved');
function isClean() {
if (editor) {
return !editor.isDirty();
} else {
return isCleanGlobal();
}
}
};
function addAppliesTo(list, name, value) {
@ -737,20 +650,30 @@ function addSection(event, section) {
toggleTestRegExpVisibility();
appliesTo.addEventListener('change', toggleTestRegExpVisibility);
$('.test-regexp', div).onclick = showRegExpTester;
function toggleTestRegExpVisibility() {
const show = [...appliesTo.children].some(item =>
$('.test-regexp', div).onclick = () => {
regExpTester.toggle();
regExpTester.update(getRegExps());
};
function getRegExps() {
return [...appliesTo.children]
.map(item =>
!item.matches('.applies-to-everything') &&
$('.applies-type', item).value === 'regexp' &&
$('.applies-value', item).value.trim()
);
)
.filter(item => item);
}
function toggleTestRegExpVisibility() {
const show = getRegExps().length > 0;
div.classList.toggle('has-regexp', show);
appliesTo.oninput = appliesTo.oninput || show && (event => {
if (
event.target.matches('.applies-value') &&
$('.applies-type', event.target.parentElement).value === 'regexp'
) {
showRegExpTester(null, div);
regExpTester.update(getRegExps());
}
});
}
@ -1075,6 +998,14 @@ function jumpToLine(cm) {
}
function toggleStyle() {
if (editor) {
editor.toggleStyle();
} else {
toggleSectionStyle();
}
}
function toggleSectionStyle() {
$('#enabled').checked = !$('#enabled').checked;
save();
}
@ -1100,6 +1031,12 @@ function toggleSectionHeight(cm) {
}
}
function setupAutocomplete(cm, enable = true) {
const onOff = enable ? 'on' : 'off';
cm[onOff]('changes', autocompleteOnTyping);
cm[onOff]('pick', autocompletePicked);
}
function autocompleteOnTyping(cm, [info], debounced) {
if (
cm.state.completionActive ||
@ -1266,14 +1203,13 @@ function getEditorInSight(nearbyElement) {
}
function beautify(event) {
onDOMscripted([
'vendor-overwrites/beautify/beautify-css-mod.js',
() => {
loadScript('/vendor-overwrites/beautify/beautify-css-mod.js')
.then(() => {
if (!window.css_beautify && window.exports) {
window.css_beautify = window.exports.css_beautify;
}
},
]).then(doBeautify);
})
.then(doBeautify);
function doBeautify() {
const tabs = prefs.get('editor.indentWithTabs');
@ -1361,46 +1297,52 @@ onDOMready().then(init);
function init() {
initCodeMirror();
const params = getParams();
if (!params.id) {
getStyle().then(style => {
styleId = style.id;
sessionStorage.justEditedStyleId = styleId;
if (!isUsercss(style)) {
initWithSectionStyle({style});
} else {
editor = createSourceEditor(style);
}
});
function getStyle() {
const id = new URLSearchParams(location.search).get('id');
if (!id) {
// match should be 2 - one for the whole thing, one for the parentheses
// This is an add
$('#heading').textContent = t('addStyleTitle');
const section = {code: ''};
for (const i in CssToProperty) {
if (params[i]) {
section[CssToProperty[i]] = [params[i]];
return Promise.resolve(createEmptyStyle());
}
}
addSection(null, section);
editors[0].setOption('lint', CodeMirror.defaults.lint);
editors[0].focus();
// default to enabled
$('#enabled').checked = true;
initHooks();
setCleanGlobal();
updateTitle();
return;
}
// This is an edit
$('#heading').textContent = t('editStyleHeading');
getStylesSafe({id: params.id}).then(styles => {
// This is an edit
return getStylesSafe({id}).then(styles => {
let style = styles[0];
if (!style) {
style = {id: null, sections: []};
style = createEmptyStyle();
history.replaceState({}, document.title, location.pathname);
}
styleId = style.id;
sessionStorage.justEditedStyleId = styleId;
setStyleMeta(style);
window.onload = () => {
window.onload = null;
initWithStyle({style});
};
if (document.readyState !== 'loading') {
window.onload();
}
return style;
});
}
function createEmptyStyle() {
const params = new URLSearchParams(location.search);
const style = {
id: null,
name: '',
enabled: true,
sections: [{code: ''}]
};
for (const i in CssToProperty) {
if (params.get(i)) {
style.sections[0][CssToProperty[i]] = [params.get(i)];
}
}
return style;
}
}
function setStyleMeta(style) {
@ -1409,7 +1351,14 @@ function setStyleMeta(style) {
$('#url').href = style.url || '';
}
function initWithStyle({style, codeIsUpdated}) {
function isUsercss(style) {
return (
style.usercssData ||
!style.id && prefs.get('newStyleAsUsercss')
);
}
function initWithSectionStyle({style, codeIsUpdated}) {
setStyleMeta(style);
if (codeIsUpdated === false) {
@ -1452,6 +1401,16 @@ function initWithStyle({style, codeIsUpdated}) {
}
}
function setupOptionsExpand() {
$('#options').open = prefs.get('editor.options.expanded');
$('#options h2').addEventListener('click', () => {
setTimeout(() => prefs.set('editor.options.expanded', $('#options').open));
});
prefs.subscribe(['editor.options.expanded'], (key, value) => {
$('#options').open = value;
});
}
function initHooks() {
if (initHooks.alreadyDone) {
return;
@ -1471,14 +1430,7 @@ function initHooks() {
$('#keyMap-help').addEventListener('click', showKeyMapHelp, false);
$('#cancel-button').addEventListener('click', goBackToManage);
$('#options').open = prefs.get('editor.options.expanded');
$('#options h2').addEventListener('click', () => {
setTimeout(() => prefs.set('editor.options.expanded', $('#options').open));
});
prefs.subscribe(['editor.options.expanded'], (key, value) => {
$('#options').open = value;
});
setupOptionsExpand();
initLint();
if (!FIREFOX) {
@ -1605,6 +1557,14 @@ function updateLintReportIfEnabled(...args) {
}
function save() {
if (editor) {
editor.save();
} else {
saveSectionStyle();
}
}
function saveSectionStyle() {
updateLintReportIfEnabled(null, 0);
// save the contents of the CodeMirror editors back into the textareas
@ -1679,17 +1639,7 @@ function showMozillaFormat() {
}
function toMozillaFormat() {
return getSectionsHashes().map(section => {
let cssMds = [];
for (const i in propertyToCss) {
if (section[i]) {
cssMds = cssMds.concat(section[i].map(v =>
propertyToCss[i] + '("' + v.replace(/\\/g, '\\\\') + '")'
));
}
}
return cssMds.length ? '@-moz-document ' + cssMds.join(', ') + ' {\n' + section.code + '\n}' : section.code;
}).join('\n\n');
return mozParser.format({sections: getSectionsHashes()});
}
function fromMozillaFormat() {
@ -1714,133 +1664,29 @@ function fromMozillaFormat() {
});
function doImport(event) {
// parserlib contained in CSSLint-worker.js
onDOMscripted(['vendor-overwrites/csslint/csslint-worker.js']).then(() => {
doImportWhenReady(event.target);
const replaceOldStyle = event.target.name === 'import-replace';
const mozStyle = trimNewLines(popup.codebox.getValue());
mozParser.parse(mozStyle)
.then(updateSection)
.then(() => {
editors.forEach(cm => updateLintReportIfEnabled(cm, 1));
editors.last.state.renderLintReportNow = true;
});
}
function doImportWhenReady(target) {
const replaceOldStyle = target.name === 'import-replace';
$('.dismiss', popup).onclick();
const mozStyle = trimNewLines(popup.codebox.getValue());
const parser = new parserlib.css.Parser();
const lines = mozStyle.split('\n');
const sectionStack = [{code: '', start: {line: 1, col: 1}}];
const errors = [];
// let oldSectionCount = editors.length;
let firstAddedCM;
})
.catch(showError);
parser.addListener('startdocument', function (e) {
let outerText = getRange(sectionStack.last.start, (--e.col, e));
const gapComment = outerText.match(/(\/\*[\s\S]*?\*\/)[\s\n]*$/);
const section = {code: '', start: backtrackTo(this, parserlib.css.Tokens.LBRACE, 'end')};
// move last comment before @-moz-document inside the section
if (gapComment && !gapComment[1].match(/\/\*\s*AGENT_SHEET\s*\*\//)) {
section.code = gapComment[1] + '\n';
outerText = trimNewLines(outerText.substring(0, gapComment.index));
function showError(errors) {
if (!Array.isArray(errors)) {
errors = [errors];
}
if (outerText.trim()) {
sectionStack.last.code = outerText;
doAddSection(sectionStack.last);
sectionStack.last.code = '';
}
for (const f of e.functions) {
const m = f && f.match(/^([\w-]*)\((['"]?)(.+?)\2?\)$/);
if (!m || !/^(url|url-prefix|domain|regexp)$/.test(m[1])) {
errors.push(`${e.line}:${e.col + 1} invalid function "${m ? m[1] : f || ''}"`);
continue;
}
const aType = CssToProperty[m[1]];
const aValue = aType !== 'regexps' ? m[3] : m[3].replace(/\\\\/g, '\\');
(section[aType] = section[aType] || []).push(aValue);
}
sectionStack.push(section);
});
parser.addListener('enddocument', function () {
const end = backtrackTo(this, parserlib.css.Tokens.RBRACE, 'start');
const section = sectionStack.pop();
section.code += getRange(section.start, end);
sectionStack.last.start = (++end.col, end);
doAddSection(section);
});
parser.addListener('endstylesheet', () => {
// add nonclosed outer sections (either broken or the last global one)
const endOfText = {line: lines.length, col: lines.last.length + 1};
sectionStack.last.code += getRange(sectionStack.last.start, endOfText);
sectionStack.forEach(doAddSection);
delete maximizeCodeHeight.stats;
editors.forEach(cm => {
maximizeCodeHeight(cm.getSection(), cm === editors.last);
});
makeSectionVisible(firstAddedCM);
firstAddedCM.focus();
if (errors.length) {
showHelp(t('linterIssues'), $element({
showHelp(t('styleFromMozillaFormatError'), $element({
tag: 'pre',
textContent: errors.join('\n'),
}));
}
});
parser.addListener('error', e => {
errors.push(e.line + ':' + e.col + ' ' +
e.message.replace(/ at line \d.+$/, ''));
});
parser.parse(mozStyle);
function getRange(start, end) {
const L1 = start.line - 1;
const C1 = start.col - 1;
const L2 = end.line - 1;
const C2 = end.col - 1;
if (L1 === L2) {
return lines[L1].substr(C1, C2 - C1 + 1);
} else {
const middle = lines.slice(L1 + 1, L2).join('\n');
return lines[L1].substr(C1) + '\n' + middle +
(L2 >= lines.length ? '' : ((middle ? '\n' : '') + lines[L2].substring(0, C2)));
}
}
function doAddSection(section) {
section.code = section.code.trim();
// don't add empty sections
if (
!section.code &&
!section.urls &&
!section.urlPrefixes &&
!section.domains &&
!section.regexps
) {
return;
}
if (!firstAddedCM) {
if (!initFirstSection(section)) {
return;
}
}
setCleanItem(addSection(null, section), false);
firstAddedCM = firstAddedCM || editors.last;
}
// do onetime housekeeping as the imported text is confirmed to be a valid style
function initFirstSection(section) {
// skip adding the first global section when there's no code/comments
if (
/* ignore boilerplate NS */
!section.code.replace('@namespace url(http://www.w3.org/1999/xhtml);', '')
/* ignore all whitespace including new lines */
.replace(/[\s\n]/g, '')
) {
return false;
}
function updateSection(sections) {
if (replaceOldStyle) {
editors.slice(0).reverse().forEach(cm => {
removeSection({target: cm.getSection().firstElementChild});
@ -1851,17 +1697,24 @@ function fromMozillaFormat() {
removeSection({target: editors.last.getSection()});
}
}
return true;
}
}
function backtrackTo(parser, tokenType, startEnd) {
const tokens = parser._tokenStream._lt;
for (let i = parser._tokenStream._ltIndex - 1; i >= 0; --i) {
if (tokens[i].type === tokenType) {
return {line: tokens[i][startEnd + 'Line'], col: tokens[i][startEnd + 'Col']};
}
const firstSection = sections[0];
setCleanItem(addSection(null, firstSection), false);
const firstAddedCM = editors.last;
for (const section of sections.slice(1)) {
setCleanItem(addSection(null, section), false);
}
delete maximizeCodeHeight.stats;
editors.forEach(cm => {
maximizeCodeHeight(cm.getSection(), cm === editors.last);
});
makeSectionVisible(firstAddedCM);
firstAddedCM.focus();
}
}
function trimNewLines(s) {
return s.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, '');
}
@ -1984,151 +1837,6 @@ function showKeyMapHelp() {
}
}
function showRegExpTester(event, section = getSectionForChild(this)) {
const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain=';
const OWN_ICON = chrome.runtime.getManifest().icons['16'];
const cachedRegexps = showRegExpTester.cachedRegexps =
showRegExpTester.cachedRegexps || new Map();
const regexps = [...$('.applies-to-list', section).children]
.map(item =>
!item.matches('.applies-to-everything') &&
$('.applies-type', item).value === 'regexp' &&
$('.applies-value', item).value.trim()
)
.filter(item => item)
.map(text => {
const rxData = Object.assign({text}, cachedRegexps.get(text));
if (!rxData.urls) {
cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome, see detectSloppyRegexps()
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));
}
return rxData;
});
chrome.tabs.onUpdated.addListener(function _(tabId, info) {
if ($('.regexp-report')) {
if (info.url) {
showRegExpTester(event, section);
}
} else {
chrome.tabs.onUpdated.removeListener(_);
}
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
queryTabs().then(tabs => {
const supported = tabs.map(tab => tab.url)
.filter(url => URLS.supported(url));
const unique = [...new Set(supported).values()];
for (const rxData of regexps) {
const {rx, urls} = rxData;
if (rx) {
const urlsNow = new Map();
for (const url of unique) {
const match = urls.get(url) || getMatchInfo(url.match(rx));
if (match) {
urlsNow.set(url, match);
}
}
rxData.urls = urlsNow;
}
}
const stats = {
full: {data: [], label: t('styleRegexpTestFull')},
partial: {data: [], label: [
t('styleRegexpTestPartial'),
template.regexpTestPartial.cloneNode(true),
]},
none: {data: [], label: t('styleRegexpTestNone')},
invalid: {data: [], label: t('styleRegexpTestInvalid')},
};
// collect stats
for (const {text, rx, urls} of regexps) {
if (!rx) {
stats.invalid.data.push({text});
continue;
}
if (!urls.size) {
stats.none.data.push({text});
continue;
}
const full = [];
const partial = [];
for (const [url, match] of urls.entries()) {
const faviconUrl = url.startsWith(URLS.ownOrigin)
? OWN_ICON
: GET_FAVICON_URL + new URL(url).hostname;
const icon = $element({tag: 'img', src: faviconUrl});
if (match.text.length === url.length) {
full.push($element({appendChild: [
icon,
url,
]}));
} else {
partial.push($element({appendChild: [
icon,
url.substr(0, match.pos),
$element({tag: 'mark', textContent: match.text}),
url.substr(match.pos + match.text.length),
]}));
}
}
if (full.length) {
stats.full.data.push({text, urls: full});
}
if (partial.length) {
stats.partial.data.push({text, urls: partial});
}
}
// render stats
const report = $element({className: 'regexp-report'});
const br = $element({tag: 'br'});
for (const type in stats) {
// top level groups: full, partial, none, invalid
const {label, data} = stats[type];
if (!data.length) {
continue;
}
const block = report.appendChild($element({
tag: 'details',
open: true,
dataset: {type},
appendChild: $element({tag: 'summary', appendChild: label}),
}));
// 2nd level: regexp text
for (const {text, urls} of data) {
if (urls) {
// type is partial or full
block.appendChild($element({
tag: 'details',
open: true,
appendChild: [
$element({tag: 'summary', textContent: text}),
// 3rd level: tab urls
...urls,
],
}));
} else {
// type is none or invalid
block.appendChild(document.createTextNode(text));
block.appendChild(br.cloneNode());
}
}
}
showHelp(t('styleRegexpTestTitle'), report);
$('.regexp-report').onclick = event => {
const target = event.target.closest('a, .regexp-report div');
if (target) {
openURL({url: target.href || target.textContent});
event.preventDefault();
}
};
});
}
function showHelp(title, body) {
const div = $('#help-popup');
div.classList.remove('big');
@ -2182,40 +1890,55 @@ function showCodeMirrorPopup(title, html, options) {
return popup;
}
function getParams() {
const params = {};
const urlParts = location.href.split('?', 2);
if (urlParts.length === 1) {
return params;
}
urlParts[1].split('&').forEach(keyValue => {
const splitKeyValue = keyValue.split('=', 2);
params[decodeURIComponent(splitKeyValue[0])] = decodeURIComponent(splitKeyValue[1]);
});
return params;
}
chrome.runtime.onMessage.addListener(onRuntimeMessage);
function replaceStyle(request) {
const codeIsUpdated = request.codeIsUpdated !== false;
if (!isUsercss(request.style)) {
initWithSectionStyle(request);
return;
}
if (!codeIsUpdated) {
editor.replaceMeta(request.style);
return;
}
askDiscardChanges()
.then(result => {
if (result) {
editor.replaceStyle(request.style);
} else {
editor.setStyleDirty(request.style);
}
});
function askDiscardChanges() {
if (!editor.isTouched()) {
return Promise.resolve(true);
}
return messageBox.confirm(t('styleUpdateDiscardChanges'));
}
}
function onRuntimeMessage(request) {
switch (request.method) {
case 'styleUpdated':
if (styleId && styleId === request.style.id && request.reason !== 'editSave') {
if (styleId && styleId === request.style.id && request.reason !== 'editSave' && request.reason !== 'config') {
if ((request.style.sections[0] || {}).code === null) {
// the code-less style came from notifyAllTabs
onBackgroundReady().then(() => {
request.style = BG.cachedStyles.byId.get(request.style.id);
initWithStyle(request);
replaceStyle(request);
});
} else {
initWithStyle(request);
replaceStyle(request);
}
}
break;
case 'styleDeleted':
if (styleId && styleId === request.id) {
if (styleId === request.id || editor && editor.getStyle().id === request.id) {
window.onbeforeunload = () => {};
window.close();
closeCurrentTab();
break;
}
break;

View File

@ -27,7 +27,7 @@ window.linterConfig.defaults.stylelint = (defaultSeverity => ({
'property-no-unknown': [true, defaultSeverity],
'selector-pseudo-class-no-unknown': [true, defaultSeverity],
'selector-pseudo-element-no-unknown': [true, defaultSeverity],
'selector-type-no-unknown': [true, defaultSeverity],
'selector-type-no-unknown': false, // for scss/less/stylus-lang
'string-no-newline': [true, defaultSeverity],
'unit-no-unknown': [true, defaultSeverity],

View File

@ -1,9 +1,10 @@
/* global CodeMirror messageBox */
/* global editors makeSectionVisible showCodeMirrorPopup showHelp */
/* global onDOMscripted injectCSS require CSSLint stylelint */
/* global loadScript require CSSLint stylelint */
/* global makeLink */
'use strict';
loadLinterAssets();
onDOMready().then(loadLinterAssets);
// eslint-disable-next-line no-var
var linterConfig = {
@ -20,18 +21,27 @@ var linterConfig = {
stylelint: 'editorStylelintConfig',
},
getCurrent(linter = prefs.get('editor.linter')) {
getDefault() {
// some dirty hacks to override editor.linter getting from prefs
const linter = prefs.get('editor.linter');
if (linter && editors[0] && editors[0].getOption('mode') !== 'css') {
return 'stylelint';
}
return linter;
},
getCurrent(linter = linterConfig.getDefault()) {
return this.fallbackToDefaults(this[linter] || {});
},
getForCodeMirror(linter = prefs.get('editor.linter')) {
getForCodeMirror(linter = linterConfig.getDefault()) {
return CodeMirror.lint && CodeMirror.lint[linter] ? {
getAnnotations: CodeMirror.lint[linter],
delay: prefs.get('editor.lintDelay'),
} : false;
},
fallbackToDefaults(config, linter = prefs.get('editor.linter')) {
fallbackToDefaults(config, linter = linterConfig.getDefault()) {
if (config && Object.keys(config).length) {
if (linter === 'stylelint') {
// always use default syntax because we don't expose it in config UI
@ -43,16 +53,16 @@ var linterConfig = {
}
},
setLinter(linter = prefs.get('editor.linter')) {
setLinter(linter = linterConfig.getDefault()) {
linter = linter.toLowerCase();
linter = linter === 'csslint' || linter === 'stylelint' ? linter : '';
if (prefs.get('editor.linter') !== linter) {
if (linterConfig.getDefault() !== linter) {
prefs.set('editor.linter', linter);
}
return linter;
},
findInvalidRules(config, linter = prefs.get('editor.linter')) {
findInvalidRules(config, linter = linterConfig.getDefault()) {
const rules = linter === 'stylelint' ? config.rules : config;
const allRules = new Set(
linter === 'stylelint'
@ -63,7 +73,7 @@ var linterConfig = {
},
stringify(config = this.getCurrent()) {
if (prefs.get('editor.linter') === 'stylelint') {
if (linterConfig.getDefault() === 'stylelint') {
config.syntax = undefined;
}
return JSON.stringify(config, null, 2)
@ -72,7 +82,7 @@ var linterConfig = {
save(config) {
config = this.fallbackToDefaults(config);
const linter = prefs.get('editor.linter');
const linter = linterConfig.getDefault();
this[linter] = config;
BG.chromeSync.setLZValue(this.storageName[linter], config);
return config;
@ -117,6 +127,13 @@ var linterConfig = {
}
}, 2000);
},
init() {
if (!linterConfig.init.pending) {
linterConfig.init.pending = linterConfig.loadAll();
}
return linterConfig.init.pending;
}
};
function initLint() {
@ -130,21 +147,22 @@ function initLint() {
$('#lint h2').addEventListener('click', toggleLintReport);
}
linterConfig.loadAll();
updateLinter();
linterConfig.watchStorage();
prefs.subscribe(['editor.linter'], updateLinter);
updateLinter();
}
function updateLinter({immediately} = {}) {
function updateLinter({immediately, linter = linterConfig.getDefault()} = {}) {
if (!immediately) {
debounce(updateLinter, 0, {immediately: true});
debounce(updateLinter, 0, {immediately: true, linter});
return;
}
const linter = prefs.get('editor.linter');
const GUTTERS_CLASS = 'CodeMirror-lint-markers';
loadLinterAssets(linter).then(updateEditors);
Promise.all([
linterConfig.init(),
loadLinterAssets(linter)
]).then(updateEditors);
$('#linter-settings').style.display = !linter ? 'none' : 'inline-block';
$('#lint').style.display = 'none';
@ -357,13 +375,7 @@ function toggleLintReport() {
}
function showLintHelp() {
const makeLink = (href, textContent) => $element({
tag: 'a',
target: '_blank',
href,
textContent,
});
const linter = prefs.get('editor.linter');
const linter = linterConfig.getDefault();
const baseUrl = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
// some CSSLint rules do not have a url
@ -451,7 +463,7 @@ function setupLinterSettingsEvents(popup) {
}
function setupLinterPopup(config) {
const linter = prefs.get('editor.linter');
const linter = linterConfig.getDefault();
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
function makeButton(className, text, options = {}) {
@ -503,43 +515,48 @@ function setupLinterPopup(config) {
$('.save', popup).disabled = cm.isClean();
});
setupLinterSettingsEvents(popup);
onDOMscripted([
'vendor/codemirror/mode/javascript/javascript.js',
'vendor/codemirror/addon/lint/json-lint.js',
'vendor/jsonlint/jsonlint.js'
loadScript([
'/vendor/codemirror/mode/javascript/javascript.js',
'/vendor/codemirror/addon/lint/json-lint.js',
'/vendor/jsonlint/jsonlint.js'
]).then(() => {
popup.codebox.setOption('mode', 'application/json');
popup.codebox.setOption('lint', 'json');
});
}
function loadLinterAssets(name = prefs.get('editor.linter')) {
if (loadLinterAssets.loadingName === name) {
return onDOMscripted();
function loadLinterAssets(name = linterConfig.getDefault()) {
if (!name) {
return Promise.resolve();
}
loadLinterAssets.loadingName = name;
const scripts = [];
return loadLibrary().then(loadAddon);
function loadLibrary() {
if (name === 'csslint' && !window.CSSLint) {
scripts.push(
'vendor-overwrites/csslint/csslint-worker.js',
'edit/lint-defaults-csslint.js'
);
} else if (name === 'stylelint' && !window.stylelint) {
scripts.push(
'vendor-overwrites/stylelint/stylelint-bundle.min.js',
() => (window.stylelint = require('stylelint')),
'edit/lint-defaults-stylelint.js'
);
return loadScript([
'/vendor-overwrites/csslint/csslint-worker.js',
'/edit/lint-defaults-csslint.js'
]);
}
if (name && !$('script[src$="vendor/codemirror/addon/lint/lint.js"]')) {
injectCSS('vendor/codemirror/addon/lint/lint.css');
injectCSS('msgbox/msgbox.css');
scripts.push(
'vendor/codemirror/addon/lint/lint.js',
'edit/lint-codemirror-helper.js',
'msgbox/msgbox.js'
);
if (name === 'stylelint' && !window.stylelint) {
return loadScript([
'/vendor-overwrites/stylelint/stylelint-bundle.min.js',
'/edit/lint-defaults-stylelint.js'
]).then(() => (window.stylelint = require('stylelint')));
}
return Promise.resolve();
}
function loadAddon() {
if (CodeMirror.lint) {
return;
}
return loadScript([
'/vendor/codemirror/addon/lint/lint.css',
'/msgbox/msgbox.css',
'/vendor/codemirror/addon/lint/lint.js',
'/edit/lint-codemirror-helper.js',
'/msgbox/msgbox.js'
]);
}
return onDOMscripted(scripts)
.then(() => (loadLinterAssets.loadingName = null));
}

181
edit/regexp-tester.js Normal file
View File

@ -0,0 +1,181 @@
/* global showHelp */
'use strict';
// eslint-disable-next-line no-var
var 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;
function init() {
isInit = true;
chrome.tabs.onUpdated.addListener(onTabUpdate);
}
function uninit() {
chrome.tabs.onUpdated.removeListener(onTabUpdate);
isInit = false;
}
function onTabUpdate(tabId, info) {
if (info.url) {
update();
}
}
function isShowed() {
return Boolean($('.regexp-report'));
}
function toggle(state = !isShowed()) {
if (state && !isShowed()) {
if (!isInit) {
init();
}
showHelp('', $element({className: 'regexp-report'}));
} else if (!state && isShowed()) {
if (isInit) {
uninit();
}
// TODO: need a closeHelp function
$('#help-popup .dismiss').onclick();
}
}
function update(newRegexps) {
if (!isShowed()) {
if (isInit) {
uninit();
}
return;
}
if (newRegexps) {
currentRegexps = newRegexps;
}
const regexps = currentRegexps.map(text => {
const rxData = Object.assign({text}, cachedRegexps.get(text));
if (!rxData.urls) {
cachedRegexps.set(text, Object.assign(rxData, {
// imitate buggy Stylish-for-chrome, see detectSloppyRegexps()
rx: tryRegExp('^' + text + '$'),
urls: new Map(),
}));
}
return rxData;
});
const getMatchInfo = m => m && {text: m[0], pos: m.index};
queryTabs().then(tabs => {
const supported = tabs.map(tab => tab.url)
.filter(url => URLS.supported(url));
const unique = [...new Set(supported).values()];
for (const rxData of regexps) {
const {rx, urls} = rxData;
if (rx) {
const urlsNow = new Map();
for (const url of unique) {
const match = urls.get(url) || getMatchInfo(url.match(rx));
if (match) {
urlsNow.set(url, match);
}
}
rxData.urls = urlsNow;
}
}
const stats = {
full: {data: [], label: t('styleRegexpTestFull')},
partial: {data: [], label: [
t('styleRegexpTestPartial'),
template.regexpTestPartial.cloneNode(true),
]},
none: {data: [], label: t('styleRegexpTestNone')},
invalid: {data: [], label: t('styleRegexpTestInvalid')},
};
// collect stats
for (const {text, rx, urls} of regexps) {
if (!rx) {
stats.invalid.data.push({text});
continue;
}
if (!urls.size) {
stats.none.data.push({text});
continue;
}
const full = [];
const partial = [];
for (const [url, match] of urls.entries()) {
const faviconUrl = url.startsWith(URLS.ownOrigin)
? OWN_ICON
: GET_FAVICON_URL + new URL(url).hostname;
const icon = $element({tag: 'img', src: faviconUrl});
if (match.text.length === url.length) {
full.push($element({appendChild: [
icon,
url,
]}));
} else {
partial.push($element({appendChild: [
icon,
url.substr(0, match.pos),
$element({tag: 'mark', textContent: match.text}),
url.substr(match.pos + match.text.length),
]}));
}
}
if (full.length) {
stats.full.data.push({text, urls: full});
}
if (partial.length) {
stats.partial.data.push({text, urls: partial});
}
}
// render stats
const report = $element({className: 'regexp-report'});
const br = $element({tag: 'br'});
for (const type in stats) {
// top level groups: full, partial, none, invalid
const {label, data} = stats[type];
if (!data.length) {
continue;
}
const block = report.appendChild($element({
tag: 'details',
open: true,
dataset: {type},
appendChild: $element({tag: 'summary', appendChild: label}),
}));
// 2nd level: regexp text
for (const {text, urls} of data) {
if (urls) {
// type is partial or full
block.appendChild($element({
tag: 'details',
open: true,
appendChild: [
$element({tag: 'summary', textContent: text}),
// 3rd level: tab urls
...urls,
],
}));
} else {
// type is none or invalid
block.appendChild(document.createTextNode(text));
block.appendChild(br.cloneNode());
}
}
}
showHelp(t('styleRegexpTestTitle'), report);
$('.regexp-report').onclick = event => {
const target = event.target.closest('a, .regexp-report div');
if (target) {
openURL({url: target.href || target.textContent});
event.preventDefault();
}
};
});
}
return {toggle, update};
})();

293
edit/source-editor.js Normal file
View File

@ -0,0 +1,293 @@
/* global CodeMirror dirtyReporter initLint beautify showKeyMapHelp */
/* global showToggleStyleHelp goBackToManage updateLintReportIfEnabled */
/* global hotkeyRerouter setupAutocomplete setupOptionsExpand */
/* global editors linterConfig updateLinter regExpTester mozParser */
/* global makeLink createAppliesToLineWidget messageBox */
'use strict';
function createSourceEditor(style) {
// a flag for isTouched()
let hadBeenSaved = false;
// draw HTML
$('#sections').textContent = '';
$('#name').disabled = true;
$('#mozilla-format-heading').parentNode.remove();
$('#sections').appendChild(
$element({className: 'single-editor', appendChild: [
$element({tag: 'textarea'})
]})
);
$('#header').appendChild($element({
id: 'footer',
appendChild: makeLink('https://github.com/openstyles/stylus/wiki/Usercss', t('externalUsercssDocument'))
}));
setupOptionsExpand();
// dirty reporter
const dirty = dirtyReporter();
dirty.onChange(() => {
const DIRTY = dirty.isDirty();
document.body.classList.toggle('dirty', DIRTY);
$('#save-button').disabled = !DIRTY;
updateTitle();
});
// normalize style
if (!style.id) {
setupNewStyle(style);
} else {
// style might be an object reference to background page
style = deepCopy(style);
}
// draw CodeMirror
$('#sections textarea').value = style.sourceCode;
const cm = CodeMirror.fromTextArea($('#sections textarea'));
// too many functions depend on this global
editors.push(cm);
// draw metas info
updateMeta();
initHooks();
initAppliesToLineWidget();
// setup linter
initLint();
initLinterSwitch();
function initAppliesToLineWidget() {
const PREF_NAME = 'editor.appliesToLineWidget';
const widget = createAppliesToLineWidget(cm);
const optionEl = buildOption();
$('#options').insertBefore(optionEl, $('#options > .option.aligned'));
widget.toggle(prefs.get(PREF_NAME));
prefs.subscribe([PREF_NAME], (key, value) => {
widget.toggle(value);
optionEl.checked = value;
});
optionEl.addEventListener('change', e => {
prefs.set(PREF_NAME, e.target.checked);
});
function buildOption() {
return $element({className: 'option', appendChild: [
$element({
tag: 'input',
type: 'checkbox',
id: PREF_NAME,
checked: prefs.get(PREF_NAME)
}),
$element({
tag: 'label',
htmlFor: PREF_NAME,
textContent: ' ' + t('appliesLineWidgetLabel'),
title: t('appliesLineWidgetWarning')
})
]});
}
}
function initLinterSwitch() {
const linterEl = $('#editor.linter');
cm.on('optionChange', (cm, option) => {
if (option !== 'mode') {
return;
}
updateLinter();
update();
});
linterEl.addEventListener('change', update);
function update() {
linterEl.value = linterConfig.getDefault();
const cssLintOption = linterEl.querySelector('[value="csslint"]');
if (cm.getOption('mode') !== 'css') {
cssLintOption.disabled = true;
cssLintOption.title = t('linterCSSLintIncompatible', cm.getOption('mode'));
} else {
cssLintOption.disabled = false;
cssLintOption.title = '';
}
}
}
function setupNewStyle(style) {
style.sections[0].code = ' '.repeat(prefs.get('editor.tabSize')) + '/* Insert code here... */';
let section = mozParser.format(style);
if (!section.includes('@-moz-document')) {
style.sections[0].domains = ['example.com'];
section = mozParser.format(style);
}
const sourceCode = `/* ==UserStyle==
@name New Style - ${Date.now()}
@namespace github.com/openstyles/stylus
@version 0.1.0
@description A new userstyle
@author Me
==/UserStyle== */
${section}
`;
dirty.modify('source', '', sourceCode);
style.sourceCode = sourceCode;
}
function initHooks() {
// sidebar commands
$('#save-button').onclick = save;
$('#beautify').onclick = beautify;
$('#keyMap-help').onclick = showKeyMapHelp;
$('#toggle-style-help').onclick = showToggleStyleHelp;
$('#cancel-button').onclick = goBackToManage;
// enable
$('#enabled').onchange = e => {
const value = e.target.checked;
dirty.modify('enabled', style.enabled, value);
style.enabled = value;
};
// source
cm.on('change', () => {
const value = cm.getValue();
dirty.modify('source', style.sourceCode, value);
style.sourceCode = value;
updateLintReportIfEnabled(cm);
});
// hotkeyRerouter
cm.on('focus', () => {
hotkeyRerouter.setState(false);
});
cm.on('blur', () => {
hotkeyRerouter.setState(true);
});
// autocomplete
if (prefs.get('editor.autocompleteOnTyping')) {
setupAutocomplete(cm);
}
}
function updateMeta() {
$('#name').value = style.name;
$('#enabled').checked = style.enabled;
$('#url').href = style.url;
const {usercssData: {preprocessor} = {}} = style;
cm.setPreprocessor(preprocessor);
// beautify only works with regular CSS
$('#beautify').disabled = cm.getOption('mode') !== 'css';
updateTitle();
}
function updateTitle() {
// title depends on dirty and style meta
if (!style.id) {
document.title = t('addStyleTitle');
} else {
document.title = (dirty.isDirty() ? '* ' : '') + t('editStyleTitle', [style.name]);
}
}
function replaceStyle(newStyle) {
if (!style.id && newStyle.id) {
history.replaceState({}, '', `?id=${newStyle.id}`);
}
style = deepCopy(newStyle);
updateMeta();
if (style.sourceCode !== cm.getValue()) {
const cursor = cm.getCursor();
cm.setValue(style.sourceCode);
cm.setCursor(cursor);
}
dirty.clear();
hadBeenSaved = false;
}
function setStyleDirty(newStyle) {
dirty.clear();
dirty.modify('source', newStyle.sourceCode, style.sourceCode);
dirty.modify('enabled', newStyle.enabled, style.enabled);
}
function toggleStyle() {
const value = !style.enabled;
dirty.modify('enabled', style.enabled, value);
style.enabled = value;
updateMeta();
// save when toggle enable state?
save();
}
function save() {
if (!dirty.isDirty()) {
return;
}
return onBackgroundReady()
.then(() => BG.usercssHelper.save({
reason: 'editSave',
id: style.id,
enabled: style.enabled,
sourceCode: style.sourceCode
}))
.then(replaceStyle)
.then(() => {
hadBeenSaved = true;
})
.catch(err => {
const contents = [String(err)];
if (Number.isInteger(err.index)) {
const pos = cm.posFromIndex(err.index);
contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`;
contents.push($element({
tag: 'pre',
textContent: drawLinePointer(pos)
}));
}
console.error(err);
messageBox.alert(contents);
});
function drawLinePointer(pos) {
const SIZE = 60;
const line = cm.getLine(pos.line);
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) + rightPad + '\n' +
' '.repeat(leftPad.length) + pointer.slice(start, end);
}
}
function isTouched() {
// indicate that the editor had been touched by the user
return dirty.isDirty() || hadBeenSaved;
}
function replaceMeta(newStyle) {
style.enabled = newStyle.enabled;
dirty.clear('enabled');
updateMeta();
}
return {
replaceStyle,
replaceMeta,
setStyleDirty,
save,
toggleStyle,
isDirty: dirty.isDirty,
getStyle: () => style,
isTouched
};
}

95
edit/util.js Normal file
View File

@ -0,0 +1,95 @@
'use strict';
function dirtyReporter() {
const dirty = new Map();
const onchanges = [];
function add(obj, value) {
const saved = dirty.get(obj);
if (!saved) {
dirty.set(obj, {type: 'add', newValue: value});
} else if (saved.type === 'remove') {
if (saved.savedValue === value) {
dirty.delete(obj);
} else {
saved.newValue = value;
saved.type = 'modify';
}
}
}
function remove(obj, value) {
const saved = dirty.get(obj);
if (!saved) {
dirty.set(obj, {type: 'remove', savedValue: value});
} else if (saved.type === 'add') {
dirty.delete(obj);
} else if (saved.type === 'modify') {
saved.type = 'remove';
}
}
function modify(obj, oldValue, newValue) {
const saved = dirty.get(obj);
if (!saved) {
if (oldValue !== newValue) {
dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
}
} else if (saved.type === 'modify') {
if (saved.savedValue === newValue) {
dirty.delete(obj);
} else {
saved.newValue = newValue;
}
} else if (saved.type === 'add') {
saved.newValue = newValue;
}
}
function clear(obj) {
if (obj === undefined) {
dirty.clear();
} else {
dirty.delete(obj);
}
}
function isDirty() {
return dirty.size > 0;
}
function onChange(cb) {
// make sure the callback doesn't throw
onchanges.push(cb);
}
function wrap(obj) {
for (const key of ['add', 'remove', 'modify', 'clear']) {
obj[key] = trackChange(obj[key]);
}
return obj;
}
function emitChange() {
for (const cb of onchanges) {
cb();
}
}
function trackChange(fn) {
return function () {
const dirty = isDirty();
const result = fn.apply(null, arguments);
if (dirty !== isDirty()) {
emitChange();
}
return result;
};
}
function has(key) {
return dirty.has(key);
}
return wrap({add, remove, modify, clear, isDirty, onChange, has});
}

92
install-usercss.html Normal file
View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Loading...</title>
<link rel="stylesheet" href="/install-usercss/install-usercss.css">
<script src="/js/messaging.js"></script>
<script src="/js/prefs.js"></script>
<script src="/js/dom.js"></script>
<script src="/js/localization.js"></script>
<script src="/vendor/node-semver/semver.js"></script>
<script src="/msgbox/msgbox.js"></script>
<link rel="stylesheet" href="/msgbox/msgbox.css"></script>
<!-- FIXME: do we need all of these? -->
<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="/edit/codemirror-default.js"></script>
<link rel="stylesheet" href="/edit/codemirror-default.css">
<link rel="stylesheet" href="/vendor/codemirror/lib/codemirror.css">
<script src="/vendor/codemirror/mode/css/css.js"></script>
<link rel="stylesheet" href="/vendor/codemirror/addon/dialog/dialog.css">
<link rel="stylesheet" href="/vendor/codemirror/addon/search/matchesonscrollbar.css">
<script src="/vendor/codemirror/addon/scroll/annotatescrollbar.js"></script>
<script src="/vendor/codemirror/addon/search/matchesonscrollbar.js"></script>
<script src="/vendor-overwrites/codemirror/addon/search/match-highlighter.js"></script>
<script src="/vendor/codemirror/addon/dialog/dialog.js"></script>
<script src="/vendor/codemirror/addon/search/searchcursor.js"></script>
<script src="/vendor/codemirror/addon/search/search.js"></script>
<script src="/vendor/codemirror/addon/comment/comment.js"></script>
<script src="/vendor/codemirror/addon/selection/active-line.js"></script>
<link rel="stylesheet" href="/vendor/codemirror/addon/fold/foldgutter.css" />
<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/comment-fold.js"></script>
<script src="/vendor/codemirror/addon/edit/matchbrackets.js"></script>
<link rel="stylesheet" href="/vendor/codemirror/addon/lint/lint.css" />
<link rel="stylesheet" href="/vendor/codemirror/addon/hint/show-hint.css" />
<script src="/vendor/codemirror/addon/hint/show-hint.js"></script>
<script src="/vendor/codemirror/addon/hint/css-hint.js"></script>
<script src="/vendor/codemirror/addon/mode/loadmode.js"></script>
</head>
<body>
<div class="container">
<div class="header">
<h1>
<span class="meta-name"></span>
<small class="meta-version"></small>
</h1>
<div class="actions">
<button class="install" i18n-text="installButton"></button>
<label class="set-update-url">
<input type="checkbox">
<span></span>
</label>
<label class="live-reload">
<input type="checkbox">
<span i18n-text="liveReloadLabel"></span>
</label>
</div>
<p class="meta-description"></p>
<div>
<h3 i18n-text="author"></h3>
<span class="meta-author"></span>
</div>
<div>
<h3 i18n-text="license"></h3>
<span class="meta-license"></span>
</div>
<h3 i18n-text="appliesLabel"></h3>
<ul class="applies-to">
</ul>
<div class="external-link"></div>
</div>
<div class="main">
<div class="code">
<textarea></textarea>
</div>
</div>
</div>
<script src="/content/util.js"></script>
<script src="/install-usercss/install-usercss.js"></script>
</body>
</html>

View File

@ -0,0 +1,121 @@
body {
margin: 0;
font: 12px arial, sans-serif;
background: white;
}
* {
box-sizing: border-box;
}
a {
color: #000;
transition: color .5s;
text-decoration-skip: ink;
}
a:hover {
color: #666;
}
img.icon,
svg.icon {
height: 1.4em;
vertical-align: middle;
}
.container {
display: flex;
height: 100vh;
align-items: stretch;
}
.header {
flex: 0 0 280px;
padding: 15px;
border-right: 1px dashed #aaa;
box-shadow: 0 0 50px -18px black;
overflow-wrap: break-word;
overflow: auto;
}
.header > :first-child {
margin-top: 0;
}
h1 small {
font-size: 0.6em;
}
.meta-version::before {
content: " v";
}
.warning {
padding: 3px 6px;
border: 1px dashed black;
border-color: #ef6969;
background: #ffe2e2;
}
.header .warning {
margin: 3px 0;
}
.actions {
margin: 15px 0;
}
.actions label {
max-width: fit-content;
max-width: -moz-fit-content;
display: flex;
align-items: center;
margin: 0.5em 0;
}
.actions label input {
margin: 0 0.5em 0 0;
flex: 0 0 auto;
}
.actions label span {
min-width: 0;
}
.external {
text-align: center;
}
.external > * {
margin: 0 7.5px;
}
.code {
padding: 2em;
}
.main {
flex: 1 1 auto;
overflow: hidden;
overflow-wrap: break-word;
min-width: 0;
display: flex;
flex-direction: column;
}
.main > :first-child {
flex: 0 0 auto;
}
.main > :last-child {
flex: 1 1 auto;
min-height: 0;
}
.main .code,
.main .CodeMirror {
height: 100%;
}

View File

@ -0,0 +1,299 @@
/* global CodeMirror semverCompare makeLink closeCurrentTab runtimeSend */
/* global messageBox */
'use strict';
(() => {
const params = new URLSearchParams(location.search);
let liveReload = false;
let installed = false;
const port = chrome.tabs.connect(
Number(params.get('tabId')),
{name: 'usercss-install', frameId: 0}
);
port.postMessage({method: 'getSourceCode'});
port.onMessage.addListener(msg => {
switch (msg.method) {
case 'getSourceCodeResponse':
if (msg.error) {
messageBox.alert(msg.error);
} else {
initSourceCode(msg.sourceCode);
}
break;
case 'sourceCodeChanged':
if (msg.error) {
messageBox.alert(msg.error);
} else {
liveReloadUpdate(msg.sourceCode);
}
break;
}
});
port.onDisconnect.addListener(closeCurrentTab);
const cm = CodeMirror.fromTextArea($('.code textarea'), {readOnly: true});
let liveReloadPending = Promise.resolve();
function liveReloadUpdate(sourceCode) {
liveReloadPending = liveReloadPending.then(() => {
const scrollInfo = cm.getScrollInfo();
const cursor = cm.getCursor();
cm.setValue(sourceCode);
cm.setCursor(cursor);
cm.scrollTo(scrollInfo.left, scrollInfo.top);
return runtimeSend({
id: installed.id,
method: 'saveUsercss',
reason: 'update',
sourceCode
}).then(updateMeta)
.catch(showError);
});
}
function updateMeta(style, dup) {
$$('.main .warning').forEach(e => e.remove());
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
// update editor
cm.setPreprocessor(data.preprocessor);
// update metas
document.title = `${installButtonLabel()} ${data.name}`;
$('.install').textContent = installButtonLabel();
$('.set-update-url').title = dup && dup.updateUrl && t('installUpdateFrom', dup.updateUrl) || '';
$('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description;
if (data.author) {
$('.meta-author').parentNode.style.display = '';
$('.meta-author').textContent = '';
$('.meta-author').appendChild(makeAuthor(data.author));
} else {
$('.meta-author').parentNode.style.display = 'none';
}
$('.meta-license').parentNode.style.display = data.license ? '' : 'none';
$('.meta-license').textContent = data.license;
$('.applies-to').textContent = '';
getAppliesTo(style).forEach(pattern =>
$('.applies-to').appendChild($element({tag: 'li', textContent: pattern}))
);
$('.external-link').textContent = '';
const externalLink = makeExternalLink();
if (externalLink) {
$('.external-link').appendChild(externalLink);
}
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(makeLink(`mailto:${email}`, name));
} else {
frag.appendChild($element({
tag: 'span',
textContent: name
}));
}
if (url) {
frag.appendChild(makeLink(
url,
$element({
tag: 'svg#svg',
viewBox: '0 0 20 20',
class: 'icon',
appendChild: $element({
tag: 'svg#path',
d: 'M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z'
})
})
));
}
return frag;
}
function makeExternalLink() {
const urls = [];
if (data.homepageURL) {
urls.push([data.homepageURL, t('externalHomepage')]);
}
if (data.supportURL) {
urls.push([data.supportURL, t('externalSupport')]);
}
if (urls.length) {
return $element({appendChild: [
$element({tag: 'h3', textContent: t('externalLink')}),
$element({tag: 'ul', appendChild: urls.map(args =>
$element({tag: 'li', appendChild: makeLink(...args)})
)})
]});
}
}
function installButtonLabel() {
return t(
installed ? 'installButtonInstalled' :
!dup ? 'installButton' :
versionTest > 0 ? 'installButtonUpdate' : 'installButtonReinstall'
);
}
}
function showError(err) {
$$('.main .warning').forEach(e => e.remove());
const main = $('.main');
main.insertBefore(buildWarning(err), main.firstChild);
}
function install(style) {
const request = Object.assign(style, {
method: 'saveUsercss',
reason: 'update'
});
return runtimeSend(request)
.then(result => {
installed = result;
$$('.warning')
.forEach(el => el.remove());
$('.install').disabled = true;
$('.install').classList.add('installed');
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = result.updateUrl ?
t('installUpdateFrom', result.updateUrl) : '';
updateMeta(result);
chrome.runtime.sendMessage({method: 'openEditor', id: result.id});
if (!liveReload) {
port.postMessage({method: 'closeTab'});
}
window.dispatchEvent(new CustomEvent('installed'));
})
.catch(err => {
messageBox.alert(chrome.i18n.getMessage('styleInstallFailed', String(err)));
});
}
function initSourceCode(sourceCode) {
cm.setValue(sourceCode);
runtimeSend({
method: 'buildUsercss',
sourceCode,
checkDup: true
}).then(init, initError);
}
function initError(err) {
$('.main').insertBefore(buildWarning(err), $('.main').childNodes[0]);
$('.header').style.display = 'none';
}
function buildWarning(err) {
return $element({className: 'warning', appendChild: [
t('parseUsercssError'),
$element({tag: 'pre', textContent: String(err)})
]});
}
function init({style, dup}) {
const data = style.usercssData;
const dupData = dup && dup.usercssData;
const versionTest = dup && semverCompare(data.version, dupData.version);
updateMeta(style, dup);
// update UI
if (versionTest < 0) {
$('.actions').parentNode.insertBefore(
$element({className: 'warning', textContent: t('versionInvalidOlder')}),
$('.actions')
);
}
$('button.install').onclick = () => {
const message = dup ?
chrome.i18n.getMessage('styleInstallOverwrite', [
data.name, dupData.version, data.version
]) :
chrome.i18n.getMessage('styleInstall', [data.name]);
messageBox.confirm(message).then(result => {
if (result) {
return install(style);
}
});
};
// set updateUrl
const setUpdate = $('.set-update-url input[type=checkbox]');
const updateUrl = new URL(params.get('updateUrl'));
$('.set-update-url > span').textContent = t('installUpdateFromLabel');
if (dup && dup.updateUrl === updateUrl.href) {
setUpdate.checked = true;
// there is no way to "unset" updateUrl, you can only overwrite it.
setUpdate.disabled = true;
} else if (updateUrl.protocol !== 'file:') {
setUpdate.checked = true;
style.updateUrl = updateUrl.href;
}
setUpdate.onchange = e => {
if (e.target.checked) {
style.updateUrl = updateUrl.href;
} else {
delete style.updateUrl;
}
};
// live reload
const setLiveReload = $('.live-reload input[type=checkbox]');
if (updateUrl.protocol !== 'file:') {
setLiveReload.parentNode.remove();
} else {
setLiveReload.addEventListener('change', () => {
liveReload = setLiveReload.checked;
if (installed) {
const method = 'liveReload' + (liveReload ? 'Start' : 'Stop');
port.postMessage({method});
}
});
window.addEventListener('installed', () => {
if (liveReload) {
port.postMessage({method: 'liveReloadStart'});
}
});
}
}
function getAppliesTo(style) {
function *_gen() {
for (const section of style.sections) {
for (const type of ['urls', 'urlPrefixes', 'domains', 'regexps']) {
if (section[type]) {
yield *section[type];
}
}
}
}
const result = [..._gen()];
if (!result.length) {
result.push(chrome.i18n.getMessage('appliesToEverything'));
}
return result;
}
})();

40
js/color-parser.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
// eslint-disable-next-line no-var
var colorParser = (() => {
const el = document.createElement('div');
// https://bugs.webkit.org/show_bug.cgi?id=14563
document.head.appendChild(el);
function parseRGB(color) {
const [r, g, b, a = 1] = color.match(/[.\d]+/g).map(Number);
return {r, g, b, a};
}
function parse(color) {
el.style.color = color;
if (el.style.color === '') {
throw new Error(chrome.i18n.getMessage('styleMetaErrorColor', color));
}
color = getComputedStyle(el).color;
el.style.color = '';
return parseRGB(color);
}
function format({r, g, b, a = 1}) {
if (a === 1) {
return `rgb(${r}, ${g}, ${b})`;
}
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function formatHex({r, g, b, a = null}) {
let hex = '#' + (0x1000000 + (r << 16) + (g << 8) + (b | 0)).toString(16).substr(1);
if (a !== null) {
hex += (0x100 + Math.floor(a * 255)).toString(16).substr(1);
}
return hex;
}
return {parse, format, formatHex};
})();

118
js/dom.js
View File

@ -33,10 +33,19 @@ for (const type of [NodeList, NamedNodeMap, HTMLCollection, HTMLAllCollection])
// enqueue after DOMContentLoaded/load events
setTimeout(addTooltipsToEllipsized);
// throttle on continuous resizing
window.addEventListener('resize', () => debounce(addTooltipsToEllipsized, 100));
let timer;
window.addEventListener('resize', () => {
clearTimeout(timer);
timer = setTimeout(addTooltipsToEllipsized, 100);
});
}
onDOMready().then(() => $('#firefox-transitions-bug-suppressor').remove());
onDOMready().then(() => {
const el = $('#firefox-transitions-bug-suppressor');
if (el) {
el.remove();
}
});
if (!chrome.app) {
// die if unable to access BG directly
@ -80,95 +89,6 @@ function onDOMready() {
}
function onDOMscripted(scripts) {
const queue = onDOMscripted.queue = onDOMscripted.queue || [];
if (scripts) {
return new Promise(resolve => {
addResolver(resolve);
queue.push(...scripts.filter(el => !queue.includes(el)));
loadNextScript();
});
}
if (queue.length) {
return new Promise(resolve => addResolver(resolve));
}
if (document.readyState !== 'loading') {
if (onDOMscripted.resolveOnReady) {
onDOMscripted.resolveOnReady.forEach(r => r());
onDOMscripted.resolveOnReady = null;
}
return Promise.resolve();
}
return onDOMready().then(onDOMscripted);
function loadNextScript() {
const empty = !queue.length;
const next = !empty && queue.shift();
if (empty) {
onDOMscripted();
} else if (typeof next === 'function') {
Promise.resolve(next())
.then(loadNextScript);
} else {
Promise.all(
(next instanceof Array ? next : [next]).map(next =>
typeof next === 'function'
? next()
: injectScript({src: next, async: true})
)
).then(loadNextScript);
}
}
function addResolver(r) {
if (!onDOMscripted.resolveOnReady) {
onDOMscripted.resolveOnReady = [];
}
onDOMscripted.resolveOnReady.push(r);
}
}
function injectScript(properties) {
if (typeof properties === 'string') {
properties = {src: properties};
}
if (!properties || !properties.src) {
return;
}
if (injectScript.cache) {
if (injectScript.cache.has(properties.src)) {
return Promise.resolve();
}
} else {
injectScript.cache = new Set();
}
injectScript.cache.add(properties.src);
const script = document.head.appendChild(document.createElement('script'));
Object.assign(script, properties);
if (!properties.onload) {
return new Promise(resolve => {
script.onload = () => {
script.onload = null;
resolve();
};
});
}
}
function injectCSS(url) {
if (!url) {
return;
}
document.head.appendChild($element({
tag: 'link',
rel: 'stylesheet',
href: url
}));
}
function scrollElementIntoView(element) {
// align to the top/bottom of the visible area if wasn't visible
const bounds = element.getBoundingClientRect();
@ -272,3 +192,19 @@ function $element(opt) {
}
return element;
}
function makeLink(href = '', content) {
const opt = {
tag: 'a',
target: '_blank',
rel: 'noopener'
};
if (typeof href === 'object') {
Object.assign(opt, href);
} else {
opt.href = href;
opt.appendChild = content;
}
return $element(opt);
}

View File

@ -103,7 +103,11 @@ function tNodeList(nodes) {
function tDocLoader() {
t.DOMParser = new DOMParser();
t.cache = tryJSONparse(localStorage.L10N) || {};
t.cache = (() => {
try {
return JSON.parse(localStorage.L10N);
} catch (e) {}
})() || {};
// reset L10N cache on UI language change
const UIlang = chrome.i18n.getUILanguage();

View File

@ -382,15 +382,47 @@ function deleteStyleSafe({id, notify = true} = {}) {
function download(url) {
return new Promise((resolve, reject) => {
url = new URL(url);
const TIMEOUT = 10000;
const options = {
method: url.search ? 'POST' : 'GET',
body: url.search ? url.search.slice(1) : null,
headers: {
'Content-type': 'application/x-www-form-urlencoded'
}
};
if (url.protocol === 'file:' && FIREFOX) {
// https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path
options.mode = 'same-origin';
// FIXME: add FetchController when it is available.
// https://developer.mozilla.org/en-US/docs/Web/API/FetchController/abort
let timer;
fetch(url.href, {mode: 'same-origin'})
.then(r => {
clearTimeout(timer);
if (r.status !== 200) {
throw r.status;
}
return r.text();
})
.then(resolve, reject);
timer = setTimeout(
() => reject(new Error(`Fetch URL timeout: ${url.href}`)),
TIMEOUT
);
return;
}
const xhr = new XMLHttpRequest();
xhr.timeout = 10e3;
xhr.onloadend = () => (xhr.status === 200
xhr.timeout = TIMEOUT;
xhr.onload = () => (xhr.status === 200 || url.protocol === 'file:'
? resolve(xhr.responseText)
: reject(xhr.status));
const [mainUrl, query] = url.split('?');
xhr.open(query ? 'POST' : 'GET', mainUrl, true);
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send(query);
xhr.onerror = reject;
xhr.open(options.method, url.href, true);
for (const key of Object.keys(options.headers)) {
xhr.setRequestHeader(key, options.headers[key]);
}
xhr.send(options.body);
});
}
@ -400,3 +432,26 @@ function invokeOrPostpone(isInvoke, fn, ...args) {
? fn(...args)
: setTimeout(invokeOrPostpone, 0, true, fn, ...args);
}
function openEditor(id) {
let url = '/edit.html';
if (id) {
url += `?id=${id}`;
}
if (prefs.get('openEditInWindow')) {
chrome.windows.create(Object.assign({url}, prefs.get('windowPosition')));
} else {
openURL({url});
}
}
function closeCurrentTab() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
getOwnTab().then(tab => {
if (tab) {
chrome.tabs.remove(tab.id);
}
});
}

145
js/moz-parser.js Normal file
View File

@ -0,0 +1,145 @@
/* global parserlib, loadScript */
'use strict';
// eslint-disable-next-line no-var
var mozParser = (() => {
// direct & reverse mapping of @-moz-document keywords and internal property names
const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'};
const CssToProperty = {'url': 'urls', 'url-prefix': 'urlPrefixes', 'domain': 'domains', 'regexp': 'regexps'};
function backtrackTo(parser, tokenType, startEnd) {
const tokens = parser._tokenStream._lt;
for (let i = parser._tokenStream._ltIndex - 1; i >= 0; --i) {
if (tokens[i].type === tokenType) {
return {line: tokens[i][startEnd + 'Line'], col: tokens[i][startEnd + 'Col']};
}
}
}
function trimNewLines(s) {
return s.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, '');
}
function parseMozFormat(mozStyle) {
return new Promise((resolve, reject) => {
const parser = new parserlib.css.Parser();
const lines = mozStyle.split('\n');
const sectionStack = [{code: '', start: {line: 1, col: 1}}];
const errors = [];
const sections = [];
parser.addListener('startdocument', function (e) {
const lastSection = sectionStack[sectionStack.length - 1];
let outerText = getRange(lastSection.start, (--e.col, e));
const gapComment = outerText.match(/(\/\*[\s\S]*?\*\/)[\s\n]*$/);
const section = {code: '', start: backtrackTo(this, parserlib.css.Tokens.LBRACE, 'end')};
// move last comment before @-moz-document inside the section
if (gapComment && !gapComment[1].match(/\/\*\s*AGENT_SHEET\s*\*\//)) {
section.code = gapComment[1] + '\n';
outerText = trimNewLines(outerText.substring(0, gapComment.index));
}
if (outerText.trim()) {
lastSection.code = outerText;
doAddSection(lastSection);
lastSection.code = '';
}
for (const f of e.functions) {
const m = f && f.match(/^([\w-]*)\((['"]?)(.+?)\2?\)$/);
if (!m || !/^(url|url-prefix|domain|regexp)$/.test(m[1])) {
errors.push(`${e.line}:${e.col + 1} invalid function "${m ? m[1] : f || ''}"`);
continue;
}
const aType = CssToProperty[m[1]];
const aValue = aType !== 'regexps' ? m[3] : m[3].replace(/\\\\/g, '\\');
(section[aType] = section[aType] || []).push(aValue);
}
sectionStack.push(section);
});
parser.addListener('enddocument', function () {
const end = backtrackTo(this, parserlib.css.Tokens.RBRACE, 'start');
const section = sectionStack.pop();
const lastSection = sectionStack[sectionStack.length - 1];
section.code += getRange(section.start, end);
lastSection.start = (++end.col, end);
doAddSection(section);
});
parser.addListener('endstylesheet', () => {
// add nonclosed outer sections (either broken or the last global one)
const lastLine = lines[lines.length - 1];
const endOfText = {line: lines.length, col: lastLine.length + 1};
const lastSection = sectionStack[sectionStack.length - 1];
lastSection.code += getRange(lastSection.start, endOfText);
sectionStack.forEach(doAddSection);
if (errors.length) {
reject(errors);
} else {
resolve(sections);
}
});
parser.addListener('error', e => {
errors.push(e.line + ':' + e.col + ' ' +
e.message.replace(/ at line \d.+$/, ''));
});
parser.parse(mozStyle);
function getRange(start, end) {
const L1 = start.line - 1;
const C1 = start.col - 1;
const L2 = end.line - 1;
const C2 = end.col - 1;
if (L1 === L2) {
return lines[L1].substr(C1, C2 - C1 + 1);
} else {
const middle = lines.slice(L1 + 1, L2).join('\n');
return lines[L1].substr(C1) + '\n' + middle +
(L2 >= lines.length ? '' : ((middle ? '\n' : '') + lines[L2].substring(0, C2)));
}
}
function doAddSection(section) {
section.code = section.code.trim();
// don't add empty sections
if (
!section.code &&
!section.urls &&
!section.urlPrefixes &&
!section.domains &&
!section.regexps
) {
return;
}
/* ignore boilerplate NS */
if (section.code === '@namespace url(http://www.w3.org/1999/xhtml);') {
return;
}
sections.push(Object.assign({}, section));
}
});
}
return {
// Parse mozilla-format userstyle into sections
parse(text) {
return loadScript('/vendor-overwrites/csslint/csslint-worker.js')
.then(() => parseMozFormat(text));
},
format(style) {
return style.sections.map(section => {
let cssMds = [];
for (const i in propertyToCss) {
if (section[i]) {
cssMds = cssMds.concat(section[i].map(v =>
propertyToCss[i] + '("' + v.replace(/\\/g, '\\\\') + '")'
));
}
}
return cssMds.length ? '@-moz-document ' + cssMds.join(', ') + ' {\n' + section.code + '\n}' : section.code;
}).join('\n\n');
}
};
})();

View File

@ -9,6 +9,7 @@ var prefs = new function Prefs() {
'show-badge': true, // display text on popup menu icon
'disableAll': false, // boss key
'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes
'newStyleAsUsercss': false, // create new style in usercss format
'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs
'popup.breadcrumbs.usePath': false, // use URL path for 'this URL'
@ -51,6 +52,8 @@ var prefs = new function Prefs() {
'editor.autocompleteOnTyping': false, // show autocomplete dropdown on typing a word token
'editor.contextDelete': contextDeleteMissing(), // "Delete" item in context menu
'editor.appliesToLineWidget': true, // show applies-to line widget on the editor
'iconset': 0, // 0 = dark-themed icon
// 1 = light-themed icon

46
js/script-loader.js Normal file
View File

@ -0,0 +1,46 @@
'use strict';
// loadScript(script: Array<Promise|string>|string): Promise
// eslint-disable-next-line no-var
var 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.onerror = () => {
el.onload = null;
el.onerror = null;
reject(new Error(`Failed to load script: ${file}`));
};
document.head.appendChild(el);
});
}
return files => {
if (!Array.isArray(files)) {
files = [files];
}
return Promise.all(files.map(f => (typeof f === 'string' ? inject(f) : f)));
};
})();

540
js/usercss.js Normal file
View File

@ -0,0 +1,540 @@
/* global loadScript mozParser semverCompare colorParser */
'use strict';
// eslint-disable-next-line no-var
var usercss = (() => {
// true for global, false for private
const METAS = {
__proto__: null,
author: true,
advanced: false,
description: true,
homepageURL: false,
// icon: false,
license: false,
name: true,
namespace: false,
// noframes: false,
preprocessor: false,
supportURL: false,
'var': false,
version: false
};
const META_VARS = ['text', 'color', 'checkbox', 'select', 'dropdown', 'image'];
const BUILDER = {
default: {
postprocess(sections, vars) {
const varDef =
':root {\n' +
Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join('') +
'}\n';
for (const section of sections) {
section.code = varDef + section.code;
}
}
},
stylus: {
preprocess(source, vars) {
return loadScript('/vendor/stylus-lang/stylus.min.js').then(() => (
new Promise((resolve, reject) => {
const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join('');
// eslint-disable-next-line no-undef
stylus(varDef + source).render((err, output) => {
if (err) {
reject(err);
} else {
resolve(output);
}
});
})
));
}
},
uso: {
preprocess(source, vars) {
const pool = new Map();
return Promise.resolve(doReplace(source));
function getValue(name, rgb) {
if (!vars.hasOwnProperty(name)) {
if (name.endsWith('-rgb')) {
return getValue(name.slice(0, -4), true);
}
return null;
}
if (rgb) {
if (vars[name].type === 'color') {
// eslint-disable-next-line no-use-before-define
const color = colorParser.parse(vars[name].value);
return `${color.r}, ${color.g}, ${color.b}`;
}
return null;
}
if (vars[name].type === 'dropdown' || vars[name].type === 'select') {
// prevent infinite recursion
pool.set(name, '');
return doReplace(vars[name].value);
}
return vars[name].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);
});
}
}
}
};
function getMetaSource(source) {
const commentRe = /\/\*[\s\S]*?\*\//g;
const metaRe = /==userstyle==[\s\S]*?==\/userstyle==/i;
let m;
// iterate through each comment
while ((m = commentRe.exec(source))) {
const commentSource = source.slice(m.index, m.index + m[0].length);
const n = commentSource.match(metaRe);
if (n) {
return {
index: m.index + n.index,
text: n[0]
};
}
}
}
function parseWord(state, error = 'invalid word') {
const match = state.text.slice(state.re.lastIndex).match(/^([\w-]+)\s*/);
if (!match) {
throw new Error(error);
}
state.value = match[1];
state.re.lastIndex += match[0].length;
}
function parseVar(state) {
const result = {
type: null,
label: null,
name: null,
value: null,
default: null,
options: null
};
parseWord(state, 'missing type');
result.type = state.type = state.value;
if (!META_VARS.includes(state.type)) {
throw new Error(`unknown type: ${state.type}`);
}
parseWord(state, 'missing name');
result.name = state.value;
parseString(state);
result.label = state.value;
if (state.type === 'checkbox') {
const match = state.text.slice(state.re.lastIndex).match(/([01])\s+/);
if (!match) {
throw new Error('value must be 0 or 1');
}
state.re.lastIndex += match[0].length;
result.default = match[1];
} else if (state.type === 'select' || (state.type === 'image' && state.key === 'var')) {
parseJSONValue(state);
if (Array.isArray(state.value)) {
result.options = state.value.map(text => createOption(text));
} else {
result.options = Object.keys(state.value).map(k => createOption(k, state.value[k]));
}
result.default = result.options[0].name;
} else if (state.type === 'dropdown' || state.type === 'image') {
if (state.text[state.re.lastIndex] !== '{') {
throw new Error('no open {');
}
result.options = [];
state.re.lastIndex++;
while (state.text[state.re.lastIndex] !== '}') {
const option = {};
parseStringUnquoted(state);
option.name = state.value;
parseString(state);
option.label = state.value;
if (state.type === 'dropdown') {
parseEOT(state);
} else {
parseString(state);
}
option.value = state.value;
result.options.push(option);
}
state.re.lastIndex++;
eatWhitespace(state);
result.default = result.options[0].name;
} else {
// text, color
parseStringToEnd(state);
result.default = state.value;
}
state.usercssData.vars[result.name] = result;
validVar(result);
}
function createOption(label, value) {
let name;
const match = label.match(/^(\w+):(.*)/);
if (match) {
([, name, label] = match);
}
if (!name) {
name = label;
}
if (!value) {
value = name;
}
return {name, label, value};
}
function parseEOT(state) {
const re = /<<<EOT([\s\S]+?)EOT;/y;
re.lastIndex = state.re.lastIndex;
const match = state.text.match(re);
if (!match) {
throw new Error('missing EOT');
}
state.re.lastIndex += match[0].length;
state.value = match[1].trim().replace(/\*\\\//g, '*/');
eatWhitespace(state);
}
function parseStringUnquoted(state) {
const re = /[^"]*/y;
re.lastIndex = state.re.lastIndex;
const match = state.text.match(re);
state.re.lastIndex += match[0].length;
state.value = match[0].trim().replace(/\s+/g, '-');
}
function parseString(state) {
const match = state.text.slice(state.re.lastIndex).match(
state.text[state.re.lastIndex] === '`' ?
/^(`(?:\\`|[\s\S])*?`)\s*/ :
/^((['"])(?:\\\2|[^\n])*?\2|\w+)\s*/
);
state.re.lastIndex += match[0].length;
state.value = unquote(match[1]);
}
function parseJSONValue(state) {
const JSON_PRIME = {
__proto__: null,
'null': null,
'true': true,
'false': false
};
if (state.text[state.re.lastIndex] === '{') {
// object
const obj = {};
state.re.lastIndex++;
eatWhitespace(state);
while (state.text[state.re.lastIndex] !== '}') {
parseString(state);
const key = state.value;
if (state.text[state.re.lastIndex] !== ':') {
throw new Error('missing \':\'');
}
state.re.lastIndex++;
eatWhitespace(state);
parseJSONValue(state);
obj[key] = state.value;
if (state.text[state.re.lastIndex] === ',') {
state.re.lastIndex++;
eatWhitespace(state);
} else if (state.text[state.re.lastIndex] !== '}') {
throw new Error('missing \',\' or \'}\'');
}
}
state.re.lastIndex++;
eatWhitespace(state);
state.value = obj;
} else if (state.text[state.re.lastIndex] === '[') {
// array
const arr = [];
state.re.lastIndex++;
eatWhitespace(state);
while (state.text[state.re.lastIndex] !== ']') {
parseJSONValue(state);
arr.push(state.value);
if (state.text[state.re.lastIndex] === ',') {
state.re.lastIndex++;
eatWhitespace(state);
} else if (state.text[state.re.lastIndex] !== ']') {
throw new Error('missing \',\' or \']\'');
}
}
state.re.lastIndex++;
eatWhitespace(state);
state.value = arr;
} else if (state.text[state.re.lastIndex] === '"' || state.text[state.re.lastIndex] === '`') {
// string
parseString(state);
} else if (/\d/.test(state.text[state.re.lastIndex])) {
// number
parseNumber(state);
} else {
parseWord(state);
if (!(state.value in JSON_PRIME)) {
throw new Error(`unknown literal '${state.value}'`);
}
state.value = JSON_PRIME[state.value];
}
}
function parseNumber(state) {
const match = state.slice(state.re.lastIndex).match(/^-?\d+(\.\d+)?\s*/);
if (!match) {
throw new Error('invalid number');
}
state.value = Number(match[0].trim());
state.re.lastIndex += match[0].length;
}
function eatWhitespace(state) {
const match = state.text.slice(state.re.lastIndex).match(/\s*/);
state.re.lastIndex += match[0].length;
}
function parseStringToEnd(state) {
const match = state.text.slice(state.re.lastIndex).match(/.+/);
state.value = unquote(match[0].trim());
state.re.lastIndex += match[0].length;
}
function unquote(s) {
const q = s[0];
if (q === s[s.length - 1] && /['"`]/.test(q)) {
// http://www.json.org/
return s.slice(1, -1).replace(
new RegExp(`\\\\([${q}\\\\/bfnrt]|u[0-9a-fA-F]{4})`, 'g'),
s => {
if (s[1] === q) {
return q;
}
return JSON.parse(`"${s}"`);
}
);
}
return s;
}
function buildMeta(sourceCode) {
sourceCode = sourceCode.replace(/\r\n?/g, '\n');
const usercssData = {
vars: {}
};
const style = {
enabled: true,
sourceCode,
sections: [],
usercssData
};
const {text, index: metaIndex} = getMetaSource(sourceCode);
const re = /@(\w+)\s+/mg;
const state = {style, re, text, usercssData};
function doParse() {
let match;
while ((match = re.exec(text))) {
state.key = match[1];
if (!(state.key in METAS)) {
continue;
}
if (state.key === 'var' || state.key === 'advanced') {
if (state.key === 'advanced') {
state.maybeUSO = true;
}
parseVar(state);
} else {
parseStringToEnd(state);
usercssData[state.key] = state.value;
}
if (state.key === 'version') {
usercssData[state.key] = normalizeVersion(usercssData[state.key]);
validVersion(usercssData[state.key]);
}
if (METAS[state.key]) {
style[state.key] = usercssData[state.key];
}
if (state.key === 'homepageURL' || state.key === 'supportURL') {
validUrl(usercssData[state.key]);
}
}
}
try {
doParse();
} catch (e) {
// grab additional info
e.index = metaIndex + state.re.lastIndex;
throw e;
}
if (state.maybeUSO && !usercssData.preprocessor) {
usercssData.preprocessor = 'uso';
}
if (usercssData.homepageURL) {
style.url = usercssData.homepageURL;
}
validate(style);
return style;
}
function normalizeVersion(version) {
// https://docs.npmjs.com/misc/semver#versions
if (version[0] === 'v' || version[0] === '=') {
return version.slice(1);
}
return version;
}
function buildCode(style) {
const {usercssData: {preprocessor, vars}, sourceCode} = style;
let builder;
if (preprocessor) {
if (!BUILDER[preprocessor]) {
return Promise.reject(chrome.i18n.getMessage('styleMetaErrorPreprocessor', preprocessor));
}
builder = BUILDER[preprocessor];
} else {
builder = BUILDER.default;
}
const sVars = simpleVars(vars);
return Promise.resolve().then(() => {
// preprocess
if (builder.preprocess) {
return builder.preprocess(sourceCode, sVars);
}
return sourceCode;
}).then(mozStyle =>
// moz-parser
loadScript('/js/moz-parser.js').then(() =>
mozParser.parse(mozStyle).then(sections => {
style.sections = sections;
})
)
).then(() => {
// postprocess
if (builder.postprocess) {
return builder.postprocess(style.sections, sVars);
}
}).then(() => style);
}
function simpleVars(vars) {
// 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;
}
return va[prop];
}
function validate(style) {
const {usercssData: data} = style;
// mandatory fields
for (const prop of ['name', 'namespace', 'version']) {
if (!data[prop]) {
throw new Error(chrome.i18n.getMessage('styleMissingMeta', prop));
}
}
// validate version
validVersion(data.version);
// validate URLs
validUrl(data.homepageURL);
validUrl(data.supportURL);
// validate vars
for (const key of Object.keys(data.vars)) {
validVar(data.vars[key]);
}
}
function validVersion(version) {
semverCompare(version, '0.0.0');
}
function validUrl(url) {
if (!url) {
return;
}
url = new URL(url);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`${url.protocol} is not a valid protocol`);
}
}
function validVar(va, value = 'default') {
if (va.type === 'select' || va.type === 'dropdown') {
if (va.options.every(o => o.name !== va[value])) {
throw new Error(chrome.i18n.getMessage('styleMetaErrorSelectValueMismatch'));
}
} else if (va.type === 'checkbox' && !/^[01]$/.test(va[value])) {
throw new Error(chrome.i18n.getMessage('styleMetaErrorCheckbox'));
} else if (va.type === 'color') {
va[value] = colorParser.format(colorParser.parse(va[value]));
}
}
function assignVars(style, oldStyle) {
const {usercssData: {vars}} = style;
const {usercssData: {vars: oldVars}} = oldStyle;
// The type of var might be changed during the update. Set value to null if the value is invalid.
for (const key of Object.keys(vars)) {
if (oldVars[key] && oldVars[key].value) {
vars[key].value = oldVars[key].value;
try {
validVar(vars[key], 'value');
} catch (e) {
vars[key].value = null;
}
}
}
}
return {buildMeta, buildCode, assignVars};
})();

View File

@ -5,6 +5,7 @@
<title i18n-text="manageTitle"></title>
<link rel="stylesheet" href="manage/manage.css">
<link rel="stylesheet" href="msgbox/msgbox.css">
<link rel="stylesheet" href="options/onoffswitch.css">
<style id="style-overrides"></style>
@ -83,6 +84,16 @@
</svg>
</template>
<template data-id="configureIcon">
<span class="configure-usercss" i18n-title="configureStyle">
<svg class="svg-icon" viewBox="0 0 20 20">
<path
d="M 10,2.0423828 A 7.9575898,7.9575898 0 0 0 8.8908203,2.1285156 V 4.355664 A 5.7578608,5.7578608 0 0 0 6.7919922,5.2240235 l -1.575,-1.575 A 7.9575898,7.9575898 0 0 0 3.6507813,5.21875 L 5.2222656,6.7902344 A 5.7578608,5.7578608 0 0 0 4.3521485,8.8908203 H 2.1302735 A 7.9575898,7.9575898 0 0 0 2.0423828,10 7.9575898,7.9575898 0 0 0 2.1285156,11.10918 H 4.355664 a 5.7578608,5.7578608 0 0 0 0.8683595,2.098828 l -1.575,1.575 A 7.9575898,7.9575898 0 0 0 5.21875,16.349219 l 1.5714844,-1.571484 a 5.7578608,5.7578608 0 0 0 2.1005859,0.870117 v 2.221875 A 7.9575898,7.9575898 0 0 0 10,17.957617 a 7.9575898,7.9575898 0 0 0 1.10918,-0.08613 v -2.227149 a 5.7578608,5.7578608 0 0 0 2.098828,-0.868359 l 1.575,1.575 a 7.9575898,7.9575898 0 0 0 1.566211,-1.569727 l -1.571484,-1.571485 a 5.7578608,5.7578608 0 0 0 0.870117,-2.100585 h 2.221875 A 7.9575898,7.9575898 0 0 0 17.957617,10 7.9575898,7.9575898 0 0 0 17.871485,8.8908203 H 15.644336 A 5.7578608,5.7578608 0 0 0 14.775977,6.7919922 l 1.575,-1.575 A 7.9575898,7.9575898 0 0 0 14.78125,3.6507813 L 13.209765,5.2222656 A 5.7578608,5.7578608 0 0 0 11.10918,4.3521485 V 2.1302735 A 7.9575898,7.9575898 0 0 0 10,2.0423828 Z m 0,4.2574219 A 3.6994645,3.6994645 0 0 1 13.700195,10 3.6994645,3.6994645 0 0 1 10,13.700195 3.6994645,3.6994645 0 0 1 6.2998047,10 3.6994645,3.6994645 0 0 1 10,6.2998047 Z"
/>
</svg>
</span>
</template>
<template data-id="updaterIcons">
<span class="updater-icons">
<span class="check-update" i18n-title="checkForUpdate">
@ -139,6 +150,8 @@
<script src="manage/filters.js"></script>
<script src="manage/updater-ui.js"></script>
<script src="manage/object-diff.js"></script>
<script src="js/color-parser.js"></script>
<script src="manage/config-dialog.js"></script>
<script src="manage/manage.js"></script>
</head>

156
manage/config-dialog.js Normal file
View File

@ -0,0 +1,156 @@
/* global colorParser messageBox makeLink */
'use strict';
function configDialog(style) {
const form = buildConfigForm();
return messageBox({
title: `${style.name} v${style.usercssData.version}`,
className: 'config-dialog',
contents: [
$element({
className: 'config-heading',
appendChild: style.usercssData.supportURL && makeLink({
className: 'external-support',
href: style.usercssData.supportURL,
textContent: t('externalFeedback')
})
}),
$element({
className: 'config-body',
appendChild: form.elements
})
],
buttons: [
t('confirmSave'),
{
textContent: t('confirmDefault'),
onclick: form.useDefault
},
t('confirmCancel')
]
}).then(result => {
if (result.button !== 0 && !result.enter) {
return;
}
return form.getVars();
});
function buildConfigForm() {
const labels = [];
const vars = deepCopy(style.usercssData.vars);
for (const key of Object.keys(vars)) {
const va = vars[key];
let appendChild;
switch (va.type) {
case 'color':
va.inputColor = $element({tag: 'input', type: 'color'});
va.inputAlpha = $element({
tag: 'input',
type: 'range',
min: 0,
max: 1,
title: chrome.i18n.getMessage('alphaChannel'),
step: 'any'
});
va.inputColor.onchange = va.inputAlpha.oninput = () => {
va.dirty = true;
const color = colorParser.parse(va.inputColor.value);
color.a = Number(va.inputAlpha.value);
va.value = colorParser.format(color);
va.inputColor.style.opacity = color.a;
};
appendChild = [
$element({appendChild: [va.inputColor, va.inputAlpha]})
];
break;
case 'checkbox':
va.input = $element({tag: 'input', type: 'checkbox'});
va.input.onchange = () => {
va.dirty = true;
va.value = String(Number(va.input.checked));
};
appendChild = [
$element({tag: 'span', className: 'onoffswitch', appendChild: [
va.input,
$element({tag: 'span'})
]})
];
break;
case 'select':
case 'dropdown':
case 'image':
// TODO: a image picker input?
va.input = $element({
tag: 'select',
appendChild: va.options.map(o => $element({
tag: 'option', value: o.name, textContent: o.label
}))
});
va.input.onchange = () => {
va.dirty = true;
va.value = va.input.value;
};
appendChild = [va.input];
break;
default:
va.input = $element({tag: 'input', type: 'text'});
va.input.oninput = () => {
va.dirty = true;
va.value = va.input.value;
};
appendChild = [va.input];
break;
}
appendChild.unshift($element({tag: 'span', appendChild: va.label}));
labels.push($element({
tag: 'label',
className: `config-${va.type}`,
appendChild
}));
}
drawValues();
function drawValues() {
for (const key of Object.keys(vars)) {
const va = vars[key];
const value = va.value === null || va.value === undefined ?
va.default : va.value;
if (va.type === 'color') {
const color = colorParser.parse(value);
va.inputAlpha.value = color.a;
va.inputColor.style.opacity = color.a;
delete color.a;
va.inputColor.value = colorParser.formatHex(color);
} else if (va.type === 'checkbox') {
va.input.checked = Number(value);
} else {
va.input.value = value;
}
}
}
function useDefault() {
for (const key of Object.keys(vars)) {
const va = vars[key];
va.dirty = va.value !== null && va.value !== undefined && va.value !== va.default;
va.value = null;
}
drawValues();
}
function getVars() {
return vars;
}
return {
elements: labels,
useDefault,
getVars
};
}
}

View File

@ -652,6 +652,79 @@ fieldset > *:not(legend) {
text-overflow: ellipsis;
}
/* config dialog */
.config-dialog .config-heading {
float: right;
margin: -1.25rem 0 0 0;
font-size: 0.9em;
}
.config-dialog label {
display: flex;
padding: .75em 0;
align-items: center;
}
.config-dialog label:first-child {
padding-top: 0;
}
.config-dialog label:last-child {
padding-bottom: 0;
}
.config-dialog label:not(:first-child) {
border-top: 1px dotted #ccc;
}
.config-dialog label > :first-child {
margin-right: 8px;
flex-grow: 1;
}
.config-dialog label:not([disabled]) > :first-child {
cursor: default;
}
.config-dialog label:not([disabled]):hover > :first-child {
text-shadow: 0 0 0.01px rgba(0, 0, 0, .25);
cursor: pointer;
}
.config-dialog input,
.config-dialog select,
.config-dialog .onoffswitch {
width: 60px;
margin: 0;
height: 2em;
box-sizing: border-box;
vertical-align: middle;
}
.config-dialog select {
width: auto;
min-width: 60px;
max-width: 124px;
}
.config-dialog .onoffswitch {
height: auto;
margin: calc((2em - 12px) / 2) 0;
}
.config-dialog input[type="text"] {
padding-left: 0.25em;
}
.config-dialog label > :last-child {
box-sizing: border-box;
flex-shrink: 0;
}
.config-dialog label > :last-child:not(.onoffswitch) > :not(:last-child) {
margin-right: 4px;
}
@keyframes fadein {
from {
opacity: 0;

View File

@ -2,6 +2,7 @@
/* global filtersSelector, filterAndAppend */
/* global checkUpdate, handleUpdateInstalled */
/* global objectDiff */
/* global configDialog */
'use strict';
let installed;
@ -192,12 +193,19 @@ function createStyleElement({style, name}) {
if (style.updateUrl && newUI.enabled) {
$('.actions', entry).appendChild(template.updaterIcons.cloneNode(true));
}
if (shouldShowConfig() && newUI.enabled) {
$('.actions', entry).appendChild(template.configureIcon.cloneNode(true));
}
// name being supplied signifies we're invoked by showStyles()
// which debounces its main loop thus loading the postponed favicons
createStyleTargetsElement({entry, style, postponeFavicons: name});
return entry;
function shouldShowConfig() {
return style.usercssData && Object.keys(style.usercssData.vars).length > 0;
}
}
@ -275,6 +283,25 @@ Object.assign(handleEvent, {
'.update': 'update',
'.delete': 'delete',
'.applies-to .expander': 'expandTargets',
'.configure-usercss': 'config'
},
config(event, {styleMeta: style}) {
configDialog(style).then(vars => {
if (!vars) {
return;
}
const keys = Object.keys(vars).filter(k => vars[k].dirty);
if (!keys.length) {
return;
}
style.reason = 'config';
for (const key of keys) {
style.usercssData.vars[key].value = vars[key].value;
}
onBackgroundReady()
.then(() => BG.usercssHelper.save(style));
});
},
entryClicked(event) {
@ -331,12 +358,18 @@ Object.assign(handleEvent, {
},
update(event, entry) {
// update everything but name
saveStyleSafe(Object.assign(entry.updatedCode, {
const request = Object.assign(entry.updatedCode, {
id: entry.styleId,
name: null,
reason: 'update',
}));
});
if (entry.updatedCode.usercssData) {
onBackgroundReady()
.then(() => BG.usercssHelper.save(request));
} else {
// update everything but name
request.name = null;
saveStyleSafe(request);
}
},
delete(event, entry) {

View File

@ -114,7 +114,11 @@ function reportUpdateState(state, style, details) {
if (entry.classList.contains('can-update')) {
break;
}
const same = details === BG.updater.SAME_MD5 || details === BG.updater.SAME_CODE;
const same = (
details === BG.updater.SAME_MD5 ||
details === BG.updater.SAME_CODE ||
details === BG.updater.SAME_VERSION
);
const edited = details === BG.updater.EDITED || details === BG.updater.MAYBE_EDITED;
entry.dataset.details = details;
if (!details) {

View File

@ -22,9 +22,14 @@
"scripts": [
"js/messaging.js",
"vendor-overwrites/lz-string/LZString-2xspeedup.js",
"js/color-parser.js",
"js/usercss.js",
"background/storage.js",
"background/usercss-helper.js",
"js/prefs.js",
"js/script-loader.js",
"background/background.js",
"vendor/node-semver/semver.js",
"background/update.js"
]
},
@ -49,6 +54,13 @@
"run_at": "document_start",
"all_frames": false,
"js": ["content/install.js"]
},
{
"matches": ["<all_urls>"],
"include_globs": ["*.user.css", "*.user.styl"],
"run_at": "document_idle",
"all_frames": false,
"js": ["content/util.js", "content/install-user-css.js"]
}
],
"browser_action": {

View File

@ -40,17 +40,27 @@
text-align: center;
}
#message-box.center #message-box-contents pre {
text-align: left;
}
#message-box.center > div {
top: unset;
right: unset;
}
#message-box.pre #message-box-contents {
white-space: pre-line;
}
#message-box-title {
font-weight: bold;
background-color: rgb(145, 208, 198);
padding: .75rem 24px .75rem 52px;
font-size: 1rem;
position: relative;
min-height: 42px;
box-sizing: border-box;
}
#message-box-title::before {

View File

@ -4,7 +4,7 @@ function messageBox({
title, // [mandatory] string
contents, // [mandatory] 1) DOM element 2) string
className = '', // string, CSS class name of the message box element
buttons = [], // array of strings used as labels
buttons = [], // array of strings or objects like {textContent[string], onclick[function]}.
onshow, // function(messageboxElement) invoked after the messagebox is shown
blockScroll, // boolean, blocks the page scroll
}) { // RETURNS: Promise resolved to {button[number], enter[boolean], esc[boolean]}
@ -69,14 +69,12 @@ function messageBox({
onclick: messageBox.listeners.closeIcon}),
$element({id: `${id}-contents`, appendChild: tHTML(contents)}),
$element({id: `${id}-buttons`, appendChild:
buttons.map((textContent, buttonIndex) => textContent &&
$element({
buttons.map((content, buttonIndex) => content && $element({
tag: 'button',
buttonIndex,
textContent,
onclick: messageBox.listeners.button,
})
)
textContent: content.textContent || content,
onclick: content.onclick || messageBox.listeners.button,
}))
}),
]}),
]});
@ -101,3 +99,17 @@ function messageBox({
messageBox.resolve = null;
}
}
messageBox.alert = text =>
messageBox({
contents: text,
className: 'pre center',
buttons: [t('confirmClose')]
});
messageBox.confirm = text =>
messageBox({
contents: text,
className: 'pre center',
buttons: [t('confirmYes'), t('confirmNo')]
}).then(result => result.button === 0 || result.enter);

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<title i18n-text-append="optionsHeading">Stylus </title>
<link rel="stylesheet" href="options/options.css">
<link rel="stylesheet" href="options/onoffswitch.css">
<style id="firefox-transitions-bug-suppressor">
/* restrict to FF */
@ -122,6 +123,13 @@
<span></span>
</span>
</label>
<label>
<span i18n-text="optionsAdvancedNewStyleAsUsercss"></span>
<span class="onoffswitch">
<input type="checkbox" id="newStyleAsUsercss">
<span></span>
</span>
</label>
</div>
</div>

59
options/onoffswitch.css Normal file
View File

@ -0,0 +1,59 @@
/* On/Off FlipSwitch https://proto.io/freebies/onoff/ */
.onoffswitch {
position: relative;
margin: 1ex 0;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.onoffswitch input {
display: none;
}
.onoffswitch span {
display: block;
overflow: hidden;
cursor: pointer;
height: 12px;
padding: 0;
line-height: 12px;
border: 0 solid #E3E3E3;
border-radius: 12px;
background-color: #E0E0E0;
box-shadow: inset 2px 2px 4px rgba(0,0,0,0.1);
}
.onoffswitch span::before {
content: "";
display: block;
width: 18px;
height: 18px;
margin: -3px;
background: #efefef;
position: absolute;
top: 0;
bottom: 0;
right: 46px;
border-radius: 18px;
box-shadow: 0 3px 13px 0 rgba(0, 0, 0, 0.4);
}
.onoffswitch input:checked + span {
background-color: #CAEBE3;
}
.onoffswitch input:checked + span, .onoffswitch input:checked + span::before {
border-color: #CAEBE3;
}
.onoffswitch input:checked + span .onoffswitch-inner {
margin-left: 0;
}
.onoffswitch input:checked + span::before {
right: 0;
background-color: #04BA9F;
box-shadow: 3px 6px 18px 0 rgba(0, 0, 0, 0.2);
}

View File

@ -96,6 +96,7 @@ label:not([disabled]):hover > :first-child {
button,
input[type=number],
input[type="color"],
select,
.onoffswitch {
width: 60px;
box-sizing: border-box;
@ -221,63 +222,3 @@ sup {
25% { opacity: 1 }
100% { opacity: 0 }
}
/* On/Off FlipSwitch https://proto.io/freebies/onoff/ */
.onoffswitch {
position: relative;
margin: 1ex 0;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.onoffswitch input {
display: none;
}
.onoffswitch span {
display: block;
overflow: hidden;
cursor: pointer;
height: 12px;
padding: 0;
line-height: 12px;
border: 0 solid #E3E3E3;
border-radius: 12px;
background-color: #E0E0E0;
box-shadow: inset 2px 2px 4px rgba(0,0,0,0.1);
}
.onoffswitch span::before {
content: "";
display: block;
width: 18px;
height: 18px;
margin: -3px;
background: #efefef;
position: absolute;
top: 0;
bottom: 0;
right: 46px;
border-radius: 18px;
box-shadow: 0 3px 13px 0 rgba(0, 0, 0, 0.4);
}
.onoffswitch input:checked + span {
background-color: #CAEBE3;
}
.onoffswitch input:checked + span, .onoffswitch input:checked + span::before {
border-color: #CAEBE3;
}
.onoffswitch input:checked + span .onoffswitch-inner {
margin-left: 0;
}
.onoffswitch input:checked + span::before {
right: 0;
background-color: #04BA9F;
box-shadow: 3px 6px 18px 0 rgba(0, 0, 0, 0.2);
}

View File

@ -0,0 +1,64 @@
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: http://codemirror.net/LICENSE
(function(mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"), "cjs");
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror"], function(CM) { mod(CM, "amd"); });
else // Plain browser env
mod(CodeMirror, "plain");
})(function(CodeMirror, env) {
if (!CodeMirror.modeURL) CodeMirror.modeURL = "../mode/%N/%N.js";
var loading = {};
function splitCallback(cont, n) {
var countDown = n;
return function() { if (--countDown == 0) cont(); };
}
function ensureDeps(mode, cont) {
var deps = CodeMirror.modes[mode].dependencies;
if (!deps) return cont();
var missing = [];
for (var i = 0; i < deps.length; ++i) {
if (!CodeMirror.modes.hasOwnProperty(deps[i]))
missing.push(deps[i]);
}
if (!missing.length) return cont();
var split = splitCallback(cont, missing.length);
for (var i = 0; i < missing.length; ++i)
CodeMirror.requireMode(missing[i], split);
}
CodeMirror.requireMode = function(mode, cont) {
if (typeof mode != "string") mode = mode.name;
if (CodeMirror.modes.hasOwnProperty(mode)) return ensureDeps(mode, cont);
if (loading.hasOwnProperty(mode)) return loading[mode].push(cont);
var file = CodeMirror.modeURL.replace(/%N/g, mode);
if (env == "plain") {
var script = document.createElement("script");
script.src = file;
var others = document.getElementsByTagName("script")[0];
var list = loading[mode] = [cont];
CodeMirror.on(script, "load", function() {
ensureDeps(mode, function() {
for (var i = 0; i < list.length; ++i) list[i]();
});
});
others.parentNode.insertBefore(script, others);
} else if (env == "cjs") {
require(file);
cont();
} else if (env == "amd") {
requirejs([file], cont);
}
};
CodeMirror.autoLoadMode = function(instance, mode) {
if (!CodeMirror.modes.hasOwnProperty(mode))
CodeMirror.requireMode(mode, function() {
instance.setOption("mode", instance.getOption("mode"));
});
};
});

771
vendor/codemirror/mode/stylus/stylus.js vendored Normal file

File diff suppressed because one or more lines are too long

1
vendor/node-semver/README.md vendored Normal file
View File

@ -0,0 +1 @@
See https://github.com/eight04/node-semver-bundle.

1
vendor/node-semver/semver.js vendored Normal file

File diff suppressed because one or more lines are too long

1
vendor/stylus-lang/README.md vendored Normal file
View File

@ -0,0 +1 @@
The content of this folder belongs to [stylus preprocessor](https://github.com/stylus/stylus/).

6
vendor/stylus-lang/stylus.min.js vendored Normal file

File diff suppressed because one or more lines are too long