feat: add configurable operator appearance and layout

This commit is contained in:
2026-07-23 11:14:10 +09:00
parent cf117de144
commit f43483533a
34 changed files with 5942 additions and 740 deletions

View File

@@ -13,6 +13,9 @@
const settingsWorkspace = document.getElementById("settings-workspace");
const marketTabs = document.getElementById("market-tabs");
const title = document.getElementById("workspace-title");
const shell = document.querySelector(".operator-shell");
const splitter = document.getElementById("workspace-splitter");
const playlistPanel = document.querySelector(".playlist-panel");
if (!region || !navigation || !navigationItems || !toggle || !toggleLabel ||
!stockTab || !settingsTab || !stockWorkspace || !catalogWorkspace ||
@@ -29,6 +32,51 @@
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]"));
@@ -98,9 +146,159 @@
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;
@@ -166,8 +364,14 @@
toggle.addEventListener("click", function () {
setNavigationExpanded(toggle.getAttribute("aria-expanded") !== "true");
});
stockTab.addEventListener("click", function () { setWorkspace("stock"); });
settingsTab.addEventListener("click", function () { setWorkspace("settings"); });
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");
@@ -202,20 +406,25 @@
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"; }
expanded: function () { return toggle.getAttribute("aria-expanded") === "true"; },
scheduleWidth: function () { return scheduleWidthPercent; }
});
}());