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

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,466 @@
import { apiJson } from "./core.js";
import { animationPreviewFrameAtElapsed } from "./animation-ui-model.js";
export const ANIMATION_PREVIEW_PAGE_SIZE = 500;
export const ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT = 2;
export const ANIMATION_PREVIEW_MIN_MAX_CONCURRENT = 1;
export const ANIMATION_PREVIEW_MAX_MAX_CONCURRENT = 50;
export const ANIMATION_PREVIEW_PRELOAD_COUNT = 3;
export function normalizeAnimationPreviewMaxConcurrent(value) {
if (!Number.isInteger(value)
|| value < ANIMATION_PREVIEW_MIN_MAX_CONCURRENT
|| value > ANIMATION_PREVIEW_MAX_MAX_CONCURRENT) {
throw new Error(
`动态缩略图并发数量必须是 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数`,
);
}
return value;
}
function stalePreviewError() {
const error = new Error("动图已在预览加载期间发生变化");
error.code = "ANIMATION_PREVIEW_STALE";
return error;
}
export async function loadCompleteAnimationPreview(
animation,
{ fetchJson = apiJson, signal, pageSize = ANIMATION_PREVIEW_PAGE_SIZE } = {},
) {
const expectedRevision = String(animation?.revision || "");
const expectedCount = Number(animation?.frame_count || 0);
if (!animation?.id || !expectedRevision) throw new Error("动图预览缺少标识或修订值");
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > ANIMATION_PREVIEW_PAGE_SIZE) {
throw new Error("动图预览分页大小无效");
}
if (expectedCount === 0) return [];
const frames = [];
let offset = 0;
let total = expectedCount;
while (offset < total) {
const query = new URLSearchParams({
frame_offset: String(offset),
frame_limit: String(pageSize),
});
const page = await fetchJson(`/api/animations/${animation.id}?${query}`, { signal });
if (String(page?.revision || "") !== expectedRevision) throw stalePreviewError();
if (!Array.isArray(page.frames)) throw new Error("动图预览帧列表无效");
total = Number(page.frame_total ?? page.frame_count ?? page.frames.length);
if (!Number.isInteger(total) || total < 0 || total !== expectedCount) throw stalePreviewError();
if (!page.frames.length && offset < total) throw new Error("动图预览分页提前结束");
frames.push(...page.frames);
offset += page.frames.length;
}
if (frames.length !== expectedCount) throw stalePreviewError();
return frames;
}
class AnimationPreviewEngine {
constructor() {
this.records = new Set();
this.initialQueue = [];
this.activeInitialLoads = 0;
this.queue = [];
this.activeLoads = 0;
this.maxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT;
this.timer = null;
this.cache = new Map();
this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
this.observer = typeof IntersectionObserver === "function"
? new IntersectionObserver((entries) => this.handleIntersections(entries), { rootMargin: "0px" })
: null;
this.viewportSyncPending = false;
this.handleFallbackViewport = () => {
if (this.observer || this.viewportSyncPending) return;
this.viewportSyncPending = true;
window.requestAnimationFrame(() => {
this.viewportSyncPending = false;
this.syncAll();
});
};
this.handleVisibility = () => this.syncAll();
this.handleMotionChange = () => this.syncAll();
document.addEventListener("visibilitychange", this.handleVisibility);
this.reducedMotion.addEventListener?.("change", this.handleMotionChange);
window.addEventListener("scroll", this.handleFallbackViewport, true);
window.addEventListener("resize", this.handleFallbackViewport);
}
cacheKey(animation) {
return `${animation.id}:${animation.revision}`;
}
setMaxConcurrent(value) {
const normalized = normalizeAnimationPreviewMaxConcurrent(value);
if (normalized === this.maxConcurrent) return normalized;
this.maxConcurrent = normalized;
this.syncAll();
return normalized;
}
createGroup() {
const engine = this;
const records = new Set();
return {
active: false,
register(options) {
const record = engine.register(this, options);
records.add(record);
return () => {
records.delete(record);
engine.disposeRecord(record);
engine.syncAll();
};
},
setActive(active) {
this.active = Boolean(active);
engine.syncAll();
},
clear() {
const keys = new Set();
for (const record of records) {
keys.add(engine.cacheKey(record.animation));
engine.disposeRecord(record);
}
records.clear();
for (const key of keys) engine.cache.delete(key);
engine.scheduleTick();
},
};
}
register(group, {
animation,
image,
initialUrl,
target = image,
onFrame = null,
onLoad = null,
onInitialError = null,
}) {
const record = {
group,
animation,
image,
initialUrl,
target,
onFrame,
onLoad,
onInitialError,
visible: false,
disposed: false,
initialQueued: false,
initialLoading: false,
initialReady: false,
initialFailed: false,
queued: false,
loading: false,
loadController: null,
schedule: null,
startedAt: null,
currentIndex: -1,
preloads: new Map(),
lastSuccessfulUrl: "",
restoring: false,
};
record.handleLoad = () => {
if (record.initialLoading) this.finishInitialLoad(record, true);
record.lastSuccessfulUrl = image.currentSrc || image.src;
record.restoring = false;
onLoad?.();
};
record.handleError = () => {
if (record.initialLoading) {
this.finishInitialLoad(record, false);
record.restoring = false;
onInitialError?.();
return;
}
const failedUrl = image.currentSrc || image.src;
if (record.lastSuccessfulUrl && failedUrl !== record.lastSuccessfulUrl && !record.restoring) {
record.restoring = true;
image.src = record.lastSuccessfulUrl;
return;
}
record.restoring = false;
onInitialError?.();
};
image.addEventListener("load", record.handleLoad);
image.addEventListener("error", record.handleError);
this.records.add(record);
this.observer?.observe(target);
this.syncAll();
return record;
}
disposeRecord(record) {
if (record.disposed) return;
record.disposed = true;
record.initialQueued = false;
if (record.initialLoading) {
record.initialLoading = false;
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
record.image.removeAttribute("src");
}
record.loadController?.abort();
record.preloads.clear();
record.image.removeEventListener("load", record.handleLoad);
record.image.removeEventListener("error", record.handleError);
this.observer?.unobserve(record.target);
this.records.delete(record);
this.pumpInitialQueue();
}
canLoadInitial(record) {
return !record.disposed
&& record.group.active
&& this.isRecordVisible(record)
&& document.visibilityState === "visible"
&& Boolean(record.initialUrl);
}
isRecordVisible(record) {
if (this.observer) return record.visible;
const rect = record.target.getBoundingClientRect();
return rect.width > 0
&& rect.height > 0
&& rect.right > 0
&& rect.bottom > 0
&& rect.left < window.innerWidth
&& rect.top < window.innerHeight;
}
syncInitial(record) {
if (!this.canLoadInitial(record)
|| record.initialReady
|| record.initialFailed
|| record.initialLoading
|| record.initialQueued) return;
record.initialQueued = true;
this.initialQueue.push(record);
}
pumpInitialQueue() {
while (this.activeInitialLoads < this.maxConcurrent
&& this.initialQueue.length) {
const record = this.initialQueue.shift();
record.initialQueued = false;
if (!this.canLoadInitial(record) || record.initialReady || record.initialFailed) continue;
record.initialLoading = true;
this.activeInitialLoads += 1;
record.image.src = record.initialUrl;
}
}
finishInitialLoad(record, succeeded) {
if (!record.initialLoading) return;
record.initialLoading = false;
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
record.initialReady = succeeded;
record.initialFailed = !succeeded;
this.pumpInitialQueue();
this.syncAll();
}
handleIntersections(entries) {
for (const entry of entries) {
for (const record of this.records) {
if (record.target !== entry.target) continue;
record.visible = entry.isIntersecting;
}
}
this.syncAll();
}
canRun(record) {
return !record.disposed
&& record.group.active
&& this.isRecordVisible(record)
&& document.visibilityState === "visible"
&& !this.reducedMotion.matches
&& Number(record.animation.frame_count) > 1;
}
shouldRun(record) {
let selected = 0;
for (const candidate of this.records) {
if (!this.canRun(candidate)) continue;
if (candidate === record) return selected < this.maxConcurrent;
selected += 1;
if (selected >= this.maxConcurrent) return false;
}
return false;
}
syncRecord(record) {
if (!this.shouldRun(record)) {
record.loadController?.abort();
record.loadController = null;
record.loading = false;
record.queued = false;
record.preloads.clear();
if (record.schedule) this.cache.delete(this.cacheKey(record.animation));
record.schedule = null;
record.startedAt = null;
record.currentIndex = -1;
return;
}
if (!record.initialReady) return;
if (record.schedule) return;
const cached = this.cache.get(this.cacheKey(record.animation));
if (cached) {
record.schedule = cached;
record.startedAt = performance.now();
return;
}
if (!record.loading && !record.queued) {
record.queued = true;
this.queue.push(record);
this.pumpQueue();
}
}
syncAll() {
for (const record of this.records) this.syncInitial(record);
this.pumpInitialQueue();
for (const record of this.records) this.syncRecord(record);
this.scheduleTick();
}
pumpQueue() {
while (this.activeLoads < this.maxConcurrent && this.queue.length) {
const record = this.queue.shift();
record.queued = false;
if (!this.shouldRun(record) || record.disposed || record.schedule) continue;
this.loadRecord(record);
}
}
async loadRecord(record) {
record.loading = true;
record.loadController = new AbortController();
this.activeLoads += 1;
try {
const frames = await loadCompleteAnimationPreview(record.animation, {
signal: record.loadController.signal,
});
if (record.disposed || !this.shouldRun(record)) return;
record.schedule = frames;
record.startedAt = performance.now();
this.cache.set(this.cacheKey(record.animation), frames);
} catch (error) {
if (error?.name !== "AbortError" && error?.code !== "ANIMATION_PREVIEW_STALE") {
// The first frame remains usable; a later refresh can retry the full schedule.
}
} finally {
record.loading = false;
record.loadController = null;
this.activeLoads -= 1;
this.pumpQueue();
this.scheduleTick();
}
}
preloadAhead(record, index) {
const wanted = new Set();
const length = record.schedule.length;
for (let step = 1; step <= ANIMATION_PREVIEW_PRELOAD_COUNT && step < length; step += 1) {
const nextIndex = (index + step) % length;
wanted.add(nextIndex);
if (!record.preloads.has(nextIndex)) {
const preload = new Image();
preload.decoding = "async";
preload.src = record.schedule[nextIndex].thumbnail_url;
record.preloads.set(nextIndex, preload);
}
}
for (const existing of record.preloads.keys()) {
if (!wanted.has(existing)) record.preloads.delete(existing);
}
}
scheduleTick() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
this.timer = null;
}
let nextDelay = Infinity;
const now = performance.now();
for (const record of this.records) {
if (!this.shouldRun(record) || !record.schedule?.length) continue;
if (record.startedAt === null) record.startedAt = now;
const selected = animationPreviewFrameAtElapsed(record.schedule, now - record.startedAt);
if (!selected) continue;
if (record.currentIndex !== selected.index) {
record.currentIndex = selected.index;
record.image.src = selected.frame.thumbnail_url;
record.onFrame?.({
frame: selected.frame,
index: selected.index,
total: record.schedule.length,
});
this.preloadAhead(record, selected.index);
}
nextDelay = Math.min(nextDelay, selected.remainingMs);
}
if (Number.isFinite(nextDelay)) {
this.timer = window.setTimeout(() => {
this.timer = null;
this.scheduleTick();
}, Math.max(16, Math.ceil(nextDelay)));
}
}
}
let sharedEngine = null;
function getSharedEngine() {
if (!sharedEngine) sharedEngine = new AnimationPreviewEngine();
return sharedEngine;
}
export function createAnimationPreviewGroup() {
return getSharedEngine().createGroup();
}
export function setAnimationPreviewMaxConcurrent(value) {
return getSharedEngine().setMaxConcurrent(value);
}
export function createAnimationThumbnailBox(group, animation) {
const imageBox = document.createElement("div");
imageBox.className = "template-image-box";
if (!animation.thumbnail_url || !animation.frame_count) {
imageBox.classList.add("template-image-empty");
imageBox.setAttribute("aria-label", "空动图");
return imageBox;
}
const image = document.createElement("img");
image.className = "template-image";
image.alt = `${animation.name} 动图缩略图`;
image.width = 64;
image.height = 64;
image.draggable = false;
image.loading = "lazy";
imageBox.append(image);
group.register({
animation,
image,
initialUrl: animation.thumbnail_url,
target: imageBox,
onLoad: () => {
image.hidden = false;
imageBox.classList.remove("template-image-error");
imageBox.removeAttribute("aria-label");
},
onInitialError: () => {
image.hidden = true;
imageBox.classList.add("template-image-error");
imageBox.setAttribute("aria-label", "缩略图加载失败");
},
});
return imageBox;
}
@@ -0,0 +1,82 @@
export const MIN_FRAME_DURATION_MS = 50;
export const MAX_FRAME_DURATION_MS = 604800000;
export function effectiveFrameName(animationName, frame, position = frame?.position) {
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
if (customName) return customName;
return `${String(animationName || "动图").trim() || "动图"}-第${position}帧`;
}
export function animationEditContextName(animationName, frame, position = frame?.position) {
const normalizedAnimationName = String(animationName || "动图").trim() || "动图";
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
return `${normalizedAnimationName}-${customName || `第${position}帧`}`;
}
export function animationPreviewFrameAtElapsed(frames, elapsedMs) {
if (!Array.isArray(frames) || !frames.length) return null;
const durations = frames.map((frame) => Number(frame?.duration_ms));
if (durations.some((duration) => !Number.isFinite(duration) || duration <= 0)) return null;
const totalDurationMs = durations.reduce((total, duration) => total + duration, 0);
const normalizedElapsed = Number.isFinite(Number(elapsedMs)) ? Number(elapsedMs) : 0;
const cycleElapsedMs = ((normalizedElapsed % totalDurationMs) + totalDurationMs) % totalDurationMs;
let boundary = 0;
for (let index = 0; index < frames.length; index += 1) {
boundary += durations[index];
if (cycleElapsedMs < boundary) {
return {
frame: frames[index],
index,
remainingMs: boundary - cycleElapsedMs,
totalDurationMs,
};
}
}
return null;
}
export function formatFrameDuration(durationMs, unit = "ms") {
const duration = Number(durationMs);
if (!Number.isInteger(duration)) return "";
if (unit === "ms") return String(duration);
if (unit !== "s") throw new Error("unknown animation duration unit");
return (duration / 1000).toFixed(3).replace(/\.?0+$/, "");
}
export function validateFrameDuration(value, unit = "ms") {
const normalized = typeof value === "string" ? value.trim() : String(value ?? "").trim();
const millisecondsPattern = /^\d+$/;
const secondsPattern = /^\d+(?:\.\d{1,3})?$/;
if (!normalized || (unit === "ms" ? !millisecondsPattern.test(normalized) : !secondsPattern.test(normalized))) {
return {
valid: false,
duration: null,
error: unit === "ms"
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
};
}
const numeric = Number(normalized);
const duration = unit === "s" ? numeric * 1000 : numeric;
if (!Number.isInteger(duration) || duration < MIN_FRAME_DURATION_MS || duration > MAX_FRAME_DURATION_MS) {
return {
valid: false,
duration: null,
error: unit === "ms"
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
};
}
return { valid: true, duration, error: "" };
}
export function reorderSelectedFrameIds(frameIds, selectedFrameIds, insertAfterFrameId = null) {
if (!Array.isArray(frameIds) || !Array.isArray(selectedFrameIds) || !selectedFrameIds.length) return null;
const selected = new Set(selectedFrameIds);
if (selected.size !== selectedFrameIds.length || selectedFrameIds.some((id) => !frameIds.includes(id))) return null;
if (insertAfterFrameId !== null && (!frameIds.includes(insertAfterFrameId) || selected.has(insertAfterFrameId))) return null;
const moving = frameIds.filter((id) => selected.has(id));
const remaining = frameIds.filter((id) => !selected.has(id));
const insertAt = insertAfterFrameId === null ? 0 : remaining.indexOf(insertAfterFrameId) + 1;
return [...remaining.slice(0, insertAt), ...moving, ...remaining.slice(insertAt)];
}
+379
View File
@@ -0,0 +1,379 @@
import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js";
import "./interaction-feedback.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js";
import { setBoardWorkspace } from "./scene-board.js";
import { renderUiCopy } from "./ui-copy-runtime.js";
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
import {
ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT,
setAnimationPreviewMaxConcurrent,
} from "./animation-thumbnail-preview.js";
import "./color-picker.js";
import "./views/canvas.js";
import { refreshStatus } from "./views/device.js";
import "./views/text.js";
import "./views/media-import.js";
import "./views/animations.js";
import "./views/templates.js";
import "./views/settings.js";
const drawer = document.getElementById("workspace-drawer");
const backdrop = document.getElementById("drawer-backdrop");
const menuButton = document.getElementById("drawer-toggle");
const navigation = document.getElementById("workspace-nav");
const workspaceOrderToggle = document.getElementById("workspace-order-toggle");
const workspaceOrderFeedback = document.getElementById("workspace-order-feedback");
const currentTitle = document.getElementById("current-workspace-title");
const saveTemplateButton = document.getElementById("save-template");
const definitions = getWorkspaces();
const buttons = new Map();
let activeWorkspace = null;
let savedWorkspaceOrder = definitions.map((definition) => definition.id);
let draftWorkspaceOrder = null;
let workspaceOrderEditing = false;
let workspaceOrderBusy = false;
let workspaceOrderLoadPromise = null;
let statusPollPending = false;
const loadedAppVersion = document.querySelector('meta[name="app-version"]')?.content || null;
function setWorkspaceOrderToggleLabel() {
if (workspaceOrderEditing) {
renderUiCopy("copy.dynamic.workspace_order.save", workspaceOrderToggle, "保存");
} else {
renderUiCopy("copy.dynamic.workspace_order.edit", workspaceOrderToggle, "自定义项目位置");
}
}
function setWorkspaceOrderFeedback(copyId, defaultText, values = {}, state = "ready") {
renderUiCopy(copyId, workspaceOrderFeedback, defaultText, values);
workspaceOrderFeedback.hidden = false;
workspaceOrderFeedback.dataset.state = state;
workspaceOrderFeedback.setAttribute("role", state === "error" ? "alert" : "status");
announce(workspaceOrderFeedback.textContent, state === "error");
}
function clearWorkspaceOrderFeedback() {
workspaceOrderFeedback.hidden = true;
delete workspaceOrderFeedback.dataset.state;
}
function orderedDefinitions(orderIds) {
return resolveWorkspaceOrder(definitions, orderIds);
}
function buildNavigation() {
const orderIds = workspaceOrderEditing ? draftWorkspaceOrder : savedWorkspaceOrder;
const ordered = orderedDefinitions(orderIds);
navigation.replaceChildren();
navigation.classList.toggle("is-order-editing", workspaceOrderEditing);
buttons.clear();
ordered.forEach((definition, index) => {
const row = document.createElement("div");
row.className = "nav-item-row";
row.dataset.workspace = definition.id;
const button = document.createElement("button");
button.type = "button";
button.className = "nav-item";
button.dataset.workspace = definition.id;
button.textContent = definition.title;
if (workspaceOrderEditing) {
button.setAttribute("aria-disabled", "true");
button.dataset.unavailableReason = "请先保存项目位置";
} else {
button.addEventListener("click", () => {
navigate(definition.id);
requestCloseDrawer(true);
});
}
if (activeWorkspace?.id === definition.id) button.setAttribute("aria-current", "page");
row.appendChild(button);
buttons.set(definition.id, button);
if (workspaceOrderEditing) {
const actions = document.createElement("div");
actions.className = "nav-order-actions";
const moveUp = document.createElement("button");
moveUp.type = "button";
moveUp.className = "nav-order-action";
moveUp.dataset.orderAction = "up";
moveUp.dataset.workspace = definition.id;
moveUp.setAttribute("aria-label", `上移${definition.title}`);
renderUiCopy("copy.dynamic.workspace_order.move_up", moveUp, "上移");
moveUp.addEventListener("click", () => moveDraftWorkspace(index, -1, "up"));
const moveDown = document.createElement("button");
moveDown.type = "button";
moveDown.className = "nav-order-action";
moveDown.dataset.orderAction = "down";
moveDown.dataset.workspace = definition.id;
moveDown.setAttribute("aria-label", `下移${definition.title}`);
renderUiCopy("copy.dynamic.workspace_order.move_down", moveDown, "下移");
moveDown.addEventListener("click", () => moveDraftWorkspace(index, 1, "down"));
actions.append(moveUp, moveDown);
row.appendChild(actions);
if (index === 0) setControlUnavailable(moveUp, "当前已经是第一项,不能继续上移。");
if (index === ordered.length - 1) {
setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
}
}
navigation.appendChild(row);
});
}
function moveDraftWorkspace(index, offset, action) {
const moved = moveWorkspaceOrder(draftWorkspaceOrder, index, offset);
if (!moved) return;
const workspaceId = draftWorkspaceOrder[index];
draftWorkspaceOrder = moved;
clearWorkspaceOrderFeedback();
buildNavigation();
Array.from(navigation.querySelectorAll(`[data-order-action="${action}"]`))
.find((button) => button.dataset.workspace === workspaceId)
?.focus();
}
async function loadWorkspaceOrder() {
if (workspaceOrderLoadPromise) return workspaceOrderLoadPromise;
navigation.setAttribute("aria-busy", "true");
workspaceOrderLoadPromise = apiJson("/api/config")
.then((config) => {
try {
setAnimationPreviewMaxConcurrent(config.animation_preview_max_concurrent);
} catch {
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
}
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
if (!workspaceOrderEditing) buildNavigation();
return savedWorkspaceOrder;
})
.catch((error) => {
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
throw error;
})
.finally(() => {
navigation.removeAttribute("aria-busy");
workspaceOrderLoadPromise = null;
});
return workspaceOrderLoadPromise;
}
async function beginWorkspaceOrderEdit() {
if (workspaceOrderBusy || workspaceOrderEditing) return;
workspaceOrderBusy = true;
setControlBusy(workspaceOrderToggle);
renderUiCopy("copy.dynamic.workspace_order.loading", workspaceOrderToggle, "正在读取…");
try {
await loadWorkspaceOrder();
draftWorkspaceOrder = [...savedWorkspaceOrder];
workspaceOrderEditing = true;
clearWorkspaceOrderFeedback();
buildNavigation();
} catch (error) {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.load_failed",
"项目位置读取失败:{reason}",
{ reason: error.message },
"error",
);
} finally {
workspaceOrderBusy = false;
setControlReady(workspaceOrderToggle);
setWorkspaceOrderToggleLabel();
}
}
async function saveWorkspaceOrder() {
if (workspaceOrderBusy || !workspaceOrderEditing || !draftWorkspaceOrder) return;
workspaceOrderBusy = true;
setControlBusy(workspaceOrderToggle);
renderUiCopy("copy.dynamic.workspace_order.saving", workspaceOrderToggle, "正在保存…");
try {
const config = await apiJson("/api/config", {
method: "PUT",
body: JSON.stringify({ workspace_order: draftWorkspaceOrder }),
});
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
draftWorkspaceOrder = null;
workspaceOrderEditing = false;
buildNavigation();
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.saved",
"项目位置已保存",
{},
"success",
);
} catch (error) {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.save_failed",
"项目位置保存失败:{reason}",
{ reason: error.message },
"error",
);
} finally {
workspaceOrderBusy = false;
setControlReady(workspaceOrderToggle);
setWorkspaceOrderToggleLabel();
}
}
function remindWorkspaceOrderSave() {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.save_required",
"请先保存项目位置",
);
workspaceOrderToggle.focus();
}
function resolveWorkspaceId() {
const hashId = decodeURIComponent(window.location.hash.replace(/^#/, ""));
return definitions.some((item) => item.id === hashId) ? hashId : DEFAULT_WORKSPACE_ID;
}
function navigate(id, updateHash = true) {
const next = definitions.find((item) => item.id === id)
|| definitions.find((item) => item.id === DEFAULT_WORKSPACE_ID)
|| definitions[0];
if (!next || activeWorkspace?.id === next.id) return;
if (activeWorkspace) {
document.getElementById(activeWorkspace.elementId).hidden = true;
buttons.get(activeWorkspace.id)?.removeAttribute("aria-current");
activeWorkspace.onLeave?.();
}
activeWorkspace = next;
const element = document.getElementById(next.elementId);
element.hidden = false;
setBoardWorkspace(next.id);
saveTemplateButton.hidden = !next.capabilities.includes("scene-template-save");
currentTitle.textContent = next.title;
buttons.get(next.id)?.setAttribute("aria-current", "page");
document.title = `${next.title} · 奇妙小屏幕控制器`;
Promise.resolve(next.onEnter?.()).catch((error) => {
console.error(`工作区 ${next.id} 初始化失败`, error);
const message = `${next.title}初始化失败:${error.message}`;
const feedback = element.querySelector("[aria-live]");
if (feedback) {
feedback.textContent = message;
feedback.dataset.state = "error";
}
announce(message, true);
});
if (updateHash && window.location.hash !== `#${next.id}`) {
history.pushState(null, "", `#${next.id}`);
}
window.scrollTo({ top: 0, behavior: "auto" });
}
function openDrawer() {
drawer.classList.add("open");
drawer.removeAttribute("inert");
drawer.setAttribute("aria-hidden", "false");
backdrop.hidden = false;
document.body.classList.add("drawer-open");
menuButton.setAttribute("aria-expanded", "true");
menuButton.setAttribute("aria-label", "关闭功能菜单");
if (!workspaceOrderEditing && !workspaceOrderBusy) {
loadWorkspaceOrder().catch((error) => {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.load_failed",
"项目位置读取失败:{reason}",
{ reason: error.message },
"error",
);
});
}
}
function closeDrawer(restoreFocus = false) {
drawer.classList.remove("open");
drawer.setAttribute("inert", "");
drawer.setAttribute("aria-hidden", "true");
backdrop.hidden = true;
document.body.classList.remove("drawer-open");
menuButton.setAttribute("aria-expanded", "false");
menuButton.setAttribute("aria-label", "打开功能菜单");
if (restoreFocus) menuButton.focus();
}
function requestCloseDrawer(restoreFocus = false) {
if (workspaceOrderEditing || workspaceOrderBusy) {
remindWorkspaceOrderSave();
return false;
}
closeDrawer(restoreFocus);
return true;
}
workspaceOrderToggle.addEventListener("click", () => {
if (workspaceOrderEditing) saveWorkspaceOrder();
else beginWorkspaceOrderEdit();
});
menuButton.addEventListener("click", () => {
if (drawer.classList.contains("open")) {
requestCloseDrawer(true);
} else {
openDrawer();
}
});
backdrop.addEventListener("click", () => requestCloseDrawer(true));
window.addEventListener("hashchange", () => navigate(resolveWorkspaceId(), false));
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && drawer.classList.contains("open")) {
event.preventDefault();
requestCloseDrawer(true);
return;
}
if (event.key === "Tab" && drawer.classList.contains("open")) {
const focusable = [menuButton, ...Array.from(drawer.querySelectorAll("button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled)"))]
.filter((element) => 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();
}
}
});
buildNavigation();
setWorkspaceOrderToggleLabel();
navigate(resolveWorkspaceId(), false);
loadWorkspaceOrder().catch((error) => {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.load_failed",
"项目位置读取失败:{reason}",
{ reason: error.message },
"error",
);
});
document.getElementById("reload-app").addEventListener("click", () => window.location.reload());
async function pollStatus() {
if (statusPollPending || document.visibilityState !== "visible") return;
statusPollPending = true;
try {
const status = await refreshStatus({ source: "poll" });
const currentVersion = status.service?.app_version;
if (loadedAppVersion && currentVersion && currentVersion !== loadedAppVersion) {
markAppStale();
}
} catch (_error) {
// The device workspace reports persistent failures when the user opens it.
} finally {
statusPollPending = false;
}
}
window.setInterval(pollStatus, 5000);
window.addEventListener("focus", pollStatus);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") pollStatus();
});
pollStatus();
@@ -0,0 +1,174 @@
export const BRUSH_MODE_NORMAL = "normal";
export const BRUSH_MODE_PIXEL = "pixel";
export const CANVAS_VIEW_MIN_SCALE = 1;
export const CANVAS_VIEW_MAX_SCALE = 8;
export const CANVAS_UNDO_LIMIT = 50;
const MODE_SPECS = Object.freeze({
[BRUSH_MODE_NORMAL]: Object.freeze({ min: 1, max: 16, presets: Object.freeze([1, 2, 4, 8]) }),
[BRUSH_MODE_PIXEL]: Object.freeze({ min: 1, max: 4, presets: Object.freeze([1, 2, 3, 4]) }),
});
function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
export function brushModeSpec(mode) {
return MODE_SPECS[mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL];
}
export function normalizeBrushSize(value, mode) {
const spec = brushModeSpec(mode);
const parsed = Number.parseInt(value, 10);
return Math.max(spec.min, Math.min(spec.max, Number.isFinite(parsed) ? parsed : spec.min));
}
export function restoreCanvasToolState(savedV2, savedV1 = null) {
const current = record(savedV2);
const legacy = record(savedV1);
const source = current || legacy || {};
return {
paintColor: typeof source.paintColor === "string" ? source.paintColor : null,
backgroundColor: typeof source.backgroundColor === "string" ? source.backgroundColor : null,
tool: source.tool === "eraser" ? "eraser" : "brush",
pixelEditEnabled: Boolean(current?.pixelEditEnabled),
normalBrushSize: normalizeBrushSize(
current?.normalBrushSize ?? source.brushSize,
BRUSH_MODE_NORMAL,
),
pixelBrushSize: normalizeBrushSize(current?.pixelBrushSize, BRUSH_MODE_PIXEL),
};
}
export function linePoints(from, to) {
const points = [];
let x = from.x;
let y = from.y;
const dx = Math.abs(to.x - from.x);
const sx = from.x < to.x ? 1 : -1;
const dy = -Math.abs(to.y - from.y);
const sy = from.y < to.y ? 1 : -1;
let error = dx + dy;
while (true) {
points.push({ x, y });
if (x === to.x && y === to.y) break;
const doubled = 2 * error;
if (doubled >= dy) {
error += dy;
x += sx;
}
if (doubled <= dx) {
error += dx;
y += sy;
}
}
return points;
}
export function brushStampPoints(centerX, centerY, size, mode, width = 64, height = 64) {
const normalizedMode = mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
const diameter = normalizeBrushSize(size, normalizedMode);
const start = -Math.floor((diameter - 1) / 2);
const radius = Math.max(0.25, (diameter - 0.5) / 2);
const shapeCenter = (diameter - 1) / 2;
const points = [];
for (let localY = 0; localY < diameter; localY += 1) {
for (let localX = 0; localX < diameter; localX += 1) {
if (normalizedMode === BRUSH_MODE_NORMAL) {
const normalizedX = (localX - shapeCenter) / radius;
const normalizedY = (localY - shapeCenter) / radius;
if ((normalizedX * normalizedX) + (normalizedY * normalizedY) > 1) continue;
}
const x = centerX + start + localX;
const y = centerY + start + localY;
if (x >= 0 && x < width && y >= 0 && y < height) points.push({ x, y });
}
}
return points;
}
export function pixelBuffersEqual(left, right) {
if (!left || !right || left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
export class PixelUndoHistory {
constructor(limit = CANVAS_UNDO_LIMIT) {
this.limit = Math.max(1, Number.isInteger(limit) ? limit : CANVAS_UNDO_LIMIT);
this.entries = [];
}
get size() {
return this.entries.length;
}
record(before, after) {
if (pixelBuffersEqual(before, after)) return false;
this.entries.push(Uint8Array.from(before));
if (this.entries.length > this.limit) this.entries.splice(0, this.entries.length - this.limit);
return true;
}
undo() {
return this.entries.pop() || null;
}
clear() {
this.entries.length = 0;
}
}
export function normalizeCanvasScale(value) {
const scale = Number(value);
if (!Number.isFinite(scale)) return CANVAS_VIEW_MIN_SCALE;
return Math.max(CANVAS_VIEW_MIN_SCALE, Math.min(CANVAS_VIEW_MAX_SCALE, scale));
}
export function clampCanvasView(
view,
{ viewportWidth, viewportHeight, baseSize, minVisible = 48 },
) {
const scale = normalizeCanvasScale(view?.scale);
const width = Math.max(1, Number(baseSize) || 1) * scale;
const height = width;
const visibleX = Math.min(Math.max(1, Number(minVisible) || 1), width, viewportWidth);
const visibleY = Math.min(Math.max(1, Number(minVisible) || 1), height, viewportHeight);
const limitX = Math.max(0, (viewportWidth + width) / 2 - visibleX);
const limitY = Math.max(0, (viewportHeight + height) / 2 - visibleY);
return {
scale,
x: Math.max(-limitX, Math.min(limitX, Number(view?.x) || 0)),
y: Math.max(-limitY, Math.min(limitY, Number(view?.y) || 0)),
};
}
export function zoomCanvasView(view, nextScale, anchor, bounds) {
const scale = normalizeCanvasScale(nextScale);
const previousScale = normalizeCanvasScale(view?.scale);
const centerX = bounds.viewportWidth / 2;
const centerY = bounds.viewportHeight / 2;
const ratio = scale / previousScale;
const x = (anchor.x - centerX) - ratio * (anchor.x - centerX - (Number(view?.x) || 0));
const y = (anchor.y - centerY) - ratio * (anchor.y - centerY - (Number(view?.y) || 0));
return clampCanvasView({ scale, x, y }, bounds);
}
export function clampFloatingButton(position, {
viewportWidth,
viewportHeight,
size = 56,
inset = 8,
}) {
const half = size / 2;
const minX = inset + half;
const maxX = Math.max(minX, viewportWidth - inset - half);
const minY = inset + half;
const maxY = Math.max(minY, viewportHeight - inset - half);
return {
x: Math.max(minX, Math.min(maxX, Number(position?.x) || minX)),
y: Math.max(minY, Math.min(maxY, Number(position?.y) || minY)),
};
}
@@ -0,0 +1,76 @@
export function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value)));
}
export function normalizeHex(value) {
const match = /^#?([0-9a-f]{6})$/i.exec(String(value).trim());
return match ? `#${match[1].toUpperCase()}` : null;
}
export function rgbToHex({ r, g, b }) {
return `#${[r, g, b]
.map((channel) => Math.round(clamp(channel, 0, 255)).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
}
export function hexToRgb(value) {
const hex = normalizeHex(value);
if (!hex) throw new TypeError("颜色必须是 #RRGGBB");
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
export function rgbToHsv({ r, g, b }) {
const red = clamp(r, 0, 255) / 255;
const green = clamp(g, 0, 255) / 255;
const blue = clamp(b, 0, 255) / 255;
const maximum = Math.max(red, green, blue);
const minimum = Math.min(red, green, blue);
const delta = maximum - minimum;
let hue = 0;
if (delta !== 0) {
if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
else hue = 60 * ((red - green) / delta + 4);
}
if (hue < 0) hue += 360;
return {
h: Math.round(hue),
s: Math.round(maximum === 0 ? 0 : (delta / maximum) * 100),
v: Math.round(maximum * 100),
};
}
export function hsvToRgb({ h, s, v }) {
const hue = ((Number(h) % 360) + 360) % 360;
const saturation = clamp(s, 0, 100) / 100;
const value = clamp(v, 0, 100) / 100;
const chroma = value * saturation;
const segment = hue / 60;
const secondary = chroma * (1 - Math.abs((segment % 2) - 1));
let channels = [0, 0, 0];
if (segment < 1) channels = [chroma, secondary, 0];
else if (segment < 2) channels = [secondary, chroma, 0];
else if (segment < 3) channels = [0, chroma, secondary];
else if (segment < 4) channels = [0, secondary, chroma];
else if (segment < 5) channels = [secondary, 0, chroma];
else channels = [chroma, 0, secondary];
const match = value - chroma;
return {
r: Math.round((channels[0] + match) * 255),
g: Math.round((channels[1] + match) * 255),
b: Math.round((channels[2] + match) * 255),
};
}
export function updateRecentColors(colors, value, limit = 10) {
const normalized = normalizeHex(value);
if (!normalized) return Array.isArray(colors) ? colors.slice(0, limit) : [];
const existing = Array.isArray(colors)
? colors.map(normalizeHex).filter(Boolean).filter((color) => color !== normalized)
: [];
return [normalized, ...existing].slice(0, limit);
}
@@ -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));
});
+198
View File
@@ -0,0 +1,198 @@
const workspaces = new Map();
const PERSISTENCE_POLICIES = new Set(["none", "local", "server"]);
let appStale = false;
export const DEFAULT_API_TIMEOUT_MS = 15000;
export const DEFAULT_WORKSPACE_ID = "device";
export function isAppStale() {
return appStale;
}
export function markAppStale() {
if (appStale) return;
appStale = true;
document.getElementById("app-version-notice")?.removeAttribute("hidden");
announce("控制程序已更新,请刷新页面后再操作设备", true);
document.dispatchEvent(new CustomEvent("matrix:app-stale"));
}
function ensureWriteAllowed(path, options) {
const method = String(options.method || "GET").toUpperCase();
const isWrite = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
if (appStale && isWrite && !path.startsWith("/api/preview/")) {
const error = new Error("页面版本已过期,请刷新后再提交此操作");
error.status = 409;
error.code = "APP_STALE";
throw error;
}
}
export function registerWorkspace(definition) {
const required = ["id", "title", "elementId", "order", "persistence"];
for (const key of required) {
if (definition[key] === undefined || definition[key] === null || definition[key] === "") {
throw new Error(`工作区缺少 ${key}`);
}
}
if (workspaces.has(definition.id)) {
throw new Error(`工作区 ${definition.id} 重复注册`);
}
if (!PERSISTENCE_POLICIES.has(definition.persistence)) {
throw new Error(`工作区 ${definition.id} 的持久化策略无效`);
}
const capabilities = definition.capabilities || [];
if (!Array.isArray(capabilities) || capabilities.some((item) => typeof item !== "string")) {
throw new Error(`工作区 ${definition.id} 的能力声明无效`);
}
workspaces.set(definition.id, Object.freeze({
...definition,
capabilities: Object.freeze([...new Set(capabilities)]),
}));
}
export function getWorkspaces() {
return Array.from(workspaces.values()).sort((left, right) => (
left.order - right.order
|| left.title.localeCompare(right.title, "zh-CN")
));
}
async function fetchWithTimeout(path, options) {
const { timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
let timedOut = false;
const forwardAbort = () => controller.abort(externalSignal?.reason);
if (externalSignal?.aborted) forwardAbort();
else externalSignal?.addEventListener("abort", forwardAbort, { once: true });
const timer = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await fetch(path, { ...fetchOptions, signal: controller.signal });
} catch (error) {
if (timedOut) {
const timeoutError = new Error("请求超时,请检查核桃派网络或服务状态后重试");
timeoutError.code = "REQUEST_TIMEOUT";
throw timeoutError;
}
throw error;
} finally {
window.clearTimeout(timer);
externalSignal?.removeEventListener("abort", forwardAbort);
}
}
export async function apiJson(path, options = {}) {
ensureWriteAllowed(path, options);
const response = await fetchWithTimeout(path, {
...options,
cache: "no-store",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.detail || response.statusText);
error.status = response.status;
error.detail = data.detail;
throw error;
}
return data;
}
export async function apiBlob(path, options = {}) {
ensureWriteAllowed(path, options);
const response = await fetchWithTimeout(path, {
...options,
cache: "no-store",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
if (!response.ok) {
let message = response.statusText;
try {
const data = await response.json();
message = data.detail || message;
} catch (_error) {
// Keep the HTTP status text when the error body is not JSON.
}
const error = new Error(message);
error.status = response.status;
throw error;
}
return response.blob();
}
export async function apiFile(path, file, options = {}) {
ensureWriteAllowed(path, options);
const response = await fetchWithTimeout(path, {
...options,
method: options.method || "POST",
body: file,
cache: "no-store",
headers: { "Content-Type": "application/octet-stream", ...(options.headers || {}) },
});
let data = null;
try {
data = await response.json();
} catch (_error) {
data = null;
}
if (!response.ok) {
const error = new Error(data?.detail || response.statusText);
error.status = response.status;
error.detail = data?.detail;
throw error;
}
return data;
}
export function announce(text, isError = false) {
const message = document.getElementById("last-message");
message.textContent = text;
message.classList.toggle("message-error", isError);
message.setAttribute("role", isError ? "alert" : "status");
}
export function createLocalState(namespace, version) {
const key = `matrixController:${namespace}:v${version}`;
return {
load(fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (_error) {
return fallback;
}
},
save(value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (_error) {
announce("浏览器无法保存当前编辑草稿", true);
}
},
};
}
export function setActiveButton(buttons, activeButton) {
buttons.forEach((button) => {
const active = button === activeButton;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
export function debounce(callback, wait) {
let timer = null;
const wrapped = (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => callback(...args), wait);
};
wrapped.flush = (...args) => {
window.clearTimeout(timer);
timer = null;
callback(...args);
};
return wrapped;
}
@@ -0,0 +1,78 @@
export const CURRENT_DISPLAY_SPEEDS = Object.freeze([0.5, 1, 1.5, 2]);
export function formatPlaybackTime(valueMs) {
const milliseconds = Math.max(0, Math.floor(Number(valueMs) || 0));
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
const hundredths = Math.floor((milliseconds % 1000) / 10);
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`;
}
export function projectPlaybackPosition(playback, elapsedMs = 0) {
if (!playback?.active || !Number.isFinite(Number(playback.total_duration_ms))) return null;
const total = Math.max(1, Number(playback.total_duration_ms));
const base = Math.max(0, Number(playback.position_ms) || 0);
if (playback.paused) return Math.min(total - 1, base);
const speed = Number(playback.speed) || 1;
const projected = base + Math.max(0, Number(elapsedMs) || 0) * speed;
return Math.min(total - 1, projected % total);
}
export class LatestSeekDispatcher {
constructor(send, {
intervalMs = 100,
now = () => performance.now(),
setTimer = (callback, delay) => window.setTimeout(callback, delay),
clearTimer = (timer) => window.clearTimeout(timer),
onError = () => {},
} = {}) {
this.send = send;
this.intervalMs = intervalMs;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this.onError = onError;
this.inFlight = false;
this.lastStartedAt = Number.NEGATIVE_INFINITY;
this.queued = null;
this.timer = null;
}
enqueue(positionMs, { flush = false } = {}) {
this.queued = { positionMs, flush: flush || Boolean(this.queued?.flush) };
this.#drain();
}
clear() {
this.queued = null;
if (this.timer !== null) this.clearTimer(this.timer);
this.timer = null;
}
#drain() {
if (this.inFlight || this.queued === null) return;
const waitMs = this.intervalMs - (this.now() - this.lastStartedAt);
if (!this.queued.flush && waitMs > 0) {
if (this.timer === null) {
this.timer = this.setTimer(() => {
this.timer = null;
this.#drain();
}, waitMs);
}
return;
}
if (this.timer !== null) this.clearTimer(this.timer);
this.timer = null;
const item = this.queued;
this.queued = null;
this.inFlight = true;
this.lastStartedAt = this.now();
Promise.resolve()
.then(() => this.send(item.positionMs))
.catch((error) => this.onError(error, item.positionMs))
.finally(() => {
this.inFlight = false;
this.#drain();
});
}
}
@@ -0,0 +1,207 @@
const SOURCE_ORDER = Object.freeze(["automatic", "imported", "system"]);
const SOURCE_LABELS = Object.freeze({
automatic: "自动字体",
imported: "已导入字体",
system: "系统字体",
});
export function normalizeFontItems(items) {
const seen = new Set();
const normalized = [];
for (const raw of Array.isArray(items) ? items : []) {
if (!raw || typeof raw.id !== "string" || !raw.id || seen.has(raw.id)) continue;
if (!SOURCE_ORDER.includes(raw.source)) continue;
seen.add(raw.id);
const family = String(raw.family || "未命名字体").trim() || "未命名字体";
const style = String(raw.style || "Regular").trim() || "Regular";
normalized.push(Object.freeze({
id: raw.id,
label: String(raw.label || family).trim() || family,
family,
style,
source: raw.source,
}));
}
return normalized;
}
export function filterFontItems(items, query = "") {
const needle = String(query).trim().toLocaleLowerCase("zh-CN");
if (!needle) return [...items];
return items.filter((item) => (
`${item.label} ${item.family} ${item.style}`.toLocaleLowerCase("zh-CN").includes(needle)
));
}
export function groupFontItems(items) {
return SOURCE_ORDER
.map((source) => ({
source,
label: SOURCE_LABELS[source],
items: items.filter((item) => item.source === source),
}))
.filter((group) => group.items.length);
}
export function createFontPicker({ input, listbox, onCommit }) {
let items = [];
let selectedId = "default";
let visibleItems = [];
let activeIndex = -1;
let enabled = false;
let closeTimer = null;
function selectedItem() {
return items.find((item) => item.id === selectedId) || null;
}
function selectedLabel() {
return selectedItem()?.label || "当前字体不可用,将自动回退";
}
function restoreLabel() {
input.value = selectedLabel();
input.classList.toggle("font-reference-missing", !selectedItem());
}
function close({ restore = true } = {}) {
window.clearTimeout(closeTimer);
listbox.hidden = true;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
activeIndex = -1;
if (restore) restoreLabel();
}
function optionId(index) {
return `${listbox.id}-option-${index}`;
}
function setActive(index) {
if (!visibleItems.length) {
activeIndex = -1;
input.removeAttribute("aria-activedescendant");
return;
}
activeIndex = Math.max(0, Math.min(index, visibleItems.length - 1));
input.setAttribute("aria-activedescendant", optionId(activeIndex));
listbox.querySelectorAll('[role="option"]').forEach((option, optionIndex) => {
const active = optionIndex === activeIndex;
option.classList.toggle("active", active);
if (active) option.scrollIntoView({ block: "nearest" });
});
}
function commit(item) {
if (!item) return;
selectedId = item.id;
restoreLabel();
close({ restore: false });
onCommit?.(item);
}
function render(query = "") {
visibleItems = filterFontItems(items, query);
const groups = groupFontItems(visibleItems);
const nodes = [];
let optionIndex = 0;
for (const group of groups) {
const heading = document.createElement("div");
heading.className = "font-option-group";
heading.textContent = group.label;
heading.setAttribute("role", "presentation");
nodes.push(heading);
for (const item of group.items) {
const option = document.createElement("div");
option.id = optionId(optionIndex);
option.className = "font-option";
option.setAttribute("role", "option");
option.setAttribute("aria-selected", String(item.id === selectedId));
option.setAttribute("aria-label", item.label);
option.dataset.fontId = item.id;
const label = document.createElement("span");
label.textContent = item.family;
const source = document.createElement("small");
source.textContent = item.style;
source.setAttribute("aria-hidden", "true");
option.append(label, source);
option.addEventListener("pointerdown", (event) => event.preventDefault());
option.addEventListener("click", () => commit(item));
nodes.push(option);
optionIndex += 1;
}
}
if (!visibleItems.length) {
const empty = document.createElement("div");
empty.className = "font-options-empty";
empty.textContent = "没有匹配的字体";
nodes.push(empty);
}
listbox.replaceChildren(...nodes);
activeIndex = visibleItems.findIndex((item) => item.id === selectedId);
if (activeIndex < 0 && visibleItems.length) activeIndex = 0;
setActive(activeIndex);
}
function open(query = "") {
if (!enabled) return;
render(query);
listbox.hidden = false;
input.setAttribute("aria-expanded", "true");
}
input.addEventListener("focus", () => {
if (!enabled) return;
input.select();
open();
});
input.addEventListener("click", () => open());
input.addEventListener("input", (event) => {
event.stopPropagation();
open(input.value);
});
input.addEventListener("keydown", (event) => {
if (!enabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (listbox.hidden) open();
setActive(activeIndex + (event.key === "ArrowDown" ? 1 : -1));
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
if (listbox.hidden) open();
setActive(event.key === "Home" ? 0 : visibleItems.length - 1);
} else if (event.key === "Enter" && !listbox.hidden) {
event.preventDefault();
commit(visibleItems[activeIndex]);
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
});
input.addEventListener("blur", () => {
closeTimer = window.setTimeout(() => close(), 0);
});
restoreLabel();
return Object.freeze({
setItems(nextItems) {
items = normalizeFontItems(nextItems);
restoreLabel();
if (!listbox.hidden) render();
},
setValue(identifier) {
selectedId = typeof identifier === "string" && identifier ? identifier : "default";
restoreLabel();
},
getValue() {
return selectedId;
},
setEnabled(value) {
enabled = Boolean(value);
input.readOnly = !enabled;
if (!enabled) close();
},
open,
close,
});
}
@@ -0,0 +1,943 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#111318">
<title>奇妙小屏幕控制器</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-shell">
<button id="drawer-toggle" class="drawer-toggle" type="button" aria-controls="workspace-drawer" aria-expanded="false" aria-label="打开功能菜单">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="drawer-toggle-menu-icon" d="M4 7h16M4 12h16M4 17h16"></path>
<path class="drawer-toggle-close-icon" d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
<aside id="workspace-drawer" class="drawer" aria-label="功能选择" aria-hidden="true" inert>
<div class="drawer-header">
<div>
<p class="eyebrow" data-ui-copy-default-text="64 × 64 点阵屏"></p>
<p class="brand">奇妙小屏幕控制器</p>
</div>
</div>
<div class="workspace-order-editor">
<button id="workspace-order-toggle" class="workspace-order-toggle compact-button" type="button">自定义项目位置</button>
<p id="workspace-order-feedback" class="workspace-order-feedback" role="status" aria-live="polite" hidden></p>
</div>
<nav id="workspace-nav" class="workspace-nav" aria-label="工作区"></nav>
<p class="drawer-note" data-ui-copy-default-text="局域网设备控制台"></p>
</aside>
<button id="drawer-backdrop" class="drawer-backdrop" type="button" aria-label="关闭功能菜单" hidden></button>
<div class="app-main">
<header class="topbar">
<div class="topbar-copy">
<h1 id="current-workspace-title">设备状态和测试</h1>
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
</div>
<div class="topbar-statuses" aria-label="设备实时状态">
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
当前画面:正在读取…
</button>
<button id="topbar-cpu" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看 CPU 占用详情">
CPU -
</button>
<button id="topbar-memory" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看内存占用详情">
RAM -
</button>
<div
id="topbar-low-voltage-warning"
class="low-voltage-warning"
data-mode="disabled"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></div>
<div id="topbar-voltage" class="voltage-indicator" data-ui-copy-value data-status="starting" role="status" aria-live="polite">
正在读取电压…
</div>
</div>
</header>
<section id="app-version-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
<div>
<strong>控制程序已更新</strong>
<p>当前页面版本已过期。画板仍可编辑和导出,但刷新前不会再向设备或模板提交操作。</p>
</div>
<button id="reload-app" class="primary-button" type="button">刷新到新版本</button>
</section>
<section id="draft-conflict-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
<div>
<strong>另一标签页修改了画板草稿</strong>
<p>请选择载入另一标签页的最新草稿,或明确保留当前标签页的草稿。</p>
</div>
<div class="sync-notice-actions">
<button id="load-external-draft" type="button">载入其他标签草稿</button>
<button id="keep-current-draft" class="primary-button" type="button">保留当前草稿</button>
</div>
</section>
<section id="board-shell" class="board-shell" aria-labelledby="board-title">
<div id="standard-board-shell">
<div class="board-heading">
<div>
<p id="board-eyebrow" class="eyebrow" data-ui-copy-value>64 × 64 统一画板</p>
<h2 id="board-title" data-ui-copy-value>合成预览</h2>
</div>
<div class="board-heading-context">
<span id="board-mode" class="orientation-label" data-ui-copy-value>正向 0° · 统一画板</span>
<section id="animation-edit-notice" class="animation-edit-notice" aria-live="polite" hidden>
<strong id="animation-edit-name">正在编辑:</strong>
<span id="animation-edit-status">已保存</span>
</section>
</div>
</div>
<div id="board-stage" class="board-stage">
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
</div>
<p id="board-status" class="board-status" aria-live="polite">正在恢复画板草稿…</p>
<div class="board-actions">
<button id="apply-composition" class="primary-button" type="button">应用统一画板</button>
<button id="exit-animation-edit" type="button" hidden>退出动图编辑</button>
<button id="export-composition" type="button">导出合成 PNG</button>
<button id="save-template" type="button" hidden>保存为模板</button>
</div>
<div id="canvas-edit-assistive" class="canvas-edit-assistive" hidden>
<button id="canvas-assistive-toggle" class="canvas-assistive-toggle" type="button"
aria-controls="canvas-assistive-menu" aria-expanded="false" aria-label="打开画布编辑菜单">
<span aria-hidden="true"></span>
</button>
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden>
<p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
<button id="exit-canvas-edit" type="button" role="menuitem">退出编辑</button>
<button id="move-canvas-view" type="button" role="menuitem">移动画布视线</button>
<button id="continue-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="当前已经是绘画模式。">继续编辑</button>
</div>
</div>
</div>
<section id="animation-preview-shell" class="animation-preview-shell" aria-labelledby="animation-preview-shell-title" hidden>
<h2 id="animation-preview-shell-title" class="sr-only">动图与当前画板预览</h2>
<article class="animation-preview-card">
<p class="eyebrow">已保存内容</p>
<h3>当前动图</h3>
<div class="animation-small-preview" aria-label="当前打开动图的循环预览">
<img id="animation-preview-image" alt="" width="64" height="64" hidden>
<span id="animation-preview-placeholder">请先打开一个动图</span>
</div>
<p id="animation-preview-status" class="animation-preview-status" aria-live="polite">请先打开一个动图</p>
</article>
<article class="animation-preview-card">
<p class="eyebrow">新建帧来源</p>
<h3>当前画板</h3>
<div class="animation-small-preview">
<canvas id="animation-board-preview" width="64" height="64" aria-label="当前统一画板预览"></canvas>
</div>
<p class="animation-preview-status">“从当前画板新建帧”将复制此画面</p>
</article>
</section>
</section>
<main id="workspace-root" class="workspace-root">
<section id="workspace-device" class="workspace" aria-label="设备状态和测试" hidden>
<div class="device-refresh-row">
<button id="refresh-status" class="secondary-button" type="button">刷新状态</button>
</div>
<article class="panel status-panel">
<div class="panel-heading">
<h3>屏幕状态</h3>
<span class="status-badge">仅为程序设定值,屏幕实际情况软件无法得知</span>
</div>
<dl class="status-grid status-grid-compact">
<div><dt>屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)</dt><dd id="status-orientation" data-ui-copy-value>-</dd></div>
<div><dt>亮度(实际亮度会受到电压保护功能限制)</dt><dd id="status-brightness" data-ui-copy-value>-</dd></div>
</dl>
</article>
<article class="panel">
<h3>纯色测试</h3>
<div class="choice-grid color-choices" role="group" aria-label="纯色测试选择">
<button type="button" data-test-fill="#000000">全黑(不亮)</button>
<button type="button" data-test-fill="#FF0000">全红</button>
<button type="button" data-test-fill="#00FF00">全绿</button>
<button type="button" data-test-fill="#0000FF">全蓝</button>
<button type="button" data-test-fill="#FFFFFF">全白(高亮度会导致严重耗电)</button>
<button id="custom-test-fill" type="button">自定义纯色</button>
</div>
<div class="color-field"><span>颜色 RGB / HEX</span>
<button id="test-color" class="color-trigger" type="button" value="#40A0FF" data-color-picker data-color-readonly="true" aria-label="查看测试颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#40A0FF</span>
</button>
</div>
<div id="display-test-controls" class="display-test-controls" hidden>
<div class="panel-heading">
<label for="test-brightness">测试亮度(此亮度独立于屏幕亮度)</label>
<output id="test-brightness-value" data-ui-copy-value for="test-brightness" class="brightness-output">50%</output>
</div>
<input id="test-brightness" class="brightness-slider" type="range" min="1" max="100" value="50">
<p id="test-brightness-protection" class="brightness-protection-status" role="status" aria-live="polite" hidden></p>
<p id="display-test-feedback" class="settings-feedback" aria-live="polite" hidden></p>
<button id="exit-display-test" class="danger-button" type="button">退出测试</button>
</div>
</article>
<article class="panel device-storage-panel">
<div class="panel-heading"><h3>设备容量</h3></div>
<section class="template-storage device-storage-grid" aria-label="设备与软件空间">
<div><span>设备总空间</span><strong id="device-total-space" data-ui-copy-value>-</strong></div>
<div><span>设备可用空间</span><strong id="device-free-space" data-ui-copy-value>-</strong></div>
<div><span>当前软件总占用空间</span><strong id="device-software-size" data-ui-copy-value>-</strong></div>
<div><span>模板占用空间</span><strong id="device-template-size" data-ui-copy-value>-</strong></div>
</section>
<p id="device-storage-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</article>
</section>
<section id="workspace-canvas" class="workspace" aria-labelledby="canvas-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="canvas-title" aria-label="像素画布" data-ui-copy-default-text="像素画布"></h2>
</div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
</div>
<article class="panel canvas-workbench">
<div class="canvas-toolbar">
<div class="color-field"><span>画笔颜色</span>
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button>
</div>
<div class="color-field"><span>背景颜色</span>
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
</button>
</div>
<div class="segmented canvas-tools" role="group" aria-label="绘制工具">
<button type="button" data-canvas-tool="brush">画笔</button>
<button type="button" data-canvas-tool="eraser">橡皮</button>
<button type="button" data-canvas-tool="eyedropper">吸管</button>
</div>
<div class="brush-size-control">
<div class="brush-size-heading"><span>笔尖粗细</span><output id="brush-size-value" data-ui-copy-value for="brush-size">1 px</output></div>
<div class="brush-size-row">
<span id="brush-size-preview" class="brush-size-preview" aria-hidden="true"></span>
<input id="brush-size" type="range" min="1" max="16" value="1" aria-label="画笔和橡皮粗细">
</div>
<div class="brush-size-presets" role="group" aria-label="笔尖粗细快捷选择">
<button type="button" data-brush-size="1">1</button><button type="button" data-brush-size="2">2</button>
<button type="button" data-brush-size="4">4</button><button type="button" data-brush-size="8">8</button>
</div>
<button id="pixel-edit-toggle" class="pixel-edit-toggle" type="button" aria-pressed="false" aria-describedby="pixel-edit-status">开启单像素编辑</button>
<p id="pixel-edit-status" class="pixel-edit-status" aria-live="polite" hidden></p>
</div>
</div>
<div class="choice-grid canvas-actions">
<button id="fill-canvas" type="button">填充背景</button>
<button id="clear-canvas" type="button">清空像素层</button>
</div>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section>
<section id="workspace-text" class="workspace" aria-labelledby="text-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="text-title" aria-label="文字显示" data-ui-copy-default-text="文字显示"></h2>
</div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
</div>
<article class="panel text-elements-panel">
<div class="panel-heading">
<div>
<h3>文字元素</h3>
<p id="text-selection-status" class="subtle">新建文字或从列表选择一个元素</p>
</div>
<span class="status-badge" data-ui-copy-default-text="Pillow"></span>
</div>
<div class="text-element-actions">
<button id="create-text" class="primary-button" type="button">新建文字</button>
<button id="duplicate-text" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">复制选中</button>
<button id="delete-text" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">删除选中</button>
</div>
<div id="text-element-list" class="text-element-list" role="list" aria-label="文字元素列表"></div>
</article>
<form id="text-form" class="panel text-form" autocomplete="off">
<fieldset id="text-content-fields" disabled>
<label class="wide-field">文字内容
<textarea id="text-value" rows="2" maxlength="512"></textarea>
</label>
</fieldset>
<div class="font-field wide-field">
<label id="text-font-label" for="text-font">字体</label>
<div class="font-control-row">
<div class="font-combobox-shell">
<input id="text-font" type="search" role="combobox" value="自动多语言字体 — 自动回退"
aria-labelledby="text-font-label" aria-autocomplete="list" aria-expanded="false"
aria-controls="text-font-options" autocapitalize="off" autocomplete="off" spellcheck="false">
<div id="text-font-options" class="font-options" role="listbox" aria-label="可用字体" hidden></div>
</div>
<button id="import-font" type="button">导入字体</button>
<input id="font-file" class="sr-only" type="file" accept=".ttf,.otf,.ttc,.otc">
</div>
<span class="hint">选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件;缺字时会自动回退。</span>
<p id="font-feedback" class="font-feedback" role="status" aria-live="polite" hidden></p>
</div>
<fieldset id="text-fields" disabled>
<div class="form-grid">
<label>字号
<input id="text-size" type="number" min="1" max="64" value="12" inputmode="numeric">
</label>
<label>X 坐标
<input id="text-x" type="number" min="-128" max="128" value="4" inputmode="numeric">
</label>
<label>Y 坐标
<input id="text-y" type="number" min="-128" max="128" value="24" inputmode="numeric">
</label>
<label>对齐
<select id="text-align">
<option value="left">左对齐</option>
<option value="center">居中</option>
<option value="right">右对齐</option>
</select>
</label>
<div class="color-field"><span>文字颜色</span>
<button id="text-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择文字颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button>
</div>
</div>
</fieldset>
</form>
</section>
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="media-import-title" data-ui-copy-default-text="媒体内容转换"></h2>
</div>
<button id="refresh-media-imports" class="secondary-button" type="button" data-ui-copy-default-text="刷新任务"></button>
</div>
<form id="media-upload-form" class="panel media-upload-panel">
<div class="panel-heading">
<h3 data-ui-copy-default-text="导入图片、动图或视频"></h3>
</div>
<div class="form-grid">
<label class="media-source-field"><span data-ui-copy-default-text="源文件"></span>
<input id="media-source-file" type="file" required
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,image/heic,image/heif,image/bmp,image/tiff,image/jp2,video/*,.mkv,.m4v,.mpeg,.mpg">
</label>
</div>
<button id="media-upload-button" class="primary-button" type="submit">上传文件</button>
<progress id="media-upload-progress" max="100" value="0" hidden></progress>
</form>
<p id="media-import-feedback" class="settings-feedback" aria-live="polite"
data-ui-copy-default-text="选择文件并填写保存名称。"></p>
<div id="media-import-jobs" class="media-job-list" aria-live="polite"></div>
</section>
<section id="workspace-animations" class="workspace" aria-labelledby="animations-title" hidden>
<div class="workspace-heading">
<div><p class="eyebrow" data-ui-copy-default-text="内容"></p><h2 id="animations-title">动图管理</h2></div>
<button id="refresh-animations" class="secondary-button" type="button">刷新</button>
</div>
<div class="animation-toolbar panel">
<button id="create-animation" class="primary-button" type="button">新建动图</button>
<button id="add-animation-frame" type="button" aria-disabled="true" data-unavailable-reason="请先新建或打开一个动图,再把当前画板添加为帧。">从当前画板新建帧</button>
</div>
<p id="animation-feedback" class="settings-feedback" aria-live="polite">请选择或新建动图。</p>
<section id="animation-frame-panel" class="panel" hidden>
<div class="panel-heading"><h3 id="animation-frame-title">当前动图</h3></div>
<div id="animation-batch-toolbar" class="animation-batch-toolbar" aria-label="动图帧批量操作">
<p id="animation-selection-status" aria-live="polite">未选择帧</p>
<div class="animation-batch-actions">
<button id="animation-batch-copy" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量复制</button>
<button id="animation-batch-move" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量移动</button>
<button id="animation-batch-delete" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量删除</button>
</div>
</div>
<div id="animation-frames" class="template-grid" aria-label="动图帧"></div>
<nav id="animation-frame-pagination" class="animation-frame-pagination" aria-label="动图帧分页" hidden>
<button id="animation-page-prev" type="button">上一页</button>
<p id="animation-page-status" aria-live="polite">第 1 / 1 页 · 共 0 帧</p>
<button id="animation-page-next" type="button">下一页</button>
</nav>
</section>
<section class="animation-existing" aria-labelledby="animation-existing-title">
<div class="panel-heading"><h3 id="animation-existing-title">保存的动图</h3></div>
<div id="animation-folders" class="animation-folder-list" aria-label="已有动图"></div>
</section>
</section>
<section id="workspace-templates" class="workspace" aria-labelledby="templates-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="templates-title">模板管理</h2>
</div>
<div class="workspace-heading-actions">
<button id="select-default-content" class="secondary-button" type="button" aria-pressed="false">设置默认显示内容</button>
<button id="refresh-templates" class="secondary-button" type="button">刷新</button>
</div>
</div>
<p id="template-feedback" class="settings-feedback" aria-live="polite">正在读取模板…</p>
<div id="template-grid" class="template-grid" aria-label="已保存模板"></div>
</section>
<section id="workspace-settings" class="workspace" aria-labelledby="settings-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="设备"></p>
<h2 id="settings-title">系统设置</h2>
</div>
</div>
<article class="panel setting-card">
<div>
<div class="panel-heading">
<div>
<h3>屏幕亮度</h3>
<p class="hint">拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)</p>
</div>
<output id="brightness-value" data-ui-copy-value for="config-brightness" class="brightness-output">40%</output>
</div>
<input id="config-brightness" class="brightness-slider" type="range" min="1" max="100" value="40">
<p
id="brightness-protection-status"
class="brightness-protection-status"
data-mode="inactive"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></p>
</div>
</article>
<article class="panel setting-card">
<div>
<h3>屏幕方向</h3>
<p class="hint">角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。</p>
</div>
<div class="orientation-grid" role="group" aria-label="屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)">
<button type="button" data-orientation="0">0°</button>
<button type="button" data-orientation="90">90°</button>
<button type="button" data-orientation="180">180°</button>
<button type="button" data-orientation="270">270°</button>
</div>
</article>
<article class="panel setting-card">
<div>
<h3>屏幕扫描刷新率</h3>
<p class="hint">设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。</p>
</div>
<div id="matrix-refresh-rate-grid" class="refresh-rate-grid" role="group" aria-label="屏幕扫描刷新率上限">
<button type="button" data-matrix-refresh-rate="15">15 Hz</button>
<button type="button" data-matrix-refresh-rate="20">20 Hz</button>
<button type="button" data-matrix-refresh-rate="30">30 Hz</button>
<button type="button" data-matrix-refresh-rate="45">45 Hz</button>
<button type="button" data-matrix-refresh-rate="60">60 Hz</button>
<button type="button" data-matrix-refresh-rate="80">80 Hz</button>
<button type="button" data-matrix-refresh-rate="100">100 Hz</button>
</div>
<p class="hint" data-ui-copy-default-text="这里设置的是扫描上限,不是保证精确达到的实时测量值,也不同于下方网页预览刷新间隔。"></p>
</article>
<article class="panel setting-card voltage-setting-card">
<div>
<h3>供电电压</h3>
<p class="hint">设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。</p>
</div>
<dl class="voltage-details">
<div><dt>当前电压</dt><dd id="voltage-current" data-ui-copy-value>-</dd></div>
<div><dt>传感器状态</dt><dd id="voltage-sensor-status" data-ui-copy-value>正在读取</dd></div>
<div><dt>校准状态</dt><dd id="voltage-calibration-status" data-ui-copy-value>未校准</dd></div>
<div><dt>校准时间</dt><dd id="voltage-calibrated-at" data-ui-copy-value>-</dd></div>
</dl>
<div class="voltage-actions">
<button id="start-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="正在读取电压传感器状态,请稍候。">校准电压传感器</button>
<button id="reset-voltage-calibration" type="button">恢复标称值</button>
</div>
<div class="low-voltage-protection-setting">
<div class="low-voltage-protection-copy">
<h3>低电压亮度保护</h3>
<p id="low-voltage-protection-description" class="hint">开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度</p>
</div>
<label class="switch-control" for="low-voltage-protection-enabled">
<input
id="low-voltage-protection-enabled"
type="checkbox"
role="switch"
aria-label="低电压亮度保护"
aria-describedby="low-voltage-protection-description low-voltage-protection-feedback"
aria-disabled="true"
data-unavailable-reason="正在读取保护开关状态,请稍候。"
>
<span class="switch-track" aria-hidden="true"></span>
<span id="low-voltage-protection-switch-state" class="switch-state">关闭</span>
</label>
</div>
<p
id="low-voltage-protection-feedback"
class="low-voltage-protection-feedback"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></p>
</article>
<article class="panel setting-card">
<div>
<h3>预览刷新间隔</h3>
<p class="hint">设置设备状态和系统设置中当前屏幕画面的刷新速度。</p>
</div>
<label class="number-unit-field" for="preview-refresh-interval">
<input
id="preview-refresh-interval"
type="number"
min="1"
max="60000"
step="1"
inputmode="numeric"
value="1000"
>
<span>毫秒</span>
</label>
<p id="preview-refresh-warning" class="preview-refresh-warning" role="status" hidden>
刷新间隔过短,会增加核桃派负载,可能影响系统运行。
</p>
<p id="preview-refresh-error" class="error-text" role="alert" hidden></p>
</article>
<article class="panel setting-card wifi-setting-card">
<div>
<h3>WiFi设置</h3>
<p class="hint">设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip</p>
</div>
<div class="wifi-fields">
<label>WiFi 名称(SSID)
<input id="wifi-ssid" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-ssid-modified" required>
<span id="wifi-ssid-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>WiFi 密码
<input id="wifi-password" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-password-modified" placeholder="读取中…">
<span id="wifi-password-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<div class="wifi-dhcp-row">
<div>
<strong>自动获取 IP(DHCP)</strong>
<p id="wifi-dhcp-description" class="hint">关闭后可指定固定 IPv4 地址。</p>
</div>
<label class="switch-control" for="wifi-dhcp-enabled">
<input id="wifi-dhcp-enabled" type="checkbox" role="switch" aria-describedby="wifi-dhcp-description wifi-dhcp-modified" checked>
<span class="switch-track" aria-hidden="true"></span>
<span id="wifi-dhcp-state" class="switch-state">开启</span>
</label>
<p id="wifi-dhcp-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</p>
</div>
<div id="wifi-manual-fields" class="wifi-manual-fields" hidden>
<label>静态 IPv4 地址
<input id="wifi-address" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-address-modified" placeholder="192.168.1.50">
<span id="wifi-address-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>网关
<input id="wifi-gateway" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-gateway-modified" placeholder="192.168.1.1">
<span id="wifi-gateway-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>子网前缀(选填)
<input id="wifi-prefix" data-ui-copy-ignore type="number" min="1" max="32" step="1" inputmode="numeric" aria-describedby="wifi-prefix-modified" placeholder="24">
<span id="wifi-prefix-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>DNS(选填)
<input id="wifi-dns" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-dns-modified" placeholder="留空使用网关,多个用逗号分隔">
<span id="wifi-dns-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
</div>
<p id="wifi-active-status" class="hint" role="status">正在读取当前网络…</p>
</div>
<div class="wifi-actions">
<button id="wifi-apply-immediate" type="button">立即生效(会导致网页断开)</button>
<button id="wifi-apply-next-boot" type="button">下次开机生效</button>
</div>
<p id="wifi-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</article>
<article class="panel setting-card wifi-prompt-setting-card">
<div>
<h3>网络提示显示</h3>
<p class="hint">设置设备开机后等待多少秒再显示网络连接提示。</p>
</div>
<div class="wifi-prompt-delay-row">
<label class="number-unit-field" for="wifi-prompt-delay">
<input id="wifi-prompt-delay" type="number" min="1" max="3600" step="1" inputmode="numeric" value="30">
<span>秒后显示网络提示</span>
</label>
<button id="wifi-prompt-delay-save" type="button">保存</button>
</div>
<p id="wifi-prompt-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</article>
<article class="panel setting-card ota-setting-card">
<div>
<h3>软件版本</h3>
</div>
<dl class="ota-version-details">
<div><dt>当前软件版本</dt><dd id="ota-current-version" data-ui-copy-value>读取中…</dd></div>
<div><dt>最新功能更新时间</dt><dd id="ota-feature-updated-at" data-ui-copy-value>读取中…</dd></div>
</dl>
<input id="ota-file-input" class="sr-only" type="file" accept=".ota,application/octet-stream">
<div class="ota-actions">
<button id="ota-select-file" class="primary-button" type="button">导入 OTA 更新</button>
<span class="ota-power-warning">不要在更新期间断电。</span>
</div>
<div id="ota-progress-group" class="ota-progress-group" hidden>
<div class="ota-progress-heading">
<strong id="ota-progress-stage" data-ui-copy-value>准备更新</strong>
<span id="ota-progress-value" data-ui-copy-value>0%</span>
</div>
<progress id="ota-progress" max="100" value="0">0%</progress>
</div>
<p id="ota-feedback" class="settings-feedback" role="status" aria-live="polite" aria-atomic="true" hidden></p>
<div id="ota-failure-log-actions" class="ota-failure-log-actions" hidden>
<button id="ota-view-failure-log" type="button">查看失败日志</button>
<button id="ota-copy-failure-log" type="button">复制日志</button>
</div>
</article>
<article class="panel setting-card">
<div class="low-voltage-protection-setting">
<div class="low-voltage-protection-copy">
<h3>性能模式</h3>
<p id="performance-mode-description" class="hint">如果使用出现卡顿,可以打开此选项,但会降低续航。</p>
</div>
<label class="switch-control" for="performance-mode-enabled">
<input
id="performance-mode-enabled"
type="checkbox"
role="switch"
aria-label="性能模式"
aria-describedby="performance-mode-description performance-mode-feedback"
aria-disabled="true"
data-unavailable-reason="正在读取 CPU 调频状态,请稍候。"
>
<span class="switch-track" aria-hidden="true"></span>
<span id="performance-mode-switch-state" class="switch-state">关闭</span>
</label>
</div>
<p
id="performance-mode-feedback"
class="low-voltage-protection-feedback"
role="status"
aria-live="polite"
aria-atomic="true"
>正在读取 CPU 调频状态…</p>
</article>
<article class="panel setting-card">
<div>
<h3>模板界面同时播放的动图数量</h3>
<p id="animation-preview-concurrency-warning" class="hint">不建议开启太多,否则会出现严重的性能问题。</p>
</div>
<label class="number-unit-field" for="animation-preview-max-concurrent">
<input
id="animation-preview-max-concurrent"
type="number"
min="1"
max="50"
step="1"
inputmode="numeric"
value="2"
aria-describedby="animation-preview-concurrency-warning animation-preview-concurrency-feedback"
>
<span>个</span>
</label>
<p
id="animation-preview-concurrency-feedback"
class="settings-feedback"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></p>
</article>
<article class="panel setting-card frp-setting-card">
<div>
<h3>frp配置</h3>
<p class="hint">此功能通常是远程排查使用,请保证你知道你在做什么。</p>
</div>
<dl class="frp-status-details">
<div><dt>当前配置</dt><dd id="frp-selected-name">无</dd></div>
<div><dt>运行状态</dt><dd id="frp-service-state">关闭</dd></div>
</dl>
<p id="frp-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
<div class="frp-actions">
<button id="frp-service-toggle" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先导入或新建 FRP 配置。">启动frp</button>
<button id="frp-edit-config" type="button">编辑配置文件</button>
<button id="network-diagnostics-run" type="button">网络检测</button>
</div>
</article>
<p id="settings-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</section>
</main>
</div>
</div>
<dialog id="template-name-dialog" class="template-dialog">
<form method="dialog">
<div class="template-dialog-heading">
<h2 id="template-dialog-title">保存为模板</h2>
<p id="template-dialog-note" hidden></p>
</div>
<label>模板名称
<input id="template-name-input" type="text" maxlength="80" autocomplete="off" required>
</label>
<div class="template-dialog-actions">
<button type="submit" value="cancel" formnovalidate>取消</button>
<button class="primary-button" type="submit" value="save">确认</button>
</div>
</form>
</dialog>
<dialog id="template-confirm-dialog" class="template-dialog">
<form method="dialog">
<h2>确认模板操作</h2>
<p id="template-confirm-message"></p>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button class="primary-button" type="submit" value="confirm">确认</button>
</div>
</form>
</dialog>
<dialog id="template-save-choice-dialog" class="template-dialog">
<form method="dialog">
<h2>保存模板</h2>
<p id="template-save-choice-message">请选择保存方式。</p>
<div class="template-dialog-actions template-save-choice-actions">
<button type="submit" value="cancel">取消</button>
<button id="template-save-new" type="submit" value="new">另存为新模板</button>
<button id="template-save-current" class="primary-button" type="submit" value="current">保存到当前模板</button>
</div>
</form>
</dialog>
<dialog id="library-copy-dialog" class="template-dialog">
<form method="dialog">
<h2>选择复制目标</h2>
<label>复制到
<select id="library-copy-target"><option value="static">静态模板</option></select>
</label>
<div class="template-dialog-actions"><button value="cancel">取消</button><button class="primary-button" value="copy">复制</button></div>
</form>
</dialog>
<dialog id="animation-dirty-dialog" class="template-dialog">
<form method="dialog">
<h2>当前动图帧尚未保存</h2>
<p id="animation-dirty-message">是否应用当前修改到动图?</p>
<div class="template-dialog-actions"><button value="cancel">取消</button><button value="discard">否,放弃修改</button><button class="primary-button" value="save">应用修改</button></div>
</form>
</dialog>
<dialog id="animation-operation-dialog" class="template-dialog">
<form method="dialog">
<h2 id="animation-operation-title">确认动图操作</h2>
<p id="animation-operation-message"></p>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button id="animation-operation-confirm" class="primary-button" type="submit" value="confirm">确认</button>
</div>
</form>
</dialog>
<dialog id="animation-copy-destination-dialog" class="template-dialog">
<form method="dialog">
<h2>选择批量复制目标</h2>
<label>复制到动图
<select id="animation-copy-destination"></select>
</label>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button class="primary-button" type="submit" value="next">下一步</button>
</div>
</form>
</dialog>
<dialog id="animation-position-dialog" class="template-dialog animation-position-dialog">
<form method="dialog">
<h2 id="animation-position-title">选择插入位置</h2>
<p id="animation-position-hint">选择目标帧后,所选帧将插入到该帧之后;选择“插入到最前”则置于第一帧前。</p>
<label>插入位置
<select id="animation-position-target"></select>
</label>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button class="primary-button" type="submit" value="confirm">确认位置</button>
</div>
</form>
</dialog>
<dialog id="voltage-calibration-dialog" class="template-dialog voltage-calibration-dialog">
<form method="dialog">
<h2>校准电压传感器</h2>
<p>保持整机正常约 5V。把可靠万用表并联到 ADC 相同的屏幕输入 5V/GND 测量点,再输入万用表读数。</p>
<label>万用表参考电压(V)
<input id="voltage-reference-input" type="number" min="4.500" max="5.500" step="0.001" inputmode="decimal" value="5.000" required>
</label>
<p id="voltage-calibration-dialog-feedback" class="hint" aria-live="polite">点击“采样并预览”,约需 1 秒。</p>
<dl id="voltage-calibration-preview" class="voltage-details" hidden>
<div><dt>参考电压</dt><dd id="calibration-reference-result">-</dd></div>
<div><dt>未校准读数</dt><dd id="calibration-raw-result">-</dd></div>
<div><dt>当前系数</dt><dd id="calibration-current-factor">-</dd></div>
<div><dt>建议系数</dt><dd id="calibration-proposed-factor">-</dd></div>
<div><dt>预计结果</dt><dd id="calibration-projected-result">-</dd></div>
</dl>
<div class="voltage-calibration-actions">
<button type="submit" value="cancel">取消</button>
<button id="preview-voltage-calibration" type="button">采样并预览</button>
<button id="confirm-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先完成“采样并预览”,再保存校准。">保存校准</button>
</div>
</form>
</dialog>
<dialog id="current-display-dialog" class="template-dialog current-display-dialog" aria-labelledby="current-display-dialog-title">
<div class="current-display-dialog-body">
<div class="current-display-dialog-heading">
<div>
<h2 id="current-display-dialog-title">当前画面预览</h2>
<p id="current-display-dialog-name" class="subtle" data-ui-copy-value>正在读取…</p>
</div>
<button id="current-display-dialog-close" type="button" aria-label="关闭当前画面预览">关闭</button>
</div>
<button id="current-display-preview-surface" class="current-display-preview-surface" type="button" aria-label="当前实屏逻辑帧预览">
<img id="current-display-preview-image" alt="当前实屏逻辑帧" hidden>
<span id="current-display-preview-placeholder">正在读取当前画面…</span>
</button>
<section id="current-display-playback-controls" class="current-display-playback-controls" aria-label="动图播放控制" hidden>
<span id="current-display-seek-unavailable-reason" class="sr-only">请先完成当前进度跳转。</span>
<div class="current-display-progress-heading">
<label for="current-display-progress">播放位置</label>
<output id="current-display-progress-time" for="current-display-progress" data-ui-copy-value>0:00.00 / 0:00.00</output>
</div>
<input id="current-display-progress" type="range" min="0" max="0" step="1" value="0" aria-label="动图播放位置">
<div class="current-display-playback-actions">
<button id="current-display-play-pause" class="primary-button" type="button">暂停</button>
<div id="current-display-speed-controls" class="current-display-speed-controls" role="group" aria-label="动图播放倍速">
<button type="button" data-playback-speed="0.5">0.5×</button>
<button type="button" data-playback-speed="1">1×</button>
<button type="button" data-playback-speed="1.5">1.5×</button>
<button type="button" data-playback-speed="2">2×</button>
</div>
</div>
<p id="current-display-playback-feedback" class="hint" aria-live="polite" hidden></p>
</section>
</div>
</dialog>
<dialog id="resource-details-dialog" class="template-dialog resource-details-dialog" aria-labelledby="resource-details-title">
<form method="dialog">
<h2 id="resource-details-title">资源占用详情</h2>
<p id="resource-details-message" class="resource-details-message" role="status"></p>
<section class="resource-details-section" aria-labelledby="resource-cpu-title">
<h3 id="resource-cpu-title">CPU</h3>
<dl class="resource-details-grid">
<div><dt>应用占用核心</dt><dd id="resource-cpu-app-cores">-</dd></div>
<div><dt>折合单核占用</dt><dd id="resource-cpu-single-percent">-</dd></div>
<div><dt>占整机 CPU</dt><dd id="resource-cpu-app-percent">-</dd></div>
<div><dt>整机 CPU 总占用</dt><dd id="resource-cpu-total-percent">-</dd></div>
</dl>
<div id="resource-core-list" class="resource-core-list" aria-label="每核心 CPU 占用"></div>
</section>
<section class="resource-details-section" aria-labelledby="resource-memory-title">
<h3 id="resource-memory-title">内存</h3>
<dl class="resource-details-grid">
<div><dt>应用常驻内存</dt><dd id="resource-memory-app">-</dd></div>
<div><dt>应用占整机内存</dt><dd id="resource-memory-app-percent">-</dd></div>
<div><dt>整机内存总占用</dt><dd id="resource-memory-total-percent">-</dd></div>
</dl>
</section>
<div class="template-dialog-actions resource-dialog-actions">
<button id="resource-details-close" class="primary-button" type="submit" value="close">关闭</button>
</div>
</form>
</dialog>
<dialog id="ota-failure-log-dialog" class="template-dialog ota-failure-log-dialog" aria-labelledby="ota-failure-log-title">
<div class="ota-failure-log-dialog-body">
<div class="template-dialog-heading">
<div>
<h2 id="ota-failure-log-title">OTA 更新失败日志</h2>
<p>可将完整内容复制后提供给维护人员排查。</p>
</div>
</div>
<pre id="ota-failure-log-content" tabindex="0" aria-label="OTA 更新失败诊断日志"></pre>
<p id="ota-failure-log-feedback" class="settings-feedback" role="status" aria-live="polite" hidden></p>
<div class="template-dialog-actions">
<button id="ota-failure-log-close" type="button">关闭</button>
<button id="ota-failure-log-copy" class="primary-button" type="button">复制全部日志</button>
</div>
</div>
</dialog>
<dialog id="frp-config-dialog" class="template-dialog frp-config-dialog" aria-labelledby="frp-config-dialog-title">
<div class="frp-config-dialog-body">
<div class="template-dialog-heading">
<div>
<h2 id="frp-config-dialog-title">编辑 FRP 配置文件</h2>
<p>配置可能包含服务器地址和令牌,请只在可信网络中操作。</p>
</div>
</div>
<label>已保存配置
<select id="frp-config-select"><option value="">暂无配置</option></select>
</label>
<label>配置名称
<input id="frp-config-name" type="text" maxlength="80" autocomplete="off" placeholder="远程维护">
</label>
<label>文件格式
<select id="frp-config-format">
<option value=".toml">TOML</option>
<option value=".yaml">YAML</option>
<option value=".json">JSON</option>
<option value=".ini">INI(兼容格式)</option>
</select>
</label>
<label>配置原文
<textarea id="frp-config-content" data-ui-copy-ignore spellcheck="false" aria-describedby="frp-config-feedback"></textarea>
</label>
<input id="frp-config-file" class="sr-only" type="file" accept=".toml,.yaml,.yml,.json,.ini,text/plain,application/json">
<p id="frp-config-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
<div class="frp-config-secondary-actions">
<button id="frp-config-new" type="button">新建空白配置</button>
<button id="frp-config-upload" type="button">上传配置文件</button>
<button id="frp-config-delete" class="danger-button" type="button">删除</button>
</div>
<div class="template-dialog-actions">
<button id="frp-config-close" type="button">关闭</button>
<button id="frp-config-save" class="primary-button" type="button">保存配置</button>
</div>
</div>
</dialog>
<dialog id="network-diagnostics-dialog" class="template-dialog network-diagnostics-dialog" aria-labelledby="network-diagnostics-title">
<div class="network-diagnostics-dialog-body">
<h2 id="network-diagnostics-title">网络检测结果</h2>
<p id="network-diagnostics-summary" role="status">正在检测…</p>
<ul id="network-diagnostics-checks"></ul>
<div class="template-dialog-actions">
<button id="network-diagnostics-close" class="primary-button" type="button">关闭</button>
</div>
</div>
</dialog>
<div id="control-unavailable-tooltip" class="control-unavailable-tooltip" role="tooltip" hidden></div>
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<noscript>此控制台需要启用 JavaScript。</noscript>
<script type="module" src="/static/app.js"></script>
</body>
</html>
@@ -0,0 +1,182 @@
import { announce } from "./core.js";
const tooltip = document.getElementById("control-unavailable-tooltip");
const toast = document.getElementById("control-feedback-toast");
const controlRecords = new WeakMap();
let descriptionSequence = 0;
let toastTimer = null;
function recordFor(control) {
if (!controlRecords.has(control)) {
controlRecords.set(control, {
label: "textContent" in control ? control.textContent : "",
description: null,
describedBy: control.getAttribute("aria-describedby") || "",
});
}
return controlRecords.get(control);
}
function removeDescription(control) {
const record = recordFor(control);
record.description?.remove();
record.description = null;
if (record.describedBy) control.setAttribute("aria-describedby", record.describedBy);
else control.removeAttribute("aria-describedby");
}
function setDescription(control, reason) {
const record = recordFor(control);
if (!record.description) {
const description = document.createElement("span");
description.id = `control-unavailable-reason-${++descriptionSequence}`;
description.className = "sr-only";
control.insertAdjacentElement("afterend", description);
record.description = description;
}
record.description.textContent = reason;
const ids = [record.describedBy, record.description.id].filter(Boolean).join(" ");
control.setAttribute("aria-describedby", ids);
}
function restoreLabel(control) {
const record = recordFor(control);
if ("textContent" in control && control.dataset.busyLabelApplied === "true") {
control.textContent = record.label;
}
delete control.dataset.busyLabelApplied;
}
function clearBusy(control) {
restoreLabel(control);
if ("disabled" in control) control.disabled = false;
control.removeAttribute("aria-busy");
delete control.dataset.controlState;
}
export function setControlReady(control) {
clearBusy(control);
removeDescription(control);
control.removeAttribute("aria-disabled");
delete control.dataset.unavailableReason;
}
export function setControlUnavailable(control, reason) {
if (!reason || !String(reason).trim()) throw new Error("不可用控件必须提供原因");
clearBusy(control);
control.setAttribute("aria-disabled", "true");
control.dataset.unavailableReason = String(reason).trim();
setDescription(control, control.dataset.unavailableReason);
}
export function setControlBusy(control, { label = "", message = "" } = {}) {
const record = recordFor(control);
const wasBusy = control.dataset.controlState === "busy";
removeDescription(control);
control.removeAttribute("aria-disabled");
delete control.dataset.unavailableReason;
control.dataset.controlState = "busy";
control.setAttribute("aria-busy", "true");
if (label && "textContent" in control) {
if (control.dataset.busyLabelApplied !== "true") record.label = control.textContent;
control.textContent = label;
control.dataset.busyLabelApplied = "true";
}
if ("disabled" in control) control.disabled = true;
if (message && !wasBusy) announce(message);
}
export async function runControlAction(control, options, action) {
setControlBusy(control, options);
try {
return await action();
} finally {
setControlReady(control);
}
}
function unavailableControl(target) {
return target instanceof Element
? target.closest('[aria-disabled="true"][data-unavailable-reason]')
: null;
}
function positionTooltip(control, text = control.dataset.unavailableReason) {
if (!tooltip || !text) return;
tooltip.textContent = text;
tooltip.hidden = false;
const controlRect = control.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const margin = 10;
const left = Math.min(
window.innerWidth - tooltipRect.width - margin,
Math.max(margin, controlRect.left + (controlRect.width - tooltipRect.width) / 2),
);
let top = controlRect.top - tooltipRect.height - margin;
if (top < margin) top = controlRect.bottom + margin;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(top)}px`;
}
function overflowTooltipTarget(target) {
return target instanceof Element ? target.closest("[data-hover-tooltip]") : null;
}
function canShowOverflowTooltip(target, event) {
return event.pointerType === "mouse"
&& window.matchMedia("(hover: hover) and (pointer: fine)").matches
&& target.scrollWidth > target.clientWidth;
}
function hideTooltip() {
if (tooltip) tooltip.hidden = true;
}
function showToast(reason) {
if (!toast) return;
window.clearTimeout(toastTimer);
toast.textContent = reason;
toast.hidden = false;
toastTimer = window.setTimeout(() => {
toast.hidden = true;
}, 4000);
}
document.addEventListener("pointerover", (event) => {
const control = unavailableControl(event.target);
if (control) {
positionTooltip(control);
return;
}
const overflowTarget = overflowTooltipTarget(event.target);
if (overflowTarget && canShowOverflowTooltip(overflowTarget, event)) {
positionTooltip(overflowTarget, overflowTarget.dataset.hoverTooltip);
}
});
document.addEventListener("pointerout", (event) => {
const control = unavailableControl(event.target);
if (control && !control.contains(event.relatedTarget)) {
hideTooltip();
return;
}
const overflowTarget = overflowTooltipTarget(event.target);
if (overflowTarget && !overflowTarget.contains(event.relatedTarget)) hideTooltip();
});
document.addEventListener("focusin", (event) => {
const control = unavailableControl(event.target);
if (control) positionTooltip(control);
});
document.addEventListener("focusout", (event) => {
const control = unavailableControl(event.target);
if (control) hideTooltip();
});
document.addEventListener("click", (event) => {
const control = unavailableControl(event.target);
if (!control) return;
event.preventDefault();
event.stopImmediatePropagation();
const reason = control.dataset.unavailableReason;
hideTooltip();
showToast(reason);
announce(reason);
}, true);
@@ -0,0 +1,56 @@
const OUTPUT_PIXELS = 64;
function positiveNumber(value, field) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
throw new TypeError(field + " must be a positive number");
}
return number;
}
export function cropGeometry({
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom, centerX, centerY,
}) {
const viewWidth = positiveNumber(viewportWidth, "viewportWidth");
const viewHeight = positiveNumber(viewportHeight, "viewportHeight");
const imageWidth = positiveNumber(sourceWidth, "sourceWidth");
const imageHeight = positiveNumber(sourceHeight, "sourceHeight");
const scaleZoom = positiveNumber(zoom, "zoom");
const x = Number(centerX), y = Number(centerY);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new TypeError("crop center must be finite");
}
const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight) * scaleZoom;
const width = imageWidth * scale;
const height = imageHeight * scale;
return {
width,
height,
left: viewWidth / 2 - x * width,
top: viewHeight / 2 - y * height,
};
}
export function cropCenterBounds({
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
}) {
const geometry = cropGeometry({
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
centerX: 0.5, centerY: 0.5,
});
const visibleX = Math.min(Number(viewportWidth) / OUTPUT_PIXELS, geometry.width);
const visibleY = Math.min(Number(viewportHeight) / OUTPUT_PIXELS, geometry.height);
return {
minX: (visibleX - Number(viewportWidth) / 2) / geometry.width,
maxX: 1 + (Number(viewportWidth) / 2 - visibleX) / geometry.width,
minY: (visibleY - Number(viewportHeight) / 2) / geometry.height,
maxY: 1 + (Number(viewportHeight) / 2 - visibleY) / geometry.height,
};
}
export function clampCropCenter(value, bounds) {
return {
centerX: Math.max(bounds.minX, Math.min(bounds.maxX, Number(value.centerX))),
centerY: Math.max(bounds.minY, Math.min(bounds.maxY, Number(value.centerY))),
};
}
@@ -0,0 +1,5 @@
export function shouldReuseMediaJobCard(rendered, incoming) {
if (!rendered || !incoming || rendered.id !== incoming.id) return false;
if (rendered.state !== "awaiting_settings" || incoming.state !== "awaiting_settings") return false;
return rendered.dirty || rendered.updatedAt === incoming.updated_at;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,577 @@
export const SCENE_VERSION = 1;
export const SCENE_WIDTH = 64;
export const SCENE_HEIGHT = 64;
export const RGB_CHANNELS = 3;
export const RGBA_CHANNELS = 4;
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS;
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS;
export const SCENE_STORAGE_KEY = "matrixController:scene";
export const LEGACY_STORAGE_KEYS = Object.freeze({
sceneV1: "matrixController:scene:v1",
canvasV3: "matrixController:canvas:v3",
canvasV2: "matrixController:canvas:v2",
canvasRgb: "matrixCanvasRgb",
textV1: "matrixController:text:v1",
});
export const DEFAULT_TEXT_ELEMENT = Object.freeze({
type: "text",
text: "ok",
font: "default",
size: 12,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
revision: 0,
});
const ALIGNMENTS = new Set(["left", "center", "right"]);
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function finiteInteger(value, fallback, minimum, maximum) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(number)));
}
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
return typeof value === "string" && HEX_COLOR.test(value.trim())
? value.trim().toUpperCase()
: fallback;
}
function normalizeId(value) {
if (typeof value !== "string") return null;
const id = value.trim();
return id && id.length <= 128 ? id : null;
}
function byteArray(value, expectedLength) {
if (value === null || value === undefined || typeof value.length !== "number") return null;
if (value.length !== expectedLength) return null;
const result = new Uint8ClampedArray(expectedLength);
for (let index = 0; index < expectedLength; index += 1) {
const channel = value[index];
if (!Number.isInteger(channel) || channel < 0 || channel > 255) return null;
result[index] = channel;
}
return result;
}
function encodeBase64(bytes) {
let encoded = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index];
const hasSecond = index + 1 < bytes.length;
const hasThird = index + 2 < bytes.length;
const second = hasSecond ? bytes[index + 1] : 0;
const third = hasThird ? bytes[index + 2] : 0;
encoded += BASE64_ALPHABET[first >> 2];
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)];
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "=";
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "=";
}
return encoded;
}
function decodeBase64(value) {
if (typeof value !== "string" || value.length % 4 !== 0) return null;
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding);
let output = 0;
for (let index = 0; index < value.length; index += 4) {
const first = BASE64_ALPHABET.indexOf(value[index]);
const second = BASE64_ALPHABET.indexOf(value[index + 1]);
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]);
const fourth = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3]);
const packed = (first << 18) | (second << 12) | (third << 6) | fourth;
if (output < result.length) result[output++] = (packed >> 16) & 255;
if (output < result.length) result[output++] = (packed >> 8) & 255;
if (output < result.length) result[output++] = packed & 255;
}
return result;
}
function parseJsonValue(value) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function sourceValue(source, alias, storageKey) {
if (!isRecord(source)) return null;
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias];
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey];
return null;
}
function copyTextElement(element) {
return {
id: element.id,
type: "text",
text: element.text,
font: element.font,
size: element.size,
x: element.x,
y: element.y,
align: element.align,
color: element.color,
revision: element.revision,
};
}
function storageTextElement(element) {
const { revision: _revision, ...stored } = copyTextElement(element);
return stored;
}
function normalizedElementList(elements) {
if (!Array.isArray(elements)) return [];
const normalized = [];
const usedIds = new Set();
for (const value of elements) {
const element = normalizeTextElement(value, { strict: true });
if (!element || usedIds.has(element.id)) continue;
usedIds.add(element.id);
normalized.push(element);
}
return normalized;
}
function assertScene(scene) {
if (!isRecord(scene)) throw new TypeError("scene must be an object");
if (scene.version !== SCENE_VERSION || scene.width !== SCENE_WIDTH || scene.height !== SCENE_HEIGHT) {
throw new RangeError("scene must be version 1 with a 64x64 frame");
}
const pixels = byteArray(scene.pixels, RGB_BYTE_LENGTH);
if (!pixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(scene.elements)) throw new TypeError("scene elements must be an array");
const elements = normalizedElementList(scene.elements);
if (elements.length !== scene.elements.length) throw new TypeError("scene contains an invalid or duplicate element");
return { pixels, elements };
}
function sceneWithElements(scene, elements) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checked.pixels,
elements: elements.map(copyTextElement),
};
}
export function createRgbPixels(color = [0, 0, 0]) {
const channels = byteArray(color, RGB_CHANNELS);
if (!channels) throw new RangeError("color must contain three RGB bytes");
const pixels = new Uint8ClampedArray(RGB_BYTE_LENGTH);
for (let offset = 0; offset < pixels.length; offset += RGB_CHANNELS) pixels.set(channels, offset);
return pixels;
}
export function createScene({ pixels = createRgbPixels(), elements = [] } = {}) {
const checkedPixels = byteArray(pixels, RGB_BYTE_LENGTH);
if (!checkedPixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checkedPixels,
elements: normalizedElementList(elements),
};
}
export function sceneToStorageValue(scene) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixelRgb: encodeBase64(checked.pixels),
elements: checked.elements.map(storageTextElement),
};
}
export function serializeScene(scene) {
return JSON.stringify(sceneToStorageValue(scene));
}
export function deserializeScene(value) {
const stored = parseJsonValue(value);
if (!isRecord(stored)) return null;
if (stored.version !== SCENE_VERSION || stored.width !== SCENE_WIDTH || stored.height !== SCENE_HEIGHT) return null;
const decoded = decodeBase64(stored.pixelRgb);
if (!decoded || decoded.length !== RGB_BYTE_LENGTH) return null;
return createScene({
pixels: decoded,
elements: normalizedElementList(stored.elements),
});
}
export function inspectSceneStorageValue(value) {
if (value === null || value === undefined) return { status: "missing", scene: null };
let stored = value;
if (typeof value === "string") {
try {
stored = JSON.parse(value);
} catch (_error) {
return { status: "damaged", scene: null };
}
}
if (!isRecord(stored)) return { status: "damaged", scene: null };
if (Number.isInteger(stored.version) && stored.version > SCENE_VERSION) {
return { status: "future", scene: null };
}
const restored = deserializeScene(stored);
return restored
? { status: "valid", scene: restored }
: { status: "damaged", scene: null };
}
export function nextTextElementId(elements = []) {
const used = new Set(
Array.isArray(elements)
? elements.map((element) => normalizeId(element?.id)).filter(Boolean)
: [],
);
let sequence = 1;
while (used.has(`text-${sequence}`)) sequence += 1;
return `text-${sequence}`;
}
export function normalizeTextElement(value = {}, { id = null, strict = false } = {}) {
if (!isRecord(value) || (value.type !== undefined && value.type !== "text")) return null;
if (strict && (typeof value.text !== "string" || !normalizeId(value.id))) return null;
const normalizedId = normalizeId(value.id) || normalizeId(id) || "text-1";
const text = typeof value.text === "string" ? value.text.slice(0, 512) : DEFAULT_TEXT_ELEMENT.text;
const font = typeof value.font === "string" && value.font.trim()
? value.font.trim().slice(0, 512)
: DEFAULT_TEXT_ELEMENT.font;
return {
id: normalizedId,
type: "text",
text,
font,
size: finiteInteger(value.size, DEFAULT_TEXT_ELEMENT.size, 1, 64),
x: finiteInteger(value.x, DEFAULT_TEXT_ELEMENT.x, -128, 128),
y: finiteInteger(value.y, DEFAULT_TEXT_ELEMENT.y, -128, 128),
align: ALIGNMENTS.has(value.align) ? value.align : DEFAULT_TEXT_ELEMENT.align,
color: normalizeColor(value.color),
revision: finiteInteger(value.revision, 0, 0, Number.MAX_SAFE_INTEGER),
};
}
export function createTextElement(existingElements = [], overrides = {}) {
const id = nextTextElementId(existingElements);
return normalizeTextElement({ ...DEFAULT_TEXT_ELEMENT, ...overrides, id }, { id });
}
export function addTextElement(scene, overrides = {}) {
const checked = assertScene(scene);
const element = createTextElement(checked.elements, overrides);
return sceneWithElements(scene, [...checked.elements, element]);
}
export function updateTextElement(scene, id, patch = {}) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
const index = checked.elements.findIndex((element) => element.id === targetId);
if (index < 0 || !isRecord(patch)) return scene;
const current = checked.elements[index];
const updated = normalizeTextElement({
...current,
...patch,
id: current.id,
type: "text",
revision: current.revision + 1,
}, { id: current.id });
const elements = checked.elements.map((element, elementIndex) => (
elementIndex === index ? updated : copyTextElement(element)
));
return sceneWithElements(scene, elements);
}
export function deleteTextElement(scene, id) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
if (!checked.elements.some((element) => element.id === targetId)) return scene;
return sceneWithElements(scene, checked.elements.filter((element) => element.id !== targetId));
}
export function duplicateTextElement(scene, id) {
const checked = assertScene(scene);
const source = checked.elements.find((element) => element.id === normalizeId(id));
if (!source) return scene;
const duplicate = createTextElement(checked.elements, {
...source,
x: source.x + 2,
y: source.y + 2,
revision: 0,
});
return sceneWithElements(scene, [...checked.elements, duplicate]);
}
function legacyCanvasPixels(value) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
return byteArray(parsed.pixels, RGB_BYTE_LENGTH);
}
function legacyCsvPixels(value) {
if (typeof value !== "string") return byteArray(value, RGB_BYTE_LENGTH);
const channels = value.split(",").map((channel) => Number(channel));
return byteArray(channels, RGB_BYTE_LENGTH);
}
function migrateTextV1(value, options) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
const text = parsed["text-value"] ?? parsed.text;
if (typeof text !== "string") return null;
return normalizeTextElement({
id: "text-1",
type: "text",
text,
font: parsed["text-font"] ?? parsed.font ?? options.defaultFont,
size: parsed["text-size"] ?? parsed.size ?? options.defaultTextSize,
x: parsed["text-x"] ?? parsed.x,
y: parsed["text-y"] ?? parsed.y,
align: parsed["text-align"] ?? parsed.align,
color: parsed["text-color"] ?? parsed.color,
}, { id: "text-1" });
}
export function migrateLegacyScene(source = {}, options = {}) {
const migrationOptions = {
defaultFont: typeof options.defaultFont === "string" && options.defaultFont.trim()
? options.defaultFont.trim()
: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: finiteInteger(options.defaultTextSize, DEFAULT_TEXT_ELEMENT.size, 1, 64),
};
const canvasCandidates = [
["canvasV3", LEGACY_STORAGE_KEYS.canvasV3],
["canvasV2", LEGACY_STORAGE_KEYS.canvasV2],
];
let pixels = null;
for (const [alias, storageKey] of canvasCandidates) {
pixels = legacyCanvasPixels(sourceValue(source, alias, storageKey));
if (pixels) break;
}
if (!pixels) {
pixels = legacyCsvPixels(sourceValue(source, "matrixCanvasRgb", LEGACY_STORAGE_KEYS.canvasRgb));
}
const text = migrateTextV1(
sourceValue(source, "textV1", LEGACY_STORAGE_KEYS.textV1),
migrationOptions,
);
if (!pixels && !text) return null;
return createScene({
pixels: pixels || createRgbPixels(),
elements: text ? [text] : [],
});
}
function draftLoadResult(scene, {
canPersist,
status,
notice = null,
} = {}) {
return { scene, canPersist, status, notice };
}
function blockedDraft(scene, status, notice) {
return draftLoadResult(scene, { canPersist: false, status, notice });
}
function readStorageSnapshot(storage) {
if (!storage || typeof storage.getItem !== "function") {
throw new TypeError("browser storage is unavailable");
}
const values = {};
values.current = storage.getItem(SCENE_STORAGE_KEY);
for (const [alias, key] of Object.entries(LEGACY_STORAGE_KEYS)) {
values[alias] = storage.getItem(key);
}
return values;
}
function invalidSplitLegacyKeys(values) {
const invalid = [];
if (values.canvasV3 !== null && !legacyCanvasPixels(values.canvasV3)) invalid.push(LEGACY_STORAGE_KEYS.canvasV3);
if (values.canvasV2 !== null && !legacyCanvasPixels(values.canvasV2)) invalid.push(LEGACY_STORAGE_KEYS.canvasV2);
if (values.canvasRgb !== null && !legacyCsvPixels(values.canvasRgb)) invalid.push(LEGACY_STORAGE_KEYS.canvasRgb);
if (values.textV1 !== null && !migrateTextV1(values.textV1, {
defaultFont: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: DEFAULT_TEXT_ELEMENT.size,
})) invalid.push(LEGACY_STORAGE_KEYS.textV1);
return invalid;
}
function writeMigratedDraft(storage, scene, hasLegacyData) {
try {
if (typeof storage.setItem !== "function") throw new TypeError("browser storage is read-only");
storage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
} catch (_error) {
return blockedDraft(
scene,
"write-failed",
hasLegacyData
? "浏览器无法写入升级后的画板草稿;旧草稿已原样保留,本页不会覆盖它。"
: "浏览器无法初始化画板草稿;本页编辑不会自动保存或应用。",
);
}
if (!hasLegacyData) {
return draftLoadResult(scene, { canPersist: true, status: "created" });
}
const cleanupFailures = [];
for (const key of Object.values(LEGACY_STORAGE_KEYS)) {
try {
if (typeof storage.removeItem !== "function") throw new TypeError("browser storage cannot remove old keys");
storage.removeItem(key);
} catch (_error) {
cleanupFailures.push(key);
}
}
if (cleanupFailures.length) {
return draftLoadResult(scene, {
canPersist: true,
status: "cleanup-warning",
notice: "画板草稿已升级,但部分旧版草稿键无法清理;旧值仍被保留。",
});
}
return draftLoadResult(scene, {
canPersist: true,
status: "migrated",
notice: "已将旧版浏览器画板草稿升级到当前格式。",
});
}
export function restoreSceneDraft(storage) {
const blankScene = createScene();
let values;
try {
values = readStorageSnapshot(storage);
} catch (_error) {
return blockedDraft(
blankScene,
"read-failed",
"浏览器草稿存储不可用;本页不会自动保存或应用空白画板。",
);
}
const current = inspectSceneStorageValue(values.current);
if (current.status === "valid") {
return draftLoadResult(current.scene, { canPersist: true, status: "current" });
}
if (current.status === "future") {
return blockedDraft(
blankScene,
"future-version",
"检测到更高版本的浏览器画板草稿;原值已保留,本页不会覆盖或应用它。",
);
}
if (current.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-current",
"浏览器画板草稿损坏或格式无法识别;原值已保留,本页不会覆盖或应用它。",
);
}
const legacyScene = inspectSceneStorageValue(values.sceneV1);
if (legacyScene.status === "future") {
return blockedDraft(
blankScene,
"future-legacy-version",
"旧版草稿键中包含更高版本数据;原值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-legacy-scene",
"旧版画板草稿损坏或无法迁移;原值已保留,本页不会覆盖或应用它。",
);
}
const hasSplitLegacyData = ["canvasV3", "canvasV2", "canvasRgb", "textV1"]
.some((alias) => values[alias] !== null);
const hasLegacyData = values.sceneV1 !== null || hasSplitLegacyData;
const invalidLegacyKeys = invalidSplitLegacyKeys(values);
const migrated = migrateLegacyScene({
canvasV3: values.canvasV3,
canvasV2: values.canvasV2,
matrixCanvasRgb: values.canvasRgb,
textV1: values.textV1,
});
if (invalidLegacyKeys.length) {
return blockedDraft(
legacyScene.scene || migrated || blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "valid") {
return writeMigratedDraft(storage, legacyScene.scene, hasLegacyData);
}
if (hasSplitLegacyData && !migrated) {
return blockedDraft(
blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
return writeMigratedDraft(storage, migrated || blankScene, hasLegacyData);
}
function compositeInto(target, rgba) {
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < rgba.length; sourceOffset += 4, targetOffset += 3) {
const alpha = rgba[sourceOffset + 3];
if (alpha === 0) continue;
if (alpha === 255) {
target[targetOffset] = rgba[sourceOffset];
target[targetOffset + 1] = rgba[sourceOffset + 1];
target[targetOffset + 2] = rgba[sourceOffset + 2];
continue;
}
const inverse = 255 - alpha;
target[targetOffset] = Math.round((rgba[sourceOffset] * alpha + target[targetOffset] * inverse) / 255);
target[targetOffset + 1] = Math.round((rgba[sourceOffset + 1] * alpha + target[targetOffset + 1] * inverse) / 255);
target[targetOffset + 2] = Math.round((rgba[sourceOffset + 2] * alpha + target[targetOffset + 2] * inverse) / 255);
}
}
export function compositeRgbaLayer(pixels, rgba) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
const layer = byteArray(rgba, RGBA_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!layer) throw new RangeError("layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
return result;
}
export function compositeRgbaLayers(pixels, layers = []) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(layers)) throw new TypeError("layers must be an array");
for (const value of layers) {
const layer = byteArray(value, RGBA_BYTE_LENGTH);
if (!layer) throw new RangeError("each layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
}
return result;
}
@@ -0,0 +1,79 @@
export const DRAG_START_GUARD_PX = 8;
const INTERACTIVE_SELECTOR = "button, input, select, textarea, a[href], [contenteditable]:not([contenteditable='false'])";
export function pointInsideExpandedRect(x, y, rect, padding = DRAG_START_GUARD_PX) {
return x >= rect.left - padding
&& x <= rect.right + padding
&& y >= rect.top - padding
&& y <= rect.bottom + padding;
}
export function isProtectedDragStart(card, event, padding = DRAG_START_GUARD_PX) {
const target = typeof Element !== "undefined" && event.target instanceof Element ? event.target : null;
if (target?.closest(INTERACTIVE_SELECTOR)) return true;
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false;
return [...card.querySelectorAll(INTERACTIVE_SELECTOR)].some((control) => (
pointInsideExpandedRect(event.clientX, event.clientY, control.getBoundingClientRect(), padding)
));
}
export function protectCardDragStart(card, { locked = false } = {}) {
let dragging = false;
let restoreListeners = null;
const restore = () => {
restoreListeners?.();
restoreListeners = null;
if (!locked && !dragging) card.draggable = true;
};
card.draggable = !locked;
card.addEventListener("pointerdown", (event) => {
if (locked || dragging) return;
restore();
if (!isProtectedDragStart(card, event)) {
card.draggable = true;
return;
}
card.draggable = false;
const pointerId = event.pointerId;
const finish = (finishEvent) => {
if (finishEvent.pointerId !== pointerId) return;
queueMicrotask(restore);
};
window.addEventListener("pointerup", finish, true);
window.addEventListener("pointercancel", finish, true);
restoreListeners = () => {
window.removeEventListener("pointerup", finish, true);
window.removeEventListener("pointercancel", finish, true);
};
}, true);
card.addEventListener("dragstart", (event) => {
if (locked || !card.draggable || isProtectedDragStart(card, event)) {
event.preventDefault();
restore();
return;
}
dragging = true;
restoreListeners?.();
restoreListeners = null;
card.classList.add("is-dragging");
});
card.addEventListener("dragend", () => {
dragging = false;
card.classList.remove("is-dragging");
restore();
});
return { restore };
}
export function reorderByKey(items, sourceKey, targetKey) {
const order = [...items];
const from = order.indexOf(sourceKey);
const target = order.indexOf(targetKey);
if (from < 0 || target < 0 || from === target) return null;
order.splice(target, 0, order.splice(from, 1)[0]);
return order;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
export function createTemplateEditContext(template, baseline) {
return {
id: template.id,
name: template.name,
revision: template.revision,
readOnly: Boolean(template.read_only ?? template.readOnly),
baseline,
};
}
export function templateEditSnapshot(context, serializedScene) {
if (!context) return null;
return {
id: context.id,
name: context.name,
revision: context.revision,
readOnly: Boolean(context.readOnly),
dirty: serializedScene !== context.baseline,
};
}
export function sceneNeedsTemplateSave(context, serializedScene) {
return !context || serializedScene !== context.baseline;
}
@@ -0,0 +1,279 @@
{
"copy.dynamic.template.save_bound": {
"text": "保存模板",
"scope": "templates",
"source": "views/templates.js::save_bound"
},
"copy.dynamic.template.save_new": {
"text": "保存为模板",
"scope": "templates",
"source": "views/templates.js::save_new"
},
"copy.dynamic.template.save_choice": {
"text": "请选择“{name}”的保存方式。",
"scope": "templates",
"source": "views/templates.js::save_choice"
},
"copy.dynamic.template.unsaved_replace": {
"text": "当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
"scope": "templates",
"source": "views/templates.js::unsaved_replace"
},
"copy.dynamic.animation.page_status": {
"text": "第 {page} / {pages} 页 · 共 {total} 帧",
"scope": "animations",
"source": "views/animations.js::page_status"
},
"copy.dynamic.workspace_order.edit": {
"text": "自定义项目位置",
"scope": "drawer",
"source": "app.js::workspace_order_edit"
},
"copy.dynamic.workspace_order.save": {
"text": "保存",
"scope": "drawer",
"source": "app.js::workspace_order_save"
},
"copy.dynamic.workspace_order.loading": {
"text": "正在读取…",
"scope": "drawer",
"source": "app.js::workspace_order_loading"
},
"copy.dynamic.workspace_order.move_up": {
"text": "上移",
"scope": "drawer",
"source": "app.js::workspace_order_move_up"
},
"copy.dynamic.workspace_order.move_down": {
"text": "下移",
"scope": "drawer",
"source": "app.js::workspace_order_move_down"
},
"copy.dynamic.workspace_order.first_item": {
"text": "当前已经是第一项,不能继续上移。",
"scope": "drawer",
"source": "app.js::workspace_order_first_item"
},
"copy.dynamic.workspace_order.last_item": {
"text": "当前已经是最后一项,不能继续下移。",
"scope": "drawer",
"source": "app.js::workspace_order_last_item"
},
"copy.dynamic.workspace_order.saving": {
"text": "正在保存…",
"scope": "drawer",
"source": "app.js::workspace_order_saving"
},
"copy.dynamic.workspace_order.saved": {
"text": "项目位置已保存",
"scope": "drawer",
"source": "app.js::workspace_order_saved"
},
"copy.dynamic.workspace_order.save_required": {
"text": "请先保存项目位置",
"scope": "drawer",
"source": "app.js::workspace_order_save_required"
},
"copy.dynamic.workspace_order.load_failed": {
"text": "项目位置读取失败:{reason}",
"scope": "drawer",
"source": "app.js::workspace_order_load_failed"
},
"copy.dynamic.workspace_order.save_failed": {
"text": "项目位置保存失败:{reason}",
"scope": "drawer",
"source": "app.js::workspace_order_save_failed"
},
"copy.dynamic.board.monitor_eyebrow": {
"text": "64 × 64 屏幕监视器",
"scope": "scene-board",
"source": "scene-board.js::monitor_eyebrow",
"default_hidden": true
},
"copy.dynamic.board.monitor_title": {
"text": "当前屏幕画面",
"scope": "scene-board",
"source": "scene-board.js::monitor_title"
},
"copy.dynamic.board.monitor_mode": {
"text": "预览当前显示屏幕画面",
"scope": "scene-board",
"source": "scene-board.js::monitor_mode",
"default_hidden": true
},
"copy.dynamic.board.composition_eyebrow": {
"text": "64 × 64 统一画板",
"scope": "scene-board",
"source": "scene-board.js::composition_eyebrow",
"default_hidden": true
},
"copy.dynamic.board.composition_title": {
"text": "合成预览",
"scope": "scene-board",
"source": "scene-board.js::composition_title",
"default_hidden": true
},
"copy.dynamic.board.composition_mode": {
"text": "正向 0° · 统一画板",
"scope": "scene-board",
"source": "scene-board.js::composition_mode",
"default_hidden": true
},
"copy.dynamic.board.animation_edit_mode": {
"text": "动图帧编辑 · 不会直接更新实屏",
"scope": "scene-board",
"source": "scene-board.js::animation_edit_mode"
},
"copy.dynamic.device.current_content": {
"text": "当前画面:{name}",
"scope": "topbar",
"source": "views/device.js::current_content"
},
"copy.dynamic.device.voltage_ok": {
"text": "屏幕输入电压 {volts} V",
"scope": "device",
"source": "views/device.js::voltage_ok"
},
"copy.dynamic.device.voltage_disconnected": {
"text": "电压传感器断开",
"scope": "device",
"source": "views/device.js::voltage_disconnected"
},
"copy.dynamic.device.voltage_error": {
"text": "电压读取异常",
"scope": "device",
"source": "views/device.js::voltage_error"
},
"copy.dynamic.device.voltage_loading": {
"text": "正在读取电压…",
"scope": "device",
"source": "views/device.js::voltage_loading"
},
"copy.dynamic.text.font_selected": {
"text": "已选择字体“{name}”",
"scope": "text",
"source": "views/text.js::font_selected"
},
"copy.dynamic.text.font_type_error": {
"text": "只支持 TTF、OTF、TTC、OTC 字体文件。",
"scope": "text",
"source": "views/text.js::font_type_error"
},
"copy.dynamic.text.font_size_error": {
"text": "字体文件不能超过 32 MiB。",
"scope": "text",
"source": "views/text.js::font_size_error"
},
"copy.dynamic.text.font_importing": {
"text": "正在导入字体“{name}”…",
"scope": "text",
"source": "views/text.js::font_importing"
},
"copy.dynamic.text.font_imported": {
"text": "字体“{name}”已导入",
"scope": "text",
"source": "views/text.js::font_imported"
},
"copy.dynamic.text.font_imported_applied": {
"text": "字体“{name}”已导入并应用到当前文字",
"scope": "text",
"source": "views/text.js::font_imported_applied"
},
"copy.dynamic.text.font_exists": {
"text": "字体“{name}”已经存在",
"scope": "text",
"source": "views/text.js::font_exists"
},
"copy.dynamic.text.font_exists_applied": {
"text": "字体“{name}”已经存在,已应用到当前文字",
"scope": "text",
"source": "views/text.js::font_exists_applied"
},
"copy.dynamic.text.font_error_stale": {
"text": "页面版本已过期,请刷新后再导入字体。",
"scope": "text",
"source": "views/text.js::font_error_stale"
},
"copy.dynamic.text.font_error_timeout": {
"text": "字体导入超时,请检查核桃派网络或服务状态后重试。",
"scope": "text",
"source": "views/text.js::font_error_timeout"
},
"copy.dynamic.text.font_error_too_large": {
"text": "字体导入失败:文件超过 32 MiB。",
"scope": "text",
"source": "views/text.js::font_error_too_large"
},
"copy.dynamic.text.font_error_type": {
"text": "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。",
"scope": "text",
"source": "views/text.js::font_error_type"
},
"copy.dynamic.text.font_error_invalid": {
"text": "字体导入失败:文件损坏或不包含可用字体。",
"scope": "text",
"source": "views/text.js::font_error_invalid"
},
"copy.dynamic.text.font_error_storage": {
"text": "字体导入失败:设备存储空间不足。",
"scope": "text",
"source": "views/text.js::font_error_storage"
},
"copy.dynamic.text.font_error_network": {
"text": "字体导入失败:无法连接设备,请检查网络后重试。",
"scope": "text",
"source": "views/text.js::font_error_network"
},
"copy.dynamic.text.font_error_unknown": {
"text": "字体导入失败:服务器未能完成导入,请稍后重试。",
"scope": "text",
"source": "views/text.js::font_error_unknown"
},
"copy.dynamic.text.font_catalog_failed": {
"text": "字体目录读取失败,当前字体值已保留:{error}",
"scope": "text",
"source": "views/text.js::font_catalog_failed"
},
"copy.dynamic.media.state_uploading": {"text": "上传中", "scope": "media-import", "source": "views/media-import.js::state_uploading"},
"copy.dynamic.media.state_analyzing": {"text": "分析中", "scope": "media-import", "source": "views/media-import.js::state_analyzing"},
"copy.dynamic.media.state_awaiting": {"text": "等待设置", "scope": "media-import", "source": "views/media-import.js::state_awaiting"},
"copy.dynamic.media.state_queued": {"text": "排队中", "scope": "media-import", "source": "views/media-import.js::state_queued"},
"copy.dynamic.media.state_converting": {"text": "转换中", "scope": "media-import", "source": "views/media-import.js::state_converting"},
"copy.dynamic.media.state_failed": {"text": "失败", "scope": "media-import", "source": "views/media-import.js::state_failed"},
"copy.dynamic.media.queue_position": {"text": "{state} · 队列第 {position} 位", "scope": "media-import", "source": "views/media-import.js::queue_position"},
"copy.dynamic.media.preview": {"text": "代表帧 {index}", "scope": "media-import", "source": "views/media-import.js::preview"},
"copy.dynamic.media.output_name": {"text": "保存名称", "scope": "media-import", "source": "views/media-import.js::output_name"},
"copy.dynamic.media.fit_mode": {"text": "1:1 适配", "scope": "media-import", "source": "views/media-import.js::fit_mode"},
"copy.dynamic.media.mode_crop": {"text": "自由取景", "scope": "media-import", "source": "views/media-import.js::mode_crop"},
"copy.dynamic.media.mode_contain": {"text": "完整包含", "scope": "media-import", "source": "views/media-import.js::mode_contain"},
"copy.dynamic.media.mode_stretch": {"text": "拉伸", "scope": "media-import", "source": "views/media-import.js::mode_stretch"},
"copy.dynamic.media.crop_zoom": {"text": "取景缩放", "scope": "media-import", "source": "views/media-import.js::crop_zoom"},
"copy.dynamic.media.transparency": {"text": "透明像素填充色", "scope": "media-import", "source": "views/media-import.js::transparency"},
"copy.dynamic.media.padding": {"text": "留白颜色", "scope": "media-import", "source": "views/media-import.js::padding"},
"copy.dynamic.media.convert": {"text": "保存设置并开始转换", "scope": "media-import", "source": "views/media-import.js::convert"},
"copy.dynamic.media.cancel_source": {"text": "取消并删除源文件", "scope": "media-import", "source": "views/media-import.js::cancel_source"},
"copy.dynamic.media.retry": {"text": "重试", "scope": "media-import", "source": "views/media-import.js::retry"},
"copy.dynamic.media.cancel": {"text": "取消并删除", "scope": "media-import", "source": "views/media-import.js::cancel"},
"copy.dynamic.media.deleted": {"text": "任务和源文件已删除。", "scope": "media-import", "source": "views/media-import.js::deleted"},
"copy.dynamic.media.delete_failed": {"text": "删除任务失败:{error}", "scope": "media-import", "source": "views/media-import.js::delete_failed"},
"copy.dynamic.media.queued": {"text": "任务已加入转换队列。", "scope": "media-import", "source": "views/media-import.js::queued"},
"copy.dynamic.media.name_conflict": {"text": "保存名称已存在,请更换名称。", "scope": "media-import", "source": "views/media-import.js::name_conflict"},
"copy.dynamic.media.convert_failed": {"text": "无法开始转换:{error}", "scope": "media-import", "source": "views/media-import.js::convert_failed"},
"copy.dynamic.media.retry_failed": {"text": "重试失败:{error}", "scope": "media-import", "source": "views/media-import.js::retry_failed"},
"copy.dynamic.media.completed": {"text": "“{name}”转换完成,已加入内容库。", "scope": "media-import", "source": "views/media-import.js::completed"},
"copy.dynamic.media.empty": {"text": "当前没有等待或转换中的任务。", "scope": "media-import", "source": "views/media-import.js::empty"},
"copy.dynamic.media.list_failed": {"text": "任务列表读取失败:{error}", "scope": "media-import", "source": "views/media-import.js::list_failed"},
"copy.dynamic.media.uploaded": {"text": "上传完成,设备正在分析媒体。", "scope": "media-import", "source": "views/media-import.js::uploaded"},
"copy.dynamic.media.upload_failed": {"text": "上传失败:{error}", "scope": "media-import", "source": "views/media-import.js::upload_failed"},
"copy.dynamic.current_display.loading": {"text": "正在读取当前画面…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_loading"},
"copy.dynamic.current_display.load_failed": {"text": "当前画面读取失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_load_failed"},
"copy.dynamic.current_display.pause": {"text": "暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pause"},
"copy.dynamic.current_display.resume": {"text": "继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resume"},
"copy.dynamic.current_display.pausing": {"text": "正在暂停…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pausing"},
"copy.dynamic.current_display.resuming": {"text": "正在继续播放…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resuming"},
"copy.dynamic.current_display.paused": {"text": "动图已暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_paused"},
"copy.dynamic.current_display.resumed": {"text": "动图继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resumed"},
"copy.dynamic.current_display.speed_changing": {"text": "正在切换到 {speed}×…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changing"},
"copy.dynamic.current_display.speed_changed": {"text": "已切换到 {speed}×", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changed"},
"copy.dynamic.current_display.control_failed": {"text": "动图播放控制失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_control_failed"}
}
@@ -0,0 +1,129 @@
const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__ || null;
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
const targetRegistry = new Map();
const accessibleNameState = new WeakMap();
let activeDraft = bootstrap?.initial_draft || { overrides: {} };
function formatTemplate(text, values = {}) {
return String(text).replace(placeholderPattern, (_match, name) => values[name] ?? `{${name}}`);
}
function preserveAccessibleName(record, text) {
const host = record.host;
if (!host) return;
if (!accessibleNameState.has(host)) accessibleNameState.set(host, host.getAttribute("aria-label"));
host.setAttribute("aria-label", text || record.defaultText);
}
function restoreAccessibleName(record) {
if (!record.host || !accessibleNameState.has(record.host)) return;
const original = accessibleNameState.get(record.host);
if (original === null) record.host.removeAttribute("aria-label");
else record.host.setAttribute("aria-label", original);
accessibleNameState.delete(record.host);
}
function applyRecord(record) {
if (!record.target.isConnected) return;
const override = activeDraft.overrides?.[record.copyId] || {};
const sourceText = override.text ?? record.defaultText;
const renderedText = formatTemplate(sourceText, record.values);
if (record.attribute) {
record.target.setAttribute(record.attribute, renderedText);
return;
}
const hidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(record.defaultHidden);
record.target.textContent = hidden ? "" : renderedText;
if (hidden) preserveAccessibleName(record, renderedText);
else restoreAccessibleName(record);
if (override.font_size_px) {
if (!record.fontCaptured) {
record.originalFontSize = record.target.style.fontSize;
record.fontCaptured = true;
}
record.target.style.fontSize = `${override.font_size_px}px`;
} else if (record.fontCaptured) {
record.target.style.fontSize = record.originalFontSize;
record.fontCaptured = false;
}
record.target.classList.toggle("ui-copy-has-override", Boolean(activeDraft.overrides?.[record.copyId]));
}
function registerRecord(record) {
const previous = targetRegistry.get(record.target);
if (previous) {
record.originalFontSize = previous.originalFontSize;
record.fontCaptured = previous.fontCaptured;
}
targetRegistry.set(record.target, record);
if (bootstrap) record.target.dataset.uiCopyId = record.copyId;
applyRecord(record);
return record;
}
export function renderUiCopy(copyId, element, defaultText, values = {}, options = {}) {
if (!(element instanceof Element)) return;
if (!bootstrap) {
const renderedText = formatTemplate(defaultText, values);
const record = { host: element, defaultText };
element.textContent = options.defaultHidden ? "" : renderedText;
if (options.defaultHidden) preserveAccessibleName(record, renderedText);
else restoreAccessibleName(record);
return;
}
registerRecord({
copyId,
target: element,
host: element,
defaultText,
values,
kind: "dynamic_template",
attribute: null,
defaultHidden: Boolean(options.defaultHidden),
});
}
export function renderUiValue(element, value) {
if (!(element instanceof Element)) return;
const priorRecord = targetRegistry.get(element);
restoreAccessibleName(priorRecord || { host: element });
targetRegistry.delete(element);
delete element.dataset.uiCopyId;
element.textContent = String(value ?? "");
}
export function registerStaticUiCopy(copyId, target, host, defaultText, kind, attribute = null, defaultHidden = false) {
if (!bootstrap || !(target instanceof Element)) return null;
return registerRecord({
copyId,
target,
host: host || target,
defaultText,
values: {},
kind,
attribute,
defaultHidden: Boolean(defaultHidden),
});
}
export function setUiCopyDraft(nextDraft) {
activeDraft = nextDraft;
for (const record of targetRegistry.values()) applyRecord(record);
}
export function applyUiCopyId(copyId) {
for (const record of targetRegistry.values()) {
if (record.copyId === copyId) applyRecord(record);
}
}
export function uiCopyRecords(copyId = null) {
const records = Array.from(targetRegistry.values()).filter((record) => record.target.isConnected);
return copyId === null ? records : records.filter((record) => record.copyId === copyId);
}
export function unregisterUiCopySubtree(root) {
for (const [target] of targetRegistry) {
if (!target.isConnected || target === root || root?.contains?.(target)) targetRegistry.delete(target);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,585 @@
import {
announce,
createLocalState,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
import { hexToRgb, rgbToHex } from "../color-math.js";
import {
BRUSH_MODE_NORMAL,
BRUSH_MODE_PIXEL,
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
clampCanvasView,
clampFloatingButton,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
zoomCanvasView,
} from "../canvas-tools-model.js";
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import {
boardPoint,
commitPixelChanges,
fillPixelLayer,
getBoardOverlay,
getInteractionMode,
getPixelBuffer,
renderPixelDraft,
} from "../scene-board.js";
const canvas = getBoardOverlay();
const localState = createLocalState("canvas-tools", 2);
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
const legacyState = createLocalState("canvas", 3).load(null)
|| createLocalState("canvas", 2).load(null)
|| {};
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
const paintColor = document.getElementById("paint-color");
const backgroundColor = document.getElementById("background-color");
const brushSizeInput = document.getElementById("brush-size");
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
const pixelEditStatus = document.getElementById("pixel-edit-status");
const pixelGrid = document.getElementById("pixel-grid");
const boardStage = document.getElementById("board-stage");
const canvasWorkbench = document.querySelector(".canvas-workbench");
const startEditButton = document.getElementById("start-canvas-edit");
const assistive = document.getElementById("canvas-edit-assistive");
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
const assistiveMenu = document.getElementById("canvas-assistive-menu");
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
const undoButton = document.getElementById("undo-canvas-edit");
const exitEditButton = document.getElementById("exit-canvas-edit");
const moveViewButton = document.getElementById("move-canvas-view");
const continueEditButton = document.getElementById("continue-canvas-edit");
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
let tool = savedTools.tool;
let previousDrawingTool = tool;
let pixelEditEnabled = savedTools.pixelEditEnabled;
let normalBrushSize = savedTools.normalBrushSize;
let pixelBrushSize = savedTools.pixelBrushSize;
let drawing = false;
let drawingPointerId = null;
let lastPoint = null;
let drawingBefore = null;
let editorActive = false;
let editorMode = "draw";
let view = { scale: 1, x: 0, y: 0 };
let viewGesture = null;
let assistiveDrag = null;
let suppressAssistiveClick = false;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
const undoHistory = new PixelUndoHistory();
paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
function brushMode() {
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
}
function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
function setPixel(x, y, rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return;
const offset = (y * 64 + x) * 3;
const pixels = getPixelBuffer();
pixels[offset] = rgb.r;
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
}
function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
.forEach((point) => setPixel(point.x, point.y, rgb));
}
function paintLine(from, to) {
const rgb = activeRgb();
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
}
function paintEvent(event) {
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
for (const sample of samples.length ? samples : [event]) {
const point = boardPoint(sample);
paintLine(lastPoint || point, point);
lastPoint = point;
}
renderPixelDraft();
}
function pixelSnapshot() {
return Uint8Array.from(getPixelBuffer());
}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer());
updateUndoControl();
return recorded;
}
function viewportBounds() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
baseSize: Math.max(1, boardStage.offsetWidth),
minVisible: 48,
};
}
function applyCanvasView(next = view) {
view = clampCanvasView(next, viewportBounds());
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
}
function resetCanvasView() {
view = { scale: 1, x: 0, y: 0 };
applyCanvasView();
}
function applyAssistivePosition(next = assistivePosition) {
assistivePosition = clampFloatingButton(next, {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
size: 56,
inset: 8,
});
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
}
function resetAssistivePosition() {
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
}
function closeAssistiveMenu({ restoreFocus = false } = {}) {
assistiveMenu.hidden = true;
assistiveToggle.setAttribute("aria-expanded", "false");
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
if (restoreFocus && editorActive) assistiveToggle.focus();
}
function openAssistiveMenu() {
applyAssistivePosition();
assistiveMenu.hidden = false;
assistiveToggle.setAttribute("aria-expanded", "true");
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
firstAvailable?.focus();
}
function toggleAssistiveMenu() {
if (assistiveMenu.hidden) openAssistiveMenu();
else closeAssistiveMenu({ restoreFocus: true });
}
function updateEditorModeControls() {
const moving = editorMode === "move";
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
}
updateUndoControl();
}
function resetViewGesture() {
const points = Array.from(viewPointers.values());
if (points.length >= 2) {
const [first, second] = points;
viewGesture = {
kind: "pinch",
startView: { ...view },
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
};
} else if (points.length === 1) {
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
} else {
viewGesture = null;
}
}
function setEditorMode(mode) {
if (!editorActive) return;
if (mode === "move") finishDrawing();
editorMode = mode === "move" ? "move" : "draw";
viewPointers.clear();
viewGesture = null;
updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布");
}
function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return;
editorActive = true;
editorMode = "draw";
canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active");
assistive.hidden = false;
closeAssistiveMenu();
updateEditorModeControls();
window.requestAnimationFrame(() => {
resetCanvasView();
resetAssistivePosition();
assistiveToggle.focus();
});
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
}
function exitCanvasEditor({ returnToTools = true } = {}) {
if (!editorActive) return;
finishDrawing();
editorActive = false;
editorMode = "draw";
viewPointers.clear();
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
delete document.body.dataset.canvasEditorMode;
boardStage.style.removeProperty("--canvas-view-scale");
boardStage.style.removeProperty("--canvas-view-x");
boardStage.style.removeProperty("--canvas-view-y");
view = { scale: 1, x: 0, y: 0 };
if (returnToTools) {
window.requestAnimationFrame(() => {
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
startEditButton.focus({ preventScroll: true });
});
}
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function persistTools() {
localState.save({
paintColor: paintColor.value,
backgroundColor: backgroundColor.value,
tool,
pixelEditEnabled,
normalBrushSize,
pixelBrushSize,
});
}
function renderBrushSize() {
const mode = brushMode();
const brushSize = currentBrushSize();
const spec = brushModeSpec(mode);
brushSizeInput.min = String(spec.min);
brushSizeInput.max = String(spec.max);
brushSizeInput.value = String(brushSize);
document.getElementById("brush-size-value").textContent = pixelEditEnabled
? `${brushSize} × ${brushSize}`
: `${brushSize} px`;
const preview = document.getElementById("brush-size-preview");
const previewSize = Math.max(4, brushSize * 2);
preview.style.width = `${previewSize}px`;
preview.style.height = `${previewSize}px`;
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
brushSizePresets.forEach((button, index) => {
const value = spec.presets[index];
button.dataset.brushSize = String(value);
button.textContent = String(value);
});
setActiveButton(
brushSizePresets,
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
);
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
pixelEditStatus.hidden = !pixelEditEnabled;
pixelEditStatus.textContent = pixelEditEnabled
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
: "";
}
function setBrushSize(value) {
const normalized = normalizeBrushSize(value, brushMode());
if (pixelEditEnabled) pixelBrushSize = normalized;
else normalBrushSize = normalized;
renderBrushSize();
persistTools();
}
function updatePixelGridVisibility() {
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
}
function togglePixelEditMode() {
pixelEditEnabled = !pixelEditEnabled;
renderBrushSize();
updatePixelGridVisibility();
persistTools();
announce(pixelEditEnabled
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
}
function selectTool(button) {
tool = button.dataset.canvasTool;
if (tool !== "eyedropper") previousDrawingTool = tool;
setActiveButton(toolButtons, button);
persistTools();
}
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return;
if (editorMode === "move") {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture(event.pointerId);
resetViewGesture();
return;
}
if (drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3;
const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({
r: pixels[offset],
g: pixels[offset + 1],
b: pixels[offset + 2],
}), { recordHistory: true });
tool = previousDrawingTool;
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
persistTools();
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
return;
}
event.preventDefault();
drawing = true;
drawingPointerId = event.pointerId;
drawingBefore = pixelSnapshot();
lastPoint = boardPoint(event);
canvas.setPointerCapture(event.pointerId);
paintEvent(event);
});
canvas.addEventListener("pointermove", (event) => {
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
const points = Array.from(viewPointers.values());
if (viewGesture?.kind === "pinch" && points.length >= 2) {
const [first, second] = points;
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
const scaled = zoomCanvasView(
viewGesture.startView,
viewGesture.startView.scale * (distance / viewGesture.startDistance),
viewGesture.startMidpoint,
viewportBounds(),
);
applyCanvasView({
...scaled,
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
});
} else if (viewGesture?.kind === "pan" && points.length === 1) {
applyCanvasView({
...viewGesture.startView,
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
});
}
return;
}
if (drawing && drawingPointerId === event.pointerId && editorActive
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
});
function finishDrawing() {
if (!drawing) return;
drawing = false;
drawingPointerId = null;
lastPoint = null;
const changed = recordPixelOperation(drawingBefore);
drawingBefore = null;
if (changed) commitPixelChanges("pixel-draw");
persistTools();
}
function finishCanvasPointer(event) {
if (viewPointers.delete(event.pointerId)) {
resetViewGesture();
return;
}
if (drawingPointerId === event.pointerId) finishDrawing();
}
canvas.addEventListener("pointerup", finishCanvasPointer);
canvas.addEventListener("pointercancel", finishCanvasPointer);
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
canvas.addEventListener("wheel", (event) => {
if (!editorActive || editorMode !== "move") return;
event.preventDefault();
const factor = Math.exp(-event.deltaY * 0.002);
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
x: event.clientX,
y: event.clientY,
}, viewportBounds()));
}, { passive: false });
paintColor.addEventListener("input", persistTools);
backgroundColor.addEventListener("input", persistTools);
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
pixelEditToggle.addEventListener("click", togglePixelEditMode);
document.getElementById("fill-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]);
recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return;
event.preventDefault();
event.stopPropagation();
assistiveDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
origin: { ...assistivePosition },
moved: false,
};
assistiveToggle.setPointerCapture(event.pointerId);
});
assistiveToggle.addEventListener("pointermove", (event) => {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const dx = event.clientX - assistiveDrag.startX;
const dy = event.clientY - assistiveDrag.startY;
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
if (assistiveDrag.moved) {
closeAssistiveMenu();
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
}
});
function finishAssistiveDrag(event) {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
suppressAssistiveClick = assistiveDrag.moved;
assistiveDrag = null;
event.stopPropagation();
}
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
assistiveToggle.addEventListener("click", () => {
if (suppressAssistiveClick) {
suppressAssistiveClick = false;
return;
}
toggleAssistiveMenu();
});
document.addEventListener("keydown", (event) => {
if (!editorActive || event.key !== "Escape") return;
event.preventDefault();
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
else exitCanvasEditor();
});
window.addEventListener("resize", () => {
if (!editorActive) return;
applyCanvasView();
applyAssistivePosition();
});
document.addEventListener("matrix:composition-applied", clearUndoHistory);
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
refreshColorTrigger(paintColor);
refreshColorTrigger(backgroundColor);
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
renderBrushSize();
persistTools();
updateEditorModeControls();
registerWorkspace({
id: "canvas",
title: "像素画布",
elementId: "workspace-canvas",
order: 30,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter() {
renderPixelDraft();
updatePixelGridVisibility();
},
onLeave() {
exitCanvasEditor({ returnToTools: false });
finishDrawing();
pixelGrid.hidden = true;
},
});
@@ -0,0 +1,960 @@
import {
announce,
apiBlob,
apiJson,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshCurrentDisplayPreview } from "../scene-board.js";
import { refreshColorTrigger } from "../color-picker.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { renderUiCopy, renderUiValue } from "../ui-copy-runtime.js";
import {
LatestSeekDispatcher,
formatPlaybackTime,
projectPlaybackPosition,
} from "../current-display-playback.js";
const statusEls = {
orientation: document.getElementById("status-orientation"),
brightness: document.getElementById("status-brightness"),
currentContent: document.getElementById("topbar-current-content"),
cpu: document.getElementById("topbar-cpu"),
memory: document.getElementById("topbar-memory"),
lowVoltageWarning: document.getElementById("topbar-low-voltage-warning"),
voltage: document.getElementById("topbar-voltage"),
};
const fillButtons = Array.from(document.querySelectorAll("[data-test-fill]"));
const customFillButton = document.getElementById("custom-test-fill");
const customColor = document.getElementById("test-color");
const refreshStatusButton = document.getElementById("refresh-status");
const displayTestControls = document.getElementById("display-test-controls");
const testBrightness = document.getElementById("test-brightness");
const testBrightnessValue = document.getElementById("test-brightness-value");
const testBrightnessProtection = document.getElementById("test-brightness-protection");
const displayTestFeedback = document.getElementById("display-test-feedback");
const exitDisplayTest = document.getElementById("exit-display-test");
const storageEls = {
total: document.getElementById("device-total-space"),
free: document.getElementById("device-free-space"),
software: document.getElementById("device-software-size"),
templates: document.getElementById("device-template-size"),
feedback: document.getElementById("device-storage-feedback"),
};
const resourceDialog = document.getElementById("resource-details-dialog");
const resourceDialogClose = document.getElementById("resource-details-close");
const resourceDetailsMessage = document.getElementById("resource-details-message");
const resourceCoreList = document.getElementById("resource-core-list");
const resourceDetailEls = {
cpuAppCores: document.getElementById("resource-cpu-app-cores"),
cpuSinglePercent: document.getElementById("resource-cpu-single-percent"),
cpuAppPercent: document.getElementById("resource-cpu-app-percent"),
cpuTotalPercent: document.getElementById("resource-cpu-total-percent"),
memoryApp: document.getElementById("resource-memory-app"),
memoryAppPercent: document.getElementById("resource-memory-app-percent"),
memoryTotalPercent: document.getElementById("resource-memory-total-percent"),
};
const currentDisplayDialog = document.getElementById("current-display-dialog");
const currentDisplayDialogName = document.getElementById("current-display-dialog-name");
const currentDisplayDialogClose = document.getElementById("current-display-dialog-close");
const currentDisplayPreviewSurface = document.getElementById("current-display-preview-surface");
const currentDisplayPreviewImage = document.getElementById("current-display-preview-image");
const currentDisplayPreviewPlaceholder = document.getElementById("current-display-preview-placeholder");
const currentDisplayPlaybackControls = document.getElementById("current-display-playback-controls");
const currentDisplayProgress = document.getElementById("current-display-progress");
const currentDisplayProgressTime = document.getElementById("current-display-progress-time");
const currentDisplayPlayPause = document.getElementById("current-display-play-pause");
const currentDisplaySpeedButtons = Array.from(document.querySelectorAll("[data-playback-speed]"));
const currentDisplayPlaybackFeedback = document.getElementById("current-display-playback-feedback");
const currentDisplaySeekUnavailableReason = document.getElementById("current-display-seek-unavailable-reason");
let loadedOnce = false;
let lastStatusIdentity = null;
let latestResources = null;
let latestProtection = null;
let latestStatus = null;
let currentDisplayOpenIdentity = null;
let currentDisplayPlayback = null;
let currentDisplayPlaybackSampledAt = 0;
let currentDisplayPreviewTimer = null;
let currentDisplayPreviewPending = false;
let currentDisplayPreviewRequested = false;
let currentDisplayPreviewSequence = 0;
let currentDisplayPreviewUrl = null;
let currentDisplayAnimationFrame = null;
let currentDisplayDragging = false;
let currentDisplayFinalSeekPosition = null;
let currentDisplayControlPending = false;
let customTestColor = "#40A0FF";
let testActionPending = false;
let desiredTestBrightness = null;
let applyingTestBrightness = false;
let storageTimer = null;
const STORAGE_REFRESH_INTERVAL_MS = 300000;
const LOW_VOLTAGE_WARNING_TEXT = Object.freeze({
limiting: "电压过低,屏幕亮度限制",
critical: "电压过低,屏幕已经禁用,请充电",
});
function finiteNumber(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function formatPercent(value) {
const number = finiteNumber(value);
return number === null ? "-" : `${number.toFixed(1)}%`;
}
function formatMemoryDetailed(bytes) {
const number = finiteNumber(bytes);
if (number === null || number < 0) return "-";
const mebibytes = number / (1024 * 1024);
return `${mebibytes.toFixed(1)} MiB`;
}
function formatMemoryCompact(bytes) {
const number = finiteNumber(bytes);
if (number === null || number < 0) return "-";
return `${Math.round(number / (1024 * 1024))}M`;
}
function formatCoreEquivalent(value) {
const number = finiteNumber(value);
return number === null ? "-" : `${number.toFixed(1)}核`;
}
function renderCoreMeters(cpu) {
resourceCoreList.replaceChildren();
const count = Number.isInteger(Number(cpu.logical_cpu_count))
? Math.max(0, Number(cpu.logical_cpu_count))
: 0;
const values = Array.isArray(cpu.cores_percent) ? cpu.cores_percent : [];
for (let index = 0; index < count; index += 1) {
const value = finiteNumber(values[index]);
const row = document.createElement("div");
row.className = "resource-core-row";
const label = document.createElement("span");
label.textContent = `CPU ${index + 1}`;
const progress = document.createElement("progress");
progress.max = 100;
progress.value = value === null ? 0 : Math.max(0, Math.min(100, value));
progress.setAttribute("aria-label", `CPU ${index + 1} 占用`);
const output = document.createElement("output");
output.textContent = formatPercent(value);
row.append(label, progress, output);
resourceCoreList.appendChild(row);
}
}
function updateResourceDetails(resources) {
const status = resources?.status || "starting";
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const coreCount = finiteNumber(cpu.logical_cpu_count);
const coreEquivalent = finiteNumber(cpu.application_core_equivalent);
resourceDetailEls.cpuAppCores.textContent = coreEquivalent === null
? "-"
: `${coreEquivalent.toFixed(2)} / ${coreCount ?? "-"} 核`;
resourceDetailEls.cpuSinglePercent.textContent = coreEquivalent === null
? "-"
: formatPercent(coreEquivalent * 100);
resourceDetailEls.cpuAppPercent.textContent = formatPercent(cpu.application_percent);
resourceDetailEls.cpuTotalPercent.textContent = formatPercent(cpu.total_percent);
resourceDetailEls.memoryApp.textContent = formatMemoryDetailed(memory.application_bytes);
resourceDetailEls.memoryAppPercent.textContent = formatPercent(memory.application_percent);
resourceDetailEls.memoryTotalPercent.textContent = formatPercent(memory.total_percent);
renderCoreMeters(cpu);
if (status === "error") {
resourceDetailsMessage.textContent = "资源读取异常,屏幕、电压和其他控制功能不受影响。";
} else if (status !== "ok") {
resourceDetailsMessage.textContent = "正在收集资源样本…";
} else if (coreEquivalent === null) {
resourceDetailsMessage.textContent = "正在等待第二个 CPU 差分样本;内存数据已可用。";
} else {
resourceDetailsMessage.textContent = "";
}
resourceDetailsMessage.hidden = resourceDetailsMessage.textContent === "";
}
function showTopbarResources(resources) {
latestResources = resources || null;
const status = resources?.status || "starting";
statusEls.cpu.dataset.status = status;
statusEls.memory.dataset.status = status;
if (status === "error") {
statusEls.cpu.textContent = "CPU !";
statusEls.memory.textContent = "RAM !";
statusEls.cpu.setAttribute("aria-label", "CPU 读取异常,查看资源占用详情");
statusEls.memory.setAttribute("aria-label", "内存读取异常,查看资源占用详情");
updateResourceDetails(resources);
return;
}
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const cpuCompact = finiteNumber(cpu.application_core_equivalent) === null
? "CPU -"
: `CPU ${formatCoreEquivalent(cpu.application_core_equivalent)} · ${formatPercent(cpu.total_percent)}`;
const memoryCompact = finiteNumber(memory.application_bytes) === null
? "RAM -"
: `RAM ${formatMemoryCompact(memory.application_bytes)} · ${formatPercent(memory.total_percent)}`;
statusEls.cpu.textContent = cpuCompact;
statusEls.memory.textContent = memoryCompact;
statusEls.cpu.setAttribute("aria-label", `${cpuCompact},查看 CPU 占用详情`);
statusEls.memory.setAttribute("aria-label", `${memoryCompact},查看内存占用详情`);
updateResourceDetails(resources);
}
function openResourceDetails() {
updateResourceDetails(latestResources);
if (!resourceDialog.open) resourceDialog.showModal();
resourceDialogClose.focus();
}
statusEls.cpu.addEventListener("click", openResourceDetails);
statusEls.memory.addEventListener("click", openResourceDetails);
function activeCurrentDisplayPlayback(status = latestStatus) {
const content = status?.state?.current_content;
const playback = status?.state?.animation_playback;
if (
content?.category !== "animation"
|| playback?.active !== true
|| typeof playback.session_id !== "string"
|| playback.animation_id !== content.id
) return null;
return playback;
}
function currentDisplayIdentity(status = latestStatus) {
const content = status?.state?.current_content || {};
const playback = activeCurrentDisplayPlayback(status);
return [
content.category || "unsaved",
content.id || "",
content.name || "未保存内容",
playback?.session_id || "",
].join(":");
}
function setCurrentDisplayPlaybackFeedback(copyId = "", defaultText = "", values = {}, state = "idle") {
if (copyId) renderUiCopy(copyId, currentDisplayPlaybackFeedback, defaultText, values);
else renderUiValue(currentDisplayPlaybackFeedback, "");
currentDisplayPlaybackFeedback.dataset.state = state;
currentDisplayPlaybackFeedback.hidden = !defaultText;
}
function syncCurrentDisplayControlAvailability() {
const controls = [currentDisplayPlayPause, ...currentDisplaySpeedButtons];
if (currentDisplayControlPending) {
[currentDisplayProgress, ...controls].forEach((control) => setControlBusy(control));
return;
}
setControlReady(currentDisplayProgress);
if (currentDisplayDragging) {
const reason = currentDisplaySeekUnavailableReason.textContent || "请先完成当前进度跳转。";
controls.forEach((control) => setControlUnavailable(control, reason));
} else {
controls.forEach((control) => setControlReady(control));
}
}
function renderCurrentDisplayPosition(positionMs, { force = false } = {}) {
if (!currentDisplayPlayback) return;
const total = Math.max(1, Number(currentDisplayPlayback.total_duration_ms) || 1);
const position = Math.max(0, Math.min(total - 1, Number(positionMs) || 0));
if (force || !currentDisplayDragging) currentDisplayProgress.value = String(Math.floor(position));
currentDisplayProgressTime.textContent = `${formatPlaybackTime(position)} / ${formatPlaybackTime(total)}`;
}
function applyCurrentDisplayPlayback(playback, { preservePosition = currentDisplayDragging } = {}) {
if (!playback?.active) return;
currentDisplayPlayback = { ...playback };
currentDisplayPlaybackSampledAt = performance.now();
const total = Math.max(1, Number(playback.total_duration_ms) || 1);
currentDisplayProgress.max = String(total - 1);
renderUiCopy(
playback.paused ? "copy.dynamic.current_display.resume" : "copy.dynamic.current_display.pause",
currentDisplayPlayPause,
playback.paused ? "继续播放" : "暂停",
);
currentDisplayPlayPause.setAttribute("aria-pressed", String(Boolean(playback.paused)));
currentDisplaySpeedButtons.forEach((button) => {
button.setAttribute("aria-pressed", String(Number(button.dataset.playbackSpeed) === Number(playback.speed)));
});
if (!preservePosition) renderCurrentDisplayPosition(playback.position_ms, { force: true });
syncCurrentDisplayControlAvailability();
if (currentDisplayAnimationFrame === null) {
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
}
}
function animateCurrentDisplayProgress(now) {
currentDisplayAnimationFrame = null;
if (!currentDisplayDialog.open || currentDisplayPlaybackControls.hidden || !currentDisplayPlayback) return;
if (!currentDisplayDragging) {
renderCurrentDisplayPosition(
projectPlaybackPosition(currentDisplayPlayback, now - currentDisplayPlaybackSampledAt),
);
}
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
}
function scheduleCurrentDisplayPreview(delay = null) {
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
const configured = Number(latestStatus?.ui?.preview_refresh_interval_ms);
const wait = delay === null ? Math.max(1, Number.isFinite(configured) ? configured : 1000) : delay;
currentDisplayPreviewTimer = window.setTimeout(() => {
currentDisplayPreviewTimer = null;
refreshCurrentDisplayDialogFrame();
}, wait);
}
async function refreshCurrentDisplayDialogFrame() {
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
if (currentDisplayPreviewPending) {
currentDisplayPreviewRequested = true;
return;
}
currentDisplayPreviewPending = true;
currentDisplayPreviewRequested = false;
const sequence = currentDisplayPreviewSequence;
try {
const blob = await apiBlob("/api/display/current-frame");
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
const nextUrl = URL.createObjectURL(blob);
const previousUrl = currentDisplayPreviewUrl;
currentDisplayPreviewUrl = nextUrl;
currentDisplayPreviewImage.src = nextUrl;
currentDisplayPreviewImage.hidden = false;
currentDisplayPreviewPlaceholder.hidden = true;
if (previousUrl !== null) URL.revokeObjectURL(previousUrl);
} catch (error) {
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
currentDisplayPreviewImage.hidden = true;
currentDisplayPreviewPlaceholder.hidden = false;
renderUiCopy(
"copy.dynamic.current_display.load_failed",
currentDisplayPreviewPlaceholder,
"当前画面读取失败:{reason}",
{ reason: error.message },
);
} finally {
currentDisplayPreviewPending = false;
if (!currentDisplayDialog.open) return;
if (sequence !== currentDisplayPreviewSequence) {
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
return;
}
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
else scheduleCurrentDisplayPreview();
}
}
function requestCurrentDisplayPreviewRefresh() {
if (currentDisplayPreviewPending) {
currentDisplayPreviewRequested = true;
return;
}
scheduleCurrentDisplayPreview(0);
}
function cleanupCurrentDisplayDialog() {
currentDisplayOpenIdentity = null;
currentDisplayPlayback = null;
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
currentDisplayControlPending = false;
currentDisplayPreviewSequence += 1;
currentDisplayPreviewRequested = false;
seekDispatcher.clear();
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
currentDisplayPreviewTimer = null;
if (currentDisplayAnimationFrame !== null) window.cancelAnimationFrame(currentDisplayAnimationFrame);
currentDisplayAnimationFrame = null;
if (currentDisplayPreviewUrl !== null) URL.revokeObjectURL(currentDisplayPreviewUrl);
currentDisplayPreviewUrl = null;
currentDisplayPreviewImage.removeAttribute("src");
currentDisplayPreviewImage.hidden = true;
currentDisplayPreviewPlaceholder.hidden = false;
renderUiCopy(
"copy.dynamic.current_display.loading",
currentDisplayPreviewPlaceholder,
"正在读取当前画面…",
);
setCurrentDisplayPlaybackFeedback();
}
function closeCurrentDisplayDialog() {
if (currentDisplayDialog.open) currentDisplayDialog.close();
else cleanupCurrentDisplayDialog();
}
async function openCurrentDisplayDialog() {
if (currentDisplayDialog.open) return;
setControlBusy(statusEls.currentContent);
try {
await refreshStatus({ source: "manual" });
} catch (_error) {
if (!latestStatus) return;
} finally {
setControlReady(statusEls.currentContent);
}
const content = latestStatus?.state?.current_content || { name: "未保存内容" };
const playback = activeCurrentDisplayPlayback();
currentDisplayOpenIdentity = currentDisplayIdentity();
currentDisplayDialogName.textContent = content.name || "未保存内容";
currentDisplayPlaybackControls.hidden = !playback;
currentDisplayDialog.showModal();
currentDisplayPreviewSequence += 1;
if (playback) applyCurrentDisplayPlayback(playback, { preservePosition: false });
refreshCurrentDisplayDialogFrame();
if (playback) currentDisplayProgress.focus();
else currentDisplayPreviewSurface.focus();
}
function syncCurrentDisplayDialogStatus(status) {
latestStatus = status;
if (!currentDisplayDialog.open) return;
const identity = currentDisplayIdentity(status);
if (identity !== currentDisplayOpenIdentity) {
closeCurrentDisplayDialog();
return;
}
const playback = activeCurrentDisplayPlayback(status);
if (playback) applyCurrentDisplayPlayback(playback);
}
async function sendCurrentDisplayPlaybackPatch(fields) {
const sessionId = currentDisplayPlayback?.session_id;
if (!sessionId) throw new Error("当前动图播放会话已经结束");
const result = await apiJson("/api/display/animation-playback", {
method: "PATCH",
body: JSON.stringify({ session_id: sessionId, ...fields }),
});
const playback = result.animation_playback;
if (
currentDisplayDialog.open
&& currentDisplayPlayback?.session_id === sessionId
&& playback?.session_id === sessionId
) {
if (latestStatus?.state) latestStatus.state.animation_playback = playback;
if (Number.isInteger(result.state_revision) && latestStatus?.service?.instance_id) {
lastStatusIdentity = `${latestStatus.service.instance_id}:${result.state_revision}`;
}
applyCurrentDisplayPlayback(playback);
requestCurrentDisplayPreviewRefresh();
}
return playback;
}
function handleCurrentDisplayPlaybackError(error) {
const defaultText = "动图播放控制失败:{reason}";
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.control_failed",
defaultText,
{ reason: error.message },
"error",
);
announce(`动图播放控制失败:${error.message}`, true);
if (error.status === 409) {
closeCurrentDisplayDialog();
refreshStatus({ source: "manual" }).catch(() => {});
}
}
async function sendCurrentDisplaySeek(positionMs) {
const playback = await sendCurrentDisplayPlaybackPatch({ position_ms: Math.round(positionMs) });
if (currentDisplayFinalSeekPosition === positionMs) {
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
applyCurrentDisplayPlayback(playback, { preservePosition: false });
syncCurrentDisplayControlAvailability();
}
}
const seekDispatcher = new LatestSeekDispatcher(sendCurrentDisplaySeek, {
intervalMs: 100,
onError: (error, positionMs) => {
if (currentDisplayFinalSeekPosition === positionMs) {
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
syncCurrentDisplayControlAvailability();
}
handleCurrentDisplayPlaybackError(error);
},
});
statusEls.currentContent.addEventListener("click", openCurrentDisplayDialog);
currentDisplayDialogClose.addEventListener("click", closeCurrentDisplayDialog);
currentDisplayDialog.addEventListener("close", cleanupCurrentDisplayDialog);
currentDisplayDialog.addEventListener("click", (event) => {
if (currentDisplayPlaybackControls.hidden || event.target === currentDisplayDialog) {
closeCurrentDisplayDialog();
}
});
currentDisplayProgress.addEventListener("pointerdown", () => {
currentDisplayDragging = true;
currentDisplayFinalSeekPosition = null;
syncCurrentDisplayControlAvailability();
});
currentDisplayProgress.addEventListener("input", () => {
currentDisplayDragging = true;
currentDisplayFinalSeekPosition = null;
const position = Number(currentDisplayProgress.value);
renderCurrentDisplayPosition(position, { force: true });
syncCurrentDisplayControlAvailability();
seekDispatcher.enqueue(position);
});
function flushCurrentDisplaySeek() {
if (!currentDisplayDragging) return;
const position = Number(currentDisplayProgress.value);
currentDisplayFinalSeekPosition = position;
seekDispatcher.enqueue(position, { flush: true });
}
currentDisplayProgress.addEventListener("change", flushCurrentDisplaySeek);
currentDisplayProgress.addEventListener("pointerup", flushCurrentDisplaySeek);
currentDisplayProgress.addEventListener("pointercancel", flushCurrentDisplaySeek);
currentDisplayPlayPause.addEventListener("click", async () => {
if (!currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging) return;
currentDisplayControlPending = true;
syncCurrentDisplayControlAvailability();
setCurrentDisplayPlaybackFeedback(
currentDisplayPlayback.paused
? "copy.dynamic.current_display.resuming"
: "copy.dynamic.current_display.pausing",
currentDisplayPlayback.paused ? "正在继续播放…" : "正在暂停…",
{},
"pending",
);
try {
const playback = await sendCurrentDisplayPlaybackPatch({ paused: !currentDisplayPlayback.paused });
setCurrentDisplayPlaybackFeedback(
playback.paused
? "copy.dynamic.current_display.paused"
: "copy.dynamic.current_display.resumed",
playback.paused ? "动图已暂停" : "动图继续播放",
{},
"success",
);
} catch (error) {
handleCurrentDisplayPlaybackError(error);
} finally {
currentDisplayControlPending = false;
syncCurrentDisplayControlAvailability();
}
});
currentDisplaySpeedButtons.forEach((button) => {
button.addEventListener("click", async () => {
const speed = Number(button.dataset.playbackSpeed);
if (
!currentDisplayPlayback
|| currentDisplayControlPending
|| currentDisplayDragging
|| speed === Number(currentDisplayPlayback.speed)
) return;
currentDisplayControlPending = true;
syncCurrentDisplayControlAvailability();
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.speed_changing",
"正在切换到 {speed}×…",
{ speed },
"pending",
);
try {
await sendCurrentDisplayPlaybackPatch({ speed });
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.speed_changed",
"已切换到 {speed}×",
{ speed },
"success",
);
} catch (error) {
handleCurrentDisplayPlaybackError(error);
} finally {
currentDisplayControlPending = false;
syncCurrentDisplayControlAvailability();
}
});
});
document.addEventListener("visibilitychange", () => {
if (!currentDisplayDialog.open) return;
if (document.visibilityState === "visible") requestCurrentDisplayPreviewRefresh();
else if (currentDisplayPreviewTimer !== null) {
window.clearTimeout(currentDisplayPreviewTimer);
currentDisplayPreviewTimer = null;
}
});
function showTopbarVoltage(voltage) {
const status = voltage?.status || "starting";
statusEls.voltage.dataset.status = status;
if (status === "ok" && Number.isFinite(Number(voltage.volts))) {
renderUiCopy(
"copy.dynamic.device.voltage_ok",
statusEls.voltage,
"屏幕输入电压 {volts} V",
{ volts: Number(voltage.volts).toFixed(2) },
);
} else if (status === "disconnected") {
renderUiCopy("copy.dynamic.device.voltage_disconnected", statusEls.voltage, "电压传感器断开");
} else if (status === "error") {
renderUiCopy("copy.dynamic.device.voltage_error", statusEls.voltage, "电压读取异常");
} else {
renderUiCopy("copy.dynamic.device.voltage_loading", statusEls.voltage, "正在读取电压…");
}
}
function showCurrentContent(state) {
const name = typeof state?.current_content?.name === "string"
? state.current_content.name
: "未保存内容";
renderUiCopy(
"copy.dynamic.device.current_content",
statusEls.currentContent,
"当前画面:{name}",
{ name },
);
}
function showTopbarLowVoltageProtection(protection) {
const requestedMode = typeof protection?.mode === "string" ? protection.mode : "unavailable";
const warningMode = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, requestedMode);
const mode = warningMode
? requestedMode
: ["disabled", "unavailable", "inactive"].includes(requestedMode)
? requestedMode
: "unavailable";
const previousMode = statusEls.lowVoltageWarning.dataset.mode;
const visible = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, mode);
statusEls.lowVoltageWarning.dataset.mode = mode;
if (!visible) {
statusEls.lowVoltageWarning.hidden = true;
if (previousMode !== mode) statusEls.lowVoltageWarning.textContent = "";
return;
}
if (previousMode !== mode) {
statusEls.lowVoltageWarning.textContent = LOW_VOLTAGE_WARNING_TEXT[mode];
}
statusEls.lowVoltageWarning.hidden = false;
}
function showDeviceBrightness(state) {
const userBrightness = finiteNumber(state?.brightness);
const effectiveBrightness = finiteNumber(state?.effective_brightness);
if (userBrightness === null) {
statusEls.brightness.textContent = "-";
return;
}
if (effectiveBrightness === null) {
statusEls.brightness.textContent = `${userBrightness}%`;
return;
}
statusEls.brightness.textContent = `用户 ${userBrightness}% · 实际 ${effectiveBrightness}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && size >= 1024; index += 1) {
size /= 1024;
unit = units[index];
}
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
}
function normalizedHex(value) {
const match = /^#([0-9A-Fa-f]{6})$/.exec(String(value || "").trim());
return match ? `#${match[1].toUpperCase()}` : null;
}
function showTestColor(value, { editable = false } = {}) {
const color = normalizedHex(value) || customTestColor;
customColor.value = color;
customColor.dataset.colorReadonly = String(!editable);
customColor.setAttribute("aria-label", editable ? "编辑自定义测试颜色" : "查看测试颜色");
refreshColorTrigger(customColor);
}
function setDisplayTestFeedback(text = "", state = "idle") {
displayTestFeedback.textContent = text;
displayTestFeedback.dataset.state = state;
displayTestFeedback.hidden = !text;
}
function showTestProtection(testValue, effectiveValue) {
const mode = latestProtection?.mode;
if (mode !== "limiting" && mode !== "critical") {
testBrightnessProtection.hidden = true;
testBrightnessProtection.textContent = "";
return;
}
testBrightnessProtection.dataset.mode = mode;
if (mode === "critical") {
testBrightnessProtection.textContent = "电压过低,纯色测试暂时由 35% 亮度的低电图标覆盖;退出或恢复电压前,测试颜色和亮度会保留。";
} else {
const limit = finiteNumber(latestProtection?.brightness_limit_percent);
const effective = finiteNumber(effectiveValue);
testBrightnessProtection.textContent = `电压过低,当前允许的最高亮度为 ${limit ?? "-"}%;测试设置 ${testValue}%,实际 ${effective ?? "-"}%。`;
}
testBrightnessProtection.hidden = false;
}
function syncDisplayTest(state, protection = latestProtection) {
latestProtection = protection || null;
const displayTest = state?.display_test || {};
const active = displayTest.active === true;
displayTestControls.hidden = !active;
if (!active) {
setActiveButton(fillButtons, null);
customFillButton.classList.remove("active");
customFillButton.setAttribute("aria-pressed", "false");
showTestColor(customTestColor);
showTestProtection(50, state?.effective_brightness);
return;
}
const color = normalizedHex(displayTest.color) || customTestColor;
const brightness = Math.max(1, Math.min(100, Number(displayTest.brightness) || 50));
const matchingFixed = fillButtons.find((button) => button.dataset.testFill === color) || null;
setActiveButton(fillButtons, matchingFixed);
customFillButton.classList.toggle("active", !matchingFixed);
customFillButton.setAttribute("aria-pressed", String(!matchingFixed));
testBrightness.value = String(brightness);
testBrightnessValue.textContent = `${brightness}%`;
showTestColor(color, { editable: !matchingFixed });
showTestProtection(brightness, state?.effective_brightness);
}
export async function refreshDeviceStorage() {
storageEls.feedback.textContent = "正在读取设备容量…";
storageEls.feedback.dataset.state = "pending";
storageEls.feedback.hidden = false;
try {
const data = await apiJson("/api/device-storage");
storageEls.total.textContent = formatBytes(data.device_total_bytes);
storageEls.free.textContent = formatBytes(data.device_free_bytes);
storageEls.software.textContent = formatBytes(data.software_bytes);
storageEls.templates.textContent = formatBytes(data.templates_bytes);
storageEls.feedback.textContent = "";
storageEls.feedback.hidden = true;
return data;
} catch (error) {
storageEls.feedback.textContent = `容量读取失败:${error.message}`;
storageEls.feedback.dataset.state = "error";
announce(storageEls.feedback.textContent, true);
throw error;
}
}
export async function refreshStatus({ source = "manual" } = {}) {
try {
const data = await apiJson("/api/status");
syncCurrentDisplayDialogStatus(data);
statusEls.orientation.textContent = `${data.state.orientation}°`;
showDeviceBrightness(data.state);
showCurrentContent(data.state);
showTopbarResources(data.resources);
showTopbarLowVoltageProtection(data.power?.low_voltage_protection);
showTopbarVoltage(data.power?.screen_input_voltage);
syncDisplayTest(data.state, data.power?.low_voltage_protection);
const statusIdentity = `${data.service?.instance_id || "legacy"}:${data.state.revision ?? "legacy"}`;
const externalChange = source === "poll" && lastStatusIdentity !== null && statusIdentity !== lastStatusIdentity;
lastStatusIdentity = statusIdentity;
document.dispatchEvent(new CustomEvent("matrix:status", {
detail: { ...data, externalChange },
}));
if (externalChange) {
announce("设备状态已由其他页面更新;本页画板未被自动应用");
}
return data;
} catch (error) {
if (source !== "poll") announce(`状态读取失败:${error.message}`, true);
throw error;
}
}
fillButtons.forEach((button) => {
button.addEventListener("click", () => startDisplayTest(
button,
button.dataset.testFill,
button.textContent.trim(),
));
});
async function startDisplayTest(button, color, label, { openEditor = false } = {}) {
if (testActionPending) return;
testActionPending = true;
[...fillButtons, customFillButton].forEach((control) => setControlBusy(control));
setDisplayTestFeedback(`正在显示${label}…`, "pending");
try {
await apiJson("/api/display/test/fill", {
method: "POST",
body: JSON.stringify({ color }),
});
showTestColor(color, { editable: openEditor });
setDisplayTestFeedback(`${label}测试已开始`, "success");
announce(`${label}测试已开始`);
refreshCurrentDisplayPreview();
await refreshStatus();
if (openEditor) {
showTestColor(color, { editable: true });
customColor.click();
}
} catch (error) {
setDisplayTestFeedback(`纯色测试失败:${error.message}`, "error");
announce(`纯色测试失败:${error.message}`, true);
} finally {
testActionPending = false;
[...fillButtons, customFillButton].forEach((control) => setControlReady(control));
}
}
customFillButton.addEventListener("click", () => startDisplayTest(
customFillButton,
customTestColor,
"自定义纯色",
{ openEditor: true },
));
customColor.addEventListener("change", async () => {
if (customColor.dataset.colorReadonly === "true") return;
const requested = normalizedHex(customColor.value);
if (!requested || requested === customTestColor) return;
setControlBusy(customColor, { message: "正在保存并应用自定义纯色…" });
setDisplayTestFeedback("正在保存并应用自定义纯色…", "pending");
try {
const config = await apiJson("/api/config", {
method: "PUT",
body: JSON.stringify({ custom_test_color: requested }),
});
customTestColor = normalizedHex(config.custom_test_color) || requested;
await apiJson("/api/display/test/fill", {
method: "POST",
body: JSON.stringify({ color: customTestColor }),
});
setDisplayTestFeedback(`自定义纯色 ${customTestColor} 已保存并应用`, "success");
announce(`自定义纯色 ${customTestColor} 已保存并应用`);
refreshCurrentDisplayPreview();
await refreshStatus();
} catch (error) {
setDisplayTestFeedback(`自定义纯色保存或应用失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
await refreshStatus().catch(() => {});
} finally {
setControlReady(customColor);
}
});
async function processTestBrightnessQueue() {
if (applyingTestBrightness) return;
applyingTestBrightness = true;
while (desiredTestBrightness !== null) {
const requested = desiredTestBrightness;
desiredTestBrightness = null;
setDisplayTestFeedback(`正在应用测试亮度 ${requested}%…`, "pending");
try {
const response = await apiJson("/api/display/test/brightness", {
method: "PUT",
body: JSON.stringify({ brightness: requested }),
});
const applied = Number(response.display_test?.brightness) || requested;
testBrightness.value = String(applied);
testBrightnessValue.textContent = `${applied}%`;
showTestProtection(applied, response.effective_brightness);
setDisplayTestFeedback(`测试亮度 ${applied}% 已生效,不会修改用户亮度`, "success");
} catch (error) {
setDisplayTestFeedback(`测试亮度应用失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
if (desiredTestBrightness === null) await refreshStatus().catch(() => {});
}
}
applyingTestBrightness = false;
refreshCurrentDisplayPreview();
await refreshStatus().catch(() => {});
}
function queueTestBrightness(value) {
desiredTestBrightness = Math.max(1, Math.min(100, Number(value)));
void processTestBrightnessQueue();
}
testBrightness.addEventListener("input", () => {
testBrightnessValue.textContent = `${testBrightness.value}%`;
showTestProtection(testBrightness.value, null);
queueTestBrightness(testBrightness.value);
});
testBrightness.addEventListener("change", () => queueTestBrightness(testBrightness.value));
exitDisplayTest.addEventListener("click", async () => {
setControlBusy(exitDisplayTest, { label: "正在退出…", message: "正在退出纯色测试…" });
setDisplayTestFeedback("正在退出纯色测试…", "pending");
try {
await apiJson("/api/display/test", { method: "DELETE" });
setDisplayTestFeedback();
announce("已退出纯色测试,恢复进入测试前的设备状态");
refreshCurrentDisplayPreview();
await refreshStatus();
} catch (error) {
setDisplayTestFeedback(`退出测试失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
} finally {
setControlReady(exitDisplayTest);
}
});
refreshStatusButton.addEventListener("click", async () => {
setControlBusy(refreshStatusButton, { label: "正在刷新…" });
announce("正在刷新设备状态…");
try {
await refreshStatus();
announce("设备状态已刷新");
} catch (_error) {
// refreshStatus already reports the failure.
} finally {
setControlReady(refreshStatusButton);
}
});
registerWorkspace({
id: "device",
title: "设备状态和测试",
elementId: "workspace-device",
order: 10,
persistence: "none",
async onEnter() {
if (!loadedOnce) {
loadedOnce = true;
try {
const config = await apiJson("/api/config");
customTestColor = normalizedHex(config.custom_test_color) || customTestColor;
showTestColor(customTestColor);
} catch (error) {
announce(`自定义纯色读取失败:${error.message}`, true);
}
}
await refreshStatus();
await refreshDeviceStorage().catch(() => {});
window.clearInterval(storageTimer);
storageTimer = window.setInterval(() => refreshDeviceStorage().catch(() => {}), STORAGE_REFRESH_INTERVAL_MS);
},
onLeave() {
window.clearInterval(storageTimer);
storageTimer = null;
},
});
document.addEventListener("matrix:composition-applied", () => refreshStatus().catch(() => {}));
@@ -0,0 +1,322 @@
import { announce, apiJson } from "../core.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
const selectedName = document.getElementById("frp-selected-name");
const serviceState = document.getElementById("frp-service-state");
const feedback = document.getElementById("frp-feedback");
const serviceToggle = document.getElementById("frp-service-toggle");
const editButton = document.getElementById("frp-edit-config");
const diagnosticsButton = document.getElementById("network-diagnostics-run");
const dialog = document.getElementById("frp-config-dialog");
const configSelect = document.getElementById("frp-config-select");
const configName = document.getElementById("frp-config-name");
const configFormat = document.getElementById("frp-config-format");
const configContent = document.getElementById("frp-config-content");
const configFile = document.getElementById("frp-config-file");
const configFeedback = document.getElementById("frp-config-feedback");
const newButton = document.getElementById("frp-config-new");
const uploadButton = document.getElementById("frp-config-upload");
const deleteButton = document.getElementById("frp-config-delete");
const closeButton = document.getElementById("frp-config-close");
const saveButton = document.getElementById("frp-config-save");
const diagnosticsDialog = document.getElementById("network-diagnostics-dialog");
const diagnosticsSummary = document.getElementById("network-diagnostics-summary");
const diagnosticsChecks = document.getElementById("network-diagnostics-checks");
const diagnosticsClose = document.getElementById("network-diagnostics-close");
const SUPPORTED_SUFFIXES = new Set([".toml", ".yaml", ".yml", ".json", ".ini"]);
const MAX_CONFIG_BYTES = 1024 * 1024;
let state = null;
let editingId = null;
let generation = 0;
function setMessage(element, text, kind = "idle") {
element.textContent = text;
element.dataset.state = kind;
element.hidden = !text;
}
function profileById(id) {
return state?.profiles?.find((profile) => profile.id === id) || null;
}
function serviceLabel(service) {
if (!service?.installed) return "frpc 未安装";
if (service.running) return service.enabled ? "运行中(开机自动启动)" : "运行中";
if (service.state === "failed") return "启动失败";
return service.enabled ? "等待启动" : "关闭";
}
function renderState(next) {
state = next;
const selected = profileById(next?.selected_id);
selectedName.textContent = selected?.name || "无";
serviceState.textContent = serviceLabel(next?.service);
const running = next?.service?.running === true;
serviceToggle.textContent = running || next?.service?.enabled ? "关闭frp" : "启动frp";
if (!next?.available) {
setControlUnavailable(serviceToggle, next?.error || "FRP 配置目录不可用。");
setMessage(feedback, next?.error || "FRP 配置目录不可用。", "error");
} else if (!next?.service?.installed) {
setControlUnavailable(serviceToggle, "frpc 系统软件尚未安装。");
setMessage(feedback, "frpc 系统软件尚未安装。", "error");
} else if (!selected && !running) {
setControlUnavailable(serviceToggle, "请先导入或新建 FRP 配置。");
setMessage(feedback, "尚未保存 FRP 配置。", "idle");
} else {
setControlReady(serviceToggle);
setMessage(feedback, next?.service?.last_error || "", next?.service?.last_error ? "error" : "idle");
}
}
function renderProfileOptions() {
configSelect.replaceChildren();
if (!state?.profiles?.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "暂无配置";
configSelect.append(option);
configSelect.disabled = true;
} else {
configSelect.disabled = false;
state.profiles.forEach((profile) => {
const option = document.createElement("option");
option.value = profile.id;
option.textContent = profile.name;
configSelect.append(option);
});
configSelect.value = editingId || state.selected_id || state.profiles[0].id;
}
deleteButton.disabled = !editingId;
}
async function requestText(path, options = {}) {
const response = await fetch(path, { ...options, cache: "no-store" });
if (!response.ok) {
let message = response.statusText;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
return response.text();
}
async function loadProfile(id) {
const currentGeneration = generation;
const profile = profileById(id);
if (!profile) {
editingId = null;
configName.value = "";
configContent.value = "";
renderProfileOptions();
return;
}
setMessage(configFeedback, "正在读取配置…");
const content = await requestText(`/api/frp/configs/${encodeURIComponent(id)}`);
if (generation !== currentGeneration) return;
editingId = id;
configName.value = profile.name;
const suffix = profile.filename.slice(profile.filename.lastIndexOf(".")).toLowerCase();
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
configContent.value = content;
renderProfileOptions();
setMessage(configFeedback, "");
}
export async function loadFrpSettings() {
const currentGeneration = ++generation;
try {
const next = await apiJson("/api/frp");
if (generation !== currentGeneration) return;
renderState(next);
} catch (error) {
setMessage(feedback, `FRP 状态读取失败:${error.message}`, "error");
}
}
export function discardFrpDrafts() {
generation += 1;
editingId = null;
configName.value = "";
configContent.value = "";
configFile.value = "";
setMessage(configFeedback, "");
if (dialog.open) dialog.close();
if (diagnosticsDialog.open) diagnosticsDialog.close();
}
editButton.addEventListener("click", async () => {
try {
await loadFrpSettings();
editingId = state?.selected_id || state?.profiles?.[0]?.id || null;
renderProfileOptions();
if (editingId) await loadProfile(editingId);
else {
configName.value = "";
configFormat.value = ".toml";
configContent.value = "";
}
dialog.showModal();
(editingId ? configContent : configName).focus();
} catch (error) {
announce(`打开 FRP 配置失败:${error.message}`, true);
}
});
closeButton.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => {
configFile.value = "";
setMessage(configFeedback, "");
});
newButton.addEventListener("click", () => {
editingId = null;
configSelect.value = "";
configName.value = "";
configFormat.value = ".toml";
configContent.value = "";
deleteButton.disabled = true;
setMessage(configFeedback, "请输入完整配置;保存前会使用 frpc 官方校验。", "idle");
configName.focus();
});
uploadButton.addEventListener("click", () => configFile.click());
configFile.addEventListener("change", async () => {
const file = configFile.files?.[0];
if (!file) return;
const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (!SUPPORTED_SUFFIXES.has(suffix)) {
setMessage(configFeedback, "请选择 TOML、YAML、JSON 或 INI 配置文件。", "error");
return;
}
if (file.size > MAX_CONFIG_BYTES) {
setMessage(configFeedback, "配置文件不能超过 1 MiB。", "error");
return;
}
try {
const content = await file.text();
editingId = null;
configName.value = file.name.slice(0, -suffix.length);
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
configContent.value = content;
deleteButton.disabled = true;
setMessage(configFeedback, "文件已读取,点击保存后执行官方校验。", "idle");
} catch (error) {
setMessage(configFeedback, `读取文件失败:${error.message}`, "error");
} finally {
configFile.value = "";
}
});
configSelect.addEventListener("change", async () => {
const id = configSelect.value;
if (!id) return;
try {
if (id !== state?.selected_id) {
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(id)}/select`, { method: "POST" });
renderState(result.frp);
}
await loadProfile(id);
} catch (error) {
setMessage(configFeedback, `切换配置失败:${error.message}`, "error");
renderProfileOptions();
}
});
saveButton.addEventListener("click", async () => {
const name = configName.value.trim();
const content = configContent.value;
if (!name || !content.trim()) {
setMessage(configFeedback, "请输入配置名称和完整配置内容。", "error");
return;
}
setControlBusy(saveButton, { label: "正在校验…", message: "正在使用 frpc 校验配置" });
try {
const suffix = configFormat.value;
const path = editingId
? `/api/frp/configs/${encodeURIComponent(editingId)}?name=${encodeURIComponent(name)}`
: `/api/frp/configs?filename=${encodeURIComponent(`frpc${suffix}`)}&name=${encodeURIComponent(name)}`;
const response = await fetch(path, {
method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: content,
cache: "no-store",
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || response.statusText);
editingId = result.profile.id;
renderState(result.frp);
renderProfileOptions();
setMessage(configFeedback, result.frp.service.running ? "配置已保存并重启 FRP。" : "配置已保存并通过校验。", "success");
announce("FRP 配置已保存");
} catch (error) {
setMessage(configFeedback, `配置保存失败:${error.message}`, "error");
} finally {
setControlReady(saveButton);
}
});
deleteButton.addEventListener("click", async () => {
if (!editingId) return;
const profile = profileById(editingId);
if (!window.confirm(`确认删除 FRP 配置“${profile?.name || "未命名"}”?`)) return;
setControlBusy(deleteButton, { label: "正在删除…" });
try {
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(editingId)}`, { method: "DELETE" });
renderState(result.frp);
editingId = result.frp.selected_id || result.frp.profiles?.[0]?.id || null;
renderProfileOptions();
if (editingId) await loadProfile(editingId);
else {
configName.value = "";
configContent.value = "";
}
announce("FRP 配置已删除");
} catch (error) {
setMessage(configFeedback, `删除失败:${error.message}`, "error");
} finally {
setControlReady(deleteButton);
}
});
serviceToggle.addEventListener("click", async () => {
const stopping = state?.service?.running || state?.service?.enabled;
setControlBusy(serviceToggle, { label: stopping ? "正在关闭…" : "正在启动…" });
try {
const result = await apiJson(`/api/frp/service/${stopping ? "stop" : "start"}`, { method: "POST" });
renderState(result.frp);
announce(stopping ? "FRP 已关闭" : "FRP 已启动");
} catch (error) {
setMessage(feedback, `${stopping ? "关闭" : "启动"} FRP 失败:${error.message}`, "error");
await loadFrpSettings();
}
});
diagnosticsButton.addEventListener("click", async () => {
diagnosticsSummary.textContent = "正在检测网络,请稍候…";
diagnosticsSummary.dataset.state = "idle";
diagnosticsChecks.replaceChildren();
diagnosticsDialog.showModal();
setControlBusy(diagnosticsButton, { label: "正在检测…" });
try {
const result = await apiJson("/api/network/diagnostics", { method: "POST" });
diagnosticsSummary.textContent = result.diagnosis;
diagnosticsSummary.dataset.state = result.overall === "ok" ? "success" : "error";
result.checks.forEach((check) => {
const item = document.createElement("li");
item.dataset.state = check.ok ? "success" : (check.warning ? "warning" : "error");
item.textContent = `${check.ok ? "通过" : (check.warning ? "提示" : "失败")}:${check.message}`;
diagnosticsChecks.append(item);
});
} catch (error) {
diagnosticsSummary.textContent = `网络检测失败:${error.message}`;
diagnosticsSummary.dataset.state = "error";
} finally {
setControlReady(diagnosticsButton);
}
});
diagnosticsClose.addEventListener("click", () => diagnosticsDialog.close());
document.addEventListener("matrix:status", (event) => {
if (event.detail?.frp) renderState(event.detail.frp);
});
@@ -0,0 +1,410 @@
import { announce, apiJson, registerWorkspace } from "../core.js";
import { clampCropCenter, cropCenterBounds, cropGeometry } from "../media-crop-geometry.js";
import { shouldReuseMediaJobCard } from "../media-import-ui-model.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
const form = document.getElementById("media-upload-form");
const fileInput = document.getElementById("media-source-file");
const uploadButton = document.getElementById("media-upload-button");
const uploadProgress = document.getElementById("media-upload-progress");
const refreshButton = document.getElementById("refresh-media-imports");
const feedback = document.getElementById("media-import-feedback");
const jobsRoot = document.getElementById("media-import-jobs");
const stateNames = {
uploading: ["copy.dynamic.media.state_uploading", "上传中"],
analyzing: ["copy.dynamic.media.state_analyzing", "分析中"],
awaiting_settings: ["copy.dynamic.media.state_awaiting", "等待设置"],
queued: ["copy.dynamic.media.state_queued", "排队中"],
converting: ["copy.dynamic.media.state_converting", "转换中"],
failed: ["copy.dynamic.media.state_failed", "失败"],
};
let initialized = false;
let pollingTimer = 0;
let priorJobs = new Map();
let emptyState = null;
function setFeedback(copyId, defaultText, values = {}, error = false) {
renderUiCopy(copyId, feedback, defaultText, values);
feedback.dataset.state = error ? "error" : "success";
}
function makeButton(copyId, text, className = "") {
const element = document.createElement("button");
element.type = "button";
renderUiCopy(copyId, element, text);
if (className) element.className = className;
return element;
}
function labelControl(copyId, text, control) {
const label = document.createElement("label");
const span = document.createElement("span");
renderUiCopy(copyId, span, text);
label.append(span, control);
return label;
}
async function cancelJob(id) {
try {
await apiJson("/api/media-imports/" + id, { method: "DELETE" });
setFeedback("copy.dynamic.media.deleted", "任务和源文件已删除。");
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.delete_failed", "删除任务失败:{error}", { error: error.message }, true);
}
}
function settingsCard(job, card) {
const markDirty = () => { card.dataset.localDraft = "true"; };
const stage = document.createElement("div");
stage.className = "media-crop-stage";
stage.dataset.fit = job.settings.fit_mode;
const sourcePlane = document.createElement("div");
sourcePlane.className = "media-crop-source";
const image = document.createElement("img");
image.alt = job.filename + " 代表帧";
image.src = job.previews[0] || "";
sourcePlane.append(image);
stage.append(sourcePlane);
const tabs = document.createElement("div");
tabs.className = "media-preview-tabs";
job.previews.forEach((url, index) => {
const tab = makeButton("copy.dynamic.media.preview", "代表帧 " + (index + 1));
renderUiCopy("copy.dynamic.media.preview", tab, "代表帧 {index}", { index: index + 1 });
tab.setAttribute("aria-pressed", index === 0 ? "true" : "false");
tab.addEventListener("click", () => {
markDirty();
image.src = url;
tabs.querySelectorAll("button").forEach((item) => {
item.setAttribute("aria-pressed", item === tab ? "true" : "false");
});
});
tabs.append(tab);
});
const grid = document.createElement("div");
grid.className = "media-settings-grid";
const name = document.createElement("input");
name.type = "text"; name.maxLength = 80; name.value = job.name;
const fit = document.createElement("select");
[
["crop", "copy.dynamic.media.mode_crop", "自由取景"],
["contain", "copy.dynamic.media.mode_contain", "完整包含"],
["stretch", "copy.dynamic.media.mode_stretch", "拉伸"],
].forEach((pair) => {
const option = document.createElement("option");
option.value = pair[0];
renderUiCopy(pair[1], option, pair[2]);
option.selected = pair[0] === job.settings.fit_mode;
fit.append(option);
});
const zoom = document.createElement("input");
zoom.type = "range"; zoom.min = "1"; zoom.max = "16";
zoom.step = "0.05"; zoom.value = job.settings.zoom;
const transparency = document.createElement("input");
transparency.type = "color"; transparency.value = job.settings.transparency_color;
const padding = document.createElement("input");
padding.type = "color"; padding.value = job.settings.padding_color;
grid.append(
labelControl("copy.dynamic.media.output_name", "保存名称", name),
labelControl("copy.dynamic.media.fit_mode", "1:1 适配", fit),
labelControl("copy.dynamic.media.crop_zoom", "取景缩放", zoom),
labelControl("copy.dynamic.media.transparency", "透明像素填充色", transparency),
labelControl("copy.dynamic.media.padding", "留白颜色", padding),
);
let centerX = Number(job.settings.center_x);
let centerY = Number(job.settings.center_y);
function sourceSize() {
return {
width: image.naturalWidth || 1,
height: image.naturalHeight || 1,
};
}
function cropBounds() {
const source = sourceSize();
return cropCenterBounds({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: Number(zoom.value),
});
}
function clampCenter() {
const clamped = clampCropCenter({ centerX, centerY }, cropBounds());
centerX = clamped.centerX;
centerY = clamped.centerY;
}
function updateCropVisual() {
stage.dataset.fit = fit.value;
stage.style.backgroundColor = padding.value;
sourcePlane.style.backgroundColor = transparency.value;
const source = sourceSize();
if (fit.value === "stretch") {
sourcePlane.style.width = "100%";
sourcePlane.style.height = "100%";
sourcePlane.style.left = "0";
sourcePlane.style.top = "0";
return;
}
if (fit.value === "crop") clampCenter();
const geometry = cropGeometry({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: fit.value === "crop" ? Number(zoom.value) : 1,
centerX: fit.value === "crop" ? centerX : 0.5,
centerY: fit.value === "crop" ? centerY : 0.5,
});
sourcePlane.style.width = geometry.width + "px";
sourcePlane.style.height = geometry.height + "px";
sourcePlane.style.left = geometry.left + "px";
sourcePlane.style.top = geometry.top + "px";
}
name.addEventListener("input", markDirty);
fit.addEventListener("change", () => { markDirty(); updateCropVisual(); });
zoom.addEventListener("input", () => { markDirty(); updateCropVisual(); });
transparency.addEventListener("input", () => { markDirty(); updateCropVisual(); });
padding.addEventListener("input", () => { markDirty(); updateCropVisual(); });
let drag = null;
stage.addEventListener("pointerdown", (event) => {
if (fit.value !== "crop") return;
drag = { x: event.clientX, y: event.clientY, centerX, centerY };
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener("pointermove", (event) => {
if (!drag) return;
markDirty();
const source = sourceSize();
const geometry = cropGeometry({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: Number(zoom.value), centerX: drag.centerX, centerY: drag.centerY,
});
centerX = drag.centerX - (event.clientX - drag.x) / geometry.width;
centerY = drag.centerY - (event.clientY - drag.y) / geometry.height;
clampCenter();
updateCropVisual();
});
stage.addEventListener("pointerup", () => { drag = null; });
stage.addEventListener("pointercancel", () => { drag = null; });
image.addEventListener("load", updateCropVisual);
const cropResizeObserver = new ResizeObserver(updateCropVisual);
cropResizeObserver.observe(stage);
card.mediaCropDispose = () => cropResizeObserver.disconnect();
updateCropVisual();
const actions = document.createElement("div");
actions.className = "media-job-actions";
const convert = makeButton("copy.dynamic.media.convert", "保存设置并开始转换", "primary-button");
const cancel = makeButton("copy.dynamic.media.cancel_source", "取消并删除源文件", "danger-button");
actions.append(convert, cancel);
convert.addEventListener("click", async () => {
convert.disabled = true;
try {
await apiJson("/api/media-imports/" + job.id + "/settings", {
method: "PUT",
body: JSON.stringify({
name: name.value, fit_mode: fit.value, center_x: centerX, center_y: centerY,
zoom: Number(zoom.value), transparency_color: transparency.value,
padding_color: padding.value,
}),
});
await apiJson("/api/media-imports/" + job.id + "/convert", { method: "POST", body: "{}" });
setFeedback("copy.dynamic.media.queued", "任务已加入转换队列。");
await refreshJobs();
} catch (error) {
if (error.message === "output name already exists") {
setFeedback("copy.dynamic.media.name_conflict", "保存名称已存在,请更换名称。", {}, true);
} else {
setFeedback("copy.dynamic.media.convert_failed", "无法开始转换:{error}", { error: error.message }, true);
}
convert.disabled = false;
}
});
cancel.addEventListener("click", () => cancelJob(job.id));
card.append(tabs, stage, grid, actions);
}
function renderJob(job) {
const card = document.createElement("article");
card.className = "media-job-card";
card.dataset.jobId = job.id;
card.dataset.jobState = job.state;
card.dataset.serverUpdatedAt = job.updated_at;
card.dataset.localDraft = "false";
const heading = document.createElement("div");
heading.className = "media-job-heading";
const title = document.createElement("h3");
title.textContent = job.name;
const state = document.createElement("p");
const stateCopy = stateNames[job.state];
if (job.queue_position) {
renderUiCopy("copy.dynamic.media.queue_position", state, "{state} · 队列第 {position} 位", {
state: stateCopy ? stateCopy[1] : job.state, position: job.queue_position,
});
} else if (stateCopy) {
renderUiCopy(stateCopy[0], state, stateCopy[1]);
} else {
state.textContent = job.state;
}
heading.append(title, state);
const source = document.createElement("p");
source.textContent = job.filename + " · " + (job.source_size / 1024 / 1024).toFixed(1) + " MiB";
card.append(heading, source);
if (job.state === "awaiting_settings") {
settingsCard(job, card);
return card;
}
const progress = document.createElement("progress");
progress.max = 100; progress.value = job.progress || 0;
card.append(progress);
if (job.error) {
const error = document.createElement("p");
error.textContent = job.error; error.className = "settings-feedback";
error.dataset.state = "error"; card.append(error);
}
const actions = document.createElement("div");
actions.className = "media-job-actions";
if (job.state === "failed") {
const retry = makeButton("copy.dynamic.media.retry", "重试", "primary-button");
retry.addEventListener("click", async () => {
try {
await apiJson("/api/media-imports/" + job.id + "/retry", { method: "POST", body: "{}" });
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.retry_failed", "重试失败:{error}", { error: error.message }, true);
}
});
actions.append(retry);
}
const cancel = makeButton("copy.dynamic.media.cancel", "取消并删除", "danger-button");
cancel.addEventListener("click", () => cancelJob(job.id));
actions.append(cancel); card.append(actions);
return card;
}
function renderedCardState(card) {
if (!card) return null;
return {
id: card.dataset.jobId,
state: card.dataset.jobState,
updatedAt: card.dataset.serverUpdatedAt,
dirty: card.dataset.localDraft === "true",
};
}
function reconcileJobCards(jobs) {
const oldCards = new Map(
Array.from(jobsRoot.querySelectorAll(":scope > .media-job-card"))
.map((card) => [card.dataset.jobId, card]),
);
if (jobs.length) {
emptyState?.remove();
jobs.forEach((job, index) => {
const oldCard = oldCards.get(job.id) || null;
const card = shouldReuseMediaJobCard(renderedCardState(oldCard), job)
? oldCard
: renderJob(job);
if (oldCard && card !== oldCard) {
oldCard.mediaCropDispose?.();
oldCard.replaceWith(card);
}
const position = jobsRoot.children[index] || null;
if (position !== card) jobsRoot.insertBefore(card, position);
oldCards.delete(job.id);
});
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
return;
}
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
if (!emptyState) {
emptyState = document.createElement("p");
emptyState.className = "media-job-empty";
renderUiCopy("copy.dynamic.media.empty", emptyState, "当前没有等待或转换中的任务。");
}
if (!emptyState.isConnected) jobsRoot.append(emptyState);
}
async function refreshJobs() {
if (document.hidden) return;
try {
const result = await apiJson("/api/media-imports");
const next = new Map(result.jobs.map((job) => [job.id, job]));
for (const pair of priorJobs) {
const id = pair[0], job = pair[1];
if (!next.has(id) && ["queued", "converting"].includes(job.state)) {
const message = "“" + job.name + "”转换完成,已加入内容库。";
setFeedback("copy.dynamic.media.completed", "“{name}”转换完成,已加入内容库。", { name: job.name });
announce(message);
document.getElementById("refresh-templates")?.click();
document.getElementById("refresh-animations")?.click();
}
}
priorJobs = next;
reconcileJobCards(result.jobs);
} catch (error) {
setFeedback("copy.dynamic.media.list_failed", "任务列表读取失败:{error}", { error: error.message }, true);
}
}
function upload(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/media-imports?filename=" + encodeURIComponent(file.name));
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.responseType = "json";
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) uploadProgress.value = event.loaded * 100 / event.total;
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject(new Error(xhr.response?.detail || xhr.statusText || "上传失败"));
});
xhr.addEventListener("error", () => reject(new Error("上传连接中断")));
xhr.send(file);
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) return;
uploadButton.disabled = true; uploadProgress.hidden = false; uploadProgress.value = 0;
try {
await upload(file);
form.reset();
setFeedback("copy.dynamic.media.uploaded", "上传完成,设备正在分析媒体。");
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.upload_failed", "上传失败:{error}", { error: error.message }, true);
} finally {
uploadButton.disabled = false; uploadProgress.hidden = true;
}
});
refreshButton.addEventListener("click", refreshJobs);
document.addEventListener("visibilitychange", () => {
window.clearInterval(pollingTimer);
if (!document.hidden && initialized) {
refreshJobs(); pollingTimer = window.setInterval(refreshJobs, 2000);
}
});
async function initialize() {
if (!initialized) {
initialized = true; pollingTimer = window.setInterval(refreshJobs, 2000);
}
await refreshJobs();
}
registerWorkspace({
id: "media-import", title: "媒体内容转换", elementId: "workspace-media-import",
order: 45, persistence: "server",
capabilities: ["media-upload", "background-conversion"], onEnter: initialize,
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,627 @@
import { announce, apiJson, registerWorkspace } from "../core.js";
import {
animationEditState,
enterTemplateEdit,
getScene,
markTemplateSaved,
ordinarySceneNeedsTemplateSave,
refreshCurrentDisplayPreview,
templateEditState,
} from "../scene-board.js";
import { sceneToStorageValue } from "../scene-model.js";
import { protectCardDragStart, reorderByKey } from "../sortable-card.js";
import { resolveAnimationEditBeforeNavigation } from "./animations.js";
import { refreshStatus } from "./device.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
createAnimationPreviewGroup,
createAnimationThumbnailBox,
} from "../animation-thumbnail-preview.js";
const grid = document.getElementById("template-grid");
const feedback = document.getElementById("template-feedback");
const refreshButton = document.getElementById("refresh-templates");
const selectDefaultButton = document.getElementById("select-default-content");
const saveButton = document.getElementById("save-template");
const nameDialog = document.getElementById("template-name-dialog");
const nameDialogTitle = document.getElementById("template-dialog-title");
const nameDialogNote = document.getElementById("template-dialog-note");
const nameInput = document.getElementById("template-name-input");
const confirmDialog = document.getElementById("template-confirm-dialog");
const confirmMessage = document.getElementById("template-confirm-message");
const saveChoiceDialog = document.getElementById("template-save-choice-dialog");
const saveChoiceMessage = document.getElementById("template-save-choice-message");
const saveCurrentButton = document.getElementById("template-save-current");
const copyDialog = document.getElementById("library-copy-dialog");
const copyTarget = document.getElementById("library-copy-target");
let loading = false;
let animationList = [];
let activeAnimation = null;
let libraryOrder = { items: [], revision: "" };
let draggingLibraryKey = null;
let defaultContent = null;
let selectingDefault = false;
const demoRestrictedMessage = "演示案例不得编辑,请复制";
const libraryDragType = "application/x-matrix-library-item";
const templateAnimationPreviewGroup = createAnimationPreviewGroup();
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && size >= 1024; index += 1) {
size /= 1024;
unit = units[index];
}
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
}
function askName(title, initial = "", { note = "" } = {}) {
nameDialogTitle.textContent = title;
nameDialogNote.textContent = note;
nameDialogNote.hidden = !note;
nameInput.value = initial;
nameInput.required = true;
nameDialog.returnValue = "";
nameDialog.showModal();
window.setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 0);
return new Promise((resolve) => {
nameDialog.addEventListener("close", () => {
resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null);
}, { once: true });
});
}
function askConfirmation(message, { copyId = "", values = {} } = {}) {
if (copyId) renderUiCopy(copyId, confirmMessage, message, values);
else confirmMessage.textContent = message;
confirmDialog.returnValue = "";
confirmDialog.showModal();
return new Promise((resolve) => {
confirmDialog.addEventListener("close", () => {
resolve(confirmDialog.returnValue === "confirm");
}, { once: true });
});
}
function actionButton(label, action, className = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
if (className) button.className = className;
button.addEventListener("click", () => action(button));
return button;
}
function restrictDemoAction(control, message = demoRestrictedMessage) {
control.classList.add("demo-restricted-action");
setControlUnavailable(control, message);
return control;
}
function syncSaveButton() {
if (!animationEditState() && templateEditState()) {
renderUiCopy("copy.dynamic.template.save_bound", saveButton, "保存模板");
} else {
renderUiCopy("copy.dynamic.template.save_new", saveButton, "保存为模板");
}
}
function askSaveChoice(context) {
renderUiCopy(
"copy.dynamic.template.save_choice",
saveChoiceMessage,
"请选择“{name}”的保存方式。",
{ name: context.name },
);
saveCurrentButton.classList.remove("demo-restricted-action");
setControlReady(saveCurrentButton);
if (context.readOnly) {
restrictDemoAction(saveCurrentButton, "演示案例不得覆盖,请另存为新模板");
}
saveChoiceDialog.returnValue = "";
saveChoiceDialog.showModal();
return new Promise((resolve) => {
saveChoiceDialog.addEventListener("close", () => {
resolve(["current", "new"].includes(saveChoiceDialog.returnValue)
? saveChoiceDialog.returnValue
: "cancel");
}, { once: true });
});
}
async function confirmOrdinarySceneReplacement(name) {
if (!ordinarySceneNeedsTemplateSave()) return true;
return askConfirmation(
"当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
{
copyId: "copy.dynamic.template.unsaved_replace",
values: { name },
},
);
}
function setBusy(value) {
loading = value;
if (value) {
setControlBusy(refreshButton);
setControlBusy(selectDefaultButton);
setControlBusy(saveButton);
grid.setAttribute("aria-busy", "true");
} else {
setControlReady(refreshButton);
setControlReady(selectDefaultButton);
setControlReady(saveButton);
grid.removeAttribute("aria-busy");
}
grid.toggleAttribute("inert", value);
}
function isDefaultContent(type, id) {
return defaultContent?.type === type && defaultContent?.id === id;
}
function syncDefaultSelectionButton() {
selectDefaultButton.setAttribute("aria-pressed", String(selectingDefault));
selectDefaultButton.classList.toggle("active", selectingDefault);
selectDefaultButton.textContent = selectingDefault ? "取消设置默认内容" : "设置默认显示内容";
}
async function chooseDefaultContent(type, item) {
await mutate(async () => {
defaultContent = await apiJson("/api/display/default-content", {
method: "PUT",
body: JSON.stringify({ type, id: item.id }),
});
selectingDefault = false;
syncDefaultSelectionButton();
await refreshStatus();
}, `已将“${item.name}”设为默认显示内容并立即显示`);
}
function decorateDefaultContent(card, type, item, { available = true } = {}) {
if (isDefaultContent(type, item.id)) {
card.classList.add("template-default-content");
const badge = document.createElement("span");
badge.className = "template-default-badge";
badge.textContent = "当前默认";
card.insertBefore(badge, card.querySelector(".template-card-content-actions"));
}
if (!selectingDefault) return card;
const actions = document.createElement("div");
actions.className = "template-card-actions template-card-default-actions";
const choose = actionButton(
isDefaultContent(type, item.id) ? "当前默认" : "设为默认",
() => chooseDefaultContent(type, item),
"primary-button",
);
if (isDefaultContent(type, item.id)) {
setControlUnavailable(choose, "这项已经是当前默认显示内容。");
} else if (!available) {
setControlUnavailable(choose, "空动图不能设为默认,请先添加至少一帧。");
}
actions.append(choose);
card.append(actions);
return card;
}
async function mutate(action, successMessage) {
setBusy(true);
feedback.textContent = "正在处理模板操作…";
feedback.dataset.state = "pending";
try {
await action();
announce(successMessage);
setBusy(false);
await refreshTemplates();
} catch (error) {
const conflict = error.code !== "APP_STALE" && (error.status === 409 || error.status === 428);
feedback.textContent = conflict
? "模板已被其他页面修改,列表已刷新;请检查最新内容后重新操作。"
: error.message;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
if (conflict) {
setBusy(false);
await refreshTemplates();
}
} finally {
setBusy(false);
}
}
function appendContentActions(card, { play, edit, copy, rename, remove }) {
const actions = document.createElement("div");
actions.className = "template-card-actions template-card-content-actions";
remove.classList.add("template-card-delete-action");
actions.append(play, edit, copy, rename, remove);
card.append(actions);
}
async function playStaticTemplate(template) {
await mutate(async () => {
await apiJson(`/api/templates/${template.id}/play`, {
method: "POST",
headers: { "If-Match": `"${template.revision}"` },
body: "{}",
});
refreshCurrentDisplayPreview();
await refreshStatus();
}, `开始播放“${template.name}”`);
}
async function editStaticTemplate(template) {
if (!await resolveAnimationEditBeforeNavigation()) return;
if (!await confirmOrdinarySceneReplacement(template.name)) return;
setBusy(true);
feedback.textContent = `正在读取“${template.name}”…`;
feedback.dataset.state = "pending";
try {
const full = await apiJson(`/api/templates/${template.id}`);
enterTemplateEdit(full);
window.location.hash = "#canvas";
announce(`正在编辑模板“${template.name}”`);
} catch (error) {
feedback.textContent = `模板读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setBusy(false);
}
}
function renderCard(template) {
const card = document.createElement("article");
card.className = "template-card";
const imageBox = document.createElement("div");
imageBox.className = "template-image-box";
const image = document.createElement("img");
image.className = "template-image";
image.src = template.thumbnail_url;
image.alt = `${template.name} 模板缩略图`;
image.width = 64;
image.height = 64;
image.draggable = false;
image.loading = "lazy";
image.addEventListener("error", () => {
image.hidden = true;
imageBox.classList.add("template-image-error");
imageBox.setAttribute("aria-label", "缩略图加载失败");
});
imageBox.appendChild(image);
const name = document.createElement("h3");
name.className = "template-card-name";
name.textContent = template.name;
const badge = document.createElement("span");
badge.className = "content-type-badge";
badge.textContent = "静态";
const size = document.createElement("p");
size.className = "template-card-size";
size.textContent = `占用 ${formatBytes(template.size_bytes)}`;
const play = actionButton("播放", () => playStaticTemplate(template), "primary-button");
const edit = actionButton("编辑", () => editStaticTemplate(template));
const copy = actionButton("复制", (control) => copyStaticTemplate(template, control));
const rename = actionButton("重命名", async () => {
const nextName = await askName("重命名模板", template.name);
if (!nextName || nextName === template.name) return;
mutate(() => apiJson(`/api/templates/${template.id}`, {
method: "PATCH",
headers: { "If-Match": `"${template.revision}"` },
body: JSON.stringify({ name: nextName }),
}), `模板已重命名为“${nextName}”`);
});
const remove = actionButton("删除", async () => {
if (!await askConfirmation(`永久删除模板“${template.name}”及其缩略图吗?`)) return;
mutate(() => apiJson(`/api/templates/${template.id}`, {
method: "DELETE",
headers: { "If-Match": `"${template.revision}"` },
}), `模板“${template.name}”已删除`);
}, "danger-button");
if (template.read_only) {
restrictDemoAction(rename);
restrictDemoAction(remove);
}
card.append(imageBox, badge, name, size);
appendContentActions(card, { play, edit, copy, rename, remove });
return decorateDefaultContent(card, "template", template);
}
async function chooseCopyTarget() {
const data = await apiJson("/api/animations");
copyTarget.replaceChildren(new Option("静态模板", "static"), ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}:${item.revision}`)));
copyDialog.returnValue = ""; copyDialog.showModal();
await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true }));
if (copyDialog.returnValue !== "copy") return null;
const [type, animationId, revision] = copyTarget.value.split(":");
return { type, animationId, revision };
}
async function copyStaticTemplate(template, control) {
setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" });
try {
const target = await chooseCopyTarget(); if (!target) return;
await mutate(() => apiJson("/api/library/copy", { method: "POST", body: JSON.stringify({
source_type: "template", source_id: template.id, source_revision: template.revision,
destination_type: target.type, destination_animation_id: target.animationId || null, destination_revision: target.revision || null,
}) }), `模板“${template.name}”已复制`);
} catch (error) {
feedback.textContent = `复制目标读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(control);
}
}
function renderAnimationCard(animation) {
const card = document.createElement("article"); card.className = "template-card";
const imageBox = createAnimationThumbnailBox(templateAnimationPreviewGroup, animation);
const badge = document.createElement("span"); badge.className = "content-type-badge content-type-animation"; badge.textContent = "动图";
const name = document.createElement("h3"); name.className = "template-card-name animation-folder-name"; name.textContent = animation.name;
name.dataset.hoverTooltip = animation.name;
const active = activeAnimation?.id === animation.id;
const meta = document.createElement("p"); meta.className = "template-card-size"; meta.textContent = `${animation.frame_count} 帧 · ${(animation.total_duration_ms / 1000).toFixed(2)} 秒 · ${formatBytes(animation.size_bytes)}${active ? activeAnimation.revision === animation.revision ? " · 正在播放" : " · 正在播放旧版本" : ""}`;
const play = actionButton("播放", () => mutate(async () => {
await apiJson(`/api/animations/${animation.id}/play`, {
method: "POST",
headers: { "If-Match": `"${animation.revision}"` },
body: "{}",
});
refreshCurrentDisplayPreview();
await refreshStatus();
}, `开始播放“${animation.name}”`), "primary-button");
if (!animation.frame_count) setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。");
const copy = actionButton("复制", () => mutate(() => apiJson(`/api/animations/${animation.id}/copy`, { method: "POST", body: "{}" }), `动图“${animation.name}”已复制`));
const edit = actionButton("编辑", () => { window.location.hash = "#animations"; document.dispatchEvent(new CustomEvent("matrix:open-animation", { detail: { id: animation.id } })); });
const rename = actionButton("重命名", async () => { const next = await askName("重命名动图", animation.name); if (next) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "PATCH", headers: { "If-Match": `"${animation.revision}"` }, body: JSON.stringify({ name: next }) }), "动图已重命名"); });
const remove = actionButton("删除", async () => { if (await askConfirmation(`永久删除动图“${animation.name}”及全部帧吗?`)) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "DELETE", headers: { "If-Match": `"${animation.revision}"` } }), "动图已删除"); }, "danger-button");
if (animation.read_only) {
restrictDemoAction(edit);
restrictDemoAction(rename);
restrictDemoAction(remove);
}
card.append(imageBox, badge, name, meta);
appendContentActions(card, { play, edit, copy, rename, remove });
return decorateDefaultContent(card, "animation", animation, { available: animation.frame_count > 0 });
}
function libraryItemKey(item) {
return `${item.type}:${item.id}`;
}
function clearLibraryDragIndicators() {
grid.querySelectorAll(".library-drop-target").forEach((card) => card.classList.remove("library-drop-target"));
}
function saveLibraryOrder(keys) {
const byKey = new Map(libraryOrder.items.map((item) => [libraryItemKey(item), item]));
const items = keys.map((key) => byKey.get(key)).filter(Boolean);
if (items.length !== libraryOrder.items.length) return;
mutate(() => apiJson("/api/library/order", {
method: "PUT",
headers: { "If-Match": `"${libraryOrder.revision}"` },
body: JSON.stringify({ items }),
}), "模板顺序已更新");
}
function moveLibraryItem(index, offset) {
const keys = libraryOrder.items.map(libraryItemKey);
const target = index + offset;
if (target < 0 || target >= keys.length) return;
[keys[index], keys[target]] = [keys[target], keys[index]];
saveLibraryOrder(keys);
}
function prepareDemoCard(card) {
card.classList.add("library-demo-fixed");
card.querySelectorAll("img").forEach((image) => { image.draggable = false; });
protectCardDragStart(card, { locked: true });
const orderActions = document.createElement("div");
orderActions.className = "template-card-actions template-card-order-actions";
const moveUp = restrictDemoAction(
actionButton("上移", () => {}),
"演示案例固定在最前,不能移动。",
);
const moveDown = restrictDemoAction(
actionButton("下移", () => {}),
"演示案例固定在最前,不能移动。",
);
orderActions.append(moveUp, moveDown);
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
return card;
}
function prepareSortableLibraryCard(card, item, index, total) {
const key = libraryItemKey(item);
card.classList.add("sortable-card", "library-sortable-card");
card.dataset.libraryKey = key;
protectCardDragStart(card);
const orderActions = document.createElement("div");
orderActions.className = "template-card-actions template-card-order-actions";
const moveUp = actionButton("上移", () => moveLibraryItem(index, -1));
const moveDown = actionButton("下移", () => moveLibraryItem(index, 1));
if (index === 0) setControlUnavailable(moveUp, "两个演示案例固定在最前,当前已经是第一项。");
if (index === total - 1) setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
orderActions.append(moveUp, moveDown);
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
card.addEventListener("dragstart", (event) => {
if (event.defaultPrevented) return;
draggingLibraryKey = key;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(libraryDragType, key);
event.dataTransfer.setData("text/plain", key);
});
card.addEventListener("dragover", (event) => {
if (!draggingLibraryKey || draggingLibraryKey === key) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
clearLibraryDragIndicators();
card.classList.add("library-drop-target");
});
card.addEventListener("dragleave", (event) => {
if (!card.contains(event.relatedTarget)) card.classList.remove("library-drop-target");
});
card.addEventListener("drop", (event) => {
if (!draggingLibraryKey) return;
event.preventDefault();
const source = event.dataTransfer.getData(libraryDragType) || draggingLibraryKey;
const keys = libraryOrder.items.map(libraryItemKey);
const reordered = reorderByKey(keys, source, key);
clearLibraryDragIndicators();
if (reordered) saveLibraryOrder(reordered);
});
card.addEventListener("dragend", () => {
draggingLibraryKey = null;
clearLibraryDragIndicators();
});
return card;
}
export async function refreshTemplates() {
if (loading) return;
setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取模板和动图…" });
feedback.textContent = "正在读取模板…";
feedback.dataset.state = "pending";
try {
const [data, animations, order, selectedDefault] = await Promise.all([
apiJson("/api/templates?include_demo=true"),
apiJson("/api/animations?include_demo=true"),
apiJson("/api/library/order"),
apiJson("/api/display/default-content"),
]);
defaultContent = selectedDefault;
animationList = animations.animations;
libraryOrder = order;
const demoCards = [
...data.templates.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "static" })),
...animationList.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "animation" })),
].sort((left, right) => left.demo_order - right.demo_order);
const userItems = [
...data.templates.filter((item) => !item.is_demo).map((item) => ({ type: "template", id: item.id, value: item })),
...animationList.filter((item) => !item.is_demo).map((item) => ({ type: "animation", id: item.id, value: item })),
];
const userByKey = new Map(userItems.map((item) => [libraryItemKey(item), item]));
const orderedUsers = order.items.map((item) => userByKey.get(libraryItemKey(item))).filter(Boolean);
templateAnimationPreviewGroup.clear();
const renderedDemos = demoCards.map((item) => prepareDemoCard(item.content_type === "static" ? renderCard(item) : renderAnimationCard(item)));
const renderedUsers = orderedUsers.map((item, index) => prepareSortableLibraryCard(
item.type === "template" ? renderCard(item.value) : renderAnimationCard(item.value),
item,
index,
orderedUsers.length,
));
grid.replaceChildren(...renderedDemos, ...renderedUsers);
if (!userItems.length) {
const empty = document.createElement("p");
empty.className = "template-empty panel";
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
grid.appendChild(empty);
}
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
feedback.dataset.state = "ready";
announce(feedback.textContent);
} catch (error) {
feedback.textContent = `模板读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(refreshButton);
}
}
async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
const name = await askName("保存为模板", initial, { note });
if (!name) return;
await mutate(async () => {
const result = await apiJson("/api/templates", {
method: "POST",
body: JSON.stringify({ name, scene: sceneToStorageValue(getScene()) }),
});
if (bind) markTemplateSaved(result);
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-save" } }));
return result;
}, `模板“${name}”已保存`);
}
async function saveSceneToCurrentTemplate(context) {
await mutate(async () => {
const result = await apiJson(`/api/templates/${context.id}`, {
method: "PUT",
headers: { "If-Match": `"${context.revision}"` },
body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }),
});
markTemplateSaved(result);
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-update" } }));
return result;
}, `模板“${context.name}”已保存`);
}
saveButton.addEventListener("click", async () => {
if (animationEditState()) {
await saveSceneAsNew({
note: "当前帧将被保存为一个静态图模板",
bind: false,
});
return;
}
const context = templateEditState();
if (!context) {
await saveSceneAsNew();
return;
}
const choice = await askSaveChoice(context);
if (choice === "current") {
await saveSceneToCurrentTemplate(context);
} else if (choice === "new") {
await saveSceneAsNew({ initial: `${context.name} - 副本` });
}
});
refreshButton.addEventListener("click", refreshTemplates);
selectDefaultButton.addEventListener("click", async () => {
if (loading) return;
selectingDefault = !selectingDefault;
syncDefaultSelectionButton();
announce(selectingDefault ? "请选择下方内容设为默认显示" : "已取消设置默认显示内容");
await refreshTemplates();
});
document.addEventListener("matrix:library-change", refreshTemplates);
document.addEventListener("matrix:template-edit-change", syncSaveButton);
document.addEventListener("matrix:animation-edit-change", syncSaveButton);
document.addEventListener("matrix:status", (event) => {
const state = event.detail?.state;
activeAnimation = state?.animation_active ? { id: state.animation_id, revision: state.animation_revision } : null;
});
registerWorkspace({
id: "templates",
title: "模板管理",
elementId: "workspace-templates",
order: 60,
persistence: "server",
onEnter: async () => {
templateAnimationPreviewGroup.setActive(true);
await refreshTemplates();
},
onLeave: () => {
templateAnimationPreviewGroup.setActive(false);
templateAnimationPreviewGroup.clear();
},
});
syncDefaultSelectionButton();
syncSaveButton();
@@ -0,0 +1,349 @@
import { announce, apiFile, apiJson, registerWorkspace } from "../core.js";
import { refreshColorTrigger } from "../color-picker.js";
import { createFontPicker } from "../font-picker.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
runControlAction,
setControlReady,
setControlUnavailable,
} from "../interaction-feedback.js";
import {
addTextElement,
deleteTextElement,
duplicateTextElement,
getScene,
getSelectedTextElement,
selectTextElement,
subscribeScene,
updateTextElement,
} from "../scene-board.js";
const form = document.getElementById("text-form");
const fields = document.getElementById("text-fields");
const contentFields = document.getElementById("text-content-fields");
const list = document.getElementById("text-element-list");
const selectionStatus = document.getElementById("text-selection-status");
const duplicateButton = document.getElementById("duplicate-text");
const deleteButton = document.getElementById("delete-text");
const colorTrigger = document.getElementById("text-color");
const fontInput = document.getElementById("text-font");
const fontOptions = document.getElementById("text-font-options");
const importFontButton = document.getElementById("import-font");
const fontFileInput = document.getElementById("font-file");
const fontFeedback = document.getElementById("font-feedback");
let initialized = false;
let suppressForm = false;
let renderedSelectionId = null;
let defaults = { font: "default", size: 12 };
let fontCatalogReady = false;
let fontMaxUploadBytes = 32 * 1024 * 1024;
let acceptedFontExtensions = [".ttf", ".otf", ".ttc", ".otc"];
const fontPicker = createFontPicker({
input: fontInput,
listbox: fontOptions,
onCommit(item) {
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, { font: item.id }, "form");
setFontFeedbackTemplate(
"copy.dynamic.text.font_selected",
"已选择字体“{name}”",
{ name: item.label },
);
},
});
function field(id) {
return document.getElementById(id);
}
function setFontFeedback(message = "", isError = false) {
fontFeedback.textContent = message;
fontFeedback.hidden = !message;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function setFontFeedbackTemplate(copyId, defaultText, values = {}, isError = false) {
renderUiCopy(copyId, fontFeedback, defaultText, values);
fontFeedback.hidden = false;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function updateFontPickerAvailability(element = getSelectedTextElement()) {
if (!fontCatalogReady) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "字体目录尚未加载完成。");
} else if (!element) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "请先新建或选择一个文字元素;字体导入仍可使用。");
} else {
setControlReady(fontInput);
fontPicker.setEnabled(true);
}
}
function applyElementToFields(element) {
suppressForm = true;
if (element) {
field("text-value").value = element.text;
fontPicker.setValue(element.font);
field("text-size").value = String(element.size);
field("text-x").value = String(element.x);
field("text-y").value = String(element.y);
field("text-align").value = element.align;
colorTrigger.value = element.color;
} else {
field("text-value").value = "";
fontPicker.setValue(defaults.font);
field("text-size").value = String(defaults.size);
field("text-x").value = "32";
field("text-y").value = "26";
field("text-align").value = "center";
colorTrigger.value = "#FFFFFF";
}
refreshColorTrigger(colorTrigger);
contentFields.toggleAttribute("disabled", !element);
fields.toggleAttribute("disabled", !element);
updateFontPickerAvailability(element);
renderedSelectionId = element?.id || null;
suppressForm = false;
}
function displayName(element, index) {
const text = element.text.trim() || "(空文字)";
return `${index + 1}. ${text.replace(/\s+/g, " ").slice(0, 28)}`;
}
function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "text-element-item";
button.textContent = displayName(element, index);
button.title = `${element.text || "空文字"} · ${element.size}px · ${element.color}`;
const selected = element.id === selectedId;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
button.addEventListener("click", () => selectTextElement(element.id));
return button;
});
list.replaceChildren(...items);
}
function renderSelection({ scene, selectedTextId, reason }) {
const selected = scene.elements.find((element) => element.id === selectedTextId) || null;
renderElementList(scene, selectedTextId);
if (selected) {
setControlReady(duplicateButton);
setControlReady(deleteButton);
} else {
setControlUnavailable(duplicateButton, "请先新建或选择一个文字元素。");
setControlUnavailable(deleteButton, "请先新建或选择一个文字元素。");
}
selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected);
}
function numericValue(id, fallback) {
const raw = field(id).value.trim();
const value = Number(raw);
return raw && Number.isFinite(value) ? value : fallback;
}
function formPatch(element) {
return {
text: field("text-value").value,
font: fontPicker.getValue() || "default",
size: numericValue("text-size", element.size),
x: numericValue("text-x", element.x),
y: numericValue("text-y", element.y),
align: field("text-align").value,
color: colorTrigger.value,
};
}
form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form");
});
document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({
text: "ok",
font: defaults.font,
size: defaults.size,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
});
announce(`已创建文字“${created.text}”,编辑完成后再应用统一画板`);
});
duplicateButton.addEventListener("click", () => {
const duplicate = duplicateTextElement();
if (duplicate) announce(`已复制文字“${duplicate.text}”并置于最上层`);
});
deleteButton.addEventListener("click", () => {
const selected = getSelectedTextElement();
if (selected && deleteTextElement(selected.id)) announce(`已删除文字“${selected.text || "空文字"}”`);
});
subscribeScene(renderSelection);
async function loadFontCatalog() {
const catalog = await apiJson("/api/fonts");
fontPicker.setItems(catalog.items);
acceptedFontExtensions = Array.isArray(catalog.accepted_extensions)
? catalog.accepted_extensions.map((value) => String(value).toLowerCase())
: acceptedFontExtensions;
fontMaxUploadBytes = Number(catalog.max_upload_bytes) || fontMaxUploadBytes;
fontCatalogReady = true;
updateFontPickerAvailability();
if (catalog.warnings?.length) {
setFontFeedback(catalog.warnings.join(";"), true);
}
return catalog;
}
function validateFontFile(file) {
const extension = file.name.includes(".")
? `.${file.name.split(".").at(-1).toLowerCase()}`
: "";
if (!acceptedFontExtensions.includes(extension)) {
return "type";
}
if (file.size > fontMaxUploadBytes) {
return "size";
}
return "";
}
function describeFontImportError(error) {
if (error?.code === "APP_STALE") {
return ["copy.dynamic.text.font_error_stale", "页面版本已过期,请刷新后再导入字体。"];
}
if (error?.code === "REQUEST_TIMEOUT") {
return ["copy.dynamic.text.font_error_timeout", "字体导入超时,请检查核桃派网络或服务状态后重试。"];
}
const messages = {
413: ["copy.dynamic.text.font_error_too_large", "字体导入失败:文件超过 32 MiB。"],
415: ["copy.dynamic.text.font_error_type", "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。"],
422: ["copy.dynamic.text.font_error_invalid", "字体导入失败:文件损坏或不包含可用字体。"],
507: ["copy.dynamic.text.font_error_storage", "字体导入失败:设备存储空间不足。"],
};
if (messages[error?.status]) return messages[error.status];
if (error instanceof TypeError) {
return ["copy.dynamic.text.font_error_network", "字体导入失败:无法连接设备,请检查网络后重试。"];
}
return ["copy.dynamic.text.font_error_unknown", "字体导入失败:服务器未能完成导入,请稍后重试。"];
}
importFontButton.addEventListener("click", () => fontFileInput.click());
fontFileInput.addEventListener("change", async () => {
const file = fontFileInput.files?.[0];
fontFileInput.value = "";
if (!file) return;
const validationError = validateFontFile(file);
if (validationError) {
const copy = validationError === "type"
? ["copy.dynamic.text.font_type_error", "只支持 TTF、OTF、TTC、OTC 字体文件。"]
: ["copy.dynamic.text.font_size_error", "字体文件不能超过 32 MiB。"];
setFontFeedbackTemplate(copy[0], copy[1], {}, true);
announce(fontFeedback.textContent, true);
return;
}
try {
setFontFeedbackTemplate(
"copy.dynamic.text.font_importing",
"正在导入字体“{name}”…",
{ name: file.name },
);
announce(fontFeedback.textContent);
const result = await runControlAction(
importFontButton,
{ label: "正在导入…" },
async () => apiFile(
`/api/fonts/import?filename=${encodeURIComponent(file.name)}`,
file,
{ method: "POST" },
),
);
await loadFontCatalog();
const selected = getSelectedTextElement();
const imported = result.fonts?.[0] || null;
if (selected && imported) {
fontPicker.setValue(imported.id);
updateTextElement(selected.id, { font: imported.id }, "form");
}
const name = imported?.label || file.name;
const feedback = result.created
? (selected
? ["copy.dynamic.text.font_imported_applied", "字体“{name}”已导入并应用到当前文字"]
: ["copy.dynamic.text.font_imported", "字体“{name}”已导入"])
: (selected
? ["copy.dynamic.text.font_exists_applied", "字体“{name}”已经存在,已应用到当前文字"]
: ["copy.dynamic.text.font_exists", "字体“{name}”已经存在"]);
setFontFeedbackTemplate(feedback[0], feedback[1], { name });
announce(fontFeedback.textContent);
} catch (error) {
const feedback = describeFontImportError(error);
setFontFeedbackTemplate(feedback[0], feedback[1], {}, true);
announce(fontFeedback.textContent, true);
}
});
async function initializeTextWorkspace() {
if (!initialized) {
initialized = true;
const [configResult, fontsResult] = await Promise.allSettled([
apiJson("/api/config"),
loadFontCatalog(),
]);
if (configResult.status === "fulfilled") {
const config = configResult.value;
defaults = {
font: config.default_font || "default",
size: Number(config.default_text_size) || 12,
};
} else {
announce(`默认文字设置读取失败,已使用内置默认值:${configResult.reason.message}`, true);
}
if (fontsResult.status === "rejected") {
fontCatalogReady = false;
updateFontPickerAvailability();
setFontFeedbackTemplate(
"copy.dynamic.text.font_catalog_failed",
"字体目录读取失败,当前字体值已保留:{error}",
{ error: fontsResult.reason.message },
true,
);
announce(fontFeedback.textContent, true);
}
}
if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
registerWorkspace({
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});
@@ -0,0 +1,345 @@
import { announce, apiJson } from "../core.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { createLatchedDraftState } from "../wifi-draft-state.js";
const ssidInput = document.getElementById("wifi-ssid");
const passwordInput = document.getElementById("wifi-password");
const dhcpToggle = document.getElementById("wifi-dhcp-enabled");
const dhcpState = document.getElementById("wifi-dhcp-state");
const dhcpRow = dhcpToggle.closest(".wifi-dhcp-row");
const manualFields = document.getElementById("wifi-manual-fields");
const addressInput = document.getElementById("wifi-address");
const gatewayInput = document.getElementById("wifi-gateway");
const prefixInput = document.getElementById("wifi-prefix");
const dnsInput = document.getElementById("wifi-dns");
const immediateButton = document.getElementById("wifi-apply-immediate");
const nextBootButton = document.getElementById("wifi-apply-next-boot");
const activeStatus = document.getElementById("wifi-active-status");
const feedback = document.getElementById("wifi-feedback");
const delayInput = document.getElementById("wifi-prompt-delay");
const delaySaveButton = document.getElementById("wifi-prompt-delay-save");
const delayFeedback = document.getElementById("wifi-prompt-feedback");
const networkFields = new Map([
["ssid", { control: ssidInput, marker: document.getElementById("wifi-ssid-modified"), highlight: ssidInput }],
["password", { control: passwordInput, marker: document.getElementById("wifi-password-modified"), highlight: passwordInput }],
["dhcp", { control: dhcpToggle, marker: document.getElementById("wifi-dhcp-modified"), highlight: dhcpRow }],
["address", { control: addressInput, marker: document.getElementById("wifi-address-modified"), highlight: addressInput }],
["gateway", { control: gatewayInput, marker: document.getElementById("wifi-gateway-modified"), highlight: gatewayInput }],
["prefix", { control: prefixInput, marker: document.getElementById("wifi-prefix-modified"), highlight: prefixInput }],
["dns", { control: dnsInput, marker: document.getElementById("wifi-dns-modified"), highlight: dnsInput }],
]);
const networkDraft = createLatchedDraftState(networkFields.keys());
const delayDraft = createLatchedDraftState(["prompt_delay_seconds"]);
let wifiPending = false;
let delayPending = false;
let settingsLoaded = false;
let loadGeneration = 0;
let baseline = null;
function setFeedback(message, state = "idle") {
feedback.textContent = message;
feedback.dataset.state = state;
feedback.hidden = !message;
}
function setDelayFeedback(message, state = "idle") {
delayFeedback.textContent = message;
delayFeedback.dataset.state = state;
delayFeedback.hidden = !message;
}
function showDhcpMode() {
const enabled = dhcpToggle.checked;
dhcpState.textContent = enabled ? "开启" : "关闭";
manualFields.hidden = enabled;
addressInput.required = !enabled;
gatewayInput.required = !enabled;
}
function splitDns(value) {
return value.split(/[,,\s]+/u).map((item) => item.trim()).filter(Boolean);
}
function showStatus(data) {
const active = data.active || {};
if (active.connected) {
activeStatus.textContent = `当前已连接:${active.ssid || "未知 WiFi"} · ${active.ipv4_address || "等待 IPv4"}`;
} else if (data.available === false) {
activeStatus.textContent = `网络状态不可用:${data.error || "NetworkManager 无响应"}`;
} else {
activeStatus.textContent = "当前未连接 WiFi";
}
}
function formStateFromResponse(data) {
const saved = data.saved || {};
return {
ssid: saved.ssid || "",
password: saved.password || "",
passwordConfigured: Boolean(saved.password_configured),
dhcp: (saved.ipv4_mode || "dhcp") === "dhcp",
address: saved.address || "",
gateway: saved.gateway || "",
prefix: saved.prefix ?? "",
dns: (saved.dns_servers || []).join(", "),
promptDelay: data.prompt_delay_seconds ?? 30,
};
}
function applyFormState(state) {
if (!state) return;
ssidInput.value = state.ssid;
passwordInput.value = state.password;
passwordInput.placeholder = state.passwordConfigured ? "" : "请输入 8..63 字节密码";
dhcpToggle.checked = state.dhcp;
addressInput.value = state.address;
gatewayInput.value = state.gateway;
prefixInput.value = state.prefix;
dnsInput.value = state.dns;
delayInput.value = String(state.promptDelay);
showDhcpMode();
}
function setNetworkMarker(key, modified) {
const field = networkFields.get(key);
field.marker.hidden = !modified;
field.highlight.classList.toggle("wifi-field-modified", modified);
}
function clearNetworkDraft() {
networkDraft.clear();
networkFields.forEach((_field, key) => setNetworkMarker(key, false));
updateNetworkButtons();
}
function clearDelayDraft() {
delayDraft.clear();
updateDelayButton();
}
function updateNetworkButtons() {
if (wifiPending) return;
const ready = settingsLoaded && networkDraft.dirty;
immediateButton.classList.toggle("wifi-action-ready", ready);
nextBootButton.classList.toggle("wifi-action-ready", ready);
if (ready) {
setControlReady(immediateButton);
setControlReady(nextBootButton);
return;
}
const reason = settingsLoaded
? "请先修改 WiFi 或 DHCP 设置,再选择生效方式。"
: "请等待当前 WiFi 设置读取完成。";
setControlUnavailable(immediateButton, reason);
setControlUnavailable(nextBootButton, reason);
}
function updateDelayButton() {
if (delayPending) return;
const ready = settingsLoaded && delayDraft.dirty;
delaySaveButton.classList.toggle("wifi-action-ready", ready);
if (ready) {
setControlReady(delaySaveButton);
return;
}
setControlUnavailable(
delaySaveButton,
settingsLoaded ? "请先修改网络提示等待时间。" : "请等待当前网络提示设置读取完成。",
);
}
function markNetworkField(key) {
if (!settingsLoaded) return;
networkDraft.mark(key);
setNetworkMarker(key, true);
updateNetworkButtons();
}
function markDelayField() {
if (!settingsLoaded) return;
delayDraft.mark("prompt_delay_seconds");
updateDelayButton();
}
function syncForm(data) {
baseline = formStateFromResponse(data);
applyFormState(baseline);
settingsLoaded = true;
clearNetworkDraft();
clearDelayDraft();
showStatus(data);
}
export async function loadWifiSettings() {
const generation = ++loadGeneration;
settingsLoaded = false;
clearNetworkDraft();
clearDelayDraft();
try {
const data = await apiJson("/api/network/wifi");
if (generation !== loadGeneration) return;
syncForm(data);
setFeedback("");
setDelayFeedback("");
} catch (error) {
if (generation !== loadGeneration) return;
setFeedback(`WiFi 设置读取失败:${error.message}`, "error");
announce(`WiFi 设置读取失败:${error.message}`, true);
updateNetworkButtons();
updateDelayButton();
}
}
function buildRequest(activation) {
const ssid = ssidInput.value.trim();
if (!ssid) throw new Error("请输入 WiFi 名称(SSID)");
const password = passwordInput.value;
if (ssid !== baseline.ssid && !password) throw new Error("修改 WiFi 名称时必须填写新密码");
if (!password && !baseline.passwordConfigured) throw new Error("当前没有已保存密码,请填写 WiFi 密码");
const manual = !dhcpToggle.checked;
if (manual && !addressInput.value.trim()) throw new Error("关闭 DHCP 后必须填写静态 IPv4 地址");
if (manual && !gatewayInput.value.trim()) throw new Error("关闭 DHCP 后必须填写网关");
const rawPrefix = prefixInput.value.trim();
const prefix = rawPrefix ? Number(rawPrefix) : null;
if (rawPrefix && (!Number.isInteger(prefix) || prefix < 1 || prefix > 32)) {
throw new Error("子网前缀必须是 1..32 的整数");
}
return {
ssid,
password: password || null,
ipv4_mode: manual ? "manual" : "dhcp",
address: manual ? addressInput.value.trim() : null,
gateway: manual ? gatewayInput.value.trim() : null,
prefix,
dns_servers: manual ? splitDns(dnsInput.value) : [],
activation,
};
}
function captureNetworkBaseline(request) {
baseline = {
...baseline,
ssid: request.ssid,
password: request.password || baseline.password,
passwordConfigured: Boolean(request.password || baseline.passwordConfigured),
dhcp: request.ipv4_mode === "dhcp",
address: request.address || "",
gateway: request.gateway || "",
prefix: request.prefix ?? "",
dns: (request.dns_servers || []).join(", "),
};
}
async function applyWifi(activation) {
if (wifiPending || !networkDraft.dirty) return;
let request;
try {
request = buildRequest(activation);
} catch (error) {
setFeedback(error.message, "error");
announce(error.message, true);
return;
}
wifiPending = true;
setControlBusy(immediateButton, {
label: activation === "immediate" ? "正在保存…" : undefined,
message: "正在安全保存 WiFi 与 IPv4 配置…",
});
setControlBusy(nextBootButton, {
label: activation === "next_boot" ? "正在保存…" : undefined,
});
setFeedback(
activation === "immediate"
? "正在保存;设备随后会切换网络,当前页面可能断开。"
: "正在保存下次断电开机使用的网络配置…",
"pending",
);
try {
const result = await apiJson("/api/network/wifi", {
method: "PUT",
body: JSON.stringify(request),
});
captureNetworkBaseline(request);
clearNetworkDraft();
setFeedback(result.message, "success");
announce(result.message);
if (result.network) showStatus(result.network);
} catch (error) {
setFeedback(`WiFi 设置保存失败:${error.message}`, "error");
announce(`WiFi 设置保存失败:${error.message}`, true);
} finally {
wifiPending = false;
updateNetworkButtons();
}
}
function validatedPromptDelay() {
const delay = Number(delayInput.value);
if (!Number.isInteger(delay) || delay < 1 || delay > 3600) {
throw new Error("网络提示等待时间必须是 1..3600 秒的整数");
}
return delay;
}
async function savePromptDelay() {
if (delayPending || !delayDraft.dirty) return;
let delay;
try {
delay = validatedPromptDelay();
} catch (error) {
setDelayFeedback(error.message, "error");
announce(error.message, true);
return;
}
delayPending = true;
setControlBusy(delaySaveButton, {
label: "正在保存…",
message: "正在保存网络提示等待时间…",
});
setDelayFeedback("正在保存网络提示等待时间…", "pending");
try {
const result = await apiJson("/api/network/wifi/prompt-delay", {
method: "PUT",
body: JSON.stringify({ prompt_delay_seconds: delay }),
});
baseline = { ...baseline, promptDelay: result.prompt_delay_seconds };
delayInput.value = String(result.prompt_delay_seconds);
clearDelayDraft();
setDelayFeedback(result.message, "success");
announce(result.message);
} catch (error) {
setDelayFeedback(`网络提示等待时间保存失败:${error.message}`, "error");
announce(`网络提示等待时间保存失败:${error.message}`, true);
} finally {
delayPending = false;
updateDelayButton();
}
}
export function discardWifiSettingsDrafts() {
loadGeneration += 1;
applyFormState(baseline);
settingsLoaded = false;
clearNetworkDraft();
clearDelayDraft();
setFeedback("");
setDelayFeedback("");
}
for (const [key, field] of networkFields) {
if (key === "dhcp") continue;
field.control.addEventListener("input", () => markNetworkField(key));
}
dhcpToggle.addEventListener("change", () => {
showDhcpMode();
markNetworkField("dhcp");
});
delayInput.addEventListener("input", markDelayField);
immediateButton.addEventListener("click", () => applyWifi("immediate"));
nextBootButton.addEventListener("click", () => applyWifi("next_boot"));
delaySaveButton.addEventListener("click", savePromptDelay);
updateNetworkButtons();
updateDelayButton();
@@ -0,0 +1,25 @@
export function createLatchedDraftState(allowedKeys) {
const allowed = new Set(allowedKeys);
const modified = new Set();
return Object.freeze({
mark(key) {
if (!allowed.has(key)) throw new Error(`Unknown draft field: ${key}`);
const wasModified = modified.has(key);
modified.add(key);
return !wasModified;
},
clear() {
modified.clear();
},
has(key) {
return modified.has(key);
},
get dirty() {
return modified.size > 0;
},
keys() {
return [...modified];
},
});
}
@@ -0,0 +1,34 @@
export function resolveWorkspaceOrder(definitions, savedOrder) {
const defaults = Array.from(definitions || []);
const byId = new Map(defaults.map((definition) => [definition.id, definition]));
const resolved = [];
const seen = new Set();
if (Array.isArray(savedOrder)) {
savedOrder.forEach((workspaceId) => {
if (seen.has(workspaceId) || !byId.has(workspaceId)) return;
seen.add(workspaceId);
resolved.push(byId.get(workspaceId));
});
}
defaults.forEach((definition) => {
if (seen.has(definition.id)) return;
seen.add(definition.id);
resolved.push(definition);
});
return resolved;
}
export function moveWorkspaceOrder(workspaceIds, index, offset) {
if (!Array.isArray(workspaceIds) || !Number.isInteger(index) || !Number.isInteger(offset)) {
return null;
}
const target = index + offset;
if (index < 0 || index >= workspaceIds.length || target < 0 || target >= workspaceIds.length) {
return null;
}
const moved = [...workspaceIds];
[moved[index], moved[target]] = [moved[target], moved[index]];
return moved;
}