Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js

456 lines
17 KiB
JavaScript

(function () {
"use strict";
const region = document.getElementById("workspace-region");
const navigation = document.getElementById("workspace-navigation");
const navigationItems = document.getElementById("workspace-nav-items");
const toggle = document.getElementById("workspace-nav-toggle");
const toggleLabel = toggle && toggle.querySelector(".workspace-nav-toggle-label");
const stockTab = document.getElementById("workspace-stock-tab");
const settingsTab = document.getElementById("workspace-settings-tab");
const stockWorkspace = document.getElementById("stock-workspace");
const catalogWorkspace = document.getElementById("catalog-workspace");
const settingsWorkspace = document.getElementById("settings-workspace");
const marketTabs = document.getElementById("market-tabs");
const title = document.getElementById("workspace-title");
const titleIcon = document.querySelector(".workspace-heading-icon");
const shell = document.querySelector(".operator-shell");
const splitter = document.getElementById("workspace-splitter");
const playlistPanel = document.querySelector(".playlist-panel");
const brandWordmark = document.querySelector("img.brand-wordmark");
const brandWordmarkPlate = brandWordmark?.closest(".brand-wordmark-plate");
function showBrandWordmarkFallback() {
brandWordmarkPlate?.classList.add("wordmark-fallback-active");
}
if (brandWordmark) {
brandWordmark.addEventListener("error", showBrandWordmarkFallback, { once: true });
if (brandWordmark.complete && brandWordmark.naturalWidth === 0) {
showBrandWordmarkFallback();
}
}
if (!region || !navigation || !navigationItems || !toggle || !toggleLabel ||
!stockTab || !settingsTab || !stockWorkspace || !catalogWorkspace ||
!settingsWorkspace || !marketTabs || !title) {
return;
}
const views = Object.freeze({
stock: Object.freeze({ view: stockWorkspace, title: "종목·컷" }),
catalog: Object.freeze({ view: catalogWorkspace, title: "기타 그래픽" }),
settings: Object.freeze({ view: settingsWorkspace, title: "설정" })
});
let activeWorkspace = "stock";
let activeMarketTabId = marketTabs.querySelector("button.active[data-tab-id]")
?.dataset.tabId || marketTabs.querySelector("button[data-tab-id]")?.dataset.tabId || null;
let pendingKeyboardMarketTabId = null;
let rememberWorkspaceChanges = false;
let startPreferenceApplied = false;
let splitterGesture = null;
let scheduleWidthPercent = null;
const lastWorkspaceStorageKey = "mbn-stock-webview.last-workspace.v1";
const scheduleWidthStorageKey = "mbn-stock-webview.schedule-width.v1";
const scheduleMinimumPercent = 34;
const scheduleMaximumPercent = 48;
const workspaceMinimumPixels = 780;
const scheduleMinimumPixels = 600;
const splitterPixels = 8;
function readStoredValue(key) {
try {
return window.localStorage && window.localStorage.getItem(key);
} catch (_) {
return null;
}
}
function writeStoredValue(key, value) {
try {
if (window.localStorage) window.localStorage.setItem(key, value);
} catch (_) {
// Presentation preferences must never interrupt operator input.
}
}
function currentWorkspaceToken() {
if (activeWorkspace !== "catalog") return activeWorkspace;
return activeMarketTabId ? "catalog:" + activeMarketTabId : "stock";
}
function rememberActiveWorkspace() {
if (!rememberWorkspaceChanges) return;
writeStoredValue(lastWorkspaceStorageKey, currentWorkspaceToken());
}
function storedWorkspaceToken() {
const token = readStoredValue(lastWorkspaceStorageKey);
if (token === "stock" || token === "settings") return token;
if (typeof token !== "string" || !token.startsWith("catalog:")) return null;
const tabId = token.slice("catalog:".length);
return marketButton(tabId) ? token : null;
}
function marketButtons() {
return Array.from(marketTabs.querySelectorAll("button[data-tab-id]"));
}
function marketButton(tabId) {
return marketButtons().find(function (button) {
return button.dataset.tabId === tabId;
}) || null;
}
function marketLabel(button) {
const label = button && button.querySelector(".workspace-nav-label");
return (label && label.textContent.trim()) || button?.textContent.trim() || views.catalog.title;
}
function selectedMenuButton(name) {
if (name === "stock") return stockTab;
if (name === "settings") return settingsTab;
if (name === "catalog") {
return marketButton(activeMarketTabId) || marketButtons()[0] || null;
}
return null;
}
function syncHeadingIcon(button) {
const source = button && button.querySelector("svg.workspace-nav-icon[data-icon]");
if (!titleIcon || !source) return;
const next = source.cloneNode(true);
next.removeAttribute("aria-hidden");
titleIcon.dataset.icon = source.dataset.icon || "";
titleIcon.replaceChildren(next);
}
function refreshCurrentMenu() {
const selected = selectedMenuButton(activeWorkspace);
Array.from(navigation.querySelectorAll(".workspace-nav-item")).forEach(function (button) {
const isCurrent = button === selected;
button.classList.toggle("workspace-current", isCurrent);
button.setAttribute("aria-current", isCurrent ? "page" : "false");
});
region.dataset.activeWorkspace = activeWorkspace;
if (activeMarketTabId) region.dataset.activeMarketTab = activeMarketTabId;
}
function setNavigationExpanded(expanded) {
const isExpanded = expanded === true;
region.classList.toggle("nav-collapsed", !isExpanded);
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
toggle.setAttribute("aria-label", isExpanded ? "작업 메뉴 접기" : "작업 메뉴 펼치기");
toggle.title = isExpanded ? "메뉴 접기" : "메뉴 펼치기";
toggleLabel.textContent = isExpanded ? "메뉴 접기" : "메뉴 펼치기";
}
function setWorkspace(name) {
const selected = views[name];
if (!selected) return false;
const selectedButton = selectedMenuButton(name);
const currentFocus = document.activeElement;
const focusWillBeHidden = Object.keys(views).some(function (key) {
return key !== name && views[key].view.contains(currentFocus);
});
if (focusWillBeHidden && selectedButton) selectedButton.focus();
Object.keys(views).forEach(function (key) {
const entry = views[key];
const isCurrent = key === name;
entry.view.hidden = !isCurrent;
entry.view.toggleAttribute("inert", !isCurrent);
entry.view.setAttribute("aria-hidden", isCurrent ? "false" : "true");
});
activeWorkspace = name;
refreshCurrentMenu();
syncHeadingIcon(selectedButton);
title.textContent = name === "catalog"
? marketLabel(selectedMenuButton(name))
: selected.title;
rememberActiveWorkspace();
return true;
}
function applyStartPreference(mode) {
if (startPreferenceApplied) return true;
const token = mode === "lastWorkspace" ? storedWorkspaceToken() : "stock";
if (!token || token === "stock" || token === "settings") {
startPreferenceApplied = true;
return setWorkspace(token === "settings" ? "settings" : "stock");
}
const tabId = token.slice("catalog:".length);
const button = marketButton(tabId);
if (!button || button.disabled) return false;
startPreferenceApplied = true;
if (activeMarketTabId === tabId) {
syncMarket(tabId);
return setWorkspace("catalog");
}
button.click();
return true;
}
function numericScheduleBounds() {
if (!shell) {
return { minimum: scheduleMinimumPercent, maximum: scheduleMaximumPercent };
}
const width = shell.getBoundingClientRect().width;
if (!Number.isFinite(width) || width <= 0) {
return { minimum: scheduleMinimumPercent, maximum: scheduleMaximumPercent };
}
const minimum = Math.max(
scheduleMinimumPercent,
scheduleMinimumPixels / width * 100);
const maximum = Math.min(
scheduleMaximumPercent,
(width - workspaceMinimumPixels - splitterPixels) / width * 100);
return {
minimum: Math.min(minimum, maximum),
maximum: Math.max(minimum, maximum)
};
}
function clampScheduleWidth(value) {
const bounds = numericScheduleBounds();
return Math.min(bounds.maximum, Math.max(bounds.minimum, value));
}
function applyScheduleWidth(value, persist) {
if (!shell || !splitter || !Number.isFinite(value)) return false;
scheduleWidthPercent = clampScheduleWidth(value);
const serialized = scheduleWidthPercent.toFixed(3) + "%";
shell.style.setProperty("--schedule-pane-width", serialized);
const bounds = numericScheduleBounds();
const rounded = Math.round(scheduleWidthPercent);
splitter.setAttribute("aria-valuemin", bounds.minimum.toFixed(1));
splitter.setAttribute("aria-valuemax", bounds.maximum.toFixed(1));
splitter.setAttribute("aria-valuenow", scheduleWidthPercent.toFixed(1));
splitter.setAttribute("aria-valuetext", "송출 스케줄 " + rounded + "%");
if (persist === true) {
writeStoredValue(scheduleWidthStorageKey, scheduleWidthPercent.toFixed(3));
}
return true;
}
function schedulePercentFromPointer(clientX) {
const bounds = shell.getBoundingClientRect();
return (bounds.right - clientX) / bounds.width * 100;
}
function finishSplitterGesture(restore) {
if (!splitterGesture || !splitter) return;
const gesture = splitterGesture;
splitterGesture = null;
if (splitter.hasPointerCapture?.(gesture.pointerId)) {
splitter.releasePointerCapture(gesture.pointerId);
}
document.body.classList.remove("resizing-workspace");
splitter.classList.remove("dragging");
applyScheduleWidth(
restore === true ? gesture.startPercent : scheduleWidthPercent,
restore !== true);
}
function initializeSplitter() {
if (!shell || !splitter || !playlistPanel) return;
const stored = Number.parseFloat(readStoredValue(scheduleWidthStorageKey));
let initial = Number.isFinite(stored) ? stored : null;
if (initial === null) {
const shellBounds = shell.getBoundingClientRect();
const scheduleBounds = playlistPanel.getBoundingClientRect();
initial = shellBounds.width > 0
? scheduleBounds.width / shellBounds.width * 100
: 40;
}
applyScheduleWidth(initial, false);
splitter.addEventListener("pointerdown", function (event) {
if (event.button !== 0 || event.isPrimary === false || splitterGesture) return;
splitterGesture = {
pointerId: event.pointerId,
startPercent: scheduleWidthPercent
};
splitter.setPointerCapture?.(event.pointerId);
splitter.classList.add("dragging");
document.body.classList.add("resizing-workspace");
splitter.focus({ preventScroll: true });
event.preventDefault();
});
splitter.addEventListener("pointermove", function (event) {
if (!splitterGesture || splitterGesture.pointerId !== event.pointerId ||
(event.buttons & 1) === 0) return;
applyScheduleWidth(schedulePercentFromPointer(event.clientX), false);
event.preventDefault();
});
splitter.addEventListener("pointerup", function (event) {
if (!splitterGesture || splitterGesture.pointerId !== event.pointerId) return;
finishSplitterGesture(false);
event.preventDefault();
});
splitter.addEventListener("pointercancel", function (event) {
if (!splitterGesture || splitterGesture.pointerId !== event.pointerId) return;
finishSplitterGesture(true);
});
splitter.addEventListener("lostpointercapture", function (event) {
if (!splitterGesture || splitterGesture.pointerId !== event.pointerId) return;
finishSplitterGesture(false);
});
splitter.addEventListener("keydown", function (event) {
if (event.key === "Escape" && splitterGesture) {
event.preventDefault();
event.stopImmediatePropagation();
finishSplitterGesture(true);
return;
}
let next = scheduleWidthPercent;
if (event.key === "ArrowLeft") next += 2;
else if (event.key === "ArrowRight") next -= 2;
else if (event.key === "Home") next = numericScheduleBounds().minimum;
else if (event.key === "End") next = numericScheduleBounds().maximum;
else return;
event.preventDefault();
event.stopPropagation();
applyScheduleWidth(next, true);
});
window.addEventListener("resize", function () {
window.requestAnimationFrame(function () {
applyScheduleWidth(scheduleWidthPercent, false);
});
});
}
function syncMarket(tabId, options) {
const button = marketButton(tabId);
if (!button) return false;
activeMarketTabId = tabId;
region.dataset.activeMarketTab = tabId;
if (activeWorkspace === "catalog") {
refreshCurrentMenu();
syncHeadingIcon(button);
title.textContent = marketLabel(button);
}
const authoritativeNativeRender = options &&
options.authoritativeNativeRender === true;
if (authoritativeNativeRender && pendingKeyboardMarketTabId &&
pendingKeyboardMarketTabId !== tabId) {
// The native operation completed on a different tab. Do not carry a
// failed or superseded keyboard request into a later render.
pendingKeyboardMarketTabId = null;
}
if (authoritativeNativeRender && pendingKeyboardMarketTabId === tabId &&
!button.disabled) {
const pendingTabId = pendingKeyboardMarketTabId;
// Native loading temporarily disables the selected menu button, which
// can move focus to <body>. Only the final, non-busy native render may
// consume this request; the optimistic capture-phase click must not.
window.requestAnimationFrame(function () {
if (pendingKeyboardMarketTabId !== pendingTabId) return;
pendingKeyboardMarketTabId = null;
const pendingButton = marketButton(pendingTabId);
const active = document.activeElement;
const focusWasLost = !active ||
active === document.body ||
active === document.documentElement;
if (activeWorkspace === "catalog" && pendingButton &&
!pendingButton.disabled && focusWasLost) {
pendingButton.focus();
}
});
}
return true;
}
function hasPendingKeyboardMarketFocus(tabId) {
return pendingKeyboardMarketTabId !== null &&
(tabId === undefined || tabId === pendingKeyboardMarketTabId);
}
function select(name) {
if (views[name]) return setWorkspace(name);
if (!syncMarket(name)) return false;
return setWorkspace("catalog");
}
function focusAdjacentItem(direction) {
const items = Array.from(navigation.querySelectorAll(".workspace-nav-item"))
.filter(function (button) { return button.disabled !== true; });
if (items.length === 0) return;
const currentIndex = items.indexOf(document.activeElement);
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + items.length) % items.length;
items[nextIndex].focus();
}
toggle.addEventListener("click", function () {
setNavigationExpanded(toggle.getAttribute("aria-expanded") !== "true");
});
stockTab.addEventListener("click", function () {
startPreferenceApplied = true;
setWorkspace("stock");
});
settingsTab.addEventListener("click", function () {
startPreferenceApplied = true;
setWorkspace("settings");
});
navigation.addEventListener("keydown", function (event) {
const menuButton = event.target.closest && event.target.closest(".workspace-nav-item");
if (!menuButton) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
const keyboardMarketTabId = menuButton.dataset.tabId || null;
pendingKeyboardMarketTabId = keyboardMarketTabId;
menuButton.click();
return;
}
if (event.key === "Tab") {
pendingKeyboardMarketTabId = null;
return;
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
focusAdjacentItem(event.key === "ArrowUp" ? -1 : 1);
});
document.addEventListener("pointerdown", function () {
// A real pointer action transfers focus ownership to the user. A delayed
// native response must never pull it back to the keyboard-selected menu.
pendingKeyboardMarketTabId = null;
}, true);
// Reveal the selected catalog screen before app.js dispatches its native
// select-tab intent. The native state response remains authoritative and
// is reconciled through syncMarket after every render.
marketTabs.addEventListener("click", function (event) {
const button = event.target.closest("button[data-tab-id]");
if (!button || !marketTabs.contains(button)) return;
startPreferenceApplied = true;
syncMarket(button.dataset.tabId);
setWorkspace("catalog");
}, true);
setNavigationExpanded(true);
setWorkspace("stock");
rememberWorkspaceChanges = true;
initializeSplitter();
window.LegacyWorkspaceNavigation = Object.freeze({
select: select,
syncMarket: syncMarket,
hasPendingKeyboardMarketFocus: hasPendingKeyboardMarketFocus,
setExpanded: setNavigationExpanded,
applyStartPreference: applyStartPreference,
active: function () { return activeWorkspace; },
activeMarket: function () { return activeMarketTabId; },
expanded: function () { return toggle.getAttribute("aria-expanded") === "true"; },
scheduleWidth: function () { return scheduleWidthPercent; }
});
}());