Files
matrix-screen-controller/核桃派软件源代码/app/static/color-picker.js
T

427 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { announce, apiJson, createLocalState, setActiveButton } from "./core.js";
import {
clamp,
hexToRgb,
hsvToRgb,
normalizeHex,
rgbToHex,
rgbToHsv,
updateRecentColors,
} from "./color-math.js";
import { setControlBusy, setControlReady } from "./interaction-feedback.js";
const localState = createLocalState("colors", 1);
const savedState = localState.load({});
let recentColors = Array.isArray(savedState.recentColors)
? savedState.recentColors.map(normalizeHex).filter(Boolean).slice(0, 10)
: [];
let mode = savedState.mode === "rgb" ? "rgb" : "hsv";
let palette = [];
let activeTrigger = null;
let originalColor = "#000000";
let currentRgb = hexToRgb(originalColor);
let currentHsv = rgbToHsv(currentRgb);
let paletteEditing = false;
let pickerReadonly = false;
const overlay = document.createElement("div");
overlay.id = "color-picker-overlay";
overlay.className = "color-picker-overlay";
overlay.hidden = true;
overlay.innerHTML = `
<section class="color-picker-panel" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
<header class="color-picker-header">
<div><p class="eyebrow">软件自有控件</p><h2 id="color-picker-title">选择颜色</h2></div>
<button class="compact-button" type="button" data-color-action="cancel" aria-label="关闭颜色选择器">关闭</button>
</header>
<div class="color-comparison" aria-label="原颜色与新颜色对比">
<div><span>原颜色</span><i id="color-original-preview"></i></div>
<div><span>新颜色</span><i id="color-current-preview"></i></div>
</div>
<div class="color-mode-tabs segmented" role="tablist" aria-label="颜色模式">
<button type="button" role="tab" data-color-mode="hsv">HSV</button>
<button type="button" role="tab" data-color-mode="rgb">RGB</button>
</div>
<div id="color-hsv-panel" class="color-mode-panel" role="tabpanel">
<div id="color-sv-field" class="color-sv-field" role="slider" tabindex="0" aria-label="饱和度和明度" aria-valuemin="0" aria-valuemax="100">
<span id="color-sv-handle" class="color-track-handle"></span>
</div>
<div id="color-hue-track" class="color-linear-track color-hue-track" role="slider" tabindex="0" aria-label="色相" aria-valuemin="0" aria-valuemax="360">
<span id="color-hue-handle" class="color-track-handle"></span>
</div>
<div class="color-number-grid">
<label>H<input id="color-h" type="number" min="0" max="360" inputmode="numeric"></label>
<label>S<input id="color-s" type="number" min="0" max="100" inputmode="numeric"></label>
<label>V<input id="color-v" type="number" min="0" max="100" inputmode="numeric"></label>
</div>
</div>
<div id="color-rgb-panel" class="color-mode-panel" role="tabpanel" hidden>
<div class="rgb-channel"><span>R</span><div class="color-linear-track" data-rgb-track="r" role="slider" tabindex="0" aria-label="红色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-r" type="number" min="0" max="255" inputmode="numeric"></div>
<div class="rgb-channel"><span>G</span><div class="color-linear-track" data-rgb-track="g" role="slider" tabindex="0" aria-label="绿色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-g" type="number" min="0" max="255" inputmode="numeric"></div>
<div class="rgb-channel"><span>B</span><div class="color-linear-track" data-rgb-track="b" role="slider" tabindex="0" aria-label="蓝色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-b" type="number" min="0" max="255" inputmode="numeric"></div>
</div>
<label class="color-hex-field">HEX<input id="color-hex" type="text" maxlength="7" autocapitalize="characters" spellcheck="false" inputmode="text"></label>
<section class="color-collection" aria-labelledby="recent-colors-title">
<div class="color-collection-heading"><h3 id="recent-colors-title">最近颜色</h3></div>
<div id="recent-colors" class="color-swatch-grid"></div>
</section>
<section class="color-collection" aria-labelledby="palette-colors-title">
<div class="color-collection-heading"><h3 id="palette-colors-title">设备收藏色</h3><div><button type="button" class="compact-button" data-color-action="favorite">收藏当前</button><button type="button" class="compact-button" data-color-action="edit-palette">编辑</button></div></div>
<div id="palette-colors" class="color-swatch-grid"></div>
<p id="palette-status" class="hint" aria-live="polite"></p>
</section>
<footer class="color-picker-actions">
<button type="button" data-color-action="cancel">取消</button>
<button type="button" class="primary-button" data-color-action="confirm">使用此颜色</button>
</footer>
</section>`;
document.body.appendChild(overlay);
const panel = overlay.querySelector(".color-picker-panel");
const hsvPanel = document.getElementById("color-hsv-panel");
const rgbPanel = document.getElementById("color-rgb-panel");
const svField = document.getElementById("color-sv-field");
const hueTrack = document.getElementById("color-hue-track");
const modeButtons = Array.from(overlay.querySelectorAll("[data-color-mode]"));
const rgbTracks = Array.from(overlay.querySelectorAll("[data-rgb-track]"));
function persistLocalColors() {
localState.save({ recentColors, mode });
}
function renderTrigger(trigger) {
const value = normalizeHex(trigger.value) || "#000000";
trigger.value = value;
trigger.querySelector(".color-trigger-swatch").style.background = value;
trigger.querySelector(".color-trigger-value").textContent = value;
}
function setMode(nextMode) {
mode = nextMode === "rgb" ? "rgb" : "hsv";
setActiveButton(modeButtons, modeButtons.find((button) => button.dataset.colorMode === mode));
modeButtons.forEach((button) => button.setAttribute("aria-selected", String(button.dataset.colorMode === mode)));
hsvPanel.hidden = mode !== "hsv";
rgbPanel.hidden = mode !== "rgb";
persistLocalColors();
}
function setCurrentRgb(rgb) {
if (pickerReadonly) return;
currentRgb = {
r: Math.round(clamp(rgb.r, 0, 255)),
g: Math.round(clamp(rgb.g, 0, 255)),
b: Math.round(clamp(rgb.b, 0, 255)),
};
currentHsv = rgbToHsv(currentRgb);
renderPicker();
}
function setCurrentHsv(hsv) {
if (pickerReadonly) return;
currentHsv = {
h: Math.round(clamp(hsv.h, 0, 360)),
s: Math.round(clamp(hsv.s, 0, 100)),
v: Math.round(clamp(hsv.v, 0, 100)),
};
currentRgb = hsvToRgb(currentHsv);
renderPicker();
}
function renderPicker() {
const hex = rgbToHex(currentRgb);
document.getElementById("color-original-preview").style.background = originalColor;
document.getElementById("color-current-preview").style.background = hex;
document.getElementById("color-hex").value = hex;
for (const channel of ["r", "g", "b"]) document.getElementById(`color-${channel}`).value = currentRgb[channel];
for (const channel of ["h", "s", "v"]) document.getElementById(`color-${channel}`).value = currentHsv[channel];
svField.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${currentHsv.h} 100% 50%))`;
const svHandle = document.getElementById("color-sv-handle");
svHandle.style.left = `${currentHsv.s}%`;
svHandle.style.top = `${100 - currentHsv.v}%`;
svField.setAttribute("aria-valuenow", String(currentHsv.s));
svField.setAttribute("aria-valuetext", `饱和度 ${currentHsv.s}%,明度 ${currentHsv.v}%`);
document.getElementById("color-hue-handle").style.left = `${currentHsv.h / 3.6}%`;
hueTrack.setAttribute("aria-valuenow", String(currentHsv.h));
rgbTracks.forEach((track) => {
const channel = track.dataset.rgbTrack;
const start = { ...currentRgb, [channel]: 0 };
const end = { ...currentRgb, [channel]: 255 };
track.style.background = `linear-gradient(to right, ${rgbToHex(start)}, ${rgbToHex(end)})`;
track.querySelector(".color-track-handle").style.left = `${(currentRgb[channel] / 255) * 100}%`;
track.setAttribute("aria-valuenow", String(currentRgb[channel]));
});
renderSwatches();
}
function swatchButton(color, label) {
const button = document.createElement("button");
button.type = "button";
button.className = "color-swatch";
button.style.background = color;
button.title = color;
button.setAttribute("aria-label", `${label} ${color}`);
button.addEventListener("click", () => setCurrentRgb(hexToRgb(color)));
return button;
}
function renderSwatches() {
const recentContainer = document.getElementById("recent-colors");
recentContainer.replaceChildren(...recentColors.map((color) => swatchButton(color, "最近颜色")));
if (!recentColors.length) recentContainer.textContent = "还没有最近颜色";
const paletteContainer = document.getElementById("palette-colors");
paletteContainer.replaceChildren(...palette.map((color) => {
const wrapper = document.createElement("span");
wrapper.className = "palette-swatch";
wrapper.appendChild(swatchButton(color, "收藏颜色"));
if (paletteEditing) {
const remove = document.createElement("button");
remove.type = "button";
remove.className = "palette-remove";
remove.textContent = "×";
remove.setAttribute("aria-label", `删除收藏颜色 ${color}`);
remove.addEventListener("click", () => deleteFavorite(color, remove));
wrapper.appendChild(remove);
}
return wrapper;
}));
if (!palette.length) paletteContainer.textContent = "收藏色板为空";
}
async function loadPalette() {
const status = document.getElementById("palette-status");
status.textContent = "正在读取设备收藏色…";
try {
const response = await apiJson("/api/colors/palette");
palette = response.colors.map(normalizeHex).filter(Boolean);
status.textContent = `${palette.length} / ${response.limit}`;
renderSwatches();
} catch (error) {
status.textContent = `收藏色读取失败:${error.message}`;
}
}
async function addFavorite(control) {
setControlBusy(control, { label: "正在收藏…", message: "正在收藏当前颜色…" });
try {
const response = await apiJson("/api/colors/palette", {
method: "POST",
body: JSON.stringify({ color: rgbToHex(currentRgb) }),
});
palette = response.colors;
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
renderSwatches();
} catch (error) {
announce(`收藏颜色失败:${error.message}`, true);
} finally {
setControlReady(control);
}
}
async function deleteFavorite(color, control) {
setControlBusy(control, { message: `正在删除收藏颜色 ${color}…` });
try {
const response = await apiJson(`/api/colors/palette/${color.slice(1)}`, { method: "DELETE" });
palette = response.colors;
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
renderSwatches();
} catch (error) {
announce(`删除收藏颜色失败:${error.message}`, true);
} finally {
setControlReady(control);
}
}
function trackRatio(event, track) {
const rect = track.getBoundingClientRect();
return clamp((event.clientX - rect.left) / rect.width, 0, 1);
}
function bindHorizontalTrack(track, update) {
let activePointer = null;
const move = (event) => {
if (activePointer !== event.pointerId) return;
update(trackRatio(event, track));
};
track.addEventListener("pointerdown", (event) => {
activePointer = event.pointerId;
track.setPointerCapture(event.pointerId);
update(trackRatio(event, track));
});
track.addEventListener("pointermove", move);
const finish = (event) => { if (activePointer === event.pointerId) activePointer = null; };
track.addEventListener("pointerup", finish);
track.addEventListener("pointercancel", finish);
}
bindHorizontalTrack(hueTrack, (ratio) => setCurrentHsv({ ...currentHsv, h: ratio * 360 }));
rgbTracks.forEach((track) => bindHorizontalTrack(track, (ratio) => {
setCurrentRgb({ ...currentRgb, [track.dataset.rgbTrack]: ratio * 255 });
}));
let svPointer = null;
function updateSvFromPointer(event) {
if (event.pointerId !== svPointer) return;
const rect = svField.getBoundingClientRect();
setCurrentHsv({
...currentHsv,
s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
v: clamp((1 - ((event.clientY - rect.top) / rect.height)) * 100, 0, 100),
});
}
svField.addEventListener("pointerdown", (event) => {
svPointer = event.pointerId;
svField.setPointerCapture(event.pointerId);
updateSvFromPointer(event);
});
svField.addEventListener("pointermove", updateSvFromPointer);
svField.addEventListener("pointerup", () => { svPointer = null; });
svField.addEventListener("pointercancel", () => { svPointer = null; });
function sliderKeyDelta(event, large = 10) {
if (["ArrowRight", "ArrowUp"].includes(event.key)) return event.shiftKey ? large : 1;
if (["ArrowLeft", "ArrowDown"].includes(event.key)) return event.shiftKey ? -large : -1;
if (event.key === "PageUp") return large;
if (event.key === "PageDown") return -large;
return 0;
}
hueTrack.addEventListener("keydown", (event) => {
const delta = sliderKeyDelta(event, 10);
if (!delta) return;
event.preventDefault();
setCurrentHsv({ ...currentHsv, h: clamp(currentHsv.h + delta, 0, 360) });
});
rgbTracks.forEach((track) => track.addEventListener("keydown", (event) => {
const delta = sliderKeyDelta(event, 10);
if (!delta) return;
event.preventDefault();
const channel = track.dataset.rgbTrack;
setCurrentRgb({ ...currentRgb, [channel]: currentRgb[channel] + delta });
}));
svField.addEventListener("keydown", (event) => {
const delta = event.shiftKey ? 10 : 1;
const next = { ...currentHsv };
if (event.key === "ArrowLeft") next.s -= delta;
else if (event.key === "ArrowRight") next.s += delta;
else if (event.key === "ArrowDown") next.v -= delta;
else if (event.key === "ArrowUp") next.v += delta;
else return;
event.preventDefault();
setCurrentHsv(next);
});
for (const channel of ["r", "g", "b"]) {
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
setCurrentRgb({ ...currentRgb, [channel]: event.target.value });
});
}
for (const channel of ["h", "s", "v"]) {
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
setCurrentHsv({ ...currentHsv, [channel]: event.target.value });
});
}
document.getElementById("color-hex").addEventListener("input", (event) => {
const hex = normalizeHex(event.target.value);
if (hex) setCurrentRgb(hexToRgb(hex));
});
function closePicker() {
overlay.hidden = true;
document.body.classList.remove("color-picker-open");
const trigger = activeTrigger;
activeTrigger = null;
trigger?.focus();
}
function confirmPicker() {
if (!activeTrigger || pickerReadonly) return;
setColorValue(activeTrigger, rgbToHex(currentRgb), { recordHistory: true });
closePicker();
}
function openPicker(trigger) {
activeTrigger = trigger;
pickerReadonly = trigger.dataset.colorReadonly === "true";
originalColor = normalizeHex(trigger.value) || "#000000";
currentRgb = hexToRgb(originalColor);
currentHsv = rgbToHsv(currentRgb);
paletteEditing = false;
overlay.querySelector("[data-color-action='edit-palette']").classList.remove("active");
setMode(mode);
renderPicker();
panel.classList.toggle("color-picker-readonly", pickerReadonly);
document.getElementById("color-picker-title").textContent = pickerReadonly ? "查看颜色" : "选择颜色";
panel.querySelectorAll(".color-collection, .color-picker-actions").forEach((element) => {
element.hidden = pickerReadonly;
});
for (const id of ["color-h", "color-s", "color-v", "color-r", "color-g", "color-b", "color-hex"]) {
document.getElementById(id).readOnly = pickerReadonly;
}
[svField, hueTrack, ...rgbTracks].forEach((track) => {
track.setAttribute("aria-disabled", String(pickerReadonly));
track.tabIndex = pickerReadonly ? -1 : 0;
});
overlay.hidden = false;
document.body.classList.add("color-picker-open");
if (!pickerReadonly) loadPalette();
window.requestAnimationFrame(() => panel.querySelector("[data-color-action='cancel']").focus());
}
export function setColorValue(trigger, value, { recordHistory = true } = {}) {
const color = normalizeHex(value);
if (!color) throw new TypeError("颜色必须是 #RRGGBB");
trigger.value = color;
renderTrigger(trigger);
if (recordHistory) {
recentColors = updateRecentColors(recentColors, color, 10);
persistLocalColors();
}
trigger.dispatchEvent(new Event("input", { bubbles: true }));
trigger.dispatchEvent(new Event("change", { bubbles: true }));
}
export function refreshColorTrigger(trigger) {
renderTrigger(trigger);
}
modeButtons.forEach((button) => button.addEventListener("click", () => setMode(button.dataset.colorMode)));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) closePicker();
const action = event.target.closest("[data-color-action]")?.dataset.colorAction;
if (action === "cancel") closePicker();
else if (action === "confirm") confirmPicker();
else if (action === "favorite") addFavorite(event.target.closest("[data-color-action='favorite']"));
else if (action === "edit-palette") {
paletteEditing = !paletteEditing;
event.target.classList.toggle("active", paletteEditing);
renderSwatches();
}
});
window.addEventListener("keydown", (event) => {
if (overlay.hidden) return;
if (event.key === "Escape") {
event.preventDefault();
closePicker();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(panel.querySelectorAll("button:not(:disabled), input:not(:disabled), [tabindex='0']"))
.filter((element) => !element.closest("[hidden]") && element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
document.querySelectorAll("[data-color-picker]").forEach((trigger) => {
renderTrigger(trigger);
trigger.addEventListener("click", () => openPicker(trigger));
});