427 lines
18 KiB
JavaScript
427 lines
18 KiB
JavaScript
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));
|
||
});
|