feat: restore legacy modal and drag behavior
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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>
|
||||
|
||||
109
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-cut-drag.js
Normal file
109
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-cut-drag.js
Normal 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
|
||||
});
|
||||
}));
|
||||
354
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-modal-focus.js
Normal file
354
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-modal-focus.js
Normal 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
|
||||
};
|
||||
}));
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user