Files
matrix-screen-controller/核桃派软件源代码/app/static/app.js
T

384 lines
14 KiB
JavaScript

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/layers.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();
if(window.location.hash==="#text")window.location.hash="#canvas";
window.addEventListener("hashchange",()=>{if(window.location.hash==="#text")window.location.hash="#canvas";});