---
name: 渐变色高级配置面板
description: 悬浮面板:强大的渐变色编辑器,支持实时预览、多节点色标控制、径向渐变选择,并可从选中元素反向读取状态。
author: ymjr
version: 1.0.0
license: MIT
usage: 运行此脚本呼出高级渐变色控制面板。支持可视化选择渐变方向、添加/删除/拖拽多个色标点。如果打开面板时已选中带有渐变的元素,面板会自动反向读取其渐变配置以供修改。
features:
- 提供可视化的多节点颜色与进度百分比 (`offset`) 调节
- 支持线性(水平、垂直、对角)及径向(中心发散)渐变
- 支持将用户的配置转化为底层 Canvas 指令脚本存入元素的 `customData.gradient`
dependencies:
- 必须依赖 [Feature-Gradient-Engine] 后台引擎进行最终的渲染与 SVG 导出拦截
autorun: false
---
/*
```javascript
*/
var locales = {
zh: {
notice_no_select: "⚠️ 当前未选中任何元素,您可以先调整面板,随后选中元素将自动应用。",
notice_cleared: "🧹 已清除所选元素的渐变色",
ui_title: "🌈 渐变编辑器",
ui_type: "渐变类型",
ui_linear_h: "线性 - 水平",
ui_linear_v: "线性 - 垂直",
ui_linear_d: "线性 - 对角",
ui_radial: "径向 - 中心发散",
ui_stops_title: "色标节点 (0% - 100%)",
ui_add_stop: "➕ 添加节点",
ui_clear: "清除渐变",
ui_done: "完成"
},
en: {
notice_no_select: "⚠️ No element selected. You can adjust the panel first; it will apply to selected elements later.",
notice_cleared: "🧹 Gradient cleared for selected elements",
ui_title: "🌈 Gradient Editor",
ui_type: "Gradient Type",
ui_linear_h: "Linear - Horizontal",
ui_linear_v: "Linear - Vertical",
ui_linear_d: "Linear - Diagonal",
ui_radial: "Radial - Center",
ui_stops_title: "Stops (0% - 100%)",
ui_add_stop: "➕ Add Stop",
ui_clear: "Clear",
ui_done: "Done"
}
};
const { Notice } = ea.obsidian;
const api = ea.getExcalidrawAPI();
// ==========================================
// 1. 状态管理与初始化
// ==========================================
let uiState = {
direction: "horizontal", // horizontal, vertical, diagonal, radial
stops: [
{ offset: 0, color: "#5EFCE8" },
{ offset: 1, color: "#736EFE" }
]
};
// 反向读取当前选中元素的渐变状态
const selectedEls = ea.getViewSelectedElements();
if (selectedEls.length > 0 && selectedEls[0].customData?.gradient) {
const script = selectedEls[0].customData.gradient;
if (script.includes("createRadialGradient")) uiState.direction = "radial";
else if (script.includes("0, 0, 0, el.height")) uiState.direction = "vertical";
else if (script.includes("0, 0, el.width, el.height")) uiState.direction = "diagonal";
else uiState.direction = "horizontal";
const stopMatches = [...script.matchAll(/addColorStop\(([\d.]+),\s*["']([^"']+)["']/g)];
if (stopMatches.length > 0) {
uiState.stops = stopMatches.map(m => ({ offset: parseFloat(m[1]), color: m[2] }));
// 确保按 offset 排序
uiState.stops.sort((a, b) => a.offset - b.offset);
}
} else if (selectedEls.length === 0) {
new Notice(t("notice_no_select"));
}
// ==========================================
// 2. 核心渲染逻辑 (实时应用)
// ==========================================
function applyGradient() {
const els = ea.getViewSelectedElements();
if (els.length === 0) return;
let coords = "0, 0, el.width, 0";
let typeFn = "createLinearGradient";
if (uiState.direction === "radial") {
typeFn = "createRadialGradient";
coords = "el.width/2, el.height/2, 0, el.width/2, el.height/2, Math.max(el.width, el.height)/2";
} else if (uiState.direction === "vertical") {
coords = "0, 0, 0, el.height";
} else if (uiState.direction === "diagonal") {
coords = "0, 0, el.width, el.height";
}
let script = `var gradient = ctx.${typeFn}(${coords});\n`;
// 强制按 offset 从小到大排序后生成脚本
const sortedStops = [...uiState.stops].sort((a, b) => a.offset - b.offset);
sortedStops.forEach(s => {
script += `gradient.addColorStop(${s.offset}, "${s.color}");\n`;
});
els.forEach((el) => {
el.customData = { ...el.customData, gradient: script };
api.ShapeCache?.cache?.delete(el);
});
ea.copyViewElementsToEAforEditing(els);
ea.addElementsToView(false, false, false);
}
function removeGradient() {
const els = ea.getViewSelectedElements();
if (els.length === 0) return;
els.forEach((el) => {
if (el.customData?.gradient) {
delete el.customData.gradient;
api.ShapeCache?.cache?.delete(el);
}
});
ea.copyViewElementsToEAforEditing(els);
ea.addElementsToView(false, false, false);
new Notice(t("notice_cleared"));
}
// ==========================================
// 3. UI 构建与交互
// ==========================================
function createGradientPanel() {
if (document.getElementById("ea-gradient-adv-panel")) return;
const panel = document.createElement('div');
panel.id = "ea-gradient-adv-panel";
panel.style.cssText = `position:fixed; top:120px; right:80px; width:280px; background:var(--background-primary); border:1px solid var(--background-modifier-border); box-shadow:0 10px 30px rgba(0,0,0,0.25); border-radius:8px; z-index:9999; display:flex; flex-direction:column;`;
// 头部
const header = document.createElement('div');
header.style.cssText = `padding:12px 15px; background:var(--background-secondary); cursor:move; border-bottom:1px solid var(--background-modifier-border); display:flex; justify-content:space-between; align-items:center; user-select:none; border-radius:8px 8px 0 0;`;
header.innerHTML = `${t("ui_title")}`;
panel.appendChild(header);
const content = document.createElement('div');
content.style.cssText = `padding:15px; display:flex; flex-direction:column; gap:15px;`;
// 类型选择
const typeDiv = document.createElement('div');
typeDiv.style.cssText = "display:flex; justify-content:space-between; align-items:center;";
typeDiv.innerHTML = ``;
const dirSelect = document.createElement('select');
dirSelect.style.cssText = "width:150px; background:var(--background-modifier-form-field); border-radius:4px; padding:4px;";
const dirs = { "horizontal": t("ui_linear_h"), "vertical": t("ui_linear_v"), "diagonal": t("ui_linear_d"), "radial": t("ui_radial") };
Object.entries(dirs).forEach(([val, text]) => {
const o = document.createElement('option'); o.value = val; o.innerText = text;
if(uiState.direction === val) o.selected = true; dirSelect.appendChild(o);
});
dirSelect.onchange = (e) => { uiState.direction = e.target.value; applyGradient(); };
typeDiv.appendChild(dirSelect);
content.appendChild(typeDiv);
// 节点控制器列表
const stopsContainerWrapper = document.createElement('div');
stopsContainerWrapper.style.cssText = `background:var(--background-secondary-alt); border:1px solid var(--background-modifier-border); border-radius:6px; padding:10px; display:flex; flex-direction:column; gap:10px;`;
const stopsTitle = document.createElement('div');
stopsTitle.style.cssText = "font-size:12px; font-weight:bold; color:var(--text-muted); display:flex; justify-content:space-between;";
stopsTitle.innerHTML = `${t("ui_stops_title")}`;
stopsContainerWrapper.appendChild(stopsTitle);
const stopsList = document.createElement('div');
stopsList.style.cssText = "display:flex; flex-direction:column; gap:8px;";
stopsContainerWrapper.appendChild(stopsList);
// 渲染色标节点的函数
const renderStops = () => {
stopsList.innerHTML = "";
uiState.stops.forEach((stop, index) => {
const row = document.createElement('div');
row.style.cssText = "display:flex; align-items:center; gap:8px;";
const cPicker = document.createElement('input');
cPicker.type = "color"; cPicker.value = stop.color;
cPicker.style.cssText = "width:28px; height:28px; padding:0; border:none; background:none; cursor:pointer;";
cPicker.oninput = (e) => { uiState.stops[index].color = e.target.value; applyGradient(); };
const slider = document.createElement('input');
slider.type = "range"; slider.min = "0"; slider.max = "100"; slider.value = Math.round(stop.offset * 100);
slider.style.cssText = "flex:1; cursor:pointer;";
const valLabel = document.createElement('span');
valLabel.style.cssText = "font-size:11px; width:30px; text-align:right; color:var(--text-muted); font-family:monospace;";
valLabel.innerText = `${slider.value}%`;
slider.oninput = (e) => {
const val = e.target.value;
valLabel.innerText = `${val}%`;
uiState.stops[index].offset = val / 100;
applyGradient();
};
const delBtn = document.createElement('button');
delBtn.innerHTML = "✕";
delBtn.style.cssText = "background:none; border:none; cursor:pointer; color:var(--text-error); padding:2px 6px; font-size:10px;";
delBtn.disabled = uiState.stops.length <= 2;
if(uiState.stops.length <= 2) delBtn.style.opacity = "0.3";
delBtn.onclick = () => {
if(uiState.stops.length > 2) {
uiState.stops.splice(index, 1);
renderStops(); applyGradient();
}
};
row.appendChild(cPicker); row.appendChild(slider); row.appendChild(valLabel); row.appendChild(delBtn);
stopsList.appendChild(row);
});
};
renderStops();
// 添加节点按钮
const btnAddStop = document.createElement('button');
btnAddStop.innerText = t("ui_add_stop");
btnAddStop.style.cssText = "width:100%; padding:4px; font-size:12px; background:var(--interactive-normal); border:1px dashed var(--background-modifier-border); border-radius:4px; cursor:pointer; color:var(--text-muted);";
btnAddStop.onclick = () => {
uiState.stops.push({ offset: 0.5, color: "#ffffff" });
renderStops(); applyGradient();
};
stopsContainerWrapper.appendChild(btnAddStop);
content.appendChild(stopsContainerWrapper);
panel.appendChild(content);
// 底部操作区
const footer = document.createElement('div');
footer.style.cssText = `padding:12px 15px; border-top:1px solid var(--background-modifier-border); display:flex; justify-content:space-between; background:var(--background-secondary); border-radius: 0 0 8px 8px;`;
const btnClear = document.createElement('button');
btnClear.innerText = t("ui_clear"); btnClear.onclick = removeGradient;
const btnDone = document.createElement('button');
btnDone.innerText = t("ui_done"); btnDone.className = "mod-cta";
btnDone.onclick = () => panel.remove();
footer.appendChild(btnClear); footer.appendChild(btnDone);
panel.appendChild(footer);
document.body.appendChild(panel);
header.querySelector('#close-btn').onclick = () => panel.remove();
// 拖拽逻辑
let isDragging = false, startX, startY, initLeft, initTop;
header.onmousedown = (e) => {
if(e.target.tagName === 'BUTTON') return;
isDragging = true; startX = e.clientX; startY = e.clientY;
const rect = panel.getBoundingClientRect(); initLeft = rect.left; initTop = rect.top;
e.preventDefault();
document.onmousemove = (e) => {
if(!isDragging) return;
panel.style.left = (initLeft + e.clientX - startX) + 'px';
panel.style.top = (initTop + e.clientY - startY) + 'px';
};
document.onmouseup = () => { isDragging = false; document.onmousemove = null; document.onmouseup = null; };
};
// 初始如果选中了元素,立刻渲染一遍预览
if (selectedEls.length > 0 && !selectedEls[0].customData?.gradient) {
applyGradient();
}
}
createGradientPanel();