初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user