Add: toggle dark/night mode styles automatically (#736)

* Add: color-scheme.js

* Add: handle color scheme

* Add: styleManager.setMeta

* Add: make setupLivePrefs work with radio

* Change: drop setupRadioButtons

* Add: UI for schemeSwitcher

* Add: prefer-scheme select in installation page

* Fix: add alarm listener

* Add: display excluded reason in popup

* Fix: rely on data-value-type instead of input name

* Fix: oldValue and newValue should have the same type

* Change: detect media change in content script

* Fix: duplicate capitalize

* Fix: minor

* Update web-ext

* Fix: valueAsNumber doesn't work for all inputs

* Fix: disable colorscheme selection after install

* Fix: API error
This commit is contained in:
eight 2021-12-03 00:49:03 +08:00 committed by GitHub
parent 19ebeedf6a
commit 6c13db1468
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 2264 additions and 6432 deletions

View File

@ -596,6 +596,18 @@
"message": "Update style", "message": "Update style",
"description": "Label for update button" "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": { "installUpdate": {
"message": "Install update", "message": "Install update",
"description": "Label for the button to install an update for a single style" "description": "Label for the button to install an update for a single style"
@ -1053,6 +1065,18 @@
"optionsAdvancedNewStyleAsUsercss": { "optionsAdvancedNewStyleAsUsercss": {
"message": "Write new style as usercss" "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": { "optionsAdvancedPatchCsp": {
"message": "Patch <code>CSP</code> to allow style assets" "message": "Patch <code>CSP</code> to allow style assets"
}, },
@ -1533,6 +1557,12 @@
"message": "Style was not applied due to its incorrect usage of 'regexp()'", "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" "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": { "styleRegexpInvalidExplanation": {
"message": "Some 'regexp()' rules that could not be compiled at all." "message": "Some 'regexp()' rules that could not be compiled at all."
}, },

View File

@ -15,6 +15,7 @@
findExistingTab findExistingTab
openURL openURL
*/ // toolbox.js */ // toolbox.js
/* global colorScheme */ // color-scheme.js
'use strict'; 'use strict';
//#region API //#region API
@ -41,6 +42,7 @@ addAPI(/** @namespace API */ {
updater: updateMan, updater: updateMan,
usercss: usercssMan, usercss: usercssMan,
usw: uswApi, usw: uswApi,
colorScheme,
/** @type {BackgroundWorker} */ /** @type {BackgroundWorker} */
worker: createWorker({url: '/background/background-worker'}), worker: createWorker({url: '/background/background-worker'}),

100
background/color-scheme.js Normal file
View File

@ -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();
}
}
})();

View File

@ -6,6 +6,7 @@
/* global prefs */ /* global prefs */
/* global tabMan */ /* global tabMan */
/* global usercssMan */ /* global usercssMan */
/* global colorScheme */
'use strict'; 'use strict';
/* /*
@ -61,6 +62,14 @@ const styleMan = (() => {
let ready = init(); let ready = init();
chrome.runtime.onConnect.addListener(handleLivePreview); 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 //#endregion
//#region Exports //#region Exports
@ -183,6 +192,7 @@ const styleMan = (() => {
const query = createMatchQuery(url); const query = createMatchQuery(url);
for (const style of styles) { for (const style of styles) {
let excluded = false; let excluded = false;
let excludedScheme = false;
let sloppy = false; let sloppy = false;
let sectionMatched = false; let sectionMatched = false;
const match = urlMatchStyle(query, style); const match = urlMatchStyle(query, style);
@ -193,6 +203,9 @@ const styleMan = (() => {
if (match === 'excluded') { if (match === 'excluded') {
excluded = true; excluded = true;
} }
if (match === 'excludedScheme') {
excludedScheme = true;
}
for (const section of style.sections) { for (const section of style.sections) {
if (styleSectionGlobal(section) && styleCodeEmpty(section.code)) { if (styleSectionGlobal(section) && styleCodeEmpty(section.code)) {
continue; continue;
@ -207,7 +220,7 @@ const styleMan = (() => {
} }
} }
if (sectionMatched) { if (sectionMatched) {
result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy}); result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy, excludedScheme});
} }
} }
return result; return result;
@ -546,6 +559,9 @@ const styleMan = (() => {
if (!style.enabled) { if (!style.enabled) {
return 'disabled'; return 'disabled';
} }
if (!colorScheme.shouldIncludeStyle(style)) {
return 'excludedScheme';
}
return true; return true;
} }

View File

@ -68,6 +68,13 @@
window.addEventListener(orphanEventId, orphanCheck, true); 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() { function onInjectorUpdate() {
if (!isOrphaned) { if (!isOrphaned) {
updateCount(); updateCount();

View File

@ -50,6 +50,14 @@
<svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg> <svg class="svg-icon checked"><use xlink:href="#svg-icon-checked"/></svg>
<span i18n-text="liveReloadLabel"></span> <span i18n-text="liveReloadLabel"></span>
</label> </label>
<label class="set-prefer-scheme">
<span i18n-text="installPreferSchemeLabel"></span>
<select>
<option value="none" i18n-text="installPreferSchemeNone"></option>
<option value="dark" i18n-text="installPreferSchemeDark"></option>
<option value="light" i18n-text="installPreferSchemeLight"></option>
</select>
</label>
<p hidden class="installed-actions"> <p hidden class="installed-actions">
<a href="manage.html" tabindex="0"><button i18n-text="openManage"></button></a> <a href="manage.html" tabindex="0"><button i18n-text="openManage"></button></a>
<a href="edit.html?id=" tabindex="0"><button i18n-text="editStyleLabel"></button></a> <a href="edit.html?id=" tabindex="0"><button i18n-text="editStyleLabel"></button></a>

View File

@ -242,7 +242,6 @@ h2.installed.active ~ .configure-usercss:hover svg {
.set-update-url { .set-update-url {
flex-wrap: wrap; flex-wrap: wrap;
} }
.set-update-url p { .set-update-url p {
word-break: break-all; word-break: break-all;
opacity: .5; opacity: .5;
@ -250,6 +249,10 @@ h2.installed.active ~ .configure-usercss:hover svg {
margin: .25em 0 .25em; margin: .25em 0 .25em;
} }
label.set-prefer-scheme:not(.unavailable) {
padding-left: 0;
}
.external { .external {
text-align: center; text-align: center;
} }
@ -299,6 +302,7 @@ li {
} }
label { label {
/* FIXME: why do we want to give all labels a padding? */
padding-left: 16px; padding-left: 16px;
position: relative; position: relative;
} }

View File

@ -135,6 +135,13 @@ setTimeout(() => !cm && showSpinner($('#header')), 200);
$('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href : $('.set-update-url p').textContent = updateUrl.href.length < 300 ? updateUrl.href :
updateUrl.href.slice(0, 300) + '...'; 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)) { if (URLS.isLocalhost(initialUrl)) {
$('.live-reload input').onchange = liveReload.onToggled; $('.live-reload input').onchange = liveReload.onToggled;
} else { } else {
@ -167,6 +174,9 @@ function updateMeta(style, dup = installedDup) {
$('.meta-name').textContent = data.name; $('.meta-name').textContent = data.name;
$('.meta-version').textContent = data.version; $('.meta-version').textContent = data.version;
$('.meta-description').textContent = data.description; $('.meta-description').textContent = data.description;
$('.set-prefer-scheme select').value =
style.preferScheme === 'dark' ? 'dark' :
style.preferScheme === 'light' ? 'light' : 'none';
if (data.author) { if (data.author) {
$('.meta-author').parentNode.style.display = ''; $('.meta-author').parentNode.style.display = '';
@ -305,6 +315,7 @@ function install(style) {
$('.set-update-url input[type=checkbox]').disabled = true; $('.set-update-url input[type=checkbox]').disabled = true;
$('.set-update-url').title = style.updateUrl ? $('.set-update-url').title = style.updateUrl ?
t('installUpdateFrom', style.updateUrl) : ''; t('installUpdateFrom', style.updateUrl) : '';
$('.set-prefer-scheme select').disabled = true;
enablePostActions(); enablePostActions();
updateMeta(style); updateMeta(style);
} }

View File

@ -291,39 +291,64 @@ function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
* Accepts an array of pref names (values are fetched via prefs.get) * 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 * 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; let forceUpdate = true;
prefs.subscribe(ids, updateElement, {runNow: true}); prefs.subscribe(ids, updateElement, {runNow: true});
forceUpdate = false; 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() { 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) { function getValue(el) {
return el.type === 'checkbox' ? 'checked' const type = el.dataset.valueType || el.type;
: el.type === 'number' ? 'valueAsNumber' : return type === 'checkbox' ? el.checked :
'value'; // 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) { function isSame(el, oldValue, value) {
return el[propName] === value || return oldValue === value ||
typeof value === 'boolean' && typeof value === 'boolean' &&
el.tagName === 'SELECT' && el.tagName === 'SELECT' &&
el[propName] === `${value}`; oldValue === `${value}` ||
el.type === 'radio' && (oldValue === value) === el.checked;
} }
function updateElement(id, value) { function updateElement(id, value) {
const el = $('#' + id); const els = $$(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`);
if (el) { if (!els.length) {
const prop = getPropName(el); // FIXME: why do we unsub all ids when a single id is missing from the page
if (!isSame(el, prop, value) || forceUpdate) { prefs.unsubscribe(ids, updateElement);
el[prop] = value; 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})); el.dispatchEvent(new Event('change', {bubbles: true}));
} }
} else {
prefs.unsubscribe(ids, updateElement);
} }
} }
} }

View File

@ -30,6 +30,10 @@
// checkbox in style config dialog // checkbox in style config dialog
'config.autosave': true, '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': true, // display 'New style' links as URL breadcrumbs
'popup.breadcrumbs.usePath': false, // use URL path for 'this URL' 'popup.breadcrumbs.usePath': false, // use URL path for 'this URL'
'popup.enabledFirst': true, // display enabled styles before disabled styles 'popup.enabledFirst': true, // display enabled styles before disabled styles

View File

@ -37,6 +37,7 @@
"background/common.js", "background/common.js",
"background/db.js", "background/db.js",
"background/color-scheme.js",
"background/icon-manager.js", "background/icon-manager.js",
"background/navigation-manager.js", "background/navigation-manager.js",
"background/style-search-db.js", "background/style-search-db.js",

View File

@ -52,7 +52,7 @@
<label> <label>
<span i18n-text="optionsIconDark"></span> <span i18n-text="optionsIconDark"></span>
<div class="iconset"> <div class="iconset">
<input type="radio" name="iconset"> <input type="radio" name="iconset" value="0" data-value-type="number">
<img src="/images/icon/16.png"> <img src="/images/icon/16.png">
<img src="/images/icon/16w.png"> <img src="/images/icon/16w.png">
<img src="/images/icon/16x.png"> <img src="/images/icon/16x.png">
@ -61,7 +61,7 @@
<label> <label>
<span i18n-text="optionsIconLight"></span> <span i18n-text="optionsIconLight"></span>
<div class="iconset"> <div class="iconset">
<input type="radio" name="iconset"> <input type="radio" name="iconset" value="1" data-value-type="number">
<img src="/images/icon/light/16.png"> <img src="/images/icon/light/16.png">
<img src="/images/icon/light/16w.png"> <img src="/images/icon/light/16w.png">
<img src="/images/icon/light/16x.png"> <img src="/images/icon/light/16x.png">
@ -272,6 +272,26 @@
<span></span> <span></span>
</span> </span>
</label> </label>
<div class="radio-group">
<span i18n-text="optionsAdvancedAutoSwitchScheme" class="radio-group-label"></span>
<label class="radio-group-item">
<input type="radio" value="never" name="schemeSwitcher.enabled" class="radio">
<span i18n-text="optionsAdvancedAutoSwitchSchemeNever"></span>
</label>
<label class="radio-group-item">
<input type="radio" value="system" name="schemeSwitcher.enabled" class="radio">
<span i18n-text="optionsAdvancedAutoSwitchSchemeBySystem"></span>
</label>
<label class="radio-group-item">
<input type="radio" value="time" name="schemeSwitcher.enabled" class="radio">
<span>
<span i18n-text="optionsAdvancedAutoSwitchSchemeByTime"></span>
<input type="text" pattern="\d{2}:\d{2}" required id="schemeSwitcher.nightStart" class="input-sm">
~
<input type="text" pattern="\d{2}:\d{2}" required id="schemeSwitcher.nightEnd" class="input-sm">
</span>
</label>
</div>
</div> </div>
</div> </div>

View File

@ -192,7 +192,8 @@ input[type=number] {
text-align: right; text-align: right;
} }
input[type=number]:invalid { input[type=number]:invalid,
input[type=text]:invalid {
background-color: rgba(255, 0, 0, 0.1); background-color: rgba(255, 0, 0, 0.1);
color: darkred; color: darkred;
} }
@ -376,6 +377,40 @@ html:not(.firefox):not(.opera) #updates {
position: relative; position: relative;
} }
/* radio group */
.radio-group-item {
display: flex;
align-items: center;
min-height: 1.5em;
}
.radio-group-item > input {
margin: 0 8px 0 0;
flex-grow: 0;
}
.radio-group-label {
display: block;
margin: 0 0 .3em;
}
.input-sm {
width: 3em;
}
/* pixel perfect radio */
input[type="radio"].radio::after {
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
height: auto;
width: auto;
transform: scale(0);
}
input[type="radio"].radio:checked::after {
transform: scale(.65);
}
@keyframes fadeinout { @keyframes fadeinout {
0% { opacity: 0 } 0% { opacity: 0 }
10% { opacity: 1 } 10% { opacity: 1 }

View File

@ -23,7 +23,6 @@
'use strict'; 'use strict';
setupLivePrefs(); setupLivePrefs();
setupRadioButtons();
$$('input[min], input[max]').forEach(enforceInputRange); $$('input[min], input[max]').forEach(enforceInputRange);
if (CHROME_POPUP_BORDER_BUG) { if (CHROME_POPUP_BORDER_BUG) {
@ -196,29 +195,6 @@ function checkUpdates() {
} }
} }
function setupRadioButtons() {
const sets = {};
const onChange = function () {
const newValue = sets[this.name].indexOf(this);
if (newValue >= 0 && prefs.get(this.name) !== newValue) {
prefs.set(this.name, newValue);
}
};
// group all radio-inputs by name="prefName" attribute
for (const el of $$('input[type="radio"][name]')) {
(sets[el.name] = sets[el.name] || []).push(el);
el.on('change', onChange);
}
// select the input corresponding to the actual pref value
for (const name in sets) {
sets[name][prefs.get(name)].checked = true;
}
// listen to pref changes and update the values
prefs.subscribe(Object.keys(sets), (key, value) => {
sets[key][value].checked = true;
});
}
function customizeHotkeys() { function customizeHotkeys() {
// command name -> i18n id // command name -> i18n id
const hotkeys = new Map([ const hotkeys = new Map([

8349
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@
"make-fetch-happen": "^8.0.7", "make-fetch-happen": "^8.0.7",
"sync-version": "^1.0.1", "sync-version": "^1.0.1",
"tiny-glob": "^0.2.6", "tiny-glob": "^0.2.6",
"web-ext": "^5.5.0" "web-ext": "^6.5.0"
}, },
"scripts": { "scripts": {
"lint": "eslint \"**/*.js\" --cache", "lint": "eslint \"**/*.js\" --cache",

View File

@ -9,6 +9,7 @@
CHROME_POPUP_BORDER_BUG CHROME_POPUP_BORDER_BUG
FIREFOX FIREFOX
URLS URLS
capitalize
getActiveTab getActiveTab
isEmptyObj isEmptyObj
*/// toolbox.js */// toolbox.js
@ -372,13 +373,14 @@ function createStyleElement(style) {
const styleName = $('.style-name', entry); const styleName = $('.style-name', entry);
styleName.lastChild.textContent = style.customName || style.name; styleName.lastChild.textContent = style.customName || style.name;
setTimeout(() => { setTimeout(() => {
styleName.title = entry.styleMeta.sloppy ? styleName.title =
t('styleNotAppliedRegexpProblemTooltip') : entry.styleMeta.sloppy ? t('styleNotAppliedRegexpProblemTooltip') :
styleName.scrollWidth > styleName.clientWidth + 1 ? entry.styleMeta.excludedScheme ? t(`styleNotAppliedScheme${capitalize(entry.styleMeta.preferScheme)}`) :
styleName.textContent : ''; styleName.scrollWidth > styleName.clientWidth + 1 ? styleName.textContent :
'';
}); });
entry.classList.toggle('not-applied', style.excluded || style.sloppy); entry.classList.toggle('not-applied', style.excluded || style.sloppy || style.excludedScheme);
entry.classList.toggle('regexp-partial', style.sloppy); entry.classList.toggle('regexp-partial', style.sloppy);
$('.exclude-by-domain-checkbox', entry).checked = Events.isStyleExcluded(style, 'domain'); $('.exclude-by-domain-checkbox', entry).checked = Events.isStyleExcluded(style, 'domain');