diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 2e2be027..66a885f8 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -596,6 +596,18 @@
"message": "Update style",
"description": "Label for update button"
},
+ "installPreferSchemeLabel": {
+ "message": "The style should be applied:"
+ },
+ "installPreferSchemeNone": {
+ "message": "Always"
+ },
+ "installPreferSchemeDark": {
+ "message": "In Dark Mode"
+ },
+ "installPreferSchemeLight": {
+ "message": "In Light Mode"
+ },
"installUpdate": {
"message": "Install update",
"description": "Label for the button to install an update for a single style"
@@ -1053,6 +1065,18 @@
"optionsAdvancedNewStyleAsUsercss": {
"message": "Write new style as usercss"
},
+ "optionsAdvancedAutoSwitchScheme": {
+ "message": "Toggle Light/Dark Mode styles automatically"
+ },
+ "optionsAdvancedAutoSwitchSchemeNever": {
+ "message": "Never"
+ },
+ "optionsAdvancedAutoSwitchSchemeBySystem": {
+ "message": "By system preference"
+ },
+ "optionsAdvancedAutoSwitchSchemeByTime": {
+ "message": "By night time:"
+ },
"optionsAdvancedPatchCsp": {
"message": "Patch CSP
to allow style assets"
},
@@ -1533,6 +1557,12 @@
"message": "Style was not applied due to its incorrect usage of 'regexp()'",
"description": "Tooltip in the popup for styles that were not applied at all"
},
+ "styleNotAppliedSchemeDark": {
+ "message": "This style is only applied in Dark Mode"
+ },
+ "styleNotAppliedSchemeLight": {
+ "message": "This style is only applied in Light Mode"
+ },
"styleRegexpInvalidExplanation": {
"message": "Some 'regexp()' rules that could not be compiled at all."
},
diff --git a/background/background.js b/background/background.js
index b3a31a92..404639e5 100644
--- a/background/background.js
+++ b/background/background.js
@@ -15,6 +15,7 @@
findExistingTab
openURL
*/ // toolbox.js
+/* global colorScheme */ // color-scheme.js
'use strict';
//#region API
@@ -41,6 +42,7 @@ addAPI(/** @namespace API */ {
updater: updateMan,
usercss: usercssMan,
usw: uswApi,
+ colorScheme,
/** @type {BackgroundWorker} */
worker: createWorker({url: '/background/background-worker'}),
diff --git a/background/color-scheme.js b/background/color-scheme.js
new file mode 100644
index 00000000..66e2e4c6
--- /dev/null
+++ b/background/color-scheme.js
@@ -0,0 +1,100 @@
+/* global prefs */
+/* exported colorScheme */
+
+'use strict';
+
+const colorScheme = (() => {
+ let systemPreferDark = false;
+ let timePreferDark = false;
+ const changeListeners = new Set();
+
+ const checkTime = ['schemeSwitcher.nightStart', 'schemeSwitcher.nightEnd'];
+ prefs.subscribe(checkTime, (key, value) => {
+ updateTimePreferDark();
+ createAlarm(key, value);
+ });
+ checkTime.forEach(key => createAlarm(key, prefs.get(key)));
+
+ prefs.subscribe(['schemeSwitcher.enabled'], emitChange);
+
+ chrome.alarms.onAlarm.addListener(info => {
+ if (checkTime.includes(info.name)) {
+ updateTimePreferDark();
+ }
+ });
+
+ updateSystemPreferDark();
+ updateTimePreferDark();
+
+ return {shouldIncludeStyle, onChange, updateSystemPreferDark};
+
+ function createAlarm(key, value) {
+ const date = new Date();
+ applyDate(date, value);
+ if (date.getTime() < Date.now()) {
+ date.setDate(date.getDate() + 1);
+ }
+ chrome.alarms.create(key, {
+ when: date.getTime(),
+ periodInMinutes: 24 * 60,
+ });
+ }
+
+ function shouldIncludeStyle(style) {
+ const isDark = style.preferScheme === 'dark';
+ const isLight = style.preferScheme === 'light';
+ if (!isDark && !isLight) {
+ return true;
+ }
+ const switcherState = prefs.get('schemeSwitcher.enabled');
+ if (switcherState === 'never') {
+ return true;
+ }
+ if (switcherState === 'system') {
+ return systemPreferDark && isDark ||
+ !systemPreferDark && isLight;
+ }
+ return timePreferDark && isDark ||
+ !timePreferDark && isLight;
+ }
+
+ function updateSystemPreferDark() {
+ const oldValue = systemPreferDark;
+ systemPreferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ if (systemPreferDark !== oldValue) {
+ emitChange();
+ }
+ return true;
+ }
+
+ function updateTimePreferDark() {
+ const oldValue = timePreferDark;
+ const date = new Date();
+ const now = date.getTime();
+ applyDate(date, prefs.get('schemeSwitcher.nightStart'));
+ const start = date.getTime();
+ applyDate(date, prefs.get('schemeSwitcher.nightEnd'));
+ const end = date.getTime();
+ timePreferDark = start > end ?
+ now >= start || now < end :
+ now >= start && now < end;
+ if (timePreferDark !== oldValue) {
+ emitChange();
+ }
+ }
+
+ function applyDate(date, time) {
+ const [h, m] = time.split(':').map(Number);
+ date.setHours(h, m, 0, 0);
+ }
+
+ function onChange(listener) {
+ changeListeners.add(listener);
+ }
+
+ function emitChange() {
+ for (const listener of changeListeners) {
+ listener();
+ }
+ }
+})();
diff --git a/background/style-manager.js b/background/style-manager.js
index 8bbf3d69..c71a70ba 100644
--- a/background/style-manager.js
+++ b/background/style-manager.js
@@ -6,6 +6,7 @@
/* global prefs */
/* global tabMan */
/* global usercssMan */
+/* global colorScheme */
'use strict';
/*
@@ -61,6 +62,14 @@ const styleMan = (() => {
let ready = init();
chrome.runtime.onConnect.addListener(handleLivePreview);
+ // function handleColorScheme() {
+ colorScheme.onChange(() => {
+ for (const {style: data} of dataMap.values()) {
+ if (data.preferScheme === 'dark' || data.preferScheme === 'light') {
+ broadcastStyleUpdated(data, 'colorScheme', undefined, false);
+ }
+ }
+ });
//#endregion
//#region Exports
@@ -183,6 +192,7 @@ const styleMan = (() => {
const query = createMatchQuery(url);
for (const style of styles) {
let excluded = false;
+ let excludedScheme = false;
let sloppy = false;
let sectionMatched = false;
const match = urlMatchStyle(query, style);
@@ -193,6 +203,9 @@ const styleMan = (() => {
if (match === 'excluded') {
excluded = true;
}
+ if (match === 'excludedScheme') {
+ excludedScheme = true;
+ }
for (const section of style.sections) {
if (styleSectionGlobal(section) && styleCodeEmpty(section.code)) {
continue;
@@ -207,7 +220,7 @@ const styleMan = (() => {
}
}
if (sectionMatched) {
- result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy});
+ result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy, excludedScheme});
}
}
return result;
@@ -546,6 +559,9 @@ const styleMan = (() => {
if (!style.enabled) {
return 'disabled';
}
+ if (!colorScheme.shouldIncludeStyle(style)) {
+ return 'excludedScheme';
+ }
return true;
}
diff --git a/content/apply.js b/content/apply.js
index 336b3bc9..0f5dd087 100644
--- a/content/apply.js
+++ b/content/apply.js
@@ -68,6 +68,13 @@
window.addEventListener(orphanEventId, orphanCheck, true);
}
+ // detect media change in content script
+ // FIXME: move this to background page when following bugs are fixed:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1561546
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=968651
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+ media.addListener(() => API.colorScheme.updateSystemPreferDark().catch(console.error));
+
function onInjectorUpdate() {
if (!isOrphaned) {
updateCount();
diff --git a/install-usercss.html b/install-usercss.html
index 03c78549..23e4dc4c 100644
--- a/install-usercss.html
+++ b/install-usercss.html
@@ -50,6 +50,14 @@
+
diff --git a/install-usercss/install-usercss.css b/install-usercss/install-usercss.css
index 65ea5552..0b25f5a2 100644
--- a/install-usercss/install-usercss.css
+++ b/install-usercss/install-usercss.css
@@ -242,7 +242,6 @@ h2.installed.active ~ .configure-usercss:hover svg {
.set-update-url {
flex-wrap: wrap;
}
-
.set-update-url p {
word-break: break-all;
opacity: .5;
@@ -250,6 +249,10 @@ h2.installed.active ~ .configure-usercss:hover svg {
margin: .25em 0 .25em;
}
+label.set-prefer-scheme:not(.unavailable) {
+ padding-left: 0;
+}
+
.external {
text-align: center;
}
@@ -299,6 +302,7 @@ li {
}
label {
+ /* FIXME: why do we want to give all labels a padding? */
padding-left: 16px;
position: relative;
}
diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js
index 2cc9a06a..6c79a9fd 100644
--- a/install-usercss/install-usercss.js
+++ b/install-usercss/install-usercss.js
@@ -135,6 +135,13 @@ setTimeout(() => !cm && showSpinner($('#header')), 200);
$('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
updateUrl.href.slice(0, 300) + '...';
+ // set prefer scheme
+ const preferScheme = $('.set-prefer-scheme select');
+ preferScheme.onchange = () => {
+ style.preferScheme = preferScheme.value;
+ };
+ preferScheme.onchange();
+
if (URLS.isLocalhost(initialUrl)) {
$('.live-reload input').onchange = liveReload.onToggled;
} else {
@@ -167,6 +174,9 @@ function updateMeta(style, dup = installedDup) {
$('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description;
+ $('.set-prefer-scheme select').value =
+ style.preferScheme === 'dark' ? 'dark' :
+ style.preferScheme === 'light' ? 'light' : 'none';
if (data.author) {
$('.meta-author').parentNode.style.display = '';
@@ -305,6 +315,7 @@ function install(style) {
$('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : '';
+ $('.set-prefer-scheme select').disabled = true;
enablePostActions();
updateMeta(style);
}
diff --git a/js/dom.js b/js/dom.js
index fb82ba8c..a2601fc1 100644
--- a/js/dom.js
+++ b/js/dom.js
@@ -291,39 +291,64 @@ function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
* Accepts an array of pref names (values are fetched via prefs.get)
* and establishes a two-way connection between the document elements and the actual prefs
*/
-function setupLivePrefs(ids = prefs.knownKeys.filter(id => $('#' + id))) {
+function setupLivePrefs(ids = prefs.knownKeys.filter(id => $(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`))) {
let forceUpdate = true;
prefs.subscribe(ids, updateElement, {runNow: true});
forceUpdate = false;
- ids.forEach(id => $('#' + id).on('change', onChange));
+
+ for (const id of ids) {
+ const elements = $$(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`);
+ for (const element of elements) {
+ element.addEventListener('change', onChange);
+ }
+ }
function onChange() {
- prefs.set(this.id, this[getPropName(this)]);
+ if (!this.checkValidity()) {
+ return;
+ }
+ if (this.type === 'radio' && !this.checked) {
+ return;
+ }
+ prefs.set(this.id || this.name, getValue(this));
}
- function getPropName(el) {
- return el.type === 'checkbox' ? 'checked'
- : el.type === 'number' ? 'valueAsNumber' :
- 'value';
+ function getValue(el) {
+ const type = el.dataset.valueType || el.type;
+ return type === 'checkbox' ? el.checked :
+ // https://stackoverflow.com/questions/18062069/why-does-valueasnumber-return-nan-as-a-value
+ // valueAsNumber is not applicable for input[text/radio] or select
+ type === 'number' ? Number(el.value) :
+ el.value;
}
- function isSame(el, propName, value) {
- return el[propName] === value ||
+ function isSame(el, oldValue, value) {
+ return oldValue === value ||
typeof value === 'boolean' &&
el.tagName === 'SELECT' &&
- el[propName] === `${value}`;
+ oldValue === `${value}` ||
+ el.type === 'radio' && (oldValue === value) === el.checked;
}
function updateElement(id, value) {
- const el = $('#' + id);
- if (el) {
- const prop = getPropName(el);
- if (!isSame(el, prop, value) || forceUpdate) {
- el[prop] = value;
+ const els = $$(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`);
+ if (!els.length) {
+ // FIXME: why do we unsub all ids when a single id is missing from the page
+ prefs.unsubscribe(ids, updateElement);
+ return;
+ }
+ for (const el of els) {
+ const oldValue = getValue(el);
+ if (!isSame(el, oldValue, value) || forceUpdate) {
+ if (el.type === 'radio') {
+ el.checked = value === oldValue;
+ } else if (el.type === 'checkbox') {
+ el.checked = value;
+ } else {
+ el.value = value;
+ }
el.dispatchEvent(new Event('change', {bubbles: true}));
}
- } else {
- prefs.unsubscribe(ids, updateElement);
}
}
}
diff --git a/js/prefs.js b/js/prefs.js
index e11f5c0b..7f461173 100644
--- a/js/prefs.js
+++ b/js/prefs.js
@@ -30,6 +30,10 @@
// checkbox in style config dialog
'config.autosave': true,
+ 'schemeSwitcher.enabled': 'never',
+ 'schemeSwitcher.nightStart': '18:00',
+ 'schemeSwitcher.nightEnd': '06:00',
+
'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs
'popup.breadcrumbs.usePath': false, // use URL path for 'this URL'
'popup.enabledFirst': true, // display enabled styles before disabled styles
diff --git a/manifest.json b/manifest.json
index 78a0c74e..4ed8a4a1 100644
--- a/manifest.json
+++ b/manifest.json
@@ -37,6 +37,7 @@
"background/common.js",
"background/db.js",
+ "background/color-scheme.js",
"background/icon-manager.js",
"background/navigation-manager.js",
"background/style-search-db.js",
diff --git a/options.html b/options.html
index 8e9fc5f1..a6170f82 100644
--- a/options.html
+++ b/options.html
@@ -52,7 +52,7 @@