456 lines
17 KiB
JavaScript
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; }
|
|
});
|
|
}());
|