// ==UserScript==
// @name DeepFlood & NodeSeek 主题皮肤切换器
// @namespace https://nodeseek-userscripts.local
// @version 0.1.3
// @author https://www.nodeseek.com/space/38137
// @description 为 DeepFlood 和 NodeSeek 论坛添加主题颜色切换器,支持自定义颜色和预设颜色,所选颜色会被记住并应用于当前域名
// @match *://*.nodeseek.com/*
// @match *://*.deepflood.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const storageKey = "dfThemeColor:" + location.host;
const originalMetaTheme = getMetaThemeColor();
const defaultColor = detectDefaultColor() || "#3d6c45";
let currentColor = loadStoredColor();
let themeStyleTag = null;
let activePanel = null;
let activeButton = null;
let globalListenersBound = false;
let observerInitialized = false;
if (currentColor) {
applyThemeColor(currentColor, true);
}
ready(function () {
injectUiStyles();
setupObserver();
const switcher = document.querySelector(".color-theme-switcher");
if (switcher) {
setupColorPicker(switcher);
} else {
waitForElement(".color-theme-switcher", 15000)
.then(function (el) {
setupColorPicker(el);
})
.catch(function () {
// element not found
});
}
});
function setupObserver() {
if (observerInitialized) {
return;
}
const observer = new MutationObserver(function () {
if (!document.querySelector(".tm-theme-color-wrapper")) {
const switcher = document.querySelector(".color-theme-switcher");
if (switcher) {
setupColorPicker(switcher);
}
}
});
const attachObserver = function () {
if (observerInitialized || !document.body) {
return;
}
observer.observe(document.body, { childList: true, subtree: true });
observerInitialized = true;
};
if (document.body) {
attachObserver();
} else {
const domReadyHandler = function () {
document.removeEventListener("DOMContentLoaded", domReadyHandler);
attachObserver();
};
document.addEventListener("DOMContentLoaded", domReadyHandler);
}
}
function setupColorPicker(themeSwitcher) {
if (!themeSwitcher) {
return;
}
const parent = themeSwitcher.parentElement;
if (!parent) {
return;
}
if (parent.querySelector(".tm-theme-color-wrapper")) {
return;
}
const wrapper = document.createElement("div");
wrapper.className = "tm-theme-color-wrapper";
const toggleButton = document.createElement("button");
toggleButton.type = "button";
toggleButton.className = "tm-theme-color-button";
toggleButton.setAttribute("aria-haspopup", "true");
toggleButton.setAttribute("aria-expanded", "false");
toggleButton.setAttribute("title", "Customize theme color");
toggleButton.innerHTML =
'<span class="tm-theme-color-preview"></span><span class="tm-theme-color-label">Color</span>';
const preview = toggleButton.querySelector(".tm-theme-color-preview");
const panel = document.createElement("div");
panel.className = "tm-theme-color-panel";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "Theme color picker");
const heading = document.createElement("div");
heading.className = "tm-theme-color-heading";
heading.textContent = "Theme color";
panel.appendChild(heading);
const colorRow = document.createElement("div");
colorRow.className = "tm-theme-color-row";
const colorLabel = document.createElement("label");
colorLabel.className = "tm-theme-color-input-label";
colorLabel.textContent = "Custom color";
colorRow.appendChild(colorLabel);
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.className = "tm-theme-color-input";
colorRow.appendChild(colorInput);
panel.appendChild(colorRow);
const colorValue = document.createElement("div");
colorValue.className = "tm-theme-color-value";
panel.appendChild(colorValue);
const presetTitle = document.createElement("div");
presetTitle.className = "tm-theme-color-subtitle";
presetTitle.textContent = "Presets";
panel.appendChild(presetTitle);
const presetWrapper = document.createElement("div");
presetWrapper.className = "tm-theme-color-presets";
panel.appendChild(presetWrapper);
const presets = [
"#3d6c45",
"#1e6fff",
"#ff6b00",
"#00aa5b",
"#c37ff1",
"#ffab00",
"#ff3366",
"#2b908f",
];
for (var i = 0; i < presets.length; i += 1) {
var presetColor = presets[i];
var presetButton = document.createElement("button");
presetButton.type = "button";
presetButton.className = "tm-theme-color-preset";
presetButton.dataset.color = presetColor;
presetButton.style.backgroundColor = presetColor;
presetButton.title = "Use " + presetColor;
presetButton.addEventListener("click", function (event) {
var selected = event.currentTarget.dataset.color;
selectColor(selected, true);
closePanel();
});
presetWrapper.appendChild(presetButton);
}
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.className = "tm-theme-color-reset";
resetButton.textContent = "Reset to default";
resetButton.addEventListener("click", function () {
resetTheme();
closePanel();
});
panel.appendChild(resetButton);
const note = document.createElement("div");
note.className = "tm-theme-color-note";
note.textContent = "Picked colors are remembered per domain.";
panel.appendChild(note);
wrapper.appendChild(toggleButton);
wrapper.appendChild(panel);
var baseColor =
normalizeColor(currentColor) || normalizeColor(defaultColor) || "#3d6c45";
updateButtonPreview(baseColor, preview, toggleButton);
colorInput.value = baseColor;
updateDisplayedColor(baseColor);
colorInput.addEventListener("input", function (event) {
var value = normalizeColor(event.target.value);
if (!value) {
return;
}
selectColor(value, false);
});
colorInput.addEventListener("change", function (event) {
var value = normalizeColor(event.target.value);
if (!value) {
return;
}
selectColor(value, true);
});
toggleButton.addEventListener("click", function (event) {
event.stopPropagation();
if (panel.classList.contains("open")) {
closePanel();
} else {
openPanel();
}
});
panel.addEventListener("click", function (event) {
event.stopPropagation();
});
parent.insertBefore(wrapper, themeSwitcher.nextSibling);
if (!globalListenersBound) {
document.addEventListener("click", onDocumentClick, true);
document.addEventListener("keydown", onDocumentKeydown, true);
globalListenersBound = true;
}
function openPanel() {
if (activePanel && activePanel !== panel) {
closeActivePanel();
}
panel.classList.add("open");
toggleButton.setAttribute("aria-expanded", "true");
activePanel = panel;
activeButton = toggleButton;
}
function closePanel() {
if (activePanel === panel) {
closeActivePanel();
} else {
panel.classList.remove("open");
toggleButton.setAttribute("aria-expanded", "false");
}
}
function selectColor(value, persist) {
var normalized = normalizeColor(value);
if (!normalized) {
return;
}
currentColor = normalized;
applyThemeColor(normalized);
updateButtonPreview(normalized, preview, toggleButton);
updateDisplayedColor(normalized);
colorInput.value = normalized;
if (persist) {
saveColor(normalized);
}
}
function resetTheme() {
clearStoredColor();
currentColor = null;
removeThemeStyle();
restoreMetaThemeColor();
var restored = normalizeColor(defaultColor) || "#3d6c45";
updateButtonPreview(restored, preview, toggleButton);
updateDisplayedColor(restored);
colorInput.value = restored;
}
function updateDisplayedColor(value) {
var normalized = normalizeColor(value);
colorValue.textContent = normalized || "";
}
}
function onDocumentClick(event) {
if (!activePanel) {
return;
}
if (activePanel.contains(event.target)) {
return;
}
if (activeButton && activeButton.contains(event.target)) {
return;
}
closeActivePanel();
}
function onDocumentKeydown(event) {
if ((event.key === "Escape" || event.key === "Esc") && activePanel) {
closeActivePanel();
}
}
function closeActivePanel() {
if (!activePanel) {
return;
}
activePanel.classList.remove("open");
if (activeButton) {
activeButton.setAttribute("aria-expanded", "false");
}
activePanel = null;
activeButton = null;
}
function updateButtonPreview(color, previewElement, buttonElement) {
var normalized = normalizeColor(color);
if (!normalized) {
return;
}
if (previewElement) {
previewElement.style.backgroundColor = normalized;
previewElement.setAttribute("data-color", normalized);
}
if (buttonElement) {
buttonElement.style.setProperty("--tm-theme-selected-color", normalized);
}
}
function injectUiStyles() {
if (document.getElementById("tm-theme-color-ui-style")) {
return;
}
var style = document.createElement("style");
style.id = "tm-theme-color-ui-style";
style.textContent = [
".tm-theme-color-wrapper{position:relative;display:inline-flex;align-items:center;margin-left:8px;}",
".tm-theme-color-button{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;border:1px solid rgba(0,0,0,0.15);background:var(--bg-sub-color,#f7f7f7);color:var(--text-color,#333);font-size:12px;line-height:1;cursor:pointer;transition:background-color 0.2s ease,border-color 0.2s ease,box-shadow 0.2s ease;}",
".tm-theme-color-button:hover,.tm-theme-color-button:focus{border-color:var(--tm-theme-selected-color,#3d6c45);box-shadow:0 0 0 3px rgba(0,0,0,0.05);outline:none;}",
'.tm-theme-color-button[aria-expanded="true"]{border-color:var(--tm-theme-selected-color,#3d6c45);}',
".tm-theme-color-preview{width:14px;height:14px;border-radius:50%;border:2px solid rgba(0,0,0,0.1);background:var(--tm-theme-selected-color,#3d6c45);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.6);}",
".tm-theme-color-label{font-weight:600;letter-spacing:0.02em;}",
".tm-theme-color-panel{position:absolute;top:calc(100% + 6px);right:0;min-width:220px;background:var(--bg-main-color,#fff);border:1px solid rgba(0,0,0,0.12);border-radius:10px;padding:12px;box-shadow:0 12px 30px rgba(17,20,39,0.18);display:none;z-index:2500;}",
".tm-theme-color-panel.open{display:block;}",
".tm-theme-color-heading{font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text-color,#333);}",
".tm-theme-color-row{display:flex;align-items:center;justify-content:space-between;gap:12px;}",
".tm-theme-color-input-label{font-size:12px;color:var(--fade-color,#666);}",
".tm-theme-color-input{flex:0 0 100px;height:32px;border:1px solid rgba(0,0,0,0.2);border-radius:6px;background:var(--bg-sub-color,#f7f7f7);cursor:pointer;}",
".tm-theme-color-input::-moz-color-swatch,.tm-theme-color-input::-webkit-color-swatch{border:none;border-radius:4px;}",
".tm-theme-color-value{margin-top:6px;font-family:monospace;font-size:12px;letter-spacing:0.04em;color:var(--fade-color,#666);}",
".tm-theme-color-subtitle{margin-top:12px;font-size:12px;font-weight:600;color:var(--text-color,#333);}",
".tm-theme-color-presets{margin-top:6px;display:flex;flex-wrap:wrap;gap:6px;}",
".tm-theme-color-preset{width:28px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.15);cursor:pointer;transition:transform 0.15s ease,box-shadow 0.15s ease;}",
".tm-theme-color-preset:hover,.tm-theme-color-preset:focus{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.2);outline:none;}",
".tm-theme-color-reset{width:100%;margin-top:12px;padding:6px 10px;border-radius:6px;border:1px solid rgba(0,0,0,0.16);background:transparent;color:var(--text-color,#333);font-size:12px;cursor:pointer;transition:background-color 0.2s ease,border-color 0.2s ease;}",
".tm-theme-color-reset:hover{background:rgba(0,0,0,0.04);}",
".tm-theme-color-note{margin-top:10px;font-size:11px;line-height:1.4;color:var(--fade-color,#666);}",
"@media (max-width:680px){.tm-theme-color-panel{right:auto;left:0;}}",
].join("");
document.head.appendChild(style);
}
function ready(callback) {
if (document.readyState === "loading") {
const handler = function () {
document.removeEventListener("DOMContentLoaded", handler);
callback();
};
document.addEventListener("DOMContentLoaded", handler);
} else {
callback();
}
}
function waitForElement(selector, timeout) {
return new Promise(function (resolve, reject) {
const existing = document.querySelector(selector);
if (existing) {
resolve(existing);
return;
}
const observer = new MutationObserver(function () {
const found = document.querySelector(selector);
if (found) {
observer.disconnect();
resolve(found);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
if (timeout) {
setTimeout(function () {
observer.disconnect();
reject(new Error("Element not found: " + selector));
}, timeout);
}
});
}
function detectDefaultColor() {
const meta = normalizeColor(getMetaThemeColor());
if (meta) {
return meta;
}
try {
const computed = getComputedStyle(
document.documentElement
).getPropertyValue("--main-color");
const normalized = normalizeColor(computed);
if (normalized) {
return normalized;
}
} catch (error) {
// ignore
}
return "#3d6c45";
}
function getMetaThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
return null;
}
return meta.getAttribute("content");
}
function loadStoredColor() {
let value = null;
try {
if (typeof GM_getValue === "function") {
value = GM_getValue(storageKey, null);
} else {
value = window.localStorage.getItem(storageKey);
}
} catch (error) {
value = null;
}
return normalizeColor(value);
}
function saveColor(color) {
if (!color) {
return;
}
try {
if (typeof GM_setValue === "function") {
GM_setValue(storageKey, color);
} else {
window.localStorage.setItem(storageKey, color);
}
} catch (error) {
// ignore
}
}
function clearStoredColor() {
try {
if (typeof GM_deleteValue === "function") {
GM_deleteValue(storageKey);
} else {
window.localStorage.removeItem(storageKey);
}
} catch (error) {
// ignore
}
}
function applyThemeColor(color, preferAddStyle) {
const normalized = normalizeColor(color);
if (!normalized) {
return;
}
const cssText = buildThemeCss(normalized);
const style = ensureThemeStyleTag(Boolean(preferAddStyle));
style.textContent = cssText;
updateMetaThemeColor(normalized);
}
function buildThemeCss(normalized) {
const lighter = lighten(normalized, 18);
const slight = lighten(normalized, 10);
const dark = darken(normalized, 18);
const borderShade = darken(normalized, 28);
const contrast = getContrastingTextColor(normalized);
const overlay = toRgba(normalized, 0.15);
const bgMainLight = lighten(normalized, 86);
const bgSubLight = lighten(normalized, 92);
const bgMainDark = darken(normalized, 70);
const surfaceDark = darken(normalized, 60);
const headerDark = darken(normalized, 50);
const darkOverlay = toRgba(bgMainDark, 0.35);
const fadeLight = toRgba(bgMainLight, 0.45);
const fadeDark = toRgba(bgMainDark, 0.55);
const textOnDark = getContrastingTextColor(bgMainDark);
return [
":root{",
" --main-color:",
normalized,
";",
" --sub-color:",
slight,
";",
" --link-color:",
normalized,
";",
" --link-hover-color:",
lighter,
";",
" --glass-color:",
overlay,
" !important;",
" --bg-main-color:",
bgMainLight,
" !important;",
" --bg-sub-color:",
bgSubLight,
" !important;",
" --fade-color:",
fadeLight,
" !important;",
"}",
"body.bg1{",
" background-color:",
bgMainLight,
" !important;",
" background-image:none !important;",
"}",
"body.bg2{",
" background-color:",
bgSubLight,
" !important;",
" background-image:none !important;",
"}",
"body.dark-layout,.dark-layout{",
" --bg-main-color:",
bgMainDark,
" !important;",
" --bg-sub-color:",
surfaceDark,
" !important;",
" --glass-color:",
darkOverlay,
" !important;",
" --fade-color:",
fadeDark,
" !important;",
" --text-color:",
textOnDark,
" !important;",
"}",
"body.dark-layout.bg1,.dark-layout.bg1{",
" background-color:",
bgMainDark,
" !important;",
" background-image:none !important;",
"}",
"body.dark-layout.bg2,.dark-layout.bg2{",
" background-color:",
surfaceDark,
" !important;",
" background-image:none !important;",
"}",
".dark-layout header,.dark-layout .nav-menu,.dark-layout .mobile-nav{",
" background-color:",
headerDark,
" !important;",
"}",
".dark-layout .nsk-panel,.dark-layout .post-list-item,.dark-layout .message-item .content-column .content{",
" background-color:",
surfaceDark,
" !important;",
" border-color:",
borderShade,
" !important;",
"}",
".btn,.btn-primary,.pure-button-primary,.meta-button,.pure-button.pure-button-primary{",
" background-color:",
normalized,
" !important;",
" border-color:",
dark,
" !important;",
" color:",
contrast,
" !important;",
"}",
".btn:hover,.btn-primary:hover,.pure-button-primary:hover,.meta-button:hover{",
" background-color:",
slight,
" !important;",
" border-color:",
dark,
" !important;",
" color:",
contrast,
" !important;",
"}",
".pager-cur,.pager-pos.pager-cur,.page-item.active .page-link,.pure-menu-selected{",
" background-color:",
normalized,
" !important;",
" border-color:",
borderShade,
" !important;",
" color:",
contrast,
" !important;",
"}",
"a,a:visited,.post-title a{",
" color:",
normalized,
" !important;",
"}",
"a:hover,.post-title a:hover{",
" color:",
lighter,
" !important;",
"}",
".color-theme-switcher svg{",
" color:",
normalized,
" !important;",
"}",
].join("");
}
function ensureThemeStyleTag(preferAddStyle) {
if (themeStyleTag && themeStyleTag.parentNode) {
return themeStyleTag;
}
if (
(preferAddStyle || !themeStyleTag) &&
typeof GM_addStyle === "function"
) {
const created = GM_addStyle("");
if (created) {
created.id = created.id || "tm-theme-accent-style";
themeStyleTag = created;
return themeStyleTag;
}
}
const style = document.createElement("style");
style.id = "tm-theme-accent-style";
const target = document.head || document.documentElement;
target.appendChild(style);
themeStyleTag = style;
return style;
}
function removeThemeStyle() {
if (themeStyleTag && themeStyleTag.parentNode) {
themeStyleTag.parentNode.removeChild(themeStyleTag);
}
themeStyleTag = null;
}
function updateMetaThemeColor(color) {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
return;
}
meta.setAttribute("content", color);
}
function restoreMetaThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
return;
}
if (originalMetaTheme) {
meta.setAttribute("content", originalMetaTheme);
} else {
meta.removeAttribute("content");
}
}
function normalizeColor(value) {
if (!value || typeof value !== "string") {
return null;
}
let color = value.trim().toLowerCase();
if (!color) {
return null;
}
if (color.indexOf("rgb") === 0) {
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1], 10);
const g = parseInt(match[2], 10);
const b = parseInt(match[3], 10);
return rgbToHex(r, g, b);
}
}
if (color.charAt(0) !== "#") {
if (/^[0-9a-f]{6}$/i.test(color) || /^[0-9a-f]{3}$/i.test(color)) {
color = "#" + color;
} else {
return null;
}
}
if (/^#[0-9a-f]{3}$/i.test(color)) {
return (
"#" +
color.charAt(1) +
color.charAt(1) +
color.charAt(2) +
color.charAt(2) +
color.charAt(3) +
color.charAt(3)
);
}
if (/^#[0-9a-f]{6}$/i.test(color)) {
return color;
}
return null;
}
function hexToRgb(color) {
const normalized = normalizeColor(color);
if (!normalized) {
return null;
}
const value = parseInt(normalized.slice(1), 16);
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255,
};
}
function rgbToHex(r, g, b) {
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
}
function componentToHex(component) {
const value = Math.round(clamp(component, 0, 255));
const hex = value.toString(16);
return hex.length === 1 ? "0" + hex : hex;
}
function lighten(color, percent) {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
const ratio = clamp(percent, 0, 100) / 100;
const r = Math.round(rgb.r + (255 - rgb.r) * ratio);
const g = Math.round(rgb.g + (255 - rgb.g) * ratio);
const b = Math.round(rgb.b + (255 - rgb.b) * ratio);
return rgbToHex(r, g, b);
}
function darken(color, percent) {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
const ratio = clamp(percent, 0, 100) / 100;
const r = Math.round(rgb.r * (1 - ratio));
const g = Math.round(rgb.g * (1 - ratio));
const b = Math.round(rgb.b * (1 - ratio));
return rgbToHex(r, g, b);
}
function toRgba(color, alpha) {
const rgb = hexToRgb(color);
if (!rgb) {
const fallback = clamp(alpha, 0, 1);
return "rgba(0, 0, 0, " + fallback + ")";
}
const value = clamp(alpha, 0, 1);
return (
"rgba(" +
rgb.r +
", " +
rgb.g +
", " +
rgb.b +
", " +
value.toFixed(2) +
")"
);
}
function clamp(value, min, max) {
const number = Number(value);
if (!isFinite(number)) {
return min;
}
if (number < min) {
return min;
}
if (number > max) {
return max;
}
return number;
}
function getContrastingTextColor(color) {
const rgb = hexToRgb(color);
if (!rgb) {
return "#ffffff";
}
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance > 0.6 ? "#000000" : "#ffffff";
}
})();