diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 6122067fbd..2c9aff70c1 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -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"
},
diff --git a/background/background.js b/background/background.js
index 6ab3711ae2..0d5be06573 100644
--- a/background/background.js
+++ b/background/background.js
@@ -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});
+ });
+ });
+}
diff --git a/background/storage.js b/background/storage.js
index fe8a222ba7..fe6a98fcaa 100644
--- a/background/storage.js
+++ b/background/storage.js
@@ -381,20 +381,29 @@ function saveStyle(style) {
}
let existed;
let codeIsUpdated;
- if (reason === 'update' || reason === 'update-digest') {
- return calcStyleDigest(style).then(digest => {
- style.originalDigest = digest;
- return decide();
- });
+
+ return maybeCalcDigest()
+ .then(maybeImportFix)
+ .then(decide);
+
+ function maybeCalcDigest() {
+ if (reason === 'update' || reason === 'update-digest') {
+ return calcStyleDigest(style).then(digest => {
+ style.originalDigest = digest;
+ });
+ }
+ return Promise.resolve();
}
- if (reason === 'import') {
- style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
- delete style.styleDigest; // TODO: remove in the future
- if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) {
- delete style.originalDigest;
+
+ function maybeImportFix() {
+ if (reason === 'import') {
+ style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future
+ delete style.styleDigest; // TODO: remove in the future
+ if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) {
+ delete style.originalDigest;
+ }
}
}
- return decide();
function decide() {
if (id !== null) {
@@ -712,7 +721,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);
diff --git a/background/update.js b/background/update.js
index c400a29b64..36adcde749 100644
--- a/background/update.js
+++ b/background/update.js
@@ -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) {
- 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);
+ 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)
+ .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) {
diff --git a/background/usercss-helper.js b/background/usercss-helper.js
new file mode 100644
index 0000000000..c3a8432c8e
--- /dev/null
+++ b/background/usercss-helper.js
@@ -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};
+})();
diff --git a/content/install-user-css.js b/content/install-user-css.js
new file mode 100644
index 0000000000..150199c240
--- /dev/null
+++ b/content/install-user-css.js
@@ -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();
+}
diff --git a/content/util.js b/content/util.js
new file mode 100644
index 0000000000..34160e87bd
--- /dev/null
+++ b/content/util.js
@@ -0,0 +1,10 @@
+'use strict';
+
+function runtimeSend(request) {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ request,
+ ({success, result}) => (success ? resolve : reject)(result)
+ );
+ });
+}
diff --git a/edit.html b/edit.html
index bc1527f647..e1dd9fce88 100644
--- a/edit.html
+++ b/edit.html
@@ -17,9 +17,15 @@
+
+
+
+
+
+
@@ -50,9 +56,14 @@
+
+
+
+
+
diff --git a/edit/applies-to-line-widget.js b/edit/applies-to-line-widget.js
new file mode 100644
index 0000000000..9923394368
--- /dev/null
+++ b/edit/applies-to-line-widget.js
@@ -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;
+ }
+}
diff --git a/edit/codemirror-default.css b/edit/codemirror-default.css
new file mode 100644
index 0000000000..96626241f0
--- /dev/null
+++ b/edit/codemirror-default.css
@@ -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;
+}
diff --git a/edit/codemirror-default.js b/edit/codemirror-default.js
new file mode 100644
index 0000000000..5d38a29376
--- /dev/null
+++ b/edit/codemirror-default.js
@@ -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');
+ });
+})();
diff --git a/edit/edit.css b/edit/edit.css
index 5c7eff9f09..1b174b545e 100644
--- a/edit/edit.css
+++ b/edit/edit.css
@@ -140,6 +140,10 @@ h2 .svg-icon, label .svg-icon {
content: "";
opacity: .15;
}
+/* footer */
+#footer {
+ margin-top: 1em;
+}
/************ content ***********/
#sections > div {
margin: 0.7rem;
@@ -173,18 +177,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;
@@ -192,34 +189,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);
@@ -534,6 +512,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 {
diff --git a/edit/edit.js b/edit/edit.js
index 49f695c377..9a82ea08ac 100644
--- a/edit/edit.js
+++ b/edit/edit.js
@@ -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;
+ $('.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 = [...appliesTo.children].some(item =>
- !item.matches('.applies-to-everything') &&
- $('.applies-type', item).value === 'regexp' &&
- $('.applies-value', item).value.trim()
- );
+ 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) {
- // 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]];
- }
- }
- 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 => {
- let style = styles[0];
- if (!style) {
- style = {id: null, sections: []};
- history.replaceState({}, document.title, location.pathname);
- }
+ getStyle().then(style => {
styleId = style.id;
sessionStorage.justEditedStyleId = styleId;
- setStyleMeta(style);
- window.onload = () => {
- window.onload = null;
- initWithStyle({style});
- };
- if (document.readyState !== 'loading') {
- window.onload();
+
+ 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');
+ return Promise.resolve(createEmptyStyle());
+ }
+ $('#heading').textContent = t('editStyleHeading');
+ // This is an edit
+ return getStylesSafe({id}).then(styles => {
+ let style = styles[0];
+ if (!style) {
+ style = createEmptyStyle();
+ history.replaceState({}, document.title, location.pathname);
+ }
+ 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);
- editors.forEach(cm => updateLintReportIfEnabled(cm, 1));
- editors.last.state.renderLintReportNow = true;
- });
- }
-
- function doImportWhenReady(target) {
- const replaceOldStyle = target.name === 'import-replace';
- $('.dismiss', popup).onclick();
+ const replaceOldStyle = event.target.name === 'import-replace';
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;
-
- 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));
- }
- 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({
- 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;
+ mozParser.parse(mozStyle)
+ .then(updateSection)
+ .then(() => {
+ editors.forEach(cm => updateLintReportIfEnabled(cm, 1));
+ editors.last.state.renderLintReportNow = true;
+ $('.dismiss', popup).onclick();
+ })
+ .catch(showError);
+
+ function showError(errors) {
+ if (!Array.isArray(errors)) {
+ errors = [errors];
+ }
+ showHelp(t('styleFromMozillaFormatError'), $element({
+ tag: 'pre',
+ textContent: errors.join('\n'),
+ }));
}
- // 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;
+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'));
}
- 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 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;
diff --git a/edit/lint-defaults-stylelint.js b/edit/lint-defaults-stylelint.js
index 0b12052c3e..b75bf6f4f3 100644
--- a/edit/lint-defaults-stylelint.js
+++ b/edit/lint-defaults-stylelint.js
@@ -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],
diff --git a/edit/lint.js b/edit/lint.js
index 0fc9d2b3e5..7ac4dd9cf3 100644
--- a/edit/lint.js
+++ b/edit/lint.js
@@ -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 = [];
- 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 loadLibrary().then(loadAddon);
+
+ function loadLibrary() {
+ if (name === 'csslint' && !window.CSSLint) {
+ return loadScript([
+ '/vendor-overwrites/csslint/csslint-worker.js',
+ '/edit/lint-defaults-csslint.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();
}
- 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'
- );
+
+ 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));
}
diff --git a/edit/regexp-tester.js b/edit/regexp-tester.js
new file mode 100644
index 0000000000..fd5be5052b
--- /dev/null
+++ b/edit/regexp-tester.js
@@ -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};
+})();
diff --git a/edit/source-editor.js b/edit/source-editor.js
new file mode 100644
index 0000000000..922b301dff
--- /dev/null
+++ b/edit/source-editor.js
@@ -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
+ };
+}
diff --git a/edit/util.js b/edit/util.js
new file mode 100644
index 0000000000..84b2e7d291
--- /dev/null
+++ b/edit/util.js
@@ -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});
+}
diff --git a/install-usercss.html b/install-usercss.html
new file mode 100644
index 0000000000..ae6ea04e43
--- /dev/null
+++ b/install-usercss.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/install-usercss/install-usercss.css b/install-usercss/install-usercss.css
new file mode 100644
index 0000000000..eb4df0a651
--- /dev/null
+++ b/install-usercss/install-usercss.css
@@ -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%;
+}
diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js
new file mode 100644
index 0000000000..8098622308
--- /dev/null
+++ b/install-usercss/install-usercss.js
@@ -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;
+ }
+})();
diff --git a/js/color-parser.js b/js/color-parser.js
new file mode 100644
index 0000000000..29e7eda8dd
--- /dev/null
+++ b/js/color-parser.js
@@ -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};
+})();
diff --git a/js/dom.js b/js/dom.js
index 249de7225e..02c3e33042 100644
--- a/js/dom.js
+++ b/js/dom.js
@@ -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 (navigator.userAgent.includes('Firefox')) {
// 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);
+}
diff --git a/js/localization.js b/js/localization.js
index 0065d22c24..79b242e93b 100644
--- a/js/localization.js
+++ b/js/localization.js
@@ -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();
diff --git a/js/messaging.js b/js/messaging.js
index 432ba0fd77..6f6efbd64b 100644
--- a/js/messaging.js
+++ b/js/messaging.js
@@ -370,15 +370,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);
});
}
@@ -395,3 +427,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);
+ }
+ });
+}
diff --git a/js/moz-parser.js b/js/moz-parser.js
new file mode 100644
index 0000000000..72f01b6966
--- /dev/null
+++ b/js/moz-parser.js
@@ -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');
+ }
+ };
+})();
diff --git a/js/prefs.js b/js/prefs.js
index 7938a475f1..761e0adc00 100644
--- a/js/prefs.js
+++ b/js/prefs.js
@@ -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
diff --git a/js/script-loader.js b/js/script-loader.js
new file mode 100644
index 0000000000..35d0ecdeb0
--- /dev/null
+++ b/js/script-loader.js
@@ -0,0 +1,46 @@
+'use strict';
+
+// loadScript(script: Array|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)));
+ };
+})();
diff --git a/js/usercss.js b/js/usercss.js
new file mode 100644
index 0000000000..0a4edd21a3
--- /dev/null
+++ b/js/usercss.js
@@ -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 = /<< {
+ 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};
+})();
diff --git a/manage.html b/manage.html
index 20a2a55318..8532a542f5 100644
--- a/manage.html
+++ b/manage.html
@@ -5,6 +5,7 @@
+
@@ -83,6 +84,16 @@
+
+
+
+
+
+
@@ -139,6 +150,8 @@
+
+
diff --git a/manage/config-dialog.js b/manage/config-dialog.js
new file mode 100644
index 0000000000..57f8b330c2
--- /dev/null
+++ b/manage/config-dialog.js
@@ -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
+ };
+ }
+}
diff --git a/manage/manage.css b/manage/manage.css
index 7aab86d68f..6d2c984371 100644
--- a/manage/manage.css
+++ b/manage/manage.css
@@ -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;
diff --git a/manage/manage.js b/manage/manage.js
index 202da0fc10..0b1f41ae0e 100644
--- a/manage/manage.js
+++ b/manage/manage.js
@@ -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) {
diff --git a/manage/updater-ui.js b/manage/updater-ui.js
index a17a170b02..b48b9d7314 100644
--- a/manage/updater-ui.js
+++ b/manage/updater-ui.js
@@ -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) {
diff --git a/manifest.json b/manifest.json
index 0aa9354179..5d2c9eff2a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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": [""],
+ "include_globs": ["*.user.css", "*.user.styl"],
+ "run_at": "document_idle",
+ "all_frames": false,
+ "js": ["content/util.js", "content/install-user-css.js"]
}
],
"browser_action": {
diff --git a/msgbox/msgbox.css b/msgbox/msgbox.css
index 5cb45d34a0..5a5ab86062 100644
--- a/msgbox/msgbox.css
+++ b/msgbox/msgbox.css
@@ -41,17 +41,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 {
diff --git a/msgbox/msgbox.js b/msgbox/msgbox.js
index 468ecb960b..0562757fc0 100644
--- a/msgbox/msgbox.js
+++ b/msgbox/msgbox.js
@@ -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({
- tag: 'button',
- buttonIndex,
- textContent,
- onclick: messageBox.listeners.button,
- })
- )
+ buttons.map((content, buttonIndex) => content && $element({
+ tag: 'button',
+ buttonIndex,
+ 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);
diff --git a/options.html b/options.html
index 5d0743aeb3..4528eb7303 100644
--- a/options.html
+++ b/options.html
@@ -4,6 +4,7 @@
Stylus
+