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

@@ -983,7 +983,8 @@ public sealed partial class MainWindow
LegacyDeleteSelectedNamedPlaylistIntent or
LegacyChooseOperatorFolderIntent or
LegacyResetOperatorFolderIntent or
LegacySetOperatorNavigationExpandedIntent;
LegacySetOperatorNavigationExpandedIntent or
LegacySetOperatorAppearanceIntent;
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
LegacySetManualNetSellCellIntent or

View File

@@ -14,20 +14,20 @@ public sealed partial class MainWindow
private LegacyOperatorSettingsMessageKind _operatorSettingsMessageKind =
LegacyOperatorSettingsMessageKind.Neutral;
private LegacyOperatorFolderSnapshot _designFolderSnapshot = new(
"앱 기본 디자인 폴더",
"앱 기본",
IsCustom: false,
IsValid: false,
"확인 중");
private LegacyOperatorFolderSnapshot _resourceFolderSnapshot = new(
"앱 기본 설정 폴더",
"앱 기본",
IsCustom: false,
IsValid: false,
"확인 중");
private LegacyOperatorFolderSnapshot _backgroundFolderSnapshot = new(
"장면 폴더 기준 자동 탐색",
"자동",
IsCustom: false,
IsValid: true,
"필요할 때 선택");
"사용 가능");
private void InitializeOperatorSettingsStatus(string? warningMessage)
{
@@ -58,9 +58,17 @@ public sealed partial class MainWindow
{
NavigationExpanded = navigation.Expanded
},
navigation.Expanded
? "왼쪽 메뉴를 펼쳐서 시작하도록 저장했습니다."
: "왼쪽 메뉴를 접어서 시작하도록 저장했습니다.");
"저장됨");
break;
case LegacySetOperatorAppearanceIntent appearance:
SaveOperatorSettings(
_operatorSettings with
{
ColorTheme = appearance.ColorTheme,
ViewMode = appearance.ViewMode,
StartWorkspace = appearance.StartWorkspace
},
"저장됨");
break;
default:
SetOperatorSettingsMessage(
@@ -90,9 +98,6 @@ public sealed partial class MainWindow
var selected = await picker.PickSingleFolderAsync();
if (selected is null)
{
SetOperatorSettingsMessage(
"폴더 선택을 취소했습니다.",
LegacyOperatorSettingsMessageKind.Information);
return;
}
@@ -137,11 +142,7 @@ public sealed partial class MainWindow
},
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
var savedMessage = kind == LegacyOperatorFolderKind.Design &&
validated.MissingOptionalExternalAssetCount > 0
? $"디자인 폴더를 저장했습니다. 외부 영상 {validated.MissingOptionalExternalAssetCount}개가 없어 관련 장면만 제한됩니다. 앱을 다시 시작하면 적용됩니다."
: $"{OperatorFolderLabel(kind)} 폴더를 저장했습니다. 앱을 다시 시작하면 적용됩니다.";
SaveOperatorSettings(next, savedMessage);
SaveOperatorSettings(next, "저장됨 · 재시작 필요");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -175,7 +176,7 @@ public sealed partial class MainWindow
};
SaveOperatorSettings(
next,
$"{OperatorFolderLabel(kind)} 폴더를 기본값으로 되돌렸습니다. 앱을 다시 시작하면 적용됩니다.");
"초기화됨 · 재시작 필요");
}
private void SaveOperatorSettings(
@@ -252,31 +253,27 @@ public sealed partial class MainWindow
_designFolderSnapshot = CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Scene,
_operatorSettings.SceneDirectory,
"앱 기본 디자인 폴더",
"활성 장면·기본 자산 확인됨");
"앱 기본값");
_resourceFolderSnapshot = CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Resource,
_operatorSettings.ResourceDirectory,
"앱 기본 설정 폴더",
"UI 설정 검증됨");
"앱 기본값");
_backgroundFolderSnapshot = _operatorSettings.BackgroundDirectory is null
? new LegacyOperatorFolderSnapshot(
"장면 폴더 기준 자동 탐색",
"자동",
IsCustom: false,
IsValid: true,
"필요할 때 선택")
"사용 가능")
: CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Background,
_operatorSettings.BackgroundDirectory,
"장면 폴더 기준 자동 탐색",
"배경 폴더 확인됨");
"자동");
}
private static LegacyOperatorFolderSnapshot CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind kind,
string? configuredPath,
string defaultDisplay,
string validStatus)
string defaultDisplay)
{
if (configuredPath is null)
{
@@ -307,7 +304,7 @@ public sealed partial class MainWindow
defaultDisplay,
IsCustom: false,
IsValid: true,
FolderStatus(kind, defaultFolder, validStatus));
"사용 가능");
}
var validation = LegacyOperatorFolderValidator.Validate(kind, configuredPath);
@@ -320,32 +317,22 @@ public sealed partial class MainWindow
"폴더 확인 필요");
}
var status = kind == LegacyOperatorSettingsFolderKind.Resource &&
validation.Folder.DatabaseIniDetected
? "UI·DB 설정 검증됨"
: FolderStatus(kind, validation.Folder, validStatus);
return new LegacyOperatorFolderSnapshot(
validation.Folder.AbbreviatedPath,
IsCustom: true,
IsValid: true,
status);
"사용 가능");
}
private static string FolderStatus(
LegacyOperatorSettingsFolderKind kind,
LegacyOperatorValidatedFolder folder,
string validStatus) =>
kind == LegacyOperatorSettingsFolderKind.Scene &&
folder.MissingOptionalExternalAssetCount > 0
? $"핵심 자산 확인됨 · 외부 영상 {folder.MissingOptionalExternalAssetCount}개 제외"
: validStatus;
private LegacyOperatorSettingsSnapshot CreateOperatorSettingsSnapshot() => new(
Volatile.Read(ref _operatorSettingsRevision),
_designFolderSnapshot,
_resourceFolderSnapshot,
_backgroundFolderSnapshot,
_operatorSettings.NavigationExpanded,
_operatorSettings.ColorTheme,
_operatorSettings.ViewMode,
_operatorSettings.StartWorkspace,
RestartRequired: !OperatorFolderSettingsEqual(
_operatorSettings,
_appliedOperatorSettings),
@@ -408,11 +395,4 @@ public sealed partial class MainWindow
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
private static string OperatorFolderLabel(LegacyOperatorFolderKind kind) => kind switch
{
LegacyOperatorFolderKind.Design => "디자인",
LegacyOperatorFolderKind.Resource => "설정",
LegacyOperatorFolderKind.Background => "운영 배경",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
}

View File

@@ -831,7 +831,8 @@ public sealed partial class MainWindow : Window
private static bool IsOperatorSettingsIntent(LegacyUiIntent intent) => intent is
LegacyChooseOperatorFolderIntent or
LegacyResetOperatorFolderIntent or
LegacySetOperatorNavigationExpandedIntent;
LegacySetOperatorNavigationExpandedIntent or
LegacySetOperatorAppearanceIntent;
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
intent is LegacyRefreshNamedPlaylistsIntent or

View File

@@ -4,6 +4,7 @@
const webview = window.chrome && window.chrome.webview;
const searchInput = document.getElementById("stock-search");
const searchButton = document.getElementById("stock-search-button");
const stockSearchMeta = document.getElementById("stock-search-meta");
const stockResults = document.getElementById("stock-results");
const cutList = document.getElementById("cut-list");
const cutDragFeedback = document.getElementById("cut-drag-feedback");
@@ -24,6 +25,23 @@
const operatorSettingsMessage = document.getElementById("operator-settings-message");
const operatorSettingsRestart = document.getElementById("operator-settings-restart");
const operatorNavigationExpanded = document.getElementById("operator-navigation-expanded");
const operatorAppearanceControls = Object.freeze({
colorTheme: Object.freeze({
element: document.getElementById("operator-color-theme"),
values: Object.freeze(["system", "light", "dark"]),
fallback: "system"
}),
viewMode: Object.freeze({
element: document.getElementById("operator-view-mode"),
values: Object.freeze(["automatic", "compact", "cards"]),
fallback: "automatic"
}),
startWorkspace: Object.freeze({
element: document.getElementById("operator-start-workspace"),
values: Object.freeze(["stockCut", "lastWorkspace"]),
fallback: "stockCut"
})
});
const operatorSettingsAppVersion = document.getElementById("operator-settings-app-version");
const operatorSettingsPlayoutMode = document.getElementById("operator-settings-playout-mode");
const operatorSettingsPlayoutConnection = document.getElementById(
@@ -223,6 +241,11 @@
let operatorCatalogSearchFocus = null;
let operatorSettingsRenderRevision = null;
let operatorSettingsRevisionInitialized = false;
let operatorAppearanceSnapshot = Object.freeze({
colorTheme: "system",
viewMode: "automatic",
startWorkspace: "stockCut"
});
let pendingOperatorSettingsFocusId = null;
let pendingOperatorSettingsFallbackFocusId = null;
let operatorSettingsWasBusy = false;
@@ -1326,6 +1349,31 @@
}
}
function renderStockSearchMeta(state) {
const results = Array.isArray(state.searchResults) ? state.searchResults : [];
const selected = results.find(function (row) {
return row.index === state.selectedStockIndex;
});
let label = "";
if (state.isBusy === true) {
label = "검색 중";
} else if (results.length > 0 || String(state.searchText || "").trim().length > 0) {
label = String(results.length) + "건";
if (selected && selected.displayName) {
label += " · " + selected.displayName;
}
}
stockSearchMeta.textContent = label;
stockSearchMeta.hidden = label.length === 0;
if (state.isBusy !== true && results.length === 0) {
stockResults.dataset.emptyLabel = "결과 없음";
} else {
delete stockResults.dataset.emptyLabel;
}
}
function pointerPosition(event) {
const bounds = cutList.getBoundingClientRect();
return {
@@ -1573,6 +1621,14 @@
if (cutDragGesture && !cutList.contains(cutDragGesture.element)) {
clearCutDragGesture();
}
if (state.cutRows.length === 0) {
cutList.dataset.emptyLabel = Number.isInteger(state.selectedStockIndex)
? "컷 없음"
: "종목을 선택하세요";
} else {
delete cutList.dataset.emptyLabel;
}
}
cutList.addEventListener("pointerdown", function (event) {
@@ -5028,11 +5084,13 @@
const controls = operatorFolderControls[kind];
const current = folder && typeof folder === "object" ? folder : {};
const defaultNames = {
design: "앱 기본 디자인 폴더",
resource: "앱 기본 설정 폴더",
background: "앱 기본 배경 폴더"
design: "앱 기본",
resource: "앱 기본",
background: "자동"
};
const displayName = operatorSettingsText(current.displayName, defaultNames[kind]);
const displayName = current.isCustom === true
? operatorSettingsText(current.displayName, defaultNames[kind])
: defaultNames[kind];
if (controls.name.textContent !== displayName) controls.name.textContent = displayName;
controls.name.title = displayName;
@@ -5050,6 +5108,55 @@
controls.reset.disabled = isBusy || current.isCustom !== true;
}
function normalizeOperatorAppearanceValue(key, value) {
const definition = operatorAppearanceControls[key];
return typeof value === "string" && definition.values.includes(value)
? value
: definition.fallback;
}
function operatorAppearanceFromSettings(settings) {
return {
colorTheme: normalizeOperatorAppearanceValue(
"colorTheme",
settings && settings.colorTheme),
viewMode: normalizeOperatorAppearanceValue("viewMode", settings && settings.viewMode),
startWorkspace: normalizeOperatorAppearanceValue(
"startWorkspace",
settings && settings.startWorkspace)
};
}
function operatorAppearanceFromControls() {
return {
colorTheme: normalizeOperatorAppearanceValue(
"colorTheme",
operatorAppearanceControls.colorTheme.element.value),
viewMode: normalizeOperatorAppearanceValue(
"viewMode",
operatorAppearanceControls.viewMode.element.value),
startWorkspace: normalizeOperatorAppearanceValue(
"startWorkspace",
operatorAppearanceControls.startWorkspace.element.value)
};
}
function syncOperatorAppearanceControls(appearance) {
Object.keys(operatorAppearanceControls).forEach(function (key) {
const control = operatorAppearanceControls[key].element;
const nextValue = appearance[key];
if (control.value !== nextValue) control.value = nextValue;
});
}
function applyOperatorAppearance(appearance) {
[document.documentElement, document.body].forEach(function (element) {
element.dataset.colorTheme = appearance.colorTheme;
element.dataset.viewMode = appearance.viewMode;
element.dataset.startWorkspace = appearance.startWorkspace;
});
}
function renderOperatorSettings(state) {
const settings = state && state.operatorSettings;
const isBusy = !settings || state.isBusy === true;
@@ -5060,11 +5167,18 @@
renderOperatorFolder("background", settings && settings.background, isBusy);
operatorNavigationExpanded.disabled = isBusy;
Object.keys(operatorAppearanceControls).forEach(function (key) {
operatorAppearanceControls[key].element.disabled = isBusy;
});
if (settings) {
const revision = settings.revision === undefined ? null : settings.revision;
const appearance = operatorAppearanceFromSettings(settings);
revisionChanged = !operatorSettingsRevisionInitialized ||
revision !== operatorSettingsRenderRevision;
if (revisionChanged) {
operatorAppearanceSnapshot = Object.freeze(appearance);
syncOperatorAppearanceControls(appearance);
applyOperatorAppearance(appearance);
const expanded = settings.navigationExpanded !== false;
operatorNavigationExpanded.checked = expanded;
if (window.LegacyWorkspaceNavigation &&
@@ -5074,6 +5188,10 @@
operatorSettingsRenderRevision = revision;
operatorSettingsRevisionInitialized = true;
}
if (window.LegacyWorkspaceNavigation &&
typeof window.LegacyWorkspaceNavigation.applyStartPreference === "function") {
window.LegacyWorkspaceNavigation.applyStartPreference(appearance.startWorkspace);
}
}
const message = operatorSettingsText(settings && settings.message, "");
@@ -5147,6 +5265,7 @@
updateDoubleClickTime(state);
updateCutDragSize(state);
renderStockResults(state);
renderStockSearchMeta(state);
renderCuts(state);
renderTabs(state);
movingAverage5.checked = state.movingAverages ? state.movingAverages.ma5 : true;
@@ -5170,7 +5289,7 @@
renderNamedPlaylist(state);
renderDialog(state);
syncModalFocus();
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
status.textContent = isBusy ? "처리 중" : (state.statusMessage || "");
}
Object.keys(operatorFolderControls).forEach(function (kind) {
@@ -5209,6 +5328,26 @@
}
});
Object.keys(operatorAppearanceControls).forEach(function (key) {
const control = operatorAppearanceControls[key].element;
control.addEventListener("change", function () {
const appearance = operatorAppearanceFromControls();
pendingOperatorSettingsFocusId = control.id;
pendingOperatorSettingsFallbackFocusId = null;
applyOperatorAppearance(appearance);
if (!send("set-operator-appearance", {
colorTheme: appearance.colorTheme,
viewMode: appearance.viewMode,
startWorkspace: appearance.startWorkspace
})) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
syncOperatorAppearanceControls(operatorAppearanceSnapshot);
applyOperatorAppearance(operatorAppearanceSnapshot);
}
});
});
document.addEventListener("input", function (event) {
rememberSearchDraft(event.target);
});

View File

@@ -1,12 +1,14 @@
<!doctype html>
<html lang="ko">
<html lang="ko" data-color-theme="system" data-view-mode="automatic"
data-start-workspace="stockCut">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>V-Stock 증권정보송출시스템</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<body data-color-theme="system" data-view-mode="automatic"
data-start-workspace="stockCut">
<main class="operator-shell" aria-label="V-Stock 증권정보송출시스템">
<section id="workspace-region" class="workspace-region" aria-label="작업 영역">
<aside id="workspace-navigation" class="workspace-navigation" role="navigation"
@@ -152,15 +154,10 @@
</svg>
</span>
<div class="workspace-heading-copy">
<small>현재 작업</small>
<h1 id="workspace-title">종목·컷</h1>
</div>
</div>
<div class="playout-connection-summary">
<span class="playout-connection-copy" aria-hidden="true">
<small>송출 연결</small>
<strong>Tornado2</strong>
</span>
<div class="connection-state" id="playout-connection-state" role="status"
aria-label="Tornado2 연결 상태" aria-live="polite"
aria-atomic="true">미연결</div>
@@ -180,6 +177,8 @@
</svg>
검색
</button>
<span id="stock-search-meta" class="search-meta" role="status"
aria-live="polite" aria-atomic="true" hidden></span>
</div>
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
@@ -204,26 +203,12 @@
<section id="catalog-workspace" class="catalog-panel workspace-view"
aria-label="분류별 그래픽" hidden inert>
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> Expand</label>
<div class="category-title">Category</div>
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> 전체 펼치기</label>
<div id="category-tree" class="category-tree" aria-label="원본 카테고리"></div>
</section>
<section id="settings-workspace" class="settings-panel workspace-view"
aria-label="설정" hidden inert>
<div class="settings-intro">
<div class="settings-intro-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.4 1A8 8 0 0 0 15 6.2L14.7 4h-4l-.3 2.2a8 8 0 0 0-1.5.9l-2.4-1-2 3.4 2 1.5a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.4-1a8 8 0 0 0 1.5.9l.3 2.2h4l.3-2.2a8 8 0 0 0 1.5-.9l2.4 1 2-3.4-2-1.5a7 7 0 0 0 .1-1Z"></path>
</svg>
</div>
<div>
<h2>운영 설정</h2>
<p>이 PC에서 사용할 디자인·설정·배경 폴더와 시작 화면 환경을 관리합니다. 폴더는 Windows 선택 창에서만 지정할 수 있습니다.</p>
</div>
</div>
<div id="operator-settings-message" class="settings-message" role="status"
aria-live="polite" aria-atomic="true" hidden></div>
<div id="operator-settings-restart" class="settings-restart-banner" role="status"
@@ -232,19 +217,14 @@
<path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"></path>
</svg>
<div>
<strong>앱을 다시 시작하면 새 폴더가 적용됩니다.</strong>
<span>현재 송출 작업은 그대로 유지되며, 다음 실행부터 변경된 위치를 사용합니다.</span>
<strong>재시작하면 적용됩니다.</strong>
</div>
</div>
<div class="settings-grid">
<article class="settings-card settings-card-wide settings-folder-card">
<div class="settings-card-heading">
<div>
<h3>운영 폴더</h3>
<p>경로를 직접 입력하지 않고 폴더 선택 창으로 안전하게 지정합니다.</p>
</div>
<span class="settings-card-label">로컬 PC</span>
<h3>폴더</h3>
</div>
<div class="settings-folder-list">
@@ -261,17 +241,16 @@
<h4 id="operator-design-folder-title">디자인 폴더</h4>
<span class="settings-folder-kind">Cuts</span>
</div>
<p>송출 장면(.t2s)과 장면별 디자인 자산의 기준 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-design-folder-name">앱 기본 디자인 폴더</strong>
<strong id="operator-design-folder-name">앱 기본</strong>
<span id="operator-design-folder-status" class="settings-status-badge neutral">확인 중</span>
<span id="operator-design-folder-source" class="settings-folder-source">앱 기본값</span>
<span id="operator-design-folder-source" class="settings-folder-source" hidden>앱 기본값</span>
</div>
</div>
<div class="settings-folder-actions">
<button id="operator-design-folder-select" class="settings-primary-action"
type="button">폴더 선택</button>
<button id="operator-design-folder-reset" type="button" disabled>기본값</button>
type="button">변경</button>
<button id="operator-design-folder-reset" type="button" disabled>초기화</button>
</div>
</section>
@@ -288,17 +267,16 @@
<h4 id="operator-resource-folder-title">설정 폴더</h4>
<span class="settings-folder-kind">Res</span>
</div>
<p>종목 목록 등 UI 설정과, MmoneyCoder.ini가 있으면 다음 시작부터 적용할 DB 프로필의 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-resource-folder-name">앱 기본 설정 폴더</strong>
<strong id="operator-resource-folder-name">앱 기본</strong>
<span id="operator-resource-folder-status" class="settings-status-badge neutral">확인 중</span>
<span id="operator-resource-folder-source" class="settings-folder-source">앱 기본값</span>
<span id="operator-resource-folder-source" class="settings-folder-source" hidden>앱 기본값</span>
</div>
</div>
<div class="settings-folder-actions">
<button id="operator-resource-folder-select" class="settings-primary-action"
type="button">폴더 선택</button>
<button id="operator-resource-folder-reset" type="button" disabled>기본값</button>
type="button">변경</button>
<button id="operator-resource-folder-reset" type="button" disabled>초기화</button>
</div>
</section>
@@ -312,36 +290,62 @@
</div>
<div class="settings-folder-copy">
<div class="settings-folder-title-line">
<h4 id="operator-background-folder-title">운영 배경 폴더</h4>
<h4 id="operator-background-folder-title">배경 폴더</h4>
<span class="settings-folder-kind">VRV/JPG/PNG</span>
</div>
<p>F2에서 선택할 수 있는 검증된 VRV/JPG/PNG 배경의 기준·허용 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-background-folder-name">앱 기본 배경 폴더</strong>
<strong id="operator-background-folder-name">자동</strong>
<span id="operator-background-folder-status" class="settings-status-badge neutral">확인 중</span>
<span id="operator-background-folder-source" class="settings-folder-source">앱 기본값</span>
<span id="operator-background-folder-source" class="settings-folder-source" hidden>앱 기본값</span>
</div>
</div>
<div class="settings-folder-actions">
<button id="operator-background-folder-select" class="settings-primary-action"
type="button">폴더 선택</button>
<button id="operator-background-folder-reset" type="button" disabled>기본값</button>
type="button">변경</button>
<button id="operator-background-folder-reset" type="button" disabled>초기화</button>
</div>
</section>
</div>
</article>
<article class="settings-card settings-appearance-card">
<div class="settings-card-heading">
<h3>화면</h3>
</div>
<div class="settings-choice-list">
<label class="settings-choice-row" for="operator-color-theme">
<strong>테마</strong>
<select id="operator-color-theme">
<option value="system">자동</option>
<option value="light">라이트</option>
<option value="dark">다크</option>
</select>
</label>
<label class="settings-choice-row" for="operator-view-mode">
<strong>보기</strong>
<select id="operator-view-mode">
<option value="automatic">자동</option>
<option value="compact">컴팩트</option>
<option value="cards">카드</option>
</select>
</label>
<label class="settings-choice-row" for="operator-start-workspace">
<strong>시작</strong>
<select id="operator-start-workspace">
<option value="stockCut">종목·컷</option>
<option value="lastWorkspace">마지막 화면</option>
</select>
</label>
</div>
</article>
<article class="settings-card">
<div class="settings-card-heading">
<div>
<h3>시작 화면</h3>
<p>앱을 열었을 때의 작업 공간 표시 방식을 정합니다.</p>
</div>
<h3>메뉴</h3>
</div>
<label class="settings-switch-row" for="operator-navigation-expanded">
<span>
<strong>왼쪽 메뉴 펼쳐서 시작</strong>
<small>끄면 아이콘만 보이는 간결한 메뉴로 시작합니다.</small>
<strong>메뉴 펼치기</strong>
</span>
<input id="operator-navigation-expanded" type="checkbox" checked>
<span class="settings-switch" aria-hidden="true"></span>
@@ -350,11 +354,7 @@
<article class="settings-card settings-diagnostics-card">
<div class="settings-card-heading">
<div>
<h3>현재 상태</h3>
<p>문제 확인에 필요한 읽기 전용 정보입니다.</p>
</div>
<span class="settings-card-label settings-card-label-readonly">읽기 전용</span>
<h3>상태</h3>
</div>
<dl class="settings-diagnostics">
<div><dt>앱 버전</dt><dd id="operator-settings-app-version">확인 중</dd></div>
@@ -366,24 +366,17 @@
</dl>
</article>
<article class="settings-card settings-card-wide">
<div class="settings-protected-copy">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="5" y="10" width="14" height="10" rx="2"></rect>
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v2"></path>
</svg>
<div>
<h3>보호되는 운영 설정</h3>
<p>DB 자격 증명의 내용은 이 화면에 표시하거나 직접 편집할 수 없습니다. 다만 선택한 설정(Res) 폴더에 검증된 MmoneyCoder.ini가 있으면 일반 실행의 다음 시작부터 그 DB 프로필을 사용합니다. Gate A 검증 실행에서는 선택한 프로필 대신 검증 계획에 고정된 DB 구성을 사용합니다. Tornado2 송출 대상, Live/DryRun 권한과 장면 허용 목록은 변경할 수 없으며 기존 송출 안전 규칙이 계속 적용됩니다.</p>
</div>
</div>
</article>
</div>
</section>
</div>
</section>
</section>
<div id="workspace-splitter" class="workspace-splitter" role="separator"
aria-label="작업 영역과 송출 스케줄 너비 조절" aria-orientation="vertical"
aria-controls="workspace-region" aria-valuemin="34" aria-valuemax="48"
aria-valuenow="39" tabindex="0"></div>
<section class="playlist-panel" aria-label="플레이리스트">
<header class="playlist-heading">
<div class="playlist-heading-icon" aria-hidden="true">
@@ -392,20 +385,33 @@
<path d="M8 9h8M8 12h8M8 15h5"></path>
</svg>
</div>
<div><small>항상 표시</small><h2>송출 스케줄</h2></div>
<div><h2>송출 스케줄</h2></div>
<div class="playlist-state-legend" aria-label="스케줄 상태">
<span class="legend-selected"><i aria-hidden="true"></i>선택</span>
<span class="legend-next"><i aria-hidden="true"></i>다음</span>
<span class="legend-on-air"><i aria-hidden="true"></i>ON AIR</span>
</div>
<div id="playlist-heading-count" class="playlist-heading-count"
role="status" aria-live="polite" aria-atomic="true">총 0개 · 송출 0개</div>
</header>
<div class="playlist-toolbar">
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
<label><input id="moving-average-5" type="checkbox" checked> 5일선</label>
<label><input id="moving-average-20" type="checkbox" checked> 20일선</label>
<button id="playlist-move-up" type="button" disabled aria-label="선택 행 위로 이동"></button>
<button id="playlist-move-down" type="button" disabled aria-label="선택 행 아래로 이동"></button>
<span class="toolbar-spacer"></span>
<label><input id="playout-background-none" type="checkbox" disabled> 배경없음(F3)</label>
<button id="playout-background-file" type="button" disabled>배경파일(F2)</button>
<input id="playout-background-name" class="background-name" value="배경없음" readonly>
<div class="playlist-toolbar-group" aria-label="표시">
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
<label><input id="moving-average-5" type="checkbox" checked> 5일</label>
<label><input id="moving-average-20" type="checkbox" checked> 20일</label>
</div>
<div class="playlist-toolbar-group playlist-toolbar-order" aria-label="순서">
<button id="playlist-move-up" type="button" disabled aria-label="선택 행 위로 이동"></button>
<button id="playlist-move-down" type="button" disabled aria-label="선택 행 아래로 이동"></button>
</div>
<div class="playlist-toolbar-group playlist-toolbar-background" aria-label="배경">
<label><input id="playout-background-none" type="checkbox" disabled
aria-keyshortcuts="F3"> 없음</label>
<button id="playout-background-file" type="button" disabled
aria-keyshortcuts="F2">배경 선택</button>
<input id="playout-background-name" class="background-name" value="배경없음" readonly
aria-label="선택한 배경">
</div>
<label class="fade-control" for="playout-fade-duration" hidden
aria-hidden="true">DissolveTime</label>
<select id="playout-fade-duration" disabled hidden aria-hidden="true" tabindex="-1"
@@ -421,10 +427,15 @@
<option value="16">17</option><option value="17">18</option>
<option value="18">19</option><option value="19">20</option>
</select>
<button id="db-save-button" type="button" disabled>DB 저장</button>
<button id="db-load-button" type="button" disabled>DB 불러오기</button>
<button id="playlist-delete-selected" type="button" disabled>선택삭제(DEL)</button>
<button id="playlist-delete-all" type="button" disabled>전체삭제</button>
<div class="playlist-toolbar-group playlist-toolbar-db" aria-label="DB">
<button id="db-load-button" type="button" disabled>불러오기</button>
<button id="db-save-button" type="button" disabled>저장</button>
</div>
<div class="playlist-toolbar-group playlist-toolbar-danger" aria-label="삭제">
<button id="playlist-delete-selected" type="button" disabled
aria-keyshortcuts="Delete">선택 삭제</button>
<button id="playlist-delete-all" type="button" disabled>전체 삭제</button>
</div>
</div>
<div id="playlist-drop-zone" class="playlist-grid">
@@ -438,11 +449,11 @@
<button id="playout-prepare" class="prepare" type="button" disabled hidden
aria-hidden="true">PREPARE</button>
<button id="playout-take-in" class="take-in" type="button" disabled
aria-label="TAKE IN" aria-keyshortcuts="F8"> TAKE IN [F8]</button>
aria-label="TAKE IN" aria-keyshortcuts="F8"> TAKE IN [F8]</button>
<button id="playout-next" class="next" type="button" disabled
aria-label="NEXT"> NEXT</button>
<button id="playout-take-out" class="take-out" type="button" disabled
aria-label="TAKE OUT" aria-keyshortcuts="Escape">✖ TAKE OUT[Esc]</button>
aria-label="TAKE OUT" aria-keyshortcuts="Escape">✖ TAKE OUT [Esc]</button>
</div>
<div id="playout-status" class="playout-status" aria-live="polite">송출 상태를 확인하고 있습니다.</div>
</section>

View File

@@ -76,12 +76,30 @@
playout.nextKind !== "none" && playout.nextKind !== "endOfPlaylist";
}
function statusState(playout, busy) {
if (!playout) return "error";
if (playout.outcomeUnknown === true ||
playout.connectionState === "outcomeUnknown") {
return "outcome-unknown";
}
if (busy) return "busy";
if (playout.mode === "disabled" ||
playout.connectionState === "faulted" ||
playout.connectionState === "disposed") {
return "error";
}
if (playout.phase === "program") return "program";
if (playout.phase === "prepared") return "prepared";
return "idle";
}
function render(state) {
currentState = state || currentState;
const playout = currentState && currentState.playout;
const nativeBusy = !!(currentState && currentState.isBusy) ||
!!(playout && playout.isBusy);
const busy = localBusy || nativeBusy;
status.dataset.state = statusState(playout, busy);
if (!playout) {
connection.textContent = "미연결";
status.textContent = "송출 엔진 상태를 사용할 수 없습니다.";
@@ -193,6 +211,7 @@
});
} else {
setAllDisabled(true);
status.dataset.state = "error";
status.textContent = "WebView2 송출 브리지를 사용할 수 없습니다.";
status.title = status.textContent;
}

File diff suppressed because it is too large Load Diff

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