初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,426 @@
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));
});