385 lines
14 KiB
JavaScript
385 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, initializeStartupBoard } 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();
|
|
initializeStartupBoard();
|
|
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";});
|