// ==UserScript==
// @name ChatGPT Bubble Theme Pro
// @namespace https://greasyforks.org/zh-CN/users/1503226-loom29
// @version 1.9.3
// @author Ech0
// @description 自定义 ChatGPT 气泡:纯色/线性/放射/弥散光渐变、磨砂、外发光、描边、圆角/内边距……支持 Alt+G 打开面板;移动端优化与节流。
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const IS_MOBILE = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent) ||
(window.matchMedia && matchMedia("(max-width: 820px)").matches);
const SAFE_MODE = !!IS_MOBILE;
/*** 性能:样式节流 + 归拢开关 ***/
let NEED_ENSURE = true;
let styleTimer = null;
const requestStyleUpdate = () => {
if (styleTimer) return;
styleTimer = setTimeout(() => { styleTimer = null; applyStyle(); }, IS_MOBILE ? 90 : 45);
};
/*** 主题与默认值 ***/
const THEMES = {
"海盐": { mode:"diffuse", angle:135, colors:["#89d0d2","#a8ced7","#88b9ce"], weights:[34,33,33], alpha:0.25, blur:10, glow:0, outlineColor:"#78afb0", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#2d3d43", soft:10, autoText:false },
"春日": { mode:"diffuse", angle:130, colors:["#d5e7ab","#e2e29d","#b4d4ab"], weights:[34,33,33], alpha:0.45, blur:9, glow:0, outlineColor:"#b0cda7", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#2e3f27", autoText:false },
"蜜桃": { mode:"diffuse", angle:125, colors:["#f2d8d4","#ebbeb3","#f4d5b8"], weights:[34,33,33], alpha:0.40, blur:8, glow:0, outlineColor:"#e6a08e", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#47352e", autoText:false },
"洋芋": { mode:"diffuse", angle:125, colors:["#9898c5","#b7cfe5","#b9b5c5"], weights:[34,33,33], alpha:0.30, blur:8, glow:0, outlineColor:"#9189be", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#4d4860", autoText:false },
"奶油": { mode:"diffuse", angle:125, colors:["#f0e6d0","#e2dfca","#ecd8c1"], weights:[34,33,33], alpha:0.30, blur:8, glow:0, outlineColor:"#c8b493", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#5c5951", autoText:false },
"湖畔": { mode:"diffuse", angle:135, colors:["#76b9d5","#a9d3d6","#34b7a1"], weights:[34,33,33], alpha:0.70, blur:10, glow:0, outlineColor:"#65b3b8", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#09161b", soft:20, autoText:false },
"热异常": { mode:"linear", angle:180, colors:["#dcd6d6","#eab06d","#e95f28"], weights:[27,47,26], alpha:1, blur:9, glow:0, outlineColor:"#610505", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#2e3f27", soft:20, autoText:true },
"水泥": { mode:"diffuse", angle:135, colors:["#4d5b6f","#626e7a","#4f5863"], weights:[34,33,33], alpha:0.85, blur:10, glow:0, outlineColor:"#0d3f5e", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#ffffff", autoText:true },
"墨玉": { mode:"diffuse", angle:135, colors:["#3c5639","#274b37","#2a5026"], weights:[34,33,33], alpha:0.75, blur:10, glow:0, outlineColor:"#0d631f", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#ffffff", autoText:true },
"红酒": { mode:"diffuse", angle:135, colors:["#640707","#2f0909","#570a29"], weights:[34,33,33], alpha:0.90, blur:10, glow:0, outlineColor:"#651406", outlineAlpha:0, outlineWidth:1, radius:16, padV:8, padH:12, textColor:"#ffffff", autoText:true },
"纯黑": { mode:"solid", angle:135, colors:["#000000","#000000","#000000"], weights:[100,0,0], alpha:1.00, blur:10, glow:0, outlineColor:"#000000", outlineAlpha:0, outlineWidth:0, radius:16, padV:8, padH:12, textColor:"#ffffff", soft:10, autoText:true }
};
const DEFAULTS = {
targets: { user:true, assistant:false, sendbtn:true },
followOfSendBtn: "user",
user: structuredClone(THEMES["海盐"]),
assistant: structuredClone(THEMES["海盐"]),
lastTheme: { user:"海盐", assistant:"海盐" },
overrides: { user:{}, assistant:{} },
customThemes: {},
galleryOrder: Object.keys(THEMES),
panel: { x:null, y:null, w:560, h:560 }
};
// —— 这里只声明一次,避免重复声明告警 ——
const PANEL_ID = "ech0-theme-panel";
const STYLE_ID = "ech0-theme-style";
const clone = (x)=>JSON.parse(JSON.stringify(x));
const clamp=(v,min,max)=>Math.min(max,Math.max(min,v));
const pad2=(n)=>n.toString(16).padStart(2,"0");
const hexToRgb=(hex)=>{ let h=(hex||"").replace("#","").trim(); if(h.length===3) h=h.split("").map(x=>x+x).join(""); const n=parseInt(h||"000000",16); return { r:(n>>16)&255, g:(n>>8)&255, b:n&255 }; };
const rgba=(hex,a)=>{ const { r,g,b }=hexToRgb(hex); return `rgba(${r},${g},${b},${clamp(a,0,1)})`; };
const load=()=>{ try{ const s=GM_getValue("ech0_theme_pro"); if(s) return Object.assign(clone(DEFAULTS), s);}catch{} return clone(DEFAULTS); };
const save=(cfg)=>{ try{ GM_setValue("ech0_theme_pro", cfg); }catch{} };
let CFG = load(); // 提前加载,保证后续调用可用
function hslToRgb(h,s,l){
h=((h%360)+360)%360; s/=100; l/=100;
const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs((h/60)%2-1)), m=l-c/2;
let r=0,g=0,b=0;
if(h<60){ r=c; g=x; }
else if(h<120){ r=x; g=c; }
else if(h<180){ g=c; b=x; }
else if(h<240){ g=x; b=c; }
else if(h<300){ r=x; b=c; }
else { r=c; b=x; }
return { r:Math.round((r+m)*255), g:Math.round((g+m)*255), b:Math.round((b+m)*255) };
}
function parseColorToHex(s){
if(!s) return null;
s=s.trim();
let m=s.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if(m){ let h=m[1]; if(h.length===3) h=h.split("").map(x=>x+x).join(""); return "#"+h.toLowerCase(); }
m=s.match(/^rgba?\s*\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
if(m){ const r=clamp(Math.round(+m[1]),0,255), g=clamp(Math.round(+m[2]),0,255), b=clamp(Math.round(+m[3]),0,255); return "#"+pad2(r)+pad2(g)+pad2(b); }
m=s.match(/^hsla?\s*\(\s*([-+]?\d+(?:\.\d+)?)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i);
if(m){ const { r,g,b }=hslToRgb(+m[1],+m[2],+m[3]); return "#"+pad2(r)+pad2(g)+pad2(b); }
return null;
}
function relLuma(hex){
const { r,g,b }=hexToRgb(hex);
const sr=[r,g,b].map(v=>v/255).map(v=>v<=0.04045? v/12.92 : Math.pow((v+0.055)/1.055,2.4));
return 0.2126*sr[0]+0.7152*sr[1]+0.0722*sr[2];
}
function themeLuma(s){
const w=s.weights||[1,1,1], sum=w.reduce((a,b)=>a+b,0)||1;
const cols=(s.colors||[]).filter(Boolean); let L=0;
cols.forEach((c,i)=>{ L+=relLuma(c)*(w[i]||0)/sum; });
return L;
}
const pickAutoTextColor=(s)=> themeLuma(s)>0.55 ? "#000000" : "#ffffff";
const SOFT=10, MAX_SOFT=50;
function makeBackground(s){
const cols=(s.colors||[]).filter(Boolean);
const c1=cols[0]||"#000000", c2=cols[1]||c1, c3=cols[2]||c2;
const a1=rgba(c1,s.alpha), a2=rgba(c2,s.alpha), a3=rgba(c3,s.alpha);
const w=(s.weights||[100,0,0]).map(n=>Math.max(0,+n||0));
const sum=w.reduce((a,b)=>a+b,0);
if(s.mode==="solid") return a1;
const softBase=Math.max(0,Math.min((s.soft??SOFT),MAX_SOFT));
if(sum<=0) return a1;
if(s.mode==="diffuse"){
const k1=w[0]/sum, k2=w[1]/sum, k3=w[2]/sum;
const angle=typeof s.angle==="number"?s.angle:135;
return [
`radial-gradient(60% 60% at 0% 0%, ${a1} ${Math.round(k1*60)}%, transparent 60%)`,
`radial-gradient(60% 60% at 100% 0%, ${a2} ${Math.round(k2*60)}%, transparent 60%)`,
`radial-gradient(60% 60% at 0% 100%, ${a3} ${Math.round(k3*60)}%, transparent 60%)`,
`linear-gradient(${angle}deg, ${a1}, ${a2}, ${a3})`
].join(",");
}
if(s.mode==="radial"){
const r1=w[0]/sum*100, r2=(w[0]+w[1])/sum*100;
const t1=Math.min(softBase,r1,(r2-r1)/2), t2=Math.min(softBase,(r2-r1)/2,100-r2);
return `radial-gradient(120% 100% at 0% 0%,
${a1} 0%, ${a1} ${Math.max(0,r1-t1)}%,
${a2} ${Math.min(100,r1+t1)}%, ${a2} ${Math.max(0,r2-t2)}%,
${a3} ${Math.min(100,r2+t2)}%, ${a3} 100%)`;
}
const p1=w[0]*100/sum, p2=(w[0]+w[1])*100/sum;
const t1=Math.min(softBase,p1,(p2-p1)/2,100-p1);
const t2=Math.min(softBase,(p2-p1)/2,100-p2);
const angle=typeof s.angle==="number"?s.angle:135;
if(p1<=0 && p2<=0) return `linear-gradient(${angle}deg, ${a3} 0%, ${a3} 100%)`;
if(p1>=100) return `linear-gradient(${angle}deg, ${a1} 0%, ${a1} 100%)`;
return `linear-gradient(${angle}deg,
${a1} 0%, ${a1} ${Math.max(0,p1-t1)}%,
${a2} ${Math.min(100,p1+t1)}%, ${a2} ${Math.max(0,p2-t2)}%,
${a3} ${Math.min(100,p2+t2)}%, ${a3} 100%)`;
}
const CANDIDATES=[":scope > .markdown",":scope .markdown",":scope .prose",":scope .whitespace-pre-wrap"];
const getCandidateBlocks=(el)=> Array.from(el.querySelectorAll(CANDIDATES.join(","))).filter(n=>!n.closest(".ech0-bubble"));
const containsAll=(c,nodes)=> nodes.every(n=>c.contains(n));
function findCommonContainer(msgEl,nodes){
if(!nodes.length) return null;
for(let c=nodes[0].parentElement; c && c!==msgEl; c=c.parentElement){
if(containsAll(c,nodes)) return c;
}
return msgEl;
}
function wrapIntoBubble(container,nodes){
if(!container) return;
let shell=container.querySelector(":scope > .ech0-bubble");
if(!shell){ shell=document.createElement("div"); shell.className="ech0-bubble"; container.insertBefore(shell,container.firstChild); }
const tops=[];
nodes.forEach(n=>{ let t=n; while(t.parentElement && t.parentElement!==container) t=t.parentElement; if(!tops.includes(t)) tops.push(t); });
tops.sort((a,b)=>a.compareDocumentPosition(b)&Node.DOCUMENT_POSITION_FOLLOWING?-1:1);
tops.forEach(t=>{ if(t!==shell) shell.appendChild(t); });
}
function glueOneMessage(msgEl){
const nodes=getCandidateBlocks(msgEl);
if(!nodes.length) return;
const c=findCommonContainer(msgEl,nodes);
if (SAFE_MODE){ c.classList.add("ech0-bubble-s"); return; }
wrapIntoBubble(c,nodes);
}
function ensureShells(role){
document.querySelectorAll(`[data-message-author-role="${role}"]`).forEach(glueOneMessage);
}
const SEND_BTN_SEL=[
'button[data-testid="send-button"]',
'form button[type="submit"]',
'button[aria-label="Send"]',
'[data-testid="composer-send-button"] button'
].join(",");
const makeGlow=(hex,str)=> str>0?`0 6px 18px ${rgba(hex,clamp(str*0.55,0,1))}, 0 0 18px ${rgba(hex,clamp(str*0.35,0,1))}`:"none";
function cssForRole(role,s){
const bg=makeBackground(s);
const outline=rgba(s.outlineColor,s.outlineAlpha);
const border=(s.outlineAlpha<=0 || s.outlineWidth<=0)?"none":`${s.outlineWidth}px solid ${outline}`;
const shadow=makeGlow(s.outlineColor,s.glow);
const pfx=role==="user"?"user":"asst";
const isLight=themeLuma(s)>0.55;
const preBg=isLight?"rgba(0,0,0,.08)":"rgba(255,255,255,.12)";
const preBorder=isLight?"1px solid rgba(0,0,0,.12)":"1px solid rgba(255,255,255,.16)";
const text=s.autoText?pickAutoTextColor(s):(s.textColor||"#101418");
const blurPx = IS_MOBILE ? Math.min(s.blur, 4) : s.blur; // 移动端 blur 限幅
const bubbleSel = SAFE_MODE ? `.ech0-bubble-s` : `.ech0-bubble`;
const clearHost = SAFE_MODE ? "" : `
[data-message-author-role="${role}"], [data-message-author-role="${role}"] > div,
[data-message-author-role="${role}"] .text-message, [data-message-author-role="${role}"] .relative{
background:transparent!important; box-shadow:none!important; border-color:transparent!important;
}`;
return `
:root{ --ech0-${pfx}-text:${text}; }
${clearHost}
[data-message-author-role="${role}"] ${bubbleSel}{
display:inline-block; width:fit-content; max-width:100%;
background:${bg}!important; color:var(--ech0-${pfx}-text)!important;
border:${border}!important; border-radius:${s.radius}px!important;
padding:${s.padV}px ${s.padH}px!important; box-shadow:${shadow}!important;
-webkit-backdrop-filter:blur(${blurPx}px)!important; backdrop-filter:blur(${blurPx}px)!important;
}
[data-message-author-role="${role}"] ${bubbleSel} :not(pre):not(code){
color:var(--ech0-${pfx}-text)!important;
-webkit-text-fill-color:var(--ech0-${pfx}-text)!important;
}
[data-message-author-role="${role}"] ${bubbleSel} pre{
background:${preBg}!important; border:${preBorder}!important; border-radius:10px!important;
}`;
}
function applyStyle(){
if (NEED_ENSURE){
if (CFG.targets.user) ensureShells("user");
if (CFG.targets.assistant) ensureShells("assistant");
NEED_ENSURE = false;
}
let css=`
.ech0-bubble{ background:transparent; background-clip:padding-box; }
.ech0-bubble-s{ background:transparent; background-clip:padding-box; }
#${PANEL_ID} .hd{ position:sticky; top:0; z-index:5; }
#${PANEL_ID} .previewRow{ position:relative; z-index:1; }
`;
if(CFG.targets.user) css+=cssForRole("user",CFG.user);
if(CFG.targets.assistant) css+=cssForRole("assistant",CFG.assistant);
if(CFG.targets.sendbtn){
const s=CFG.followOfSendBtn==="assistant"?CFG.assistant:CFG.user;
const bg=makeBackground(s);
const outline=rgba(s.outlineColor,s.outlineAlpha);
const border=(s.outlineAlpha<=0 || s.outlineWidth<=0)?"none":`${s.outlineWidth}px solid ${outline}`;
const textColor = s.autoText ? pickAutoTextColor(s) : (s.textColor || "#101418");
const blurPx = IS_MOBILE ? Math.min(s.blur, 4) : s.blur;
css+=`
${SEND_BTN_SEL}{
background:${bg}!important; border:${border}!important; border-radius:9999px!important;
-webkit-backdrop-filter:blur(${blurPx}px)!important; backdrop-filter:blur(${blurPx}px)!important;
color:${textColor}!important;
}
${SEND_BTN_SEL} svg{
color:${textColor}!important;
--send-stroke: 0.10px;
}
${SEND_BTN_SEL} svg *{
filter:none!important; mix-blend-mode:normal!important; opacity:1!important;
-webkit-mask-image:none!important; mask:none!important;
fill:none!important; stroke:currentColor!important; stroke-linecap:round!important; stroke-linejoin:round!important;
vector-effect:non-scaling-stroke!important; stroke-width:var(--send-stroke)!important; shape-rendering:geometricPrecision; paint-order:stroke fill markers;
}
`;
}
let node=document.getElementById(STYLE_ID);
if(!node){ node=document.createElement("style"); node.id=STYLE_ID; document.head.appendChild(node); }
node.textContent=css;
updatePreview();
}
function openPanel(){
if(document.getElementById(PANEL_ID)) return;
const w=document.createElement("div"); w.id=PANEL_ID;
w.innerHTML=`
<div class="hd" id="ech0-hd"><strong>Bubble Theme Pro</strong><div class="sp"></div><button data-act="close" title="关闭">✕</button></div>
<div class="bar">
<label><input type="checkbox" id="t-user"> 用户气泡</label>
<label><input type="checkbox" id="t-assistant"> AI 气泡</label>
<label><input type="checkbox" id="t-sendbtn"> 发送按钮</label>
<span class="sep"></span>
<label>发送按钮跟随
<select id="followSel"><option value="user">用户</option><option value="assistant">AI</option></select>
</label>
</div>
<div class="tabs">
<button class="tab active" data-role="user">编辑:用户</button>
<button class="tab" data-role="assistant">编辑:AI</button>
<div class="sp"></div>
<button class="chipEdit" id="editChipsBtn">编辑主题</button>
</div>
<div class="chipWall" id="chipWall"></div>
<div class="previewRow">
<div class="previewLabel">预览:</div>
<div class="previewWrap"><div id="ech0-preview" class="previewBubble">正在输入中.............</div></div>
</div>
<div class="grid">
<div class="col">
<div class="item"><label>背景类型</label>
<select id="mode"><option value="linear">线性渐变</option><option value="radial">放射渐变</option><option value="diffuse">弥散光渐变</option><option value="solid">纯色</option></select>
</div>
<div class="item"><label>角度(线性)</label><div class="dual"><input type="range" id="angleR" min="0" max="360" step="1"><input type="number" id="angle" min="0" max="360" step="1"></div></div>
<div class="item"><label>文字颜色</label>
<div class="dualColor">
<input type="text" id="textHex" class="hex" placeholder="#RRGGBB">
<input type="color" id="textColor">
<label class="inline"><input type="checkbox" id="autoText"> 自动</label>
</div>
</div>
<div class="item"><label>颜色1</label><div class="dualColor"><input type="text" id="h1" class="hex" placeholder="#RRGGBB"><input type="color" id="c1"></div></div>
<div class="item"><label>颜色2</label><div class="dualColor"><input type="text" id="h2" class="hex" placeholder="#RRGGBB"><input type="color" id="c2"></div></div>
<div class="item"><label>颜色3</label><div class="dualColor"><input type="text" id="h3" class="hex" placeholder="#RRGGBB"><input type="color" id="c3"></div></div>
<div class="item"><label>配重1/2/3(%)</label>
<div class="weights">
<div class="row"><input type="range" id="w1r" min="0" max="100" step="1"><input type="number" id="w1" min="0" max="100" step="1"></div>
<div class="row"><input type="range" id="w2r" min="0" max="100" step="1"><input type="number" id="w2" min="0" max="100" step="1"></div>
<div class="row"><input type="range" id="w3r" min="0" max="100" step="1" disabled><input type="number" id="w3" min="0" max="100" step="1" readonly></div>
</div>
</div>
<div class="hint" id="weightHint">配重合计:0%(建议=100%)</div>
</div>
<div class="col">
<div class="item"><label>过渡羽化(%)</label><div class="dual"><input type="range" id="softR" min="0" max="50" step="1"><input type="number" id="soft" min="0" max="50" step="1"></div></div>
<div class="item"><label>透明度</label><div class="dual"><input type="range" id="alphaR" min="0" max="1" step="0.01"><input type="number" id="alpha" min="0" max="1" step="0.01"></div></div>
<div class="item"><label>磨砂(blur)</label><div class="dual"><input type="range" id="blurR" min="0" max="30" step="1"><input type="number" id="blur" min="0" max="30" step="1"></div></div>
<div class="item"><label>发光强度</label><div class="dual"><input type="range" id="glowR" min="0" max="1" step="0.01"><input type="number" id="glow" min="0" max="1" step="0.01"></div></div>
<div class="item"><label>描边色</label>
<div class="dualColor">
<input type="text" id="outlineHex" class="hex" placeholder="#RRGGBB">
<input type="color" id="outlineColor">
</div>
</div>
<div class="item"><label>描边透明</label><div class="dual"><input type="range" id="outlineAlphaR" min="0" max="1" step="0.01"><input type="number" id="outlineAlpha" min="0" max="1" step="0.01"></div></div>
<div class="item"><label>描边宽(px)</label><div class="dual"><input type="range" id="outlineWidthR" min="0" max="6" step="1"><input type="number" id="outlineWidth" min="0" max="6" step="1"></div></div>
<div class="item"><label>圆角(px)</label><div class="dual"><input type="range" id="radiusR" min="0" max="40" step="1"><input type="number" id="radius" min="0" max="40" step="1"></div></div>
<div class="item"><label>内边距-垂直</label><div class="dual"><input type="range" id="padVR" min="0" max="40" step="1"><input type="number" id="padV" min="0" max="40" step="1"></div></div>
<div class="item"><label>内边距-水平</label><div class="dual"><input type="range" id="padHR" min="0" max="40" step="1"><input type="number" id="padH" min="0" max="40" step="1"></div></div>
</div>
</div>
<div class="presets">
<div class="left">
<input id="presetName" placeholder="预设名称">
<button data-act="savePreset">另存为</button>
</div>
<div class="right">
<button data-act="save">保存</button>
<button data-act="reset">重置默认</button>
</div>
</div>
`;
document.body.appendChild(w);
w.querySelector('[data-act="close"]').addEventListener("click",(ev)=>{ ev.stopPropagation(); w.remove(); });
GM_addStyle(`
#${PANEL_ID}{ position:fixed; z-index:999999; left:50%; top:60px; transform:translateX(-50%);
width:${CFG.panel.w||560}px; height:${CFG.panel.h||560}px; background:#fff; color:#1b1f24;
border:1px solid rgba(0,0,0,.12); border-radius:14px; box-shadow:0 16px 48px rgba(0,0,0,.16);
overflow:auto; resize:both; font:13px/1.35 system-ui, Segoe UI, Arial; cursor:default; }
#${PANEL_ID} .hd{ position:sticky; top:0; z-index:5; display:flex; align-items:center; gap:8px; padding:8px 10px;
user-select:none; background:#fff; border-bottom:1px solid rgba(0,0,0,.06); border-top-left-radius:14px; border-top-right-radius:14px; cursor:default; }
#${PANEL_ID} .sp{ flex:1; }
#${PANEL_ID} .bar{ display:flex; align-items:center; gap:8px; padding:6px 10px; flex-wrap:wrap; cursor:default; }
#${PANEL_ID} .sep{ width:1px; height:16px; background:rgba(0,0,0,.08); margin:0 4px; }
#${PANEL_ID} .tabs{ display:flex; align-items:center; gap:6px; padding:6px 10px; border-top:1px solid rgba(0,0,0,.06); cursor:default; }
#${PANEL_ID} .tab{ border:1px solid rgba(0,0,0,.12); background:#f5f7fb; border-radius:8px; padding:5px 8px; cursor:default; }
#${PANEL_ID} .tab.active{ background:#e9eef9; }
#${PANEL_ID} .chipEdit{ border:1px solid rgba(0,0,0,.12); background:#fff; border-radius:999px; padding:5px 10px; cursor:default; }
#${PANEL_ID} .chipWall{ display:flex; flex-wrap:wrap; gap:6px; padding:0 10px 6px; border-bottom:1px solid rgba(0,0,0,.06); cursor:default; }
#${PANEL_ID} .chip{ display:flex; align-items:center; gap:6px; padding:5px 8px; border:1px solid rgba(0,0,0,.12); background:#f7f9fc; border-radius:999px; cursor:default; user-select:none; }
#${PANEL_ID} .chip .dot{ width:14px; height:14px; border-radius:999px; border:1px solid rgba(0,0,0,.12); background:linear-gradient(135deg,var(--c1),var(--c2),var(--c3)); }
#${PANEL_ID} .chip .x{ display:none; width:16px; height:16px; border-radius:50%; border:1px solid rgba(0,0,0,.25); background:#fff; line-height:14px; text-align:center; font-size:11px; }
#${PANEL_ID}.editing .chip .x{ display:inline-block; }
#${PANEL_ID} .chip.active{ background:#e9eef9; border-color:rgba(59,130,246,.35); box-shadow: inset 0 0 0 2px rgba(59,130,246,.18); }
#${PANEL_ID} .previewRow{ display:grid; grid-template-columns:56px 1fr; align-items:center; gap:8px; padding:6px 10px; }
#${PANEL_ID} .previewBubble{ display:inline-block; padding:6px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.12); width:fit-content; max-width:100%; white-space:nowrap; }
#${PANEL_ID} .grid{ display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:6px 10px; }
#${PANEL_ID} .col{ display:flex; flex-direction:column; gap:8px; }
#${PANEL_ID} .item{ display:flex; align-items:center; gap:8px; cursor:default; }
#${PANEL_ID} label{ width:116px; color:#3b4250; }
#${PANEL_ID} .bar label{ width:auto!important; display:inline-flex; align-items:center; gap:8px; margin:0; white-space:nowrap; }
#${PANEL_ID} #followSel{ min-width:92px; padding-right:22px; }
#${PANEL_ID} .dual{ display:flex; align-items:center; gap:6px; }
#${PANEL_ID} .dual input[type="range"]{ width:128px; }
#${PANEL_ID} .dual input[type="number"]{ width:56px; height:26px; line-height:26px; font-size:13px; padding:4px 6px; border-radius:7px; cursor:default; }
#${PANEL_ID} .dualColor{ display:flex; align-items:center; gap:6px; }
#${PANEL_ID} .dualColor input.hex{ width:96px; height:26px; line-height:26px; font-size:13px; }
#${PANEL_ID} .dualColor .inline{ display:inline-flex; align-items:center; gap:4px; font-size:12px; color:#64748b; margin-left:6px; }
#${PANEL_ID} .dualColor input:disabled{ opacity:.6; }
#${PANEL_ID} input[type="color"]{ width:34px; height:24px; border:none; background:transparent; cursor:default; }
#${PANEL_ID} input[type="number"], #${PANEL_ID} select, #${PANEL_ID} input[type="text"]{ font-size:13px; height:26px; line-height:26px; padding:4px 6px; border-radius:7px; border:1px solid rgba(0,0,0,.14); background:#fff; cursor:default; }
#${PANEL_ID} input[type="range"]{ -webkit-appearance:none; appearance:none; background:transparent; height:16px; cursor:default; }
#${PANEL_ID} input[type="range"]::-webkit-slider-runnable-track{ height:3px; border-radius:2px; background:#d1d5db; }
#${PANEL_ID} input[type="range"]::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; width:12px; height:12px; border-radius:50%; background:#4b5563; border:none; margin-top:-4.5px; }
#${PANEL_ID} .weights .row{ display:flex; align-items:center; gap:6px; margin-bottom:2px; }
#${PANEL_ID} .weights input[type="range"]{ width:128px; }
#${PANEL_ID} .weights input[type="number"]{ width:56px; height:26px; line-height:26px; font-size:13px; }
#${PANEL_ID} .hint{ padding-left:116px; color:#6b7280; }
#${PANEL_ID} .presets{ display:flex; justify-content:space-between; align-items:center; gap:8px; padding:8px 10px; border-top:1px solid rgba(0,0,0,.06); cursor:default; }
#${PANEL_ID} .presets .left{ display:flex; align-items:center; gap:6px; }
#${PANEL_ID} .presets input{ width:140px; cursor:default; }
#${PANEL_ID} button{ background:#f5f7fb; border:1px solid rgba(0,0,0,.12); border-radius:8px; padding:5px 10px; cursor:default; }
#${PANEL_ID} #followSel, #${PANEL_ID} #mode{ font-size:12px; height:26px; line-height:26px; padding:0 24px 0 8px; text-align:center; text-align-last:center; cursor:default; }
#${PANEL_ID} #mode{ min-width:128px; }
#${PANEL_ID} #followSel option, #${PANEL_ID} #mode option{ text-align:center; }
@media (max-width: 820px){
#${PANEL_ID}{ left:50%!important; top:10px!important; transform:translateX(-50%)!important;
width:min(94vw, ${CFG.panel.w||560}px)!important; height:min(78vh, ${CFG.panel.h||560}px)!important;
max-width:96vw; max-height:82vh; -webkit-overflow-scrolling:touch; overscroll-behavior:contain; touch-action:auto; }
#${PANEL_ID} .hd{ touch-action:none; }
#${PANEL_ID} .grid{ grid-template-columns:1fr; }
#${PANEL_ID} .previewRow{ grid-template-columns:48px 1fr; }
}`);
if(CFG.panel.x!=null && CFG.panel.y!=null){ w.style.left=CFG.panel.x+"px"; w.style.top=CFG.panel.y+"px"; w.style.transform="none"; }
// 拖动
(function enableDrag(){
const hd = w.querySelector("#ech0-hd");
let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
const clampToViewport = (nx,ny)=>{
const pad=8, r=w.getBoundingClientRect(), vw=innerWidth, vh=innerHeight;
nx = Math.max(pad, Math.min(vw - r.width - pad, nx));
ny = Math.max(pad, Math.min(vh - r.height - pad, ny));
return { nx, ny };
};
const getPoint = (e) => (e.touches && e.touches[0]) || e;
const isInteractive = (el) => el && el.closest && el.closest('[data-act="close"],button,select,input,textarea,a');
function onStart(e){ if (isInteractive(e.target)) return;
const p=getPoint(e); dragging=true; const r=w.getBoundingClientRect(); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top;
if (hd.setPointerCapture && e.pointerId!=null) hd.setPointerCapture(e.pointerId); e.preventDefault(); }
function onMove(e){ if(!dragging) return; const p=getPoint(e); let nx=ox+(p.clientX-sx), ny=oy+(p.clientY-sy);
({nx,ny}=clampToViewport(nx,ny)); w.style.left=nx+"px"; w.style.top=ny+"px"; w.style.transform="none"; e.preventDefault(); }
function onEnd(){ if(!dragging) return; dragging=false; const r=w.getBoundingClientRect();
CFG.panel.x=Math.round(r.left); CFG.panel.y=Math.round(r.top); CFG.panel.w=Math.round(r.width); CFG.panel.h=Math.round(r.height); save(CFG); }
if (window.PointerEvent){ hd.addEventListener("pointerdown", onStart); addEventListener("pointermove", onMove); addEventListener("pointerup", onEnd); }
else { hd.addEventListener("mousedown", onStart); addEventListener("mousemove", onMove); addEventListener("mouseup", onEnd);
hd.addEventListener("touchstart", onStart, { passive:false }); addEventListener("touchmove", onMove, { passive:false }); addEventListener("touchend", onEnd); }
addEventListener("resize", ()=>{ const r=w.getBoundingClientRect(), {nx,ny}=clampToViewport(r.left,r.top); w.style.left=Math.round(nx)+"px"; w.style.top=Math.round(ny)+"px";});
})();
const $=(s)=>w.querySelector(s);
$("#t-user").checked=!!CFG.targets.user;
$("#t-assistant").checked=!!CFG.targets.assistant;
$("#t-sendbtn").checked=!!CFG.targets.sendbtn;
$("#followSel").value=CFG.followOfSendBtn;
["t-user","t-assistant","t-sendbtn","followSel"].forEach(id=>{
$("#"+id).addEventListener("change",()=>{
CFG.targets.user=$("#t-user").checked;
CFG.targets.assistant=$("#t-assistant").checked;
CFG.targets.sendbtn=$("#t-sendbtn").checked;
CFG.followOfSendBtn=$("#followSel").value;
save(CFG); NEED_ENSURE = true; requestStyleUpdate();
});
});
let editMode=false, ACTIVE="user";
const isBuiltin=(name)=> name in THEMES;
const chipTheme=(name)=> isBuiltin(name)?THEMES[name]:(CFG.customThemes[name]||THEMES["海盐"]);
function galleryNames(){ const builtins=Object.keys(THEMES), customs=Object.keys(CFG.customThemes||{}), set=new Set([...builtins,...customs]);
const arr=(CFG.galleryOrder||[]).filter(n=>set.has(n)); for(const n of set){ if(!arr.includes(n)) arr.push(n); } CFG.galleryOrder=arr; save(CFG); return arr; }
function renderChips(){ const wall=$("#chipWall"); wall.innerHTML=""; const current=CFG.lastTheme[ACTIVE];
galleryNames().forEach(name=>{ const st=chipTheme(name), btn=document.createElement("div");
btn.className="chip"; btn.draggable=true; btn.dataset.name=name; if(name===current) btn.classList.add("active");
btn.innerHTML=`<span class="dot" style="--c1:${st.colors[0]};--c2:${st.colors[1]};--c3:${st.colors[2]}"></span><span class="nm">${name}</span>${isBuiltin(name)?"":'<span class="x" title="删除">×</span>'}`;
wall.appendChild(btn); }); wall.parentElement.classList.toggle("editing",editMode); }
function pickThemeByName(name){ CFG.lastTheme[ACTIVE]=name; const over=(CFG.overrides[ACTIVE]||{})[name];
CFG[ACTIVE]=clone(over||chipTheme(name)); fillForm(); save(CFG); renderChips(); }
// 标签:用户 / AI
w.querySelector(".tabs").addEventListener("click",(e)=>{
const t = e.target.closest(".tab"); if(!t) return;
const role = t.dataset.role; if(!role || role === ACTIVE) return;
w.querySelectorAll(".tabs .tab").forEach(x=>x.classList.remove("active")); t.classList.add("active");
ACTIVE = role; renderChips(); fillForm(); updatePreview();
});
$("#editChipsBtn").addEventListener("click",()=>{ editMode=!editMode; $("#editChipsBtn").textContent=editMode?"完成":"编辑主题"; renderChips(); });
$("#chipWall").addEventListener("click",(e)=>{ const chip=e.target.closest(".chip"); if(!chip) return; const name=chip.dataset.name;
if(editMode){ if(e.target.classList.contains("x")){ if(isBuiltin(name)) return;
if(confirm(`删除主题「${name}」?`)){ delete CFG.customThemes[name]; CFG.galleryOrder=CFG.galleryOrder.filter(n=>n!==name); save(CFG); renderChips(); } }
}else{ pickThemeByName(name); }});
let dragName=null;
$("#chipWall").addEventListener("dragstart",(e)=>{ const c=e.target.closest(".chip"); if(!c) return; dragName=c.dataset.name; e.dataTransfer.effectAllowed="move"; });
$("#chipWall").addEventListener("dragover",(e)=>{ if(!dragName) return; e.preventDefault(); });
$("#chipWall").addEventListener("drop",(e)=>{ e.preventDefault(); const c=e.target.closest(".chip"); if(!c || !dragName) return;
const target=c.dataset.name; if(target===dragName) return; const arr=galleryNames(), a=arr.indexOf(dragName), b=arr.indexOf(target);
arr.splice(b,0,arr.splice(a,1)[0]); CFG.galleryOrder=arr; save(CFG); dragName=null; renderChips(); });
$("#chipWall").addEventListener("dragend",()=>{ dragName=null; renderChips(); });
renderChips();
function bindHex(hexId,colorId){
const hex=$(hexId), col=$(colorId);
const setHex = () => { hex.value = (col.value || "#000000").toLowerCase(); };
const setCol=()=>{ const p=parseColorToHex(hex.value); if(p){ col.value=p; hex.value=p; } };
setHex(); col.addEventListener("input",()=>{ setHex(); readForm(); requestStyleUpdate(); });
hex.addEventListener("input",()=>{ setCol(); readForm(); requestStyleUpdate(); });
hex.addEventListener("change",()=>{ setCol(); readForm(); requestStyleUpdate(); });
hex.addEventListener("paste",()=>{ setTimeout(()=>{ setCol(); readForm(); requestStyleUpdate(); },0); });
}
function bindPair(rangeId,numId,onChange){
const r=$(rangeId), n=$(numId);
function sync(from){ if(from==="r"){ n.value=r.value; } else { r.value=n.value; } onChange&&onChange(+n.value); }
r.addEventListener("input",()=>sync("r")); n.addEventListener("input",()=>sync("n")); sync("n");
}
function applyWeightUI(w1,w2){ const w3=clamp(100-w1-w2,0,100); $("#w1r").value=w1; $("#w1").value=w1; $("#w2r").value=w2; $("#w2").value=w2; $("#w3r").value=w3; $("#w3").value=w3; $("#weightHint").textContent=`配重合计:${w1+w2+w3}%(建议=100%)`; }
function readWeightUIToModel(){ let w1=+$("#w1").value||0, w2=+$("#w2").value||0; if(w1<0) w1=0; if(w1>100) w1=100; if(w2<0) w2=0; if(w1+w2>100) w2=100-w1; const w3=100-w1-w2; applyWeightUI(w1,w2); const s=CFG[ACTIVE]; s.weights=[w1,w2,w3]; return s.weights; }
$("#w1r").addEventListener("input",()=>{ $("#w1").value=$("#w1r").value; readWeightUIToModel(); requestStyleUpdate(); });
$("#w2r").addEventListener("input",()=>{ $("#w2").value=$("#w2r").value; readWeightUIToModel(); requestStyleUpdate(); });
$("#w1").addEventListener("input",()=>{ readWeightUIToModel(); requestStyleUpdate(); });
$("#w2").addEventListener("input",()=>{ readWeightUIToModel(); requestStyleUpdate(); });
function fillForm(){
const s=CFG[ACTIVE];
$("#mode").value=s.mode; $("#angle").value=s.angle; $("#angleR").value=s.angle;
$("#textColor").value=s.textColor||"#101418"; $("#textHex").value=$("#textColor").value.toLowerCase();
$("#autoText").checked=!!s.autoText; const dis=!!s.autoText;
$("#textHex").disabled=dis; $("#textColor").disabled=dis;
$("#autoText").onchange=()=>{ const on=$("#autoText").checked; $("#textHex").disabled=on; $("#textColor").disabled=on; readForm(); requestStyleUpdate(); };
$("#c1").value=s.colors[0]||"#000000"; $("#h1").value=$("#c1").value.toLowerCase();
$("#c2").value=s.colors[1]||"#000000"; $("#h2").value=$("#c2").value.toLowerCase();
$("#c3").value=s.colors[2]||"#000000"; $("#h3").value=$("#c3").value.toLowerCase();
const w1=s.weights?.[0]??34, w2=s.weights?.[1]??33; applyWeightUI(w1,w2);
$("#soft").value=s.soft??SOFT; $("#softR").value=$("#soft").value;
$("#alpha").value=s.alpha; $("#alphaR").value=s.alpha;
$("#blur").value=s.blur; $("#blurR").value=s.blur;
$("#glow").value=s.glow; $("#glowR").value=$("#glow").value;
$("#outlineColor").value=s.outlineColor; $("#outlineHex").value=$("#outlineColor").value.toLowerCase();
$("#outlineAlpha").value=s.outlineAlpha; $("#outlineAlphaR").value=$("#outlineAlpha").value;
$("#outlineWidth").value=s.outlineWidth; $("#outlineWidthR").value=$("#outlineWidth").value;
$("#radius").value=s.radius; $("#radiusR").value=s.radius;
$("#padV").value=s.padV; $("#padVR").value=$("#padV").value;
$("#padH").value=s.padH; $("#padHR").value=$("#padH").value;
bindHex("#h1","#c1"); bindHex("#h2","#c2"); bindHex("#h3","#c3"); bindHex("#textHex","#textColor"); bindHex("#outlineHex","#outlineColor");
bindPair("#angleR","#angle",()=>{ CFG[ACTIVE].angle=+$("#angle").value; });
bindPair("#softR","#soft",()=>{ CFG[ACTIVE].soft=+$("#soft").value; });
bindPair("#alphaR","#alpha",()=>{ CFG[ACTIVE].alpha=+$("#alpha").value; });
bindPair("#blurR","#blur",()=>{ CFG[ACTIVE].blur=+$("#blur").value; });
bindPair("#glowR","#glow",()=>{ CFG[ACTIVE].glow=+$("#glow").value; });
bindPair("#outlineAlphaR","#outlineAlpha",()=>{ CFG[ACTIVE].outlineAlpha=+$("#outlineAlpha").value; });
bindPair("#outlineWidthR","#outlineWidth",()=>{ CFG[ACTIVE].outlineWidth=+$("#outlineWidth").value; });
bindPair("#radiusR","#radius",()=>{ CFG[ACTIVE].radius=+$("#radius").value; });
bindPair("#padVR","#padV",()=>{ CFG[ACTIVE].padV=+$("#padV").value; });
bindPair("#padHR","#padH",()=>{ CFG[ACTIVE].padH=+$("#padH").value; });
requestStyleUpdate();
}
function readForm(){
const s=CFG[ACTIVE];
s.mode=$("#mode").value; s.angle=+$("#angle").value;
s.autoText=$("#autoText").checked;
const parsedText=parseColorToHex($("#textHex").value)||$("#textColor").value; s.textColor=parsedText||s.textColor||"#101418";
s.colors=[$("#c1").value,$("#c2").value,$("#c3").value].filter(Boolean);
s.weights=readWeightUIToModel();
s.soft=+$("#soft").value; s.alpha=+$("#alpha").value; s.blur=+$("#blur").value; s.glow=+$("#glow").value;
const parsedOutline=parseColorToHex($("#outlineHex").value)||$("#outlineColor").value; s.outlineColor=parsedOutline||"#000000";
s.outlineAlpha=+$("#outlineAlpha").value; s.outlineWidth=+$("#outlineWidth").value;
s.radius=+$("#radius").value; s.padV=+$("#padV").value; s.padH=+$("#padH").value;
const tname=CFG.lastTheme[ACTIVE]; if(!CFG.overrides[ACTIVE]) CFG.overrides[ACTIVE]={};
CFG.overrides[ACTIVE][tname]=clone(s);
save(CFG);
}
w.querySelector(".grid").addEventListener("input",()=>{ readForm(); requestStyleUpdate(); });
w.querySelector(".grid").addEventListener("change",()=>{ readForm(); requestStyleUpdate(); });
w.querySelector('[data-act="save"]').addEventListener("click",()=>{ save(CFG); alert("已保存配置"); });
w.querySelector('[data-act="savePreset"]').addEventListener("click",()=>{
const name=$("#presetName").value.trim(); if(!name){ alert("请输入预设名称"); return; }
if(THEMES[name] && !confirm("同名内置主题已存在,是否覆盖为自定义?")) return;
if(!CFG.customThemes) CFG.customThemes={};
CFG.customThemes[name]=clone(CFG[ACTIVE]); CFG.galleryOrder=[...new Set([name,...CFG.galleryOrder])];
CFG.lastTheme[ACTIVE]=name; delete (CFG.overrides[ACTIVE]||{})[name]; save(CFG); renderChips(); pickThemeByName(name); alert("已另存为自定义主题");
});
w.querySelector('[data-act="reset"]').addEventListener("click",()=>{
const name=CFG.lastTheme[ACTIVE];
const source=isBuiltin(name)?THEMES[name]:CFG.customThemes[name];
if(!source){ alert("未找到默认主题定义"); return; }
if(CFG.overrides[ACTIVE]) delete CFG.overrides[ACTIVE][name];
CFG[ACTIVE]=clone(source); save(CFG); fillForm(); requestStyleUpdate(); alert("已重置为默认");
});
fillForm();
}
function updatePreview(){
const wp = document.getElementById("ech0-preview");
if (!wp) return;
const active = document.querySelector(`#${PANEL_ID} .tab.active`);
const role = active ? active.dataset.role : "user";
const s = role === "assistant" ? CFG.assistant : CFG.user;
const bg = makeBackground(s);
const outline = rgba(s.outlineColor, s.outlineAlpha);
const border = (s.outlineAlpha <= 0 || s.outlineWidth <= 0) ? "none" : `${s.outlineWidth}px solid ${outline}`;
const text = s.autoText ? (themeLuma(s)>0.55 ? "#000" : "#fff") : (s.textColor || "#111");
const blurPx = IS_MOBILE ? Math.min(s.blur, 4) : s.blur;
wp.style.background = bg;
wp.style.border = border;
wp.style.borderRadius = s.radius + "px";
wp.style.padding = `${s.padV}px ${s.padH}px`;
wp.style.color = text;
wp.style.webkitTextFillColor = text;
wp.style.webkitBackdropFilter = `blur(${blurPx}px)`;
wp.style.backdropFilter = `blur(${blurPx}px)`;
wp.style.boxShadow = makeGlow(s.outlineColor, s.glow);
wp.textContent = "正在输入中.............";
}
// 面板开关(只此一处声明)
const togglePanel = () => {
const p = document.getElementById(PANEL_ID);
if (p) { p.remove(); } else { openPanel(); }
};
document.addEventListener("keydown", (e) => {
if (e.altKey && e.key.toLowerCase() === "g") { e.preventDefault(); togglePanel(); }
});
GM_registerMenuCommand("打开/关闭设置面板 (Alt+G)", togglePanel);
// 初次渲染
NEED_ENSURE = true;
requestStyleUpdate();
// 监听新节点:仅置位“需要归拢”,由节流器统一更新
const mo = new MutationObserver(() => { NEED_ENSURE = true; requestStyleUpdate(); });
mo.observe(document.querySelector("main") || document.documentElement, { childList: true, subtree: true });
})();