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