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 = ` `; 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)); });