Greasy Fork is available in English.
将特定网站从暗色模式转换为亮色模式,支持记录网站、实时切换、精细化颜色反转
// ==UserScript==
// @name LightMode - 暗色模式转亮色模式
// @namespace lightmode-atseiunsky
// @version 1.0.0
// @description 将特定网站从暗色模式转换为亮色模式,支持记录网站、实时切换、精细化颜色反转
// @author atSeiunSky
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @run-at document-start
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ============================================================
* 常量 & 默认配置
* ============================================================ */
const STORAGE_KEY = 'lightmode_data';
const DEFAULT_DATA = {
sites: {},
globalSettings: {
intensity: 1.0,
preserveImages: true,
preserveVideos: true,
},
};
/* ============================================================
* 存储工具
* ============================================================ */
function loadData() {
try {
const raw = GM_getValue(STORAGE_KEY, null);
if (!raw) return structuredClone(DEFAULT_DATA);
const d = typeof raw === 'string' ? JSON.parse(raw) : raw;
return { ...DEFAULT_DATA, ...d };
} catch {
return structuredClone(DEFAULT_DATA);
}
}
function saveData(data) {
GM_setValue(STORAGE_KEY, JSON.stringify(data));
}
function currentHost() {
return location.hostname;
}
function isSiteEnabled(data, host) {
// 精确匹配
if (data.sites[host]?.enabled) return true;
// 通配符匹配(*.example.com)
for (const [pattern, cfg] of Object.entries(data.sites)) {
if (!cfg.enabled) continue;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1); // .example.com
if (host.endsWith(suffix) || host === pattern.slice(2)) return true;
}
}
return false;
}
/* ============================================================
* CSS Filter 引擎
* ============================================================ */
const LIGHT_MODE_CSS_ID = 'lightmode-global-css';
const EXEMPT_SELECTOR = [
'img',
'video',
'canvas',
'picture',
'svg image',
'[style*="background-image"]',
'iframe',
'.lightmode-panel', // 控制面板本身
'#lightmode-panel-container',
].join(', ');
function buildCSS(intensity, preserveImages, preserveVideos) {
const inv = intensity.toFixed(2);
let exemptParts = ['canvas', 'iframe', '.lightmode-panel', '#lightmode-panel-container'];
if (preserveImages) exemptParts.push('img', 'picture', 'svg image', 'video[poster]', '[style*="background-image"]');
if (preserveVideos) exemptParts.push('video');
const exemptSelector = exemptParts.join(', ');
return `
html.lightmode-active {
filter: invert(${inv}) hue-rotate(180deg) !important;
-webkit-filter: invert(${inv}) hue-rotate(180deg) !important;
background-color: #fff !important;
}
html.lightmode-active ${exemptSelector} {
filter: invert(${inv}) hue-rotate(180deg) !important;
-webkit-filter: invert(${inv}) hue-rotate(180deg) !important;
}
/* 保证嵌套的豁免元素不会被双重反转 */
html.lightmode-active img img,
html.lightmode-active video video {
filter: none !important;
}
`;
}
function injectCSS(cssText) {
let el = document.getElementById(LIGHT_MODE_CSS_ID);
if (!el) {
el = document.createElement('style');
el.id = LIGHT_MODE_CSS_ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = cssText;
}
function removeCSS() {
const el = document.getElementById(LIGHT_MODE_CSS_ID);
if (el) el.remove();
document.documentElement.classList.remove('lightmode-active');
}
function applyLightMode(data) {
const { intensity, preserveImages, preserveVideos } = data.globalSettings;
injectCSS(buildCSS(intensity, preserveImages, preserveVideos));
document.documentElement.classList.add('lightmode-active');
}
/* ============================================================
* MutationObserver — 动态内容监听
* ============================================================ */
let observer = null;
function startObserver() {
if (observer) return;
observer = new MutationObserver((mutations) => {
// 仅在 lightmode-active 状态下才需要处理
if (!document.documentElement.classList.contains('lightmode-active')) return;
for (const m of mutations) {
if (m.type === 'childList') {
// 确保新插入的 style 标签不会覆盖我们的样式
for (const node of m.addedNodes) {
if (node.id === LIGHT_MODE_CSS_ID) continue;
if (node.nodeName === 'STYLE' || node.nodeName === 'LINK') {
// 重新注入我们的样式到末尾以保持优先级
const ourEl = document.getElementById(LIGHT_MODE_CSS_ID);
if (ourEl && ourEl.parentNode) {
ourEl.parentNode.appendChild(ourEl);
}
}
}
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function stopObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
}
/* ============================================================
* 控制面板 UI(Shadow DOM 隔离)
* ============================================================ */
let panelVisible = false;
let panelHost = null;
function createPanel() {
if (panelHost) return;
panelHost = document.createElement('div');
panelHost.id = 'lightmode-panel-container';
// 让面板不受 invert 影响
panelHost.style.cssText = 'position:fixed;top:0;right:0;z-index:2147483647;';
document.body.appendChild(panelHost);
const shadow = panelHost.attachShadow({ mode: 'closed' });
const style = document.createElement('style');
style.textContent = getPanelCSS();
shadow.appendChild(style);
const wrapper = document.createElement('div');
wrapper.className = 'lm-panel';
wrapper.innerHTML = getPanelHTML();
shadow.appendChild(wrapper);
// 绑定事件
bindPanelEvents(shadow, wrapper);
}
function getPanelCSS() {
return `
* { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
.lm-panel {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
position: fixed;
top: 16px;
right: 16px;
width: 360px;
max-height: 80vh;
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
border-radius: 16px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.08),
0 20px 40px -4px rgba(0, 0, 0, 0.14),
0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
display: none;
flex-direction: column;
color: #1a1a2e;
animation: lm-slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
backdrop-filter: blur(20px);
}
.lm-panel.visible {
display: flex;
}
@keyframes lm-slideIn {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Header */
.lm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.lm-header h2 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 8px;
}
.lm-header h2 .icon {
font-size: 20px;
}
.lm-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.lm-close:hover { background: rgba(255,255,255,0.35); }
/* Body */
.lm-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
}
/* Section */
.lm-section {
margin-bottom: 20px;
}
.lm-section:last-child { margin-bottom: 0; }
.lm-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #8b8fa3;
margin-bottom: 10px;
}
/* Toggle Row */
.lm-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: #f0f1f8;
border-radius: 12px;
margin-bottom: 8px;
transition: background 0.15s;
}
.lm-toggle-row:hover { background: #e8eaf4; }
.lm-toggle-row .label {
font-size: 14px;
font-weight: 500;
color: #2d2d44;
}
.lm-toggle-row .sublabel {
font-size: 12px;
color: #8b8fa3;
margin-top: 2px;
}
/* Toggle Switch */
.lm-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.lm-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.lm-switch .slider {
position: absolute;
inset: 0;
background: #c7c9d9;
border-radius: 24px;
cursor: pointer;
transition: background 0.25s;
}
.lm-switch .slider::before {
content: '';
position: absolute;
width: 18px;
height: 18px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.lm-switch input:checked + .slider {
background: linear-gradient(135deg, #667eea, #764ba2);
}
.lm-switch input:checked + .slider::before {
transform: translateX(20px);
}
/* Slider Range */
.lm-range-row {
padding: 10px 14px;
background: #f0f1f8;
border-radius: 12px;
margin-bottom: 8px;
}
.lm-range-row .range-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.lm-range-row .label { font-size: 14px; font-weight: 500; color: #2d2d44; }
.lm-range-row .value { font-size: 13px; font-weight: 600; color: #667eea; }
.lm-range-row input[type=range] {
-webkit-appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(90deg, #667eea, #764ba2);
outline: none;
}
.lm-range-row input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
cursor: pointer;
}
/* Site List */
.lm-site-list {
list-style: none;
max-height: 200px;
overflow-y: auto;
}
.lm-site-list::-webkit-scrollbar { width: 4px; }
.lm-site-list::-webkit-scrollbar-track { background: transparent; }
.lm-site-list::-webkit-scrollbar-thumb { background: #c7c9d9; border-radius: 2px; }
.lm-site-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.15s;
gap: 8px;
}
.lm-site-item:hover { background: #f0f1f8; }
.lm-site-item .site-name {
font-size: 13px;
font-weight: 500;
color: #2d2d44;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lm-site-item .site-enabled {
font-size: 10px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
background: #e8f5e9;
color: #2e7d32;
}
.lm-site-item .site-enabled.off {
background: #fce4ec;
color: #c62828;
}
.lm-site-item .lm-delete {
background: none;
border: none;
color: #c7c9d9;
cursor: pointer;
font-size: 16px;
padding: 4px;
border-radius: 6px;
transition: color 0.2s, background 0.2s;
line-height: 1;
}
.lm-site-item .lm-delete:hover {
color: #ef5350;
background: #fce4ec;
}
.lm-empty {
text-align: center;
padding: 20px;
color: #b0b3c6;
font-size: 13px;
}
/* Add Site */
.lm-add-row {
display: flex;
gap: 8px;
margin-top: 10px;
}
.lm-add-row input {
flex: 1;
padding: 8px 12px;
border: 2px solid #e8eaf4;
border-radius: 10px;
font-size: 13px;
outline: none;
font-family: inherit;
transition: border-color 0.2s;
color: #2d2d44;
background: #fff;
}
.lm-add-row input::placeholder { color: #b0b3c6; }
.lm-add-row input:focus { border-color: #667eea; }
.lm-add-row button {
padding: 8px 16px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
white-space: nowrap;
}
.lm-add-row button:hover { opacity: 0.9; transform: scale(1.02); }
.lm-add-row button:active { transform: scale(0.98); }
/* Footer */
.lm-footer {
padding: 12px 20px;
border-top: 1px solid #f0f1f8;
text-align: center;
font-size: 11px;
color: #b0b3c6;
}
.lm-footer kbd {
padding: 2px 6px;
background: #f0f1f8;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
color: #667eea;
font-weight: 600;
}
`;
}
function getPanelHTML() {
const data = loadData();
const host = currentHost();
const enabled = isSiteEnabled(data, host);
const gs = data.globalSettings;
return `
<div class="lm-header">
<h2><span class="icon">☀️</span> LightMode</h2>
<button class="lm-close" data-action="close" title="关闭">✕</button>
</div>
<div class="lm-body">
<!-- 当前网站开关 -->
<div class="lm-section">
<div class="lm-section-title">当前网站</div>
<div class="lm-toggle-row">
<div>
<div class="label">${host}</div>
<div class="sublabel">为此域名启用亮色模式</div>
</div>
<label class="lm-switch">
<input type="checkbox" data-action="toggle-current" ${enabled ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</div>
<!-- 全局设置 -->
<div class="lm-section">
<div class="lm-section-title">全局设置</div>
<div class="lm-range-row">
<div class="range-header">
<span class="label">反转强度</span>
<span class="value" data-display="intensity">${Math.round(gs.intensity * 100)}%</span>
</div>
<input type="range" min="0" max="100" value="${Math.round(gs.intensity * 100)}" data-action="intensity">
</div>
<div class="lm-toggle-row">
<div><div class="label">保留图片原色</div></div>
<label class="lm-switch">
<input type="checkbox" data-action="preserve-images" ${gs.preserveImages ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
<div class="lm-toggle-row">
<div><div class="label">保留视频原色</div></div>
<label class="lm-switch">
<input type="checkbox" data-action="preserve-videos" ${gs.preserveVideos ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</div>
<!-- 已保存网站列表 -->
<div class="lm-section">
<div class="lm-section-title">已保存网站</div>
<ul class="lm-site-list" data-list="sites">
${buildSiteListHTML(data)}
</ul>
<div class="lm-add-row">
<input type="text" placeholder="输入域名,如 example.com" data-input="add-site">
<button data-action="add-site">添加</button>
</div>
</div>
</div>
<div class="lm-footer">
快捷键:<kbd>Alt+L</kbd> 切换 | <kbd>Alt+Shift+L</kbd> 面板
</div>
`;
}
function buildSiteListHTML(data) {
const entries = Object.entries(data.sites);
if (entries.length === 0) {
return '<li class="lm-empty">尚未添加任何网站</li>';
}
return entries
.map(
([site, cfg]) => `
<li class="lm-site-item">
<span class="site-name" title="${site}">${site}</span>
<span class="site-enabled ${cfg.enabled ? '' : 'off'}">${cfg.enabled ? '已启用' : '已禁用'}</span>
<button class="lm-delete" data-action="delete-site" data-site="${site}" title="删除">✕</button>
</li>`
)
.join('');
}
function bindPanelEvents(shadow, wrapper) {
// 使用事件代理
wrapper.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (!action) return;
if (action === 'close') {
togglePanel(wrapper);
} else if (action === 'delete-site') {
const site = e.target.closest('[data-site]').dataset.site;
deleteSite(site, shadow, wrapper);
} else if (action === 'add-site') {
const input = shadow.querySelector('[data-input="add-site"]');
const site = input.value.trim();
if (site) {
addSite(site, shadow, wrapper);
input.value = '';
}
}
});
wrapper.addEventListener('change', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (!action) return;
const data = loadData();
if (action === 'toggle-current') {
const host = currentHost();
if (!data.sites[host]) data.sites[host] = { enabled: false, customRules: [] };
data.sites[host].enabled = e.target.checked;
saveData(data);
refreshLightMode(data);
refreshSiteList(shadow, wrapper, data);
} else if (action === 'preserve-images') {
data.globalSettings.preserveImages = e.target.checked;
saveData(data);
refreshLightMode(data);
} else if (action === 'preserve-videos') {
data.globalSettings.preserveVideos = e.target.checked;
saveData(data);
refreshLightMode(data);
}
});
wrapper.addEventListener('input', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'intensity') {
const val = parseInt(e.target.value, 10);
const display = shadow.querySelector('[data-display="intensity"]');
if (display) display.textContent = val + '%';
const data = loadData();
data.globalSettings.intensity = val / 100;
saveData(data);
refreshLightMode(data);
}
});
// Enter 键添加网站
wrapper.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.matches('[data-input="add-site"]')) {
const site = e.target.value.trim();
if (site) {
addSite(site, shadow, wrapper);
e.target.value = '';
}
}
});
}
function addSite(site, shadow, wrapper) {
// 清理输入
site = site.replace(/^https?:\/\//, '').replace(/\/.*$/, '').trim().toLowerCase();
if (!site) return;
const data = loadData();
data.sites[site] = { enabled: true, customRules: [] };
saveData(data);
refreshLightMode(data);
refreshSiteList(shadow, wrapper, data);
}
function deleteSite(site, shadow, wrapper) {
const data = loadData();
delete data.sites[site];
saveData(data);
refreshLightMode(data);
refreshSiteList(shadow, wrapper, data);
// 更新当前网站的 toggle 状态
const toggle = shadow.querySelector('[data-action="toggle-current"]');
if (toggle) toggle.checked = isSiteEnabled(data, currentHost());
}
function refreshSiteList(shadow, wrapper, data) {
const list = shadow.querySelector('[data-list="sites"]');
if (list) list.innerHTML = buildSiteListHTML(data);
}
function togglePanel(wrapper) {
panelVisible = !panelVisible;
if (wrapper) {
wrapper.classList.toggle('visible', panelVisible);
}
}
function showPanel() {
if (!panelHost) createPanel();
const shadow = panelHost.shadowRoot || panelHost;
if (!panelHost._shadow) {
// 重建面板以刷新数据
panelHost.remove();
panelHost = null;
panelVisible = false;
createPanel();
}
panelVisible = true;
}
let _shadowRef = null;
let _wrapperRef = null;
function createPanelV2() {
if (panelHost) {
panelHost.remove();
panelHost = null;
}
panelHost = document.createElement('div');
panelHost.id = 'lightmode-panel-container';
panelHost.style.cssText = 'position:fixed;top:0;right:0;z-index:2147483647;pointer-events:none;width:0;height:0;';
document.body.appendChild(panelHost);
const shadow = panelHost.attachShadow({ mode: 'open' });
_shadowRef = shadow;
const style = document.createElement('style');
style.textContent = getPanelCSS();
shadow.appendChild(style);
const wrapper = document.createElement('div');
wrapper.className = 'lm-panel';
wrapper.style.pointerEvents = 'auto';
wrapper.innerHTML = getPanelHTML();
shadow.appendChild(wrapper);
_wrapperRef = wrapper;
bindPanelEvents(shadow, wrapper);
}
function togglePanelV2() {
if (!panelHost) createPanelV2();
panelVisible = !panelVisible;
if (_wrapperRef) {
_wrapperRef.classList.toggle('visible', panelVisible);
}
// 每次打开时刷新面板内容
if (panelVisible && _wrapperRef && _shadowRef) {
_wrapperRef.innerHTML = getPanelHTML();
bindPanelEvents(_shadowRef, _wrapperRef);
}
}
/* ============================================================
* 刷新亮色模式状态
* ============================================================ */
function refreshLightMode(data) {
if (!data) data = loadData();
const host = currentHost();
if (isSiteEnabled(data, host)) {
applyLightMode(data);
startObserver();
} else {
removeCSS();
stopObserver();
}
}
/* ============================================================
* 快捷键
* ============================================================ */
function handleKeydown(e) {
// Alt+L: 切换当前网站
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const data = loadData();
const host = currentHost();
if (!data.sites[host]) data.sites[host] = { enabled: false, customRules: [] };
data.sites[host].enabled = !data.sites[host].enabled;
saveData(data);
refreshLightMode(data);
}
// Alt+Shift+L: 开关面板
if (e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
togglePanelV2();
}
}
/* ============================================================
* Tampermonkey 菜单命令
* ============================================================ */
function registerMenuCommands() {
const data = loadData();
const host = currentHost();
const enabled = isSiteEnabled(data, host);
GM_registerMenuCommand(
enabled ? `✅ 已启用 — ${host}(点击关闭)` : `☀️ 启用亮色模式 — ${host}`,
() => {
const d = loadData();
if (!d.sites[host]) d.sites[host] = { enabled: false, customRules: [] };
d.sites[host].enabled = !d.sites[host].enabled;
saveData(d);
refreshLightMode(d);
}
);
GM_registerMenuCommand('⚙️ 打开 LightMode 面板', () => {
if (!panelVisible) togglePanelV2();
});
}
/* ============================================================
* 初始化
* ============================================================ */
function init() {
// 1. 尽早注入 CSS(document-start 阶段)
const data = loadData();
const host = currentHost();
if (isSiteEnabled(data, host)) {
applyLightMode(data);
}
// 2. DOM 就绪后初始化其他功能
const onReady = () => {
if (isSiteEnabled(data, host)) {
startObserver();
}
document.addEventListener('keydown', handleKeydown);
registerMenuCommands();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady);
} else {
onReady();
}
}
init();
})();