feat: restore legacy modal and drag behavior

This commit is contained in:
2026-07-16 01:33:48 +09:00
parent 83398044c6
commit d7ec571343
30 changed files with 3597 additions and 321 deletions

View File

@@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
@@ -11,17 +12,31 @@ namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow : Window
{
private const int SmCxDrag = 68;
private const int SmCyDrag = 69;
private const uint WmSettingChange = 0x001A;
private const uint WmDisplayChange = 0x007E;
private const uint WmDpiChanged = 0x02E0;
private const nuint InteractionMetricsSubclassId = 0x4D424E49;
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
private bool _interactionMetricsRefreshQueued;
private bool _intentBusy;
private bool _windowSubclassAttached;
private bool _webViewReady;
private nint _windowHandle;
private LegacyInteractionMetrics _interactionMetrics =
LegacyInteractionMetrics.Default;
public MainWindow()
{
_windowSubclassProcedure = OnWindowSubclassMessage;
InitializeComponent();
EnsureCloseConfirmationAttached();
var localApplicationData = Environment.GetFolderPath(
@@ -202,6 +217,9 @@ public sealed partial class MainWindow : Window
{
EnsureCloseConfirmationAttached();
var windowHandle = WindowNative.GetWindowHandle(this);
_windowHandle = windowHandle;
AttachInteractionMetricsRefresh();
RefreshInteractionMetrics();
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)";
@@ -244,8 +262,11 @@ public sealed partial class MainWindow : Window
CoreWebView2HostResourceAccessKind.DenyCors);
coreWebView.Settings.AreDevToolsEnabled =
System.Diagnostics.Debugger.IsAttached;
coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
coreWebView.Settings.IsStatusBarEnabled = false;
coreWebView.Settings.IsZoomControlEnabled = false;
coreWebView.Settings.IsPinchZoomEnabled = false;
coreWebView.NavigationStarting += OnNavigationStarting;
coreWebView.NavigationCompleted += OnNavigationCompleted;
coreWebView.NewWindowRequested += OnNewWindowRequested;
@@ -327,6 +348,7 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
var dispatchStarted = false;
try
{
// MainForm performed database search synchronously. The busy snapshot
@@ -337,7 +359,11 @@ public sealed partial class MainWindow : Window
_lifetimeCancellation.Token);
if (!gateEntered)
{
PostState(_controller.Current);
PostState(IsNamedPlaylistModalIntent(intent)
? ReportIntentFailure(
intent,
"다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.")
: _controller.Current);
return;
}
@@ -348,7 +374,8 @@ public sealed partial class MainWindow : Window
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
{
PostState(_controller.ReportError(
PostState(ReportIntentFailure(
intent,
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
return;
}
@@ -357,14 +384,16 @@ public sealed partial class MainWindow : Window
IsOperatorMutationIntent(intent) &&
!IsFixedSectionBatchMutationIntent(intent))
{
PostState(_controller.ReportError(
PostState(ReportIntentFailure(
intent,
"고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요."));
return;
}
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
{
PostState(_controller.ReportError(
PostState(ReportIntentFailure(
intent,
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
return;
}
@@ -433,6 +462,7 @@ public sealed partial class MainWindow : Window
PostState(_controller.Current);
}
dispatchStarted = true;
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
ObserveOperatorMutationQuarantine(state);
if (!_closing)
@@ -450,8 +480,12 @@ public sealed partial class MainWindow : Window
catch
{
_intentBusy = false;
PostState(_controller.ReportError(
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요."));
var failureState = ReportIntentFailure(
intent,
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.",
writeOutcomeMayBeUnknown: dispatchStarted);
ObserveOperatorMutationQuarantine(failureState);
PostState(failureState);
}
finally
{
@@ -467,6 +501,82 @@ public sealed partial class MainWindow : Window
}
}
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
intent is LegacyRefreshNamedPlaylistsIntent or
LegacySelectNamedPlaylistIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent or
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent;
private LegacyOperatorSnapshot ReportIntentFailure(
LegacyUiIntent intent,
string message,
bool writeOutcomeMayBeUnknown = false) =>
intent switch
{
LegacyRefreshNamedPlaylistsIntent refresh
when !string.IsNullOrWhiteSpace(refresh.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"refresh-named-playlists",
refresh.RequestId,
message),
LegacySelectNamedPlaylistIntent selection
when !string.IsNullOrWhiteSpace(selection.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"select-named-playlist",
selection.RequestId,
message),
LegacyCreateNamedPlaylistIntent create
when writeOutcomeMayBeUnknown &&
!string.IsNullOrWhiteSpace(create.RequestId) =>
_controller.ReportNamedPlaylistWriteOutcomeUnknown(
"create-named-playlist",
create.RequestId,
message),
LegacyCreateNamedPlaylistIntent create
when !string.IsNullOrWhiteSpace(create.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"create-named-playlist",
create.RequestId,
message),
LegacyDeleteSelectedNamedPlaylistIntent delete
when writeOutcomeMayBeUnknown &&
!string.IsNullOrWhiteSpace(delete.RequestId) =>
_controller.ReportNamedPlaylistWriteOutcomeUnknown(
"delete-selected-named-playlist",
delete.RequestId,
message),
LegacyDeleteSelectedNamedPlaylistIntent delete
when !string.IsNullOrWhiteSpace(delete.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"delete-selected-named-playlist",
delete.RequestId,
message),
LegacyLoadNamedPlaylistByIdIntent load
when !string.IsNullOrWhiteSpace(load.DefinitionId) =>
_controller.ReportNamedPlaylistCommandFailure(
"load-named-playlist-by-id",
load.DefinitionId,
message),
LegacySaveCurrentNamedPlaylistToIntent save
when writeOutcomeMayBeUnknown &&
!string.IsNullOrWhiteSpace(save.DefinitionId) =>
_controller.ReportNamedPlaylistWriteOutcomeUnknown(
"save-current-named-playlist-to",
save.DefinitionId,
message),
LegacySaveCurrentNamedPlaylistToIntent save
when !string.IsNullOrWhiteSpace(save.DefinitionId) =>
_controller.ReportNamedPlaylistCommandFailure(
"save-current-named-playlist-to",
save.DefinitionId,
message),
_ => _controller.ReportError(message)
};
private async Task<LegacyOperatorSnapshot> HandleIntentAsync(
LegacyUiIntent intent,
CancellationToken cancellationToken)
@@ -714,10 +824,14 @@ public sealed partial class MainWindow : Window
cancellationToken),
LegacyDeleteOperatorCatalogIntent =>
await _controller.DeleteOperatorCatalogAsync(cancellationToken),
LegacyRefreshNamedPlaylistsIntent =>
await _controller.RefreshNamedPlaylistsAsync(cancellationToken),
LegacyRefreshNamedPlaylistsIntent refresh =>
await RefreshNamedPlaylistsForDispatchAsync(
refresh.RequestId,
cancellationToken),
LegacySelectNamedPlaylistIntent selection =>
_controller.SelectNamedPlaylist(selection.DefinitionId),
SelectNamedPlaylistForDispatch(
selection.DefinitionId,
selection.RequestId),
LegacyLoadSelectedNamedPlaylistIntent =>
await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken),
LegacyLoadNamedPlaylistByIdIntent load =>
@@ -725,8 +839,9 @@ public sealed partial class MainWindow : Window
load.DefinitionId,
cancellationToken),
LegacyCreateNamedPlaylistIntent create =>
await _controller.CreateNamedPlaylistAsync(
await CreateNamedPlaylistForDispatchAsync(
create.Title,
create.RequestId,
cancellationToken),
LegacySaveCurrentNamedPlaylistIntent =>
await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken),
@@ -734,8 +849,10 @@ public sealed partial class MainWindow : Window
await _controller.SaveCurrentNamedPlaylistToAsync(
save.DefinitionId,
cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent =>
await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent delete =>
await DeleteNamedPlaylistForDispatchAsync(
delete.RequestId,
cancellationToken),
LegacyOpenManualFinancialIntent manual =>
await _controller.OpenManualFinancialAsync(
manual.Screen,
@@ -835,18 +952,280 @@ public sealed partial class MainWindow : Window
};
}
private async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsForDispatchAsync(
string requestId,
CancellationToken cancellationToken)
{
var state = await _controller.RefreshNamedPlaylistsAsync(cancellationToken);
var succeeded = state.NamedPlaylist?.ReadStatus ==
LegacyNamedPlaylistReadStatus.Ready;
return _controller.CompleteNamedPlaylistDispatch(
"refresh-named-playlists",
requestId,
succeeded);
}
private LegacyOperatorSnapshot SelectNamedPlaylistForDispatch(
string definitionId,
string requestId)
{
var state = _controller.SelectNamedPlaylist(definitionId);
var succeeded = string.Equals(
state.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal);
return _controller.CompleteNamedPlaylistDispatch(
"select-named-playlist",
requestId,
succeeded);
}
private async Task<LegacyOperatorSnapshot> CreateNamedPlaylistForDispatchAsync(
string title,
string requestId,
CancellationToken cancellationToken)
{
var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1;
var state = await _controller.CreateNamedPlaylistAsync(title, cancellationToken);
var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision &&
IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome);
return _controller.CompleteNamedPlaylistDispatch(
"create-named-playlist",
requestId,
succeeded);
}
private async Task<LegacyOperatorSnapshot> DeleteNamedPlaylistForDispatchAsync(
string requestId,
CancellationToken cancellationToken)
{
var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1;
var state = await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken);
var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision &&
IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome);
return _controller.CompleteNamedPlaylistDispatch(
"delete-selected-named-playlist",
requestId,
succeeded);
}
private static bool IsCommittedNamedPlaylistMutation(
LegacyNamedPlaylistMutationOutcome? outcome) =>
outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
private void PostState(LegacyOperatorSnapshot snapshot)
{
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
{
// SystemInformation.DragSize was evaluated on every original
// ListView.MouseDown. Refreshing at every native-to-web state boundary,
// in addition to the settings/DPI message hook, prevents a startup-only
// value from surviving a runtime Windows preference change.
RefreshInteractionMetrics();
Browser.CoreWebView2.PostWebMessageAsJson(
LegacyBridgeProtocol.SerializeState(
snapshot,
_intentBusy,
CreatePlayoutSnapshot()));
CreatePlayoutSnapshot(),
interactionMetrics: _interactionMetrics));
}
}
private void AttachInteractionMetricsRefresh()
{
if (_windowSubclassAttached || _windowHandle == 0)
{
return;
}
try
{
_windowSubclassAttached = SetWindowSubclass(
_windowHandle,
_windowSubclassProcedure,
InteractionMetricsSubclassId,
0);
}
catch (DllNotFoundException)
{
}
catch (EntryPointNotFoundException)
{
}
catch (BadImageFormatException)
{
}
catch (PlatformNotSupportedException)
{
}
}
private void DetachInteractionMetricsRefresh()
{
if (!_windowSubclassAttached || _windowHandle == 0)
{
return;
}
try
{
RemoveWindowSubclass(
_windowHandle,
_windowSubclassProcedure,
InteractionMetricsSubclassId);
}
catch (DllNotFoundException)
{
}
catch (EntryPointNotFoundException)
{
}
catch (BadImageFormatException)
{
}
catch (PlatformNotSupportedException)
{
}
finally
{
_windowSubclassAttached = false;
}
}
private nint OnWindowSubclassMessage(
nint windowHandle,
uint message,
nuint wParam,
nint lParam,
nuint subclassId,
nuint referenceData)
{
if (message is WmSettingChange or WmDisplayChange or WmDpiChanged)
{
QueueInteractionMetricsRefresh();
}
return DefSubclassProc(windowHandle, message, wParam, lParam);
}
private void QueueInteractionMetricsRefresh()
{
if (_closing || _interactionMetricsRefreshQueued)
{
return;
}
_interactionMetricsRefreshQueued = true;
if (!DispatcherQueue.TryEnqueue(
Microsoft.UI.Dispatching.DispatcherQueuePriority.High,
() =>
{
_interactionMetricsRefreshQueued = false;
if (_closing)
{
return;
}
RefreshInteractionMetrics();
if (_webViewReady)
{
PostState(_controller.Current);
}
}))
{
_interactionMetricsRefreshQueued = false;
}
}
private void RefreshInteractionMetrics()
{
_interactionMetrics = ReadSystemInteractionMetrics();
}
private LegacyInteractionMetrics ReadSystemInteractionMetrics()
{
try
{
// GetSystemMetrics returns physical pixels and is not DPI-aware.
// Asking the DPI-aware API for its 96-DPI values produces the same
// logical DIP coordinate space used by XAML. The bridge also carries
// XamlRoot.RasterizationScale so JavaScript can account for a zoom
// value retained by an older WebView profile without private API use.
return LegacyInteractionMetrics.FromPixelsAtDpi(
GetSystemMetricsForDpi(
SmCxDrag,
LegacyInteractionMetrics.CssPixelsPerInch),
GetSystemMetricsForDpi(
SmCyDrag,
LegacyInteractionMetrics.CssPixelsPerInch),
LegacyInteractionMetrics.CssPixelsPerInch,
ReadHostRasterizationScale());
}
catch (DllNotFoundException)
{
return CreateFallbackInteractionMetrics();
}
catch (EntryPointNotFoundException)
{
return CreateFallbackInteractionMetrics();
}
catch (BadImageFormatException)
{
return CreateFallbackInteractionMetrics();
}
catch (PlatformNotSupportedException)
{
return CreateFallbackInteractionMetrics();
}
}
private LegacyInteractionMetrics CreateFallbackInteractionMetrics() =>
LegacyInteractionMetrics.Default with
{
HostRasterizationScale = ReadHostRasterizationScale()
};
private double ReadHostRasterizationScale()
{
var scale = Root.XamlRoot?.RasterizationScale ?? 1d;
return double.IsFinite(scale) && scale > 0d ? scale : 1d;
}
[DllImport("user32.dll", ExactSpelling = true)]
private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
[DllImport("comctl32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowSubclass(
nint windowHandle,
WindowSubclassProcedure subclassProcedure,
nuint subclassId,
nuint referenceData);
[DllImport("comctl32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool RemoveWindowSubclass(
nint windowHandle,
WindowSubclassProcedure subclassProcedure,
nuint subclassId);
[DllImport("comctl32.dll", ExactSpelling = true)]
private static extern nint DefSubclassProc(
nint windowHandle,
uint message,
nuint wParam,
nint lParam);
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
private delegate nint WindowSubclassProcedure(
nint windowHandle,
uint message,
nuint wParam,
nint lParam,
nuint subclassId,
nuint referenceData);
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
{
ObserveOperatorMutationQuarantine(
@@ -882,6 +1261,7 @@ public sealed partial class MainWindow : Window
Root.Loaded -= OnRootLoaded;
Closed -= OnClosed;
DetachCloseConfirmation();
DetachInteractionMetricsRefresh();
_lifetimeCancellation.Cancel();
ShutdownPlayoutRuntime();
ShutdownManualListsRuntime();

View File

@@ -34,6 +34,14 @@
const namedPlaylistDeleteButton = document.getElementById("named-playlist-delete-button");
const namedPlaylistCancelButton = document.getElementById("named-playlist-cancel-button");
const namedPlaylistConfirmButton = document.getElementById("named-playlist-confirm-button");
const namedPlaylistConfirmationModal = document.getElementById(
"named-playlist-confirmation");
const namedPlaylistConfirmationMessage = document.getElementById(
"named-playlist-confirmation-message");
const namedPlaylistConfirmationYes = document.getElementById(
"named-playlist-confirmation-yes");
const namedPlaylistConfirmationNo = document.getElementById(
"named-playlist-confirmation-no");
const manualButtons = Array.from(document.querySelectorAll("button[data-manual-screen]"));
const manualModal = document.getElementById("manual-financial-modal");
const manualTitle = document.getElementById("manual-financial-title");
@@ -73,9 +81,33 @@
const operatorCatalogDelete = document.getElementById("operator-catalog-delete");
const operatorCatalogCancel = document.getElementById("operator-catalog-cancel");
const operatorCatalogSave = document.getElementById("operator-catalog-save");
const namedPlaylistDialog = namedPlaylistModal.querySelector("[role='dialog']");
const namedPlaylistConfirmationDialog = namedPlaylistConfirmationModal.querySelector(
"[role='alertdialog']");
const manualDialog = manualModal.querySelector("[role='dialog']");
const manualListDialog = manualListModal.querySelector("[role='dialog']");
const operatorCatalogDialog = operatorCatalogModal.querySelector("[role='dialog']");
const legacyAlertDialog = dialog.querySelector("[role='alertdialog']");
if (!window.LegacyModalFocus || typeof window.LegacyModalFocus.create !== "function") {
throw new Error("Legacy modal focus support was not loaded.");
}
if (!window.LegacyCutDrag ||
typeof window.LegacyCutDrag.createDragRectangle !== "function" ||
typeof window.LegacyCutDrag.shouldStart !== "function" ||
typeof window.LegacyCutDrag.toCssDragSize !== "function") {
throw new Error("Legacy cut drag support was not loaded.");
}
if (!window.LegacyNamedPlaylistSelection ||
typeof window.LegacyNamedPlaylistSelection.create !== "function") {
throw new Error("Legacy named-playlist selection support was not loaded.");
}
const modalFocusManager = window.LegacyModalFocus.create({ document: document });
const namedPlaylistSelection = window.LegacyNamedPlaylistSelection.create();
modalFocusManager.start();
let uiBusy = false;
let namedPlaylistMode = null;
let namedPlaylistState = null;
let namedPlaylistConfirmation = null;
let manualDraft = null;
let manualDraftKey = "";
let operatorCatalogState = null;
@@ -86,10 +118,10 @@
let comparisonTargetClickTimer = null;
let manualViResultClickTimer = null;
let operatorCatalogStockClickTimer = null;
let namedPlaylistResultClickTimer = null;
let pendingNamedPlaylistCommand = null;
let nextNamedPlaylistRequestId = 1;
let lastCommandSequence = 0;
const selectedCutsDragToken = "legacy-selected-cuts";
let operatorMutationLocked = true;
const playlistDragMime = "text/x-mbn-playlist-row";
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
let draggedPlaylistRowId = null;
@@ -97,6 +129,8 @@
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
let cutSelectionEnabled = false;
let cutDragSize = { width: 4, height: 4 };
let cutDragGesture = null;
let lastPresentedPlayoutRowKey = "";
let draggedTabId = null;
let tabDragLastTargetId = null;
@@ -106,9 +140,80 @@
let operatorCatalogMutationLocked = false;
let renderedTreeTabId = null;
let treeInteractionLocked = false;
let treeFocusOwnedBeforeRender = false;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
function preferredModalFocus(modal) {
if (modal === legacyAlertDialog) return dialogOk;
if (modal === namedPlaylistConfirmationDialog) return namedPlaylistConfirmationYes;
if (modal === namedPlaylistDialog) {
return namedPlaylistDefinitions.querySelector("button[aria-selected='true']") ||
namedPlaylistDefinitions.querySelector("button") || namedPlaylistNewTitle;
}
if (modal === manualDialog) {
return manualWorkspace.querySelector("button.selected[data-manual-result-id]") ||
manualWorkspace.querySelector("button[data-manual-result-id]") ||
manualWorkspace.querySelector(".manual-financial-list-header");
}
if (modal === manualListDialog) {
return manualListWorkspace.querySelector("input:not([disabled])") ||
manualListWorkspace.querySelector("button:not([disabled])");
}
if (modal === operatorCatalogDialog) {
if (!operatorCatalogEditor.hidden && !operatorCatalogName.disabled) {
return operatorCatalogName;
}
return operatorCatalogResults.querySelector("button[aria-selected='true']") ||
operatorCatalogResults.querySelector("button") || operatorCatalogQuery;
}
return null;
}
function createModalOpenerReference() {
const opener = document.activeElement;
if (!renderedTreeTabId || !categoryTree.contains(opener)) return opener;
captureTreeUiState(renderedTreeTabId);
const node = opener.closest("[data-tree-node-key]");
const descriptor = {
tabId: renderedTreeTabId,
controlId: opener.id || null,
nodeKey: node ? node.dataset.treeNodeKey : null,
selectionStart: Number.isInteger(opener.selectionStart) ? opener.selectionStart : null,
selectionEnd: Number.isInteger(opener.selectionEnd) ? opener.selectionEnd : null
};
return function resolveTreeModalOpener() {
if (descriptor.tabId !== renderedTreeTabId) return null;
let resolved = descriptor.controlId
? document.getElementById(descriptor.controlId) : null;
if (!resolved && descriptor.nodeKey) {
resolved = findTreeNodeByKey(descriptor.nodeKey);
}
if (!resolved || !categoryTree.contains(resolved) || resolved.disabled === true) {
return null;
}
if (Number.isInteger(descriptor.selectionStart) &&
Number.isInteger(descriptor.selectionEnd) &&
typeof resolved.setSelectionRange === "function") {
try {
resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd);
} catch (_) {
// A recreated non-text control may expose, but reject, selection ranges.
}
}
return resolved;
};
}
function rememberModalOpener(modal) {
if (modal) modalFocusManager.rememberOpener(modal, createModalOpenerReference());
}
function syncModalFocus() {
modalFocusManager.sync(preferredModalFocus);
}
function activeTreeTabId(state) {
if (!state || !Array.isArray(state.tabs)) return null;
const active = state.tabs.find(function (tab) { return tab.isActive === true; });
@@ -214,19 +319,33 @@
});
const selected = categoryTree.querySelector(
"[data-tree-node-key].tree-node-selected");
const active = categoryTree.contains(document.activeElement)
? document.activeElement.closest("[data-tree-node-key]")
const activeElement = categoryTree.contains(document.activeElement)
? document.activeElement
: null;
const active = activeElement
? activeElement.closest("[data-tree-node-key]")
: null;
const controlFocus = activeElement && activeElement.id
? {
id: activeElement.id,
selectionStart: Number.isInteger(activeElement.selectionStart)
? activeElement.selectionStart : null,
selectionEnd: Number.isInteger(activeElement.selectionEnd)
? activeElement.selectionEnd : null
}
: null;
treeUiStateByTab.set(tabId, {
openSections: openSections,
selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null,
focusKey: active ? active.dataset.treeNodeKey : null,
focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null,
controlFocus: activeElement ? controlFocus : previous.controlFocus || null,
scrollTop: categoryTree.scrollTop
});
}
function beginTreeRender(state) {
const nextTabId = activeTreeTabId(state);
treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
if (nextTabId !== renderedTreeTabId) {
renderedTreeTabId = nextTabId;
@@ -250,7 +369,7 @@
syncTreeSectionExpanded(section);
});
const firstRoot = roots[0];
selectTreeNode(firstRoot, true);
selectTreeNode(firstRoot, !modalFocusManager.getActiveModal());
if (typeof firstRoot.scrollIntoView === "function") {
firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" });
}
@@ -278,15 +397,41 @@
}
const selected = findTreeNodeByKey(saved.selectedKey);
if (selected) selectTreeNode(selected, false);
const canRestoreFocus = treeFocusOwnedBeforeRender &&
!modalFocusManager.getActiveModal();
const focused = findTreeNodeByKey(saved.focusKey);
if (focused && typeof focused.focus === "function") {
if (canRestoreFocus && focused && typeof focused.focus === "function") {
focused.focus({ preventScroll: true });
}
if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id) {
const control = document.getElementById(saved.controlFocus.id);
if (control && categoryTree.contains(control) && control.disabled !== true &&
typeof control.focus === "function") {
control.focus({ preventScroll: true });
if (Number.isInteger(saved.controlFocus.selectionStart) &&
Number.isInteger(saved.controlFocus.selectionEnd) &&
typeof control.setSelectionRange === "function") {
try {
control.setSelectionRange(
saved.controlFocus.selectionStart,
saved.controlFocus.selectionEnd);
} catch (_) {
// Non-text controls can expose selection properties without accepting a range.
}
}
}
}
if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop;
}
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
if (!webview || uiBusy) return false;
try {
webview.postMessage({ type: type, payload: payload || {} });
return true;
} catch (_) {
return false;
}
}
function isOperatorMutationLocked(state) {
@@ -491,11 +636,94 @@
function pointerPosition(event) {
const bounds = cutList.getBoundingClientRect();
return {
x: Math.max(0, Math.round(event.clientX - bounds.left)),
y: Math.max(0, Math.round(event.clientY - bounds.top))
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
};
}
function pointInsideElement(element, event) {
if (!element || typeof element.getBoundingClientRect !== "function") return false;
const bounds = element.getBoundingClientRect();
return event.clientX >= bounds.left && event.clientX < bounds.right &&
event.clientY >= bounds.top && event.clientY < bounds.bottom;
}
function clearCutDragGesture(releaseCapture) {
const gesture = cutDragGesture;
if (!gesture) return;
cutDragGesture = null;
gesture.element.classList.remove("dragging");
playlistDropZone.classList.remove("cut-drag-over");
if (releaseCapture !== false &&
typeof gesture.element.hasPointerCapture === "function" &&
gesture.element.hasPointerCapture(gesture.pointerId) &&
typeof gesture.element.releasePointerCapture === "function") {
try {
gesture.element.releasePointerCapture(gesture.pointerId);
} catch (_) {
// The browser may release capture between the key event and cleanup.
}
}
}
function beginCutDragGesture(element, event, position) {
clearCutDragGesture();
if (event.button !== 0) return;
cutDragGesture = {
element: element,
pointerId: event.pointerId,
down: position,
rectangle: window.LegacyCutDrag.createDragRectangle(position, cutDragSize),
started: false
};
if (typeof element.setPointerCapture === "function") {
try {
element.setPointerCapture(event.pointerId);
} catch (_) {
// Pointer capture can fail if the WebView has already released it.
}
}
}
function onCutPointerMove(event) {
const gesture = cutDragGesture;
if (!gesture || gesture.element !== event.currentTarget ||
gesture.pointerId !== event.pointerId) return;
if ((event.buttons & 1) !== 1) {
clearCutDragGesture();
return;
}
if (!gesture.started &&
window.LegacyCutDrag.shouldStart(gesture.rectangle, pointerPosition(event))) {
gesture.started = true;
gesture.element.classList.add("dragging");
}
if (gesture.started) {
event.preventDefault();
playlistDropZone.classList.toggle(
"cut-drag-over",
!playlistMutationLocked && pointInsideElement(playlistDropZone, event));
}
}
function onCutPointerUp(event) {
const gesture = cutDragGesture;
if (!gesture || gesture.element !== event.currentTarget ||
gesture.pointerId !== event.pointerId) return;
const shouldDrop = gesture.started && !playlistMutationLocked &&
pointInsideElement(playlistDropZone, event);
clearCutDragGesture();
if (shouldDrop) send("drop-selected-cuts", {});
}
function onCutPointerCancel(event) {
const gesture = cutDragGesture;
if (gesture && gesture.element === event.currentTarget &&
gesture.pointerId === event.pointerId) {
clearCutDragGesture(event.type !== "lostpointercapture");
}
}
function legacyCutKey(key) {
return {
ArrowUp: "arrow-up",
@@ -533,7 +761,7 @@
element.setAttribute("role", "option");
element.dataset.physicalIndex = String(row.physicalIndex);
element.tabIndex = -1;
element.draggable = true;
element.draggable = false;
const number = document.createElement("span");
number.className = "cut-number";
@@ -551,11 +779,18 @@
physicalIndex: row.physicalIndex,
control: event.ctrlKey,
shift: event.shiftKey,
x: position.x,
y: position.y,
// C# retains integer coordinates only for the native double-click
// equality contract. Drag geometry keeps the raw fractional CSS point.
x: Math.round(position.x),
y: Math.round(position.y),
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
});
beginCutDragGesture(element, event, position);
});
element.addEventListener("pointermove", onCutPointerMove);
element.addEventListener("pointerup", onCutPointerUp);
element.addEventListener("pointercancel", onCutPointerCancel);
element.addEventListener("lostpointercapture", onCutPointerCancel);
element.addEventListener("keydown", function (event) {
const key = legacyCutKey(event.key);
if (!key) return;
@@ -572,14 +807,6 @@
shift: event.shiftKey
});
});
element.addEventListener("dragstart", function (event) {
if (playlistMutationLocked || !event.dataTransfer) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", selectedCutsDragToken);
});
return element;
}
@@ -592,6 +819,10 @@
// renderCuts precedes renderPlaylist, so publish the same lock before a
// cut row can initiate an append drag for this state revision.
playlistMutationLocked = cutDragLocked;
if (!cutSelectionEnabled || cutDragLocked ||
(cutDragGesture && !cutList.contains(cutDragGesture.element))) {
clearCutDragGesture();
}
state.cutRows.forEach(function (row, index) {
let element = cutList.children.item(index);
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
@@ -605,18 +836,24 @@
element.className = "cut-row" +
(row.isSelected ? " selected" : "") +
(row.isSeparator ? " separator" : "");
(row.isSeparator ? " separator" : "") +
(cutDragGesture && cutDragGesture.element === element && cutDragGesture.started
? " dragging" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0)
? 0
: -1;
element.draggable = !cutDragLocked;
element.draggable = false;
element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
});
while (cutList.children.length > state.cutRows.length) {
cutList.lastElementChild.remove();
}
if (cutDragGesture && !cutList.contains(cutDragGesture.element)) {
clearCutDragGesture();
}
}
cutList.addEventListener("pointerdown", function (event) {
@@ -1772,6 +2009,7 @@
return;
}
if (manualModal.hidden) rememberModalOpener(manualDialog);
manualModal.hidden = false;
manualTitle.textContent = manual.label + " · GraphE 수동 입력";
const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" +
@@ -1914,6 +2152,7 @@
return;
}
if (manualListModal.hidden) rememberModalOpener(manualListDialog);
manualListModal.hidden = false;
const busy = state.isBusy === true;
if (manual.screen !== "vi") {
@@ -2144,24 +2383,210 @@
}
function openNamedPlaylist(mode) {
if (operatorMutationLocked) return false;
namedPlaylistSelection.cancel();
namedPlaylistMode = mode;
namedPlaylistTitle.textContent = mode === "save"
? "DB 재생목록 저장" : "DB 재생목록 불러오기";
namedPlaylistConfirmButton.textContent = mode === "save" ? "현재 목록 저장" : "불러오기";
namedPlaylistNewTitle.value = "";
if (namedPlaylistModal.hidden) rememberModalOpener(namedPlaylistDialog);
namedPlaylistModal.hidden = false;
if (namedPlaylistState) {
renderNamedPlaylist({ namedPlaylist: namedPlaylistState, isBusy: uiBusy });
}
send("refresh-named-playlists", {});
syncModalFocus();
beginNamedPlaylistBusyCycle("refresh-named-playlists", {});
return true;
}
function closeNamedPlaylist() {
clearDelayedClick(namedPlaylistResultClickTimer);
namedPlaylistResultClickTimer = null;
pendingNamedPlaylistCommand = null;
if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false;
namedPlaylistMode = null;
namedPlaylistModal.hidden = true;
syncModalFocus();
return true;
}
function isNamedPlaylistModalLocked() {
return uiBusy || pendingNamedPlaylistCommand !== null ||
namedPlaylistConfirmation !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
}
function isNamedPlaylistActionLocked() {
return isNamedPlaylistModalLocked() || operatorMutationLocked ||
namedPlaylistSelection.isPending();
}
function isNamedPlaylistDefinitionLocked() {
return operatorMutationLocked || pendingNamedPlaylistCommand !== null ||
namedPlaylistConfirmation !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true) ||
(uiBusy && !namedPlaylistSelection.isPending());
}
function markNamedPlaylistVisualSelection(definitionId) {
namedPlaylistDefinitions
.querySelectorAll("button[data-named-playlist-definition-id]")
.forEach(function (button) {
const selected = button.dataset.namedPlaylistDefinitionId === definitionId;
button.setAttribute("aria-selected", selected ? "true" : "false");
button.classList.toggle("selected", selected);
});
}
function lockNamedPlaylistSelectionActions() {
namedPlaylistClose.disabled = true;
namedPlaylistCancelButton.disabled = true;
namedPlaylistNewTitle.disabled = true;
namedPlaylistCreateButton.disabled = true;
namedPlaylistDeleteButton.disabled = true;
namedPlaylistConfirmButton.disabled = true;
}
function beginNamedPlaylistSelection(definitionId) {
if (namedPlaylistSelection.isPending()) {
const pending = namedPlaylistSelection.begin(
definitionId,
namedPlaylistState && namedPlaylistState.selectedDefinitionId);
if (pending.accepted) markNamedPlaylistVisualSelection(definitionId);
return pending.accepted;
}
if (isNamedPlaylistModalLocked() || operatorMutationLocked) return false;
const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
const started = namedPlaylistSelection.begin(
definitionId,
namedPlaylistState && namedPlaylistState.selectedDefinitionId,
requestId,
lastCommandSequence + 1);
if (!started.accepted) return false;
markNamedPlaylistVisualSelection(definitionId);
if (!started.shouldSend) return true;
lockNamedPlaylistSelectionActions();
if (!send("select-named-playlist", {
definitionId: definitionId,
requestId: started.requestId
})) {
namedPlaylistSelection.cancel();
restoreNamedPlaylistAfterConfirmation();
return false;
}
return true;
}
function runNamedPlaylistDoubleClick(definitionId, mode) {
if (mode === "save") {
return openNamedPlaylistConfirmation({
kind: "save",
definitionId: definitionId,
message: "저장하겠습니까?",
showSavedAlert: false,
closeOnNo: true
});
}
return beginNamedPlaylistCommand("load-named-playlist-by-id", definitionId, false);
}
function lockNamedPlaylistControls() {
namedPlaylistModal.querySelectorAll("button, input").forEach(function (control) {
control.disabled = true;
});
}
function beginNamedPlaylistBusyCycle(type, payload) {
if (isNamedPlaylistActionLocked()) return false;
const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
pendingNamedPlaylistCommand = {
command: type,
targetId: requestId,
minimumSequence: lastCommandSequence + 1,
closeOnSuccess: false,
showSavedAlert: false
};
const correlatedPayload = Object.assign({}, payload || {}, { requestId: requestId });
if (!send(type, correlatedPayload)) {
pendingNamedPlaylistCommand = null;
return false;
}
lockNamedPlaylistControls();
return true;
}
function beginNamedPlaylistCommand(command, definitionId, showSavedAlert) {
if (isNamedPlaylistActionLocked() || !definitionId) return false;
pendingNamedPlaylistCommand = {
command: command,
targetId: definitionId,
definitionId: definitionId,
minimumSequence: lastCommandSequence + 1,
closeOnSuccess: true,
showSavedAlert: showSavedAlert === true
};
if (!send(command, { definitionId: definitionId })) {
pendingNamedPlaylistCommand = null;
return false;
}
lockNamedPlaylistControls();
return true;
}
function isMatchingNamedPlaylistReceipt(busy, pending, commandResult) {
const exact = !!pending && !!commandResult &&
Number.isSafeInteger(commandResult.sequence) &&
commandResult.sequence >= pending.minimumSequence &&
commandResult.command === pending.command &&
commandResult.targetId === pending.targetId;
return exact && (commandResult.succeeded === false ||
(busy !== true && commandResult.succeeded === true));
}
function openNamedPlaylistConfirmation(options) {
if (!options || isNamedPlaylistActionLocked()) return false;
namedPlaylistConfirmation = options;
namedPlaylistConfirmationMessage.textContent = options.message;
if (namedPlaylistConfirmationModal.hidden) {
rememberModalOpener(namedPlaylistConfirmationDialog);
}
namedPlaylistConfirmationModal.hidden = false;
lockNamedPlaylistControls();
syncModalFocus();
return true;
}
function restoreNamedPlaylistAfterConfirmation() {
if (!namedPlaylistState || namedPlaylistModal.hidden) return;
renderNamedPlaylist({
namedPlaylist: namedPlaylistState,
isBusy: uiBusy,
commandResult: null,
statusKind: null
});
}
function resolveNamedPlaylistConfirmation(confirmed) {
const confirmation = namedPlaylistConfirmation;
if (!confirmation) return false;
namedPlaylistConfirmation = null;
namedPlaylistConfirmationModal.hidden = true;
syncModalFocus();
if (!confirmed) {
if (confirmation.closeOnNo === true) closeNamedPlaylist();
else restoreNamedPlaylistAfterConfirmation();
return true;
}
const started = confirmation.kind === "delete"
? beginNamedPlaylistBusyCycle("delete-selected-named-playlist", {})
: beginNamedPlaylistCommand(
"save-current-named-playlist-to",
confirmation.definitionId,
confirmation.showSavedAlert === true);
if (!started) restoreNamedPlaylistAfterConfirmation();
return started;
}
function renderNamedPlaylist(state) {
@@ -2169,32 +2594,49 @@
namedPlaylistState = named || null;
const busy = state.isBusy === true;
const commandResult = state.commandResult;
const selectionResult = namedPlaylistSelection.reconcile(
commandResult,
named && named.selectedDefinitionId);
const deferredSelectionAction = selectionResult.deferredAction;
if (commandResult && Number.isSafeInteger(commandResult.sequence)) {
lastCommandSequence = Math.max(lastCommandSequence, commandResult.sequence);
}
if (!busy && pendingNamedPlaylistCommand && commandResult &&
commandResult.sequence >= pendingNamedPlaylistCommand.minimumSequence &&
commandResult.command === pendingNamedPlaylistCommand.command &&
commandResult.targetId === pendingNamedPlaylistCommand.definitionId) {
if (isMatchingNamedPlaylistReceipt(
busy,
pendingNamedPlaylistCommand,
commandResult
)) {
const completed = pendingNamedPlaylistCommand;
const succeeded = commandResult.succeeded === true;
pendingNamedPlaylistCommand = null;
if (succeeded) {
if (succeeded && completed.closeOnSuccess) {
if (completed.showSavedAlert) window.alert("저장되었습니다");
closeNamedPlaylist();
}
}
dbLoadButton.disabled = busy || !named;
dbSaveButton.disabled = busy || !named || named.canMutate !== true;
dbLoadButton.disabled = operatorMutationLocked || busy || !named;
dbSaveButton.disabled = operatorMutationLocked || busy || !named ||
named.canMutate !== true;
if (!named || namedPlaylistModal.hidden) return;
const modalLocked = isNamedPlaylistActionLocked();
const definitionLocked = isNamedPlaylistDefinitionLocked();
const existingDefinitions = new Map();
namedPlaylistDefinitions
.querySelectorAll("button[data-named-playlist-definition-id]")
.forEach(function (button) {
existingDefinitions.set(button.dataset.namedPlaylistDefinitionId, button);
});
const fragment = document.createDocumentFragment();
named.definitions.forEach(function (definition) {
const button = document.createElement("button");
const button = existingDefinitions.get(definition.definitionId) ||
document.createElement("button");
button.type = "button";
button.dataset.namedPlaylistDefinitionId = definition.definitionId;
button.setAttribute("role", "option");
button.setAttribute("aria-selected", definition.isSelected ? "true" : "false");
button.className = definition.isSelected ? "selected" : "";
button.disabled = busy || named.isWriteInProgress === true;
button.disabled = definitionLocked;
button.textContent = definition.title;
fragment.appendChild(button);
});
@@ -2212,11 +2654,24 @@
namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true ||
named.statusKind === "error");
namedPlaylistCreateButton.disabled = busy || named.canCreate !== true ||
const closeLocked = isNamedPlaylistModalLocked() ||
namedPlaylistSelection.isPending();
namedPlaylistClose.disabled = closeLocked;
namedPlaylistCancelButton.disabled = closeLocked;
namedPlaylistNewTitle.disabled = modalLocked;
namedPlaylistCreateButton.disabled = modalLocked || named.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
namedPlaylistDeleteButton.disabled = busy || named.canDelete !== true;
namedPlaylistConfirmButton.disabled = busy ||
namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
namedPlaylistConfirmButton.disabled = modalLocked ||
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
namedPlaylistConfirmationYes.disabled = operatorMutationLocked || uiBusy ||
pendingNamedPlaylistCommand !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
if (deferredSelectionAction) {
runNamedPlaylistDoubleClick(
deferredSelectionAction.definitionId,
deferredSelectionAction.mode);
}
}
function clearOperatorCatalogDragVisuals() {
@@ -2329,6 +2784,7 @@
clearOperatorCatalogDragVisuals();
}
if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog);
operatorCatalogModal.hidden = false;
const isTheme = catalog.entity === "theme";
const editing = catalog.mode === "create" || catalog.mode === "edit";
@@ -2512,22 +2968,46 @@
function renderDialog(state) {
if (state.dialog && state.dialog.message) {
dialogMessage.textContent = state.dialog.message;
if (dialog.hidden) rememberModalOpener(legacyAlertDialog);
dialog.hidden = false;
dialogOk.focus();
} else {
dialog.hidden = true;
}
}
function updateCutDragSize(state) {
const metrics = state && state.interactionMetrics;
if (!metrics || metrics.coordinateSpace !== "host-dip" ||
!Number.isInteger(metrics.cutDragWidthDips) ||
!Number.isInteger(metrics.cutDragHeightDips) ||
metrics.cutDragWidthDips <= 0 || metrics.cutDragHeightDips <= 0 ||
typeof metrics.hostRasterizationScale !== "number") return;
const nextDragSize = window.LegacyCutDrag.toCssDragSize({
width: metrics.cutDragWidthDips,
height: metrics.cutDragHeightDips
}, metrics.hostRasterizationScale, window.devicePixelRatio);
cutDragSize = nextDragSize;
if (cutDragGesture && !cutDragGesture.started) {
// The cut-pointer-down reply is also a native state boundary. If a
// Windows preference change raced the previous rendered state, update
// the still-pending gesture before its next pointermove decision.
cutDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle(
cutDragGesture.down,
nextDragSize);
}
}
function render(state) {
const isBusy = state.isBusy === true;
const treeTabId = beginTreeRender(state);
uiBusy = isBusy;
operatorMutationLocked = isOperatorMutationLocked(state);
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
searchInput.readOnly = isBusy;
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
if (document.activeElement !== searchInput) searchInput.value = state.searchText || "";
updateCutDragSize(state);
renderStockResults(state);
renderCuts(state);
renderTabs(state);
@@ -2550,6 +3030,7 @@
renderPlaylist(state);
renderNamedPlaylist(state);
renderDialog(state);
syncModalFocus();
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
}
@@ -2576,36 +3057,25 @@
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (!definition || definition.disabled) return;
event.preventDefault();
clearDelayedClick(namedPlaylistResultClickTimer);
namedPlaylistResultClickTimer = null;
const definitionId = definition.dataset.namedPlaylistDefinitionId;
if (namedPlaylistMode === "save" &&
!window.confirm("선택한 DB 재생목록에 현재 목록을 저장하시겠습니까?")) {
if (namedPlaylistSelection.isPending()) {
namedPlaylistSelection.defer(definitionId, {
definitionId: definitionId,
mode: namedPlaylistMode
});
return;
}
const command = namedPlaylistMode === "save"
? "save-current-named-playlist-to" : "load-named-playlist-by-id";
pendingNamedPlaylistCommand = {
command: command,
definitionId: definitionId,
minimumSequence: lastCommandSequence + 1
};
send(command, { definitionId: definitionId });
runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode);
});
namedPlaylistDefinitions.addEventListener("click", function (event) {
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (definition && !definition.disabled) {
clearDelayedClick(namedPlaylistResultClickTimer);
namedPlaylistResultClickTimer = window.setTimeout(function () {
namedPlaylistResultClickTimer = null;
send("select-named-playlist", {
definitionId: definition.dataset.namedPlaylistDefinitionId
});
}, 250);
beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId);
}
});
namedPlaylistNewTitle.addEventListener("input", function () {
namedPlaylistCreateButton.disabled = uiBusy || !namedPlaylistState ||
namedPlaylistCreateButton.disabled = isNamedPlaylistActionLocked() ||
!namedPlaylistState ||
namedPlaylistState.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
});
@@ -2618,23 +3088,42 @@
namedPlaylistCreateButton.addEventListener("click", function () {
const title = namedPlaylistNewTitle.value;
if (isCanonicalNamedPlaylistTitle(title)) {
send("create-named-playlist", { title: title });
beginNamedPlaylistBusyCycle("create-named-playlist", { title: title });
}
});
namedPlaylistDeleteButton.addEventListener("click", function () {
const title = namedPlaylistState && namedPlaylistState.selectedTitle
? "'" + namedPlaylistState.selectedTitle + "'" : "선택한 재생목록";
if (window.confirm(title + "을(를) DB에서 삭제하겠습니까?")) {
send("delete-selected-named-playlist", {});
}
openNamedPlaylistConfirmation({
kind: "delete",
message: "프로그램이 삭제됩니다. 계속 하시겠습니까?",
closeOnNo: false
});
});
namedPlaylistConfirmButton.addEventListener("click", function () {
const definitionId = namedPlaylistState && namedPlaylistState.selectedDefinitionId;
if (!definitionId) return;
if (namedPlaylistMode === "save") {
send("save-current-named-playlist", {});
openNamedPlaylistConfirmation({
kind: "save",
definitionId: definitionId,
message: "저장하겠습니까?",
showSavedAlert: true,
closeOnNo: true
});
} else if (namedPlaylistMode === "load") {
send("load-selected-named-playlist", {});
beginNamedPlaylistCommand(
"load-named-playlist-by-id",
definitionId,
false);
}
});
namedPlaylistConfirmationYes.addEventListener("click", function () {
if (!namedPlaylistConfirmationYes.disabled) {
resolveNamedPlaylistConfirmation(true);
}
});
namedPlaylistConfirmationNo.addEventListener("click", function () {
resolveNamedPlaylistConfirmation(false);
});
manualButtons.forEach(function (button) {
button.addEventListener("click", function () {
@@ -2962,31 +3451,6 @@
searchOperatorCatalog();
});
function hasDragType(dataTransfer, expectedType) {
return !!dataTransfer && Array.from(dataTransfer.types || [])
.some(function (type) { return type === expectedType; });
}
function isSelectedCutsDrop(dataTransfer) {
return hasDragType(dataTransfer, "text/plain") &&
dataTransfer.getData("text/plain") === selectedCutsDragToken;
}
playlistDropZone.addEventListener("dragover", function (event) {
// A playlist row also drops inside this ancestor. Only advertise the
// drop zone for the cut-list payload; otherwise the bubbled row drop
// would append the still-selected cut as an extra playlist entry.
if (playlistMutationLocked ||
!hasDragType(event.dataTransfer, "text/plain")) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
send("drop-selected-cuts", {});
});
playlistMoveUp.addEventListener("click", function () {
send("move-selected-playlist-rows", { direction: "up" });
});
@@ -3420,13 +3884,31 @@
operatorCatalogDragBlockedRowId = null;
});
document.addEventListener("keydown", function (event) {
if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
const cutDragShortcut = event.key === "Escape" || event.key === "F2" ||
event.key === "F3" || event.key === "F8";
if (cutDragGesture && cutDragGesture.started && cutDragShortcut) {
// Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run
// until that loop ends; Escape cancels the drag without issuing TAKE OUT.
event.preventDefault();
if (event.key === "Escape") clearCutDragGesture();
} else if (event.key === "Escape" && modalFocusManager.getActiveModal()) {
// The legacy child forms did not assign a CancelButton and none handled
// Escape. ShowDialog isolated MainForm, so Escape cannot fall through to
// MainForm's TAKE OUT shortcut. The one-button MessageBox is the exception:
// its Escape is equivalent to acknowledging OK.
event.preventDefault();
if (!event.repeat && modalFocusManager.getActiveModal() === legacyAlertDialog) {
send("dismiss-dialog", {});
}
} else if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden &&
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden) {
// MainForm.KeyPreview invoked playlist deletion but did not suppress the key,
// so an editor that owns focus must still perform its normal text deletion.
if (!playlistMutationLocked) send("delete-selected-playlist-rows", {});
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden &&
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden &&
event.target.closest(".playlist-data-row")) {
event.preventDefault();
if (!playlistMutationLocked) {
@@ -3439,26 +3921,11 @@
}
} else if ((event.key === "Home" || event.key === "End") &&
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden &&
operatorCatalogModal.hidden) {
// MainForm handled its playlist/timer side effect and then returned the
// base key result, so a focused textbox/list control still receives Home/End.
selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
} else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) {
event.preventDefault();
closeNamedPlaylist();
} else if (event.key === "Escape" && !event.repeat && !manualModal.hidden) {
event.preventDefault();
clearDelayedClick(manualResultClickTimer);
manualResultClickTimer = null;
send("close-manual-financial", {});
} else if (event.key === "Escape" && !event.repeat && !manualListModal.hidden) {
event.preventDefault();
send("close-manual-list", {});
} else if (event.key === "Escape" && !event.repeat && !operatorCatalogModal.hidden) {
event.preventDefault();
clearDelayedClick(operatorCatalogStockClickTimer);
operatorCatalogStockClickTimer = null;
send("close-operator-catalog", {});
}
});
dialogOk.addEventListener("click", function () {

View File

@@ -69,8 +69,10 @@
<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>
<label class="fade-control" for="playout-fade-duration">DissolveTime</label>
<select id="playout-fade-duration" disabled aria-label="DissolveTime">
<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"
aria-label="DissolveTime">
<option value="0">1</option><option value="1">2</option>
<option value="2">3</option><option value="3">4</option>
<option value="4">5</option><option value="5">6</option>
@@ -135,6 +137,17 @@
</section>
</div>
<div id="named-playlist-confirmation" class="named-playlist-confirmation-backdrop" hidden>
<section class="named-playlist-confirmation-dialog" role="alertdialog" aria-modal="true"
aria-labelledby="named-playlist-confirmation-message">
<div id="named-playlist-confirmation-message"></div>
<footer>
<button id="named-playlist-confirmation-yes" type="button"></button>
<button id="named-playlist-confirmation-no" type="button">아니요</button>
</footer>
</section>
</div>
<div id="manual-financial-modal" class="manual-financial-backdrop" hidden>
<section class="manual-financial-dialog" role="dialog" aria-modal="true"
aria-labelledby="manual-financial-title">
@@ -239,6 +252,9 @@
</div>
</div>
<script src="legacy-cut-drag.js"></script>
<script src="legacy-modal-focus.js"></script>
<script src="legacy-named-playlist-selection.js"></script>
<script src="app.js"></script>
<script src="playout-ui.js"></script>
</body>

View File

@@ -0,0 +1,109 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) {
module.exports = api;
}
if (root) {
root.LegacyCutDrag = api;
}
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
function assertPoint(point, name) {
if (!point || typeof point !== "object" ||
!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
throw new TypeError(name + " must contain finite x and y numbers.");
}
}
function assertDragSize(dragSize) {
if (!dragSize || typeof dragSize !== "object" ||
!Number.isFinite(dragSize.width) ||
!Number.isFinite(dragSize.height) ||
dragSize.width <= 0 || dragSize.height <= 0) {
throw new RangeError(
"dragSize must contain positive finite width and height values.");
}
}
function assertRectangle(rectangle) {
if (!rectangle || typeof rectangle !== "object" ||
!Number.isFinite(rectangle.left) ||
!Number.isFinite(rectangle.top) ||
!Number.isFinite(rectangle.right) ||
!Number.isFinite(rectangle.bottom) ||
!Number.isFinite(rectangle.width) ||
!Number.isFinite(rectangle.height) ||
rectangle.width <= 0 || rectangle.height <= 0 ||
rectangle.right !== rectangle.left + rectangle.width ||
rectangle.bottom !== rectangle.top + rectangle.height) {
throw new TypeError("rectangle must be a valid legacy drag rectangle.");
}
}
function createDragRectangle(down, dragSize) {
assertPoint(down, "down");
assertDragSize(dragSize);
const left = down.x - dragSize.width;
const top = down.y - dragSize.height;
return Object.freeze({
left: left,
top: top,
width: dragSize.width,
height: dragSize.height,
right: left + dragSize.width,
bottom: top + dragSize.height
});
}
function contains(rectangle, point) {
assertRectangle(rectangle);
assertPoint(point, "point");
// System.Drawing.Rectangle.Contains includes left/top and excludes
// right/bottom. The original mouse-down point is therefore outside.
return point.x >= rectangle.left && point.x < rectangle.right &&
point.y >= rectangle.top && point.y < rectangle.bottom;
}
function shouldStart(rectangle, point) {
return !contains(rectangle, point);
}
function toCssDragSize(dipSize, hostRasterizationScale, devicePixelRatio) {
assertDragSize(dipSize);
// Chromium's devicePixelRatio includes both the XAML monitor scale and
// CoreWebView2 browser zoom. Dividing the DIP metric by their ratio maps
// the Windows threshold into PointerEvent clientX/clientY CSS pixels.
const hostScale = Number.isFinite(hostRasterizationScale) &&
hostRasterizationScale > 0 ? hostRasterizationScale : 1;
const deviceScale = Number.isFinite(devicePixelRatio) &&
devicePixelRatio > 0 ? devicePixelRatio : hostScale;
const candidateZoom = deviceScale / hostScale;
// WebView2 normalizes controller zoom into its supported practical range.
// Treat values outside that range as an untrustworthy measurement and use
// a neutral ratio instead of producing an unusable threshold.
const browserZoom = Number.isFinite(candidateZoom) &&
candidateZoom >= 0.25 && candidateZoom <= 5
? candidateZoom
: 1;
return Object.freeze({
width: dipSize.width / browserZoom,
height: dipSize.height / browserZoom
});
}
return Object.freeze({
createDragRectangle: createDragRectangle,
contains: contains,
shouldStart: shouldStart,
toCssDragSize: toCssDragSize
});
}));

View File

@@ -0,0 +1,354 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) {
module.exports = api;
} else {
root.LegacyModalFocus = api;
}
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const defaultModalSelector = "[role='dialog'], [role='alertdialog']";
const focusableSelector = [
"a[href]",
"area[href]",
"button",
"input",
"select",
"textarea",
"iframe",
"object",
"embed",
"summary",
"audio[controls]",
"video[controls]",
"[contenteditable]",
"[tabindex]"
].join(",");
function attribute(element, name) {
return element && typeof element.getAttribute === "function"
? element.getAttribute(name) : null;
}
function hasAttribute(element, name) {
return !!element && typeof element.hasAttribute === "function" &&
element.hasAttribute(name);
}
function computedStyle(element, documentObject) {
const view = documentObject && documentObject.defaultView;
if (!view || typeof view.getComputedStyle !== "function") return null;
try {
return view.getComputedStyle(element);
} catch (_) {
return null;
}
}
function isVisible(element, documentObject) {
if (!element) return false;
const documentRef = documentObject || element.ownerDocument;
if (documentRef && typeof documentRef.contains === "function" &&
!documentRef.contains(element)) return false;
for (let current = element; current; current = current.parentElement) {
if (current.hidden === true || current.inert === true ||
hasAttribute(current, "hidden") || hasAttribute(current, "inert") ||
attribute(current, "aria-hidden") === "true") return false;
const style = computedStyle(current, documentRef);
if (style && (style.display === "none" || style.visibility === "hidden" ||
style.visibility === "collapse" || style.contentVisibility === "hidden")) {
return false;
}
}
return true;
}
function isNativeFocusable(element) {
const tagName = String(element && element.tagName || "").toLowerCase();
if (["button", "select", "textarea", "iframe", "object", "embed", "summary"]
.includes(tagName)) return true;
if (tagName === "input") return String(attribute(element, "type") || "").toLowerCase() !== "hidden";
if (tagName === "a" || tagName === "area") return hasAttribute(element, "href");
if (tagName === "audio" || tagName === "video") return hasAttribute(element, "controls");
return hasAttribute(element, "contenteditable") &&
attribute(element, "contenteditable") !== "false";
}
function isFocusable(element, documentObject) {
if (!element || typeof element.focus !== "function" ||
!isVisible(element, documentObject)) return false;
if (element.disabled === true || hasAttribute(element, "disabled") ||
attribute(element, "aria-disabled") === "true") return false;
if (typeof element.matches === "function") {
try {
if (element.matches(":disabled")) return false;
} catch (_) {
// Minimal WebView test doubles do not have to implement selector matching.
}
}
const tabIndexAttribute = attribute(element, "tabindex");
if (tabIndexAttribute !== null && Number(tabIndexAttribute) < 0) return false;
return isNativeFocusable(element) ||
(tabIndexAttribute !== null && Number(tabIndexAttribute) >= 0);
}
function getFocusableElements(modal, documentObject) {
if (!modal || typeof modal.querySelectorAll !== "function") return [];
return Array.from(modal.querySelectorAll(focusableSelector)).filter(function (element) {
return isFocusable(element, documentObject || modal.ownerDocument);
});
}
function findVisibleModals(documentObject, selector) {
if (!documentObject || typeof documentObject.querySelectorAll !== "function") return [];
return Array.from(documentObject.querySelectorAll(selector || defaultModalSelector))
.filter(function (modal) { return isVisible(modal, documentObject); });
}
function findVisibleModal(documentObject, selector) {
const modals = findVisibleModals(documentObject, selector);
return modals.length > 0 ? modals[modals.length - 1] : null;
}
function contains(container, element) {
return !!container && !!element && typeof container.contains === "function" &&
container.contains(element);
}
function focus(element) {
if (!element || typeof element.focus !== "function") return false;
try {
element.focus({ preventScroll: true });
} catch (_) {
try {
element.focus();
} catch (_) {
return false;
}
}
return true;
}
function resolveFocusTarget(target) {
let resolved = target;
const visited = new Set();
while (typeof resolved === "function") {
if (visited.has(resolved)) return null;
visited.add(resolved);
try {
resolved = resolved();
} catch (_) {
return null;
}
}
return resolved || null;
}
function preferredElement(modal, preferred, documentObject) {
let candidate = preferred;
if (typeof candidate === "function") candidate = candidate(modal);
if (typeof candidate === "string" && typeof modal.querySelector === "function") {
candidate = modal.querySelector(candidate);
}
if (isFocusable(candidate, documentObject) && contains(modal, candidate)) return candidate;
if (typeof modal.querySelector === "function") {
candidate = modal.querySelector("[data-modal-initial-focus], [autofocus]");
if (isFocusable(candidate, documentObject) && contains(modal, candidate)) return candidate;
}
const focusable = getFocusableElements(modal, documentObject);
return focusable.length > 0 ? focusable[0] : null;
}
function create(options) {
const settings = options || {};
const documentObject = settings.document ||
(typeof document !== "undefined" ? document : null);
if (!documentObject) throw new Error("LegacyModalFocus requires a document.");
const selector = settings.modalSelector || defaultModalSelector;
const entries = new Map();
let activeModal = null;
let observer = null;
let started = false;
function entryFor(modal, opener) {
let entry = entries.get(modal);
if (!entry) {
entry = { opener: opener || null, lastFocused: null };
entries.set(modal, entry);
}
return entry;
}
function rememberOpener(modal, opener) {
if (!modal) return null;
const entry = entryFor(modal, opener || documentObject.activeElement);
if (!entry.opener) entry.opener = opener || documentObject.activeElement || null;
return entry.opener;
}
function modalContaining(element) {
for (const modal of entries.keys()) {
if (contains(modal, element)) return modal;
}
return null;
}
function unwindReturnTarget(entry, visibleSet) {
let target = resolveFocusTarget(entry && entry.opener);
const visited = new Set();
while (target) {
const owner = modalContaining(target);
if (!owner || visibleSet.has(owner)) break;
if (visited.has(owner)) return null;
visited.add(owner);
const ownerEntry = entries.get(owner);
target = resolveFocusTarget(ownerEntry && ownerEntry.opener);
}
return isFocusable(target, documentObject) ? target : null;
}
function cleanClosedEntries(visibleSet) {
for (const modal of Array.from(entries.keys())) {
if (!visibleSet.has(modal)) entries.delete(modal);
}
}
function sync(preferredFocus) {
const visible = findVisibleModals(documentObject, selector);
const visibleSet = new Set(visible);
const nextModal = visible.length > 0 ? visible[visible.length - 1] : null;
const previousModal = activeModal;
const previousEntry = previousModal && entries.get(previousModal);
if (previousModal && previousEntry && contains(previousModal, documentObject.activeElement)) {
previousEntry.lastFocused = documentObject.activeElement;
}
if (nextModal !== previousModal) {
if (nextModal) {
const opener = documentObject.activeElement;
const nextEntry = entryFor(nextModal, contains(nextModal, opener) ? null : opener);
activeModal = nextModal;
if (previousModal && !visibleSet.has(previousModal)) {
const returned = unwindReturnTarget(previousEntry, visibleSet);
if (returned && contains(nextModal, returned)) {
nextEntry.lastFocused = returned;
focus(returned);
} else {
focus(nextEntry.lastFocused && isFocusable(nextEntry.lastFocused, documentObject)
? nextEntry.lastFocused
: preferredElement(nextModal, preferredFocus || settings.preferredFocus,
documentObject));
}
} else {
focus(preferredElement(nextModal, preferredFocus || settings.preferredFocus,
documentObject));
}
} else {
activeModal = null;
focus(unwindReturnTarget(previousEntry, visibleSet));
}
} else if (nextModal && preferredFocus &&
!contains(nextModal, documentObject.activeElement)) {
focus(preferredElement(nextModal, preferredFocus, documentObject));
}
cleanClosedEntries(visibleSet);
return activeModal;
}
function onFocusIn(event) {
const modal = activeModal || findVisibleModal(documentObject, selector);
if (!modal || !contains(modal, event.target)) return;
entryFor(modal, null).lastFocused = event.target;
}
function handleKeydown(event) {
if (!event || event.defaultPrevented || event.key !== "Tab") return false;
const modal = activeModal || sync();
if (!modal) return false;
const focusable = getFocusableElements(modal, documentObject);
if (focusable.length === 0) {
if (typeof event.preventDefault === "function") event.preventDefault();
if (!hasAttribute(modal, "tabindex") && typeof modal.setAttribute === "function") {
modal.setAttribute("tabindex", "-1");
}
focus(modal);
return true;
}
const current = documentObject.activeElement;
const index = focusable.indexOf(current);
let target = null;
if (event.shiftKey === true && index <= 0) {
target = focusable[focusable.length - 1];
} else if (event.shiftKey !== true &&
(index < 0 || index === focusable.length - 1)) {
target = focusable[0];
}
if (!target) return false;
if (typeof event.preventDefault === "function") event.preventDefault();
focus(target);
return true;
}
function start() {
if (started) return manager;
started = true;
documentObject.addEventListener("keydown", handleKeydown, true);
documentObject.addEventListener("focusin", onFocusIn, true);
const Observer = settings.MutationObserver ||
(documentObject.defaultView && documentObject.defaultView.MutationObserver);
if (typeof Observer === "function") {
observer = new Observer(function () { sync(); });
observer.observe(documentObject.body || documentObject.documentElement, {
attributes: true,
attributeFilter: ["hidden", "aria-hidden", "inert", "style", "class"],
childList: true,
subtree: true
});
}
sync();
return manager;
}
function stop() {
if (!started) return;
started = false;
documentObject.removeEventListener("keydown", handleKeydown, true);
documentObject.removeEventListener("focusin", onFocusIn, true);
if (observer) observer.disconnect();
observer = null;
}
const manager = {
getActiveModal: function () { return activeModal; },
getFocusableElements: function (modal) {
return getFocusableElements(modal || activeModal, documentObject);
},
handleKeydown: handleKeydown,
rememberOpener: rememberOpener,
start: start,
stop: stop,
sync: sync
};
return manager;
}
return {
create: create,
findVisibleModal: findVisibleModal,
findVisibleModals: findVisibleModals,
getFocusableElements: getFocusableElements,
isFocusable: isFocusable,
isVisible: isVisible
};
}));

View File

@@ -0,0 +1,110 @@
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.LegacyNamedPlaylistSelection = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
function requireDefinitionId(value) {
if (typeof value !== "string" || value.length === 0) {
throw new TypeError("A named-playlist definition id is required.");
}
return value;
}
function create() {
let pending = null;
function begin(
definitionId,
selectedDefinitionId,
requestId,
minimumSequence
) {
const targetId = requireDefinitionId(definitionId);
if (pending) {
return {
accepted: pending.definitionId === targetId,
shouldSend: false,
definitionId: pending.definitionId,
requestId: pending.requestId
};
}
if (selectedDefinitionId === targetId) {
return {
accepted: true,
shouldSend: false,
definitionId: targetId,
requestId: null
};
}
const correlationId = requireDefinitionId(requestId);
if (!Number.isSafeInteger(minimumSequence) || minimumSequence < 1) {
throw new TypeError("A positive minimum command sequence is required.");
}
pending = {
definitionId: targetId,
requestId: correlationId,
minimumSequence: minimumSequence,
deferredAction: null
};
return {
accepted: true,
shouldSend: true,
definitionId: targetId,
requestId: correlationId
};
}
function defer(definitionId, action) {
const targetId = requireDefinitionId(definitionId);
if (!pending || pending.definitionId !== targetId || !action) return false;
pending.deferredAction = Object.freeze(Object.assign({}, action));
return true;
}
function reconcile(commandResult, selectedDefinitionId) {
if (!pending) {
return { completed: false, succeeded: false, deferredAction: null };
}
const exact = !!commandResult &&
Number.isSafeInteger(commandResult.sequence) &&
commandResult.sequence >= pending.minimumSequence &&
commandResult.command === "select-named-playlist" &&
commandResult.targetId === pending.requestId;
if (!exact) {
return { completed: false, succeeded: false, deferredAction: null };
}
const succeeded = commandResult.succeeded === true &&
selectedDefinitionId === pending.definitionId;
const deferredAction = succeeded ? pending.deferredAction : null;
pending = null;
return {
completed: true,
succeeded: succeeded,
deferredAction: deferredAction
};
}
function cancel() {
pending = null;
}
return Object.freeze({
begin: begin,
defer: defer,
reconcile: reconcile,
cancel: cancel,
isPending: function () { return pending !== null; },
pendingDefinitionId: function () {
return pending ? pending.definitionId : null;
}
});
}
return Object.freeze({ create: create });
});

View File

@@ -116,7 +116,10 @@
takeOut.disabled = busy || !commandAvailable;
backgroundNone.disabled = busy || playout.canChangeBackground !== true;
backgroundFile.disabled = busy || playout.canChangeBackground !== true;
fadeDuration.disabled = busy || playout.canChangeBackground !== true;
// MainForm.Designer keeps panel3 (which owns ComboDi) invisible. Preserve
// the native fade value as read-only state, but never expose an operator
// interaction path from the WebView shell.
fadeDuration.disabled = true;
backgroundNone.checked = playout.backgroundEnabled !== true;
backgroundName.value = playout.backgroundEnabled === true
? (playout.backgroundFileName || "선택한 배경") : "배경없음";
@@ -149,12 +152,17 @@
backgroundNone.addEventListener("change", function () {
post("toggle-background", {});
});
fadeDuration.addEventListener("change", function () {
post("set-fade-duration", { duration: Number(fadeDuration.value) });
});
document.addEventListener("keydown", function (event) {
if (event.defaultPrevented || event.repeat || hasOpenDialog()) return;
if (event.defaultPrevented) return;
const legacyShortcut = event.key === "F2" || event.key === "F3" ||
event.key === "F8" || event.key === "Escape";
if (hasOpenDialog()) {
// ShowDialog/MessageBox own these keys. Consume browser defaults (notably
// Chromium's F3 Find Next) without invoking a MainForm playout shortcut.
if (legacyShortcut) event.preventDefault();
return;
}
if (event.repeat) return;
if (event.key === "F2") {
event.preventDefault();
if (!backgroundFile.disabled) backgroundFile.click();

View File

@@ -71,6 +71,12 @@ button:disabled { color: #555; opacity: 1; }
.named-playlist-dialog > footer button { min-width: 96px; height: 32px; }
.named-playlist-footer-spacer { flex: 1; }
.named-playlist-dialog > footer #named-playlist-confirm-button { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.named-playlist-confirmation-backdrop { position: fixed; inset: 0; z-index: 26; background: rgba(0, 0, 0, .28); }
.named-playlist-confirmation-backdrop[hidden] { display: none; }
.named-playlist-confirmation-dialog { position: absolute; left: 50%; top: 50%; width: 360px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #666; background: #fff; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); text-align: center; }
.named-playlist-confirmation-dialog > footer { display: flex; justify-content: center; gap: 10px; margin-top: 24px; }
.named-playlist-confirmation-dialog button { min-width: 88px; padding: 6px 15px; }
.named-playlist-confirmation-dialog #named-playlist-confirmation-yes { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.manual-financial-backdrop { position: fixed; inset: 0; z-index: 18; background: rgba(0, 0, 0, .3); }
.manual-financial-backdrop[hidden] { display: none; }
@@ -135,6 +141,9 @@ button:disabled { color: #555; opacity: 1; }
.cut-row.selected { background: #dcdcdc; }
.cut-row:focus { outline: 1px dotted #222; outline-offset: -2px; }
.cut-row.separator { background: #fff; }
.cut-row[data-cut-drag-enabled="true"] { cursor: grab; }
.cut-row.dragging { opacity: .58; cursor: grabbing; }
.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }
@@ -306,7 +315,7 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.playout-actions .prepare.active { color: #fff; background: #1a7700; }
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; }
.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); }
.dialog-backdrop { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .25); }
.dialog-backdrop[hidden] { display: none; }
.legacy-dialog { position: absolute; left: 50%; top: 50%; width: 320px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #777; background: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, .3); text-align: center; }
.legacy-dialog button { min-width: 80px; margin-top: 24px; padding: 6px 15px; }