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";});